mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
|
||||||
|
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
|
||||||
|
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
|
||||||
|
View, статическая и скрытая изометрическая геометрия, а также свет используют
|
||||||
|
один исправленный контур
|
||||||
|
([#150](https://github.com/Matysh/houseplan-card/issues/150)).
|
||||||
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
|
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
|
||||||
без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
|
без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
|
||||||
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
|
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user