mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
Six normative cuts, both mirrors symmetric (spec revision 3): - §2.1 node keys quantise to 1e-7 with the repository's canonicalisation formula (sign·floor(|v|·1e7+0.5)/1e7, -0 normalised) — toFixed(6) keys split one node into two on floating debris and produced two false П4 refusals on a legitimate resize (reproduced: -1e-8 vs 0). Node pairs within 2e-7 of each other (raw coordinates) are ONE node, and the node-to-wall incidence uses the same quantum. - §2.2 a ~0° wedge IS a violation: two rays leaving a node the same way are a duplicated or overlaid wall (a butt joint yields 180°, never 0°) — the worst degenerate case was invisible while 0.5° was refused. - §2.3/§2.4 the wall run is an iterative edge walk over the collinear component: no recursion (10 000 atoms answered, not RangeError), no silently dropped fork (the old .find lost every branch but the first), O(E) by construction, and collinearity is measured against the BASE segment's axis so an arc of 0.9°-per-atom pieces cannot pose as one wall. - §2.5 an exception while judging the CANDIDATE refuses the write with the junction.limit_check_failed toast (fail-closed, as the #278 guard); the baseline branch stays fail-open by design and the smoke proves the asymmetry by breaking only the second call of the deterministic pair. - §2.6 the python mirror narrows its except on the candidate side only: a genuine migration bug (TypeError) surfaces as an honest WS error, while a previous-side bug keeps the wide "no baseline" fallback — the two AC6 cases pin the asymmetry so swapped sides turn a unit red. Parity fixtures gain the new boundary classes (debris node, duplicate wall, collinear fork); four new mutants pin the filter, the key precision, the dropped branch and the fail-open hole. Issue: #331 User-Visible: yes
367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
/**
|
||
* 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'
|
||
/** #331 §2.5: the check itself failed — the write is refused, not waved through. */
|
||
| 'check_failed';
|
||
|
||
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;
|
||
/**
|
||
* #331 §2.1: below this two points are ONE node / a node is ON the wall —
|
||
* floating debris of pre-canonicalisation arithmetic, not a near miss. Two
|
||
* orders above the storage grid (1e-9), orders below any meaningful plan
|
||
* gap (the smallest rule threshold is 5 cm ≈ 4e-4).
|
||
*/
|
||
const INCIDENT_EPS = 2e-7;
|
||
const KEY_FACTOR = 1e7;
|
||
/**
|
||
* #331 §2.1: quantised with the repository's canonicalisation formula —
|
||
* native Math.round and Python round() part ways on .5 ticks (banker's
|
||
* rounding), the exact parity lesson coordinate-canonicalization encodes.
|
||
* `-0` normalises to `0` so the string key cannot fork on the sign of zero.
|
||
*/
|
||
const quantizeKeyCoord = (value: number): number => {
|
||
const rounded = Math.sign(value) * Math.floor(Math.abs(value) * KEY_FACTOR + 0.5) / KEY_FACTOR;
|
||
return Object.is(rounded, -0) ? 0 : rounded;
|
||
};
|
||
const key = (point: number[]): string =>
|
||
`${quantizeKeyCoord(point[0])},${quantizeKeyCoord(point[1])}`;
|
||
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<string, number[]>();
|
||
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;
|
||
// #331 §2.2: a ~0° delta IS a violation — two rays leaving one node in
|
||
// the same direction are a duplicated or overlaid wall (a collinear
|
||
// butt joint of one straight wall yields a 180° pair, never 0°).
|
||
const degrees = (delta * 180) / Math.PI;
|
||
if (degrees < smallest) smallest = degrees;
|
||
}
|
||
if (smallest < minAngleDeg - 1e-9) {
|
||
violations.push({ rule: 'angle', subject: node, actual: smallest, limit: minAngleDeg });
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
/** Direction of a segment normalised to [0, 180). */
|
||
const axisDegrees = (segment: LimitSegment): number => {
|
||
const degrees = (Math.atan2(segment.b[1] - segment.a[1], segment.b[0] - segment.a[0])
|
||
* 180) / Math.PI;
|
||
return ((degrees % 180) + 180) % 180;
|
||
};
|
||
|
||
const collinear = (left: LimitSegment, right: LimitSegment, toleranceDeg = 1): boolean => {
|
||
const delta = Math.abs(axisDegrees(left) - axisDegrees(right));
|
||
return Math.min(delta, 180 - delta) <= toleranceDeg;
|
||
};
|
||
|
||
const buildNodeIndex = (segments: readonly LimitSegment[]): Map<string, LimitSegment[]> => {
|
||
const byNode = new Map<string, LimitSegment[]>();
|
||
for (const item of segments) {
|
||
for (const point of [item.a, item.b]) {
|
||
const list = byNode.get(key(point));
|
||
if (list) list.push(item);
|
||
else byNode.set(key(point), [item]);
|
||
}
|
||
}
|
||
return byNode;
|
||
};
|
||
|
||
/**
|
||
* Length of the whole WALL a segment belongs to, not of the atom.
|
||
*
|
||
* The model splits a straight wall into atoms at every junction, so a plain
|
||
* run picks up short pieces that no one drew: where a 30 cm wall meets a
|
||
* 20 cm one, atomisation leaves a (30−20)/2 = 5 cm piece that compensates the
|
||
* thickness step (owner report 2026-08-27). Those pieces are collinear
|
||
* continuations of the same wall at the same thickness, so П3 measures the
|
||
* maximal collinear chain through the segment's nodes.
|
||
*/
|
||
export function collinearRunLengthUnits(
|
||
segment: LimitSegment, segments: readonly LimitSegment[],
|
||
byNodeIndex?: Map<string, LimitSegment[]>,
|
||
): number {
|
||
const usable = usableSegments(segments);
|
||
// #330 §4.3: building the node index per SEGMENT made П3 quadratic
|
||
// (289 ms on 576 atoms). The caller that loops over every segment builds
|
||
// it once and passes it in; a direct call still builds its own.
|
||
const byNode = byNodeIndex ?? buildNodeIndex(usable);
|
||
// #331 §2.3/§2.4: an iterative edge walk over the collinear component —
|
||
// no recursion (a 10 000-atom chain must answer, not overflow the stack),
|
||
// no combinatorial DFS (every atom joins the run at most once, O(E)), and
|
||
// no silently dropped branch (the old `.find` lost every fork but the
|
||
// first). Collinearity is measured against the BASE segment's axis, not
|
||
// the previous atom's, so an arc of 0.9°-per-atom pieces cannot creep
|
||
// around a corner while posing as one straight wall.
|
||
const visited = new Set<LimitSegment>([segment]);
|
||
let total = length(segment.a, segment.b);
|
||
const frontier: number[][] = [segment.a, segment.b];
|
||
while (frontier.length) {
|
||
const node = frontier.pop() as number[];
|
||
for (const candidate of byNode.get(key(node)) || []) {
|
||
if (visited.has(candidate)) continue;
|
||
if (!collinear(candidate, segment)) continue;
|
||
if (Number(candidate.cm || 0) !== Number(segment.cm || 0)) continue;
|
||
visited.add(candidate);
|
||
total += length(candidate.a, candidate.b);
|
||
frontier.push(candidate.a, candidate.b);
|
||
}
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/** П3: a wall 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[] = [];
|
||
const usable = usableSegments(segments);
|
||
const byNode = buildNodeIndex(usable);
|
||
for (const segment of usable) {
|
||
const units = collinearRunLengthUnits(segment, usable, byNode);
|
||
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).
|
||
*
|
||
* #330 §4.5: the all-pairs form cost 104 ms on 576 atoms and grew
|
||
* quadratically. Nodes and segment bounding boxes (padded by the threshold)
|
||
* are hashed into a grid with the threshold as cell size, so each node is
|
||
* compared only against its 9-cell neighbourhood — verdicts are identical
|
||
* (equivalence pinned by unit tests and the TS↔Python parity suite).
|
||
*/
|
||
export function checkNodeDistances(
|
||
segments: readonly LimitSegment[],
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
{ minDistanceCm = MIN_NODE_DISTANCE_CM } = {},
|
||
): JunctionLimitViolation[] {
|
||
const usable = usableSegments(segments);
|
||
const nodes = new Map<string, number[]>();
|
||
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 size = minUnits > EPS ? minUnits : 1;
|
||
const cellOf = (x: number, y: number): string =>
|
||
`${Math.floor(x / size)},${Math.floor(y / size)}`;
|
||
|
||
const nodeGrid = new Map<string, [string, number[]][]>();
|
||
for (const [nodeKey, point] of nodes) {
|
||
const cell = cellOf(point[0], point[1]);
|
||
const list = nodeGrid.get(cell);
|
||
if (list) list.push([nodeKey, point]);
|
||
else nodeGrid.set(cell, [[nodeKey, point]]);
|
||
}
|
||
const segmentGrid = new Map<string, LimitSegment[]>();
|
||
for (const segment of usable) {
|
||
const x0 = Math.min(segment.a[0], segment.b[0]) - minUnits;
|
||
const x1 = Math.max(segment.a[0], segment.b[0]) + minUnits;
|
||
const y0 = Math.min(segment.a[1], segment.b[1]) - minUnits;
|
||
const y1 = Math.max(segment.a[1], segment.b[1]) + minUnits;
|
||
for (let cx = Math.floor(x0 / size); cx <= Math.floor(x1 / size); cx++) {
|
||
for (let cy = Math.floor(y0 / size); cy <= Math.floor(y1 / size); cy++) {
|
||
const cell = `${cx},${cy}`;
|
||
const list = segmentGrid.get(cell);
|
||
if (list) list.push(segment);
|
||
else segmentGrid.set(cell, [segment]);
|
||
}
|
||
}
|
||
}
|
||
|
||
const violations: JunctionLimitViolation[] = [];
|
||
for (const [nodeKey, point] of nodes) {
|
||
const cx = Math.floor(point[0] / size);
|
||
const cy = Math.floor(point[1] / size);
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
for (let dy = -1; dy <= 1; dy++) {
|
||
for (const [otherKey, other] of nodeGrid.get(`${cx + dx},${cy + dy}`) || []) {
|
||
// Each unordered pair once: the lexicographic order replaces the
|
||
// i<j of the all-pairs loop, so the verdict set is identical.
|
||
if (nodeKey >= otherKey) continue;
|
||
const distance = length(point, other);
|
||
// #331 §2.1: raw-coordinate debris within the incidence quantum is
|
||
// ONE node that landed on two neighbouring keys — never a near miss.
|
||
if (distance <= INCIDENT_EPS) continue;
|
||
if (distance < minUnits - 1e-9) {
|
||
violations.push({
|
||
rule: 'distance', subject: `${nodeKey} ↔ ${otherKey}`,
|
||
actual: (distance / gridPitch) * (cellCm || 1), limit: minDistanceCm,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const segment of segmentGrid.get(`${cx},${cy}`) || []) {
|
||
// 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(point, 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, counted per rule.
|
||
*
|
||
* Subject identity churns across a structural write (segments are re-atomised
|
||
* and re-keyed), so matching by subject would report an inherited violation as
|
||
* new the moment its carrier is re-keyed — that alone refused legitimate
|
||
* resizes of a real plan. Counting per rule keeps the spec's boundary (§3)
|
||
* without depending on identity: a write may keep existing violations, never
|
||
* add one.
|
||
*/
|
||
export function increasedViolations(
|
||
candidate: readonly JunctionLimitViolation[],
|
||
previous: readonly JunctionLimitViolation[],
|
||
): JunctionLimitViolation[] {
|
||
const before = new Map<JunctionLimitRule, number>();
|
||
for (const item of previous || []) before.set(item.rule, (before.get(item.rule) || 0) + 1);
|
||
const after = new Map<JunctionLimitRule, JunctionLimitViolation[]>();
|
||
for (const item of candidate || []) {
|
||
after.set(item.rule, [...(after.get(item.rule) || []), item]);
|
||
}
|
||
const introduced: JunctionLimitViolation[] = [];
|
||
for (const [rule, items] of after) {
|
||
const grew = items.length - (before.get(rule) || 0);
|
||
if (grew > 0) introduced.push(...items.slice(0, grew));
|
||
}
|
||
return introduced;
|
||
}
|
||
|
||
/** 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}`));
|
||
}
|