Skip to content

Commit d37d4e7

Browse files
dpsideriusclaude
andcommitted
Implement live-drawing-controls: per-artwork live PNG/SVG controls + plot lock
Two-stage pipeline: retain each artwork's source in memory; source controls (PNG threshold/levels/invert/contrast, SVG sampling tolerance) re-derive the master live (debounced 200ms); the geometry Detail/smoothing slider thins the cached master immediately. Both preview and plot use the same geometry. - raster.ts split into imageToField (decode→reusable grayscale field) + traceField (pure: invert/contrast + iso-contours); flattenImageFile composes. - New src/plot/controls.ts centralises ArtControls, defaults, slider ranges. - App.tsx: per-artwork kind+controls, in-memory sources, debounced re-derive, reusable Slider (drag or type), "Drawing controls" panel, plot lock. - PlotCanvas gains a `locked` prop (no drag/transform during a plot). - sessionStore persists kind+controls; normalizeArt migrates old sessions. - Source kept in memory only (degrades gracefully after reload). - Geometry knobs kept as one Detail slider (folded simplify+min-stroke; noted). Tasks 1.x, 2.1, 4.x, 5.x done; 3.x/2.2/4.2 deviations noted; 6.x verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6c566f4 commit d37d4e7

7 files changed

Lines changed: 464 additions & 103 deletions

File tree

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
11
## 1. Source-retaining trace pipeline (PNG)
22

3-
- [ ] 1.1 Split `raster.ts`: a step that decodes the image to a reusable grayscale `Float32Array` field (at `MASTER_MAXDIM`), and a separate `traceField(field, opts)` that runs iso-contours → polylines. Re-tracing reuses the cached field (no re-decode)
4-
- [ ] 1.2 Add invert + contrast transforms on the field before tracing (cheap point ops); unit-test field transforms and that re-trace at new threshold/levels matches a fresh import
5-
- [ ] 1.3 Keep `flattenImageFile` working (compose the two steps) for first import
3+
- [x] 1.1 Split `raster.ts`: `imageToField` decodes to a reusable grayscale `Float32Array` field (at `MASTER_MAXDIM`); `traceField(field, opts)` runs iso-contours → polylines. Live re-tracing reuses the cached field (no re-decode)
4+
- [x] 1.2 Add invert + contrast transforms on the field before tracing (`adjustValue`, applied to a copy so the source field is never mutated); unit-tested in `raster.test.ts` (invert flips a light-on-dark source to the dark-on-light trace; contrast clamps; deterministic re-trace)
5+
- [x] 1.3 Keep `flattenImageFile` working — now composes `imageToField` + `traceField`
66

77
## 2. Re-flattenable SVG
88

9-
- [ ] 2.1 Retain SVG text per artwork; allow re-running `flattenSvg(text, tolerance)` at a chosen tolerance to produce a fresh master
10-
- [ ] 2.2 Unit-test that a coarser/finer tolerance yields fewer/more points within deviation bounds
9+
- [x] 2.1 Retain SVG text per artwork (in-memory `sourcesRef`); `deriveMaster` re-runs `flattenSvg(text, samplingMm)` at the artwork's chosen tolerance to produce a fresh master
10+
- [~] 2.2 Coarser/finer tolerance fewer/more points: NOT a unit test here — `flattenSvg` needs the DOM `getPointAtLength`, and tests run in Node (no DOM). Covered by runtime verification 6.2 instead
1111

1212
## 3. Geometry controls surfaced
1313

14-
- [ ] 3.1 Expose simplify tolerance + minimum stroke length from `detail.ts` as direct controls (reuse `applyDetail` logic); keep the existing `detail` slider working
15-
- [ ] 3.2 Unit-test the direct knobs against the existing detail mapping
14+
- [~] 3.1 DEVIATION (kept simple): `detail.ts` already maps the single `detail` slider to BOTH a simplify tolerance and a min-stroke-length (`detailParams`). Exposing separate simplify + min-stroke sliders would drive the same two knobs and confuse the UI, so the geometry stage keeps one per-artwork **Detail / smoothing** slider. Suggest updating the spec/proposal to match.
15+
- [~] 3.2 Folded into 3.1 — existing `detail.test.ts` still covers the detail→(epsilon, minLen) mapping; no separate knobs added
1616

