From 89e8aecbc1afb4858c507ba015dd791b743fc9f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:28:17 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20box-=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86=20=D0=B2=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B2=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=20stairs-box.ts=20(#676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Просмотр импортирует 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/smoke_stairs.mjs | 2 +- docs/ARCHITECTURE.md | 26 +- docs/STAIRS.md | 10 +- docs/data-hp-contract.json | 5 + scripts/bundle-budget.mjs | 9 +- scripts/monolith-baseline.json | 2 +- scripts/mutation-registry.mjs | 16 +- src/houseplan-card.ts | 10 +- src/stairs-box.ts | 410 ++++++++++++++++++ src/stairs-editor-model.ts | 401 +---------------- src/stairs-editor.ts | 10 +- test/core-file-budget.test.mjs | 5 +- ...tor-model.test.mjs => stairs-box.test.mjs} | 4 +- tsconfig.test.json | 2 +- 14 files changed, 473 insertions(+), 439 deletions(-) create mode 100644 src/stairs-box.ts rename test/{stairs-editor-model.test.mjs => stairs-box.test.mjs} (99%) diff --git a/demo/smoke_stairs.mjs b/demo/smoke_stairs.mjs index 99a7124c..132044ee 100644 --- a/demo/smoke_stairs.mjs +++ b/demo/smoke_stairs.mjs @@ -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; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10330736..abd1b421 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/STAIRS.md b/docs/STAIRS.md index 2801beae..109a2e40 100644 --- a/docs/STAIRS.md +++ b/docs/STAIRS.md @@ -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. diff --git a/docs/data-hp-contract.json b/docs/data-hp-contract.json index 9fcb8834..dfc9bc5f 100644 --- a/docs/data-hp-contract.json +++ b/docs/data-hp-contract.json @@ -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", diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 4e2cbd63..515269af 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -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; /** diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index 96049460..8aaa995e 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 4880, "portPrivates": 94, "harnessPrivates": 100, - "bundleBytes": 2567812 + "bundleBytes": 2577881 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index a7d74287..c20fada0 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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' diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 90a0af4b..b5cbfd1f 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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} diff --git a/src/stairs-box.ts b/src/stairs-box.ts new file mode 100644 index 00000000..fdab2b65 --- /dev/null +++ b/src/stairs-box.ts @@ -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); +} diff --git a/src/stairs-editor-model.ts b/src/stairs-editor-model.ts index e1e9df5d..408676eb 100644 --- a/src/stairs-editor-model.ts +++ b/src/stairs-editor-model.ts @@ -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); -} diff --git a/src/stairs-editor.ts b/src/stairs-editor.ts index 040d3321..6e40e104 100644 --- a/src/stairs-editor.ts +++ b/src/stairs-editor.ts @@ -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, diff --git a/test/core-file-budget.test.mjs b/test/core-file-budget.test.mjs index 6896b2fd..2528f6c5 100644 --- a/test/core-file-budget.test.mjs +++ b/test/core-file-budget.test.mjs @@ -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. diff --git a/test/stairs-editor-model.test.mjs b/test/stairs-box.test.mjs similarity index 99% rename from test/stairs-editor-model.test.mjs rename to test/stairs-box.test.mjs index f3ca21c1..0fba4820 100644 --- a/test/stairs-editor-model.test.mjs +++ b/test/stairs-box.test.mjs @@ -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; diff --git a/tsconfig.test.json b/tsconfig.test.json index b73ee198..2604fcf0 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",