diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx
index 94e621b6..9eff1157 100644
--- a/src-frontend/src/App.jsx
+++ b/src-frontend/src/App.jsx
@@ -31,7 +31,6 @@ export default function App() {
const [sidebarWidth, setSidebarWidth] = useState(320)
const [nodeWidth, setNodeWidth] = useState(450)
- const [dpi, setDpi] = useState(150)
const [projectPath, setProjectPath] = useState(null) // null = unsaved
const resizing = useRef(false)
@@ -42,10 +41,12 @@ export default function App() {
// deps; without memoisation, every gcodeConfig drag tick would
// recreate the object, recreate `draw`, and restart Viewport's
// chunked-stroke renderer mid-render.
+ // 150 DPI is just a sensible default for components that want a
+ // canvas-pixel size — actual processing DPI lives per-Source now.
const canvasDims = useMemo(() => ({
- width: Math.round(gcodeConfig.paper_w_mm * dpi / 25.4),
- height: Math.round(gcodeConfig.paper_h_mm * dpi / 25.4),
- }), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, dpi])
+ width: Math.round(gcodeConfig.paper_w_mm * 150 / 25.4),
+ height: Math.round(gcodeConfig.paper_h_mm * 150 / 25.4),
+ }), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
// Has a Source node with a loaded file path? Used for the empty-state overlay.
const anyLoadedSource = (passes[0]?.graph?.nodes ?? [])
.some(n => n.kind === 'Source' && n.file_path)
@@ -98,10 +99,8 @@ export default function App() {
// Always-fresh refs so debounced callbacks never close over stale state
const passesRef = useRef(passes)
- const dpiRef = useRef(dpi)
const gcodeConfigRef = useRef(gcodeConfig)
passesRef.current = passes
- dpiRef.current = dpi
gcodeConfigRef.current = gcodeConfig
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
@@ -155,14 +154,20 @@ export default function App() {
try {
// Backend letterboxes every Source into the paper canvas, so we hand
// it the paper dimensions directly — no per-image scaling knob anymore.
+ // DPI lives per-Source now; the project canvas runs at the highest
+ // source DPI so no source loses detail. Default 150 if no Source.
const paperW = gcodeConfigRef.current.paper_w_mm
const paperH = gcodeConfigRef.current.paper_h_mm
+ const projectDpi = Math.max(150, ...(pass.graph?.nodes ?? [])
+ .filter(n => n.kind === 'Source')
+ .map(n => n.dpi ?? 150))
const result = await tauri.processPass({
pass_index: idx,
graph: pass.graph,
- dpi: dpiRef.current,
+ dpi: projectDpi,
img_w_mm: paperW,
img_h_mm: paperH,
+ pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5,
})
const js_process = Math.round(performance.now() - t0)
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
@@ -194,7 +199,7 @@ export default function App() {
useEffect(() => {
scheduleProcess()
- }, [dpi, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
+ }, [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, gcodeConfig.pen_tip_mm])
// ── Export ─────────────────────────────────────────────────────────────────
async function exportAll() {
@@ -225,7 +230,6 @@ export default function App() {
}
try {
const json = serialize({
- dpi,
nodeWidth,
graph: passes[0].graph,
gcodeConfig,
@@ -249,7 +253,6 @@ export default function App() {
// Apply non-image state immediately
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
- if (restored.dpi) setDpi(restored.dpi)
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
// Replace the pass graph
@@ -340,83 +343,83 @@ export default function App() {
- {/* Graph */}
-
-
Graph
-
setNodeWidth(v)} unit="px" />
-
-
- {/* Pipeline */}
-
-
Pipeline
-
setDpi(v)} />
-
-
- {/* Paper */}
-
-
Paper
-
- {PAPER_SIZES.map(ps => {
- const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
- const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
- return (
-
- )
- })}
-
+ {/* ── Pipeline view: graph layout, DPI, paper size ──────────── */}
+ {viewMode === 'pipeline' && (<>
+
+
Graph
+
setNodeWidth(v)} unit="px" />
-
- {/* Plotter */}
-
-
-
Plotter
-
+
+
Paper
+
+ {PAPER_SIZES.map(ps => {
+ const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
+ const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
+ return (
+
+ )
+ })}
+
+
-
setGcode({ feed_draw: v })} unit=" mm/m" />
- setGcode({ feed_travel: v })} unit=" mm/m" />
- setGcode({ pen_up_z_mm: v })} />
- setGcode({ pen_dwell_ms: v })} />
-
+ >)}
- {/* Calibration: corner jog + axis-scale */}
-
-
+ {/* ── G-code view: plotter motion params, corner-jog, export ── */}
+ {viewMode === 'gcode' && (<>
+
+
+
setGcode({ feed_draw: v })} unit=" mm/m" />
+ setGcode({ feed_travel: v })} unit=" mm/m" />
+ setGcode({ pen_up_z_mm: v })} />
+ setGcode({ pen_dwell_ms: v })} />
+ setGcode({ pen_tip_mm: v })} />
+
- {/* Export & upload */}
-
-
Output
-
-
Use the Printer tab to upload & run.
-
+
+
+
+
Output
+
+
Use the Printer tab to upload & run.
+
+ >)}
+
+ {/* ── Printer view: axis-scale calibration ──────────────────── */}
+ {viewMode === 'printer' && (
+
+ )}
diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx
index e0198c04..be408a7f 100644
--- a/src-frontend/src/components/NodeGraph.jsx
+++ b/src-frontend/src/components/NodeGraph.jsx
@@ -557,6 +557,8 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
{node.file_path}
)}
+ updateNode(node.id, { dpi: v })} />
>)}
{node.kind === 'Kernel' && (<>
@@ -619,6 +621,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
Invert
>)}
+ {/* Per-kernel internal DPI: 0 = canvas DPI (no resample),
+ lower values downsample input for speed. */}
+ updateNode(node.id, { kernel_dpi: v > 0 ? v : null })} />
>)}
{node.kind === 'Combine' && (<>
@@ -647,8 +653,6 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
onChange={v => updateNode(node.id, { threshold: v })} />
updateNode(node.id, { min_area: v })} />
- updateNode(node.id, { rdp_epsilon: v })} />
Connectivity
{['four','eight'].map(c => (
@@ -684,7 +688,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
>{s}
))}
- updateNode(node.id, { spacing: v })} />
{FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && (
({ color: p.color, points: s }))
)
- // Offscreen canvas sized to stroke coordinate space (pipeline dims, after DPI scaling).
- const sw = strokes.img_width ?? imgSize.width
- const sh = strokes.img_height ?? imgSize.height
+ // Offscreen canvas sized to paper × INTERNAL_PX_PER_MM. Strokes are
+ // mm coords drawn through an octx.scale that maps mm → offscreen px.
+ // Then drawImage scales the offscreen onto the image rect on screen
+ // — pan/zoom doesn't invalidate this canvas.
+ const INTERNAL_PX_PER_MM = 10
+ const paperWmm = strokes.paper_w_mm ?? 210
+ const paperHmm = strokes.paper_h_mm ?? 297
+ const sw = Math.max(1, Math.round(paperWmm * INTERNAL_PX_PER_MM))
+ const sh = Math.max(1, Math.round(paperHmm * INTERNAL_PX_PER_MM))
const off = document.createElement('canvas')
- off.width = sw * 4
- off.height = sh * 4
+ off.width = sw
+ off.height = sh
const octx = off.getContext('2d')
octx.fillStyle = '#f5f0e8'
octx.fillRect(0, 0, off.width, off.height)
- octx.scale(4, 4)
+ octx.scale(INTERNAL_PX_PER_MM, INTERNAL_PX_PER_MM)
offscreenRef.current = off
+ // Line width directly in mm — physical pen tip diameter. The
+ // octx.scale transform converts to offscreen pixels.
+ const lineWidthMm = Math.max(0.05, gcodeConfig?.pen_tip_mm ?? 0.5)
+
chunkRef.current = { flat, idx: 0, raf: null }
function drawChunk() {
@@ -211,32 +220,13 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
const end = Math.min(idx + CHUNK_SIZE, flat.length)
- if (viewMode === 'gcode') {
- // Debug view: every stroke gets its own hue (golden-ratio cycle for
- // maximal visual separation). One beginPath per stroke since each has
- // a unique strokeStyle.
- octx.lineWidth = 1.5
- octx.lineCap = 'round'
- for (let i = idx; i < end; i++) {
- const pts = flat[i].points
- if (pts.length < 2) continue
- const hue = (i * 137.508) % 360
- octx.strokeStyle = `hsl(${hue.toFixed(1)}, 80%, 50%)`
- octx.beginPath()
- octx.moveTo(pts[0][0], pts[0][1])
- for (let k = 1; k < pts.length; k++) {
- octx.lineTo(pts[k][0], pts[k][1])
- }
- octx.stroke()
- }
- } else {
- // Fill view: pen-color batching (consecutive same-color strokes
- // share one beginPath for performance).
- let i = idx
- while (i < end) {
+ // Pen-color batching: consecutive same-color strokes share one
+ // beginPath for perf. Color comes from each PenOutput's color.
+ let i = idx
+ while (i < end) {
const [r, g, b] = flat[i].color
octx.strokeStyle = `rgb(${r},${g},${b})`
- octx.lineWidth = 1.5
+ octx.lineWidth = lineWidthMm
octx.lineCap = 'round'
octx.beginPath()
let j = i
@@ -255,7 +245,6 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
}
octx.stroke()
i = j
- }
}
state.idx = end
@@ -276,7 +265,12 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
chunkRef.current.raf = null
}
}
- }, [strokes, imgSize, viewMode, draw])
+ }, [strokes, imgSize, viewMode, draw,
+ gcodeConfig?.pen_tip_mm, gcodeConfig?.paper_w_mm])
+ // pen_tip_mm + paper_w_mm: only fields that affect the offscreen
+ // line width — listed individually so changes here re-render the
+ // strokes, but unrelated gcodeConfig drag mutations (offsets,
+ // img_w_mm) do NOT.
useEffect(() => { draw() }, [draw])
diff --git a/src-frontend/src/project.js b/src-frontend/src/project.js
index 04f61d59..c7711055 100644
--- a/src-frontend/src/project.js
+++ b/src-frontend/src/project.js
@@ -21,13 +21,15 @@ const MIGRATIONS = [
]
// ── Serialize ──────────────────────────────────────────────────────────────────
-export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig }) {
+export function serialize({ imagePath, nodeWidth, graph, gcodeConfig }) {
+ // dpi used to live here; now per-Source on the graph node, so just stops
+ // being emitted. Old projects with top-level `dpi` deserialize fine —
+ // the Source nodes already have their own dpi default.
return JSON.stringify({
version: CURRENT_VERSION,
app: 'trac3r',
saved_at: new Date().toISOString(),
image_path: imagePath ?? null,
- dpi,
node_width: nodeWidth,
graph,
gcode: gcodeConfig,
@@ -70,7 +72,6 @@ export function deserialize(json, { migrations: migs = MIGRATIONS, currentVersio
return {
imagePath: doc.image_path ?? null,
- dpi: doc.dpi ?? 150,
nodeWidth: doc.node_width ?? 450,
graph: doc.graph ?? null,
gcodeConfig: doc.gcode ?? null,
diff --git a/src-frontend/src/project.test.js b/src-frontend/src/project.test.js
index 77ed73d0..43983d82 100644
--- a/src-frontend/src/project.test.js
+++ b/src-frontend/src/project.test.js
@@ -96,11 +96,6 @@ describe('serialize', () => {
expect(doc.image_path).toBeNull()
})
- it('includes dpi', () => {
- const doc = JSON.parse(serialize(FULL_STATE))
- expect(doc.dpi).toBe(300)
- })
-
it('includes node_width', () => {
const doc = JSON.parse(serialize(FULL_STATE))
expect(doc.node_width).toBe(500)
@@ -138,7 +133,6 @@ describe('deserialize — happy path', () => {
it('loads a well-formed v1 document', () => {
const result = deserialize(makeV1Doc())
expect(result.imagePath).toBe('/some/image.jpg')
- expect(result.dpi).toBe(150)
expect(result.nodeWidth).toBe(450)
expect(result.graph.nodes).toHaveLength(MINIMAL_GRAPH.nodes.length)
expect(result.graph.edges).toHaveLength(MINIMAL_GRAPH.edges.length)
@@ -175,12 +169,6 @@ describe('deserialize — happy path', () => {
// ── deserialize — missing optional fields use defaults ─────────────────────────
describe('deserialize — missing optional fields', () => {
- it('defaults dpi to 150 when missing', () => {
- const { dpi: _, ...doc } = JSON.parse(makeV1Doc())
- const result = deserialize(JSON.stringify(doc))
- expect(result.dpi).toBe(150)
- })
-
it('defaults node_width to 450 when missing', () => {
const doc = JSON.parse(makeV1Doc())
delete doc.node_width
@@ -218,7 +206,7 @@ describe('deserialize — missing optional fields', () => {
const minimalDoc = JSON.stringify({ version: 1, app: 'trac3r' })
const result = deserialize(minimalDoc)
expect(result).toEqual({
- imagePath: null, dpi: 150, nodeWidth: 450, graph: null, gcodeConfig: null,
+ imagePath: null, nodeWidth: 450, graph: null, gcodeConfig: null,
})
})
})
@@ -299,7 +287,7 @@ describe('deserialize — version handling', () => {
// Inject a migration that should NOT run (file is already at current version)
const result = deserialize(makeV1Doc(), { migrations: [spy] })
expect(spy).not.toHaveBeenCalled()
- expect(result.dpi).toBe(150)
+ expect(result.nodeWidth).toBe(450)
})
it('warns (not throws) when file version is ahead of the app', () => {
@@ -373,7 +361,6 @@ describe('round-trip: serialize → deserialize', () => {
const json = serialize(FULL_STATE)
const result = deserialize(json)
expect(result.imagePath).toBe(FULL_STATE.imagePath)
- expect(result.dpi).toBe(FULL_STATE.dpi)
expect(result.nodeWidth).toBe(FULL_STATE.nodeWidth)
expect(result.gcodeConfig).toEqual(FULL_STATE.gcodeConfig)
})
diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js
index 0a24c9e6..65b0899b 100644
--- a/src-frontend/src/store.js
+++ b/src-frontend/src/store.js
@@ -59,6 +59,9 @@ export function defaultKernelProps() {
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
color_filter: buildColorIsolateFilter(ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min),
ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min,
+ // null = run at canvas DPI (= max source DPI). Lower values
+ // downsample the kernel's input internally for speed.
+ kernel_dpi: null,
}
}
@@ -82,8 +85,10 @@ export function defaultColorFilter() {
}
export function defaultFillParams() {
+ // spacing is in mm (DPI-independent, paper-relative). Backend converts
+ // to pipeline pixels via canvas_w / paper_w_mm at process time.
return {
- strategy: 'hatch', spacing: 5, angle: 0, param: 1.0,
+ strategy: 'hatch', spacing: 2.0, angle: 0, param: 1.0,
smooth_rdp: 1.0, smooth_iters: 2,
}
}
@@ -117,13 +122,18 @@ export function defaultTextParams() {
export function defaultHullParams() {
return {
- threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
+ threshold: 128, min_area: 4, connectivity: 'four',
color_filter: defaultColorFilter(),
}
}
export function defaultSourceParams() {
- return { file_path: null }
+ // dpi is the source's sampling resolution: how many pixels per mm of
+ // paper the source image gets letterboxed into. Higher = finer detail
+ // captured from the source. With multi-source projects, the project
+ // canvas runs at max(source.dpi) so the highest-detail source isn't
+ // limited by the lowest.
+ return { file_path: null, dpi: 150 }
}
export function defaultGraph() {
@@ -178,6 +188,7 @@ export function defaultGcodeConfig() {
offset_x_mm: 15, offset_y_mm: 15,
feed_draw: 1000, feed_travel: 5000,
pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2, pen_dwell_ms: 250,
+ pen_tip_mm: 0.5, // visual only — gcode preview renders strokes at this physical ink width
printer_url: 'http://fluidnc.local',
}
return { ...cfg, ...centerPaperOnBed(cfg) }
diff --git a/src/detect.rs b/src/detect.rs
index 15c97ca0..6ad16347 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -56,6 +56,12 @@ pub struct DetectionLayer {
pub ci_hue_min: f32, pub ci_hue_max: f32,
pub ci_sat_min: f32, pub ci_sat_max: f32,
pub ci_val_min: f32, pub ci_val_max: f32,
+ /// Per-kernel internal DPI. None = use canvas DPI. If set lower
+ /// than canvas DPI, the kernel's input gets downsampled to that
+ /// resolution before applying the layer (faster for slow kernels
+ /// like Canny on big images), then upsampled back to canvas dims
+ /// so downstream nodes see a consistent map.
+ pub kernel_dpi: Option,
}
impl Default for DetectionLayer {
@@ -74,10 +80,37 @@ impl Default for DetectionLayer {
ci_hue_min: 0.0, ci_hue_max: 360.0,
ci_sat_min: 0.0, ci_sat_max: 1.0,
ci_val_min: 0.0, ci_val_max: 1.0,
+ kernel_dpi: None,
}
}
}
+/// Apply a layer with optional DPI downsampling. If `layer.kernel_dpi`
+/// is set lower than `canvas_dpi`, the input is downsampled to that
+/// resolution before applying the kernel and the result upsampled back
+/// to the input's dimensions — same output shape, kernel works on a
+/// smaller image internally.
+pub fn apply_layer_with_dpi(rgb: &RgbImage, layer: &DetectionLayer, canvas_dpi: f32) -> Vec {
+ if let Some(kdpi) = layer.kernel_dpi {
+ let kdpi = kdpi.max(1) as f32;
+ if kdpi + 0.5 < canvas_dpi {
+ let ratio = kdpi / canvas_dpi;
+ let new_w = (rgb.width() as f32 * ratio).round().max(1.0) as u32;
+ let new_h = (rgb.height() as f32 * ratio).round().max(1.0) as u32;
+ let small = image::DynamicImage::ImageRgb8(rgb.clone())
+ .resize_exact(new_w, new_h, image::imageops::FilterType::Triangle)
+ .to_rgb8();
+ let small_resp = apply_layer(&small, layer);
+ let small_gray = image::GrayImage::from_raw(new_w, new_h, small_resp).expect("gray buf");
+ let up = image::DynamicImage::ImageLuma8(small_gray)
+ .resize_exact(rgb.width(), rgb.height(), image::imageops::FilterType::Triangle)
+ .to_luma8();
+ return up.into_raw();
+ }
+ }
+ apply_layer(rgb, layer)
+}
+
/// Ordered stack of detection layers combined by weighted average.
#[derive(Debug, Clone)]
pub struct DetectionParams {
@@ -477,7 +510,6 @@ pub enum NodeKind {
Hull {
threshold: u8,
min_area: u32,
- rdp_epsilon: f32,
eight_conn: bool,
cf_enabled: bool,
cf_hue_min: f32, cf_hue_max: f32,
@@ -551,6 +583,7 @@ pub fn evaluate_graph(
node_rgbs: &std::collections::HashMap,
canvas_w: u32,
canvas_h: u32,
+ canvas_dpi: f32,
) -> GraphMaps {
use std::collections::{HashMap, VecDeque};
@@ -625,9 +658,9 @@ pub fn evaluate_graph(
let v = up[(y * src_rgb.width() + x) as usize];
image::Rgb([v, v, v])
});
- apply_layer(&gray_rgb, layer)
+ apply_layer_with_dpi(&gray_rgb, layer, canvas_dpi)
} else {
- apply_layer(src_rgb, layer)
+ apply_layer_with_dpi(src_rgb, layer, canvas_dpi)
};
let w = layer.weight;
Some(if (w - 1.0).abs() < 1e-6 {
diff --git a/src/fill.rs b/src/fill.rs
index 9efa263f..e0664318 100644
--- a/src/fill.rs
+++ b/src/fill.rs
@@ -1,17 +1,200 @@
// Fill-path generation for pen-plotter G-code output.
-// All algorithms work in pixel coordinates.
+//
+// Two layers:
+// • Internal pixel-coord algorithms — `parallel_hatch`, `outline`, etc.
+// These take a pixel `Hull` and produce strokes in that hull's pixel
+// coord system. Their existing tests still pass.
+// • Mm-coord wrappers — `*_mm` functions take an `MmHull` (polygon in
+// mm), locally rasterize it to a pixel hull at a fixed internal
+// resolution, run the pixel algorithm, and convert the output strokes
+// back to mm. Result is DPI-independent: a 2 mm hatch on A4 looks
+// the same regardless of project DPI.
+//
+// FillResult.strokes are mm coords when produced by the `*_mm` variants
+// (the path the lib.rs dispatcher takes); pixel coords when produced by
+// the legacy direct calls (test-only).
use std::collections::{HashMap, HashSet, VecDeque};
-use crate::hulls::{Hull, trace_contour};
+use crate::hulls::{Hull, MmHull, Bounds, trace_contour};
+
+/// Internal raster resolution used by the mm-wrapper layer when a fill
+/// algorithm needs a pixel grid (inside-tests, distance transforms, etc).
+/// 10 px / mm = 254 DPI — finer than a 0.5 mm pen tip resolves, plenty
+/// for plotter-quality output. Picked to be DPI-independent (downstream
+/// of Hull, project DPI no longer matters).
+pub const FILL_INTERNAL_PX_PER_MM: f32 = 10.0;
/// One hull's worth of fill strokes.
#[derive(Debug, Clone)]
pub struct FillResult {
pub hull_id: u32,
- /// Each inner Vec is one polyline stroke (sequence of pixel-space points).
+ /// Polyline strokes. Coordinate frame depends on producer:
+ /// the `*_mm` wrappers emit mm; the legacy direct fills emit pixels
+ /// at the input hull's resolution.
pub strokes: Vec>,
}
+// ── Mm wrapper layer: rasterize MmHull → run pixel fill → mm strokes ──────
+
+/// Polygon scanline fill: rasterizes an `MmHull` into a `Hull` at
+/// `px_per_mm` resolution (a private pixel grid for internal use by mm
+/// fill wrappers). Coords inside the returned Hull are in *that*
+/// rasterization's pixel space; multiply by 1/px_per_mm to go back to mm.
+pub fn rasterize_mm_hull(mm: &MmHull, px_per_mm: f32) -> Hull {
+ let s = px_per_mm.max(0.01);
+ // Pixel bbox — pad by 1 to give scanline some slack at the boundary.
+ let x_min = (mm.bounds.x_min * s).floor().max(0.0) as u32;
+ let y_min = (mm.bounds.y_min * s).floor().max(0.0) as u32;
+ let x_max = (mm.bounds.x_max * s).ceil() as u32;
+ let y_max = (mm.bounds.y_max * s).ceil() as u32;
+ if x_max < x_min || y_max < y_min {
+ return Hull {
+ id: mm.id, pixels: vec![], contour: vec![], simplified: vec![],
+ area: 0, avg_luminance: 0.0, avg_color: mm.avg_color,
+ bounds: Bounds { x_min, y_min, x_max: x_min, y_max: y_min },
+ };
+ }
+ // Polygon in pixel coords for scanline math.
+ let poly: Vec<(f32, f32)> = mm.contour.iter().map(|&(x, y)| (x * s, y * s)).collect();
+ let n = poly.len();
+ let mut pixels: Vec<(u32, u32)> = Vec::new();
+ if n >= 3 {
+ for py in y_min..=y_max {
+ let y = py as f32 + 0.5;
+ // Find x-intersections of polygon edges with horizontal scanline y.
+ let mut xs: Vec = Vec::new();
+ for i in 0..n {
+ let (ax, ay) = poly[i];
+ let (bx, by) = poly[(i + 1) % n];
+ // Skip horizontal edges (don't change crossing parity).
+ if (ay > y) == (by > y) { continue; }
+ let t = (y - ay) / (by - ay);
+ xs.push(ax + t * (bx - ax));
+ }
+ xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+ // Pair-walk: even-odd fill rule.
+ let mut i = 0;
+ while i + 1 < xs.len() {
+ let x_lo = xs[i].max(0.0).ceil() as i64;
+ let x_hi = xs[i + 1].floor() as i64;
+ if x_hi >= x_lo {
+ for px in x_lo..=x_hi {
+ if px >= x_min as i64 && px <= x_max as i64 {
+ pixels.push((px as u32, py));
+ }
+ }
+ }
+ i += 2;
+ }
+ }
+ }
+ let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
+ let contour = trace_contour(&pixel_set);
+ let simplified = contour.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
+ let area = pixel_set.len() as u32;
+ Hull {
+ id: mm.id,
+ pixels: pixel_set.into_iter().collect(),
+ contour, simplified,
+ area, avg_luminance: 0.0, avg_color: mm.avg_color,
+ bounds: Bounds { x_min, y_min, x_max, y_max },
+ }
+}
+
+/// Convert pixel-space strokes (at `px_per_mm` resolution) back to mm strokes.
+fn px_strokes_to_mm(strokes: Vec>, px_per_mm: f32) -> Vec> {
+ let inv = if px_per_mm > 0.0 { 1.0 / px_per_mm } else { 0.0 };
+ strokes.into_iter().map(|s| s.into_iter().map(|(x, y)| (x * inv, y * inv)).collect()).collect()
+}
+
+/// Outline fill (closed polygon stroke) — direct from MmHull, no raster needed.
+pub fn outline_mm(mm: &MmHull) -> FillResult {
+ if mm.contour.len() < 2 {
+ return FillResult { hull_id: mm.id, strokes: vec![] };
+ }
+ let mut s: Vec<(f32, f32)> = mm.contour.clone();
+ if let Some(&first) = s.first() { s.push(first); }
+ FillResult { hull_id: mm.id, strokes: vec![s] }
+}
+
+/// Wrapper macro: rasterize → run a pixel-space fill that takes
+/// `(hull, spacing_px)` → convert strokes back to mm.
+macro_rules! mm_wrap_simple {
+ ($name:ident, $px_fn:ident) => {
+ pub fn $name(mm: &MmHull, spacing_mm: f32) -> FillResult {
+ let s = FILL_INTERNAL_PX_PER_MM;
+ let h = rasterize_mm_hull(mm, s);
+ let spacing_px = (spacing_mm * s).max(0.5);
+ let r = $px_fn(&h, spacing_px);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
+ }
+ };
+}
+mm_wrap_simple!(contour_offset_mm, contour_offset);
+mm_wrap_simple!(spiral_mm, spiral);
+mm_wrap_simple!(voronoi_fill_mm, voronoi_fill);
+mm_wrap_simple!(hilbert_fill_mm, hilbert_fill);
+
+/// Hatch + zigzag share the (spacing, angle) signature.
+macro_rules! mm_wrap_hatch {
+ ($name:ident, $px_fn:ident) => {
+ pub fn $name(mm: &MmHull, spacing_mm: f32, angle_deg: f32) -> FillResult {
+ let s = FILL_INTERNAL_PX_PER_MM;
+ let h = rasterize_mm_hull(mm, s);
+ let spacing_px = (spacing_mm * s).max(0.5);
+ let r = $px_fn(&h, spacing_px, angle_deg);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
+ }
+ };
+}
+mm_wrap_hatch!(parallel_hatch_mm, parallel_hatch);
+mm_wrap_hatch!(zigzag_hatch_mm, zigzag_hatch);
+
+pub fn circle_pack_mm(mm: &MmHull, spacing_mm: f32, min_radius_factor: f32) -> FillResult {
+ let s = FILL_INTERNAL_PX_PER_MM;
+ let h = rasterize_mm_hull(mm, s);
+ let spacing_px = (spacing_mm * s).max(0.5);
+ let r = circle_pack(&h, spacing_px, min_radius_factor);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
+}
+
+pub fn wave_interference_mm(mm: &MmHull, spacing_mm: f32, num_sources: usize) -> FillResult {
+ let s = FILL_INTERNAL_PX_PER_MM;
+ let h = rasterize_mm_hull(mm, s);
+ let spacing_px = (spacing_mm * s).max(0.5);
+ let r = wave_interference(&h, spacing_px, num_sources);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
+}
+
+pub fn flow_field_mm(mm: &MmHull, spacing_mm: f32, angle_deg: f32, amplitude_scale: f32) -> FillResult {
+ let s = FILL_INTERNAL_PX_PER_MM;
+ let h = rasterize_mm_hull(mm, s);
+ let spacing_px = (spacing_mm * s).max(0.5);
+ let r = flow_field(&h, spacing_px, angle_deg, amplitude_scale);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
+}
+
+/// Gradient fills sample from the kernel response map, which lives at the
+/// project's source DPI. We rasterize the hull at that same resolution
+/// (`source_px_per_mm = canvas_w / paper_w_mm`) so sample coords align.
+/// As a result these fills are NOT fully DPI-independent — that's a
+/// deliberate compromise; gradient density is information from the image.
+pub fn gradient_hatch_mm(mm: &MmHull, response: &[u8], img_w: u32, source_px_per_mm: f32,
+ spacing_mm: f32, angle_deg: f32, min_scale: f32) -> FillResult {
+ let h = rasterize_mm_hull(mm, source_px_per_mm);
+ let spacing_px = (spacing_mm * source_px_per_mm).max(0.5);
+ let r = gradient_hatch(&h, response, img_w, spacing_px, angle_deg, min_scale);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, source_px_per_mm) }
+}
+
+pub fn gradient_cross_hatch_mm(mm: &MmHull, response: &[u8], img_w: u32, source_px_per_mm: f32,
+ spacing_mm: f32, angle_deg: f32, min_scale: f32) -> FillResult {
+ let h = rasterize_mm_hull(mm, source_px_per_mm);
+ let spacing_px = (spacing_mm * source_px_per_mm).max(0.5);
+ let r = gradient_cross_hatch(&h, response, img_w, spacing_px, angle_deg, min_scale);
+ FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, source_px_per_mm) }
+}
+
// ── Parallel hatch ─────────────────────────────────────────────────────────────
/// Horizontal scan lines through the hull at `spacing_px` pixel intervals,
@@ -1109,6 +1292,100 @@ mod tests {
.fold(0.0f32, f32::max)
}
+ // ── MmHull / mm-fill tests ────────────────────────────────────────────────
+
+ fn rect_mm_hull(w_mm: f32, h_mm: f32) -> MmHull {
+ MmHull {
+ id: 0,
+ contour: vec![(0.0, 0.0), (w_mm, 0.0), (w_mm, h_mm), (0.0, h_mm)],
+ holes: vec![],
+ bounds: crate::hulls::BoundsMm { x_min: 0.0, y_min: 0.0, x_max: w_mm, y_max: h_mm },
+ area_mm2: w_mm * h_mm,
+ avg_color: [0, 0, 0],
+ }
+ }
+
+ /// Build a pixel Hull representing a (50 × 30) mm rect at the given DPI,
+ /// then convert via MmHull and run mm-fill — for DPI-independence checks.
+ fn pixel_rect_to_mm_hull(dpi: f32) -> MmHull {
+ let px_per_mm = dpi / 25.4;
+ let w = (50.0 * px_per_mm).round() as u32;
+ let h = (30.0 * px_per_mm).round() as u32;
+ let pixels: Vec<(u32, u32)> = (0..h)
+ .flat_map(|y| (0..w).map(move |x| (x, y)))
+ .collect();
+ // hull_from_pixels is only available later in this module; build directly.
+ let hull = Hull {
+ id: 0,
+ pixels: pixels.clone(),
+ contour: pixels.clone(),
+ simplified: vec![(0.0, 0.0), (w as f32, 0.0), (w as f32, h as f32), (0.0, h as f32)],
+ area: w * h, avg_luminance: 0.0, avg_color: [0, 0, 0],
+ bounds: crate::hulls::Bounds { x_min: 0, y_min: 0, x_max: w - 1, y_max: h - 1 },
+ };
+ MmHull::from_pixel_hull(&hull, px_per_mm)
+ }
+
+ #[test]
+ fn rasterize_mm_hull_produces_pixels_in_bounds() {
+ let mm = rect_mm_hull(20.0, 10.0);
+ let h = rasterize_mm_hull(&mm, 10.0); // 10 px/mm → 200 × 100 px target
+ assert!(!h.pixels.is_empty(), "rasterized rect produced 0 pixels");
+ assert!(h.area > 18_000, "rasterized 20×10mm @ 10px/mm should fill ≥18k px, got {}", h.area);
+ for (x, y) in &h.pixels {
+ assert!(*x <= 200 && *y <= 100, "pixel ({x},{y}) out of bounds");
+ }
+ }
+
+ #[test]
+ fn outline_mm_emits_single_closed_polyline() {
+ let mm = rect_mm_hull(50.0, 30.0);
+ let r = outline_mm(&mm);
+ assert_eq!(r.strokes.len(), 1);
+ let s = &r.strokes[0];
+ // 4 polygon vertices + closing point = 5
+ assert_eq!(s.len(), 5);
+ assert_eq!(s[0], s[4], "first and last point must match (closed)");
+ }
+
+ #[test]
+ fn parallel_hatch_mm_strokes_are_in_mm_range() {
+ let mm = rect_mm_hull(50.0, 30.0);
+ let r = parallel_hatch_mm(&mm, 5.0, 0.0);
+ assert!(!r.strokes.is_empty());
+ for stroke in &r.strokes {
+ for &(x, y) in stroke {
+ assert!(x >= -1.0 && x <= 51.0, "stroke x={x} outside mm bounds");
+ assert!(y >= -1.0 && y <= 31.0, "stroke y={y} outside mm bounds");
+ }
+ }
+ }
+
+ #[test]
+ fn parallel_hatch_mm_dpi_independent() {
+ // Same 50×30 mm rect built at 150 DPI vs 600 DPI should produce
+ // matching mm strokes — the wrapper rasterizes at a fixed internal
+ // resolution, so source DPI is decoupled from output.
+ let r150 = parallel_hatch_mm(&pixel_rect_to_mm_hull(150.0), 5.0, 0.0);
+ let r600 = parallel_hatch_mm(&pixel_rect_to_mm_hull(600.0), 5.0, 0.0);
+ assert_eq!(r150.strokes.len(), r600.strokes.len(),
+ "stroke count differs across DPI: 150→{}, 600→{}",
+ r150.strokes.len(), r600.strokes.len());
+ // First stroke endpoints should match within ½mm (rounding wiggle).
+ let (x1, y1) = r150.strokes[0][0];
+ let (x2, y2) = r600.strokes[0][0];
+ assert!((x1 - x2).abs() < 0.5 && (y1 - y2).abs() < 0.5,
+ "first stroke at 150dpi=({x1:.2},{y1:.2}) vs 600dpi=({x2:.2},{y2:.2})");
+ }
+
+ #[test]
+ fn px_strokes_to_mm_divides_by_px_per_mm() {
+ let strokes = vec![vec![(20.0, 30.0), (40.0, 60.0)]];
+ let mm = px_strokes_to_mm(strokes, 10.0);
+ assert_eq!(mm[0][0], (2.0, 3.0));
+ assert_eq!(mm[0][1], (4.0, 6.0));
+ }
+
// ── Hull builders ─────────────────────────────────────────────────────────
fn make_square_hull(x0: u32, y0: u32, side: u32) -> Hull {
@@ -1639,8 +1916,9 @@ mod tests {
..Default::default()
};
let response = crate::detect::apply_stack(&img, &crate::detect::DetectionParams { layers: vec![layer] });
+ let _ = rdp_eps; // legacy debug-dump field; RDP no longer applied at extract time
let hull_params = crate::hulls::HullParams {
- threshold, min_area, rdp_epsilon: rdp_eps,
+ threshold, min_area,
connectivity: crate::hulls::Connectivity::Four,
};
let hulls = crate::hulls::extract_hulls(&response, &img, w, h, &hull_params);
diff --git a/src/gcode.rs b/src/gcode.rs
index 46baae0d..63ac4936 100644
--- a/src/gcode.rs
+++ b/src/gcode.rs
@@ -90,10 +90,18 @@ impl GcodeConfig {
}
/// Convert fill results to G-code.
-/// Pixel coordinates are scaled by `img_w_mm / img_w` (uniform, aspect-correct),
-/// then offset by `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)`.
-pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
- let scale = cfg.px_to_mm(img_w);
+/// Strokes are in mm relative to the image origin. We apply
+/// `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)`
+/// to position the image on the bed, and `img_w_mm / paper_w_mm` (when
+/// the user has scaled the image away from paper width) to scale the
+/// strokes — but typically that's 1.0. The legacy `_img_w` / `_img_h`
+/// args are kept so older callers compile; only `img_w` is consulted to
+/// derive the scale-on-paper ratio if `img_w_mm != paper_w_mm`. Most of
+/// the time `scale == 1.0` and we're just adding offsets.
+pub fn to_gcode(results: &[FillResult], _img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
+ // Image-on-paper scale: lets the user resize the image rect. 1.0 means
+ // strokes plot at their native mm dimensions.
+ let scale = if cfg.paper_w_mm > 0.0 { cfg.img_w_mm / cfg.paper_w_mm } else { 1.0 };
let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
@@ -200,22 +208,48 @@ mod tests {
}
#[test]
- fn gcode_aspect_ratio_preserved() {
- // 200×100 px image → 200mm wide → scale = 1.0 px/mm
- // A point at (50, 50) should map to (50*1.0 + ox, 50*1.0 + oy)
+ fn gcode_strokes_are_mm_with_offsets_applied() {
+ // Strokes are mm. img_w_mm == paper_w_mm so on-paper scale = 1.
+ // Point at (50, 50) mm should land at (50 + offset_x, 50 + offset_y).
let cfg = GcodeConfig {
+ paper_w_mm: 200.0,
+ paper_h_mm: 200.0,
img_w_mm: 200.0,
offset_x_mm: 10.0,
offset_y_mm: 20.0,
+ paper_offset_x_mm: 0.0,
+ paper_offset_y_mm: 0.0,
..GcodeConfig::default()
};
let result = FillResult {
hull_id: 0,
strokes: vec![vec![(0.0, 0.0), (50.0, 50.0)]],
};
- let code = to_gcode(&[result], 200, 100, &cfg);
- assert!(code.contains("X60.000"), "expected X=50*1.0+10=60");
- assert!(code.contains("Y70.000"), "expected Y=50*1.0+20=70");
+ let code = to_gcode(&[result], 0, 0, &cfg);
+ assert!(code.contains("X60.000"), "expected X=50+10=60, got: {code}");
+ assert!(code.contains("Y70.000"), "expected Y=50+20=70, got: {code}");
+ }
+
+ #[test]
+ fn gcode_image_scale_below_paper_shrinks_strokes() {
+ // img_w_mm = half paper width → image is half-size on paper.
+ // Stroke at 100mm in the image plots at 50mm on paper.
+ let cfg = GcodeConfig {
+ paper_w_mm: 200.0,
+ paper_h_mm: 200.0,
+ img_w_mm: 100.0,
+ offset_x_mm: 0.0,
+ offset_y_mm: 0.0,
+ paper_offset_x_mm: 0.0,
+ paper_offset_y_mm: 0.0,
+ ..GcodeConfig::default()
+ };
+ let result = FillResult {
+ hull_id: 0,
+ strokes: vec![vec![(0.0, 0.0), (100.0, 0.0)]],
+ };
+ let code = to_gcode(&[result], 0, 0, &cfg);
+ assert!(code.contains("X50.000"), "expected X=100*0.5=50, got: {code}");
}
#[test]
diff --git a/src/hulls.rs b/src/hulls.rs
index 916796f9..2216ae22 100644
--- a/src/hulls.rs
+++ b/src/hulls.rs
@@ -15,6 +15,50 @@ pub struct Hull {
pub bounds: Bounds,
}
+// ── Mm-coordinate hull ─────────────────────────────────────────────────────────
+// Polygon-only hull, in mm. Produced from a pixel `Hull` after extraction by
+// dividing pixel coords by `px_per_mm`. Downstream of the Hull stage, fills
+// take this and produce mm strokes — DPI-independent. Fills that internally
+// need a pixel grid (parallel_hatch's row inside-test, contour_offset's
+// distance transform, etc.) rasterize the polygon themselves at whatever
+// resolution they want via `mm_hull_to_pixel_hull`.
+#[derive(Debug, Clone)]
+pub struct MmHull {
+ pub id: u32,
+ pub contour: Vec<(f32, f32)>, // outer polygon, mm coords
+ pub holes: Vec>, // inner polygons (holes); empty for now
+ pub area_mm2: f32,
+ pub avg_color: [u8; 3],
+ pub bounds: BoundsMm,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct BoundsMm {
+ pub x_min: f32, pub y_min: f32,
+ pub x_max: f32, pub y_max: f32,
+}
+
+impl MmHull {
+ /// Build an MmHull from a pixel `Hull` — divides the simplified polygon
+ /// and bounds by `px_per_mm` to land in mm coords.
+ pub fn from_pixel_hull(h: &Hull, px_per_mm: f32) -> Self {
+ let inv = if px_per_mm > 0.0 { 1.0 / px_per_mm } else { 0.0 };
+ let contour: Vec<(f32, f32)> = h.simplified.iter()
+ .map(|&(x, y)| (x * inv, y * inv)).collect();
+ let bounds = BoundsMm {
+ x_min: h.bounds.x_min as f32 * inv,
+ y_min: h.bounds.y_min as f32 * inv,
+ x_max: (h.bounds.x_max as f32 + 1.0) * inv,
+ y_max: (h.bounds.y_max as f32 + 1.0) * inv,
+ };
+ let area_mm2 = h.area as f32 * inv * inv;
+ Self {
+ id: h.id, contour, holes: vec![],
+ area_mm2, avg_color: h.avg_color, bounds,
+ }
+ }
+}
+
// ── Color filter ───────────────────────────────────────────────────────────────
/// Per-pass HSV range filter. A hull passes if its average color falls in all three ranges.
@@ -97,7 +141,6 @@ pub enum Connectivity {
pub struct HullParams {
pub threshold: u8, // pixels strictly darker than this = ink
pub min_area: u32, // discard components with fewer pixels (noise filter)
- pub rdp_epsilon: f32, // RDP tolerance in pixels
pub connectivity: Connectivity, // 4- or 8-connected flood fill
}
@@ -106,7 +149,6 @@ impl Default for HullParams {
Self {
threshold: 128,
min_area: 4,
- rdp_epsilon: 1.5,
connectivity: Connectivity::Four,
}
}
@@ -123,7 +165,10 @@ pub fn extract_hulls(luma: &[u8], rgb: &image::RgbImage, width: u32, height: u32
components.into_iter().enumerate().map(|(id, pixels)| {
let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
let contour = trace_contour(&pixel_set);
- let simplified = rdp_simplify(&contour, params.rdp_epsilon);
+ // No more RDP — `simplified` keeps its name for source compat but
+ // is just contour cast to f32. Downstream fills internally rasterize
+ // at fixed resolution and re-simplify in mm-space if desired.
+ let simplified: Vec<(f32, f32)> = contour.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
let (mut xn, mut yn) = (u32::MAX, u32::MAX);
let (mut xx, mut yx) = (0u32, 0u32);
@@ -274,47 +319,6 @@ pub(crate) fn trace_contour(component: &HashSet<(u32, u32)>) -> Vec<(u32, u32)>
contour
}
-// ── Ramer-Douglas-Peucker ──────────────────────────────────────────────────────
-
-fn rdp_simplify(pts: &[(u32, u32)], epsilon: f32) -> Vec<(f32, f32)> {
- let fp: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
- if fp.len() <= 2 { return fp; }
- rdp_rec(&fp, epsilon)
-}
-
-fn rdp_rec(pts: &[(f32, f32)], eps: f32) -> Vec<(f32, f32)> {
- if pts.len() <= 2 { return pts.to_vec(); }
-
- let (first, last) = (pts[0], *pts.last().unwrap());
- let (mut dmax, mut idx) = (0f32, 0);
-
- for (i, &p) in pts[1..pts.len() - 1].iter().enumerate() {
- let d = perp_dist(p, first, last);
- if d > dmax { dmax = d; idx = i + 1; }
- }
-
- if dmax > eps {
- let mut out = rdp_rec(&pts[..=idx], eps);
- out.pop();
- out.extend(rdp_rec(&pts[idx..], eps));
- out
- } else {
- vec![first, last]
- }
-}
-
-fn perp_dist(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
- let (dx, dy) = (b.0 - a.0, b.1 - a.1);
- let len2 = dx * dx + dy * dy;
- if len2 < 1e-10 {
- return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
- }
- let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2;
- let cx = a.0 + t.clamp(0.0, 1.0) * dx;
- let cy = a.1 + t.clamp(0.0, 1.0) * dy;
- ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
-}
-
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -343,6 +347,28 @@ pub mod tests {
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).collect()
}
+ #[test]
+ fn mm_hull_from_pixel_hull_scales_coords() {
+ // A 60×40 px hull at 10 px/mm should be 6×4 mm.
+ let h = Hull {
+ id: 0,
+ pixels: filled_rect(0, 0, 59, 39),
+ contour: vec![(0,0),(59,0),(59,39),(0,39)],
+ simplified: vec![(0.0,0.0),(59.0,0.0),(59.0,39.0),(0.0,39.0)],
+ area: 60*40,
+ avg_luminance: 0.0,
+ avg_color: [0,0,0],
+ bounds: Bounds { x_min: 0, y_min: 0, x_max: 59, y_max: 39 },
+ };
+ let mm = MmHull::from_pixel_hull(&h, 10.0);
+ assert_eq!(mm.id, 0);
+ assert!((mm.bounds.x_max - 6.0).abs() < 1e-3);
+ assert!((mm.bounds.y_max - 4.0).abs() < 1e-3);
+ assert_eq!(mm.contour.len(), 4);
+ assert!((mm.contour[1].0 - 5.9).abs() < 1e-3);
+ assert!((mm.area_mm2 - 24.0).abs() < 1e-3);
+ }
+
/// 1px border of a rectangle (outline only).
pub fn rect_outline(x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<(u32, u32)> {
let mut v = Vec::new();
@@ -397,7 +423,7 @@ pub mod tests {
fn single_filled_square_one_hull() {
let dark = filled_rect(10, 10, 29, 29); // 20×20
let luma = make_image(64, 64, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
assert_eq!(hulls.len(), 1, "one square → one hull");
assert_eq!(hulls[0].area, 400, "20×20 = 400 px");
@@ -409,7 +435,7 @@ pub mod tests {
let mut dark = filled_rect(2, 2, 11, 11); // 10×10
dark.extend(filled_rect(20, 20, 29, 29)); // 10×10, separated by gap
let luma = make_image(40, 40, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(40, 40), 40, 40, &p);
assert_eq!(hulls.len(), 2, "two squares → two hulls");
let mut areas: Vec = hulls.iter().map(|h| h.area).collect();
@@ -421,7 +447,7 @@ pub mod tests {
#[test]
fn single_pixel_hull() {
let luma = make_image(16, 16, &[(8, 8)]);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 0.5, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
assert_eq!(hulls.len(), 1);
assert_eq!(hulls[0].area, 1);
@@ -431,7 +457,7 @@ pub mod tests {
#[test]
fn all_dark_image_one_hull() {
let luma = vec![0u8; 16 * 16];
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
assert_eq!(hulls.len(), 1);
assert_eq!(hulls[0].area, 256);
@@ -452,7 +478,7 @@ pub mod tests {
dark.push((0, 0)); // 1px noise
dark.push((31, 31)); // 1px noise
let luma = make_image(32, 32, &dark);
- let p = HullParams { threshold: 128, min_area: 4, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 4, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
assert_eq!(hulls.len(), 1, "min_area=4 must remove single-pixel noise");
assert_eq!(hulls[0].area, 100);
@@ -464,7 +490,7 @@ pub mod tests {
.flat_map(|x| [31u32, 32, 33].iter().map(move |&y| (x, y)))
.collect();
let luma = make_image(64, 64, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
assert_eq!(hulls.len(), 1, "horizontal line → one hull");
assert_eq!(hulls[0].area, 64 * 3);
@@ -477,7 +503,7 @@ pub mod tests {
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
.collect();
let luma = make_image(64, 64, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
assert_eq!(hulls.len(), 3, "three separate lines → three hulls");
let mut areas: Vec = hulls.iter().map(|h| h.area).collect();
@@ -493,7 +519,7 @@ pub mod tests {
let mut dark = filled_rect(0, 0, 4, 4);
dark.extend(filled_rect(5, 5, 9, 9)); // touches corner (4,4)↔(5,5) diagonally
let luma = make_image(16, 16, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, connectivity: Connectivity::Four };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
assert_eq!(hulls.len(), 2, "diagonally touching squares = two 4-connected hulls");
}
@@ -504,7 +530,7 @@ pub mod tests {
let mut dark = filled_rect(0, 0, 4, 4);
dark.extend(filled_rect(5, 5, 9, 9));
let luma = make_image(16, 16, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, connectivity: Connectivity::Eight };
+ let p = HullParams { threshold: 128, min_area: 1, connectivity: Connectivity::Eight };
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
assert_eq!(hulls.len(), 1, "diagonally touching squares = one 8-connected hull");
}
@@ -515,43 +541,13 @@ pub mod tests {
fn contour_pixels_are_on_boundary() {
let dark = filled_rect(5, 5, 20, 20);
let luma = make_image(32, 32, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 0.5, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
assert_eq!(hulls.len(), 1);
assert!(!hulls[0].contour.is_empty());
assert_contour_on_boundary(&hulls[0]);
}
- #[test]
- fn square_rdp_yields_four_corners() {
- // 40×40 square starting at (10,10) → corners at (10,10),(49,10),(49,49),(10,49)
- let dark = filled_rect(10, 10, 49, 49);
- let luma = make_image(64, 64, &dark);
- // epsilon=2.0: straight edges collapse, only corners survive
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 2.0, ..HullParams::default() };
- let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
- assert_eq!(hulls.len(), 1);
- let n = hulls[0].simplified.len();
- assert!(n >= 4 && n <= 6,
- "40×40 square should simplify to 4 corners (±1 for loop endpoint), got {n}: {:?}",
- hulls[0].simplified);
- }
-
- #[test]
- fn single_line_rdp_yields_two_endpoints() {
- // Thin 1px horizontal line — only start and end should survive RDP
- let dark: Vec<(u32, u32)> = (0..64u32).map(|x| (x, 32)).collect();
- let luma = make_image(64, 64, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
- let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
- assert_eq!(hulls.len(), 1);
- let n = hulls[0].simplified.len();
- // A straight line: start + end = 2 points, maybe 3-4 for the thin-hull two-sided contour
- assert!(n <= 6,
- "straight line should simplify to very few points, got {n}: {:?}",
- hulls[0].simplified);
- }
-
// ── Coverage comparison ───────────────────────────────────────────────────
/// Reconstruct a binary mask from hull pixels and compare with original.
@@ -579,7 +575,7 @@ pub mod tests {
fn coverage_score_perfect_for_square() {
let dark = filled_rect(5, 5, 25, 25);
let luma = make_image(32, 32, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
let (prec, rec) = coverage_score(&luma, 32, 32, &hulls, p.threshold);
assert_eq!(prec, 1.0, "precision must be 1.0: no hull pixel is light");
@@ -592,7 +588,7 @@ pub mod tests {
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
.collect();
let luma = make_image(64, 64, &dark);
- let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
let (prec, rec) = coverage_score(&luma, 64, 64, &hulls, p.threshold);
assert_eq!(prec, 1.0);
@@ -613,6 +609,7 @@ pub mod tests {
let rgb = dyn_img.to_rgb8();
let (w, h) = img.dimensions();
let params = HullParams { threshold: 128, min_area: 4, ..HullParams::default() };
+ let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
let hulls = extract_hulls(img.as_raw(), &rgb, w, h, ¶ms);
assert_eq!(hulls.len(), 128, "checkerboard has 128 dark cells");
for hull in &hulls {
diff --git a/src/lib.rs b/src/lib.rs
index f2e25c07..8869c491 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -83,6 +83,7 @@ fn fp_kernel(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
h.write_u32(cf.sat_min.to_bits()); h.write_u32(cf.sat_max.to_bits());
h.write_u32(cf.val_min.to_bits()); h.write_u32(cf.val_max.to_bits());
}
+ h.write_u32(node.kernel_dpi.unwrap_or(0));
h.finish()
}
fn fp_combine(node: &GraphNodePayload, upstream_fps: &[u64]) -> u64 {
@@ -96,7 +97,6 @@ fn fp_hull(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
h.write_u64(upstream_fp);
h.write_u8(node.threshold.unwrap_or(128));
h.write_u32(node.min_area.unwrap_or(4));
- h.write_u32(node.rdp_epsilon.unwrap_or(1.5).to_bits());
h.write(node.connectivity.as_deref().unwrap_or("four").as_bytes());
if let Some(cf) = &node.color_filter {
h.write_u8(cf.enabled as u8);
@@ -117,12 +117,15 @@ fn fp_fill(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
h.write_u32(node.smooth_iters.unwrap_or(2));
h.finish()
}
-fn fp_pen(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
+fn fp_pen(node: &GraphNodePayload, upstream_fp: u64, pen_tip_mm: Option) -> u64 {
let mut h = DefaultHasher::new();
h.write_u64(upstream_fp);
for &v in node.pen_color.as_deref().unwrap_or(&[20, 20, 20]) { h.write_u8(v); }
h.write(node.pen_label.as_deref().unwrap_or("").as_bytes());
h.write_u32(node.pen_order.unwrap_or(0));
+ // Pen tip diameter affects the rendered preview's stroke width; bake
+ // it into the fingerprint so changing the slider re-renders the card.
+ h.write_u32(pen_tip_mm.unwrap_or(0.5).to_bits());
h.finish()
}
fn fp_text(node: &GraphNodePayload) -> u64 {
@@ -176,7 +179,7 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload)
"Combine" => fp_combine(node, &up_fps),
"Hull" => fp_hull(node, first),
"Fill" => fp_fill(node, first),
- "PenOutput" => fp_pen(node, first),
+ "PenOutput" => fp_pen(node, first, payload.pen_tip_mm),
"Text" => fp_text(node),
_ => 0,
};
@@ -207,6 +210,8 @@ struct PassState {
response_map: Vec,
img_w: u32,
img_h: u32,
+ paper_w_mm: f32, // mm dims of the paper this pass was rendered for
+ paper_h_mm: f32,
node_cache: NodeCache,
}
@@ -245,10 +250,10 @@ pub struct GraphNodePayload {
pub xdog_phi: Option,
// Combine params (optional)
pub blend_mode: Option,
+ pub kernel_dpi: Option,
// Hull params (optional — only for kind="Hull")
pub threshold: Option,
pub min_area: Option,
- pub rdp_epsilon: Option,
pub connectivity: Option,
pub color_filter: Option,
// Fill params (optional — only for kind="Fill")
@@ -311,6 +316,9 @@ pub struct ProcessPassPayload {
/// projects work without an image load. Image-input projects
/// ignore this and derive height from aspect ratio.
pub img_h_mm: Option,
+ /// Pen tip diameter in mm — drives the pen-card preview stroke
+ /// width so previews show the physical ink width rather than 1 px.
+ pub pen_tip_mm: Option,
}
#[derive(Serialize, Clone, Default)]
@@ -377,9 +385,12 @@ fn default_pen_dwell() -> u32 { 250 }
#[derive(Serialize)]
pub struct AllStrokesPayload {
- pub passes: Vec,
- pub img_width: u32,
- pub img_height: u32,
+ pub passes: Vec,
+ /// Stroke coords are in mm. paper_w_mm / paper_h_mm describe the
+ /// paper rect the strokes are positioned within so the frontend
+ /// knows how to scale them to screen.
+ pub paper_w_mm: f32,
+ pub paper_h_mm: f32,
}
#[derive(Serialize)]
@@ -425,6 +436,7 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
ci_sat_max: cf.map(|f| f.sat_max).unwrap_or(1.0),
ci_val_min: cf.map(|f| f.val_min).unwrap_or(0.0),
ci_val_max: cf.map(|f| f.val_max).unwrap_or(1.0),
+ kernel_dpi: n.kernel_dpi,
})
}
"Combine" => {
@@ -466,7 +478,6 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
detect::NodeKind::Hull {
threshold: n.threshold.unwrap_or(128),
min_area: n.min_area.unwrap_or(4),
- rdp_epsilon: n.rdp_epsilon.unwrap_or(1.5),
eight_conn: n.connectivity.as_deref() == Some("eight"),
cf_enabled: cf.map(|f| f.enabled).unwrap_or(false),
cf_hue_min: cf.map(|f| f.hue_min).unwrap_or(0.0),
@@ -530,11 +541,51 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String {
// ── Pipeline inner functions (no Tauri, no mutex) ─────────────────────────────
-/// Rasterize fill strokes in the pen's color on a light background at full image resolution.
-fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h: u32) -> String {
+/// Fixed preview resolution — paper × this rate gives the preview
+/// thumbnail's pixel dimensions. DPI-independent so Hull/Fill/Pen
+/// thumbnails look identical regardless of project DPI; they're just
+/// rendering polygon/mm-stroke data that doesn't change with DPI.
+const PREVIEW_PX_PER_MM: f32 = 5.0;
+
+fn preview_dims_for_paper(paper_w_mm: f32, paper_h_mm: f32) -> (u32, u32) {
+ let w = (paper_w_mm * PREVIEW_PX_PER_MM).round().max(1.0) as u32;
+ let h = (paper_h_mm * PREVIEW_PX_PER_MM).round().max(1.0) as u32;
+ (w, h)
+}
+
+/// Rasterize mm-coord fill strokes onto a preview canvas. The preview
+/// dimensions are fixed at `paper × PREVIEW_PX_PER_MM`, decoupled from
+/// project DPI — pen-output data is purely mm so thumbnails should look
+/// identical regardless of DPI.
+fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult,
+ paper_w_mm: f32, paper_h_mm: f32, pen_tip_mm: f32) -> String {
+ let (img_w, img_h) = preview_dims_for_paper(paper_w_mm, paper_h_mm);
+ let radius_px = (pen_tip_mm * PREVIEW_PX_PER_MM / 2.0).max(0.5);
+ let scaled = fill_strokes_mm_to_preview_px(fill, paper_w_mm, img_w);
let mut pix = vec![[235u8, 235u8, 235u8]; (img_w * img_h) as usize];
- for stroke in &fill.strokes {
+ // Pre-compute pen-disk pixel offsets so each Bresenham step stamps a
+ // circle, giving the preview the actual physical ink width.
+ let r_ceil = radius_px.ceil() as i32;
+ let r2 = radius_px * radius_px;
+ let mut disk: Vec<(i32, i32)> = Vec::with_capacity((r_ceil * r_ceil * 4) as usize + 1);
+ for dy in -r_ceil..=r_ceil {
+ for dx in -r_ceil..=r_ceil {
+ let d2 = (dx * dx + dy * dy) as f32;
+ if d2 <= r2 { disk.push((dx, dy)); }
+ }
+ }
+
+ let stamp = |pix: &mut Vec<[u8; 3]>, x: i32, y: i32| {
+ for &(dx, dy) in &disk {
+ let nx = x + dx;
+ let ny = y + dy;
+ if nx < 0 || ny < 0 || nx >= img_w as i32 || ny >= img_h as i32 { continue; }
+ pix[(ny as u32 * img_w + nx as u32) as usize] = color;
+ }
+ };
+
+ for stroke in &scaled.strokes {
for pair in stroke.windows(2) {
let (mut x, mut y) = (pair[0].0.round() as i32, pair[0].1.round() as i32);
let (x1, y1) = (pair[1].0.round() as i32, pair[1].1.round() as i32);
@@ -542,9 +593,7 @@ fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h
let dy = -(y1 - y).abs(); let sy_ = if y < y1 { 1i32 } else { -1 };
let mut err = dx + dy;
loop {
- if x >= 0 && y >= 0 && (x as u32) < img_w && (y as u32) < img_h {
- pix[(y as u32 * img_w + x as u32) as usize] = color;
- }
+ stamp(&mut pix, x, y);
if x == x1 && y == y1 { break; }
let e2 = 2 * err;
if e2 >= dy { err += dy; x += sx_; }
@@ -560,11 +609,23 @@ fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h
B64.encode(buf.into_inner())
}
-/// Rasterize fill strokes into a JPEG preview at full image resolution.
-fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> String {
+/// Convert a FillResult's mm strokes into pixel strokes for the preview canvas.
+fn fill_strokes_mm_to_preview_px(result: &fill::FillResult, paper_w_mm: f32, img_w: u32) -> fill::FillResult {
+ let s = if paper_w_mm > 0.0 { img_w as f32 / paper_w_mm } else { 1.0 };
+ let strokes: Vec> = result.strokes.iter()
+ .map(|stk| stk.iter().map(|&(x, y)| (x * s, y * s)).collect())
+ .collect();
+ fill::FillResult { hull_id: result.hull_id, strokes }
+}
+
+/// Rasterize mm-coord fill strokes into a JPEG preview at fixed
+/// preview resolution (DPI-independent).
+fn render_fill_preview(result: &fill::FillResult, paper_w_mm: f32, paper_h_mm: f32) -> String {
+ let (img_w, img_h) = preview_dims_for_paper(paper_w_mm, paper_h_mm);
+ let scaled = fill_strokes_mm_to_preview_px(result, paper_w_mm, img_w);
let mut pix = vec![20u8; (img_w * img_h) as usize];
- for stroke in &result.strokes {
+ for stroke in &scaled.strokes {
for pair in stroke.windows(2) {
let (mut x, mut y) = (pair[0].0.round() as i32, pair[0].1.round() as i32);
let (x1, y1) = (pair[1].0.round() as i32, pair[1].1.round() as i32);
@@ -591,21 +652,33 @@ fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> Str
B64.encode(buf.into_inner())
}
-fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u32) -> String {
- let mut rgba = vec![15u8; (w * h * 4) as usize];
+/// Hull preview: paint each hull's pixel set onto a fixed-resolution
+/// preview canvas. Pixel coords are scaled from canvas pixels (where the
+/// hulls live) to preview pixels via `paper_w_mm`-derived ratios — so
+/// the rendered thumbnail is DPI-independent.
+fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull],
+ canvas_w: u32, canvas_h: u32,
+ paper_w_mm: f32, paper_h_mm: f32) -> String {
+ let (pw, ph) = preview_dims_for_paper(paper_w_mm, paper_h_mm);
+ let sx = if canvas_w > 0 { pw as f32 / canvas_w as f32 } else { 1.0 };
+ let sy = if canvas_h > 0 { ph as f32 / canvas_h as f32 } else { 1.0 };
+ let mut rgba = vec![15u8; (pw * ph * 4) as usize];
for chunk in rgba.chunks_mut(4) { chunk[3] = 255; }
for hull in hulls_list {
let (hr, hg, hb) = hash_color(hull.id);
for &(px, py) in &hull.pixels {
- let resp = response.get((py * w + px) as usize).copied().unwrap_or(0);
+ let nx = (px as f32 * sx) as u32;
+ let ny = (py as f32 * sy) as u32;
+ if nx >= pw || ny >= ph { continue; }
+ let resp = response.get((py * canvas_w + px) as usize).copied().unwrap_or(0);
let intensity = (255u32 - resp as u32) as f32 / 255.0;
- let i = ((py * w + px) * 4) as usize;
+ let i = ((ny * pw + nx) * 4) as usize;
rgba[i] = (hr as f32 * intensity) as u8;
rgba[i+1] = (hg as f32 * intensity) as u8;
rgba[i+2] = (hb as f32 * intensity) as u8;
}
}
- rgba_to_b64_png(&rgba, w, h)
+ rgba_to_b64_png(&rgba, pw, ph)
}
fn process_pass_work(
@@ -627,6 +700,13 @@ fn process_pass_work(
// in a single coord frame.
let (w, h) = (canvas_w, canvas_h);
+ // Paper dims (mm) — shared by mm-fill conversion, preview rendering,
+ // and gradient-fill source sampling. Hoisted so every stage uses the
+ // same numbers.
+ let paper_w_mm_for_scale = payload.img_w_mm.unwrap_or(210.0).max(1.0);
+ let paper_h_mm_for_scale = payload.img_h_mm.unwrap_or(297.0).max(1.0);
+ let px_per_mm_fill = w as f32 / paper_w_mm_for_scale;
+
// ── Per-node Source RGB lookup ────────────────────────────────────────────
// Trees can't merge (frontend enforces; backend assumes), so each non-
// Source node has exactly one Source ancestor. Topo-walk to propagate
@@ -701,7 +781,8 @@ fn process_pass_work(
}
} else {
cache_misses += 1;
- let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h);
+ let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h,
+ payload.dpi.unwrap_or(150) as f32);
cache.detect_fp = detect_fp;
cache.detect_response = maps.response.clone();
cache.detect_maps = maps.raw_maps.clone();
@@ -767,7 +848,7 @@ fn process_pass_work(
for node in &det_graph.nodes {
if let detect::NodeKind::Hull {
- threshold, min_area, rdp_epsilon, eight_conn, ..
+ threshold, min_area, eight_conn, ..
} = &node.kind {
let response = match graph_maps.raw_maps.get(&node.id) {
Some(m) => m,
@@ -791,7 +872,7 @@ fn process_pass_work(
}
all_hulls.extend(entry.hulls.clone());
let p = preview.unwrap_or_else(|| {
- let p = render_hull_preview(response, &entry.hulls, w, h);
+ let p = render_hull_preview(response, &entry.hulls, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.preview_cache.insert(node.id.clone(), (hull_fp, p.clone()));
p
});
@@ -809,12 +890,11 @@ fn process_pass_work(
let hull_params = hulls::HullParams {
threshold: *threshold,
min_area: *min_area,
- rdp_epsilon: *rdp_epsilon,
connectivity: if *eight_conn { hulls::Connectivity::Eight }
else { hulls::Connectivity::Four },
};
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
- let preview = render_hull_preview(response, &extracted, w, h);
+ let preview = render_hull_preview(response, &extracted, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
fp: hull_fp,
hulls: extracted.clone(),
@@ -831,12 +911,11 @@ fn process_pass_work(
let hull_params = hulls::HullParams {
threshold: *threshold,
min_area: *min_area,
- rdp_epsilon: *rdp_epsilon,
connectivity: if *eight_conn { hulls::Connectivity::Eight }
else { hulls::Connectivity::Four },
};
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
- let preview = render_hull_preview(response, &extracted, w, h);
+ let preview = render_hull_preview(response, &extracted, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
(extracted, preview)
};
@@ -871,16 +950,16 @@ fn process_pass_work(
let fill_fp = node_fps.get(&node.id).copied().unwrap_or(0);
- let (optimised, preview) = if fill_fp != 0 {
+ // Try the cache first.
+ if fill_fp != 0 {
if let Some(entry) = cache.fill_entries.get(&node.id) {
if entry.fp == fill_fp {
- // Cache hit
let preview = cache.preview_cache.get(&node.id)
.filter(|(fp, _)| *fp == fill_fp)
.map(|(_, p)| p.clone());
cache_hits += 1;
let p = preview.unwrap_or_else(|| {
- let p = render_fill_preview(&entry.fill, w, h);
+ let p = render_fill_preview(&entry.fill, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.preview_cache.insert(node.id.clone(), (fill_fp, p.clone()));
p
});
@@ -890,61 +969,43 @@ fn process_pass_work(
}
}
cache_misses += 1;
- // Cache miss — compute
- let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
- let (strategy, spacing, angle, param, smooth_rdp, smooth_iters) =
- (strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
- let img_w = w;
- let raw: Vec = hulls_for_fill.par_iter().map(|hull| {
- match strategy.as_str() {
- "outline" => fill::outline(hull),
- "zigzag" => fill::zigzag_hatch(hull, spacing, angle),
- "offset" => fill::contour_offset(hull, spacing),
- "spiral" => fill::spiral(hull, spacing),
- "circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
- "voronoi" => fill::voronoi_fill(hull, spacing),
- "hilbert" => fill::hilbert_fill(hull, spacing),
- "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
- "flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
- "gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
- "gradient_cross_hatch" => fill::gradient_cross_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
- _ => fill::parallel_hatch(hull, spacing, angle),
- }
- }).collect();
- let smoothed: Vec = raw.iter()
- .map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)).collect();
- let opt = fill::optimize_travel(&smoothed);
- let preview = render_fill_preview(&opt, w, h);
+ }
+
+ // Compute. Convert pixel hulls to mm hulls once; mm fills handle
+ // their own internal rasterization at FILL_INTERNAL_PX_PER_MM.
+ let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
+ let (strategy, spacing_mm, angle, param, smooth_rdp, smooth_iters) =
+ (strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
+ let mm_hulls: Vec = hulls_for_fill.iter()
+ .map(|h| hulls::MmHull::from_pixel_hull(h, px_per_mm_fill))
+ .collect();
+ let img_w = w;
+ let src_px_per_mm = px_per_mm_fill;
+ let raw: Vec = mm_hulls.par_iter().map(|mh| {
+ match strategy.as_str() {
+ "outline" => fill::outline_mm(mh),
+ "zigzag" => fill::zigzag_hatch_mm(mh, spacing_mm, angle),
+ "offset" => fill::contour_offset_mm(mh, spacing_mm),
+ "spiral" => fill::spiral_mm(mh, spacing_mm),
+ "circles" => fill::circle_pack_mm(mh, spacing_mm, param.max(0.1)),
+ "voronoi" => fill::voronoi_fill_mm(mh, spacing_mm),
+ "hilbert" => fill::hilbert_fill_mm(mh, spacing_mm),
+ "waves" => fill::wave_interference_mm(mh, spacing_mm, param.round().max(1.0) as usize),
+ "flow" => fill::flow_field_mm(mh, spacing_mm, angle, param.max(0.0)),
+ "gradient_hatch" => fill::gradient_hatch_mm(mh, &response_arc, img_w, src_px_per_mm, spacing_mm, angle, param.clamp(0.05, 1.0)),
+ "gradient_cross_hatch" => fill::gradient_cross_hatch_mm(mh, &response_arc, img_w, src_px_per_mm, spacing_mm, angle, param.clamp(0.05, 1.0)),
+ _ => fill::parallel_hatch_mm(mh, spacing_mm, angle),
+ }
+ }).collect();
+ let smoothed: Vec = raw.iter()
+ .map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)).collect();
+ let opt = fill::optimize_travel(&smoothed);
+ let preview = render_fill_preview(&opt, paper_w_mm_for_scale, paper_h_mm_for_scale);
+ if fill_fp != 0 {
cache.fill_entries.insert(node.id.clone(), FillCacheEntry { fp: fill_fp, fill: opt.clone() });
cache.preview_cache.insert(node.id.clone(), (fill_fp, preview.clone()));
- (opt, preview)
- } else {
- let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
- let (strategy, spacing, angle, param, smooth_rdp, smooth_iters) =
- (strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
- let img_w = w;
- let raw: Vec = hulls_for_fill.par_iter().map(|hull| {
- match strategy.as_str() {
- "outline" => fill::outline(hull),
- "zigzag" => fill::zigzag_hatch(hull, spacing, angle),
- "offset" => fill::contour_offset(hull, spacing),
- "spiral" => fill::spiral(hull, spacing),
- "circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
- "voronoi" => fill::voronoi_fill(hull, spacing),
- "hilbert" => fill::hilbert_fill(hull, spacing),
- "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
- "flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
- "gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
- "gradient_cross_hatch" => fill::gradient_cross_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
- _ => fill::parallel_hatch(hull, spacing, angle),
- }
- }).collect();
- let smoothed: Vec = raw.iter()
- .map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)).collect();
- let opt = fill::optimize_travel(&smoothed);
- let preview = render_fill_preview(&opt, w, h);
- (opt, preview)
- };
+ }
+ let (optimised, preview) = (opt, preview);
node_previews.insert(node.id.clone(), preview);
fill_outputs.insert(node.id.clone(), optimised);
@@ -953,30 +1014,21 @@ fn process_pass_work(
t = lap!(steps, "fill", t);
// ── Text nodes ─────────────────────────────────────────────────────────────
- // Text nodes produce strokes in mm directly via Hershey; we convert to
- // pixel coords matching the rest of the pipeline so PenOutput can pull
- // them from `fill_outputs` exactly like a Fill node.
- let dpi = payload.dpi.unwrap_or(150).max(1) as f32;
- let paper_w_mm_for_scale = payload.img_w_mm.unwrap_or(w as f32 * 25.4 / dpi);
- let px_per_mm = w as f32 / paper_w_mm_for_scale.max(1e-3);
+ // Text nodes produce strokes in mm directly via Hershey — they're already
+ // in the FillResult coord system (mm), so just pass through.
for node in &det_graph.nodes {
if let detect::NodeKind::Text {
text, font, font_size_mm, line_spacing_mm,
x_mm, y_mm, align, underline,
} = &node.kind {
- let mm_strokes = text::render_text(
+ let strokes = text::render_text(
text, font, *font_size_mm, *line_spacing_mm,
*x_mm, *y_mm,
text::Align::from_str(align),
*underline,
);
- let strokes: Vec> = mm_strokes.into_iter()
- .map(|s| s.into_iter()
- .map(|(mx, my)| (mx * px_per_mm, my * px_per_mm))
- .collect())
- .collect();
let fill = fill::FillResult { hull_id: 0, strokes };
- let preview = render_fill_preview(&fill, w, h);
+ let preview = render_fill_preview(&fill, paper_w_mm_for_scale, paper_h_mm_for_scale);
node_previews.insert(node.id.clone(), preview);
fill_outputs.insert(node.id.clone(), fill);
}
@@ -984,6 +1036,11 @@ fn process_pass_work(
t = lap!(steps, "text", t);
// ── PenOutput nodes ────────────────────────────────────────────────────────
+ // Pen preview stamps a disk at each Bresenham step; radius scales the
+ // physical pen tip into preview-canvas pixels via px_per_mm_fill.
+ let pen_tip_mm = payload.pen_tip_mm.unwrap_or(0.5).max(0.05);
+ let pen_radius_px = (pen_tip_mm * px_per_mm_fill / 2.0).max(0.5);
+
let mut pen_results: Vec = Vec::new();
let mut pen_output_results: Vec = Vec::new();
@@ -1003,18 +1060,18 @@ fn process_pass_work(
cached_p.clone()
} else {
cache_misses += 1;
- let p = render_pen_preview(*color, &fill, w, h);
+ let p = render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm);
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
p
}
} else {
cache_misses += 1;
- let p = render_pen_preview(*color, &fill, w, h);
+ let p = render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm);
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
p
}
} else {
- render_pen_preview(*color, &fill, w, h)
+ render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm)
};
node_previews.insert(node.id.clone(), preview);
pen_output_results.push(PenOutputResult {
@@ -1176,6 +1233,8 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex>,
) -> Result {
let st = state.lock().unwrap();
- // Use the scaled pipeline dimensions (stored after process_pass) so the
- // viewport offscreen canvas matches the coordinate space of the strokes.
- let (img_width, img_height) = st.passes.first()
- .filter(|p| p.img_w > 0)
- .map(|p| (p.img_w, p.img_h))
- .unwrap_or((1, 1));
+ // Strokes are in mm; frontend uses paper dims to scale to screen.
+ let (paper_w_mm, paper_h_mm) = st.passes.first()
+ .filter(|p| p.paper_w_mm > 0.0)
+ .map(|p| (p.paper_w_mm, p.paper_h_mm))
+ .unwrap_or((210.0, 297.0));
let mut all: Vec = Vec::new();
for ps in st.passes.iter() {
let mut pens = ps.pen_results.clone();
@@ -2103,7 +2161,7 @@ fn get_all_strokes(
all.push(PassStrokesPayload { pass_index: i, color: pr.color, strokes });
}
}
- Ok(AllStrokesPayload { passes: all, img_width, img_height })
+ Ok(AllStrokesPayload { passes: all, paper_w_mm, paper_h_mm })
}
/// Returns base64-encoded SVG — one per pen with subsampled points.
@@ -2285,7 +2343,8 @@ mod blocking_tests {
sat_min_value: None, canny_low: None, canny_high: None,
xdog_sigma2: None, xdog_tau: None, xdog_phi: None,
blend_mode: None,
- threshold: None, min_area: None, rdp_epsilon: None,
+ kernel_dpi: None,
+ threshold: None, min_area: None,
connectivity: None, color_filter: None,
strategy: None, spacing: None, angle: None, param: None,
smooth_rdp: None, smooth_iters: None,
@@ -2311,7 +2370,6 @@ mod blocking_tests {
k1.xdog_phi = Some(10.0);
hull.threshold = Some(128);
hull.min_area = Some(10);
- hull.rdp_epsilon = Some(2.0);
hull.connectivity = Some("four".into());
let mut fill_node = node("fill", "Fill");
fill_node.strategy = Some("hatch".into());
@@ -2330,6 +2388,7 @@ mod blocking_tests {
dpi: None,
img_w_mm: None,
img_h_mm: None,
+ pen_tip_mm: None,
graph: DetectionGraphPayload {
nodes: vec![node("source", "Source"), k1, hull, fill_node, pen_node],
edges: vec![
@@ -2433,7 +2492,7 @@ mod viz_tests {
let rgb = image::RgbImage::from_pixel(img_w, img_h, image::Rgb([255,255,255]));
let params = HullParams { threshold: 128, min_area: 1,
- rdp_epsilon: 1.0, connectivity: hulls::Connectivity::Four };
+ connectivity: hulls::Connectivity::Four };
hulls::extract_hulls(&luma, &rgb, img_w, img_h, ¶ms)
}
@@ -2444,7 +2503,7 @@ mod viz_tests {
let (w, h) = img.dimensions();
let luma: Vec = img.pixels().map(|p| p[0]).collect();
let params = HullParams { threshold: 128, min_area: 4,
- rdp_epsilon: 1.0, connectivity: hulls::Connectivity::Four };
+ connectivity: hulls::Connectivity::Four };
hulls::extract_hulls(&luma, &img, w, h, ¶ms)
}
@@ -2624,7 +2683,6 @@ mod viz_tests {
let config = &json["passes"][0]["config"];
let threshold = config["threshold"].as_u64().unwrap_or(128) as u8;
let min_area = config["min_area"].as_u64().unwrap_or(4) as u32;
- let rdp_eps = config["rdp_epsilon"].as_f64().unwrap_or(1.5) as f32;
let stored = json["image_path"].as_str().unwrap_or("");
let img_path = if std::path::Path::new(stored).exists() {
@@ -2657,7 +2715,7 @@ mod viz_tests {
detect::GraphNode { id: "source".into(), kind: detect::NodeKind::Source { file_path: None } },
detect::GraphNode { id: "k1".into(), kind: detect::NodeKind::Kernel(layer) },
detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull {
- threshold: threshold as u8, min_area, rdp_epsilon: rdp_eps,
+ threshold: threshold as u8, min_area,
eight_conn: false,
cf_enabled: false,
cf_hue_min: 0.0, cf_hue_max: 360.0,
@@ -2672,9 +2730,9 @@ mod viz_tests {
};
let node_rgbs: std::collections::HashMap =
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
- let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h);
+ let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h, 150.0);
let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response);
- let params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps,
+ let params = HullParams { threshold, min_area,
connectivity: hulls::Connectivity::Four };
let hs = hulls::extract_hulls(&response, &img, w, h, ¶ms);
(hs, w, h)