diff --git a/src/App.tsx b/src/App.tsx
index 04c6c04..afc5b1e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -22,6 +22,7 @@ function App() {
0.5,
0,
0.25,
+ 0.125,
"in",
"LR4",
));
@@ -33,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
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,
)