From 9a9c141e87fff0306b03a095036fe6caea1daccc Mon Sep 17 00:00:00 2001 From: Julius van der Vaart Date: Fri, 3 Jul 2026 22:08:50 +0200 Subject: [PATCH 1/2] Configurable dog-bones for inside slot corners Add a CNC bit diameter setting; every interior notch corner gets a minimal 45-degree dog-bone (per fablab.ruc.dk/more-elegant-cnc-dogbones): a bit-radius circle centred r into the slot on the corner bisector, passing exactly through the corner point, joined to the walls sqrt(2)*r from the vertex by a semicircular arc. Diameter 0 disables them. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 1 + src/components/TableEditor.tsx | 7 +++++++ src/components/TablePropEditor.tsx | 1 + src/components/TestParts.tsx | 12 +++++++---- src/components/XSpar.tsx | 18 +++++++++++----- src/components/YSpar.tsx | 17 +++++++++------ src/lib/dogbone.ts | 33 ++++++++++++++++++++++++++++++ src/models/Table.ts | 13 +++++++++++- 8 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 src/lib/dogbone.ts diff --git a/src/App.tsx b/src/App.tsx index 04c6c04..beb9293 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ function App() { 0.5, 0, 0.25, + 0.125, "in", "LR4", )); diff --git a/src/components/TableEditor.tsx b/src/components/TableEditor.tsx index 2a3fdda..742abdf 100644 --- a/src/components/TableEditor.tsx +++ b/src/components/TableEditor.tsx @@ -22,6 +22,7 @@ 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" } } @@ -94,6 +95,12 @@ export default function TableEditor(props: TableEditorProps) { table={props.table} updateTable={props.updateTable} /> +
{ svg() { const thickness = this.props.table.thickness; const material = this.props.table.material; + const r = this.props.table.dogBoneRadius; + const upDepth = (thickness / 2) - (thickness / 50); + const downDepth = (thickness / 2) + (thickness / 50); let pathstr = `M 0 0` pathstr += `L ${material * 3} 0` pathstr += `L ${material * 3} ${thickness}` pathstr += `L ${material * 2} ${thickness}` - pathstr += `L ${material * 2} ${(thickness / 2) - (thickness / 50)}` - pathstr += `L ${material} ${(thickness / 2) - (thickness / 50)}` + pathstr += dogBoneCorner(material * 2, upDepth, 0, -1, -1, 0, r) + pathstr += dogBoneCorner(material, upDepth, -1, 0, 0, 1, r) pathstr += `L ${material} ${thickness}` pathstr += `L 0 ${thickness}` pathstr += 'z' pathstr += `M ${material * 3.5} 0` pathstr += `L ${material * 4.5} 0` - pathstr += `L ${material * 4.5} ${(thickness / 2) + (thickness / 50)}` - pathstr += `L ${material * 5.5} ${(thickness / 2) + (thickness / 50)}` + pathstr += dogBoneCorner(material * 4.5, downDepth, 0, 1, 1, 0, r) + pathstr += dogBoneCorner(material * 5.5, downDepth, 1, 0, 0, -1, r) pathstr += `L ${material * 5.5} 0` pathstr += `L ${material * 6.5} 0` pathstr += `L ${material * 6.5} ${thickness}` diff --git a/src/components/XSpar.tsx b/src/components/XSpar.tsx index f28e21c..9964745 100644 --- a/src/components/XSpar.tsx +++ b/src/components/XSpar.tsx @@ -1,6 +1,7 @@ import { SVG } from '@svgdotjs/svg.js'; import { SVGComponent, SVGProps } from './SVGComponent'; import { Table } from '../models/Table'; +import { dogBoneCorner } from '../lib/dogbone'; interface XSparProps extends SVGProps { table: Table, @@ -19,6 +20,8 @@ export default class XSpar extends SVGComponent { const yMortises = this.props.table.ySparCount; const overhang = this.props.table.overhang; const yGap = this.props.table.ySparGap; + const r = this.props.table.dogBoneRadius; + const depth = (thickness / 2) + (thickness / 50); let pathstr = ''; const start = flatBuffer + flatOutsideBuffer + overhang - (xShrink / 2); @@ -31,13 +34,18 @@ export default class XSpar extends SVGComponent { for (let mortise = 0; mortise < yMortises; mortise++) { const x = (mortise * yGap) + start; if (x != 0) { - pathstr += `L ${x} 0`; + pathstr += `L ${x} 0`; + // bottom-left corner: down the left wall, relieve, across the base + pathstr += dogBoneCorner(x, depth, 0, 1, 1, 0, r); + } else { + pathstr += `L ${x} ${depth}`; } - pathstr += `L ${x} ${(thickness / 2) + (thickness / 50)}`; - pathstr += `L ${x + material} ${(thickness / 2) + (thickness / 50)}`; - if (x + material != xCut) { - pathstr += `L ${x + material} 0`; + // bottom-right corner: across the base, relieve, up the right wall + pathstr += dogBoneCorner(x + material, depth, 1, 0, 0, -1, r); + pathstr += `L ${x + material} 0`; + } else { + pathstr += `L ${x + material} ${depth}`; } } diff --git a/src/components/YSpar.tsx b/src/components/YSpar.tsx index a70cdb9..e3ba6b0 100644 --- a/src/components/YSpar.tsx +++ b/src/components/YSpar.tsx @@ -1,6 +1,7 @@ import { SVG } from '@svgdotjs/svg.js'; import { SVGComponent, SVGProps } from './SVGComponent'; import { Table } from '../models/Table'; +import { dogBoneCorner } from '../lib/dogbone'; interface YSparProps extends SVGProps { table: Table, @@ -14,17 +15,21 @@ export default class YSpar extends SVGComponent { const material = this.props.table.material; const xMortises = this.props.table.xSparCount - 1; const xGap = this.props.table.xSparGap; + const r = this.props.table.dogBoneRadius; + const depth = (thickness / 2) - (thickness / 50); let pathstr = `M 0 0` - pathstr += `L 0 ${(thickness / 2) - (thickness / 50)}`; + pathstr += `L 0 ${depth}`; for (let mortise = 0; mortise < xMortises; mortise++) { const x = (mortise * xGap); - pathstr += `L ${x + material} ${(thickness / 2) - (thickness / 50)}`; - pathstr += `L ${x + material} ${thickness}`; - pathstr += `L ${x + xGap} ${thickness}`; - pathstr += `L ${x + xGap} ${(thickness / 2) - (thickness / 50)}`; + // floor -> right corner -> down the wall + pathstr += dogBoneCorner(x + material, depth, 1, 0, 0, 1, r); + pathstr += `L ${x + material} ${thickness}`; + pathstr += `L ${x + xGap} ${thickness}`; + // up the wall -> left corner -> floor + pathstr += dogBoneCorner(x + xGap, depth, 0, -1, 1, 0, r); } - pathstr += `L ${yCut} ${(thickness / 2) - (thickness / 50)}` + pathstr += `L ${yCut} ${depth}` pathstr += `L ${yCut} 0` pathstr += 'z' diff --git a/src/lib/dogbone.ts b/src/lib/dogbone.ts new file mode 100644 index 0000000..a29d643 --- /dev/null +++ b/src/lib/dogbone.ts @@ -0,0 +1,33 @@ +// Emits the path commands for one inside corner at vertex (vx, vy), integrating +// a minimal 45deg dog-bone (per https://fablab.ruc.dk/more-elegant-cnc-dogbones/): +// a circle of the bit radius r is centred r into the slot along the corner +// bisector, so it passes exactly through the corner point. The outline joins the +// circle sqrt(2)*r before the corner, sweeps the semicircle through the corner +// point, and rejoins the other wall sqrt(2)*r past it. The relief bulges only +// ~0.29*r past each wall - the least material removed while letting the mating +// square corner seat fully. +// +// (inDx, inDy) is the unit direction the outline travels *into* the corner; +// (outDx, outDy) is the unit direction it leaves along. +// +// r <= 0 returns a plain corner (`L vx vy`), so dog-bones can be disabled. +export function dogBoneCorner( + vx: number, vy: number, + inDx: number, inDy: number, + outDx: number, outDy: number, + r: number, +): string { + if (r <= 0) return `L ${vx} ${vy} `; + + // The circle through the corner crosses each wall sqrt(2)*r from the vertex. + const setback = Math.SQRT2 * r; + const ax = vx - inDx * setback; + const ay = vy - inDy * setback; + const bx = vx + outDx * setback; + const by = vy + outDy * setback; + + // Semicircle through the corner point, swept towards the material side. + const sweep = (inDx * outDy - inDy * outDx) > 0 ? 1 : 0; + + return `L ${ax} ${ay} A ${r} ${r} 0 0 ${sweep} ${bx} ${by} `; +} diff --git a/src/models/Table.ts b/src/models/Table.ts index ed387f3..0dff882 100644 --- a/src/models/Table.ts +++ b/src/models/Table.ts @@ -16,6 +16,7 @@ export interface TableEditable { flatInsideBuffer: number; railOutsideBuffer: number; railInsideBuffer: number; + bitDiameter: number; } export class Table implements TableEditable { @@ -33,6 +34,7 @@ export class Table implements TableEditable { flatInsideBuffer: number; railOutsideBuffer: number; railInsideBuffer: number; + bitDiameter: number; units: Units; configuration: Configuration; @@ -51,7 +53,8 @@ export class Table implements TableEditable { flatInsideBuffer: number, railOutsideBuffer: number, railInsideBuffer: number, - units: Units, + bitDiameter: number, + units: Units, configuration: Configuration ) { this.xCut = xCut; @@ -68,6 +71,7 @@ export class Table implements TableEditable { this.flatInsideBuffer = flatInsideBuffer; this.railOutsideBuffer = railOutsideBuffer; this.railInsideBuffer = railInsideBuffer; + this.bitDiameter = bitDiameter; this.units = units; this.configuration = configuration; } @@ -220,6 +224,10 @@ export class Table implements TableEditable { }[this.units]; } + get dogBoneRadius(): number { + return this.bitDiameter / 2; + } + get inMillimeters(): Table { const convert = { "mm": (x: number) => x, @@ -242,6 +250,7 @@ export class Table implements TableEditable { convert(this.flatInsideBuffer), convert(this.railOutsideBuffer), convert(this.railInsideBuffer), + convert(this.bitDiameter), "mm", this.configuration, ) @@ -269,6 +278,7 @@ export class Table implements TableEditable { convert(this.flatInsideBuffer), convert(this.railOutsideBuffer), convert(this.railInsideBuffer), + convert(this.bitDiameter), "cm", this.configuration, ) @@ -296,6 +306,7 @@ export class Table implements TableEditable { convert(this.flatInsideBuffer), convert(this.railOutsideBuffer), convert(this.railInsideBuffer), + convert(this.bitDiameter), "in", this.configuration, ) From 881468875ccdfa2be381f930f63eb8dbe44d5ffa Mon Sep 17 00:00:00 2001 From: Julius van der Vaart Date: Fri, 3 Jul 2026 22:09:00 +0200 Subject: [PATCH 2/2] Export SVG at real-world scale The downloaded SVG now sets width/height from the viewBox with the active unit suffix, so 1 user unit maps to 1 unit of length (1px = 1mm when exporting in mm). The on-screen responsive SVG is untouched; the download serializes a clone with fixed dimensions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 2 +- src/components/SVGDownloadButton.tsx | 49 ++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index beb9293..afc5b1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ function App() {
- +

Calibration square is {table.calibrationSquareSize} {table.units}


diff --git a/src/components/SVGDownloadButton.tsx b/src/components/SVGDownloadButton.tsx index cc8ef95..28b051a 100644 --- a/src/components/SVGDownloadButton.tsx +++ b/src/components/SVGDownloadButton.tsx @@ -1,33 +1,54 @@ +import { Units } from '../models/Table'; -export default function SVGDownloadButton(props: {className: string}) { +export default function SVGDownloadButton(props: {className: string, units: Units}) { 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; - + 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.units}`); + clone.setAttribute('height', `${height}${props.units}`); + } + clone.removeAttribute('class'); + + // Serialize the cleaned-up SVG. + const svgContent = new XMLSerializer().serializeToString(clone); + // 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); }; @@ -40,8 +61,8 @@ export default function SVGDownloadButton(props: {className: string}) { onClick={handleDownload}> Download SVG - Report a bug or request a feature