floor-planner/src/lib/geometry.ts
2026-09-11 16:09:28 +01:00

231 lines
8 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.

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,
);
}