fix: close both code-review mediums and drop the dead paper layer (#302 r1 M1,M2)

**M1.** `docs/WALL-THICKNESS.md` §3 «Junction nodes» переписан под решение №5:
полный mitre, фаска #249 в отставке, `bevelMultiWallBody` — только адресный
латеральный трим. Прежний абзац описывал отменённое утреннее решение.

**M2.** Guard мутанта `junction-fans-disabled` собирает `test-build` и бандл
перед смоком: `smoke_junction_holes` — единственный смок, импортирующий из
`test-build`, и в чистом worktree он падал `ERR_MODULE_NOT_FOUND` до
применения мутации. Ревью прав: после переякорения guard'а на смок я не
перегнал его штатным харнесом — только ручной test-build-патч, который worktree
не видит.

**Low + следствие.** `bevelMultiWallPaper` удалена как мёртвый код; следом
измерено (фикстура #197 и репро владельца — байт в байт с веерами и без), что
и `paperWithNodeCorners` бумаге ничего не даёт: footprint ∪ shell уже
покрывает каждый узел. Слой удалён целиком, бумага возвращена к rawPaper.
Осиротевший мутант `multi-wall-paper-full-origin-cut` (#261, «белый клин от
вычитающего разреза бумаги») снят с обоснованием: в бумаге не осталось ни
одного вычитания — этот класс регресса невозможен по построению.

`npm test` 1303/1303; `golden:verify` 126/126; контракт-проба репро — 0.

Issue: #302
User-Visible: no
This commit is contained in:
Codex
2026-08-25 17:34:19 +03:00
parent 58c096cef1
commit ec8dc6fde1
5 changed files with 249 additions and 350 deletions
File diff suppressed because one or more lines are too long
+112 -112
View File
File diff suppressed because one or more lines are too long
+15 -12
View File
@@ -185,18 +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
**Junction nodes (#302, owner decision #5).** A degree-3+ node closes with a
FULL mitre, like an ordinary wall intersection on a drawing — the #249 chamfer
is retired. For every pair of angularly adjacent rays `junctionNodeGeometry`
builds one additive fan: the mitre is accepted when it sits IN the sector
(forward along the rays for an ordinary pair, backward for a reflex outer
corner), within the classic `MITRE_LIMIT` and never past a ray's thick
support (#271); a reflex pair without a valid mitre closes with the plain
chord, an ordinary one with a local bevel bounded by the support, the limit
and twice the pair's depth. The node also gets the exact support quads of its
rays. All pieces are clipped by the plain-corner facade bound
(`junctionNodeBound`), so a node cannot grow new facade at a concave vertex.
`bevelMultiWallBody` survives only as a TARGETED lateral trim for nodes with a
degenerately short thick support (#271); every other node is purely additive.
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.
+3 -40
View File
@@ -614,45 +614,6 @@ export const MUTANTS = [
replace: ' return v;',
}],
},
{
id: 'multi-wall-paper-full-origin-cut',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue #197 keeps" '
+ 'test/wall-thickness.test.mjs',
because: 'the exterior paper must retain valid multi-wall overlap up to R; cutting from the '
+ 'offset origins recreates the measured white T-junction wedge from #261',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const cuts = multiWallEffectiveCutGeometry(\n'
+ ' node, map, true, true, protectedStrips,\n'
+ ' );',
replace: ' const cuts = multiWallEffectiveCutGeometry(\n'
+ ' node, map, false, true, protectedStrips,\n'
+ ' );',
}, {
file: 'src/wall-thickness.ts',
find: ' if (envelope) localInside = intersection(localInside, envelope);\n'
+ ' else if (centre) localInside = intersection(localInside, centre);',
replace: ' if (centre) localInside = intersection(localInside, centre);\n'
+ ' else if (envelope) localInside = intersection(localInside, envelope);',
}, {
file: 'src/wall-thickness.ts',
find: ' return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;',
replace: ' return cuts;',
}, {
file: 'src/wall-thickness.ts',
find: ' if (protectedStrips) local = union(local, protectedStrips);',
replace: ' // protected-strip fail-safe intentionally disabled by the mutant',
}, {
file: 'src/wall-thickness.ts',
find: ' if (protectedStrips) {\n'
+ ' try {\n'
+ ' let protectedInside = protectedStrips;',
replace: ' if (protectedStrips && false) {\n'
+ ' try {\n'
+ ' let protectedInside = protectedStrips;',
}],
},
{
id: 'multi-wall-orthogonal-strip-protection-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -2211,7 +2172,9 @@ export const MUTANTS = [
},
{
id: 'junction-fans-disabled',
guard: 'node demo/smoke_junction_holes.mjs',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& npm run build >/dev/null 2>&1 && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js '
+ '&& node demo/smoke_junction_holes.mjs',
because: 'без вееров сектор между соседними полосами узла остаётся дырой — '
+ 'это и есть класс артефактов #302',
patches: [{
+7 -74
View File
@@ -2952,40 +2952,6 @@ function bevelMultiWallBody(
return current;
}
function bevelMultiWallPaper(
paper: any,
centre: any,
map: MultiWallNodeMap,
): any {
let beveled = paper;
let protectedStrips: any = null;
try {
protectedStrips = multiWallProtectedMapGeometry(map);
} catch {
return paper;
}
for (const node of map.nodes) {
try {
const cuts = multiWallEffectiveCutGeometry(
node, map, true, true, protectedStrips,
);
if (cuts) beveled = difference(beveled, cuts);
} catch {
// Isolate a failed optional node cut; retain the last valid paper.
}
}
try {
if (protectedStrips) {
const protectedPaper = intersection(protectedStrips, paper);
beveled = union(beveled, protectedPaper);
}
// 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
@@ -3191,15 +3157,7 @@ export function floorFootprintGeometry(
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
return multiWallNodes.nodes.length
? paperWithNodeCorners(
paper, multiWallNodes,
junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
),
)
: paper;
return paper;
} catch {
return null;
}
@@ -3260,19 +3218,6 @@ export function junctionNodeBound(
}
}
/** Paper under the node corner: the same additive supports and fans (#302). */
function paperWithNodeCorners(paper: any, map: MultiWallNodeMap, bound?: any): any {
const corners = junctionNodeGeometry(map);
let out = paper;
for (const piece of [...corners.supports, ...corners.fans]) {
try {
let ring: any = [closedRing(piece)];
if (bound) ring = intersection(ring, bound);
if (ring?.length) out = union(out, ring);
} catch { /* keep paper */ }
}
return dropDegenerateRings(out, Math.max(map.epsilon, 1e-9) ** 2);
}
export function polyclipToPathD(geom: any): string {
if (!geom) return '';
@@ -3520,15 +3465,11 @@ export function wallBodiesGeometry(
: [];
// Paper: the approved chamfer first, then the same additive fans that
// complete the masonry corner complete the paper beneath it (#302).
const paperGeom = multiWallNodes.nodes.length
? paperWithNodeCorners(
rawPaperGeom, multiWallNodes,
junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
),
)
: rawPaperGeom;
// Paper needs no node pieces: the footprint-plus-shell union already
// covers every junction (measured on #197 and the owner repro — byte-equal
// with and without them), and with the subtractive paper bevel gone the
// #261 white-wedge class is impossible by construction.
const paperGeom = 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;
@@ -3972,15 +3913,7 @@ export function paperRoomShapesWithWalls(
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const paper = multiWallNodes.nodes.length
? paperWithNodeCorners(
rawPaper, multiWallNodes,
junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
),
)
: rawPaper;
const paper = rawPaper;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}