diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 0032c589..cee46510 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -1,7 +1,7 @@ import { fixtureWallKey } from '../fixtures/visual-matrix.mjs'; /** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */ -export const GOLDEN_MATRIX_VERSION = 18; +export const GOLDEN_MATRIX_VERSION = 19; const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } }; const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } }; @@ -15,6 +15,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([ { id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split', cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage }, { id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view', + // Stage 2 material/floor-edge plus door, window, gate and nested-room coverage. labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage }, { id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view', labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage }, diff --git a/demo/smoke_isometric_contract.mjs b/demo/smoke_isometric_contract.mjs index e3fc632c..bcdcbe02 100644 --- a/demo/smoke_isometric_contract.mjs +++ b/demo/smoke_isometric_contract.mjs @@ -1,4 +1,4 @@ -// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary. +// #122 Stage 2: Labs lifecycle, structural composition and flat editor boundary. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 1000, height: 850 }); @@ -44,7 +44,12 @@ const out = await page.evaluate(async () => { const device = root().querySelector('[data-hp="device"]'); const roomLabel = root().querySelector('[data-hp="room-label"]'); result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true' + && !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side') && !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top'); + result.sharedProjectionSnapshot = ['.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg'] + .map((selector) => root().querySelector(selector)?.getAttribute('viewBox')) + .every((value, _index, values) => !!value && value === values[0]); + result.stage2DefinitionsBounded = root().querySelectorAll('[id^="hp-iso-"]').length <= 5; result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso'; result.anchorsFinite = [device, roomLabel].every((node) => node && center(node).every((value) => Number.isFinite(value))); @@ -79,7 +84,7 @@ const out = await page.evaluate(async () => { build: () => { attempts++; if (shouldFail) throw new Error('injected isometric failure'); - return []; + return { walls: [], floor: [], openings: [] }; }, }); card.requestUpdate(); @@ -108,7 +113,8 @@ const out = await page.evaluate(async () => { await frame(); result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]' && !root().querySelector('[data-hp="projection-toggle"]') - && !root().querySelector('[data-hp="iso-walls"]'); + && !root().querySelector('[data-hp="iso-walls"]') + && !root().querySelector('[id^="hp-iso-"]'); return result; }); diff --git a/demo/smoke_isometric_live_touch.mjs b/demo/smoke_isometric_live_touch.mjs index f468c19c..5435249a 100644 --- a/demo/smoke_isometric_live_touch.mjs +++ b/demo/smoke_isometric_live_touch.mjs @@ -1,4 +1,4 @@ -// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths. +// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch( @@ -26,6 +26,8 @@ const out = await page.evaluate(async () => { contact: 'binary_sensor.window' }, { id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12, contact: 'binary_sensor.window', lock: 'lock.front_door' }, + { id: 'iso-gate', type: 'gate', x: 0.72, y: 0.14, angle: 0, length: 0.16, + contact: 'binary_sensor.gate', lock: 'lock.front_door' }, ]; configSpace.partitions = [{ id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15, @@ -86,6 +88,7 @@ const out = await page.evaluate(async () => { glowSources: count('.glowlayer [data-glow-source]'), sun: count('.sunlayer'), openings: count('[data-hp="opening"]'), + verticalOpenings: count('[data-hp="iso-openings"] .iso-opening-panel'), devices: count('[data-hp="device"]'), hover: count('.room-hover-fill'), vacuumPucks: count('.vacpuck'), @@ -104,6 +107,8 @@ const out = await page.evaluate(async () => { const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint; const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry; const light = original.hass.states['light.ceiling']; + const openingPanelBefore = root(original) + .querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d'); original.hass = { ...original.hass, states: { @@ -111,6 +116,9 @@ const out = await page.evaluate(async () => { 'light.ceiling': { ...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] }, }, + 'binary_sensor.window': { + ...original.hass.states['binary_sensor.window'], state: 'off', + }, }, }; await original.updateComplete; @@ -118,22 +126,26 @@ const out = await page.evaluate(async () => { const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')] .map((node) => node.getAttribute('data-lit-parts')); const ordered = [ + root(original).querySelector('.iso-underlay-svg'), root(original).querySelector('.hp-backdrop'), root(original).querySelector('.decorlayer'), root(original).querySelector('[data-hp="room"]'), root(original).querySelector('.glow-base-layer'), root(original).querySelector('.glowlayer'), root(original).querySelector('.sunlayer'), + root(original).querySelector('.iso-shadows-svg'), root(original).querySelector('.iso-walls-svg'), root(original).querySelector('.vacpuck'), ]; const result = { isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'), - flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before) + flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings }) + === JSON.stringify(before) && JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts), liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2 && before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0 - && before.glowSources >= 2 && before.sun > 0 && before.openings >= 2 + && before.glowSources >= 2 && before.sun > 0 && before.openings === 0 + && before.verticalOpenings === 5 && before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0 && before.vacuumTrails >= 2, liveLayersStable: JSON.stringify(after) === JSON.stringify(before), @@ -147,9 +159,50 @@ const out = await page.evaluate(async () => { .some((stop) => stop.getAttribute('stop-color') === '#ff4020'), sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint, haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry, - noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'), + contactUpdateMovesOnlyLivePanel: !!openingPanelBefore + && root(original).querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d') + !== openingPanelBefore + && original._isoGeometryCache.size === 1, + oneLightModel: !root(original).querySelector('.window-light,.iso-window-light,.iso-glow,.iso-sun'), + oldFloorSymbolsNotDuplicated: before.openings === 0, }; + configSpace.settings.hide_openings = true; + original._cfgEpoch++; + original.requestUpdate(); + await original.updateComplete; + result.hideOpeningsKeepsStructure = !!root(original).querySelector('[data-hp="iso-walls"]') + && !root(original).querySelector('[data-hp="iso-openings"]') + && !root(original).querySelector('[data-hp="opening"]') + && root(original).querySelectorAll('.glowlayer [data-glow-source]').length >= 2; + + configSpace.settings.hide_openings = false; + configSpace.settings.show_borders = false; + original._cfgEpoch++; + original.requestUpdate(); + await original.updateComplete; + result.noBordersUsesFloorSymbols = !root(original).querySelector('[data-hp="iso-walls"]') + && !root(original).querySelector('[data-hp="iso-underlay"]') + && root(original).querySelectorAll('[data-hp="opening"]').length === 3; + + configSpace.settings.show_borders = true; + original._cfgEpoch++; + original.requestUpdate(); + await original.updateComplete; + result.visibleBordersRestoreStage2 = !!root(original).querySelector('[data-hp="iso-openings"]') + && original._isoGeometryCache.size === 1; + + const supports = CSS.supports; + CSS.supports = () => false; + original.requestUpdate(); + await original.updateComplete; + result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]') + && !!root(original).querySelector('[data-hp="iso-openings"]') + && !root(original).querySelector('[data-hp="iso-shadows"]'); + CSS.supports = supports; + original.requestUpdate(); + await original.updateComplete; + const calls = []; original.hass = { ...original.hass, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0d180e0..09267963 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -340,6 +340,31 @@ cache guarded by a structural geometry fingerprint. This is computed render state only: it never rewrites rooms or wall entries, and an HA state tick does not rebuild topology. +### Hidden Isometric Stage 2 composition (#122) + +The hidden `iso` View reuses that masonry but has one bounded structural scene, +not a second house model. `_isoGeometryCache` remains an eight-entry LRU keyed +by room/wall/opening geometry (including opening flips), scale/camera, fixed +wall/floor-edge heights and an algorithm revision. Each value holds wall faces, +the room/exterior slab edge, immutable opening jamb bases and the projected +frame. HA state, theme, hover and filter support are presentation inputs and +never enter this key. + +`floorFootprintGeometry()` derives only the union of room floors and exterior +masonry; unlike wall volume, it has no independent partition/column input. +`buildIsoFloorGeometry()` emits visible low faces for outer component rings, +not internal edges or holes. `src/iso-openings.ts` stores jamb/axis topology and +applies `openingAmount()` only during live projection, keeping contact updates +out of the boolean geometry path. + +Composition is shared-viewBox SVG: ambient shadow/floor edge → the existing +affine-projected floor/live scene → contact/leaf shadows → wall material and +vertical panels → existing screen-facing HTML overlays. A constant set of +gradients/filters serves every face. Unsupported decoration or forced colours +remove nuance/shadows without changing projection; only structural failure +uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in +`docs/adr/122-isometric-stage2-composition.md`. + ## Markup editor (v1.4.0+) State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/ diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index 72fc158b..d3921ab4 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -1,4 +1,4 @@ -# Isometric Stage 1 internals +# Hidden Isometric View internals Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a hidden, presentation-only volumetric View experiment. The normative contract is @@ -90,3 +90,78 @@ the rollback path and does not depend on the iso cache. Golden references are accepted only from the complete reviewed Linux artifact. The full `large-house-isometric-v1` performance comparison is also canonical on the exact Linux CI SHA. + +## Stage 2 composition (#122) + +Stage 2 evolves the same hidden `iso` experiment; it does not add a flag, +setting or public activation path. The accepted implementation contract is +`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in +`docs/adr/122-isometric-stage2-composition.md`. The Labs `since: 1.62.0` and +exclusive `expires: 1.65.0` boundary are unchanged. + +### One structural scene, live opening leaves + +The per-card LRU remains capped at eight entries. Its Stage 2 value contains: + +- canonical wall top/sides and the physical-wall contact path; +- a room/exterior floor footprint and its low visible outer faces; +- immutable opening jamb/axis bases, including type, flips and selected wall + face; +- the shared projected frame, including wall/opening tops and the low floor + edge. + +The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and +edge heights and algorithm revision. It excludes HA state, theme, hover, +day/night and filter capability. `openingAmount()` is applied only after an LRU +hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without +repeating a wall or floor boolean operation. + +`floorFootprintGeometry()` deliberately accepts no independent physical-body +input. The slab is the union of room floors and derived exterior masonry: +internal room boundaries and nested holes make no decorative step, detached +room components keep separate outside edges, while partitions and columns do +not enlarge it. + +### Layer order and materials + +All geometry roots use one scene `viewBox`. The existing floor/live nodes are +grouped under the Stage 1 affine matrix; HTML anchors still use +`projectPlanPoint()`. + +```text +stage background +→ shared ambient shadow + low exterior floor edge +→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun) +→ shared contact and live leaf shadows +→ canonical wall sides/top + inert vertical opening panels +→ existing HTML devices, labels/cards, locks and vacuum overlays +``` + +Wall top and side use two shared matte gradients. Ambient, contact and leaf +shadows use three shared filters; definition count is constant per card, never +per face or opening. Forced colours use solid `Canvas`/`CanvasText` faces and +omit decoration. A runtime without the required filter paint keeps solid +structure, floor edge and vertical panels but emits no Stage 2 shadows; this +does not enter the structural fallback latch. + +### Vertical openings and display settings + +`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra: +door has one jamb-hinged leaf, gate has two leaves with the established +0–10° exterior-face turn, and window has two light neutral casements. Heights +are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field. +Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA +actions remain the only interactive opening surface. + +- borders visible: vertical panels replace the floor-plane symbols; +- `hide_openings: true`: panels and leaf shadows disappear, while masonry + cuts, Glow/sun and contact/lock meaning remain; +- `show_borders: false`: Stage 2 roots are absent and the established floor + symbols return (subject to `hide_openings`), avoiding floating panels; +- Flat, editors and `houseplan-space-card` retain their old symbols and DOM. + +Stage 2 adds no window beam, Glow source, sun renderer, material config, +network request or HA service path. Structural topology/projection exceptions +still use the Stage 1 latched Flat fallback. The known independent exact-SHA +view-toggle performance debt remains tracked in #124; #122 neither weakens its +budget nor treats fallback as benchmark success. diff --git a/docs/STATUS.md b/docs/STATUS.md index f9e6b9bc..86da9689 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -17,13 +17,13 @@ change must pass through a published beta/RC before stable. Stable release commits are promotion-only (versions, generated bundles and release/changelog metadata). Only an explicit owner-approved emergency hotfix may skip this gate. -## Snapshot (2026-08-13) +## Snapshot (2026-08-14) | Item | State | |---|---| | Version | **v1.63.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion candidate after published v1.63.0-beta.2 | -| Current local cycle | v1.63.0 promotes the published beta line without product-code changes. v1.63.0-beta.2 preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light; v1.63.0-beta.1 contains the empty-plan and opening-reference fixes (#111, #104), plus reviewed process automation #105 and #118–#121. | -| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. | +| Current local cycle | v1.63.0 is the stable base. #122 Stage 2 is being implemented on `issue/122-isometric-stage2` under an explicit owner arbitration to start while the independent #124 exact-SHA view-toggle performance debt remains open; no Stage 2 release has been published. | +| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation is explicitly a separate task. | | Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand | | GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) | | CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. | diff --git a/docs/adr/122-isometric-stage2-composition.md b/docs/adr/122-isometric-stage2-composition.md new file mode 100644 index 00000000..6c19898b --- /dev/null +++ b/docs/adr/122-isometric-stage2-composition.md @@ -0,0 +1,89 @@ +# ADR #122 — Stage 2 isometric composition + +- Issue: https://github.com/Matysh/houseplan-card/issues/122 +- Status: accepted for implementation +- Date: 2026-08-14 +- Normative spec: `docs/specs/122-isometric-stage2.md`, revision 1 +- Predecessor: `docs/adr/089-isometric-stage1-renderer.md` + +## Context + +Stage 1 established one fixed affine floor projection, canonical wall volume, +screen-facing HTML anchors and a bounded structural LRU. Stage 2 must add matte +depth, an exterior floor edge, grounding shadows and vertical openings without +creating another plan/light model or making HA state rebuild topology. + +The owner explicitly allowed implementation to begin with #124's known +view-toggle performance debt still open. That arbitration removes the DoR +blocker only: the unchanged exact-SHA performance budget remains a gate and +fallback may not be forced to make it green. + +## Decision + +### Structural/live boundary + +The existing eight-entry Iso LRU becomes a structural scene cache. Each entry +contains wall faces, floor footprint/edge, immutable opening bases and one +projected frame. The fingerprint includes opening geometry and flips plus the +fixed wall/edge heights and algorithm revision. It excludes HA state and every +decorative capability. + +An opening basis stores its resolved wall face, jamb hinge, closed vector, +quarter-turn vector and fixed height bounds. Rendering applies the existing +`openingAmount()` after the cache lookup. Door/window/gate live changes are +therefore O(O) vector projection and cannot invoke polygon boolean operations. + +### Floor footprint + +The slab source is the canonical union of rooms plus derived exterior masonry. +Independent partitions, drafts and columns remain canonical wall volume and +light occluders but are not accepted as floor-footprint input. Stage 2 projects +only outer rings down by ten plan units; holes and internal/shared boundaries +cannot become steps. Disconnected outer polygons remain disconnected. + +### Composition + +Four absolute SVG roots share the effective scene `viewBox`: underlay, the +existing floor scene, shadows and wall/opening volume. The old floor nodes are +not copied; they live under the same affine matrix Stage 1 used. HTML overlays +stay above every SVG root. + +The stable order is ambient shadow, floor edge, current floor/live layers, +contact/leaf shadows, wall sides/top, vertical panels, then HTML overlays. +All new geometry is pointer/focus/ARIA inert. + +### Material and degradation + +Two shared SVG gradients distinguish matte wall top and side. Three shared +filters provide ambient/contact/leaf softness. Definitions are O(1) per card; +there are no per-face filters, raster textures, data URLs or runtime +dependencies. + +Filter-paint failure removes shadows only. Forced colours also remove gradient +nuance and use solid system colours. Structural wall/opening/edge geometry and +all existing live layers remain Iso. Only a structural topology/projection +exception enters the established `space|fingerprint` Flat fallback latch. + +### Opening presentation and settings + +- door: one full jamb-hinged leaf, 92% of wall height; +- gate: two half leaves, 88%, preserving the existing 0–10° face-aware turn; +- window: two light inserts between 27% and 78% of wall height. + +These are presentation constants, not persisted fields. With visible borders, +vertical panels replace the old floor symbols. `hide_openings` hides panels and +leaf shadows. With borders disabled, every Stage 2 root is absent and the +existing floor symbols return, so there are no floating vertical leaves. + +## Consequences + +- Flat, editors, static card, schema, storage keys, i18n, backend and HA actions + are unchanged. +- Glow/spill and sunlight remain the only room/window light models. +- Theme, hover, filter fallback and HA-only updates do not grow the structural + LRU. +- Fit includes structural opening/wall tops and the low floor edge, never blur. +- Golden and browser evidence must review new Iso pixels; Flat and the existing + no-borders baseline remain unchanged. +- Stage 2 stays hidden and expires with the existing `iso` Labs entry unless a + separate owner-reviewed rollout/graduation issue changes that contract. diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 01061e27..6e612e94 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -56,6 +56,7 @@ import { degradeWalls, rekeyWallsAfterMove, setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits, wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, + floorFootprintGeometry, innerContourForRoom, roomWallProfile, outsetContour, openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex, openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom, @@ -176,12 +177,18 @@ import { currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot, } from './labs'; import { - ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, projectedFrame, + ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss, + projectPlanPoint, projectedFrame, unprojectFloorPoint, type ScenePoint, type ViewRect, } from './iso-projection'; import { - buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, type IsoWallGeometry, + buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, + type IsoFloorGeometry, type IsoWallGeometry, } from './iso-walls'; +import { + buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration, + type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel, +} from './iso-openings'; import { acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses, fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot, @@ -1152,7 +1159,12 @@ class HouseplanCard extends LitElement { key: string; value: ReturnType; } | null = null; - private _isoGeometryCache = new Map(); + private _isoGeometryCache = new Map(); private _isoFallback = new Set(); private _openingTunnelCache: { key: string; @@ -4418,21 +4430,31 @@ class HouseplanCard extends LitElement { } /** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */ - private _isoSource(): { key: string; build: () => any } { + private _isoSource(): { key: string; build: () => { + walls: any; + floor: any; + openings: readonly IsoOpeningBasis[]; + } } { const space = this._spaceModel(); const walls = this._spaceWalls; const openCuts = this._openPairs().flatMap((pair) => pair.segs); - const openings = (this._curSpaceCfg?.openings || []).map((opening: any) => ({ - x: Number(opening.x) * NORM_W, - y: Number(opening.y) * NORM_W, + const openings = this._openingsR.map((opening, sourceIndex) => ({ + id: String(opening.id || sourceIndex), + sourceIndex, + type: opening.type, + x: opening.rx, + y: opening.ry, angle: Number(opening.angle) || 0, - length: (Number(opening.length) > 0 ? Number(opening.length) : 0.9) * NORM_W, + length: opening.rlen > 0 ? opening.rlen : 0.9 * NORM_W, + flipH: !!opening.flip_h, + flipV: !!opening.flip_v, })); const key = `${space.id}|${isoGeometryFingerprint({ rooms: space.rooms, walls, openCuts, openings, partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns, cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch, - camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2, + camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, + floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3, })}`; return { key, @@ -4443,9 +4465,32 @@ class HouseplanCard extends LitElement { space.rooms, walls, openCuts, openings, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras, ) - : { geom: [] }; - if (!united) throw new Error('wall boolean geometry failed'); - return united.geom; + : null; + if ((walls.length || extras.length) && !united) + throw new Error('wall boolean geometry failed'); + const floor = united?.paperGeom ?? floorFootprintGeometry( + space.rooms, walls, openCuts, + this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, + ); + if (!floor) throw new Error('floor boolean geometry failed'); + const openingIndex = united?.openingIndex || buildOpeningWallIndex( + space.rooms, walls, openCuts, + this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, + ); + const openingBases = openings.map((opening) => { + const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV; + const face = walls.length || opening.type === 'gate' + ? openingInnerFaceOffsetFromIndex(openingIndex, { + x: opening.x, + y: opening.y, + angle: opening.angle, + length: opening.length, + flip_v: faceFlipV, + }) + : { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 }; + return buildIsoOpeningBasis({ ...opening, face }); + }); + return { walls: united?.geom || [], floor, openings: Object.freeze(openingBases) }; }, }; } @@ -4455,14 +4500,29 @@ class HouseplanCard extends LitElement { try { return this._isoSource().key; } catch { return `${this._space}|invalid`; } } - private _isoScene(): { key: string; geometry: IsoWallGeometry; frame: Rect } { + private _isoScene(): { + key: string; + geometry: IsoWallGeometry; + floor: IsoFloorGeometry; + openings: readonly IsoOpeningBasis[]; + frame: Rect; + } { const source = this._isoSource(); const cached = this._isoGeometryCache.get(source.key); if (cached) return { key: source.key, ...cached }; const flat = this._frameOf().rect; - const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT }); - const geometry = buildIsoWallGeometry(source.build()); - const value = { geometry, frame }; + const structural = source.build(); + const openingFrame = isoOpeningBounds(structural.openings); + const structuralFrame = openingFrame ? unionRect(flat, openingFrame) : flat; + const frame = projectedFrame({ + rect: structuralFrame, + wallHeight: ISO_WALL_HEIGHT, + openingHeight: ISO_WALL_HEIGHT, + floorDepth: ISO_FLOOR_EDGE_HEIGHT, + }); + const geometry = buildIsoWallGeometry(structural.walls); + const floor = buildIsoFloorGeometry(structural.floor, ISO_FLOOR_EDGE_HEIGHT); + const value = { geometry, floor, openings: structural.openings, frame }; lruWrite(this._isoGeometryCache, source.key, value, 8); return { key: source.key, ...value }; } @@ -10057,15 +10117,102 @@ class HouseplanCard extends LitElement { ` as unknown as TemplateResult; } - private _renderIsoWalls(): TemplateResult { + private _isoDecorationLayers(disp: SpaceDisplay): IsoDecorationLayers { + const filtersSupported = typeof CSS === 'undefined' + || typeof CSS.supports !== 'function' + || CSS.supports('filter', 'blur(1px)'); + const forcedColors = typeof matchMedia === 'function' + && matchMedia('(forced-colors: active)').matches; + return resolveIsoDecoration({ + showBorders: disp.showBorders, + hideOpenings: disp.hideOpenings, + filtersSupported, + forcedColors, + }); + } + + private _isoOpeningPanels(layers: IsoDecorationLayers): IsoOpeningPanel[] { + if (!layers.panels) return []; + const scene = this._isoScene(); + const panels = scene.openings.flatMap((basis) => { + const opening = this._openingsR[basis.sourceIndex]; + return opening ? projectIsoOpening(basis, this._openingAmt(opening)) : []; + }); + return panels.sort((a, b) => a.depth - b.depth + || a.sourceIndex - b.sourceIndex || a.leaf - b.leaf); + } + + private _renderIsoDefs( + layers: IsoDecorationLayers, + root: 'underlay' | 'shadows' | 'walls', + ): TemplateResult { + return svg` + ${root === 'walls' && layers.materialNuance ? svg` + + + + + + ` : nothing} + ${root === 'underlay' && layers.shadows ? svg` + + + ` : nothing} + ${root === 'shadows' && layers.shadows ? svg` + + + + + + ` : nothing} + ` as unknown as TemplateResult; + } + + private _renderIsoUnderlay(layers: IsoDecorationLayers): TemplateResult { + if (!layers.structural) return svg`` as unknown as TemplateResult; + const floor = this._isoScene().floor; + return svg`` as unknown as TemplateResult; + } + + private _renderIsoShadows( + layers: IsoDecorationLayers, + panels: readonly IsoOpeningPanel[], + ): TemplateResult { + if (!layers.shadows) return svg`` as unknown as TemplateResult; + const geometry = this._isoScene().geometry; + return svg`` as unknown as TemplateResult; + } + + private _renderIsoWalls( + layers: IsoDecorationLayers, + panels: readonly IsoOpeningPanel[], + ): TemplateResult { if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult; - const disp = this._spaceDisplayForRender(); - if (!disp.showBorders) return svg`` as unknown as TemplateResult; + if (!layers.structural) return svg`` as unknown as TemplateResult; const scene = this._isoScene(); return svg` + ${this._renderIsoDefs(layers, 'walls')} ${scene.geometry.sides.map((face) => svg``)} + ${layers.panels ? svg`` : nothing} ` as unknown as TemplateResult; } @@ -13858,6 +14005,10 @@ class HouseplanCard extends LitElement { const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts)); const deviceSnapshot = this._renderDeviceSnapshot; const disp = this._spaceDisplayForRender(); + // Stage 2 capability work is deliberately absent from Flat/Labs-off + // renders. Decorative capability never enters the structural cache key. + const isoLayers = iso ? this._isoDecorationLayers(disp) : null; + const isoPanels = isoLayers ? this._isoOpeningPanels(isoLayers) : []; const roomFills = this._resolvedRoomFills(space, disp); const glowBase = this._resolvedGlowBase(space, disp, roomFills); const showLqi = disp.showLqi ?? this._config.show_signal ?? true; @@ -14014,8 +14165,17 @@ class HouseplanCard extends LitElement {
- + ${iso && isoLayers?.structural ? svg`` : nothing} + + ${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner 2026-08-04). Opaque shapes stop the scene background — bg_color or the 'daynight' sky — from bleeding through the @@ -14207,7 +14367,11 @@ class HouseplanCard extends LitElement { preview deliberately paint AFTER real wall bodies. Their full centreline geometry remains visible for editing. */} ${this._editing ? this._renderOpenWalls(disp) : nothing} - ${disp.hideOpenings && !this._markup ? nothing : this._renderOpenings(disp)} + ${disp.hideOpenings && !this._markup + ? nothing + : isoLayers && !isoLayers.floorSymbols + ? nothing + : this._renderOpenings(disp)} ${this._renderWallThickUi()} ${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing} ${''/* editor chrome, not plan content: the backdrop frame sits on @@ -14216,10 +14380,16 @@ class HouseplanCard extends LitElement { backdrop editor, where rooms and devices are pointer-inert. */} ${this._renderBackdropFrame(view)} ${this._renderTextFrame(view)} + - ${iso ? svg` + ` : nothing} ${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and scales with it when you zoom — the behaviour the card always diff --git a/src/iso-openings.ts b/src/iso-openings.ts new file mode 100644 index 00000000..4d301c6c --- /dev/null +++ b/src/iso-openings.ts @@ -0,0 +1,214 @@ +import { + ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, + type IsoCamera, type PlanPoint, type ScenePoint, +} from './iso-projection'; + +export type IsoOpeningType = 'door' | 'window' | 'gate'; + +export interface IsoOpeningFace { + ox: number; + oy: number; + side: -1 | 1; +} + +export interface IsoOpeningInput { + id: string; + sourceIndex: number; + type: IsoOpeningType; + x: number; + y: number; + angle: number; + length: number; + flipH: boolean; + flipV: boolean; + face: IsoOpeningFace; +} + +export interface IsoOpeningLeafBasis { + leaf: number; + hinge: PlanPoint; + closedVector: PlanPoint; + quarterVector: PlanPoint; + turnDeg: number; + bottom: number; + top: number; +} + +/** Immutable jamb/axis topology stored in the structural Iso LRU. */ +export interface IsoOpeningBasis { + id: string; + sourceIndex: number; + type: IsoOpeningType; + leaves: readonly IsoOpeningLeafBasis[]; +} + +export interface IsoOpeningPanel { + id: string; + sourceIndex: number; + type: IsoOpeningType; + leaf: number; + d: string; + shadowD: string; + depth: number; +} + +export interface IsoOpeningBounds { x: number; y: number; w: number; h: number; } + +const finite = (values: readonly number[]): boolean => values.every(Number.isFinite); + +function add(a: PlanPoint, b: PlanPoint): PlanPoint { + return [a[0] + b[0], a[1] + b[1]]; +} + +function transformVector( + point: PlanPoint, angleDeg: number, sx: number, sy: number, +): PlanPoint { + const x = point[0] * sx, y = point[1] * sy; + const angle = angleDeg * Math.PI / 180; + return [x * Math.cos(angle) - y * Math.sin(angle), + x * Math.sin(angle) + y * Math.cos(angle)]; +} + +function leafBasis( + input: IsoOpeningInput, + leaf: number, + localHinge: PlanPoint, + localVector: PlanPoint, + turnDeg: number, + bottom: number, + top: number, +): IsoOpeningLeafBasis { + const sx = input.flipH ? -1 : 1; + const sy = input.flipV ? -1 : 1; + const origin: PlanPoint = [input.x + input.face.ox, input.y + input.face.oy]; + const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy)); + const closedVector = transformVector(localVector, input.angle, sx, sy); + const quarterVector = transformVector( + [-localVector[1], localVector[0]], input.angle, sx, sy, + ); + return { leaf, hinge, closedVector, quarterVector, turnDeg, bottom, top }; +} + +/** + * Build only stable opening topology. The transform is algebraically the same + * as the floor symbol's translate/rotate/flip nesting, including the gate's + * 0..10 degree exterior-face convention. + */ +export function buildIsoOpeningBasis( + input: IsoOpeningInput, + wallHeight = ISO_WALL_HEIGHT, +): IsoOpeningBasis { + if (!finite([ + input.x, input.y, input.angle, input.length, + input.face.ox, input.face.oy, wallHeight, + ]) || !(input.length > 0) || !(wallHeight > 0)) { + throw new Error('invalid isometric opening input'); + } + const half = input.length / 2; + let leaves: IsoOpeningLeafBasis[]; + if (input.type === 'gate') { + const sy = input.flipV ? -1 : 1; + const turn = input.face.side * sy * 10; + leaves = [ + leafBasis(input, 0, [-half, 0], [half, 0], turn, 0, wallHeight * 0.88), + leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0, wallHeight * 0.88), + ]; + } else if (input.type === 'window') { + leaves = [ + leafBasis(input, 0, [-half, 0], [half, 0], -90, wallHeight * 0.27, wallHeight * 0.78), + leafBasis(input, 1, [half, 0], [-half, 0], 90, wallHeight * 0.27, wallHeight * 0.78), + ]; + } else { + leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -90, 0, wallHeight * 0.92)]; + } + return { + id: input.id, + sourceIndex: input.sourceIndex, + type: input.type, + leaves: Object.freeze(leaves.map((leaf) => Object.freeze(leaf))), + }; +} + +const pointText = (point: ScenePoint): string => + `${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`; + +function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint { + const angle = leaf.turnDeg * amount * Math.PI / 180; + return [ + leaf.hinge[0] + leaf.closedVector[0] * Math.cos(angle) + + leaf.quarterVector[0] * Math.sin(angle), + leaf.hinge[1] + leaf.closedVector[1] * Math.cos(angle) + + leaf.quarterVector[1] * Math.sin(angle), + ]; +} + +/** Apply live state after the structural cache: O(leaves), no topology work. */ +export function projectIsoOpening( + basis: IsoOpeningBasis, + amount: number, + camera: IsoCamera = ISO_CAMERA, +): IsoOpeningPanel[] { + const liveAmount = Math.max(0, Math.min(1, Number.isFinite(amount) ? amount : 0)); + return basis.leaves.map((leaf) => { + const tip = liveTip(leaf, liveAmount); + const floorHinge = projectPlanPoint(leaf.hinge, leaf.bottom, camera); + const floorTip = projectPlanPoint(tip, leaf.bottom, camera); + const topTip = projectPlanPoint(tip, leaf.top, camera); + const topHinge = projectPlanPoint(leaf.hinge, leaf.top, camera); + return { + id: basis.id, + sourceIndex: basis.sourceIndex, + type: basis.type, + leaf: leaf.leaf, + d: `M ${pointText(floorHinge)} L ${pointText(floorTip)} L ${pointText(topTip)} L ${pointText(topHinge)} Z`, + shadowD: `M ${pointText(projectPlanPoint(leaf.hinge, 0, camera))} L ${pointText(projectPlanPoint(tip, 0, camera))}`, + depth: Math.max(floorHinge[1], floorTip[1]), + }; + }); +} + +/** State-independent plan envelope for fit/home; blur is intentionally absent. */ +export function isoOpeningBounds( + bases: readonly IsoOpeningBasis[], +): IsoOpeningBounds | null { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const basis of bases) { + for (const leaf of basis.leaves) { + const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1]); + minX = Math.min(minX, leaf.hinge[0] - radius); + minY = Math.min(minY, leaf.hinge[1] - radius); + maxX = Math.max(maxX, leaf.hinge[0] + radius); + maxY = Math.max(maxY, leaf.hinge[1] + radius); + } + } + return finite([minX, minY, maxX, maxY]) + ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } + : null; +} + +export interface IsoDecorationInput { + showBorders: boolean; + hideOpenings: boolean; + filtersSupported: boolean; + forcedColors: boolean; +} + +export interface IsoDecorationLayers { + structural: boolean; + panels: boolean; + shadows: boolean; + materialNuance: boolean; + floorSymbols: boolean; +} + +/** Display/capability policy is pure so decoration can never trigger Flat fallback. */ +export function resolveIsoDecoration(input: IsoDecorationInput): IsoDecorationLayers { + const structural = !!input.showBorders; + return { + structural, + panels: structural && !input.hideOpenings, + shadows: structural && input.filtersSupported && !input.forcedColors, + materialNuance: structural && !input.forcedColors, + floorSymbols: !input.hideOpenings && !structural, + }; +} diff --git a/src/iso-projection.ts b/src/iso-projection.ts index 52f0264a..7a97b84a 100644 --- a/src/iso-projection.ts +++ b/src/iso-projection.ts @@ -14,6 +14,8 @@ export interface ViewRect { x: number; y: number; w: number; h: number; } export interface IsoFrameInput { rect: ViewRect; wallHeight: number; + openingHeight?: number; + floorDepth?: number; } export const ISO_CAMERA: Readonly = Object.freeze({ @@ -25,6 +27,7 @@ export const ISO_CAMERA: Readonly = Object.freeze({ }); export const ISO_WALL_HEIGHT = 64; +export const ISO_FLOOR_EDGE_HEIGHT = 10; function finiteCamera(camera: IsoCamera): boolean { return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale, @@ -97,17 +100,21 @@ export function projectedFrame( input: IsoFrameInput, camera: IsoCamera = ISO_CAMERA, ): ViewRect { const { rect, wallHeight } = input; - if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)) + const openingHeight = input.openingHeight ?? wallHeight; + const floorDepth = input.floorDepth ?? 0; + if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight) + || !Number.isFinite(openingHeight) || !Number.isFinite(floorDepth) + || wallHeight < 0 || openingHeight < 0 || floorDepth < 0) throw new Error('invalid isometric frame'); const corners: PlanPoint[] = [ [rect.x, rect.y], [rect.x + rect.w, rect.y], [rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h], ]; + const top = Math.max(wallHeight, openingHeight); const points = corners.flatMap((point) => [ - projectPlanPoint(point, 0, camera), projectPlanPoint(point, wallHeight, camera), + projectPlanPoint(point, -floorDepth, camera), projectPlanPoint(point, top, camera), ]); const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]); const x = Math.min(...xs), y = Math.min(...ys); return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; } - diff --git a/src/iso-walls.ts b/src/iso-walls.ts index 75a93613..c87c3ca1 100644 --- a/src/iso-walls.ts +++ b/src/iso-walls.ts @@ -14,6 +14,22 @@ export interface IsoWallFace { export interface IsoWallGeometry { topPath: string; sides: readonly IsoWallFace[]; + contactPath: string; + edgeCount: number; +} + +export interface IsoFloorFace { + d: string; + depth: number; + component: number; + edge: number; + planEdge: readonly [PlanPoint, PlanPoint]; +} + +export interface IsoFloorGeometry { + footprintPath: string; + sides: readonly IsoFloorFace[]; + componentCount: number; edgeCount: number; } @@ -53,6 +69,19 @@ function normalizedRing(raw: any, hole: boolean): PlanPoint[] { return (signedArea(points) > 0) === wantPositive ? points : [...points].reverse(); } +function canonicalRing(raw: any, hole: boolean): PlanPoint[] { + const points = normalizedRing(raw, hole); + if (!points.length) return points; + let start = 0; + for (let index = 1; index < points.length; index++) { + if (points[index][0] < points[start][0] + || (points[index][0] === points[start][0] && points[index][1] < points[start][1])) { + start = index; + } + } + return [...points.slice(start), ...points.slice(0, start)]; +} + function topRingPath(points: readonly PlanPoint[], camera: IsoCamera, height: number): string { return points.length ? `M ${points.map((point) => pointText(projectPlanPoint(point, height, camera))).join(' L ')} Z` @@ -75,6 +104,7 @@ export function buildIsoWallGeometry( if (!Number.isFinite(height) || height < 0) throw new Error('invalid wall height'); const tops: string[] = []; const sides: IsoWallFace[] = []; + const contacts: string[] = []; let edgeCount = 0; for (let polygon = 0; polygon < (geometry || []).length; polygon++) { const source = geometry[polygon]; @@ -85,9 +115,10 @@ export function buildIsoWallGeometry( edgeCount += points.length; for (let edge = 0; edge < points.length; edge++) { const a = points[edge], b = points[(edge + 1) % points.length]; - if (visibleNormalY(a, b, camera) <= 1e-9) continue; const floorA = projectPlanPoint(a, 0, camera); const floorB = projectPlanPoint(b, 0, camera); + contacts.push(`M ${pointText(floorA)} L ${pointText(floorB)}`); + if (visibleNormalY(a, b, camera) <= 1e-9) continue; const topB = projectPlanPoint(b, height, camera); const topA = projectPlanPoint(a, height, camera); sides.push({ @@ -99,7 +130,62 @@ export function buildIsoWallGeometry( } sides.sort((a, b) => a.depth - b.depth || a.polygon - b.polygon || a.ring - b.ring || a.edge - b.edge); - return { topPath: tops.join(' '), sides, edgeCount }; + return { topPath: tops.join(' '), sides, contactPath: contacts.join(' '), edgeCount }; +} + +/** + * Project the already-unioned canonical room/exterior footprint. Only outer + * rings receive a low edge; holes and nested rooms therefore cannot become + * decorative steps. Polygon order and ring start points are canonicalised. + */ +export function buildIsoFloorGeometry( + geometry: any, + edgeHeight: number, + camera: IsoCamera = ISO_CAMERA, +): IsoFloorGeometry { + if (!Number.isFinite(edgeHeight) || edgeHeight < 0) throw new Error('invalid floor edge height'); + const components = (geometry || []).map((source: any) => { + const rings = (source || []).map((ring: any, index: number) => canonicalRing(ring, index > 0)) + .filter((ring: PlanPoint[]) => ring.length >= 3 && Math.abs(signedArea(ring)) >= 1e-9); + return rings; + }).filter((rings: PlanPoint[][]) => rings.length && signedArea(rings[0]) > 0); + components.sort((a: PlanPoint[][], b: PlanPoint[][]) => { + const aa = a[0][0], bb = b[0][0]; + return aa[0] - bb[0] || aa[1] - bb[1] + || Math.abs(signedArea(b[0])) - Math.abs(signedArea(a[0])); + }); + + const footprints: string[] = []; + const sides: IsoFloorFace[] = []; + let edgeCount = 0; + for (let component = 0; component < components.length; component++) { + const rings = components[component]; + for (const ring of rings) footprints.push(topRingPath(ring, camera, 0)); + const outer = rings[0]; + edgeCount += outer.length; + for (let edge = 0; edge < outer.length; edge++) { + const a = outer[edge], b = outer[(edge + 1) % outer.length]; + if (visibleNormalY(a, b, camera) <= 1e-9) continue; + const floorA = projectPlanPoint(a, 0, camera); + const floorB = projectPlanPoint(b, 0, camera); + const lowB = projectPlanPoint(b, -edgeHeight, camera); + const lowA = projectPlanPoint(a, -edgeHeight, camera); + sides.push({ + d: `M ${pointText(floorA)} L ${pointText(floorB)} L ${pointText(lowB)} L ${pointText(lowA)} Z`, + depth: Math.max(lowA[1], lowB[1]), + component, + edge, + planEdge: [a, b], + }); + } + } + sides.sort((a, b) => a.depth - b.depth || a.component - b.component || a.edge - b.edge); + return { + footprintPath: footprints.join(' '), + sides, + componentCount: components.length, + edgeCount, + }; } function mixHash(hash: number, value: number): number { diff --git a/src/styles.ts b/src/styles.ts index 92a8b37d..6acfeacf 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -469,25 +469,97 @@ export const cardStyles = css` height: 100%; display: block; } + .plan-svg { z-index: 1; } + .iso-underlay-svg { z-index: 0; overflow: visible; } + .iso-shadows-svg { z-index: 3; overflow: visible; } .iso-walls-svg { z-index: 4; overflow: visible; } + .iso-underlay-svg, + .iso-shadows-svg, + .iso-walls-svg, + .iso-underlay, + .iso-shadows, + .iso-walls, + .iso-openings { + pointer-events: none; + } + .iso-side-hi { stop-color: #b9bdbe; } + .iso-side-lo { stop-color: #969c9f; } + .iso-top-hi { stop-color: #fafaf7; } + .iso-top-lo { stop-color: #e2e4e2; } .iso-wall-side { - fill: #a8acae; + fill: url(#hp-iso-wall-side) #a8acae; stroke: #92989b; stroke-width: 0.7; vector-effect: non-scaling-stroke; } .iso-wall-top { - fill: #f3f3f1; + fill: url(#hp-iso-wall-top) #f3f3f1; stroke: #d7d9d8; stroke-width: 0.8; vector-effect: non-scaling-stroke; } + .iso-floor-side { + fill: #858b8d; + stroke: #71787b; + stroke-width: 0.7; + vector-effect: non-scaling-stroke; + } + .iso-opening-panel { + fill: #d7d9d7; + fill-opacity: 0.96; + stroke: #7f878b; + stroke-width: 0.9; + vector-effect: non-scaling-stroke; + } + .iso-opening-panel.iso-window { + fill: #dfeff4; + fill-opacity: 0.72; + stroke: #8aa7b1; + } + .iso-ambient-shadow { + fill: rgba(15, 21, 25, 0.22); + filter: url(#hp-iso-ambient-shadow); + } + .iso-contact-shadow { + fill: none; + stroke: rgba(22, 28, 31, 0.25); + stroke-width: 3; + filter: url(#hp-iso-contact-shadow); + vector-effect: non-scaling-stroke; + } + .iso-leaf-shadow { + fill: none; + stroke: rgba(18, 23, 27, 0.24); + stroke-width: 4; + filter: url(#hp-iso-leaf-shadow); + vector-effect: non-scaling-stroke; + } @media (prefers-color-scheme: dark) { - .iso-wall-side { fill: #3f474c; stroke: #333a3e; } - .iso-wall-top { fill: #596166; stroke: #747d82; } + .iso-side-hi { stop-color: #4c555a; } + .iso-side-lo { stop-color: #343c40; } + .iso-top-hi { stop-color: #687176; } + .iso-top-lo { stop-color: #50585d; } + .iso-wall-side { stroke: #30373b; } + .iso-wall-top { stroke: #7b858a; } + .iso-floor-side { fill: #2d3438; stroke: #20272a; } + .iso-opening-panel { fill: #626b70; stroke: #899399; } + .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; } + .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); } + .iso-contact-shadow, .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); } + } + @media (forced-colors: active) { + .iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel { + fill: Canvas; + stroke: CanvasText; + forced-color-adjust: auto; + } + .iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; } + } + @supports not (filter: blur(1px)) { + .iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; } } /* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky shows ONLY around the plan, never through it. The colour is the diff --git a/src/wall-thickness.ts b/src/wall-thickness.ts index 32e90949..57aabaf7 100644 --- a/src/wall-thickness.ts +++ b/src/wall-thickness.ts @@ -1313,6 +1313,34 @@ function exteriorEnvelopeGeometry( return { centre, shell: shell || [] }; } +/** + * Canonical Stage floor footprint: room union plus derived exterior masonry. + * Independent partitions/columns are deliberately not accepted here, so they + * can never enlarge the slab perimeter. Null is a boolean failure; an empty + * array is a valid space without room geometry. + */ +export function floorFootprintGeometry( + rooms: any[], + walls: WallEntry[] | null | undefined, + openCuts: number[][], + pitch: number, + cellCm: number, + gridPitch: number, + coordScale = 1, +): any | null { + try { + const exterior = exteriorEnvelopeGeometry( + rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, + ); + if (!exterior) return []; + return exterior.shell?.length + ? union(exterior.centre, exterior.shell) + : exterior.centre; + } catch { + return null; + } +} + function polyclipToPathD(geom: any): string { if (!geom) return ''; let d = ''; @@ -1475,7 +1503,7 @@ export function wallBodiesGeometry( gridPitch: number, coordScale = 1, extraBodies: number[][][] = [], -): { geom: any; paperGeom: any; depthUnits: number } | null { +): { geom: any; paperGeom: any; depthUnits: number; openingIndex: OpeningWallIndex | null } | null { if (!walls?.length && !extraBodies.length) return null; const roomRings: { outset: number[][]; inset: number[][] | null }[] = []; let maxDepth = 0; @@ -1583,7 +1611,7 @@ export function wallBodiesGeometry( if (extra.length < 3) continue; body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)]; } - return { geom: body || [], paperGeom, depthUnits: maxDepth }; + return { geom: body || [], paperGeom, depthUnits: maxDepth, openingIndex }; } catch { return null; } diff --git a/test/iso-openings.test.mjs b/test/iso-openings.test.mjs new file mode 100644 index 00000000..cf3801bf --- /dev/null +++ b/test/iso-openings.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration, +} from '../test-build/iso-openings.js'; +import { openingAmount } from '../test-build/logic.js'; +import { projectPlanPoint } from '../test-build/iso-projection.js'; + +const opening = (patch = {}) => ({ + id: 'op-1', sourceIndex: 0, type: 'door', x: 100, y: 80, + angle: 0, length: 60, flipH: false, flipV: false, + face: { ox: 0, oy: 5, side: 1 }, ...patch, +}); + +test('door basis is immutable and live amount preserves the exact jamb anchor', () => { + const basis = buildIsoOpeningBasis(opening()); + assert.equal(Object.isFrozen(basis.leaves), true); + const closed = projectIsoOpening(basis, 0)[0]; + const open = projectIsoOpening(basis, 1)[0]; + const hinge = projectPlanPoint(basis.leaves[0].hinge, basis.leaves[0].bottom) + .map((value) => Number(value.toFixed(4))).join(' '); + assert.match(closed.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`)); + assert.match(open.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`)); + assert.notEqual(open.d, closed.d); + assert.equal(basis.leaves[0].top, 64 * 0.92); +}); + +test('window and gate retain two leaves, fixed height bounds and gate 10 degree turn', () => { + const windowBasis = buildIsoOpeningBasis(opening({ type: 'window' })); + const gateBasis = buildIsoOpeningBasis(opening({ type: 'gate', flipV: true })); + assert.equal(windowBasis.leaves.length, 2); + assert.equal(windowBasis.leaves.every((leaf) => leaf.bottom === 64 * 0.27 + && leaf.top === 64 * 0.78), true); + assert.deepEqual(gateBasis.leaves.map((leaf) => Math.abs(leaf.turnDeg)), [10, 10]); + assert.equal(gateBasis.leaves.every((leaf) => leaf.top === 64 * 0.88), true); + assert.equal(projectIsoOpening(gateBasis, 1).length, 2); +}); + +test('state-independent opening bounds contain closed and open leaf tips', () => { + const basis = buildIsoOpeningBasis(opening()); + const bounds = isoOpeningBounds([basis]); + assert.ok(bounds); + for (const amount of [0, 0.5, 1]) { + const panel = projectIsoOpening(basis, amount)[0]; + assert.match(panel.d, /^M /); + } + const leaf = basis.leaves[0]; + assert.ok(bounds.x <= leaf.hinge[0] - 60 && bounds.x + bounds.w >= leaf.hinge[0] + 60); + assert.ok(bounds.y <= leaf.hinge[1] - 60 && bounds.y + bounds.h >= leaf.hinge[1] + 60); + assert.equal(isoOpeningBounds([]), null); +}); + +test('flips move structural jamb/basis while HA state changes only projected leaves', () => { + const normal = buildIsoOpeningBasis(opening()); + const flipped = buildIsoOpeningBasis(opening({ flipH: true, flipV: true })); + assert.notDeepEqual(flipped.leaves.map((leaf) => leaf.hinge), + normal.leaves.map((leaf) => leaf.hinge)); + const basisSnapshot = structuredClone(normal); + const noContact = projectIsoOpening(normal, openingAmount('door', null)); + const unavailable = projectIsoOpening(normal, openingAmount('door', 'unavailable')); + const closed = projectIsoOpening(normal, openingAmount('door', 'off')); + const inverted = projectIsoOpening(normal, openingAmount('door', 'off', true)); + assert.deepEqual(unavailable, noContact); + assert.notDeepEqual(closed, inverted); + assert.deepEqual(normal, basisSnapshot); +}); + +test('decoration degradation never removes structure or creates floating panels', () => { + assert.deepEqual(resolveIsoDecoration({ + showBorders: true, hideOpenings: false, filtersSupported: true, forcedColors: false, + }), { + structural: true, panels: true, shadows: true, materialNuance: true, floorSymbols: false, + }); + assert.deepEqual(resolveIsoDecoration({ + showBorders: true, hideOpenings: false, filtersSupported: false, forcedColors: false, + }), { + structural: true, panels: true, shadows: false, materialNuance: true, floorSymbols: false, + }); + assert.deepEqual(resolveIsoDecoration({ + showBorders: false, hideOpenings: false, filtersSupported: true, forcedColors: false, + }), { + structural: false, panels: false, shadows: false, materialNuance: false, floorSymbols: true, + }); + assert.equal(resolveIsoDecoration({ + showBorders: true, hideOpenings: true, filtersSupported: true, forcedColors: false, + }).panels, false); +}); diff --git a/test/iso-projection.test.mjs b/test/iso-projection.test.mjs index 2647bb29..bae8129c 100644 --- a/test/iso-projection.test.mjs +++ b/test/iso-projection.test.mjs @@ -46,6 +46,17 @@ test('projected frame includes raised wall tops and is view-state independent', assert.deepEqual(projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }), frame); }); +test('Stage 2 frame includes opening tops and the structural floor edge, not blur', () => { + const rect = { x: 100, y: 200, w: 400, h: 300 }; + const stage1 = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }); + const stage2 = projectedFrame({ + rect, wallHeight: ISO_WALL_HEIGHT, openingHeight: ISO_WALL_HEIGHT + 5, floorDepth: 10, + }); + assert.ok(stage2.y < stage1.y); + assert.ok(stage2.h > stage1.h); + assert.deepEqual(Object.keys(stage2).sort(), ['h', 'w', 'x', 'y']); +}); + test('client coordinates map through the current scene view', () => { const scene = clientToScenePoint([250, 175], { left: 50, top: 25, width: 400, height: 300 }, { x: 100, y: 200, w: 800, h: 600 }); @@ -58,4 +69,3 @@ test('degenerate cameras and frames throw instead of mixing projections', () => assert.throws(() => clientToScenePoint([0, 0], { left: 0, top: 0, width: 0, height: 1 }, { x: 0, y: 0, w: 1, h: 1 })); }); - diff --git a/test/iso-walls.test.mjs b/test/iso-walls.test.mjs index 38ff4fc0..9240a64d 100644 --- a/test/iso-walls.test.mjs +++ b/test/iso-walls.test.mjs @@ -1,8 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, + buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, } from '../test-build/iso-walls.js'; +import { floorFootprintGeometry } from '../test-build/wall-thickness.js'; const closed = (points) => [...points, points[0]]; const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]); @@ -14,9 +15,49 @@ test('one top and only O(E) visible sides are built deterministically', () => { assert.equal(first.edgeCount, 4); assert.ok(first.sides.length > 0 && first.sides.length <= first.edgeCount); assert.match(first.topPath, /^M /); + assert.match(first.contactPath, /^M /); assert.equal(first.sides.every((face) => /^M .* Z$/.test(face.d)), true); }); +test('floor edge follows outer components without internal or nested steps', () => { + const second = closed([[160, 0], [220, 0], [220, 60], [160, 60]]); + const hole = closed([[30, 30], [30, 70], [70, 70], [70, 30]]); + const floor = buildIsoFloorGeometry([[square, hole], [second]], 10); + assert.equal(floor.componentCount, 2); + assert.equal(floor.edgeCount, 8); + assert.equal(floor.sides.every((face) => face.planEdge.every((point) => ( + !(point[0] >= 30 && point[0] <= 70 && point[1] >= 30 && point[1] <= 70) + ))), true); + assert.ok(floor.sides.length > 0 && floor.sides.length <= floor.edgeCount); +}); + +test('canonical adjacent room union has no edge on its shared boundary', () => { + const rooms = [ + { id: 'left', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] }, + { id: 'right', poly: [[100, 0], [200, 0], [200, 100], [100, 100]] }, + ]; + const geometry = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1); + const floor = buildIsoFloorGeometry(geometry, 10); + assert.equal(floor.componentCount, 1); + assert.equal(floor.edgeCount, 4); + assert.equal(floor.sides.some((face) => face.planEdge.every((point) => point[0] === 100)), false); +}); + +test('floor edge output is stable under component order, winding and ring start', () => { + const a = closed([[0, 0], [80, 0], [80, 60], [0, 60]]); + const b = closed([[160, 20], [210, 20], [210, 80], [160, 80]]); + const rotatedA = closed([[80, 60], [80, 0], [0, 0], [0, 60]]); + const rotatedB = closed([[210, 80], [210, 20], [160, 20], [160, 80]]); + assert.deepEqual(buildIsoFloorGeometry([[rotatedB], [rotatedA]], 10), + buildIsoFloorGeometry([[a], [b]], 10)); +}); + +test('empty room footprint emits no inferred slab', () => { + assert.deepEqual(buildIsoFloorGeometry([], 10), { + footprintPath: '', sides: [], componentCount: 0, edgeCount: 0, + }); +}); + test('holes preserve an evenodd top and add visible inner/jamb edges', () => { const hole = closed([[40, 40], [40, 60], [60, 60], [60, 40]]); const geometry = buildIsoWallGeometry([[square, hole]]); diff --git a/test/isometric-contract.test.mjs b/test/isometric-contract.test.mjs index 4edbaef2..ee7949cf 100644 --- a/test/isometric-contract.test.mjs +++ b/test/isometric-contract.test.mjs @@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs'; const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8'); const styles = readFileSync(new URL('../src/styles.ts', import.meta.url), 'utf8'); +const openings = readFileSync(new URL('../src/iso-openings.ts', import.meta.url), 'utf8'); +const packageJson = readFileSync(new URL('../package.json', import.meta.url), 'utf8'); const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8'); const spaceRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8'); @@ -15,12 +17,16 @@ test('Labs iso is presentation-only and absent from the secondary space card', ( assert.doesNotMatch(spaceRender, /hp-labs|iso-walls|projection-toggle|view\.volumetric/); }); -test('renderer uses SVG geometry without a CSS 3D context or new window light', () => { +test('Stage 2 uses inert shared-projection SVG geometry without a second light model', () => { + assert.match(card, /