1717
## 4. Per-artwork model + persistence
1818

19-
- [ ] 4.1 Extend `PlacedArt` to carry its source (SVG text / grayscale field) and per-artwork control values; seed PNG defaults from current calibration
20-
- [ ] 4.2 Persist per-artwork control values in `sessionStore`; persist source best-effort and degrade gracefully (disable source controls, keep master) when over quota
21-
- [ ] 4.3 Migration: sessions without control values load with defaults
19+
- [x] 4.1 `PlacedArt` carries `kind` + `controls`; source kept in `sourcesRef` (in memory); PNG control defaults seeded from `cal.pngThreshold/pngLevels`
20+
- [~] 4.2 Per-artwork control values persist via `sessionStore` (and migrate). DEVIATION: the raw source is held in memory only, never persisted (a PNG field is ~MBs) — so after a reload the master + values are kept but source-stage controls are disabled until re-import (the "degrade gracefully" path, always taken rather than quota-gated)
21+
- [x] 4.3 Migration: `normalizeArt` + `normalizeControls` fill `kind`/`controls` defaults for sessions saved before they existed
2222

2323
## 5. Live UI
2424

25-
- [ ] 5.1 Reusable slider-with-number control (drag or type; configurable `min`/`max`/`step`); centralise ranges/defaults
26-
- [ ] 5.2 Drawing-controls panel bound to the selected artwork: PNG (threshold, levels, invert, contrast), SVG (sampling tolerance), shared (detail, simplify, min stroke length)
27-
- [ ] 5.3 Debounce source re-derivation (~150–250 ms); keep geometry controls + canvas redraw immediate; show an "updating…" affordance during the debounce
28-
- [ ] 5.4 Drive both the canvas preview and the plotted geometry from the same result
29-
- [ ] 5.5 Lock placement + all drawing controls while a plot is active (gate on the existing plot lifecycle: lock on plot start, unlock on streamComplete / stop / streamAborted)
25+
- [x] 5.1 Reusable `Slider` (range + numeric box; drag or type an exact value, box accepts values beyond the slider range); ranges/defaults centralised in `src/plot/controls.ts`
26+
- [x] 5.2 "Drawing controls" panel bound to the selected artwork: PNG (threshold, levels, invert, contrast), SVG (sampling tolerance), shared (Detail / smoothing)
27+
- [x] 5.3 Source re-derivation debounced 200 ms with an "updating…" affordance; geometry (detail) + canvas redraw stay immediate
28+
- [x] 5.4 `displayItems` drives both the canvas preview and the plotted geometry from the same result
29+
- [x] 5.5 Lock placement + all drawing controls while a plot is active: `plotting` state set on plot start, cleared on streamComplete / streamAborted (covers stop) / disconnect; `PlotCanvas` gains a `locked` prop (no drag, no transformer)
3030

3131
## 6. Verification
3232

33-
- [ ] 6.1 Adjust PNG threshold/levels/invert/contrast on a placed image → preview updates live, no re-import
34-
- [ ] 6.2 Adjust SVG sampling tolerance on a placed SVG → re-flattens live
35-
- [ ] 6.3 Two artworks tuned independently; reload session → values restored
36-
- [ ] 6.4 Start a plot → controls + placement lock; stop/complete → they unlock
33+
- [ ] 6.1 Adjust PNG threshold/levels/invert/contrast on a placed image → preview updates live, no re-import (run the app — visual)
34+
- [ ] 6.2 Adjust SVG sampling tolerance on a placed SVG → re-flattens live (run the app — visual)
35+
- [ ] 6.3 Two artworks tuned independently; reload session → values restored (run the app)
36+
- [ ] 6.4 Start a plot → controls + placement lock; stop/complete → they unlock (run the app)
3737
- [ ] 6.5 ⚙ HARDWARE: plot a tuned PNG and a tuned SVG → confirm the pen output matches the tuned preview

src/plot/__tests__/raster.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { isoContours } from '../raster';
2+
import { adjustValue, isoContours, traceField, type FieldSource } from '../raster';
33

