mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: preserve orthogonal wall strips at multi-wall joins
Issue: #275 User-Visible: yes
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "cell-5-mixed-depth-t",
|
||||
"cell_cm": 5,
|
||||
"nodes": [[0.204166667, 0.645833333]],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "a",
|
||||
"poly": [
|
||||
[0.204166667, 0.645833333],
|
||||
[0.204166667, 0.4375],
|
||||
[0.420833333, 0.4375],
|
||||
[0.420833333, 0.645833333]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"poly": [
|
||||
[0.070833333, 0.866666667],
|
||||
[0.070833333, 0.645833333],
|
||||
[0.2375, 0.645833333],
|
||||
[0.2375, 0.866666667]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"poly": [
|
||||
[0.070833333, 0.645833333],
|
||||
[0.070833333, 0.4375],
|
||||
[0.204166667, 0.4375],
|
||||
[0.204166667, 0.645833333]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{ "key": "0.312500,0.645833@0.0000", "a": [0.420833333, 0.645833333], "b": [0.204166667, 0.645833333], "cm": 20 },
|
||||
{ "key": "0.204167,0.541667@1.5706", "a": [0.204166667, 0.645833333], "b": [0.204166667, 0.4375], "cm": 20 },
|
||||
{ "key": "0.154167,0.645833@0.0000", "a": [0.070833333, 0.645833333], "b": [0.2375, 0.645833333], "cm": 20 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cell-1-thick-crossbar-t",
|
||||
"cell_cm": 1,
|
||||
"nodes": [
|
||||
[-0.354166667, 2.0875],
|
||||
[-0.354166667, 2.2875]
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "a",
|
||||
"poly": [
|
||||
[-1.670833333, 2.95],
|
||||
[-1.670833333, 2.0875],
|
||||
[-0.354166667, 2.0875],
|
||||
[-0.354166667, 2.954166667]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"poly": [
|
||||
[-1.670833333, 2.0875],
|
||||
[-1.670833333, 1.266666667],
|
||||
[-0.354166667, 1.266666667],
|
||||
[-0.354166667, 2.0875]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"poly": [
|
||||
[-0.354166667, 2.0875],
|
||||
[-0.354166667, 1.266666667],
|
||||
[0.4, 1.266666667],
|
||||
[2.404166667, 1.266666667],
|
||||
[2.404166667, 1.245833333],
|
||||
[2.904166667, 1.245833333],
|
||||
[2.904166667, 2.308333333],
|
||||
[2.404166667, 2.308333333],
|
||||
[2.404166667, 2.2875],
|
||||
[1.058333333, 2.2875],
|
||||
[-0.354166667, 2.2875]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{ "key": "-1.012500,2.087500@0.0000", "a": [-1.670833333, 2.0875], "b": [-0.354166667, 2.0875], "cm": 40 },
|
||||
{ "key": "-0.354167,2.520833@1.5706", "a": [-0.354166667, 2.0875], "b": [-0.354166667, 2.954166667], "cm": 20 },
|
||||
{ "key": "-0.354167,1.679167@1.5706", "a": [-0.354166667, 1.266666667], "b": [-0.354166667, 2.0875], "cm": 20 },
|
||||
{ "key": "0.354167,2.287500@0.0000", "a": [-0.354166667, 2.2875], "b": [1.058333333, 2.2875], "cm": 20 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -322,7 +322,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, 40);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 41);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -419,6 +419,28 @@ test('every multi-wall golden scene declares its enclosed-hole inventory (#272)'
|
||||
}
|
||||
});
|
||||
|
||||
test('issue #275 golden preflight samples protected strips, not just enclosed holes', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.orthogonalStripContainment);
|
||||
assert.deepEqual(scenarios.map((item) => item.id), [
|
||||
'orthogonal-strip-cell-5-view-dark',
|
||||
'orthogonal-strip-cell-1-view-dark',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
scenarios.map((item) => item.orthogonalStripContainment.caseId),
|
||||
['cell-5-mixed-depth-t', 'cell-1-thick-crossbar-t'],
|
||||
);
|
||||
for (const scenario of scenarios) {
|
||||
assert.equal(scenario.mode, 'view');
|
||||
assert.ok(scenario.orthogonalStripContainment.minSamples >= 300);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.ok(space);
|
||||
assert.ok(space.nodes.length >= 1);
|
||||
assert.ok(space.walls.length >= 3);
|
||||
assert.equal(space.settings.show_borders, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue #258 golden renders the affected persisted key at its T-junction', () => {
|
||||
const scenario = GOLDEN_SCENARIOS.find(
|
||||
(item) => item.id === 'wall-key-roundtrip-view-dark',
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
|
||||
MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles,
|
||||
MULTI_WALL_ORTHOGONAL_DOT_EPSILON, multiWallProtectedRayIndexes,
|
||||
multiWallProtectedStripGeometry,
|
||||
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
|
||||
normalizeWallIntervals,
|
||||
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, openingTunnelGeometry,
|
||||
@@ -92,6 +94,42 @@ const assertNoEnclosedLocalHoles = (geometry, node, consumer) => assert.equal(
|
||||
`${consumer} retained an enclosed background hole at ${node.point}`,
|
||||
);
|
||||
|
||||
const protectedOrthogonalStripGeometry = (node, map) => {
|
||||
const protectedRays = new Set();
|
||||
for (let i = 0; i < node.rays.length; i++) {
|
||||
for (let j = i + 1; j < node.rays.length; j++) {
|
||||
const a = node.rays[i].u, b = node.rays[j].u;
|
||||
if (Math.abs(a[0] * b[0] + a[1] * b[1]) <= 1e-9) {
|
||||
protectedRays.add(i);
|
||||
protectedRays.add(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
const radius = MITRE_LIMIT * node.halfDepth + map.epsilon * 2;
|
||||
const extent = radius * 2;
|
||||
let geometry = null;
|
||||
for (const index of protectedRays) {
|
||||
const ray = node.rays[index];
|
||||
const n = [-ray.u[1], ray.u[0]];
|
||||
for (const support of ray.supports) {
|
||||
const length = Math.min(extent, support.length);
|
||||
if (!(length > map.epsilon)) continue;
|
||||
const rectangle = closedGeometry([
|
||||
[node.point[0] + n[0] * support.halfDepth,
|
||||
node.point[1] + n[1] * support.halfDepth],
|
||||
[node.point[0] + ray.u[0] * length + n[0] * support.halfDepth,
|
||||
node.point[1] + ray.u[1] * length + n[1] * support.halfDepth],
|
||||
[node.point[0] + ray.u[0] * length - n[0] * support.halfDepth,
|
||||
node.point[1] + ray.u[1] * length - n[1] * support.halfDepth],
|
||||
[node.point[0] - n[0] * support.halfDepth,
|
||||
node.point[1] - n[1] * support.halfDepth],
|
||||
]);
|
||||
geometry = geometry ? union(geometry, rectangle) : rectangle;
|
||||
}
|
||||
}
|
||||
return { geometry, protectedRays: [...protectedRays].sort((a, b) => a - b) };
|
||||
};
|
||||
|
||||
const assertBoundedMultiWallBevels = (
|
||||
rooms, walls, geometry, cell = cellCm, scale = 1,
|
||||
) => {
|
||||
@@ -113,10 +151,26 @@ const assertBoundedMultiWallBevels = (
|
||||
(triangle[0][0] + triangle[1][0]) / 2,
|
||||
(triangle[0][1] + triangle[1][1]) / 2,
|
||||
];
|
||||
assertProbeOutside(geometry.geom, [
|
||||
const probe = [
|
||||
(base[0] + triangle[2][0]) / 2,
|
||||
(base[1] + triangle[2][1]) / 2,
|
||||
], 'an excessive multi-wall wedge remains filled');
|
||||
];
|
||||
const protectedStrips = multiWallProtectedStripGeometry(node, map);
|
||||
const protectedCoverage = protectedStrips
|
||||
? geometryProbeCoverage(protectedStrips, probe)
|
||||
: 0;
|
||||
const actualCoverage = geometryProbeCoverage(geometry.geom, probe);
|
||||
if (protectedCoverage > 1e-7) {
|
||||
assert.ok(
|
||||
actualCoverage + 1e-7 >= protectedCoverage,
|
||||
`a bevel removed protected orthogonal material at ${probe}`,
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
actualCoverage < 1e-7,
|
||||
`an unprotected excessive multi-wall wedge remains filled at ${probe}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
@@ -794,6 +848,11 @@ test('issue #249 bounds the exported three-wall junction with straight bevels',
|
||||
) < 1e-6);
|
||||
assert.ok(node);
|
||||
assert.equal(node.rays.length, 3);
|
||||
assert.deepEqual(
|
||||
multiWallProtectedRayIndexes(node),
|
||||
[],
|
||||
'the approved non-orthogonal #249 join must not gain protected strips',
|
||||
);
|
||||
closeTo(node.halfDepth, 4.861111111111112, 1e-9);
|
||||
closeTo(node.limit, MULTI_WALL_JOIN_LIMIT * node.halfDepth, 1e-9);
|
||||
|
||||
@@ -854,6 +913,119 @@ test('issue #249 bounds the exported three-wall junction with straight bevels',
|
||||
assert.equal(JSON.stringify({ rooms, walls }), before, 'geometry mutated saved data');
|
||||
});
|
||||
|
||||
test('issue #275 preserves every finite strip participating in an orthogonal T join', () => {
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/275-orthogonal-strip-containment.json', import.meta.url), 'utf8',
|
||||
));
|
||||
for (const item of fixture.cases) {
|
||||
const rooms = item.rooms.map((room) => ({
|
||||
...room,
|
||||
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
||||
}));
|
||||
let walls = [];
|
||||
for (const wall of item.walls) {
|
||||
walls = setWallThickness(
|
||||
walls,
|
||||
wall.a.map((value) => value * NORM_W),
|
||||
wall.b.map((value) => value * NORM_W),
|
||||
wall.cm,
|
||||
pitch,
|
||||
NORM_W,
|
||||
);
|
||||
}
|
||||
const map = buildMultiWallNodeMap(
|
||||
wallIntervals(rooms, walls, [], pitch, item.cell_cm, GRID_PITCH, NORM_W),
|
||||
pitch * NORM_W * 0.04 * 4,
|
||||
NORM_W,
|
||||
);
|
||||
const geometry = wallBodiesGeometry(
|
||||
rooms, walls, [], [], pitch, item.cell_cm, GRID_PITCH, NORM_W,
|
||||
);
|
||||
assert.ok(geometry, `${item.id}: production geometry failed`);
|
||||
for (const storedNode of item.nodes) {
|
||||
const expectedNode = storedNode.map((value) => value * NORM_W);
|
||||
const node = map.nodes.find((candidate) => Math.hypot(
|
||||
candidate.point[0] - expectedNode[0],
|
||||
candidate.point[1] - expectedNode[1],
|
||||
) <= map.epsilon);
|
||||
assert.ok(node, `${item.id}: exact backup node ${storedNode} disappeared`);
|
||||
const required = protectedOrthogonalStripGeometry(node, map);
|
||||
assert.equal(required.protectedRays.length, node.rays.length,
|
||||
`${item.id}: fixture no longer describes a pure orthogonal T join`);
|
||||
assert.deepEqual(
|
||||
multiWallProtectedRayIndexes(node),
|
||||
required.protectedRays,
|
||||
`${item.id}: product pair classification disagrees with the independent oracle`,
|
||||
);
|
||||
assert.ok(required.geometry, `${item.id}: no protected strip geometry`);
|
||||
const productProtected = multiWallProtectedStripGeometry(node, map);
|
||||
assert.ok(productProtected, `${item.id}: product protected geometry is empty`);
|
||||
closeTo(geometryDifferenceArea(required.geometry, productProtected), 0, 1e-6);
|
||||
closeTo(geometryDifferenceArea(productProtected, required.geometry), 0, 1e-6);
|
||||
closeTo(
|
||||
geometryDifferenceArea(required.geometry, geometry.roomGeom),
|
||||
0,
|
||||
1e-6,
|
||||
);
|
||||
closeTo(
|
||||
geometryDifferenceArea(required.geometry, geometry.geom),
|
||||
0,
|
||||
1e-6,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('issue #275 classifies orthogonal rays by pair, including mixed-node and epsilon bounds', () => {
|
||||
const ray = (degrees) => {
|
||||
const radians = degrees * Math.PI / 180;
|
||||
return {
|
||||
u: [Math.cos(radians), Math.sin(radians)],
|
||||
halfDepth: 5,
|
||||
length: 100,
|
||||
supports: [{ halfDepth: 5, length: 100 }],
|
||||
};
|
||||
};
|
||||
const node = (degrees) => ({
|
||||
point: [0, 0],
|
||||
rays: degrees.map(ray),
|
||||
halfDepth: 5,
|
||||
limit: 5 * MULTI_WALL_JOIN_LIMIT,
|
||||
});
|
||||
|
||||
assert.deepEqual(multiWallProtectedRayIndexes(node([0, 90, 180])), [0, 1, 2]);
|
||||
assert.deepEqual(
|
||||
multiWallProtectedRayIndexes(node([0, 45, 90, 180])),
|
||||
[0, 2, 3],
|
||||
'the diagonal ray must not disable protection of the orthogonal rays',
|
||||
);
|
||||
assert.deepEqual(multiWallProtectedRayIndexes(node([0, 30, 200])), []);
|
||||
|
||||
const epsilonNode = (dot) => ({
|
||||
point: [0, 0],
|
||||
rays: [
|
||||
{ ...ray(0), u: [1, 0] },
|
||||
{ ...ray(90), u: [dot, Math.sqrt(1 - dot * dot)] },
|
||||
ray(210),
|
||||
],
|
||||
halfDepth: 5,
|
||||
limit: 5 * MULTI_WALL_JOIN_LIMIT,
|
||||
});
|
||||
assert.deepEqual(
|
||||
multiWallProtectedRayIndexes(
|
||||
epsilonNode(MULTI_WALL_ORTHOGONAL_DOT_EPSILON * 0.5),
|
||||
),
|
||||
[0, 1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
multiWallProtectedRayIndexes(
|
||||
epsilonNode(MULTI_WALL_ORTHOGONAL_DOT_EPSILON * 2),
|
||||
),
|
||||
[],
|
||||
'a physically diagonal ray beyond normalization noise must stay unprotected',
|
||||
);
|
||||
});
|
||||
|
||||
test('issue #271 keeps finite co-directional ray supports and never rebuilds past an endpoint', () => {
|
||||
const interval = (key, b, half) => ({
|
||||
roomId: key, a: [0, 0], b, key, kind: 'outer', cm: half * 2,
|
||||
@@ -1577,14 +1749,14 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
|
||||
`room ${room.id} clean floor leaked into the retained T-junction wedge`,
|
||||
);
|
||||
}
|
||||
// #249 intentionally bevels degree-3+ nodes in this older fixture too.
|
||||
// #249 retains the physical multi-wall overlap up to R instead of reducing
|
||||
// right-angle arms to point contacts.
|
||||
// #249 intentionally bevels unprotected degree-3+ sectors in this older
|
||||
// fixture too. #275 additionally retains every finite strip that has an
|
||||
// orthogonal partner instead of letting the same bevel create an open notch.
|
||||
// #271 removes only the area that the old node-wide 8H rectangles invented
|
||||
// after finite ray endpoints; all semantic #197/#249/#261 probes above stay.
|
||||
// #272 additionally opens any point-contact bevel cut to the exterior.
|
||||
closeTo(geometryArea(geometry.geom), 124243.7732289006, 1e-6);
|
||||
closeTo(geometryArea(geometry.paperGeom), 727303.8153386558, 1e-6);
|
||||
closeTo(geometryArea(geometry.geom), 124534.6091222676, 1e-6);
|
||||
closeTo(geometryArea(geometry.paperGeom), 727303.8194444444, 1e-6);
|
||||
assert.equal(
|
||||
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
|
||||
'rendering mutated persisted input',
|
||||
|
||||
Reference in New Issue
Block a user