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).

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}>\n',
+ replace: ' @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, \'resize\', { sx: handle.sx, sy: handle.sy })}>\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}
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, 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` {
+ event.stopPropagation();
+ if (this.owner._mode === 'plan' && !draft) this.openDialog(stair);
+ }}>
+
+ this.pointerDown(event, stair, 'move')}
+ @click=${select}>
+ ${geometry.treads.map((line) => svg``)}
+
+ ` 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` {
- event.stopPropagation();
- if (this.owner._mode === 'plan') this.openDialog(stair);
- }}>
-
- this.pointerDown(event, stair, 'move')}
- @click=${select}>
- ${geometry.treads.map((line) => svg``)}
-
- ${selected ? svg`
- ${resizeHandles.map((handle) => svg` this.pointerDown(
- event, stair, 'resize', handle.sx as -1 | 0 | 1, handle.sy as -1 | 0 | 1,
- )}>`)}
-
- this.pointerDown(event, stair, 'rotate')}>
- ` : nothing}
- `;
- });
- return svg`${items}` 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`${items}${draft}` 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` this.pointerDown(event, stair, 'resize', { sx: handle.sx, sy: handle.sy })}
+ @click=${stop}>
+ `);
+ const outline = stair.kind === 'spiral'
+ ? svg``
+ : svg` point.join(',')).join(' ')}>`;
+ // A draft has no handles yet: the pointer that draws it is the only gesture.
+ return svg`
+ ${outline}
+ ${this.draft ? nothing : svg`
+
+ this.pointerDown(event, stair, 'rotate')}
+ @click=${stop}>
+
+ ${handles}`}
+ ` as unknown as TemplateResult;
}
public renderDialog(): TemplateResult | typeof nothing {
@@ -452,14 +562,17 @@ export class StairEditorRuntime {
const directionOptions: ReadonlyArray = 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`
-