231 lines
8 KiB
TypeScript
231 lines
8 KiB
TypeScript
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,
|
||
);
|
||
}
|