..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
33 changed files with 2649 additions and 809 deletions
73
src/lib/board-tracking.ts
Normal file
73
src/lib/board-tracking.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||
import { flooringCuts, skirtingCuts } from './cut-list';
|
||||
|
||||
export interface BoardMatches {
|
||||
pieceIds: string[];
|
||||
roomIds: string[];
|
||||
boardKeys: string[];
|
||||
}
|
||||
|
||||
/** Codes match exactly; ordinary text searches room names. Qualified keys identify source boards. */
|
||||
export function findBoardMatches(
|
||||
project: Project,
|
||||
flooring: FlooringPlan,
|
||||
skirting: SkirtingPlan,
|
||||
query: string,
|
||||
): BoardMatches {
|
||||
const text = query.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
if (!text) return { pieceIds: [], roomIds: [], boardKeys: [] };
|
||||
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/.exec(text);
|
||||
const codes = text.split(/\s*[+,]\s*/);
|
||||
const isCode = codes.every((code) => /^[a-z]+\d+$/.test(code));
|
||||
const matchingRooms = new Set(
|
||||
project.rooms
|
||||
.filter((room) => room.name.toLowerCase().replace(/\s+/g, ' ').includes(text))
|
||||
.map((room) => room.id),
|
||||
);
|
||||
const pieceIds: string[] = [];
|
||||
const roomIds = new Set<string>();
|
||||
const boardKeys = new Set<string>();
|
||||
const materials = [
|
||||
{ key: 'floor', pieces: flooring.pieces },
|
||||
{ key: 'skirting', pieces: skirting.pieces },
|
||||
];
|
||||
for (const material of materials) {
|
||||
for (const piece of material.pieces) {
|
||||
const matches = qualified
|
||||
? material.key === qualified[1] && piece.stockId.toLowerCase() === qualified[2]
|
||||
: isCode
|
||||
? codes.includes(piece.id.toLowerCase()) ||
|
||||
(codes.length === 1 && codes.includes(piece.stockId.toLowerCase()))
|
||||
: matchingRooms.has(piece.roomId);
|
||||
if (!matches) continue;
|
||||
pieceIds.push(piece.id);
|
||||
roomIds.add(piece.roomId);
|
||||
boardKeys.add(`${material.key}:${piece.stockId}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
pieceIds,
|
||||
roomIds: project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id),
|
||||
boardKeys: [...boardKeys],
|
||||
};
|
||||
}
|
||||
|
||||
/** Source IDs are bookkeeping; show the cut codes when locating a whole source board. */
|
||||
export function boardSearchLabel(
|
||||
project: Project,
|
||||
flooring: FlooringPlan,
|
||||
skirting: SkirtingPlan,
|
||||
query: string,
|
||||
): string {
|
||||
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/i.exec(query.trim());
|
||||
if (!qualified) return query;
|
||||
const matches = new Set(findBoardMatches(project, flooring, skirting, query).pieceIds);
|
||||
const codes = [...flooringCuts(flooring), ...skirtingCuts(skirting)]
|
||||
.filter((piece) => matches.has(piece.id))
|
||||
.map((piece) => piece.id);
|
||||
return codes.length
|
||||
? codes.join(' + ')
|
||||
: qualified[1].toLowerCase() === 'floor'
|
||||
? 'Uncut floorboard'
|
||||
: 'Uncut skirting board';
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
||||
import { cutMm, degree } from './format';
|
||||
|
||||
export interface PreviewBox {
|
||||
x: number;
|
||||
|
|
@ -6,8 +7,6 @@ export interface PreviewBox {
|
|||
width: number;
|
||||
height: number;
|
||||
}
|
||||
export const exactMm = (value: number) =>
|
||||
new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
|
||||
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
|
||||
|
||||
export interface EdgeDimension {
|
||||
|
|
@ -73,12 +72,12 @@ export function dimensionedOutline(points: Point[], edges?: number[]) {
|
|||
};
|
||||
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 text = exactMm(edge.length);
|
||||
const text = cutMm(edge.length);
|
||||
const angle =
|
||||
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
||||
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
||||
: undefined;
|
||||
const angleText = angle === undefined ? '' : `${exactMm(angle)}°`;
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function defaultProject(): Project {
|
|||
name: 'My flooring project',
|
||||
rooms: [rectangle()],
|
||||
tolerance: 10,
|
||||
completedBoards: [],
|
||||
flooring: {
|
||||
length: 1200,
|
||||
width: 190,
|
||||
|
|
@ -54,7 +55,7 @@ export function defaultProject(): Project {
|
|||
export function defaultState(): UrlState {
|
||||
return {
|
||||
project: defaultProject(),
|
||||
view: { roomId: 'room-1', selectedPiece: null },
|
||||
view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
|
||||
};
|
||||
}
|
||||
export function nextId(prefix: string, existing: string[]): string {
|
||||
|
|
|
|||
|
|
@ -56,3 +56,19 @@ export function dimensionLabels(
|
|||
return label;
|
||||
});
|
||||
}
|
||||
|
||||
/** The drawing must include extension-line endpoints as well as the text itself. */
|
||||
export function wallDimensionBox(edge: WallEdge, label: DimensionLabel, span: number): LabelBox {
|
||||
const ends = [edge.start, edge.end].flatMap((point) =>
|
||||
[label.offset - span * 0.012, label.offset + span * 0.012].map((offset) => ({
|
||||
x: point.x + label.normal.x * offset,
|
||||
y: point.y + label.normal.y * offset,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
minX: Math.min(label.box.minX, ...ends.map((point) => point.x)),
|
||||
maxX: Math.max(label.box.maxX, ...ends.map((point) => point.x)),
|
||||
minY: Math.min(label.box.minY, ...ends.map((point) => point.y)),
|
||||
maxY: Math.max(label.box.maxY, ...ends.map((point) => point.y)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||
import { flooringInstructions } from './cut-instructions';
|
||||
import {
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
readyOffcutInstructions,
|
||||
} from './cut-list';
|
||||
|
||||
const cell = (value: string | number) => {
|
||||
const str = String(value);
|
||||
return `"${(/^[=+@\-]/.test(str) ? "'" : '') + str.replaceAll('"', '""')}"`;
|
||||
};
|
||||
|
||||
export function cutListCsv(
|
||||
project: Project,
|
||||
flooring: FlooringPlan,
|
||||
skirting: SkirtingPlan,
|
||||
): string {
|
||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||
const floorCuts = groupFlooringCuts(flooring).flat(),
|
||||
skirtCuts = groupSkirtingCuts(skirting).flat();
|
||||
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
||||
const rows: (string | number)[][] = [
|
||||
[
|
||||
'Material',
|
||||
'Room',
|
||||
'Piece',
|
||||
'Stock',
|
||||
'Length mm',
|
||||
'Width mm',
|
||||
'Left mitre deg',
|
||||
'Right mitre deg',
|
||||
'Quantity',
|
||||
'Notes',
|
||||
'Template x:y mm',
|
||||
],
|
||||
[
|
||||
'Flooring total',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
project.flooring.length,
|
||||
project.flooring.width,
|
||||
'',
|
||||
'',
|
||||
flooring.boardCount,
|
||||
`Boards to buy; ${flooring.pieces.length} installed pieces; ${flooring.cuts} saw cuts`,
|
||||
'',
|
||||
],
|
||||
[
|
||||
'Flooring uncut boards',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
project.flooring.length,
|
||||
project.flooring.width,
|
||||
'',
|
||||
'',
|
||||
wholeFloor.wholeBoards,
|
||||
'Full boards laid without cuts',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'Skirting total',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
project.skirting.thickness,
|
||||
'',
|
||||
'',
|
||||
skirting.stockCount,
|
||||
`Lengths to buy; ${skirting.pieces.length} installed pieces; ${skirting.cuts} saw cuts`,
|
||||
'',
|
||||
],
|
||||
];
|
||||
if (wholeFloor.readyOffcuts)
|
||||
rows.push([
|
||||
'Flooring ready offcuts',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
wholeFloor.readyOffcuts,
|
||||
'Already sized; no further cuts',
|
||||
'',
|
||||
]);
|
||||
for (const group of uncutSkirting(skirting)) {
|
||||
rows.push([
|
||||
'Skirting uncut lengths',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
group.length,
|
||||
project.skirting.thickness,
|
||||
'',
|
||||
'',
|
||||
group.count,
|
||||
'Full stock lengths installed without cuts',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
for (const piece of floorCuts) {
|
||||
rows.push([
|
||||
'Flooring',
|
||||
roomName(piece.roomId),
|
||||
piece.id,
|
||||
piece.stockId,
|
||||
piece.length,
|
||||
piece.width,
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
`${piece.reused ? 'Reused offcut; ' : ''}${piece.cut ? `${piece.shaped ? 'Shaped cut' : 'Straight cut'}; ${flooringInstructions(piece)}` : readyOffcutInstructions(piece)}`,
|
||||
piece.template.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | '),
|
||||
]);
|
||||
}
|
||||
for (const piece of skirtCuts) {
|
||||
rows.push([
|
||||
'Skirting',
|
||||
roomName(piece.roomId),
|
||||
piece.id,
|
||||
piece.stockId,
|
||||
piece.length,
|
||||
project.skirting.thickness,
|
||||
piece.leftMitre,
|
||||
piece.rightMitre,
|
||||
1,
|
||||
`Back length ${piece.backLength.toFixed(1)} mm; left ${piece.leftType}; right ${piece.rightType}; signed front-to-back bevels ${piece.leftBevel ?? piece.leftMitre} / ${piece.rightBevel ?? piece.rightMitre} deg; template back y=0, front y=thickness`,
|
||||
piece.template?.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | ') ??
|
||||
'',
|
||||
]);
|
||||
}
|
||||
return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
|
||||
}
|
||||
|
||||
export function downloadCsv(project: Project, flooring: FlooringPlan, skirting: SkirtingPlan) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([cutListCsv(project, flooring, skirting)], { type: 'text/csv;charset=utf-8;' }),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'flooring-cut-plan.csv';
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
|
|||
}
|
||||
|
||||
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
|
||||
function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
||||
function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: number[] = []): number[][] {
|
||||
const count = Math.ceil((height - EPSILON) / s.width);
|
||||
if (count <= 0 || count > 500) return [];
|
||||
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||
|
|
@ -139,9 +139,23 @@ function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
|||
add(last);
|
||||
add(edgeTotal / 2);
|
||||
}
|
||||
// Place a long-edge seam on an inward corner when both perimeter rows still fit.
|
||||
// This avoids thin toes made solely by a row crossing just beyond that corner.
|
||||
for (const offset of cornerOffsets.slice(0, 8)) {
|
||||
const first = phaseOf(offset, s.width);
|
||||
add(close(first, 0) ? s.width : first);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function inwardCorners(paths: Point[][]): Point[] {
|
||||
return paths.flatMap(path => path.filter((point, index) => {
|
||||
const previous = path[(index + path.length - 1) % path.length];
|
||||
const next = path[(index + 1) % path.length];
|
||||
return (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) < -EPSILON;
|
||||
}));
|
||||
}
|
||||
|
||||
function hasVerticalEdge(path: Point[], x: number): boolean {
|
||||
return path.some(
|
||||
(p, i) =>
|
||||
|
|
@ -497,7 +511,8 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
continue;
|
||||
}
|
||||
const roomBounds = bounds(paths.flat());
|
||||
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s);
|
||||
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
|
||||
inwardCorners(paths).map(point => point.y - roomBounds.minY));
|
||||
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
|
||||
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
|
||||
issues.push({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ export const mm = (value: number) =>
|
|||
Number.isFinite(value)
|
||||
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
||||
: '—';
|
||||
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
|
||||
export const cutMm = (value: number) => mm(cutLength(value));
|
||||
export const area = (value: number) =>
|
||||
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||
value / 1e6,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||
import { isSkirtingCut } from './cut-list';
|
||||
import { cutLength } from './format';
|
||||
import type {
|
||||
Issue,
|
||||
Point,
|
||||
|
|
@ -86,14 +87,19 @@ function endCuts(piece: SkirtingPiece) {
|
|||
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||
}
|
||||
|
||||
function blankLength(piece: SkirtingPiece, available: number) {
|
||||
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
|
||||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, 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.
|
||||
return Math.min(available, piece.length + (Math.abs(available - piece.length) <= EPS ? 0 : kerf));
|
||||
const length = blankLength(piece, available);
|
||||
return Math.min(available, length + (Math.abs(available - length) <= EPS ? 0 : kerf));
|
||||
}
|
||||
|
||||
function makePiece(
|
||||
|
|
@ -141,7 +147,7 @@ function shortestEdge(piece: SkirtingPiece) {
|
|||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
// The final saw pass may extend beyond the stock's tail.
|
||||
return piece.length <= available + EPS;
|
||||
return blankLength(piece, available) <= available + EPS;
|
||||
}
|
||||
|
||||
/** Zero means that no number of pieces can satisfy the end allowances. */
|
||||
|
|
|
|||
|
|
@ -44,10 +44,15 @@ export interface Project {
|
|||
flooring: FlooringSettings;
|
||||
skirting: SkirtingSettings;
|
||||
tolerance: number;
|
||||
/** Explicit progress marks for purchased boards, e.g. floor:B4 or skirting:S6. */
|
||||
completedBoards: string[];
|
||||
}
|
||||
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
|
||||
export interface ViewState {
|
||||
roomId: string;
|
||||
selectedPiece: string | null;
|
||||
search: string;
|
||||
collapsedSections: PlannerSection[];
|
||||
}
|
||||
export interface UrlState {
|
||||
project: Project;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import LZString from 'lz-string';
|
||||
import { defaultState } from './defaults';
|
||||
import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } from './types';
|
||||
import type {
|
||||
Door,
|
||||
FlooringSettings,
|
||||
PlannerSection,
|
||||
Room,
|
||||
SkirtingSettings,
|
||||
UrlState,
|
||||
Wall,
|
||||
} from './types';
|
||||
|
||||
/** Resource limits apply to links as well as edits made inside the app. */
|
||||
export const URL_LIMITS = {
|
||||
|
|
@ -13,8 +21,12 @@ export const URL_LIMITS = {
|
|||
stockLengths: 30,
|
||||
identifierLength: 120,
|
||||
nameLength: 200,
|
||||
completedBoards: 12_000,
|
||||
searchLength: 200,
|
||||
} as const;
|
||||
|
||||
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
|
||||
|
||||
function object(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an object.`);
|
||||
|
|
@ -71,6 +83,34 @@ function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
|
|||
return items;
|
||||
}
|
||||
|
||||
function uniqueValues<T extends string>(items: T[], field: string): T[] {
|
||||
if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`);
|
||||
return items.sort();
|
||||
}
|
||||
|
||||
function completedBoards(value: unknown): string[] {
|
||||
const entries = array(
|
||||
value === undefined ? [] : value,
|
||||
'Completed boards',
|
||||
URL_LIMITS.completedBoards,
|
||||
).map((value) => {
|
||||
const key = string(value, 'Completed board');
|
||||
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
|
||||
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
|
||||
return key;
|
||||
});
|
||||
return uniqueValues(entries, 'Completed boards');
|
||||
}
|
||||
|
||||
function collapsedSections(value: unknown): PlannerSection[] {
|
||||
return uniqueValues(
|
||||
array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||
choice(value, SECTIONS, 'Collapsed section'),
|
||||
),
|
||||
'Collapsed sections',
|
||||
);
|
||||
}
|
||||
|
||||
function door(value: unknown): Door {
|
||||
const data = object(value, 'Door');
|
||||
return {
|
||||
|
|
@ -151,11 +191,18 @@ function parseState(value: unknown): UrlState {
|
|||
flooring: flooring(project.flooring),
|
||||
skirting: skirting(project.skirting),
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: completedBoards(project.completedBoards),
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
selectedPiece:
|
||||
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
||||
search: string(
|
||||
view.search === undefined ? '' : view.search,
|
||||
'Cut search',
|
||||
URL_LIMITS.searchLength,
|
||||
),
|
||||
collapsedSections: collapsedSections(view.collapsedSections),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -193,9 +240,12 @@ export function encodeState(state: UrlState): string {
|
|||
});
|
||||
});
|
||||
});
|
||||
for (const board of project.completedBoards) put('done', board);
|
||||
put('selectedRoom', view.roomId);
|
||||
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
|
||||
const query = `?${params}`;
|
||||
if (view.search) put('search', view.search);
|
||||
for (const section of view.collapsedSections) put('collapsed', section);
|
||||
const query = `?${params}`.replaceAll('%3A', ':');
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||
return query;
|
||||
|
|
@ -289,6 +339,7 @@ function parseQuery(query: string): UrlState {
|
|||
version: numeric('version', 1),
|
||||
name: text('name', defaults.project.name),
|
||||
tolerance: numeric('tolerance', defaults.project.tolerance),
|
||||
completedBoards: params.getAll('done'),
|
||||
rooms,
|
||||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
|
|
@ -308,6 +359,8 @@ function parseQuery(query: string): UrlState {
|
|||
view: {
|
||||
roomId: text('selectedRoom', rooms[0].id),
|
||||
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
||||
search: text('search', ''),
|
||||
collapsedSections: params.getAll('collapsed'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ export function useProject() {
|
|||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||
const canonical = decodeState(query).state;
|
||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||
else {
|
||||
// Progress changes should not rerun geometry and stock allocation.
|
||||
if (next.project.rooms === current.state.project.rooms)
|
||||
canonical.project.rooms = current.state.project.rooms;
|
||||
if (next.project.flooring === current.state.project.flooring)
|
||||
canonical.project.flooring = current.state.project.flooring;
|
||||
if (next.project.skirting === current.state.project.skirting)
|
||||
canonical.project.skirting = current.state.project.skirting;
|
||||
}
|
||||
return { state: canonical, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
33
src/lib/wall-editing.ts
Normal file
33
src/lib/wall-editing.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { nextId } from './defaults';
|
||||
import type { Room } from './types';
|
||||
|
||||
/** Inserting a segment never renumbers walls or moves their attached doors. */
|
||||
export function insertWallAfter(room: Room, wallId: string): Room {
|
||||
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||
if (index < 0 || room.walls.length >= 100) return room;
|
||||
const wall = {
|
||||
id: nextId(
|
||||
`${room.id}-w`,
|
||||
room.walls.map((wall) => wall.id),
|
||||
),
|
||||
length: 1000,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
};
|
||||
return {
|
||||
...room,
|
||||
walls: [...room.walls.slice(0, index + 1), wall, ...room.walls.slice(index + 1)],
|
||||
};
|
||||
}
|
||||
|
||||
/** The destination is the wall's final zero-based position, after removing it from the list. */
|
||||
export function moveWall(room: Room, wallId: string, destination: number): Room {
|
||||
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||
if (index < 0 || !Number.isFinite(destination)) return room;
|
||||
const target = Math.max(0, Math.min(room.walls.length - 1, Math.trunc(destination)));
|
||||
if (target === index) return room;
|
||||
const walls = [...room.walls];
|
||||
const [wall] = walls.splice(index, 1);
|
||||
walls.splice(target, 0, wall);
|
||||
return { ...room, walls };
|
||||
}
|
||||
Loading…
Reference in a new issue