import type { Point } from './types'; import { cutMm, degree } from './format'; import { PRECISION } from './limits'; export interface PreviewBox { x: number; y: number; width: number; height: number; } export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`; export interface EdgeDimension { edge: number; length: number; text: string; /** Acute angle to the horizontal board edge; the diagram supplies its slope direction. */ angle?: number; start: Point; end: Point; lineStart: Point; lineEnd: Point; normal: Point; label: Point; labelBox: PreviewBox; } const moved = (point: Point, direction: Point, distance: number): Point => ({ x: point.x + direction.x * distance, y: point.y + direction.y * distance, }); const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) => a.x < b.x + b.width + gap && a.x + a.width + gap > b.x && a.y < b.y + b.height + gap && a.y + a.height + gap > b.y; /** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */ export function dimensionedOutline( points: Point[], edges?: number[], blank: { length?: number; width?: number } = {}, ) { if (points.length < 3) return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) }; const span = Math.max( 1, Math.max(...points.map((point) => point.x)) - Math.min(...points.map((point) => point.x)), Math.max(...points.map((point) => point.y)) - Math.min(...points.map((point) => point.y)), ); const font = span * 0.065; const orientation = Math.sign( points.reduce((sum, point, i) => { const next = points[(i + 1) % points.length]; return sum + point.x * next.y - next.x * point.y; }, 0), ) || 1; const dimensions: EdgeDimension[] = []; const selected = (edges ?? points.map((_, i) => i)) .map((edge) => ({ edge, start: points[edge], end: points[(edge + 1) % points.length], })) .map((edge) => ({ ...edge, length: Math.hypot(edge.end.x - edge.start.x, edge.end.y - edge.start.y), })) .filter((edge) => edge.length > PRECISION.geometry) .sort((a, b) => b.length - a.length || a.edge - b.edge); for (const edge of selected) { const direction = { x: (edge.end.x - edge.start.x) / edge.length, y: (edge.end.y - edge.start.y) / edge.length, }; const normal = { x: orientation * direction.y, y: -orientation * direction.x }; const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 }; const limit = Math.abs(direction.y) < PRECISION.geometry ? blank.length : Math.abs(direction.x) < PRECISION.geometry ? blank.width : undefined; const text = cutMm(edge.length, limit); const angle = Math.abs(direction.x) > PRECISION.geometry && Math.abs(direction.y) > PRECISION.geometry ? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI : undefined; const angleText = angle === undefined ? '' : degree(angle); const width = Math.max(font * 1.2, Math.max(text.length, angleText.length) * font * 0.64), height = font * (angle === undefined ? 1.3 : 2.5); const outside = Math.max( 0, ...points.map((point) => (point.x - center.x) * normal.x + (point.y - center.y) * normal.y), ); let offset = outside + font * 0.65; let label = center, labelBox: PreviewBox = { x: 0, y: 0, width, height }; let placed = false; for (let lane = 0; lane < 30 && !placed; lane++) { const base = moved( center, normal, offset + (Math.abs(normal.x) * width + Math.abs(normal.y) * height) / 2 + font * 0.18, ); for (let step = 0; step < 25; step++) { const shift = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? -1 : 1) * font * 1.55; label = moved(base, direction, shift); labelBox = { x: label.x - width / 2, y: label.y - height / 2, width, height }; if (!dimensions.some((dimension) => overlaps(labelBox, dimension.labelBox, font * 0.25))) { placed = true; break; } } if (!placed) offset += font * 1.8; } dimensions.push({ ...edge, text, angle, normal, lineStart: moved(edge.start, normal, offset), lineEnd: moved(edge.end, normal, offset), label, labelBox, }); } const xs = points.map((point) => point.x), ys = points.map((point) => point.y); for (const dimension of dimensions) { xs.push( dimension.lineStart.x, dimension.lineEnd.x, dimension.labelBox.x, dimension.labelBox.x + dimension.labelBox.width, ); ys.push( dimension.lineStart.y, dimension.lineEnd.y, dimension.labelBox.y, dimension.labelBox.y + dimension.labelBox.height, ); } const padding = font * 0.4; return { dimensions: dimensions.sort((a, b) => a.edge - b.edge), font, box: { x: Math.min(...xs) - padding, y: Math.min(...ys) - padding, width: Math.max(...xs) - Math.min(...xs) + padding * 2, height: Math.max(...ys) - Math.min(...ys) + padding * 2, }, }; } /** Padding changes the viewport only; all vertices stay in their original millimetres. */ export function fullPreviewBox(length: number, width: number): PreviewBox { const padding = Math.max(length, width, 1) * 0.06; return { x: -padding, y: -padding, width: length + 2 * padding, height: width + 2 * padding }; } /** Equal square windows enlarge both axes equally; a 45° mitre remains 45°. */ export function skirtingEndBoxes( template: Point[], thickness: number, installed?: Point[], ): { left: PreviewBox; right: PreviewBox } { const left = [ template[0].x, template[3].x, ...(installed ? [installed[0].x, installed[3].x] : []), ]; const right = [ template[1].x, template[2].x, ...(installed ? [installed[1].x, installed[2].x] : []), ]; const side = Math.max( 1, thickness * 3, (Math.max(...left) - Math.min(...left)) / 0.76, (Math.max(...right) - Math.min(...right)) / 0.76, ); const padding = side * 0.12; const common = { y: (thickness - side) / 2, width: side, height: side }; return { left: { ...common, x: Math.min(...left) - padding }, right: { ...common, x: Math.max(...right) - side + padding }, }; }