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