44
// Field convention: value <= level is "inked" (dark). 0 = black, 1 = white.
55
describe('isoContours (marching squares)', () => {
@@ -50,3 +50,61 @@ describe('isoContours (marching squares)', () => {
5050
expect(isoContours(field, w, h, 0.5)).toHaveLength(2);
5151
});
5252
});
53+
54+
describe('adjustValue (invert + contrast)', () => {
55+
it('is identity at invert=false, contrast=1', () => {
56+
expect(adjustValue(0.2, false, 1)).toBeCloseTo(0.2, 9);
57+
expect(adjustValue(0.8, false, 1)).toBeCloseTo(0.8, 9);
58+
});
59+
60+
it('inverts around 1', () => {
61+
expect(adjustValue(0.2, true, 1)).toBeCloseTo(0.8, 9);
62+
});
63+
64+
it('contrast scales around mid-grey and clamps to 0..1', () => {
65+
expect(adjustValue(0.4, false, 2)).toBeCloseTo(0.3, 9); // (0.4-0.5)*2+0.5
66+
expect(adjustValue(0, false, 3)).toBe(0); // (0-0.5)*3+0.5 = -1 → clamped
67+
expect(adjustValue(1, false, 3)).toBe(1); // clamped high
68+
});
69+
});
70+
71+
describe('traceField', () => {
72+
// 5x5 white field with a dark center cell (the isoContours fixture, as a source).
73+
const darkCenter = (): FieldSource => {
74+
const gw = 5,
75+
gh = 5;
76+
const field = new Float32Array(gw * gh).fill(1);
77+
field[2 * gw + 2] = 0;
78+
return { field, gw, gh, mmPerGrid: 1 };
79+
};
80+
81+
it('traces a dark region into mm-sized polylines', () => {
82+
const { artwork } = traceField(darkCenter(), { threshold: 0.5, levels: 1, toleranceMm: 0.01 });
83+
expect(artwork.polylines.length).toBeGreaterThanOrEqual(1);
84+
expect(artwork.widthMm).toBeGreaterThan(0);
85+
expect(artwork.heightMm).toBeGreaterThan(0);
86+
});
87+
88+
it('is deterministic and does not mutate the source field', () => {
89+
const src = darkCenter();
90+
const before = Array.from(src.field);
91+
const a = traceField(src, { threshold: 0.5, levels: 1, toleranceMm: 0.01, contrast: 2 });
92+
const b = traceField(src, { threshold: 0.5, levels: 1, toleranceMm: 0.01, contrast: 2 });
93+
expect(a.artwork.polylines.length).toBe(b.artwork.polylines.length);
94+
expect(Array.from(src.field)).toEqual(before); // invert/contrast worked on a copy
95+
});
96+
97+
it('invert flips a light-on-dark source back to a dark-on-light trace', () => {
98+
const plain = traceField(darkCenter(), { threshold: 0.5, levels: 1, toleranceMm: 0.01 });
99+
// Inverse source: dark field with a light center.
100+
const gw = 5,
101+
gh = 5;
102+
const field = new Float32Array(gw * gh).fill(0);
103+
field[2 * gw + 2] = 1;
104+
const inv = traceField(
105+
{ field, gw, gh, mmPerGrid: 1 },
106+
{ threshold: 0.5, levels: 1, toleranceMm: 0.01, invert: true },
107+
);
108+
expect(inv.artwork.polylines.length).toBe(plain.artwork.polylines.length);
109+
});
110+
});

