fix: preserve bounded multi-wall floor geometry

Issue: #249
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 06:34:42 +03:00
parent b8f511d05a
commit 26fa968477
21 changed files with 1031 additions and 831 deletions
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -58,8 +58,8 @@ const result = await page.evaluate(async (source) => {
const planD = path?.getAttribute('d') || '';
const canonical = card._wallUnionGeometry();
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
// Midpoint between the approved bevel and the old 1.80×H mitre.
const discardedWedge = new DOMPoint(330.0011713041353, 146.99331561935776);
// Midpoint between the R-bounded straight bevel and the old 1.80×H mitre.
const discardedWedge = new DOMPoint(330.3808442725, 148.8560107825);
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
&& card._spaceWalls.length === 7;
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
File diff suppressed because one or more lines are too long
+250 -250
View File
File diff suppressed because one or more lines are too long
+11 -4
View File
@@ -428,10 +428,17 @@ uses `H = max(incident half-depth)` and clips excessive overlap to a straight
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.
Paper re-unions the room centre footprint after the same cut so an interior
bevel exposes clean floor rather than scene background. Full, Static, hidden
Iso, room fills/hover, clean-floor calculations and light barriers therefore
observe the same topology, and cached HA/theme ticks do not rebuild the map.
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.
`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
floor pocket. Full, Static, hidden Iso, room fills/hover and light barriers
therefore observe the same topology, and cached HA/theme ticks do not rebuild
the map.
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
+4 -1
View File
@@ -6,7 +6,10 @@
straight bevel instead of producing long hatched spikes, including mixed wall
thicknesses. The node stays filled and matches across Plan, View, kiosk,
Static, hidden Iso and light/shadow barriers; ordinary two-wall corners keep
their previous shape ([#249](https://github.com/Matysh/houseplan-card/issues/249)).
their previous shape. Clean room fills now use that same bounded masonry, so
strongly asymmetric junctions cannot push floor outside the building or
leave a missing floor pocket
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
## v1.67.0-beta.3 — 2026-08-23
+3 -1
View File
@@ -12,7 +12,9 @@
клинья штриховки теперь заменяются ограниченной прямой фаской, в том числе
при разной толщине стен. Сам узел остаётся заполненным и одинаковым в Plan,
View, kiosk, Static, скрытой изометрии и световых/теневых барьерах; обычные
углы из двух стен сохраняют прежнюю форму
углы из двух стен сохраняют прежнюю форму. Чистая заливка комнаты использует
ту же ограниченную геометрию стен, поэтому сильно различающаяся толщина в
узле не выводит пол за контур здания и не оставляет пропущенный участок пола
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
## v1.67.0-beta.3 — 2026-08-23
+7 -3
View File
@@ -1685,9 +1685,13 @@ that it automatically runs blocking verification. Review and accept the
canonical set. See `demo/golden/README.md`.
For #249, `test/wall-thickness.test.mjs` additionally covers equal and unequal
three-/four-ray nodes, reversed input, winding/order changes, production
`coordScale = 1000`, unchanged two-ray joins and the anonymised regression
fixture in `test/fixtures/249-multiwall-junction.json`.
three-/four-ray nodes (including literal 15/50/70 cm arms), reversed input,
winding/order changes, production `coordScale = 1000`, unchanged two-ray joins
and the anonymised regression fixture in
`test/fixtures/249-multiwall-junction.json`. The asymmetric corner-Split case
also proves that the union of clean-room floors equals the original room union
minus canonical bounded masonry and that every floor vertex remains inside the
source building.
`demo/smoke_multiwall_junction.mjs` checks Plan/View/kiosk/Static/hidden-Iso
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
+19 -7
View File
@@ -78,10 +78,21 @@ At a physical node with **three or more distinct incident rays**, the stricter
multi-wall rule applies (#249). Shared room ownership and reversed interval
direction do not create extra rays. One structural node map records the largest
incident half-depth `H`; every excessive join is cut back with a straight local
bevel and may not extend beyond `R = 1.25 × H`. The node centre and each wall arm
remain masonry, so the bevel cannot create a floor pinhole. 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.
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.
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
source room on fallback. Openings and independent partitions are deliberately
excluded from this shared `roomGeom`, so a door does not change the room fill
and a detached body cannot punch it. Full and Static render paths reuse the same
structural cache instead of rebuilding wall booleans once per room.
**Hatch density is physical (#230).** The pattern step is a distance on the
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
@@ -150,9 +161,10 @@ patches. This fallback never rounds persisted rooms, walls or open spans to the
grid and never turns a failure of the mandatory exterior/body/opening passes
into a successful result. One noisy junction therefore cannot remove otherwise
valid masonry, paper, floor faces or light barriers for the whole space (#197).
The same failure isolation covers the degree-3+ bevel cuts: malformed candidates
are skipped locally, and an aggregate boolean failure retries valid cuts one at
a time instead of reverting every multi-wall node.
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.
Runtime normalisation remains lossless for every positive exact thickness
interval, regardless of its length. The explicit **Optimize plans** maintenance
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+19 -19
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"sourceFingerprint": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "aaab24546dac767fc434c627c0aaecd69458b97872cf7a97ce417686946902d1"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "3cbdf7aa2105d72b2dea0d644862f36118e268d8c8d2e285f1edcffc49648a93"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "b2d827fb2fb76d8340509a7bb4446369003ec94d017a7ea60d285923fbd4558e"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "2ac9fad407384ba3ef46330a7ffcdcb0cd6ca1e3412e0ad935f58b74f9efde6c"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "e15754f68638b6128a30fc64bc830ac1cf611be7e6e10bcfaf84f17d1965aa33"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "b729f6ac72181ffe0edc5003e400642f2238ceac6c6423e4add8eb8e45fd4253"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "b68e560bbc3744a911cbf9d37b0eee765d1ef437b27fe65402c38b13921da1fe"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "7393c93fd60666703531f8882fac9b421452aaf04e47f1a12997afae39cfb7e7"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "1678442b3d4ec4a5a59141b5a0213a0118d4455a924381bd6d80a3800eae686a"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "7017cb7faaa6ce54627b72560c18c616c766b3cc1e5e794771f4dd5dcc517d6f"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "b49138235d348ca6a2d74da5c9891298f44546fd902efcdea49ef89605654b33"
}
}
}
+10
View File
@@ -11621,6 +11621,7 @@ class HouseplanCard extends LitElement {
space.rooms.map((room) => [room, roomPoly(room)]),
);
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const roomWalls = this._wallUnionGeometry()?.roomGeom;
const pathD = (points: number[][]) =>
'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z';
const shapes = space.rooms.map((room) => {
@@ -11631,6 +11632,7 @@ class HouseplanCard extends LitElement {
? (innerContourForRoom(
space.rooms, room.id, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
) || ownPoly)
: ownPoly;
const otherPolys = space.rooms
@@ -11825,10 +11827,12 @@ class HouseplanCard extends LitElement {
? pairs.filter((p) => p.a.id === room.id || p.b.id === room.id).flatMap((p) => p.segs)
: pairs.flatMap((p) => p.segs);
const walls = this._spaceWalls;
const roomWalls = this._wallUnionGeometry()?.roomGeom;
const floor = walls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, walls, allOpenCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
) || poly)
: poly;
const contours: { axis: number[][]; face: number[][] }[] = [{ axis: poly, face: floor }];
@@ -14768,11 +14772,13 @@ class HouseplanCard extends LitElement {
const openingWallIndex = this._openingWallIndexFor(space, openCuts).value;
const innerByRoom: Record<string, number[][]> = {};
const wallDepthByOpening: Record<string, number> = {};
const roomWalls = this._wallUnionGeometry()?.roomGeom;
if (walls.length) {
for (const r of rooms) {
const inn = innerContourForRoom(
space.rooms, r.id, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
);
if (inn) innerByRoom[r.id] = inn;
}
@@ -15927,11 +15933,13 @@ class HouseplanCard extends LitElement {
const openCuts = enabled.length === polys.length
? []
: this._openPairs().flatMap((pair) => pair.segs);
const roomWalls = this._wallUnionGeometry()?.roomGeom;
const enabledClip = enabled.length === polys.length ? null : enabled.map(({ r, poly }) => {
const floorPoly = walls.length && r.id
? (innerContourForRoom(
space.rooms, r.id, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
) || poly)
: poly;
const clean = this._cleanFloor(r, floorPoly, space).path;
@@ -16772,6 +16780,7 @@ class HouseplanCard extends LitElement {
space.rooms, r.id, walls,
this._openPairs().flatMap((p) => p.segs),
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
this._wallUnionGeometry()?.roomGeom,
) || myPoly)
: myPoly;
const holes = fillPoly ? islandsOf(fillPoly, otherPolys(r)) : [];
@@ -17933,6 +17942,7 @@ class HouseplanCard extends LitElement {
space.rooms, r.id, walls,
this._openPairs().flatMap((p) => p.segs),
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
this._wallUnionGeometry()?.roomGeom,
) || poly)
: poly;
const clean = this._cleanFloor(r, floor);
+184 -32
View File
@@ -1752,6 +1752,44 @@ export function insetOffsetsForRoom(
/** Alias — half offsets drive both inset and outset. */
export const halfOffsetsForRoom = insetOffsetsForRoom;
/**
* A bounded inward bevel may cross a neighbouring source edge when incident
* wall depths differ sharply. Keep only the part that is physically inside
* the room and return its largest outer ring; room consumers accept one simple
* contour and handle nested-room holes separately.
*/
function clipInnerContourToRoom(
contour: number[][],
room: number[][],
): number[][] | null {
try {
const clipped = intersection(
closedRing(contour) as any,
closedRing(room) as any,
);
return largestOuterContour(clipped);
} catch {
return null;
}
}
/** Largest simple outer ring from polygon-clipping geometry. */
function largestOuterContour(geometry: any): number[][] | null {
let best: number[][] | null = null;
let bestArea = 0;
for (const polygon of geometry || []) {
const raw = polygon?.[0];
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((point: number[]) => [point[0], point[1]]);
const area = Math.abs(signedArea(ring));
if (ring.length >= 3 && area > bestArea) {
best = ring;
bestArea = area;
}
}
return best;
}
/**
* Inner (clean-floor) contour of a room: inset by half wall thickness.
* Returns the original poly when there is no thickness.
@@ -1765,6 +1803,8 @@ export function innerContourForRoom(
cellCm: number,
gridPitch: number,
coordScale = 1,
/** Canonical room-wall masonry before opening cuts; pass the render cache. */
sharedRoomWallGeometry?: any,
): number[][] | null {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
@@ -1775,7 +1815,22 @@ export function innerContourForRoom(
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
return insetContour(pr.poly, pr.offsets, multiWallNodes)
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!inset) return poly.map((p) => [p[0], p[1]]);
if (!multiWallNodes.nodes.length) return inset;
const roomWallGeometry = sharedRoomWallGeometry ?? wallBodiesGeometry(
rooms, walls, openCuts, [], pitch, cellCm, gridPitch, coordScale,
)?.roomGeom;
if (roomWallGeometry) {
try {
const floor = difference(closedRing(pr.poly) as any, roomWallGeometry);
const contour = largestOuterContour(floor);
if (contour) return contour;
} catch {
// Fall through to the bounded contour clip; never return an outside tip.
}
}
return clipInnerContourToRoom(inset, pr.poly)
|| poly.map((p) => [p[0], p[1]]);
}
@@ -1791,8 +1846,9 @@ interface MultiWallRoomRing {
}
/** Excess pairwise overlap triangles removed to expose the straight bevel. */
export function multiWallBevelTriangles(
function multiWallBevelTrianglesAt(
map: MultiWallNodeMap | null | undefined,
retainToLimit: boolean,
): number[][][] {
if (!map) return [];
const triangles: number[][][] = [];
@@ -1820,41 +1876,113 @@ export function multiWallBevelTriangles(
hit[0] - node.point[0], hit[1] - node.point[1],
);
if (!Number.isFinite(distance) || distance <= node.limit) continue;
const triangle = stableJunctionPatch([pA, pB, hit], map.coordinateScale);
// 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.
const advanceA = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - a.halfDepth * a.halfDepth,
)) : 0;
const advanceB = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - b.halfDepth * b.halfDepth,
)) : 0;
const qA = [pA[0] + a.u[0] * advanceA, pA[1] + a.u[1] * advanceA];
const qB = [pB[0] + b.u[0] * advanceB, pB[1] + b.u[1] * advanceB];
const triangle = stableJunctionPatch([qA, qB, hit], map.coordinateScale);
if (triangle) triangles.push(triangle);
}
}
return triangles;
}
function bevelMultiWallBody(body: any, map: MultiWallNodeMap): any {
export function multiWallBevelTriangles(
map: MultiWallNodeMap | null | undefined,
): number[][][] {
return multiWallBevelTrianglesAt(map, true);
}
function bevelMultiWallBody(
body: any,
map: MultiWallNodeMap,
centre?: any,
envelope?: any,
): any {
if (!body || !map.nodes.length) return body;
let cuts: any = null;
for (const triangle of multiWallBevelTriangles(map)) {
let current = body;
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + map.epsilon * 2;
const extent = radius * 2;
const mask = [
[node.point[0] - radius, node.point[1] - radius],
[node.point[0] + radius, node.point[1] - radius],
[node.point[0] + radius, node.point[1] + radius],
[node.point[0] - radius, node.point[1] + radius],
];
try {
const piece = closedRing(triangle) as any;
cuts = cuts ? union(cuts, piece) : piece;
} catch {
// Keep other valid local cuts; one bad candidate cannot erase the pass.
}
}
if (!cuts) return body;
try {
return difference(body, cuts);
} catch {
// Polyclip can reject a valid aggregate even when each local candidate is
// usable. Retry one-by-one so one unstable corner cannot suppress the
// bevels at every other independent node.
let recovered = body;
for (const triangle of multiWallBevelTriangles(map)) {
try {
recovered = difference(recovered, closedRing(triangle) as any);
} catch {
// Keep the last valid body and isolate this candidate only.
let boundedCurrent = current;
for (const triangle of multiWallBevelTrianglesAt({
...map,
nodes: [node],
}, false)) {
boundedCurrent = difference(boundedCurrent, closedRing(triangle) as any);
}
let local: any = null;
for (const ray of node.rays) {
const n = [-ray.u[1], ray.u[0]];
const rectangle = stableJunctionPatch([
[node.point[0] + n[0] * ray.halfDepth,
node.point[1] + n[1] * ray.halfDepth],
[node.point[0] + ray.u[0] * extent + n[0] * ray.halfDepth,
node.point[1] + ray.u[1] * extent + n[1] * ray.halfDepth],
[node.point[0] + ray.u[0] * extent - n[0] * ray.halfDepth,
node.point[1] + ray.u[1] * extent - n[1] * ray.halfDepth],
[node.point[0] - n[0] * ray.halfDepth,
node.point[1] - n[1] * ray.halfDepth],
], map.coordinateScale);
if (!rectangle) continue;
const piece: any = closedRing(rectangle) as any;
local = local ? union(local, piece) : piece;
}
for (const triangle of multiWallBevelTriangles({
...map,
nodes: [node],
})) {
// Rebuild the physical half-strips first, then remove only their
// excessive pairwise overlap. Applying this cut to the legacy room
// ring itself can delete an incident half-strip and strand floor.
local = difference(local, closedRing(triangle) as any);
}
// Rays share a mathematical endpoint. A tiny physical core turns that
// point contact into a stable polygon contact for boolean/render paths.
const coreRadius = Math.min(
...node.rays.map((ray) => ray.halfDepth),
) * 0.02;
local = union(local, closedRing([
[node.point[0] - coreRadius, node.point[1] - coreRadius],
[node.point[0] + coreRadius, node.point[1] - coreRadius],
[node.point[0] + coreRadius, node.point[1] + coreRadius],
[node.point[0] - coreRadius, node.point[1] + coreRadius],
]) 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);
const outside = difference(boundedCurrent, closedRing(mask) as any);
const preservedExterior = centre
? difference(
intersection(boundedCurrent, closedRing(mask) as any),
centre,
)
: null;
current = preservedExterior
? union(outside, preservedExterior, localInside)
: union(outside, localInside);
} catch {
// Isolate the failed node. Other valid nodes still receive their repair;
// mandatory surrounding structural failures remain fail-dark upstream.
}
return recovered;
}
return current;
}
function bevelMultiWallPaper(
@@ -1862,7 +1990,14 @@ function bevelMultiWallPaper(
centre: any,
map: MultiWallNodeMap,
): any {
const beveled = bevelMultiWallBody(paper, map);
let beveled = paper;
for (const triangle of multiWallBevelTrianglesAt(map, false)) {
try {
beveled = difference(beveled, closedRing(triangle) as any);
} catch {
// Isolate the failed local cut and retain the rest of the paper.
}
}
try {
// Paper is the complete room footprint. Interior bevel cuts expose floor,
// not the scene background, so the centre union must always remain solid.
@@ -2275,7 +2410,14 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; paperGeom: any; depthUnits: number; openingIndex: OpeningWallIndex | null } | null {
): {
geom: any;
/** Canonical room masonry before opening cuts and independent bodies. */
roomGeom: any;
paperGeom: any;
depthUnits: number;
openingIndex: OpeningWallIndex | null;
} | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: MultiWallRoomRing[] = [];
const multiWallNodes = multiWallNodesForGeometry(
@@ -2365,7 +2507,8 @@ export function wallBodiesGeometry(
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
if (body && multiWallNodes.nodes.length)
body = bevelMultiWallBody(body, multiWallNodes);
body = bevelMultiWallBody(body, multiWallNodes, exterior?.centre, paperGeom);
const roomGeom = body || [];
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
@@ -2390,7 +2533,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], paperGeom, depthUnits: maxDepth, openingIndex };
return { geom: body || [], roomGeom, paperGeom, depthUnits: maxDepth, openingIndex };
} catch {
return null;
}
@@ -2408,14 +2551,23 @@ export function wallBodiesUnionPath(
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
): {
d: string;
roomGeom: any;
paperD: string;
depthUnits: number;
fillRule: 'evenodd' | 'nonzero';
} | null {
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
);
const d = united ? polyclipToPathD(united.geom) : '';
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united && d) return {
d, roomGeom: united.roomGeom, paperD,
depthUnits: united.depthUnits, fillRule: 'evenodd',
};
if (united) return null; // successful empty result: do not resurrect raw rings
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
+22 -12
View File
@@ -625,6 +625,7 @@ test('issue #249 node classification is order, direction and scale independent',
const cases = [
{ angles: [0, 30, 200], halves: [5, 5, 5], bevel: true },
{ angles: [45, 102, 230], halves: [7, 5, 5], bevel: true },
{ angles: [45, 102, 230], halves: [1.5, 5, 7], bevel: true },
{ angles: [0, 90, 180, 270], halves: [5, 5, 5, 5], bevel: true },
{ angles: [0, 90, 180, 270], halves: [2, 5, 3, 7], bevel: false },
];
@@ -718,7 +719,11 @@ test('issue #249 node classification is order, direction and scale independent',
);
assert.equal(fanMap.nodes.length, 1);
assert.equal(fanMap.nodes[0].rays.length, fixture.angles.length);
assert.equal(fan.geometry.geom.length, 1, 'fan wall arms are disconnected');
assert.equal(
fan.geometry.geom.length,
1,
`fan ${fixture.angles.join('/')} halves ${fixture.halves.join('/')} wall arms are disconnected`,
);
assertProbeInside(fan.geometry.geom, fan.node, 'fan bevel punched a node hole');
for (const [rayIndex, ray] of fanMap.nodes[0].rays.entries()) {
const armPoint = [
@@ -1149,7 +1154,9 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
assert.ok(geometry.geom.length > 0);
assert.ok(geometry.paperGeom.length > 0);
// #249 intentionally bevels degree-3+ nodes in this older fixture too.
closeTo(geometryArea(geometry.geom), 124495.74029324856, 1e-6);
// #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);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
@@ -1512,21 +1519,24 @@ test('Split with both endpoints at exterior vertices bevels both new 3-ray corne
assert.equal(map.nodes.length, 2);
});
test('corner Split clean-floor contours use the same bounded bevel endpoints', () => {
test('corner Split clean floors equal room union minus canonical bounded walls', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const floors = fixture.rooms.map((room) => innerContourForRoom(
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
));
assert.ok(floors.every(Boolean));
const map = assertBoundedMultiWallBevels(
fixture.rooms, fixture.walls, fixture.after,
);
const boundedEndpoints = multiWallBevelTriangles(map)
.flatMap((triangle) => triangle.slice(0, 2));
for (const endpoint of boundedEndpoints) {
assert.ok(floors.some((floor) => floor.some((point) =>
Math.hypot(point[0] - endpoint[0], point[1] - endpoint[1]) < 1e-7)),
`clean-floor contours lost bevel endpoint ${endpoint}`);
const actual = union(...floors.map((floor) => closedGeometry(floor)));
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
const extra = geometryDifferenceArea(actual, expected);
const missing = geometryDifferenceArea(expected, actual);
assert.ok(extra <= 1e-7 && missing <= 1e-7,
`floor mismatch extra=${extra} missing=${missing} floors=${JSON.stringify(floors)}`);
for (const floor of floors) {
for (const point of floor) {
assert.ok(point[0] >= 100 - 1e-7 && point[0] <= 900 + 1e-7
&& point[1] >= 100 - 1e-7 && point[1] <= 700 + 1e-7,
`clean floor escaped the source building: ${point}`);
}
}
});