This commit is contained in:
parent
7959fe85cc
commit
b9485e4097
32 changed files with 4692 additions and 65 deletions
52
src/lib/analytics.ts
Normal file
52
src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
init as plausibleInit,
|
||||
track as plausibleTrack,
|
||||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
||||
const ANALYTICS_DOMAIN = 'floor-planner.schmelczer.dev';
|
||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const ANALYTICS_LOGGING = import.meta.env.DEV;
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
try {
|
||||
plausibleTrack(eventName, options);
|
||||
} catch (error) {
|
||||
console.warn(`Could not track analytics event "${eventName}".`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const initAnalytics = () => {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: ANALYTICS_DOMAIN,
|
||||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
// Query strings and legacy fragments contain the saved plan, including room names.
|
||||
transformRequest: (payload) => ({
|
||||
...payload,
|
||||
u: payload.u.split(/[?#]/)[0],
|
||||
r: payload.r?.split(/[?#]/)[0] ?? payload.r,
|
||||
}),
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
console.warn('Could not initialize analytics.', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackRoomAdded = () => track('Add Room');
|
||||
|
||||
export const trackRoomRemoved = () => track('Remove Room');
|
||||
|
||||
export const trackCopyLink = () => track('Copy Link');
|
||||
|
||||
export const trackPrint = () => track('Print');
|
||||
|
|
@ -43,6 +43,7 @@ export function defaultProject(): Project {
|
|||
width: 190,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
minLength: 200,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,39 @@ const SCALE = 1000;
|
|||
const EPSILON = 0.01;
|
||||
const MAX_BOARDS = 2000;
|
||||
|
||||
export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>;
|
||||
|
||||
/** Compare both searches before presenting their actual cuts and waste to the user. */
|
||||
export function planFlooringOptions(
|
||||
rooms: Room[],
|
||||
s: FlooringSettings,
|
||||
tolerance: number,
|
||||
): FlooringOptions {
|
||||
const shapes: ShapeCache = new Map();
|
||||
const cutsSettings = { ...s, optimisation: 'cuts' as const };
|
||||
const randomSettings = { ...s, optimisation: 'random' as const };
|
||||
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes);
|
||||
const random =
|
||||
s.pattern === 'random' ? solveFlooring(rooms, randomSettings, tolerance, shapes) : cuts;
|
||||
const errors = (solution: Solution) =>
|
||||
solution.issues.filter((issue) => issue.severity === 'error').length;
|
||||
const better = (settings: FlooringSettings) =>
|
||||
(errors(random) - errors(cuts) || compareLayouts(random.layout, cuts.layout, settings)) < 0
|
||||
? random
|
||||
: cuts;
|
||||
const cutWinner = better(cutsSettings);
|
||||
const randomWinner = better(randomSettings);
|
||||
const cutPlan = finishFlooring(cutWinner, s);
|
||||
return {
|
||||
cuts: cutPlan,
|
||||
random: randomWinner === cutWinner ? cutPlan : finishFlooring(randomWinner, s),
|
||||
};
|
||||
}
|
||||
|
||||
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||
return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts'];
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
|
|
@ -31,6 +64,8 @@ interface Layout {
|
|||
rows: FlooringRow[];
|
||||
stocks: AvailableStock[];
|
||||
cuts: number;
|
||||
shapingCuts: number;
|
||||
ripCuts: number;
|
||||
reusedCount: number;
|
||||
patternPenalty: number;
|
||||
}
|
||||
|
|
@ -48,6 +83,12 @@ interface RowShape {
|
|||
bounds: Bounds;
|
||||
}
|
||||
|
||||
interface Solution {
|
||||
layout: Layout;
|
||||
issues: Issue[];
|
||||
}
|
||||
type ShapeCache = Map<string, { shape: RowShape; cuts: Map<number, CutPiece[] | null> }>;
|
||||
|
||||
const round = (n: number) => Math.round(n * SCALE) / SCALE;
|
||||
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
|
||||
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
|
||||
|
|
@ -228,7 +269,7 @@ function allocate(
|
|||
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
let stock: AvailableStock | undefined;
|
||||
for (const candidate of layout.stocks) {
|
||||
for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
|
||||
if (
|
||||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
||||
(piece.needsLeft && !candidate.left) ||
|
||||
|
|
@ -249,6 +290,18 @@ function allocate(
|
|||
};
|
||||
layout.stocks.push(stock);
|
||||
}
|
||||
const allocation = takeStock(stock, length, piece.needsRight, pieceId, s);
|
||||
if (allocation.reused) layout.reusedCount++;
|
||||
return allocation;
|
||||
}
|
||||
|
||||
function takeStock(
|
||||
stock: AvailableStock,
|
||||
length: number,
|
||||
needsRight: boolean,
|
||||
pieceId: string,
|
||||
s: FlooringSettings,
|
||||
) {
|
||||
const reused = stock.pieceIds.length > 0;
|
||||
const reserved = blankLength(length, stock.remaining);
|
||||
const crosscut = stock.remaining - length > EPSILON;
|
||||
|
|
@ -260,13 +313,88 @@ function allocate(
|
|||
} else {
|
||||
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
|
||||
// Take starters from the right; take finishers from the left. The remainder has one new cut end.
|
||||
if (piece.needsRight) stock.right = false;
|
||||
if (needsRight) stock.right = false;
|
||||
else stock.left = false;
|
||||
}
|
||||
if (reused) layout.reusedCount++;
|
||||
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
|
||||
}
|
||||
|
||||
/** Pair boundary blanks after seeing their future demand, preserving factory ends and cut order. */
|
||||
function repack(layout: Layout, s: FlooringSettings): Layout {
|
||||
const byId = new Map(layout.pieces.map((piece, index) => [piece.id, { piece, index }]));
|
||||
// Neutral pieces can occupy a board's middle. Keep these more general cutting patterns intact.
|
||||
const fixed = layout.stocks.filter((stock) =>
|
||||
stock.pieceIds.some((id) => {
|
||||
const p = byId.get(id)!.piece;
|
||||
return !p.factoryLeft && !p.factoryRight;
|
||||
}),
|
||||
);
|
||||
const fixedIds = new Set(fixed.flatMap((stock) => stock.pieceIds));
|
||||
const movable = layout.pieces.filter((p) => !fixedIds.has(p.id));
|
||||
const left = movable.filter((p) => p.factoryLeft && !p.factoryRight);
|
||||
const right = movable.filter((p) => p.factoryRight && !p.factoryLeft);
|
||||
const paired = new Set<string>();
|
||||
const groups: string[][] = fixed.map((stock) => [...stock.pieceIds]);
|
||||
const pairCuts = (a: FlooringPiece, b: FlooringPiece) => {
|
||||
if (byId.get(a.id)!.index > byId.get(b.id)!.index) [a, b] = [b, a];
|
||||
const remaining = round(Math.max(0, s.length - blankLength(a.length, s.length) - s.kerf));
|
||||
if (blankLength(b.length, remaining) > remaining + EPSILON) return Infinity;
|
||||
return Number(!close(a.length, s.length)) + Number(!close(b.length, remaining));
|
||||
};
|
||||
const pair = (a: FlooringPiece, b: FlooringPiece) => {
|
||||
paired.add(a.id);
|
||||
paired.add(b.id);
|
||||
groups.push([a.id, b.id]);
|
||||
};
|
||||
// An exact complementary tail eliminates a saw pass. Reserve those matches first.
|
||||
for (const a of left) {
|
||||
const b = right.find((b) => !paired.has(b.id) && pairCuts(a, b) === 1);
|
||||
if (b) pair(a, b);
|
||||
}
|
||||
// With saw passes fixed, fit large finishers with small starters to reduce purchased stock.
|
||||
right.sort((a, b) => a.length - b.length);
|
||||
for (const a of left.sort((a, b) => b.length - a.length)) {
|
||||
if (paired.has(a.id)) continue;
|
||||
const b = right.find((b) => !paired.has(b.id) && Number.isFinite(pairCuts(a, b)));
|
||||
if (b) pair(a, b);
|
||||
}
|
||||
for (const p of movable) if (!paired.has(p.id)) groups.push([p.id]);
|
||||
const index = (id: string) => byId.get(id)!.index;
|
||||
groups.forEach((ids) => ids.sort((a, b) => index(a) - index(b)));
|
||||
groups.sort((a, b) => index(a[0]) - index(b[0]));
|
||||
const result: Layout = {
|
||||
...layout,
|
||||
pieces: [...layout.pieces],
|
||||
stocks: [],
|
||||
cuts: layout.shapingCuts + layout.ripCuts,
|
||||
reusedCount: 0,
|
||||
};
|
||||
for (const ids of groups) {
|
||||
const stock: AvailableStock = {
|
||||
id: `B${result.stocks.length + 1}`,
|
||||
pieceIds: [],
|
||||
remaining: s.length,
|
||||
left: true,
|
||||
right: true,
|
||||
};
|
||||
result.stocks.push(stock);
|
||||
for (const id of ids) {
|
||||
const { piece, index } = byId.get(id)!;
|
||||
const allocation = takeStock(stock, piece.length, !!piece.factoryRight, id, s);
|
||||
result.cuts += Number(allocation.crosscut);
|
||||
result.reusedCount += Number(allocation.reused);
|
||||
result.pieces[index] = {
|
||||
...piece,
|
||||
stockId: stock.id,
|
||||
reused: allocation.reused,
|
||||
blankLength: allocation.blankLength,
|
||||
cut: allocation.crosscut || piece.shaped || !!piece.ripEdge,
|
||||
};
|
||||
}
|
||||
}
|
||||
return compareLayouts(result, layout, s) < 0 ? result : layout;
|
||||
}
|
||||
|
||||
function appendRow(
|
||||
base: Layout,
|
||||
cuts: CutPiece[],
|
||||
|
|
@ -280,9 +408,13 @@ function appendRow(
|
|||
pieces: [...base.pieces],
|
||||
rows: [...base.rows],
|
||||
cuts: base.cuts,
|
||||
shapingCuts: base.shapingCuts,
|
||||
ripCuts: base.ripCuts,
|
||||
reusedCount: base.reusedCount,
|
||||
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
|
||||
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
|
||||
stocks: base.stocks.map((stock) =>
|
||||
stock.remaining > 0 ? { ...stock, pieceIds: [...stock.pieceIds] } : stock,
|
||||
),
|
||||
};
|
||||
const pieceIds: string[] = [];
|
||||
cuts.forEach((piece, column) => {
|
||||
|
|
@ -309,6 +441,8 @@ function appendRow(
|
|||
const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null;
|
||||
const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts;
|
||||
layout.cuts += passes;
|
||||
layout.shapingCuts += shapeCuts;
|
||||
layout.ripCuts += Number(ripTop) + Number(ripBottom);
|
||||
layout.pieces.push({
|
||||
id,
|
||||
roomId: room.id,
|
||||
|
|
@ -388,6 +522,104 @@ function randomPatternPenalty(
|
|||
return penalty;
|
||||
}
|
||||
|
||||
/** Reorder interchangeable full-width rectangular rows without adding any cutting operations. */
|
||||
function improveRowOrder(layout: Layout, rooms: Room[], s: FlooringSettings): Layout {
|
||||
if (s.pattern !== 'random' || layout.patternPenalty === 0) return layout;
|
||||
const rows = [...layout.rows];
|
||||
const byId = new Map(layout.pieces.map((piece) => [piece.id, piece]));
|
||||
const moves = new Map<string, { row: number; shift: Point }>();
|
||||
let improvement = 0;
|
||||
for (const room of rooms) {
|
||||
const positions = rows.flatMap((row, index) => (row.roomId === room.id ? [index] : []));
|
||||
const original = positions.map((index) => rows[index]);
|
||||
const order = [...original];
|
||||
const shapes = new Map(
|
||||
original.map((row) => {
|
||||
const pieces = row.pieceIds.map((id) => byId.get(id)!);
|
||||
const b = bounds(
|
||||
pieces.flatMap((p) => p.polygon.map((point) => boardPoint(point, room.direction))),
|
||||
);
|
||||
const rectangular =
|
||||
pieces.every((p) => !p.shaped && !p.ripEdge && close(p.width, s.width)) &&
|
||||
close(
|
||||
pieces.reduce((sum, p) => sum + p.length, 0),
|
||||
b.maxX - b.minX,
|
||||
);
|
||||
return [row, { y: b.minY, key: rectangular ? `${round(b.minX)}:${round(b.maxX)}` : '' }];
|
||||
}),
|
||||
);
|
||||
const scoreAt = (index: number) =>
|
||||
randomPatternPenalty(
|
||||
order.slice(Math.max(0, index - 4), index),
|
||||
room.id,
|
||||
order[index].offset,
|
||||
s,
|
||||
);
|
||||
let attempts = 0;
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
let best: { i: number; j: number; delta: number } | undefined;
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const key = shapes.get(order[i])!.key;
|
||||
if (!key) continue;
|
||||
for (let j = i + 1; j < order.length; j++) {
|
||||
if (++attempts > 4096) break;
|
||||
if (shapes.get(order[j])!.key !== key || close(order[i].offset, order[j].offset))
|
||||
continue;
|
||||
const affected = [
|
||||
...new Set(
|
||||
[i, j].flatMap((index) =>
|
||||
Array.from({ length: Math.min(5, order.length - index) }, (_, n) => index + n),
|
||||
),
|
||||
),
|
||||
];
|
||||
const before = affected.reduce((sum, index) => sum + scoreAt(index), 0);
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
const valid = [i, i + 1, j, j + 1].every(
|
||||
(index) =>
|
||||
index === 0 ||
|
||||
index >= order.length ||
|
||||
staggerAllowed(order[index - 1].offset, order[index].offset, s),
|
||||
);
|
||||
const delta = valid
|
||||
? affected.reduce((sum, index) => sum + scoreAt(index), 0) - before
|
||||
: 0;
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
if (delta < (best?.delta ?? 0)) best = { i, j, delta };
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
[order[best.i], order[best.j]] = [order[best.j], order[best.i]];
|
||||
improvement -= best.delta;
|
||||
}
|
||||
order.forEach((source, index) => {
|
||||
const target = original[index];
|
||||
if (source === target) return;
|
||||
rows[positions[index]] = { ...source, index: target.index };
|
||||
const shift = worldPoint(
|
||||
{ x: 0, y: shapes.get(target)!.y - shapes.get(source)!.y },
|
||||
room.direction,
|
||||
);
|
||||
source.pieceIds.forEach((id) => moves.set(id, { row: target.index, shift }));
|
||||
});
|
||||
}
|
||||
if (!improvement) return layout;
|
||||
return {
|
||||
...layout,
|
||||
rows,
|
||||
patternPenalty: layout.patternPenalty - improvement,
|
||||
pieces: layout.pieces.map((piece) => {
|
||||
const move = moves.get(piece.id);
|
||||
return move
|
||||
? {
|
||||
...piece,
|
||||
row: move.row,
|
||||
polygon: piece.polygon.map((p) => ({ x: p.x + move.shift.x, y: p.y + move.shift.y })),
|
||||
}
|
||||
: piece;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
|
||||
function fraction(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
|
|
@ -452,11 +684,16 @@ function candidatePhases(
|
|||
return candidates.slice(0, 28);
|
||||
}
|
||||
|
||||
function compareLayouts(a: Layout, b: Layout): number {
|
||||
function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
|
||||
return (
|
||||
a.stocks.length - b.stocks.length ||
|
||||
a.patternPenalty - b.patternPenalty ||
|
||||
(s.pattern === 'random' && s.optimisation === 'random'
|
||||
? a.patternPenalty - b.patternPenalty
|
||||
: 0) ||
|
||||
a.cuts - b.cuts ||
|
||||
a.shapingCuts - b.shapingCuts ||
|
||||
a.ripCuts - b.ripCuts ||
|
||||
a.patternPenalty - b.patternPenalty ||
|
||||
a.stocks.length - b.stocks.length ||
|
||||
b.reusedCount - a.reusedCount ||
|
||||
compareText(
|
||||
a.rows.map((r) => r.offset.toFixed(3)).join(','),
|
||||
|
|
@ -495,15 +732,23 @@ function validateSettings(s: FlooringSettings): string | null {
|
|||
* Only full-width end remnants are reused; ripped strips are conservatively waste.
|
||||
* This is a practical cutting plan, not a claim of a global packing optimum.
|
||||
*/
|
||||
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||
function solveFlooring(
|
||||
rooms: Room[],
|
||||
s: FlooringSettings,
|
||||
tolerance: number,
|
||||
shapes: ShapeCache,
|
||||
): Solution {
|
||||
let layout: Layout = {
|
||||
pieces: [],
|
||||
rows: [],
|
||||
stocks: [],
|
||||
cuts: 0,
|
||||
shapingCuts: 0,
|
||||
ripCuts: 0,
|
||||
reusedCount: 0,
|
||||
patternPenalty: 0,
|
||||
};
|
||||
let finalists = [layout];
|
||||
const issues: Issue[] = [];
|
||||
const invalid = validateSettings(s);
|
||||
if (invalid) issues.push({ severity: 'error', message: invalid });
|
||||
|
|
@ -553,14 +798,22 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
let y = roomBounds.minY;
|
||||
for (let row = 0; row < widths.length && beam.length; row++) {
|
||||
const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]);
|
||||
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
|
||||
if (!rowPaths.length) {
|
||||
y = end;
|
||||
continue;
|
||||
const key = `${room.id}:${widths[0]}:${row}`;
|
||||
let cached = shapes.get(key);
|
||||
if (!cached) {
|
||||
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
|
||||
if (!rowPaths.length) {
|
||||
y = end;
|
||||
continue;
|
||||
}
|
||||
cached = {
|
||||
shape: { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) },
|
||||
cuts: new Map(),
|
||||
};
|
||||
shapes.set(key, cached);
|
||||
}
|
||||
const shape: RowShape = { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) };
|
||||
const { shape, cuts: cache } = cached;
|
||||
const alternatives: Layout[] = [];
|
||||
const cache = new Map<number, CutPiece[] | null>();
|
||||
for (const base of beam) {
|
||||
const roomRows = base.rows.filter((r) => r.roomId === room.id);
|
||||
const previousPhase = roomRows.at(-1)?.offset;
|
||||
|
|
@ -586,26 +839,32 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
|
||||
}
|
||||
}
|
||||
alternatives.sort(compareLayouts);
|
||||
// Keep distinct phases/inventories; identical routes do not earn extra beam slots.
|
||||
beam = alternatives
|
||||
.filter(
|
||||
(candidate, index) =>
|
||||
index === 0 ||
|
||||
candidate.rows.at(-1)?.offset !== alternatives[index - 1].rows.at(-1)?.offset ||
|
||||
candidate.stocks
|
||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||
.join(',') !==
|
||||
alternatives[index - 1].stocks
|
||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||
.join(','),
|
||||
)
|
||||
.slice(0, beamWidth);
|
||||
alternatives.sort((a, b) => compareLayouts(a, b, s));
|
||||
// Histories with different recent joints can have different future pattern costs.
|
||||
const seen = new Set<string>();
|
||||
beam = [];
|
||||
for (const candidate of alternatives) {
|
||||
const phases = candidate.rows
|
||||
.filter((r) => r.roomId === room.id)
|
||||
.slice(s.pattern === 'random' ? -4 : -1)
|
||||
.map((r) => r.offset)
|
||||
.join(',');
|
||||
const inventory = candidate.stocks
|
||||
.filter((stock) => stock.remaining >= s.minLength - EPSILON)
|
||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
const key = `${phases}|${inventory}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
beam.push(candidate);
|
||||
if (beam.length === beamWidth) break;
|
||||
}
|
||||
y = end;
|
||||
}
|
||||
if (beam.length) complete.push(...beam);
|
||||
}
|
||||
complete.sort(compareLayouts);
|
||||
complete.sort((a, b) => compareLayouts(a, b, s));
|
||||
if (!complete.length) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
|
|
@ -615,6 +874,13 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
continue;
|
||||
}
|
||||
layout = complete[0];
|
||||
finalists = complete;
|
||||
}
|
||||
for (const candidate of finalists) {
|
||||
const improved = improveRowOrder(repack(candidate, s), rooms, s);
|
||||
if (compareLayouts(improved, layout, s) < 0) layout = improved;
|
||||
}
|
||||
for (const room of rooms) {
|
||||
if (layout.pieces.some((p) => p.roomId === room.id && p.shaped))
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
|
|
@ -622,6 +888,10 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
|
||||
});
|
||||
}
|
||||
return { layout, issues };
|
||||
}
|
||||
|
||||
function finishFlooring({ layout, issues }: Solution, s: FlooringSettings): FlooringPlan {
|
||||
const coveredArea = layout.pieces.reduce((sum, piece) => sum + area(piece.polygon), 0);
|
||||
const purchasedArea = layout.stocks.length ? layout.stocks.length * s.length * s.width : 0;
|
||||
const wasteArea = Math.max(0, purchasedArea - coveredArea);
|
||||
|
|
|
|||
|
|
@ -350,7 +350,12 @@ interface Packed {
|
|||
}
|
||||
|
||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
||||
function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed {
|
||||
function pack(
|
||||
source: SkirtingPiece[],
|
||||
stockLength: number,
|
||||
kerf: number,
|
||||
exactTails = false,
|
||||
): Packed {
|
||||
const pieces = source.map((piece) => ({ ...piece }));
|
||||
let pending = [...pieces].sort(
|
||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
|
|
@ -360,6 +365,25 @@ function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packe
|
|||
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);
|
||||
|
|
@ -525,6 +549,7 @@ export function planSkirting(
|
|||
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.
|
||||
{
|
||||
|
|
@ -540,8 +565,8 @@ export function planSkirting(
|
|||
}
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface FlooringSettings {
|
|||
width: number;
|
||||
gap: number;
|
||||
pattern: 'brick' | 'random';
|
||||
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
|
||||
optimisation?: 'cuts' | 'random';
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
minLength: number;
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@ function flooring(value: unknown): FlooringSettings {
|
|||
width: number(data.width, 'Board width'),
|
||||
gap: number(data.gap, 'Expansion gap'),
|
||||
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
|
||||
optimisation: choice(
|
||||
data.optimisation === undefined ? 'cuts' : data.optimisation,
|
||||
['cuts', 'random'] as const,
|
||||
'Flooring optimisation',
|
||||
),
|
||||
minOffset: number(data.minOffset, 'Minimum offset'),
|
||||
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
||||
minLength: number(data.minLength, 'Minimum board length'),
|
||||
|
|
@ -198,6 +203,8 @@ function parseState(value: unknown): UrlState {
|
|||
const roomId = string(view.roomId, 'Selected room');
|
||||
const skirt = skirting(project.skirting);
|
||||
const completed = completedBoards(project.completedBoards);
|
||||
const previousOptimiser =
|
||||
object(project.flooring, 'Flooring settings').optimisation === undefined;
|
||||
return {
|
||||
project: {
|
||||
version: 1,
|
||||
|
|
@ -206,7 +213,7 @@ function parseState(value: unknown): UrlState {
|
|||
flooring: flooring(project.flooring),
|
||||
skirting: skirt.settings,
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: skirt.legacyCalculations ? [] : completed,
|
||||
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
|
|
@ -362,9 +369,11 @@ function parseQuery(query: string): UrlState {
|
|||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
key === 'optimisation' && !params.has('flooring.optimisation')
|
||||
? undefined
|
||||
: typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
]),
|
||||
),
|
||||
skirting: {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,33 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
import type { UrlState } from './types';
|
||||
import { decodeState, encodeState } from './url-state';
|
||||
|
||||
const readLocation = () => decodeState(window.location.search || window.location.hash);
|
||||
export const PROJECT_STORAGE_KEY = 'floor-planner.project';
|
||||
|
||||
const readLocation = () => {
|
||||
// The landing-page CTA opens the last project; it is not a project override.
|
||||
const link =
|
||||
window.location.search === '?planner' ? '' : window.location.search || window.location.hash;
|
||||
if (link && link !== '?' && link !== '#') return decodeState(link);
|
||||
try {
|
||||
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
|
||||
if (!saved.error) return saved;
|
||||
} catch {
|
||||
// Browser privacy settings can make localStorage unavailable.
|
||||
}
|
||||
return decodeState('');
|
||||
};
|
||||
|
||||
const saveLocal = (query: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(PROJECT_STORAGE_KEY, query);
|
||||
} catch {
|
||||
// Keep URL persistence working if storage is blocked or full.
|
||||
}
|
||||
};
|
||||
|
||||
const queryUrl = (query: string) => `${window.location.pathname}${query}`;
|
||||
|
||||
/** Readable query parameters are the durable source of truth. */
|
||||
/** Persist every parameter in the URL and locally; explicit links take precedence. */
|
||||
export function useProject() {
|
||||
const [loaded, setLoaded] = useState(readLocation);
|
||||
useEffect(() => {
|
||||
|
|
@ -18,6 +41,7 @@ export function useProject() {
|
|||
const query = encodeState(next.state);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history.replaceState(null, '', queryUrl(query));
|
||||
saveLocal(query);
|
||||
} catch (error) {
|
||||
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
|
||||
}
|
||||
|
|
@ -39,6 +63,7 @@ export function useProject() {
|
|||
const query = encodeState(next);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||
saveLocal(query);
|
||||
const canonical = decodeState(query).state;
|
||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||
else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue