floor-planner/src/components/CutPreview.tsx
2026-09-11 17:57:03 +01:00

296 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import { cutMm, degree } from '../lib/format';
import { skirtingEndLabel } from '../lib/cut-list';
import {
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}>
{degree(dimension.angle)}
</tspan>
</>
)}
</text>
</g>
))}
</g>
);
}
export function FlooringPreview({
piece,
compact = false,
}: {
piece: FlooringPiece;
compact?: boolean;
}) {
const template = flooringTemplate(piece);
const dimensions = compact
? undefined
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1], {
length: piece.blankLength,
width: piece.blankWidth,
});
const Wrapper = compact ? 'span' : 'figure';
return (
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
<svg
className="flooring-preview"
viewBox={viewBox(dimensions?.box ?? fullPreviewBox(piece.length, piece.width))}
preserveAspectRatio="xMidYMid meet"
aria-hidden={compact || undefined}
role={compact ? undefined : 'img'}
aria-label={compact ? undefined : `Flooring shape ${piece.id}`}
>
<polygon
points={template.map((point) => `${point.x},${point.y}`).join(' ')}
fill="#dbeafe"
stroke="#2563eb"
strokeWidth={compact ? 1 : 1.5}
vectorEffect="non-scaling-stroke"
/>
{dimensions && <Dimensions dimensions={dimensions.dimensions} font={dimensions.font} />}
</svg>
{!compact && (
<figcaption>
{cutMm(piece.length, piece.blankLength)} × {cutMm(piece.width, piece.blankWidth)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
</figcaption>
)}
</Wrapper>
);
}
export function SkirtingPreview({
piece,
thickness,
compact = false,
}: {
piece: SkirtingPiece;
thickness: number;
compact?: boolean;
}) {
const template = skirtingTemplate(piece, thickness);
const Wrapper = compact ? 'span' : 'div';
const EndWrapper = compact ? 'span' : 'figure';
const Grid = compact ? 'span' : 'div';
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
const boxes = skirtingEndBoxes(template, thickness, piece.installedTemplate);
const dimensions = compact
? undefined
: dimensionedOutline(
[template[0], template[1], { x: piece.length, y: thickness }, { x: 0, y: thickness }],
[0, 2],
{ length: piece.blankLength, width: thickness },
);
// 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,
type: piece.leftType,
installedBack: piece.installedTemplate?.[0],
installedFront: piece.installedTemplate?.[3],
},
{
side: 'right',
back: template[1],
front: template[2],
box: boxes.right,
type: piece.rightType,
installedBack: piece.installedTemplate?.[1],
installedFront: piece.installedTemplate?.[2],
},
] as const;
return (
<Wrapper className={`skirting-preview ${compact ? 'compact-preview' : 'exact-preview'}`}>
{!compact && (
<figure className="skirting-full-preview">
<svg
viewBox={viewBox(dimensions!.box)}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={`Full skirting shape ${piece.id}`}
>
<polygon
points={points}
fill="#ffedd5"
stroke="#c2410c"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg>
<figcaption>
{cutMm(piece.length, piece.blankLength)} × {cutMm(thickness, thickness)} mm · wall side
above
{(piece.leftType === 'cope' || piece.rightType === 'cope') &&
' · preparation before coping'}
</figcaption>
</figure>
)}
<Grid className="skirting-end-grid">
{ends.map((end, index) => (
<Fragment key={end.side}>
<EndWrapper className={`skirting-end ${end.side}-end`}>
<svg
className="skirting-end-preview"
data-end={end.side}
viewBox={viewBox(end.box)}
preserveAspectRatio="xMidYMid meet"
overflow="hidden"
aria-hidden={compact || undefined}
role={compact ? undefined : 'img'}
aria-label={
compact
? undefined
: `${end.side === 'left' ? 'Left' : 'Right'} ${end.type === 'cope' ? 'cope preparation' : 'mitre'} close-up ${piece.id}`
}
>
<polygon
points={points}
fill="#ffedd5"
stroke="#c2410c"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<line
className="skirting-cut-edge"
data-end={end.side}
x1={end.back.x}
y1={end.back.y}
x2={end.front.x}
y2={end.front.y}
stroke="#9a3412"
strokeWidth={compact ? 1.5 : 2.5}
vectorEffect="non-scaling-stroke"
/>
{end.type === 'cope' && end.installedBack && end.installedFront && (
<line
className="skirting-cope-body"
data-end={end.side}
x1={end.installedBack.x}
y1={end.installedBack.y}
x2={end.installedFront.x}
y2={end.installedFront.y}
stroke="#9a3412"
strokeWidth={1.5}
strokeDasharray="3 2"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{!compact && (
<figcaption>
{index === 0 ? 'Left' : 'Right'} · {skirtingEndLabel(piece, end.side)}
</figcaption>
)}
</EndWrapper>
{index === 0 && (
<span className="board-break" aria-hidden="true">
//
</span>
)}
</Fragment>
))}
</Grid>
</Wrapper>
);
}