Files
houseplan-card/src/junction-limits.ts
T
Codex c90f5bf052 perf: junction limits scale — executor, rev cache, linear П3/П4, shared masonry pass (#330)
Six cuts, zero verdict changes (spec §3; equivalence pinned by units, the
parity suite and the smokes):

- §4.1 the CPU chain of ws_config_set and ws_plan_optimize runs in the
  executor; write_lock still serialises writes, only the HA event loop is
  freed (2.8 s of blocking per 576-atom write before).
- §4.2 the stored document's violation counts are cached on the runtime by
  rev (store.py junction_baseline); a repeated write never re-judges
  `previous`. validate_junction_limits takes baseline_counts and returns the
  candidate's counts to cache after a successful save.
- §4.3 П3 builds its node index once per check in both mirrors
  (289→11 ms TS, 285→~50 ms py).
- §4.5 П4 uses a bucket grid with the threshold as cell size in both
  mirrors (104→19 ms TS, 372→44 ms py); pair enumeration switches to
  lexicographic order — same verdict set, equivalence pinned against a
  brute-force oracle on cell borders.
- §4.6 a document already carrying the current catalogue is judged as-is:
  a no-op re-migration cost 815 ms py / 69 ms TS. Legacy documents migrate
  exactly as before (the #329 H1 test stays green).
- §4.7 П5 shares one junction-topology pass per check and pays the masonry
  union only when multi-wall nodes exist — and the resize path hands over
  the preflight's own artifact, so a pointermove never builds the union
  twice (4.2 s → 88 ms full candidate on the benchmark grid).

The frontend baseline is cached per (document identity, config epoch): ten
pointermoves make N+1 limit computations, not 2N — pinned by the smoke on a
real pointer gesture.

demo/benchmark_junction_limits.mjs (npm run benchmark:junction-limits) pins
the budgets for both mirrors: TS full candidate ≤100 ms (measured 88), py
warm validate ≤250 ms (measured 45), cold legacy ≤3.5 s — that path is
one-off and lives in the executor.

Issue: #330
User-Visible: yes
2026-08-28 03:07:11 +03:00

338 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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;
// 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;
}
/** 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);
const visited = new Set<LimitSegment>([segment]);
let total = length(segment.a, segment.b);
const walk = (from: LimitSegment, node: number[]): void => {
const next = (byNode.get(key(node)) || []).find((candidate) => (
!visited.has(candidate)
&& collinear(candidate, from)
&& Number(candidate.cm || 0) === Number(from.cm || 0)
));
if (!next) return;
visited.add(next);
total += length(next.a, next.b);
walk(next, key(next.a) === key(node) ? next.b : next.a);
};
walk(segment, segment.a);
walk(segment, segment.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);
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}`));
}