fix: straighten near-axis wall geometry

Issue: #290
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 16:30:11 +03:00
parent 6551c5d53d
commit edf8b068ed
25 changed files with 845 additions and 23 deletions
+50 -2
View File
@@ -4,7 +4,7 @@
*
* npm run invariants -- --config <файл> # экспорт, config/get или сырой config
* npm run invariants -- --config <файл> --layout <файл>
* npm run invariants -- --config <файл> --json
* npm run invariants -- --config <файл> --near-axis --json
*
* Зачем это существует. Самый дорогой класс дефектов проекта — не ошибки
* формул, а потеря согласованности между геометрией и ссылками на неё: #253
@@ -19,6 +19,7 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
import { classifyNearAxisSegment } from '../test-build/near-axis.js';
/** Доля шага сетки, в пределах которой запись считается лежащей на ребре. */
const EDGE_TOLERANCE = 0.004;
@@ -44,6 +45,42 @@ const roomPolygon = (room) => {
const edgesOf = (poly) => poly.map((a, index) => [a, poly[(index + 1) % poly.length]]);
/** Deduplicated physical near-axis segments, including both room-owner copies. */
export function nearAxisProfile(config) {
const spaces = [];
let total = 0;
const keyOf = (a, b) => {
const ka = `${a[0]},${a[1]}`, kb = `${b[0]},${b[1]}`;
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
for (const space of config?.spaces || []) {
const found = new Set();
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (!poly) continue;
for (const [a, b] of edgesOf(poly)) {
if (classifyNearAxisSegment(a, b)) found.add(keyOf(a, b));
}
}
for (const draft of space?.room_drafts || []) {
for (let index = 0; index + 1 < (draft?.points || []).length; index++) {
const a = point(draft.points[index]), b = point(draft.points[index + 1]);
if (a && b && classifyNearAxisSegment(a, b)) found.add(`draft:${draft.id}:${index}`);
}
}
for (const partition of space?.partitions || []) {
const a = point(partition?.a), b = point(partition?.b);
if (a && b && classifyNearAxisSegment(a, b)) {
found.add(`partition:${partition.id || keyOf(a, b)}`);
}
}
const count = found.size;
spaces.push({ spaceId: String(space?.id || ''), count });
total += count;
}
return { total, spaces };
}
const distToSegment = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
@@ -587,7 +624,7 @@ function main(argv) {
const configPath = arg('--config');
if (!configPath) {
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>]'
+ ' [--lattice] [--json]');
+ ' [--lattice|--near-axis] [--json]');
return 2;
}
const model = readModel(readFileSync(configPath, 'utf8'));
@@ -599,6 +636,17 @@ function main(argv) {
else console.log(latticeReport(profile));
return 0;
}
if (argv.includes('--near-axis')) {
const profile = nearAxisProfile(model.config);
if (argv.includes('--json')) console.log(JSON.stringify({ nearAxis: profile }, null, 2));
else {
console.log(`Почти осевых физических стен: ${profile.total}.`);
for (const space of profile.spaces.filter((item) => item.count)) {
console.log(` ${space.spaceId || '(без id)'}: ${space.count}`);
}
}
return 0;
}
const violations = [
...checkReferences(model, { notes }),
...checkWallKeys(model.config, { notes }),
+36 -2
View File
@@ -697,9 +697,9 @@ export const MUTANTS = [
patches: [{
file: 'src/plan-optimizer.ts',
find: ' const persistedConfig = canonicalizeConfigGeometry(config);\n'
+ ' const persistedLayout = canonicalizeLayoutGeometry(aligned.layout);',
+ ' const persistedLayout = canonicalizeLayoutGeometry(finalAligned.layout);',
replace: ' const persistedConfig = config;\n'
+ ' const persistedLayout = aligned.layout;',
+ ' const persistedLayout = finalAligned.layout;',
}],
},
{
@@ -941,6 +941,40 @@ export const MUTANTS = [
replace: ' if (false && pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {',
}],
},
{
id: 'near-axis-threshold-weakened',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#290 near-axis boundary" test/near-axis.test.mjs',
because: 'the measured 316x1 wall must stay inside the one shared 0.25 degree drafting '
+ 'tolerance while 316x2 and a true diagonal remain outside (#290)',
patches: [{
file: 'src/near-axis.ts',
find: 'export const NEAR_AXIS_MAX_DEGREES = 0.25;',
replace: 'export const NEAR_AXIS_MAX_DEGREES = 0.1;',
}],
},
{
id: 'near-axis-inclusive-boundary-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#290 near-axis boundary" test/near-axis.test.mjs',
because: 'the product contract includes an edge exactly on the 0.25 degree boundary (#290)',
patches: [{
file: 'src/near-axis.ts',
find: ' if (minor / major > NEAR_AXIS_MAX_SLOPE) return null;',
replace: ' if (minor / major >= NEAR_AXIS_MAX_SLOPE) return null;',
}],
},
{
id: 'near-axis-authoring-snap-bypassed',
guard: 'npm run bundle:sync && node demo/smoke_plan_drawing_repairs.mjs',
because: 'the production Walls hover and click must persist the exact same straight endpoint '
+ 'instead of merely repairing old data through Optimize (#290)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped;',
replace: ' const point = snapped;',
}],
},
{
id: 'optimizer-micro-interval-cleanup-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+10
View File
@@ -27,6 +27,16 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'classifyNearAxisSegment', 'snapNearAxisEndpoint', 'repairNearAxisRoomWalls',
'NEAR_AXIS_MAX_DEGREES', 'NEAR_AXIS_MAX_SLOPE',
],
smokes: ['smoke_plan_drawing_repairs.mjs', 'smoke_near_axis_optimize.mjs'],
because: 'the production bundle must make the Walls hover/click candidate exact-axis, then '
+ 'preview one deduplicated real shared wall, pass production geometry preflight, apply one '
+ 'atomic Optimize write, reload idempotently and restore the original through one Undo (#290)',
},
{
// #285: смок не произносит ни одного имени продуктового кода — он подставляет
// геометрию реального этажа и спрашивает сам продукт через isPointInFill,