mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
committed by
claude[bot]
parent
a9d999e2f2
commit
df093c7e51
File diff suppressed because one or more lines are too long
+29
-1
@@ -55,8 +55,36 @@ export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario);
|
||||
if (scenario.cornerSplitWall) {
|
||||
const stage = scenario.cornerSplitWall;
|
||||
if (!['before', 'thin', 'thick'].includes(stage))
|
||||
if (!['before', 'thin', 'thick', 'zero-taper'].includes(stage))
|
||||
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
|
||||
if (stage === 'zero-taper') {
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.405];
|
||||
const br = [0.90, 0.80], notchBottom = [0.60, 0.80];
|
||||
const notch = [0.60, 0.40], bl = [0.10, 0.40];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
name: 'Zero-depth angled Split',
|
||||
rooms: [
|
||||
{ id: 'zero-divider-main', name: 'Main room', area: null,
|
||||
poly: [a, tr, split, notch, bl] },
|
||||
{ id: 'zero-divider-child', name: 'New room', area: null,
|
||||
poly: [split, br, notchBottom, notch] },
|
||||
],
|
||||
walls: [
|
||||
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
|
||||
entry(br, notchBottom, 15), entry(notchBottom, notch, 15),
|
||||
entry(notch, bl, 15), entry(bl, a, 15),
|
||||
],
|
||||
settings: {
|
||||
show_borders: true, show_names: false,
|
||||
fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 },
|
||||
},
|
||||
});
|
||||
return fixture;
|
||||
}
|
||||
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) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 24;
|
||||
export const GOLDEN_MATRIX_VERSION = 25;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -18,6 +18,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
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: 'split-zero-divider-taper-dark', fixture: 'visual', space: 'golden-zero-divider',
|
||||
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Issue #172: a slightly angled zero-depth Split stays free of masonry. */
|
||||
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 samePoint = (a, b, epsilon = 0.02) => (
|
||||
Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon
|
||||
);
|
||||
const sharedEdge = (rooms) => {
|
||||
for (let ai = 0; ai < rooms[0].poly.length; ai++) {
|
||||
const a0 = rooms[0].poly[ai], a1 = rooms[0].poly[(ai + 1) % rooms[0].poly.length];
|
||||
for (let bi = 0; bi < rooms[1].poly.length; bi++) {
|
||||
const b0 = rooms[1].poly[bi], b1 = rooms[1].poly[(bi + 1) % rooms[1].poly.length];
|
||||
if (samePoint(a0, b1) && samePoint(a1, b0)) return [a0[0], a0[1], a1[0], a1[1]];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const pointInRing = (point, 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] > point[1]) !== (b[1] > point[1])
|
||||
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0])
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) => (
|
||||
polygon?.[0] && pointInRing(point, polygon[0])
|
||||
&& !polygon.slice(1).some((hole) => pointInRing(point, hole))
|
||||
));
|
||||
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], br = [0.90, 0.80];
|
||||
const notchBottom = [0.60, 0.80], notch = [0.60, 0.40], bl = [0.10, 0.40];
|
||||
const outer = [
|
||||
entry(a, tr, 15), entry(tr, br, 15), entry(br, notchBottom, 15),
|
||||
entry(notchBottom, notch, 15), entry(notch, bl, 15), entry(bl, a, 15),
|
||||
];
|
||||
const space = sp();
|
||||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||||
space.rooms = [{
|
||||
id: 'zero-divider-source', name: 'Zero divider source', area: null,
|
||||
poly: [a, tr, br, notchBottom, notch, bl],
|
||||
}];
|
||||
space.walls = outer;
|
||||
delete space.open_spans;
|
||||
delete space.openings;
|
||||
delete space.partitions;
|
||||
delete space.room_drafts;
|
||||
delete space.wall_columns;
|
||||
c._setMode('plan');
|
||||
c._tool = 'split';
|
||||
await update();
|
||||
|
||||
c._splitClick([700, 250]);
|
||||
c._splitClick([600, 400]);
|
||||
c._splitClick([900, 405]);
|
||||
out.realSplitReachedDialog = !!c._pendingSplit && !!c._roomDialog;
|
||||
c._nameSel = 'Zero divider child';
|
||||
c._commitRoom();
|
||||
await update();
|
||||
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const divider = rooms.length === 2 ? sharedEdge(rooms) : null;
|
||||
out.realSplitSaved = rooms.length === 2 && !c._pendingSplit;
|
||||
out.dividerFound = !!divider;
|
||||
out.anglePreserved = !!divider && Math.abs(divider[3] - divider[1]) > 1;
|
||||
out.dividerStaysZero = !!divider && c._intervalCm(divider) === 0;
|
||||
|
||||
const samples = [];
|
||||
if (divider) {
|
||||
const [x0, y0, x1, y1] = divider;
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const nx = -dy / length, ny = dx / length;
|
||||
for (const t of [0.2, 0.35, 0.5, 0.65, 0.8]) {
|
||||
for (const side of [-1, 1])
|
||||
samples.push([x0 + dx * t + nx * side * 2, y0 + dy * t + ny * side * 2]);
|
||||
}
|
||||
}
|
||||
const wall = sr().querySelector('.wallbody');
|
||||
out.planHasNoTaper = !!wall && samples.length > 0
|
||||
&& samples.every((point) => !wall.isPointInFill(new DOMPoint(...point)));
|
||||
const planD = wall?.getAttribute('d') || '';
|
||||
out.planUsesCanonicalBody = !!planD && c._wallUnionGeometry()?.d === planD;
|
||||
|
||||
const lightSpace = c._spaceModel();
|
||||
const lightPolys = lightSpace.rooms
|
||||
.filter((room) => Array.isArray(room.poly))
|
||||
.map((room) => ({ r: room, poly: room.poly }));
|
||||
const lightPhysical = c._physicalBodiesR(lightSpace);
|
||||
const masonry = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
|
||||
out.lightHasNoTaper = samples.every((point) => !pointInGeometry(point, masonry));
|
||||
|
||||
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
|
||||
c._setMode('view');
|
||||
await update();
|
||||
out.planViewParity = sr().querySelector('.wallbody')?.getAttribute('d') === 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'));
|
||||
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
|
||||
&& c._isoSource().build().walls.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
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -7,6 +7,11 @@
|
||||
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)).
|
||||
- A zero-thickness Split divider no longer borrows a triangular wedge from an
|
||||
adjoining thick wall when the cut is slightly angled. The divider stays
|
||||
visually open along its full length while the real wall ends in a local cap,
|
||||
with the same geometry in Plan, View, Static, hidden isometric and lighting
|
||||
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
|
||||
- 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
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
View, статическая и скрытая изометрическая геометрия, а также свет используют
|
||||
один исправленный контур
|
||||
([#150](https://github.com/Matysh/houseplan-card/issues/150)).
|
||||
- Нулевой разделитель после «Разделить» больше не получает треугольный клин от
|
||||
примыкающей толстой стены, если разрез нарисован под небольшим углом.
|
||||
Разделитель остаётся визуально открытым по всей длине, а настоящая стена
|
||||
заканчивается локальным торцом одинаково в Плане, View, Static, скрытой
|
||||
изометрии и световой геометрии
|
||||
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
|
||||
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
|
||||
без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
|
||||
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
|
||||
|
||||
@@ -74,6 +74,14 @@ thinner than 3 CSS px on screen, the shared full/static render policy suppresses
|
||||
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
|
||||
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
|
||||
|
||||
A variable-offset join where exactly one adjacent edge has zero depth is a
|
||||
local flat cap, not a mitre. Both `inset` and `outset` retain the physical
|
||||
edge's offset point followed by the untouched zero-edge vertex (or the reverse
|
||||
order when entering the physical edge). This keeps a zero-depth Split free of
|
||||
masonry even when it meets a thick wall at a slightly non-collinear angle;
|
||||
the cap cannot stretch into a taper along the divider. Joins between two
|
||||
positive depths keep the bounded mitre/bevel contract above.
|
||||
|
||||
Independent draft/partition segments keep flat raw quads for editor identity,
|
||||
but exact endpoint↔endpoint and endpoint↔line nodes add computed join patches
|
||||
before the presentation union. Each incident ray keeps its own half-depth;
|
||||
|
||||
@@ -801,6 +801,21 @@ export function insetContour(poly: number[][], offsets: number[]): number[][] |
|
||||
continue;
|
||||
}
|
||||
|
||||
// #172: a physical edge meeting a zero-depth divider owns a square cap,
|
||||
// not a mitre into the divider. Keep both sides of that cap explicitly:
|
||||
// the offset point of the physical edge and the untouched vertex of the
|
||||
// zero edge. Letting the generic mitre/bevel path handle a near-collinear
|
||||
// join drops the untouched vertex and stretches the cap along the complete
|
||||
// divider as a triangular wall body.
|
||||
if ((oA > 0) !== (oB > 0)) {
|
||||
const v = poly[i];
|
||||
const pa = oA > 0 ? [v[0] + nAx * oA, v[1] + nAy * oA] : [v[0], v[1]];
|
||||
const pb = oB > 0 ? [v[0] + nBx * oB, v[1] + nBy * oB] : [v[0], v[1]];
|
||||
out.push(pa);
|
||||
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
|
||||
continue;
|
||||
}
|
||||
|
||||
// AUD-159B6-01: atomic intervals put COLLINEAR neighbours in one outline.
|
||||
// Two parallel offset lines never intersect, so the mitre branch below would
|
||||
// fall through to a bevel that skips the zero side and slants the wall face.
|
||||
@@ -1950,6 +1965,16 @@ export function outsetContour(poly: number[][], offsets: number[]): number[][] |
|
||||
out.push([poly[i][0], poly[i][1]]);
|
||||
continue;
|
||||
}
|
||||
// Mirror the inset contract above: one physical edge plus one zero-depth
|
||||
// edge is a local cap with both endpoints present in traversal order.
|
||||
if ((oA > 0) !== (oB > 0)) {
|
||||
const v = poly[i];
|
||||
const pa = oA > 0 ? [v[0] - nAx * oA, v[1] - nAy * oA] : [v[0], v[1]];
|
||||
const pb = oB > 0 ? [v[0] - nBx * oB, v[1] - nBy * oB] : [v[0], v[1]];
|
||||
out.push(pa);
|
||||
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
|
||||
continue;
|
||||
}
|
||||
if (collinearJoint(uA, uB)) {
|
||||
const v = poly[i];
|
||||
const pa = [v[0] - nAx * oA, v[1] - nAy * oA];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { prepareGoldenFixture } from '../demo/golden/harness.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
|
||||
import { fixtureWallKey } from '../demo/fixtures/visual-matrix.mjs';
|
||||
|
||||
test('golden matrix has stable unique ids and bounded comparison thresholds', () => {
|
||||
assert.equal(Number.isInteger(GOLDEN_MATRIX_VERSION) && GOLDEN_MATRIX_VERSION > 0, true);
|
||||
@@ -138,12 +139,26 @@ test('wall junction goldens cover live L/T previews plus saved flat and isometri
|
||||
|
||||
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']);
|
||||
assert.deepEqual(
|
||||
scenarios.map((scenario) => scenario.cornerSplitWall),
|
||||
['before', 'thin', 'thick', 'zero-taper'],
|
||||
);
|
||||
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);
|
||||
if (scenario.cornerSplitWall === 'zero-taper') {
|
||||
assert.equal(space.rooms.length, 2);
|
||||
assert.equal(space.rooms[0].poly.some((point) => point[1] === 0.405), true);
|
||||
assert.equal(
|
||||
space.walls.some((wall) => (
|
||||
wall.key === fixtureWallKey([0.60, 0.40], [0.90, 0.405])
|
||||
)),
|
||||
false,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert.equal(space.rooms.length, scenario.cornerSplitWall === 'before' ? 1 : 2);
|
||||
if (scenario.cornerSplitWall !== 'before') {
|
||||
const divider = space.walls.find((wall) => (
|
||||
@@ -194,7 +209,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 24);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 25);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
|
||||
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
|
||||
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
|
||||
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
|
||||
wallCmToUnits, insetContour, outsetContour, inwardNormal, edgeKinds, wallEdgeBodies,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
|
||||
innerContourForRoom,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
|
||||
@@ -104,7 +104,7 @@ function cornerSplitFixture({
|
||||
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 };
|
||||
return { original, rooms, walls, divider, before, after };
|
||||
}
|
||||
|
||||
function splitThicknessTransitionFixture() {
|
||||
@@ -424,6 +424,36 @@ test('insetContour: acute corner falls back to a bevel (no infinite spike)', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('variable-offset contours keep a local cap at angled positive-to-zero joins', () => {
|
||||
const poly = [[0, 0], [10, 0], [20, 0.1], [20, 10], [0, 10]];
|
||||
const vertex = poly[1];
|
||||
const hasPoint = (contour, point) => contour.some((candidate) => (
|
||||
Math.hypot(candidate[0] - point[0], candidate[1] - point[1]) <= 1e-9
|
||||
));
|
||||
|
||||
for (const offsets of [[2, 0, 0, 0, 0], [0, 2, 0, 0, 0]]) {
|
||||
const inset = insetContour(poly, offsets);
|
||||
const outset = outsetContour(poly, offsets);
|
||||
assert.ok(inset && outset);
|
||||
assert.ok(hasPoint(inset, vertex), `inset lost the zero-edge vertex: ${JSON.stringify(offsets)}`);
|
||||
assert.ok(hasPoint(outset, vertex), `outset lost the zero-edge vertex: ${JSON.stringify(offsets)}`);
|
||||
assert.ok(
|
||||
inset.some((point) => {
|
||||
const distance = Math.hypot(point[0] - vertex[0], point[1] - vertex[1]);
|
||||
return distance > 1 && distance < 3;
|
||||
}),
|
||||
'inset must also retain the physical edge offset point',
|
||||
);
|
||||
assert.ok(
|
||||
outset.some((point) => {
|
||||
const distance = Math.hypot(point[0] - vertex[0], point[1] - vertex[1]);
|
||||
return distance > 1 && distance < 3;
|
||||
}),
|
||||
'outset must also retain the physical edge offset point',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('inwardNormal points into the rectangle', () => {
|
||||
const poly = [[0, 0], [10, 0], [10, 6], [0, 6]];
|
||||
const [nx, ny] = inwardNormal(poly, 0); // bottom edge → should point +y
|
||||
@@ -976,6 +1006,59 @@ test('corner Split preserves the facade for thin and thick outer/divider matrice
|
||||
}
|
||||
});
|
||||
|
||||
test('near-collinear zero-depth Split divider never grows a masonry taper', () => {
|
||||
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
|
||||
const dividerStrip = (segment, halfWidth) => {
|
||||
const [x0, y0, x1, y1] = segment;
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const nx = -dy / length, ny = dx / length;
|
||||
const at = (t, side) => [
|
||||
x0 + dx * t + nx * halfWidth * side,
|
||||
y0 + dy * t + ny * halfWidth * side,
|
||||
];
|
||||
// Endpoint caps are physical. Inspect only the divider interior, far past
|
||||
// the maximum 100 cm half-depth used by this matrix.
|
||||
return [at(0.2, -1), at(0.8, -1), at(0.8, 1), at(0.2, 1)];
|
||||
};
|
||||
|
||||
let reference = null;
|
||||
for (const outerCm of [1, 15, 100]) {
|
||||
for (const deltaY of [-5, -2.5, 2.5, 5]) {
|
||||
const fixture = cornerSplitFixture({
|
||||
poly,
|
||||
path: [[600, 400], [900, 400 + deltaY]],
|
||||
outerCm,
|
||||
dividerCm: 0,
|
||||
});
|
||||
const shared = wallIntervals(
|
||||
fixture.rooms, fixture.walls, [], pitch, cellCm, GRID_PITCH,
|
||||
).filter((interval) => interval.kind === 'shared');
|
||||
assert.equal(shared.length, 2, `shared interval count at ${outerCm} cm / ${deltaY}`);
|
||||
assert.ok(shared.every((interval) => interval.cm === 0));
|
||||
|
||||
const segment = fixture.divider[0];
|
||||
const halfDepth = wallCmToUnits(outerCm, cellCm, GRID_PITCH) / 2;
|
||||
const strip = dividerStrip(segment, Math.max(0.25, halfDepth * 0.75));
|
||||
const overlap = geometryArea(intersection(fixture.after.geom, closedGeometry(strip)));
|
||||
closeTo(overlap, 0, 1e-7);
|
||||
|
||||
if (outerCm === 15 && deltaY === 2.5) reference = fixture;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(reference);
|
||||
const permutedRooms = reference.rooms
|
||||
.map((room, index) => ({ id: `zero-divider-${index}`, poly: [...room.poly].reverse() }))
|
||||
.reverse();
|
||||
const permuted = wallBodiesGeometry(
|
||||
permutedRooms, reference.walls, [], [], pitch, cellCm, GRID_PITCH,
|
||||
);
|
||||
assert.ok(permuted);
|
||||
closeTo(geometryDifferenceArea(reference.after.geom, permuted.geom), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(permuted.geom, reference.after.geom), 0, 1e-7);
|
||||
});
|
||||
|
||||
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
|
||||
const fixture = cornerSplitFixture({
|
||||
outerOverrides: [
|
||||
|
||||
Reference in New Issue
Block a user