This commit is contained in:
Andras Schmelczer 2026-09-11 16:09:28 +01:00
commit d8f2407ddf
44 changed files with 10377 additions and 0 deletions

231
src/lib/geometry.ts Normal file
View file

@ -0,0 +1,231 @@
import ClipperLib from 'clipper-lib';
import type { Geometry, Issue, Point, Room, WallEdge } from './types';
const EPS = 1e-6;
const SCALE = 1000;
const MAX_WALL = 1_000_000;
const radians = (degrees: number) => (degrees * Math.PI) / 180;
const degrees = (angle: number) => (angle * 180) / Math.PI;
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
export const normalizeAngle = (angle: number) => ((((angle + 180) % 360) + 360) % 360) - 180;
export const signedArea = (points: Point[]) =>
points.reduce((sum, point, i) => {
const next = points[(i + 1) % points.length];
return sum + point.x * next.y - next.x * point.y;
}, 0) / 2;
function cross(a: Point, b: Point, c: Point) {
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}
function onSegment(a: Point, b: Point, point: Point) {
return (
Math.abs(cross(a, b, point)) < EPS &&
point.x >= Math.min(a.x, b.x) - EPS &&
point.x <= Math.max(a.x, b.x) + EPS &&
point.y >= Math.min(a.y, b.y) - EPS &&
point.y <= Math.max(a.y, b.y) + EPS
);
}
function intersects(a: WallEdge, b: WallEdge) {
const ab1 = cross(a.start, a.end, b.start),
ab2 = cross(a.start, a.end, b.end);
const ba1 = cross(b.start, b.end, a.start),
ba2 = cross(b.start, b.end, a.end);
return (
(ab1 * ab2 < -EPS && ba1 * ba2 < -EPS) ||
onSegment(a.start, a.end, b.start) ||
onSegment(a.start, a.end, b.end) ||
onSegment(b.start, b.end, a.start) ||
onSegment(b.start, b.end, a.end)
);
}
/** Measured walls are kept intact until a small accepted closure residual is snapped. */
export function buildGeometry(room: Room, tolerance: number): Geometry {
const issues: Issue[] = [];
const edges: WallEdge[] = [];
const origin = { x: 0, y: 0 };
let position = origin;
let heading = 0;
const allowedGap =
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 50 ? tolerance : 0;
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 50) {
issues.push({
severity: 'error',
message: 'Measurement tolerance must be between 0 and 50 mm.',
});
}
room.walls.forEach((wall, index) => {
if (!Number.isFinite(wall.length) || wall.length <= 0 || wall.length > MAX_WALL) {
issues.push({
severity: 'error',
message: `Wall ${index + 1} needs a positive length no greater than ${MAX_WALL} mm.`,
});
}
// The last stored turn only applies if another wall is subsequently added.
if (
index < room.walls.length - 1 &&
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= 180)
) {
issues.push({
severity: 'error',
message: `The turn after wall ${index + 1} must be between −180° and 180°.`,
});
}
const length =
Number.isFinite(wall.length) && wall.length > 0 && wall.length <= MAX_WALL ? wall.length : 0;
const end = {
x: position.x + length * Math.cos(radians(heading)),
y: position.y + length * Math.sin(radians(heading)),
};
edges.push({
wallId: wall.id,
index,
start: position,
end,
length,
heading,
turn: wall.turn,
doors: wall.doors,
});
position = end;
if (Number.isFinite(wall.turn)) heading = normalizeAngle(heading + wall.turn);
const doors = [...wall.doors].sort(
(a, b) => a.offset - b.offset || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
doors.forEach((door, doorIndex) => {
if (
!Number.isFinite(door.offset) ||
!Number.isFinite(door.width) ||
door.offset < 0 ||
door.width <= 0 ||
door.offset + door.width > length + EPS
) {
issues.push({
severity: 'error',
message: `Door ${doorIndex + 1} on wall ${index + 1} must fit within the wall, with a positive opening width.`,
});
}
if (
doorIndex > 0 &&
door.offset < doors[doorIndex - 1].offset + doors[doorIndex - 1].width - EPS
) {
issues.push({ severity: 'error', message: `Door openings overlap on wall ${index + 1}.` });
}
});
});
const last = edges.at(-1);
const gap = distance(position, origin);
const closingLength = gap;
const closingTurn =
last && gap > EPS
? normalizeAngle(degrees(Math.atan2(-position.y, -position.x)) - last.heading)
: 0;
const requiredFinalLength = last ? distance(last.start, origin) : 0;
const finalLengthError = last ? last.length - requiredFinalLength : 0;
const finalAngleError =
last && requiredFinalLength > EPS
? normalizeAngle(last.heading - degrees(Math.atan2(-last.start.y, -last.start.x)))
: 0;
const closed = edges.length >= 3 && gap <= allowedGap + EPS;
if (edges.length < 3) {
issues.push({ severity: 'error', message: 'Add at least three walls to finish an outline.' });
} else if (!closed) {
issues.push({
severity: 'error',
message: `The outline is open by ${gap.toFixed(1)} mm. Check lengths and turns, or add the closing wall.`,
});
}
if (closed && last) {
if (gap > 0.05) {
issues.push({
severity: 'warning',
message: `The outline misses by ${gap.toFixed(1)} mm, within your ${allowedGap} mm tolerance. The drawing snaps the final wall to the start. This may be measuring error or a non-straight wall; remeasure before cutting.`,
});
}
last.end = origin;
last.length = distance(last.start, origin);
last.heading = degrees(Math.atan2(-last.start.y, -last.start.x));
// A snapped wall can make a previously valid opening fall outside its new length.
if (last.doors.some((door) => door.offset + door.width > last.length + EPS)) {
issues.push({
severity: 'error',
message: 'A door on the final wall extends beyond its adjusted closing length.',
});
}
}
for (let i = 0; i < edges.length; i++) {
for (let j = i + 1; j < edges.length; j++) {
if (j === i + 1 || (closed && i === 0 && j === edges.length - 1)) continue;
if (intersects(edges[i], edges[j])) {
issues.push({
severity: 'error',
message: `Walls ${i + 1} and ${j + 1} cross or touch. Check the wall order and turns.`,
});
}
}
}
const vertices = edges.map((edge) => edge.start);
if (!closed && last) vertices.push(last.end);
const area = closed ? Math.abs(signedArea(vertices)) : 0;
if (closed && area <= EPS)
issues.push({ severity: 'error', message: 'The outline must enclose an area.' });
return {
vertices,
edges,
area,
perimeter: edges.reduce((sum, edge) => sum + edge.length, 0),
gap,
closed,
valid: closed && !issues.some((issue) => issue.severity === 'error'),
issues,
closingLength,
closingTurn,
finalLengthError,
finalAngleError,
};
}
/** Integer polygon offset handles concave recesses and narrow passages that split. */
export function insetPolygon(vertices: Point[], gap: number): Point[][] {
if (
vertices.length < 3 ||
!Number.isFinite(gap) ||
gap < 0 ||
gap > MAX_WALL ||
vertices.some(
(point) =>
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
Math.max(Math.abs(point.x), Math.abs(point.y)) > 1e9,
)
)
return [];
const path = vertices.map((point) => ({
X: Math.round(point.x * SCALE),
Y: Math.round(point.y * SCALE),
}));
if (!ClipperLib.Clipper.Orientation(path)) path.reverse();
if (gap === 0) return [path.map((point) => ({ x: point.X / SCALE, y: point.Y / SCALE }))];
const offset = new ClipperLib.ClipperOffset(20, 0.25 * SCALE);
offset.AddPath(path, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
const result: ClipperLib.Paths = [];
offset.Execute(result, -gap * SCALE);
return result
.filter((polygon) => Math.abs(ClipperLib.Clipper.Area(polygon)) > EPS)
.map((polygon) => {
if (!ClipperLib.Clipper.Orientation(polygon)) polygon.reverse();
return polygon.map((point) => ({ x: point.X / SCALE, y: point.Y / SCALE }));
})
.sort(
(a, b) =>
Math.abs(signedArea(b)) - Math.abs(signedArea(a)) || a[0].x - b[0].x || a[0].y - b[0].y,
);
}