fix: preserve wall thickness transitions

Issue: #150
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-18 17:36:54 +03:00
parent fd0b52f3a9
commit 6cbf9fbdf0
11 changed files with 372 additions and 12 deletions
File diff suppressed because one or more lines are too long
+145
View File
@@ -0,0 +1,145 @@
/**
* #150: a production-scale Split must preserve the exact 10 cm → 0 cm
* exterior breakpoint in every consumer of canonical wall geometry.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const containsPath = (path, x, y) => !!path?.isPointInFill(new DOMPoint(x, y));
const containsD = (d, x, y) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d || '');
path.setAttribute('fill-rule', 'evenodd');
svg.appendChild(path);
document.body.appendChild(svg);
const inside = containsPath(path, x, y);
svg.remove();
return inside;
};
const pointInRing = ([x, y], ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i], b = ring[j];
if ((a[1] > y) !== (b[1] > y)
&& x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside;
}
return inside;
};
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) =>
polygon?.length && pointInRing(point, polygon[0])
&& !polygon.slice(1).some((hole) => pointInRing(point, hole)));
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.rooms = [{
id: 'split-source', name: 'Source', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}];
delete space.walls;
delete space.openings;
delete space.open_spans;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
card._cfgEpoch++;
card._regSignature = '';
card._setMode('plan');
card.requestUpdate();
await settle();
// Exercise the real Plan handlers: select room, choose both wall endpoints,
// confirm the child, then apply thickness to all walls of the left child.
card._tool = 'split';
card._splitClick([300, 300]);
card._splitClick([500, 100]);
card._splitClick([500, 900]);
out.splitDialog = !!card._pendingSplit && !!card._roomDialog;
card._nameSel = 'Right';
card._saveRoom();
await settle();
const rooms = card._spaceModel().rooms;
const left = rooms.reduce((best, room) => {
const meanX = room.poly.reduce((sum, point) => sum + point[0], 0) / room.poly.length;
return !best || meanX < best.meanX ? { room, meanX } : best;
}, null)?.room;
out.splitCommitted = rooms.length === 2 && !!left;
card._tool = 'wallthick';
card._wallThickClick([300, 100]);
out.leftWallSelected = card._wallDialog?.roomId === left?.id;
card._wallDialog = { ...card._wallDialog, value: '10' };
card._wallThickApply(true);
await settle();
out.savedIntervals = card._intervalCm([100, 100, 500, 100]) === 10
&& card._intervalCm([500, 100, 900, 100]) === 0
&& card._intervalCm([500, 100, 500, 900]) === 10;
const half = (10 / card._cellCm) * card._gridPitch / 2;
const planPath = root().querySelector('[data-hp="wall"]');
const planD = planPath?.getAttribute('d') || '';
out.planFullDepth = containsPath(planPath, 300, 100 - half * 0.75)
&& containsPath(planPath, 300, 100 + half * 0.75);
out.planZeroSideClear = !containsPath(planPath, 700, 96)
&& !containsPath(planPath, 700, 104);
const canonical = card._wallUnionGeometry();
out.paperZeroSideClear = !!canonical?.paperD && !containsD(canonical.paperD, 700, 96);
card._setMode('view');
await settle();
const viewPath = root().querySelector('[data-hp="wall"]');
const viewD = viewPath?.getAttribute('d') || '';
out.viewMatchesPlan = !!planD && viewD === planD
&& !containsPath(viewPath, 700, 96) && containsPath(viewPath, 300, 96);
const model = card._spaceModel();
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
out.lightUsesSteppedMasonry = pointInGeometry([300, 96], barriers.masonryGeometry)
&& !pointInGeometry([700, 96], barriers.masonryGeometry);
await customElements.whenDefined('houseplan-space-card');
const staticCfg = JSON.parse(JSON.stringify(card._serverCfg));
const baseCall = card.hass.callWS.bind(card.hass);
const staticHass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: staticCfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: card._layout || {}, rev: 1 };
return baseCall(message);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: card._space, show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]') && Date.now() - started < 6000)
await new Promise((done) => setTimeout(done, 60));
await staticCard.updateComplete;
const staticPath = staticCard.renderRoot?.querySelector('[data-hp="wall"]');
out.staticMatchesPlan = staticPath?.getAttribute('d') === planD
&& !containsPath(staticPath, 700, 96) && containsPath(staticPath, 300, 96);
staticCard.remove();
const active = Object.freeze(['iso']);
card._onLabsSnapshot({ active, space: '' });
window.__hpLabs = active;
card._setProjection('iso');
await settle();
const isoWalls = card._isoSource().build().walls;
out.hiddenIsoMatchesCanonical = !!root().querySelector('[data-hp="iso-walls"]')
&& pointInGeometry([300, 96], isoWalls) && !pointInGeometry([700, 96], isoWalls);
return out;
});
checkAll(result);
await finish(browser, result);
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -365,6 +365,13 @@ physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. Computed independent masonry paths are emitted by that same geometry pass. Computed independent
junction patches enter as extras only after room opening cuts, so an opening junction patches enter as extras only after room opening cuts, so an opening
cannot cut a coincident partition and room exterior authority remains intact. cannot cut a coincident partition and room exterior authority remains intact.
Before the exterior offset is built, each saved atomic endpoint splits its
containing collinear union edge. Offset changes are explicit butt steps at that
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
in render units and is divided by the current edge length before it is compared
with or used to de-duplicate normalized `t` fractions; this keeps the result
scale-independent and prevents one interval's depth from leaking into its
neighbour.
The full card retains the The full card retains the
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
cache guarded by a structural geometry fingerprint. This is computed render cache guarded by a structural geometry fingerprint. This is computed render
+5
View File
@@ -2,6 +2,11 @@
## Unreleased ## Unreleased
- Collinear exterior walls now change thickness exactly at their saved
breakpoint. After splitting a room, a 10 cm wall therefore keeps its full
depth up to the divider without leaking onto an adjacent zero-thickness
facade; Plan, View, static, hidden isometric and light geometry share the
corrected outline ([#150](https://github.com/Matysh/houseplan-card/issues/150)).
- Home Assistant devices and individual entities manually assigned to a room - Home Assistant devices and individual entities manually assigned to a room
without an HA Area now stay in that exact room instead of returning to their without an HA Area now stay in that exact room instead of returning to their
registry Area. Existing saved assignments recover automatically, and registry Area. Existing saved assignments recover automatically, and
+6
View File
@@ -8,6 +8,12 @@
## Unreleased ## Unreleased
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
View, статическая и скрытая изометрическая геометрия, а также свет используют
один исправленный контур
([#150](https://github.com/Matysh/houseplan-card/issues/150)).
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате - Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
+6
View File
@@ -1981,6 +1981,12 @@ require hands on real hardware — they remain for the human pass.
path appears; room-card and tooltip m² both decrease to the same inner- path appears; room-card and tooltip m² both decrease to the same inner-
contour area contour area
[auto: smoke_wall_thickness] [auto: smoke_wall_thickness]
- [ ] **Exact thickness transition after Split**: split one zero-thickness room,
apply 10 cm to every wall of one child and keep the other child at zero.
Both halves of the 10 cm facade end at the divider endpoint; no hatch,
paper or light masonry continues along the zero side. Plan, View, static
and hidden Iso consume the same stepped geometry
[auto: smoke_wall_thickness_transition + test/wall-thickness.test.mjs]
- [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in - [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in
the body; the door swing is offset toward the inner face and gate leaves the body; the door swing is offset toward the inner face and gate leaves
toward the exterior face; with toward the exterior face; with
+15 -2
View File
@@ -53,6 +53,15 @@ 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 live HA state ticks do not repeat the boolean topology. Saved room and wall data
is not migrated or rewritten. is not migrated or rewritten.
Every endpoint of an exterior atomic interval is materialised on a collinear
boolean-union edge before offsetting. Unequal neighbouring half-depths form a
hard butt step at that exact endpoint on both the inner and outer faces; the
larger depth is never tapered or extended over the smaller/zero interval.
Geometry tolerances are render-space distances and are converted to a local
dimensionless edge fraction before breakpoint comparison or de-duplication.
This preserves the same `0 ↔ h` and `h1 ↔ h2` transition at normalized and
production (`coordScale = 1000`) scales.
## 3. Body render ## 3. Body render
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
@@ -153,7 +162,9 @@ room; nested-room tie; partially out-of-span legacy opening; overlapping
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
clipping and room-side colour ownership; whole and clipping and room-side colour ownership; whole and
atomic rekey after edge/scale; corner Split exterior equality across atomic rekey after edge/scale; corner Split exterior equality across
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints. 0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints;
production-scale `0 ↔ 10`, `10 ↔ 20` and `1 ↔ 100` collinear transitions at
their exact endpoint.
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial 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; 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 the virtual rubber band paints above the real body; sun starts at the room-side
@@ -162,7 +173,9 @@ 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 door/window/gate tunnels repeat outer/shared room fills without an axis seam
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in (`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
Plan/View/kiosk/static/isometric surfaces and the light barrier Plan/View/kiosk/static/isometric surfaces and the light barrier
(`demo/smoke_split_corner_wall.mjs`). (`demo/smoke_split_corner_wall.mjs`); a Split followed by all-room thickness
keeps the neighbouring zero facade clear across Plan, View, static, hidden Iso
and light masonry (`demo/smoke_wall_thickness_transition.mjs`).
## 9. Independent partitions, drafts and columns ## 9. Independent partitions, drafts and columns
+11 -3
View File
@@ -1384,7 +1384,8 @@ function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boo
const dx = b[0] - a[0], dy = b[1] - a[1]; const dx = b[0] - a[0], dy = b[1] - a[1];
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy; const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
const len2 = dx * dx + dy * dy; const len2 = dx * dx + dy * dy;
return dot >= -eps && dot <= len2 + eps; const projectedEps = eps * Math.sqrt(len2);
return dot >= -projectedEps && dot <= len2 + projectedEps;
} }
/** /**
@@ -1404,16 +1405,23 @@ function exteriorBoundaryProfile(
const dx = b[0] - a[0], dy = b[1] - a[1]; const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy; const len2 = dx * dx + dy * dy;
if (!(len2 > eps * eps)) continue; if (!(len2 > eps * eps)) continue;
// `t` below is a dimensionless fraction of this edge, while `eps` is a
// render-space distance. Comparing them directly drops every interior cut
// on production-scale plans (for example eps ~= 0.67 at coordScale=1000).
// Convert the shared geometry tolerance to the edge's local 0..1 domain.
const tEps = eps / Math.sqrt(len2);
const cuts = [0, 1]; const cuts = [0, 1];
for (const iv of outer) { for (const iv of outer) {
for (const p of [iv.a, iv.b]) { for (const p of [iv.a, iv.b]) {
if (!pointOnSegment(p, a, b, eps)) continue; if (!pointOnSegment(p, a, b, eps)) continue;
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
if (t > eps && t < 1 - eps) cuts.push(t); if (t > tEps && t < 1 - tEps) cuts.push(t);
} }
} }
cuts.sort((x, y) => x - y); cuts.sort((x, y) => x - y);
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps); const unique = cuts.filter(
(t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > tEps,
);
for (let at = 0; at < unique.length - 1; at++) { for (let at = 0; at < unique.length - 1; at++) {
const t0 = unique[at], t1 = unique[at + 1]; const t0 = unique[at], t1 = unique[at + 1];
const p = [a[0] + dx * t0, a[1] + dy * t0]; const p = [a[0] + dx * t0, a[1] + dy * t0];
+171 -1
View File
@@ -19,7 +19,7 @@ import {
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } 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 { GRID_PITCH } from '../test-build/space-geometry.js';
import { geometryArea } from '../test-build/physical-geometry.js'; import { geometryArea } from '../test-build/physical-geometry.js';
import { difference, union } from 'polyclip-ts'; import { difference, intersection, union } from 'polyclip-ts';
const closeTo = (got, want, tol = 1e-6) => const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`); assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
@@ -55,6 +55,22 @@ test('Stage floor footprint excludes detached independent physical bodies', () =
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b)); const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
const geometryProbeCoverage = (geom, [x, y], radius = 0.05) => {
const probe = closedGeometry([
[x - radius, y - radius],
[x + radius, y - radius],
[x + radius, y + radius],
[x - radius, y + radius],
]);
return geometryArea(intersection(geom, probe)) / ((radius * 2) ** 2);
};
const assertProbeInside = (geom, point, message) =>
assert.ok(geometryProbeCoverage(geom, point) > 0.99, message || `missing body at ${point}`);
const assertProbeOutside = (geom, point, message) =>
assert.ok(geometryProbeCoverage(geom, point) < 1e-7, message || `unexpected body at ${point}`);
function cornerSplitFixture({ function cornerSplitFixture({
poly = [[100, 100], [900, 100], [900, 700], [100, 700]], poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
path = [[100, 100], [900, 500]], path = [[100, 100], [900, 500]],
@@ -91,6 +107,20 @@ function cornerSplitFixture({
return { original, rooms, walls, before, after }; return { original, rooms, walls, before, after };
} }
function splitThicknessTransitionFixture() {
const scale = 1000;
const rooms = [
{ id: 'left', poly: [[100, 100], [500, 100], [500, 900], [100, 900]] },
{ id: 'right', poly: [[500, 100], [900, 100], [900, 900], [500, 900]] },
];
const walls = setWallThicknessForRoom([], rooms, 'left', 10, pitch, [], scale);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry, 'production-scale split fixture must produce wall geometry');
return { scale, rooms, walls, geometry };
}
// ------------------------------- key ---------------------------------------- // ------------------------------- key ----------------------------------------
test('wallKey is the same from either end of the wall', () => { test('wallKey is the same from either end of the wall', () => {
@@ -755,6 +785,146 @@ test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () =
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d); assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
}); });
test('production-scale Split keeps the 10 → 0 facade transition at the divider', () => {
const { scale, rooms, walls, geometry } = splitThicknessTransitionFixture();
const intervals = wallIntervals(
rooms, walls, [], pitch, cellCm, GRID_PITCH, scale,
);
const top = intervals.filter((iv) =>
iv.kind === 'outer' && Math.abs(iv.a[1] - 100) < 1e-7
&& Math.abs(iv.b[1] - 100) < 1e-7);
assert.deepEqual(top.map((iv) => [iv.roomId, iv.cm]), [['left', 10], ['right', 0]]);
const divider = intervals.filter((iv) =>
iv.kind === 'shared' && Math.abs(iv.a[0] - 500) < 1e-7
&& Math.abs(iv.b[0] - 500) < 1e-7);
assert.equal(divider.length, 2);
assert.ok(divider.every((iv) => iv.cm === 10), 'one physical divider keeps 10 cm');
const half = wallCmToUnits(10, cellCm, GRID_PITCH) / 2;
assertProbeInside(geometry.geom, [300, 100 - half * 0.75], 'outer half is missing');
assertProbeInside(geometry.geom, [300, 100 + half * 0.75], 'inner half is missing');
assertProbeOutside(geometry.geom, [300, 100 - half - 0.2], 'wall exceeds 10 cm');
assertProbeOutside(geometry.geom, [300, 100 + half + 0.2], 'wall exceeds 10 cm');
assertProbeOutside(geometry.geom, [700, 96], '10 cm leaked along the zero facade');
assertProbeOutside(geometry.geom, [700, 104], 'zero facade gained an inward half-wall');
assertProbeOutside(geometry.paperGeom, [700, 96], 'paper leaked past the zero facade');
assertProbeInside(geometry.geom, [500 - half * 0.5, 300], 'left divider half is missing');
assertProbeInside(geometry.geom, [500 + half * 0.5, 300], 'right divider half is missing');
assertProbeOutside(geometry.geom, [500 + half * 0.5, 96], 'divider protrudes outside');
const points = geometry.geom.flat(2);
assert.ok(points.some(([x, y]) =>
Math.abs(x - 500) < 1e-7 && Math.abs(y - (100 - half)) < 1e-7),
'the outer transition face must start at the exact divider endpoint');
const leftFloor = innerContourForRoom(
rooms, 'left', walls, [], pitch, cellCm, GRID_PITCH, scale,
);
const rightFloor = innerContourForRoom(
rooms, 'right', walls, [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(leftFloor && rightFloor);
assertProbeOutside(closedGeometry(leftFloor), [300, 102], 'left floor covers its wall');
assertProbeInside(closedGeometry(leftFloor), [300, 106], 'left clean floor starts too late');
assertProbeInside(closedGeometry(rightFloor), [700, 102], 'zero side lost clean floor');
const before = JSON.stringify({ rooms, walls });
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale));
assert.equal(JSON.stringify({ rooms, walls }), before, 'rendering must not migrate saved config');
});
test('production-scale collinear transitions keep both local depths in either direction', () => {
const scale = 1000;
const room = { id: 'room', poly: [[100, 100], [900, 100], [900, 900], [100, 900]] };
const make = (firstCm, secondCm, poly = room.poly) => {
let walls = [];
if (firstCm > 0)
walls = setWallThickness(walls, [100, 100], [500, 100], firstCm, pitch, scale);
if (secondCm > 0)
walls = setWallThickness(walls, [500, 100], [900, 100], secondCm, pitch, scale);
const geometry = wallBodiesGeometry(
[{ id: 'room', poly }], walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry, `missing geometry for ${firstCm} → ${secondCm}`);
return { geometry, walls };
};
const assertLocalDepth = (geometry, x, cm, label) => {
if (cm === 0) {
assertProbeOutside(geometry.geom, [x, 99], `${label}: zero outer side is solid`);
assertProbeOutside(geometry.geom, [x, 101], `${label}: zero inner side is solid`);
return;
}
const half = wallCmToUnits(cm, cellCm, GRID_PITCH) / 2;
assertProbeInside(geometry.geom, [x, 100 - half * 0.75], `${label}: outer half missing`);
assertProbeInside(geometry.geom, [x, 100 + half * 0.75], `${label}: inner half missing`);
assertProbeOutside(geometry.geom, [x, 100 - half - 0.2], `${label}: outer depth too large`);
assertProbeOutside(geometry.geom, [x, 100 + half + 0.2], `${label}: inner depth too large`);
};
for (const [firstCm, secondCm] of [
[0, 10], [10, 0], [10, 20], [20, 10], [1, 100], [100, 1], [10, 10],
]) {
const { geometry } = make(firstCm, secondCm);
assertLocalDepth(geometry, 300, firstCm, `${firstCm} → ${secondCm}, first`);
assertLocalDepth(geometry, 700, secondCm, `${firstCm} → ${secondCm}, second`);
if (firstCm !== secondCm) {
const points = geometry.geom.flat(2);
for (const cm of new Set([firstCm, secondCm])) {
if (!(cm > 0)) continue;
const half = wallCmToUnits(cm, cellCm, GRID_PITCH) / 2;
for (const y of [100 - half, 100 + half])
assert.ok(points.some(([x0, y0]) =>
Math.abs(x0 - 500) < 1e-7 && Math.abs(y0 - y) < 1e-7),
`${firstCm} → ${secondCm}: missing exact transition vertex at 500,${y}`);
}
}
}
const splitEqual = make(10, 10).geometry;
let wholeWalls = setWallThickness([], [100, 100], [900, 100], 10, pitch, scale);
const whole = wallBodiesGeometry(
[room], wholeWalls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(whole);
closeTo(geometryDifferenceArea(splitEqual.geom, whole.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(whole.geom, splitEqual.geom), 0, 1e-7);
const ordered = make(10, 20).geometry;
const reversed = make(10, 20, [...room.poly].reverse()).geometry;
closeTo(geometryDifferenceArea(ordered.geom, reversed.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(reversed.geom, ordered.geom), 0, 1e-7);
});
test('production-scale 45° facade keeps an exact unequal-thickness breakpoint', () => {
const scale = 1000;
const room = {
id: 'diagonal',
poly: [[200, 100], [800, 700], [600, 900], [0, 300]],
};
const transition = [500, 400];
let walls = setWallThickness([], room.poly[0], transition, 10, pitch, scale);
walls = setWallThickness(walls, transition, room.poly[1], 20, pitch, scale);
const geometry = wallBodiesGeometry(
[room], walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry);
const normal = inwardNormal(room.poly, 0);
const points = geometry.geom.flat(2);
for (const cm of [10, 20]) {
const half = wallCmToUnits(cm, cellCm, GRID_PITCH) / 2;
for (const side of [-1, 1]) {
const expected = [
transition[0] + normal[0] * half * side,
transition[1] + normal[1] * half * side,
];
assert.ok(points.some(([x, y]) =>
Math.hypot(x - expected[0], y - expected[1]) < 1e-7),
`missing 45° transition vertex ${expected}`);
}
}
});
test('corner Split keeps the original exterior wall body and paper', () => { test('corner Split keeps the original exterior wall body and paper', () => {
const { original, rooms, walls, before, after } = cornerSplitFixture(); const { original, rooms, walls, before, after } = cornerSplitFixture();
assert.ok(before); assert.ok(before);