feat: junction nodes — detector, fans over the approved chamfer, scene set (#302)

Вторая половина переработки узлов поверх ядра из прошлого коммита:

- `junctionContractHoles` — объективный инвариант «тело ⊇ полосы ∪ веера в
  фасадной границе» как экспортная чистая функция; самопроверка на заведомо
  дырявой фикстуре входит в юниты. Первая формулировка детектора из ТЗ
  («окружено кладкой с ≥5 из 8 сторон») уточнена в §8.4 по факту измерения:
  она ложно флагует легитимный пол комнаты в острых внутренних углах.
- `junctionNodeBound` — «гладкая» фасадная граница (конверт с обычными углами)
  экспортирована: ею клиппуются куски узла и ею же пользуются тесты.
- юниты #302: веера/рефлекс/короткий толстый саппорт/детектор/репро
  end-to-end; хелпер тестов старого контракта переведён на strip-safe
  семантику по саппортам и фасадной границе.
- смок `smoke_junction_holes`: контрактные пробы считаются в node из той же
  фикстуры и проверяются в браузере по реальному `d`-пути карточки.
- сет из 16 golden-сцен стыков крупным планом (звёзды лучей: T/X/Y/острые/
  почти коллинеарные/виртуальные/колонна/черновик) + сцена-репро владельца;
  билдер сцен и `zoomCenter` в harness. Контракт сцены
  `multiwall-junction-bevel-view-dark` инвертирован по решению владельца:
  проба в перекрытии полос обязана быть ЗАПОЛНЕНА (strip-safe), а не пустой.
- семь мутантов §14, включая слепоту детектора и невозврат саппортов.
- фикстура #197: площадь кладки выросла на 0.2 юнита² — слайвер вееров вдоль
  хорд фаски; константа обновлена с комментарием.

Эталоны новых сцен идут отдельным коммитом с положенными трейлерами.

Issue: #302
User-Visible: yes
This commit is contained in:
Codex
2026-08-25 17:34:19 +03:00
parent a1361974a3
commit 76d60b3e1b
24 changed files with 1160 additions and 598 deletions
File diff suppressed because one or more lines are too long
+77 -4
View File
@@ -2,6 +2,9 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { readFileSync } from 'node:fs';
const junctionArtifactsFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
@@ -224,6 +227,75 @@ export function prepareGoldenFixture(scenario) {
partitions: [], room_drafts: [], wall_columns: [], decor: [],
});
}
if (scenario.junctionNode) {
// #302: a close-up star node. Pie-slice rooms around the centre; every
// arm is a shared edge carrying its own thickness; a virtual arm is the
// same edge released by an open span.
const spec = scenario.junctionNode;
const C = [0.5, 0.5];
const R = 0.3;
const arms = spec.arms
.map((arm) => ({ ...arm, rad: (arm.deg * Math.PI) / 180 }))
.sort((a, b) => a.deg - b.deg);
const endOf = (arm) => [
C[0] + Math.cos(arm.rad) * R, C[1] + Math.sin(arm.rad) * R,
];
const rooms = [];
for (let index = 0; index < arms.length; index++) {
const a = arms[index];
const b = arms[(index + 1) % arms.length];
const sweep = ((b.deg - a.deg + 360) % 360) || 360;
const arc = [];
const steps = Math.max(1, Math.ceil(sweep / 60));
for (let step = 0; step <= steps; step++) {
const rad = ((a.deg + (sweep * step) / steps) * Math.PI) / 180;
arc.push([C[0] + Math.cos(rad) * R, C[1] + Math.sin(rad) * R]);
}
rooms.push({
id: `junction-slice-${index}`, name: `S${index}`, area: null,
poly: [C.map((v) => v), ...arc],
});
}
const walls = arms.map((arm) => {
const end = endOf(arm);
return { key: fixtureWallKey(C, end), a: [...C], b: [...end], cm: arm.cm };
});
const space = {
id: scenario.space, title: 'Junction node', view_box: [0, 0, 1, 1],
cell_cm: 5,
rooms, walls,
settings: { fill_mode: 'none', show_borders: true, show_names: false },
};
const virtual = arms.filter((arm) => arm.virtual);
if (virtual.length) {
space.open_spans = virtual.map((arm) => {
const end = endOf(arm);
return { a: [...C], b: [...end] };
});
}
if (spec.column) {
space.wall_columns = [{
id: 'junction-column', center: [...C], shape: 'circle', size_cm: 40,
}];
}
if (spec.draft) {
space.room_drafts = [{
id: 'junction-draft',
points: [[0.5, 0.5], [0.75, 0.62]],
segments: [{ cm: 15 }],
}];
}
fixture.config.spaces.push(space);
}
if (scenario.junctionArtifacts) {
fixture.config.spaces.push({
...structuredClone(junctionArtifactsFixture),
id: scenario.space,
title: 'Junction artifacts repro',
view_box: [0, 0, 1, 1],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
});
}
if (scenario.junctionPatchResilience) {
if (!Array.isArray(scenario.retainedWedgeProbe)
|| scenario.retainedWedgeProbe.length !== 2
@@ -262,7 +334,7 @@ export function prepareGoldenFixture(scenario) {
const contract = scenario.multiWallJunction;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
if (!validPoint(contract.node) || !validPoint(contract.retainedOverlapProbe)
|| !Number.isInteger(contract.rays) || contract.rays < 3
|| !Number.isInteger(contract.enclosedHoles) || contract.enclosedHoles < 0) {
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
@@ -728,11 +800,11 @@ export async function prepareGoldenScenario(page, scenario) {
}
}
if (scenario.multiWallJunction) {
const { node, discardedWedgeProbe, enclosedHoles } = scenario.multiWallJunction;
const { node, retainedOverlapProbe, enclosedHoles } = 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))) {
|| !wall.isPointInFill(at(retainedOverlapProbe))) {
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
}
// A pixel threshold missed the reported triangles because they occupy a
@@ -885,7 +957,8 @@ export async function prepareGoldenScenario(page, scenario) {
await until(() => card._renderProjection === 'iso');
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
const [zx, zy] = Array.isArray(scenario.zoomCenter) ? scenario.zoomCenter : [500, 500];
card._applyView(scenario.zoom, zx, zy);
card.requestUpdate();
await card.updateComplete;
}
+52 -2
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 44;
export const GOLDEN_MATRIX_VERSION = 45;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -306,7 +306,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
space: 'golden-multiwall-junction', mode: 'view',
multiWallJunction: {
node: [0.329166667, 0.141666667], rays: 3,
discardedWedgeProbe: [0.3303808442725, 0.1488560107825],
// #302: the chamfer is strip-safe — this point sits in the overlap of
// two incident strips and must stay FILLED (it used to be discarded by
// the old subtractive bevel, which is exactly the hole class fixed).
retainedOverlapProbe: [0.3303808442725, 0.1488560107825],
// Product contract (#272): a bounded exterior bevel is allowed, but no
// empty component may remain trapped inside the continuous masonry.
enclosedHoles: 0,
@@ -571,6 +574,53 @@ export const GOLDEN_SCENARIOS = Object.freeze([
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// --- #302: junction close-ups. One node per scene, the node fills the
// frame; the same scenes carry the objective no-holes detector in tests.
...[
['junction-t-90-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
['junction-t-90-bar50-leg15-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 90, cm: 15 }, { deg: 180, cm: 50 }] }],
['junction-t-90-bar70-leg15-dark', { arms: [
{ deg: 0, cm: 70 }, { deg: 90, cm: 15 }, { deg: 180, cm: 70 }] }],
['junction-x-90-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 },
{ deg: 270, cm: 15 }] }],
['junction-x-90-mixed-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 50 }, { deg: 180, cm: 15 },
{ deg: 270, cm: 70 }] }],
['junction-x-45-alternating-dark', { arms: [
{ deg: 45, cm: 15 }, { deg: 135, cm: 50 }, { deg: 225, cm: 15 },
{ deg: 315, cm: 50 }] }],
['junction-star5-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 72, cm: 15 }, { deg: 144, cm: 15 },
{ deg: 216, cm: 15 }, { deg: 288, cm: 15 }] }],
['junction-y-60-equal50-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 60, cm: 50 }, { deg: 120, cm: 50 }] }],
['junction-acute30-mixed-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 30, cm: 70 }, { deg: 180, cm: 15 }] }],
['junction-acute15-equal50-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 15, cm: 50 }, { deg: 180, cm: 50 }] }],
['junction-splay10-170-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 10, cm: 15 }, { deg: 180, cm: 50 }] }],
['junction-t-virtual-arm-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
{ deg: 180, cm: 15 }] }],
['junction-x-virtual-through-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
{ deg: 180, cm: 15 }, { deg: 270, cm: 15, virtual: true }] }],
['junction-column-node-dark', { column: true, arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
['junction-draft-end-node-dark', { draft: true, arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
].map(([id, junctionNode]) => ({
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
})),
{ id: 'junction-owner-repro-dark', fixture: 'visual',
space: 'golden-junction-owner-repro', mode: 'view', junctionArtifacts: true,
zoom: 6, zoomCenter: [329.17, 141.67], theme: 'dark',
viewport: { width: 900, height: 900 }, ...stage },
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
+104
View File
@@ -0,0 +1,104 @@
// Issue #302: the rendered wall body of the owner's repro covers the junction
// contract — every support strip and sector fan, clipped to the facade bound.
//
// The units own the geometry rules; this smoke owns the wiring: the same
// probes that the pure pipeline must cover are checked against the actual
// `d` attribute the card renders, so a regression anywhere between the
// geometry pass and the DOM turns it red.
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
import {
wallBodiesGeometry, wallIntervals, buildMultiWallNodeMap,
junctionNodeGeometry, junctionNodeBound, MITRE_LIMIT,
} from '../test-build/wall-thickness.js';
import { GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/302-junction-artifacts.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 map = buildMultiWallNodeMap(
wallIntervals(rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W)
.filter((iv) => !iv.open && iv.half > 0),
GRID_STEP_N * NORM_W * 0.04 * 4, NORM_W,
);
const bound = junctionNodeBound(
rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W, map,
);
const corners = junctionNodeGeometry(map);
const inPolygon = (points, x, y) => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i], [xj, yj] = points[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const inGeometry = (geom, x, y) => {
let inside = false;
for (const poly of geom || []) for (const ring of poly || []) {
if (inPolygon(ring, x, y)) inside = !inside;
}
return inside;
};
// Contract probes around every node: strip or fan, inside the facade bound.
const probes = [];
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
const step = GRID_PITCH * 0.25;
for (let dx = -radius; dx <= radius; dx += step) {
for (let dy = -radius; dy <= radius; dy += step) {
const x = node.point[0] + dx, y = node.point[1] + dy;
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = x - node.point[0], ry = y - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - step * 0.25;
}));
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
if (!inStrip && !inFan) continue;
if (bound && !inGeometry(bound, x, y)) continue;
probes.push([x, y]);
}
}
}
if (probes.length < 500) {
console.error(`FAIL: contract probe set degenerated to ${probes.length} points`);
process.exit(1);
}
const { page, browser } = await launch({ width: 1200, height: 1000 }, 1);
const res = await page.evaluate(async ({ sp, probes: probeList }) => {
const out = {};
const c = window.__card;
const settle = async () => {
for (let i = 0; i < 4; i++) await new Promise((r) => requestAnimationFrame(r));
await c.updateComplete;
};
sp.id = c._space;
c._serverCfg.spaces[c._serverCfg.spaces.findIndex((s) => s.id === c._space)] = sp;
c._cfgEpoch++; c.requestUpdate(); await settle();
await new Promise((r) => setTimeout(r, 600));
const united = c._wallUnionGeometry();
out.geometryProduced = !!united?.d;
const path = new Path2D(united.d);
const g = document.createElement('canvas').getContext('2d');
const holes = probeList.filter(([x, y]) =>
!g.isPointInPath(path, x, y, united.fillRule || 'evenodd'));
out.probes = probeList.length;
out.holes = holes.length;
out.holeSample = holes.slice(0, 5).map((p) => p.map((v) => +v.toFixed(1)));
out.rendered = !!c.shadowRoot.querySelector('.wallbody');
return out;
}, { sp: { ...JSON.parse(readFileSync(new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8')), rooms: fixture.rooms, title: 'Repro', view_box: [0, 0, 1, 1], settings: { show_borders: true } }, probes });
const verdict = {
geometryProduced: res.geometryProduced,
rendered: res.rendered,
enoughProbes: res.probes >= 500,
noContractHoles: res.holes === 0,
};
if (res.holes) console.error('дыры:', res.holeSample, 'из', res.holes);
checkAll(verdict);
await finish(browser, verdict);
+14 -50
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Wall junctions no longer leave white wedges and notches. A node where three
or more walls meet is now completed with sector fans and the exact strips of
its rays on top of the existing chamfer: an acute junction is solid masonry,
and the chamfer cut survives only where it does not touch the walls
themselves. The no-holes invariant is checked automatically on a set of 16
close-up junction scenes and on the plan from the original report
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
- Every Plan editor tool now shows the same complete set of wall axes and
structural endpoints. Switching from Walls to Thickness no longer hides room
axes, while each tool keeps its own hints and live preview
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
три и больше стен, теперь достраивается угловыми веерами и точными полосами
своих лучей поверх прежней фаски: острый стык — сплошная кладка, срез фаски
остаётся только там, где он не задевает сами стены. Инвариант «дыр нет»
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
плане из исходного сообщения
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
+15
View File
@@ -185,6 +185,21 @@ 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.
**Junction nodes (#302).** A degree-3+ node keeps the approved #249 chamfer
(`bevelMultiWallBody`, bounded by the node's join limit
`MULTI_WALL_JOIN_LIMIT × halfDepth`), and AFTER it the node additively gets
back what a chamfer must never eat: the exact support quads of its rays (each
bounded by its own finite length, so a trimmed lateral phantom — #271 — cannot
come back) and one sector fan per pair of angularly adjacent rays
(`junctionNodeGeometry`): mitre up to the node limit, bevel chord past it,
reflex sectors skipped. The pieces are clipped by the plain-corner facade
bound (`junctionNodeBound`), so a node can never grow new facade at a concave
vertex. The result is strip-safe: an acute junction is solid masonry, and the
objective invariant «body ⊇ support strips ∪ fans, inside the facade bound» is
machine-checked by `junctionContractHoles` in tests and the
`smoke_junction_holes` wiring probe. Degenerate zero-area rings left by
coincident chords are dropped.
**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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+21 -21
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"sourceFingerprint": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "c9e2203fe2df5c2627c861f92e9c4dfad585efb101df19e4b6388a6fbc27d01a"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "51551aa5237090b67ff4c48802b0f2518e884b48cb1b0319e782fced239953ea"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "a544bd821460e5b9b7a83b1636ce6b6a3fc50be68fe4f74779d23ec9e12abe6a"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "e52f419772f86480b336a0ab63d0c781207101b262fdf73f5fd581527e47dce4"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,8 +36,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "0129b6a5f1f97fa5d29db0a30bfd899bcfe53308a8ba3872d2a794132fccc9cc"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "e9a847d17b9bd02ad9fa39a6e7194f980144e90538eb94413f770f9539ceb7bc"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "7f243f578d2d286ae7911363571762e3910fe2117abc5253292d9ef2d578c8dc"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "7fd57bfc022211c5d37bd7c4a1a72b34ed7651fd577ada1d2314b64ae5061c21"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "24710a46be38418a09c4bdb3db8750611affea593d80c91cfe74c105ef8ed201"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "0ef6798b79ff0b78e1947b1dba547b92cbdf11b79b6335cffb8a415158c09b5d"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "1eaedd95180175f3db2c2e052bad70db63f6c70b296ec19c3d152ccbb81520ed"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "3842b3306d4e73a5b1d3475a96b4c1fd433c5c4334054a34d52f7cc0a20ae931"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "460f098f7761a41c6b30e42940cb5c9d6f96966ba34e4df04ea273575c293e93"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "f91c9575404abc64c5f03bb7dc9283b80231ada6bfd88774cfdf7d53fb46e0b2"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,8 +102,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
"file": "09-device-info.png",
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "29b23ec03b320c35b3b6d725b3e69dd7a2cfcd1607cfbbe01dceb37c316964c3"
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
"imageSha256": "83debe3fc2853406f8bc37df55d162a0b0dadc8c4e997dda4db83c07ca25caab"
}
}
}
+71
View File
@@ -2209,6 +2209,77 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'junction-fans-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'без вееров сектор между соседними полосами узла остаётся дырой — '
+ 'это и есть класс артефактов #302',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (mitre && mitreWithinStrips',
replace: ' if (false && mitre && mitreWithinStrips',
}, {
file: 'src/wall-thickness.ts',
find: ' push(out.fans, [[P[0], P[1]], EA, A2, B2, EB]);',
replace: ' void A2; void B2;',
}],
},
{
id: 'junction-supports-not-restored',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="hole-free end to end" test/wall-thickness.test.mjs',
because: 'фаска старого слоя срезает материал полос на острых стыках; '
+ 'без возврата саппорт-квадов узла репро владельца снова дырявое',
patches: [{
file: 'src/wall-thickness.ts',
find: ' for (const piece of [...corners.supports, ...corners.fans]) {',
replace: ' for (const piece of corners.fans) {',
}],
},
{
id: 'junction-fan-ignores-thick-length',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'веер, шагающий за короткий толстый саппорт, рисует латеральный '
+ 'фантом рядом с тонким продолжением (#271)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' mitreWithinStrips = tA <= A.thickLength && tB <= B.thickLength;',
replace: ' mitreWithinStrips = true;',
}],
},
{
id: 'junction-reflex-sector-fanned',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'рефлексный сектор — внешность выпуклого угла; веер там — лишний '
+ 'фасадный материал (контракт вогнутого Split)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (sector > Math.PI + 1e-9) continue;',
replace: ' void sector;',
}],
},
{
id: 'junction-detector-blind',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'слепой детектор превращает инвариант «нет дыр» в декорацию — '
+ 'самопроверка на заведомо дырявой фикстуре обязана краснеть',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (!inGeometry(geometry, x, y)) holes.push([x, y]);',
replace: ' void x; void y;',
}],
},
{
id: 'junction-pieces-unbounded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="concave vertex" test/wall-thickness.test.mjs',
because: 'куски узла без клипа гладкой фасадной границей отращивают новый '
+ 'фасад на вогнутой вершине',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (bound) ring = intersection(ring, bound);',
replace: ' void bound;',
}],
},
{
id: 'hatch-step-ignores-cell-cm',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+60
View File
@@ -2138,6 +2138,66 @@ export function junctionNodeFans(
return junctionNodeGeometry(map).fans;
}
/**
* The objective no-holes invariant of #302 (spec §8.4).
*
* A probe is a HOLE when the contract says the point is masonry — inside one
* of the node's support strips or sector fans, and inside the approved facade
* bound — yet the produced geometry does not cover it. Legitimate floor of an
* acute room corner never trips this (it is outside both strips and fans),
* which is what the first "surrounded by masonry" formulation got wrong.
*/
export function junctionContractHoles(
geometry: any,
map: MultiWallNodeMap | null | undefined,
options: { step: number; bound?: any },
): { node: [number, number]; holes: number[][] }[] {
if (!map?.nodes?.length || !(options.step > 0)) return [];
const corners = junctionNodeGeometry(map);
const inPolygon = (points: number[][], x: number, y: number): boolean => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
inside = !inside;
}
return inside;
};
const inGeometry = (geom: any, x: number, y: number): boolean => {
let inside = false;
for (const polygon of geom || []) for (const ring of polygon || []) {
if (inPolygon(ring, x, y)) inside = !inside;
}
return inside;
};
const reports: { node: [number, number]; holes: number[][] }[] = [];
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
const holes: number[][] = [];
for (let dx = -radius; dx <= radius; dx += options.step) {
for (let dy = -radius; dy <= radius; dy += options.step) {
const x = node.point[0] + dx;
const y = node.point[1] + dy;
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = x - node.point[0];
const ry = y - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0])
<= support.halfDepth - options.step * 0.25;
}));
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
if (!inStrip && !inFan) continue;
if (options.bound && !inGeometry(options.bound, x, y)) continue;
if (!inGeometry(geometry, x, y)) holes.push([x, y]);
}
}
if (holes.length) reports.push({ node: [...node.point], holes });
}
return reports;
}
/** Find the canonical degree-3+ node matching a contour vertex. */
export function multiWallNodeAt(
map: MultiWallNodeMap | null | undefined,
+154
View File
@@ -0,0 +1,154 @@
{
"cell_cm": 30,
"rooms": [
{
"id": "rmrwhxasn",
"poly": [
[
0.23333333333333334,
0.029166666666666667
],
[
0.525,
0.029166666666666667
],
[
0.525,
0.7666666666666667
],
[
0.23333333333333334,
0.7666666666666667
]
]
},
{
"id": "rmt4w8xzu-0",
"poly": [
[
0.30833333333333335,
0.2375
],
[
0.32916666666666666,
0.14166666666666666
],
[
0.37916666666666665,
0.19166666666666668
],
[
0.37916666666666665,
0.2375
]
]
},
{
"id": "rmt4w9f2r-0",
"poly": [
[
0.37916666666666665,
0.19166666666666668
],
[
0.32916666666666666,
0.14166666666666666
],
[
0.4083333333333333,
0.1
],
[
0.43333333333333335,
0.15833333333333333
]
]
}
],
"walls": [
{
"key": "0.320833,0.191667@1.7850",
"cm": 50,
"a": [
0.30833333333333335,
0.2375
],
"b": [
0.32916666666666666,
0.14166666666666666
]
},
{
"key": "0.370833,0.120833@2.6572",
"cm": 50,
"a": [
0.32916666666666666,
0.14166666666666666
],
"b": [
0.4083333333333333,
0.1
]
},
{
"key": "0.345833,0.237500@0.0000",
"cm": 15,
"a": [
0.37916666666666665,
0.2375
],
"b": [
0.30833333333333335,
0.2375
]
},
{
"key": "0.354167,0.166667@0.7856",
"cm": 70,
"a": [
0.32916666666666666,
0.14166666666666666
],
"b": [
0.37916666666666665,
0.19166666666666668
]
},
{
"key": "0.408333,0.175000@2.5900",
"cm": 50,
"a": [
0.43333333333333335,
0.15833333333333333
],
"b": [
0.37916666666666665,
0.19166666666666668
]
},
{
"key": "0.420833,0.129167@1.1661",
"cm": 50,
"a": [
0.4083333333333333,
0.1
],
"b": [
0.43333333333333335,
0.15833333333333333
]
},
{
"key": "0.379167,0.216667@1.5706",
"cm": 25,
"a": [
0.37916666666666665,
0.19166666666666668
],
"b": [
0.37916666666666665,
0.2375
]
}
]
}
+2 -2
View File
@@ -367,7 +367,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, 44);
assert.equal(GOLDEN_MATRIX_VERSION, 45);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -466,7 +466,7 @@ test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
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);
assert.equal(scenario.multiWallJunction.retainedOverlapProbe.length, 2);
assert.equal(scenario.multiWallJunction.enclosedHoles, 0,
'the product contract must not preserve the temporary two-hole inventory');
const fixture = prepareGoldenFixture(scenario);
+116 -1
View File
@@ -25,7 +25,7 @@ import {
WALL_HATCH_MIN_PX,
wallHatchStepUnits, wallHatchNeedsSolid,
HATCH_BASE_STEP_UNITS, HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS,
junctionNodeBound,
junctionNodeBound, junctionNodeGeometry, junctionContractHoles,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { resolveOpenCuts } from '../test-build/open-spans.js';
@@ -2897,3 +2897,118 @@ test('ownEdgeOffsets reads the atomic profile, not a whole-edge lookup (#233)',
assert.deepEqual(ownEdgeOffsets(rooms, 'r', [], [], pitch, 5, GRID_PITCH, 1), [0, 0, 0, 0]);
assert.equal(ownEdgeOffsets(rooms, 'missing', whole, [], pitch, 5, GRID_PITCH, 1), null);
});
// --- issue #302: junction node corners --------------------------------------
const nodeMapOf = (intervals, eps = 1e-6) => buildMultiWallNodeMap(intervals, eps, 1);
const starIntervals = (arms) => arms.flatMap(({ deg, half, len = 300 }, index) => {
const rad = (deg * Math.PI) / 180;
const b = [500 + Math.cos(rad) * len, 500 + Math.sin(rad) * len];
return [{
key: `arm-${index}`, a: [500, 500], b, half, cm: half * 2,
kind: 'outer', open: false, roomId: `r${index}`,
}];
});
test('issue 302 a T node covers every sector with a fan or a mitre', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 90, half: 5 }, { deg: 180, half: 5 },
]));
assert.equal(map.nodes.length, 1);
const { fans, supports } = junctionNodeGeometry(map);
assert.equal(supports.length, 3, 'one quad per support');
// Sectors: [0..90], [90..180] (two quarter fans) and [180..360] reflex —
// skipped: the outside of the bar corner is legitimately empty.
assert.equal(fans.length, 2);
});
test('issue 302 a reflex sector adds nothing', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 30, half: 5 }, { deg: 100, half: 5 },
]));
const { fans } = junctionNodeGeometry(map);
// [0..30] and [30..100] are real sectors; [100..360] (260°) is the outside
// of the convex corner — legitimately empty, no fan.
assert.equal(fans.length, 2);
});
test('issue 302 a mitre past the short thick support degrades to a bevel', () => {
// The mitre point lies inside the node limit but PAST the 2-unit thick
// support of the 0° ray: accepting it would paint a lateral phantom beside
// the thin continuation (#271), so the pair must fall back to a bevel.
const map = nodeMapOf([
...starIntervals([{ deg: 90, half: 2.5 }, { deg: 210, half: 2.5 }]),
{ key: 'short-thick', a: [500, 500], b: [502, 500], half: 10, cm: 20,
kind: 'outer', open: false, roomId: 'rt' },
]);
assert.equal(map.nodes.length, 1);
const { fans } = junctionNodeGeometry(map);
// The [0°..90°] fan: mitre would sit at (502.5, 510) — inside the limit
// (10.3 < 12.5) but past the thick support (t = 2.5 > 2) → five-point bevel.
const fan = fans.find((poly) => poly.some((point) => point[1] > 505));
assert.ok(fan, 'the sector fan between the thick and the vertical ray is gone');
assert.equal(fan.length, 5, 'the mitre past the thick support was accepted');
for (const point of fan) {
assert.ok(
Math.hypot(point[0] - 500, point[1] - 500) <= map.nodes[0].limit + 1e-7,
`fan escaped the node limit: ${point}`,
);
}
});
test('issue 302 the hole detector is not blind: a deliberately holed body is red', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 120, half: 5 }, { deg: 240, half: 5 },
]));
// A body that covers only two of the three strips: the third is a hole.
const quad = (deg, half, len) => {
const rad = (deg * Math.PI) / 180;
const u = [Math.cos(rad), Math.sin(rad)];
const e = [-u[1] * half, u[0] * half];
return [[
[500 + e[0], 500 + e[1]],
[500 + u[0] * len + e[0], 500 + u[1] * len + e[1]],
[500 + u[0] * len - e[0], 500 + u[1] * len - e[1]],
[500 - e[0], 500 - e[1]],
[500 + e[0], 500 + e[1]],
]];
};
const holed = [quad(0, 5, 300), quad(120, 5, 300)];
const reports = junctionContractHoles(holed, map, { step: 2 });
assert.equal(reports.length, 1, 'the detector missed the missing strip');
assert.ok(reports[0].holes.length > 10);
const full = [quad(0, 5, 300), quad(120, 5, 300), quad(240, 5, 300),
...junctionNodeGeometry(map).fans.map((fan) => [[...fan, fan[0]]])];
assert.equal(junctionContractHoles(full, map, { step: 2 }).length, 0,
'the detector flags a body that covers the whole contract');
});
test('issue 302 the owner repro is hole-free end to end', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/302-junction-artifacts.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 geometry = wallBodiesGeometry(
rooms, fixture.walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.equal(geometry.status, 'ok');
const map = buildMultiWallNodeMap(
wallIntervals(rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W)
.filter((iv) => !iv.open && iv.half > 0),
pitch * NORM_W * 0.04 * 4, NORM_W,
);
assert.ok(map.nodes.length >= 2, 'the repro lost its multi-wall nodes');
const bound = junctionNodeBound(
rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, map,
);
const reports = junctionContractHoles(geometry.geom, map, {
step: GRID_PITCH * 0.2, bound,
});
assert.deepEqual(
reports.map((report) => ({ node: report.node, holes: report.holes.length })),
[],
'the владелец repro still has junction holes',
);
});