)
}
diff --git a/src/components/SVGDownloadButton.tsx b/src/components/SVGDownloadButton.tsx
index cc8ef95..f394f6f 100644
--- a/src/components/SVGDownloadButton.tsx
+++ b/src/components/SVGDownloadButton.tsx
@@ -1,52 +1,47 @@
+import { Table } from '../models/Table';
+import { downloadBlob } from '../lib/download';
+import { exportFilename } from '../lib/filename';
+import { buttonClasses } from '../lib/styles';
-export default function SVGDownloadButton(props: {className: string}) {
+export default function SVGDownloadButton(props: {className: string, table: Table}) {
const handleDownload = () => {
- // Find the SVG element
- const svgElement = document.getElementById(props.className);
-
- if (!svgElement) {
+ // Find the container holding the on-screen SVG.
+ const container = document.getElementById(props.className);
+
+ if (!container) {
console.error('SVG element not found');
return;
}
- // Get the SVG content
- const svgContent = svgElement.innerHTML;
-
- // Create a Blob from the SVG content
- const blob = new Blob([svgContent], { type: 'image/svg+xml' });
-
- // Create a URL for the Blob
- const url = URL.createObjectURL(blob);
-
- // Create a temporary link element
- const link = document.createElement('a');
- link.href = url;
- link.download = 'layout.svg';
-
- // Append link to body, click it, and remove it
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- // Clean up the URL object
- URL.revokeObjectURL(url);
+ const svg = container.querySelector('svg');
+
+ if (!svg) {
+ console.error('SVG element not found');
+ return;
+ }
+
+ // Clone so we can give the exported file a real-world size without
+ // touching the responsive on-screen version. Setting width/height with
+ // the unit suffix makes 1 user unit = 1 unit of length, so drawings
+ // exported in mm come out at 1px = 1mm.
+ const clone = svg.cloneNode(true) as SVGSVGElement;
+ const viewBox = clone.getAttribute('viewBox');
+ if (viewBox) {
+ const [, , width, height] = viewBox.split(/\s+/).map(Number);
+ clone.setAttribute('width', `${width}${props.table.units}`);
+ clone.setAttribute('height', `${height}${props.table.units}`);
+ }
+ clone.removeAttribute('class');
+
+ // Serialize the cleaned-up SVG.
+ const svgContent = new XMLSerializer().serializeToString(clone);
+
+ downloadBlob(new Blob([svgContent], { type: 'image/svg+xml' }), exportFilename(props.table, 'svg'));
};
return (
- <>
-
-
- >
);
};
diff --git a/src/components/SideRail.tsx b/src/components/SideRail.tsx
index 0c3e20d..36c3a16 100644
--- a/src/components/SideRail.tsx
+++ b/src/components/SideRail.tsx
@@ -28,7 +28,8 @@ export default class SideRail extends SVGComponent {
pathstr += `M ${length - sparInset} ${thickness * 0.25}`;
pathstr += `L ${length - sparInset} ${thickness * 0.75}`;
} else {
- const buffer = material / 2;
+ // Gap between the two pieces a too-long rail is split into.
+ const buffer = this.props.table.effectivePartMargin;
let firstLength = maxLength;
const safeCutPoint = xSparGap + (4 * material) + sparInset;
if (length - firstLength < safeCutPoint) {
diff --git a/src/components/TableConfigButtons.tsx b/src/components/TableConfigButtons.tsx
new file mode 100644
index 0000000..0d2050b
--- /dev/null
+++ b/src/components/TableConfigButtons.tsx
@@ -0,0 +1,71 @@
+import { useRef } from "react";
+import { Table, parseTableSnapshot } from "../models/Table";
+import { downloadBlob } from "../lib/download";
+import { exportFilename } from "../lib/filename";
+import { smallButtonClasses } from "../lib/styles";
+
+type TableConfigButtonsProps = {
+ table: Table,
+ updateTable: (c: Table) => void,
+}
+
+// Export/import of the parameter set as JSON, so a design can be kept in a file
+// or handed to someone else. Parameters are also saved in the browser on every
+// change (see lib/storage.ts) - this is for moving them between browsers.
+export default function TableConfigButtons(props: TableConfigButtonsProps) {
+ const fileInput = useRef(null);
+
+ const handleExport = () => {
+ const json = JSON.stringify(props.table.snapshot, null, 2);
+ downloadBlob(new Blob([json], { type: "application/json" }), exportFilename(props.table, "json"));
+ };
+
+ const handleImport = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+
+ // Clear the input so picking the same file twice in a row still fires.
+ e.target.value = '';
+
+ if (!file) {
+ return;
+ }
+
+ let snapshot = null;
+ try {
+ snapshot = parseTableSnapshot(JSON.parse(await file.text()));
+ } catch {
+ alert(`${file.name} is not valid JSON.`);
+ return;
+ }
+
+ if (snapshot === null) {
+ alert(`${file.name} is not a table configuration. Export one to see the expected format.`);
+ return;
+ }
+
+ props.updateTable(Table.fromSnapshot(snapshot));
+ };
+
+ return (
+
+
+ Export JSON
+
+ {/* Amber, not slate: importing replaces the table you have open. */}
+ fileInput.current?.click()}>
+ Import JSON
+
+
+
+ );
+}
diff --git a/src/components/TableEditor.tsx b/src/components/TableEditor.tsx
index 2a3fdda..783b2d3 100644
--- a/src/components/TableEditor.tsx
+++ b/src/components/TableEditor.tsx
@@ -22,17 +22,65 @@ function propertyNameToLabel(name: keyof TableEditable): string {
case "flatOutsideBuffer": return "Flat rail outside buffer"
case "railInsideBuffer": return "Tube rail inside buffer"
case "railOutsideBuffer": return "Tube rail outside buffer"
+ case "bitDiameter": return "CNC bit diameter"
+ case "partMargin": return "Margin between parts"
}
}
+type SettingsGroup = {
+ title: string,
+ description: string,
+ fields: (keyof TableEditable)[],
+ // Groups that only affect the Lowrider 4 rails are hidden for a plain table.
+ lr4Only?: boolean,
+}
+
+// Notes shown under individual fields, for behaviour a label cannot carry.
+const fieldHints: Partial> = {
+ bitDiameter: "Set to 0 to switch off automatic dog-bones and leave inside corners square.",
+ partMargin: "Set to 0 to use 4× the bit diameter, or 20mm when the bit diameter is also 0.",
+};
+
+const settingsGroups: SettingsGroup[] = [
+ {
+ title: "Material",
+ description: "The sheet you are cutting and the tool cutting it. Slots are cut as wide as the material, the bit diameter sizes the dog-bone reliefs in the inside corners, and the margin is the clear space left between parts on the sheet.",
+ fields: ["material", "bitDiameter", "partMargin"],
+ },
+ {
+ title: "Table size",
+ description: "The finished tabletop. The spar frame underneath is inset from these dimensions by the overhang on every side, and the thickness sets how deep the spars are.",
+ fields: ["xCut", "yCut", "thickness", "overhang"],
+ },
+ {
+ title: "Spars",
+ description: "The egg-crate frame. Spars are never spaced further apart than these gaps; the spacing that comes out is divided evenly across the table, so it is usually tighter.",
+ fields: ["xSparMinGap", "ySparMinGap"],
+ },
+ {
+ title: "Rails",
+ description: "Rail stock, the longest piece your machine can cut in one go before a rail is split in two, and the spacing of the clips that hold the rails down.",
+ fields: ["railMaterialThickness", "trackCutPoint", "clipMinGap"],
+ lr4Only: true,
+ },
+ {
+ title: "Track fit",
+ description: "Padding added to the flat and tube track cut-outs. Increase these if the machine binds on the rails, decrease them if it has play.",
+ fields: ["flatInsideBuffer", "flatOutsideBuffer", "railInsideBuffer", "railOutsideBuffer"],
+ lr4Only: true,
+ },
+];
+
function updateUnits(table: Table, target: Units): Table {
return {
- "mm": table.inMillimeters,
+ "mm": table.inMillimeters,
"cm": table.inCentimeters,
"in": table.inInches,
}[target];
}
+const selectClasses = "block w-full text-sm text-gray-900 bg-gray-50 border border-gray-300 rounded-lg p-1.5 focus:ring-blue-500 focus:border-blue-500";
+
export default function TableEditor(props: TableEditorProps) {
const updateConfiguration = (configuration: Configuration) => {
let overhang = 0;
@@ -44,138 +92,69 @@ export default function TableEditor(props: TableEditorProps) {
}[props.table.units];
}
- props.table.overhang = overhang;
- props.table.configuration = configuration;
-
- props.updateTable(props.table)
+ // A fresh Table, not a mutation of the current one: React bails out of
+ // re-rendering if it is handed back the same object.
+ props.updateTable(Table.fromSnapshot({
+ ...props.table.snapshot,
+ overhang,
+ configuration,
+ }))
};
+ const groups = settingsGroups.filter(
+ (group) => !group.lr4Only || props.table.configuration == "LR4"
+ );
return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
Machine
+
+ What you are building for, and the units every dimension below is
+ given in. Switching units converts the values you have already
+ entered, rounding to something cuttable.
+
+
+
+
+
+ {groups.map((group) => (
+
+
{group.title}
+
{group.description}
+ {group.fields.map((field) => (
+
+ ))}
+
+ ))}
- >
)
-}
\ No newline at end of file
+}
diff --git a/src/components/TableLayout.tsx b/src/components/TableLayout.tsx
index 286bd03..2ed105b 100644
--- a/src/components/TableLayout.tsx
+++ b/src/components/TableLayout.tsx
@@ -16,7 +16,8 @@ export default function TableLayout(props: TableLayoutProps) {
const strokeWidth = props.strokeWidth;
const tableThickness = props.table.thickness;
const materialThickness = props.table.material
- const kerfWidth = materialThickness / 2;
+ // Clear space between neighbouring parts on the sheet.
+ const kerfWidth = props.table.effectivePartMargin;
const yCut = props.table.yCut;
const flatTrackWidth = props.table.flatTrackWidth;
const railTrackWidth = props.table.railTrackWidth;
@@ -44,9 +45,6 @@ export default function TableLayout(props: TableLayoutProps) {
}
const firstRail = ((kerfWidth + tableThickness) * (xSparCount + ySparCount)) + (strokeWidth / 2);
- const width = yCut + strokeWidth + yBuffer;
- const height = (tableThickness + strokeWidth) * (ySparCount + xSparCount) + (4 * kerfWidth) + (2 * tableThickness) + flatTrackWidth + railTrackWidth;
- const viewBox = `0 0 ${width} ${height * 1.5}`
const rails = [];
if (configuration == "LR4") {
@@ -63,14 +61,32 @@ export default function TableLayout(props: TableLayoutProps) {
const calibrationSquareX = (strokeWidth / 2) + (7 * materialThickness);
+ // Exact extent of the drawn parts, so the view fits them with no dead space.
+ // Rails that are too long for the sheet are split into two pieces, the
+ // second of which sits a kerf further right than the rail's own length.
+ const railLength = yCut + yBuffer;
+ const railSplitBuffer = railLength > props.table.trackCutPoint ? kerfWidth : 0;
+ const contentWidth = railLength + railSplitBuffer + strokeWidth;
+ // The test parts and the calibration square share the last row.
+ const lastRowHeight = Math.max(tableThickness, props.table.calibrationSquareSize);
+ const contentHeight = testPartY + lastRowHeight + (strokeWidth / 2);
+
+ // Stand the sheet up: a quarter turn counter-clockwise maps (x, y) to
+ // (y, contentWidth - x), so the width and height of the view swap. Doing it
+ // here rather than in CSS keeps the SVG's aspect ratio honest, so it scales
+ // to fit its container, and keeps the downloaded file at real-world size.
+ const viewBox = `0 0 ${contentHeight} ${contentWidth}`;
+
return (
<>
-