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
+34 -5
View File
@@ -189,6 +189,7 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import { snapNearAxisEndpoint } from './near-axis';
import type { SpaceReferenceRepairContext } from './space-reference-repair';
import { collectSpaceMarkerDependencies } from './space-deletion';
import {
@@ -6916,9 +6917,15 @@ class HouseplanCard extends LitElement {
? resolveStrictPlanSnap(snapshot.value, raw, { ...options, anchor })
: resolvePlanSnapResult(snapshot.value, raw, options);
const candidate = resolution.kind === 'resolved' ? resolution.candidate : null;
const snapped = candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45);
const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped;
// A nearby topology endpoint loses snap ownership when the drafting rule
// moves the actually persisted point away from it. Preview and click must
// describe the exact same geometry (#290).
const effectiveCandidate = candidate && samePoint(point, candidate.point) ? candidate : null;
return {
point: candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45),
candidate,
point,
candidate: effectiveCandidate,
conflicts: resolution.kind === 'ambiguous' ? resolution.conflicts : [],
ambiguous: resolution.kind === 'ambiguous',
contextKey: this._planSnapContextKey(snapshot.key),
@@ -7528,7 +7535,9 @@ class HouseplanCard extends LitElement {
if (this._path.length < 3) return;
// Preserve the established shortcut, but commit its closing wall through
// the same graph/draft path as an ordinary click on the first node.
pt = [...this._path[0]];
pt = snapNearAxisEndpoint(
this._path[this._path.length - 1], this._path[0],
);
}
// Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the
// contour may become a nested room (a column, an inner room). Partial
@@ -15326,7 +15335,7 @@ class HouseplanCard extends LitElement {
m: String(d.report.migrated + d.report.canonicalized
+ d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged
+ d.report.partitionsMerged + d.report.partitionsReconciled
+ d.report.openingsRehosted),
+ d.report.openingsRehosted + d.report.wallsStraightened),
r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped
+ d.report.positionsRemapped + d.report.markersDetached
+ d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved
@@ -16337,7 +16346,14 @@ class HouseplanCard extends LitElement {
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
+ r.wallsMerged + r.spansMerged + r.partitionsMerged
+ r.partitionsReconciled + r.openingsRehosted;
const gridWarning = r.moved + r.rotated + r.removedDrafts + r.coordsCanonicalized;
const gridWarning = r.moved + r.rotated + r.removedDrafts
+ r.coordsCanonicalized + r.wallsStraightened;
const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10;
const straightenSpace = (this._serverCfg?.spaces || []).find(
(space: any) => String(space?.id || '') === r.maxStraightenSpace,
);
const straightenWhere = (this._serverCfg?.spaces || []).length > 1 && straightenSpace
? String(straightenSpace.title || straightenSpace.id) : '';
const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved
+ r.orphanGroupPositionsRemoved;
const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean);
@@ -16416,6 +16432,14 @@ class HouseplanCard extends LitElement {
${r.openingsRehosted ? html`<p class="alignmsg">${this._t(
'gs.optimize_openings_rehosted', { n: String(r.openingsRehosted) },
)}</p>` : nothing}
${r.wallsStraightened ? html`<p class="alignmsg">${this._t(
'gs.optimize_walls_straightened', {
n: String(r.wallsStraightened), cm: String(straightenCm),
},
)}</p>` : nothing}
${straightenWhere ? html`<p class="alignmsg">${this._t(
'gs.optimize_walls_straightened_where', { s: straightenWhere },
)}</p>` : nothing}
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
spaces: String(r.glowSpacesMigrated),
@@ -16423,6 +16447,11 @@ class HouseplanCard extends LitElement {
})}</p>`
: nothing}
${gridWarning ? html`<div class="rhint">${this._t('gs.align_warn')}</div>` : nothing}`}
${!failed && r.wallsStraightenSkipped ? html`<p class="rhint">${this._t(
'gs.optimize_walls_straighten_skipped', {
n: String(r.wallsStraightenSkipped),
},
)}</p>` : nothing}
${repaired
? html`<p class="alignmsg">${this._t('gs.optimize_references', {
spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped),
+3
View File
@@ -817,6 +817,9 @@
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
"gs.optimize_coincident_partitions": "Coincident partitions converted to room walls: {n}.",
"gs.optimize_openings_rehosted": "Openings reattached to room walls: {n}.",
"gs.optimize_walls_straightened": "Walls straightened: {n}; maximum movement: {cm} cm.",
"gs.optimize_walls_straightened_where": "Largest wall correction: {s}.",
"gs.optimize_walls_straighten_skipped": "Near-axis walls left unchanged because they could not be repaired safely: {n}.",
"gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
"gs.optimize_references": "References repaired: spaces — {spaces}; rooms — {rooms}; positions — {positions}; devices detached from missing spaces — {detached}.",
"gs.optimize_reference_more": ", and {n} more",
+3
View File
@@ -817,6 +817,9 @@
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
"gs.optimize_coincident_partitions": "Совпадающие перегородки преобразованы в стены: {n}.",
"gs.optimize_openings_rehosted": "Проёмы перепривязаны к стенам комнат: {n}.",
"gs.optimize_walls_straightened": "Выпрямлено стен: {n}; максимальное перемещение: {cm} см.",
"gs.optimize_walls_straightened_where": "Максимальная правка стены: {s}.",
"gs.optimize_walls_straighten_skipped": "Оставлено почти осевых стен, которые нельзя безопасно выпрямить: {n}.",
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
"gs.optimize_references": "Исправлено ссылок: пространства — {spaces}; комнаты — {rooms}; позиции — {positions}; устройств отвязано от отсутствующих пространств — {detached}.",
"gs.optimize_reference_more": ", и ещё {n}",
+320
View File
@@ -0,0 +1,320 @@
import {
polygonArea, roomPoly, roomsOverlap, segmentsProperlyCross,
} from './logic';
/** One drafting tolerance shared by authoring, maintenance and masonry joins. */
export const NEAR_AXIS_MAX_DEGREES = 0.25;
export const NEAR_AXIS_MAX_SLOPE = Math.tan(NEAR_AXIS_MAX_DEGREES * Math.PI / 180);
export type NearAxis = 'horizontal' | 'vertical';
export interface NearAxisClassification {
axis: NearAxis;
major: number;
minor: number;
angleDegrees: number;
}
/** Exact-axis segments are already canonical and are deliberately not candidates. */
export function classifyNearAxisSegment(
a: readonly number[], b: readonly number[],
): NearAxisClassification | null {
const dx = Math.abs(Number(b[0]) - Number(a[0]));
const dy = Math.abs(Number(b[1]) - Number(a[1]));
if (![dx, dy].every(Number.isFinite) || !(dx > 0) || !(dy > 0)) return null;
const axis: NearAxis = dx >= dy ? 'horizontal' : 'vertical';
const major = Math.max(dx, dy);
const minor = Math.min(dx, dy);
if (minor / major > NEAR_AXIS_MAX_SLOPE) return null;
return {
axis, major, minor,
angleDegrees: Math.atan2(minor, major) * 180 / Math.PI,
};
}
/** Authoring moves only the free endpoint; the anchor is never rewritten silently. */
export function snapNearAxisEndpoint(
anchor: readonly number[], point: readonly number[],
): number[] {
const classified = classifyNearAxisSegment(anchor, point);
if (!classified) return [Number(point[0]), Number(point[1])];
return classified.axis === 'horizontal'
? [Number(point[0]), Number(anchor[1])]
: [Number(anchor[0]), Number(point[1])];
}
export interface NearAxisRepairReport {
wallsStraightened: number;
wallsStraightenSkipped: number;
maxStraightenShift: number;
}
export interface NearAxisRepairResult {
space: any;
report: NearAxisRepairReport;
changed: boolean;
}
interface EndpointMove {
from: number[];
to: number[];
}
interface RepairCandidate {
key: string;
a: number[];
b: number[];
axis: NearAxis;
}
const pointKey = (point: readonly number[]): string => `${point[0]},${point[1]}`;
const samePoint = (a: readonly number[], b: readonly number[]): boolean => (
a[0] === b[0] && a[1] === b[1]
);
const segmentKey = (a: readonly number[], b: readonly number[]): string => {
const ka = pointKey(a), kb = pointKey(b);
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
const signedArea = (poly: number[][]): number => {
let sum = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
sum += a[0] * b[1] - b[0] * a[1];
}
return sum / 2;
};
const simplePolygon = (poly: number[][]): boolean => {
if (poly.length < 3) return false;
for (let i = 0; i < poly.length; i++) {
if (samePoint(poly[i], poly[(i + 1) % poly.length])) return false;
for (let j = i + 1; j < poly.length; j++) {
if (j === i + 1 || (i === 0 && j === poly.length - 1)) continue;
if (segmentsProperlyCross(
poly[i], poly[(i + 1) % poly.length],
poly[j], poly[(j + 1) % poly.length],
)) return false;
}
}
return polygonArea(poly) > 1e-12;
};
const simpleOpenPath = (points: number[][]): boolean => {
for (let i = 0; i + 1 < points.length; i++) {
if (samePoint(points[i], points[i + 1])) return false;
for (let j = i + 2; j + 1 < points.length; j++) {
if (segmentsProperlyCross(points[i], points[i + 1], points[j], points[j + 1])) {
return false;
}
}
}
return true;
};
const replacePoint = (point: number[], move: EndpointMove): number[] => (
samePoint(point, move.from) ? [...move.to] : [...point]
);
const replaceRoomPoints = (rooms: any[], move: EndpointMove): any[] => rooms.map((room) => {
if (!Array.isArray(room?.poly)) return room;
return { ...room, poly: room.poly.map((point: number[]) => replacePoint(point, move)) };
});
const roomMoveIsSafe = (before: any[], after: any[], move: EndpointMove): boolean => {
const changed = before.flatMap((room, index) => (
roomPoly(room)?.some((point) => samePoint(point, move.from)) ? [index] : []
));
if (!changed.length) return false;
for (const i of changed) {
const source = roomPoly(before[i]);
const candidate = roomPoly(after[i]);
if (!source || !candidate) continue;
if (!simplePolygon(candidate)) return false;
const sourceSign = Math.sign(signedArea(source));
const candidateSign = Math.sign(signedArea(candidate));
if (sourceSign && candidateSign && sourceSign !== candidateSign) return false;
}
const compared = new Set<string>();
for (const i of changed) {
const a = roomPoly(after[i]);
if (!a) continue;
for (let j = 0; j < after.length; j++) {
if (i === j) continue;
const pair = i < j ? `${i}:${j}` : `${j}:${i}`;
if (compared.has(pair)) continue;
compared.add(pair);
const b = roomPoly(after[j]);
if (b && roomsOverlap(a, b)) return false;
}
}
return true;
};
const exactIncidentDegree = (rooms: any[], point: number[]): number => {
let count = 0;
for (const room of rooms) {
const poly = roomPoly(room);
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
if (!samePoint(a, point) && !samePoint(b, point)) continue;
if (a[0] === b[0] || a[1] === b[1]) count++;
}
}
return count;
};
const candidateMoves = (candidate: RepairCandidate, rooms: any[]): EndpointMove[] => {
const { a, b, axis } = candidate;
const moveB = axis === 'horizontal'
? { from: b, to: [b[0], a[1]] }
: { from: b, to: [a[0], b[1]] };
const moveA = axis === 'horizontal'
? { from: a, to: [a[0], b[1]] }
: { from: a, to: [b[0], a[1]] };
const ranked = [
{ move: moveB, preservedDegree: exactIncidentDegree(rooms, a) },
{ move: moveA, preservedDegree: exactIncidentDegree(rooms, b) },
];
return ranked.sort((left, right) => (
right.preservedDegree - left.preservedDegree
|| pointKey(left.move.to).localeCompare(pointKey(right.move.to))
|| pointKey(left.move.from).localeCompare(pointKey(right.move.from))
)).map((item) => item.move);
};
/**
* Explicit Optimize repair for room walls. Candidates come from immutable input;
* coincident room-owner copies are deduplicated by physical segment identity.
*/
export function repairNearAxisRoomWalls(spaceIn: any): NearAxisRepairResult {
const space = JSON.parse(JSON.stringify(spaceIn || {}));
let rooms = Array.isArray(space.rooms) ? space.rooms : [];
const candidates = new Map<string, RepairCandidate>();
for (const room of rooms) {
const poly = roomPoly(room);
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = [...poly[i]], b = [...poly[(i + 1) % poly.length]];
const classified = classifyNearAxisSegment(a, b);
if (!classified) continue;
const key = segmentKey(a, b);
if (!candidates.has(key)) candidates.set(key, { key, a, b, axis: classified.axis });
}
}
let wallsStraightened = 0;
let wallsStraightenSkipped = 0;
let maxStraightenShift = 0;
const reservedMoves = new Map<string, string>();
for (const candidate of [...candidates.values()].sort((a, b) => a.key.localeCompare(b.key))) {
let accepted: EndpointMove | null = null;
for (const move of candidateMoves(candidate, rooms)) {
const reserved = reservedMoves.get(pointKey(move.from));
if (reserved && reserved !== pointKey(move.to)) continue;
const nextRooms = replaceRoomPoints(rooms, move);
if (!roomMoveIsSafe(rooms, nextRooms, move)) continue;
accepted = move;
rooms = nextRooms;
break;
}
if (!accepted) {
wallsStraightenSkipped++;
continue;
}
reservedMoves.set(pointKey(accepted.from), pointKey(accepted.to));
wallsStraightened++;
maxStraightenShift = Math.max(
maxStraightenShift,
Math.hypot(accepted.to[0] - accepted.from[0], accepted.to[1] - accepted.from[1]),
);
}
space.rooms = rooms;
// Saved wall chains are independent authoring records. Preserve their point
// and segment counts; only an equivalence-class endpoint coordinate moves.
for (const draft of space.room_drafts || []) {
if (!Array.isArray(draft?.points) || draft.points.length < 2) continue;
const source = draft.points.map((point: number[]) => [...point]);
const draftCandidates = source.slice(0, -1).flatMap((a: number[], index: number) => {
const b = source[index + 1];
const classified = classifyNearAxisSegment(a, b);
return classified ? [{
key: `${String(draft.id || '')}:${index}`,
a: [...a], b: [...b], axis: classified.axis,
} as RepairCandidate] : [];
});
let points = source;
for (const candidate of draftCandidates) {
let accepted: EndpointMove | null = null;
for (const move of candidateMoves(candidate, [{ poly: points }])) {
const next = points.map((point: number[]) => replacePoint(point, move));
const closed = next.length >= 4 && samePoint(next[0], next[next.length - 1]);
const ring = closed ? next.slice(0, -1) : next;
if (closed ? !simplePolygon(ring) : !simpleOpenPath(ring)) continue;
accepted = move;
points = next;
break;
}
if (!accepted) {
wallsStraightenSkipped++;
continue;
}
wallsStraightened++;
maxStraightenShift = Math.max(
maxStraightenShift,
Math.hypot(accepted.to[0] - accepted.from[0], accepted.to[1] - accepted.from[1]),
);
}
draft.points = points;
}
// Independent partitions have one owner. Hosted openings are retained only
// when their along-wall interval still fits the exact-axis candidate.
for (const partition of space.partitions || []) {
if (!Array.isArray(partition?.a) || !Array.isArray(partition?.b)) continue;
const classified = classifyNearAxisSegment(partition.a, partition.b);
if (!classified) continue;
const candidate: RepairCandidate = {
key: String(partition.id || segmentKey(partition.a, partition.b)),
a: [...partition.a], b: [...partition.b], axis: classified.axis,
};
let accepted: EndpointMove | null = null;
for (const move of candidateMoves(candidate, [{ poly: [partition.a, partition.b] }])) {
const a = replacePoint(partition.a, move);
const b = replacePoint(partition.b, move);
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (!(length > 0)) continue;
const openingsFit = (space.openings || []).filter((opening: any) => (
opening?.host?.kind === 'partition' && opening.host.id === partition.id
)).every((opening: any) => {
const t = Number(opening.host.t);
const openingLength = Number(opening.length);
return Number.isFinite(t) && t >= 0 && t <= 1
&& Number.isFinite(openingLength) && openingLength > 0
&& t * length - openingLength / 2 >= -1e-12
&& t * length + openingLength / 2 <= length + 1e-12;
});
if (!openingsFit) continue;
accepted = move;
partition.a = a;
partition.b = b;
break;
}
if (!accepted) {
wallsStraightenSkipped++;
continue;
}
wallsStraightened++;
maxStraightenShift = Math.max(
maxStraightenShift,
Math.hypot(accepted.to[0] - accepted.from[0], accepted.to[1] - accepted.from[1]),
);
}
return {
space,
report: { wallsStraightened, wallsStraightenSkipped, maxStraightenShift },
changed: wallsStraightened > 0,
};
}
+49 -3
View File
@@ -32,6 +32,7 @@ import { reconcileCoincidentPartitions } from './coincident-partitions';
import {
repairSpaceReferences, type SpaceReferenceRepairContext, type SpaceReferenceReport,
} from './space-reference-repair';
import { repairNearAxisRoomWalls } from './near-axis';
/** Bump when a new lossless maintenance pass is added. */
export const PLAN_MODEL_VERSION = 7;
@@ -64,6 +65,14 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport {
partitionsReconciled: number;
/** Hosted openings materialised onto the coincident shared room wall. */
openingsRehosted: number;
/** Unique physical near-axis walls accepted for explicit straightening. */
wallsStraightened: number;
/** Near-axis walls found but rejected by structural safety checks. */
wallsStraightenSkipped: number;
/** Largest accepted endpoint movement in centimetres. */
maxStraightenShiftCm: number;
/** Space owning the largest accepted straightening movement. */
maxStraightenSpace: string;
}
export interface OptimizeResult {
@@ -431,8 +440,41 @@ export function optimizePlans(
const beforeSpaces = clone(config.spaces || []);
const aligned = alignAllToGrid(config.spaces || [], references.layout);
config.spaces = aligned.spaces;
const alignReport: AlignReport = { ...aligned.report };
let wallsStraightened = 0;
let wallsStraightenSkipped = 0;
let maxStraightenShiftCm = 0;
let maxStraightenSpace = '';
const straightenedSpaces = aligned.spaces.map((space: any) => {
const repaired = repairNearAxisRoomWalls(space);
wallsStraightened += repaired.report.wallsStraightened;
wallsStraightenSkipped += repaired.report.wallsStraightenSkipped;
const cellCm = Number(space?.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM;
const shiftCm = (repaired.report.maxStraightenShift / GRID_STEP_N) * cellCm;
if (shiftCm > maxStraightenShiftCm) {
maxStraightenShiftCm = shiftCm;
maxStraightenSpace = String(space?.id || '');
}
return repaired.space;
});
// Openings are wall-bound. Once a room endpoint has been straightened, run
// the same production alignment once more so their centres/angles follow
// the final host instead of the pre-repair edge.
const finalAligned = wallsStraightened
? alignAllToGrid(straightenedSpaces, aligned.layout)
: { ...aligned, spaces: straightenedSpaces };
config.spaces = finalAligned.spaces;
const alignReport: AlignReport = wallsStraightened ? {
...aligned.report,
moved: aligned.report.moved + finalAligned.report.moved,
coordsCanonicalized: aligned.report.coordsCanonicalized
+ finalAligned.report.coordsCanonicalized,
maxShift: Math.max(aligned.report.maxShift, finalAligned.report.maxShift),
maxShiftCm: Math.max(aligned.report.maxShiftCm, finalAligned.report.maxShiftCm),
maxSpace: finalAligned.report.maxShiftCm > aligned.report.maxShiftCm
? finalAligned.report.maxSpace : aligned.report.maxSpace,
rotated: aligned.report.rotated + finalAligned.report.rotated,
removedDrafts: aligned.report.removedDrafts + finalAligned.report.removedDrafts,
} : { ...aligned.report };
let wallsMerged = 0;
let spansMerged = 0;
@@ -588,7 +630,7 @@ export function optimizePlans(
// Canonicalise the complete pair before both the diff and the return so the
// preview, durable intent, live stores and next preview all see one target.
const persistedConfig = canonicalizeConfigGeometry(config);
const persistedLayout = canonicalizeLayoutGeometry(aligned.layout);
const persistedLayout = canonicalizeLayoutGeometry(finalAligned.layout);
// A version marker is bookkeeping, not maintenance by itself. Persist it
// only alongside a real config/layout transformation; otherwise an already
@@ -645,6 +687,10 @@ export function optimizePlans(
partitionsMerged: changed ? partitionsMerged : 0,
partitionsReconciled: changed ? partitionsReconciled : 0,
openingsRehosted: changed ? openingsRehosted : 0,
wallsStraightened: changed ? wallsStraightened : 0,
wallsStraightenSkipped,
maxStraightenShiftCm: changed ? maxStraightenShiftCm : 0,
maxStraightenSpace: changed ? maxStraightenSpace : '',
...persistedReferences,
},
changed,
+4
View File
@@ -14,6 +14,7 @@ import { intersection } from 'polyclip-ts';
import {
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
} from './logic';
import { classifyNearAxisSegment } from './near-axis';
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
export const MIN_ROOM_CM = 30;
@@ -1014,6 +1015,9 @@ export function validateSafeResize(
const edge = plan.edgeByRoom[roomId];
if (!original || !next || next.length !== plan.topology[roomId]
|| next.length !== original.poly.length || !polyIsSimple(next)) return false;
for (let index = 0; index < next.length; index++) {
if (classifyNearAxisSegment(next[index], next[(index + 1) % next.length])) return false;
}
const s0 = signedArea(original.poly);
const s1 = signedArea(next);
if (Math.abs(s1) < eps || s0 * s1 <= 0) return false;
+2 -1
View File
@@ -8,6 +8,7 @@
*/
import { union, difference, intersection } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
import { NEAR_AXIS_MAX_DEGREES } from './near-axis';
export interface WallEntry {
key: string;
@@ -81,7 +82,7 @@ export const MITRE_LIMIT = 4;
export const MULTI_WALL_JOIN_LIMIT = 1.25;
/** Maximum drafting deviation still rendered as a physical T/X junction (#279). */
export const MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES = 0.25;
export const MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES = NEAR_AXIS_MAX_DEGREES;
/** Normalized dot-product tolerance for a physically near-orthogonal ray pair. */
export const MULTI_WALL_ORTHOGONAL_DOT_EPSILON = Math.sin(