Fix corner split exterior walls

Issue: #123
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-13 18:55:49 +00:00
committed by claude[bot]
parent 7af4146614
commit 955de3e69c
18 changed files with 1234 additions and 647 deletions
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -1,5 +1,5 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
@@ -51,6 +51,34 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
const before = stage === 'before';
fixture.config.spaces.push({
id: scenario.space,
name: 'Corner Split',
rooms: before
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
: [
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
],
walls: before
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
: [
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
],
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
});
}
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
+7 -1
View File
@@ -1,13 +1,19 @@
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 = 17;
export const GOLDEN_MATRIX_VERSION = 18;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ 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',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
+141
View File
@@ -0,0 +1,141 @@
/** Issue #123: a Split ending at a room vertex must not reshape the facade. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sp = () => c._serverCfg.spaces.find((space) => space.id === c._space);
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
const update = async () => {
c._cfgEpoch++;
c._wallUnionCache = null;
c._lightBarrierCache = null;
c.requestUpdate();
await c.updateComplete;
};
const bbox = (element) => {
const box = element?.getBBox?.();
return box ? [box.x, box.y, box.width, box.height] : null;
};
const sameBox = (a, b, epsilon = 0.01) => !!a && !!b
&& a.every((value, index) => Math.abs(value - b[index]) <= epsilon);
const a = [0.10, 0.10], tr = [0.90, 0.10], b = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const original = { id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, br, bl] };
const outerBefore = [
entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15),
];
const outerAfter = [
entry(a, tr, 15), entry(tr, b, 15), entry(b, br, 15),
entry(br, bl, 15), entry(bl, a, 15),
];
const splitRooms = [
{ id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, b] },
{ id: 'corner-fresh', name: 'Corner fresh', area: null, poly: [b, br, bl, a] },
];
const space = sp();
space.settings = { ...(space.settings || {}), show_borders: true };
space.rooms = [structuredClone(original)];
space.walls = structuredClone(outerBefore);
delete space.open_spans;
delete space.openings;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
c._setMode('plan');
await update();
const beforeWall = bbox(sr().querySelector('.wallbody'));
const beforePaper = bbox(sr().querySelector('.hp-paperg'));
out.beforeDrawn = !!beforeWall && !!beforePaper;
space.rooms = structuredClone(splitRooms);
const pathByThickness = {};
for (const cm of [0, 15, 100]) {
space.walls = [
...structuredClone(outerAfter),
...(cm ? [entry(a, b, cm)] : []),
];
await update();
const wall = sr().querySelector('.wallbody');
const paper = sr().querySelector('.hp-paperg');
out[`wall${cm}KeepsFacade`] = sameBox(bbox(wall), beforeWall);
out[`paper${cm}KeepsFacade`] = sameBox(bbox(paper), beforePaper);
pathByThickness[cm] = wall?.getAttribute('d') || '';
}
out.dividerChangesInterior = pathByThickness[15] !== pathByThickness[100]
&& pathByThickness[0] !== pathByThickness[15];
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
const planD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
const lightGeom = c._lightBarriers(c._spaceModel()).masonryGeometry;
const lightPoints = lightGeom.flat(2);
const lightBox = lightPoints.length ? [
Math.min(...lightPoints.map((point) => point[0])),
Math.min(...lightPoints.map((point) => point[1])),
Math.max(...lightPoints.map((point) => point[0])) - Math.min(...lightPoints.map((point) => point[0])),
Math.max(...lightPoints.map((point) => point[1])) - Math.min(...lightPoints.map((point) => point[1])),
] : null;
out.lightUsesFacade = sameBox(lightBox, beforeWall);
c._setMode('view');
await update();
const viewD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
out.planViewParity = !!planD && viewD === planD;
const kioskBefore = c._config.kiosk;
c._config.kiosk = true;
await update();
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
c._config.kiosk = kioskBefore;
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
dispatchEvent(new HashChangeEvent('hashchange'));
await c.updateComplete;
c._setProjection('iso');
await update();
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
&& c._isoSource().build().flat(2).length > 0;
c._setProjection('flat');
await update();
await customElements.whenDefined('houseplan-space-card');
const cfg = structuredClone(c._serverCfg);
const baseCall = c.hass.callWS.bind(c.hass);
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
staticCard.remove();
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+186 -186
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -326,6 +326,20 @@ its types declare named exports — breaking either tsc or the runtime): merge a
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
bigger part keeps the room identity (name/area/devices).
`wallBodiesGeometry()` is the canonical physical masonry for flat full/static
rendering, hidden isometric projection and Glow/sun occlusion. Its exterior
shell is derived from the union of room centrelines plus the surviving `outer`
atomic intervals; internal/shared interval bodies are clipped to that union
before the shell is restored. Consequently a Split edge ending at an exterior
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
an interior join/nested-room representation, and atomic quads provide a safe
physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. The full card retains the
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
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.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Splitting a room from an existing corner no longer deforms the exterior wall
or pulls a thick internal divider through the facade. Plan, View, kiosk,
static cards, hidden isometric rendering and light obstacles now use the same
preserved exterior geometry, including already saved plans
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
## v1.63.0-beta.1 — 2026-08-13
- Deleting a standalone sensor or lock marker no longer breaks its explicit
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Split из существующего угла комнаты больше не деформирует наружную стену и
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
используют одну сохранённую наружную геометрию, в том числе для уже
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
## v1.63.0-beta.1 — 2026-08-13
- Удаление самостоятельного маркера датчика или замка больше не разрывает его
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). It also carries the reviewed process automation work #105 and #118–#121. |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). Development after that beta 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. The line also carries the reviewed process automation work #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. |
| 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) |
+3
View File
@@ -304,6 +304,9 @@ desktop: для точного рисования, Resize, модификато
- Промежуточные точки должны идти внутри комнаты и не пересекать контур или сам разрез.
- Большая по площади часть сохраняет исходную комнату и устройства.
- Для меньшей части открывается диалог новой комнаты.
- Разрез можно начать или закончить точно в существующем углу. Наружная форма
дома при этом не меняется: общая стена двух новых комнат примыкает к фасаду
только изнутри, даже если она толще наружной стены.
### Resize
+16 -2
View File
@@ -42,6 +42,17 @@ Every thick wall grows **half outward and half inward** from the polygon edge
(outer and shared alike). Silhouette is wider than the polygon by `cm/2` on
outer walls. Paper and the content frame grow under that outer half.
The exterior silhouette is generated from the boolean union of room
centrelines and its surviving `outer` atomic intervals. A shared Split edge
therefore disappears before exterior mitres are built. When Split ends at an
existing corner, its divider is clipped to the interior side of this envelope:
the real exterior mitre/bevel and unequal arm depths stay unchanged, while any
divider thickness remains inside the facade. The same computed geometry is
used for the full/static/hidden-isometric renderers and light occlusion. The
paper and masonry paths come from one cached structural pass in flat renderers;
live HA state ticks do not repeat the boolean topology. Saved room and wall data
is not migrated or rewritten.
## 3. Body render
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
@@ -132,14 +143,17 @@ virtual-T mitre; angle-aware opening; 45° wall; T-junction; detached parallel
room; nested-room tie; partially out-of-span legacy opening; overlapping
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
clipping and room-side colour ownership; whole and
atomic rekey after edge/scale.
atomic rekey after edge/scale; corner Split exterior equality across
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints.
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
the virtual rubber band paints above the real body; sun starts at the room-side
opening corners; nav mode restores after `can_write`; a 1 cm body uses
solid-only in both full and static cards while a 20 cm body keeps its hatch;
door/window/gate tunnels repeat outer/shared room fills without an axis seam
(`demo/smoke_opening_tunnel_fill.mjs`).
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
Plan/View/kiosk/static/isometric surfaces and the light barrier
(`demo/smoke_split_corner_wall.mjs`).
## 9. Independent partitions, drafts and columns
+4 -3
View File
@@ -290,9 +290,10 @@ View и киоск полностью поддерживаются: исправ
- **AC10 (`unit`):** обычный Split от середины стены, wall materialisation,
partial shared intervals, virtual-T mitre, openings и independent partitions
сохраняют действующее поведение.
- **AC11 (`performance` + ревью кода):** новый exterior/shared расчёт использует
существующее geometry caching; HA state tick не пересчитывает topology, а
общий pre-beta performance gate остаётся зелёным.
- **AC11 (ревью кода):** новый exterior/shared расчёт использует существующее
geometry caching; HA state tick не пересчитывает topology. Существующие
`performance_smoke` и large-house benchmark остаются общим предрелизным
подтверждением, а не отдельным типом доказательства AC.
- **AC12 (`typecheck` + `unit` + `build`):** быстрые гейты зелёные; три bundle
snapshot побайтно совпадают.
- **AC13 (ревью документации):** RU/EN changelog и пользовательские документы
+36 -27
View File
@@ -55,7 +55,7 @@ import {
import {
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, paperRoomShapesWithWalls,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
@@ -4432,7 +4432,7 @@ class HouseplanCard extends LitElement {
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: 1,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
})}`;
return {
key,
@@ -9646,13 +9646,39 @@ class HouseplanCard extends LitElement {
}
/** Paper under rooms, grown by shared-wall half-thickness when set. */
private _paperShapes(rooms: any[]): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
private _paperShapes(rooms: any[]): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const walls = this._spaceWalls;
if (!walls.length) return paperRoomShapes(rooms);
const united = this._wallUnionGeometry();
return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms);
}
/** Canonical paper + masonry geometry, cached by structural config epoch. */
private _wallUnionGeometry(): ReturnType<typeof wallBodiesUnionPath> {
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return null;
const openCuts = this._openPairs().flatMap((p) => p.segs);
return paperRoomShapesWithWalls(
rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
this._spaceModel().rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
}
return this._wallUnionCache.value;
}
/** Thick-wall spans in render units — suppress centreline stroke under bodies. */
@@ -10009,26 +10035,7 @@ class HouseplanCard extends LitElement {
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
return svg`` as unknown as TemplateResult;
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return svg`` as unknown as TemplateResult;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
this._spaceModel().rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
}
const united = this._wallUnionCache.value;
const united = this._wallUnionGeometry();
if (!united) return svg`` as unknown as TemplateResult;
const stage = this._stageEl;
const v = this._viewOr(this._baseVb());
@@ -14026,7 +14033,9 @@ class HouseplanCard extends LitElement {
(styles.ts) is composited once for the whole sheet, so
adjacent rooms never cast seams onto each other's paper. */}
${this._wallHatchDefs(disp.color)}${svg`<g class="hp-paperg">${this._paperShapes(space.rooms).map((sh) =>
'poly' in sh
'path' in sh
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd" pointer-events="none"></path>`
: 'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`}
+40 -9
View File
@@ -12,9 +12,7 @@ import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
} from './logic';
import {
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
import { wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry } from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
@@ -27,6 +25,7 @@ import {
import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
GRID_STEP_N, GRID_PITCH,
@@ -35,6 +34,29 @@ import {
export { spaceModels } from './space-geometry';
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
build: () => StaticWallGeometry,
): StaticWallGeometry {
let spaces = staticWallGeometryCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticWallGeometryEntry>();
staticWallGeometryCache.set(cfg as object, spaces);
}
const cached = spaces.get(spaceId);
if (cached?.fingerprint === fingerprint) return cached.value;
const value = build();
spaces.set(spaceId, { fingerprint, value });
return value;
}
export interface StaticRenderOpts {
hass: any;
registry?: HaRegistrySnapshot;
@@ -306,12 +328,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// gaps between detached buildings, and an empty space has no paper at all,
// image or no image. The picture is drawn ON the paper, one layer above.
const paperShapes = walls.length
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: paperRoomShapes(space.rooms);
const wallUnion = (walls.length || extras.length) && disp.showBorders
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras)
const needsCanonicalWallGeometry = !!(walls.length || (extras.length && disp.showBorders));
const wallGeometryFingerprint = needsCanonicalWallGeometry
? contentFingerprint({ rooms: space.rooms, walls, extras, cellCm })
: '';
const canonicalWallGeometry = needsCanonicalWallGeometry
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
))
: null;
const paperShapes = walls.length && canonicalWallGeometry?.paperD
? [{ path: canonicalWallGeometry.paperD }]
: paperRoomShapes(space.rooms);
const wallUnion = disp.showBorders ? canonicalWallGeometry : null;
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
const wallStroke = disp.color || '#607d8b';
@@ -326,7 +355,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
</pattern>
</defs>` : nothing}
${paperShapes.map((sh) =>
'poly' in sh
'path' in sh
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd"></path>`
: 'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
+177 -39
View File
@@ -6,8 +6,8 @@
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
* centreline.
*/
import { union, difference } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary } from './logic';
import { union, difference, intersection } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
export interface WallEntry {
key: string;
@@ -1206,6 +1206,113 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
interface ExteriorEnvelopeGeometry {
/** Union of room centrelines. Shared Split edges disappear from this shape. */
centre: any;
/** Wall shell generated only from the surviving exterior boundary. */
shell: any;
}
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
function geometryRings(geom: any): number[][][] {
const out: number[][][] = [];
for (const polygon of Array.isArray(geom) ? geom : []) {
if (!Array.isArray(polygon)) continue;
for (const raw of polygon) {
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
if (ring.length >= 3) out.push(ring);
}
}
return out;
}
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
const dx = b[0] - a[0], dy = b[1] - a[1];
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
const len2 = dx * dx + dy * dy;
return dot >= -eps && dot <= len2 + eps;
}
/**
* Split a boolean-union boundary at every stored exterior interval endpoint.
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
* interval breakpoints is what preserves unequal wall depths on the two sides.
*/
function exteriorBoundaryProfile(
ring: number[][],
outer: WallInterval[],
eps: number,
): { poly: number[][]; offsets: number[] } | null {
const poly: number[][] = [];
const offsets: number[] = [];
for (let i = 0; i < ring.length; i++) {
const a = ring[i], b = ring[(i + 1) % ring.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > eps * eps)) continue;
const cuts = [0, 1];
for (const iv of outer) {
for (const p of [iv.a, iv.b]) {
if (!pointOnSegment(p, a, b, eps)) continue;
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
if (t > eps && t < 1 - eps) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps);
for (let at = 0; at < unique.length - 1; at++) {
const t0 = unique[at], t1 = unique[at + 1];
const p = [a[0] + dx * t0, a[1] + dy * t0];
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
let half = 0;
for (const iv of outer) {
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
}
poly.push(p);
offsets.push(half);
}
}
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
}
/**
* Exterior masonry is derived from the union of room centrelines, not from
* each room independently. A Split edge therefore vanishes before mitres are
* built and cannot turn its artificial child corner into part of the facade.
*/
function exteriorEnvelopeGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): ExteriorEnvelopeGeometry | null {
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
if (!polys.length) return null;
let centre: any = union(closedRing(polys[0]) as any);
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
const outer = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
const eps = openEps(pitch, coordScale) * 4;
let shell: any = null;
for (const ring of geometryRings(centre)) {
const profile = exteriorBoundaryProfile(ring, outer, eps);
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(profile.poly, profile.offsets);
const inset = insetContour(profile.poly, profile.offsets);
if (!outset || !inset) continue;
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
shell = shell ? union(shell, piece) : piece;
}
return { centre, shell: shell || [] };
}
function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
@@ -1368,7 +1475,7 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; depthUnits: number } | null {
): { geom: any; paperGeom: any; depthUnits: number } | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
@@ -1404,17 +1511,54 @@ export function wallBodiesGeometry(
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
const paperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
let body: any = roomRings.length ? bodyOf(roomRings[0]) : null;
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
let body: any = null;
for (const ring of roomRings) {
try {
const piece = bodyOf(ring);
body = body ? union(body, piece) : piece;
} catch {
// An acute child contour may be invalid for boolean subtraction. The
// interval pass below still supplies its physical wall without letting
// the artificial mitre back into the exterior envelope.
}
}
// Per-room rings preserve established L/T/nested joins. Atomic quads are
// also included so a rejected acute child ring cannot remove a divider or
// an interior half-wall. Clipping them to the centre union gives a hard
// facade boundary; the canonical exterior shell is added afterwards.
if (exterior) {
for (const edge of wallEdgeBodies(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
)) {
try {
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
body = body ? union(body, piece) : piece;
} catch {
// A valid per-room ring may already own this interval. If neither
// representation is usable the final result fails closed below.
}
}
}
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.
for (const patch of junctions)
body = body ? union(body, closedRing(patch) as any) : closedRing(patch);
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
@@ -1439,7 +1583,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], depthUnits: maxDepth };
return { geom: body || [], paperGeom, depthUnits: maxDepth };
} catch {
return null;
}
@@ -1457,31 +1601,19 @@ export function wallBodiesUnionPath(
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
);
const d = united ? polyclipToPathD(united.geom) : '';
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united) return null; // successful empty result: do not resurrect raw rings
// fall back to evenodd rings concatenated
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
if (!rings.length && !extraD) return null;
// `united` is null on this branch: a successful-but-empty union returned
// above and must not be resurrected by the raw-ring fallback.
let maxDepth = 0;
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
// Each room ring already reverses its inset. `nonzero` therefore keeps
// floors as holes while overlapping independent rings add instead of
// cancelling one another (the old even-odd fallback produced pinholes at
// exactly the complex junctions for which a fallback is needed).
return {
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
depthUnits: maxDepth,
fillRule: 'nonzero',
};
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
// a boolean failure would make malformed input violate the facade invariant.
return null;
}
/**
@@ -1666,22 +1798,28 @@ export function paperRoomShapesWithWalls(
cellCm: number,
gridPitch: number,
coordScale = 1,
): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
const poly = roomPoly(r);
if (poly && poly.length >= 3) {
const pr = roomWallProfile(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const grown = pr && pr.offsets.some((o) => o > 0)
? outsetContour(pr.poly, pr.offsets)
: null;
const use = grown || poly;
out.push({ poly: use.map((p) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
if (!walls?.length) return paperRoomShapes(rooms);
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (exterior) {
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}
} catch {
// Safe fallback below: exact room centrelines never reproduce the known
// exterior Split spike, even when boolean offsetting rejected bad input.
}
return out;
return paperRoomShapes(rooms);
}
interface OpeningWallEdge {
+20 -1
View File
@@ -71,7 +71,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount'])
'isometric-large-warm-remount', 'split-corner-wall'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -80,6 +80,25 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('corner Split golden captures before, thin and thick facade states', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.cornerSplitWall);
assert.deepEqual(scenarios.map((scenario) => scenario.cornerSplitWall), ['before', 'thin', 'thick']);
for (const scenario of scenarios) {
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.equal(space.settings.show_borders, true);
assert.equal(space.rooms.length, scenario.cornerSplitWall === 'before' ? 1 : 2);
if (scenario.cornerSplitWall !== 'before') {
const divider = space.walls.find((wall) => (
wall.a?.[0] === 0.10 && wall.a?.[1] === 0.10
&& wall.b?.[0] === 0.90 && wall.b?.[1] === 0.50
));
assert.equal(divider?.cm, scenario.cornerSplitWall === 'thin' ? 15 : 100);
}
}
});
test('filled opening golden has a pixel-level seam detector', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'openings-filled-tunnel-dark');
assert.ok(scenario);
+176 -5
View File
@@ -6,7 +6,7 @@ import {
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesUnionPath, innerContourForRoom,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
@@ -14,8 +14,10 @@ import {
openingTunnelGeometries, tunnelFacePath,
WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { GRID_PITCH } from '../test-build/space-geometry.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { difference, union } from 'polyclip-ts';
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
@@ -23,6 +25,59 @@ const closeTo = (got, want, tol = 1e-6) =>
const pitch = 1 / 240; // normalised grid step
const cellCm = 5;
const closedGeometry = (poly) => {
const ring = [...poly, poly[0]].map((point) => [...point]);
return [[ring]];
};
const geometryBounds = (geom) => {
const points = geom.flat(2);
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
];
};
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
function cornerSplitFixture({
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
path = [[100, 100], [900, 500]],
outerCm = 15,
dividerCm = 15,
outerOverrides = [],
} = {}) {
const original = { id: 'source', poly: poly.map((point) => [...point]) };
const split = splitRoomPath(original.poly, path);
assert.ok(split, 'fixture must be a valid corner split');
let walls = outerCm > 0
? applyWallThicknessToNewRoom([], [original], original.id, outerCm, pitch)
: [];
for (const [a, b, cm] of outerOverrides)
walls = setWallThickness(walls, a, b, cm, pitch);
const before = walls.length
? wallBodiesGeometry([original], walls, [], [], pitch, cellCm, GRID_PITCH)
: null;
walls = materializeWallIntervals([original], walls, [], pitch, cellCm, GRID_PITCH);
const rooms = [
{ id: 'source', poly: split[0] },
{ id: 'fresh', poly: split[1] },
];
const divider = sharedBoundary(rooms[0].poly, rooms[1].poly);
assert.equal(divider.length, 1);
walls = setWallThickness(
walls, divider[0].slice(0, 2), divider[0].slice(2), dividerCm, pitch,
);
walls = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
const after = wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH);
assert.ok(after, `wall geometry missing for outer=${outerCm}, divider=${dividerCm}`);
return { original, rooms, walls, before, after };
}
// ------------------------------- key ----------------------------------------
test('wallKey is the same from either end of the wall', () => {
@@ -687,6 +742,123 @@ test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () =
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
});
test('corner Split keeps the original exterior wall body and paper', () => {
const { original, rooms, walls, before, after } = cornerSplitFixture();
assert.ok(before);
assert.deepEqual(geometryBounds(after.geom), geometryBounds(before.geom));
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(before.paperGeom, after.paperGeom), 0, 1e-7);
closeTo(geometryDifferenceArea(after.paperGeom, before.paperGeom), 0, 1e-7);
const paper = paperRoomShapesWithWalls(
rooms, walls, [], pitch, cellCm, GRID_PITCH,
);
assert.equal(paper.length, 1);
assert.ok('path' in paper[0]);
const nums = paper[0].path.match(/-?\d+(?:\.\d+)?/g).map(Number);
const paperPoints = [];
for (let i = 0; i < nums.length; i += 2) paperPoints.push([nums[i], nums[i + 1]]);
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(before.geom));
const canonical = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(canonical?.paperD, 'canonical render pass must include its paper path');
assert.equal(canonical.paperD, paper[0].path);
});
test('corner Split clips every divider thickness when exterior walls are absent', () => {
for (const dividerCm of [1, 15, 100]) {
const { original, after } = cornerSplitFixture({ outerCm: 0, dividerCm });
closeTo(geometryArea(difference(after.geom, closedGeometry(original.poly))), 0, 1e-7);
}
});
test('corner Split preserves the facade for thin and thick outer/divider matrices', () => {
for (const outerCm of [1, 15, 100]) {
for (const dividerCm of [0, 1, 15, 100]) {
const { original, before, after } = cornerSplitFixture({ outerCm, dividerCm });
assert.ok(before);
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
}
}
});
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
const fixture = cornerSplitFixture({
outerOverrides: [
[[100, 100], [900, 100], 5],
[[100, 700], [100, 100], 40],
],
dividerCm: 100,
});
const shuffled = fixture.rooms
.map((room, at) => ({ id: `renamed-${at}`, poly: [...room.poly].reverse() }))
.reverse();
const permuted = wallBodiesGeometry(
shuffled, fixture.walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(fixture.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, fixture.after.geom), 0, 1e-7);
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split from a concave vertex does not turn the child mitre into facade', () => {
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
const fixture = cornerSplitFixture({ poly, path: [[600, 400], [900, 250]], dividerCm: 100 });
const centre = closedGeometry(poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split with both endpoints at exterior vertices preserves both corners', () => {
const fixture = cornerSplitFixture({ path: [[100, 100], [900, 700]], dividerCm: 100 });
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('corner Split clean floors are exactly the room union minus canonical walls', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const floors = fixture.rooms.map((room) => innerContourForRoom(
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
));
assert.ok(floors.every(Boolean));
const actual = union(...floors.map((floor) => closedGeometry(floor)));
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
closeTo(geometryDifferenceArea(actual, expected), 0, 1e-7);
closeTo(geometryDifferenceArea(expected, actual), 0, 1e-7);
});
test('corner Split rendering does not materialize or mutate saved geometry', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const rooms = structuredClone(fixture.rooms);
const walls = structuredClone(fixture.walls);
const before = JSON.stringify({ rooms, walls });
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH));
assert.ok(paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, GRID_PITCH).length);
assert.equal(JSON.stringify({ rooms, walls }), before);
});
test('paper with walls covers shared centreline; without walls matches paperRoomShapes', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
@@ -698,9 +870,8 @@ test('paper with walls covers shared centreline; without walls matches paperRoom
const walls = [{ key: wallKey([5, 0], [5, 4], pitch), cm: 20 }];
const grown = paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, pitch);
assert.equal(grown.length, 2);
// grown polys are still present (strings)
assert.ok('poly' in grown[0]);
assert.equal(grown.length, 1);
assert.ok('path' in grown[0], 'wall-aware paper is one canonical union path');
});
test('area of the room polygon is unchanged by thickness helpers', () => {