fix: preserve bounded multi-wall floor geometry
Issue: #249 User-Visible: yes
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||