import { buildGeometry, normalizeAngle, signedArea } from './geometry'; import { isSkirtingCut } from './cut-list'; import { cutLength } from './format'; import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits'; import type { Issue, Point, Room, SkirtingPiece, SkirtingPlan, SkirtingSettings, SkirtingStock, WallEdge, } from './types'; const EPS = PRECISION.geometry; type UnpackedPiece = Omit; const rad = (degrees: number) => (degrees * Math.PI) / 180; type End = { type: SkirtingPiece['leftType']; angle: number; bevel: number; back: number; front: number; installedBack: number; installedFront: number; visible: number; }; type Run = { roomId: string; prefix: string; edge: WallEdge; from: number; to: number; left: End; right: End; thickness: number; }; const square = (): End => ({ type: 'square', angle: 0, bevel: 0, back: 0, front: 0, installedBack: 0, installedFront: 0, visible: 0, }); const firstAllowance = (end: End) => Math.max(end.installedBack, end.installedFront, end.visible); const lastAllowance = (end: End) => Math.min(end.installedBack, end.installedFront, end.visible); function roomPrefix(index: number): string { let value = index + 1, result = ''; while (value > 0) { value--; result = String.fromCharCode(65 + (value % 26)) + result; value = Math.floor(value / 26); } return result; } function pointAt(edge: WallEdge, offset: number): Point { const ratio = offset / edge.length; return { x: edge.start.x + (edge.end.x - edge.start.x) * ratio, y: edge.start.y + (edge.end.y - edge.start.y) * ratio, }; } /** * Copes retain the inside-mitre long point at the wall corner. The moulding's * actual profile is then scribed and back-cut to fit the receiving piece. * https://www.finehomebuilding.com/pdf/021174063.pdf (Gary Katz): inside cuts use the back * long point and fit the receiving end first. For a turn t, intersecting faces * meet thickness*tan(t/2) from the corner; the full-depth body starts at thickness*csc(t). */ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: boolean): End { if (Math.abs(turn) < EPS || thickness < EPS) return square(); const sign = side === 'left' ? 1 : -1; if (turn < 0) { const front = sign * thickness * Math.tan(rad(turn / 2)); return { type: 'external', angle: -turn / 2, bevel: (sign * turn) / 2, back: 0, front, installedBack: 0, installedFront: front, visible: front, }; } const faceJoint = sign * thickness * Math.tan(rad(turn / 2)); if (cope) { return { type: 'cope', angle: turn / 2, bevel: (sign * turn) / 2, back: 0, front: faceJoint, installedBack: (sign * thickness) / Math.sin(rad(turn)), installedFront: faceJoint, visible: faceJoint, }; } // A square receiving end fits a right-angle wall; an oblique wall needs a // wall-fitting bevel. Its face extends past the visible joint under the cope. const front = (-sign * thickness) / Math.tan(rad(turn)); return { type: 'internal', angle: Math.abs(90 - turn), bevel: sign * (turn - 90), back: 0, front, installedBack: 0, installedFront: front, visible: faceJoint, }; } function endCuts(piece: UnpackedPiece) { return ( Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS) + Number(piece.leftType === 'cope') + Number(piece.rightType === 'cope') ); } function blankLength(piece: UnpackedPiece, available: number) { return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length); } function requiredCuts(piece: UnpackedPiece, available: number) { return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS); } function consumedLength(piece: UnpackedPiece, available: number, kerf: number) { // Cut a square blank, then bevel within its longest-point envelope. This leaves // a square reusable offcut. Bevel kerfs remove material inside the blank. const length = blankLength(piece, available); return Math.min(available, length + (Math.abs(available - length) <= EPS ? 0 : kerf)); } function makePiece( run: Run, from: number, to: number, first: boolean, last: boolean, ): UnpackedPiece { const left = first ? run.left : square(), right = last ? run.right : square(); const backFrom = from + left.back, backTo = to + right.back; const frontFrom = from + left.front, frontTo = to + right.front; const origin = Math.min(backFrom, frontFrom); return { id: '', roomId: run.roomId, wallId: run.edge.wallId, wallIndex: run.edge.index, start: pointAt(run.edge, backFrom), end: pointAt(run.edge, backTo), backLength: backTo - backFrom, length: Math.max(backTo, frontTo) - Math.min(backFrom, frontFrom), leftMitre: left.angle, rightMitre: right.angle, leftType: left.type, rightType: right.type, stockId: '', visibleLength: to + right.visible - from - left.visible, leftOverlap: Math.abs(left.visible - (left.type === 'cope' ? left.back : left.front)), rightOverlap: Math.abs(right.visible - (right.type === 'cope' ? right.back : right.front)), installedTemplate: [ { x: from + left.installedBack - origin, y: 0 }, { x: to + right.installedBack - origin, y: 0 }, { x: to + right.installedFront - origin, y: run.thickness }, { x: from + left.installedFront - origin, y: run.thickness }, ], leftBevel: left.bevel, rightBevel: right.bevel, template: [ { x: backFrom - origin, y: 0 }, { x: backTo - origin, y: 0 }, { x: frontTo - origin, y: run.thickness }, { x: frontFrom - origin, y: run.thickness }, ], }; } function shortestEdge(piece: UnpackedPiece) { const installed = piece.installedTemplate; return Math.min( installed[1].x - installed[0].x, installed[2].x - installed[3].x, piece.visibleLength, ); } function fits(piece: UnpackedPiece, available: number) { // The final saw pass may extend beyond the stock's tail. return blankLength(piece, available) <= available + EPS; } /** Zero means that no number of pieces can satisfy the end allowances. */ function splitCount(run: Run, target: number, minimum: number): number { const full = makePiece(run, run.from, run.to, true, true); if (shortestEdge(full) < minimum - EPS) return 0; if (fits(full, target)) return 1; const count = Math.max(2, Math.ceil((full.length - EPS) / target)); const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right); if ( count > PLANNER_LIMITS.skirtingPieces || run.to - run.from < minimumSpan - EPS || firstAllowance(run.left) - Math.min(run.left.back, run.left.front) > target - minimum + EPS || Math.max(run.right.back, run.right.front) - lastAllowance(run.right) > target - minimum + EPS ) return 0; return count; } /** Split along the wall while reserving both front and back corner allowances. */ function splitRun( run: Run, target: number, settings: SkirtingSettings, balanced: boolean, ): UnpackedPiece[] | null { const count = splitCount(run, target, settings.minLength); if (!count) return null; if (count === 1) return [makePiece(run, run.from, run.to, true, true)]; const span = run.to - run.from; // A piece's shortest edge sets its minimum span. Its longest point sets // its maximum span. These differ at angled corners, at receiving and coped corners. const minimums = Array.from( { length: count }, (_, index) => settings.minLength + (index === 0 ? firstAllowance(run.left) : 0) - (index === count - 1 ? lastAllowance(run.right) : 0), ); const maximums = Array.from( { length: count }, (_, index) => target + (index === 0 ? Math.min(run.left.back, run.left.front) : 0) - (index === count - 1 ? Math.max(run.right.back, run.right.front) : 0), ); let extra = span - minimums.reduce((sum, value) => sum + value, 0); const spans = [...minimums]; if (balanced) { // Equal extra length, capped by each end's allowance; at most three // distinct capacities exist (first, middle, last). let active = spans.map((_, index) => index); while (extra > EPS && active.length) { const share = extra / active.length; for (const index of active) { const addition = Math.min(share, maximums[index] - spans[index]); spans[index] += addition; extra -= addition; } active = active.filter((index) => maximums[index] - spans[index] > EPS); } } else { for (let index = 0; index < count; index++) { const addition = Math.min(extra, maximums[index] - spans[index]); spans[index] += addition; extra -= addition; } } let from = run.from; const pieces = spans.map((span, index) => { const to = index === count - 1 ? run.to : from + span; const piece = makePiece(run, from, to, index === 0, index === count - 1); from = to; return piece; }); return pieces.every( (piece) => fits(piece, target) && shortestEdge(piece) >= settings.minLength - EPS, ) ? pieces : null; } /** Let useful tails set the next joint instead of fixing every wall's joints first. */ function splitWithOffcuts( runs: Run[], target: number, settings: SkirtingSettings, ): UnpackedPiece[] | null { const pieces: UnpackedPiece[] = []; const offcuts: number[] = []; for (const run of runs) { if (!splitCount(run, target, settings.minLength)) return null; let from = run.from; while (from < run.to - EPS) { const first = Math.abs(from - run.from) < EPS; const remaining = { ...run, from, left: first ? run.left : square() }; const full = makePiece(remaining, from, run.to, true, true); const take = (available: number) => { if (fits(full, available)) return { piece: full, to: run.to }; const to = Math.min( from + available + Math.min(remaining.left.back, remaining.left.front), run.to + lastAllowance(run.right) - settings.minLength, ); if (to <= from + EPS) return null; const piece = makePiece(remaining, from, to, true, false); if (shortestEdge(piece) < settings.minLength - EPS || !fits(piece, available)) return null; // Reusing a tail must still leave a feasible remainder at the far corner. return splitCount({ ...run, from: to, left: square() }, target, settings.minLength) ? { piece, to } : null; }; // Finish from the smallest fitting offcut; otherwise use the largest // usable tail to avoid adding unnecessary joints. offcuts.sort((a, b) => { const aFits = fits(full, a), bFits = fits(full, b); return aFits !== bFits ? Number(bFits) - Number(aFits) : aFits ? a - b : b - a; }); let next: { piece: UnpackedPiece; to: number } | null = null; let available = target; for (let index = 0; index < offcuts.length; index++) { next = take(offcuts[index]); if (next) { available = offcuts.splice(index, 1)[0]; break; } } next ??= take(target); if (!next || pieces.length >= PLANNER_LIMITS.skirtingPieces) return null; pieces.push(next.piece); const tail = available - consumedLength(next.piece, available, settings.kerf); if (tail >= settings.minLength - EPS) offcuts.push(tail); from = next.to; } } return pieces; } interface Packed { pieces: SkirtingPiece[]; stocks: SkirtingStock[]; cuts: number; waste: number; } /** Greedy decreasing packing, optionally reserving exact three-piece combinations. */ function pack( source: UnpackedPiece[], stockLength: number, kerf: number, exactTails = false, ): Packed { const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0 })); let pending = [...pieces].sort( (a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), ); const stocks: SkirtingStock[] = []; let cuts = 0; while (pending.length) { let available = stockLength; const assigned: SkirtingPiece[] = []; if (exactTails && pending.length > 2) { const head = pending[0]; const tail = stockLength - consumedLength(head, stockLength, kerf); const options = pending.slice(1, 129).filter((piece) => fits(piece, tail)); for (const second of options) { const remaining = tail - consumedLength(second, tail, kerf); const third = options.find( (piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS, ); if (!third) continue; pending = [ head, second, third, ...pending.filter((piece) => piece !== head && piece !== second && piece !== third), ]; break; } } for (const piece of pending) { if (!fits(piece, available)) continue; piece.blankLength = blankLength(piece, available); cuts += requiredCuts(piece, available); available -= consumedLength(piece, available, kerf); assigned.push(piece); } if (!assigned.length) throw new Error('A skirting piece does not fit the available stock.'); const id = `S${stocks.length + 1}`; for (const piece of assigned) piece.stockId = id; const used = assigned.reduce((sum, piece) => sum + piece.length, 0); stocks.push({ id, length: stockLength, used, waste: Math.max(0, stockLength - used), pieceIds: assigned.map((piece) => piece.id), }); const assignedIds = new Set(assigned.map((piece) => piece.id)); pending = pending.filter((piece) => !assignedIds.has(piece.id)); } return { pieces, stocks, cuts, waste: stocks.reduce((sum, stock) => sum + stock.waste, 0) }; } export function planSkirting( rooms: Room[], settings: SkirtingSettings, tolerance: number, ): SkirtingPlan { const issues: Issue[] = []; const empty = (): SkirtingPlan => ({ pieces: [], stocks: [], stockCount: 0, cuts: 0, waste: 0, issues, }); if ( !Number.isFinite(settings.stockLength) || settings.stockLength < 0.1 || settings.stockLength > PROJECT_LIMITS.maxMeasurement || !Number.isFinite(settings.thickness) || settings.thickness < 0 || settings.thickness > PROJECT_LIMITS.maxSkirtingThickness || !Number.isFinite(settings.minLength) || settings.minLength < 0.1 || settings.minLength > PROJECT_LIMITS.maxMeasurement || !Number.isFinite(settings.kerf) || settings.kerf < 0 || settings.kerf > PROJECT_LIMITS.maxSkirtingKerf ) { issues.push({ severity: 'error', message: 'Use skirting stock and minimum piece lengths between 0.1 and 1,000,000 mm, with thickness and saw kerf between 0 and 10,000 mm.', }); return empty(); } const stockLength = settings.stockLength; let runs: Run[] = []; const fallbackSplits = new Map(); rooms.forEach((room, roomIndex) => { const geometry = buildGeometry(room, tolerance); if (!geometry.valid) { issues.push({ severity: 'error', roomId: room.id, message: `${room.name}: finish a valid outline and door measurements before planning skirting.`, }); return; } const orientation = Math.sign(signedArea(geometry.vertices)); geometry.edges.forEach((edge, index, edges) => { const previous = edges[(index + edges.length - 1) % edges.length], next = edges[(index + 1) % edges.length]; const previousHasEnd = !previous.doors.some( (door) => door.offset + door.width >= previous.length - EPS, ); const nextHasStart = !next.doors.some((door) => door.offset <= EPS); const left = previousHasEnd ? corner( normalizeAngle(edge.heading - previous.heading) * orientation, settings.thickness, 'left', index > previous.index, ) : square(); const right = nextHasStart ? corner( normalizeAngle(next.heading - edge.heading) * orientation, settings.thickness, 'right', index > next.index, ) : square(); const doors = [...edge.doors].sort((a, b) => a.offset - b.offset); let from = 0; for (const door of [...doors, { id: 'end', offset: edge.length, width: 0 }]) { if (door.offset > from + EPS) { const run = { roomId: room.id, prefix: roomPrefix(roomIndex), edge, from, to: door.offset, thickness: settings.thickness, left: from < EPS ? left : square(), right: door.offset >= edge.length - EPS ? right : square(), }; if ( shortestEdge(makePiece(run, from, door.offset, true, true)) < settings.minLength - EPS ) { issues.push({ severity: 'error', roomId: room.id, message: `${room.name}, wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`, }); } else { // Establish a feasible rounded split per run before combining rooms. // A fractional stock length may need offcut-led extra joints. const split = splitRun(run, stockLength, settings, false) ?? splitRun(run, stockLength, settings, true) ?? splitWithOffcuts([run], stockLength, settings); if (split) fallbackSplits.set(run, split); else issues.push({ severity: 'error', roomId: room.id, message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the rounded minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`, }); } runs.push(run); } from = door.offset + door.width; } }); }); // An unfinished or infeasible room must not erase another room's valid plan. const invalidRooms = new Set( issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId), ); runs = runs.filter((run) => !invalidRooms.has(run.roomId)); if (!runs.length) return empty(); const candidates: Packed[] = []; const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix])); const addCandidate = (source: UnpackedPiece[]) => { // Raw spans can fit while their displayed half-millimetre blanks do not. // Reject this split without losing other feasible joint arrangements. if ( source.some( (piece) => !fits(piece, stockLength) || shortestEdge(piece) < settings.minLength - EPS, ) ) return; const counts = new Map(); for (const piece of source) { const count = (counts.get(piece.roomId) ?? 0) + 1; counts.set(piece.roomId, count); piece.id = `${prefixes.get(piece.roomId)}${count}`; } candidates.push(pack(source, stockLength, settings.kerf)); candidates.push(pack(source, stockLength, settings.kerf, true)); }; // Compare full-stock, balanced, and offcut-led wall joints. { const target = stockLength; for (const balanced of [false, true]) { const splits = runs.map( (run) => splitRun(run, target, settings, balanced) ?? fallbackSplits.get(run)!, ); addCandidate(splits.flat()); } const reused = splitWithOffcuts(runs, target, settings); if (reused) addCandidate(reused); } candidates.sort( (a, b) => a.cuts - b.cuts || (Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) || a.stocks.length - b.stocks.length || a.pieces.length - b.pieces.length, ); const best = candidates[0]; if (!best) { issues.push({ severity: 'error', message: 'The stock lengths cannot fit the minimum skirting pieces, corner allowances and saw kerf. Increase stock length or reduce the minimum.', }); return empty(); } issues.push({ severity: 'info', message: 'Lengths are longest-point preparation blanks, including the material retained for internal coping. The earlier wall receives each cope; install it first. A cope uses an inside mitre to expose the profile, then a scribed back-cut. The actual decorative profile must be marked from your moulding. Templates show preparation cuts; the plan shows the full-thickness installed body. Minimum length applies to the exposed face and installed body. External corners use equal mitres. Door widths include any trim to omit.', }); issues.push({ severity: 'info', message: 'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, prioritising fewer cuts and then less waste. Exact combinations can save a trimming pass. This is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.', }); const plan = { ...best, stockCount: best.stocks.length, issues }; const cutNumbers = new Map(); let wholeNumber = 0; const codes = new Map( plan.pieces.map((piece) => { if (!isSkirtingCut(piece, plan)) return [piece.id, `SU${++wholeNumber}`]; const number = (cutNumbers.get(piece.roomId) ?? 0) + 1; cutNumbers.set(piece.roomId, number); return [piece.id, `${prefixes.get(piece.roomId)}${number}`]; }), ); const label = (id: string) => codes.get(id)!; return { ...plan, pieces: plan.pieces.map((piece) => ({ ...piece, id: label(piece.id) })), stocks: plan.stocks.map((stock) => ({ ...stock, pieceIds: stock.pieceIds.map(label) })), }; }