mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
fix: preserve orthogonal wall strips at multi-wall joins
Issue: #275 User-Visible: yes
This commit is contained in:
@@ -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),',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user