src/plot/controls.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Per-artwork drawing controls. Two stages:
3+
* - "source" controls (PNG: threshold/levels/invert/contrast; SVG: samplingMm)
4+
* re-derive the master geometry from the retained source — expensive, debounced.
5+
* - "geometry" controls (detail) thin the master live — cheap, immediate.
6+
* Centralised here so the defaults and slider ranges live in one place.
7+
*/
8+
export interface ArtControls {
9+
/** PNG: darkness cutoff 0..1 (pixels darker than this are inked). */
10+
threshold: number;
11+
/** PNG: number of brightness contours (1 = outline, more = tonal shading). */
12+
levels: number;
13+
/** PNG: invert dark/light before tracing. */
14+
invert: boolean;
15+
/** PNG: contrast multiplier around mid-grey (1 = unchanged). */
16+
contrast: number;
17+
/** SVG: curve flattening tolerance in mm (smaller = finer sampling). */
18+
samplingMm: number;
19+
/** Shared: level of detail 0..1 (1 = full, 0 = most simplified). */
20+
detail: number;
21+
}
22+
23+
export const DEFAULT_CONTROLS: ArtControls = {
24+
threshold: 0.5,
25+
levels: 1,
26+
invert: false,
27+
contrast: 1,
28+
samplingMm: 0.2,
29+
detail: 0.6,
30+
};
31+
32+
/** Source-stage keys — changing one re-derives the master (debounced). */
33+
export const SOURCE_KEYS: (keyof ArtControls)[] = [
34+
'threshold',
35+
'levels',
36+
'invert',
37+
'contrast',
38+
'samplingMm',
39+
];
40+
41+
export interface SliderRange {
42+
min: number;
43+
max: number;
44+
step: number;
45+
}
46+
47+
/** Slider ranges (the numeric box may still accept values beyond these). */
48+
export const CONTROL_RANGES: Record<
49+
'threshold' | 'levels' | 'contrast' | 'samplingMm' | 'detail',
50+
SliderRange
51+
> = {
52+
threshold: { min: 0.05, max: 0.95, step: 0.01 },
53+
levels: { min: 1, max: 6, step: 1 },
54+
contrast: { min: 0.5, max: 3, step: 0.05 },
55+
samplingMm: { min: 0.1, max: 3, step: 0.05 },
56+
detail: { min: 0, max: 1, step: 0.01 },
57+
};
58+
59+
/** Fill any missing fields with defaults (migrates sessions saved before controls existed). */
60+
export function normalizeControls(c: Partial<ArtControls> | undefined): ArtControls {
61+
return { ...DEFAULT_CONTROLS, ...(c ?? {}) };
62+
}

src/plot/raster.ts

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,38 @@ export interface RasterOptions {
1414
threshold: number;
1515
/** Number of evenly-spaced brightness contours (1 = silhouette outline, more = tonal). */
1616
levels: number;
17+
/** Invert dark/light before tracing. */
18+
invert?: boolean;
19+
/** Contrast multiplier around mid-grey (1 = unchanged). */
20+
contrast?: number;
1721
/** Polyline simplification tolerance, mm. */
1822
toleranceMm: number;
1923
/** Working-resolution cap on the longer side (px) to keep contours smooth/fast. */
2024
maxDim?: number;
2125
}
2226

