.
This commit is contained in:
parent
d8f2407ddf
commit
9689bfcc18
23 changed files with 1815 additions and 955 deletions
|
|
@ -12,6 +12,7 @@ import { OutlineEditor } from './components/OutlineEditor';
|
|||
import { MaterialsEditor } from './components/MaterialsEditor';
|
||||
import { FloorPlan } from './components/FloorPlan';
|
||||
import { CutList } from './components/CutList';
|
||||
import { PrintPlan } from './components/PrintPlan';
|
||||
import { Issues } from './components/Fields';
|
||||
|
||||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||||
|
|
@ -254,6 +255,12 @@ export default function App() {
|
|||
/>
|
||||
</div>
|
||||
</main>
|
||||
<PrintPlan
|
||||
project={project}
|
||||
geometries={geometries}
|
||||
flooring={flooring}
|
||||
skirting={skirting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { X } from 'lucide-react';
|
|||
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
|
||||
import { flooringInstructions } from '../lib/cut-instructions';
|
||||
import { readyOffcutInstructions } from '../lib/cut-list';
|
||||
import { exactMm, flooringTemplate, skirtingTemplate } from '../lib/cut-preview';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -33,11 +32,6 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
|
|||
const room =
|
||||
selected &&
|
||||
(project.rooms.find((candidate) => candidate.id === selected.roomId)?.name ?? selected.roomId);
|
||||
const template = floor
|
||||
? flooringTemplate(floor)
|
||||
: skirt
|
||||
? skirtingTemplate(skirt, project.skirting.thickness)
|
||||
: [];
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
|
|
@ -71,87 +65,24 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
|
|||
{floor && (
|
||||
<>
|
||||
<p className="cut-origin">
|
||||
{room} · Row {floor.row} · Source {floor.stockId}
|
||||
{floor.reused ? ' · Reused offcut' : ''}
|
||||
{room} · Row {floor.row}
|
||||
</p>
|
||||
<FlooringPreview piece={floor} />
|
||||
<p className="cut-instructions">
|
||||
{floor.cut ? flooringInstructions(floor) : readyOffcutInstructions(floor)}
|
||||
</p>
|
||||
<p className="helper">
|
||||
Left and right run along the board; top and bottom are its long edges.
|
||||
</p>
|
||||
{floor.shaped && (
|
||||
<>
|
||||
<p>
|
||||
Mark these coordinates from the blank’s top-left corner: x along the board, y
|
||||
across it.
|
||||
</p>
|
||||
<div className="coordinates">
|
||||
{template.map((point, index) => (
|
||||
<span key={index}>
|
||||
{index + 1}: ({exactMm(point.x)}, {exactMm(point.y)}) mm
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="cut-edge-lengths">
|
||||
{template
|
||||
.map((point, index) => {
|
||||
const next = template[(index + 1) % template.length];
|
||||
return `${index + 1}–${((index + 1) % template.length) + 1}: ${exactMm(Math.hypot(next.x - point.x, next.y - point.y))} mm`;
|
||||
})
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{skirt && (
|
||||
<>
|
||||
<p className="cut-origin">
|
||||
{room} · Wall {skirt.wallIndex + 1} · Source {skirt.stockId}
|
||||
{room} · Wall {skirt.wallIndex + 1}
|
||||
</p>
|
||||
<SkirtingPreview piece={skirt} thickness={project.skirting.thickness} />
|
||||
<dl className="cut-measurements">
|
||||
<div>
|
||||
<dt>Long point</dt>
|
||||
<dd>{exactMm(skirt.length)} mm</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Wall-facing length</dt>
|
||||
<dd>{exactMm(skirt.backLength)} mm</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Left mitre</dt>
|
||||
<dd>
|
||||
{exactMm(Math.abs(skirt.leftMitre))}° · {skirt.leftType}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Right mitre</dt>
|
||||
<dd>
|
||||
{exactMm(Math.abs(skirt.rightMitre))}° · {skirt.rightType}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{template.length === 4 && (
|
||||
<>
|
||||
<p>
|
||||
Cut the square blank first. Mark these distances from its left end, then join the
|
||||
front and back marks at each end:
|
||||
</p>
|
||||
<div className="coordinates">
|
||||
<span>Back left: {exactMm(template[0].x)} mm</span>
|
||||
<span>Back right: {exactMm(template[1].x)} mm</span>
|
||||
<span>Front left: {exactMm(template[3].x)} mm</span>
|
||||
<span>Front right: {exactMm(template[2].x)} mm</span>
|
||||
</div>
|
||||
<p className="helper">
|
||||
Left and right follow the wall direction. Mitre angles are measured from a square
|
||||
cut; the marked points show which way to lean each cut.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="cut-instructions">
|
||||
Measure the longest points. Mitre angles are measured from square; the end views show
|
||||
the cut direction.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { downloadCsv } from '../lib/export';
|
|||
import {
|
||||
flooringCuts,
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
skirtingCuts,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
|
|
@ -12,32 +13,6 @@ import {
|
|||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
import { CutDialog } from './CutDialog';
|
||||
|
||||
function StockAllocations({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: { id: string; pieces: string[]; size: string; remainder: string }[];
|
||||
}) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<details className="stock-allocations">
|
||||
<summary>{title}</summary>
|
||||
<ul className="allocation-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="stock-card">
|
||||
<strong>
|
||||
{item.id} <span>· {item.size}</span>
|
||||
</strong>
|
||||
<span>{item.pieces.join(' + ')}</span>
|
||||
<small>{item.remainder}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
flooring: FlooringPlan;
|
||||
|
|
@ -49,29 +24,23 @@ interface Props {
|
|||
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
|
||||
const floorCuts = flooringCuts(flooring),
|
||||
skirtCuts = skirtingCuts(skirting);
|
||||
const floorGroups = groupFlooringCuts(flooring);
|
||||
const floorIds = new Set(floorCuts.map((piece) => piece.id));
|
||||
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
|
||||
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
||||
const wholeSkirt = uncutSkirting(skirting);
|
||||
const selectedFloor = floorCuts.find((piece) => piece.id === selectedPiece);
|
||||
const selectedSkirt = skirtCuts.find((piece) => piece.id === selectedPiece);
|
||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||
const errors = [...flooring.issues, ...skirting.issues].some(
|
||||
(issue) => issue.severity === 'error',
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="cut-list-card" aria-labelledby="cut-plan-title">
|
||||
<div className="cut-list-header">
|
||||
<div>
|
||||
<h2 id="cut-plan-title">Cut plan</h2>
|
||||
<p className="helper">
|
||||
Cut pieces and offcut placements. All measurements are in millimetres.
|
||||
Each group comes from one board. Select a piece for its cutting diagram.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button secondary small"
|
||||
className="button"
|
||||
onClick={() => downloadCsv(project, flooring, skirting)}
|
||||
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
|
||||
>
|
||||
|
|
@ -79,186 +48,138 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
|
|||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
|
||||
<h3 id="flooring-cuts-title">
|
||||
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
|
||||
</h3>
|
||||
<p className="cut-summary">
|
||||
Buy {flooring.boardCount} boards · {flooring.pieces.length} installed pieces ·{' '}
|
||||
{wholeFloor.wholeBoards} full boards laid uncut
|
||||
Buy {flooring.boardCount} boards · {wholeFloor.wholeBoards} laid uncut
|
||||
{wholeFloor.readyOffcuts > 0 &&
|
||||
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
|
||||
.
|
||||
</p>
|
||||
{floorCuts.length ? (
|
||||
<div className="table-scroll">
|
||||
<table className="cut-table">
|
||||
<caption className="sr-only">Flooring cuts and offcut placements</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Pieces</th>
|
||||
<th scope="col">Qty</th>
|
||||
<th scope="col">Cut size · mm</th>
|
||||
<th scope="col">Room</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{floorGroups.map((group) => {
|
||||
const first = group[0];
|
||||
return (
|
||||
<tr
|
||||
key={first.id}
|
||||
className={`flooring-batch ${group.some((piece) => piece.id === selectedPiece) ? 'selected' : ''}`}
|
||||
>
|
||||
<td data-label="Pieces" className="batch-codes">
|
||||
<div className="piece-chips">
|
||||
{group.map((piece) => (
|
||||
<button
|
||||
key={piece.id}
|
||||
className={`piece-link piece-chip cut-preview-button flooring-preview-button ${piece.id === selectedPiece ? 'selected' : ''}`}
|
||||
aria-label={`Preview flooring cut ${piece.id}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => select(piece.id, piece.roomId)}
|
||||
>
|
||||
<span className="cut-thumbnail" aria-hidden="true">
|
||||
<FlooringPreview piece={piece} compact />
|
||||
</span>
|
||||
<span className="cut-preview-code">{piece.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Qty" className="batch-qty numeric">
|
||||
{group.length}
|
||||
</td>
|
||||
<td data-label="Cut size · mm" className="batch-size numeric">
|
||||
{mm(first.length)} × {mm(first.width)}
|
||||
</td>
|
||||
<td data-label="Room" className="batch-room">
|
||||
{roomName(first.roomId)}
|
||||
</td>
|
||||
<td data-label="Action" className="batch-action">
|
||||
{!first.cut
|
||||
? 'Use offcut · no new cut'
|
||||
: first.shaped
|
||||
? 'Shaped cut'
|
||||
: 'Rectangular cut'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
{groupFlooringCuts(flooring).map((group) => (
|
||||
<section
|
||||
className="stock-cut-group"
|
||||
data-stock-id={group[0].stockId}
|
||||
key={group[0].stockId}
|
||||
aria-label={`Floorboard ${group[0].stockId}`}
|
||||
>
|
||||
<h4>
|
||||
Floorboard {group[0].stockId}
|
||||
<span>
|
||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
|
||||
</span>
|
||||
</h4>
|
||||
<ul className="board-cuts">
|
||||
{group.map((piece) => (
|
||||
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
|
||||
<button
|
||||
className="cut-preview-button flooring-preview-button"
|
||||
aria-label={`Preview flooring cut ${piece.id}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => select(piece.id, piece.roomId)}
|
||||
>
|
||||
<span className="cut-thumbnail" aria-hidden="true">
|
||||
<FlooringPreview piece={piece} compact />
|
||||
</span>
|
||||
<span className="cut-preview-code">
|
||||
{piece.id} <span aria-hidden="true">↗</span>
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
<p className="cut-size">
|
||||
{mm(piece.length)} × {mm(piece.width)} mm
|
||||
</p>
|
||||
<p className="cut-location">
|
||||
{roomName(piece.roomId)} · Row {piece.row}
|
||||
</p>
|
||||
{piece.reused && (
|
||||
<p className="cut-note">
|
||||
{piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{!floorCuts.length && (
|
||||
<p className="empty-state">
|
||||
{flooring.pieces.length
|
||||
? 'No flooring cuts needed.'
|
||||
: 'Complete the outline and flooring measurements to calculate cuts.'}
|
||||
</p>
|
||||
)}
|
||||
<StockAllocations
|
||||
title="Source boards for flooring cuts"
|
||||
items={flooring.stocks
|
||||
.filter((stock) => stock.pieceIds.some((id) => floorIds.has(id)))
|
||||
.map((stock) => ({
|
||||
id: stock.id,
|
||||
pieces: stock.pieceIds.filter((id) => floorIds.has(id)),
|
||||
size: `${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`,
|
||||
remainder: `${mm(stock.remaining)} mm remaining`,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
|
||||
<h3 id="skirting-cuts-title">
|
||||
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
|
||||
</h3>
|
||||
<p className="cut-summary">
|
||||
Buy {skirting.stockCount} lengths · {skirting.pieces.length} installed pieces ·{' '}
|
||||
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} full lengths installed uncut.
|
||||
Buy {skirting.stockCount} lengths ·{' '}
|
||||
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
|
||||
</p>
|
||||
<p className="helper">
|
||||
Lengths use the longest point. Mitres are saw settings from square; left and right follow
|
||||
the wall direction.
|
||||
</p>
|
||||
{skirtCuts.length ? (
|
||||
<div className="table-scroll">
|
||||
<table className="cut-table">
|
||||
<caption className="sr-only">Skirting pieces requiring cuts</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Piece</th>
|
||||
<th scope="col">Room / wall</th>
|
||||
<th scope="col">Length · mm</th>
|
||||
<th scope="col">Left / right</th>
|
||||
<th scope="col">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{skirtCuts.map((piece) => (
|
||||
<tr key={piece.id} className={selectedPiece === piece.id ? 'selected' : ''}>
|
||||
<td data-label="Piece">
|
||||
<button
|
||||
className="piece-link cut-preview-button skirting-preview-button"
|
||||
aria-label={`Preview skirting cut ${piece.id}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => select(piece.id, piece.roomId)}
|
||||
>
|
||||
<span className="cut-thumbnail" aria-hidden="true">
|
||||
<SkirtingPreview
|
||||
piece={piece}
|
||||
thickness={project.skirting.thickness}
|
||||
compact
|
||||
/>
|
||||
</span>
|
||||
<span className="cut-preview-code">{piece.id}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td data-label="Room / wall">
|
||||
{roomName(piece.roomId)}
|
||||
<small>Wall {piece.wallIndex + 1}</small>
|
||||
</td>
|
||||
<td data-label="Length · mm" className="numeric">
|
||||
{mm(piece.length)}
|
||||
<small>{mm(piece.backLength)} at wall</small>
|
||||
</td>
|
||||
<td data-label="Left / right">
|
||||
{degree(piece.leftMitre)} / {degree(piece.rightMitre)}
|
||||
<small>
|
||||
{piece.leftType} / {piece.rightType}
|
||||
</small>
|
||||
</td>
|
||||
<td data-label="Source">{piece.stockId}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
{groupSkirtingCuts(skirting).map((group) => (
|
||||
<section
|
||||
className="stock-cut-group"
|
||||
data-stock-id={group[0].stockId}
|
||||
key={group[0].stockId}
|
||||
aria-label={`Skirting board ${group[0].stockId}`}
|
||||
>
|
||||
<h4>
|
||||
Skirting board {group[0].stockId}
|
||||
<span>
|
||||
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
|
||||
</span>
|
||||
</h4>
|
||||
<ul className="board-cuts">
|
||||
{group.map((piece) => (
|
||||
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
|
||||
<button
|
||||
className="cut-preview-button skirting-preview-button"
|
||||
aria-label={`Preview skirting cut ${piece.id}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => select(piece.id, piece.roomId)}
|
||||
>
|
||||
<span className="cut-thumbnail" aria-hidden="true">
|
||||
<SkirtingPreview
|
||||
piece={piece}
|
||||
thickness={project.skirting.thickness}
|
||||
compact
|
||||
/>
|
||||
</span>
|
||||
<span className="cut-preview-code">
|
||||
{piece.id} <span aria-hidden="true">↗</span>
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
<p className="cut-size">
|
||||
{mm(piece.length)} mm <span className="cut-note">long point</span>
|
||||
</p>
|
||||
<p className="cut-location">
|
||||
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
|
||||
</p>
|
||||
<p className="cut-note">
|
||||
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{!skirtCuts.length && (
|
||||
<p className="empty-state">
|
||||
{skirting.pieces.length
|
||||
? 'No skirting cuts needed.'
|
||||
: 'Complete the outline and skirting measurements to calculate cuts.'}
|
||||
</p>
|
||||
)}
|
||||
<StockAllocations
|
||||
title="Source lengths for skirting cuts"
|
||||
items={skirting.stocks
|
||||
.filter((stock) => stock.pieceIds.some((id) => skirtIds.has(id)))
|
||||
.map((stock) => ({
|
||||
id: stock.id,
|
||||
pieces: stock.pieceIds.filter((id) => skirtIds.has(id)),
|
||||
size: `${mm(stock.length)} mm`,
|
||||
remainder: `${mm(stock.waste)} mm waste including kerf`,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
<CutDialog
|
||||
floor={selectedFloor}
|
||||
skirt={selectedSkirt}
|
||||
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
|
||||
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
|
||||
project={project}
|
||||
close={() => select(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,107 @@ import { Fragment } from 'react';
|
|||
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
|
||||
import {
|
||||
exactMm,
|
||||
dimensionedOutline,
|
||||
flooringTemplate,
|
||||
fullPreviewBox,
|
||||
skirtingEndBoxes,
|
||||
skirtingTemplate,
|
||||
viewBox,
|
||||
} from '../lib/cut-preview';
|
||||
import type { EdgeDimension } from '../lib/cut-preview';
|
||||
|
||||
function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) {
|
||||
return (
|
||||
<g className="cut-dimensions">
|
||||
{dimensions.map((dimension) => {
|
||||
const { start, end, lineStart, lineEnd, normal, label } = dimension;
|
||||
const center = { x: (lineStart.x + lineEnd.x) / 2, y: (lineStart.y + lineEnd.y) / 2 };
|
||||
const staggered =
|
||||
Math.abs((label.x - center.x) * -normal.y + (label.y - center.y) * normal.x) > font * 0.1;
|
||||
return (
|
||||
<g
|
||||
key={dimension.edge}
|
||||
className="dimension-lines"
|
||||
stroke="#637185"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
>
|
||||
{[
|
||||
[start, lineStart],
|
||||
[end, lineEnd],
|
||||
].map(([point, projected], i) => (
|
||||
<line
|
||||
key={i}
|
||||
className="dimension-extension"
|
||||
x1={point.x + normal.x * font * 0.12}
|
||||
y1={point.y + normal.y * font * 0.12}
|
||||
x2={projected.x + normal.x * font * 0.12}
|
||||
y2={projected.y + normal.y * font * 0.12}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
<line
|
||||
className="dimension-line"
|
||||
x1={lineStart.x}
|
||||
y1={lineStart.y}
|
||||
x2={lineEnd.x}
|
||||
y2={lineEnd.y}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{[lineStart, lineEnd].map((point, i) => (
|
||||
<line
|
||||
key={i}
|
||||
className="dimension-tick"
|
||||
x1={point.x - (normal.x - normal.y) * font * 0.075}
|
||||
y1={point.y - (normal.y + normal.x) * font * 0.075}
|
||||
x2={point.x + (normal.x - normal.y) * font * 0.075}
|
||||
y2={point.y + (normal.y + normal.x) * font * 0.075}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{staggered && (
|
||||
<line
|
||||
className="dimension-leader"
|
||||
x1={center.x}
|
||||
y1={center.y}
|
||||
x2={label.x}
|
||||
y2={label.y}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{dimensions.map((dimension) => (
|
||||
<g key={dimension.edge} className="dimension-caption" data-edge={dimension.edge}>
|
||||
<rect {...dimension.labelBox} fill="white" />
|
||||
<text
|
||||
className="dimension-label"
|
||||
x={dimension.label.x}
|
||||
y={dimension.label.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={font}
|
||||
fill="#17263f"
|
||||
>
|
||||
{dimension.angle === undefined ? (
|
||||
dimension.text
|
||||
) : (
|
||||
<>
|
||||
<tspan x={dimension.label.x} dy={-font * 0.6}>
|
||||
{dimension.text}
|
||||
</tspan>{' '}
|
||||
<tspan x={dimension.label.x} dy={font * 1.2}>
|
||||
{exactMm(dimension.angle)}°
|
||||
</tspan>
|
||||
</>
|
||||
)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlooringPreview({
|
||||
piece,
|
||||
|
|
@ -17,12 +112,15 @@ export function FlooringPreview({
|
|||
compact?: boolean;
|
||||
}) {
|
||||
const template = flooringTemplate(piece);
|
||||
const dimensions = compact
|
||||
? undefined
|
||||
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1]);
|
||||
const Wrapper = compact ? 'span' : 'figure';
|
||||
return (
|
||||
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
|
||||
<svg
|
||||
className="flooring-preview"
|
||||
viewBox={viewBox(fullPreviewBox(piece.length, piece.width))}
|
||||
viewBox={viewBox(dimensions?.box ?? fullPreviewBox(piece.length, piece.width))}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden={compact || undefined}
|
||||
role={compact ? undefined : 'img'}
|
||||
|
|
@ -35,24 +133,13 @@ export function FlooringPreview({
|
|||
strokeWidth={compact ? 1 : 1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{!compact &&
|
||||
piece.shaped &&
|
||||
template.map((point, index) => (
|
||||
<text
|
||||
key={index}
|
||||
x={point.x}
|
||||
y={point.y - Math.max(piece.length, piece.width) * 0.015}
|
||||
textAnchor="middle"
|
||||
fontSize={Math.max(piece.length, piece.width) * 0.025}
|
||||
fill="#1e40af"
|
||||
>
|
||||
{index + 1}
|
||||
</text>
|
||||
))}
|
||||
{dimensions && <Dimensions dimensions={dimensions.dimensions} font={dimensions.font} />}
|
||||
</svg>
|
||||
{!compact && (
|
||||
<figcaption>
|
||||
Length {exactMm(piece.length)} mm × width {exactMm(piece.width)} mm · top view, to scale
|
||||
{exactMm(piece.length)} × {exactMm(piece.width)} mm
|
||||
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
|
||||
' · angles from horizontal'}
|
||||
</figcaption>
|
||||
)}
|
||||
</Wrapper>
|
||||
|
|
@ -74,6 +161,18 @@ export function SkirtingPreview({
|
|||
const Grid = compact ? 'span' : 'div';
|
||||
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
|
||||
const boxes = skirtingEndBoxes(template, thickness);
|
||||
const dimensions = compact
|
||||
? undefined
|
||||
: dimensionedOutline(
|
||||
[template[0], template[1], { x: piece.length, y: thickness }, { x: 0, y: thickness }],
|
||||
[0, 2],
|
||||
);
|
||||
// The lower dimension measures the overall blank, between its actual longest points.
|
||||
const overall = dimensions?.dimensions.find((dimension) => dimension.edge === 2);
|
||||
if (overall) {
|
||||
overall.start = template.reduce((a, b) => (a.x > b.x ? a : b));
|
||||
overall.end = template.reduce((a, b) => (a.x < b.x ? a : b));
|
||||
}
|
||||
const points = template.map((point) => `${point.x},${point.y}`).join(' ');
|
||||
const ends = [
|
||||
{ side: 'left', back: template[0], front: template[3], box: boxes.left },
|
||||
|
|
@ -84,7 +183,7 @@ export function SkirtingPreview({
|
|||
{!compact && (
|
||||
<figure className="skirting-full-preview">
|
||||
<svg
|
||||
viewBox={viewBox(fullPreviewBox(piece.length, thickness))}
|
||||
viewBox={viewBox(dimensions!.box)}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label={`Full skirting shape ${piece.id}`}
|
||||
|
|
@ -96,10 +195,10 @@ export function SkirtingPreview({
|
|||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
|
||||
</svg>
|
||||
<figcaption>
|
||||
Full length {exactMm(piece.length)} mm × thickness {exactMm(thickness)} mm · top view,
|
||||
to scale
|
||||
{exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
|
|
@ -142,8 +241,9 @@ export function SkirtingPreview({
|
|||
</svg>
|
||||
{!compact && (
|
||||
<figcaption>
|
||||
{index === 0 ? 'Left' : 'Right'} end ·{' '}
|
||||
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°
|
||||
{index === 0 ? 'Left' : 'Right'} ·{' '}
|
||||
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '}
|
||||
{index === 0 ? piece.leftType : piece.rightType}
|
||||
</figcaption>
|
||||
)}
|
||||
</EndWrapper>
|
||||
|
|
@ -155,12 +255,6 @@ export function SkirtingPreview({
|
|||
</Fragment>
|
||||
))}
|
||||
</Grid>
|
||||
{!compact && (
|
||||
<p className="helper">
|
||||
End close-ups; the middle is omitted. The back edge faces the wall, the front edge faces
|
||||
the room. Both axes use the same scale.
|
||||
</p>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
64
src/components/DoorPosition.tsx
Normal file
64
src/components/DoorPosition.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Door, Wall } from '../lib/types';
|
||||
import { mm } from '../lib/format';
|
||||
|
||||
/** A wall viewed from its start toward its end; only the user's offset and width are inputs. */
|
||||
export function DoorPosition({
|
||||
wall,
|
||||
door,
|
||||
wallIndex,
|
||||
wallCount,
|
||||
closed,
|
||||
}: {
|
||||
wall: Wall;
|
||||
door: Door;
|
||||
wallIndex: number;
|
||||
wallCount: number;
|
||||
closed: boolean;
|
||||
}) {
|
||||
const start =
|
||||
wallIndex === 0 && !closed ? 'W1 start' : `W${wallIndex === 0 ? wallCount : wallIndex} corner`;
|
||||
const end =
|
||||
wallIndex === wallCount - 1 && !closed
|
||||
? `W${wallCount} end`
|
||||
: `W${((wallIndex + 1) % wallCount) + 1} corner`;
|
||||
const ratio = (n: number) => 12 + Math.max(0, Math.min(1, n / Math.max(wall.length, 1))) * 216;
|
||||
const a = ratio(door.offset),
|
||||
b = ratio(door.offset + door.width);
|
||||
return (
|
||||
<div className="door-position">
|
||||
<p>
|
||||
W{wallIndex + 1}: measure from {start} toward {end}.
|
||||
</p>
|
||||
<svg
|
||||
viewBox="0 0 240 65"
|
||||
role="img"
|
||||
aria-label={`Door on W${wallIndex + 1}: ${mm(door.offset)} mm from ${start}, then ${mm(door.width)} mm opening toward ${end}`}
|
||||
>
|
||||
<line x1="12" y1="22" x2="228" y2="22" stroke="#17263f" strokeWidth="3" />
|
||||
<line x1={a} y1="22" x2={b} y2="22" stroke="#fffdf7" strokeWidth="5" />
|
||||
<line
|
||||
x1={a}
|
||||
y1="22"
|
||||
x2={b}
|
||||
y2="22"
|
||||
stroke="#1647ab"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<circle cx="12" cy="22" r="4" fill="#17263f" />
|
||||
<path
|
||||
d="M 12 10 L 46 10 M 40 6 L 46 10 L 40 14"
|
||||
stroke="#1647ab"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<text x="12" y="45" textAnchor="start">
|
||||
{start}
|
||||
</text>
|
||||
<text x="228" y="59" textAnchor="end">
|
||||
{end}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,10 +111,19 @@ interface Props {
|
|||
flooring: FlooringPlan;
|
||||
skirting: SkirtingPlan;
|
||||
selectedPiece: string | null;
|
||||
select: (id: string) => void;
|
||||
select?: (id: string) => void;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select }: Props) {
|
||||
export function FloorPlan({
|
||||
room,
|
||||
geometry,
|
||||
flooring,
|
||||
skirting,
|
||||
selectedPiece,
|
||||
select,
|
||||
interactive = true,
|
||||
}: Props) {
|
||||
const hatch = useId().replaceAll(':', '');
|
||||
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
|
||||
const minX = Math.min(0, ...allPoints.map((p) => p.x)),
|
||||
|
|
@ -159,13 +168,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
|
|||
<div className="drawing-legend">
|
||||
<span>
|
||||
<i className="floor-key" />
|
||||
Flooring
|
||||
Flooring cuts
|
||||
</span>
|
||||
<span>
|
||||
<i className="skirt-key" />
|
||||
Skirting
|
||||
Skirting cuts
|
||||
</span>
|
||||
<span>
|
||||
<i className="uncut-key" />
|
||||
Uncut
|
||||
</span>
|
||||
</div>
|
||||
{interactive && <p className="drawing-help">Select a numbered piece for cut details.</p>}
|
||||
<div className="drawing-viewport">
|
||||
<svg
|
||||
className="room-svg"
|
||||
|
|
@ -212,18 +226,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
|
|||
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
|
||||
data-material="flooring"
|
||||
data-piece-id={piece.id}
|
||||
role={cut ? 'button' : undefined}
|
||||
tabIndex={cut ? 0 : undefined}
|
||||
role={cut && interactive ? 'button' : undefined}
|
||||
tabIndex={cut && interactive ? 0 : undefined}
|
||||
aria-hidden={cut ? undefined : true}
|
||||
aria-label={cut ? `Select flooring cut ${piece.id}` : undefined}
|
||||
aria-pressed={cut ? selectedPiece === piece.id : undefined}
|
||||
onClick={cut ? () => select(piece.id) : undefined}
|
||||
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
|
||||
onKeyDown={
|
||||
cut
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
select(piece.id);
|
||||
select?.(piece.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -283,18 +297,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
|
|||
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
|
||||
data-material="skirting"
|
||||
data-piece-id={piece.id}
|
||||
role={cut ? 'button' : undefined}
|
||||
tabIndex={cut ? 0 : undefined}
|
||||
role={cut && interactive ? 'button' : undefined}
|
||||
tabIndex={cut && interactive ? 0 : undefined}
|
||||
aria-hidden={cut ? undefined : true}
|
||||
aria-label={cut ? `Select skirting cut ${piece.id}` : undefined}
|
||||
aria-pressed={cut ? selectedPiece === piece.id : undefined}
|
||||
onClick={cut ? () => select(piece.id) : undefined}
|
||||
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
|
||||
onKeyDown={
|
||||
cut
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
select(piece.id);
|
||||
select?.(piece.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -302,8 +316,8 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
|
|||
>
|
||||
<polygon
|
||||
points={points(polygon)}
|
||||
fill={selectedPiece === piece.id ? '#17263f' : '#b74900'}
|
||||
stroke="#8d3800"
|
||||
fill={cut ? (selectedPiece === piece.id ? '#17263f' : '#b74900') : '#d8d5cd'}
|
||||
stroke={cut ? '#8d3800' : '#79756b'}
|
||||
strokeWidth={span * 0.001}
|
||||
/>
|
||||
{cut && (
|
||||
|
|
@ -317,20 +331,20 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
|
|||
/>
|
||||
)}
|
||||
{label && (
|
||||
<g transform={`translate(${label.x} ${label.y})`} pointerEvents="none">
|
||||
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
|
||||
<rect
|
||||
x={-font * 1.1}
|
||||
y={-font * 0.58}
|
||||
width={font * 2.2}
|
||||
height={font * 1.16}
|
||||
fill="#fffdf7"
|
||||
stroke="#b74900"
|
||||
fill="#b74900"
|
||||
stroke="#8d3800"
|
||||
strokeWidth={span * 0.0012}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#8d3800"
|
||||
fill="#fff"
|
||||
fontSize={font * 0.8}
|
||||
fontWeight="700"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
|
|||
<>
|
||||
<div className="editor-heading">
|
||||
<h2>Materials</h2>
|
||||
<span>Shared across rooms · mm</span>
|
||||
<span>Shared across rooms</span>
|
||||
</div>
|
||||
<section className="material-section flooring" aria-labelledby="flooring-materials">
|
||||
<h3 id="flooring-materials" className="material-heading">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { fireplace, rectangle, nextId } from '../lib/defaults';
|
|||
import { normalizeAngle } from '../lib/geometry';
|
||||
import { mm } from '../lib/format';
|
||||
import { NumberField, Issues } from './Fields';
|
||||
import { DoorPosition } from './DoorPosition';
|
||||
interface Props {
|
||||
room: Room;
|
||||
geometry: Geometry;
|
||||
|
|
@ -87,7 +88,6 @@ export function OutlineEditor({
|
|||
<>
|
||||
<div className="editor-heading">
|
||||
<h2>Room measurements</h2>
|
||||
<span>Millimetres</span>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Room name</span>
|
||||
|
|
@ -267,9 +267,7 @@ export function OutlineEditor({
|
|||
</h3>
|
||||
<span>{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings</span>
|
||||
</div>
|
||||
<p className="helper">
|
||||
Width includes trim where skirting stops. Offset is measured from the wall’s start.
|
||||
</p>
|
||||
<p className="helper">Door width includes trim where skirting stops.</p>
|
||||
{room.walls.flatMap((wall, wallIndex) =>
|
||||
wall.doors.map((door) => (
|
||||
<div className="door-card" key={door.id}>
|
||||
|
|
@ -312,9 +310,16 @@ export function OutlineEditor({
|
|||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<DoorPosition
|
||||
wall={wall}
|
||||
door={door}
|
||||
wallIndex={wallIndex}
|
||||
wallCount={room.walls.length}
|
||||
closed={geometry.closed}
|
||||
/>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Door offset"
|
||||
label={`From ${wallIndex === 0 && !geometry.closed ? 'W1 start' : `W${wallIndex === 0 ? room.walls.length : wallIndex} corner`}`}
|
||||
value={door.offset}
|
||||
onChange={(n) =>
|
||||
changeWall(wall.id, (w) => ({
|
||||
|
|
|
|||
114
src/components/PrintPlan.tsx
Normal file
114
src/components/PrintPlan.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { FlooringPlan, Geometry, Project, SkirtingPlan } from '../lib/types';
|
||||
import { area, mm } from '../lib/format';
|
||||
import {
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
readyOffcutInstructions,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
} from '../lib/cut-list';
|
||||
import { flooringInstructions } from '../lib/cut-instructions';
|
||||
import { FloorPlan } from './FloorPlan';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
||||
/** All rooms first, then cutting sheets in purchased-board order, including shared offcuts. */
|
||||
export function PrintPlan({
|
||||
project,
|
||||
geometries,
|
||||
flooring,
|
||||
skirting,
|
||||
}: {
|
||||
project: Project;
|
||||
geometries: Geometry[];
|
||||
flooring: FlooringPlan;
|
||||
skirting: SkirtingPlan;
|
||||
}) {
|
||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||
const wholeFloor = uncutFlooring(flooring, project.flooring).wholeBoards;
|
||||
const wholeSkirt = uncutSkirting(skirting).reduce((sum, group) => sum + group.count, 0);
|
||||
return (
|
||||
<section className="print-plan" aria-label="Printable plan">
|
||||
<header className="print-title">
|
||||
<h1>Flooring installation plan</h1>
|
||||
<p>
|
||||
{project.rooms.length} rooms · {mm(project.flooring.length)} ×{' '}
|
||||
{mm(project.flooring.width)} mm floorboards · {mm(project.flooring.gap)} mm wall spacers
|
||||
</p>
|
||||
<p>
|
||||
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
|
||||
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
|
||||
</p>
|
||||
</header>
|
||||
{project.rooms.map((room, index) => (
|
||||
<section className="print-room" key={room.id} data-room-id={room.id}>
|
||||
<h2>
|
||||
{index + 1}. {room.name} <span>{area(geometries[index].area)} m²</span>
|
||||
</h2>
|
||||
<FloorPlan
|
||||
room={room}
|
||||
geometry={geometries[index]}
|
||||
flooring={flooring}
|
||||
skirting={skirting}
|
||||
selectedPiece={null}
|
||||
interactive={false}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
|
||||
<h2>Flooring cuts</h2>
|
||||
{groupFlooringCuts(flooring).map((group) => (
|
||||
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
|
||||
<h3>
|
||||
Floorboard {group[0].stockId}{' '}
|
||||
<span>
|
||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
|
||||
</span>
|
||||
</h3>
|
||||
<div className="print-cut-grid">
|
||||
{group.map((piece) => (
|
||||
<article className="print-cut" data-cut-id={piece.id} key={piece.id}>
|
||||
<h4>
|
||||
{piece.id}{' '}
|
||||
<span>
|
||||
{roomName(piece.roomId)} · Row {piece.row}
|
||||
</span>
|
||||
</h4>
|
||||
<FlooringPreview piece={piece} />
|
||||
<p>{piece.cut ? flooringInstructions(piece) : readyOffcutInstructions(piece)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groupFlooringCuts(flooring).length && <p>No flooring cuts needed.</p>}
|
||||
</section>
|
||||
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
|
||||
<h2>Skirting cuts</h2>
|
||||
{groupSkirtingCuts(skirting).map((group) => (
|
||||
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
|
||||
<h3>
|
||||
Skirting board {group[0].stockId}{' '}
|
||||
<span>
|
||||
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
|
||||
</span>
|
||||
</h3>
|
||||
<div className="print-cut-grid">
|
||||
{group.map((piece) => (
|
||||
<article className="print-cut" data-cut-id={piece.id} key={piece.id}>
|
||||
<h4>
|
||||
{piece.id}{' '}
|
||||
<span>
|
||||
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
|
||||
</span>
|
||||
</h4>
|
||||
<SkirtingPreview piece={piece} thickness={project.skirting.thickness} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groupSkirtingCuts(skirting).length && <p>No skirting cuts needed.</p>}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,25 +54,18 @@ export function readyOffcutInstructions(piece: FlooringPiece): string {
|
|||
return `Use offcut; no new cut. Required factory short ends: ${ends}.`;
|
||||
}
|
||||
|
||||
/** Batch identical cuts without losing any placement codes or changing factory-edge orientation. */
|
||||
export function groupFlooringCuts(plan: FlooringPlan): FlooringPiece[][] {
|
||||
const groups = new Map<string, FlooringPiece[]>();
|
||||
const precise = (value: number) => Math.round(value * 1000);
|
||||
for (const piece of flooringCuts(plan)) {
|
||||
const key = JSON.stringify([
|
||||
piece.roomId,
|
||||
precise(piece.length),
|
||||
precise(piece.width),
|
||||
piece.shaped,
|
||||
piece.cut,
|
||||
piece.factoryLeft,
|
||||
piece.factoryRight,
|
||||
piece.ripEdge,
|
||||
piece.shaped ? piece.template.map((point) => [precise(point.x), precise(point.y)]) : null,
|
||||
]);
|
||||
const group = groups.get(key);
|
||||
/** Keep every cut from a purchased board together, in allocation order across rooms. */
|
||||
function groupByStock<T extends { stockId: string }>(pieces: T[], stocks: { id: string }[]): T[][] {
|
||||
const groups = new Map<string, T[]>(stocks.map((stock) => [stock.id, []]));
|
||||
for (const piece of pieces) {
|
||||
const group = groups.get(piece.stockId);
|
||||
if (group) group.push(piece);
|
||||
else groups.set(key, [piece]);
|
||||
else groups.set(piece.stockId, [piece]);
|
||||
}
|
||||
return [...groups.values()];
|
||||
return [...groups.values()].filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
export const groupFlooringCuts = (plan: FlooringPlan) =>
|
||||
groupByStock(flooringCuts(plan), plan.stocks);
|
||||
export const groupSkirtingCuts = (plan: SkirtingPlan) =>
|
||||
groupByStock(skirtingCuts(plan), plan.stocks);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,142 @@ 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 {
|
||||
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[]) {
|
||||
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 > 0.000001)
|
||||
.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 text = exactMm(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 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function flooringTemplate(piece: FlooringPiece): Point[] {
|
||||
if (piece.template.length) return piece.template;
|
||||
return piece.shaped
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||
import { flooringInstructions } from './cut-instructions';
|
||||
import {
|
||||
flooringCuts,
|
||||
skirtingCuts,
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
readyOffcutInstructions,
|
||||
|
|
@ -19,10 +19,8 @@ export function cutListCsv(
|
|||
skirting: SkirtingPlan,
|
||||
): string {
|
||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||
const floorCuts = flooringCuts(flooring),
|
||||
skirtCuts = skirtingCuts(skirting);
|
||||
const floorIds = new Set(floorCuts.map((piece) => piece.id));
|
||||
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
|
||||
const floorCuts = groupFlooringCuts(flooring).flat(),
|
||||
skirtCuts = groupSkirtingCuts(skirting).flat();
|
||||
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
||||
const rows: (string | number)[][] = [
|
||||
[
|
||||
|
|
@ -138,40 +136,6 @@ export function cutListCsv(
|
|||
'',
|
||||
]);
|
||||
}
|
||||
for (const stock of flooring.stocks) {
|
||||
const pieces = stock.pieceIds.filter((id) => floorIds.has(id));
|
||||
if (pieces.length)
|
||||
rows.push([
|
||||
'Flooring stock',
|
||||
'',
|
||||
pieces.join(' / '),
|
||||
stock.id,
|
||||
project.flooring.length,
|
||||
project.flooring.width,
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
`Remaining length ${stock.remaining.toFixed(1)} mm`,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
for (const stock of skirting.stocks) {
|
||||
const pieces = stock.pieceIds.filter((id) => skirtIds.has(id));
|
||||
if (pieces.length)
|
||||
rows.push([
|
||||
'Skirting stock',
|
||||
'',
|
||||
pieces.join(' / '),
|
||||
stock.id,
|
||||
stock.length,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
`Waste including kerf ${stock.waste.toFixed(1)} mm`,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface Layout {
|
|||
stocks: AvailableStock[];
|
||||
cuts: number;
|
||||
reusedCount: number;
|
||||
patternPenalty: number;
|
||||
}
|
||||
interface CutPiece {
|
||||
polygon: Point[];
|
||||
|
|
@ -250,6 +251,7 @@ function appendRow(
|
|||
rows: [...base.rows],
|
||||
cuts: base.cuts,
|
||||
reusedCount: base.reusedCount,
|
||||
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
|
||||
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
|
||||
};
|
||||
const pieceIds: string[] = [];
|
||||
|
|
@ -315,6 +317,45 @@ function staggerAllowed(a: number, b: number, s: FlooringSettings): boolean {
|
|||
return nearest >= s.minOffset - EPSILON && nearest <= s.maxOffset + EPSILON;
|
||||
}
|
||||
|
||||
/** Signed nearest-joint movement handles a step crossing the board-length boundary. */
|
||||
function jointStep(from: number, to: number, length: number): number {
|
||||
return phaseOf(to - from + length / 2, length) - length / 2;
|
||||
}
|
||||
|
||||
/** Aesthetic preferences are penalties, never reasons to reject a physically valid row. */
|
||||
function randomPatternPenalty(
|
||||
rows: FlooringRow[],
|
||||
roomId: string,
|
||||
phase: number,
|
||||
s: FlooringSettings,
|
||||
): number {
|
||||
if (s.pattern !== 'random') return 0;
|
||||
const recent = rows.filter((row) => row.roomId === roomId).slice(-4);
|
||||
if (recent.length < 2) return 0;
|
||||
const previous = recent.at(-1)!.offset;
|
||||
const twoBack = recent.at(-2)!.offset;
|
||||
const step = jointStep(previous, phase, s.length);
|
||||
const previousStep = jointStep(twoBack, previous, s.length);
|
||||
// A few millimetres of kerf drift still looks like the same joint line or step.
|
||||
const similar = Math.max(s.kerf * 2, s.length * 0.03);
|
||||
// Half-board steps have equally near joints in both directions and tend to look brick-like.
|
||||
let penalty = close(Math.abs(step), s.length / 2) ? 1 : 0;
|
||||
for (let distance = 2; distance <= recent.length; distance++) {
|
||||
if (Math.abs(jointStep(recent.at(-distance)!.offset, phase, s.length)) <= similar)
|
||||
penalty += 5 - distance;
|
||||
}
|
||||
if (step * previousStep > 0) {
|
||||
penalty += 1;
|
||||
if (Math.abs(step - previousStep) <= similar) penalty += 3;
|
||||
if (
|
||||
recent.length >= 3 &&
|
||||
previousStep * jointStep(recent.at(-3)!.offset, twoBack, s.length) > 0
|
||||
)
|
||||
penalty += 2;
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
|
||||
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
|
||||
function fraction(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
|
|
@ -338,13 +379,9 @@ function candidatePhases(
|
|||
if (s.pattern === 'brick' && firstPhase !== undefined)
|
||||
return [phaseOf(firstPhase + ((row % 2) * s.length) / 2, s.length)];
|
||||
const candidates: number[] = [];
|
||||
const twoRowsBack = layout.rows.filter((r) => r.roomId === room.id).at(-2)?.offset;
|
||||
const avoidRepeatedPair =
|
||||
s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON && s.maxOffset > EPSILON;
|
||||
const add = (value: number) => {
|
||||
const phase = phaseOf(value, s.length);
|
||||
if (previousPhase !== undefined && !staggerAllowed(phase, previousPhase, s)) return;
|
||||
if (avoidRepeatedPair && twoRowsBack !== undefined && close(phase, twoRowsBack)) return;
|
||||
if (!candidates.some((p) => close(p, phase))) candidates.push(phase);
|
||||
};
|
||||
// Reusing a suitable end is usually the most effective way to save a board and a crosscut.
|
||||
|
|
@ -386,6 +423,7 @@ function candidatePhases(
|
|||
function compareLayouts(a: Layout, b: Layout): number {
|
||||
return (
|
||||
a.stocks.length - b.stocks.length ||
|
||||
a.patternPenalty - b.patternPenalty ||
|
||||
a.cuts - b.cuts ||
|
||||
b.reusedCount - a.reusedCount ||
|
||||
compareText(
|
||||
|
|
@ -418,13 +456,22 @@ function validateSettings(s: FlooringSettings): string | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* Deterministic bounded beam search, ordered by boards consumed, then saw passes.
|
||||
* Deterministic bounded beam search: boards consumed first; random layouts then
|
||||
* prefer less visual repetition before fewer saw passes. This can spend an extra
|
||||
* crosscut to break a staircase without buying an extra board.
|
||||
* Stock is shared between rooms, but layouts and joint phases are independent.
|
||||
* 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 {
|
||||
let layout: Layout = { pieces: [], rows: [], stocks: [], cuts: 0, reusedCount: 0 };
|
||||
let layout: Layout = {
|
||||
pieces: [],
|
||||
rows: [],
|
||||
stocks: [],
|
||||
cuts: 0,
|
||||
reusedCount: 0,
|
||||
patternPenalty: 0,
|
||||
};
|
||||
const issues: Issue[] = [];
|
||||
const invalid = validateSettings(s);
|
||||
if (invalid) issues.push({ severity: 'error', message: invalid });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } f
|
|||
|
||||
/** Resource limits apply to links as well as edits made inside the app. */
|
||||
export const URL_LIMITS = {
|
||||
queryLength: 100_000,
|
||||
hashLength: 100_000,
|
||||
jsonLength: 1_000_000,
|
||||
rooms: 20,
|
||||
|
|
@ -159,46 +160,190 @@ function parseState(value: unknown): UrlState {
|
|||
};
|
||||
}
|
||||
|
||||
/** Stable URLs: canonical property order, no timestamps, random seeds or results. */
|
||||
/** Explicit keys in a fixed order: no compressed payload or derived results. */
|
||||
export function encodeState(state: UrlState): string {
|
||||
const json = JSON.stringify(parseState(state));
|
||||
if (json.length > URL_LIMITS.jsonLength)
|
||||
throw new Error('This plan is too large for a link. Split it into smaller projects.');
|
||||
const hash = `#plan=${LZString.compressToEncodedURIComponent(json)}`;
|
||||
if (hash.length > URL_LIMITS.hashLength)
|
||||
const { project, view } = parseState(state);
|
||||
const params = new URLSearchParams();
|
||||
const put = (key: string, value: string | number) => params.append(key, String(value));
|
||||
put('version', project.version);
|
||||
put('name', project.name);
|
||||
put('tolerance', project.tolerance);
|
||||
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value);
|
||||
// An empty value explicitly represents an empty stock list during editing.
|
||||
if (!project.skirting.stockLengths.length) put('skirting.stock', '');
|
||||
for (const length of project.skirting.stockLengths) put('skirting.stock', length);
|
||||
put('skirting.thickness', project.skirting.thickness);
|
||||
put('skirting.minLength', project.skirting.minLength);
|
||||
put('skirting.kerf', project.skirting.kerf);
|
||||
project.rooms.forEach((room, roomIndex) => {
|
||||
const prefix = `room.${roomIndex + 1}`;
|
||||
put(`${prefix}.id`, room.id);
|
||||
put(`${prefix}.name`, room.name);
|
||||
put(`${prefix}.direction`, room.direction);
|
||||
room.walls.forEach((wall, wallIndex) => {
|
||||
const wallPrefix = `${prefix}.wall.${wallIndex + 1}`;
|
||||
put(`${wallPrefix}.id`, wall.id);
|
||||
put(`${wallPrefix}.length`, wall.length);
|
||||
put(`${wallPrefix}.turn`, wall.turn);
|
||||
wall.doors.forEach((door, doorIndex) => {
|
||||
const doorPrefix = `${wallPrefix}.door.${doorIndex + 1}`;
|
||||
put(`${doorPrefix}.id`, door.id);
|
||||
put(`${doorPrefix}.offset`, door.offset);
|
||||
put(`${doorPrefix}.width`, door.width);
|
||||
});
|
||||
});
|
||||
});
|
||||
put('selectedRoom', view.roomId);
|
||||
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
|
||||
const query = `?${params}`;
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||
return hash;
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Never throw for an untrusted link. Return a usable starter plan and a visible error. */
|
||||
export function decodeState(hash: string): { state: UrlState; error: string | null } {
|
||||
if (hash === '' || hash === '#') return { state: defaultState(), error: null };
|
||||
function parseQuery(query: string): UrlState {
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
throw new Error('The link is too large. Use a smaller project link.');
|
||||
// URLSearchParams otherwise replaces damaged percent escapes silently.
|
||||
decodeURIComponent(query);
|
||||
const params = new URLSearchParams(query);
|
||||
const defaults = defaultState();
|
||||
const roomFields = [...params.keys()].filter((key) =>
|
||||
/^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test(
|
||||
key,
|
||||
),
|
||||
);
|
||||
const text = (key: string, fallback?: string): string => {
|
||||
const values = params.getAll(key);
|
||||
if (values.length > 1) throw new Error(`${key} must appear only once.`);
|
||||
if (!values.length) {
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`The link is missing ${key}.`);
|
||||
}
|
||||
return values[0];
|
||||
};
|
||||
const measurement = (value: string, key: string) => {
|
||||
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))
|
||||
throw new Error(`${key} must be a finite number.`);
|
||||
return number(Number(value), key);
|
||||
};
|
||||
const numeric = (key: string, fallback?: number) =>
|
||||
measurement(text(key, fallback === undefined ? undefined : String(fallback)), key);
|
||||
const indices = (prefix: string, limit: number) => {
|
||||
const found = new Set<number>();
|
||||
for (const key of roomFields) {
|
||||
if (!key.startsWith(`${prefix}.`)) continue;
|
||||
const index = key.slice(prefix.length + 1).split('.')[0];
|
||||
if (!/^[1-9]\d*$/.test(index))
|
||||
throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`);
|
||||
const value = Number(index);
|
||||
if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`);
|
||||
found.add(value);
|
||||
}
|
||||
const ordered = [...found].sort((a, b) => a - b);
|
||||
if (ordered.some((value, index) => value !== index + 1))
|
||||
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
|
||||
return ordered;
|
||||
};
|
||||
const roomIndices = indices('room', URL_LIMITS.rooms);
|
||||
const rooms = roomIndices.length
|
||||
? roomIndices.map((roomIndex) => {
|
||||
const prefix = `room.${roomIndex}`;
|
||||
const id = text(`${prefix}.id`, `room-${roomIndex}`);
|
||||
return {
|
||||
id,
|
||||
name: text(`${prefix}.name`, `Room ${roomIndex}`),
|
||||
direction: numeric(`${prefix}.direction`, 0),
|
||||
walls: indices(`${prefix}.wall`, URL_LIMITS.walls).map((wallIndex) => {
|
||||
const wallPrefix = `${prefix}.wall.${wallIndex}`;
|
||||
const wallId = text(`${wallPrefix}.id`, `${id}-w${wallIndex}`);
|
||||
return {
|
||||
id: wallId,
|
||||
length: numeric(`${wallPrefix}.length`),
|
||||
turn: numeric(`${wallPrefix}.turn`, 90),
|
||||
doors: indices(`${wallPrefix}.door`, URL_LIMITS.doors).map((doorIndex) => {
|
||||
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
|
||||
return {
|
||||
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
|
||||
offset: numeric(`${doorPrefix}.offset`),
|
||||
width: numeric(`${doorPrefix}.width`),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
: defaults.project.rooms;
|
||||
const stockValues = params.getAll('skirting.stock');
|
||||
if (stockValues.length > URL_LIMITS.stockLengths)
|
||||
throw new Error(`Skirting stock lengths supports at most ${URL_LIMITS.stockLengths} entries.`);
|
||||
const stockLengths =
|
||||
stockValues.length === 1 && stockValues[0] === ''
|
||||
? []
|
||||
: stockValues.length
|
||||
? stockValues.map((value) => measurement(value, 'skirting.stock'))
|
||||
: defaults.project.skirting.stockLengths;
|
||||
const floor = defaults.project.flooring;
|
||||
return parseState({
|
||||
project: {
|
||||
version: numeric('version', 1),
|
||||
name: text('name', defaults.project.name),
|
||||
tolerance: numeric('tolerance', defaults.project.tolerance),
|
||||
rooms,
|
||||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
]),
|
||||
),
|
||||
skirting: {
|
||||
stockLengths,
|
||||
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
|
||||
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
|
||||
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
|
||||
},
|
||||
},
|
||||
view: {
|
||||
roomId: text('selectedRoom', rooms[0].id),
|
||||
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Read existing shared links so the hook can replace them with the readable query format. */
|
||||
function parseLegacy(hash: string): UrlState {
|
||||
if (hash.length > URL_LIMITS.hashLength)
|
||||
throw new Error('The link is too large. Use a smaller project link.');
|
||||
if (!hash.startsWith('#plan='))
|
||||
throw new Error('Use a complete plan query or an existing #plan= link.');
|
||||
const compressed = decodeURIComponent(hash.slice(6));
|
||||
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed))
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
const json = LZString.decompressFromEncodedURIComponent(compressed);
|
||||
if (!json)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
if (json.length > URL_LIMITS.jsonLength)
|
||||
throw new Error('The project in this link is too large. Use a smaller project link.');
|
||||
if (LZString.compressToEncodedURIComponent(json) !== compressed)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
let value: unknown;
|
||||
try {
|
||||
if (hash.length > URL_LIMITS.hashLength)
|
||||
throw new Error('The link is too large. Use a smaller project link.');
|
||||
if (!hash.startsWith('#plan='))
|
||||
throw new Error('The link must start with #plan=. Copy the complete plan link.');
|
||||
const compressed = decodeURIComponent(hash.slice(6));
|
||||
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed)) {
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
}
|
||||
const json = LZString.decompressFromEncodedURIComponent(compressed);
|
||||
if (!json)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
if (json.length > URL_LIMITS.jsonLength)
|
||||
throw new Error('The project in this link is too large. Use a smaller project link.');
|
||||
// LZ decoding alone accepts ignored trailing characters. Require the exact
|
||||
// encoding of the decoded text so a truncated or appended link is rejected.
|
||||
if (LZString.compressToEncodedURIComponent(json) !== compressed) {
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(json);
|
||||
} catch {
|
||||
throw new Error('The project data is damaged. Copy the complete plan link again.');
|
||||
}
|
||||
return { state: parseState(value), error: null };
|
||||
value = JSON.parse(json);
|
||||
} catch {
|
||||
throw new Error('The project data is damaged. Copy the complete plan link again.');
|
||||
}
|
||||
return parseState(value);
|
||||
}
|
||||
|
||||
/** Accept a readable query or a legacy fragment; never throw for an untrusted link. */
|
||||
export function decodeState(link: string): { state: UrlState; error: string | null } {
|
||||
if (link === '' || link === '#' || link === '?') return { state: defaultState(), error: null };
|
||||
try {
|
||||
if (!link.startsWith('?') && !link.startsWith('#'))
|
||||
throw new Error('Use the complete plan query, starting with ?.');
|
||||
return { state: link.startsWith('?') ? parseQuery(link) : parseLegacy(link), error: null };
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof URIError
|
||||
|
|
|
|||
|
|
@ -2,14 +2,31 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
import type { UrlState } from './types';
|
||||
import { decodeState, encodeState } from './url-state';
|
||||
|
||||
/** The fragment is the durable source of truth. No local storage or server state. */
|
||||
const readLocation = () => decodeState(window.location.search || window.location.hash);
|
||||
const queryUrl = (query: string) => `${window.location.pathname}${query}`;
|
||||
|
||||
/** Readable query parameters are the durable source of truth. */
|
||||
export function useProject() {
|
||||
const [loaded, setLoaded] = useState(() => decodeState(window.location.hash));
|
||||
const [loaded, setLoaded] = useState(readLocation);
|
||||
useEffect(() => {
|
||||
const restore = () => setLoaded(decodeState(window.location.hash));
|
||||
const restore = () => {
|
||||
const next = readLocation();
|
||||
if (!next.error) {
|
||||
try {
|
||||
// Canonicalize editable queries and migrate existing fragment links
|
||||
// without creating an extra entry in browser history.
|
||||
const query = encodeState(next.state);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history.replaceState(null, '', queryUrl(query));
|
||||
} catch (error) {
|
||||
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
|
||||
}
|
||||
}
|
||||
setLoaded(next);
|
||||
};
|
||||
window.addEventListener('hashchange', restore);
|
||||
window.addEventListener('popstate', restore);
|
||||
if (!window.location.hash) window.history.replaceState(null, '', encodeState(loaded.state));
|
||||
restore();
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', restore);
|
||||
window.removeEventListener('popstate', restore);
|
||||
|
|
@ -19,10 +36,10 @@ export function useProject() {
|
|||
setLoaded((current) => {
|
||||
const next = transform(current.state);
|
||||
try {
|
||||
const hash = encodeState(next);
|
||||
if (hash !== window.location.hash)
|
||||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', hash);
|
||||
const canonical = decodeState(hash).state;
|
||||
const query = encodeState(next);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||
const canonical = decodeState(query).state;
|
||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||
return { state: canonical, error: null };
|
||||
} catch (error) {
|
||||
|
|
|
|||
621
src/styles.css
621
src/styles.css
|
|
@ -826,99 +826,6 @@ summary span {
|
|||
.material-cuts > .helper {
|
||||
padding: 0 17px 15px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.cut-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cut-table th {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
background: #eff2f7;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 11px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cut-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #dce1e8;
|
||||
}
|
||||
.cut-table td small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #53647c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cut-table .numeric {
|
||||
font-family: var(--mono);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
.piece-link {
|
||||
min-width: 46px;
|
||||
padding: 7px;
|
||||
font: 700 12px var(--mono);
|
||||
color: var(--blue);
|
||||
border-color: #b3c9ed;
|
||||
background: #f1f5fc;
|
||||
}
|
||||
.skirting-cuts .piece-link {
|
||||
color: #983e03;
|
||||
border-color: #e2b594;
|
||||
background: #fff5eb;
|
||||
}
|
||||
.cut-table tr.selected td {
|
||||
background: #f3f5fa;
|
||||
}
|
||||
.cut-table tr.selected .piece-link {
|
||||
color: white;
|
||||
background: var(--ink);
|
||||
}
|
||||
.coordinates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.coordinates span {
|
||||
font: 11px var(--mono);
|
||||
padding: 5px 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
.stock-allocations {
|
||||
margin: 12px 16px;
|
||||
}
|
||||
.stock-allocations summary {
|
||||
font-size: 12px;
|
||||
}
|
||||
.allocation-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.stock-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
background: #f6f7f9;
|
||||
border: 1px solid #dce1e8;
|
||||
overflow-wrap: anywhere;
|
||||
font: 11px var(--mono);
|
||||
}
|
||||
.stock-card strong span,
|
||||
.stock-card small {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
font-size: 13px;
|
||||
|
|
@ -1193,167 +1100,6 @@ summary span {
|
|||
.material-cuts > .helper {
|
||||
padding: 0 14px 15px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.cut-table,
|
||||
.cut-table tbody {
|
||||
display: block;
|
||||
}
|
||||
.cut-table thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
.cut-table tr {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.cut-table td {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.cut-table td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font:
|
||||
10px Arial,
|
||||
sans-serif;
|
||||
color: #53647c;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.cut-table td:first-child {
|
||||
grid-row: span 2;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cut-table td:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
.piece-link {
|
||||
min-height: 44px;
|
||||
min-width: 58px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cut-table .numeric {
|
||||
font-size: 14px;
|
||||
}
|
||||
.cut-table td small {
|
||||
font-size: 11px;
|
||||
}
|
||||
.cut-table tr.selected {
|
||||
background: #eef2f9;
|
||||
}
|
||||
.cut-table tr.selected td {
|
||||
background: none;
|
||||
}
|
||||
.stock-allocations {
|
||||
margin: 10px 14px;
|
||||
}
|
||||
.stock-allocations summary {
|
||||
font-size: 12px;
|
||||
}
|
||||
.coordinates span {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
background: white;
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-header,
|
||||
.project-bar,
|
||||
.header-actions,
|
||||
.input-columns,
|
||||
.page-jumps,
|
||||
.copy-status,
|
||||
.button,
|
||||
.cut-dialog {
|
||||
display: none !important;
|
||||
}
|
||||
.app-main {
|
||||
padding: 0;
|
||||
}
|
||||
.workspace,
|
||||
.drawing-column {
|
||||
display: block;
|
||||
position: static;
|
||||
}
|
||||
.drawing-card {
|
||||
break-inside: avoid;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.drawing-viewport {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
background: white;
|
||||
}
|
||||
.room-svg {
|
||||
width: 100% !important;
|
||||
min-height: 0;
|
||||
max-height: 380px;
|
||||
}
|
||||
.purchase-summary {
|
||||
margin: 15px 0;
|
||||
}
|
||||
.purchase-item {
|
||||
padding: 8px;
|
||||
}
|
||||
.cut-list-card {
|
||||
display: block;
|
||||
}
|
||||
.material-cuts {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.cut-table,
|
||||
.cut-table tbody {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
.cut-table tbody {
|
||||
display: table-row-group;
|
||||
}
|
||||
.cut-table thead {
|
||||
position: static;
|
||||
display: table-header-group;
|
||||
width: auto;
|
||||
height: auto;
|
||||
clip: auto;
|
||||
}
|
||||
.cut-table tr {
|
||||
display: table-row;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.cut-table td {
|
||||
display: table-cell;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
.cut-table td::before {
|
||||
display: none;
|
||||
}
|
||||
.stock-allocations {
|
||||
display: none;
|
||||
}
|
||||
.piece-link {
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 699px) {
|
||||
.tooltip-content {
|
||||
|
|
@ -1366,92 +1112,6 @@ summary span {
|
|||
max-width: none;
|
||||
}
|
||||
}
|
||||
.piece-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
min-width: 92px;
|
||||
}
|
||||
.batch-codes {
|
||||
width: 32%;
|
||||
}
|
||||
.batch-qty {
|
||||
text-align: center;
|
||||
}
|
||||
.flooring-batch.selected .piece-chip:not(.selected) {
|
||||
color: var(--blue);
|
||||
background: #f1f5fc;
|
||||
}
|
||||
.cut-origin {
|
||||
font: 12px var(--mono);
|
||||
color: #344963;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
@media (max-width: 699px) {
|
||||
.cut-table tr.flooring-batch {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.cut-table .flooring-batch .batch-size {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
.cut-table .flooring-batch .batch-qty {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
}
|
||||
.cut-table .flooring-batch .batch-room {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.cut-table .flooring-batch .batch-action {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.cut-table .flooring-batch td.batch-codes {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
width: auto;
|
||||
}
|
||||
.piece-chips {
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
}
|
||||
.piece-chip {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 7px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.skirting-cuts .cut-table tr {
|
||||
grid-template-columns: 76px minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.stock-allocations {
|
||||
display: block;
|
||||
}
|
||||
.stock-allocations::details-content {
|
||||
content-visibility: visible !important;
|
||||
display: block !important;
|
||||
}
|
||||
.stock-allocations > summary {
|
||||
display: none;
|
||||
}
|
||||
.piece-chips {
|
||||
gap: 6px;
|
||||
}
|
||||
.piece-chip {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Each cut has a small shape preview; exact measurements open in a native dialog. */
|
||||
.cut-preview-button {
|
||||
|
|
@ -1561,7 +1221,7 @@ summary span {
|
|||
display: block;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: #f5f8fd;
|
||||
background: white;
|
||||
}
|
||||
.skirting-full-preview svg {
|
||||
display: block;
|
||||
|
|
@ -1572,22 +1232,6 @@ summary span {
|
|||
max-width: 400px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.cut-measurements {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 14px 0;
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
.cut-measurements dt {
|
||||
font-size: 12px;
|
||||
color: #53647c;
|
||||
}
|
||||
.cut-measurements dd {
|
||||
margin: 4px 0 0;
|
||||
font: 600 13px var(--mono);
|
||||
}
|
||||
@media (max-width: 699px) {
|
||||
.cut-dialog-header {
|
||||
padding-inline: 12px;
|
||||
|
|
@ -1599,13 +1243,264 @@ summary span {
|
|||
height: 150px;
|
||||
}
|
||||
}
|
||||
.stock-cut-group {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.stock-cut-group h4 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
background: #f1f3f6;
|
||||
}
|
||||
.stock-cut-group h4 span {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
.board-cuts {
|
||||
padding: 0 16px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.board-cut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.board-cut + .board-cut {
|
||||
border-top: 1px solid #e0e4e9;
|
||||
}
|
||||
.cut-size {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cut-location,
|
||||
.cut-note {
|
||||
font-size: 12px;
|
||||
color: #4b5c73;
|
||||
font-weight: 400;
|
||||
}
|
||||
.cut-location {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.cut-preview-button {
|
||||
flex-shrink: 0;
|
||||
background: #f0f5ff;
|
||||
border-color: #1647ab;
|
||||
color: #1647ab;
|
||||
}
|
||||
.skirting-preview-button {
|
||||
background: #fff5eb;
|
||||
border-color: #b74900;
|
||||
color: #983e03;
|
||||
}
|
||||
.cut-preview-code {
|
||||
font-family: inherit;
|
||||
}
|
||||
.cut-preview-code > span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.drawing-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.uncut-key {
|
||||
width: 17px;
|
||||
height: 10px;
|
||||
display: inline-block;
|
||||
background: #deddd8;
|
||||
border: 1px solid #79756b;
|
||||
}
|
||||
.drawing-help {
|
||||
font-size: 11px;
|
||||
padding: 0 20px 12px;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.svg-cut:hover > polygon,
|
||||
.svg-cut:focus-visible > polygon {
|
||||
stroke: #17263f;
|
||||
stroke-width: 3px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.skirting-code {
|
||||
cursor: pointer;
|
||||
}
|
||||
.skirt-cut:hover .skirting-code rect,
|
||||
.skirt-cut:focus-visible .skirting-code rect {
|
||||
fill: #17263f;
|
||||
stroke: #17263f;
|
||||
}
|
||||
.door-position {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.door-position svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 6px auto 0;
|
||||
}
|
||||
.door-position text {
|
||||
font:
|
||||
11px Arial,
|
||||
sans-serif;
|
||||
fill: #17263f;
|
||||
}
|
||||
.cut-dialog-body {
|
||||
font-size: 14px;
|
||||
gap: 14px;
|
||||
}
|
||||
.cut-origin {
|
||||
font-size: 14px;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.exact-preview figcaption {
|
||||
font:
|
||||
13px Arial,
|
||||
sans-serif;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.cut-dialog-body .helper {
|
||||
font-size: 13px;
|
||||
}
|
||||
.print-plan {
|
||||
display: none;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 12mm;
|
||||
}
|
||||
@media print {
|
||||
.cut-thumbnail {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
:root {
|
||||
background: white;
|
||||
font-size: 11px;
|
||||
}
|
||||
.cut-preview-button {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
body {
|
||||
color: #17263f;
|
||||
}
|
||||
.app-header,
|
||||
.app-main {
|
||||
display: none !important;
|
||||
}
|
||||
.print-plan {
|
||||
display: block;
|
||||
}
|
||||
.print-title {
|
||||
margin-bottom: 8mm;
|
||||
}
|
||||
.print-title h1 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 3mm;
|
||||
}
|
||||
.print-title p {
|
||||
margin-top: 2mm;
|
||||
}
|
||||
.print-room {
|
||||
break-before: page;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.print-title + .print-room {
|
||||
break-before: auto;
|
||||
}
|
||||
.print-room > h2 {
|
||||
margin: 5mm 0;
|
||||
font-size: 19px;
|
||||
}
|
||||
.print-room > h2 span {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.print-room .drawing-card {
|
||||
border: 0;
|
||||
}
|
||||
.print-room .drawing-header,
|
||||
.print-room .drawing-legend {
|
||||
display: none;
|
||||
}
|
||||
.print-room .drawing-viewport {
|
||||
border: 0;
|
||||
background: white;
|
||||
}
|
||||
.print-room .room-svg {
|
||||
width: 100%;
|
||||
max-height: 190mm;
|
||||
}
|
||||
.print-material {
|
||||
break-before: page;
|
||||
columns: 2;
|
||||
column-gap: 5mm;
|
||||
column-fill: auto;
|
||||
}
|
||||
.print-material > h2 {
|
||||
column-span: all;
|
||||
font-size: 22px;
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
.print-stock {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
.print-stock > h3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5mm;
|
||||
border-block: 1px solid #9baabd;
|
||||
padding: 2mm 0;
|
||||
margin-bottom: 2mm;
|
||||
font-size: 12px;
|
||||
break-after: avoid;
|
||||
}
|
||||
.print-stock h3 span {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.print-cut {
|
||||
min-width: 0;
|
||||
break-inside: avoid;
|
||||
margin-bottom: 3mm;
|
||||
}
|
||||
.print-cut h4 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2mm;
|
||||
margin: 0 0 2mm;
|
||||
font-size: 13px;
|
||||
}
|
||||
.print-cut h4 span {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.print-cut p {
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.print-cut .exact-preview > .flooring-preview {
|
||||
height: 28mm;
|
||||
background: white;
|
||||
}
|
||||
.print-cut .exact-preview figcaption {
|
||||
font-size: 10px;
|
||||
margin: 1mm 0;
|
||||
}
|
||||
.print-cut .skirting-full-preview svg {
|
||||
height: 18mm;
|
||||
}
|
||||
.print-cut .exact-preview .skirting-end-grid {
|
||||
max-width: 38mm;
|
||||
margin: 2mm auto;
|
||||
}
|
||||
.print-cut .helper {
|
||||
font-size: 10px;
|
||||
}
|
||||
.print-plan * {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue