mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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]]);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user