refactor: вынести box-модель лестниц в ленивый модуль stairs-box.ts (#676)

Просмотр импортирует stairTargetState из stairs-editor-model.ts, и общий
модуль Rollup кладёт в eager-чанк: новые функции протяжки, ресайза, магнита
и диалога утянули туда 2,5 КБ gzip и выбили бюджет первого View. Теперь
eager-модуль снова маленький, а всё редакторское живёт в stairs-box.ts,
который импортирует только ленивый редактор: initial View 299 165 Б при
бюджете 301 066, запас 1 901 Б.

Потолок ленивого редактора 236 400 → 239 000 (замер 237 985) с записью
истории в bundle-budget.mjs; база монолита bundleBytes поднята тем же
коммитом (+10 069 Б сырых — код слоя редактирования). Хук data-hp получил
запись stair-frame; смок не пишет в приватное поле.

Issue: #676
User-Visible: no
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-27 14:41:18 +00:00
committed by claude[bot]
parent 93051edfc2
commit 89e8aecbc1
14 changed files with 473 additions and 439 deletions
+1 -1
View File
@@ -333,7 +333,7 @@ const out = await page.evaluate(async () => {
// 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;
// Every step ends with a pointerleave, so `_tip` is null at the start.
pointer(node, 'pointermove', hoverCenter, 6660);
await settled();
const shown = card._tip?.title ?? null;
+16 -10
View File
@@ -28,7 +28,8 @@ houseplan-card/
│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root
│ ├─ stairs-view.ts # eager read-only stair symbols, links and gesture guards
│ ├─ stairs-editor.ts # lazy Plan stair tools, transforms and properties
│ ├─ stairs-editor-model.ts # pure editor transforms, snapping and target-state helpers
│ ├─ stairs-editor-model.ts # eager-safe stair helpers: defaults, kind conversion, target state, stair magnet
│ ├─ stairs-box.ts # editor-only box transforms: drag-to-draw, resize, edge magnets, cursors, dialog units
│ ├─ stairs.ts # stair model, render geometry and area math
│ ├─ clean-floor.ts # shared room-floor subtraction including stair footprints
│ ├─ decor-image-editor.ts # lazy Background/Furniture image palette, upload and properties controller
@@ -1130,15 +1131,20 @@ spaces use 1 cm or 2.54 cm/1 inch, while missing legacy values fall back to 5 cm
### Stairs (#663)
Stairs are a separate Plan entity, not decor. The lazy `StairEditorRuntime`
owns the straight/spiral tool group, selection, continuous
move/resize/rotation, wall/stair magnet and properties. The eager
`StairViewRuntime` owns only read-only symbols, guarded navigation and gesture
suppression, so opening a plan does not load the editor graph. The root card
owns lifecycle composition, shared Plan history/persistence and stage pointer
terminals. `stairs.ts` is the pure boundary for the discriminated model, exact
30 cm tread geometry, cached render projection and footprint containment;
`stairs-editor-model.ts` contains the pure transform, target-state and
footprint-to-footprint snapping helpers used by the editor.
owns the straight/spiral tool group, drag-to-draw placement, selection,
continuous move/resize/rotation, edge magnets and properties, and renders the
selection frame that the root card places in its top overlay above wall bodies
(#676). The eager `StairViewRuntime` owns only read-only symbols, the hover
tooltip, guarded navigation and gesture suppression, so opening a plan does not
load the editor graph. The root card owns lifecycle composition, shared Plan
history/persistence and stage pointer terminals. `stairs.ts` is the pure
boundary for the discriminated model, exact 30 cm tread geometry, cached render
projection and footprint containment; `stairs-editor-model.ts` keeps the small
eager-safe helpers (defaults, kind conversion, target state, stair-to-stair
snapping) the View runtime shares, while `stairs-box.ts` holds the editor-only
oriented-box transforms — drawing, resizing about the anchor, wall-face edge
magnets with the outward faces of physical bodies, handle bearings and cursors,
dialog unit conversion — imported only by the lazy editor chunk.
`spaces[].stairs[]` is optional and capped at 250 records. A record exists on
one space only; `target_space_id` is a one-way navigation reference and never
+6 -4
View File
@@ -116,11 +116,13 @@ 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 — the oriented box,
- `src/stairs-editor-model.ts` keeps the eager-safe helpers the View runtime
shares (defaults, kind conversion, target state, stair-to-stair magnet);
`src/stairs-box.ts` owns the editor-only oriented-box transforms —
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).
cursors, dialog conversion — and is imported only by the lazy editor chunk;
`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.
+5
View File
@@ -162,6 +162,11 @@
"since": "1.78.0-beta.5",
"audience": ["styling", "test"]
},
"stair-frame": {
"elements": ["g"],
"since": "1.78.0-beta.6",
"audience": ["styling", "test"]
},
"support": {
"elements": ["button"],
"since": "1.73.0-beta.7",
+8 -1
View File
@@ -618,8 +618,15 @@ export const LAZY_FURNITURE_ART_GZIP_CEILING = 17_900;
* символ и межэтажный переход отделены в eager runtime; первый View не
* загружает эти editor-only ветки. Центр оставляет 957 Б сверху и 1 043 Б
* до нижней границы полосы.
* - #676: 236 400 → 239 000 (замер 237 985, +2 542 Б к замеру #663). Слой
* редактирования лестниц переписан на box-контракт декора: чистые функции
* протяжки, ресайза без зеркала, магнита по сторонам с внешними гранями
* тел, секторов курсора и конвертера диалога живут в `stairs-box.ts`,
* который импортирует только ленивый редактор — eager View получил лишь
* подсказку «Переход на этаж» (`stairs-view.ts`) и три строки словарей.
* Центр оставляет 1 015 Б сверху и 985 Б до нижней границы полосы.
*/
export const LAZY_EDITOR_GZIP_CEILING = 236_400;
export const LAZY_EDITOR_GZIP_CEILING = 239_000;
export const LAZY_GRAPH_CEILING_BAND = 2_000;
/**
+1 -1
View File
@@ -4,5 +4,5 @@
"hostRefs": 4880,
"portPrivates": 94,
"harnessPrivates": 100,
"bundleBytes": 2567812
"bundleBytes": 2577881
}
+8 -8
View File
@@ -147,11 +147,11 @@ const MUTANT_DEFINITIONS = [
{
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',
+ '&& node --test test/stairs-box.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',
file: 'src/stairs-box.ts',
find: " if (sector === 0 || sector === 4) return 'ew';\n",
replace: " if (sector >= 0) return 'nwse'; // mutant: one cursor for every handle\n",
}],
@@ -159,11 +159,11 @@ const MUTANT_DEFINITIONS = [
{
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',
+ '&& node --test test/stairs-box.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',
file: 'src/stairs-box.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',
}],
@@ -171,11 +171,11 @@ const MUTANT_DEFINITIONS = [
{
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',
+ '&& node --test test/stairs-box.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',
file: 'src/stairs-box.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',
}],
@@ -183,11 +183,11 @@ const MUTANT_DEFINITIONS = [
{
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',
+ '&& node --test test/stairs-box.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',
file: 'src/stairs-box.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'
+2 -8
View File
@@ -6700,9 +6700,7 @@ 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._editorRuntime?.stairs.stagePointerDown(ev)) return; // #676: drag-to-draw owns the press
}
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
@@ -11125,11 +11123,7 @@ 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}
${this._mode === 'plan' && this._editorRuntime ? this._editorRuntime.stairs.renderFrame(view) : nothing}
<g data-hp-live-editor></g>
</g>
</svg>
+410
View File
@@ -0,0 +1,410 @@
import { GRID_N, NORM_W } from './canvas-constants';
import type { FurnitureWallSurface } from './furniture-wall-surface';
import type { Stair } from './stairs';
import { defaultStair, normalizeStairAngle } from './stairs-editor-model';
// #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. Only the lazy
// editor chunk imports this module: the eager View bundle keeps its budget.
/** 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 cmToNorm = (cm: number, cellCm: number): number => (
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
);
const normToCm = (value: number, cellCm: number): number => (
Number(value) * (Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N
);
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);
}
+3 -398
View File
@@ -1,13 +1,9 @@
import { GRID_N, NORM_W } from './canvas-constants';
import type { FurnitureWallSurface } from './furniture-wall-surface';
import type { Stair } from './stairs';
/** 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;
// Eager module: the View runtime imports `stairTargetState` from here, so this
// file must stay small. The editor-only box transforms live in
// `stairs-box.ts`, which only the lazy editor chunk imports (#676).
const finite = (value: unknown): number | null => {
const number = Number(value);
@@ -108,394 +104,3 @@ 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);
}
+6 -4
View File
@@ -8,10 +8,12 @@ import type { EditorToolbarGroup } from './editor-secondary';
import type { I18nKey } from './i18n';
import { clampCanvasN, NORM_W } from './space-geometry';
import {
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,
draftLeadingHandle, draftStair, magnetStairMove, magnetStairResize, physicalStairSurfaces,
resizeCursor, resizeStair, stairBox, stairFieldOf, stairHandles, stairMinN,
stairRotateHandle, stairSizeFromField, STAIR_MAX_CM, STAIR_MIN_CM, type StairHandleSign,
} from './stairs-box';
import {
convertStairKind, normalizeStairAngle, snapStairToStairs, stairTargetState,
} from './stairs-editor-model';
import {
cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, type Stair,
+4 -1
View File
@@ -37,7 +37,10 @@ const CAPS = {
// src/optimize-plans-dialog.ts — ушли пять делегатов, две стрелки-заглушки,
// поле фолбэка буфера обмена, дедуп dev-лога и литерал типа состояния,
// 62 строки. Потолок опущен на выигрыш, запас прежний.
'src/houseplan-card.ts': 12889,
// 2026-09-27, #676: +2 — вызов черновика лестницы в pointerdown сцены и
// рамка лестницы в верхнем оверлее (выше тел стен). Всё остальное слоя —
// жесты, рамка, магнит, диалог — живёт в stairs-editor.ts и stairs-box.ts.
'src/houseplan-card.ts': 12891,
// #478 removed the persisted room-draft editor branch. Keep that reduction.
// #485 keeps its large setup surface in editors/radar-section.ts; these are
// only the dialog state/save seam and the thin lazy-render adapter.
@@ -5,8 +5,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { defaultStair } from '../test-build/stairs-editor-model.js';
import {
defaultStair,
draftLeadingHandle,
draftStair,
magnetStairMove,
@@ -26,7 +26,7 @@ import {
STAIR_MAGNET_ANGLE_DEG,
STAIR_MAX_CM,
STAIR_MIN_CM,
} from '../test-build/stairs-editor-model.js';
} from '../test-build/stairs-box.js';
const CELL = 5;
const SCALE = 1000;
+1 -1
View File
@@ -24,7 +24,7 @@
"src/virtual-light-state.ts", "src/config-store.ts", "src/config-reload-authority.ts", "src/config-write-conflict.ts", "src/summary-panel.ts", "src/summary-panel-metrics.ts", "src/summary-panel-i18n.ts", "src/summary-panel-identity.ts", "src/summary-panel-picker.ts", "src/summary-panel-runtime-loaded.ts", "src/header-menu.ts", "src/iso-materials.ts", "src/iso-first-frame.ts", "src/iso-tiles.ts", "src/iso-sun.ts",
"src/backdrop-probe.ts",
"src/types.ts", "src/canvas-constants.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts",
"src/space-geometry.ts", "src/stairs.ts", "src/clean-floor.ts", "src/stairs-editor-model.ts", "src/junction-limits.ts", "src/room-gear-drag.ts",
"src/space-geometry.ts", "src/stairs.ts", "src/clean-floor.ts", "src/stairs-editor-model.ts", "src/stairs-box.ts", "src/junction-limits.ts", "src/room-gear-drag.ts",
"src/space-order.ts", "src/card-editor-validation.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/version-recovery.ts", "src/version-recovery-card.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/boot-soft-layout.ts", "src/room-fit.ts", "src/editor-runtime-loader.ts", "src/editor-secondary.ts", "src/pointer-modality.ts", "src/touch-gesture-click-guard.ts",