mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
committed by
claude[bot]
parent
56e3da9b8a
commit
29904df6ee
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
+112
-112
File diff suppressed because one or more lines are too long
Vendored
+112
-112
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user