mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +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
|
||||
junction patches enter as extras only after room opening cuts, so an opening
|
||||
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
|
||||
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
|
||||
cache guarded by a structural geometry fingerprint. This is computed render
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## 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
|
||||
without an HA Area now stay in that exact room instead of returning to their
|
||||
registry Area. Existing saved assignments recover automatically, and
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
|
||||
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
|
||||
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
|
||||
View, статическая и скрытая изометрическая геометрия, а также свет используют
|
||||
один исправленный контур
|
||||
([#150](https://github.com/Matysh/houseplan-card/issues/150)).
|
||||
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
|
||||
без 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-
|
||||
contour area
|
||||
[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
|
||||
the body; the door swing is offset toward the inner face and gate leaves
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
clipping and room-side colour ownership; whole and
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
(`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`).
|
||||
(`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
|
||||
|
||||
|
||||
+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 dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * 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 len2 = dx * dx + dy * dy;
|
||||
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];
|
||||
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);
|
||||
if (t > tEps && t < 1 - tEps) cuts.push(t);
|
||||
}
|
||||
}
|
||||
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++) {
|
||||
const t0 = unique[at], t1 = unique[at + 1];
|
||||
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 { GRID_PITCH } from '../test-build/space-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) =>
|
||||
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 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({
|
||||
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
|
||||
path = [[100, 100], [900, 500]],
|
||||
@@ -91,6 +107,20 @@ function cornerSplitFixture({
|
||||
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 ----------------------------------------
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { original, rooms, walls, before, after } = cornerSplitFixture();
|
||||
assert.ok(before);
|
||||
|
||||
Reference in New Issue
Block a user