feat: add hidden isometric stage 2

Issue: #122
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-14 00:43:13 +03:00
parent 4c73e2ccdb
commit 42b3f44c4a
19 changed files with 1051 additions and 53 deletions
+2 -1
View File
@@ -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 },
+9 -3
View File
@@ -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;
});
+57 -4
View File
@@ -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,
+25
View File
@@ -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/
+76 -1
View File
@@ -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.
+3 -3
View File
@@ -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 -- <tag> --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. |
@@ -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.
+194 -24
View File
@@ -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<typeof wallBodiesUnionPath>;
} | null = null;
private _isoGeometryCache = new Map<string, { geometry: IsoWallGeometry; frame: Rect }>();
private _isoGeometryCache = new Map<string, {
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
openings: readonly IsoOpeningBasis[];
frame: Rect;
}>();
private _isoFallback = new Set<string>();
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 {
</g>` 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`<defs>
${root === 'walls' && layers.materialNuance ? svg`
<linearGradient id="hp-iso-wall-side" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="iso-side-hi"></stop><stop offset="1" class="iso-side-lo"></stop>
</linearGradient>
<linearGradient id="hp-iso-wall-top" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" class="iso-top-hi"></stop><stop offset="1" class="iso-top-lo"></stop>
</linearGradient>` : nothing}
${root === 'underlay' && layers.shadows ? svg`
<filter id="hp-iso-ambient-shadow" x="-12%" y="-12%" width="124%" height="130%">
<feGaussianBlur stdDeviation="7"></feGaussianBlur>
</filter>` : nothing}
${root === 'shadows' && layers.shadows ? svg`
<filter id="hp-iso-contact-shadow" x="-8%" y="-20%" width="116%" height="140%">
<feGaussianBlur stdDeviation="2.5"></feGaussianBlur>
</filter>
<filter id="hp-iso-leaf-shadow" x="-12%" y="-30%" width="124%" height="160%">
<feGaussianBlur stdDeviation="2"></feGaussianBlur>
</filter>` : nothing}
</defs>` 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`<g class="iso-underlay" data-hp="iso-underlay" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'underlay')}
${layers.shadows && floor.footprintPath
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath} transform="translate(0 8)"></path>`
: nothing}
<g class="iso-floor-edge">${floor.sides.map((face) =>
svg`<path class="iso-floor-side" d=${face.d} data-component=${face.component}
data-edge=${face.edge}></path>`)}</g>
</g>` 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`<g class="iso-shadows" data-hp="iso-shadows" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'shadows')}
<path class="iso-contact-shadow" d=${geometry.contactPath}></path>
<g class="iso-leaf-shadows">${panels.map((panel) =>
svg`<path class="iso-leaf-shadow" d=${panel.shadowD}
data-id=${panel.id} data-leaf=${panel.leaf}></path>`)}</g>
</g>` 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`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
${this._renderIsoDefs(layers, 'walls')}
<g class="iso-wall-sides">${scene.geometry.sides.map((face) =>
svg`<path class="iso-wall-side" d=${face.d} data-edge=${face.edge}></path>`)}</g>
<path class="iso-wall-top" d=${scene.geometry.topPath} fill-rule="evenodd"></path>
${layers.panels ? svg`<g class="iso-openings" data-hp="iso-openings"
aria-hidden="true" pointer-events="none">${panels.map((panel) =>
svg`<path class="iso-opening-panel iso-${panel.type}" d=${panel.d}
data-id=${panel.id} data-kind=${panel.type} data-leaf=${panel.leaf}></path>`)}</g>` : nothing}
</g>` 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 {
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
style="${transitionBrightness !== 1 ? `filter:brightness(${transitionBrightness.toFixed(3)})` : ''}">
<svg viewBox="${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}"
preserveAspectRatio=${iso ? 'none' : 'xMidYMid meet'}>
${iso && isoLayers?.structural ? svg`<svg class="iso-underlay-svg"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoUnderlay(isoLayers)}
</svg>` : nothing}
<svg class="plan-svg" viewBox=${iso
? `${view.x} ${view.y} ${view.w} ${view.h}`
: `${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}`}
preserveAspectRatio="xMidYMid meet">
<g class=${iso ? 'iso-floor-scene' : nothing}
transform=${iso ? isoFloorMatrixCss() : 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)}
</g>
</svg>
${iso ? svg`<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
${iso && isoLayers?.structural ? svg`<svg class="iso-shadows-svg"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls()}
${this._renderIsoShadows(isoLayers, isoPanels)}
</svg>
<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls(isoLayers, isoPanels)}
</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
+214
View File
@@ -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,
};
}
+10 -3
View File
@@ -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<IsoCamera> = Object.freeze({
@@ -25,6 +27,7 @@ export const ISO_CAMERA: Readonly<IsoCamera> = 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 };
}
+88 -2
View File
@@ -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 {
+76 -4
View File
@@ -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
+30 -2
View File
@@ -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;
}
+87
View File
@@ -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);
});
+11 -1
View File
@@ -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 }));
});
+42 -1
View File
@@ -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]]);
+24 -2
View File
@@ -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, /<svg class="iso-underlay-svg"/);
assert.match(card, /<svg class="iso-shadows-svg"/);
assert.match(card, /<svg class="iso-walls-svg"/);
assert.match(card, /class="iso-wall-top"/);
assert.match(card, /class="iso-wall-side"/);
assert.match(card, /class="iso-opening-panel iso-\$\{panel\.type\}"/);
assert.match(card, /aria-hidden="true" pointer-events="none"/);
assert.doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/);
assert.doesNotMatch(card, /iso-window|window-light|vertical-door/);
assert.doesNotMatch(card, /window-light|iso-window-light|iso-glow|iso-sun/);
});
test('all current floor/live layers remain in the one main scene', () => {
@@ -31,3 +37,19 @@ test('all current floor/live layers remain in the one main scene', () => {
assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/);
});
test('structural cache includes opening flips and excludes live HA amount', () => {
assert.match(card, /flipH: !!opening\.flip_h/);
assert.match(card, /flipV: !!opening\.flip_v/);
assert.match(card, /floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3/);
const source = card.slice(card.indexOf('private _isoSource()'), card.indexOf('private _isoSceneKey()'));
assert.doesNotMatch(source, /_openingAmt|openingAmount|\.hass|matchMedia|CSS\.supports|theme|hover/);
assert.match(card, /projectIsoOpening\(basis, this\._openingAmt\(opening\)\)/);
assert.match(card, /isoLayers && !isoLayers\.floorSymbols/);
});
test('Stage 2 adds no schema, dependency, storage, network or HA action surface', () => {
assert.doesNotMatch(openings, /localStorage|fetch\(|XMLHttpRequest|WebSocket|callService|config|schema/i);
assert.doesNotMatch(packageJson, /three|babylon|webgl/i);
assert.doesNotMatch(card, /iso2|isometric_stage|stage2_enabled/);
});
+13 -1
View File
@@ -6,7 +6,8 @@ import {
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
@@ -40,6 +41,17 @@ const geometryBounds = (geom) => {
];
};
test('Stage floor footprint excludes detached independent physical bodies', () => {
const rooms = [{ id: 'room', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] }];
const detached = [[[200, 20], [220, 20], [220, 80], [200, 80]]];
const footprint = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1);
const withBody = wallBodiesGeometry(rooms, [], [], [], 20, 250, 40, 1, detached);
assert.ok(footprint && withBody);
assert.deepEqual(geometryBounds(footprint), [0, 0, 100, 100]);
assert.deepEqual(geometryBounds(withBody.paperGeom), [0, 0, 100, 100]);
assert.deepEqual(geometryBounds(withBody.geom), [200, 20, 220, 80]);
});
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
function cornerSplitFixture({
+1 -1
View File
@@ -31,7 +31,7 @@
"src/opening-placement.ts", "src/render/opening-symbol.ts",
"src/wall-thickness.ts",
"src/physical-geometry.ts",
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts",
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts",
"src/open-spans.ts",
"src/editors/decor/geometry.ts"
]