Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa166b7982 | ||
|
|
26fa968477 | ||
|
|
b8f511d05a | ||
|
|
062a98a1d8 | ||
|
|
427ad7feda | ||
|
|
675294b9a0 | ||
|
|
748d48424b | ||
|
|
8d2e00bbbf |
@@ -5,6 +5,9 @@ import { readFileSync } from 'node:fs';
|
||||
const junctionPatchFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const multiWallJunctionFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -158,6 +161,25 @@ export function prepareGoldenFixture(scenario) {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (scenario.multiWallJunction) {
|
||||
const contract = scenario.multiWallJunction;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
|
||||
|| !Number.isInteger(contract.rays) || contract.rays < 3) {
|
||||
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
|
||||
}
|
||||
fixture.config.spaces.push({
|
||||
...structuredClone(multiWallJunctionFixture),
|
||||
id: scenario.space,
|
||||
title: 'Multi-wall bevel',
|
||||
view_box: [0.27, 0.07, 0.20, 0.21],
|
||||
settings: {
|
||||
...(multiWallJunctionFixture.settings || {}),
|
||||
fill_mode: 'none', show_borders: true, show_names: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (scenario.openingSymbolContract) {
|
||||
const contract = scenario.openingSymbolContract;
|
||||
if (!['room', 'partition'].includes(contract.kind)
|
||||
@@ -514,6 +536,15 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (scenario.multiWallJunction) {
|
||||
const { node, discardedWedgeProbe } = scenario.multiWallJunction;
|
||||
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
|
||||
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
|
||||
if (!wall?.isPointInFill?.(at(node))
|
||||
|| wall.isPointInFill(at(discardedWedgeProbe))) {
|
||||
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.roomLabelParity) {
|
||||
const labels = [...card.renderRoot.querySelectorAll('.roomlabel')];
|
||||
if (labels.length !== 2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 38;
|
||||
export const GOLDEN_MATRIX_VERSION = 39;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -266,6 +266,13 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
|
||||
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'multiwall-junction-bevel-view-dark', fixture: 'visual',
|
||||
space: 'golden-multiwall-junction', mode: 'view',
|
||||
multiWallJunction: {
|
||||
node: [0.329166667, 0.141666667], rays: 3,
|
||||
discardedWedgeProbe: [0.3300011713041353, 0.14699331561935776],
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/** Issue #249: one bounded degree-3 junction across every canonical consumer. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
|
||||
|
||||
const result = await page.evaluate(async (source) => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
await frame();
|
||||
};
|
||||
const update = async (structural = false) => {
|
||||
if (structural) {
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card._isoGeometryCache.clear();
|
||||
}
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
};
|
||||
|
||||
const space = {
|
||||
id: 'issue-249',
|
||||
title: 'Issue 249',
|
||||
cell_cm: source.cell_cm,
|
||||
view_box: [0.27, 0.07, 0.2, 0.21],
|
||||
rooms: structuredClone(source.rooms),
|
||||
walls: structuredClone(source.walls),
|
||||
settings: { show_borders: true, fill_mode: 'none' },
|
||||
};
|
||||
const cfg = {
|
||||
...structuredClone(card._serverCfg),
|
||||
spaces: [space],
|
||||
markers: [],
|
||||
};
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = {};
|
||||
card._space = space.id;
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
await update(true);
|
||||
|
||||
const persisted = JSON.stringify(card._serverCfg.spaces[0]);
|
||||
const path = root().querySelector('[data-hp="wall"]');
|
||||
const planD = path?.getAttribute('d') || '';
|
||||
const canonical = card._wallUnionGeometry();
|
||||
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
|
||||
// Midpoint between the 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- Символы дверей, окон и ворот теперь остаются точно на осевой линии стены при
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), который автор не поймал, потому что ослабил единственный тест,
|
||||
способный его обнаружить. Это блокирующая находка — заход не может быть
|
||||
зелёным.
|
||||
@@ -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 интерактивного
|
||||
инструмента — снимаю с запиской, без возврата на цикл.
|
||||
@@ -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**
|
||||
@@ -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**
|
||||
@@ -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; это не отдельный
|
||||
визуальный слой.
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"cell_cm": 30,
|
||||
"node": [0.329166667, 0.141666667],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room_a",
|
||||
"poly": [
|
||||
[0.308333333, 0.2375],
|
||||
[0.329166667, 0.141666667],
|
||||
[0.379166667, 0.191666667],
|
||||
[0.379166667, 0.2375]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "room_b",
|
||||
"poly": [
|
||||
[0.379166667, 0.191666667],
|
||||
[0.329166667, 0.141666667],
|
||||
[0.408333333, 0.1],
|
||||
[0.433333333, 0.158333333]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.320833,0.191667@1.7850",
|
||||
"cm": 50,
|
||||
"a": [0.308333333, 0.2375],
|
||||
"b": [0.329166667, 0.141666667]
|
||||
},
|
||||
{
|
||||
"key": "0.370833,0.120833@2.6572",
|
||||
"cm": 50,
|
||||
"a": [0.329166667, 0.141666667],
|
||||
"b": [0.408333333, 0.1]
|
||||
},
|
||||
{
|
||||
"key": "0.345833,0.237500@0.0000",
|
||||
"cm": 15,
|
||||
"a": [0.379166667, 0.2375],
|
||||
"b": [0.308333333, 0.2375]
|
||||
},
|
||||
{
|
||||
"key": "0.354167,0.166667@0.7856",
|
||||
"cm": 70,
|
||||
"a": [0.329166667, 0.141666667],
|
||||
"b": [0.379166667, 0.191666667]
|
||||
},
|
||||
{
|
||||
"key": "0.408333,0.175000@2.5900",
|
||||
"cm": 50,
|
||||
"a": [0.433333333, 0.158333333],
|
||||
"b": [0.379166667, 0.191666667]
|
||||
},
|
||||
{
|
||||
"key": "0.420833,0.129167@1.1661",
|
||||
"cm": 50,
|
||||
"a": [0.408333333, 0.1],
|
||||
"b": [0.433333333, 0.158333333]
|
||||
},
|
||||
{
|
||||
"key": "0.379167,0.216667@1.5706",
|
||||
"cm": 25,
|
||||
"a": [0.379166667, 0.191666667],
|
||||
"b": [0.379166667, 0.2375]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -321,7 +321,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 38);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 39);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -372,6 +372,27 @@ test('issue #197 golden keeps the complete junction fixture in Plan and View', (
|
||||
}
|
||||
});
|
||||
|
||||
test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
|
||||
const scenario = GOLDEN_SCENARIOS.find(
|
||||
(item) => item.id === 'multiwall-junction-bevel-view-dark',
|
||||
);
|
||||
assert.ok(scenario);
|
||||
assert.equal(scenario.mode, 'view');
|
||||
assert.equal(scenario.theme, 'dark');
|
||||
assert.equal(scenario.multiWallJunction.rays, 3);
|
||||
assert.equal(scenario.multiWallJunction.node.length, 2);
|
||||
assert.equal(scenario.multiWallJunction.discardedWedgeProbe.length, 2);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.ok(space);
|
||||
assert.deepEqual(space.node, scenario.multiWallJunction.node);
|
||||
const endpointRays = space.walls.filter((wall) => [wall.a, wall.b].some((point) => (
|
||||
Math.hypot(point[0] - space.node[0], point[1] - space.node[1]) < 1e-8
|
||||
)));
|
||||
assert.equal(endpointRays.length >= scenario.multiWallJunction.rays, true);
|
||||
assert.equal(space.settings.show_borders, true);
|
||||
});
|
||||
|
||||
test('the open color picker golden covers dark mobile and light desktop themes', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.dialog === 'decor-color');
|
||||
assert.deepEqual(scenarios.map(({ id, language, theme, viewport }) => ({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||