diff --git a/demo/smoke_stairs.mjs b/demo/smoke_stairs.mjs index 858133ab..99a7124c 100644 --- a/demo/smoke_stairs.mjs +++ b/demo/smoke_stairs.mjs @@ -1,6 +1,9 @@ // #663: straight and spiral stairs are plan-editor objects, clean View links -// between spaces, and flat floor content in 2.5D. Exercise only public DOM -// hooks and the harness facade for writes; card internals are read-only oracles. +// between spaces, and flat floor content in 2.5D. #676: the editing layer is +// drag-to-draw, an overlay frame with bearing-aware cursors, edge magnets and +// a dialog that keeps untouched sizes; View hover announces the target floor. +// Exercise only public DOM hooks and the harness facade for writes; card +// internals are read-only oracles. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 1100, height: 850 }); @@ -17,6 +20,28 @@ const out = await page.evaluate(async () => { const spaceCfg = (id) => card._serverCfg.spaces.find((space) => space.id === id); const stairs = (id = 'f1') => spaceCfg(id)?.stairs || []; const stairNode = (id) => root().querySelector(`[data-hp="stair"][data-id="${id}"]`); + const frame = () => root().querySelector('[data-hp="stair-frame"]'); + const handleAt = (index) => frame()?.querySelectorAll('.hp-stair-resize')[index]; + const handlePoint = (node) => [Number(node?.getAttribute('cx')), Number(node?.getAttribute('cy'))]; + const selectStair = async (id) => { + stairNode(id)?.querySelector('.hp-stair-hit')?.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }), + ); + await settled(); + }; + // The browser synthesizes a click on the pressed element after every + // pointerup; a gesture witness must send that click too (#676 К6). + const clickOn = (node) => node?.dispatchEvent(new MouseEvent('click', { + bubbles: true, composed: true, cancelable: true, + })); + const dragStage = async (from, to, id, extra = {}) => { + pointer(stage(), 'pointerdown', from, id, extra); + pointer(stage(), 'pointermove', [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2], id, extra); + pointer(stage(), 'pointermove', to, id, extra); + pointer(stage(), 'pointerup', to, id, extra); + clickOn(stage()); + await settled(); + }; const planSvg = () => root().querySelector('.plan-svg'); const stage = () => root().querySelector('.stage'); const screen = ([x, y]) => { @@ -72,8 +97,11 @@ const out = await page.evaluate(async () => { // Keep the scale explicit: this witness must prove a 20 cm physical face, // not inherit whichever default a fixture migration happens to exercise. first.cell_cm = 5; + // The partition sits well away from the r1/r4 room boundary (y = 0.58): + // the #676 magnet measures from the stair's own side, and the nearest + // parallel face wins, so the fixture must not put two faces within reach. first.partitions = [...(first.partitions || []), { - id: 'stair-smoke-wall', a: [0.15, 0.60], b: [0.85, 0.60], cm: 20, + id: 'stair-smoke-wall', a: [0.15, 0.65], b: [0.85, 0.65], cm: 20, }]; return config; }); @@ -81,17 +109,19 @@ const out = await page.evaluate(async () => { await hp.setMode('plan'); result.straightToolExists = await chooseStair('straight'); - // 625 is within the wall magnet's reach but is not itself the flush centre - // (658.33...). The previous 650 accidentally passed even without a snap. - await clickPlan([400, 625]); + // A default stair centred at 680 has its upper side at 638.3: 20 units below + // the partition's lower face (658.3), within the 25-unit reach but not flush + // (#676 К3 measures from the side). The exposed side of the body is the only + // candidate: the face hidden inside the masonry never pulls the stair up. + await clickPlan([400, 680]); const straight = stairs().find((stair) => stair.kind === 'straight'); result.straightCreatedOnlyOnCurrentFloor = !!straight && stairs('garden').length === 0; result.wallMagnetUsesPhysicalFace = !!straight - && closeTo(Math.abs(straight.y * 1000 - 600), straight.width * 500 + (20 / 5) * (1000 / 240) / 2, 2) + && closeTo(straight.y * 1000 - 650, straight.width * 500 + (20 / 5) * (1000 / 240) / 2, 0.01) && closeTo(axisAngleDistance(straight.angle), 0); result.spiralToolExists = await chooseStair('spiral'); - await clickPlan([580, 650]); + await clickPlan([580, straight.y * 1000]); const spiral = stairs().find((stair) => stair.kind === 'spiral'); result.spiralCreated = !!spiral && stairs().length === 2; result.stairMagnetTouchesOtherFootprint = !!straight && !!spiral @@ -101,6 +131,48 @@ const out = await page.evaluate(async () => { '[data-hp="stair"][data-kind="straight"] .hp-stair-arrow', ) && !!root().querySelector('[data-hp="stair"][data-kind="spiral"] .hp-stair-arrow'); + // Drag-to-draw (#676 AC1): a press-drag-release on the stage draws the + // straight stair at the drawn size with the ascent along the drag; the + // draft is visible while the pointer is down; a spiral takes the square. + await chooseStair('straight'); + const drawnBefore = stairs().length; + // Room r1 spans x 40–550, y 140–580: every drawn side stays farther than + // the magnet reach from its walls, so the drawn size is exactly the drag. + pointer(stage(), 'pointerdown', [200, 300], 6650); + pointer(stage(), 'pointermove', [260, 280], 6650); + await settled(); + result.draftIsVisibleWhileDrawing = !!root().querySelector('.hp-stair.draft') + && !!frame() && stairs().length === drawnBefore; + pointer(stage(), 'pointermove', [200 + 150, 300 - 50], 6650); + pointer(stage(), 'pointerup', [200 + 150, 300 - 50], 6650); + clickOn(stage()); + await settled(); + const drawn = stairs().filter((stair) => stair.kind === 'straight').pop(); + result.dragDrawsTheDrawnSize = stairs().length === drawnBefore + 1 && !!drawn + && closeTo(drawn.length * 1000, 150, 0.01) && closeTo(drawn.width * 1000, 50, 0.01) + && drawn.angle === 0 && drawn.direction === 'forward' + && closeTo(drawn.x * 1000, 275, 0.01) && closeTo(drawn.y * 1000, 275, 0.01) + && !root().querySelector('.hp-stair.draft'); + await dragStage([300, 500], [320, 300], 6651); + const drawnUp = stairs().filter((stair) => stair.kind === 'straight').pop(); + result.dragUpwardRisesUpward = !!drawnUp && drawnUp.angle === 270 + && closeTo(drawnUp.length * 1000, 200, 0.01) + && closeTo(drawnUp.width * 1000, (30 / 5) * (1000 / 240), 0.01); + await chooseStair('spiral'); + await dragStage([700, 300], [760, 340], 6652); + const drawnRound = stairs().filter((stair) => stair.kind === 'spiral').pop(); + result.dragDrawsTheSpiralSquare = !!drawnRound && closeTo(drawnRound.radius * 1000, 30, 0.01) + && closeTo(drawnRound.x * 1000, 730, 0.01) && closeTo(drawnRound.y * 1000, 330, 0.01); + // The stairs drawn here are not part of the rest of the scenario. + await hp.setServerConfig((config) => { + const first = config.spaces.find((space) => space.id === 'f1'); + first.stairs = first.stairs.filter((stair) => ![drawn?.id, drawnUp?.id, drawnRound?.id].includes(stair.id)); + return config; + }); + await hp.switchSpace('f1'); + await hp.setMode('plan'); + await chooseStair('straight'); + // Select and move the straight stair through its actual hit target. const start = { x: straight.x, y: straight.y }; const straightHit = stairNode(straight.id)?.querySelector('.hp-stair-hit'); @@ -127,32 +199,118 @@ const out = await page.evaluate(async () => { && closeTo(undone.x, start.x) && closeTo(undone.y, start.y) && closeTo(redone.x, moved.x) && closeTo(redone.y, moved.y); - // Resize and Shift-rotate through the visible handles. - stairNode(straight.id)?.querySelector('.hp-stair-hit')?.dispatchEvent( - new MouseEvent('click', { bubbles: true, composed: true }), - ); - await settled(); + // Resize and Shift-rotate through the visible handles of the overlay frame. + // The Stairs tool is still armed: the click after the gesture must not + // place another stair (#676 AC6), and the angle must survive (AC11). + await selectStair(straight.id); + const stairsBeforeResize = stairs().length; const beforeResize = stairs().find((stair) => stair.id === straight.id); - const resize = stairNode(straight.id)?.querySelector('.hp-stair-resize'); - const resizePoint = [Number(resize?.getAttribute('cx')), Number(resize?.getAttribute('cy'))]; + result.frameHasEightHandlesAndRotation = frame()?.querySelectorAll('.hp-stair-resize').length === 8 + && !!frame()?.querySelector('.hp-stair-rotate'); + const resize = handleAt(0); // (-1, -1) corner + const resizePoint = handlePoint(resize); pointer(resize, 'pointerdown', resizePoint, 6632); pointer(stage(), 'pointermove', [resizePoint[0] - 35, resizePoint[1] - 25], 6632); pointer(stage(), 'pointerup', [resizePoint[0] - 35, resizePoint[1] - 25], 6632); + clickOn(resize); await settled(); const resized = stairs().find((stair) => stair.id === straight.id); result.resizeIsContinuous = !!beforeResize && !!resized && (!closeTo(beforeResize.length, resized.length) || !closeTo(beforeResize.width, resized.width)); - const rotate = stairNode(straight.id)?.querySelector('.hp-stair-rotate'); - const rotatePoint = [Number(rotate?.getAttribute('cx')), Number(rotate?.getAttribute('cy'))]; + result.resizeUnderStairsToolAddsNoStair = stairs().length === stairsBeforeResize + && !!root().querySelector('.hp-stair.selected'); + result.cornerResizeKeepsAngle = !!resized && closeTo(resized.angle, beforeResize.angle, 1e-9); + const side = handleAt(5); // (1, 0) side + const sidePoint = handlePoint(side); + pointer(side, 'pointerdown', sidePoint, 6645); + pointer(stage(), 'pointermove', [sidePoint[0] + 30, sidePoint[1] + 6], 6645); + pointer(stage(), 'pointerup', [sidePoint[0] + 30, sidePoint[1] + 6], 6645); + clickOn(side); + await settled(); + const sideResized = stairs().find((stair) => stair.id === straight.id); + result.sideResizeMovesOneDimension = !!sideResized && sideResized.length > resized.length + && closeTo(sideResized.width, resized.width, 1e-9) + && closeTo(sideResized.angle, resized.angle, 1e-9); + const rotate = frame()?.querySelector('.hp-stair-rotate'); + const rotatePoint = handlePoint(rotate); pointer(rotate, 'pointerdown', rotatePoint, 6633); pointer(stage(), 'pointermove', [rotatePoint[0] + 47, rotatePoint[1] + 19], 6633, { shiftKey: true }); pointer(stage(), 'pointerup', [rotatePoint[0] + 47, rotatePoint[1] + 19], 6633, { shiftKey: true }); + clickOn(rotate); await settled(); const rotated = stairs().find((stair) => stair.id === straight.id); result.shiftRotationSnaps45 = !!rotated && closeTo(rotated.angle / 45, Math.round(rotated.angle / 45)); + result.rotateUnderStairsToolAddsNoStair = stairs().length === stairsBeforeResize; + + // Cursors follow the world bearing of each handle (#676 AC4): the stair is + // now rotated by 45°, so a side handle points diagonally and a corner + // handle points along an axis; the rotation handle keeps the circular cursor. + const cursorOf = (node) => getComputedStyle(node).cursor; + const sideCursor = cursorOf(handleAt(5)); + const cornerCursor = cursorOf(handleAt(2)); + result.cursorsFollowHandleBearing = rotated.angle % 90 === 45 + ? /nwse-resize|nesw-resize/.test(sideCursor) && /ns-resize|ew-resize/.test(cornerCursor) + : /ns-resize|ew-resize/.test(sideCursor) && /nwse-resize|nesw-resize/.test(cornerCursor); + result.rotationHandleHasCircularCursor = /url\(/.test(cursorOf(frame().querySelector('.hp-stair-rotate'))); + + // The frame paints above wall bodies and its hit radius is constant on screen (#676 AC7). + const wallBodies = root().querySelector('.wallbodies'); + result.frameAboveWallBodies = !!wallBodies && !!frame() + && !!(wallBodies.compareDocumentPosition(frame()) & Node.DOCUMENT_POSITION_FOLLOWING); + const screenRadius = () => { + const ctm = planSvg().getScreenCTM(); + return Number(handleAt(0)?.getAttribute('r')) * Math.hypot(ctm.a, ctm.b); + }; + const radiusAtOne = screenRadius(); + root().querySelector('[data-hp="zoom-in"]')?.click(); + root().querySelector('[data-hp="zoom-in"]')?.click(); + await settleCamera(); + const radiusZoomed = screenRadius(); + root().querySelector('[data-hp="zoom-fit"]')?.click(); + await settleCamera(); + result.handleRadiusConstantOnScreen = radiusAtOne > 0 + && Math.abs(radiusAtOne - radiusZoomed) <= 1; + + // Under «Select» a handle gesture keeps the selection (#676 AC6). + await hp.setTool('select'); + await selectStair(straight.id); + const selectHandle = handleAt(6); + const selectPoint = handlePoint(selectHandle); + pointer(selectHandle, 'pointerdown', selectPoint, 6646); + pointer(stage(), 'pointermove', [selectPoint[0] + 10, selectPoint[1] + 12], 6646); + pointer(stage(), 'pointerup', [selectPoint[0] + 10, selectPoint[1] + 12], 6646); + clickOn(selectHandle); + await settled(); + result.resizeUnderSelectKeepsSelection = !!root().querySelector('.hp-stair.selected') && !!frame(); + + // Under «Walls» the frame is gone and a former handle position is plain + // canvas (#676 AC12); back under «Select» the frame returns. + const formerHandle = handlePoint(handleAt(6)); + await hp.setTool('draw'); + result.frameGoneUnderWallsTool = !frame(); + const pathBefore = card._path.length; + await clickPlan(formerHandle); + result.wallsToolClickIsAWallPoint = card._path.length === pathBefore + 1; + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + await settled(); + await hp.setTool('select'); + await selectStair(straight.id); + result.frameReturnsUnderSelect = !!frame(); + await hp.setTool('stairs'); + + // Saving the properties dialog untouched changes nothing (#676 AC5): + // sizes above one metre survive and no history entry is written. + const untouched = JSON.stringify(stairs().find((stair) => stair.id === straight.id)); + const historyBefore = card._geometryHistory.size; + let dialog = await openStairDialog(straight.id); + const fieldsShown = [...dialog.querySelectorAll('input[type="number"]')].map((input) => input.value); + await saveDialog(dialog); + result.untouchedDialogSaveKeepsSizes = JSON.stringify(stairs().find((stair) => stair.id === straight.id)) === untouched + && Number(fieldsShown[0]) > 100; + result.untouchedDialogSaveWritesNoHistory = card._geometryHistory.size === historyBefore; // Properties switch kind without changing identity, then switch it back and link Garden. - let dialog = await openStairDialog(straight.id); + dialog = await openStairDialog(straight.id); const selects = dialog ? [...dialog.querySelectorAll('select')] : []; if (selects[0]) setDialogSelect(selects[0], 'spiral'); await saveDialog(dialog); @@ -167,10 +325,49 @@ const out = await page.evaluate(async () => { && linked?.kind === 'straight' && linked.target_space_id === 'garden'; await hp.setMode('view'); - const linkedNode = stairNode(straight.id); + let linkedNode = stairNode(straight.id); result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link' && linkedNode?.getAttribute('data-target-state') === 'active'; + // Hover on a link announces the target floor; every other target state + // stays silent (#676 AC8). The mouse pointer type enables hover. + const hoverCenter = [linked.x * 1000, linked.y * 1000]; + const hoverTip = async (node, expectTip) => { + card._tip = null; + pointer(node, 'pointermove', hoverCenter, 6660); + await settled(); + const shown = card._tip?.title ?? null; + node?.dispatchEvent(new PointerEvent('pointerleave', { pointerId: 6660, pointerType: 'mouse' })); + await settled(); + return expectTip ? shown === expectTip && card._tip === null : shown === null; + }; + result.activeLinkHoverShowsTargetFloor = await hoverTip(linkedNode, 'Go to floor Garden'); + const withTarget = async (target) => { + await hp.setServerConfig((config) => { + config.spaces.find((space) => space.id === 'f1').stairs + .find((stair) => stair.id === linked.id).target_space_id = target; + return config; + }); + await hp.switchSpace('f1'); + return stairNode(linked.id); + }; + result.missingTargetHoverIsSilent = await hoverTip(await withTarget(null), null) + && stairNode(linked.id)?.getAttribute('data-target-state') === 'missing'; + result.selfTargetHoverIsSilent = await hoverTip(await withTarget('f1'), null) + && stairNode(linked.id)?.getAttribute('data-target-state') === 'self'; + result.deletedTargetHoverIsSilent = await hoverTip(await withTarget('no-such-space'), null) + && stairNode(linked.id)?.getAttribute('data-target-state') === 'deleted'; + await withTarget('garden'); + card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4, floor: 'f1' }); + await settled(); + result.fixedFloorHoverIsSilent = await hoverTip(stairNode(linked.id), null) + && stairNode(linked.id)?.getAttribute('data-target-state') === 'fixed'; + card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4 }); + await settled(); + await hp.switchSpace('f1'); + await hp.setMode('view'); + linkedNode = stairNode(straight.id); + // A pan ending on the stair owns its compatibility click and cannot navigate. const linkedCenter = [linked.x * 1000, linked.y * 1000]; pointer(linkedNode, 'pointerdown', linkedCenter, 6634, { pointerType: 'touch' }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index be19536b..29ef25d4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,19 @@ when Home Assistant stops, so changes still inside their 0.5 s / 10 s write debounce windows survive an ordinary restart ([#655](https://github.com/Matysh/houseplan-card/issues/655)). +- Stairs in the Plan editor are drawn like decor shapes: press, drag and + release to draw a straight flight (the drag direction is the ascent) or the + square of a spiral stair, while a click still places the default size. The + selected stair now has the same frame as furniture, painted above wall + bodies with corner, side and rotation handles whose cursors follow the + handle's real direction. Resizing moves one side about the opposite one, + never mirrors or turns the stair, and snaps the dragged side flush to a + parallel wall face; moving snaps the nearest side and turns the stair by at + most 5°. A resize or rotation no longer places a copy under the Stairs tool + or drops the selection under Select, the properties dialog saves sizes above + one metre exactly as shown, and hovering a stair with a valid target in View + shows "Go to floor …" + ([#676](https://github.com/Matysh/houseplan-card/issues/676)). ## v1.78.0-beta.5 — 2026-09-27 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index d1ba64d3..420b03c5 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -13,6 +13,21 @@ ещё попадающие в окна отложенной записи 0,5 с и 10 с, не теряются после штатного перезапуска ([#655](https://github.com/Matysh/houseplan-card/issues/655)). +- Лестницы в редакторе плана рисуются как фигуры декора: нажмите, протяните и + отпустите — получится прямой марш (направление протяжки — направление + подъёма) или квадрат винтовой лестницы; клик по-прежнему ставит размер по + умолчанию. У выделенной лестницы теперь такая же рамка, как у мебели, — она + рисуется поверх стен, с угловыми, серединными узлами и узлом вращения, а + курсор над узлом показывает, куда он тянет. Изменение размера двигает одну + сторону относительно противоположной, никогда не зеркалит и не поворачивает + лестницу и примагничивает перетаскиваемую сторону вплотную к параллельной + грани стены; перемещение магнитит ближайшую сторону и доворачивает лестницу + не больше чем на 5°. Изменение размера или поворот больше не создают копию + под инструментом «Лестница» и не сбрасывают выделение под «Выбором», диалог + свойств сохраняет размеры больше метра ровно такими, как показывает, а + наведение на лестницу с корректной целью в Просмотре показывает подсказку + «Переход на этаж …» + ([#676](https://github.com/Matysh/houseplan-card/issues/676)). ## v1.78.0-beta.5 — 2026-09-27 diff --git a/docs/STAIRS.md b/docs/STAIRS.md index a89c66da..2801beae 100644 --- a/docs/STAIRS.md +++ b/docs/STAIRS.md @@ -8,19 +8,45 @@ creates or changes anything on the target floor. ## User contract The Plan editor has one **Stairs** group with **Straight** and **Spiral** tools. -A click places the chosen default; the object remains selected. Drag moves it, -the visible handles resize it, and the upper handle rotates it. Straight stairs -have corner and one-axis edge handles. A spiral stair remains a circle and has -one radial resize handle. Rotation is continuous; holding `Shift` snaps to the -nearest multiple of 45 degrees. `Esc` cancels an active transform or clears the -selection; Delete/Backspace removes the selected stair; the ordinary Plan -Undo/Redo history covers create, edit, transform and delete. +Stairs are drawn like decor shapes (#676): press on the plan, drag and release. +The dominant drag axis is the rise axis, the ascent points from the press to +the release ("draw from the bottom up"), the sizes are the drawn extents (never +below one 30 cm tread) and the angle is 0°, 90°, 180° or 270°; equal extents +prefer the horizontal. A spiral stair takes the square of the drag, anchored at +the press. A drag shorter than one grid cell is a click and places the default +size (240 × 100 cm straight, R 90 cm spiral) at the press. The new stair stays +selected and the tool remains armed; `Esc` during the drag discards the draft. + +The selected stair shows the same frame as furniture in the decor editor, +painted above wall bodies: a dashed outline, four corner and four side handles +(a spiral stair has four handles on its axis tangents), a stem with the +rotation handle above the local top side. Handle hit areas are finger-sized on +screen at every zoom. The cursor over a handle follows the handle's world +direction — `ew`/`ns` when it moves along an axis, a diagonal arrow otherwise — +and the rotation handle shows the circular cursor. Drag the body to move, a +handle to resize about the opposite side or corner (sizes stop at 30 cm and a +pointer dragged past the anchor never mirrors the stair; `Shift` keeps the +proportions), and the upper handle to rotate. Resizing never changes the angle. +Rotation is continuous; holding `Shift` snaps to the nearest multiple of 45 +degrees. `Esc` cancels an active transform or clears the selection; +Delete/Backspace removes the selected stair; the ordinary Plan Undo/Redo +history covers create, edit, move, resize, rotate and delete. The click a +browser synthesizes after a gesture never reaches the plan tool: it places no +copy under Stairs and keeps the selection under Select. Under any other plan +tool the frame is not rendered at all. Double click opens properties. A straight stair stores positive length and -width; a spiral stair stores a positive radius. The dialog also selects the -rise direction, rotation and an optional target space. A target cannot be the -current space. A missing, self or deleted target leaves the stair visible and -editable but shows a repair warning and makes View activation a no-op. +width; a spiral stair stores a positive radius. The fields accept 30–10000 cm +(inches when Home Assistant is imperial); a field left untouched keeps the +stored number bit for bit, and saving without changes writes nothing. The +dialog also selects the rise direction, rotation and an optional target space. +A target cannot be the current space. A missing, self or deleted target leaves +the stair visible and editable but shows a repair warning and makes View +activation a no-op. + +In View, hovering a stair with a mouse shows the card tooltip "Go to floor +" when — and only when — the stair is a valid link (`active` target +state); a missing, self, deleted or fixed-floor target shows no tooltip. In an ordinary multi-space card, a clean click/tap or keyboard activation on a valid stair switches to the target tab and restores that floor's remembered @@ -32,10 +58,17 @@ floor receives no automatic stair, highlight or camera centring. Both variants use the same continuous transform contract as furniture: their authored position, size and angle are not grid-quantised on save or by -**Optimize plans**. A stair near a wall snaps to the visible physical wall face; -a straight stair also becomes parallel to that face. Any straight/circular pair -of stairs can snap footprint-to-footprint. The other stair is never modified or -linked by the magnet. +**Optimize plans**. The wall magnet works per side (#676): a side of the box +(a tangent of the circle) that is parallel to a visible physical wall face +within 5° and within six grid cells of it lands flush on that face. While +moving, the nearest such side wins and a straight stair turns by at most 5° to +become exactly parallel — a stair standing end-on to a wall snaps with its end +and keeps its angle. While resizing or drawing, only the dragged sides snap +and the angle never changes. Room faces are one-sided; the exposed faces of +partitions and columns are derived from the body's winding, so a stair +straddling a thin wall never snaps to the face hidden inside the masonry. Any +straight/circular pair of stairs can snap footprint-to-footprint. The other +stair is never modified or linked by the magnet. Straight tread lines are perpendicular to the rise axis and start at the lower edge every 30 cm. A remainder shorter than 30 cm stays at the upper edge. @@ -83,9 +116,11 @@ links are cleared; a one-space transfer cannot invent an external target. primitives. - `src/stairs-view.ts` is the eager read-only boundary for symbols, guarded navigation and touch/pointer gesture suppression. -- `src/stairs-editor-model.ts` owns pure Plan transforms, target state and - stair-to-stair magnet math; `src/stairs-editor.ts` owns the lazy Plan UI and - properties. +- `src/stairs-editor-model.ts` owns pure Plan transforms — the oriented box, + drag-to-draw, resize about the anchor, edge magnets, handle bearings and + cursors, dialog conversion — plus target state and stair-to-stair magnet + math; `src/stairs-editor.ts` owns the lazy Plan UI (gestures, the frame + rendered by the card in its top overlay, properties). - `src/clean-floor.ts` and `src/summary-panel-metrics.ts` consume the same footprint subtraction for room cards and summary totals; PDF rendering uses the same stair outline/tread geometry without changing the room floor paint. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 434c8158..82bfdf9f 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -707,15 +707,22 @@ Other operations edit existing geometry: | Thickness | Changes one span or every wall of a room, including zero-thickness walls | | Delete room | Deletes the room after choosing whether its exclusive physical walls remain; shared walls always remain | -Choose **Stairs → Straight** or **Stairs → Spiral**, then click the plan. A -selected stair moves and resizes smoothly like furniture; `Shift` snaps its -rotation to 45°. It snaps to the visible physical face of a wall or flush to -another stair, and **Optimize plans** preserves that exact continuous -transform. Double click opens size, direction, angle and target-floor -properties. In View a clean activation switches to the valid target and -restores that floor's saved view; gesture tails, broken links and fixed-floor -cards do nothing. The target floor is never changed automatically. In 2.5D the -symbol is flat on the floor. See [Stairs](STAIRS.md). +Choose **Stairs → Straight** or **Stairs → Spiral**, then draw on the plan +like a decor shape: press, drag and release. The drag direction is the +direction of ascent, the drawn extents are the size, and a plain click still +places the default size. The selected stair shows a frame above the walls with +corner, side and rotation handles; the cursor over a handle shows which way it +moves. A handle resizes about the opposite side without turning or mirroring +the stair and snaps the dragged side flush to a parallel wall; dragging the +body snaps the nearest side to a wall (turning the stair by at most 5°) or +flush to another stair; `Shift` snaps rotation to 45°. **Optimize plans** +preserves the exact continuous transform. Double click opens size (30 cm to +100 m, saved exactly as shown), direction, angle and target-floor properties. +In View, hovering a stair with a valid target shows "Go to floor …", and a +clean activation switches to that target and restores its saved view; gesture +tails, broken links and fixed-floor cards do nothing. The target floor is +never changed automatically. In 2.5D the symbol is flat on the floor. See +[Stairs](STAIRS.md). ![Selected partition and its Plan context tray](images/05-plan-context-tray.png) diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 2e4f5762..8a0ffd22 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -791,16 +791,25 @@ Undo оптимизации. | Толщина | Задаёт толщину 0–100 см выбранному участку или всем стенам комнаты | | Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда | -Выберите **Лестница → Прямая** или **Лестница → Винтовая**, затем кликните по -плану. Выбранная лестница перемещается и меняет размер плавно, как мебель; -`Shift` привязывает поворот к 45°. Магнит ставит её к видимой физической грани -стены или вплотную к другой лестнице, а **Оптимизировать планы** сохраняет этот -continuous-transform без привязки к сетке. Двойной клик открывает размеры, -направление подъёма, угол и целевой этаж. В Просмотре чистый клик переходит на -корректную цель и восстанавливает сохранённый вид этажа; хвост жеста, битая -ссылка и карточка с закреплённым этажом ничего не делают. На целевом этаже -объект автоматически не создаётся. В 2.5D символ остаётся плоским на полу. -Подробный контракт — в [документе о лестницах](STAIRS.md). +Выберите **Лестница → Прямая** или **Лестница → Винтовая**, затем нарисуйте её +на плане как фигуру декора: нажмите, протяните и отпустите. Направление +протяжки — направление подъёма, нарисованный размер — размер лестницы, а +обычный клик по-прежнему ставит размер по умолчанию. У выделенной лестницы +поверх стен рисуется рамка с угловыми, серединными узлами и узлом вращения; +курсор над узлом показывает, куда он тянет. Узел меняет размер относительно +противоположной стороны, не поворачивая и не зеркаля лестницу, и +примагничивает перетаскиваемую сторону вплотную к параллельной стене; +перетаскивание за тело магнитит ближайшую сторону к стене (доворачивая не +больше чем на 5°) или вплотную к другой лестнице; `Shift` привязывает поворот +к 45°. **Оптимизировать планы** сохраняет этот continuous-transform без +привязки к сетке. Двойной клик открывает размеры (от 30 см до 100 м, хранятся +ровно такими, как показаны), направление подъёма, угол и целевой этаж. В +Просмотре наведение на лестницу с корректной целью показывает подсказку +«Переход на этаж …», а чистый клик переходит на эту цель и восстанавливает +сохранённый вид этажа; хвост жеста, битая ссылка и карточка с закреплённым +этажом ничего не делают. На целевом этаже объект автоматически не создаётся. +В 2.5D символ остаётся плоским на полу. Подробный контракт — в +[документе о лестницах](STAIRS.md). При варианте **«Удалить комнату, оставить стены»** эксклюзивные участки положительной толщины становятся обычными независимыми перегородками с той же diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index c441fe59..a7d74287 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -136,14 +136,94 @@ const MUTANT_DEFINITIONS = [ + 'stair on the destination floor.', patches: [{ file: 'src/stairs-editor.ts', - find: ' this.write(this.stairs.map((item) => item.id === next.id ? next : item));\n' + find: ' this.replace(next);\n' + ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n', - replace: ' this.write(this.stairs.map((item) => item.id === next.id ? next : item));\n' + replace: ' this.replace(next);\n' + ' const mirror = (this.owner as unknown as { _serverCfg: { spaces: Array<{ id: string; stairs?: Stair[] }> } })._serverCfg.spaces.find((item) => item.id === dialog.targetSpaceId);\n' + ' if (mirror) mirror.stairs = [...(mirror.stairs || []), { ...next, id: `${next.id}-mirror` }];\n' + ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n', }], }, + { + id: 'stairs-handle-cursor-ignores-bearing', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/stairs-editor-model.test.mjs', + because: '#676 AC4: the cursor over a handle must say which way it moves in world terms, ' + + 'so a rotated stair cannot show one diagonal arrow on every node.', + patches: [{ + file: 'src/stairs-editor-model.ts', + find: " if (sector === 0 || sector === 4) return 'ew';\n", + replace: " if (sector >= 0) return 'nwse'; // mutant: one cursor for every handle\n", + }], + }, + { + id: 'stairs-dialog-clamps-to-wall-thickness', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/stairs-editor-model.test.mjs', + because: '#676 AC5: the properties dialog converts stair sizes, not wall thickness; a ' + + '100 cm ceiling silently shrinks every real flight on save.', + patches: [{ + file: 'src/stairs-editor-model.ts', + find: ' return cm >= STAIR_MIN_CM && cm <= STAIR_MAX_CM ? cm : null;\n', + replace: ' return Math.max(1, Math.min(100, cm)); // mutant: the wall-thickness clamp is back\n', + }], + }, + { + id: 'stairs-resize-mirrors-past-anchor', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/stairs-editor-model.test.mjs', + because: '#676 AC2: a pointer dragged past the anchor side clamps at the minimum size; ' + + 'mirroring would silently move the ascent to the other side of the anchor.', + patches: [{ + file: 'src/stairs-editor-model.ts', + find: ' let w = handle.sx ? Math.max(minimum, handle.sx * lx + box.w / 2) : box.w;\n', + replace: ' let w = handle.sx ? Math.max(minimum, Math.abs(handle.sx * lx + box.w / 2)) : box.w; // mutant: mirrors\n', + }], + }, + { + id: 'stairs-move-magnet-turns-any-angle', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/stairs-editor-model.test.mjs', + because: '#676 AC3: the move magnet may align a nearly parallel side by the tolerance only; ' + + 'turning a stair standing at 30° to the wall changes its orientation behind the user.', + patches: [{ + file: 'src/stairs-editor-model.ts', + find: ' const snap = snapEdgeToFaces(edge, surfaces, reach, angleTolDeg);\n' + + ' if (snap && (!best || Math.abs(snap.offset) < Math.abs(best.snap.offset))) best = { edge, snap };\n', + replace: ' const snap = snapEdgeToFaces(edge, surfaces, reach, 89); // mutant: any angle snaps\n' + + ' if (snap && (!best || Math.abs(snap.offset) < Math.abs(best.snap.offset))) best = { edge, snap };\n', + }], + }, + { + id: 'stairs-gesture-click-reaches-plan-tool', + guard: 'node demo/smoke_stairs.mjs', + because: '#676 AC6: the click the browser synthesizes after a handle drag must not reach ' + + 'the plan tool: under Stairs it placed a copy, under Select it dropped the selection.', + patches: [{ + file: 'src/stairs-editor.ts', + find: ' this.owner._suppressClick = true;\n' + + ' setTimeout(() => { this.owner._suppressClick = false; }, 0);\n', + replace: ' // mutant: the click is free to reach the stage\n', + }, { + file: 'src/stairs-editor.ts', + find: ' @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, \'resize\', { sx: handle.sx, sy: handle.sy })}\n' + + ' @click=${stop}></circle>\n', + replace: ' @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, \'resize\', { sx: handle.sx, sy: handle.sy })}></circle>\n', + }], + }, + { + id: 'stairs-view-tooltip-ignores-target-state', + guard: 'node demo/smoke_stairs.mjs', + because: '#676 AC8: the hover tooltip has exactly the link condition; a missing, self, ' + + 'deleted or fixed-floor target must never announce a floor to go to.', + patches: [{ + file: 'src/stairs-view.ts', + find: ' const tip = (event: PointerEvent): void => {\n' + + ' if (!active) return;\n', + replace: ' const tip = (event: PointerEvent): void => {\n' + + ' // mutant: every stair announces something\n', + }], + }, { id: 'stairs-tread-count-depends-on-render-scale', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index efb24df8..90a0af4b 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -6700,6 +6700,9 @@ export class HouseplanCard extends LitElement { // (owner's report). Pointers that begin on interactive children still // stay out — labels and handles run their own drags. if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .rszhandle, .dev, .oplock, .op-hit, button')) return; + // #676 К1: under the Stairs tool a press on the stage starts a drag-to-draw + // draft; the release places the stair. The stage keeps pan/pinch otherwise. + if (this._editorRuntime?.stairs.stagePointerDown(ev)) return; } if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return; if (this._mode === 'decor' && this._decorPointerDown(ev)) return; @@ -11122,6 +11125,11 @@ export class HouseplanCard extends LitElement { backdrop editor, where rooms and devices are pointer-inert. */} ${this._renderBackdropFrame(view)} ${this._renderTextFrame(view)} + ${''/* #676 К2: the selected stair's frame paints above wall bodies, + where the decor frame paints, so its handles never hide + under masonry. */} + ${this._mode === 'plan' && this._editorRuntime + ? this._editorRuntime.stairs.renderFrame(view) : nothing} <g data-hp-live-editor></g> </g> </svg> diff --git a/src/i18n/de.json b/src/i18n/de.json index 9a58156a..7239657a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -48,10 +48,13 @@ "stairs.no_target": "Nicht ausgewählt", "stairs.target_warning": "Wählen Sie eine andere vorhandene Etage für die Navigation.", "stairs.fixed_floor": "In einer Karte mit fester Etage ist die Navigation deaktiviert.", + "stairs.tooltip_navigate": "Zur Etage {title}", "history.stair_add": "Treppe hinzufügen", "history.stair_edit": "Treppe ändern", "history.stair_move": "Treppe verschieben", "history.stair_delete": "Treppe löschen", + "history.stair_resize": "Treppengröße ändern", + "history.stair_rotate": "Treppe drehen", "markup.hint_column": "Klicken Sie auf einen Rasterpunkt, um eine quadratische Säule zu platzieren", "history.wall_segment": "Wandsegment hinzufügen", "history.column_add": "Säule hinzufügen", diff --git a/src/i18n/en.json b/src/i18n/en.json index cb3b3a89..b79e3dfe 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -48,10 +48,13 @@ "stairs.no_target": "Not selected", "stairs.target_warning": "Select an existing different floor to enable navigation.", "stairs.fixed_floor": "Navigation is disabled in a fixed-floor card.", + "stairs.tooltip_navigate": "Go to floor {title}", "history.stair_add": "Add stairs", "history.stair_edit": "Change stairs", "history.stair_move": "Move stairs", "history.stair_delete": "Delete stairs", + "history.stair_resize": "Resize stairs", + "history.stair_rotate": "Rotate stairs", "markup.hint_column": "click a grid point to place a square column", "history.wall_segment": "Add wall segment", "history.column_add": "Add column", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index cde3ff15..de93cbe5 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -48,10 +48,13 @@ "stairs.no_target": "Non sélectionné", "stairs.target_warning": "Sélectionnez un autre étage existant pour activer la navigation.", "stairs.fixed_floor": "La navigation est désactivée dans une carte à étage fixe.", + "stairs.tooltip_navigate": "Aller à l’étage {title}", "history.stair_add": "Ajouter un escalier", "history.stair_edit": "Modifier l’escalier", "history.stair_move": "Déplacer l’escalier", "history.stair_delete": "Supprimer l’escalier", + "history.stair_resize": "Redimensionner l’escalier", + "history.stair_rotate": "Faire pivoter l’escalier", "markup.hint_column": "cliquez sur un point de la grille pour placer une colonne carrée", "history.wall_segment": "Ajouter un segment de mur", "history.column_add": "Ajouter une colonne", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index acfc7c07..331af883 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -48,10 +48,13 @@ "stairs.no_target": "Не выбран", "stairs.target_warning": "Выберите другой существующий этаж, чтобы включить переход.", "stairs.fixed_floor": "В карточке с фиксированным этажом переход отключён.", + "stairs.tooltip_navigate": "Переход на этаж {title}", "history.stair_add": "Добавить лестницу", "history.stair_edit": "Изменить лестницу", "history.stair_move": "Переместить лестницу", "history.stair_delete": "Удалить лестницу", + "history.stair_resize": "Изменить размер лестницы", + "history.stair_rotate": "Повернуть лестницу", "markup.hint_column": "кликните точку сетки, чтобы поставить квадратную колонну", "history.wall_segment": "Добавление отрезка стены", "history.column_add": "Добавление колонны", diff --git a/src/stairs-editor-model.ts b/src/stairs-editor-model.ts index a56eabd1..e1e9df5d 100644 --- a/src/stairs-editor-model.ts +++ b/src/stairs-editor-model.ts @@ -1,7 +1,13 @@ import { GRID_N, NORM_W } from './canvas-constants'; +import type { FurnitureWallSurface } from './furniture-wall-surface'; import type { Stair } from './stairs'; -export const STAIR_MIN_N = 0.0005; +/** Smallest length, width or radius of a stair: one tread (#676). */ +export const STAIR_MIN_CM = 30; +/** Same ceiling furniture uses; larger is a typo, not a staircase (#676). */ +export const STAIR_MAX_CM = 10000; +/** A stair edge this close to parallel with a wall face may snap flush (#676). */ +export const STAIR_MAGNET_ANGLE_DEG = 5; const finite = (value: unknown): number | null => { const number = Number(value); @@ -102,3 +108,394 @@ export function stairTargetState( if (stair.target_space_id === currentSpaceId) return 'self'; return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted'; } + +// --------------------------------------------------------------------------- +// #676: the editing layer works on an oriented box — centre, length along the +// local rise axis (+x), width along local +y, angle — in plan units. A spiral +// stair is the circle inscribed in the square box of side 2r. + +export interface StairBox { + cx: number; + cy: number; + w: number; + h: number; + angle: number; +} + +/** A resize or edge handle in the stair's own frame: -1 | 0 | 1 per axis. */ +export interface StairHandleSign { + sx: -1 | 0 | 1; + sy: -1 | 0 | 1; +} + +export interface StairHandle extends StairHandleSign { + point: [number, number]; + /** World bearing of the handle's outward direction, degrees, 0 = +x, clockwise on screen. */ + normalDeg: number; +} + +/** One side of the box (a tangent of the circle) with its outward normal. */ +export interface StairEdge extends StairHandleSign { + mid: [number, number]; + dir: [number, number]; + normal: [number, number]; +} + +const axes = (angle: number): { u: [number, number]; v: [number, number] } => { + const radians = angle * Math.PI / 180; + const cosine = Math.cos(radians), sine = Math.sin(radians); + return { u: [cosine, sine], v: [-sine, cosine] }; +}; + +export function stairBox(stair: Stair, scale = NORM_W): StairBox { + const size = stair.kind === 'spiral' + ? [stair.radius * 2 * scale, stair.radius * 2 * scale] + : [stair.length * scale, stair.width * scale]; + return { cx: stair.x * scale, cy: stair.y * scale, w: size[0], h: size[1], angle: stair.angle }; +} + +export function applyStairBox(stair: Stair, box: StairBox, scale = NORM_W): Stair { + const base = { ...stair, x: box.cx / scale, y: box.cy / scale, angle: normalizeStairAngle(box.angle) }; + if (stair.kind === 'spiral') return { ...base, radius: Math.min(box.w, box.h) / 2 / scale } as Stair; + return { ...base, length: box.w / scale, width: box.h / scale } as Stair; +} + +/** Sides of the box in world units. A spiral stair uses the world axes. */ +export function stairEdges(stair: Stair, scale = NORM_W): StairEdge[] { + const box = stairBox(stair, scale); + const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(box.angle); + const edge = (sx: -1 | 0 | 1, sy: -1 | 0 | 1): StairEdge => { + const normal: [number, number] = sx + ? [sx * u[0], sx * u[1]] : [sy * v[0], sy * v[1]]; + const dir: [number, number] = sx ? [v[0], v[1]] : [u[0], u[1]]; + const half = sx ? box.w / 2 : box.h / 2; + return { + sx, sy, dir, normal, + mid: [box.cx + normal[0] * half, box.cy + normal[1] * half], + }; + }; + return [edge(1, 0), edge(-1, 0), edge(0, 1), edge(0, -1)]; +} + +const bearingOf = (x: number, y: number): number => { + const degrees = Math.atan2(y, x) * 180 / Math.PI; + return ((degrees % 360) + 360) % 360; +}; + +/** Resize handles: eight for a box, four axis tangents for a circle. */ +export function stairHandles(stair: Stair, scale = NORM_W): StairHandle[] { + const box = stairBox(stair, scale); + const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(box.angle); + const signs: StairHandleSign[] = stair.kind === 'spiral' + ? [{ sx: 1, sy: 0 }, { sx: 0, sy: 1 }, { sx: -1, sy: 0 }, { sx: 0, sy: -1 }] + : [ + { sx: -1, sy: -1 }, { sx: 1, sy: -1 }, { sx: 1, sy: 1 }, { sx: -1, sy: 1 }, + { sx: 0, sy: -1 }, { sx: 1, sy: 0 }, { sx: 0, sy: 1 }, { sx: -1, sy: 0 }, + ]; + return signs.map(({ sx, sy }) => { + const lx = sx * box.w / 2, ly = sy * box.h / 2; + const nx = sx * u[0] + sy * v[0], ny = sx * u[1] + sy * v[1]; + return { + sx, sy, + point: [box.cx + lx * u[0] + ly * v[0], box.cy + lx * u[1] + ly * v[1]], + normalDeg: bearingOf(nx, ny), + }; + }); +} + +/** The rotation stem: from the top edge's midpoint outward by `arm` units. */ +export function stairRotateHandle( + stair: Stair, arm: number, scale = NORM_W, +): { from: [number, number]; to: [number, number] } { + const box = stairBox(stair, scale); + const { v } = axes(box.angle); + const from: [number, number] = [box.cx - v[0] * box.h / 2, box.cy - v[1] * box.h / 2]; + return { from, to: [from[0] - v[0] * arm, from[1] - v[1] * arm] }; +} + +export type ResizeCursor = 'ew' | 'ns' | 'nwse' | 'nesw'; + +/** + * CSS cursor for a handle by the world bearing of its outward normal: eight + * 45° sectors, so a rotated stair still shows the arrow that matches the + * direction the handle actually moves in (#676 К2). + */ +export function resizeCursor(normalDeg: number): ResizeCursor { + const sector = ((Math.round(normalizeStairAngle(normalDeg) / 45) % 8) + 8) % 8; + if (sector === 0 || sector === 4) return 'ew'; + if (sector === 2 || sector === 6) return 'ns'; + return sector === 1 || sector === 5 ? 'nwse' : 'nesw'; +} + +export const stairMinN = (cellCm: number): number => cmToNorm(STAIR_MIN_CM, cellCm); + +/** + * Drag-to-draw (#676 К1). `a` and `b` are plan units. A drag shorter than + * `clickUnits` on both axes is a click and places the default stair at `a`. + * The dominant drag axis is the rise axis, the ascent points from a to b, and + * equal extents prefer x. + */ +export function draftStair( + kind: Stair['kind'], a: readonly number[], b: readonly number[], + cellCm: number, id: string, clickUnits: number, scale = NORM_W, +): Stair { + const dx = b[0] - a[0], dy = b[1] - a[1]; + if (Math.max(Math.abs(dx), Math.abs(dy)) < clickUnits) return defaultStair(kind, a[0], a[1], cellCm, id); + const minUnits = stairMinN(cellCm) * scale; + if (kind === 'spiral') { + const side = Math.max(Math.abs(dx), Math.abs(dy)); + const radius = Math.max(minUnits, side) / 2; + const cx = a[0] + Math.sign(dx || 1) * side / 2; + const cy = a[1] + Math.sign(dy || 1) * side / 2; + return { + id, kind, x: cx / scale, y: cy / scale, angle: 0, + direction: 'clockwise', radius: radius / scale, target_space_id: null, + }; + } + const alongX = Math.abs(dx) >= Math.abs(dy); + const angle = alongX ? (dx >= 0 ? 0 : 180) : (dy >= 0 ? 90 : 270); + const length = Math.max(minUnits, alongX ? Math.abs(dx) : Math.abs(dy)); + const width = Math.max(minUnits, alongX ? Math.abs(dy) : Math.abs(dx)); + return { + id, kind, x: (a[0] + dx / 2) / scale, y: (a[1] + dy / 2) / scale, angle, + direction: 'forward', length: length / scale, width: width / scale, target_space_id: null, + }; +} + +/** Which edges of a drawn draft face the drag end `b` (they receive the resize magnet). */ +export function draftLeadingHandle(stair: Stair, a: readonly number[], b: readonly number[]): StairHandleSign { + const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(stair.angle); + const dx = b[0] - a[0], dy = b[1] - a[1]; + const along = dx * u[0] + dy * u[1]; + const across = dx * v[0] + dy * v[1]; + const sign = (value: number): -1 | 0 | 1 => (Math.abs(value) < 1e-9 ? 0 : value > 0 ? 1 : -1); + return { sx: sign(along), sy: sign(across) }; +} + +/** + * Resize about the opposite side (#676 К4): the anchor never moves, sizes + * never drop below `minUnits`, and a pointer dragged past the anchor is + * clamped instead of mirroring the stair. Corner drags with `keepAspect` + * follow the axis that moved farther, like furniture. + */ +export function resizeStair( + stair: Stair, handle: StairHandleSign, point: readonly number[], + options: { minUnits: number; keepAspect?: boolean }, scale = NORM_W, +): Stair { + const box = stairBox(stair, scale); + const minimum = Math.max(options.minUnits, 1e-6); + const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(box.angle); + const lx = (point[0] - box.cx) * u[0] + (point[1] - box.cy) * u[1]; + const ly = (point[0] - box.cx) * v[0] + (point[1] - box.cy) * v[1]; + let w = handle.sx ? Math.max(minimum, handle.sx * lx + box.w / 2) : box.w; + let h = handle.sy ? Math.max(minimum, handle.sy * ly + box.h / 2) : box.h; + if (stair.kind === 'spiral') { + // A circle stays a circle: the dragged tangent sets the diameter. + const side = handle.sx ? w : h; + w = side; + h = side; + } else if (options.keepAspect && handle.sx && handle.sy) { + const kx = w / box.w, ky = h / box.h; + const factor = Math.abs(kx - 1) >= Math.abs(ky - 1) ? kx : ky; + const floor = Math.max(minimum / box.w, minimum / box.h); + const safe = Math.max(floor, factor); + w = box.w * safe; + h = box.h * safe; + } + // The anchor side stays: the centre moves by half the growth toward the handle. + // A spiral handle grows the perpendicular size about the centre, so it has no shift. + const shiftX = handle.sx ? handle.sx * (w - box.w) / 2 : 0; + const shiftY = handle.sy ? handle.sy * (h - box.h) / 2 : 0; + return applyStairBox(stair, { + cx: box.cx + shiftX * u[0] + shiftY * v[0], + cy: box.cy + shiftX * u[1] + shiftY * v[1], + w, h, angle: box.angle, + }, scale); +} + +export interface EdgeSnap { + /** Signed travel along the edge's outward normal that lays it on the face. */ + offset: number; + /** Signed rotation, degrees, that makes the edge exactly parallel to the face. */ + angleDelta: number; + stableId: string; + face: { a: readonly [number, number]; b: readonly [number, number] }; +} + +/** Signed distance from the edge midpoint to the face line along the edge's outward normal. */ +const offsetToFaceLine = (edge: StairEdge, a: readonly [number, number], dir: readonly [number, number]): number => { + const along = (edge.mid[0] - a[0]) * dir[0] + (edge.mid[1] - a[1]) * dir[1]; + const foot = [a[0] + dir[0] * along, a[1] + dir[1] * along]; + return (foot[0] - edge.mid[0]) * edge.normal[0] + (foot[1] - edge.mid[1]) * edge.normal[1]; +}; + +const unit = (x: number, y: number): [number, number] | null => { + const length = Math.hypot(x, y); + return length > 1e-9 ? [x / length, y / length] : null; +}; + +/** + * Faces of independent physical bodies (partitions, columns) with their + * outward normals (#676). The furniture magnet keeps these faces two-sided and + * picks a side from the intent point; a stair edge needs the exposed side + * only, otherwise a stair straddling a thin wall would snap to the face hidden + * inside the masonry. The winding of the body polygon says which side is out. + */ +export function physicalStairSurfaces(bodies: readonly number[][][]): FurnitureWallSurface[] { + const out: FurnitureWallSurface[] = []; + for (const body of bodies || []) { + if (!Array.isArray(body) || body.length < 3) continue; + const points = body.filter((point) => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1])); + if (points.length < 3) continue; + let area = 0; + for (let index = 0; index < points.length; index++) { + const a = points[index], b = points[(index + 1) % points.length]; + area += a[0] * b[1] - b[0] * a[1]; + } + if (Math.abs(area) < 1e-12) continue; + for (let index = 0; index < points.length; index++) { + const a = points[index], b = points[(index + 1) % points.length]; + const dir = unit(b[0] - a[0], b[1] - a[1]); + if (!dir) continue; + // Positive signed area: the interior lies to the left of every directed + // edge, so the outward normal is the right-hand perpendicular. + const normal: [number, number] = area > 0 + ? [dir[1] + 0, -dir[0] + 0] : [-dir[1] + 0, dir[0] + 0]; // `+ 0` folds -0 away + out.push({ + a: [a[0], a[1]], b: [b[0], b[1]], axisA: [a[0], a[1]], axisB: [b[0], b[1]], + normal, owner: 'physical', + stableId: `stair-physical:${a[0].toFixed(6)},${a[1].toFixed(6)}|${b[0].toFixed(6)},${b[1].toFixed(6)}`, + }); + } + } + return out; +} + +/** + * The nearest physical wall face a side may lie flush on (#676 К3/К4): parallel + * within `angleTolDeg`, within `reach` along the side's outward normal, the + * side's midpoint projecting onto the face segment (extended by `reach`), and — + * for a one-sided room face — looking at the stair, never through the masonry. + */ +export function snapEdgeToFaces( + edge: StairEdge, surfaces: readonly FurnitureWallSurface[], reach: number, + angleTolDeg = STAIR_MAGNET_ANGLE_DEG, +): EdgeSnap | null { + const cosTol = Math.cos(angleTolDeg * Math.PI / 180); + let best: EdgeSnap | null = null; + for (const surface of surfaces) { + const a = surface?.a, b = surface?.b; + if (!a || !b || ![a[0], a[1], b[0], b[1]].every(Number.isFinite)) continue; + const dir = unit(b[0] - a[0], b[1] - a[1]); + if (!dir) continue; + const dot = dir[0] * edge.dir[0] + dir[1] * edge.dir[1]; + if (Math.abs(dot) < cosTol) continue; + if (surface.normal) { + const facing = surface.normal[0] * edge.normal[0] + surface.normal[1] * edge.normal[1]; + if (!(facing < 0)) continue; + } + const length = Math.hypot(b[0] - a[0], b[1] - a[1]); + const along = (edge.mid[0] - a[0]) * dir[0] + (edge.mid[1] - a[1]) * dir[1]; + if (along < -reach || along > length + reach) continue; + const offset = offsetToFaceLine(edge, a, dir); + if (Math.abs(offset) > reach) continue; + // Signed angle from the edge direction to the face direction, folded to (-90, 90]. + const faceDir: [number, number] = dot < 0 ? [-dir[0], -dir[1]] : dir; + const cross = edge.dir[0] * faceDir[1] - edge.dir[1] * faceDir[0]; + const angleDelta = Math.atan2(cross, Math.abs(dot)) * 180 / Math.PI; + const stableId = typeof surface.stableId === 'string' ? surface.stableId : ''; + if (!best || Math.abs(offset) < Math.abs(best.offset) - 1e-9 + || (Math.abs(Math.abs(offset) - Math.abs(best.offset)) <= 1e-9 + && stableId.localeCompare(best.stableId) < 0)) { + best = { offset, angleDelta, stableId, face: { a, b } }; + } + } + return best; +} + +/** + * Move magnet (#676 К3): the side closest to a parallel face lands flush on it; + * a straight stair also turns by at most `angleTolDeg` to be exactly parallel. + */ +export function magnetStairMove( + stair: Stair, surfaces: readonly FurnitureWallSurface[], reach: number, + angleTolDeg = STAIR_MAGNET_ANGLE_DEG, scale = NORM_W, +): Stair { + let best: { edge: StairEdge; snap: EdgeSnap } | null = null; + for (const edge of stairEdges(stair, scale)) { + const snap = snapEdgeToFaces(edge, surfaces, reach, angleTolDeg); + if (snap && (!best || Math.abs(snap.offset) < Math.abs(best.snap.offset))) best = { edge, snap }; + } + if (!best) return stair; + let next = stair; + let edge = best.edge; + let offset = best.snap.offset; + if (stair.kind === 'straight' && Math.abs(best.snap.angleDelta) > 1e-9) { + // Turn first, then measure the same side against the same face again. + next = { ...stair, angle: normalizeStairAngle(stair.angle + best.snap.angleDelta) } as Stair; + edge = stairEdges(next, scale).find((item) => item.sx === best!.edge.sx && item.sy === best!.edge.sy)!; + const { a, b } = best.snap.face; + offset = offsetToFaceLine(edge, a, unit(b[0] - a[0], b[1] - a[1])!); + } + return { + ...next, + x: next.x + edge.normal[0] * offset / scale, + y: next.y + edge.normal[1] * offset / scale, + } as Stair; +} + +/** + * Resize magnet (#676 К4): each dragged side lands flush on a parallel face by + * growing or shrinking the stair about the anchor; the angle never changes. + */ +export function magnetStairResize( + stair: Stair, handle: StairHandleSign, surfaces: readonly FurnitureWallSurface[], + reach: number, minUnits: number, angleTolDeg = STAIR_MAGNET_ANGLE_DEG, scale = NORM_W, +): Stair { + let next = stair; + for (const axis of ['x', 'y'] as const) { + const sx = axis === 'x' ? handle.sx : 0; + const sy = axis === 'y' ? handle.sy : 0; + if (!sx && !sy) continue; + const edge = stairEdges(next, scale).find((item) => item.sx === sx && item.sy === sy)!; + const snap = snapEdgeToFaces(edge, surfaces, reach, angleTolDeg); + if (!snap) continue; + const box = stairBox(next, scale); + const grown = axis === 'x' ? box.w + snap.offset : box.h + snap.offset; + if (grown < minUnits) continue; + const { u, v } = next.kind === 'spiral' ? axes(0) : axes(box.angle); + const half = snap.offset / 2; + const shift: [number, number] = sx + ? [sx * half * u[0], sx * half * u[1]] : [sy * half * v[0], sy * half * v[1]]; + next = applyStairBox(next, { + cx: box.cx + shift[0], cy: box.cy + shift[1], + w: next.kind === 'spiral' ? grown : axis === 'x' ? grown : box.w, + h: next.kind === 'spiral' ? grown : axis === 'y' ? grown : box.h, + angle: box.angle, + }, scale); + } + return next; +} + +/** Dialog field (cm, or inches when imperial) → cm within the stair bounds; null rejects. */ +export function stairFieldToCm(raw: string | number, imperial: boolean): number | null { + const value = typeof raw === 'number' ? raw : parseFloat(String(raw).trim().replace(',', '.')); + if (!Number.isFinite(value) || value <= 0) return null; + const cm = imperial ? value * 2.54 : value; + return cm >= STAIR_MIN_CM && cm <= STAIR_MAX_CM ? cm : null; +} + +/** Stored normalized size ↔ dialog field, exact when the field was not edited. */ +export function stairFieldOf(sizeN: number, cellCm: number, imperial: boolean): string { + const cm = normToCm(sizeN, cellCm); + const shown = imperial ? cm / 2.54 : cm; + return String(Math.round(shown * 100) / 100); +} + +export function stairSizeFromField( + raw: string, previousField: string, previousN: number, cellCm: number, imperial: boolean, +): number | null { + if (raw === previousField) return previousN; + const cm = stairFieldToCm(raw, imperial); + return cm == null ? null : cmToNorm(cm, cellCm); +} diff --git a/src/stairs-editor.ts b/src/stairs-editor.ts index 5eae2ca2..040d3321 100644 --- a/src/stairs-editor.ts +++ b/src/stairs-editor.ts @@ -1,22 +1,22 @@ import { html, nothing, svg, type TemplateResult } from 'lit'; -import { resizeFurnitureTransform } from './furniture'; -import { FURN_WALL_CELLS, snapFurnitureToWall } from './furniture-placement'; +import { FURN_WALL_CELLS } from './furniture-placement'; import { - furnitureWallSurfacesFor, type FurnitureWallSurfaceSource, + furnitureWallSurfacesFor, type FurnitureWallSurface, type FurnitureWallSurfaceSource, } from './furniture-wall-surface'; import { strictNumber, type MarkupTool } from './card-runtime'; import type { EditorToolbarGroup } from './editor-secondary'; import type { I18nKey } from './i18n'; import { clampCanvasN, NORM_W } from './space-geometry'; import { - convertStairKind, defaultStair, normalizeStairAngle, snapStairToStairs, - stairPhysicalSizeCm, stairTargetState, STAIR_MIN_N, + convertStairKind, draftLeadingHandle, draftStair, magnetStairMove, magnetStairResize, + normalizeStairAngle, physicalStairSurfaces, resizeCursor, resizeStair, snapStairToStairs, stairBox, + stairFieldOf, stairHandles, stairMinN, stairRotateHandle, stairSizeFromField, + stairTargetState, STAIR_MAX_CM, STAIR_MIN_CM, type StairHandleSign, } from './stairs-editor-model'; import { cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, type Stair, } from './stairs'; import type { SpaceModel } from './types'; -import { cmToField, fieldToCm } from './wall-thickness'; type StairDialog = { id: string; @@ -27,18 +27,31 @@ type StairDialog = { angle: string; direction: Stair['direction']; targetSpaceId: string; + /** Field values at open time: an untouched field keeps the stored number bit for bit (#676 К7). */ + opened: { length: string; width: string; radius: string; angle: string }; }; type StairDrag = { pid: number; id: string; mode: 'move' | 'resize' | 'rotate'; - resizeX: -1 | 0 | 1; - resizeY: -1 | 0 | 1; + handle: StairHandleSign; start: number[]; original: Stair; before: unknown; moved: boolean; + /** Wall faces are read once per gesture: each write bumps the epoch that keys their cache. */ + surfaces: readonly FurnitureWallSurface[]; +}; + +type StairDraft = { + pid: number; + kind: Stair['kind']; + id: string; + a: number[]; + b: number[]; + stair: Stair; + surfaces: readonly FurnitureWallSurface[]; }; /** Narrow internal seam owned by HouseplanCard. The cast at construction keeps @@ -65,23 +78,26 @@ export interface StairEditorHostPort { _geometrySnapshot(): unknown; _recordGeometry(name: string, before: unknown): void; _saveConfigDebounced(): void; - _cmToUnits(cm: number): number; _svgPoint(event: MouseEvent): number[]; _tabClick(spaceId: string): void; _activateMarkupTool(tool: MarkupTool): void; } +const newStairId = (): string => `stair-${crypto.randomUUID?.() + || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`; + export class StairEditorRuntime { private preset: Stair['kind'] = 'straight'; private selectedId: string | null = null; private dialog: StairDialog | null = null; private drag: StairDrag | null = null; + private draft: StairDraft | null = null; public constructor(private readonly owner: StairEditorHostPort) {} public get selected(): string | null { return this.selectedId; } public get dialogOpen(): boolean { return this.dialog !== null; } - public get dragging(): boolean { return this.drag !== null; } + public get dragging(): boolean { return this.drag !== null || this.draft !== null; } public clearSelection(): void { this.selectedId = null; @@ -96,6 +112,24 @@ export class StairEditorRuntime { return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs); } + private get inputEnabled(): boolean { + return this.owner._mode === 'plan' + && (this.owner._tool === 'select' || this.owner._tool === 'stairs'); + } + + private get reach(): number { return this.owner._gridPitch * FURN_WALL_CELLS; } + + private get minUnits(): number { return stairMinN(this.owner._cellCm) * NORM_W; } + + /** Room faces from the furniture magnet plus physical bodies with an outward side (#676). */ + private surfaces(): readonly FurnitureWallSurface[] { + const source = this.owner as unknown as FurnitureWallSurfaceSource; + return [ + ...furnitureWallSurfacesFor(source).filter((surface) => surface.owner === 'room'), + ...physicalStairSurfaces(source._rawPhysicalBodiesR()), + ]; + } + private write(stairs: Stair[]): void { const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null; if (!space) return; @@ -107,27 +141,27 @@ export class StairEditorRuntime { this.owner.requestUpdate(); } - private withMagnet(stair: Stair, center: readonly number[]): Stair { - let cx = center[0], cy = center[1], angle = stair.angle; - const depth = (stair.kind === 'straight' ? stair.width : stair.radius * 2) * NORM_W; - const wall = snapFurnitureToWall( - cx, cy, depth, - furnitureWallSurfacesFor(this.owner as unknown as FurnitureWallSurfaceSource), - this.owner._gridPitch * FURN_WALL_CELLS, 0, [cx, cy], - ); - if (wall) { - cx = wall.cx; - cy = wall.cy; - if (stair.kind === 'straight') angle = wall.angle; - } + private replace(next: Stair): void { + this.write(this.stairs.map((item) => item.id === next.id ? next : item)); + } - const moved = { ...stair, x: cx / NORM_W, y: cy / NORM_W, angle } as Stair; - [cx, cy] = snapStairToStairs( - moved, [cx, cy], this.stairs, this.owner._gridPitch * FURN_WALL_CELLS, + /** + * The synthesized `click` after any gesture on the stair or its handles + * must never reach the plan tool underneath: under «Stairs» it would place + * another stair, under «Select» it would drop the selection (#676 К6). + */ + private swallowNextClick(): void { + this.owner._suppressClick = true; + setTimeout(() => { this.owner._suppressClick = false; }, 0); + } + + private withMoveMagnet(stair: Stair, center: readonly number[], surfaces: readonly FurnitureWallSurface[]): Stair { + const moved = { ...stair, x: center[0] / NORM_W, y: center[1] / NORM_W } as Stair; + const snapped = magnetStairMove(moved, surfaces, this.reach); + const [cx, cy] = snapStairToStairs( + snapped, [snapped.x * NORM_W, snapped.y * NORM_W], this.stairs, this.reach, ); - return { - ...stair, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W), angle, - } as Stair; + return { ...snapped, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W) } as Stair; } public activatePlacement(kind: Stair['kind']): void { @@ -136,6 +170,64 @@ export class StairEditorRuntime { this.owner._activateMarkupTool('stairs'); } + private placeStair(stair: Stair, before: unknown): void { + this.write([...this.stairs, stair]); + this.selectedId = stair.id; + this.owner._recordGeometry(this.owner._t('history.stair_add'), before); + this.owner._saveConfigDebounced(); + } + + /** + * Drag-to-draw under the Stairs tool (#676 К1): the press starts a draft on + * the stage, the release commits it. Returns false when the press is not a + * placement (secondary button, second finger, no space, limit reached), so + * the stage keeps its ordinary pan/pinch handling. + */ + public stagePointerDown(event: PointerEvent): boolean { + if (this.owner._mode !== 'plan' || this.owner._tool !== 'stairs') return false; + if (this.draft) { + // A second contact turns the gesture into navigation: the draft is gone. + if (this.draft.pid !== event.pointerId) this.cancelDraft(); + return false; + } + if (this.drag || (event.pointerType === 'mouse' && event.button !== 0) || !event.isPrimary) return false; + const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null; + if (!space) return false; + if (this.stairs.length >= MAX_STAIRS_PER_SPACE) { + this.owner._showToast(this.owner._t('toast.physical_limit')); + return false; + } + event.preventDefault(); + const point = this.owner._svgPoint(event as unknown as MouseEvent); + const id = newStairId(); + this.draft = { + pid: event.pointerId, kind: this.preset, id, a: point, b: point, + stair: draftStair(this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch), + surfaces: this.surfaces(), + }; + this.selectedId = null; + try { (event.currentTarget as Element).setPointerCapture(event.pointerId); } + catch { /* synthetic event */ } + this.owner.requestUpdate(); + return true; + } + + private draftAt(draft: StairDraft, point: number[]): Stair { + const drawn = draftStair(draft.kind, draft.a, point, this.owner._cellCm, draft.id, this.owner._gridPitch); + const isClick = Math.max(Math.abs(point[0] - draft.a[0]), Math.abs(point[1] - draft.a[1])) < this.owner._gridPitch; + if (isClick) return this.withMoveMagnet(drawn, [drawn.x * NORM_W, drawn.y * NORM_W], draft.surfaces); + return magnetStairResize( + drawn, draftLeadingHandle(drawn, draft.a, point), draft.surfaces, this.reach, this.minUnits, + ); + } + + private cancelDraft(): void { + if (!this.draft) return; + this.draft = null; + this.owner.requestUpdate(); + } + + /** Programmatic placement kept for the harness and tests: a click at `point`. */ public placeAt(point: number[]): void { const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null; if (!space || this.stairs.length >= MAX_STAIRS_PER_SPACE) { @@ -143,28 +235,30 @@ export class StairEditorRuntime { return; } const before = this.owner._geometrySnapshot(); - const id = `stair-${crypto.randomUUID?.() - || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`; - const stair = this.withMagnet( - defaultStair(this.preset, point[0], point[1], this.owner._cellCm, id), point, + const id = newStairId(); + const stair = this.withMoveMagnet( + draftStair(this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch), + [point[0], point[1]], this.surfaces(), ); - this.write([...this.stairs, stair]); - this.selectedId = id; - this.owner._recordGeometry(this.owner._t('history.stair_add'), before); - this.owner._saveConfigDebounced(); + this.placeStair(stair, before); } private openDialog(stair: Stair): void { - const sizes = stairPhysicalSizeCm(stair, this.owner._cellCm); + const cellCm = this.owner._cellCm; + const imperial = this.owner._imperial; + const fields = { + length: stairFieldOf(stair.kind === 'straight' ? stair.length : stair.radius * 2, cellCm, imperial), + width: stairFieldOf(stair.kind === 'straight' ? stair.width : stair.radius * 2, cellCm, imperial), + radius: stairFieldOf(stair.kind === 'spiral' ? stair.radius : Math.max(stair.length, stair.width) / 2, cellCm, imperial), + angle: String(stair.angle), + }; this.dialog = { id: stair.id, kind: stair.kind, - length: cmToField(stair.kind === 'straight' ? sizes[0] : 100, this.owner._imperial), - width: cmToField(stair.kind === 'straight' ? sizes[1] : 100, this.owner._imperial), - radius: cmToField(stair.kind === 'spiral' ? sizes[0] : 90, this.owner._imperial), - angle: String(stair.angle), + ...fields, direction: stair.direction, targetSpaceId: stair.target_space_id || '', + opened: fields, }; this.owner.requestUpdate(); } @@ -179,34 +273,42 @@ export class StairEditorRuntime { const dialog = this.dialog; const current = this.stairs.find((item) => item.id === dialog?.id); if (!dialog || !current) return; - const lengthCm = fieldToCm(dialog.length, this.owner._imperial); - const widthCm = fieldToCm(dialog.width, this.owner._imperial); - const radiusCm = fieldToCm(dialog.radius, this.owner._imperial); - const angle = strictNumber(dialog.angle); - if (angle == null || (dialog.kind === 'straight' - ? !(lengthCm && widthCm) : !radiusCm)) return; - const before = this.owner._geometrySnapshot(); + const cellCm = this.owner._cellCm; + const imperial = this.owner._imperial; + const angle = dialog.angle === dialog.opened.angle ? current.angle : strictNumber(dialog.angle); + if (angle == null) return; let next = convertStairKind(current, dialog.kind); - if (next.kind === 'straight') next = { - ...next, - length: Math.max(STAIR_MIN_N, this.owner._cmToUnits(lengthCm!) / NORM_W), - width: Math.max(STAIR_MIN_N, this.owner._cmToUnits(widthCm!) / NORM_W), - direction: dialog.direction === 'backward' ? 'backward' : 'forward', - }; - else next = { - ...next, - radius: Math.max(STAIR_MIN_N, this.owner._cmToUnits(radiusCm!) / NORM_W), - direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise', - }; + if (next.kind === 'straight') { + const length = stairSizeFromField(dialog.length, dialog.opened.length, next.length, cellCm, imperial); + const width = stairSizeFromField(dialog.width, dialog.opened.width, next.width, cellCm, imperial); + if (length == null || width == null) return; + next = { + ...next, length, width, + direction: dialog.direction === 'backward' ? 'backward' : 'forward', + }; + } else { + const radius = stairSizeFromField(dialog.radius, dialog.opened.radius, next.radius, cellCm, imperial); + if (radius == null) return; + next = { + ...next, radius, + direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise', + }; + } next = { ...next, angle: normalizeStairAngle(angle), target_space_id: dialog.targetSpaceId || null, } as Stair; - this.write(this.stairs.map((item) => item.id === next.id ? next : item)); + this.dialog = null; + const unchanged = JSON.stringify(next) === JSON.stringify({ ...current, target_space_id: current.target_space_id ?? null }); + if (unchanged) { + this.owner.requestUpdate(); + return; + } + const before = this.owner._geometrySnapshot(); + this.replace(next); this.owner._recordGeometry(this.owner._t('history.stair_edit'), before); this.owner._saveConfigDebounced(); - this.dialog = null; } private delete(id: string): void { @@ -228,11 +330,9 @@ export class StairEditorRuntime { event: PointerEvent, stair: Stair, mode: 'move' | 'resize' | 'rotate', - resizeX: -1 | 0 | 1 = 1, - resizeY: -1 | 0 | 1 = 1, + handle: StairHandleSign = { sx: 0, sy: 0 }, ): void { - if (this.owner._mode !== 'plan' - || (this.owner._tool !== 'select' && this.owner._tool !== 'stairs')) return; + if (!this.inputEnabled || this.draft) return; event.preventDefault(); event.stopPropagation(); this.selectedId = stair.id; @@ -240,12 +340,12 @@ export class StairEditorRuntime { pid: event.pointerId, id: stair.id, mode, - resizeX, - resizeY, + handle, start: this.owner._svgPoint(event as unknown as MouseEvent), original: structuredClone(stair), before: this.owner._geometrySnapshot(), moved: false, + surfaces: this.surfaces(), }; try { (event.currentTarget as Element).setPointerCapture(event.pointerId); } catch { /* synthetic event */ } @@ -253,53 +353,57 @@ export class StairEditorRuntime { } public pointerMove(event: PointerEvent): boolean { + if (this.draft?.pid === event.pointerId) { + const point = this.owner._svgPoint(event as unknown as MouseEvent); + this.draft.b = point; + this.draft.stair = this.draftAt(this.draft, point); + this.owner.requestUpdate(); + return true; + } const drag = this.drag; if (!drag || drag.pid !== event.pointerId) return false; const point = this.owner._svgPoint(event as unknown as MouseEvent); let next: Stair = { ...drag.original }; if (drag.mode === 'move') { - const center = [ + next = this.withMoveMagnet(next, [ drag.original.x * NORM_W + point[0] - drag.start[0], drag.original.y * NORM_W + point[1] - drag.start[1], - ]; - next = this.withMagnet(next, center); + ], drag.surfaces); } else if (drag.mode === 'rotate') { next.angle = normalizeStairAngle( Math.atan2(point[1] - next.y * NORM_W, point[0] - next.x * NORM_W) * 180 / Math.PI + 90, ); - if (event.shiftKey) next.angle = Math.round(next.angle / 45) * 45; - } else if (next.kind === 'spiral') { - next.radius = Math.max(STAIR_MIN_N, - Math.hypot(point[0] - next.x * NORM_W, point[1] - next.y * NORM_W) / NORM_W); + if (event.shiftKey) next.angle = normalizeStairAngle(Math.round(next.angle / 45) * 45); } else { - const resized = resizeFurnitureTransform({ - x: (next.x - next.length / 2) * NORM_W, - y: (next.y - next.width / 2) * NORM_W, - w: next.length * NORM_W, - h: next.width * NORM_W, - angle: next.angle, - }, drag.resizeX, drag.resizeY, point[0], point[1], false, STAIR_MIN_N * NORM_W); - next = { - ...next, - x: (resized.x + resized.w / 2) / NORM_W, - y: (resized.y + resized.h / 2) / NORM_W, - length: resized.w / NORM_W, - width: resized.h / NORM_W, - }; + next = magnetStairResize( + resizeStair(next, drag.handle, point, { minUnits: this.minUnits, keepAspect: event.shiftKey }), + drag.handle, drag.surfaces, this.reach, this.minUnits, + ); } drag.moved ||= Math.hypot(point[0] - drag.start[0], point[1] - drag.start[1]) > 0.5; - this.write(this.stairs.map((item) => item.id === next.id ? next : item)); + this.replace(next); return true; } private endDrag(event: PointerEvent, cancelled: boolean): boolean { + if (this.draft?.pid === event.pointerId) { + const draft = this.draft; + this.draft = null; + this.swallowNextClick(); + if (cancelled) { this.owner.requestUpdate(); return true; } + this.placeStair(this.draftAt(draft, draft.b), this.owner._geometrySnapshot()); + return true; + } const drag = this.drag; if (!drag || drag.pid !== event.pointerId) return false; + this.swallowNextClick(); if (cancelled) { - this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item)); + this.replace(drag.original); } else if (drag.moved) { - this.owner._recordGeometry(this.owner._t('history.stair_move'), drag.before); + const label = drag.mode === 'resize' ? 'history.stair_resize' + : drag.mode === 'rotate' ? 'history.stair_rotate' : 'history.stair_move'; + this.owner._recordGeometry(this.owner._t(label), drag.before); this.owner._saveConfigDebounced(); } this.drag = null; @@ -315,9 +419,10 @@ export class StairEditorRuntime { } public undoActiveDrag(): boolean { + if (this.draft) { this.cancelDraft(); return true; } if (!this.drag) return false; const drag = this.drag; - this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item)); + this.replace(drag.original); this.drag = null; return true; } @@ -340,6 +445,7 @@ export class StairEditorRuntime { public clearGesture(): void { this.drag = null; + this.draft = null; this.selectedId = null; } @@ -365,79 +471,83 @@ export class StairEditorRuntime { }; } + private renderStair(stair: Stair, spaceIds: ReadonlySet<string>, selected: boolean, draft: boolean): TemplateResult { + const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm); + const outline = geometry.outline.map((point) => point.join(',')).join(' '); + const targetState = stairTargetState( + stair, this.owner._space, spaceIds, this.owner._hasFixedFloor, + ); + const inputEnabled = this.inputEnabled && !draft; + const select = (event: Event): void => { + if (this.owner._mode === 'plan') { + event.stopPropagation(); + this.selectedId = stair.id; + this.owner.requestUpdate(); + } + }; + return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''} ${draft ? 'draft' : ''}" + data-hp="stair" data-id=${stair.id} data-kind=${stair.kind} + data-target-state=${targetState} + role="img" + aria-label=${this.owner._t('markup.stairs')} + @dblclick=${(event: MouseEvent) => { + event.stopPropagation(); + if (this.owner._mode === 'plan' && !draft) this.openDialog(stair); + }}> + <polygon class="hp-stair-outline" points=${outline}></polygon> + <polygon class="hp-stair-hit" points=${outline} + @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')} + @click=${select}></polygon> + ${geometry.treads.map((line) => svg`<line class="hp-stair-tread" + x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)} + <path class="hp-stair-arrow" d=${geometry.arrowPath}></path> + </g>` as unknown as TemplateResult; + } + public renderLayer(): TemplateResult { const spaceIds = new Set(this.owner._model.map((item) => item.id)); - const items = this.stairs.map((stair) => { - const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm); - const outline = geometry.outline.map((point) => point.join(',')).join(' '); - const selected = this.owner._mode === 'plan' - && (this.owner._tool === 'select' || this.owner._tool === 'stairs') - && this.selectedId === stair.id; - const targetState = stairTargetState( - stair, this.owner._space, spaceIds, this.owner._hasFixedFloor, - ); - const inputEnabled = this.owner._mode === 'plan' - && (this.owner._tool === 'select' || this.owner._tool === 'stairs'); - const select = (event: Event): void => { - if (this.owner._mode === 'plan') { - event.stopPropagation(); - this.selectedId = stair.id; - this.owner.requestUpdate(); - } - }; - const resizeHandles = stair.kind === 'spiral' - ? [{ point: [stair.x * NORM_W + stair.radius * NORM_W, stair.y * NORM_W], sx: 1, sy: 1 }] - : [ - { point: geometry.outline[0], sx: -1, sy: -1 }, - { point: geometry.outline[1], sx: 1, sy: -1 }, - { point: geometry.outline[2], sx: 1, sy: 1 }, - { point: geometry.outline[3], sx: -1, sy: 1 }, - { point: geometry.outline[0].map((value, axis) => - (value + geometry.outline[1][axis]) / 2), sx: 0, sy: -1 }, - { point: geometry.outline[1].map((value, axis) => - (value + geometry.outline[2][axis]) / 2), sx: 1, sy: 0 }, - { point: geometry.outline[2].map((value, axis) => - (value + geometry.outline[3][axis]) / 2), sx: 0, sy: 1 }, - { point: geometry.outline[3].map((value, axis) => - (value + geometry.outline[0][axis]) / 2), sx: -1, sy: 0 }, - ]; - const bearing = stair.angle * Math.PI / 180; - const outer = (stair.kind === 'spiral' ? stair.radius : stair.width / 2) * NORM_W; - const rotatePoint = [ - stair.x * NORM_W + Math.sin(bearing) * (outer + this.owner._gridPitch * 2), - stair.y * NORM_W - Math.cos(bearing) * (outer + this.owner._gridPitch * 2), - ]; - return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''}" - data-hp="stair" data-id=${stair.id} data-kind=${stair.kind} - data-target-state=${targetState} - role="img" - aria-label=${this.owner._t('markup.stairs')} - @dblclick=${(event: MouseEvent) => { - event.stopPropagation(); - if (this.owner._mode === 'plan') this.openDialog(stair); - }}> - <polygon class="hp-stair-outline" points=${outline}></polygon> - <polygon class="hp-stair-hit" points=${outline} - @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')} - @click=${select}></polygon> - ${geometry.treads.map((line) => svg`<line class="hp-stair-tread" - x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)} - <path class="hp-stair-arrow" d=${geometry.arrowPath}></path> - ${selected ? svg` - ${resizeHandles.map((handle) => svg`<circle class="hp-stair-handle hp-stair-resize" - cx=${handle.point[0]} cy=${handle.point[1]} r=${this.owner._gridPitch * 0.65} - @pointerdown=${(event: PointerEvent) => this.pointerDown( - event, stair, 'resize', handle.sx as -1 | 0 | 1, handle.sy as -1 | 0 | 1, - )}></circle>`)} - <line class="hp-stair-rotate-leader" x1=${stair.x * NORM_W} y1=${stair.y * NORM_W} - x2=${rotatePoint[0]} y2=${rotatePoint[1]}></line> - <circle class="hp-stair-handle hp-stair-rotate" - cx=${rotatePoint[0]} cy=${rotatePoint[1]} r=${this.owner._gridPitch * 0.65} - @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'rotate')}></circle> - ` : nothing} - </g>`; - }); - return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult; + const items = this.stairs.map((stair) => this.renderStair( + stair, spaceIds, this.inputEnabled && this.selectedId === stair.id, false, + )); + const draft = this.draft ? this.renderStair(this.draft.stair, spaceIds, true, true) : nothing; + return svg`<g class="hp-stairs-layer">${items}${draft}</g>` as unknown as TemplateResult; + } + + /** + * The selection frame (#676 К2) lives in the card's top overlay, above wall + * bodies, with the decor frame's chrome: finger-sized invisible hit circles, + * quarter-size visible beads, cursors by the world bearing of each handle. + */ + public renderFrame(view: { w: number; h: number }): TemplateResult | typeof nothing { + if (!this.inputEnabled) return nothing; + const stair = this.draft?.stair ?? this.stairs.find((item) => item.id === this.selectedId); + if (!stair) return nothing; + const hr = Math.max(view.w, view.h) * 0.018; + const kr = hr / 4; + const stem = stairRotateHandle(stair, hr * 2.2); + const box = stairBox(stair); + const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm); + const stop = (event: Event): void => { event.stopPropagation(); }; + const handles = stairHandles(stair).map((handle) => svg`<circle + class="dthandle hp-stair-resize dt-${resizeCursor(handle.normalDeg)}" + cx=${handle.point[0]} cy=${handle.point[1]} r=${hr.toFixed(1)} + @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'resize', { sx: handle.sx, sy: handle.sy })} + @click=${stop}></circle> + <circle class="dtknob" cx=${handle.point[0]} cy=${handle.point[1]} r=${kr.toFixed(2)}></circle>`); + const outline = stair.kind === 'spiral' + ? svg`<circle class="dtbox" cx=${box.cx} cy=${box.cy} r=${box.w / 2}></circle>` + : svg`<polygon class="dtbox" points=${geometry.outline.map((point) => point.join(',')).join(' ')}></polygon>`; + // A draft has no handles yet: the pointer that draws it is the only gesture. + return svg`<g class="dtframe hp-stair-frame" data-hp="stair-frame" data-id=${stair.id}> + ${outline} + ${this.draft ? nothing : svg` + <line class="dtstem" x1=${stem.from[0]} y1=${stem.from[1]} x2=${stem.to[0]} y2=${stem.to[1]}></line> + <circle class="dthandle dtrot hp-stair-rotate" cx=${stem.to[0]} cy=${stem.to[1]} r=${hr.toFixed(1)} + @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'rotate')} + @click=${stop}></circle> + <circle class="dtknob" cx=${stem.to[0]} cy=${stem.to[1]} r=${kr.toFixed(2)}></circle> + ${handles}`} + </g>` as unknown as TemplateResult; } public renderDialog(): TemplateResult | typeof nothing { @@ -452,14 +562,17 @@ export class StairEditorRuntime { const directionOptions: ReadonlyArray<readonly [string, I18nKey]> = dialog.kind === 'straight' ? [['forward', 'stairs.forward'], ['backward', 'stairs.backward']] as const : [['clockwise', 'stairs.clockwise'], ['counterclockwise', 'stairs.counterclockwise']] as const; + const imperial = this.owner._imperial; + const bound = (cm: number): string => String(Math.round((imperial ? cm / 2.54 : cm) * 100) / 100); const field = (key: 'length' | 'width' | 'radius', label: I18nKey) => html` <label>${this.owner._t(label)}</label> - <div class="row"><input class="namein tempin" type="number" min="0.01" step="any" + <div class="row"><input class="namein tempin" type="number" + min=${bound(STAIR_MIN_CM)} max=${bound(STAIR_MAX_CM)} step="any" .value=${dialog[key]} @input=${(event: Event) => this.updateDialog({ [key]: (event.target as HTMLInputElement).value, })}><span class="opl">${this.owner._t( - this.owner._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm', + imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm', )}</span></div>`; return html`<hp-dialog .hass=${this.owner.hass} data-kind="stairs" wide .title=${this.owner._t('stairs.properties')} icon="mdi:stairs" @@ -470,12 +583,12 @@ export class StairEditorRuntime { @change=${(event: Event) => { const kind = (event.target as HTMLSelectElement).value as Stair['kind']; const converted = convertStairKind(current, kind); - const sizes = stairPhysicalSizeCm(converted, this.owner._cellCm); + const cellCm = this.owner._cellCm; this.updateDialog({ kind, - length: cmToField(kind === 'straight' ? sizes[0] : 100, this.owner._imperial), - width: cmToField(kind === 'straight' ? sizes[1] : 100, this.owner._imperial), - radius: cmToField(kind === 'spiral' ? sizes[0] : 90, this.owner._imperial), + length: stairFieldOf(converted.kind === 'straight' ? converted.length : converted.radius * 2, cellCm, imperial), + width: stairFieldOf(converted.kind === 'straight' ? converted.width : converted.radius * 2, cellCm, imperial), + radius: stairFieldOf(converted.kind === 'spiral' ? converted.radius : Math.max(converted.length, converted.width) / 2, cellCm, imperial), direction: converted.direction, }); }}> diff --git a/src/stairs-view.ts b/src/stairs-view.ts index b45e671d..404fd6fe 100644 --- a/src/stairs-view.ts +++ b/src/stairs-view.ts @@ -14,7 +14,10 @@ export interface StairViewHostPort { _suppressClick: boolean; _cellCm: number; _tabClick(spaceId: string): void; - _t(key: 'markup.stairs'): string; + _t(key: 'markup.stairs' | 'stairs.tooltip_navigate', vars?: Record<string, string | number>): string; + /** The card's pointer tooltip: shown only for a hover-capable pointer. */ + _showTip(event: PointerEvent, title: string, meta: string): void; + _clearPointerHover(): void; } type ViewPress = { pid: number; startedAt: number }; @@ -65,6 +68,15 @@ export class StairViewRuntime { stair, this.owner._space, spaceIds, this.owner._hasFixedFloor, ); const active = interactive && targetState === 'active'; + // #676 К8: the tooltip has exactly the link's condition — `active` — so a + // missing, self, deleted or fixed-floor target never announces a floor. + const targetTitle = active + ? this.owner._model.find((item) => item.id === stair.target_space_id)?.title ?? '' + : ''; + const tip = (event: PointerEvent): void => { + if (!active) return; + this.owner._showTip(event, this.owner._t('stairs.tooltip_navigate', { title: targetTitle }), ''); + }; const navigate = (event: Event): void => { event.stopPropagation(); if (event.type === 'keydown') this.suppressClick = false; @@ -78,6 +90,9 @@ export class StairViewRuntime { role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing} aria-label=${this.owner._t('markup.stairs')} @click=${navigate} + @pointerenter=${tip} + @pointermove=${tip} + @pointerleave=${() => { if (active) this.owner._clearPointerHover(); }} @keydown=${(event: KeyboardEvent) => { if (active && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index 046e120b..d7fa95fb 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -976,10 +976,12 @@ export const planStyles = css` .dtframe .dtrot { cursor: grab; } .dtframe .dt-ew { cursor: ew-resize; } .dtframe .dt-ns { cursor: ns-resize; } - .dtfurnitureframe .dtrot { + .dtfurnitureframe .dtrot, + .hp-stair-frame .dtrot { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18.4 7.2A8 8 0 1 0 20 12' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m15.5 3.8 3.2 3.5-4.6.8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 12 12, grab; } - .dtfurnitureframe .dtrot:active { cursor: grabbing; } + .dtfurnitureframe .dtrot:active, + .hp-stair-frame .dtrot:active { cursor: grabbing; } .dtframe .dtendpoint { cursor: crosshair; } .bdframe .dtrot { cursor: grab; } .dtarea { @@ -1592,21 +1594,10 @@ export const planStyles = css` stroke: var(--hp-accent); stroke-width: calc(3px / var(--hp-plan-screen-scale, 1)); } - .hp-stair-handle { - fill: var(--hp-accent); - stroke: var(--hp-bg, #fff); - stroke-width: 2px; - vector-effect: non-scaling-stroke; - pointer-events: all; - touch-action: none; - } - .hp-stair-resize { cursor: nwse-resize; } - .hp-stair-rotate { cursor: grab; } - .hp-stair-rotate-leader { - stroke: var(--hp-accent); - stroke-width: 1.5px; - vector-effect: non-scaling-stroke; - pointer-events: none; - } + /* #676: the frame and its handles are the decor frame's chrome (.dtframe, + .dthandle, .dtknob, dt-* cursors) painted in the top overlay; the draft + being drawn is inert until it is placed */ + .hp-stair.draft .hp-stair-hit { pointer-events: none; } + .hp-stair.input-enabled .hp-stair-hit { cursor: move; } .alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; } `; diff --git a/test/stairs-editor-model.test.mjs b/test/stairs-editor-model.test.mjs new file mode 100644 index 00000000..f3ca21c1 --- /dev/null +++ b/test/stairs-editor-model.test.mjs @@ -0,0 +1,283 @@ +// #676: the stair editing layer is an oriented box with pure transforms — +// drag-to-draw, resize about the anchor without mirroring, edge magnets that +// never turn a stair more than the tolerance, cursors by world bearing, and a +// properties dialog that keeps untouched numbers bit for bit. +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + defaultStair, + draftLeadingHandle, + draftStair, + magnetStairMove, + magnetStairResize, + physicalStairSurfaces, + resizeCursor, + resizeStair, + snapEdgeToFaces, + stairBox, + stairEdges, + stairFieldOf, + stairFieldToCm, + stairHandles, + stairMinN, + stairRotateHandle, + stairSizeFromField, + STAIR_MAGNET_ANGLE_DEG, + STAIR_MAX_CM, + STAIR_MIN_CM, +} from '../test-build/stairs-editor-model.js'; + +const CELL = 5; +const SCALE = 1000; +// 1 cell = 1000 / 240 units; 30 cm = 6 cells = 25 units at 5 cm per cell. +const CELL_UNITS = SCALE / 240; +const cmUnits = (cm) => (cm / CELL) * CELL_UNITS; +const close = (actual, expected, tolerance = 1e-6) => assert.ok( + Math.abs(actual - expected) <= tolerance, `${actual} ≠ ${expected} (±${tolerance})`, +); + +const straight = (extra = {}) => ({ + id: 's', kind: 'straight', x: 0.5, y: 0.5, angle: 0, direction: 'forward', + length: cmUnits(240) / SCALE, width: cmUnits(100) / SCALE, target_space_id: null, ...extra, +}); +const spiral = (extra = {}) => ({ + id: 'r', kind: 'spiral', x: 0.5, y: 0.5, angle: 0, direction: 'clockwise', + radius: cmUnits(90) / SCALE, target_space_id: null, ...extra, +}); + +/** A horizontal wall face at y = `y` whose free side looks up (-y) or down (+y). */ +const faceAt = (y, x1, x2, looksUp, id = 'face') => ({ + a: [x1, y], b: [x2, y], axisA: [x1, y], axisB: [x2, y], + normal: looksUp ? [0, -1] : [0, 1], owner: 'physical', stableId: id, +}); +const twoSidedFace = (a, b, id = 'body') => ({ a, b, axisA: a, axisB: b, normal: null, owner: 'physical', stableId: id }); + +test('#676 AC1: a short drag is a click and places the default stair at the press', () => { + const stair = draftStair('straight', [400, 300], [402, 303], CELL, 'id', CELL_UNITS); + assert.deepEqual(stair, defaultStair('straight', 400, 300, CELL, 'id')); + const round = draftStair('spiral', [400, 300], [400, 300], CELL, 'id', CELL_UNITS); + assert.deepEqual(round, defaultStair('spiral', 400, 300, CELL, 'id')); +}); + +test('#676 AC1: the dominant drag axis is the rise axis and the ascent points from a to b', () => { + const east = draftStair('straight', [100, 100], [340, 160], CELL, 'id', CELL_UNITS); + assert.equal(east.angle, 0); + assert.equal(east.direction, 'forward'); + close(east.length * SCALE, 240); + close(east.width * SCALE, 60); + close(east.x * SCALE, 220); + close(east.y * SCALE, 130); + const west = draftStair('straight', [340, 160], [100, 100], CELL, 'id', CELL_UNITS); + assert.equal(west.angle, 180); + close(west.length * SCALE, 240); + const south = draftStair('straight', [100, 100], [160, 340], CELL, 'id', CELL_UNITS); + assert.equal(south.angle, 90); + close(south.length * SCALE, 240); + close(south.width * SCALE, 60); + const north = draftStair('straight', [160, 340], [100, 100], CELL, 'id', CELL_UNITS); + assert.equal(north.angle, 270); + // Equal extents prefer x, deterministically. + assert.equal(draftStair('straight', [0, 0], [100, 100], CELL, 'id', CELL_UNITS).angle, 0); + assert.equal(draftStair('straight', [0, 0], [-100, 100], CELL, 'id', CELL_UNITS).angle, 180); +}); + +test('#676 AC1: drawn sizes never drop below one tread, spiral takes the square from a toward b', () => { + const thin = draftStair('straight', [100, 100], [300, 104], CELL, 'id', CELL_UNITS); + close(thin.width * SCALE, cmUnits(STAIR_MIN_CM)); + close(thin.length * SCALE, 200); + const round = draftStair('spiral', [100, 100], [180, 60], CELL, 'id', CELL_UNITS); + close(round.radius * SCALE, 40); + close(round.x * SCALE, 140); + close(round.y * SCALE, 60); + assert.equal(round.direction, 'clockwise'); + const tiny = draftStair('spiral', [100, 100], [110, 108], CELL, 'id', CELL_UNITS); + close(tiny.radius * SCALE, cmUnits(STAIR_MIN_CM) / 2); + assert.deepEqual(draftLeadingHandle(round, [100, 100], [180, 60]), { sx: 1, sy: -1 }); + assert.deepEqual(draftLeadingHandle(thin, [100, 100], [300, 104]), { sx: 1, sy: 1 }); + const south = draftStair('straight', [100, 100], [160, 340], CELL, 'id', CELL_UNITS); + assert.deepEqual(draftLeadingHandle(south, [100, 100], [160, 340]), { sx: 1, sy: -1 }); +}); + +test('#676 AC2/AC11: resize keeps the anchor side fixed, the angle, and never mirrors past it', () => { + const stair = straight({ angle: 30 }); + const min = stairMinN(CELL) * SCALE; + const before = stairBox(stair); + const edgeBefore = stairEdges(stair).find((edge) => edge.sx === -1); + const east = stairHandles(stair).find((handle) => handle.sx === 1 && handle.sy === 0); + const pulled = resizeStair(stair, east, [east.point[0] + 50 * Math.cos(Math.PI / 6), east.point[1] + 50 * Math.sin(Math.PI / 6)], { minUnits: min }); + assert.equal(pulled.angle, 30); + close(pulled.length * SCALE, before.w + 50, 1e-6); + close(pulled.width * SCALE, before.h); + const edgeAfter = stairEdges(pulled).find((edge) => edge.sx === -1); + close(edgeAfter.mid[0], edgeBefore.mid[0], 1e-9); + close(edgeAfter.mid[1], edgeBefore.mid[1], 1e-9); + // Past the anchor: the size stops at the minimum and the anchor still does not move. + const west = stairEdges(stair).find((edge) => edge.sx === -1).mid; + const crossed = resizeStair(stair, east, [west[0] - 300, west[1] - 100], { minUnits: min }); + close(crossed.length * SCALE, min); + assert.equal(crossed.angle, 30); + const anchorAfterCross = stairEdges(crossed).find((edge) => edge.sx === -1); + close(anchorAfterCross.mid[0], edgeBefore.mid[0], 1e-9); + close(anchorAfterCross.mid[1], edgeBefore.mid[1], 1e-9); + // Corner with Shift: proportional, following the axis that moved farther. + const corner = stairHandles(stair).find((handle) => handle.sx === 1 && handle.sy === 1); + const proportional = resizeStair(stair, corner, [corner.point[0] + 100 * Math.cos(Math.PI / 6), corner.point[1] + 100 * Math.sin(Math.PI / 6)], { minUnits: min, keepAspect: true }); + close(proportional.length / proportional.width, stair.length / stair.width, 1e-9); + assert.equal(proportional.angle, 30); +}); + +test('#676 AC2: a spiral handle drags its tangent, the circle stays a circle and the opposite tangent stays', () => { + const stair = spiral(); + const min = stairMinN(CELL) * SCALE; + const east = stairHandles(stair).find((handle) => handle.sx === 1); + const westBefore = stairEdges(stair).find((edge) => edge.sx === -1).mid; + const grown = resizeStair(stair, east, [east.point[0] + 40, east.point[1] + 7], { minUnits: min }); + close(grown.radius * SCALE, stair.radius * SCALE + 20); + const westAfter = stairEdges(grown).find((edge) => edge.sx === -1).mid; + close(westAfter[0], westBefore[0], 1e-9); + const north = stairHandles(stair).find((handle) => handle.sy === -1); + const shrunk = resizeStair(stair, north, [north.point[0], north.point[1] + 1000], { minUnits: min }); + close(shrunk.radius * SCALE, min / 2); +}); + +test('#676 AC4: the resize cursor follows the world bearing of the handle', () => { + assert.equal(resizeCursor(0), 'ew'); + assert.equal(resizeCursor(180), 'ew'); + assert.equal(resizeCursor(90), 'ns'); + assert.equal(resizeCursor(270), 'ns'); + assert.equal(resizeCursor(45), 'nwse'); + assert.equal(resizeCursor(225), 'nwse'); + assert.equal(resizeCursor(135), 'nesw'); + assert.equal(resizeCursor(315), 'nesw'); + assert.equal(resizeCursor(-20), 'ew'); + assert.equal(resizeCursor(23), 'nwse'); + const rotated = straight({ angle: 90 }); + const byHandle = Object.fromEntries(stairHandles(rotated).map((handle) => [`${handle.sx},${handle.sy}`, resizeCursor(handle.normalDeg)])); + // Local +x now points down the screen: the "east" side handle wants ns. + assert.equal(byHandle['1,0'], 'ns'); + assert.equal(byHandle['0,1'], 'ew'); + assert.equal(byHandle['1,1'], 'nesw'); + const tilted = straight({ angle: 30 }); + assert.equal(resizeCursor(stairHandles(tilted).find((handle) => handle.sx === 1 && handle.sy === 0).normalDeg), 'nwse'); + // The top side's normal at 30° tilt is 300°: nearer the NE diagonal than vertical. + assert.equal(resizeCursor(stairHandles(tilted).find((handle) => handle.sx === 0 && handle.sy === -1).normalDeg), 'nesw'); + const gentle = straight({ angle: 10 }); + assert.equal(resizeCursor(stairHandles(gentle).find((handle) => handle.sx === 0 && handle.sy === -1).normalDeg), 'ns'); + const stem = stairRotateHandle(tilted, 40); + close(Math.hypot(stem.to[0] - stem.from[0], stem.to[1] - stem.from[1]), 40); +}); + +test('#676 AC3: a parallel side within reach lands flush; a move may turn the stair by the tolerance only', () => { + const stair = straight({ angle: 3 }); + const box = stairBox(stair); + const top = stairEdges(stair).find((edge) => edge.sy === -1); + const faceY = top.mid[1] - 12; + const surfaces = [faceAt(faceY, 0, 1000, false)]; + const snapped = magnetStairMove(stair, surfaces, 25); + assert.equal(snapped.angle, 0, 'turned to exactly parallel within the tolerance'); + const topAfter = stairEdges(snapped).find((edge) => edge.sy === -1); + close(topAfter.mid[1], faceY, 1e-6); + close(snapped.length, stair.length, 1e-12); + // The turn is the only rotation: a 30° stair is not parallel and is left alone. + const skew = straight({ angle: 30 }); + assert.deepEqual(magnetStairMove(skew, surfaces, 25), skew); + // Out of reach: untouched. + assert.deepEqual(magnetStairMove(stair, [faceAt(faceY - 100, 0, 1000, false)], 25), stair); + // A face that looks away (the other side of a room wall) never pulls through the masonry. + assert.deepEqual(magnetStairMove(stair, [faceAt(faceY, 0, 1000, true)], 25), stair); + // A face that misses the side's projection is not a candidate. + assert.deepEqual(magnetStairMove(stair, [faceAt(faceY, box.cx + 400, box.cx + 800, false)], 25), stair); + assert.equal(STAIR_MAGNET_ANGLE_DEG, 5); +}); + +test('#676 AC3: a stair standing end-on to a wall snaps with its end and keeps its angle', () => { + const stair = straight({ angle: 90 }); + // Local +x points down: the "east" side is the lower end of the flight. + const end = stairEdges(stair).find((edge) => edge.sx === 1); + const faceY = end.mid[1] + 8; + const snapped = magnetStairMove(stair, [twoSidedFace([0, faceY], [1000, faceY])], 25); + assert.equal(snapped.angle, 90); + close(stairEdges(snapped).find((edge) => edge.sx === 1).mid[1], faceY, 1e-6); + close(snapped.x, stair.x, 1e-12); +}); + +test('#676 AC3: the resize magnet moves only the dragged side and never the angle or the anchor', () => { + const stair = straight({ angle: 5 }); + const min = stairMinN(CELL) * SCALE; + const east = stairEdges(stair).find((edge) => edge.sx === 1); + const west = stairEdges(stair).find((edge) => edge.sx === -1); + // A vertical face 10 units beyond the east end, parallel to it within 5°. + const faceX = east.mid[0] + 10; + const surfaces = [twoSidedFace([faceX, east.mid[1] - 300], [faceX, east.mid[1] + 300])]; + const snapped = magnetStairResize(stair, { sx: 1, sy: 0 }, surfaces, 25, min); + assert.equal(snapped.angle, 5); + const eastAfter = stairEdges(snapped).find((edge) => edge.sx === 1); + const westAfter = stairEdges(snapped).find((edge) => edge.sx === -1); + close(eastAfter.mid[0], faceX + (eastAfter.mid[1] - east.mid[1]) * 0, 0.5); + close(westAfter.mid[0], west.mid[0], 1e-9); + close(westAfter.mid[1], west.mid[1], 1e-9); + // The side that was not dragged is never magnetised. + const untouched = magnetStairResize(stair, { sx: -1, sy: 0 }, surfaces, 25, min); + assert.deepEqual(untouched, stair); + // A snap that would shrink below the minimum is refused. + const nearFace = [twoSidedFace([west.mid[0] + 5, -1000], [west.mid[0] + 5, 2000])]; + assert.deepEqual(magnetStairResize(straight({ length: min / SCALE }), { sx: 1, sy: 0 }, nearFace, 25, min), straight({ length: min / SCALE })); +}); + +test('#676 AC3: the nearest side wins and edge geometry reports outward normals', () => { + const stair = straight(); + const edges = stairEdges(stair); + assert.deepEqual(edges.map((edge) => [edge.sx, edge.sy]), [[1, 0], [-1, 0], [0, 1], [0, -1]]); + assert.deepEqual(edges[0].normal, [1, 0]); + assert.deepEqual(edges[3].normal, [0, -1]); + const top = edges[3]; + const near = faceAt(top.mid[1] - 4, 0, 1000, false, 'near'); + const far = faceAt(top.mid[1] - 20, 0, 1000, false, 'far'); + assert.equal(snapEdgeToFaces(top, [far, near], 25).stableId, 'near'); + assert.equal(snapEdgeToFaces(top, [near, far], 25).stableId, 'near'); + const round = spiral(); + const tangents = stairEdges(round); + close(Math.hypot(tangents[0].mid[0] - round.x * SCALE, tangents[0].mid[1] - round.y * SCALE), round.radius * SCALE); +}); + +test('#676 AC5: dialog fields round-trip real sizes and reject only the stair bounds', () => { + assert.equal(stairFieldToCm('424.62', false), 424.62); + assert.equal(stairFieldToCm('155,38', false), 155.38); + assert.equal(stairFieldToCm('240', false), 240, '100 cm is not a ceiling'); + assert.equal(stairFieldToCm(String(STAIR_MAX_CM), false), STAIR_MAX_CM); + assert.equal(stairFieldToCm(String(STAIR_MAX_CM + 1), false), null); + assert.equal(stairFieldToCm('29.9', false), null); + assert.equal(stairFieldToCm('30', false), STAIR_MIN_CM); + assert.equal(stairFieldToCm('', false), null); + assert.equal(stairFieldToCm('100', true), 254); + // An untouched field keeps the stored number bit for bit, whatever rounding the field shows. + const stored = 0.3538461538461537; + const shown = stairFieldOf(stored, CELL, false); + assert.equal(shown, '424.62'); + assert.equal(stairSizeFromField(shown, shown, stored, CELL, false), stored); + close(stairSizeFromField('300', shown, stored, CELL, false) * CELL * 240, 300, 1e-9); + assert.equal(stairSizeFromField('12', shown, stored, CELL, false), null); +}); + +test('#676 AC3: physical bodies expose only their outward faces, whatever the polygon winding', () => { + const clockwise = [[100, 100], [300, 100], [300, 120], [100, 120]]; + const counter = [...clockwise].reverse(); + for (const body of [clockwise, counter]) { + const faces = physicalStairSurfaces([body]); + assert.equal(faces.length, 4); + const top = faces.find((face) => face.a[1] === 100 && face.b[1] === 100); + const bottom = faces.find((face) => face.a[1] === 120 && face.b[1] === 120); + assert.deepEqual(top.normal, [0, -1], 'the upper face looks up, away from the body'); + assert.deepEqual(bottom.normal, [0, 1], 'the lower face looks down, away from the body'); + assert.equal(top.owner, 'physical'); + } + // A stair below the wall snaps its upper side to the exposed lower face, + // never to the face hidden inside the masonry, even when that one is nearer. + const stair = straight({ x: 0.2, y: 0.14, width: 0.05 }); + const upper = stairEdges(stair).find((edge) => edge.sy === -1); + assert.ok(upper.mid[1] < 120 && upper.mid[1] > 100, 'the stair overlaps the body'); + const snapped = magnetStairMove(stair, physicalStairSurfaces([clockwise]), 25); + close(stairEdges(snapped).find((edge) => edge.sy === -1).mid[1], 120, 1e-6); + assert.deepEqual(physicalStairSurfaces([[[0, 0], [10, 0]], [[0, 0], [10, 0], [10, 0]]]), [], 'degenerate bodies have no faces'); +});