fix: preserve orthogonal wall strips at multi-wall joins

Issue: #275
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 01:47:22 +03:00
parent 29f6ad2969
commit 7bcc5b285b
31 changed files with 1369 additions and 304 deletions
+36 -6
View File
@@ -282,8 +282,12 @@ export const MUTANTS = [
+ 'offset origins recreates the measured white T-junction wedge from #261',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const cuts = multiWallCutGeometry(multiWallBevelCutsAt(map, true, true));',
replace: ' const cuts = multiWallCutGeometry(multiWallBevelCutsAt(map, false, true));',
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'
@@ -292,6 +296,32 @@ export const MUTANTS = [
+ ' else if (envelope) localInside = intersection(localInside, envelope);',
}],
},
{
id: 'multi-wall-orthogonal-strip-protection-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue #275 preserves" '
+ 'test/wall-thickness.test.mjs',
because: 'pairwise bevel cuts must exclude every finite strip with an orthogonal partner, '
+ 'and the post-cut reconstruction is the independent fail-safe; disabling both recreates '
+ 'the white notches and large missing wall areas from the exact #275 fixtures',
patches: [{
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-finite-ray-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -301,8 +331,8 @@ export const MUTANTS = [
+ 'node-wide 8H rectangle recreates the phantom wall and light barrier from #271',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const supportExtent = Math.min(extent, support.length);',
replace: ' const supportExtent = extent;',
find: ' const supportExtent = Math.min(extent, support.length);',
replace: ' const supportExtent = extent;',
}],
},
{
@@ -314,8 +344,8 @@ export const MUTANTS = [
+ 'white junction triangles from #272 while the old retained/discarded probes still pass',
patches: [{
file: 'src/wall-thickness.ts',
find: ' multiWallBevelCutsAt(nodeMap, true, true),',
replace: ' multiWallBevelCutsAt(nodeMap, true, false),',
find: ' multiWallBevelCutsAt(nodeMap, retainToLimit, connectToExterior),',
replace: ' multiWallBevelCutsAt(nodeMap, retainToLimit, false),',
}],
},
{
+9 -3
View File
@@ -46,12 +46,18 @@ export const SMOKE_LINKS = [
symbols: [
'multiWallBevelTriangles', 'multiWallBevelTrianglesAt',
'buildMultiWallNodeMap', 'MultiWallNodeRay', 'MultiWallNodeRaySupport',
'multiWallProtectedRayIndexes', 'multiWallProtectedStripGeometry',
'multiWallProtectedMapGeometry', 'multiWallEffectiveCutGeometry',
],
smokes: [
'smoke_junction_patch_resilience.mjs', 'smoke_multiwall_junction.mjs',
'smoke_multiwall_strip_containment.mjs',
],
smokes: ['smoke_junction_patch_resilience.mjs', 'smoke_multiwall_junction.mjs'],
because: 'the #197 smoke probes the repaired exterior wedge across Plan, View, kiosk, '
+ 'Static, hidden Iso and light barriers, while the #249 smoke proves the old excessive '
+ 'wedge stays empty, while #271 also proves finite ray endpoints across Plan, Static, '
+ 'hidden Iso and light barriers; the browser scenarios call none of the pure helpers by name',
+ 'wedge stays empty, #271 also proves finite ray endpoints, and #275 densely samples every '
+ 'protected orthogonal strip across Plan, Static, hidden Iso and light barriers; the browser '
+ 'scenarios call none of the pure helpers by name',
},
{
// #258: the browser sees only the resulting path/caches; it cannot call
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
// External-backup regression probe for issue #275.
//
// Build test-build first (`npx tsc -p tsconfig.test.json` plus fix-test-build),
// then pass one or more plan-only backups. The files are read locally; their
// contents are never copied into the repository or printed.
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { difference } from 'polyclip-ts';
import {
canonicalizeConfigGeometry,
canonicalizeLayoutGeometry,
} from '../test-build/coordinate-canonicalization.js';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
buildMultiWallNodeMap,
multiWallProtectedRayIndexes,
multiWallProtectedStripGeometry,
wallBodiesGeometry,
wallIntervals,
} from '../test-build/wall-thickness.js';
const AREA_EPSILON = 1e-5;
const payloadOf = (backup) => {
const payload = backup?.payload && typeof backup.payload === 'object'
? backup.payload
: backup;
const config = payload?.config && typeof payload.config === 'object'
? payload.config
: payload;
const layout = payload?.layout && typeof payload.layout === 'object'
? payload.layout
: {};
if (!config || !Array.isArray(config.spaces)) {
throw new Error('input has no config.spaces');
}
return { config, layout };
};
const inspectConfig = (config) => {
const before = JSON.stringify(config);
const models = spaceModels(config);
const spaces = [];
for (let index = 0; index < config.spaces.length; index++) {
const input = prepareSpacePhysicalGeometryInputs(config.spaces[index], models[index]);
const geometry = wallBodiesGeometry(
input.space.rooms,
input.walls,
input.openCuts,
input.roomOpenings,
input.wallKeyPitch,
input.cellCm,
input.gridPitch,
input.coordScale,
input.physicalBodies,
);
if (!geometry) throw new Error(`space ${index}: production wall geometry is null`);
const map = buildMultiWallNodeMap(
wallIntervals(
input.space.rooms,
input.walls,
input.openCuts,
input.wallKeyPitch,
input.cellCm,
input.gridPitch,
input.coordScale,
),
input.wallKeyPitch * input.coordScale * 0.04 * 4,
input.coordScale,
);
const nodes = [];
for (const node of map.nodes) {
const protectedRays = multiWallProtectedRayIndexes(node);
if (!protectedRays.length) continue;
const required = multiWallProtectedStripGeometry(node, map);
if (!required) throw new Error(`space ${index}: protected node has no strip geometry`);
const roomMissingArea = geometryArea(difference(required, geometry.roomGeom));
const paperMissingArea = geometryArea(difference(required, geometry.paperGeom));
nodes.push({
point: node.point.map((value) => value / input.coordScale),
rays: node.rays.length,
protectedRays: protectedRays.length,
roomMissingArea,
paperMissingArea,
pass: roomMissingArea <= AREA_EPSILON && paperMissingArea <= AREA_EPSILON,
});
}
spaces.push({
index,
degree3Nodes: map.nodes.length,
protectedNodes: nodes.length,
violations: nodes.filter((node) => !node.pass),
});
}
if (JSON.stringify(config) !== before) throw new Error('geometry inspection mutated config');
return {
spaces,
violations: spaces.reduce((total, space) => total + space.violations.length, 0),
};
};
const inspectBackup = (path) => {
const source = readFileSync(path);
const backup = JSON.parse(source.toString('utf8'));
const raw = payloadOf(backup);
const optimized = optimizePlans(raw.config, raw.layout);
const applied = {
config: canonicalizeConfigGeometry(optimized.config),
layout: canonicalizeLayoutGeometry(optimized.layout),
};
const reloaded = JSON.parse(JSON.stringify(applied));
const states = {
raw: inspectConfig(raw.config),
preview: inspectConfig(optimized.config),
applied: inspectConfig(applied.config),
reloaded: inspectConfig(reloaded.config),
};
return {
file: path,
sha256: createHash('sha256').update(source).digest('hex'),
optimizeChanged: optimized.changed,
states,
pass: Object.values(states).every((state) => state.violations === 0),
};
};
const paths = process.argv.slice(2);
if (!paths.length) {
console.error('usage: node scripts/wall-strip-containment.mjs <backup.json> [...]');
process.exit(2);
}
const report = paths.map(inspectBackup);
console.log(JSON.stringify({ issue: 275, areaEpsilon: AREA_EPSILON, report }, null, 2));
if (report.some((entry) => !entry.pass)) process.exitCode = 1;