fix: bound multi-wall junction bevels

Issue: #249
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 03:47:19 +00:00
committed by claude[bot]
parent 56e3da9b8a
commit 29904df6ee
20 changed files with 1352 additions and 415 deletions
File diff suppressed because one or more lines are too long
+31
View File
@@ -5,6 +5,9 @@ import { readFileSync } from 'node:fs';
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const multiWallJunctionFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
));
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -158,6 +161,25 @@ export function prepareGoldenFixture(scenario) {
},
});
}
if (scenario.multiWallJunction) {
const contract = scenario.multiWallJunction;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
|| !Number.isInteger(contract.rays) || contract.rays < 3) {
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
}
fixture.config.spaces.push({
...structuredClone(multiWallJunctionFixture),
id: scenario.space,
title: 'Multi-wall bevel',
view_box: [0.27, 0.07, 0.20, 0.21],
settings: {
...(multiWallJunctionFixture.settings || {}),
fill_mode: 'none', show_borders: true, show_names: false,
},
});
}
if (scenario.openingSymbolContract) {
const contract = scenario.openingSymbolContract;
if (!['room', 'partition'].includes(contract.kind)
@@ -514,6 +536,15 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
if (scenario.multiWallJunction) {
const { node, discardedWedgeProbe } = scenario.multiWallJunction;
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
if (!wall?.isPointInFill?.(at(node))
|| wall.isPointInFill(at(discardedWedgeProbe))) {
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
}
}
if (scenario.roomLabelParity) {
const labels = [...card.renderRoot.querySelectorAll('.roomlabel')];
if (labels.length !== 2
+8 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 38;
export const GOLDEN_MATRIX_VERSION = 39;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -266,6 +266,13 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'multiwall-junction-bevel-view-dark', fixture: 'visual',
space: 'golden-multiwall-junction', mode: 'view',
multiWallJunction: {
node: [0.329166667, 0.141666667], rays: 3,
discardedWedgeProbe: [0.3300011713041353, 0.14699331561935776],
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
+151
View File
@@ -0,0 +1,151 @@
/** Issue #249: one bounded degree-3 junction across every canonical consumer. */
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
));
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
const result = await page.evaluate(async (source) => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const update = async (structural = false) => {
if (structural) {
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._lightBarrierCache = null;
card._isoGeometryCache.clear();
}
card.requestUpdate();
await settle();
};
const space = {
id: 'issue-249',
title: 'Issue 249',
cell_cm: source.cell_cm,
view_box: [0.27, 0.07, 0.2, 0.21],
rooms: structuredClone(source.rooms),
walls: structuredClone(source.walls),
settings: { show_borders: true, fill_mode: 'none' },
};
const cfg = {
...structuredClone(card._serverCfg),
spaces: [space],
markers: [],
};
card._serverCfg = structuredClone(cfg);
card._layout = {};
card._space = space.id;
card._setMode('plan');
card._tool = 'select';
await update(true);
const persisted = JSON.stringify(card._serverCfg.spaces[0]);
const path = root().querySelector('[data-hp="wall"]');
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);
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
&& card._spaceWalls.length === 7;
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
out.nodeRemainsFilled = !!path?.isPointInFill(node);
out.excessWedgeIsEmpty = path && !path.isPointInFill(discardedWedge);
out.paperRemainsSolid = !!canonical?.paperD
&& !!root().querySelector('.hp-paper');
const model = card._spaceModel();
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
out.lightUsesSameMasonry = barriers.masonryGeometry.flat(2).length > 0
&& barriers.occluders.length > 0
&& !!barriers.fingerprint;
const barrierFingerprint = barriers.fingerprint;
const wallCache = card._wallUnionCache;
card.hass = {
...card.hass,
themes: { ...(card.hass.themes || {}), darkMode: !card.hass.themes?.darkMode },
states: {
...card.hass.states,
'sensor.issue_249_tick': {
entity_id: 'sensor.issue_249_tick', state: '1', attributes: {},
},
},
};
await update(false);
out.stateTickKeepsPath = root().querySelector('[data-hp="wall"]')
?.getAttribute('d') === planD;
out.stateTickReusesWallGeometry = card._wallUnionCache === wallCache;
out.stateTickReusesLightGeometry = card._lightBarriers(
model, polys, card._physicalBodiesR(model),
).fingerprint === barrierFingerprint;
card._setMode('view');
await update(false);
out.viewMatchesPlan = root().querySelector('[data-hp="wall"]')
?.getAttribute('d') === planD;
card._hoverRoom = { space: space.id, room: model.rooms[0] };
const hover = card._roomHoverPaths(model);
out.cleanFloorConsumerIsPresent = !!hover?.fillD && !!hover.outlineD;
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;
card._config.kiosk = kioskBefore;
await update(false);
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
const baseCall = card.hass.callWS.bind(card.hass);
staticCard.setConfig({
type: 'custom:houseplan-space-card', space: space.id, show_button: false,
});
staticCard.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
return { config: structuredClone(cfg), rev: 1 };
}
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]')
&& Date.now() - started < 6000) {
await new Promise((done) => setTimeout(done, 60));
}
await staticCard.updateComplete;
out.staticMatchesPlan = staticCard.renderRoot
?.querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
out.hiddenIsoUsesMasonry = !!root().querySelector('[data-hp="iso-walls"]')
&& card._isoSource().build().walls.flat(2).length > 0;
out.renderNeverWritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === persisted;
return out;
}, fixture);
checkAll(result);
await finish(browser, result);
File diff suppressed because one or more lines are too long
+112 -112
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -421,6 +421,17 @@ exterior/body/opening/authoritative-extra passes still returns `null` and
activates the established fail-dark consumer behaviour. This distinction
prevents one cosmetic junction repair from blanking a whole plan without
masking a real structural failure (#197).
The same structural pass builds one scale-relative physical endpoint map for
room profiles, exterior intervals and junction patches (#249). Co-directional
duplicates collapse while opposite rays remain distinct. At degree 3+ nodes it
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.
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
View File
@@ -2,6 +2,12 @@
## Unreleased
- Wall junctions where three or more physical walls meet now use a bounded
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)).
## v1.67.0-beta.3 — 2026-08-23
- Door, window and gate symbols now remain exactly on the wall centreline for
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- В узлах, где сходятся три и более физические стены, длинные выступающие
клинья штриховки теперь заменяются ограниченной прямой фаской, в том числе
при разной толщине стен. Сам узел остаётся заполненным и одинаковым в Plan,
View, kiosk, Static, скрытой изометрии и световых/теневых барьерах; обычные
углы из двух стен сохраняют прежнюю форму
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
## v1.67.0-beta.3 — 2026-08-23
- Символы дверей, окон и ворот теперь остаются точно на осевой линии стены при
+12 -1
View File
@@ -1666,7 +1666,9 @@ 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, virtual/physical boundaries,
virtual-junction resilience fixture in Plan and View, 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
opening symbols, Glow and sun, live/manual Glow overlap and light through a doorway,
light/temperature/LQI fill splits on a wall axis, hover over Glow and nested rooms, all three editors, dark/light themes,
@@ -1682,6 +1684,15 @@ that it automatically runs blocking verification. Review and accept the
`golden-images` CI artifact rather than treating a developer OS raster as 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`.
`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
spike. Full golden/smoke/performance remain pre-beta gates.
### Issue #73 baseline and implementation (2026-08-11)
The published v1.61.0-beta.6 exact SHA is the renderer baseline for #73: it
+12
View File
@@ -74,6 +74,15 @@ thinner than 3 CSS px on screen, the shared full/static render policy suppresses
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
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.
**Hatch density is physical (#230).** The pattern step is a distance on the
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
`8 × (5 / cell_cm)`, which is 9.6 cm at every grid scale and exactly the
@@ -141,6 +150,9 @@ 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.
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: 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: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

+14 -14
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceFingerprint": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "57b1407f0ac27aed88940508957454a31e5c577c0985fcda6aeca7f8ed42fe9b"
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "b2d827fb2fb76d8340509a7bb4446369003ec94d017a7ea60d285923fbd4558e"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "d34908f41d5953da3d454dba1b43495cac2483b7bd933062d94261dee7b7de98"
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "e15754f68638b6128a30fc64bc830ac1cf611be7e6e10bcfaf84f17d1965aa33"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "1678442b3d4ec4a5a59141b5a0213a0118d4455a924381bd6d80a3800eae686a"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "6a0df196b4f3d3808e292515419b71ff8959c8fd8c04a0ebde21f55914d35da2",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
- Сложность/риск: 5/10 и 6/10
- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249)
- Ветка: `issue/249-multiwall-junction-bevel`
- Статус ТЗ: редакция r2 после SPEC-REVIEW-249-r1, ожидает повторное ревью
- Статус ТЗ: редакция r2, повторное ревью зелёное
Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`,
`docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`, `docs/TESTING.md`.
+392 -24
View File
@@ -45,6 +45,31 @@ export const HATCH_MIN_STEP_PX = 2;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
/** Multi-ray joins stay inside this × the largest incident half-depth (#249). */
export const MULTI_WALL_JOIN_LIMIT = 1.25;
export interface MultiWallNodeRay {
/** Unit direction from the canonical node toward the interval's other end. */
u: [number, number];
halfDepth: number;
}
export interface MultiWallNode {
point: [number, number];
rays: MultiWallNodeRay[];
halfDepth: number;
limit: number;
}
/** Scale-relative lookup shared by every contour producer in one structural pass. */
export interface MultiWallNodeMap {
epsilon: number;
coordinateScale: number;
nodes: MultiWallNode[];
/** Spatial buckets keep vertex lookup linear instead of scanning all nodes. */
index: Map<string, MultiWallNode[]>;
}
/** One finite physical wall centreline with its already-converted half depth. */
export interface LinearWallSegment {
a: number[];
@@ -964,7 +989,11 @@ export function ownEdgeOffsets(
* Zero-offset edges stay on the original. Mitre joins; bevel when the mitre
* would spike longer than MITRE_LIMIT × max(adjacent offsets).
*/
export function insetContour(poly: number[][], offsets: number[]): number[][] | null {
export function insetContour(
poly: number[][],
offsets: number[],
multiWallNodes?: MultiWallNodeMap | null,
): number[][] | null {
const n = poly?.length || 0;
if (n < 3 || offsets.length !== n) return null;
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
@@ -1025,9 +1054,11 @@ export function insetContour(poly: number[][], offsets: number[]): number[][] |
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
?? MITRE_LIMIT * maxO;
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (dist <= MITRE_LIMIT * maxO) {
if (Number.isFinite(dist) && dist <= joinLimit) {
out.push(hit);
continue;
}
@@ -1310,6 +1341,189 @@ export interface RoomWallProfile extends AtomicPoly {
offsets: number[];
}
interface PendingMultiWallNode {
point: [number, number];
rays: Array<{ u: [number, number]; halfDepth: number; angle: number }>;
}
function spatialBucket(point: number[], epsilon: number): [number, number] {
return [Math.floor(point[0] / epsilon), Math.floor(point[1] / epsilon)];
}
function spatialBucketKey(x: number, y: number): string {
return `${x},${y}`;
}
function nearbyBuckets<T>(
index: Map<string, T[]>,
point: number[],
epsilon: number,
): T[] {
const [bx, by] = spatialBucket(point, epsilon);
const out: T[] = [];
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const values = index.get(spatialBucketKey(bx + dx, by + dy));
if (values) out.push(...values);
}
}
return out;
}
/**
* Canonical degree-3+ physical endpoint map (#249).
*
* Shared intervals may occur once per owning room. They collapse first by
* interval key and again by co-directional ray, while opposite directions
* remain distinct. Sorting makes the representative and ray order independent
* of room/wall input order and winding.
*/
export function buildMultiWallNodeMap(
input: WallInterval[],
epsilon = 1e-6,
coordinateScale = 1,
): MultiWallNodeMap {
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
const scale = Number.isFinite(coordinateScale) && coordinateScale > 0
? coordinateScale : 1;
const valid = (input || [])
.filter((iv) => iv && !iv.open && iv.kind !== null && Number.isFinite(iv.half) && iv.half > 0
&& Array.isArray(iv.a) && Array.isArray(iv.b)
&& iv.a.length >= 2 && iv.b.length >= 2
&& [iv.a[0], iv.a[1], iv.b[0], iv.b[1]].every(Number.isFinite)
&& Math.hypot(iv.b[0] - iv.a[0], iv.b[1] - iv.a[1]) > eps)
.sort((a, b) => a.key.localeCompare(b.key)
|| a.a[0] - b.a[0] || a.a[1] - b.a[1]
|| a.b[0] - b.b[0] || a.b[1] - b.b[1]
|| a.half - b.half);
const byPhysicalKey = new Map<string, WallInterval>();
for (const interval of valid) {
const previous = byPhysicalKey.get(interval.key);
if (!previous) {
byPhysicalKey.set(interval.key, interval);
} else if (interval.half > previous.half) {
// A shared physical interval may be emitted by both room owners. Keep
// one deterministic axis and the largest effective physical half-depth.
byPhysicalKey.set(interval.key, { ...previous, half: interval.half });
}
}
const intervals = [...byPhysicalKey.values()];
const endpoints = intervals.flatMap((iv) => [
{ point: [iv.a[0], iv.a[1]] as [number, number], other: iv.b, halfDepth: iv.half },
{ point: [iv.b[0], iv.b[1]] as [number, number], other: iv.a, halfDepth: iv.half },
]).sort((a, b) => a.point[0] - b.point[0] || a.point[1] - b.point[1]
|| a.other[0] - b.other[0] || a.other[1] - b.other[1]
|| a.halfDepth - b.halfDepth);
const pending: PendingMultiWallNode[] = [];
const pendingIndex = new Map<string, PendingMultiWallNode[]>();
for (const endpoint of endpoints) {
const candidates = nearbyBuckets(pendingIndex, endpoint.point, eps)
.filter((node) => Math.hypot(
node.point[0] - endpoint.point[0], node.point[1] - endpoint.point[1],
) <= eps)
.sort((a, b) => Math.hypot(
a.point[0] - endpoint.point[0], a.point[1] - endpoint.point[1],
) - Math.hypot(
b.point[0] - endpoint.point[0], b.point[1] - endpoint.point[1],
) || a.point[0] - b.point[0] || a.point[1] - b.point[1]);
let node = candidates[0];
if (!node) {
node = { point: [...endpoint.point], rays: [] };
pending.push(node);
const [bx, by] = spatialBucket(node.point, eps);
const key = spatialBucketKey(bx, by);
const bucket = pendingIndex.get(key) || [];
bucket.push(node);
pendingIndex.set(key, bucket);
}
const dx = endpoint.other[0] - endpoint.point[0];
const dy = endpoint.other[1] - endpoint.point[1];
const length = Math.hypot(dx, dy);
if (!(length > eps)) continue;
const u: [number, number] = [dx / length, dy / length];
let angle = Math.atan2(u[1], u[0]);
if (angle < 0) angle += Math.PI * 2;
node.rays.push({ u, halfDepth: endpoint.halfDepth, angle });
}
const nodes: MultiWallNode[] = [];
const angleEps = 1e-9;
for (const node of pending) {
const sorted = node.rays.sort((a, b) => a.angle - b.angle || a.halfDepth - b.halfDepth);
const rays: Array<{ u: [number, number]; halfDepth: number; angle: number }> = [];
for (const ray of sorted) {
const previous = rays[rays.length - 1];
if (previous && Math.abs(ray.angle - previous.angle) <= angleEps) {
if (ray.halfDepth > previous.halfDepth) {
previous.halfDepth = ray.halfDepth;
previous.u = ray.u;
}
} else {
rays.push({ ...ray, u: [...ray.u] });
}
}
if (rays.length > 1
&& Math.PI * 2 - rays[rays.length - 1].angle + rays[0].angle <= angleEps) {
const last = rays.pop()!;
if (last.halfDepth > rays[0].halfDepth) {
rays[0].halfDepth = last.halfDepth;
rays[0].u = last.u;
}
}
if (rays.length < 3) continue;
const halfDepth = Math.max(...rays.map((ray) => ray.halfDepth));
if (!(halfDepth > 0) || !Number.isFinite(halfDepth)) continue;
nodes.push({
point: [...node.point],
rays: rays.map(({ u, halfDepth: half }) => ({ u: [...u], halfDepth: half })),
halfDepth,
limit: MULTI_WALL_JOIN_LIMIT * halfDepth,
});
}
nodes.sort((a, b) => a.point[0] - b.point[0] || a.point[1] - b.point[1]);
const index = new Map<string, MultiWallNode[]>();
for (const node of nodes) {
const [bx, by] = spatialBucket(node.point, eps);
const key = spatialBucketKey(bx, by);
const bucket = index.get(key) || [];
bucket.push(node);
index.set(key, bucket);
}
return { epsilon: eps, coordinateScale: scale, nodes, index };
}
/** Find the canonical degree-3+ node matching a contour vertex. */
export function multiWallNodeAt(
map: MultiWallNodeMap | null | undefined,
point: number[],
): MultiWallNode | null {
if (!map || !Array.isArray(point) || point.length < 2
|| !point.slice(0, 2).every(Number.isFinite)) return null;
return nearbyBuckets(map.index, point, map.epsilon)
.filter((node) => Math.hypot(node.point[0] - point[0], node.point[1] - point[1]) <= map.epsilon)
.sort((a, b) => Math.hypot(a.point[0] - point[0], a.point[1] - point[1])
- Math.hypot(b.point[0] - point[0], b.point[1] - point[1])
|| a.point[0] - b.point[0] || a.point[1] - b.point[1])[0] || null;
}
function multiWallNodesForGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): MultiWallNodeMap {
return buildMultiWallNodeMap(
wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale),
openEps(pitch, coordScale) * 4,
coordScale,
);
}
export function roomWallProfile(
rooms: any[],
roomId: string,
@@ -1558,7 +1772,11 @@ export function innerContourForRoom(
if (!walls?.length) return poly.map((p) => [p[0], p[1]]);
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || !pr.offsets.some((o) => o > 0)) return poly.map((p) => [p[0], p[1]]);
return insetContour(pr.poly, pr.offsets) || poly.map((p) => [p[0], p[1]]);
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
return insetContour(pr.poly, pr.offsets, multiWallNodes)
|| poly.map((p) => [p[0], p[1]]);
}
function closedRing(poly: number[][]): number[][][] {
@@ -1567,6 +1785,93 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
interface MultiWallRoomRing {
outset: number[][];
inset: number[][] | null;
}
/** Excess pairwise overlap triangles removed to expose the straight bevel. */
export function multiWallBevelTriangles(
map: MultiWallNodeMap | null | undefined,
): number[][][] {
if (!map) return [];
const triangles: number[][][] = [];
for (const node of map.nodes) {
for (let i = 0; i < node.rays.length; i++) {
const a = node.rays[i], b = node.rays[(i + 1) % node.rays.length];
const angleA = Math.atan2(a.u[1], a.u[0]);
let angleB = Math.atan2(b.u[1], b.u[0]);
while (angleB <= angleA) angleB += Math.PI * 2;
const gap = angleB - angleA;
if (!(gap > 1e-9) || gap >= Math.PI - 1e-9) continue;
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const pA = [
node.point[0] + nA[0] * a.halfDepth,
node.point[1] + nA[1] * a.halfDepth,
];
const pB = [
node.point[0] - nB[0] * b.halfDepth,
node.point[1] - nB[1] * b.halfDepth,
];
const hit = lineIntersect(pA, a.u, pB, b.u);
if (!hit) continue;
const distance = Math.hypot(
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);
if (triangle) triangles.push(triangle);
}
}
return triangles;
}
function bevelMultiWallBody(body: any, map: MultiWallNodeMap): any {
if (!body || !map.nodes.length) return body;
let cuts: any = null;
for (const triangle of multiWallBevelTriangles(map)) {
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.
}
}
return recovered;
}
}
function bevelMultiWallPaper(
paper: any,
centre: any,
map: MultiWallNodeMap,
): any {
const beveled = bevelMultiWallBody(paper, map);
try {
// Paper is the complete room footprint. Interior bevel cuts expose floor,
// not the scene background, so the centre union must always remain solid.
return union(centre, beveled);
} catch {
return paper;
}
}
/**
* Collapse arithmetic noise on computed junction vertices before polyclip sees
* them. The quantum is relative to the caller coordinate scale and remains
@@ -1714,27 +2019,35 @@ function exteriorEnvelopeGeometry(
cellCm: number,
gridPitch: number,
coordScale: number,
sharedMultiWallNodes?: MultiWallNodeMap | null,
): ExteriorEnvelopeGeometry | null {
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
const polys = (rooms || []).map(roomPoly)
.filter((p): p is number[][] => !!p && p.length >= 3);
if (!polys.length) return null;
let centre: any = union(closedRing(polys[0]) as any);
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
const outer = wallIntervals(
const intervals = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
);
const outer = intervals.filter((iv) => iv.kind === 'outer' && iv.half > 0);
const eps = openEps(pitch, coordScale) * 4;
const multiWallNodes = sharedMultiWallNodes
|| buildMultiWallNodeMap(intervals, eps, coordScale);
let shell: any = null;
for (const ring of geometryRings(centre)) {
const profile = exteriorBoundaryProfile(ring, outer, eps);
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(profile.poly, profile.offsets);
const inset = insetContour(profile.poly, profile.offsets);
const outset = outsetContour(profile.poly, profile.offsets, multiWallNodes);
const inset = insetContour(profile.poly, profile.offsets, multiWallNodes);
if (!outset || !inset) continue;
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
shell = shell ? union(shell, piece) : piece;
}
return { centre, shell: shell || [] };
return {
centre,
shell: shell || [],
};
}
/**
@@ -1753,13 +2066,19 @@ export function floorFootprintGeometry(
coordScale = 1,
): any | null {
try {
const exterior = exteriorEnvelopeGeometry(
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
);
if (!exterior) return [];
return exterior.shell?.length
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
return multiWallNodes.nodes.length
? bevelMultiWallPaper(paper, exterior.centre, multiWallNodes)
: paper;
} catch {
return null;
}
@@ -1803,6 +2122,7 @@ export function virtualJunctionPatches(
cellCm: number,
gridPitch: number,
coordScale: number,
sharedMultiWallNodes?: MultiWallNodeMap | null,
): number[][][] {
if (!walls?.length || !openCuts?.length) return [];
const eps = openEps(pitch, coordScale) * 4;
@@ -1813,6 +2133,8 @@ export function virtualJunctionPatches(
}
const intervals = [...unique.values()];
if (intervals.length < 2) return [];
const multiWallNodes = sharedMultiWallNodes
|| buildMultiWallNodeMap(intervals, eps);
const nodes: number[][] = [];
for (const cut of openCuts) {
@@ -1850,8 +2172,31 @@ export function virtualJunctionPatches(
const pb = [v[0] - b.u[0] * db, v[1] - b.u[1] * db];
const far = [pa[0] + pb[0] - v[0], pa[1] + pb[1] - v[1]];
const maxHalf = Math.max(a.iv.half, b.iv.half, 1e-9);
if (Math.hypot(far[0] - v[0], far[1] - v[1]) > MITRE_LIMIT * maxHalf) continue;
out.push(cross > 0 ? [v.slice(), pa, far, pb] : [v.slice(), pb, far, pa]);
const multiNode = multiWallNodeAt(multiWallNodes, v);
const limit = multiNode?.limit ?? MITRE_LIMIT * maxHalf;
const farDistance = Math.hypot(far[0] - v[0], far[1] - v[1]);
let patch: number[][];
if (farDistance <= limit) {
patch = cross > 0 ? [v.slice(), pa, far, pb] : [v.slice(), pb, far, pa];
} else if (multiNode) {
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const sign = cross < 0 ? 1 : -1;
const edgeA = [
v[0] + nA[0] * a.iv.half * sign,
v[1] + nA[1] * a.iv.half * sign,
];
const edgeB = [
v[0] - nB[0] * b.iv.half * sign,
v[1] - nB[1] * b.iv.half * sign,
];
patch = cross > 0 ? [v.slice(), edgeA, edgeB] : [v.slice(), edgeB, edgeA];
} else {
// Preserve the exact two-ray contract: an over-limit legacy mitre
// produces no virtual patch, just as before #249.
continue;
}
if (Math.abs(signedArea(patch)) > eps * eps) out.push(patch);
}
}
}
@@ -1873,12 +2218,15 @@ export function wallBodyRings(
): WallBodyPath[] {
if (!walls?.length) return [];
const out: WallBodyPath[] = [];
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(pr.poly, pr.offsets);
const inset = insetContour(pr.poly, pr.offsets);
const outset = outsetContour(pr.poly, pr.offsets, multiWallNodes);
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!outset || !inset) continue;
const d = `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
let key = '';
@@ -1929,15 +2277,18 @@ export function wallBodiesGeometry(
extraBodies: number[][][] = [],
): { geom: any; paperGeom: any; depthUnits: number; openingIndex: OpeningWallIndex | null } | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
const roomRings: MultiWallRoomRing[] = [];
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
let maxDepth = 0;
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
for (const o of pr.offsets) if (o > 0) maxDepth = Math.max(maxDepth, o * 2);
const outC = outsetContour(pr.poly, pr.offsets);
const inC = insetContour(pr.poly, pr.offsets);
const outC = outsetContour(pr.poly, pr.offsets, multiWallNodes);
const inC = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!outC) continue;
roomRings.push({ outset: outC, inset: inC });
}
@@ -1957,20 +2308,23 @@ export function wallBodiesGeometry(
}
}
const junctions = virtualJunctionPatches(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
);
const openingIndex = openings.length
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
const paperGeom = exterior
const rawPaperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const paperGeom = multiWallNodes.nodes.length && exterior
? bevelMultiWallPaper(rawPaperGeom, exterior.centre, multiWallNodes)
: rawPaperGeom;
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
@@ -2010,6 +2364,8 @@ export function wallBodiesGeometry(
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
if (body && multiWallNodes.nodes.length)
body = bevelMultiWallBody(body, multiWallNodes);
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
@@ -2180,7 +2536,11 @@ export function paperOutwardOffsets(
* Expand a polygon outward by per-edge offsets (mirror of inset with flipped
* normals). Used for paper under shared thick walls.
*/
export function outsetContour(poly: number[][], offsets: number[]): number[][] | null {
export function outsetContour(
poly: number[][],
offsets: number[],
multiWallNodes?: MultiWallNodeMap | null,
): number[][] | null {
const n = poly?.length || 0;
if (n < 3 || offsets.length !== n) return null;
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
@@ -2233,9 +2593,11 @@ export function outsetContour(poly: number[][], offsets: number[]): number[][] |
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
?? MITRE_LIMIT * maxO;
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (dist <= MITRE_LIMIT * maxO) {
if (Number.isFinite(dist) && dist <= joinLimit) {
out.push(hit);
continue;
}
@@ -2270,9 +2632,15 @@ export function paperRoomShapesWithWalls(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (exterior) {
const paper = exterior.shell?.length
const rawPaper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const paper = multiWallNodes.nodes.length
? bevelMultiWallPaper(rawPaper, exterior.centre, multiWallNodes)
: rawPaper;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}
+68
View File
@@ -0,0 +1,68 @@
{
"cell_cm": 30,
"node": [0.329166667, 0.141666667],
"rooms": [
{
"id": "room_a",
"poly": [
[0.308333333, 0.2375],
[0.329166667, 0.141666667],
[0.379166667, 0.191666667],
[0.379166667, 0.2375]
]
},
{
"id": "room_b",
"poly": [
[0.379166667, 0.191666667],
[0.329166667, 0.141666667],
[0.408333333, 0.1],
[0.433333333, 0.158333333]
]
}
],
"walls": [
{
"key": "0.320833,0.191667@1.7850",
"cm": 50,
"a": [0.308333333, 0.2375],
"b": [0.329166667, 0.141666667]
},
{
"key": "0.370833,0.120833@2.6572",
"cm": 50,
"a": [0.329166667, 0.141666667],
"b": [0.408333333, 0.1]
},
{
"key": "0.345833,0.237500@0.0000",
"cm": 15,
"a": [0.379166667, 0.2375],
"b": [0.308333333, 0.2375]
},
{
"key": "0.354167,0.166667@0.7856",
"cm": 70,
"a": [0.329166667, 0.141666667],
"b": [0.379166667, 0.191666667]
},
{
"key": "0.408333,0.175000@2.5900",
"cm": 50,
"a": [0.433333333, 0.158333333],
"b": [0.379166667, 0.191666667]
},
{
"key": "0.420833,0.129167@1.1661",
"cm": 50,
"a": [0.408333333, 0.1],
"b": [0.433333333, 0.158333333]
},
{
"key": "0.379167,0.216667@1.5706",
"cm": 25,
"a": [0.379166667, 0.191666667],
"b": [0.379166667, 0.2375]
}
]
}
+22 -1
View File
@@ -321,7 +321,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, 38);
assert.equal(GOLDEN_MATRIX_VERSION, 39);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -372,6 +372,27 @@ test('issue #197 golden keeps the complete junction fixture in Plan and View', (
}
});
test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
const scenario = GOLDEN_SCENARIOS.find(
(item) => item.id === 'multiwall-junction-bevel-view-dark',
);
assert.ok(scenario);
assert.equal(scenario.mode, 'view');
assert.equal(scenario.theme, 'dark');
assert.equal(scenario.multiWallJunction.rays, 3);
assert.equal(scenario.multiWallJunction.node.length, 2);
assert.equal(scenario.multiWallJunction.discardedWedgeProbe.length, 2);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.deepEqual(space.node, scenario.multiWallJunction.node);
const endpointRays = space.walls.filter((wall) => [wall.a, wall.b].some((point) => (
Math.hypot(point[0] - space.node[0], point[1] - space.node[1]) < 1e-8
)));
assert.equal(endpointRays.length >= scenario.multiWallJunction.rays, true);
assert.equal(space.settings.show_borders, true);
});
test('the open color picker golden covers dark mobile and light desktop themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.dialog === 'decor-color');
assert.deepEqual(scenarios.map(({ id, language, theme, viewport }) => ({
+281 -37
View File
@@ -12,6 +12,7 @@ import {
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, openingTunnelGeometry,
@@ -76,6 +77,35 @@ const assertProbeInside = (geom, point, message) =>
const assertProbeOutside = (geom, point, message) =>
assert.ok(geometryProbeCoverage(geom, point) < 1e-7, message || `unexpected body at ${point}`);
const assertBoundedMultiWallBevels = (
rooms, walls, geometry, cell = cellCm, scale = 1,
) => {
const map = buildMultiWallNodeMap(
wallIntervals(rooms, walls, [], pitch, cell, GRID_PITCH, scale),
pitch * scale * 0.04 * 4,
scale,
);
const triangles = multiWallBevelTriangles(map);
assert.ok(triangles.length > 0, 'fixture no longer exercises an oversized multi-wall join');
for (const triangle of triangles) {
const node = map.nodes.find((candidate) => triangle.slice(0, 2).every((point) =>
Math.hypot(
point[0] - candidate.point[0],
point[1] - candidate.point[1],
) <= candidate.limit + 1e-7));
assert.ok(node, 'bevel endpoints escaped every multi-wall node limit');
const base = [
(triangle[0][0] + triangle[1][0]) / 2,
(triangle[0][1] + triangle[1][1]) / 2,
];
assertProbeOutside(geometry.geom, [
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
], 'an excessive multi-wall wedge remains filled');
}
return map;
};
function cornerSplitFixture({
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
path = [[100, 100], [900, 500]],
@@ -509,6 +539,218 @@ test('variable-offset contours keep a local cap at angled positive-to-zero joins
}
});
test('issue #249 bounds the exported three-wall junction with straight bevels', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const walls = structuredClone(fixture.walls);
const nodePoint = fixture.node.map((value) => value * NORM_W);
const before = JSON.stringify({ rooms, walls });
const intervals = wallIntervals(
rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
const nodes = buildMultiWallNodeMap(
intervals, pitch * NORM_W * 0.04 * 4,
NORM_W,
);
const node = nodes.nodes.find((candidate) =>
Math.hypot(
candidate.point[0] - nodePoint[0],
candidate.point[1] - nodePoint[1],
) < 1e-6);
assert.ok(node);
assert.equal(node.rays.length, 3);
closeTo(node.halfDepth, 4.861111111111112, 1e-9);
closeTo(node.limit, MULTI_WALL_JOIN_LIMIT * node.halfDepth, 1e-9);
const localTriangles = multiWallBevelTriangles(nodes).filter((triangle) =>
Math.hypot(
triangle[0][0] - nodePoint[0],
triangle[0][1] - nodePoint[1],
) < node.limit + 1e-6);
assert.equal(localTriangles.length, 2, 'fixture must exercise both oversized wedges');
for (const triangle of localTriangles) {
for (const point of triangle.slice(0, 2)) {
assert.ok(
Math.hypot(point[0] - nodePoint[0], point[1] - nodePoint[1])
<= node.limit + 1e-7,
`bevel endpoint escaped the approved radius: ${point}`,
);
}
assert.ok(
Math.hypot(
triangle[2][0] - nodePoint[0],
triangle[2][1] - nodePoint[1],
) > node.limit,
'fixture no longer contains the excessive mitre being removed',
);
}
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(geometry);
assert.equal(geometry.geom.length, 1, 'the three wall arms became disconnected');
assertProbeInside(geometry.geom, nodePoint, 'the bevel punched a hole at the node');
for (const ray of node.rays) {
assertProbeInside(geometry.geom, [
nodePoint[0] + ray.u[0] * node.halfDepth * 2,
nodePoint[1] + ray.u[1] * node.halfDepth * 2,
], 'an incident wall arm no longer touches the junction');
}
for (const triangle of localTriangles) {
const base = [
(triangle[0][0] + triangle[1][0]) / 2,
(triangle[0][1] + triangle[1][1]) / 2,
];
assertProbeOutside(geometry.geom, [
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
], 'the discarded mitre wedge is still filled');
}
const repeated = wallBodiesGeometry(
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(repeated);
closeTo(geometryDifferenceArea(geometry.geom, repeated.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(repeated.geom, geometry.geom), 0, 1e-7);
assert.equal(JSON.stringify({ rooms, walls }), before, 'geometry mutated saved data');
});
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: [0, 90, 180, 270], halves: [5, 5, 5, 5], bevel: true },
{ angles: [0, 90, 180, 270], halves: [2, 5, 3, 7], bevel: false },
];
const make = ({ angles, halves }, scale = 1) => angles.map((degrees, index) => {
const radians = degrees * Math.PI / 180;
return {
roomId: `r${index}`,
a: [0, 0],
b: [Math.cos(radians) * 100 * scale, Math.sin(radians) * 100 * scale],
key: `ray-${index}`,
kind: 'outer',
cm: halves[index] * 2,
open: false,
half: halves[index] * scale,
};
});
const signature = (map, scale) => map.nodes.map((node) => ({
point: node.point.map((value) => value / scale),
halfDepth: node.halfDepth / scale,
limit: node.limit / scale,
rays: node.rays.map((ray) => [
Math.round(ray.u[0] * 1e9) / 1e9,
Math.round(ray.u[1] * 1e9) / 1e9,
ray.halfDepth / scale,
]),
}));
const makeFanGeometry = (fixture, permuted = false) => {
const scale = NORM_W;
const node = [0.5 * scale, 0.5 * scale];
const points = fixture.angles.map((degrees) => {
const radians = degrees * Math.PI / 180;
return [
node[0] + Math.cos(radians) * 0.3 * scale,
node[1] + Math.sin(radians) * 0.3 * scale,
];
});
let rooms = points.map((point, index) => ({
id: `fan-${index}`,
poly: [node, point, points[(index + 1) % points.length]].map((p) => [...p]),
}));
let walls = [];
for (let index = 0; index < points.length; index++) {
walls = setWallThickness(
walls, node, points[index], fixture.halves[index] * 10, pitch, scale,
);
}
if (permuted) {
rooms = rooms.reverse().map((room) => ({
...room, poly: [...room.poly].reverse(),
}));
walls = walls.reverse();
}
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry, 'multi-wall fan geometry failed');
return { rooms, walls, geometry, node, scale };
};
for (const fixture of cases) {
const source = make(fixture);
const baseline = buildMultiWallNodeMap(source, 1e-6);
assert.equal(baseline.nodes.length, 1);
assert.equal(baseline.nodes[0].rays.length, fixture.angles.length);
closeTo(
baseline.nodes[0].limit,
MULTI_WALL_JOIN_LIMIT * Math.max(...fixture.halves),
1e-9,
);
const permuted = buildMultiWallNodeMap(
[...source].reverse().map((interval) => ({
...interval,
a: [...interval.b],
b: [...interval.a],
})),
1e-6,
);
assert.deepEqual(signature(permuted, 1), signature(baseline, 1));
const production = buildMultiWallNodeMap(make(fixture, 1000), 1e-3, 1000);
assert.deepEqual(signature(production, 1000), signature(baseline, 1));
assert.equal(multiWallBevelTriangles(baseline).length > 0, fixture.bevel);
const fan = makeFanGeometry(fixture);
const fanMap = buildMultiWallNodeMap(
wallIntervals(
fan.rooms, fan.walls, [], pitch, cellCm, GRID_PITCH, fan.scale,
),
pitch * fan.scale * 0.04 * 4,
fan.scale,
);
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');
assertProbeInside(fan.geometry.geom, fan.node, 'fan bevel punched a node hole');
for (const [rayIndex, ray] of fanMap.nodes[0].rays.entries()) {
const armPoint = [
fan.node[0] + ray.u[0] * fanMap.nodes[0].halfDepth * 2,
fan.node[1] + ray.u[1] * fanMap.nodes[0].halfDepth * 2,
];
const coverage = geometryProbeCoverage(fan.geometry.geom, armPoint);
// A straight bevel may legitimately remove the sector-side half of an
// acute arm close to the node; positive masonry on the centreline plus
// the single-component assertion is the required connectivity contract.
assert.ok(coverage > 0.1,
`fan ${fixture.angles.join('/')} ray ${rayIndex} at ${armPoint} coverage=${coverage}`);
}
if (multiWallBevelTriangles(fanMap).length) {
assertBoundedMultiWallBevels(
fan.rooms, fan.walls, fan.geometry, cellCm, fan.scale,
);
}
const permutedFan = makeFanGeometry(fixture, true);
closeTo(geometryDifferenceArea(fan.geometry.geom, permutedFan.geometry.geom), 0, 1e-6);
closeTo(geometryDifferenceArea(permutedFan.geometry.geom, fan.geometry.geom), 0, 1e-6);
}
const twoRay = make({ angles: [0, 55], halves: [5, 7] });
const twoRayMap = buildMultiWallNodeMap(twoRay, 1e-6);
assert.equal(twoRayMap.nodes.length, 0);
const poly = [[0, 0], [10, 0], [8, 9], [0, 8]];
const offsets = [2, 2, 2, 2];
assert.deepEqual(insetContour(poly, offsets, twoRayMap), insetContour(poly, offsets));
assert.deepEqual(outsetContour(poly, offsets, twoRayMap), outsetContour(poly, offsets));
});
test('inwardNormal points into the rectangle', () => {
const poly = [[0, 0], [10, 0], [10, 6], [0, 6]];
const [nx, ny] = inwardNormal(poly, 0); // bottom edge → should point +y
@@ -906,8 +1148,9 @@ 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);
closeTo(geometryArea(geometry.geom), 124991.31944444453, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727303.8194444444, 1e-6);
// #249 intentionally bevels degree-3+ nodes in this older fixture too.
closeTo(geometryArea(geometry.geom), 124495.74029324856, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727248.4374999999, 1e-6);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
'rendering mutated persisted input',
@@ -1126,18 +1369,11 @@ test('production-scale 45° facade keeps an exact unequal-thickness breakpoint',
}
});
test('corner Split keeps the original exterior wall body and paper', () => {
const { original, rooms, walls, before, after } = cornerSplitFixture();
test('corner Split bounds the exterior join created by its third ray', () => {
const { rooms, walls, before, after } = cornerSplitFixture();
assert.ok(before);
assert.deepEqual(geometryBounds(after.geom), geometryBounds(before.geom));
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(before.paperGeom, after.paperGeom), 0, 1e-7);
closeTo(geometryDifferenceArea(after.paperGeom, before.paperGeom), 0, 1e-7);
assertBoundedMultiWallBevels(rooms, walls, after);
const paper = paperRoomShapesWithWalls(
rooms, walls, [], pitch, cellCm, GRID_PITCH,
@@ -1147,7 +1383,7 @@ test('corner Split keeps the original exterior wall body and paper', () => {
const nums = paper[0].path.match(/-?\d+(?:\.\d+)?/g).map(Number);
const paperPoints = [];
for (let i = 0; i < nums.length; i += 2) paperPoints.push([nums[i], nums[i + 1]]);
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(before.geom));
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(after.paperGeom));
const canonical = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH,
@@ -1163,16 +1399,22 @@ test('corner Split clips every divider thickness when exterior walls are absent'
}
});
test('corner Split preserves the facade for thin and thick outer/divider matrices', () => {
test('corner Split keeps facade bounds and bevels every positive-thickness 3-ray matrix', () => {
for (const outerCm of [1, 15, 100]) {
for (const dividerCm of [0, 1, 15, 100]) {
const { original, before, after } = cornerSplitFixture({ outerCm, dividerCm });
const { original, rooms, walls, before, after } = cornerSplitFixture({
outerCm, dividerCm,
});
assert.ok(before);
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
assert.deepEqual(geometryBounds(after.paperGeom), geometryBounds(before.paperGeom));
if (dividerCm > 0) {
assertBoundedMultiWallBevels(rooms, walls, after);
} else {
const centre = closedGeometry(original.poly);
closeTo(geometryDifferenceArea(
difference(before.geom, centre), difference(after.geom, centre),
), 0, 1e-7);
}
}
}
});
@@ -1247,12 +1489,9 @@ test('corner Split keeps unequal exterior arms and is order/id/winding independe
assert.ok(permuted);
closeTo(geometryDifferenceArea(fixture.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, fixture.after.geom), 0, 1e-7);
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
assertBoundedMultiWallBevels(
fixture.rooms, fixture.walls, fixture.after,
);
});
test('Split from a concave vertex does not turn the child mitre into facade', () => {
@@ -1265,25 +1504,30 @@ test('Split from a concave vertex does not turn the child mitre into facade', ()
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split with both endpoints at exterior vertices preserves both corners', () => {
test('Split with both endpoints at exterior vertices bevels both new 3-ray corners', () => {
const fixture = cornerSplitFixture({ path: [[100, 100], [900, 700]], dividerCm: 100 });
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
const map = assertBoundedMultiWallBevels(
fixture.rooms, fixture.walls, fixture.after,
);
assert.equal(map.nodes.length, 2);
});
test('corner Split clean floors are exactly the room union minus canonical walls', () => {
test('corner Split clean-floor contours use the same bounded bevel endpoints', () => {
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 actual = union(...floors.map((floor) => closedGeometry(floor)));
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
closeTo(geometryDifferenceArea(actual, expected), 0, 1e-7);
closeTo(geometryDifferenceArea(expected, actual), 0, 1e-7);
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}`);
}
});
test('corner Split rendering does not materialize or mutate saved geometry', () => {