diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e46236..4215531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Holding tabs for cutting.** Short uncut bridges that keep each piece attached to the + sheet until it is snapped out by hand — the opposite of the overcut, and wanted just as + often. Count, width and a minimum contour length are set in the knife profile; bridges are + distributed by arc length, nudged off sharp corners (where a bridge tears instead of + snapping), kept clear of the stretch the overcut re-traces, and never produce a fragment + too small to cut. Off by default. The canvas now draws the prepared cutting path — overcut + tail and tab gaps — over the faded artwork, so where a piece stays attached can be checked + before a sheet of vinyl is committed. - **Travel routes around the magnets.** Pen-up moves that would cross a keep-out zone now detour clear of it — every leg, including the first one out and the return home — and the detours are drawn on the canvas so the route can be checked before starting. The plot-time diff --git a/README.md b/README.md index 5e5e78e..e663be8 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,11 @@ unattended **Raspberry Pi** setup needs. the **knife profile** (its own Z, dwell and cut feed), places imports **1:1** at the position the file gives them, offers only outline conversions (a fill would shred a sticker), previews in cut red, and prepares the toolpath for a drag knife — an - **overcut** past the closing point so the piece releases, and optional **blade-offset - compensation** at corners (off until you've measured your holder). + **overcut** past the closing point so the piece releases, optional **blade-offset + compensation** at corners (off until you've measured your holder), and **holding tabs** — + short uncut bridges that keep each piece attached until you snap it out, placed away from + corners and clear of the overcut. The canvas shows the path the blade will follow, gaps + and all. - **Paper type.** Preview the drawing on the stock you are about to use — plain, cream, dotted, grid, lined, kraft, or black card (which previews the artwork light, for a white or metallic pen). Appearance only: it never touches the generated paths. diff --git a/openspec/changes/archive/2026-09-12-holding-tabs/.openspec.yaml b/openspec/changes/archive/2026-09-12-holding-tabs/.openspec.yaml new file mode 100644 index 0000000..2b596d1 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-holding-tabs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/archive/2026-09-12-holding-tabs/design.md b/openspec/changes/archive/2026-09-12-holding-tabs/design.md new file mode 100644 index 0000000..8164e8f --- /dev/null +++ b/openspec/changes/archive/2026-09-12-holding-tabs/design.md @@ -0,0 +1,38 @@ +## Context + +`prepareForCut` was two transforms in a fixed order (compensate, then overcut) on one polyline, +returning one polyline. Tabs break that shape: one contour becomes several strokes. + +## Decisions + +- **Tabs are a split, not a new concept.** The writer already puts the tool up between strokes, so + a tabbed contour is just several strokes and the bridges come out for free. Nothing downstream — + ordering, framing, the estimate — needs to know tabs exist. +- **Order: compensate → overcut → tabs, and `closed` decided once up front.** Tabs have to come + last, because after them there is no closed contour left for the overcut to recognise and it + would silently do nothing. For the same reason "was this closed?" is answered before any + transform runs and carried through: it is a property of the artwork, not of the intermediate path. +- **Tabs stay out of the stretch the overcut re-traces.** Otherwise the overcut cuts through the + first bridge — the interaction the issue flagged, and the one that would make both features look + broken at once. +- **Bridges are nudged off corners.** Even distribution by arc length is the v1 rule, but a bridge + landing on a corner tears rather than snapping, so a tab within a millimetre of a sharp turn + shuffles along the contour (bounded, so it cannot walk into its neighbour). +- **The count is reduced, not honoured blindly.** Each bridge needs cut on both sides of it; asking + for fifty on a short contour would produce a dotted line, so the number is capped by what the + contour can afford. +- **No degenerate fragments.** `orderPolylines` drops anything shorter than two points, so a + zero-length slice would vanish silently and leave an unexplained gap in the cut. Slices that + collapse are discarded explicitly instead. +- **Stroke reversal is already off when cutting** (#58), which resolves the issue's other worry: + tab positions are relative to the contour's start, and a reversed contour would move them. +- **The cut preview draws the prepared path over faded artwork.** Grey is the drawing, red is what + the knife does. Fading rather than hiding keeps the object selectable and draggable. + +## Risks / Trade-offs + +- Tab width is a length along the path, not a chord: on a tight curve the bridge is very slightly + longer than the straight-line gap. At 0.5 mm the difference is far below the kerf. +- A tab is placed by arc length from the contour's start, which is wherever the importer began the + path. Two visually identical contours can therefore have bridges in different places. Consistent + placement would need a canonical start point, which is a bigger idea than this needs. diff --git a/openspec/changes/archive/2026-09-12-holding-tabs/proposal.md b/openspec/changes/archive/2026-09-12-holding-tabs/proposal.md new file mode 100644 index 0000000..0068e0f --- /dev/null +++ b/openspec/changes/archive/2026-09-12-holding-tabs/proposal.md @@ -0,0 +1,43 @@ +## Why + +A closed contour is severed the moment its loop closes — and the overcut added in #58 exists to +make sure of it. That is right for the last piece and wrong for every piece before it: + +- a freed piece can shift or lift under the drag knife while later contours are still being cut; +- pieces come loose during the job rather than at weeding time, so the sheet cannot be handled as + one until it is finished; +- there is no way to cut a part that should stay put — a hinged flap, or something to be snapped + out by hand later. + +Overcut and tabs are opposite controls, and both are wanted, per job. + +## What Changes + +- **`src/plot/tabs.ts`** — a pure transform: a closed cut path in, the strokes that should actually + be cut out, with the bridge spans omitted. No new idea is needed downstream, because the G-code + writer already lifts the tool between strokes. +- **Tab settings in the knife profile**: count, width (default 0.5 mm), and a minimum contour + length below which a contour gets none. **Off by default** — a cut with no tabs is what the + machine did before, and a piece that unexpectedly stays attached is as surprising as one that + does not. +- **Bridges avoid sharp corners**, where a bridge tears instead of snapping, by nudging along the + contour until they sit on a straight span. +- **Bridges keep clear of the overcut**, which re-traces the start of the path — a tab there would + be cut straight through, making both features look broken at once. +- **The canvas shows the prepared cut path**: the overcut tail and the tab gaps, drawn over faded + artwork, because where a piece stays attached is exactly what has to be checked before a sheet of + vinyl is committed. + +## Capabilities + +### Modified Capabilities +- `cutting-mode`: cut contours can be interrupted by holding tabs, and the preview shows them. + +## Impact + +- **Code:** new `src/plot/tabs.ts` (+ tests); `src/plot/cut.ts`, `src/plot/knife.ts` (via + `CutOptions`), `src/ui/SettingsPage.tsx`, `src/ui/PlotCanvas.tsx`, `src/ui/App.tsx`. +- **Behaviour:** with tabs off, `prepareForCut` produces exactly what it did before, pinned by a + test. Drawing mode is untouched. +- **Hardware:** unverified — a real sticker has to confirm the piece stays put during the job and + snaps out cleanly afterwards. diff --git a/openspec/changes/archive/2026-09-12-holding-tabs/specs/cutting-mode/spec.md b/openspec/changes/archive/2026-09-12-holding-tabs/specs/cutting-mode/spec.md new file mode 100644 index 0000000..e047492 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-holding-tabs/specs/cutting-mode/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Holding tabs keep a cut piece attached + +Cutting SHALL be able to leave short uncut bridges in a closed contour, so the piece stays attached +to the sheet until it is snapped out by hand. The number of bridges, their width, and the contour +length below which none are placed SHALL be configurable, and bridges SHALL be off by default. + +#### Scenario: Cutting with tabs + +- **WHEN** a closed contour is cut with tabs enabled +- **THEN** the contour is cut as several strokes separated by uncut bridges of the configured width + +#### Scenario: Off by default + +- **WHEN** the operator has not enabled tabs +- **THEN** contours are cut exactly as they were before the feature existed + +#### Scenario: Small detail is not bridged + +- **WHEN** a contour is shorter than the configured minimum +- **THEN** it is cut without tabs, because a bridge as long as the shape destroys it + +#### Scenario: More tabs than the contour can hold + +- **WHEN** more bridges are asked for than the contour can carry with cut between them +- **THEN** the number is reduced, rather than the contour being cut as a dotted line + +### Requirement: Bridges are placed where they will hold + +Bridges SHALL be distributed around the contour, SHALL avoid sharp corners, and SHALL NOT be placed +where an overcut would cut through them. + +#### Scenario: Away from corners + +- **WHEN** a contour has sharp corners +- **THEN** no bridge is placed on one, because a bridge on a corner tears instead of snapping + +#### Scenario: Clear of the overcut + +- **WHEN** both an overcut and tabs are enabled +- **THEN** no bridge lies in the stretch the overcut re-traces, so the overcut cannot cut through it + +#### Scenario: Every piece is drawable + +- **WHEN** any combination of tab count and width is used +- **THEN** every resulting stroke is one the machine can cut, with no degenerate fragments + +### Requirement: The preview shows what the blade will follow + +In cutting mode the canvas SHALL show the prepared cutting path — including the overcut and the +gaps left by holding tabs — distinctly from the artwork it came from. + +#### Scenario: Seeing where the piece stays attached + +- **WHEN** tabs are enabled +- **THEN** the gaps are visible on the canvas before the job is started diff --git a/openspec/changes/archive/2026-09-12-holding-tabs/tasks.md b/openspec/changes/archive/2026-09-12-holding-tabs/tasks.md new file mode 100644 index 0000000..a32144a --- /dev/null +++ b/openspec/changes/archive/2026-09-12-holding-tabs/tasks.md @@ -0,0 +1,26 @@ +## 1. Transform + +- [x] 1.1 `src/plot/tabs.ts`: `applyTabs`, arc-length distribution, corner avoidance, count capping +- [x] 1.2 Unit tests: off by default, span count, exact width removed, even spacing, open path + untouched, short contour untouched, absurd count capped, no degenerate fragments across many + count/width combinations, bridges off corners, bridges clear of the overcut + +## 2. Integration + +- [x] 2.1 `CutOptions` extends `TabOptions`; `prepareForCut` applies compensate → overcut → tabs + with `closed` decided up front +- [x] 2.2 Tests: order preserved, unchanged output with tabs off, open geometry still one stroke + +## 3. UI + +- [x] 3.1 Tab count, width and minimum contour in the knife profile on the settings page +- [x] 3.2 Canvas draws the prepared cut path (overcut + gaps) over faded artwork + +## 4. Docs, gate, verification + +- [x] 4.1 README, CHANGELOG +- [x] 4.2 `mise run ci` green +- [x] 4.3 Verified in the browser: 4 tabs of 2 mm on a rectangle produce 5 cut spans in the emitted + G-code, with gaps measuring exactly 2.00 mm, evenly spread, each ≥130 mm from a corner +- [ ] 4.4 ⚙ HARDWARE: cut a real sticker with tabs on — confirm the piece stays attached during the + job and snaps out cleanly by hand diff --git a/openspec/specs/cutting-mode/spec.md b/openspec/specs/cutting-mode/spec.md index 1a5fd7d..0072741 100644 --- a/openspec/specs/cutting-mode/spec.md +++ b/openspec/specs/cutting-mode/spec.md @@ -109,3 +109,59 @@ The canvas SHALL draw a cutting job's geometry as cut lines rather than in a pen - **WHEN** the job is in cutting mode - **THEN** the artwork is drawn in the cut colour, and pen selection is not offered + +### Requirement: Holding tabs keep a cut piece attached + +Cutting SHALL be able to leave short uncut bridges in a closed contour, so the piece stays attached +to the sheet until it is snapped out by hand. The number of bridges, their width, and the contour +length below which none are placed SHALL be configurable, and bridges SHALL be off by default. + +#### Scenario: Cutting with tabs + +- **WHEN** a closed contour is cut with tabs enabled +- **THEN** the contour is cut as several strokes separated by uncut bridges of the configured width + +#### Scenario: Off by default + +- **WHEN** the operator has not enabled tabs +- **THEN** contours are cut exactly as they were before the feature existed + +#### Scenario: Small detail is not bridged + +- **WHEN** a contour is shorter than the configured minimum +- **THEN** it is cut without tabs, because a bridge as long as the shape destroys it + +#### Scenario: More tabs than the contour can hold + +- **WHEN** more bridges are asked for than the contour can carry with cut between them +- **THEN** the number is reduced, rather than the contour being cut as a dotted line + +### Requirement: Bridges are placed where they will hold + +Bridges SHALL be distributed around the contour, SHALL avoid sharp corners, and SHALL NOT be placed +where an overcut would cut through them. + +#### Scenario: Away from corners + +- **WHEN** a contour has sharp corners +- **THEN** no bridge is placed on one, because a bridge on a corner tears instead of snapping + +#### Scenario: Clear of the overcut + +- **WHEN** both an overcut and tabs are enabled +- **THEN** no bridge lies in the stretch the overcut re-traces, so the overcut cannot cut through it + +#### Scenario: Every piece is drawable + +- **WHEN** any combination of tab count and width is used +- **THEN** every resulting stroke is one the machine can cut, with no degenerate fragments + +### Requirement: The preview shows what the blade will follow + +In cutting mode the canvas SHALL show the prepared cutting path — including the overcut and the +gaps left by holding tabs — distinctly from the artwork it came from. + +#### Scenario: Seeing where the piece stays attached + +- **WHEN** tabs are enabled +- **THEN** the gaps are visible on the canvas before the job is started diff --git a/src/plot/__tests__/cut.test.ts b/src/plot/__tests__/cut.test.ts index 005be76..05d8368 100644 --- a/src/plot/__tests__/cut.test.ts +++ b/src/plot/__tests__/cut.test.ts @@ -168,19 +168,29 @@ describe('prepareForCut', () => { it('compensates first, then overcuts the compensated path', () => { // Order matters: the overcut has to re-trace the path the blade actually // follows, which is the compensated one. - const [out] = prepareForCut([square], { overcutMm: 2, bladeOffsetMm: 0.5 }); + const [out] = prepareForCut([square], { + ...DEFAULT_CUT_OPTIONS, + overcutMm: 2, + bladeOffsetMm: 0.5, + }); const compensated = applyBladeOffset(square, 0.5); expect(out.slice(0, compensated.length)).toEqual(compensated); expect(polylineLength(out)).toBeCloseTo(polylineLength(compensated) + 2, 6); }); it('passes open geometry through with corner compensation only', () => { - const [out] = prepareForCut([openLine], { overcutMm: 2, bladeOffsetMm: 0 }); + const [out] = prepareForCut([openLine], { + ...DEFAULT_CUT_OPTIONS, + overcutMm: 2, + bladeOffsetMm: 0, + }); expect(out).toEqual(openLine); }); it('leaves everything alone with both features off', () => { const polys = [square, openLine]; - expect(prepareForCut(polys, { overcutMm: 0, bladeOffsetMm: 0 })).toEqual(polys); + expect( + prepareForCut(polys, { ...DEFAULT_CUT_OPTIONS, overcutMm: 0, bladeOffsetMm: 0 }), + ).toEqual(polys); }); }); diff --git a/src/plot/__tests__/tabs.test.ts b/src/plot/__tests__/tabs.test.ts new file mode 100644 index 0000000..f7dfc6b --- /dev/null +++ b/src/plot/__tests__/tabs.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { applyTabs, DEFAULT_TABS, type ApplyTabsOptions } from '../tabs'; +import { DEFAULT_CUT_OPTIONS, polylineLength, prepareForCut } from '../cut'; +import type { Polyline } from '../types'; + +/** A closed 100 mm square: 400 mm of contour, four sharp corners. */ +const square: Polyline = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + { x: 0, y: 0 }, +]; + +/** A closed circle, flattened — no corners anywhere. */ +const circle: Polyline = (() => { + const pts: Polyline = []; + for (let i = 0; i <= 120; i++) { + const t = (i / 120) * Math.PI * 2; + pts.push({ x: 50 * Math.cos(t), y: 50 * Math.sin(t) }); + } + return pts; +})(); + +const opts = (over: Partial = {}): ApplyTabsOptions => ({ + ...DEFAULT_TABS, + tabCount: 4, + closed: true, + ...over, +}); + +const totalLength = (polys: Polyline[]) => polys.reduce((n, p) => n + polylineLength(p), 0); + +describe('applyTabs', () => { + it('is off by default — a cut with no tabs is what the machine did before', () => { + expect(DEFAULT_TABS.tabCount).toBe(0); + expect(applyTabs(square, opts({ tabCount: 0 }))).toEqual([square]); + }); + + it('splits a contour into one stroke per gap', () => { + const pieces = applyTabs(square, opts({ tabCount: 4, tabWidthMm: 2 })); + // Four bridges in a closed loop leave four cut spans (the first and last + // are separated by the seam, which the path already starts and ends at). + expect(pieces.length).toBe(5); + for (const p of pieces) expect(p.length).toBeGreaterThanOrEqual(2); + }); + + it('leaves exactly the configured width uncut', () => { + const width = 2; + const pieces = applyTabs(square, opts({ tabCount: 4, tabWidthMm: width })); + const removed = polylineLength(square) - totalLength(pieces); + expect(removed).toBeCloseTo(4 * width, 3); + }); + + it('spaces the bridges evenly around the contour', () => { + const pieces = applyTabs(circle, opts({ tabCount: 4, tabWidthMm: 1 })); + const lengths = pieces.map(polylineLength); + // The seam splits one span into two, so compare the whole spans only. + const middle = lengths.slice(1, -1); + for (const l of middle) expect(l).toBeCloseTo(middle[0], 1); + }); + + it('leaves an open path alone', () => { + // There is nothing to hold on: an open cut is already attached at both ends. + const open: Polyline = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]; + expect(applyTabs(open, opts({ closed: false }))).toEqual([open]); + }); + + it('leaves a contour shorter than the minimum alone', () => { + // A bridge as long as the shape destroys the detail rather than holding it. + const small: Polyline = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 0 }, + ]; + expect(applyTabs(small, opts({ minContourMm: 30 }))).toEqual([small]); + }); + + it('cuts the tab count down rather than producing a dotted line', () => { + // Each bridge needs cut on both sides of it; asking for 50 on a 400 mm + // contour with 10 mm tabs cannot be honoured. + const pieces = applyTabs(square, opts({ tabCount: 50, tabWidthMm: 10 })); + expect(pieces.length).toBeLessThan(50); + for (const p of pieces) expect(polylineLength(p)).toBeGreaterThan(0); + }); + + it('never emits a degenerate fragment', () => { + // `orderPolylines` drops anything shorter than two points, so a degenerate + // fragment would vanish silently and leave an unexplained gap in the cut. + for (const count of [1, 2, 3, 7, 11]) { + for (const width of [0.2, 0.5, 3]) { + const pieces = applyTabs(square, opts({ tabCount: count, tabWidthMm: width })); + for (const p of pieces) { + expect(p.length).toBeGreaterThanOrEqual(2); + expect(polylineLength(p)).toBeGreaterThan(0); + } + } + } + }); + + it('keeps bridges off sharp corners', () => { + // A bridge on a corner tears instead of snapping. + const pieces = applyTabs(square, opts({ tabCount: 4, tabWidthMm: 2 })); + const corners = [ + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + // Each gap is between the end of one piece and the start of the next. + for (let i = 1; i < pieces.length; i++) { + const gapStart = pieces[i - 1][pieces[i - 1].length - 1]; + const gapEnd = pieces[i][0]; + const mid = { x: (gapStart.x + gapEnd.x) / 2, y: (gapStart.y + gapEnd.y) / 2 }; + for (const c of corners) { + expect(Math.hypot(mid.x - c.x, mid.y - c.y)).toBeGreaterThan(1); + } + } + }); + + it('keeps bridges out of the stretch an overcut re-traces', () => { + // Otherwise the overcut cuts straight through the first bridge, and both + // features look broken at once. + const pieces = applyTabs(square, opts({ tabCount: 4, tabWidthMm: 2, protectStartMm: 20 })); + const firstGapStart = polylineLength(pieces[0]); + expect(firstGapStart).toBeGreaterThanOrEqual(20); + }); +}); + +describe('prepareForCut with tabs', () => { + it('applies compensation, then overcut, then tabs', () => { + const pieces = prepareForCut([square], { + ...DEFAULT_CUT_OPTIONS, + overcutMm: 5, + tabCount: 3, + tabWidthMm: 1, + }); + expect(pieces.length).toBeGreaterThan(1); + // The overcut still happened: the total cut length exceeds the contour, + // minus what the bridges removed. + expect(totalLength(pieces)).toBeCloseTo(polylineLength(square) + 5 - 3 * 1, 2); + }); + + it('is unchanged from before when tabs are off', () => { + const withTabs = prepareForCut([square], { ...DEFAULT_CUT_OPTIONS, tabCount: 0 }); + expect(withTabs).toHaveLength(1); + expect(polylineLength(withTabs[0])).toBeCloseTo( + polylineLength(square) + DEFAULT_CUT_OPTIONS.overcutMm, + 6, + ); + }); + + it('leaves open geometry as one stroke even with tabs on', () => { + const open: Polyline = [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + ]; + expect(prepareForCut([open], { ...DEFAULT_CUT_OPTIONS, tabCount: 4 })).toEqual([open]); + }); +}); diff --git a/src/plot/cut.ts b/src/plot/cut.ts index 80c7024..64c701c 100644 --- a/src/plot/cut.ts +++ b/src/plot/cut.ts @@ -10,9 +10,10 @@ * Both are geometry problems, so they are solved here, on polylines, rather than * in the G-code writer — which stays the same one the pen uses. */ +import { applyTabs, DEFAULT_TABS, type TabOptions } from './tabs'; import type { Point, Polyline } from './types'; -export interface CutOptions { +export interface CutOptions extends TabOptions { /** * How far past the closure point the blade keeps cutting, in mm. Without it * the loop does not release; with too much it cuts into the neighbouring @@ -27,6 +28,7 @@ export interface CutOptions { } export const DEFAULT_CUT_OPTIONS: CutOptions = { + ...DEFAULT_TABS, overcutMm: 1, // Off by default: a wrong offset is worse than none, and the right value has // to be measured on the actual blade holder. @@ -148,15 +150,31 @@ export function applyBladeOffset(poly: Polyline, offsetMm: number, minAngleDeg = } /** - * Prepare polylines for cutting: blade-offset compensation first, then overcut. + * Prepare polylines for cutting: blade-offset compensation, then overcut, then + * holding tabs. * - * Order matters. Compensation inserts corner overshoots, which change the - * polyline's start and end neighbourhood; applying the overcut afterwards means - * it re-traces the *compensated* path, which is the path the blade actually - * follows. + * The order is the whole design. Compensation inserts corner overshoots, which + * change the path's start and end neighbourhood, so the overcut has to come + * after it to re-trace the path the blade actually follows. Tabs come last + * because they *split* the path — after them there is no closed contour left + * for the overcut to recognise, and it would silently do nothing. + * + * Whether the contour was closed is decided once, up front, and carried + * through: it is a property of the artwork, not of the intermediate path. */ export function prepareForCut(polylines: Polyline[], opts: CutOptions): Polyline[] { - return polylines.map((poly) => - applyOvercut(applyBladeOffset(poly, opts.bladeOffsetMm), opts.overcutMm), - ); + return polylines.flatMap((poly) => { + const closed = isClosed(poly); + const compensated = applyBladeOffset(poly, opts.bladeOffsetMm); + const overcut = applyOvercut(compensated, opts.overcutMm); + return applyTabs(overcut, { + tabWidthMm: opts.tabWidthMm, + tabCount: opts.tabCount, + minContourMm: opts.minContourMm, + closed, + // Keep tabs out of the stretch the overcut re-traces, or the overcut + // cuts straight through the first bridge and both features look broken. + protectStartMm: opts.overcutMm, + }); + }); } diff --git a/src/plot/tabs.ts b/src/plot/tabs.ts new file mode 100644 index 0000000..1bb348f --- /dev/null +++ b/src/plot/tabs.ts @@ -0,0 +1,155 @@ +/** + * Holding tabs: short uncut bridges that keep a cut piece attached to the sheet. + * + * A closed contour is severed the moment its loop closes, and the overcut in + * `cut.ts` exists to make sure of it. That is right for the last piece and wrong + * for everything before it: a freed piece can shift or lift under the drag knife + * while later contours are still being cut, and the sheet cannot be handled as + * one until the job is done. Tabs are the opposite control, and both are wanted + * per job. + * + * The trick is that no new idea is needed downstream. A tabbed contour is just + * *several strokes* with gaps between them, and the G-code writer already lifts + * the tool between strokes — so this is a polylines-in, polylines-out transform, + * and the bridges come out for free. + */ +import type { Point, Polyline } from './types'; + +export interface TabOptions { + /** Length of each uncut bridge, in mm. The usual vinyl figure is ~0.5 mm. */ + tabWidthMm: number; + /** How many bridges per contour. 0 disables tabs — today's behaviour. */ + tabCount: number; + /** + * Contours shorter than this get no tabs. A bridge as long as the shape + * itself destroys small detail rather than holding it. + */ + minContourMm: number; +} + +export const DEFAULT_TABS: TabOptions = { + tabWidthMm: 0.5, + // Off by default: a cut with no tabs is what the machine did before, and a + // piece that unexpectedly stays attached is as surprising as one that does not. + tabCount: 0, + minContourMm: 30, +}; + +/** Turn sharper than this counts as a corner, where a bridge tears rather than snaps. */ +const CORNER_DEG = 35; +/** How far a tab may be nudged along the contour to get off a corner, as a fraction of its spacing. */ +const MAX_NUDGE_FRACTION = 0.4; + +const dist = (a: Point, b: Point) => Math.hypot(b.x - a.x, b.y - a.y); + +/** Cumulative arc length at each vertex. */ +function arcLengths(poly: Polyline): number[] { + const acc = [0]; + for (let i = 1; i < poly.length; i++) acc.push(acc[i - 1] + dist(poly[i - 1], poly[i])); + return acc; +} + +/** Point at a given arc length along the polyline. */ +function pointAt(poly: Polyline, acc: number[], s: number): Point { + if (s <= 0) return poly[0]; + const total = acc[acc.length - 1]; + if (s >= total) return poly[poly.length - 1]; + let i = 1; + while (i < acc.length && acc[i] < s) i++; + const seg = acc[i] - acc[i - 1]; + const t = seg > 0 ? (s - acc[i - 1]) / seg : 0; + const a = poly[i - 1]; + const b = poly[i]; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +/** Arc positions of the contour's sharp corners. */ +function cornerPositions(poly: Polyline, acc: number[]): number[] { + const minTurn = (CORNER_DEG * Math.PI) / 180; + const out: number[] = []; + for (let i = 1; i < poly.length - 1; i++) { + const inAngle = Math.atan2(poly[i].y - poly[i - 1].y, poly[i].x - poly[i - 1].x); + const outAngle = Math.atan2(poly[i + 1].y - poly[i].y, poly[i + 1].x - poly[i].x); + let turn = outAngle - inAngle; + while (turn > Math.PI) turn -= 2 * Math.PI; + while (turn < -Math.PI) turn += 2 * Math.PI; + if (Math.abs(turn) >= minTurn) out.push(acc[i]); + } + return out; +} + +/** The slice of a polyline between two arc lengths, as its own polyline. */ +function sliceByArc(poly: Polyline, acc: number[], from: number, to: number): Polyline { + if (to - from <= 0) return []; + const out: Polyline = [pointAt(poly, acc, from)]; + for (let i = 0; i < poly.length; i++) { + if (acc[i] > from && acc[i] < to) out.push(poly[i]); + } + out.push(pointAt(poly, acc, to)); + // Two coincident points are not a stroke: the ordering step drops anything + // shorter than two points, so a degenerate slice would vanish silently. + return dist(out[0], out[out.length - 1]) > 1e-9 ? out : []; +} + +export interface ApplyTabsOptions extends TabOptions { + /** Whether this path came from a closed contour (tabs are meaningless otherwise). */ + closed: boolean; + /** + * Leading arc length to keep tabs out of — the stretch an overcut re-traces + * at the end of the path. A tab there would be cut through by the overcut, + * which is exactly the interaction that makes both features look broken. + */ + protectStartMm?: number; +} + +/** + * Split a cut path into the strokes that should actually be cut, leaving the + * tab spans out. Returns the path unchanged (as a single stroke) whenever tabs + * do not apply — disabled, open geometry, or a contour too short to bridge. + */ +export function applyTabs(path: Polyline, opts: ApplyTabsOptions): Polyline[] { + const count = Math.floor(opts.tabCount); + if (!opts.closed || count < 1 || opts.tabWidthMm <= 0 || path.length < 3) return [path]; + + const acc = arcLengths(path); + const total = acc[acc.length - 1]; + if (total < opts.minContourMm) return [path]; + + const protect = Math.max(0, opts.protectStartMm ?? 0); + const usable = total - protect; + // Every bridge needs cut on both sides of it; if the contour cannot afford + // that many, cut the number down rather than producing a dotted line. + const maxTabs = Math.floor(usable / (opts.tabWidthMm * 4)); + const n = Math.min(count, maxTabs); + if (n < 1) return [path]; + + const spacing = usable / n; + const corners = cornerPositions(path, acc); + const nudgeLimit = spacing * MAX_NUDGE_FRACTION; + const half = opts.tabWidthMm / 2; + + const spans: Array<[number, number]> = []; + for (let k = 0; k < n; k++) { + let centre = protect + (k + 0.5) * spacing; + // A bridge on a corner tears instead of snapping, so shuffle it along the + // contour until it sits on a straight span (or give up and take the corner). + const tooClose = (c: number) => corners.some((x) => Math.abs(x - c) < half + 1); + let moved = 0; + while (tooClose(centre) && moved < nudgeLimit) { + centre += Math.min(1, nudgeLimit - moved); + moved += 1; + } + spans.push([Math.max(0, centre - half), Math.min(total, centre + half)]); + } + + const out: Polyline[] = []; + let cursor = 0; + for (const [from, to] of spans) { + const piece = sliceByArc(path, acc, cursor, from); + if (piece.length >= 2) out.push(piece); + cursor = to; + } + const tail = sliceByArc(path, acc, cursor, total); + if (tail.length >= 2) out.push(tail); + return out.length > 0 ? out : [path]; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 52b359d..c481362 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -896,6 +896,15 @@ export function App() { }); }, [penGroups, cal, cutting, knife, magnets]); + // What the blade will actually follow: the overcut tail and the gaps the + // holding tabs leave. Shown only in cutting mode, where those are the details + // that decide whether a sheet comes out usable. + const cutPreview = useMemo( + () => + cutting && placedPolylines.length > 0 ? prepareForCut(placedPolylines, knife) : undefined, + [cutting, placedPolylines, knife], + ); + // Travel legs, shown on the canvas only where a magnet forces a detour — // drawing every pen-up move all the time would bury the artwork in dashes. const travelPreview = useMemo(() => { @@ -1493,6 +1502,7 @@ export function App() { magnetsHit={hitMagnets.map((m) => m.id)} onMagnetMove={plotting ? undefined : moveMagnet} travel={travelPreview} + cutPath={cutPreview} artworks={displayItems} selectedIds={selectedIds} onSelect={selectObject} diff --git a/src/ui/PlotCanvas.tsx b/src/ui/PlotCanvas.tsx index 65977f9..6ccca32 100644 --- a/src/ui/PlotCanvas.tsx +++ b/src/ui/PlotCanvas.tsx @@ -46,6 +46,13 @@ interface Props { * cannot sanity-check before starting the job. */ travel?: Polyline[]; + /** + * The prepared cutting path, in paper mm: what the blade actually follows, + * including the overcut and the gaps left by holding tabs. Drawn over the + * artwork — which fades — because the gaps are the point: an operator has to + * see where the piece stays attached before committing a sheet of vinyl. + */ + cutPath?: Polyline[]; artworks: CanvasArt[]; /** Every selected object. The transformer acts on all of them at once. */ selectedIds: string[]; @@ -73,6 +80,7 @@ export function PlotCanvas(props: Props) { magnetsHit, onMagnetMove, travel, + cutPath, artworks, selectedIds, penPos, @@ -195,6 +203,7 @@ export function PlotCanvas(props: Props) { ? CUT_STROKE : (a.penColor ?? (selected.has(a.id) ? selectedStroke : strokeColor)) } + opacity={cutting && cutPath ? 0.25 : 1} strokeWidth={penPx(a.penWidthMm)} strokeScaleEnabled={false} lineCap="round" @@ -215,6 +224,7 @@ export function PlotCanvas(props: Props) { selected, onSelectMany, cutting, + cutPath, ], ); @@ -325,6 +335,18 @@ export function PlotCanvas(props: Props) { /> ))} {artNodes} + {(cutPath ?? []).map((piece, i) => ( + [p.x, p.y])} + stroke={CUT_STROKE} + strokeWidth={1.6} + strokeScaleEnabled={false} + lineCap="round" + lineJoin="round" + listening={false} + /> + ))} {(magnets ?? []).map((m) => { const hit = magnetsHit?.includes(m.id); return ( diff --git a/src/ui/SettingsPage.tsx b/src/ui/SettingsPage.tsx index 85088fd..71dc0e8 100644 --- a/src/ui/SettingsPage.tsx +++ b/src/ui/SettingsPage.tsx @@ -263,6 +263,29 @@ export function SettingsPage(p: SettingsPageProps) { step={0.05} onChange={p.onKnife('bladeOffsetMm')} /> + + + +

+ Tabs are short uncut bridges that keep each piece attached to the sheet until you snap + it out — 0 cuts every piece free as it finishes. They are placed away from corners, + where a bridge tears instead of snapping. +

Overcut carries the blade past the closing point so the loop releases. Blade offset is how far the tip trails the holder's pivot (typically 0.25–0.5 mm); leave it at 0 until