fix: retain bounded T-junction masonry

Issue: #261
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 15:35:27 +03:00
parent 61b437fd3f
commit 0dc6c39784
17 changed files with 185 additions and 28 deletions
File diff suppressed because one or more lines are too long
+17
View File
@@ -151,6 +151,11 @@ export function prepareGoldenFixture(scenario) {
});
}
if (scenario.junctionPatchResilience) {
if (!Array.isArray(scenario.retainedWedgeProbe)
|| scenario.retainedWedgeProbe.length !== 2
|| !scenario.retainedWedgeProbe.every(Number.isFinite)) {
throw new Error(`invalid golden retainedWedgeProbe: ${scenario.id}`);
}
fixture.config.spaces.push({
...structuredClone(junctionPatchFixture),
id: scenario.space,
@@ -562,6 +567,18 @@ export async function prepareGoldenScenario(page, scenario) {
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
}
}
if (scenario.retainedWedgeProbe) {
const point = new DOMPoint(
scenario.retainedWedgeProbe[0] * 1000,
scenario.retainedWedgeProbe[1] * card._spaceH,
);
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
const papers = [...card.renderRoot.querySelectorAll('.hp-paper')];
if (!wall?.isPointInFill?.(point)
|| !papers.some((paper) => paper.isPointInFill?.(point))) {
throw new Error(`golden retained T-junction wedge contract failed: ${scenario.id}`);
}
}
if (scenario.wallKeyRoundtrip) {
const { node, incidentArm } = scenario.wallKeyRoundtrip;
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
+2
View File
@@ -262,9 +262,11 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'junction-patch-resilience-plan-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'plan',
retainedWedgeProbe: [0.8955, 0.556],
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
retainedWedgeProbe: [0.8955, 0.556],
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'multiwall-junction-bevel-view-dark', fixture: 'visual',
space: 'golden-multiwall-junction', mode: 'view',
+37 -1
View File
@@ -11,6 +11,22 @@ const result = await page.evaluate(async (spaceFixture) => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const retainedWedgeProbe = [895.5, 556];
const svgContains = (element, point = retainedWedgeProbe) =>
!!element?.isPointInFill?.(new DOMPoint(point[0], point[1]));
const ringContains = (ring, point) => {
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 geometryContains = (geometry, point = retainedWedgeProbe) =>
(geometry || []).some((polygon) => ringContains(polygon[0] || [], point)
&& !(polygon.slice(1) || []).some((hole) => ringContains(hole, point)));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
@@ -62,6 +78,9 @@ const result = await page.evaluate(async (spaceFixture) => {
const canonical = card._wallUnionGeometry();
out.planKeepsMasonry = !!planD && !!planPath && canonical?.d === planD;
out.paperKeepsFootprint = !!canonical?.paperD && !!root().querySelector('.hp-paper');
out.planRetainsMeasuredWedge = svgContains(planPath);
out.paperRetainsMeasuredWedge = [...root().querySelectorAll('.hp-paper')]
.some((paper) => svgContains(paper));
const model = card._spaceModel();
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
@@ -69,6 +88,7 @@ const result = await page.evaluate(async (spaceFixture) => {
out.lightAndSunKeepCanonicalMasonry = barriers.masonryGeometry.flat(2).length > 0
&& barriers.occluders.length > 0
&& !!barriers.fingerprint;
out.lightAndSunRetainMeasuredWedge = geometryContains(barriers.masonryGeometry);
const barrierFingerprint = barriers.fingerprint;
const cacheBeforeState = card._wallUnionCache;
@@ -92,15 +112,26 @@ const result = await page.evaluate(async (spaceFixture) => {
card._setMode('view');
await update(false);
out.viewMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
out.viewRetainsMeasuredWedge = svgContains(root().querySelector('[data-hp="wall"]'));
card._hoverRoom = { space: spaceFixture.id, room: model.rooms[0] };
const hoverFloor = card._roomHoverPaths(model);
out.cleanFloorConsumerStaysNonEmpty = !!hoverFloor?.fillD && !!hoverFloor.outlineD;
out.cleanFloorExcludesMeasuredWedge = model.rooms.every((room) => {
card._hoverRoom = { space: spaceFixture.id, room };
const floor = card._roomHoverPaths(model);
if (!floor?.fillD) return true;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', floor.fillD);
path.setAttribute('fill-rule', 'evenodd');
return !svgContains(path);
});
card._hoverRoom = null;
const kioskBefore = card._config.kiosk;
card._config.kiosk = true;
await update(false);
out.kioskMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
out.kioskRetainsMeasuredWedge = svgContains(root().querySelector('[data-hp="wall"]'));
card._config.kiosk = kioskBefore;
await update(false);
@@ -124,6 +155,9 @@ const result = await page.evaluate(async (spaceFixture) => {
await staticCard.updateComplete;
out.staticMatchesPlan = staticCard.renderRoot
?.querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
out.staticRetainsMeasuredWedge = svgContains(
staticCard.renderRoot?.querySelector('[data-hp="wall"]'),
);
staticCard.remove();
const labs = Object.freeze(['iso']);
@@ -131,8 +165,10 @@ const result = await page.evaluate(async (spaceFixture) => {
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
const isoWalls = card._isoSource().build().walls;
out.hiddenIsoKeepsMasonry = !!root().querySelector('[data-hp="iso-walls"]')
&& card._isoSource().build().walls.flat(2).length > 0;
&& isoWalls.flat(2).length > 0;
out.hiddenIsoRetainsMeasuredWedge = geometryContains(isoWalls);
out.renderNeverWritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
return out;
+2 -2
View File
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -429,10 +429,12 @@ bevel bounded by `1.25 × H`; degree-2 joins keep the legacy `MITRE_LIMIT = 4`.
The final bevel is applied to canonical masonry after its room/atomic/exterior
union, preventing later boolean inputs from recreating the discarded spike.
Canonical masonry replaces each affected local mask with complete physical ray
strips clipped to the room union, retains overlap through the approved radius,
and preserves the established full exterior cut outside the room union. This
prevents the repair from deleting half an incident strip or changing a concave
facade. Paper re-unions the room centre footprint after its facade cut.
strips clipped to the bounded physical paper envelope, not just the room union,
and retains overlap through the approved radius on both sides of the facade.
Only the excessive portion beyond `1.25 × H` is removed. This prevents the
repair from deleting an exterior half-strip into a white T-junction wedge while
still rejecting the old unbounded spike. Paper applies that same bounded cut
before re-unioning the room centre footprint (#261).
`wallBodiesGeometry.roomGeom` caches this repaired room masonry before openings
and independent bodies; clean-floor consumers subtract it from each source room
and clip their fallback, so fill cannot escape the building or silently drop a
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Thick T-junctions no longer show white triangular gaps on the exterior side
when three or more walls of different thickness meet. The bounded bevel now
preserves valid masonry and paper up to the same limit on both sides of the
room outline, consistently in Plan, View, kiosk, Static, hidden Iso and
light/sun barriers, without restoring the former long mitre spike
([#261](https://github.com/Matysh/houseplan-card/issues/261)).
- Wall thickness now stays consistent across structural consumers when
“Optimize plans” rewrites a compatibility key. Wall identity remains stable
when an exact `1/240` grid endpoint is persisted with nine decimal places,
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- В толстых T-стыках больше не появляются белые треугольные щели с наружной
стороны, когда сходятся три и более стены разной толщины. Ограниченная фаска
теперь сохраняет допустимую кладку и подложку до одного предела по обе стороны
контура комнаты — одинаково в Plan, View, kiosk, Static, скрытой изометрии и
световых/солнечных барьерах — и не возвращает прежний длинный mitre-зуб
([#261](https://github.com/Matysh/houseplan-card/issues/261)).
- После перезаписи compatibility-key командой «Оптимизировать планы» толщина
стены теперь остаётся согласованной у всех структурных потребителей.
Идентификатор стены стабилен, когда точный узел сетки `1/240` сохраняется с
+20 -1
View File
@@ -1677,7 +1677,8 @@ error-free report captured from the current source fingerprint; the entire set
is validated before any reference is copied.
The matrix covers thick wall junctions, the full #197 multi-room
virtual-junction resilience fixture in Plan and View, the #249 three-ray
virtual-junction resilience fixture in Plan and View (including the #261
measured exterior-wedge fill probe), the #249 three-ray
unequal-thickness fixture with a semantic filled-node/empty-old-wedge gate,
virtual/physical boundaries,
partitions/columns, axis-aligned and 45° door/window/gate tunnels, hidden
@@ -1708,6 +1709,14 @@ parity, paper and clean-floor presence, shared Glow/sun masonry, cache reuse on
HA/theme ticks, no saved-config mutation, a filled node and the removed old
spike. Full golden/smoke/performance remain pre-beta gates.
For #261, the anonymised #197 fixture also probes the real regression point
`(895.5, 556)`: `roomGeom`, final masonry and paper must fill it, while every
clean-floor contour must exclude it. The browser smoke repeats semantic point
coverage in Plan, View, kiosk, Static, hidden Iso and light/sun masonry; the two
existing #197 goldens require `SVGGeometryElement.isPointInFill()` at the same
point. Mutation `multi-wall-paper-full-origin-cut` restores the faulty
offset-origin cut and must make that regression test fail.
### Issue #73 baseline and implementation (2026-08-11)
The published v1.61.0-beta.6 exact SHA is the renderer baseline for #73: it
@@ -2623,6 +2632,16 @@ require hands on real hardware — they remain for the human pass.
[auto: test/wall-thickness.test.mjs +
smoke_junction_patch_resilience + junction-patch-resilience golden
scenarios].
- [ ] **A bounded T-junction keeps its exterior half-wall (#261)**: in the
anonymised #197 fixture the measured point `(895.5, 556)` is filled by
room masonry, final masonry and paper, and excluded from clean floor.
Plan, View, kiosk, Static, hidden Iso and light/sun barriers agree at that
point, while the old excessive #249 spike remains absent
[unit: test/wall-thickness.test.mjs; auto:
smoke_junction_patch_resilience; golden:
junction-patch-resilience-plan-dark +
junction-patch-resilience-view-dark; mutation:
multi-wall-paper-full-origin-cut].
- [ ] **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
+3 -1
View File
@@ -325,7 +325,9 @@ Other operations edit existing geometry:
Wall thickness is stored in real units. A room may have different thicknesses
on different spans. Open wall branches and T-junctions are allowed; shared
geometry remains joined without painted end caps.
geometry remains joined without painted end caps. At a saved T-junction both
physical half-walls stay solid: the bounded bevel removes only an excessive
projecting corner and never leaves a white triangular gap.
If Resize moves only part of a longer thick wall, the moved part keeps its
thickness and follows the room while the remaining continuation stays in
place. Openings on the moved part follow it in the same Undo/Redo operation.
+3 -1
View File
@@ -389,7 +389,9 @@ Cancel и «Оставить стенами» ничего не исправля
T-соединение входит в проходящую стену без видимого торца. Это действует уже у
активного rubber-band до клика и сохраняется после клика, в View и на статичной
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
конец незамкнутой стены остаётся плоским.
конец незамкнутой стены остаётся плоским. У сохранённого T-стыка обе половины
физической стены остаются сплошными: фаска убирает только чрезмерно выступающий
угол и не оставляет белого треугольного клина.
Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет
последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют.
+9 -6
View File
@@ -111,11 +111,13 @@ incident half-depth `H`; every excessive join is cut back with a straight local
bevel and may not extend beyond `R = 1.25 × H`. Inside the room union, a bounded
mask replaces the legacy ring with the complete finite ray strips, retains their
overlap through `R`, and removes only the remaining excessive pairwise wedge.
Outside the room union, the established full facade cut is preserved. This keeps
the node centre and every arm area-connected without allowing an interior child
mitre to change a concave facade. Ordinary two-ray corners retain the exact
historical `MITRE_LIMIT = 4` contract. This is computed geometry only: saved room
outlines and wall entries are not rewritten.
The same bounded rule applies to the exterior half-wall and paper envelope:
local ray strips are clipped to that physical envelope rather than the room
centre, so a valid T-junction cannot become a white wedge. This keeps the node
centre and every arm area-connected without allowing overlap beyond `R` or an
interior child mitre to change a concave facade. Ordinary two-ray corners retain
the exact historical `MITRE_LIMIT = 4` contract. This is computed geometry only:
saved room outlines and wall entries are not rewritten (#261).
Clean-floor consumers subtract the cached, repaired canonical room masonry from
their source room and take its outer component. The result is clipped to the
@@ -194,7 +196,8 @@ valid masonry, paper, floor faces or light barriers for the whole space (#197).
The same failure isolation covers degree-3+ repair: every node is rebuilt and
committed independently inside its bounded mask. A malformed local candidate
therefore keeps that node's previous body without reverting successful repairs
at unrelated nodes.
at unrelated nodes. The exterior paper uses the same `R`-bounded overlap as the
masonry; it never restarts the cut at the offset origins (#261).
Runtime normalisation remains lossless for every positive exact thickness
interval, regardless of its length. The explicit **Optimize plans** maintenance
+1 -1
View File
@@ -6,7 +6,7 @@
сложность/риск 7/10
- Issue: [#261](https://github.com/Matysh/houseplan-card/issues/261)
- Ветка: `issue/261-white-wedges-root-cause`
- Статус ТЗ: первая редакция, ожидает ревью
- Статус ТЗ: реализовано, ожидает код-ревью
Канонические документы: `docs/SCOPE.md`, `docs/USER-GUIDE.ru.md`,
`docs/ARCHITECTURE.md`, `docs/WALL-THICKNESS.md`,
+19
View File
@@ -218,6 +218,25 @@ export const MUTANTS = [
replace: ' return v;',
}],
},
{
id: 'multi-wall-paper-full-origin-cut',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue #197 keeps" '
+ 'test/wall-thickness.test.mjs',
because: 'the exterior paper must retain valid multi-wall overlap up to R; cutting from the '
+ 'offset origins recreates the measured white T-junction wedge from #261',
patches: [{
file: 'src/wall-thickness.ts',
find: ' for (const triangle of multiWallBevelTriangles(map)) {',
replace: ' for (const triangle of multiWallBevelTrianglesAt(map, false)) {',
}, {
file: 'src/wall-thickness.ts',
find: ' if (envelope) localInside = intersection(localInside, envelope);\n'
+ ' else if (centre) localInside = intersection(localInside, centre);',
replace: ' if (centre) localInside = intersection(localInside, centre);\n'
+ ' else if (envelope) localInside = intersection(localInside, envelope);',
}],
},
{
id: 'wall-exact-span-fallback-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+9 -7
View File
@@ -1996,10 +1996,9 @@ function multiWallBevelTrianglesAt(
hit[0] - node.point[0], hit[1] - node.point[1],
);
if (!Number.isFinite(distance) || distance <= node.limit) continue;
// Canonical masonry retains pairwise overlap up to R so ordinary
// right-angle arms stay area-connected. The exterior paper uses the
// offset origins instead: its job is to remove the complete facade
// tooth, and the room centre is unioned back immediately afterwards.
// Canonical masonry and its exterior paper retain pairwise overlap up
// to R so ordinary right-angle arms stay area-connected. Starting the
// cut at the offset origins removes valid exterior half-wall material.
const advanceA = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - a.halfDepth * a.halfDepth,
)) : 0;
@@ -2085,8 +2084,11 @@ function bevelMultiWallBody(
]) as any);
if (!local) continue;
let localInside = intersection(local, closedRing(mask) as any);
if (centre) localInside = intersection(localInside, centre);
else if (envelope) localInside = intersection(localInside, envelope);
// `envelope` is the bounded physical paper, including the exterior
// half-walls. Clipping the repair to the room-centre union first drops
// exactly the valid T-junction wedge this reconstruction must retain.
if (envelope) localInside = intersection(localInside, envelope);
else if (centre) localInside = intersection(localInside, centre);
const outside = difference(boundedCurrent, closedRing(mask) as any);
const preservedExterior = centre
? difference(
@@ -2111,7 +2113,7 @@ function bevelMultiWallPaper(
map: MultiWallNodeMap,
): any {
let beveled = paper;
for (const triangle of multiWallBevelTrianglesAt(map, false)) {
for (const triangle of multiWallBevelTriangles(map)) {
try {
beveled = difference(beveled, closedRing(triangle) as any);
} catch {
+1
View File
@@ -371,6 +371,7 @@ test('issue #197 golden keeps the complete junction fixture in Plan and View', (
for (const scenario of scenarios) {
assert.equal(scenario.theme, 'dark');
assert.equal(scenario.junctionPatchResilience, true);
assert.deepEqual(scenario.retainedWedgeProbe, [0.8955, 0.556]);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
+42 -2
View File
@@ -1330,6 +1330,15 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
const intervals = wallIntervals(
rooms, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
const multiWallMap = buildMultiWallNodeMap(
intervals, pitch * NORM_W * 0.04 * 4, NORM_W,
);
const affectedNode = multiWallMap.nodes.find((node) =>
Math.hypot(node.point[0] - 887.5, node.point[1] - 550) < 1e-6);
assert.equal(affectedNode?.rays.length, 3, 'the affected T-junction lost an incident ray');
assert.equal(multiWallMap.nodes.some((node) =>
Math.hypot(node.point[0] - 620.8333333333334, node.point[1] - 550) < 1e-6), false,
'the zero-depth shared edge became a physical multi-wall node');
const nodeCms = intervals
.filter((iv) => Math.abs(iv.a[1] - 550) < 1e-6
&& Math.abs(iv.b[1] - 550) < 1e-6)
@@ -1369,11 +1378,42 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
assert.ok(geometry, 'one rejected junction patch must not erase the whole plan');
assert.ok(geometry.geom.length > 0);
assert.ok(geometry.paperGeom.length > 0);
const retainedWedgeProbe = [895.5, 556];
assertProbeInside(
geometry.roomGeom, retainedWedgeProbe,
'the room masonry lost the bounded exterior half-wall at the T-junction',
);
assertProbeInside(
geometry.geom, retainedWedgeProbe,
'the canonical masonry lost the bounded exterior half-wall at the T-junction',
);
assertProbeInside(
geometry.paperGeom, retainedWedgeProbe,
'the paper exposed the scene background through the bounded T-junction',
);
for (const ray of affectedNode.rays) assertProbeInside(
geometry.roomGeom,
[
affectedNode.point[0] + ray.u[0] * affectedNode.limit * 0.5,
affectedNode.point[1] + ray.u[1] * affectedNode.limit * 0.5,
],
'the repaired T-junction disconnected an incident physical ray',
);
for (const room of rooms) {
const cleanFloor = innerContourForRoom(
rooms, room.id, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
geometry.roomGeom,
);
if (cleanFloor) assertProbeOutside(
closedGeometry(cleanFloor), retainedWedgeProbe,
`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.
closeTo(geometryArea(geometry.geom), 124512.89263371378, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727248.4374999999, 1e-6);
closeTo(geometryArea(geometry.geom), 124568.27047237023, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727303.8153386558, 1e-6);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
'rendering mutated persisted input',