From 5b5b808184da5d2bf77f0882b760e070c04ba606 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 27 Sep 2026 22:35:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=B8=D0=BB=D1=8C=20=D0=BB?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86=20(#683)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #683 User-Visible: yes --- custom_components/houseplan/import_export.py | 1 + .../houseplan/support_package.py | 1 + custom_components/houseplan/validation.py | 4 + demo/smoke_stairs.mjs | 104 +++++++++++++- docs/ARCHITECTURE.md | 9 +- docs/CHANGELOG.md | 10 ++ docs/CHANGELOG.ru.md | 10 ++ docs/CONFIG-COMPATIBILITY.md | 7 +- docs/STAIRS.md | 39 ++++-- docs/USER-GUIDE.md | 6 +- docs/USER-GUIDE.ru.md | 7 +- scripts/bundle-budget.mjs | 9 +- scripts/monolith-baseline.json | 2 +- scripts/mutation-registry.mjs | 32 ++++- src/i18n/de.json | 6 +- src/i18n/en.json | 6 +- src/i18n/fr.json | 6 +- src/i18n/ru.json | 6 +- src/pdf/pdf-scene.ts | 5 +- src/space-render.ts | 11 +- src/stairs-box.ts | 9 +- src/stairs-editor-model.ts | 28 ++-- src/stairs-editor.ts | 52 ++++++- src/stairs-view.ts | 6 +- src/stairs.ts | 130 +++++++++++++++--- src/styles/plan.styles.ts | 14 +- test/pdf-scene.test.mjs | 21 +++ test/stairs.test.mjs | 92 ++++++++++--- tests_backend/test_ha_import_export.py | 4 + tests_backend/test_support_package.py | 4 + tests_backend/test_validation.py | 6 + 31 files changed, 554 insertions(+), 93 deletions(-) diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index 2d2f50bd..5c18edfe 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -299,6 +299,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]: _pick_fields(stair, ( "id", "kind", "x", "y", "angle", "direction", "target_space_id", "length", "width", "radius", + "color", "opacity", "fill_color", "fill_opacity", )) for stair in space.get("stairs") or [] ] diff --git a/custom_components/houseplan/support_package.py b/custom_components/houseplan/support_package.py index d8e97e24..c7c04557 100644 --- a/custom_components/houseplan/support_package.py +++ b/custom_components/houseplan/support_package.py @@ -381,6 +381,7 @@ def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[ projected = {"id": ids.get("stair", item.get("id"))} projected.update(_copy_keys(item, ( "kind", "x", "y", "angle", "direction", "length", "width", "radius", + "color", "opacity", "fill_color", "fill_opacity", ))) if item.get("target_space_id"): projected["target_space_id"] = ids.get("space", item.get("target_space_id")) diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index 2d317bd3..c25119ff 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -1576,6 +1576,10 @@ STAIR_SCHEMA = vol.All( vol.Optional("length"): _FURN_SIZE, vol.Optional("width"): _FURN_SIZE, vol.Optional("radius"): _FURN_SIZE, + vol.Optional("color"): _COLOR, + vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)), + vol.Optional("fill_color"): _COLOR, + vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)), }, extra=vol.ALLOW_EXTRA, ), diff --git a/demo/smoke_stairs.mjs b/demo/smoke_stairs.mjs index 132044ee..47d851bb 100644 --- a/demo/smoke_stairs.mjs +++ b/demo/smoke_stairs.mjs @@ -85,11 +85,23 @@ const out = await page.evaluate(async () => { const activeSpace = () => root().querySelector('[data-hp="space-tab"][aria-current="page"]') ?.getAttribute('data-id'); const closeTo = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance; + const pathClose = (a, b, tolerance = 1e-5) => { + const numbers = (value) => String(value || '').match(/-?\d+(?:\.\d+)?/g)?.map(Number) || []; + const left = numbers(a); + const right = numbers(b); + return left.length === right.length + && left.every((value, index) => closeTo(value, right[index], tolerance)); + }; + const stairStyle = (stair) => ({ + color: stair?.color, opacity: stair?.opacity, + fill_color: stair?.fill_color, fill_opacity: stair?.fill_opacity, + }); const axisAngleDistance = (angle) => { const normalized = ((angle % 180) + 180) % 180; return Math.min(normalized, 180 - normalized); }; const result = {}; + let dialog; await hp.setServerConfig((config) => { for (const space of config.spaces) delete space.stairs; @@ -130,6 +142,38 @@ const out = await page.evaluate(async () => { result.renderedTypesAndDirections = !!root().querySelector( '[data-hp="stair"][data-kind="straight"] .hp-stair-arrow', ) && !!root().querySelector('[data-hp="stair"][data-kind="spiral"] .hp-stair-arrow'); + result.newStairsSnapshotCurrentDecorStyle = [straight, spiral].every((stair) => + stair.color === card._decorStyle.color + && stair.opacity === card._decorStyle.opacity + && stair.fill_color === card._decorStyle.color + && stair.fill_opacity === 0); + result.straightHasTrapezoidAndSpiralDoesNot = + stairNode(straight.id)?.querySelectorAll('.hp-stair-trapezoid').length === 3 + && stairNode(spiral.id)?.querySelectorAll('.hp-stair-trapezoid').length === 0; + + // A legacy record remains untouched on read; its first explicit Save writes + // the complete visual quartet using the current decor fallback. + await hp.setServerConfig((config) => { + const legacy = config.spaces.find((space) => space.id === 'f1').stairs + .find((stair) => stair.id === spiral.id); + delete legacy.color; + delete legacy.opacity; + delete legacy.fill_color; + delete legacy.fill_opacity; + return config; + }); + await hp.switchSpace('f1'); + await hp.setMode('plan'); + const legacyRead = stairs().find((stair) => stair.id === spiral.id); + result.legacyReadDoesNotMaterializeStyle = !Object.hasOwn(legacyRead, 'color') + && stairNode(spiral.id)?.style.getPropertyValue('--hp-stair-line') === card._decorStyle.color; + dialog = await openStairDialog(spiral.id); + await saveDialog(dialog); + const materialized = stairs().find((stair) => stair.id === spiral.id); + result.firstLegacySaveMaterializesStyle = materialized.color === card._decorStyle.color + && materialized.opacity === card._decorStyle.opacity + && materialized.fill_color === card._decorStyle.color + && materialized.fill_opacity === 0; // Drag-to-draw (#676 AC1): a press-drag-release on the stage draws the // straight stair at the drawn size with the ascent along the drag; the @@ -302,13 +346,65 @@ const out = await page.evaluate(async () => { // sizes above one metre survive and no history entry is written. const untouched = JSON.stringify(stairs().find((stair) => stair.id === straight.id)); const historyBefore = card._geometryHistory.size; - let dialog = await openStairDialog(straight.id); + dialog = await openStairDialog(straight.id); const fieldsShown = [...dialog.querySelectorAll('input[type="number"]')].map((input) => input.value); await saveDialog(dialog); result.untouchedDialogSaveKeepsSizes = JSON.stringify(stairs().find((stair) => stair.id === straight.id)) === untouched && Number(fieldsShown[0]) > 100; result.untouchedDialogSaveWritesNoHistory = card._geometryHistory.size === historyBefore; + // Both standard colour controls are persisted together. Down flips only the + // trapezoid: the canonical arrow path does not move (#683 owner decision). + const arrowBeforeDirection = stairNode(straight.id)?.querySelector('.hp-stair-arrow')?.getAttribute('d'); + const trapezoidBeforeDirection = [...(stairNode(straight.id) + ?.querySelectorAll('.hp-stair-trapezoid') || [])].map((line) => line.outerHTML).join(''); + const beforeCancelledStyle = structuredClone(stairs().find((stair) => stair.id === straight.id)); + dialog = await openStairDialog(straight.id); + let colorPickers = dialog ? [...dialog.querySelectorAll('hp-color-opacity')] : []; + result.propertiesExposeBothColourControls = colorPickers.length === 2 + && colorPickers[0].label === 'Tread and arrow colour' + && colorPickers[1].label === 'Fill colour'; + colorPickers[0]?.dispatchEvent(new CustomEvent('hp-color-opacity-change', { + detail: { color: '#ff0000', opacity: 0.1 }, bubbles: true, composed: true, + })); + colorPickers[1]?.dispatchEvent(new CustomEvent('hp-color-opacity-change', { + detail: { color: '#00ff00', opacity: 0.9 }, bubbles: true, composed: true, + })); + await hp.close(dialog, { via: 'cancel' }); + result.cancelKeepsStairVisualStyle = JSON.stringify(stairStyle( + stairs().find((stair) => stair.id === straight.id), + )) === JSON.stringify(stairStyle(beforeCancelledStyle)); + + dialog = await openStairDialog(straight.id); + colorPickers = dialog ? [...dialog.querySelectorAll('hp-color-opacity')] : []; + result.reopenRestoresPersistedStyle = colorPickers[0]?.color === beforeCancelledStyle.color + && colorPickers[0]?.opacity === beforeCancelledStyle.opacity + && colorPickers[1]?.color === beforeCancelledStyle.fill_color + && colorPickers[1]?.opacity === beforeCancelledStyle.fill_opacity; + colorPickers[0]?.dispatchEvent(new CustomEvent('hp-color-opacity-change', { + detail: { color: '#123456', opacity: 0.7 }, bubbles: true, composed: true, + })); + colorPickers[1]?.dispatchEvent(new CustomEvent('hp-color-opacity-change', { + detail: { color: '#abcdef', opacity: 0.25 }, bubbles: true, composed: true, + })); + await settled(); + dialog = root().querySelector('[data-hp="dialog"][data-kind="stairs"]'); + const down = [...(dialog?.querySelectorAll('.segmented .btn') || [])] + .find((button) => button.textContent.trim() === 'Down'); + down?.click(); + await saveDialog(dialog); + const styled = stairs().find((stair) => stair.id === straight.id); + const styledNode = stairNode(straight.id); + result.coloursAndOpacityPersistAsOneEdit = styled?.color === '#123456' + && styled.opacity === 0.7 && styled.fill_color === '#abcdef' + && styled.fill_opacity === 0.25 + && styledNode?.style.getPropertyValue('--hp-stair-line') === '#123456' + && styledNode?.style.getPropertyValue('--hp-stair-fill') === '#abcdef'; + result.downFlipsOnlyTrapezoid = styled?.direction === 'backward' + && pathClose(styledNode?.querySelector('.hp-stair-arrow')?.getAttribute('d'), arrowBeforeDirection) + && [...(styledNode?.querySelectorAll('.hp-stair-trapezoid') || [])] + .map((line) => line.outerHTML).join('') !== trapezoidBeforeDirection; + // Properties switch kind without changing identity, then switch it back and link Garden. dialog = await openStairDialog(straight.id); const selects = dialog ? [...dialog.querySelectorAll('select')] : []; @@ -322,12 +418,14 @@ const out = await page.evaluate(async () => { await saveDialog(dialog); const linked = stairs().find((stair) => stair.id === straight.id); result.kindSwitchKeepsIdentityAndLink = converted?.kind === 'spiral' - && linked?.kind === 'straight' && linked.target_space_id === 'garden'; + && linked?.kind === 'straight' && linked.target_space_id === 'garden' + && linked.color === '#123456' && linked.fill_color === '#abcdef'; await hp.setMode('view'); let linkedNode = stairNode(straight.id); result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link' - && linkedNode?.getAttribute('data-target-state') === 'active'; + && linkedNode?.getAttribute('data-target-state') === 'active' + && getComputedStyle(linkedNode).cursor === 'pointer'; // Hover on a link announces the target floor; every other target state // stays silent (#676 AC8). The mouse pointer type enables hover. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5a7821bf..8f31bcad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -311,7 +311,14 @@ ordinary saves. Saves strip the legacy root `space.segments`. Separate Plan entity, not decor (`STAIRS.md`). Eager `StairViewRuntime`: symbols, tooltip, guarded navigation; lazy `StairEditorRuntime`: drawing, transforms, magnets, properties (a plan never loads the editor graph). The root -card keeps lifecycle, shared history/persistence and stage pointer terminals. +card keeps lifecycle, shared history/persistence and stage pointer terminals; +pure model, tread/trapezoid geometry and style resolution stay in `stairs.ts`. + +The optional `color`/`opacity` and `fill_color`/`fill_opacity` fields are a +snapshot owned by the stair. Missing legacy fields resolve at render/dialog +time from the current decor default but are not written until the user saves +that stair. Screen renderers consume the colour fields; PDF deliberately uses +the same geometry with its existing monochrome ink palette. ## Editor chrome and contextual controls diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5537c6b4..31b5a928 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Stairs now have two independent line and fill colours with opacity controls. A + new stair snapshots the current main decor colour and starts with a fully + transparent fill. Straight flights show an internal 100%/80% trapezoid: + **Up/Down** flips the taper while the ascent arrow keeps its canonical + direction, so rotating the whole stair is the way to point the arrow. Both + straight and spiral stairs now divide their full run into equal intervals + whose physical size is as close as possible to 30 cm, without a short last + tread or sector. Valid floor links also consistently use the hand cursor + ([#683](https://github.com/Matysh/houseplan-card/issues/683)). + ## v1.78.0-beta.6 — 2026-09-27 - Manual virtual-light state and the latest vacuum-trail points are now flushed diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 26d0eb01..00a04aa1 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,16 @@ ## Не выпущено +- У лестниц появились два собственных цвета: для линий и для заливки, а также + отдельная прозрачность каждого. Новая лестница копирует текущий основной цвет декора и + создаётся с полностью прозрачной заливкой. В прямом марше рисуется внутренняя + трапеция шириной 100%/80%: варианты **«Вверх/Вниз»** разворачивают только её, + а стрелка сохраняет каноническое направление — чтобы направить стрелку, + нужно повернуть всю лестницу. Прямые и винтовые лестницы теперь делятся на + равные интервалы, максимально близкие к 30 см, без короткой последней ступени + или сектора. Корректная ссылка на этаж также всегда показывает курсор-руку + ([#683](https://github.com/Matysh/houseplan-card/issues/683)). + ## v1.78.0-beta.6 — 2026-09-27 - Состояние виртуальных источников света и последние точки маршрута пылесоса diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index 2e1422a2..f2aae7c7 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -141,7 +141,12 @@ discriminated collection. `kind: "straight"` stores positive `length` and `width` plus `direction: "forward" | "backward"`; `kind: "spiral"` stores a positive `radius` plus `direction: "clockwise" | "counterclockwise"`. Both variants carry stable `id`, continuous normalized centre `x/y`, scalar -`angle`, and an optional nullable `target_space_id`. +`angle`, and an optional nullable `target_space_id`. Optional `color`, +`opacity`, `fill_color` and `fill_opacity` store the stair-owned screen style. +Their absence is the legacy form: renderers/dialogs resolve the current decor +default without rewriting the config, and the first successful Properties save +materialises all four fields. Changing the global decor default later does not +recolour a stair that already owns the fields. Stair transforms follow furniture's continuous contract. Config writes apply only the nine-decimal scalar cleanup to position, size and angle; they do not diff --git a/docs/STAIRS.md b/docs/STAIRS.md index 109a2e40..4215358c 100644 --- a/docs/STAIRS.md +++ b/docs/STAIRS.md @@ -39,7 +39,14 @@ Double click opens properties. A straight stair stores positive length and width; a spiral stair stores a positive radius. The fields accept 30–10000 cm (inches when Home Assistant is imperial); a field left untouched keeps the stored number bit for bit, and saving without changes writes nothing. The -dialog also selects the rise direction, rotation and an optional target space. +dialog also selects the rise direction, rotation, two colour/opacity pairs +(all linework; full-footprint fill) and an optional target space. A new stair +snapshots the current main decor colour for its outline, treads, trapezoid and +arrow; its full rectangle/circle fill starts completely transparent. The +colours then belong to that stair and do not follow later default-decor changes. +For a straight stair the choices are **Up** and **Down**. They flip only the +100%/80% trapezoid; the arrow always points along the canonical local axis, so +turn the whole stair to point the arrow elsewhere. A target cannot be the current space. A missing, self or deleted target leaves the stair visible and editable but shows a repair warning and makes View activation a no-op. @@ -70,10 +77,14 @@ straddling a thin wall never snaps to the face hidden inside the masonry. Any straight/circular pair of stairs can snap footprint-to-footprint. The other stair is never modified or linked by the magnet. -Straight tread lines are perpendicular to the rise axis and start at the lower -edge every 30 cm. A remainder shorter than 30 cm stays at the upper edge. -Spiral stairs make one full turn; radial treads use the same 30 cm physical step -measured on the travel line at two thirds of the radius. Their direction is +The straight symbol contains a centred full-length trapezoid. Its wide base is +100% of the stair width and its narrow base is 80%, with symmetric 10% side +insets. **Up** widens toward the arrow tip; **Down** narrows toward it. Treads +end on the trapezoid sides and never enter the side strips. The full physical +length is divided into an integer number of equal intervals whose size is +closest to 30 cm (a tie chooses the larger count), so there is no remainder. +Spiral stairs similarly divide the complete travel-line circumference at two +thirds of the radius into equal sectors closest to 30 cm. Their direction is clockwise or counter-clockwise when viewed from above. The arrow always means physical ascent, not the direction of navigation between named tabs. @@ -97,17 +108,25 @@ Each space may contain `stairs: []`, bounded to 250 valid records: "length": 0.24, "width": 0.10, "direction": "forward", - "target_space_id": "floor-2" + "target_space_id": "floor-2", + "color": "#607d8b", + "opacity": 1, + "fill_color": "#607d8b", + "fill_opacity": 0 } ``` `kind:"spiral"` replaces `length`/`width` with `radius` and uses direction `clockwise` or `counterclockwise`. Coordinates and sizes use the normalized plan coordinate system; physical labels are derived through `cell_cm`. -`target_space_id` is nullable. Unknown sibling fields survive validation and -round trips for forward compatibility. Full backup/import/export, one-space -transfer, plan-only transfer, diagnostics and support packages preserve the -same records. When a complete backup removes a target space its incoming stair +`target_space_id` is nullable. The four visual fields are optional for backward +compatibility. A legacy record resolves missing fields from the current main +decor colour without being rewritten; its first successful Properties save +materialises the complete visual quartet. Unknown sibling fields survive +validation and round trips for forward compatibility. Full backup/import/export, +one-space transfer, plan-only transfer, diagnostics and support packages +preserve the same records. When a complete backup removes a target space its +incoming stair links are cleared; a one-space transfer cannot invent an external target. ## Implementation boundary diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 82bfdf9f..dd0e2bd3 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -717,7 +717,11 @@ the stair and snaps the dragged side flush to a parallel wall; dragging the body snaps the nearest side to a wall (turning the stair by at most 5°) or flush to another stair; `Shift` snaps rotation to 45°. **Optimize plans** preserves the exact continuous transform. Double click opens size (30 cm to -100 m, saved exactly as shown), direction, angle and target-floor properties. +100 m, saved exactly as shown), direction, angle, line/fill colour and opacity, +and target-floor properties. A new stair copies the main decor colour and has +no visible fill. Straight **Up/Down** changes the 100%/80% taper but not the +arrow direction; rotate the whole stair to turn the arrow. Treads and spiral +sectors divide the full run evenly at the closest possible step to 30 cm. In View, hovering a stair with a valid target shows "Go to floor …", and a clean activation switches to that target and restores its saved view; gesture tails, broken links and fixed-floor cards do nothing. The target floor is diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 8a0ffd22..f5c7d5e9 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -803,7 +803,12 @@ Undo оптимизации. больше чем на 5°) или вплотную к другой лестнице; `Shift` привязывает поворот к 45°. **Оптимизировать планы** сохраняет этот continuous-transform без привязки к сетке. Двойной клик открывает размеры (от 30 см до 100 м, хранятся -ровно такими, как показаны), направление подъёма, угол и целевой этаж. В +ровно такими, как показаны), направление подъёма, угол, цвет/прозрачность линий +и заливки и целевой этаж. Новая лестница копирует основной цвет декора и не +имеет видимой заливки. У прямого марша «Вверх/Вниз» разворачивает трапецию +100%/80%, но не стрелку — стрелка поворачивается вместе со всей лестницей. +Ступени и сектора винтовой лестницы равномерно делят весь путь с шагом, +максимально близким к 30 см. В Просмотре наведение на лестницу с корректной целью показывает подсказку «Переход на этаж …», а чистый клик переходит на эту цель и восстанавливает сохранённый вид этажа; хвост жеста, битая ссылка и карточка с закреплённым diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 3413f6cf..6a9ba041 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -367,8 +367,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000; * чистой площади нужны непосредственно в View. Placement, transform и * properties вынесены в lazy editor; общий бюджет 301 066 Б не меняется. * Центр оставляет 978 Б сверху и 1 022 Б до нижней границы полосы. + * + * 2026-09-27, #683: 299 700 → 300 200 (замер 299 638). Разрешение сохранённых + * цветов лестницы, CSS-переменные её символа и геометрия трапеции/равных + * ступеней нужны обоим View-renderer'ам до загрузки редактора; сами два + * color-picker и запись свойств остаются в lazy editor. Новый центр оставляет + * 562 Б сверху и 1 438 Б до нижней границы полосы. Общий бюджет 301 066 Б не + * меняется; малый остаток продолжает печататься отдельным предупреждением. */ -export const INITIAL_VIEW_GZIP_CEILING = 299_700; +export const INITIAL_VIEW_GZIP_CEILING = 300_200; export const INITIAL_VIEW_CEILING_BAND = 2_000; /** diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index 8aaa995e..5176e8a8 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 4880, "portPrivates": 94, "harnessPrivates": 100, - "bundleBytes": 2577881 + "bundleBytes": 2581147 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 542c23cd..db972382 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -228,12 +228,36 @@ const MUTANT_DEFINITIONS = [ id: 'stairs-tread-count-depends-on-render-scale', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs.test.mjs', - because: '#663 AC3: 30 cm is a physical interval; viewport scale may change pixels but ' - + 'must not change the number or placement of treads.', + because: '#683 AC6: the equal interval nearest 30 cm is physical; viewport scale may ' + + 'change pixels but must not change the number or placement of treads.', patches: [{ file: 'src/stairs.ts', - find: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);\n', - replace: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm) * NORM_W / scale; // mutant\n', + find: ' const count = stairIntervalCount(stair.length * cellCm * GRID_N);\n', + replace: ' const count = stairIntervalCount(stair.length * cellCm * GRID_N * NORM_W / scale); // mutant\n', + }], + }, + { + id: 'stairs-legacy-save-keeps-implicit-style', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern=legacy test/stairs.test.mjs', + because: '#683 AC8: reading a legacy stair stays non-mutating, but its first explicit Save ' + + 'must materialize the complete visual quartet so the result no longer follows global defaults.', + patches: [{ + file: 'src/stairs.ts', + find: ' fill_opacity: resolved.fillOpacity,\n', + replace: ' fill_opacity: 0, // mutant: one explicit field is not materialized\n', + }], + }, + { + id: 'stairs-active-link-loses-pointer-cursor', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern=cursor test/stairs.test.mjs', + because: '#683 AC12: only a stair with an active floor target promises navigation with the ' + + 'standard pointer cursor; removing it makes the clickable symbol look inert.', + patches: [{ + file: 'src/styles/plan.styles.ts', + find: ' .hp-stair.navigable { cursor: pointer; }\n', + replace: ' .hp-stair.navigable { cursor: default; } /* mutant */\n', }], }, { diff --git a/src/i18n/de.json b/src/i18n/de.json index 7239657a..2ace4bad 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -40,8 +40,10 @@ "stairs.radius": "Radius", "stairs.rotation": "Drehung", "stairs.direction": "Aufstiegsrichtung", - "stairs.forward": "Vorwärts", - "stairs.backward": "Rückwärts", + "stairs.up": "Aufwärts", + "stairs.down": "Abwärts", + "stairs.line_color": "Farbe von Stufen und Pfeil", + "stairs.fill_color": "Füllfarbe", "stairs.clockwise": "Im Uhrzeigersinn", "stairs.counterclockwise": "Gegen den Uhrzeigersinn", "stairs.target": "Führt zu Etage", diff --git a/src/i18n/en.json b/src/i18n/en.json index b79e3dfe..da7ae9f8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -40,8 +40,10 @@ "stairs.radius": "Radius", "stairs.rotation": "Rotation", "stairs.direction": "Ascent direction", - "stairs.forward": "Forward", - "stairs.backward": "Backward", + "stairs.up": "Up", + "stairs.down": "Down", + "stairs.line_color": "Tread and arrow colour", + "stairs.fill_color": "Fill colour", "stairs.clockwise": "Clockwise", "stairs.counterclockwise": "Counterclockwise", "stairs.target": "Leads to floor", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index de93cbe5..e7f6a18f 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -40,8 +40,10 @@ "stairs.radius": "Rayon", "stairs.rotation": "Rotation", "stairs.direction": "Sens de montée", - "stairs.forward": "Avant", - "stairs.backward": "Arrière", + "stairs.up": "Monter", + "stairs.down": "Descendre", + "stairs.line_color": "Couleur des marches et de la flèche", + "stairs.fill_color": "Couleur de remplissage", "stairs.clockwise": "Sens horaire", "stairs.counterclockwise": "Sens antihoraire", "stairs.target": "Mène à l’étage", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 331af883..1ecc9588 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -40,8 +40,10 @@ "stairs.radius": "Радиус", "stairs.rotation": "Поворот", "stairs.direction": "Направление подъёма", - "stairs.forward": "Вперёд", - "stairs.backward": "Назад", + "stairs.up": "Вверх", + "stairs.down": "Вниз", + "stairs.line_color": "Цвет ступеней и стрелки", + "stairs.fill_color": "Цвет заливки", "stairs.clockwise": "По часовой стрелке", "stairs.counterclockwise": "Против часовой стрелки", "stairs.target": "Ведёт на этаж", diff --git a/src/pdf/pdf-scene.ts b/src/pdf/pdf-scene.ts index aaae6eb4..2160f2cc 100644 --- a/src/pdf/pdf-scene.ts +++ b/src/pdf/pdf-scene.ts @@ -474,8 +474,11 @@ function buildPdfCandidate( commands.push({ kind: 'path', rings: [geometry.outline.map(pt)], stroke: INK, width: 0.25 * MM, }); + for (const edge of geometry.trapezoid) commands.push({ + kind: 'line', points: [pt(edge.a), pt(edge.b)], stroke: INK, width: 0.25 * MM, + }); for (const tread of geometry.treads) commands.push({ - kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.2 * MM, + kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.25 * MM, }); commands.push({ kind: 'vector', ops: transformSvgPath(geometry.arrowPath, stairMatrix), diff --git a/src/space-render.ts b/src/space-render.ts index efb6180f..02ef6545 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -60,7 +60,7 @@ import { geometryOpenings } from './plan-geometry-preflight'; import { resolveDeviceAreaRelocations } from './device-area-relocation'; import { projectDecorImage } from './decor-assets'; import type { DecorShape } from './editors/decor/types'; -import { cachedStairRenderGeometry, stairOutline } from './stairs'; +import { cachedStairRenderGeometry, stairOutline, stairStyleVars } from './stairs'; import { buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools, @@ -279,6 +279,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { if (!space) return null; const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId)); const colors = fillColorsOf(o.cfg.settings); + const storedDecor = (o.cfg.settings as { decor_default_style?: Record }) + ?.decor_default_style; + const stairFallback = { + color: typeof storedDecor?.color === 'string' ? storedDecor.color : '#607d8b', + opacity: Number.isFinite(Number(storedDecor?.opacity)) ? Number(storedDecor?.opacity) : 1, + }; const cfgSize = o.iconSize ?? 2.5; const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const deviceBasePct = effectiveDeviceBaseSize(iconPct); @@ -906,8 +912,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { const geometry = cachedStairRenderGeometry(stair, cellCm); return svg`