Compare commits

...
Author SHA1 Message Date
claude[bot] aa166b7982 docs: review document for #249
Validate / docs (push) Failing after 36s
Validate / reuse (push) Successful in 57s
Validate / process-gate (push) Failing after 1m36s
Validate / changes (push) Successful in 1m38s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / backend (push) Skipped
Validate / provenance (push) Successful in 1m47s
Validate / frontend (push) Successful in 9m13s
Validate / golden (push) Failing after 16m25s
Validate / performance_smoke (push) Failing after 20m41s
Validate / smoke (push) Failing after 41m28s
Issue: #249
User-Visible: no
2026-08-23 03:47:17 +00:00
Sergey Matyunin 26fa968477 fix: preserve bounded multi-wall floor geometry
Issue: #249
User-Visible: yes
2026-08-23 06:34:42 +03:00
claude[bot] b8f511d05a docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 03:17:15 +00:00
Sergey Matyunin 062a98a1d8 fix: bound multi-wall junction bevels
Issue: #249
User-Visible: yes
2026-08-23 05:58:27 +03:00
claude[bot] 427ad7feda docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 02:24:30 +00:00
Sergey Matyunin 675294b9a0 docs(spec): address #249 review findings
Issue: #249
User-Visible: no
2026-08-23 05:19:59 +03:00
claude[bot] 748d48424b docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 02:18:37 +00:00
Sergey Matyunin 8d2e00bbbf docs(spec): define bounded multi-wall junctions
Issue: #249
User-Visible: no
2026-08-23 05:07:48 +03:00
30 changed files with 3191 additions and 835 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 R-bounded straight bevel and the old 1.80×H mitre.
const discardedWedge = new DOMPoint(330.3808442725, 148.8560107825);
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
&& card._spaceWalls.length === 7;
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
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
+250 -250
View File
File diff suppressed because one or more lines are too long
+18
View File
@@ -421,6 +421,24 @@ 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.
Canonical masonry replaces each affected local mask with complete physical ray
strips clipped to the room union, retains overlap through the approved radius,
and preserves the established full exterior cut outside the room union. This
prevents the repair from deleting half an incident strip or changing a concave
facade. Paper re-unions the room centre footprint after its facade cut.
`wallBodiesGeometry.roomGeom` caches this repaired room masonry before openings
and independent bodies; clean-floor consumers subtract it from each source room
and clip their fallback, so fill cannot escape the building or silently drop a
floor pocket. Full, Static, hidden Iso, room fills/hover and light barriers
therefore observe the same topology, and cached HA/theme ticks do not rebuild
the map.
Before the exterior offset is built, each saved atomic endpoint splits its
containing collinear union edge. Offset changes are explicit butt steps at that
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
+9
View File
@@ -2,6 +2,15 @@
## 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. Clean room fills now use that same bounded masonry, so
strongly asymmetric junctions cannot push floor outside the building or
leave a missing floor pocket
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
## v1.67.0-beta.3 — 2026-08-23
- Door, window and gate symbols now remain exactly on the wall centreline for
+9
View File
@@ -8,6 +8,15 @@
## Не выпущено
- В узлах, где сходятся три и более физические стены, длинные выступающие
клинья штриховки теперь заменяются ограниченной прямой фаской, в том числе
при разной толщине стен. Сам узел остаётся заполненным и одинаковым в Plan,
View, kiosk, Static, скрытой изометрии и световых/теневых барьерах; обычные
углы из двух стен сохраняют прежнюю форму. Чистая заливка комнаты использует
ту же ограниченную геометрию стен, поэтому сильно различающаяся толщина в
узле не выводит пол за контур здания и не оставляет пропущенный участок пола
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
## v1.67.0-beta.3 — 2026-08-23
- Символы дверей, окон и ворот теперь остаются точно на осевой линии стены при
+16 -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,19 @@ 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 (including literal 15/50/70 cm arms), reversed input,
winding/order changes, production `coordScale = 1000`, unchanged two-ray joins
and the anonymised regression fixture in
`test/fixtures/249-multiwall-junction.json`. The asymmetric corner-Split case
also proves that the union of clean-room floors equals the original room union
minus canonical bounded masonry and that every floor vertex remains inside the
source building.
`demo/smoke_multiwall_junction.mjs` checks Plan/View/kiosk/Static/hidden-Iso
parity, paper and clean-floor presence, shared Glow/sun masonry, cache reuse on
HA/theme ticks, no saved-config mutation, a filled node and the removed old
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
+24
View File
@@ -74,6 +74,26 @@ 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`. Inside the room union, a bounded
mask replaces the legacy ring with the complete finite ray strips, retains their
overlap through `R`, and removes only the remaining excessive pairwise wedge.
Outside the room union, the established full facade cut is preserved. This keeps
the node centre and every arm area-connected without allowing an interior child
mitre to change a concave facade. Ordinary two-ray corners retain the exact
historical `MITRE_LIMIT = 4` contract. This is computed geometry only: saved room
outlines and wall entries are not rewritten.
Clean-floor consumers subtract the cached, repaired canonical room masonry from
their source room and take its outer component. The result is clipped to the
source room on fallback. Openings and independent partitions are deliberately
excluded from this shared `roomGeom`, so a door does not change the room fill
and a detached body cannot punch it. Full and Static render paths reuse the same
structural cache instead of rebuilding wall booleans once per room.
**Hatch density is physical (#230).** The pattern step is a distance on the
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
`8 × (5 / cell_cm)`, which is 9.6 cm at every grid scale and exactly the
@@ -141,6 +161,10 @@ patches. This fallback never rounds persisted rooms, walls or open spans to the
grid and never turns a failure of the mandatory exterior/body/opening passes
into a successful result. One noisy junction therefore cannot remove otherwise
valid masonry, paper, floor faces or light barriers for the whole space (#197).
The same failure isolation covers degree-3+ repair: every node is rebuilt and
committed independently inside its bounded mask. A malformed local candidate
therefore keeps that node's previous body without reverting successful repairs
at unrelated nodes.
Runtime normalisation remains lossless for every positive exact thickness
interval, regardless of its length. The explicit **Optimize plans** maintenance
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+19 -19
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceFingerprint": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "aaab24546dac767fc434c627c0aaecd69458b97872cf7a97ce417686946902d1"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "3cbdf7aa2105d72b2dea0d644862f36118e268d8c8d2e285f1edcffc49648a93"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "57b1407f0ac27aed88940508957454a31e5c577c0985fcda6aeca7f8ed42fe9b"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "2ac9fad407384ba3ef46330a7ffcdcb0cd6ca1e3412e0ad935f58b74f9efde6c"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "d34908f41d5953da3d454dba1b43495cac2483b7bd933062d94261dee7b7de98"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "b729f6ac72181ffe0edc5003e400642f2238ceac6c6423e4add8eb8e45fd4253"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "b68e560bbc3744a911cbf9d37b0eee765d1ef437b27fe65402c38b13921da1fe"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "7393c93fd60666703531f8882fac9b421452aaf04e47f1a12997afae39cfb7e7"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "7017cb7faaa6ce54627b72560c18c616c766b3cc1e5e794771f4dd5dcc517d6f"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "a4282c0efd728658ba5bf6aa66591b60d827b9615d0c219f0ab6232314085bd6",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
"sourceSha256": "7290c3656eb8bb60f2c0f616dc74fcb6b05b4fb20f0fd5e4c4c6479d79a1b6b7",
"imageSha256": "b49138235d348ca6a2d74da5c9891298f44546fd902efcdea49ef89605654b33"
}
}
}
+257
View File
@@ -0,0 +1,257 @@
# CODE-REVIEW-249-r1
- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249) — ограниченная геометрия узла из трёх и более стен
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (этот заход не расходует бюджет: вердикт красный)
- Коммит на ревью: `062a98a1d841a5de633392357dcf0d4264ed3620` ("fix: bound multi-wall junction bevels")
- ТЗ: `docs/specs/249-multiwall-junction-bevel.md`, редакция r2 (SPEC-REVIEW-249-r2, зелёный)
- Материал: `git diff origin/dev...HEAD`, полный (не delta-review — это первый заход этапа code)
## Скоуп проверки
Прочитаны: `docs/SCOPE.md` (J1), `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, ТЗ
`docs/specs/249-multiwall-junction-bevel.md` (все 13 разделов, включая §9 AC1–AC7),
тело issue #249 и все комментарии (аналитика, продуктовые Q&A, ТЗ, оба раунда
spec-ревью, отчёт автора о реализации). Просмотрен весь diff
`src/wall-thickness.ts` (416 добавленных/изменённых строк), новый fixture,
`test/wall-thickness.test.mjs` (переписанные и новые тесты),
`demo/smoke_multiwall_junction.mjs`, изменения golden matrix/harness/test,
обновления `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`,
`docs/CHANGELOG.md`/`.ru.md`.
## Как проверялось
Гейты реализации (соразмерны диапазону diff — трогает `src/**`, требует
check-docs; смоки выбраны по AC4 и по инструменту `smoke-select.mjs`, а не
прогнаны полным набором):
| Гейт | Результат | Прогнал |
|---|---|---|
| `npx tsc --noEmit` | зелёный | да |
| `npm test` | 1117 passed / 0 failed / 0 skipped (автор заявлял 1116+1 skip — итог тестов совпадает, расхождение по skip не расследовано, не блокирует) | да |
| `npm run build` | зелёный | да |
| SHA-256 трёх копий бандла | совпадают: `9e2c89fd...eefe05ec7` | да, сверил вручную |
| `node scripts/check-docs.mjs` | зелёный (7 файлов, 10 внешних ссылок) | да, обязателен — diff трогает `src/**` |
| `node demo/smoke_multiwall_junction.mjs` | зелёный, 15/15 | да — прямой AC4 гейт |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | нашёл 1 прямое совпадение: `smoke_decor_layer_order.mjs` (символ `roomPoly`) | да |
| `node demo/smoke_decor_layer_order.mjs` | зелёный, 26/26 | да |
| `node demo/smoke_split_corner_wall.mjs` | зелёный, 14/14 | да — тема пересекается с новым junction-кодом |
| `node demo/smoke_junction_patch_resilience.mjs` | зелёный, 14/14 | да |
| `node demo/smoke_wall_junctions.mjs` | зелёный, 12/12 | да |
**Не прогонялось** (предрелизные гейты по PROCESS.md §8, не гейт ревью):
`npm run golden:verify` (полный), `npm run golden:accept`, performance-профили,
полный `smokes:select`/167 смоков, backend/HA-harness (Python не тронут).
Golden baseline не принимался — соответствует отчёту автора.
Все заявленные автором числа (SHA-256 бандла, счётчики smoke JSON, состав
`smoke-select`) подтверждены самостоятельным прогоном, а не переписаны со слов
автора.
Дополнительно я самостоятельно воспроизвёл геометрию (см. находку H1) через
compiled `test-build/wall-thickness.js`, вызывая экспортированные
`roomWallProfile`/`insetContour`/`outsetContour`/`buildMultiWallNodeMap`
напрямую, и сравнил результат с тем же вызовом на `origin/dev` (через
временный `git worktree`) — оба прогона выполнены, артефакты и worktree
удалены до завершения ревью, в репозитории не оставлено файлов.
## Находки
### H1 (High, блокирует). `innerContourForRoom` (clean floor) получает
геометрически неверную bevel-точку на узлах с несимметричной толщиной
сходящихся стен — floor выходит за пределы контура здания
**Точка отказа:** `src/wall-thickness.ts:1066-1069` (fallback-ветка
`insetContour`/`outsetContour`, идентичный код в обеих функциях) —
существующий (не новый) код, который для чрезмерной митры кладёт две точки
`poly[i] + normal·offset` независимо для каждого ребра, без проверки, что
результат остаётся внутри многоугольника. Раньше эта ветка почти не
срабатывала для узлов 3+ (порог был `MITRE_LIMIT×maxO`, то есть до `4×`
толщины), поэтому дефект был латентным. #249 делает порог `1.25×H` — то есть
именно тем механизмом, который теперь регулярно уводит обычные T/L-стыки
перегородки с внешней стеной в эту ветку, — и подставляет туда несимметричные
толщины (перегородка много толще внешней стены), при которых точка одного
ребра уходит по нормали на всю величину offset этого ребра, что при остром угле
между рёбрами пробивает противоположную сторону многоугольника насквword.
**Воспроизведение (числами, не «на глаз»):** тот же `cornerSplitFixture`, что
уже используется в тестах `test/wall-thickness.test.mjs` (`outerCm: 15`,
`dividerCm: 100`, path по умолчанию — то есть комбинация, реально
прогоняемая гейтом «corner Split preserves facade... every positive-thickness
3-ray matrix», строка `outerCm=15, dividerCm=100`):
```
node — узел (100, 100), угол между наружным ребром и перегородкой ≈ 26.6°
H = 41.6667 (полутолщина перегородки), R = 1.25×H = 52.0833
roomWallProfile('source').poly = [[100,100],[900,100],[900,500]]
roomWallProfile('source').offsets = [6.25, 6.25, 41.6667]
insetContour(poly, offsets, multiWallNodes) →
[118.63389981249824, 62.7322003750035], ← точка floor ВНЕ здания
[100, 106.25],
[893.75, 106.25],
[893.75, 450.2902504687544]
```
Комната "source" — часть прямоугольника `y ∈ [100,700]`. Точка
`[118.63, 62.73]` имеет `y = 62.73 < 100`, то есть лежит **выше верхнего
края здания**, вне контура вообще (не просто вне комнаты — вне здания
целиком). Расстояние точки от узла — ровно `41.6667` (халф-толщина
перегородки), то есть формально `≤ R` (52.08), поэтому проверка §7.2.4
(«join-вершина не дальше `R + epsilon`») этот дефект не ловит: контракт
ограничивает только *расстояние*, а не сторону многоугольника, а фактическая
точка получена смещением от узла по нормали чужого (перегородочного) ребра на
всю его полутолщину — без проверки, что это не пробивает соседнее ребро.
Union-уровневый `multiWallBevelTriangles`/`bevelMultiWallBody` (новый код
#249, добавляющий вырезание лишнего клина из финального wall body) эту же
несимметрию на этом узле не ломает так явно, потому что `wallBodiesGeometry`
дополнительно пересекает итоговое тело с `exterior.centre`
(`src/wall-thickness.ts:2364-ish`, `if (body && exterior) body =
intersection(body, exterior.centre)`), что подрезает вылезшую геометрию
обратно к контуру здания. У `innerContourForRoom` (clean floor) такой
защитной пересечки нет вовсе — функция отдаёт результат `insetContour`
напрямую (после диффа: `src/wall-thickness.ts` в районе
`innerContourForRoom`), поэтому дефект долетает до потребителя без всякой
подрезки.
**Проверка «было/стало»:** тот же вызов на `origin/dev` (до #249, через
временный `git worktree`, тот же `cornerSplitFixture`, идентичный код helper'ов)
даёт `insetContour` без всплеска — расхождение строгого инварианта
`floor_union == original.poly − wall_body` составляет `≈ 6.5e-11` (шум
плавающей точки, тест на dev проходит с допуском `1e-7`). На коммите #249 то
же расхождение — `≈ 1686.24` (на 10 порядков больше допуска), причём вся
дельта — это именно тот вырвавшийся за пределы здания клин.
**Почему это не поймано авторскими гейтами:** этот же fixture
(`outerCm=15`, `dividerCm=100`) используется в тесте
`test/wall-thickness.test.mjs` — было
`'corner Split clean floors are exactly the room union minus canonical
walls'` со строгим `closeTo(geometryDifferenceArea(actual, expected), 0,
1e-7)` в обоих направлениях. Автор **заменил** этот тест на
`'corner Split clean-floor contours use the same bounded bevel endpoints'`
(новое имя), где вместо точного геометрического равенства теперь проверяется
только «каждая bevel-точка присутствует где-то среди вершин floor-контура»
(`floors.some((floor) => floor.some((point) => distance < 1e-7))`) — эта
проверка тривиально проходит, даже когда floor-контур неверно вышел за
пределы здания, поскольку сама вырвавшаяся точка — это ровно та «bevel-точка»,
которую тест ищет. Ни `demo/smoke_multiwall_junction.mjs`
(`cleanFloorConsumerIsPresent` проверяет только наличие path, не его форму),
ни новый golden-сценарий (`fill_mode: 'none'` — floor не рисуется вовсе)
дефект не покрывают. AC4 ТЗ прямо называет floor/room fills/room hover
каноническим потребителем той же исправленной геометрии (§7.4) — это
нарушено.
**Почему это Blocking, а не косметика:** это ровно тот же класс дефекта, для
которого написано ТЗ (клин, вылезающий за пределы стены), только
переехавший с внешнего контура кладки на внутренний контур пола, причём для
совершенно реалистичной конфигурации — перегородка заметно толще наружной
стены, встречающая её под острым/умеренным углом. Инструмент, которым это
создаётся («Split» комнаты по диагонали с последующей установкой толщины
перегородки), — штатный редакторский путь, не синтетика теста.
**Что нужно автору:** переоценить fallback-ветку `insetContour`/
`outsetContour` (строки 1066–1069) для случаев, когда единичное смещение по
нормали одного ребра на всю величину его offset выходит за пределы,
образуемые соседним ребром — либо ограничивать длину смещения проекцией на
соседнее ребро/дистанцией до узла, либо (как минимум) вернуть
`innerContourForRoom` защитное пересечение с исходным полигоном комнаты,
аналогичное тому, что `wallBodiesGeometry` уже делает для wall body. Отдельно
нужно вернуть строгую (не ослабленную) проверку инварианта
`floor_union == room_union − canonical_wall_body`, которая существовала до
этого коммита — она и обнаружила бы дефект сама.
---
### M1 (Medium, в скоупе — чинится в этом же цикле). AC2 не покрыт буквально
заявленным кейсом «три стены 15/50/70 см» (три РАЗНЫЕ толщины у трёх лучей)
Спецификация (§9, AC2) требует минимум четыре сценария, включая «трёх стен
15/50/70 см» — то есть узел, где ВСЕ ТРИ луча имеют разную толщину (без
повторов). Матрица в `test/wall-thickness.test.mjs`
(`'issue #249 node classification is order, direction and scale
independent'`) покрывает: 3 равных луча, 3 луча с halves `[7,5,5]`
(**только два разных значения** — 7 и 5,5), 4 равных луча, 4 луча
`[2,5,3,7]` (все разные). Ни один 3-лучевой случай с тремя различными
попарными halfDepth не тестируется явно; сам fixture AC1 тоже
50/50/70 см (две толщины совпадают). Кейс `[7,5,5]` покрывает оба
направления одной несимметричной пары через обход по кругу, но не покрывает
тройку из трёх взаимно разных величин (нет пары «средний-большой» без
повторов), которую владелец в issue привёл как конкретный числовой пример
(«например 15/50/70»).
Учитывая находку H1 (именно несимметрия толщин на многолучевом узле — источник
дефекта), это не формальная придирка: тест с тремя различными halfDepth,
особенно при остром угле, с высокой вероятностью тоже поймал бы H1 или его
аналог. Чинится точечно — один дополнительный кейс в существующем
`cases`-массиве с тремя различными halves, без новых продуктовых вопросов.
## Что проверено и корректно
- **AC1** (экспортный fixture #249, узел 50/50/70): unit
`'issue #249 bounds the exported three-wall junction with straight
bevels'` — воспроизведён и зелёный; `H = 4.8611`, все join-вершины `≤
1.25×H + epsilon`, один связный компонент, повторный расчёт даёт тот же
результат, вход не мутируется (deep-equal до/после). Для ЭТОГО конкретного
узла (толщины 50/50/70, угол не острый) floor-консьюмер H1 не проявляет —
проверил отдельно (`innerContourForRoom` на этом fixture даёт точки на
разумном расстоянии 3.5–4.9 от узла, без выхода за пределы), дефект
специфичен для более несимметричных/острых конфигураций.
- **AC3** (двухлучевые узлы не меняются): существующий 2-лучевой набор
тестов не тронут (кроме одного, №197-фикстура, у которого расширенный
fixture содержит собственный узел степени 3+ — это отдельный, задокументированный
в тесте, легитимный сдвиг площади, не 2-лучевой регресс). Отдельный
тест `twoRay` подтверждает `insetContour`/`outsetContour` с пустой
multi-wall картой идентичны вызову без карты.
- **AC4** (общий body для всех поверхностей): `demo/smoke_multiwall_junction.mjs`
зелёный по всем 15 полям — Plan/View/kiosk/Static/hidden-Iso совпадают
байт-в-байт по пути, light barriers используют ту же masonry, HA-tick и
смена темы не перестраивают topology (кэш переиспользуется).
- **AC5** (golden фиксирует видимый результат): новый сценарий
`multiwall-junction-bevel-view-dark` с semantic assertions (узел заполнен,
отброшенный клин пуст, 3 луча) — не может пройти на пустом/неверном кадре;
версия матрицы поднята (38→39); `test/golden-matrix.test.mjs` проверяет
форму сценария.
- **AC6** (данные не мутируются): подтверждено deep-equality до/после в новом
unit-тесте и в `'corner Split rendering does not materialize or mutate
saved geometry'`.
- **AC7** (гейты реализации): все четыре зелёные, включая build/typecheck —
перепроверено самостоятельно, не только со слов автора.
- **Документация**: `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`,
`docs/TESTING.md` обновлены по существу (не формально) и соответствуют
фактическому коду; `docs/CHANGELOG.md`/`.ru.md` оба обновлены в этом же
коммите при `User-Visible: yes`; трейлеры `Issue: #249` и
`User-Visible: yes` на месте.
- **Union-уровневая коррекция** (`multiWallBevelTriangles`/
`bevelMultiWallBody`) корректна для симметричных/сбалансированных узлов
(проверено чтением кода и подтверждено зелёными таргетированными unit- и
smoke-тестами) — проблема локализована именно в старой fallback-ветке
`insetContour`/`outsetContour`, не в новой union-логике.
## Чего не проверял
- Полный `npm run golden:verify`/`golden:accept`, полный набор из 167 смоков,
performance-профили — предрелизные гейты по PROCESS.md §8, не обязаны быть
прогнаны на этом заходе; риск R1 самой спеки («широкий blast radius для
T-стыков») явно требует именно полного golden-прогона перед бетой, и находка
H1 показывает, что этот прогон обязателен и по причине, шире описанной в
спеке — не только визуальный дрейф, но и потенциальный явный дефект.
Golden-сценарий #249 не поймает H1 — там `fill_mode: 'none'`.
Не устанавливал/не запускал этот набор сам.
- Не искал системно другие узлы (существующие golden fixtures, реальные
сохранённые планы) с похожей несимметрией толщин/острым углом, где H1 мог
бы проявиться иначе (например, в `openingWallIndex`/tunnel-геометрии рядом с
таким узлом) — за рамками того, что доказывает конкретный repro.
- Backend/Python — не тронут диффом, HA-harness не требовался и не запускал.
## Резюме
Реализация в целом добросовестно покрывает контракт ТЗ (единая node map,
union-уровневая коррекция клина, документация, golden/smoke на заявленном
экспортном сценарии) — но именно в том месте, где спецификация явно
предупреждала о риске (§11 R2: «фаска может создать щель или ложную
кладку… визуально малый дефект»), обнаружен подтверждённый воспроизведением
дефект (H1), который автор не поймал, потому что ослабил единственный тест,
способный его обнаружить. Это блокирующая находка — заход не может быть
зелёным.
+212
View File
@@ -0,0 +1,212 @@
# CODE-REVIEW-249-r2
- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249) — ограниченная геометрия узла из трёх и более стен
- Этап: code (PROCESS.md §2.7)
- Заход: r2 · блокирующих циклов израсходовано 1 из 4 (r1 — красный, потратил 1; этот заход зелёный и бюджет не расходует, #227)
- Коммит на ревью: `26fa9684777b7557d384883f18bb3d097a171caf` ("fix: preserve bounded multi-wall floor geometry")
- База delta-review: `062a98a1d841a5de633392357dcf0d4264ed3620` (коммит, получивший CODE-REVIEW-249-r1, красный)
- ТЗ: `docs/specs/249-multiwall-junction-bevel.md` (SPEC-REVIEW-249-r2, зелёный)
- Материал: `git diff 062a98a1d841a5de633392357dcf0d4264ed3620..26fa9684777b7557d384883f18bb3d097a171caf` — целевой delta-review по PROCESS.md §2.10, не полный прогон
## Скоуп проверки (по дельте)
Автор заявил исправление H1 (floor выходил за пределы здания на несимметричных
многолучевых узлах) и M1 (AC2 не покрывал буквальный кейс «15/50/70 см», три
взаимно разные толщины). Дельта r1→r2 касается:
- `src/wall-thickness.ts`: переписан `bevelMultiWallBody`/`multiWallBevelTrianglesAt`
(пофрагментная перестройка каждого узла в ограниченной маске вместо
агрегированного вырезания треугольников), новая `clipInnerContourToRoom` /
`largestOuterContour`, `innerContourForRoom` получил необязательный параметр
`sharedRoomWallGeometry` и теперь строит чистый пол как
`difference(room.poly, roomGeom)` с защитным клипом на fallback,
`wallBodiesGeometry`/`wallBodiesUnionPath` возвращают новое поле `roomGeom`
(кэшируемая канонические кладка комнат до вырезания проёмов и independent
bodies).
- `src/houseplan-card.ts`: 6 из 8 вызовов `innerContourForRoom` в путях
рендера пробрасывают `this._wallUnionGeometry()?.roomGeom` пятым
дополнительным аргументом.
- `test/wall-thickness.test.mjs`: восстановлен строгий инвариант
`floor_union == room_union − canonical_bounded_walls` (тест, который H1
требовал вернуть), добавлен матричный кейс `halves: [1.5, 5, 7]` (три
взаимно разные толщины).
- `demo/smoke_multiwall_junction.mjs`: пересчитана координата
`discardedWedge` (геометрия узла сместилась из-за новой retain-to-limit
логики; проверил формулой в комментарии — не ослабление, а пересчёт).
- `docs/ARCHITECTURE.md`, `docs/WALL-THICKNESS.md`, `docs/TESTING.md`,
оба `docs/CHANGELOG*.md`, docs-скриншоты — обновлены по существу в этом же
коммите.
Не в дельте и не перепроверялось заново: golden matrix/harness, spec-файл,
общая продуктовая рамка ТЗ, `docs/SCOPE.md` — см. «Унаследовано из r1».
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **H1** (High). `innerContourForRoom` отдавал floor-точку вне здания на несимметричных многолучевых узлах (`cornerSplitFixture({outerCm:15, dividerCm:100})`, extra=1686.24 против допуска 1e-7) | `innerContourForRoom` теперь вычисляет чистый пол как `difference(room.poly, roomGeom)` — канонической, уже ограниченной `1.25×H` кладки, с защитным `clipInnerContourToRoom` на любом fallback-пути; `roomGeom` кэшируется в `wallBodiesGeometry`/`wallBodiesUnionPath` и пробрасывается в 6 из 8 сайтов рендера | `src/wall-thickness.ts:1797-1835` (сама функция), `src/houseplan-card.ts` (6 обновлённых call site); тест `'corner Split clean floors equal room union minus canonical bounded walls'` (`test/wall-thickness.test.mjs:1522`) — **проверил сам**: на `062a98a` (до фикса, через временный `git worktree`) этот же тест падает с `extra=1686.2432660548943` — ровно то число, что называла находка H1; на `26fa968` тест зелёный |
| **M1** (Medium, в скоупе). AC2 не покрывал буквальный кейс «15/50/70 см» — три взаимно разные толщины | В матрицу `cases` добавлен `{ angles: [45, 102, 230], halves: [1.5, 5, 7], bevel: true }` — три genuinely разные halfDepth, прогоняется через тот же полный цикл проверок, что и остальные кейсы (baseline/permutation/production-scale/`multiWallBevelTriangles`/`makeFanGeometry` + `wallBodiesGeometry` с реальным bevel) | `test/wall-thickness.test.mjs:628` (новая строка массива `cases`), выполнение — `test/wall-thickness.test.mjs:686-731` (тот же `for`-цикл, без специального исключения для нового кейса) |
## Как проверялось
Гейты, соразмерные диапазону дельты (диапазон трогает `src/**`, требует
`check-docs`; смоки — по `smoke-select.mjs` относительно базы r1, не по
полному набору):
| Гейт | Результат | Прогнал |
|---|---|---|
| `npx tsc --noEmit` | зелёный, без вывода | да |
| `npm test` | 1117 passed / 0 failed / 0 skipped | да |
| `npm run build` | зелёный | да |
| SHA-256 трёх копий бандла | совпадают: `69b40f0508a6d3372b6d3acd165a643a27d5d0b11fe845fb4c002e13696a5466` (совпадает с заявленным автором) | да, вручную |
| `node scripts/check-docs.mjs` | зелёный (7 файлов, 10 внешних ссылок) | да, обязателен — diff трогает `src/**` |
| `node --test` целевого теста `'corner Split clean floors equal room union minus canonical bounded walls'` | зелёный на `26fa968`; **красный на `062a98a`** (`extra=1686.2432660548943`) — проверено через временный `git worktree`, удалённый по завершении | да, дисциплина «тест умеет падать» подтверждена явно |
| `node demo/smoke_multiwall_junction.mjs` | зелёный, 15/15 | да — прямой AC1/AC4 гейт |
| `node scripts/smoke-select.mjs --base 062a98a --head HEAD` | 5 прямых совпадений: `smoke_glow_fail_dark`, `smoke_junction_patch_resilience`, `smoke_multiwall_junction`, `smoke_wall_thickness_transition`, `smoke_zero_divider_taper` (все ← символы `wallBodiesGeometry`/`_wallUnionGeometry`) | да |
| `node demo/smoke_glow_fail_dark.mjs` | зелёный, 4/4 полей | да |
| `node demo/smoke_wall_thickness_transition.mjs` | зелёный, 11/11 полей | да |
| `node demo/smoke_zero_divider_taper.mjs` | зелёный, 13/13 полей | да |
| `node demo/smoke_junction_patch_resilience.mjs` | зелёный, 15/15 полей | да — переисполнил заново на этом коммите, не унаследовал из r1 |
**Не прогонялось** (предрелизные гейты по PROCESS.md §8, не гейт ревью):
полный `npm run golden:verify`/`golden:accept`, полный набор из 170 смоков,
performance-профили, backend/HA-harness (Python не тронут дельтой). Golden
baseline не принимался.
## Что проверено и корректно (по дельте)
- **H1 закрыта фактически, не только по имени теста.** Числовое
воспроизведение из CODE-REVIEW-249-r1 (`extra=1686.24`, `missing≈0`)
подтверждено моим независимым прогоном того же теста на коммите r1 — тест
падает с точно той же величиной, значит новый тест действительно
чувствителен к дефекту, а не переименован без содержания. На `26fa968` тот
же тест зелёный с допуском `1e-7` в обе стороны плюс отдельная проверка,
что каждая вершина пола остаётся внутри `[100..900]×[100..700]`.
- **Механизм фикса не подвержен тому же классу дефекта.** `bevelMultiWallPaper`
и путь `difference(room.poly, roomGeom)` только *вычитают* заранее
вычисленные ограниченные фигуры — в отличие от старой ветки
`insetContour`/`outsetContour`, которая *добавляла* смещение по нормали без
проверки соседнего ребра (корень H1). `clipInnerContourToRoom` на любом
fallback-пути дополнительно пересекает результат с `pr.poly`, так что
выход точки за пределы контура комнаты структурно исключён на уровне самой
функции, а не только на happy path.
- **M1 закрыта содержательно.** Новый кейс `[1.5, 5, 7]` — три взаимно разные
halfDepth — проходит через тот же строгий цикл (baseline/permutation/
production-scale `coordScale=1000`/реальный `wallBodiesGeometry` с fan-топологией
из 3 комнат), что и остальные кейсы матрицы, без специального послабления.
- **AC4 (общий body для всех поверхностей) переподтверждён.** Затронутые этой
дельтой пути — `bevelMultiWallPaper` (paper) и clean-floor consumer в
`innerContourForRoom` — проверены смоком `smoke_multiwall_junction.mjs`
(`paperRemainsSolid`, `cleanFloorConsumerIsPresent`, `planUsesCanonicalPath`,
parity Plan/View/kiosk/Static/hidden-Iso) — все зелёные.
- **6 из 8 вызовов `innerContourForRoom`** в `src/houseplan-card.ts` (полный
рендер: floor fills, room hover/labels, decor clip, glow/light) корректно
пробрасывают `this._wallUnionGeometry()?.roomGeom` — проверено построчно
(`grep -n "innerContourForRoom("`, 8 вызовов, 6 с новым аргументом).
- **AC3 (двухлучевые узлы)** дельтой не задета: путь для `multiWallNodes.nodes.length
=== 0` не изменился (`return inset` напрямую, как раньше); подтверждено тем,
что полный `npm test` зелёный без правок существующих двухлучевых ожидаемых
значений.
- **Трейлеры и changelog.** Коммит `26fa968` несёт `Issue: #249` и
`User-Visible: yes`; `docs/CHANGELOG.md` и `.ru.md` правлены в этом же
коммите.
- **Документация обновлена по существу**, не формально: `WALL-THICKNESS.md` и
`ARCHITECTURE.md` описывают именно новый механизм (`roomGeom`, вычитание, а
не старую агрегированную triangle-diff схему) — сверил текст с кодом.
## Находки
Блокирующих (High) нет. Medium в скоупе — нет.
### L1 (Low, снимаю без правки, с записью). Два из восьми вызовов `innerContourForRoom` не получили `sharedRoomWallGeometry`
`src/houseplan-card.ts:8502` (`_rszEdgeLabels`) и `:8549` (`_rszScaleLabels`) —
подписи площади/размера во время **живого drag** resize/scale — вызывают
`innerContourForRoom` с неполным списком комнат (`res.polys`, только реально
подвинутые комнаты, либо вовсе один элемент для scale) и без пятого
аргумента. Проверил обе стороны эффекта:
1. **Корректность.** Даже без `sharedRoomWallGeometry`, `innerContourForRoom`
сам вычисляет `wallBodiesGeometry(rooms, ...)` на этом же неполном списке
и, если результат не подойдёт, уходит в `clipInnerContourToRoom(inset,
pr.poly)` — обе ветки ограничены пересечением с `pr.poly` этой же комнаты,
поэтому именно дефект H1 (точка вне здания) здесь физически не
воспроизводим: защита в самой функции универсальна, а не завязана на то,
передан ли кэш. Возможное следствие неполного списка комнат — узел на
границе с посторонней (не двигающейся) комнатой может быть на мгновение
классифицирован как двухлучевой вместо 3+-лучевого в подписи площади во
время drag; это не влияет на итоговую сохранённую геометрию и
самоисправляется на `pointerup`, когда рендер снова читает полный
`_wallUnionGeometry()`.
2. **Производительность.** Раз кэш не передан, а в комнатах есть узел
3+ (обычная ситуация, не экзотика — см. текст issue), `innerContourForRoom`
пересчитывает `wallBodiesGeometry()` заново при каждом вызове внутри
`_rszEdgeLabels`/`_rszScaleLabels`, то есть на каждое `pointermove` во время
resize-драга, отдельно от того, что тот же union уже пересчитывается для
рендера стен через `_wallUnionGeometry()` (кэш которой инвалидируется на
каждый `pointermove`, потому что `_rszApplyPreview` увеличивает
`_cfgEpoch` перед вызовом этих функций). Это дублирующая, но не новая по
порядку величины нагрузка (сам рендер стен уже пересчитывает то же самое
на каждый кадр драга) и касается только редакторского инструмента resize.
Не блокирую и не прошу правку в этом цикле: последствие ограничено overlay-
подписью во время интерактивного admin-only drag (§ SCOPE.md — редакторы вне
View), не влияет ни на один AC, самоисправляется по окончании драга, и
дополнительная нагрузка того же порядка, что уже существующий пересчёт стен
на каждый кадр. Снимаю как Low с этой записью; если автор захочет, дешёвое
улучшение — прокинуть `this._wallUnionGeometry()?.roomGeom` и в эти два
вызова, поскольку `_curSpaceCfg`/`_spaceModel()` в момент вызова уже отражает
живой `_rszPreview` (полный список комнат), в отличие от `res.polys`.
## Чего не проверял
- Полный `npm run golden:verify`/`golden:accept`, полный набор из 170 смоков,
performance-профили, backend/HA-harness — предрелизные гейты по
PROCESS.md §8, не обязаны быть прогнаны на этом заходе; риск R1 спеки
(«широкий blast radius для T-стыков») и в r1, и здесь требует полного
golden-прогона перед бетой отдельно от этого цикла.
- Не искал новых экземпляров L1-паттерна (неполный список комнат) вне
`_rszEdgeLabels`/`_rszScaleLabels` — это единственные два незатронутых
дельтой вызова, остальные шесть проверены построчно.
- Не гонял смоки, не входящие в прямые совпадения `smoke-select` для этой
дельты и не относящиеся напрямую к AC (`smoke_split_corner_wall.mjs`,
`smoke_wall_junctions.mjs` — были зелёными в r1 на функции, которые эта
дельта не меняла повторно; не перезапускал, доверяю r1 по §2.10, дельта их
не касается).
- Backend/Python — не тронут диффом.
## Унаследовано из r1 (без повторной проверки)
Источник: `docs/reviews/CODE-REVIEW-249-r1.md`, вердикт по коммиту `062a98a`.
- **Продуктовая рамка и AC как формулировки** (§7.1 ТЗ, разделы 1–9 спека) —
дельта не меняет ни ТЗ, ни AC; полная перепроверка соответствия ТЗ пройдена
в r1.
- **AC5 (golden фиксирует видимый результат)** — golden matrix/harness файлы
не тронуты дельтой r1→r2 (нет изменений в `demo/golden/**`); вывод r1
«golden-сценарий синтаксически валиден, но заведомо не поймает H1 (`fill_mode:
'none'`)» остаётся в силе и не пересматривался.
- **AC7 (гейты типа build/typecheck)** — методика не изменилась; перепрогнаны
заново в этом раунде (см. таблицу выше), не просто унаследованы со слов.
- **`multiWallBevelTriangles`/классификация узла (степень, epsilon, порядок,
winding, production scale) вне нового M1-кейса** — не переоценивались
повторно вне того, что уже покрывает общий `for`-цикл матрицы; сам механизм
классификации (`buildMultiWallNodeMap`) дельтой не тронут.
- **Смоки `smoke_split_corner_wall.mjs`, `smoke_wall_junctions.mjs`** —
зелёные результаты r1 приняты без повторного прогона: дельта не касается
кода, который эти смоки покрывают (только H1/M1-специфичные пути).
- **Отсутствие влияния на backend/i18n/миграцию** — подтверждено в r1 и не
оспаривается дельтой (Python/i18n файлы не тронуты ни r1, ни r2).
## Резюме
Обе блокирующие находки r1 закрыты предметно: H1 — новым путём вычисления
чистого пола (`difference` от кэшированной ограниченной канонической кладки, с
универсальным защитным клипом), проверенным через явную бисекцию «тест падает
на старом коммите с той же величиной, что называла находка, и зелёный на
новом»; M1 — буквальным кейсом трёх взаимно разных толщин, прогнанным через
тот же строгий матричный цикл. Новая находка L1 (Low) касается двух
call-site, не получивших кэш геометрии в живом resize-драге; она
структурно не может воспроизвести H1 (защита находится в самой функции) и
ограничена overlay-подписью во время admin-only интерактивного
инструмента — снимаю с запиской, без возврата на цикл.
+261
View File
@@ -0,0 +1,261 @@
# SPEC-REVIEW-249-r1
- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249) — «Стык трёх стен разной толщины рисуется шипом наружу вместо соединённого узла»
- ТЗ: `docs/specs/249-multiwall-junction-bevel.md`, ветка `issue/249-multiwall-junction-bevel`, коммит `8d2e00bbbfbb9151ca2079eaf6e51f916cb59eff`
- Этап: spec (PROCESS.md §2.4), заход r1, трек обычный (не `small`)
- Вердикт: **жёлтый**
## Скоуп ревью
Первый заход — разбор полный. Разделов «Закрытие раунда» и «Унаследовано»
нет: PROCESS.md §2.10 применяется начиная со второго цикла, а вердикт
предыдущего раунда по этой задаче отсутствует (в комментариях issue есть
только этапы аналитики и продуктовых Q&A, ревью ТЗ ещё не проводилось).
Проверено по порядку из инструкции:
1. `docs/SCOPE.md` — сценарий закрывает J1 («Show the whole home... live
spatial overview»): дефект постоянно виден в View/kiosk, где View — сам
продукт для двух из трёх персон. Не найдено конфликтов с `TOUCH-SUPPORT.md`
— правка не создаёт и не меняет интеракций.
2. `AGENTS.md`, `PROCESS.md` §2.4, §2.5, §7.1, §4, §12 — обязательные разделы
ТЗ, критерии DoR, формат вердикта, лимит циклов.
3. Тело issue #249 и все пять комментариев: воспроизведение и корневая
причина автором ТЗ, продуктовые вопросы Q1/Q2 с предложенными default,
решения владельца, объявление о готовом ТЗ.
4. `docs/USER-GUIDE.ru.md` — термины «стык», «mitre/bevel» (строка 487)
уже существуют и согласуются со словарём ТЗ («митра», «фаска»); новой
пользовательской терминологии эта задача не вводит (чистый bugfix
геометрии, не новая UX-возможность).
5. Канонический документ подсистемы — `docs/WALL-THICKNESS.md` целиком
(277 строк): модель `walls`, рост ±½, единый structural pass, `MITRE_LIMIT`,
virtual-junction patches и их fail-isolation (#197), тестовый раздел §8.
## Как проверялось
Ревью состязательное: каждое техническое утверждение ТЗ сверялось с кодом на
`HEAD`, а не принималось на слово автора.
- `src/wall-thickness.ts` — подтверждены все процитированные в issue и ТЗ
места: `export const MITRE_LIMIT = 4` (строка 46); `insetContour()` со
сравнением `dist <= MITRE_LIMIT * maxO` (строка 1030, функция начинается на
967); `outsetContour()` с идентичным сравнением (строка 2238, функция
начинается на 2183); митра-лимит для independent-junction patches (строка
1853); лимит на строке 701. Все совпадают с номерами строк, названными в
теле issue и ТЗ §2.
- Строка 626: `same.halfDepth = Math.max(same.halfDepth, halfDepth)` —
подтверждает, что «совпадающие сонаправленные фрагменты берут максимальную
полутолщину» (ТЗ §6, `ray`) не изобретённое правило, а уже существующий в
коде паттерн дедупликации интервалов.
- `src/wall-merge.ts` — `junctionAt()` (строка 101) и `spaceMergeGeometry()`
(строка 278) существуют и, как верно указано в ТЗ §2/§6, работают с
independent partitions, а не с чисто комнатными узлами — что и объясняет,
почему баг не ловится существующим junction-кодом.
- Пересчитаны числа из ТЗ §2 самостоятельно: `cell_cm=30` даёт множитель
`wallCmToUnits`, при котором 50 см → 3.4722, 70 см → 4.8611 render units;
измеренная join-вершина `8.7312 / 4.8611 = 1.7958 ≈ 1.80×H`, что совпадает
с текстом ТЗ и подтверждением владельца в третьем комментарии issue. Числа
внутренне непротиворечивы и не выданы за факт без проверки.
- `docs/WALL-THICKNESS.md` §3 «Body render»: «Mitre joins; bevel when the
mitre spike exceeds `MITRE_LIMIT × thickness`» и §8 (тестовый охват) —
подтверждают, что описанный в ТЗ переход mitre→bevel является
существующим контрактом для степени 2, который ТЗ §7.3 явно сохраняет
без изменений.
- Сопоставлены заголовки шести последних полноформатных ТЗ (`248`, `244`,
`239`, `229`, `197`, `198`) — все имеют отдельные разделы «UX, i18n,
accessibility и touch», «План [реализации и] автотестов» и «Риски,
производительность и rollback/security». См. находки ниже.
## Разделы §7.1 — проверка полноты
| Требуемый раздел | В ТЗ #249 | Комментарий |
|---|---|---|
| Сценарий | §1 | ок |
| Что человек увидит до/после | §3 | ок, размещён после «проблемы» — порядок не регламентирован |
| Проблема | §2 | ок (воспроизведение + корневая причина) |
| Скоуп и не-скоуп | §5 | ок |
| Контракт поведения | §7 | ок по объёму, но см. Medium-находку M2 (внутреннее противоречие §7.2.1/7.2.2) |
| UX | §8 (одна строка: «Нет новых i18n, настроек, действий, жестов либо различий mouse/touch») | контента по существу достаточно (правка не создаёт новых интеракций), но отдельного раздела нет — см. Low ниже |
| Модель данных и миграция | §8 (первые пункты) | ок |
| i18n | §8 (та же строка) | ок, новых строк нет |
| AC1…ACn с доказательством | §9 | ок, см. таблицу ниже |
| План автотестов | распределён по «Доказательство:» в каждом AC | по содержанию покрыто, но не собран отдельным разделом — см. Low |
| **Риски** | **отсутствует** | **см. Medium-находку M1** |
| Откат | §11 | ок |
| Release-артефакты | §10 | ок |
## AC — однозначность и способ доказательства
| AC | Однозначен? | Доказательство названо и выполнимо? |
|---|---|---|
| AC1 | Да — конкретный узел, конкретные числа `H=4.8611`, `R=1.25×H`, инварианты связности | unit + новый fixture `test/fixtures/249-multiwall-junction.json` — файл ещё не существует (ожидаемо для ТЗ), путь и формат согласованы с существующим паттерном фикстур в `test/` |
| AC2 | Да по формуле, но см. M2 — сама граничная формула (§7.2.1 vs §7.2.2) внутренне противоречива в полосе `(R, R+epsilon]`, что делает пограничный случай матрицы недоказуемым однозначно | table-driven unit, численный bound + сравнение нормализованной геометрии — метод выполним для всех точек вне спорной полосы |
| AC3 | Да — «те же точки/paths», «без обновления двухлучевых expected values» — сильная, проверяемая гарантия регрессии | unit regression на существующих `insetContour()`/`outsetContour()` тестах |
| AC4 | Да — все поверхности через один smoke с path/geometry assertions и cache parity | `demo/smoke_multiwall_junction.mjs` — новый файл, обязателен к запуску локально перед `S7-code-review` (`AGENTS.md` «Гейты») |
| AC5 | Да — golden с semantic assertions на число лучей и габарит, что явно исключает пустой/неверно кадрированный PNG (защита от регресса #234) | `demo/golden/matrix.mjs` + `test/golden-matrix.test.mjs`, приёмка эталона только по `npm run golden:accept -- --reviewed` на полном Linux CI |
| AC6 | Да, стандартная формулировка deep-equality | unit + существующий зелёный набор без изменений |
| AC7 | Да, состав гейтов реализации соразмерен задаче (§8 PROCESS.md): typecheck/test/build/targeted smoke здесь, golden/полный smoke/perf — предрелизно | команды названы точно |
Ни один AC не содержит домысла о поведении, которого нет ни в одном
документе: контракт §7 — прямое продолжение уже существующего
`MITRE_LIMIT`/bevel-механизма из `docs/WALL-THICKNESS.md` §3, распространённое
на узлы степени 3+ по явному решению владельца (Q1/Q2 в комментариях issue),
а не изобретённое автором. Открытых продуктовых вопросов в тексте не
осталось — оба вопроса, заданные владельцу, получили ответ и дословно перенесены в §4 ТЗ.
## Находки
Блокирующих (High) находок нет.
### Medium — в скоупе задачи, чинится в этом же ТЗ
**M1. Обязательный раздел «Риски» отсутствует полностью.**
`PROCESS.md` §7.1 перечисляет «риски» как отдельный обязательный раздел ТЗ, а
DoR-чеклист §2.5 требует «риски перечислены» как блокирующий пункт готовности
к разработке. В ТЗ #249 такого раздела нет вовсе (проверено `grep -ni
"риск"` по всему файлу — единственное упоминание это унаследованная из
аналитики метка «Сложность/риск: 5/10 и 6/10» в шапке документа, без единого
слова разбора). Для сравнения — все шесть последних полноформатных ТЗ (`239`,
`244`, `248`, `229`, `197`, `198`) содержат отдельный раздел с конкретными
пунктами и мерами снижения.
Здесь эта пустота предметна, а не формальна: сама задача в issue названа
владельцем «риск 6/10» именно потому, что правка меняет каноническую
геометрию узла для **любого** узла из 3+ разных физических лучей — а такие
узлы (обычные T-стыки внутренних перегородок с наружной стеной) есть почти в
каждом сохранённом плане, не только в приложенном экспорте. Правка сужает
допустимый габарит join-вершины с `4×H` (старый `MITRE_LIMIT`) до `1.25×H`
для всех узлов степени 3+ — то есть потенциально меняет вид существующих,
сегодня визуально корректных T-стыков, чья текущая митра лежит в интервале
`(1.25×H, 4×H]`. AC2 проверяет только новые синтетические сценарии, AC6 явно
исключает лишь independent-partition junctions и двухлучевые углы — ни один
AC не требует прогона существующего golden-набора ради проверки именно этого
побочного эффекта на реальных (не связанных с #249) планах. Требование это
частично закрывается штатным пре-релизным гейтом (`golden:verify` на полном
наборе перед бетой, §8 PROCESS.md), но ТЗ обязано назвать этот риск явно:
«неизменность T-стыков неявно полагается на пре-релизный гейт» — это не то
же самое, что «названо в разделе рисков».
**Почему это делает ТЗ невыполнимым без правки:** без явного раздела рисков
DoR-чеклист (§2.5) формально не проходит («риски перечислены» — пункт
блокирующий), а автор реализации не получит явного указания, что перед
переводом в `S8-merged` нужно специально просмотреть diff полного golden-
прогона на предмет затронутых T-стыков за пределами фикстуры #249, а не
просто принять новый эталон.
**Предлагаемое закрытие:** добавить раздел «Риски», перенести и раскрыть уже
названные в аналитике риски («меняется каноническая геометрия узла; ошибка
способна дать щель, лишнюю кладку либо изменить обычные двухлучевые углы»),
явно указать risk «широкий blast radius: любой существующий T-стык степени 3+
может визуально сдвинуться» и назвать митигацию (полный `golden:verify` перед
бетой обязателен, а не опционален, именно из-за этого риска).
**M2. Внутреннее противоречие критерия mitre/bevel в §7.2, пункты 1 и 2.**
Текст:
> 1. существующее пересечение смещённых граней остаётся митрой, если его
> расстояние от node `<= R + epsilon`;
> 2. если пересечения нет, оно не конечное либо дальше `R`, join содержит две
> штатные offset-точки соседних граней — прямую фаску;
Для расстояния `d`, такого что `R < d <= R + epsilon`, оба условия истинны
одновременно: по правилу 1 вершина «остаётся митрой» (`d <= R + epsilon`), по
правилу 2 она же должна стать фаской (`дальше R`). Это не гипотетическая
придирка к формулировке — это прямое противоречие в самом контракте
поведения (§7 — обязательный раздел, самый важный для реализуемости),
из-за которого ни автор кода, ни автор unit-теста не могут детерминированно
решить, какой веткой идти для точки в этой полосе шириной `epsilon`. AC1 и
AC2 наследуют ту же двусмысленность, поскольку оба формулируют допуск как
`1.25 × H + epsilon`, то есть используют границу правила 1, но не оговаривают,
что при пересечении именно в этой полосе достаточно **любого** из двух
результатов (что было бы законным способом снять противоречие, если это
осознанный допуск на точность).
Практическое влияние ограничено полосой шириной `epsilon` (масштаб
машинной точности геометрии, `openEps(pitch, coordScale)`), то есть вряд ли
проявится на конкретном экспорте владельца — поэтому не блокирует, но делает
геометрический контракт не полностью однозначным, что прямо противоречит
задаче ревьюера «найти, где ТЗ не выполнимо или не проверяемо».
**Предлагаемое закрытие:** унифицировать порог — либо оба правила используют
`R` (эпсилон остаётся только допуском сравнения чисел с плавающей точкой, а
не расширением зоны «остаётся митрой»), либо явно указать, что при `d` в
полосе `(R, R + epsilon]` оба исхода (митра или фаска) считаются корректными
и не различаются ни одним AC/тестом.
### Low — не блокирует, снимается ревьюером с записью
1. **Раздел «UX, i18n, accessibility и touch» и «План автотестов» не
выделены отдельными заголовками**, в отличие от шести последних
полноформатных ТЗ (`239`, `244`, `248`, `229`, `197`, `198`), где это
устойчивый, повторяющийся шаблон. Контент по существу присутствует
(распределён по §8 и по полю «Доказательство:» каждого AC) и ничего не
пропускает по содержанию — в отличие от M1, здесь нет содержательной
пустоты, только структурное расхождение с шаблоном соседних задач.
Снимаю без требования правки: не создаёт риска для DoR и не влияет на
проверяемость AC. Автору стоит учесть при следующем ТЗ для единообразия
документов в `docs/specs/`.
## Что проверено и корректно
- Корневая причина (§2 ТЗ) подтверждена цитированием реального кода на
`HEAD`: `MITRE_LIMIT = 4`, независимые mitre-вычисления двух комнат в
`insetContour()`/`outsetContour()`, отсутствие покрытия со стороны
`junctionAt()`/`spaceMergeGeometry()` — не заявлена на веру.
- Численные значения (`H = 4.8611`, `R = 6.076...`, измеренный спайк `8.7312`,
`1.80×H`) внутренне согласованы и совпадают с независимым пересчётом.
- Продуктовые решения владельца (Q1 fallback = прямая фаска без округления;
Q2 лимит `1.25 × max half-depth` для узлов 3+ лучей, двухлучевые узлы без
изменений) перенесены в §4 ТЗ дословно, без искажений и без добавления
новых, не согласованных с владельцем решений.
- Scope/не-scope (§5) корректно исключает миграцию, изменение толщины стен,
редакторские инструменты, округлённые joins и не трогает
`linearWallJoinPatches()` — не расширяет и не сужает баг-скоуп в
рефакторинг.
- AC1–AC7 в целом именуют реальный, а не гипотетический тестовый
инструментарий: `test/wall-thickness.test.mjs`, `demo/golden/matrix.mjs`,
`test/golden-matrix.test.mjs` — все три файла существуют в текущем дереве
проекта и являются подходящим местом для новых кейсов.
- AC5 защищён от пустого/неверно кадрированного PNG semantic-assertions —
прямая мера против того же класса дефекта, что уже стоил регресса на
#234 (`docs`-джоб покраснел из-за пропущенной пересъёмки, здесь риск иного
рода — «зелёный, но бессодержательный» golden — предупреждён явно).
- Термины §6 ТЗ («ray», «node», «join vertex», дедупликация shared-интервала
между двумя комнатами) обоснованы существующим кодом (строка 626 —
`Math.max` при слиянии дублей), а не изобретены заново.
- i18n/touch/данные (§8): корректно константируют отсутствие изменений —
правка не создаёт новых строк, настроек, миграций или touch-специфичного
поведения; согласуется с характером задачи (bugfix геометрии рендера).
## Чего не проверял
- Не запускал никаких гейтов (`typecheck`/`test`/`build`) — на этапе
spec-review предмет проверки текст ТЗ, а класс A файлов в этом коммите не
менялся (дифф — только `docs/specs/249-multiwall-junction-bevel.md`).
- Не исполнял и не мог исполнить AC1–AC5 — упомянутые в них новые файлы
(`test/fixtures/249-multiwall-junction.json`,
`demo/smoke_multiwall_junction.mjs`, golden-сценарий узла) ещё не написаны;
это ожидаемо для стадии ТЗ и будет предметом код-ревью.
- Не проверял степень визуального совпадения нового «1.25×H» лимита с уже
существующими golden-эталонами, где встречаются T-стыки степени 3 —
именно это отсутствие и составляет находку M1: полный `golden:verify`
относится к предрелизному гейту, а не к этапу ТЗ, но должен быть назван
как явный риск в тексте самого ТЗ.
- Не пересчитывал вручную геометрию для матрицы AC2 (15/50/70 см, 4 луча) —
формула `R = 1.25 × max(halfDepth)` детерминирована и проверяема
тривиально на этапе код-ревью через unit-тест.
## Итог
ТЗ описывает реальный, подтверждённый по коду дефект и переносит явные
продуктовые решения владельца без искажений и без домыслов. Блокирующих
находок нет. Две находки Medium в скоупе задачи: полностью отсутствующий
обязательный раздел «Риски» (M1) и внутреннее противоречие порога
mitre/bevel в самом важном разделе — контракте поведения (M2). Обе чинятся
без переписывания ТЗ — точечным дополнением одного раздела и унификацией
одного порога, поэтому исправление не требует нового цикла продуктовых
вопросов владельцу.
**Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 2**
+116
View File
@@ -0,0 +1,116 @@
# SPEC-REVIEW-249-r2
- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249) — «Стык трёх стен разной толщины рисуется шипом наружу вместо соединённого узла»
- ТЗ: `docs/specs/249-multiwall-junction-bevel.md`, ветка `issue/249-multiwall-junction-bevel`, коммит `675294b9a0c62fb412220bdc04194f6eabedc0c4`
- Этап: spec (PROCESS.md §2.4), заход **r2**, блокирующих циклов израсходовано **1 из 4**
- Трек: обычный (не `small`) — файл в `docs/specs/` существует
- Вердикт: **зелёный**
## Скоуп ревью — по дельте (PROCESS.md §2.10)
Предыдущий вердикт найден в комментарии issue от `claude` (2026-08-23T02:18:27Z):
жёлтый, заход r1, High 0 / Medium 2 (M1 — отсутствует раздел «Риски», M2 —
внутреннее противоречие mitre/bevel в §7.2). **SHA, на котором получен вердикт
r1, в самом вердикте не назван** — это находка процесса r1, а не r2; я
восстановил его по цепочке коммитов: документ ревью `748d484` («review
document for #249») зафиксировал `docs/reviews/SPEC-REVIEW-249-r1.md` с явной
строкой «ТЗ: … коммит `8d2e00bbbfbb9151ca2079eaf6e51f916cb59eff`», и по времени
(`8d2e00b` — 05:07:48, вердикт — 02:18:27 UTC = 05:18:27 MSK, `748d484` —
05:18:37 MSK) это единственный кандидат. Далее — SHA r1.
Дельта раунда: `git diff 8d2e00b..675294b -- docs/specs/249-multiwall-junction-bevel.md`.
Дельта **локальна**: изменена статусная строка шапки, переписан §7.2 (пункты
1–2, добавлены пункты 3–4), добавлен новый раздел 11 «Риски и меры снижения»
(R1–R5), старые §11 «Откат» и §12 «Предположения» сдвинуты на §12/§13. Ни
сценарий, ни AC, ни scope/не-scope, ни термины §6, ни §7.1/7.3/7.4/7.5, ни
раздел 10 не тронуты. Смены контракта поведения, ребейза или новой подсистемы
нет — правило mitre/bevel то же самое (`d <= R` → митра, `d > R` → фаска),
неоднозначная полоса убрана, а не расширена. Поэтому разбор — по дельте, не
заново.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — обязательный раздел «Риски» отсутствовал полностью | Добавлен раздел 11 «Риски и меры снижения» с пятью пунктами (R1 blast radius T-стыков, R2 щель/ложная кладка, R3 порядок/winding/scale, R4 boolean-ошибка, R5 перформанс), каждый с явной митигацией | `docs/specs/249-multiwall-junction-bevel.md:297-350` (см. `git diff 8d2e00b..675294b`, добавленные `+### R1.` … `+### R5.`); R1 прямо называет тот самый риск, что был предметом M1: «Сегодня визуально приемлемая митра в диапазоне `(1.25×H, 4×H]` станет фаской… изменение может затронуть golden-сценарии, не связанные с #249», митигация — обязательный полный `golden:verify` перед бетой с просмотром **всех** изменённых кадров, а не только нового crop |
| **M2** — правила §7.2.1/7.2.2 противоречили друг другу в полосе `(R, R+epsilon]` | Оба правила переписаны на единственную границу `R`: п.1 «остаётся митрой, если его **конечное** расстояние от node `d <= R`», п.2 «если пересечения нет, оно не конечное либо `d > R` — фаска». Добавлен явный п.3: «epsilon не расширяет область выбора митры: branch decision всегда разделён единственной границей `R`. Допуск epsilon используется только при проверке и сравнении уже вычисленных floating-point координат» | `docs/specs/249-multiwall-junction-bevel.md:138-151`; выбран ровно вариант закрытия, предложенный r1 («либо оба правила используют R, а epsilon остаётся только допуском сравнения») |
Оба закрытия проверены по тексту диффа, не по заявлению автора комментария.
### Побочная проверка: делает ли фикс M2 что-то ещё непоследовательным
§7.5 «Ошибки и fallback» (не тронут дельтой) уже содержал формулировку «если
точная митра не укладывается в `R`, результатом служит фаска» — то есть до
фикса M2 этот раздел молчаливо предполагал границу `R`, а §7.2 колебался между
`R` и `R+epsilon`. Новая редакция §7.2 приводит оба места к одному порогу без
дополнительной правки §7.5 — расхождение снято, а не создано новое. AC1 (§9,
не тронут) по-прежнему использует `1.25×H + epsilon` как допуск **проверки**
результата (п.4 нового §7.2), а не как границу branch decision — противоречия
между AC1 и переписанным §7.2 нет.
Внутренние перекрёстные ссылки (`§7.5`, `§8` — единственные, что встречаются
в тексте документа) не задеты сдвигом нумерации §11→12, §12→13; проверено
`grep -n "§[0-9]"` по всему файлу.
## Унаследовано из r1
Без повторной проверки в этом раунде приняты выводы `docs/reviews/SPEC-REVIEW-249-r1.md`
(коммит ТЗ `8d2e00b`, документ опубликован коммитом `748d484`):
- сценарий и соответствие J1 (`docs/SCOPE.md`);
- подтверждение корневой причины по коду на `HEAD` (`MITRE_LIMIT = 4` в
`src/wall-thickness.ts:46`, независимые митры двух комнат в
`insetContour()`/`outsetContour()`, строки 1030/2238/701/1853;
`wall-merge.ts` `junctionAt()`/`spaceMergeGeometry()` действительно не
формируют этот узел);
- согласованность чисел `H = 4.8611`, спайк `8.7312 ≈ 1.80×H`;
- перенос решений владельца Q1/Q2 в §4 без искажений;
- Scope/не-scope §5;
- термины §6 (`ray`, `node`, `join vertex`), обоснованные кодом (`Math.max` при
слиянии дублей, строка 626);
- полнота и проверяемость AC1–AC7 (таблица AC из r1) — дельта их текст не
меняла;
- соответствие терминологии `docs/USER-GUIDE.ru.md` («стык», «митра/фаска»);
- Low-находка о нестандартной структуре заголовков — принята как снятая
ревьюером r1 без требования правки, автор её и не трогал.
Эти пункты не переверялись повторно, так как дельта r2 их не касается.
## Что проверено в этом раунде (сверх таблицы закрытия)
- DoR-чеклист §2.5 PROCESS.md: «риски перечислены» и «открытых продуктовых
вопросов нет» — оба условия теперь выполнены (риски — новый §11; открытых
вопросов в тексте r2 не появилось, оба Q1/Q2 остались решёнными в §4).
- Новый раздел рисков внутренне согласован с остальным ТЗ: R1↔AC3 (двухлучевые
expected values не меняются), R2↔AC1/AC2 (ненулевая связность, отсутствие
hole), R3↔AC2 (матрица permutation/winding/`coordScale=1000`), R4↔§7.5 и
regression #197, R5↔§8 (`O(E)`/`O(E log E)`) и обязательный
предрелизный performance smoke. Ни один риск не изобретён — все пять уже
фигурировали как отдельные абзацы аналитики/issue до этого ТЗ (см. первый
комментарий владельца в issue: «Риск: 6/10… ошибка способна дать щель, лишнюю
кладку либо изменить обычные двухлучевые углы»), r2 их структурировал под
требуемый раздел, а не придумал заново.
- Тонкая граница `d <= R` вместо `d <= R + epsilon` не расширяет,
а сужает зону неоднозначности до нуля — это не новый риск детерминизма,
а устранение единственного такого риска, названного в M2.
## Чего не проверял
- Не запускал `typecheck`/`test`/`build` — класс A (`src/**`) в дельте r1→r2 не
менялся, дифф ограничен `docs/specs/249-multiwall-junction-bevel.md`.
- Не переисполнял AC1–AC7 (соответствующих файлов тестов/smoke/fixture ещё не
существует — ожидаемо для этапа ТЗ, будет предметом код-ревью).
- Не проводил повторный полный разбор сценария, root cause, scope и терминов —
см. раздел «Унаследовано из r1»: делта их не касается, значит не задевает и их
доказательства.
## Итог
Обе Medium-находки r1 закрыты точечно и именно так, как предлагало ревью:
раздел «Риски» добавлен по существу (не формальная заглушка — пять пунктов с
митигациями, согласованных с остальным ТЗ), а противоречие mitre/bevel снято
единой границей `R` с epsilon только как допуском проверки. Дельта раунда
локальна, не затрагивает AC/scope/термины и не создаёт нового расхождения.
Открытых High/Medium находок нет.
**Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0**
+373
View File
@@ -0,0 +1,373 @@
# Issue #249 — ограниченная геометрия узла из трёх и более стен
- Дата: 2026-08-23
- Тип: bug + polish · приоритет P2
- Ценность: пользовательская 7/10 · для разработки 8/10
- Сложность/риск: 5/10 и 6/10
- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249)
- Ветка: `issue/249-multiwall-junction-bevel`
- Статус ТЗ: редакция r2, повторное ревью зелёное
Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`,
`docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`, `docs/TESTING.md`.
## 1. Сценарий и персона
Администратор рисует обычную пристройку или несколько комнат, сходящихся в
одной точке. У трёх стен могут отличаться толщины. В View, kiosk и Static он
видит не единый кладочный узел, а узкий клин, который выступает наружу на
десятки сантиметров. Контуры пола рядом визуально наезжают друг на друга, хотя
сохранённые оси стен и комнаты корректны.
Это нарушает J1 из `docs/SCOPE.md`: план перестаёт давать достоверную
пространственную картину дома. Дефект постоянно виден, а не ограничен
редактором.
## 2. Воспроизведение и измеренный дефект
Канонический regression fixture минимизируется из приложенного к issue экспорта
`houseplan-space-space-2026-08-22_21-33-04.json` (`card_version
1.67.0-beta.2`, `model_version 6`, `cell_cm = 30`). В узле
`(0.329166667, 0.141666667)` сходятся три положительных физических интервала:
| Луч от узла | Второй конец | Толщина | Направление |
|---|---|---:|---:|
| A | `(0.308333333, 0.237500000)` | 50 см | 102.3° |
| B | `(0.408333333, 0.100000000)` | 50 см | −27.8° |
| C | `(0.379166667, 0.191666667)` | 70 см | 45.0° |
Углы между соседними лучами — 57.3°, 72.8° и 130.0°. Максимальная
полутолщина `H = 4.8611` render units. Текущий объединённый wall body содержит
join-вершину на расстоянии `8.7312`, то есть `1.80 × H`, и рисует видимый зубец.
Причина подтверждена production-вызовами `spaceModels()` и
`wallBodiesGeometry()`: две комнаты независимо строят митры пары 50/70 см через
`outsetContour()`/`insetContour()`. Обе митры проходят общий
`MITRE_LIMIT = 4`, после чего boolean union сохраняет противоположные выступы.
`junctionAt()` и `spaceMergeGeometry()` этот узел не формируют.
## 3. Что человек увидит до и после
**До:** штриховка у многолучевого узла выходит клином далеко за физическую
толщину стен; рядом видны наложенные контуры комнат.
**После:** те же сохранённые оси образуют один заполненный узел. Корректные
короткие митры остаются острыми, а только чрезмерная join-вершина заменяется
прямой локальной фаской. В узле нет щели, округления или выступа за установленный
габарит. Обычные углы ровно из двух лучей выглядят как раньше.
## 4. Решения владельца
1. Fallback чрезмерной митры — **прямая локальная фаска**. Она образует единый
заполненный узел; округление не добавляется.
2. В узле из `3+` различных физических лучей допустимый радиус join-вершины:
`R = 1.25 × H`, где `H` — максимальная полутолщина всех сходящихся стен.
3. Правило применяется независимо от числа комнат и от того, равны ли толщины.
4. Узлы ровно из двух лучей сохраняют `MITRE_LIMIT = 4` и существующий вид.
5. Исправляется одна каноническая вычисляемая геометрия для Full, Static,
hidden Iso и световых барьеров. Persisted rooms/walls не переписываются.
## 5. Scope
### Входит
- классификация физических узлов по уникальным инцидентным лучам;
- общий лимит `1.25 × max half-depth` для join-геометрии узлов степени `3+`;
- ограниченная митра либо прямая фаска в room inset/outset и в обязательных
локальных junction pieces;
- согласованная каноническая кладка, clean-floor/room-fill границы, бумага,
Full/Static/hidden-Iso и Glow/sun occlusion;
- unit-набор на 3/4 луча, разные толщины, порядок, winding и `coordScale`;
- browser smoke и golden-сценарий на минимизированном экспорте владельца;
- обновление `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`,
`docs/TESTING.md` и двух changelog.
### Не входит
- изменение сохранённых координат комнат, `walls`, partitions или drafts;
- привязка узлов к сетке и Optimize-миграция старых планов;
- изменение толщины стен либо правил shared/outer/virtual interval;
- новый редакторский инструмент, настройка коэффициента в UI или i18n;
- округлённые joins, отделочные слои и отдельный материал узла;
- изменение двухлучевых острых/тупых углов;
- исправление независимой partition-топологии, уже принадлежащей
`linearWallJoinPatches()`.
## 6. Термины и классификация узла
`physical interval` — атомарный room-wall interval с положительной толщиной,
который не является virtual/open. Его полутолщина уже переведена в render units
существующим `wallCmToUnits()`.
`node` — группа совпадающих концов физических интервалов в существующей
scale-relative геометрической погрешности (`openEps(pitch, coordScale)` либо
единый эквивалент подсистемы). Координаты не округляются к drawing grid.
`ray` — направление от node к противоположному концу interval:
- повтор одного физического shared interval от второй комнаты удаляется по
каноническому interval key;
- совпадающие сонаправленные атомарные фрагменты считаются одним лучом и берут
максимальную физическую полутолщину;
- противоположные направления прямой стены являются двумя разными лучами;
- zero-thickness и virtual intervals не увеличивают степень узла.
`multi-wall node` — node с тремя или более различными rays. Для него
`H = max(ray.halfDepth)`, `R = 1.25 × H`. Если конечные значения и положительный
`H` получить нельзя, локальный limiter не создаёт новую геометрию и не должен
ронять весь structural pass.
`join vertex` — точка пересечения смещённых граней либо точка локального
junction patch, созданная именно для соединения интервалов в node. Дальние
концы самих стен не являются join vertices и радиусом `R` не ограничиваются.
## 7. Геометрический контракт
### 7.1 Единая карта узлов
Один pure structural pass строит детерминированную карту multi-wall nodes из
эффективных `wallIntervals()`. Карта не зависит от порядка rooms/walls,
направления сохранённого отрезка или winding комнаты. Она строится один раз для
текущей геометрии и передаётся всем contour/junction вычислениям; отдельные
рендереры не распознают узел повторно.
### 7.2 Ограничение join
Когда вершина room profile совпадает с multi-wall node:
1. существующее пересечение смещённых граней остаётся митрой, если его
конечное расстояние от node `d <= R`;
2. если пересечения нет, оно не конечное либо `d > R`, join содержит две
штатные offset-точки соседних граней — прямую фаску;
3. epsilon не расширяет область выбора митры: branch decision всегда разделён
единственной границей `R`. Допуск `epsilon` используется только при проверке
и сравнении уже вычисленных floating-point координат;
4. каждая созданная для этого node join-вершина остаётся не дальше
`R + epsilon` при численной проверке результата;
5. локальное соединение имеет ненулевую площадь, node не попадает в hole wall
body, а тела всех инцидентных rays входят в один связный кладочный компонент;
6. boolean union не вправе вернуть в body отброшенную дальнюю вершину из другого
независимого room ring. Все ring/piece producers получают одну карту и лимит
до объединения.
Offset-точка фаски находится на расстоянии собственной полутолщины, поэтому
для валидного interval автоматически укладывается в `H <= R`. Круг либо
аппроксимация окружности не строятся.
### 7.3 Двухлучевая совместимость
Для node степени `1` или `2` сигнатуры и результат `insetContour()`,
`outsetContour()` и существующих bounded junction patches не меняются:
`MITRE_LIMIT = 4` остаётся единственным лимитом. Новый коэффициент не становится
глобальной заменой `MITRE_LIMIT`.
### 7.4 Канонические потребители
Исправленная structural geometry является общей для:
- wall body/hatch в Full, kiosk и Static;
- Plan preview сохранённой геометрии;
- hidden isometric masonry;
- clean floor, room fills и room hover, использующих внутреннюю грань;
- paper/exterior shell там, где multi-wall node принадлежит внешней границе;
- Glow/sun/light source occlusion.
Существующие structural caches продолжают ключеваться полной геометрией.
Курсор, hover и HA state tick не перестраивают topology. Исправление не вводит
расходящиеся SVG-only и physics-only patches.
### 7.5 Ошибки и fallback
- Валидный multi-wall node не может молча вернуться к дальнему
`MITRE_LIMIT = 4` join: если точная митра не укладывается в `R`, результатом
служит фаска.
- Неконечный или вырожденный локальный candidate отбрасывается изолированно;
он не превращает успешную остальную кладку, paper или light barriers в
`null`/пустой план.
- Существующий fail-dark контракт обязательного structural pass сохраняется:
исправление не должно маскировать независимую ошибку exterior/body/opening.
## 8. Данные, совместимость и производительность
- Схема и `model_version` не меняются; config/layout остаются byte-for-byte.
- Результат вычисляется при чтении и сразу исправляет старые планы без Save.
- Backend и WebSocket не меняются.
- Нет новых i18n, настроек, действий, жестов либо различий mouse/touch.
- Построение node map ограничено structural geometry pass; допускается
линейная либо `O(E log E)` группировка. Новый полный `O(E²)` обход на каждый
render/state tick запрещён.
## 9. Acceptance criteria и доказательства
### AC1. Экспорт владельца больше не даёт шип
Минимизированный fixture хранит только необходимые rooms, wall entries и
scale-параметры из экспорта. Для node `(0.329166667, 0.141666667)`:
- `wallBodiesGeometry()` возвращает успешную непустую геометрию;
- `H = 4.8611`, и каждая join-вершина node находится не дальше
`1.25 × H + epsilon`;
- node не лежит в hole, локальный wall body имеет ненулевую площадь и один
компонент касается тел всех трёх rays;
- повторный расчёт даёт тот же нормализованный geometry/path.
**Доказательство:** unit в `test/wall-thickness.test.mjs` и новый fixture
`test/fixtures/249-multiwall-junction.json`.
### AC2. Матрица многолучевых узлов
Те же инварианты выполняются минимум для:
- трёх стен одинаковой толщины;
- трёх стен 15/50/70 см;
- четырёх стен с равными и разными толщинами;
- перестановки rooms/walls, обратного направления intervals, обоих windings и
production `coordScale = 1000`.
Ни один вариант не создаёт gap, self-intersection, `null` либо второй локальный
кладочный компонент.
**Доказательство:** table-driven unit с численным bound и сравнением
нормализованной геометрии.
### AC3. Два луча не меняются
Ровно две стены одинаковой толщины под углами fixture #249 и существующие
acute/obtuse contour cases возвращают прежние точки/paths и продолжают
использовать `MITRE_LIMIT = 4`. Новый limiter не превращает корректную митру в
фаску.
**Доказательство:** unit regression на `insetContour()`/`outsetContour()` и
существующий wall-thickness unit-набор без обновления его двухлучевых expected
values.
### AC4. Все поверхности используют один body
На fixture #249 Full/View, Plan с сохранённым контуром, kiosk, Static, hidden
Iso и light occlusion получают одну исправленную physical geometry: клина нет,
а луч света не проходит через заполненный узел. Переключение темы и HA state не
меняет structural path и не вызывает его пересчёт.
**Доказательство:** targeted browser smoke
`demo/smoke_multiwall_junction.mjs` с path/geometry assertions и cache parity.
### AC5. Golden фиксирует видимый результат
В golden matrix добавлен отдельный crop узла из минимизированного экспорта:
видны три луча, фаска и отсутствие щели. Сценарий имеет semantic assertions на
число rays и ограниченный габарит, поэтому пустой или неверно кадрированный PNG
не может пройти.
**Доказательство:** `demo/golden/matrix.mjs`, harness/fixture и
`test/golden-matrix.test.mjs`. Новый baseline принимается только по полному
Linux CI artifact командой `npm run golden:accept -- --reviewed` перед бетой.
### AC6. Данные и смежные контракты не меняются
Расчёт не мутирует rooms/walls/open spans, не меняет schema/model version и не
затрагивает independent partition junctions, openings или двухлучевые внешние
углы.
**Доказательство:** deep-equality unit входа до/после и зелёные существующие
wall/opening/junction tests.
### AC7. Гейты реализации
В цикле реализации зелёные:
- `npm run typecheck`;
- `npm test`;
- `npm run build`;
- targeted `node demo/smoke_multiwall_junction.mjs` после копирования свежего
bundle в demo.
Полный smoke, golden и performance запускаются перед бетой; полный backend
HA-harness не требуется, если Python/backend не меняются, но общий exact-SHA
Validate остаётся обязательным.
## 10. Документация и changelog
- `docs/WALL-THICKNESS.md`: степень node, `R = 1.25 × H`, фаска и сохранение
`MITRE_LIMIT = 4` для двух лучей;
- `docs/ARCHITECTURE.md`: единая node map и потребители canonical masonry;
- `docs/TESTING.md`: unit/smoke/golden evidence #249;
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: пользовательский bugfix в том
же коммите, что продуктовая правка.
## 11. Риски и меры снижения
### R1. Широкий blast radius для существующих T-стыков
Новый контракт действует не только на экспорт #249, а на каждый валидный узел
степени `3+`. Сегодня визуально приемлемая митра в диапазоне `(1.25 × H, 4 × H]`
станет фаской. Такие T-стыки встречаются в обычных сохранённых планах, поэтому
изменение может затронуть golden-сценарии, не связанные с #249.
**Митигация:** targeted unit/golden не заменяют полный `golden:verify`. Перед
бетой обязателен полный Linux golden artifact; просматриваются все изменённые
кадры и diff, а не только новый crop #249. Необъяснимое изменение другого узла
останавливает baseline acceptance. Существующие двухлучевые expected values
дополнительно остаются неизменными по AC3.
### R2. Фаска может создать щель или ложную кладку
Независимое ограничение двух room rings способно оставить pinhole, разорвать
один ray от узла либо, наоборот, заполнить сектор пола лишним материалом.
Расхождение особенно опасно для Glow/sun: визуально малый дефект становится
ложным световым проходом или барьером.
**Митигация:** единая node map применяется до boolean union всеми producers;
AC1/AC2 требуют ненулевой положительной связности с каждым ray, отсутствия hole
и единой canonical geometry для рендера и occlusion. Отдельный SVG overlay
запрещён.
### R3. Порядок, winding и масштаб могут менять классификацию
Shared interval присутствует в профилях двух комнат, а атомарные отрезки могут
иметь обратное направление. Ошибка дедупликации превратит двухлучевой угол в
multi-wall node либо даст разные фаски при перестановке данных. Абсолютный
epsilon может разойтись между normalized и production scale.
**Митигация:** канонический interval/ray key, scale-relative tolerance и
обязательная permutation/winding/`coordScale = 1000` матрица AC2.
### R4. Локальная boolean-ошибка может погасить весь structural pass
Новая ограничивающая геометрия проходит через `polyclip-ts`; вырожденный patch
может бросить исключение или дать zero-area polygon.
**Митигация:** локальный candidate проверяется до union и отбрасывается
изолированно по §7.5. Существующий fail-dark контракт обязательных стадий не
маскируется; regression #197 остаётся зелёной.
### R5. Дополнительный topology pass может ухудшить live rendering
Повторный поиск всех совпадающих endpoints на каждом HA tick сделал бы стоимость
видимого bugfix непропорциональной.
**Митигация:** карта строится только в cached structural pass с ограничением
`O(E)`/`O(E log E)` из §8. Targeted smoke проверяет cache parity, а обязательный
предрелизный performance smoke контролирует тяжёлый дом.
## 12. Откат
Откатывается pure multi-wall node classification/limit и связанные tests/docs.
Данные не мигрируются, поэтому отдельный data rollback не нужен. Golden baseline
откатывается только вместе с соответствующей product geometry.
## 13. Принятые технические предположения
1. Коэффициент `1.25` — именованная константа канонической wall geometry, а не
настройка и не литерал в рендерерах.
2. Shared interval, встреченный из двух room profiles, является одним
физическим лучом; иначе обычный T ошибочно классифицировался бы по числу
владельцев.
3. В regression fixture не переносится остальной пользовательский экспорт:
сохраняется минимальная обезличенная геометрия, достаточная для численного и
визуального воспроизведения.
4. `epsilon` берётся из существующей scale-relative геометрической политики и
не ослабляется до шага drawing grid.
5. Если реализация требует отдельного bounded fill patch для положительной
связности, он остаётся частью canonical structural geometry, целиком лежит в
радиусе `R` и проходит тот же детерминизм/failure isolation; это не отдельный
визуальный слой.
+10
View File
@@ -11621,6 +11621,7 @@ class HouseplanCard extends LitElement {
space.rooms.map((room) => [room, roomPoly(room)]),
);
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const roomWalls = this._wallUnionGeometry()?.roomGeom;
const pathD = (points: number[][]) =>
'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z';
const shapes = space.rooms.map((room) => {
@@ -11631,6 +11632,7 @@ class HouseplanCard extends LitElement {
? (innerContourForRoom(
space.rooms, room.id, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
) || ownPoly)
: ownPoly;
const otherPolys = space.rooms
@@ -11825,10 +11827,12 @@ class HouseplanCard extends LitElement {
? pairs.filter((p) => p.a.id === room.id || p.b.id === room.id).flatMap((p) => p.segs)
: pairs.flatMap((p) => p.segs);
const walls = this._spaceWalls;
const roomWalls = this._wallUnionGeometry()?.roomGeom;
const floor = walls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, walls, allOpenCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
) || poly)
: poly;
const contours: { axis: number[][]; face: number[][] }[] = [{ axis: poly, face: floor }];
@@ -14768,11 +14772,13 @@ class HouseplanCard extends LitElement {
const openingWallIndex = this._openingWallIndexFor(space, openCuts).value;
const innerByRoom: Record<string, number[][]> = {};
const wallDepthByOpening: Record<string, number> = {};
const roomWalls = this._wallUnionGeometry()?.roomGeom;
if (walls.length) {
for (const r of rooms) {
const inn = innerContourForRoom(
space.rooms, r.id, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
);
if (inn) innerByRoom[r.id] = inn;
}
@@ -15927,11 +15933,13 @@ class HouseplanCard extends LitElement {
const openCuts = enabled.length === polys.length
? []
: this._openPairs().flatMap((pair) => pair.segs);
const roomWalls = this._wallUnionGeometry()?.roomGeom;
const enabledClip = enabled.length === polys.length ? null : enabled.map(({ r, poly }) => {
const floorPoly = walls.length && r.id
? (innerContourForRoom(
space.rooms, r.id, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
) || poly)
: poly;
const clean = this._cleanFloor(r, floorPoly, space).path;
@@ -16772,6 +16780,7 @@ class HouseplanCard extends LitElement {
space.rooms, r.id, walls,
this._openPairs().flatMap((p) => p.segs),
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
this._wallUnionGeometry()?.roomGeom,
) || myPoly)
: myPoly;
const holes = fillPoly ? islandsOf(fillPoly, otherPolys(r)) : [];
@@ -17933,6 +17942,7 @@ class HouseplanCard extends LitElement {
space.rooms, r.id, walls,
this._openPairs().flatMap((p) => p.segs),
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
this._wallUnionGeometry()?.roomGeom,
) || poly)
: poly;
const clean = this._cleanFloor(r, floor);
+548 -28
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,
@@ -1538,6 +1752,44 @@ export function insetOffsetsForRoom(
/** Alias — half offsets drive both inset and outset. */
export const halfOffsetsForRoom = insetOffsetsForRoom;
/**
* A bounded inward bevel may cross a neighbouring source edge when incident
* wall depths differ sharply. Keep only the part that is physically inside
* the room and return its largest outer ring; room consumers accept one simple
* contour and handle nested-room holes separately.
*/
function clipInnerContourToRoom(
contour: number[][],
room: number[][],
): number[][] | null {
try {
const clipped = intersection(
closedRing(contour) as any,
closedRing(room) as any,
);
return largestOuterContour(clipped);
} catch {
return null;
}
}
/** Largest simple outer ring from polygon-clipping geometry. */
function largestOuterContour(geometry: any): number[][] | null {
let best: number[][] | null = null;
let bestArea = 0;
for (const polygon of geometry || []) {
const raw = polygon?.[0];
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((point: number[]) => [point[0], point[1]]);
const area = Math.abs(signedArea(ring));
if (ring.length >= 3 && area > bestArea) {
best = ring;
bestArea = area;
}
}
return best;
}
/**
* Inner (clean-floor) contour of a room: inset by half wall thickness.
* Returns the original poly when there is no thickness.
@@ -1551,6 +1803,8 @@ export function innerContourForRoom(
cellCm: number,
gridPitch: number,
coordScale = 1,
/** Canonical room-wall masonry before opening cuts; pass the render cache. */
sharedRoomWallGeometry?: any,
): number[][] | null {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
@@ -1558,7 +1812,26 @@ 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,
);
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!inset) return poly.map((p) => [p[0], p[1]]);
if (!multiWallNodes.nodes.length) return inset;
const roomWallGeometry = sharedRoomWallGeometry ?? wallBodiesGeometry(
rooms, walls, openCuts, [], pitch, cellCm, gridPitch, coordScale,
)?.roomGeom;
if (roomWallGeometry) {
try {
const floor = difference(closedRing(pr.poly) as any, roomWallGeometry);
const contour = largestOuterContour(floor);
if (contour) return contour;
} catch {
// Fall through to the bounded contour clip; never return an outside tip.
}
}
return clipInnerContourToRoom(inset, pr.poly)
|| poly.map((p) => [p[0], p[1]]);
}
function closedRing(poly: number[][]): number[][][] {
@@ -1567,6 +1840,173 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
interface MultiWallRoomRing {
outset: number[][];
inset: number[][] | null;
}
/** Excess pairwise overlap triangles removed to expose the straight bevel. */
function multiWallBevelTrianglesAt(
map: MultiWallNodeMap | null | undefined,
retainToLimit: boolean,
): 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;
// Canonical masonry retains pairwise overlap up to R so ordinary
// right-angle arms stay area-connected. The exterior paper uses the
// offset origins instead: its job is to remove the complete facade
// tooth, and the room centre is unioned back immediately afterwards.
const advanceA = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - a.halfDepth * a.halfDepth,
)) : 0;
const advanceB = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - b.halfDepth * b.halfDepth,
)) : 0;
const qA = [pA[0] + a.u[0] * advanceA, pA[1] + a.u[1] * advanceA];
const qB = [pB[0] + b.u[0] * advanceB, pB[1] + b.u[1] * advanceB];
const triangle = stableJunctionPatch([qA, qB, hit], map.coordinateScale);
if (triangle) triangles.push(triangle);
}
}
return triangles;
}
export function multiWallBevelTriangles(
map: MultiWallNodeMap | null | undefined,
): number[][][] {
return multiWallBevelTrianglesAt(map, true);
}
function bevelMultiWallBody(
body: any,
map: MultiWallNodeMap,
centre?: any,
envelope?: any,
): any {
if (!body || !map.nodes.length) return body;
let current = body;
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + map.epsilon * 2;
const extent = radius * 2;
const mask = [
[node.point[0] - radius, node.point[1] - radius],
[node.point[0] + radius, node.point[1] - radius],
[node.point[0] + radius, node.point[1] + radius],
[node.point[0] - radius, node.point[1] + radius],
];
try {
let boundedCurrent = current;
for (const triangle of multiWallBevelTrianglesAt({
...map,
nodes: [node],
}, false)) {
boundedCurrent = difference(boundedCurrent, closedRing(triangle) as any);
}
let local: any = null;
for (const ray of node.rays) {
const n = [-ray.u[1], ray.u[0]];
const rectangle = stableJunctionPatch([
[node.point[0] + n[0] * ray.halfDepth,
node.point[1] + n[1] * ray.halfDepth],
[node.point[0] + ray.u[0] * extent + n[0] * ray.halfDepth,
node.point[1] + ray.u[1] * extent + n[1] * ray.halfDepth],
[node.point[0] + ray.u[0] * extent - n[0] * ray.halfDepth,
node.point[1] + ray.u[1] * extent - n[1] * ray.halfDepth],
[node.point[0] - n[0] * ray.halfDepth,
node.point[1] - n[1] * ray.halfDepth],
], map.coordinateScale);
if (!rectangle) continue;
const piece: any = closedRing(rectangle) as any;
local = local ? union(local, piece) : piece;
}
for (const triangle of multiWallBevelTriangles({
...map,
nodes: [node],
})) {
// Rebuild the physical half-strips first, then remove only their
// excessive pairwise overlap. Applying this cut to the legacy room
// ring itself can delete an incident half-strip and strand floor.
local = difference(local, closedRing(triangle) as any);
}
// Rays share a mathematical endpoint. A tiny physical core turns that
// point contact into a stable polygon contact for boolean/render paths.
const coreRadius = Math.min(
...node.rays.map((ray) => ray.halfDepth),
) * 0.02;
local = union(local, closedRing([
[node.point[0] - coreRadius, node.point[1] - coreRadius],
[node.point[0] + coreRadius, node.point[1] - coreRadius],
[node.point[0] + coreRadius, node.point[1] + coreRadius],
[node.point[0] - coreRadius, node.point[1] + coreRadius],
]) as any);
if (!local) continue;
let localInside = intersection(local, closedRing(mask) as any);
if (centre) localInside = intersection(localInside, centre);
else if (envelope) localInside = intersection(localInside, envelope);
const outside = difference(boundedCurrent, closedRing(mask) as any);
const preservedExterior = centre
? difference(
intersection(boundedCurrent, closedRing(mask) as any),
centre,
)
: null;
current = preservedExterior
? union(outside, preservedExterior, localInside)
: union(outside, localInside);
} catch {
// Isolate the failed node. Other valid nodes still receive their repair;
// mandatory surrounding structural failures remain fail-dark upstream.
}
}
return current;
}
function bevelMultiWallPaper(
paper: any,
centre: any,
map: MultiWallNodeMap,
): any {
let beveled = paper;
for (const triangle of multiWallBevelTrianglesAt(map, false)) {
try {
beveled = difference(beveled, closedRing(triangle) as any);
} catch {
// Isolate the failed local cut and retain the rest of the paper.
}
}
try {
// Paper is the complete room footprint. Interior bevel cuts expose floor,
// not the scene background, so the centre union must always remain solid.
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 +2154,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 +2201,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 +2257,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 +2268,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 +2307,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 +2353,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 = '';
@@ -1927,17 +2410,27 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; paperGeom: any; depthUnits: number; openingIndex: OpeningWallIndex | null } | null {
): {
geom: any;
/** Canonical room masonry before opening cuts and independent bodies. */
roomGeom: any;
paperGeom: any;
depthUnits: number;
openingIndex: OpeningWallIndex | null;
} | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { 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 +2450,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 +2506,9 @@ 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, exterior?.centre, paperGeom);
const roomGeom = body || [];
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
@@ -2034,7 +2533,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], paperGeom, depthUnits: maxDepth, openingIndex };
return { geom: body || [], roomGeom, paperGeom, depthUnits: maxDepth, openingIndex };
} catch {
return null;
}
@@ -2052,14 +2551,23 @@ export function wallBodiesUnionPath(
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
): {
d: string;
roomGeom: any;
paperD: string;
depthUnits: number;
fillRule: 'evenodd' | 'nonzero';
} | null {
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
);
const d = united ? polyclipToPathD(united.geom) : '';
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united && d) return {
d, roomGeom: united.roomGeom, paperD,
depthUnits: united.depthUnits, fillRule: 'evenodd',
};
if (united) return null; // successful empty result: do not resurrect raw rings
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
@@ -2180,7 +2688,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 +2745,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 +2784,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 }) => ({
+289 -35
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,223 @@ 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: [45, 102, 230], halves: [1.5, 5, 7], bevel: true },
{ angles: [0, 90, 180, 270], halves: [5, 5, 5, 5], bevel: true },
{ angles: [0, 90, 180, 270], halves: [2, 5, 3, 7], bevel: false },
];
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 ${fixture.angles.join('/')} halves ${fixture.halves.join('/')} wall arms are disconnected`,
);
assertProbeInside(fan.geometry.geom, fan.node, 'fan bevel punched a node hole');
for (const [rayIndex, ray] of fanMap.nodes[0].rays.entries()) {
const armPoint = [
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 +1153,11 @@ 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.
// #249 retains the physical multi-wall overlap up to R instead of reducing
// right-angle arms to point contacts.
closeTo(geometryArea(geometry.geom), 124512.89263371378, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727248.4374999999, 1e-6);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
'rendering mutated persisted input',
@@ -1126,18 +1376,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 +1390,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 +1406,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 +1496,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,16 +1511,15 @@ 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 floors equal room union minus canonical bounded walls', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const floors = fixture.rooms.map((room) => innerContourForRoom(
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
@@ -1282,8 +1527,17 @@ test('corner Split clean floors are exactly the room union minus canonical walls
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 extra = geometryDifferenceArea(actual, expected);
const missing = geometryDifferenceArea(expected, actual);
assert.ok(extra <= 1e-7 && missing <= 1e-7,
`floor mismatch extra=${extra} missing=${missing} floors=${JSON.stringify(floors)}`);
for (const floor of floors) {
for (const point of floor) {
assert.ok(point[0] >= 100 - 1e-7 && point[0] <= 900 + 1e-7
&& point[1] >= 100 - 1e-7 && point[1] <= 700 + 1e-7,
`clean floor escaped the source building: ${point}`);
}
}
});
test('corner Split rendering does not materialize or mutate saved geometry', () => {