From 6fc57f2ae88c6ddbe91411e17bf47fe7cc6be06e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 20:11:30 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20pure=20wall-junction=20limit=20checks?= =?UTF-8?q?=20(#329=20=D0=9F1-=D0=9F5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's five limits as pure functions: minimum 15 degrees between neighbouring rays of a node (a straight wall through the node is a 180 pair, not a violation), at most 6 walls per node, a segment at least max(20 cm, its own thickness), 5 cm clearance between non-incident nodes and between a node and a foreign wall (a T-joint sitting exactly on that wall is incidence, not a near miss), and a room interior of at least 25 cm2 after the masonry is subtracted. Thresholds are absolute and do not scale with cell_cm. newViolations() implements the spec's inheritance boundary: only violations introduced by the write are reported. Issue: #329 User-Visible: no --- src/junction-limits.ts | 205 ++++++++++++++++++++++++++++++++++ test/junction-limits.test.mjs | 102 +++++++++++++++++ tsconfig.test.json | 2 +- 3 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/junction-limits.ts create mode 100644 test/junction-limits.test.mjs diff --git a/src/junction-limits.ts b/src/junction-limits.ts new file mode 100644 index 00000000..c08f699b --- /dev/null +++ b/src/junction-limits.ts @@ -0,0 +1,205 @@ +/** + * Issue #329 — limits on wall junctions (owner decision 2026-08-27). + * + * Reasonable plans never contain the shapes that break wall-body geometry: + * a 10° apex whose wall bodies overlap for 86 cm, seven walls in one node, + * a segment shorter than its own thickness, two nodes 4 cm apart, or a room + * whose masonry eats the whole interior. These pure checks refuse such a + * WRITE; existing documents are never re-validated (spec §3) — migration, + * import and restore stay untouched. + * + * Thresholds are absolute (centimetres, degrees) and do not scale with the + * space's `cell_cm` (spec r1-L1). + */ + +export const MIN_JUNCTION_ANGLE_DEG = 15; +export const MAX_JUNCTION_VALENCE = 6; +export const MIN_SEGMENT_LENGTH_CM = 20; +export const MIN_NODE_DISTANCE_CM = 5; +export const MIN_ROOM_CLEARANCE_CM2 = 25; + +export type JunctionLimitRule = + | 'angle' | 'valence' | 'length' | 'distance' | 'clearance'; + +export interface JunctionLimitViolation { + rule: JunctionLimitRule; + /** Node key, segment id or room id — whatever the rule is about. */ + subject: string; + /** Actual value in the rule's own unit (degrees, count, cm, cm²). */ + actual: number; + /** The limit that was violated, same unit. */ + limit: number; +} + +export interface LimitSegment { + id?: string; + a: number[]; + b: number[]; + /** Wall thickness in centimetres; 0 for a bodyless wall (#306). */ + cm?: number; +} + +const EPS = 1e-9; +/** Below this a node is ON the wall (T-joint), not near it. */ +const INCIDENT_EPS = 1e-9; +const key = (point: number[]): string => `${point[0].toFixed(6)},${point[1].toFixed(6)}`; +const length = (a: number[], b: number[]): number => Math.hypot(b[0] - a[0], b[1] - a[1]); + +/** Normalised units per centimetre for a space (`cell_cm` on a grid pitch). */ +export const cmToUnits = (cm: number, cellCm: number, gridPitch: number): number => + (cm / (cellCm || 1)) * gridPitch; + +const angleBetween = (from: number[], to: number[]): number => + Math.atan2(to[1] - from[1], to[0] - from[0]); + +const finitePoint = (point: unknown): point is number[] => + Array.isArray(point) && point.length >= 2 && point.every((value) => Number.isFinite(value)); + +const usableSegments = (segments: readonly LimitSegment[]): LimitSegment[] => + (segments || []).filter((segment) => finitePoint(segment?.a) && finitePoint(segment?.b) + && length(segment.a, segment.b) > EPS); + +/** П1 + П2: per-node valence and the smallest angle between neighbours. */ +export function checkNodes( + segments: readonly LimitSegment[], + { minAngleDeg = MIN_JUNCTION_ANGLE_DEG, maxValence = MAX_JUNCTION_VALENCE } = {}, +): JunctionLimitViolation[] { + const rays = new Map(); + for (const segment of usableSegments(segments)) { + for (const [from, to] of [[segment.a, segment.b], [segment.b, segment.a]]) { + const list = rays.get(key(from)) || []; + list.push(angleBetween(from, to)); + rays.set(key(from), list); + } + } + const violations: JunctionLimitViolation[] = []; + for (const [node, angles] of rays) { + if (angles.length > maxValence) { + violations.push({ rule: 'valence', subject: node, actual: angles.length, limit: maxValence }); + } + if (angles.length < 2) continue; + const sorted = [...angles].sort((x, y) => x - y); + let smallest = Infinity; + for (let index = 0; index < sorted.length; index++) { + const next = sorted[(index + 1) % sorted.length]; + let delta = next - sorted[index]; + if (index === sorted.length - 1) delta += Math.PI * 2; + // Collinear rays of one straight wall passing through the node are a + // 180° pair, not a violation; only a genuine narrow wedge counts. + const degrees = (delta * 180) / Math.PI; + if (degrees > EPS && degrees < smallest) smallest = degrees; + } + if (smallest < minAngleDeg - 1e-9) { + violations.push({ rule: 'angle', subject: node, actual: smallest, limit: minAngleDeg }); + } + } + return violations; +} + +/** П3: a segment is at least 20 cm and never shorter than its own thickness. */ +export function checkSegmentLengths( + segments: readonly LimitSegment[], + cellCm: number, + gridPitch: number, + { minLengthCm = MIN_SEGMENT_LENGTH_CM } = {}, +): JunctionLimitViolation[] { + const violations: JunctionLimitViolation[] = []; + for (const segment of usableSegments(segments)) { + const units = length(segment.a, segment.b); + const cm = (units / gridPitch) * (cellCm || 1); + const limit = Math.max(minLengthCm, Number(segment.cm) > 0 ? Number(segment.cm) : 0); + if (cm < limit - 1e-9) { + violations.push({ + rule: 'length', subject: String(segment.id || key(segment.a)), actual: cm, limit, + }); + } + } + return violations; +} + +const distanceToSegment = (point: number[], a: number[], b: number[]): number => { + const dx = b[0] - a[0], dy = b[1] - a[1]; + const lengthSq = dx * dx + dy * dy; + const t = lengthSq <= EPS ? 0 + : Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSq)); + return Math.hypot(point[0] - (a[0] + dx * t), point[1] - (a[1] + dy * t)); +}; + +/** П4: non-incident nodes and node-to-foreign-wall clearance (absolute cm). */ +export function checkNodeDistances( + segments: readonly LimitSegment[], + cellCm: number, + gridPitch: number, + { minDistanceCm = MIN_NODE_DISTANCE_CM } = {}, +): JunctionLimitViolation[] { + const usable = usableSegments(segments); + const nodes = new Map(); + for (const segment of usable) { + nodes.set(key(segment.a), segment.a); + nodes.set(key(segment.b), segment.b); + } + const minUnits = cmToUnits(minDistanceCm, cellCm, gridPitch); + const violations: JunctionLimitViolation[] = []; + const entries = [...nodes.entries()]; + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const distance = length(entries[i][1], entries[j][1]); + if (distance < minUnits - 1e-9) { + violations.push({ + rule: 'distance', subject: `${entries[i][0]} ↔ ${entries[j][0]}`, + actual: (distance / gridPitch) * (cellCm || 1), limit: minDistanceCm, + }); + } + } + } + for (const [nodeKey, node] of nodes) { + for (const segment of usable) { + // A node that belongs to the wall (either end) is a legal T-joint or + // corner — the rule is about NEAR misses, not incidence. + if (key(segment.a) === nodeKey || key(segment.b) === nodeKey) continue; + const distance = distanceToSegment(node, segment.a, segment.b); + // Sitting exactly ON the wall is the other legal incidence: a T-joint + // into the middle of a foreign wall (spec П4). Only a real gap counts. + if (distance <= INCIDENT_EPS) continue; + if (distance < minUnits - 1e-9) { + violations.push({ + rule: 'distance', subject: `${nodeKey} → ${String(segment.id || key(segment.a))}`, + actual: (distance / gridPitch) * (cellCm || 1), limit: minDistanceCm, + }); + } + } + } + return violations; +} + +/** П5: the room keeps a real interior after its masonry is subtracted. */ +export function checkRoomClearance( + roomId: string, + innerContour: number[][] | null | undefined, + cellCm: number, + gridPitch: number, + { minClearanceCm2 = MIN_ROOM_CLEARANCE_CM2 } = {}, +): JunctionLimitViolation[] { + const points = (innerContour || []).filter(finitePoint); + const areaUnits = points.length < 3 ? 0 : Math.abs(points.reduce((sum, point, index) => { + const next = points[(index + 1) % points.length]; + return sum + (point[0] * next[1] - next[0] * point[1]); + }, 0)) / 2; + const cmPerUnit = (cellCm || 1) / gridPitch; + const areaCm2 = areaUnits * cmPerUnit * cmPerUnit; + if (areaCm2 < minClearanceCm2 - 1e-9) { + return [{ + rule: 'clearance', subject: roomId, actual: areaCm2, limit: minClearanceCm2, + }]; + } + return []; +} + +/** Violations introduced BY THIS WRITE: inherited ones are never reported. */ +export function newViolations( + candidate: readonly JunctionLimitViolation[], + previous: readonly JunctionLimitViolation[], +): JunctionLimitViolation[] { + const inherited = new Set((previous || []).map((item) => `${item.rule}|${item.subject}`)); + return (candidate || []).filter((item) => !inherited.has(`${item.rule}|${item.subject}`)); +} diff --git a/test/junction-limits.test.mjs b/test/junction-limits.test.mjs new file mode 100644 index 00000000..27b1f6dd --- /dev/null +++ b/test/junction-limits.test.mjs @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MAX_JUNCTION_VALENCE, MIN_JUNCTION_ANGLE_DEG, MIN_NODE_DISTANCE_CM, + MIN_ROOM_CLEARANCE_CM2, MIN_SEGMENT_LENGTH_CM, + checkNodeDistances, checkNodes, checkRoomClearance, checkSegmentLengths, + cmToUnits, newViolations, +} from '../test-build/junction-limits.js'; +import { GRID_STEP_N } from '../test-build/space-geometry.js'; + +// #329, решения владельца 2026-08-27. Пороги абсолютные: 15°, 6 стен, +// max(20 см, толщина), 5 см, 25 см². cell_cm на них не влияет. +const CELL = 5; +const PITCH = GRID_STEP_N; +const cm = (value) => cmToUnits(value, CELL, PITCH); + +const rayAt = (degrees, lengthCm = 100) => { + const radians = (degrees * Math.PI) / 180; + return { a: [0, 0], b: [Math.cos(radians) * cm(lengthCm), Math.sin(radians) * cm(lengthCm)] }; +}; + +test('П1: угол ниже 15° отклоняется, 15° и выше проходит', () => { + assert.equal(MIN_JUNCTION_ANGLE_DEG, 15); + const narrow = checkNodes([rayAt(0), rayAt(14)]); + assert.equal(narrow.filter((item) => item.rule === 'angle').length, 1); + assert.ok(Math.abs(narrow[0].actual - 14) < 1e-6); + assert.deepEqual(checkNodes([rayAt(0), rayAt(15)]).filter((item) => item.rule === 'angle'), []); + assert.deepEqual(checkNodes([rayAt(0), rayAt(16)]).filter((item) => item.rule === 'angle'), []); + // Прямая стена, проходящая через узел, — это 180°, а не нарушение. + assert.deepEqual(checkNodes([rayAt(0), rayAt(180)]).filter((item) => item.rule === 'angle'), []); +}); + +test('П2: шесть стен в узле проходят, седьмая — нет', () => { + assert.equal(MAX_JUNCTION_VALENCE, 6); + const six = [0, 60, 120, 180, 240, 300].map((degrees) => rayAt(degrees)); + assert.deepEqual(checkNodes(six).filter((item) => item.rule === 'valence'), []); + const seven = [0, 51, 102, 154, 205, 257, 308].map((degrees) => rayAt(degrees)); + const valence = checkNodes(seven).filter((item) => item.rule === 'valence'); + assert.equal(valence.length, 1); + assert.equal(valence[0].actual, 7); +}); + +test('П3: сегмент короче 20 см или короче собственной толщины отклоняется', () => { + assert.equal(MIN_SEGMENT_LENGTH_CM, 20); + const segment = (lengthCm, thicknessCm) => ({ + id: `s-${lengthCm}-${thicknessCm}`, a: [0, 0], b: [cm(lengthCm), 0], cm: thicknessCm, + }); + assert.equal(checkSegmentLengths([segment(19, 15)], CELL, PITCH).length, 1); + assert.deepEqual(checkSegmentLengths([segment(20, 15)], CELL, PITCH), []); + // Длина 25 см при толщине 30 см — «квадрат», запрещено. + const thick = checkSegmentLengths([segment(25, 30)], CELL, PITCH); + assert.equal(thick.length, 1); + assert.equal(thick[0].limit, 30); + assert.deepEqual(checkSegmentLengths([segment(30, 30)], CELL, PITCH), []); + // Нулевая стена (#306) держит общий минимум 20 см. + assert.equal(checkSegmentLengths([segment(19, 0)], CELL, PITCH).length, 1); + assert.deepEqual(checkSegmentLengths([segment(20, 0)], CELL, PITCH), []); +}); + +test('П4: почти совпадающие узлы и почти-касания отклоняются, T-стык — нет', () => { + assert.equal(MIN_NODE_DISTANCE_CM, 5); + const wall = { id: 'w', a: [0, 0], b: [cm(200), 0] }; + // Узел «почти касается» тела чужой стены: конец в 4 см над ней, вдали от + // её концов — именно тот микро-зазор, который рождает атомы-пылинки. + const near = { id: 'n', a: [cm(100), cm(4)], b: [cm(100), cm(200)] }; + const far = { id: 'f', a: [cm(100), cm(5)], b: [cm(100), cm(200)] }; + const nearNodes = { id: 'x', a: [cm(204), 0], b: [cm(204), cm(100)] }; + + assert.ok(checkNodeDistances([wall, near], CELL, PITCH) + .some((item) => item.rule === 'distance')); + assert.deepEqual(checkNodeDistances([wall, far], CELL, PITCH), []); + // Узел в 4 см от чужого узла. + assert.ok(checkNodeDistances([wall, nearNodes], CELL, PITCH).length > 0); + // T-стык: конец стены стоит ровно на чужой стене — законно. + const tee = { id: 't', a: [cm(100), 0], b: [cm(100), cm(200)] }; + assert.deepEqual(checkNodeDistances([wall, tee], CELL, PITCH), []); +}); + +test('П5: просвет комнаты меньше 25 см² отклоняется (независимо от П1)', () => { + assert.equal(MIN_ROOM_CLEARANCE_CM2, 25); + const square = (sideCm) => [ + [0, 0], [cm(sideCm), 0], [cm(sideCm), cm(sideCm)], [0, cm(sideCm)], + ]; + assert.deepEqual(checkRoomClearance('r', square(6), CELL, PITCH), []); + const tight = checkRoomClearance('r', square(4), CELL, PITCH); + assert.equal(tight.length, 1); + assert.ok(tight[0].actual < 25); + // Пустой/вырожденный контур — тоже нарушение просвета. + assert.equal(checkRoomClearance('r', null, CELL, PITCH).length, 1); + assert.equal(checkRoomClearance('r', [[0, 0], [cm(50), 0]], CELL, PITCH).length, 1); +}); + +test('унаследованные нарушения не считаются новыми (граница §3 ТЗ)', () => { + const inherited = checkNodes([rayAt(0), rayAt(9)]); + assert.equal(inherited.length, 1); + // Тот же документ переписан без изменения этого узла — новых нарушений нет. + assert.deepEqual(newViolations(inherited, inherited), []); + // Появился второй узкий узел — он и есть новое нарушение. + const worse = [...inherited, { rule: 'angle', subject: 'other', actual: 3, limit: 15 }]; + assert.deepEqual(newViolations(worse, inherited).map((item) => item.subject), ['other']); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index c33ceeda..e326d9cc 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -19,7 +19,7 @@ "src/devices.ts", "src/virtual-light-state.ts", "src/types.ts", - "src/space-geometry.ts", + "src/space-geometry.ts", "src/junction-limits.ts", "src/space-order.ts", "src/card-editor-validation.ts", "src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts", "src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",