2327
/**
24-
* Convert a raster image into plottable polylines by tracing grayscale
25-
* iso-contours (marching squares). Browser-only: rasterizes the image on a
26-
* canvas to read pixels. The geometry "looks like a drawing" — closed outlines
27-
* of the dark regions, which a pen plotter can draw efficiently.
28+
* The decoded grayscale of an image, kept so the trace can be re-run live at new
29+
* threshold/levels/invert/contrast without re-decoding the file.
2830
*/
29-
export async function flattenImageFile(file: File, opts: RasterOptions): Promise<ImportResult> {
31+
export interface FieldSource {
32+
/** Grayscale field in 0..1 (0 = black, 1 = white), row-major gw×gh. */
33+
field: Float32Array;
34+
gw: number;
35+
gh: number;
36+
/** mm per grid cell, so traced contours come out in real-world mm. */
37+
mmPerGrid: number;
38+
}
39+
40+
/**
41+
* Decode an image to a reusable grayscale field (browser-only: rasterizes on a
42+
* canvas to read pixels). Separated from tracing so the field can be re-traced
43+
* live as the controls change.
44+
*/
45+
export async function imageToField(file: File, maxDim = 500): Promise<FieldSource> {
3046
const url = URL.createObjectURL(file);
3147
try {
3248
const img = await loadImage(url);
33-
const maxDim = opts.maxDim ?? 500;
3449
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
3550
const gw = Math.max(2, Math.round(img.width * scale));
3651
const gh = Math.max(2, Math.round(img.height * scale));
@@ -45,7 +60,6 @@ export async function flattenImageFile(file: File, opts: RasterOptions): Promise
4560
ctx.drawImage(img, 0, 0, gw, gh);
4661
const { data } = ctx.getImageData(0, 0, gw, gh);
4762

48-
// Grayscale field in 0..1 (0 = black, 1 = white).
4963
const field = new Float32Array(gw * gh);
5064
for (let i = 0; i < gw * gh; i++) {
5165
const r = data[i * 4],
@@ -54,33 +68,66 @@ export async function flattenImageFile(file: File, opts: RasterOptions): Promise
5468
field[i] = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
5569
}
5670

57-
const mmPerGrid = (img.width * PX_TO_MM) / gw;
58-
const levels = Math.max(1, Math.round(opts.levels));
59-
const t = Math.min(0.999, Math.max(0.001, opts.threshold));
60-
const polylines: Polyline[] = [];
61-
let skipped = 0;
62-
63-
for (let k = 1; k <= levels; k++) {
64-
const value = (t * k) / levels; // light → threshold; nested for shading
65-
const contours = isoContours(field, gw, gh, value);
66-
if (contours.length === 0) {
67-
skipped++;
68-
continue;
69-
}
70-
for (const c of contours) {
71-
const mm = c.map((p) => ({ x: p.x * mmPerGrid, y: p.y * mmPerGrid }));
72-
const simplified = simplifyPolyline(mm, opts.toleranceMm);
73-
if (simplified.length >= 2) polylines.push(simplified);
74-
}
75-
}
76-
77-
const { widthMm, heightMm } = normalizeToOrigin(polylines);
78-
return { artwork: { polylines, widthMm, heightMm }, skipped };
71+
return { field, gw, gh, mmPerGrid: (img.width * PX_TO_MM) / gw };
7972
} finally {
8073
URL.revokeObjectURL(url);
8174
}
8275
}
8376

77+
/** Apply invert + contrast (around mid-grey) to a grayscale value, clamped to 0..1. */
78+
export function adjustValue(v: number, invert: boolean, contrast: number): number {
79+
const g = invert ? 1 - v : v;
80+
const c = (g - 0.5) * contrast + 0.5;
81+
return c < 0 ? 0 : c > 1 ? 1 : c;
82+
}
83+
84+
/**
85+
* Trace a decoded field into plottable polylines (pure — no DOM, unit-testable).
86+
* Applies invert/contrast, then traces brightness iso-contours (marching squares)
87+
* at the threshold (nested for tonal shading), and returns mm-sized geometry.
88+
*/
89+
export function traceField(src: FieldSource, opts: RasterOptions): ImportResult {
90+
const { gw, gh, mmPerGrid } = src;
91+
const invert = opts.invert ?? false;
92+
const contrast = opts.contrast ?? 1;
93+
const adjusted =
94+
invert || contrast !== 1
95+
? Float32Array.from(src.field, (v) => adjustValue(v, invert, contrast))
96+
: src.field;
97+
98+
const levels = Math.max(1, Math.round(opts.levels));
99+
const t = Math.min(0.999, Math.max(0.001, opts.threshold));
100+
const polylines: Polyline[] = [];
101+
let skipped = 0;
102+
103+
for (let k = 1; k <= levels; k++) {
104+
const value = (t * k) / levels; // light → threshold; nested for shading
105+
const contours = isoContours(adjusted, gw, gh, value);
106+
if (contours.length === 0) {
107+
skipped++;
108+
continue;
109+
}
110+
for (const c of contours) {
111+
const mm = c.map((p) => ({ x: p.x * mmPerGrid, y: p.y * mmPerGrid }));
112+
const simplified = simplifyPolyline(mm, opts.toleranceMm);
113+
if (simplified.length >= 2) polylines.push(simplified);
114+
}
115+
}
116+
117+
const { widthMm, heightMm } = normalizeToOrigin(polylines);
118+
return { artwork: { polylines, widthMm, heightMm }, skipped };
119+
}
120+
121+
/**
122+
* Convert a raster image into plottable polylines: decode to a grayscale field,
123+
* then trace it. The geometry "looks like a drawing" — closed outlines of the
124+
* dark regions, which a pen plotter can draw efficiently.
125+
*/
126+
export async function flattenImageFile(file: File, opts: RasterOptions): Promise<ImportResult> {
127+
const src = await imageToField(file, opts.maxDim ?? 500);
128+
return traceField(src, opts);
129+
}
130+
84131
function loadImage(url: string): Promise<HTMLImageElement> {
85132
return new Promise((resolve, reject) => {
86133
const img = new Image();

0 commit comments

Comments
 (0)