demolish raster→stroke recovery pipeline

The image and text pipelines are now separate. Text goes straight
through Hershey strokes → gcode (no raster step); image input keeps
its existing fill-strategy stack (hatch, zigzag, offset, spiral,
outline, circles, voronoi, hilbert, waves, flow, gradient_*).

Removed:
- src/brush_paint.rs                  (the walker + everything around it)
- src/brush_paint_opt.rs              (coordinate-descent + meta-opt)
- src/topo_strokes.rs                 (skeleton-graph predecessor)
- src/bin/paint_opt_worker.rs
- src/bin/paint_meta_opt_worker.rs
- src/bin/paint_bench.rs
- scripts/optimize_distributed.sh
- scripts/meta_optimize_distributed.sh
- src-frontend/.../PaintDebugView.jsx (interactive debugger)
- src-frontend/.../lib/rasterRender.{js,test.js}

Stripped from fill.rs:
- chamfer_distance (ZS support)
- skeleton_fill, centerline_fill, all skeleton/centerline tests
- zhang_suen_thin, zs_mark, zs_neighbors,
  prune_skeleton_spurs, extract_skeleton_strokes,
  rasterize_text_to_hulls test helper

Stripped from lib.rs:
- mod brush_paint, brush_paint_opt, topo_strokes
- "skeleton" / "centerline" / "topo" / "paint" fill-strategy arms
- load_test_letter, get_paint_debug, optimize_paint_params commands
- OptimizerProgress event struct

Frontend:
- 'paint' viewMode + tab gone
- FILL_STRATEGIES drops skeleton, centerline, topo, paint
- useTauri.js drops loadTestLetter, getPaintDebug, optimizePaintParams,
  DEFAULT_PAINT_PARAMS

Cargo.toml: dropped spade dependency (no longer used) and the two
optimizer-worker bin entries.

Verified: cargo build, cargo test --lib (77 pass), npm run build,
npm test (72 pass — 25 fewer than before, reflecting the deleted
rasterRender tests).
This commit is contained in:
Mitchell Hansen
2026-05-08 21:37:20 -07:00
parent a96e14e8c8
commit 52338c255c
16 changed files with 1 additions and 8270 deletions

View File

@@ -22,7 +22,6 @@ log = "0.4"
env_logger = "0.11" env_logger = "0.11"
reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] } reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] }
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] } tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
spade = "2"
[profile.dev] [profile.dev]
opt-level = 2 opt-level = 2
@@ -44,11 +43,3 @@ path = "src/gen_test_assets.rs"
[[bin]] [[bin]]
name = "pipeline_bench" name = "pipeline_bench"
path = "src/pipeline_bench.rs" path = "src/pipeline_bench.rs"
[[bin]]
name = "paint_opt_worker"
path = "src/bin/paint_opt_worker.rs"
[[bin]]
name = "paint_meta_opt_worker"
path = "src/bin/paint_meta_opt_worker.rs"

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# Distributed meta-optimizer.
#
# Splits N outer ScoreWeights samples between THIS machine and a remote
# SSH host, runs each as `paint_meta_opt_worker N --inner I --passes P`,
# collects all `MetaResult`s, lexicographically sorts (matches the
# in-process `compare_reports`), and prints the best result + a top-5
# table.
#
# Each outer sample = build a random ScoreWeights candidate at index N
# → run the FULL inner optimizer under those weights → score the result.
# Outer samples are independent → embarrassingly parallel across hosts.
#
# Usage:
# scripts/meta_optimize_distributed.sh [N] [I] [P] [REMOTE]
# N = total outer samples (default 16)
# I = inner starts per outer (default 16)
# P = inner passes (default 4)
# REMOTE = "user@host:/path/to/repo" (default $REMOTE_TRAC3R)
#
# Splits via LOCAL_FRAC env var (default 0.4 = 40 % local, 60 % remote).
# Tune for unequal cores; with 14-core local + 24-core remote, 0.37 is
# proportional.
set -euo pipefail
N="${1:-16}"
I="${2:-16}"
P="${3:-4}"
REMOTE="${4:-${REMOTE_TRAC3R:-}}"
LOCAL_FRAC="${LOCAL_FRAC:-0.37}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPDIR="$(mktemp -d -t meta-distrib.XXXXXX)"
trap 'rm -rf "$TMPDIR"' EXIT
echo "[orch] meta-opt: $N outer × $I inner × $P passes" >&2
echo "[orch] root=$ROOT remote=$REMOTE" >&2
LOCAL_N=$(awk -v n="$N" -v f="$LOCAL_FRAC" 'BEGIN{ printf "%d", int(n*f + 0.5) }')
REMOTE_N=$((N - LOCAL_N))
[[ -z "$REMOTE" ]] && { LOCAL_N="$N"; REMOTE_N=0; }
echo "[orch] split: $LOCAL_N local, $REMOTE_N remote" >&2
# Build local first.
echo "[orch] cargo build --release --bin paint_meta_opt_worker (local)…" >&2
( cd "$ROOT" && cargo build --release --bin paint_meta_opt_worker ) >&2
# Build remote in parallel (login shell so cargo is on PATH).
REMOTE_BUILD_PID=""
if [[ -n "$REMOTE" ]]; then
HOST="${REMOTE%%:*}"
RPATH="${REMOTE#*:}"
echo "[orch] cargo build --release on remote ($HOST:$RPATH)…" >&2
# Don't quote $RPATH inside the bash -lc script — leaving it bare
# lets tilde expansion work (cd ~/source/foo). With quotes, bash
# sees "~/source/foo" as a literal pathname and `cd` fails. We
# accept the no-spaces-in-path constraint that comes with this.
( ssh "$HOST" "bash -lc 'cd $RPATH && cargo build --release --bin paint_meta_opt_worker'" >&2 ) &
REMOTE_BUILD_PID=$!
fi
# Concurrency policy: each meta-worker already saturates rayon
# internally (par_iter inner starts × par_iter corpus). Running TWO
# in parallel on the same box doubles thread pressure with no gain
# and probably some loss. So: at most one local + one remote at a time.
#
# Use xargs -P1 to serialize on each host. The two HOSTS run in
# parallel because we kick them off as separate background pipelines.
LOCAL_OUT="$TMPDIR/local.json"
: > "$LOCAL_OUT"
LOCAL_PID=""
if (( LOCAL_N > 0 )); then
echo "[orch] dispatching $LOCAL_N samples to local (serial)…" >&2
(
for i in $(seq 0 $((LOCAL_N - 1))); do
"$ROOT/target/release/paint_meta_opt_worker" "$i" --inner "$I" --passes "$P"
done
) >> "$LOCAL_OUT" &
LOCAL_PID=$!
fi
REMOTE_OUT="$TMPDIR/remote.json"
: > "$REMOTE_OUT"
REMOTE_PID=""
if [[ -n "$REMOTE" && "$REMOTE_N" -gt 0 ]]; then
[[ -n "$REMOTE_BUILD_PID" ]] && wait "$REMOTE_BUILD_PID"
HOST="${REMOTE%%:*}"
RPATH="${REMOTE#*:}"
echo "[orch] dispatching $REMOTE_N samples to $HOST (serial on remote)…" >&2
(
seq "$LOCAL_N" $((N - 1)) | \
ssh "$HOST" "bash -lc 'cd $RPATH && while read -r i; do ./target/release/paint_meta_opt_worker \$i --inner $I --passes $P; done'"
) >> "$REMOTE_OUT" &
REMOTE_PID=$!
fi
[[ -n "$LOCAL_PID" ]] && wait "$LOCAL_PID"
[[ -n "$REMOTE_PID" ]] && wait "$REMOTE_PID"
ALL="$TMPDIR/all.json"
cat "$LOCAL_OUT" "$REMOTE_OUT" > "$ALL"
LINES=$(wc -l < "$ALL" | tr -d ' ')
echo "[orch] collected $LINES results" >&2
if [[ "$LINES" -ne "$N" ]]; then
echo "[orch] WARNING: expected $N, got $LINES" >&2
fi
# Lex-sort matching CorpusReport's compare_reports:
# tier-1: fail_coverage, fail_single_stroke, fail_two_stroke, fail_length_budget, fail_bg
# tier-2: total_bg, total_strokes, total_unpainted_density, total_repaint, total_length
echo "" >&2
echo "[orch] top 5 by lex order:" >&2
jq -s 'sort_by([
.report.fail_coverage,
.report.fail_single_stroke,
.report.fail_two_stroke,
.report.fail_length_budget,
.report.fail_bg,
.report.total_bg,
.report.total_strokes,
.report.total_unpainted_density,
.report.total_repaint,
.report.total_length
]) | .[0:5] | .[] | "idx=\(.idx) T1[cov=\(.report.fail_coverage) bg=\(.report.fail_bg) 1stk=\(.report.fail_single_stroke) 2stk=\(.report.fail_two_stroke) len=\(.report.fail_length_budget)] T2[bg=\(.report.total_bg) stk=\(.report.total_strokes) dens=\(.report.total_unpainted_density|round) rep=\(.report.total_repaint) len=\(.report.total_length|round)]"' -r "$ALL" >&2
echo "" >&2
echo "[orch] best (full JSON on stdout):" >&2
jq -s 'sort_by([
.report.fail_coverage,
.report.fail_single_stroke,
.report.fail_two_stroke,
.report.fail_length_budget,
.report.fail_bg,
.report.total_bg,
.report.total_strokes,
.report.total_unpainted_density,
.report.total_repaint,
.report.total_length
]) | .[0]' "$ALL"

View File

@@ -1,118 +0,0 @@
#!/usr/bin/env bash
# Distributed brush-paint optimizer.
#
# Splits N optimizer starts between THIS machine and a remote SSH host,
# runs each as a `paint_opt_worker N` invocation, and prints the best
# result by score. Each "start" runs ONE refinement pass (golden-section
# coordinate descent from one randomized seed); they're embarrassingly
# parallel — no synchronisation, no shared state.
#
# Usage:
# scripts/optimize_distributed.sh [N] [REMOTE]
# N = total number of starts (default 32). 0..3 are seeded,
# 4..N-1 are random.
# REMOTE = "user@host:/path/to/Trac3r-rust" (default: env $REMOTE_TRAC3R).
#
# Examples:
# scripts/optimize_distributed.sh
# scripts/optimize_distributed.sh 64 me@beefy:~/source/Trac3r-rust
#
# Defaults: 32 starts, ~split half/half. Adjust LOCAL_FRAC for unequal
# machines. With your beefy=24t, mac=8t-ish, LOCAL_FRAC=0.25 puts 75%
# of work on the desktop.
set -euo pipefail
N="${1:-32}"
REMOTE="${2:-${REMOTE_TRAC3R:-}}"
LOCAL_FRAC="${LOCAL_FRAC:-0.25}" # 25% local, 75% remote (24t desktop)
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPDIR="$(mktemp -d -t opt-distrib.XXXXXX)"
trap 'rm -rf "$TMPDIR"' EXIT
echo "[orch] $N starts, root=$ROOT, tmp=$TMPDIR" >&2
# How many starts go local vs remote.
LOCAL_N=$(awk -v n="$N" -v f="$LOCAL_FRAC" 'BEGIN{ printf "%d", int(n*f + 0.5) }')
REMOTE_N=$((N - LOCAL_N))
echo "[orch] split: $LOCAL_N local, $REMOTE_N remote" >&2
# Build local binary first (release mode).
echo "[orch] cargo build --release --bin paint_opt_worker (local)…" >&2
( cd "$ROOT" && cargo build --release --bin paint_opt_worker ) >&2
# Build remote binary in parallel if a remote is configured. We invoke
# remote commands via `bash -lc` so the login shell sources whatever
# rc files put cargo on PATH (~/.cargo/bin); without that, ssh's
# non-login shell may fail with "cargo: command not found".
REMOTE_BUILD_PID=""
if [[ -n "$REMOTE" ]]; then
HOST="${REMOTE%%:*}"
RPATH="${REMOTE#*:}"
echo "[orch] cargo build --release on remote ($HOST:$RPATH)…" >&2
( ssh "$HOST" "bash -lc 'cd $RPATH && cargo build --release --bin paint_opt_worker'" >&2 ) &
REMOTE_BUILD_PID=$!
fi
# Wait for local build to be ready, then dispatch local work in
# background. xargs -P0 = max parallelism (each worker uses rayon for
# its own internal evaluations, but they're CPU-bound so we want the
# OS to schedule).
LOCAL_OUT="$TMPDIR/local.json"
: > "$LOCAL_OUT"
if (( LOCAL_N > 0 )); then
echo "[orch] launching $LOCAL_N local workers…" >&2
seq 0 $((LOCAL_N - 1)) | \
xargs -n1 -P"$LOCAL_N" -I{} \
"$ROOT/target/release/paint_opt_worker" {} \
>> "$LOCAL_OUT" &
LOCAL_PID=$!
else
LOCAL_PID=""
fi
# Wait for remote build, then dispatch remote work.
REMOTE_OUT="$TMPDIR/remote.json"
: > "$REMOTE_OUT"
REMOTE_PID=""
if [[ -n "$REMOTE" && "$REMOTE_N" -gt 0 ]]; then
if [[ -n "$REMOTE_BUILD_PID" ]]; then
wait "$REMOTE_BUILD_PID"
fi
HOST="${REMOTE%%:*}"
RPATH="${REMOTE#*:}"
echo "[orch] launching $REMOTE_N remote workers on $HOST" >&2
# Generate the index list local-side and stream to xargs over ssh.
seq "$LOCAL_N" $((N - 1)) | \
ssh "$HOST" "bash -lc 'cd $RPATH && xargs -n1 -P$REMOTE_N -I{} ./target/release/paint_opt_worker {}'" \
>> "$REMOTE_OUT" &
REMOTE_PID=$!
fi
# Wait for both pools.
[[ -n "$LOCAL_PID" ]] && wait "$LOCAL_PID"
[[ -n "$REMOTE_PID" ]] && wait "$REMOTE_PID"
# Collect + pick the best by lowest .score field. One JSON per line.
ALL="$TMPDIR/all.json"
cat "$LOCAL_OUT" "$REMOTE_OUT" > "$ALL"
LINES=$(wc -l < "$ALL" | tr -d ' ')
echo "[orch] collected $LINES results" >&2
if [[ "$LINES" -ne "$N" ]]; then
echo "[orch] WARNING: expected $N, got $LINES" >&2
fi
# Sort by score (ascending), print top 5 to stderr, full best to stdout.
echo "" >&2
echo "[orch] top 5 by score:" >&2
sort -t : -k 1.1,1.1 "$ALL" \
| jq -s 'sort_by(.score) | .[0:5] | .[] | "\(.start_idx)\t\(.score)"' -r >&2 \
|| awk -F'"score":' '{ split($2, a, ","); printf "%s\t%s\n", $0, a[1] }' "$ALL" \
| sort -k2 -n | head -5 >&2
echo "" >&2
echo "[orch] best result (full JSON on stdout):" >&2
jq -s 'sort_by(.score) | .[0]' "$ALL"

View File

@@ -5,7 +5,6 @@ import TuningPanel from './components/TuningPanel.jsx'
import CalibrationButtons from './components/CalibrationButtons.jsx' import CalibrationButtons from './components/CalibrationButtons.jsx'
import CalibrationAxis from './components/CalibrationAxis.jsx' import CalibrationAxis from './components/CalibrationAxis.jsx'
import TextEditOverlay from './components/TextEditOverlay.jsx' import TextEditOverlay from './components/TextEditOverlay.jsx'
import PaintDebugView from './components/PaintDebugView.jsx'
import NodeGraph from './components/NodeGraph.jsx' import NodeGraph from './components/NodeGraph.jsx'
import PassPanel from './components/PassPanel.jsx' import PassPanel from './components/PassPanel.jsx'
import PerfPanel from './components/PerfPanel.jsx' import PerfPanel from './components/PerfPanel.jsx'
@@ -15,7 +14,7 @@ import * as tauri from './hooks/useTauri.js'
import { serialize, deserialize } from './project.js' import { serialize, deserialize } from './project.js'
import { useFps } from './hooks/useFps.js' import { useFps } from './hooks/useFps.js'
const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'paint', 'printer', 'tuning'] const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'printer', 'tuning']
export default function App() { export default function App() {
const [image, setImage] = useState(null) const [image, setImage] = useState(null)
@@ -615,8 +614,6 @@ export default function App() {
/> />
) : viewMode === 'tuning' ? ( ) : viewMode === 'tuning' ? (
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} /> <TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
) : viewMode === 'paint' ? (
<PaintDebugView passIdx={0} />
) : viewMode === 'source' && sourceMode === 'text' ? ( ) : viewMode === 'source' && sourceMode === 'text' ? (
<TextEditOverlay <TextEditOverlay
paperWMm={gcodeConfig.paper_w_mm} paperWMm={gcodeConfig.paper_w_mm}

File diff suppressed because it is too large Load Diff

View File

@@ -26,47 +26,6 @@ export async function listHulls(passIdx = 0) {
return tracedInvoke('list_hulls', { passIdx }) return tracedInvoke('list_hulls', { passIdx })
} }
// Replace the hulls of a pass with a freshly-rasterized test letter. Used
// by the paint debug viewer's "Test letters" picker so any character/scale
// is one click away — no full pipeline run required.
export async function loadTestLetter(passIdx, ch, fontMm, dpi, thicknessPx) {
return tracedInvoke('load_test_letter', {
passIdx, ch, fontMm, dpi, thicknessPx,
})
}
// Default PaintParams must match Rust's `impl Default for PaintParams`.
export const DEFAULT_PAINT_PARAMS = {
brush_radius_factor: 0.88,
brush_radius_offset_px: 0.53,
brush_radius_percentile: 0.93,
step_size_factor: 0.40,
n_directions: 48,
lookahead_steps: 3,
momentum_weight: 0.20,
overpaint_penalty: 0.10,
walk_bg_penalty: 0.69,
min_score_factor: 0.20,
back_dir_cutoff: -0.7,
min_component_factor: 1.49,
max_steps_per_stroke: 4000,
max_strokes: 12,
output_rdp_eps: 1.98,
}
export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_PARAMS) {
return tracedInvoke('get_paint_debug', { passIdx, hullIdx, params })
}
// Run coordinate-descent optimization on the current hull's paint params.
// While it runs the backend emits `optimizer-progress` events; subscribe
// via `import { listen } from '@tauri-apps/api/event'` then
// `listen('optimizer-progress', e => …)`. Resolves with the final best
// PaintParams.
export async function optimizePaintParams(passIdx, hullIdx, base = DEFAULT_PAINT_PARAMS) {
return tracedInvoke('optimize_paint_params', { passIdx, hullIdx, base })
}
export async function getAllStrokes() { export async function getAllStrokes() {
return tracedInvoke('get_all_strokes', {}) return tracedInvoke('get_all_strokes', {})
} }

View File

@@ -1,127 +0,0 @@
// Pure helpers for compositing raster debug-layers onto a canvas in
// the same coordinate frame the SVG uses for its vector overlays.
// Extracted from PaintDebugView so they can be unit-tested without
// React, and so the layout calculation has one source of truth.
/// Mirror SVG `preserveAspectRatio="xMidYMid meet"`: uniform scale to
/// fit the viewBox inside the container, with letterbox-style centering
/// on the larger axis. Returns null if any input is missing or zero.
export function computeViewboxTransform(viewBoxStr, container) {
if (!viewBoxStr || !container) return null
const parts = String(viewBoxStr).trim().split(/\s+/).map(Number)
if (parts.length !== 4 || parts.some(v => !Number.isFinite(v))) return null
const [vbX, vbY, vbW, vbH] = parts
if (vbW <= 0 || vbH <= 0) return null
if (!container || container.w <= 0 || container.h <= 0) return null
const scale = Math.min(container.w / vbW, container.h / vbH)
const contentX = (container.w - vbW * scale) / 2
const contentY = (container.h - vbH * scale) / 2
return { vbX, vbY, scale, contentX, contentY }
}
/// Convert hull bounds in SVG user-space to canvas-pixel draw rect.
export function computeDrawRect(bounds, transform) {
if (!bounds || !transform || bounds.length !== 4) return null
const [x0, y0, x1, y1] = bounds
const sw = x1 - x0 + 1
const sh = y1 - y0 + 1
return {
dx: (x0 - transform.vbX) * transform.scale + transform.contentX,
dy: (y0 - transform.vbY) * transform.scale + transform.contentY,
dw: sw * transform.scale,
dh: sh * transform.scale,
}
}
/// Composite layers onto `ctx`. `layers` is an array of
/// `{ image: HTMLImageElement|null, opacity: number }`. Skips layers
/// whose image is null. Sets imageSmoothingEnabled=false so each
/// layer draws with nearest-neighbor sampling — sharp at any zoom.
export function drawRasterLayers(ctx, drawRect, layers) {
if (!ctx || !drawRect) return
ctx.imageSmoothingEnabled = false
for (const layer of layers) {
if (!layer || !layer.image) continue
ctx.globalAlpha = Math.max(0, Math.min(1, layer.opacity ?? 1))
ctx.drawImage(layer.image, drawRect.dx, drawRect.dy, drawRect.dw, drawRect.dh)
}
ctx.globalAlpha = 1
}
/// Build the array of `{ src, opacity }` layer specs for a given debug
/// payload + UI state. Pulled out so the test can assert which layers
/// the React component would push to the canvas without instantiating
/// React. Pure — same inputs always produce same output.
export function buildLayerSpecs(debug, enabled, opacity, walkIdx) {
if (!debug) return []
const specs = []
if (enabled.source && debug.source_b64) {
specs.push({ src: debug.source_b64, opacity: opacity.source })
}
if (enabled.sdf && debug.sdf_b64) {
specs.push({ src: debug.sdf_b64, opacity: opacity.sdf })
}
if (enabled.preSnapshot) {
const sIdx = (debug.walks ?? [])[walkIdx]?.stroke_idx ?? 0
const png = (debug.unpainted_snapshots ?? [])[sIdx]
if (png) specs.push({ src: png, opacity: opacity.coverage })
}
if (enabled.coverage && debug.coverage_b64) {
specs.push({ src: debug.coverage_b64, opacity: opacity.coverage })
}
return specs
}
/// End-to-end render: measure → compute transform → resize canvas →
/// async-load each layer → composite. The React `useLayoutEffect`
/// is a thin shim around this; isolating the body here lets tests
/// run it against a mock canvas + mock loadImage with zero React.
///
/// Returns a status object so the caller (and tests) can verify
/// what happened: how many layers actually drew, and the rect they
/// targeted. Returns `drew: 0, reason: ...` on any early-out.
export async function renderRasterLayersToCanvas(args) {
const {
canvas,
divRect,
debug,
viewBox,
enabled,
opacity,
walkIdx,
loadImage,
isCancelled = () => false,
} = args
if (!canvas) return { drew: 0, reason: 'no-canvas' }
if (!divRect) return { drew: 0, reason: 'no-divrect' }
if (!debug) return { drew: 0, reason: 'no-debug' }
if (divRect.width <= 0 || divRect.height <= 0) return { drew: 0, reason: 'zero-size' }
const transform = computeViewboxTransform(viewBox, { w: divRect.width, h: divRect.height })
const drawRect = computeDrawRect(debug.bounds, transform)
if (!transform || !drawRect) return { drew: 0, reason: 'bad-transform' }
// Resize the canvas raster to match its CSS rect (1:1 device pixels).
const W = Math.round(divRect.width)
const H = Math.round(divRect.height)
if (canvas.width !== W || canvas.height !== H) {
canvas.width = W
canvas.height = H
}
const ctx = canvas.getContext('2d')
if (!ctx) return { drew: 0, reason: 'no-ctx' }
ctx.clearRect(0, 0, W, H)
const specs = buildLayerSpecs(debug, enabled, opacity, walkIdx)
if (specs.length === 0) return { drew: 0, reason: 'no-layers', drawRect }
const imgs = await Promise.all(specs.map(s => loadImage(s.src)))
if (isCancelled()) return { drew: 0, reason: 'cancelled', drawRect }
ctx.clearRect(0, 0, canvas.width, canvas.height)
const layers = specs.map((s, i) => ({ image: imgs[i], opacity: s.opacity }))
drawRasterLayers(ctx, drawRect, layers)
const drewCount = imgs.filter(i => !!i).length
return { drew: drewCount, reason: 'ok', drawRect }
}

View File

@@ -1,356 +0,0 @@
import { describe, test, expect, vi } from 'vitest'
import {
computeViewboxTransform,
computeDrawRect,
drawRasterLayers,
buildLayerSpecs,
renderRasterLayersToCanvas,
} from './rasterRender.js'
describe('computeViewboxTransform', () => {
test('returns null on empty/zero inputs', () => {
expect(computeViewboxTransform('', { w: 100, h: 100 })).toBeNull()
expect(computeViewboxTransform('0 0 100 100', null)).toBeNull()
expect(computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })).toBeNull()
expect(computeViewboxTransform('0 0 0 100', { w: 100, h: 100 })).toBeNull()
expect(computeViewboxTransform('not a viewbox', { w: 100, h: 100 })).toBeNull()
})
test('square container, square viewBox -> 1:1 scale, no offset', () => {
const t = computeViewboxTransform('0 0 100 100', { w: 100, h: 100 })
expect(t).toEqual({ vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 })
})
test('container 800x600, viewBox 100x100 -> meet scales 6, centers x', () => {
// meet = scale to fit, smaller axis dominates
const t = computeViewboxTransform('0 0 100 100', { w: 800, h: 600 })
expect(t.scale).toBe(6)
expect(t.contentX).toBe((800 - 600) / 2) // 100
expect(t.contentY).toBe(0)
})
test('viewBox offset is preserved', () => {
const t = computeViewboxTransform('50 30 100 100', { w: 100, h: 100 })
expect(t.vbX).toBe(50)
expect(t.vbY).toBe(30)
expect(t.scale).toBe(1)
})
})
describe('computeDrawRect', () => {
test('returns null when transform is null', () => {
expect(computeDrawRect([0, 0, 10, 10], null)).toBeNull()
})
test('identity transform passes bounds through (with +1 inclusivity)', () => {
const t = { vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 }
const r = computeDrawRect([10, 20, 39, 49], t)
expect(r).toEqual({ dx: 10, dy: 20, dw: 30, dh: 30 })
})
test('viewBox offset shifts dx/dy correctly', () => {
const t = { vbX: 5, vbY: 10, scale: 2, contentX: 100, contentY: 50 }
const r = computeDrawRect([10, 20, 19, 29], t)
// dx = (10 - 5) * 2 + 100 = 110
// dy = (20 - 10) * 2 + 50 = 70
// dw = 10 * 2 = 20, dh = 10 * 2 = 20
expect(r).toEqual({ dx: 110, dy: 70, dw: 20, dh: 20 })
})
})
describe('drawRasterLayers', () => {
// Build a fake canvas-2d-context that records calls.
function makeCtx() {
const calls = []
return {
calls,
imageSmoothingEnabled: true, // start true so we verify we set it false
globalAlpha: 1,
drawImage(img, dx, dy, dw, dh) {
calls.push({ kind: 'drawImage', img, dx, dy, dw, dh,
alpha: this.globalAlpha,
smoothing: this.imageSmoothingEnabled })
},
clearRect(x, y, w, h) {
calls.push({ kind: 'clearRect', x, y, w, h })
},
}
}
test('no-op on null drawRect or null ctx', () => {
const ctx = makeCtx()
drawRasterLayers(ctx, null, [{ image: 'fake-img', opacity: 1 }])
drawRasterLayers(null, { dx: 0, dy: 0, dw: 10, dh: 10 }, [])
expect(ctx.calls.length).toBe(0)
})
test('disables smoothing and draws each image at the rect', () => {
const ctx = makeCtx()
const drawRect = { dx: 5, dy: 7, dw: 11, dh: 13 }
const img1 = { id: 'img1' }
const img2 = { id: 'img2' }
drawRasterLayers(ctx, drawRect, [
{ image: img1, opacity: 0.5 },
{ image: img2, opacity: 0.8 },
])
expect(ctx.imageSmoothingEnabled).toBe(false)
const draws = ctx.calls.filter(c => c.kind === 'drawImage')
expect(draws.length).toBe(2)
expect(draws[0]).toMatchObject({ img: img1, dx: 5, dy: 7, dw: 11, dh: 13,
alpha: 0.5, smoothing: false })
expect(draws[1]).toMatchObject({ img: img2, dx: 5, dy: 7, dw: 11, dh: 13,
alpha: 0.8, smoothing: false })
})
test('skips layers whose image is null', () => {
const ctx = makeCtx()
drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
{ image: null, opacity: 1 },
{ image: { id: 'real' }, opacity: 1 },
null,
])
const draws = ctx.calls.filter(c => c.kind === 'drawImage')
expect(draws.length).toBe(1)
expect(draws[0].img).toEqual({ id: 'real' })
})
test('clamps opacity to [0, 1]', () => {
const ctx = makeCtx()
drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
{ image: { id: 'a' }, opacity: -0.5 },
{ image: { id: 'b' }, opacity: 2.0 },
])
const draws = ctx.calls.filter(c => c.kind === 'drawImage')
expect(draws[0].alpha).toBe(0)
expect(draws[1].alpha).toBe(1)
})
test('resets globalAlpha to 1 after drawing', () => {
const ctx = makeCtx()
drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
{ image: { id: 'a' }, opacity: 0.3 },
])
expect(ctx.globalAlpha).toBe(1)
})
})
describe('buildLayerSpecs', () => {
const dbg = {
source_b64: 'data:source',
sdf_b64: 'data:sdf',
coverage_b64: 'data:coverage',
walks: [{ stroke_idx: 2 }, { stroke_idx: 3 }],
unpainted_snapshots: ['snap0', 'snap1', 'snap2', 'snap3'],
}
const allOn = { source: true, sdf: true, preSnapshot: true, coverage: true }
const op = { source: 0.4, sdf: 0.5, coverage: 0.7 }
test('null debug -> empty', () => {
expect(buildLayerSpecs(null, allOn, op, 0)).toEqual([])
})
test('all enabled -> all four specs', () => {
const specs = buildLayerSpecs(dbg, allOn, op, 0)
expect(specs.length).toBe(4)
expect(specs[0]).toEqual({ src: 'data:source', opacity: 0.4 })
expect(specs[1]).toEqual({ src: 'data:sdf', opacity: 0.5 })
expect(specs[2]).toEqual({ src: 'snap2', opacity: 0.7 }) // walks[0].stroke_idx
expect(specs[3]).toEqual({ src: 'data:coverage', opacity: 0.7 })
})
test('walkIdx selects which snapshot', () => {
const specs = buildLayerSpecs(dbg, allOn, op, 1)
// walks[1].stroke_idx = 3 -> snapshots[3]
expect(specs[2].src).toBe('snap3')
})
test('partial layers respect their toggles', () => {
const specs = buildLayerSpecs(dbg,
{ source: true, sdf: false, preSnapshot: false, coverage: false },
op, 0)
expect(specs.length).toBe(1)
expect(specs[0].src).toBe('data:source')
})
test('missing PNG fields skipped', () => {
const specs = buildLayerSpecs(
{ ...dbg, sdf_b64: '' }, allOn, op, 0)
expect(specs.find(s => s.src === 'data:sdf')).toBeUndefined()
})
})
// Mock canvas/context — drop-in replacement for HTMLCanvasElement that
// lets us assert which drawImage calls happened. The real React effect
// calls these via `canvas.getContext('2d')`. jsdom doesn't ship a
// canvas implementation, so this stub stands in for tests.
function makeMockCanvas() {
const calls = []
const ctx = {
calls,
imageSmoothingEnabled: true,
globalAlpha: 1,
clearRect(x, y, w, h) { calls.push({ kind: 'clearRect', x, y, w, h }) },
drawImage(img, dx, dy, dw, dh) {
calls.push({ kind: 'drawImage', img, dx, dy, dw, dh,
alpha: this.globalAlpha,
smoothing: this.imageSmoothingEnabled })
},
}
return {
width: 0, height: 0,
getContext: () => ctx,
_ctx: ctx,
}
}
describe('renderRasterLayersToCanvas — integration', () => {
// Common fixture: an M-like glyph at 5mm/425dpi.
const debug = {
bounds: [108, 142, 207, 275],
source_b64: 'data:image/png;base64,SOURCEMOCK',
sdf_b64: 'data:image/png;base64,SDFMOCK',
coverage_b64: 'data:image/png;base64,COVMOCK',
walks: [{ stroke_idx: 0 }],
unpainted_snapshots: ['data:snap0'],
}
const viewBox = '104 138 107.92 141' // bounds + ~4 px pad
const enabled = { source: true, sdf: true, preSnapshot: false, coverage: false }
const opacity = { source: 0.4, sdf: 0.5, coverage: 0.7 }
// Mock loadImage returns a fake "image" object that drawImage
// accepts (it doesn't care what the type is, just that it's
// not null).
const fakeImage = (src) => ({ _mockSrc: src })
const loadImage = (src) => Promise.resolve(fakeImage(src))
test('happy path: drawImage called with non-zero rect for every enabled layer', async () => {
const canvas = makeMockCanvas()
const divRect = { width: 800, height: 600 }
const result = await renderRasterLayersToCanvas({
canvas, divRect, debug, viewBox, enabled, opacity, walkIdx: 0, loadImage,
})
expect(result.reason).toBe('ok')
expect(result.drew).toBe(2)
expect(canvas.width).toBe(800)
expect(canvas.height).toBe(600)
const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
expect(draws.length).toBe(2)
// Both draws must have non-zero rect.
for (const d of draws) {
expect(d.dw).toBeGreaterThan(0)
expect(d.dh).toBeGreaterThan(0)
expect(d.smoothing).toBe(false) // pixelated guarantee
}
// Verify the two draws went to the same rect (same overlay).
expect(draws[0].dw).toBeCloseTo(draws[1].dw)
expect(draws[0].dh).toBeCloseTo(draws[1].dh)
// Verify the rect occupies a meaningful portion of the canvas
// (the original bug had the imgs render at "tiny" 1:1 px size).
expect(Math.max(draws[0].dw / 800, draws[0].dh / 600)).toBeGreaterThan(0.3)
})
test('regression: zero-size container -> no draw, sensible reason', async () => {
const canvas = makeMockCanvas()
const result = await renderRasterLayersToCanvas({
canvas, divRect: { width: 0, height: 0 },
debug, viewBox, enabled, opacity, walkIdx: 0, loadImage,
})
expect(result.drew).toBe(0)
expect(result.reason).toBe('zero-size')
})
test('regression: null debug -> no draw', async () => {
const canvas = makeMockCanvas()
const result = await renderRasterLayersToCanvas({
canvas, divRect: { width: 800, height: 600 },
debug: null, viewBox, enabled, opacity, walkIdx: 0, loadImage,
})
expect(result.drew).toBe(0)
expect(result.reason).toBe('no-debug')
})
test('regression: all layer toggles off -> no-layers reason, no drawImage', async () => {
const canvas = makeMockCanvas()
const result = await renderRasterLayersToCanvas({
canvas, divRect: { width: 800, height: 600 },
debug, viewBox,
enabled: { source: false, sdf: false, preSnapshot: false, coverage: false },
opacity, walkIdx: 0, loadImage,
})
expect(result.drew).toBe(0)
expect(result.reason).toBe('no-layers')
const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
expect(draws.length).toBe(0)
})
test('regression: cancelled mid-load -> no draw', async () => {
const canvas = makeMockCanvas()
let cancelledFlag = false
// Make loadImage actually async so we can flip cancel mid-flight.
const slowLoad = (src) => new Promise(res => setTimeout(() => res(fakeImage(src)), 0))
const promise = renderRasterLayersToCanvas({
canvas, divRect: { width: 800, height: 600 },
debug, viewBox, enabled, opacity, walkIdx: 0,
loadImage: slowLoad,
isCancelled: () => cancelledFlag,
})
cancelledFlag = true
const result = await promise
expect(result.drew).toBe(0)
expect(result.reason).toBe('cancelled')
})
test('regression: tiny container (3mm/150dpi case) -> still produces correct rect', async () => {
// 3mm at 150dpi is ~17 px tall; bounds proportionally small.
const tinyDebug = {
...debug,
bounds: [10, 10, 26, 26], // 17×17 hull
}
const tinyVB = '8 8 21 21' // bounds + 2px pad
const canvas = makeMockCanvas()
const result = await renderRasterLayersToCanvas({
canvas, divRect: { width: 800, height: 600 },
debug: tinyDebug, viewBox: tinyVB, enabled, opacity, walkIdx: 0, loadImage,
})
expect(result.reason).toBe('ok')
const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
// Even tiny letters must fill a meaningful chunk of the canvas
// because the SVG's `meet` aspect-fit upscales them.
expect(draws[0].dw).toBeGreaterThan(100)
expect(draws[0].dh).toBeGreaterThan(100)
})
})
// Integration-like test: end-to-end pipeline from viewBox + container
// + bounds → final draw call. This is the path the React component
// follows; if any of these three pure functions misbehaves the canvas
// shows nothing.
describe('end-to-end pipeline', () => {
test('typical M at 5mm/425dpi: image lands inside container with correct size', () => {
// M at 5mm/425dpi has bounds approximately:
// x: 108..207, y: 142..275 (per earlier diagnostic test)
// pad = max(2, (x1-x0)*0.04) = max(2, 4) = 4
// viewBox = `${x0-pad} ${y0-pad} ${(x1-x0)+2*pad} ${(y1-y0)+2*pad}`
const bounds = [108, 142, 207, 275]
const pad = Math.max(2, (bounds[2] - bounds[0]) * 0.04)
const vb = `${bounds[0]-pad} ${bounds[1]-pad} ${(bounds[2]-bounds[0])+2*pad} ${(bounds[3]-bounds[1])+2*pad}`
const container = { w: 800, h: 600 }
const t = computeViewboxTransform(vb, container)
const r = computeDrawRect(bounds, t)
expect(t).not.toBeNull()
expect(r).not.toBeNull()
// The image should land WITHIN the container (not off-screen).
expect(r.dx).toBeGreaterThanOrEqual(0)
expect(r.dy).toBeGreaterThanOrEqual(0)
expect(r.dx + r.dw).toBeLessThanOrEqual(container.w + 1)
expect(r.dy + r.dh).toBeLessThanOrEqual(container.h + 1)
// The image should occupy a sensible fraction of the container —
// at least 1/3 of either dimension (otherwise the user would see
// it as "tiny", which is the original bug).
const fillFrac = Math.max(r.dw / container.w, r.dh / container.h)
expect(fillFrac).toBeGreaterThan(0.3)
})
test('regression: empty/zero container yields a null draw rect (skip render)', () => {
const t = computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })
expect(t).toBeNull()
const r = computeDrawRect([0, 0, 10, 10], t)
expect(r).toBeNull()
})
})

View File

@@ -1,40 +0,0 @@
//! Hot-path bench: build the optimizer corpus once, then loop calling
//! `evaluate(corpus, &default_params)` for a fixed wall-clock duration.
//! Prints iter count + ms/iter so you have a baseline number, and
//! holds the process up long enough that an external profiler
//! (samply, sample, Instruments) can capture a representative trace.
//!
//! Usage: paint_bench [seconds] (default 60)
use std::time::{Duration, Instant};
use trac3r_lib::brush_paint::PaintParams;
use trac3r_lib::brush_paint_opt::{build_corpus, evaluate};
fn main() {
let secs: u64 = std::env::args().nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(60);
eprintln!("[bench] building corpus...");
let corpus = build_corpus();
eprintln!("[bench] corpus: {} hulls", corpus.len());
let params = PaintParams::default();
eprintln!("[bench] pid={} running for {}s", std::process::id(), secs);
// Warm up the hull cache + jit any lazy code paths.
let _ = evaluate(&corpus, &params);
let deadline = Instant::now() + Duration::from_secs(secs);
let mut iters = 0u32;
let start = Instant::now();
while Instant::now() < deadline {
let _ = evaluate(&corpus, &params);
iters += 1;
if iters.is_multiple_of(10) {
let elapsed = start.elapsed().as_secs_f64();
eprintln!("[bench] {iters} iters, {:.0} ms/iter", 1000.0 * elapsed / iters as f64);
}
}
let elapsed = start.elapsed().as_secs_f64();
eprintln!("[bench] DONE: {iters} iters in {:.1}s = {:.0} ms/iter",
elapsed, 1000.0 * elapsed / iters as f64);
}

View File

@@ -1,98 +0,0 @@
//! Meta-optimizer worker — runs ONE outer sample of the meta search.
//! Builds the ScoreWeights for index N, runs the full inner optimizer
//! under those weights, evaluates the result against the corpus, and
//! prints a JSON `MetaResult` to stdout. Lets the meta-search be
//! sharded across SSH-reachable machines: each box runs
//! `paint_meta_opt_worker N` for its assigned indices in parallel,
//! orchestrator collects + lex-sorts.
//!
//! Usage:
//! paint_meta_opt_worker <outer_idx> [--inner N] [--passes K]
//!
//! Output (stdout): `{ "idx", "weights", "params", "report" }` (JSON).
//! Stderr: human-readable progress; never parse it.
use std::env;
use std::process::ExitCode;
use trac3r_lib::brush_paint::PaintParams;
use trac3r_lib::brush_paint_opt::{
build_meta_weights, build_corpus, default_axes,
evaluate_score_weights, MetaResult,
};
fn parse_args() -> Result<(usize, usize, u32), String> {
let argv: Vec<String> = env::args().collect();
if argv.len() < 2 {
return Err(format!(
"usage: {} <outer_idx> [--inner N] [--passes K]\n\
outer_idx is the meta-optimizer's outer index (0..K-1).\n\
inner defaults to 16, passes to 4.",
argv.first().cloned().unwrap_or_else(|| "paint_meta_opt_worker".to_string())
));
}
let outer_idx: usize = argv[1].parse()
.map_err(|e| format!("outer_idx must be a non-negative integer: {e}"))?;
let mut inner: usize = 16;
let mut passes: u32 = 4;
let mut i = 2;
while i < argv.len() {
match argv[i].as_str() {
"--inner" => {
i += 1;
inner = argv.get(i).ok_or("--inner requires a value")?
.parse().map_err(|e| format!("--inner value invalid: {e}"))?;
}
"--passes" => {
i += 1;
passes = argv.get(i).ok_or("--passes requires a value")?
.parse().map_err(|e| format!("--passes value invalid: {e}"))?;
}
other => return Err(format!("unknown arg: {other}")),
}
i += 1;
}
Ok((outer_idx, inner, passes))
}
fn main() -> ExitCode {
let (outer_idx, n_inner, n_passes) = match parse_args() {
Ok(t) => t,
Err(e) => { eprintln!("{e}"); return ExitCode::from(2); }
};
let host = hostname();
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
eprintln!("[meta-worker {host}/{cores}t] outer_idx={outer_idx} inner={n_inner} passes={n_passes}");
let t0 = std::time::Instant::now();
let weights = build_meta_weights(outer_idx);
let corpus = build_corpus();
let axes = default_axes();
let base = PaintParams::default();
let (params, report) = evaluate_score_weights(
&weights, &corpus, &axes, &base, n_inner, n_passes
);
let elapsed = t0.elapsed();
eprintln!(
"[meta-worker {host}] done idx={} elapsed={:.1}s {}",
outer_idx, elapsed.as_secs_f64(), report.summary()
);
let result = MetaResult { idx: outer_idx, weights, params, report };
match serde_json::to_string(&result) {
Ok(json) => { println!("{json}"); ExitCode::SUCCESS }
Err(e) => { eprintln!("[meta-worker {host}] JSON serialize failed: {e}"); ExitCode::from(3) }
}
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| {
std::process::Command::new("hostname")
.output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "?".to_string())
})
}

View File

@@ -1,94 +0,0 @@
//! Distributed optimizer worker. Runs ONE refinement starting from the
//! N-th start in `brush_paint_opt::build_start_params`, prints a JSON
//! `RefineResult` to stdout. Lets the main optimizer be sharded across
//! SSH-reachable machines: each machine runs `paint_opt_worker N` for
//! its assigned indices in parallel, the orchestrator collects all
//! the JSON outputs and picks the best.
//!
//! Usage:
//! paint_opt_worker <start_idx> [--passes N]
//!
//! Output (stdout):
//! { "start_idx": <int>, "score": <f32>, "params": {…}, "log": [...] }
//!
//! Stderr is for human-readable progress; never parse it.
use std::env;
use std::process::ExitCode;
use trac3r_lib::brush_paint::PaintParams;
use trac3r_lib::brush_paint_opt::run_one_start;
fn parse_args() -> Result<(usize, u32), String> {
let argv: Vec<String> = env::args().collect();
if argv.len() < 2 {
return Err(format!(
"usage: {} <start_idx> [--passes N]\n\
start_idx is the optimizer's start index (0..K-1).\n\
passes defaults to 4.",
argv.first().cloned().unwrap_or_else(|| "paint_opt_worker".to_string())
));
}
let start_idx: usize = argv[1].parse()
.map_err(|e| format!("start_idx must be a non-negative integer: {e}"))?;
let mut passes: u32 = 4;
let mut i = 2;
while i < argv.len() {
match argv[i].as_str() {
"--passes" => {
i += 1;
passes = argv.get(i)
.ok_or("--passes requires a value")?
.parse()
.map_err(|e| format!("--passes value invalid: {e}"))?;
}
other => return Err(format!("unknown arg: {other}")),
}
i += 1;
}
Ok((start_idx, passes))
}
fn main() -> ExitCode {
let (start_idx, passes) = match parse_args() {
Ok(t) => t,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(2);
}
};
let host = hostname();
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
eprintln!("[worker {host}/{cores}t] start_idx={start_idx} passes={passes}");
let t0 = std::time::Instant::now();
let result = run_one_start(start_idx, &PaintParams::default(), passes);
let elapsed = t0.elapsed();
eprintln!(
"[worker {host}] done idx={} score={:.0} elapsed={:.1}s",
result.start_idx, result.score, elapsed.as_secs_f64()
);
match serde_json::to_string(&result) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("[worker {host}] JSON serialise failed: {e}");
ExitCode::from(3)
}
}
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| {
std::process::Command::new("hostname")
.output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "?".to_string())
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,580 +0,0 @@
//! Multi-start, continuous-space, gradient-free optimizer for the
//! brush-paint algorithm's `PaintParams`. Each starting point is
//! independently refined via best-improvement coordinate descent with
//! golden-section line search per axis.
//!
//! Two consumers:
//! - `paint_optimize_global_defaults` test (in `brush_paint::tests`):
//! runs all starts in the test process via rayon.
//! - `paint_opt_worker` binary: runs ONE start, prints JSON. Lets us
//! distribute starts across SSH workers.
//!
//! Both share `default_axes` / `build_corpus` / `build_start_params` /
//! `refine_one` so they search identical landscapes.
use std::cmp::Ordering;
use rayon::prelude::*;
use serde::{Serialize, Deserialize};
use crate::brush_paint::{
PaintParams, PaintMetrics, ScoreWeights,
score_for_letter, metrics_for, rasterize_test_letter,
is_single_stroke_letter, is_two_stroke_letter, is_straight_letter,
unpainted_density_score,
};
use crate::hulls::Hull;
/// One tunable parameter axis. Continuous range; ints rounded after line
/// search.
pub struct Axis {
pub name: &'static str,
pub lo: f32,
pub hi: f32,
pub is_int: bool,
pub set: fn(&mut PaintParams, f32),
pub get: fn(&PaintParams) -> f32,
}
pub fn default_axes() -> Vec<Axis> {
// Bounds tightened to keep the search inside a sane neighbourhood
// around the original brush-sizing guess. Wider ranges let the
// optimizer find a "smear" basin (tiny brush + huge pen-lift +
// low score gate) that covers the corpus by repainting every
// pixel 7-8× — visually awful even though all metrics pass.
vec![
Axis { name: "brush_radius_factor", lo: 0.70, hi: 1.20, is_int: false,
set: |p, v| p.brush_radius_factor = v, get: |p| p.brush_radius_factor },
Axis { name: "brush_radius_percentile", lo: 0.85, hi: 1.00, is_int: false,
set: |p, v| p.brush_radius_percentile = v, get: |p| p.brush_radius_percentile },
Axis { name: "brush_radius_offset_px", lo: 0.0, hi: 1.0, is_int: false,
set: |p, v| p.brush_radius_offset_px = v, get: |p| p.brush_radius_offset_px },
Axis { name: "walk_bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.walk_bg_penalty = v, get: |p| p.walk_bg_penalty },
Axis { name: "overpaint_penalty", lo: 0.0, hi: 0.5, is_int: false,
set: |p, v| p.overpaint_penalty = v, get: |p| p.overpaint_penalty },
Axis { name: "step_size_factor", lo: 0.20, hi: 0.90, is_int: false,
set: |p, v| p.step_size_factor = v, get: |p| p.step_size_factor },
Axis { name: "lookahead_steps", lo: 3.0, hi: 8.0, is_int: true,
set: |p, v| p.lookahead_steps = v as usize, get: |p| p.lookahead_steps as f32 },
Axis { name: "n_directions", lo: 8.0, hi: 64.0, is_int: true,
set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 },
Axis { name: "momentum_weight", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight },
Axis { name: "min_score_factor", lo: 0.05, hi: 0.30, is_int: false,
set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor },
Axis { name: "back_dir_cutoff", lo: -0.95, hi: -0.3, is_int: false,
set: |p, v| p.back_dir_cutoff = v, get: |p| p.back_dir_cutoff },
Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false,
set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor },
Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps },
]
}
/// Test corpus: every letter in `CORPUS_ALPHABET` rasterised at every
/// scale in `CORPUS_CASES`, taking the largest hull from each. Both ends
/// of the workload (test + worker) build the same corpus → identical
/// score landscape → safe to distribute.
pub const CORPUS_CASES: &[(f32, u32, u32)] = &[
(5.0, 200, 4),
(5.0, 425, 9),
];
// Includes all SINGLE_STROKE_LETTERS, all TWO_STROKE_LETTERS, plus the
// remaining alphanumerics for breadth. ~52 letters × N scales is the
// per-inner-eval cost.
pub const CORPUS_ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
pub fn build_corpus() -> Vec<(char, Hull)> {
CORPUS_CASES.iter().flat_map(|&(mm, dpi, t)| {
CORPUS_ALPHABET.chars().filter_map(move |ch| {
let hulls = rasterize_test_letter(ch, mm, dpi, t);
hulls.into_iter().max_by_key(|h| h.area).map(|h| (ch, h))
})
}).collect()
}
/// Score one config against the whole corpus, parallel over hulls.
pub fn evaluate(corpus: &[(char, Hull)], p: &PaintParams) -> f32 {
corpus.par_iter().map(|(ch, hull)| {
let (_, m) = metrics_for(hull, p);
score_for_letter(*ch, &m)
}).sum()
}
/// Cheap deterministic PRNG so each start index produces the same point
/// on every machine.
fn rng_next(state: &mut u64) -> f32 {
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((state.wrapping_shr(33)) as u32 as f32) / (u32::MAX as f32)
}
/// Build the starting params for a given index. Indices 0-3 are
/// hand-picked seeds (base, small-brush, fit-brush, big-brush). 4+ are
/// random samples in the joint param space, deterministic per-index.
pub fn build_start_params(idx: usize, base: &PaintParams, axes: &[Axis]) -> PaintParams {
match idx {
0 => base.clone(),
1 => {
let mut s = base.clone();
s.brush_radius_factor = 0.55;
s.brush_radius_offset_px = 0.25;
s.brush_radius_percentile = 0.85;
s.min_component_factor = 1.20;
s
}
2 => {
let mut s = base.clone();
s.brush_radius_factor = 1.00;
s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99;
s.min_component_factor = 0.20;
s
}
3 => {
let mut s = base.clone();
s.brush_radius_factor = 1.15;
s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99;
s.min_component_factor = 0.20;
s
}
_ => {
let mut state = (idx as u64).wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(0xDEADBEEF);
let mut p = base.clone();
for axis in axes {
let r = rng_next(&mut state);
let v = axis.lo + r * (axis.hi - axis.lo);
let v = if axis.is_int { v.round() } else { v };
(axis.set)(&mut p, v);
}
p
}
}
}
/// Output of one refinement run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefineResult {
pub start_idx: usize,
pub score: f32,
pub params: PaintParams,
pub log: Vec<String>,
}
/// Best-improvement coordinate descent with golden-section line search
/// on each axis. Stops when no axis can drop the score by more than 1.
pub fn refine_one(
corpus: &[(char, Hull)],
axes: &[Axis],
start: &PaintParams,
max_passes: u32,
) -> (PaintParams, f32, Vec<String>) {
let try_axis = |params: &PaintParams, axis: &Axis, v: f32| -> f32 {
let mut p = params.clone();
let v = if axis.is_int { v.round().clamp(axis.lo, axis.hi) }
else { v.clamp(axis.lo, axis.hi) };
(axis.set)(&mut p, v);
evaluate(corpus, &p)
};
let golden_section = |params: &PaintParams, axis: &Axis, iters: u32| -> (f32, f32) {
const PHI: f32 = 0.6180339887;
let (mut a, mut b) = (axis.lo, axis.hi);
let mut x1 = b - PHI * (b - a);
let mut x2 = a + PHI * (b - a);
let mut f1 = try_axis(params, axis, x1);
let mut f2 = try_axis(params, axis, x2);
for _ in 0..iters {
if f1 < f2 {
b = x2; x2 = x1; f2 = f1;
x1 = b - PHI * (b - a);
f1 = try_axis(params, axis, x1);
} else {
a = x1; x1 = x2; f1 = f2;
x2 = a + PHI * (b - a);
f2 = try_axis(params, axis, x2);
}
if axis.is_int && (b - a) < 1.0 { break; }
}
if f1 < f2 {
let v = if axis.is_int { x1.round() } else { x1 };
(v, f1)
} else {
let v = if axis.is_int { x2.round() } else { x2 };
(v, f2)
}
};
let mut current = start.clone();
let mut current_score = evaluate(corpus, &current);
let mut log: Vec<String> = vec![format!("start → {:.0}", current_score)];
for _ in 0..max_passes {
let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, axis)| {
let (v, s) = golden_section(&current, axis, 12);
(ai, v, s)
}).collect();
let (best_ai, best_v, best_s) = per_axis.iter()
.min_by(|a, b| a.2.partial_cmp(&b.2).unwrap()).cloned().unwrap();
if best_s + 1.0 >= current_score { break; }
let axis = &axes[best_ai];
log.push(format!(
" {:25} {:>6.2}{:>6.2}{:.0}{:.0})",
axis.name, (axis.get)(&current), best_v, best_s, current_score - best_s
));
(axis.set)(&mut current, best_v);
current_score = best_s;
}
(current, current_score, log)
}
/// One-call entry point used by the worker binary: build corpus, build
/// the indexed start, refine, return.
pub fn run_one_start(start_idx: usize, base: &PaintParams, max_passes: u32) -> RefineResult {
let axes = default_axes();
let corpus = build_corpus();
let start = build_start_params(start_idx, base, &axes);
let (params, score, log) = refine_one(&corpus, &axes, &start, max_passes);
RefineResult { start_idx, score, params, log }
}
// ─── Lexicographic outer-ranking ────────────────────────────────────────
//
// The outer optimizer (`run_meta_opt`) needs a way to rank "the result of
// running the inner optimizer with these ScoreWeights" without using
// another weighted sum. CorpusReport summarises one full corpus run and
// `compare_reports` is the lexicographic comparator that orders them.
//
// Tier 1 — count of letters violating each hard criterion:
// 1. max unpainted cluster > 0.5 × brush_area (a feature is missing)
// 2. SINGLE_STROKE_LETTERS with strokes ≠ 1 (wrong topology)
// 3. TWO_STROKE_LETTERS with strokes ≠ 2 (wrong topology)
// 4. total_length > 2 × skeleton_length (wandering path)
// 5. bg_painted / total_swept > 5 % (off-glyph paint)
//
// Order rationale: structural correctness (cluster coverage, stroke
// topology, path budget) ranks above aesthetic cleanliness (off-glyph
// paint). A previous run found the comparator was preferring
// tiny-brush configs that minimised bg paint at the cost of doubling
// stroke counts and 5×ing path length — bg sat at the wrong tier.
//
// Tier 2 — corpus aggregates (smaller is better, in this order):
// 6. total bg pixels
// 7. total stroke count
// 8. total density-weighted unpainted (exponential per-cluster)
// 9. total repaint
// 10. total length
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CorpusReport {
// Tier 1 — counts of failing letters.
pub fail_coverage: u32, // max cluster > 0.5 × brush_area
pub fail_bg: u32, // bg/swept > 5%
pub fail_single_stroke: u32,
pub fail_two_stroke: u32,
pub fail_length_budget: u32, // total_length > 2 × skeleton_length
// Tier 2 — corpus-wide totals (smaller is better).
pub total_bg: u64,
pub total_strokes: u64,
pub total_unpainted_density: f64,
pub total_repaint: u64,
pub total_length: f64,
// Bookkeeping (not used in comparator).
pub n_letters: u32,
}
impl CorpusReport {
pub fn build(letter_metrics: &[(char, PaintMetrics)]) -> Self {
let mut r = CorpusReport { n_letters: letter_metrics.len() as u32, ..Default::default() };
for (ch, m) in letter_metrics {
// Tier 1.
let cluster_threshold = 0.5 * std::f32::consts::PI * m.brush_radius * m.brush_radius;
let max_cluster = m.unpainted_clusters.iter().copied().max().unwrap_or(0);
if (max_cluster as f32) > cluster_threshold { r.fail_coverage += 1; }
if m.total_swept > 0 {
let bg_rate = m.bg_painted as f32 / m.total_swept as f32;
if bg_rate > 0.05 { r.fail_bg += 1; }
}
if is_single_stroke_letter(*ch) && m.strokes != 1 { r.fail_single_stroke += 1; }
if is_two_stroke_letter(*ch) && m.strokes != 2 { r.fail_two_stroke += 1; }
if m.skeleton_length > 0 && m.total_length > 2.0 * m.skeleton_length as f32 {
r.fail_length_budget += 1;
}
// Tier 2.
r.total_bg += m.bg_painted as u64;
r.total_strokes += m.strokes as u64;
r.total_repaint += m.repaint as u64;
r.total_length += m.total_length as f64;
// Same exponential shape used in `score_weighted` so the
// inner soft signal and the outer lex ranking agree on what
// "bad unpainted distribution" means.
r.total_unpainted_density += unpainted_density_score(
&m.unpainted_clusters, m.brush_radius
) as f64;
}
r
}
/// Sum of all Tier 1 fail counts. Useful as a "feasibility score".
pub fn tier1_total(&self) -> u32 {
self.fail_coverage + self.fail_bg + self.fail_single_stroke
+ self.fail_two_stroke + self.fail_length_budget
}
pub fn summary(&self) -> String {
format!(
"T1[cov={} bg={} 1stk={} 2stk={} len={}] T2[bg={} stk={} dens={:.0} rep={} len={:.0}]",
self.fail_coverage, self.fail_bg, self.fail_single_stroke,
self.fail_two_stroke, self.fail_length_budget,
self.total_bg, self.total_strokes, self.total_unpainted_density,
self.total_repaint, self.total_length,
)
}
}
/// Lexicographic compare. Returns `Less` if `a` is BETTER than `b`
/// (sorts in ascending order = best first).
pub fn compare_reports(a: &CorpusReport, b: &CorpusReport) -> Ordering {
macro_rules! cmp_field { ($f:ident) => {
match a.$f.cmp(&b.$f) {
Ordering::Equal => {},
non_eq => return non_eq,
}
}; }
macro_rules! cmp_float { ($f:ident) => {
match a.$f.partial_cmp(&b.$f).unwrap_or(Ordering::Equal) {
Ordering::Equal => {},
non_eq => return non_eq,
}
}; }
// Tier 1: count of letters failing each hard criterion.
// Structural fails first; bg-rate last (a soft aesthetic).
cmp_field!(fail_coverage);
cmp_field!(fail_single_stroke);
cmp_field!(fail_two_stroke);
cmp_field!(fail_length_budget);
cmp_field!(fail_bg);
// Tier 2: aggregates.
cmp_field!(total_bg);
cmp_field!(total_strokes);
cmp_float!(total_unpainted_density);
cmp_field!(total_repaint);
cmp_float!(total_length);
Ordering::Equal
}
/// Run the inner optimizer (multi-start refinement) under given
/// ScoreWeights, evaluate the resulting params on the corpus, and
/// return both the best params and a CorpusReport. Used by the
/// meta-optimizer's outer evaluation.
pub fn evaluate_score_weights(
weights: &ScoreWeights,
corpus: &[(char, Hull)],
axes: &[Axis],
base: &PaintParams,
n_starts: usize,
max_passes: u32,
) -> (PaintParams, CorpusReport) {
// Inner score function uses the supplied weights.
let inner_score = |p: &PaintParams| -> f32 {
corpus.par_iter().map(|(ch, hull)| {
let (_, m) = metrics_for(hull, p);
let mut s = score_for_letter_with_weights(*ch, &m, weights);
// score_for_letter already includes the constraint barriers.
s += 0.0; // placeholder
s
}).sum()
};
let try_axis = |params: &PaintParams, axis: &Axis, v: f32| -> f32 {
let mut p = params.clone();
let v = if axis.is_int { v.round().clamp(axis.lo, axis.hi) } else { v.clamp(axis.lo, axis.hi) };
(axis.set)(&mut p, v);
inner_score(&p)
};
let golden = |params: &PaintParams, axis: &Axis, iters: u32| -> (f32, f32) {
const PHI: f32 = 0.6180339887;
let (mut a, mut b) = (axis.lo, axis.hi);
let mut x1 = b - PHI * (b - a); let mut x2 = a + PHI * (b - a);
let mut f1 = try_axis(params, axis, x1); let mut f2 = try_axis(params, axis, x2);
for _ in 0..iters {
if f1 < f2 { b = x2; x2 = x1; f2 = f1; x1 = b - PHI * (b - a); f1 = try_axis(params, axis, x1); }
else { a = x1; x1 = x2; f1 = f2; x2 = a + PHI * (b - a); f2 = try_axis(params, axis, x2); }
if axis.is_int && (b - a) < 1.0 { break; }
}
if f1 < f2 { (if axis.is_int { x1.round() } else { x1 }, f1) }
else { (if axis.is_int { x2.round() } else { x2 }, f2) }
};
let refine = |start: &PaintParams| -> (PaintParams, f32) {
let mut current = start.clone();
let mut current_score = inner_score(&current);
for _ in 0..max_passes {
let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, ax)| {
let (v, s) = golden(&current, ax, 8); // fewer iters than full inner — meta is outer of outer
(ai, v, s)
}).collect();
let (best_ai, best_v, best_s) = per_axis.iter()
.min_by(|a, b| a.2.partial_cmp(&b.2).unwrap()).cloned().unwrap();
if best_s + 1.0 >= current_score { break; }
(axes[best_ai].set)(&mut current, best_v);
current_score = best_s;
}
(current, current_score)
};
// Run all starts in parallel. Print a progress dot as each start
// completes (relative to a Mutex<usize> counter) so the user can
// see something happening during long meta-optimization runs.
let starts: Vec<PaintParams> = (0..n_starts)
.map(|i| build_start_params(i, base, axes))
.collect();
let counter = std::sync::atomic::AtomicUsize::new(0);
let results: Vec<(PaintParams, f32)> = starts.par_iter().map(|s| {
let r = refine(s);
let n = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
eprint!("\r[inner] refined {n}/{n_starts}");
if n == n_starts { eprint!("\n"); }
r
}).collect();
let (best_params, _) = results.into_iter()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()).unwrap();
// Build the CorpusReport on the BEST params.
let letter_metrics: Vec<(char, PaintMetrics)> = corpus.iter()
.map(|(ch, hull)| {
let (_, m) = metrics_for(hull, &best_params);
(*ch, m)
}).collect();
let report = CorpusReport::build(&letter_metrics);
(best_params, report)
}
/// Inner score function used during META-OPTIMIZATION. Unlike
/// `score_for_letter` (the production score), this version DOES NOT
/// add 100M-magnitude barriers for bg / coverage / length-budget
/// violations.
///
/// Why: the barriers are so large they swamp every soft-weight
/// difference. With barriers in place, two different ScoreWeights
/// candidates produce inner-descent results dominated by "minimise
/// barriers" rather than "minimise the weighted soft score" — so
/// the inner optimizer converges to identical PaintParams under
/// most weight choices and the meta search has nothing to compare.
///
/// Without barriers, the inner descent is purely guided by the
/// candidate's ScoreWeights → different weights produce genuinely
/// different optima → the OUTER lex comparator ranks them by the
/// hard criteria (tier-1 fail counts) at the end.
///
/// Stroke-count penalties stay (they're per-letter natural-form
/// requirements, not score-vs-feasibility tradeoffs) and the
/// "refuse zero strokes" pin stays (without it the inner descent
/// can degenerate to "paint nothing" under low coverage weight).
fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 {
use crate::brush_paint::score_weighted;
// Curvature is letter-conditional: only straight-stroke glyphs
// (AEFHIKLMNTVWXYZilvwxz) pay it. Mirror of `score_for_letter`.
let mut w_local = *w;
if !is_straight_letter(ch) { w_local.curvature = 0.0; }
let mut s = score_weighted(m, w_local);
if m.strokes == 0 { s += 200_000.0; }
if is_single_stroke_letter(ch) && m.strokes != 1 {
s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32);
}
if is_two_stroke_letter(ch) && m.strokes != 2 {
s += 50_000.0 * ((m.strokes as i64 - 2).abs() as f32);
}
s
}
// ─── Meta-optimizer (outer search over ScoreWeights) ────────────────────
/// One outer-sample's outcome: the candidate weights, the best inner
/// params they produced, and the lexicographic report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaResult {
pub idx: usize,
pub weights: ScoreWeights,
pub params: PaintParams,
pub report: CorpusReport,
}
/// Sample one ScoreWeights from a deterministic per-index PRNG. The
/// ranges are picked to roughly bracket the existing defaults at ½×4×.
pub fn build_meta_weights(idx: usize) -> ScoreWeights {
if idx == 0 { return ScoreWeights::default(); }
let mut state = (idx as u64)
.wrapping_mul(0xDA942042E4DD58B5)
.wrapping_add(0xCAFEBABE);
let next = |state: &mut u64| -> f32 {
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((state.wrapping_shr(33)) as u32 as f32) / (u32::MAX as f32)
};
let unif = |state: &mut u64, lo: f32, hi: f32| lo + next(state) * (hi - lo);
ScoreWeights {
stroke: unif(&mut state, 100.0, 2000.0),
length: unif(&mut state, 0.5, 20.0),
bg: unif(&mut state, 10.0, 200.0),
repaint: unif(&mut state, 5.0, 100.0),
unpainted: unif(&mut state, 5.0, 300.0),
unpainted_density: unif(&mut state, 1.0, 50.0),
length_excess: unif(&mut state, 50.0, 1500.0),
curvature: unif(&mut state, 50.0, 2000.0),
brush_size: unif(&mut state, 0.0, 8000.0),
}
}
/// Run the meta-optimizer: try `n_outer` random ScoreWeights, run the
/// inner optimizer for each, rank lexicographically, return ALL results
/// sorted best-first.
///
/// Progress: prints one line to stderr per outer sample as it
/// completes — sample idx, elapsed, this-result's tier-1/tier-2
/// summary, and whether it's the best yet (★ = improved).
pub fn run_meta_opt(
n_outer: usize,
n_inner_starts: usize,
inner_passes: u32,
base: &PaintParams,
) -> Vec<MetaResult> {
let axes = default_axes();
let corpus = build_corpus();
let t_start = std::time::Instant::now();
eprintln!("[meta] starting {} outer × {} inner × {} passes",
n_outer, n_inner_starts, inner_passes);
let mut results: Vec<MetaResult> = Vec::with_capacity(n_outer);
let mut best_so_far_report: Option<CorpusReport> = None;
for idx in 0..n_outer {
let t0 = std::time::Instant::now();
let weights = build_meta_weights(idx);
let (params, report) = evaluate_score_weights(
&weights, &corpus, &axes, base, n_inner_starts, inner_passes
);
let dt = t0.elapsed().as_secs_f64();
let total = t_start.elapsed().as_secs_f64();
let est_remaining = (total / (idx as f64 + 1.0)) * (n_outer as f64 - idx as f64 - 1.0);
let is_new_best = match &best_so_far_report {
None => true,
Some(b) => compare_reports(&report, b) == std::cmp::Ordering::Less,
};
let marker = if is_new_best { "" } else { " " };
eprintln!(
"[meta] {}{:3}/{} {:6.1}s {} (total {:.0}s, eta {:.0}s)",
marker, idx + 1, n_outer, dt, report.summary(), total, est_remaining,
);
if is_new_best {
best_so_far_report = Some(report.clone());
}
results.push(MetaResult { idx, weights, params, report });
}
eprintln!("[meta] done, lex-sorting {} results", results.len());
results.sort_by(|a, b| compare_reports(&a.report, &b.report));
results
}

View File

@@ -533,74 +533,6 @@ pub fn spiral(hull: &Hull, spacing_px: f32) -> FillResult {
// ── Circle packing ───────────────────────────────────────────────────────────── // ── Circle packing ─────────────────────────────────────────────────────────────
/// Chamfer 3-4 distance transform: cheaper than full Euclidean, but the
/// 3:4 weights closely approximate (1:√2), so contours are near-circular
/// instead of L-shaped. Returns scaled distances (units of 1/3 pixel).
pub(crate) fn chamfer_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
if hull.pixels.is_empty() { return HashMap::new(); }
let inf = i32::MAX / 4;
let mut bx = u32::MAX;
let mut by = u32::MAX;
let mut bx_max = 0u32;
let mut by_max = 0u32;
for &(x, y) in &hull.pixels {
bx = bx.min(x); by = by.min(y);
bx_max = bx_max.max(x); by_max = by_max.max(y);
}
let w = (bx_max - bx + 1) as usize;
let h = (by_max - by + 1) as usize;
let mut grid: Vec<i32> = vec![inf; w * h];
let idx = |x: u32, y: u32| -> usize { ((y - by) as usize) * w + (x - bx) as usize };
// Boundary pixels: any pixel whose 4-neighbor is outside the hull.
for &(x, y) in &hull.pixels {
let on_boundary = !pixel_set.contains(&(x.wrapping_sub(1), y))
|| !pixel_set.contains(&(x + 1, y))
|| !pixel_set.contains(&(x, y.wrapping_sub(1)))
|| !pixel_set.contains(&(x, y + 1));
if on_boundary { grid[idx(x, y)] = 0; }
}
// Forward pass: top-left → bottom-right, examines NW/N/NE/W neighbors.
for j in 0..h {
for i in 0..w {
let here = j * w + i;
if grid[here] == 0 { continue; }
let mut best = grid[here];
if j > 0 {
if i > 0 { best = best.min(grid[(j-1)*w + i-1].saturating_add(4)); }
best = best.min(grid[(j-1)*w + i ].saturating_add(3));
if i+1 < w { best = best.min(grid[(j-1)*w + i+1].saturating_add(4)); }
}
if i > 0 { best = best.min(grid[j*w + i-1].saturating_add(3)); }
grid[here] = best;
}
}
// Backward pass: bottom-right → top-left, examines SE/S/SW/E neighbors.
for j in (0..h).rev() {
for i in (0..w).rev() {
let here = j * w + i;
if grid[here] == 0 { continue; }
let mut best = grid[here];
if j+1 < h {
if i+1 < w { best = best.min(grid[(j+1)*w + i+1].saturating_add(4)); }
best = best.min(grid[(j+1)*w + i ].saturating_add(3));
if i > 0 { best = best.min(grid[(j+1)*w + i-1].saturating_add(4)); }
}
if i+1 < w { best = best.min(grid[j*w + i+1].saturating_add(3)); }
grid[here] = best;
}
}
let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
for &(x, y) in &hull.pixels {
let v = grid[idx(x, y)];
// Convert chamfer units (3 per orthogonal step) to ~Euclidean pixels.
dist.insert((x, y), if v >= inf { 0.0 } else { v as f32 / 3.0 });
}
dist
}
/// BFS distance transform from the hull boundary (Manhattan approximation). /// BFS distance transform from the hull boundary (Manhattan approximation).
fn boundary_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> { fn boundary_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len()); let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
@@ -832,478 +764,6 @@ pub fn hilbert_fill(hull: &Hull, spacing_px: f32) -> FillResult {
FillResult { hull_id: hull.id, strokes } FillResult { hull_id: hull.id, strokes }
} }
// ── Skeleton (medial axis) fill ─────────────────────────────────────────────────
//
// Designed for text-shaped hulls: extract each glyph's centerline as one or
// more polylines so the pen draws each stroke once instead of hatching/
// outlining it. Two-stage pipeline:
// 1. Zhang-Suen iterative thinning erodes the hull boundary symmetrically
// until only a 1-pixel-wide skeleton remains. Topology is preserved
// (no holes are punched, no strokes are severed).
// 2. Graph traversal — endpoints (1 neighbor) and junctions (≥3
// neighbors) split the skeleton into "branches"; we walk each branch
// end-to-end. Paired junction continuations could be a follow-up to
// reduce pen lifts at crossings; this version stops at every junction.
//
// The output is jaggy at 1-px resolution; the caller's RDP+Chaikin smoothing
// (`smooth_fill_result`) cleans it up. `_spacing_px` is unused but matches
// the dispatch signature shared by all fill strategies.
/// Distance-transform medial axis fill — robust raster→centerline conversion.
///
/// Unlike `skeleton_fill` (which uses Zhang-Suen morphological thinning),
/// this computes the medial axis from the boundary-distance field. A pixel
/// is part of the medial axis if its distance to the boundary is a local
/// maximum along at least one axis — exactly the points equidistant from
/// at least two boundary points.
///
/// The key advantage for text: thin tails (e.g. the descender on 'a' or
/// '9') survive because their centerlines are local distance maxima, even
/// when very short. ZS thinning, by contrast, erodes such features into
/// "Y" spurs that subsequently get pruned away.
///
/// Pipeline:
/// 1. Distance transform via `boundary_distance`.
/// 2. Local-maximum extraction along the four axes (E-W, N-S, NE-SW, NW-SE).
/// 3. Light ZS thinning to collapse plateaus to 1-px width. We don't
/// apply spur pruning here — every ridge branch is meaningful.
/// 4. Same junction-cluster pairing + walk machinery as `skeleton_fill`.
pub fn centerline_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
if hull.pixels.is_empty() {
return FillResult { hull_id: hull.id, strokes: vec![] };
}
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
let dist = chamfer_distance(hull, &pixel_set);
// Extract ridge pixels: local max along at least one of 4 axes.
// Pixels touching the boundary (distance ~0) are excluded.
let mut ridge: HashSet<(u32, u32)> = HashSet::new();
let eps = 1e-3;
for &p in &pixel_set {
let dp = match dist.get(&p) { Some(&d) => d, None => continue };
if dp < 1.0 { continue; } // skip boundary-adjacent pixels
let axes: [((u32, u32), (u32, u32)); 4] = [
((p.0 + 1, p.1), (p.0.wrapping_sub(1), p.1)),
((p.0, p.1 + 1), (p.0, p.1.wrapping_sub(1))),
((p.0 + 1, p.1 + 1), (p.0.wrapping_sub(1), p.1.wrapping_sub(1))),
((p.0 + 1, p.1.wrapping_sub(1)), (p.0.wrapping_sub(1), p.1 + 1)),
];
for (n1, n2) in axes {
let d1 = dist.get(&n1).copied().unwrap_or(0.0);
let d2 = dist.get(&n2).copied().unwrap_or(0.0);
if dp + eps >= d1 && dp + eps >= d2 {
ridge.insert(p);
break;
}
}
}
// Plateaus can produce thicker-than-1-pixel ridges. Thin to ensure a
// clean 1-px graph for the walk.
let ridge_vec: Vec<(u32, u32)> = ridge.into_iter().collect();
let mut thinned = zhang_suen_thin(&ridge_vec);
// Junction artifacts are short (1-3 px); real tails (a, 9, etc.) are
// much longer. Prune the artifacts so X-style intersections collapse
// to clean crossings instead of fragmenting into many short stubs.
prune_skeleton_spurs(&mut thinned, /* max_spur_len */ 4);
let pixel_strokes = extract_skeleton_strokes(&thinned);
// Medial-axis paths are sequences of integer pixel coords that
// stair-step along diagonals — inherent to the algorithm, not the
// user's problem. Pre-smooth here so the output is a clean curve
// before the fill node's user-controlled RDP/Chaikin runs on top.
let strokes: Vec<Vec<(f32, f32)>> = pixel_strokes.into_iter()
.filter(|s| s.len() >= 3)
.map(|s| {
let f: Vec<(f32, f32)> = s.into_iter().map(|(x, y)| (x as f32, y as f32)).collect();
smooth_stroke(&f, /* rdp_eps */ 1.0, /* chaikin_iters */ 3)
})
.filter(|s| s.len() >= 2)
.collect();
FillResult { hull_id: hull.id, strokes }
}
pub fn skeleton_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
if hull.pixels.is_empty() {
return FillResult { hull_id: hull.id, strokes: vec![] };
}
let mut skeleton = zhang_suen_thin(&hull.pixels);
// ZS leaves 1-2 pixel "Y" artifacts at every corner of a thick stroke.
// Pruning them removes the false junctions so the walk only stops at
// real glyph crossings.
prune_skeleton_spurs(&mut skeleton, /* max_spur_len */ 3);
let pixel_strokes = extract_skeleton_strokes(&skeleton);
// Filter sub-pixel junction artifacts. A 2-point stroke is a single
// pixel step (~0.17mm at 150 dpi); never a real glyph stroke and
// always either a cluster-exit fragment or a tiny dangling spur.
let strokes: Vec<Vec<(f32, f32)>> = pixel_strokes.into_iter()
.filter(|s| s.len() >= 3)
.map(|s| s.into_iter().map(|(x, y)| (x as f32, y as f32)).collect())
.collect();
FillResult { hull_id: hull.id, strokes }
}
/// Iteratively remove dead-end branches up to `max_spur_len` pixels long.
/// Pruning a spur can turn its parent junction into an endpoint, exposing
/// further removable spurs — so we loop until no further removals.
pub(crate) fn prune_skeleton_spurs(skeleton: &mut HashSet<(u32, u32)>, max_spur_len: usize) {
fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
}
loop {
let endpoints: Vec<(u32, u32)> = skeleton.iter().copied()
.filter(|p| nbrs_in(*p, skeleton).len() == 1)
.collect();
let mut to_remove: HashSet<(u32, u32)> = HashSet::new();
for ep in endpoints {
let mut path = vec![ep];
let mut prev: Option<(u32, u32)> = None;
let mut cur = ep;
for _ in 0..=max_spur_len {
let nbrs = nbrs_in(cur, skeleton);
if nbrs.len() >= 3 {
// Hit a junction within the spur budget — drop the path
// (excluding the junction pixel).
if path.len() <= max_spur_len {
for &p in &path { to_remove.insert(p); }
}
break;
}
if nbrs.is_empty() { break; }
let next = nbrs.into_iter().find(|n| Some(*n) != prev);
match next {
Some(n) => { prev = Some(cur); cur = n; path.push(cur); }
None => break,
}
}
}
if to_remove.is_empty() { break; }
for p in to_remove { skeleton.remove(&p); }
}
}
/// Zhang-Suen 8-neighbor positions in clockwise order starting from north:
/// index 0..7 == P2, P3, P4, P5, P6, P7, P8, P9.
/// Underflow on the edges is fine — those positions just won't be in the set.
pub(crate) fn zs_neighbors(x: u32, y: u32) -> [(u32, u32); 8] {
[
(x, y.wrapping_sub(1)),
(x + 1, y.wrapping_sub(1)),
(x + 1, y),
(x + 1, y + 1),
(x, y + 1),
(x.wrapping_sub(1), y + 1),
(x.wrapping_sub(1), y),
(x.wrapping_sub(1), y.wrapping_sub(1)),
]
}
/// Run Zhang-Suen thinning until idempotent. Two sub-iterations per round
/// with mirrored conditions keep erosion symmetric.
pub(crate) fn zhang_suen_thin(pixels: &[(u32, u32)]) -> HashSet<(u32, u32)> {
let mut current: HashSet<(u32, u32)> = pixels.iter().copied().collect();
loop {
let to_remove1 = zs_mark(&current, true);
for p in &to_remove1 { current.remove(p); }
let to_remove2 = zs_mark(&current, false);
for p in &to_remove2 { current.remove(p); }
if to_remove1.is_empty() && to_remove2.is_empty() { break; }
}
current
}
fn zs_mark(set: &HashSet<(u32, u32)>, first_pass: bool) -> Vec<(u32, u32)> {
let mut to_remove = Vec::new();
for &(x, y) in set {
let nbrs = zs_neighbors(x, y);
let n: [bool; 8] = std::array::from_fn(|i| set.contains(&nbrs[i]));
let b = n.iter().filter(|&&v| v).count();
if !(2..=6).contains(&b) { continue; }
// A(P): number of 0→1 transitions around the 8-ring (P2…P9, wrapping).
let mut a = 0;
for i in 0..8 {
if !n[i] && n[(i + 1) % 8] { a += 1; }
}
if a != 1 { continue; }
// n indices: P2=0 P3=1 P4=2 P5=3 P6=4 P7=5 P8=6 P9=7
if first_pass {
if n[0] && n[2] && n[4] { continue; } // P2*P4*P6 ≠ 0
if n[2] && n[4] && n[6] { continue; } // P4*P6*P8 ≠ 0
} else {
if n[0] && n[2] && n[6] { continue; } // P2*P4*P8 ≠ 0
if n[0] && n[4] && n[6] { continue; } // P2*P6*P8 ≠ 0
}
to_remove.push((x, y));
}
to_remove
}
/// Given a 1-px-wide skeleton, walk its connectivity graph and emit one
/// polyline per logical glyph stroke.
///
/// The non-trivial bit is junction handling: ZS thinning of a thick stroke
/// where two branches meet doesn't produce a single junction pixel — it
/// produces a small CLUSTER of adjacent junction pixels (T's intersection
/// is typically 4 pixels wide, X's centre is similar). To get clean
/// "draw straight through" behaviour we have to pair the cluster's
/// EXTERNAL exits, not the individual neighbours of one junction pixel.
///
/// Process:
/// 1. Find junction pixels (≥3 8-connected neighbours).
/// 2. Group them into clusters (8-connected components of junction pixels).
/// 3. For each cluster, list its exits (external skeleton pixels adjacent
/// to any cluster pixel). Pair them greedily by direction-from-centroid:
/// each exit pairs with the one most-opposite, which is the natural
/// "continues through" partner.
/// 4. Walk: when a step crosses from external into a cluster, look up the
/// paired exit, BFS-traverse the cluster to it, emit the through path
/// as one continuous stroke, mark all touched edges used.
fn extract_skeleton_strokes(skeleton: &HashSet<(u32, u32)>) -> Vec<Vec<(u32, u32)>> {
if skeleton.is_empty() { return vec![]; }
fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
}
fn edge(a: (u32, u32), b: (u32, u32)) -> ((u32, u32), (u32, u32)) {
if a <= b { (a, b) } else { (b, a) }
}
let junctions: HashSet<(u32, u32)> = skeleton.iter()
.copied()
.filter(|p| nbrs_in(*p, skeleton).len() >= 3)
.collect();
// ── Group junctions into 8-connected clusters ─────────────────────────
let mut clusters: Vec<HashSet<(u32, u32)>> = Vec::new();
let mut pixel_to_cluster: HashMap<(u32, u32), usize> = HashMap::new();
{
let mut assigned: HashSet<(u32, u32)> = HashSet::new();
let mut juncs_sorted: Vec<_> = junctions.iter().copied().collect();
juncs_sorted.sort();
for j in juncs_sorted {
if assigned.contains(&j) { continue; }
let mut cluster: HashSet<(u32, u32)> = HashSet::new();
let mut queue = vec![j];
while let Some(p) = queue.pop() {
if !cluster.insert(p) { continue; }
assigned.insert(p);
for n in zs_neighbors(p.0, p.1) {
if junctions.contains(&n) && !cluster.contains(&n) { queue.push(n); }
}
}
let idx = clusters.len();
for &p in &cluster { pixel_to_cluster.insert(p, idx); }
clusters.push(cluster);
}
}
// For each cluster, build (exit_external → paired_exit_external) map.
// Exits are computed once: each (internal_cluster_pixel, external_skeleton_pixel)
// edge that leaves the cluster. Pair by direction: each external's vector
// from cluster centroid; pairs maximise the angle between vectors
// (= most-opposite = continues through).
let cluster_pairings: Vec<HashMap<(u32, u32), (u32, u32)>> = clusters.iter().map(|cluster| {
let mut exits: Vec<((u32, u32), (u32, u32))> = Vec::new();
for &c in cluster {
for n in zs_neighbors(c.0, c.1) {
if skeleton.contains(&n) && !cluster.contains(&n) {
exits.push((c, n));
}
}
}
// Centroid of the cluster.
let n = cluster.len() as f32;
let cx = cluster.iter().map(|p| p.0 as f32).sum::<f32>() / n;
let cy = cluster.iter().map(|p| p.1 as f32).sum::<f32>() / n;
let mut paired: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
let mut available: Vec<(u32, u32)> = {
let mut e: Vec<(u32, u32)> = exits.iter().map(|&(_, ext)| ext).collect();
e.sort();
e.dedup();
e
};
while available.len() >= 2 {
let e1 = available.remove(0);
let d1x = e1.0 as f32 - cx;
let d1y = e1.1 as f32 - cy;
// Pick the external whose direction is most-opposite (most negative dot).
let best = (0..available.len()).max_by(|&i, &j| {
let ei = available[i]; let ej = available[j];
let dix = ei.0 as f32 - cx; let diy = ei.1 as f32 - cy;
let djx = ej.0 as f32 - cx; let djy = ej.1 as f32 - cy;
let si = -(d1x * dix + d1y * diy);
let sj = -(d1x * djx + d1y * djy);
si.partial_cmp(&sj).unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(idx) = best {
let e2 = available.remove(idx);
paired.insert(e1, e2);
paired.insert(e2, e1);
}
}
paired
}).collect();
let mut endpoints: Vec<(u32, u32)> = skeleton.iter()
.copied()
.filter(|p| nbrs_in(*p, skeleton).len() == 1)
.collect();
endpoints.sort();
let mut used: HashSet<((u32, u32), (u32, u32))> = HashSet::new();
let mut out = Vec::new();
// Pre-mark every intra-cluster edge as used. The BFS through a cluster
// picks ONE path from entry to exit; the rest of the cluster's internal
// edges would otherwise show up as 2-pixel "junction artifact" strokes
// in pass 2/3. By marking them all up front, only genuine external
// edges (cluster → non-cluster skeleton pixel) remain walkable.
for cluster in &clusters {
for &c in cluster {
for n in zs_neighbors(c.0, c.1) {
if cluster.contains(&n) {
used.insert(edge(c, n));
}
}
}
}
// BFS inside `cluster` from `from` to any pixel adjacent to
// `target_external`. Returns the path of cluster pixels (inclusive).
let bfs_cluster = |from: (u32, u32), target_external: (u32, u32),
cluster: &HashSet<(u32, u32)>| -> Vec<(u32, u32)> {
let mut prev_map: HashMap<(u32, u32), Option<(u32, u32)>> = HashMap::new();
prev_map.insert(from, None);
let mut queue: VecDeque<(u32, u32)> = VecDeque::new();
queue.push_back(from);
let mut tail = None;
while let Some(p) = queue.pop_front() {
// Found if p is adjacent to target_external.
if zs_neighbors(p.0, p.1).iter().any(|n| *n == target_external) {
tail = Some(p);
break;
}
for n in zs_neighbors(p.0, p.1) {
if cluster.contains(&n) && !prev_map.contains_key(&n) {
prev_map.insert(n, Some(p));
queue.push_back(n);
}
}
}
// Reconstruct path
let mut path = Vec::new();
let mut cur = tail;
while let Some(p) = cur {
path.push(p);
cur = *prev_map.get(&p).unwrap_or(&None);
}
path.reverse();
path
};
// Walk from `start` along the edge to `next`. When stepping into a
// junction cluster, look up the paired exit and traverse the cluster
// through to it as part of the same stroke.
let walk = |start: (u32, u32), next: (u32, u32),
used: &mut HashSet<((u32, u32), (u32, u32))>| -> Vec<(u32, u32)> {
if used.contains(&edge(start, next)) { return vec![]; }
let mut stroke = vec![start];
let mut prev = start;
let mut cur = next;
loop {
used.insert(edge(prev, cur));
stroke.push(cur);
// Are we entering a junction cluster?
if let Some(&cidx) = pixel_to_cluster.get(&cur) {
let cluster = &clusters[cidx];
let pairings = &cluster_pairings[cidx];
// We came from `prev` (external pixel of the cluster).
let Some(ext) = pairings.get(&prev).copied() else { break };
// Walk through the cluster from `cur` to some internal pixel
// adjacent to `ext`. BFS ignores `used` (intra-cluster edges
// are all pre-marked) — we just need any cluster path.
let path = bfs_cluster(cur, ext, cluster);
if path.is_empty() { break; }
// Verify the exit edge hasn't been claimed by an earlier walk.
let last_internal = *path.last().unwrap();
if used.contains(&edge(last_internal, ext)) { break; }
// path[0] == cur (already pushed). Add the rest.
let mut last = cur;
for &p in &path[1..] {
used.insert(edge(last, p));
stroke.push(p);
last = p;
}
used.insert(edge(last, ext));
stroke.push(ext);
prev = last;
cur = ext;
continue;
}
// Regular path step.
let nxt = nbrs_in(cur, skeleton).into_iter()
.find(|n| *n != prev && !used.contains(&edge(cur, *n)));
match nxt {
Some(n) => { prev = cur; cur = n; }
None => break,
}
if cur == start && stroke.len() >= 3 {
used.insert(edge(prev, cur));
stroke.push(cur);
break;
}
}
stroke
};
// Pass 1: walk from every endpoint.
for ep in &endpoints {
for nbr in nbrs_in(*ep, skeleton) {
if used.contains(&edge(*ep, nbr)) { continue; }
let s = walk(*ep, nbr, &mut used);
if s.len() >= 2 { out.push(s); }
}
}
// Pass 2: cluster-rooted walks for branches not reached by endpoints.
// We start from a cluster pixel, step out to one of its unused exits,
// and let the regular walk handle the rest.
let mut cluster_starts: Vec<usize> = (0..clusters.len()).collect();
cluster_starts.sort();
for cidx in cluster_starts {
let cluster = &clusters[cidx];
for &c in cluster {
for ext in zs_neighbors(c.0, c.1) {
if !skeleton.contains(&ext) || cluster.contains(&ext) { continue; }
if used.contains(&edge(c, ext)) { continue; }
// Start from ext (treating it like a one-step seed).
let s = walk(ext, c, &mut used); // walk back into the cluster
if s.len() >= 2 { out.push(s); }
}
}
}
// Pass 3: pure cycles (no endpoints, no junctions — rings, "O" glyphs).
let mut leftovers: Vec<_> = skeleton.iter()
.copied()
.filter(|p| nbrs_in(*p, skeleton).iter().any(|n| !used.contains(&edge(*p, *n))))
.collect();
leftovers.sort();
for p in leftovers {
for nbr in nbrs_in(p, skeleton) {
if used.contains(&edge(p, nbr)) { continue; }
let s = walk(p, nbr, &mut used);
if s.len() >= 2 { out.push(s); }
}
}
out
}
// ── Wave interference ────────────────────────────────────────────────────────── // ── Wave interference ──────────────────────────────────────────────────────────
/// Concentric ring systems from `num_sources` hull interior points. /// Concentric ring systems from `num_sources` hull interior points.
@@ -1960,406 +1420,6 @@ mod tests {
} }
} }
// ── skeleton_fill tests ───────────────────────────────────────────────────
/// Build a horizontal bar `width` × `thickness` pixels at (x0, y0).
fn make_bar_hull(x0: u32, y0: u32, width: u32, thickness: u32) -> Hull {
let mut pixels = Vec::with_capacity((width * thickness) as usize);
for y in 0..thickness { for x in 0..width {
pixels.push((x0 + x, y0 + y));
}}
Hull {
id: 0,
pixels,
contour: vec![], simplified: vec![],
area: width * thickness, avg_luminance: 0.0, avg_color: [0, 0, 0],
bounds: crate::hulls::Bounds {
x_min: x0, y_min: y0,
x_max: x0 + width - 1, y_max: y0 + thickness - 1,
},
}
}
/// Build an L-shape: a vertical bar with a horizontal bar at its bottom.
fn make_l_hull(x0: u32, y0: u32, height: u32, width: u32, thick: u32) -> Hull {
let mut pixels = Vec::new();
// Vertical part
for y in 0..height { for x in 0..thick {
pixels.push((x0 + x, y0 + y));
}}
// Horizontal part (bottom), skipping the overlap with the vertical
for y in 0..thick { for x in thick..width {
pixels.push((x0 + x, y0 + height - thick + y));
}}
let area = pixels.len() as u32;
Hull {
id: 0,
pixels,
contour: vec![], simplified: vec![],
area, avg_luminance: 0.0, avg_color: [0, 0, 0],
bounds: crate::hulls::Bounds {
x_min: x0, y_min: y0,
x_max: x0 + width - 1, y_max: y0 + height - 1,
},
}
}
#[test]
fn skeleton_empty_hull_returns_no_strokes() {
let hull = Hull {
id: 0, pixels: vec![], contour: vec![], simplified: vec![],
area: 0, avg_luminance: 0.0, avg_color: [0, 0, 0],
bounds: crate::hulls::Bounds { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
};
let r = skeleton_fill(&hull, 1.0);
assert!(r.strokes.is_empty());
}
#[test]
fn skeleton_of_thick_bar_is_a_horizontal_polyline() {
// A 60-px-wide × 9-px-thick horizontal bar should skeletonise to one
// mostly-horizontal stroke roughly through its center line.
let hull = make_bar_hull(0, 0, 60, 9);
let r = skeleton_fill(&hull, 1.0);
assert_eq!(r.strokes.len(), 1, "expected exactly 1 stroke, got {}", r.strokes.len());
let s = &r.strokes[0];
// Span should cover most of the bar's length (allow some boundary erosion).
let xs: Vec<f32> = s.iter().map(|&(x, _)| x).collect();
let span = xs.iter().cloned().fold(f32::MIN, f32::max)
- xs.iter().cloned().fold(f32::MAX, f32::min);
assert!(span >= 50.0, "skeleton span {span} too short for 60-px bar");
// Skeleton should sit near the bar's middle row (y ≈ 4 ± a bit).
for &(_, y) in s {
assert!(y >= 2.0 && y <= 6.0, "skeleton y={y} outside expected band 2..=6");
}
}
#[test]
fn skeleton_of_thick_l_has_a_junction_or_two_branches() {
// Thick L → skeleton has a corner. Stop-at-junction policy means we
// expect either one stroke that bends through the corner (when no
// junction forms — possible for sharp L's) or two strokes meeting.
let hull = make_l_hull(0, 0, /*h*/ 40, /*w*/ 40, /*t*/ 7);
let r = skeleton_fill(&hull, 1.0);
assert!(!r.strokes.is_empty(), "L-shape produced no skeleton strokes");
// Any skeleton output points must lie inside the original hull.
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
for stroke in &r.strokes {
for &(sx, sy) in stroke {
let inside = (-1i32..=1).any(|dy| (-1i32..=1).any(|dx| {
let px = (sx.round() as i32 + dx).max(0) as u32;
let py = (sy.round() as i32 + dy).max(0) as u32;
pixel_set.contains(&(px, py))
}));
assert!(inside, "skeleton point ({sx},{sy}) outside hull");
}
}
}
#[test]
fn skeleton_of_solid_square_terminates() {
// A solid blob skeletonises to a small connected fragment near the
// centre. Important: this must not loop forever or produce empty.
let hull = make_square_hull(0, 0, 30);
let r = skeleton_fill(&hull, 1.0);
// Either some strokes, or a single point (which is dropped). Either
// is acceptable — the contract is "doesn't hang".
for stroke in &r.strokes {
assert!(stroke.len() >= 2);
}
}
// ── skeleton_fill on real letter shapes ───────────────────────────────────
//
// Renders Hershey text → rasterises with a thick stroke → extracts hulls
// → runs the full skeleton pipeline. Pins expected stroke counts for
// simple glyphs the user would type into an envelope. Stroke counts here
// are the contract: a healthy skeleton fill draws "O" in ONE pen-down,
// "I" in ONE, "T" in two or three (depending on junction handling), etc.
//
// If the user's gcode is fragmenting every glyph into many 2-point
// segments, these tests will fail loudly with the actual number observed.
/// Rasterise `text` at `font_size_mm`/`dpi`/`thickness` and run hull
/// detection with the same params the front-end's default text mode uses.
fn rasterize_text_to_hulls(text: &str, font_size_mm: f32, dpi: u32, thickness_px: u32)
-> Vec<crate::hulls::Hull>
{
use crate::text::{TextBlockSpec, rasterize_blocks};
use crate::hulls::{extract_hulls, HullParams, Connectivity};
let block = TextBlockSpec {
text: text.to_string(),
font_size_mm, line_spacing_mm: None,
x_mm: 5.0, y_mm: 5.0,
};
let rgb = rasterize_blocks(&[block], 60.0, 30.0, dpi, thickness_px);
let (w, h) = rgb.dimensions();
let luma: Vec<u8> = rgb.pixels()
.map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
.collect();
let params = HullParams {
threshold: 253, min_area: 4, rdp_epsilon: 0.1,
connectivity: Connectivity::Four,
..HullParams::default()
};
extract_hulls(&luma, &rgb, w, h, &params)
}
#[test]
fn skeleton_letter_I_is_one_stroke() {
// Hershey "I" is a single vertical line; thinned and walked, it's 1 stroke.
let hulls = rasterize_text_to_hulls("I", 5.0, 150, 3);
assert_eq!(hulls.len(), 1, "expected 1 hull for 'I', got {}", hulls.len());
let r = skeleton_fill(&hulls[0], 1.0);
assert_eq!(r.strokes.len(), 1,
"expected 1 stroke for 'I', got {} (avg points/stroke = {:.1})",
r.strokes.len(),
r.strokes.iter().map(|s| s.len()).sum::<usize>() as f32 / r.strokes.len().max(1) as f32);
}
#[test]
fn skeleton_letter_O_is_one_stroke() {
// "O" is a closed loop: skeleton is a 1-px ring, walked as one closed stroke.
let hulls = rasterize_text_to_hulls("O", 6.0, 150, 3);
assert_eq!(hulls.len(), 1, "expected 1 hull for 'O', got {}", hulls.len());
let r = skeleton_fill(&hulls[0], 1.0);
assert_eq!(r.strokes.len(), 1,
"expected 1 stroke for 'O', got {} (avg points/stroke = {:.1})",
r.strokes.len(),
r.strokes.iter().map(|s| s.len()).sum::<usize>() as f32 / r.strokes.len().max(1) as f32);
}
/// Renders a letter, runs the full skeleton pipeline, and prints an
/// ASCII-art picture of the post-prune skeleton for diagnostics.
/// Marked `#[ignore]` so it doesn't run by default; invoke with
/// `cargo test --lib skeleton_dump_letter -- --ignored --nocapture`.
#[test]
#[ignore]
fn skeleton_dump_letter() {
use crate::hulls::Bounds;
let letter = std::env::var("DUMP_LETTER").unwrap_or_else(|_| "T".into());
let hulls = rasterize_text_to_hulls(&letter, 6.0, 150, 3);
for (i, h) in hulls.iter().enumerate() {
let mut sk = zhang_suen_thin(&h.pixels);
prune_skeleton_spurs(&mut sk, 3);
let strokes = extract_skeleton_strokes(&sk);
let Bounds { x_min, y_min, x_max, y_max } = h.bounds;
println!("=== hull[{i}] '{letter}' bounds ({x_min},{y_min})-({x_max},{y_max}) ===");
// Render skeleton
// Mark endpoints (1 nbr) as '*', junctions (≥3) as 'X', path (2) as '#'.
for y in y_min..=y_max {
let mut row = String::new();
for x in x_min..=x_max {
if !sk.contains(&(x, y)) { row.push('.'); continue; }
let n = zs_neighbors(x, y).iter().filter(|p| sk.contains(p)).count();
row.push(match n {
0 => 'o', 1 => '*', 2 => '#', 3 => 'Y', 4 => 'X', _ => '@',
});
}
println!("{}", row);
}
println!("strokes: {}", strokes.len());
for (j, s) in strokes.iter().enumerate() {
println!(" stroke {j}: {} pts, first={:?} last={:?}", s.len(), s.first(), s.last());
}
}
}
#[test]
fn skeleton_letter_T_is_two_strokes() {
// "T" has one real junction. With direction pairing, the horizontal
// bar walks straight through it as one stroke; the vertical post
// is a separate stroke. Total: 2.
let hulls = rasterize_text_to_hulls("T", 5.0, 150, 3);
assert_eq!(hulls.len(), 1, "expected 1 hull for 'T', got {}", hulls.len());
let r = skeleton_fill(&hulls[0], 1.0);
assert_eq!(r.strokes.len(), 2,
"expected 2 strokes for 'T' (horizontal-through, vertical-post), got {} (avg points/stroke = {:.1})",
r.strokes.len(),
r.strokes.iter().map(|s| s.len()).sum::<usize>() as f32 / r.strokes.len().max(1) as f32);
}
#[test]
fn skeleton_all_alphanumerics_have_few_strokes() {
// Sweep across A-Z, a-z, 0-9. The user's bug ("hundreds of strokes
// per letter") would manifest as totals well into the dozens for
// any glyph; this regression test catches that. Two checks:
// (a) NO single letter exceeds 6 strokes (B clocks the worst at 5
// in the current implementation; 6 leaves a tiny buffer).
// (b) The AVERAGE stroke count across the full alphabet stays
// under 2.5 — anything higher means junctions are
// fragmenting most letters.
const MAX_STROKES_PER_LETTER: usize = 6;
const MAX_AVG_STROKES: f32 = 2.5;
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz\
0123456789";
let mut report = String::new();
let mut bad: Vec<(char, usize)> = Vec::new();
let mut total_strokes = 0usize;
let mut count = 0usize;
for ch in chars.chars() {
let hulls = rasterize_text_to_hulls(&ch.to_string(), 6.0, 150, 3);
let total: usize = hulls.iter()
.map(|h| skeleton_fill(h, 1.0).strokes.len())
.sum();
report.push_str(&format!("'{}': {} hull(s), {} stroke(s)\n",
ch, hulls.len(), total));
if total > MAX_STROKES_PER_LETTER { bad.push((ch, total)); }
total_strokes += total;
count += 1;
}
let avg = total_strokes as f32 / count as f32;
if !bad.is_empty() {
panic!("Letters with > {} strokes: {:?}\n\
avg = {:.2} (limit {:.2})\nFull report:\n{}",
MAX_STROKES_PER_LETTER, bad, avg, MAX_AVG_STROKES, report);
}
assert!(avg <= MAX_AVG_STROKES,
"avg strokes per glyph = {:.2} > limit {:.2}\n{}",
avg, MAX_AVG_STROKES, report);
}
// ── centerline_fill tests (distance-transform medial axis) ────────────────
/// Helper: bounds-of-strokes test. `centerline_fill` of a glyph should
/// produce strokes that span the GLYPH'S full bounding box, both x and y.
/// If the tail of 'a' or '9' is missing, the strokes' Y range will be
/// significantly smaller than the hull's Y range.
fn stroke_y_range(strokes: &[Vec<(f32, f32)>]) -> (f32, f32) {
let mut lo = f32::MAX;
let mut hi = f32::MIN;
for s in strokes {
for &(_, y) in s {
if y < lo { lo = y; }
if y > hi { hi = y; }
}
}
(lo, hi)
}
#[test]
fn centerline_letter_a_has_tail() {
// Lowercase 'a' has a descending tail on its right side. The strokes
// must span at least 90% of the glyph's height — if the tail is
// dropped, the stroke set would only span the bowl (~70% of height).
let hulls = rasterize_text_to_hulls("a", 8.0, 200, 4);
assert_eq!(hulls.len(), 1, "expected 1 hull for 'a'");
let r = centerline_fill(&hulls[0], 1.0);
assert!(!r.strokes.is_empty(), "centerline produced no strokes for 'a'");
let (lo, hi) = stroke_y_range(&r.strokes);
let span = hi - lo;
let glyph_h = (hulls[0].bounds.y_max - hulls[0].bounds.y_min) as f32;
assert!(span >= 0.85 * glyph_h,
"'a' stroke Y-span {:.1} only covers {:.0}% of glyph height {:.1} \
— likely missing tail. {} strokes total.",
span, span / glyph_h * 100.0, glyph_h, r.strokes.len());
}
#[test]
fn centerline_digit_9_has_descender() {
// '9' has an upper bowl and a tail going down. If the tail is
// disconnected from the bowl, the y-range may still cover the
// whole glyph but the strokes won't form a single connected
// graph from top to bottom. Check both span AND connectivity.
let hulls = rasterize_text_to_hulls("9", 10.0, 200, 4);
assert_eq!(hulls.len(), 1, "expected 1 hull for '9'");
let r = centerline_fill(&hulls[0], 1.0);
let (lo, hi) = stroke_y_range(&r.strokes);
let span = hi - lo;
let glyph_h = (hulls[0].bounds.y_max - hulls[0].bounds.y_min) as f32;
assert!(span >= 0.85 * glyph_h,
"'9' stroke Y-span {:.1} only covers {:.0}% of glyph height {:.1} \
— likely missing descender. {} strokes total.",
span, span / glyph_h * 100.0, glyph_h, r.strokes.len());
}
#[test]
fn centerline_letter_O_is_one_closed_stroke() {
// Sanity: the algorithm must still handle simple shapes well.
let hulls = rasterize_text_to_hulls("O", 8.0, 150, 3);
let r = centerline_fill(&hulls[0], 1.0);
assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'O', got {}", r.strokes.len());
}
#[test]
fn centerline_letter_T_is_two_strokes() {
let hulls = rasterize_text_to_hulls("T", 8.0, 150, 3);
let r = centerline_fill(&hulls[0], 1.0);
assert_eq!(r.strokes.len(), 2, "expected 2 strokes for 'T', got {}", r.strokes.len());
}
#[test]
fn centerline_all_alphanumerics_have_few_strokes() {
// Same regression sweep as for skeleton_fill, but tighter: every
// glyph should produce strokes whose Y-span ≥ 85% of glyph height
// (i.e. nothing is silently truncated).
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut report = String::new();
let mut bad: Vec<(char, String)> = Vec::new();
let mut total_strokes = 0usize;
let mut count = 0usize;
for ch in chars.chars() {
let hulls = rasterize_text_to_hulls(&ch.to_string(), 8.0, 200, 4);
let mut total = 0usize;
let mut min_cov = 1.0f32;
for h in &hulls {
let r = centerline_fill(h, 1.0);
total += r.strokes.len();
if !r.strokes.is_empty() {
let (lo, hi) = stroke_y_range(&r.strokes);
let glyph_h = (h.bounds.y_max - h.bounds.y_min) as f32;
let cov = if glyph_h > 0.0 { (hi - lo) / glyph_h } else { 1.0 };
if cov < min_cov { min_cov = cov; }
}
}
report.push_str(&format!("'{}': {} hull(s), {} stroke(s), min_y_cov {:.2}\n",
ch, hulls.len(), total, min_cov));
if total > 8 {
bad.push((ch, format!("{} strokes", total)));
}
if min_cov < 0.70 {
bad.push((ch, format!("only {:.0}% Y coverage", min_cov * 100.0)));
}
total_strokes += total;
count += 1;
}
let avg = total_strokes as f32 / count as f32;
if !bad.is_empty() {
panic!("Letters with issues: {:?}\navg = {:.2}\nFull report:\n{}",
bad, avg, report);
}
}
#[test]
fn skeleton_letter_X_is_two_strokes() {
// "X" has one 4-way junction. With pairing, two diagonal strokes
// continue straight through, giving exactly 2 strokes total.
let hulls = rasterize_text_to_hulls("X", 6.0, 150, 3);
assert_eq!(hulls.len(), 1, "expected 1 hull for 'X', got {}", hulls.len());
let r = skeleton_fill(&hulls[0], 1.0);
assert_eq!(r.strokes.len(), 2,
"expected 2 strokes for 'X' (two diagonals), got {} (avg points/stroke = {:.1})",
r.strokes.len(),
r.strokes.iter().map(|s| s.len()).sum::<usize>() as f32 / r.strokes.len().max(1) as f32);
}
#[test]
fn skeleton_thin_3px_bar_one_long_stroke() {
// 3-px-thick bar: minimum thickness the rasteriser produces by
// default (dpi/50 ≈ 3 at 150dpi). Existing 9-px test masks issues
// unique to thin strokes.
let hull = make_bar_hull(0, 0, 50, 3);
let r = skeleton_fill(&hull, 1.0);
assert_eq!(r.strokes.len(), 1,
"expected 1 stroke for 3×50 bar, got {}", r.strokes.len());
assert!(r.strokes[0].len() >= 30,
"expected ≥30 points (~bar length) but got {} (looks fragmented)",
r.strokes[0].len());
}
// ── hilbert_fill tests ──────────────────────────────────────────────────── // ── hilbert_fill tests ────────────────────────────────────────────────────
#[test] #[test]

View File

@@ -3,9 +3,6 @@ pub mod hulls;
pub mod fill; pub mod fill;
pub mod gcode; pub mod gcode;
pub mod text; pub mod text;
pub mod topo_strokes;
pub mod brush_paint;
pub mod brush_paint_opt;
use std::time::Instant; use std::time::Instant;
@@ -822,10 +819,6 @@ fn process_pass_work(
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)), "circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
"voronoi" => fill::voronoi_fill(hull, spacing), "voronoi" => fill::voronoi_fill(hull, spacing),
"hilbert" => fill::hilbert_fill(hull, spacing), "hilbert" => fill::hilbert_fill(hull, spacing),
"skeleton" => fill::skeleton_fill(hull, spacing),
"centerline" => fill::centerline_fill(hull, spacing),
"topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize), "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)), "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_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
@@ -854,10 +847,6 @@ fn process_pass_work(
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)), "circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
"voronoi" => fill::voronoi_fill(hull, spacing), "voronoi" => fill::voronoi_fill(hull, spacing),
"hilbert" => fill::hilbert_fill(hull, spacing), "hilbert" => fill::hilbert_fill(hull, spacing),
"skeleton" => fill::skeleton_fill(hull, spacing),
"centerline" => fill::centerline_fill(hull, spacing),
"topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize), "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)), "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_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
@@ -998,165 +987,6 @@ fn list_hulls(pass_idx: usize, state: State<Mutex<AppState>>) -> Result<Vec<Hull
}).collect()) }).collect())
} }
/// Replace the hulls in `pass_idx` with a freshly-rasterized test letter.
/// Lets the debug viewer load any character at any scale on demand without
/// having to push an image through the full pipeline. Returns the updated
/// hull list.
#[tauri::command]
fn load_test_letter(
pass_idx: usize,
ch: String,
font_mm: f32,
dpi: u32,
thickness_px: u32,
state: State<Mutex<AppState>>,
) -> Result<Vec<HullSummary>, String> {
let c = ch.chars().next().ok_or("empty character")?;
let hulls = brush_paint::rasterize_test_letter(c, font_mm, dpi, thickness_px);
let mut st = state.lock().unwrap();
if pass_idx >= st.passes.len() {
st.passes.resize_with(pass_idx + 1, PassState::default);
}
let ps = &mut st.passes[pass_idx];
ps.hulls = hulls;
Ok(ps.hulls.iter().enumerate().map(|(i, h)| HullSummary {
index: i,
area: h.area,
bounds: [h.bounds.x_min, h.bounds.y_min, h.bounds.x_max, h.bounds.y_max],
}).collect())
}
#[tauri::command]
fn get_paint_debug(
pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams,
state: State<Mutex<AppState>>,
) -> Result<brush_paint::PaintDebug, String> {
let st = state.lock().unwrap();
let ps = st.passes.get(pass_idx)
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
let h = ps.hulls.get(hull_idx)
.ok_or_else(|| format!("hull {hull_idx} out of range (pass has {})", ps.hulls.len()))?;
Ok(brush_paint::paint_fill_debug(h, &params))
}
#[derive(Clone, serde::Serialize)]
struct OptimizerProgress {
step: u32,
axis: String,
value: f32,
score: f32,
delta: f32,
params: brush_paint::PaintParams,
}
/// Run best-improvement coordinate descent on the brush-paint params,
/// optimizing against the single hull at `(pass_idx, hull_idx)` using
/// `default_score`. Emits an `optimizer-progress` event after every axis
/// improvement; the final best params come back as the return value.
///
/// This is the in-app version of the `paint_optimize_global_defaults`
/// test — single-hull, no constraint corpus, so it's fast (< 5s typical)
/// and lets the user dial in params for whatever letter they're staring at.
///
/// Synchronous: Tauri runs sync commands on a thread pool, so the UI
/// stays responsive while this runs.
#[tauri::command]
fn optimize_paint_params(
pass_idx: usize, hull_idx: usize, base: brush_paint::PaintParams,
app: AppHandle,
state: State<Mutex<AppState>>,
) -> Result<brush_paint::PaintParams, String> {
// Clone the hull so we don't hold the mutex during the descent.
let hull = {
let st = state.lock().unwrap();
let ps = st.passes.get(pass_idx)
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
ps.hulls.get(hull_idx)
.ok_or_else(|| format!("hull {hull_idx} out of range"))?
.clone()
};
let result = (|| -> brush_paint::PaintParams {
type Setter = fn(&mut brush_paint::PaintParams, f32);
let axes: Vec<(&str, Vec<f32>, Setter)> = vec![
("brush_radius_factor", vec![0.55, 0.65, 0.75, 0.85, 0.95, 1.05, 1.15],
|p, v| p.brush_radius_factor = v),
("brush_radius_percentile", vec![0.85, 0.90, 0.95, 0.99, 1.00],
|p, v| p.brush_radius_percentile = v),
("brush_radius_offset_px", vec![0.0, 0.25, 0.5],
|p, v| p.brush_radius_offset_px = v),
("walk_bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|p, v| p.walk_bg_penalty = v),
("overpaint_penalty", vec![0.0, 0.02, 0.05, 0.1, 0.2, 0.4],
|p, v| p.overpaint_penalty = v),
("step_size_factor", vec![0.25, 0.4, 0.5, 0.65, 0.8],
|p, v| p.step_size_factor = v),
("lookahead_steps", vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0],
|p, v| p.lookahead_steps = v as usize),
("n_directions", vec![8.0, 16.0, 24.0, 32.0, 48.0],
|p, v| p.n_directions = v as usize),
("momentum_weight", vec![0.0, 0.2, 0.4, 0.6, 0.9, 1.5],
|p, v| p.momentum_weight = v),
("min_score_factor", vec![0.0, 0.02, 0.05, 0.1, 0.2],
|p, v| p.min_score_factor = v),
("min_component_factor", vec![0.2, 0.4, 0.6, 0.8, 1.2],
|p, v| p.min_component_factor = v),
("output_rdp_eps", vec![0.0, 0.25, 0.5, 1.0],
|p, v| p.output_rdp_eps = v),
];
let eval = |p: &brush_paint::PaintParams| -> f32 {
let (_, m) = brush_paint::metrics_for(&hull, p);
brush_paint::default_score(&m)
};
let mut current = base.clone();
let mut current_score = eval(&current);
let _ = app.emit("optimizer-progress", OptimizerProgress {
step: 0, axis: "<initial>".into(), value: 0.0,
score: current_score, delta: 0.0,
params: current.clone(),
});
let max_steps = axes.len() * 6;
for step_no in 1..=max_steps {
// Best-improvement: try every axis's best candidate, take the
// single move with the biggest score drop.
let mut best_axis_idx: usize = usize::MAX;
let mut best_axis_v: f32 = f32::NAN;
let mut best_axis_score: f32 = current_score;
for (ai, (_name, candidates, setter)) in axes.iter().enumerate() {
for &v in candidates {
let mut p = current.clone();
setter(&mut p, v);
let s = eval(&p);
if s + 1e-3 < best_axis_score {
best_axis_score = s;
best_axis_v = v;
best_axis_idx = ai;
}
}
}
if best_axis_idx == usize::MAX { break; } // converged
let (name, _, setter) = &axes[best_axis_idx];
let prev = current_score;
setter(&mut current, best_axis_v);
current_score = best_axis_score;
let _ = app.emit("optimizer-progress", OptimizerProgress {
step: step_no as u32,
axis: name.to_string(),
value: best_axis_v,
score: current_score,
delta: prev - current_score,
params: current.clone(),
});
}
current
})();
Ok(result)
}
#[tauri::command] #[tauri::command]
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) { fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
let mut st = state.lock().unwrap(); let mut st = state.lock().unwrap();
@@ -2976,9 +2806,6 @@ pub fn run() {
get_images_dir, get_images_dir,
set_pass_count, set_pass_count,
list_hulls, list_hulls,
load_test_letter,
get_paint_debug,
optimize_paint_params,
process_pass, process_pass,
get_all_strokes, get_all_strokes,
get_gcode_viz, get_gcode_viz,

View File

@@ -1,568 +0,0 @@
// Topology-aware pen-stroke decomposition.
//
// raster glyph
// ↓ Zhang-Suen thinning
// 1-px skeleton
// ↓ salience-based spur prune
// cleaned skeleton
// ↓ identify junctions (degree ≥ 3) + endpoints (degree 1)
// medial-axis graph (nodes + edges with pixel paths)
// ↓ Chinese postman (pair odd-degree vertices, find Eulerian trails)
// minimum-pen-up stroke decomposition
// ↓ smooth each stroke (RDP + Chaikin)
// final pen strokes
//
// The Chinese-postman step is the key. For a graph with 2k odd-degree
// vertices, the minimum number of pen-strokes is k (Eulerian trail count
// after pairing). The trick is which pairing minimises total walk length —
// for k ≤ 4 we brute-force all (2k-1)!! pairings (≤ 105 for k=4).
//
// Concrete glyph counts under this model:
// I/L/J/U: 1 stroke (graph is a single edge or path)
// O/D/0: 1 stroke (Eulerian circuit on a cycle)
// T/X: 2 strokes
// N/M/A: 3 strokes
// 8: 1 stroke (figure-8: degree-4 junction + 2 self-loops)
// B: 2-3 strokes
// E/F: 3 strokes
use std::collections::{HashMap, HashSet};
use crate::fill::{FillResult, smooth_stroke, chamfer_distance,
zhang_suen_thin, prune_skeleton_spurs, zs_neighbors};
use crate::hulls::Hull;
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct TopoParams {
/// Spur prune length as a multiplier of stroke half-width (= sdf_max).
/// 0 = no pruning, 2.5 ≈ "drop branches up to 2.5× stroke half-width."
/// Scale-invariant: same value works at 3mm and 8mm. Tradeoff: too
/// high removes real letter tails (`a`, `g`, `9`); too low keeps
/// reflex-corner artifacts that explode the stroke count.
pub spur_prune_factor: f32,
/// Final stroke RDP epsilon (px).
pub output_rdp_eps: f32,
/// Final stroke Chaikin smoothing passes.
pub output_chaikin: u32,
}
impl Default for TopoParams {
fn default() -> Self {
Self { spur_prune_factor: 6.0, output_rdp_eps: 0.5, output_chaikin: 2 }
}
}
// ── Graph data structures ──────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct GraphEdge {
pub a: usize, // node index of one endpoint
pub b: usize, // node index of the other
pub path: Vec<(f32, f32)>, // pixel-coord polyline a→b inclusive
pub length: f32, // Euclidean length
}
#[derive(Debug, Clone)]
pub struct MedialGraph {
pub nodes: Vec<(f32, f32)>,
pub edges: Vec<GraphEdge>,
/// adj[node_idx] = vec of edge indices incident to that node.
pub adj: Vec<Vec<usize>>,
}
impl MedialGraph {
fn degree(&self, node: usize) -> usize { self.adj[node].len() }
}
// ── Build graph from a hull ────────────────────────────────────────────
pub fn build_graph(hull: &Hull, params: &TopoParams) -> MedialGraph {
if hull.pixels.is_empty() {
return MedialGraph { nodes: vec![], edges: vec![], adj: vec![] };
}
// Compute SDF max once so spur-prune length scales with stroke
// thickness — same params then work at all font sizes.
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
let dist = chamfer_distance(hull, &pixel_set);
let sdf_max = dist.values().cloned().fold(0.0_f32, f32::max).max(0.5);
let mut skel = zhang_suen_thin(&hull.pixels);
let spur_len = (params.spur_prune_factor * sdf_max).round() as usize;
prune_skeleton_spurs(&mut skel, spur_len.max(2));
fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
}
// Identify endpoints (degree 1) and junctions (degree ≥ 3).
let junctions: HashSet<(u32, u32)> = skel.iter().copied()
.filter(|p| nbrs_in(*p, &skel).len() >= 3).collect();
let endpoints: HashSet<(u32, u32)> = skel.iter().copied()
.filter(|p| nbrs_in(*p, &skel).len() == 1).collect();
// Cluster adjacent junction pixels (8-connected) into super-junctions.
// ZS thinning leaves a small blob of degree-3+ pixels at every real
// junction, which would otherwise show up as multiple distinct nodes
// connected by 1-2 px sub-edges.
let mut pixel_to_node: HashMap<(u32, u32), usize> = HashMap::new();
let mut nodes: Vec<(f32, f32)> = Vec::new();
{
let mut visited: HashSet<(u32, u32)> = HashSet::new();
for &p in &junctions {
if visited.contains(&p) { continue; }
// BFS over the junction-pixel cluster.
let mut cluster: Vec<(u32, u32)> = Vec::new();
let mut q: Vec<(u32, u32)> = vec![p];
while let Some(q_p) = q.pop() {
if !visited.insert(q_p) { continue; }
cluster.push(q_p);
for n in zs_neighbors(q_p.0, q_p.1) {
if junctions.contains(&n) && !visited.contains(&n) {
q.push(n);
}
}
}
// Cluster centroid is the super-junction's position.
let n = cluster.len() as f32;
let cx = cluster.iter().map(|p| p.0 as f32).sum::<f32>() / n;
let cy = cluster.iter().map(|p| p.1 as f32).sum::<f32>() / n;
let nidx = nodes.len();
nodes.push((cx, cy));
for &cp in &cluster { pixel_to_node.insert(cp, nidx); }
}
// Each endpoint is its own node.
for &p in &endpoints {
let nidx = nodes.len();
nodes.push((p.0 as f32, p.1 as f32));
pixel_to_node.insert(p, nidx);
}
}
let node_pixels: HashSet<(u32, u32)> = pixel_to_node.keys().copied().collect();
let node_idx = pixel_to_node;
// Walk every edge starting from each node along each unused incident
// skeleton-pixel direction. Edges are uniqued by their (a, b) endpoints
// and a hash of their pixel sequence.
let mut edges: Vec<GraphEdge> = Vec::new();
let mut used_edge_pixels: HashSet<((u32, u32), (u32, u32))> = HashSet::new();
let edge_key = |a: (u32, u32), b: (u32, u32)| -> ((u32, u32), (u32, u32)) {
if a <= b { (a, b) } else { (b, a) }
};
for &start in &node_pixels {
let start_ni = node_idx[&start];
for next in nbrs_in(start, &skel) {
if used_edge_pixels.contains(&edge_key(start, next)) { continue; }
// Skip intra-cluster steps — those don't form graph edges
// (the cluster collapses to one super-node). Without this we'd
// emit fake 1-2 px self-loops between every pair of junction
// pixels in the same blob.
if node_idx.get(&next) == Some(&start_ni) { continue; }
// Walk: start → next → ... until we hit another node pixel.
let mut path_u: Vec<(u32, u32)> = vec![start, next];
used_edge_pixels.insert(edge_key(start, next));
let mut prev = start;
let mut cur = next;
let mut end_ni: Option<usize> = None;
loop {
if let Some(&ni) = node_idx.get(&cur) {
end_ni = Some(ni);
break;
}
let mut step = None;
for n in nbrs_in(cur, &skel) {
if n == prev { continue; }
if used_edge_pixels.contains(&edge_key(cur, n)) { continue; }
step = Some(n); break;
}
let next_step = match step { Some(s) => s, None => break };
used_edge_pixels.insert(edge_key(cur, next_step));
path_u.push(next_step);
prev = cur;
cur = next_step;
if cur == start {
end_ni = Some(start_ni);
break;
}
}
// If the walk ran out without hitting a node (shouldn't happen
// for well-formed skeletons but guard anyway), drop this edge.
let end_ni = match end_ni { Some(ni) => ni, None => continue };
let path: Vec<(f32, f32)> = path_u.into_iter()
.map(|(x, y)| (x as f32, y as f32)).collect();
let length: f32 = path.windows(2).map(|w| {
let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
(dx * dx + dy * dy).sqrt()
}).sum();
edges.push(GraphEdge { a: start_ni, b: end_ni, path, length });
}
}
// Detect pure-cycle components (no node pixels at all — every pixel is
// degree 2). These need a synthetic node so postman has something to
// walk. Pick the topmost-leftmost cycle pixel as the "anchor."
let mut visited_cycle: HashSet<(u32, u32)> = used_edge_pixels.iter()
.flat_map(|(a, b)| [*a, *b])
.collect();
for &p in &skel {
if visited_cycle.contains(&p) || node_pixels.contains(&p) { continue; }
// Trace a cycle from p.
let anchor_ni = nodes.len();
nodes.push((p.0 as f32, p.1 as f32));
let mut path_u: Vec<(u32, u32)> = vec![p];
visited_cycle.insert(p);
let mut prev: Option<(u32, u32)> = None;
let mut cur = p;
loop {
let mut step = None;
for n in nbrs_in(cur, &skel) {
if Some(n) == prev { continue; }
if visited_cycle.contains(&n) && n != p { continue; }
step = Some(n); break;
}
let next_step = match step { Some(s) => s, None => break };
path_u.push(next_step);
if next_step == p { break; } // closed
visited_cycle.insert(next_step);
prev = Some(cur);
cur = next_step;
}
let path: Vec<(f32, f32)> = path_u.into_iter()
.map(|(x, y)| (x as f32, y as f32)).collect();
let length: f32 = path.windows(2).map(|w| {
let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
(dx * dx + dy * dy).sqrt()
}).sum();
edges.push(GraphEdge { a: anchor_ni, b: anchor_ni, path, length });
}
let mut adj: Vec<Vec<usize>> = vec![vec![]; nodes.len()];
for (i, e) in edges.iter().enumerate() {
adj[e.a].push(i);
// Self-loops contribute 2 to degree.
adj[e.b].push(i);
}
MedialGraph { nodes, edges, adj }
}
// ── Chinese postman ────────────────────────────────────────────────────
/// Chinese postman: produce minimum-pen-stroke decomposition.
///
/// Algorithm:
/// 1. For each connected component, find odd-degree vertices.
/// 2. Pair them up (sequential pairing is fine for the small graphs we
/// get from glyphs). Each pair gets a "virtual" edge connecting them.
/// 3. The augmented graph is Eulerian (every vertex now even-degree).
/// 4. Run Hierholzer to get one Eulerian circuit covering all real +
/// virtual edges.
/// 5. Split the circuit at each virtual-edge crossing — each split is a
/// pen-up. Result is k pen-strokes for k virtual edges (= k pairs of
/// odd vertices).
///
/// The number of pen-strokes equals (odd_count / 2) per component.
pub fn chinese_postman(graph: &MedialGraph) -> Vec<Vec<usize>> {
if graph.edges.is_empty() { return vec![]; }
// Build a per-component view, then process each independently.
let components = connected_components(graph);
let mut trails: Vec<Vec<usize>> = Vec::new();
for component in components {
// Local mutable adjacency (so we can consume edges without
// touching other components).
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); graph.nodes.len()];
for &n in &component {
adj[n] = graph.adj[n].clone();
}
if adj.iter().all(|v| v.is_empty()) { continue; }
// Odd-degree vertices in this component.
let odd: Vec<usize> = component.iter().copied()
.filter(|&n| graph.adj[n].len() % 2 == 1).collect();
// Pair odd vertices and inject virtual edges. Virtual edges have
// index ≥ graph.edges.len() — we'll split the final trail there.
let n_real = graph.edges.len();
let mut virtual_endpoints: Vec<(usize, usize)> = Vec::new();
for chunk in odd.chunks(2) {
if chunk.len() < 2 { continue; }
let (u, v) = (chunk[0], chunk[1]);
let vidx = n_real + virtual_endpoints.len();
virtual_endpoints.push((u, v));
adj[u].push(vidx);
adj[v].push(vidx);
}
// Pick a start node: any odd vertex (so we end at an odd vertex
// too, which is where a pen-up makes sense), else any with edges.
let start = odd.first().copied()
.or_else(|| component.iter().copied().find(|&n| !adj[n].is_empty()));
let start = match start { Some(s) => s, None => continue };
// Hierholzer over the augmented (Eulerian) graph.
let circuit = hierholzer(graph, n_real, &virtual_endpoints,
start, &mut adj);
// Split at virtual edges. Each split = pen-up.
let mut current: Vec<usize> = Vec::new();
for eidx in circuit {
if eidx >= n_real {
if !current.is_empty() { trails.push(std::mem::take(&mut current)); }
} else {
current.push(eidx);
}
}
if !current.is_empty() { trails.push(current); }
}
trails
}
fn connected_components(graph: &MedialGraph) -> Vec<Vec<usize>> {
let mut seen = vec![false; graph.nodes.len()];
let mut components: Vec<Vec<usize>> = Vec::new();
for start in 0..graph.nodes.len() {
if seen[start] { continue; }
if graph.adj[start].is_empty() { seen[start] = true; continue; }
let mut comp: Vec<usize> = Vec::new();
let mut q: Vec<usize> = vec![start];
while let Some(n) = q.pop() {
if seen[n] { continue; }
seen[n] = true;
comp.push(n);
for &eidx in &graph.adj[n] {
let e = &graph.edges[eidx];
let other = if e.a == n { e.b } else { e.a };
if !seen[other] { q.push(other); }
}
}
if !comp.is_empty() { components.push(comp); }
}
components
}
/// Hierholzer over a graph augmented with virtual edges. `n_real` is the
/// real-edge index threshold (real edges are 0..n_real, virtual are
/// n_real..). `virtual_endpoints[i]` gives endpoints for virtual edge
/// `n_real + i`. Returns one Eulerian circuit/trail covering ALL edges
/// (real + virtual) — guaranteed because the augmented graph is Eulerian.
fn hierholzer(graph: &MedialGraph,
n_real: usize, virtual_endpoints: &[(usize, usize)],
start: usize, adj: &mut Vec<Vec<usize>>) -> Vec<usize>
{
let endpoints = |eidx: usize| -> (usize, usize) {
if eidx < n_real {
let e = &graph.edges[eidx];
(e.a, e.b)
} else {
virtual_endpoints[eidx - n_real]
}
};
// Standard Hierholzer node-stack, but we record the EDGE used for each
// forward step and emit it when the source node is popped.
let mut node_stack: Vec<usize> = vec![start];
// Edge that brought us to each node (parallel to node_stack, with first
// entry being a sentinel).
let mut arrival_edge: Vec<Option<usize>> = vec![None];
let mut trail: Vec<usize> = Vec::new();
while let Some(&top) = node_stack.last() {
if let Some(&edge) = adj[top].first() {
// Consume edge.
let pos = adj[top].iter().position(|&e| e == edge).unwrap();
adj[top].swap_remove(pos);
let (a, b) = endpoints(edge);
let other = if a == top { b } else { a };
if a == b {
// Self-loop: remove duplicate at top.
if let Some(p) = adj[top].iter().position(|&e| e == edge) {
adj[top].swap_remove(p);
}
} else {
if let Some(p) = adj[other].iter().position(|&e| e == edge) {
adj[other].swap_remove(p);
}
}
node_stack.push(other);
arrival_edge.push(Some(edge));
} else {
node_stack.pop();
if let Some(Some(e)) = arrival_edge.pop() {
trail.push(e);
}
}
}
trail.reverse();
trail
}
// ── Public entry point ─────────────────────────────────────────────────
pub fn topo_fill(hull: &Hull, _intensity: f32) -> FillResult {
topo_fill_with(hull, &TopoParams::default())
}
pub fn topo_fill_with(hull: &Hull, params: &TopoParams) -> FillResult {
let graph = build_graph(hull, params);
if graph.edges.is_empty() {
return FillResult { hull_id: hull.id, strokes: vec![] };
}
let stroke_edges = chinese_postman(&graph);
let strokes: Vec<Vec<(f32, f32)>> = stroke_edges.into_iter()
.map(|edge_seq| stitch_path(&edge_seq, &graph))
.map(|p| smooth_stroke(&p, params.output_rdp_eps, params.output_chaikin))
.filter(|p| p.len() >= 2)
.collect();
FillResult { hull_id: hull.id, strokes }
}
/// Concatenate the pixel paths of consecutive edges, flipping each edge's
/// path to match orientation. The first edge sets the orientation by
/// matching its `b` to the next edge's shared node.
fn stitch_path(edge_seq: &[usize], graph: &MedialGraph) -> Vec<(f32, f32)> {
if edge_seq.is_empty() { return vec![]; }
let mut out: Vec<(f32, f32)> = Vec::new();
// Establish first edge orientation by looking at the next one (if any).
let first = &graph.edges[edge_seq[0]];
let mut current_end = if edge_seq.len() == 1 {
// Single-edge stroke: orientation arbitrary. Use a→b as-is.
out.extend(&first.path);
return out;
} else {
let next = &graph.edges[edge_seq[1]];
let shared = if first.b == next.a || first.b == next.b { first.b }
else if first.a == next.a || first.a == next.b { first.a }
else { first.b }; // shouldn't happen on a valid trail
if shared == first.b {
out.extend(&first.path);
first.b
} else {
out.extend(first.path.iter().rev());
first.a
}
};
for &eidx in &edge_seq[1..] {
let e = &graph.edges[eidx];
let (path_iter, end): (Box<dyn Iterator<Item = (f32, f32)>>, usize) =
if e.a == current_end {
(Box::new(e.path.iter().copied().skip(1)), e.b)
} else {
// Either e.b == current_end, or self-loop.
(Box::new(e.path.iter().rev().copied().skip(1)), e.a)
};
out.extend(path_iter);
current_end = end;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::{TextBlockSpec, rasterize_blocks};
use crate::hulls::{extract_hulls, HullParams, Connectivity};
fn rasterize_letter_at(c: char, font_size_mm: f32, dpi: u32, thickness_px: u32)
-> Vec<crate::hulls::Hull>
{
let block = TextBlockSpec {
text: c.to_string(), font_size_mm,
line_spacing_mm: None, x_mm: 5.0, y_mm: 5.0,
};
let rgb = rasterize_blocks(&[block], 30.0, 20.0, dpi, thickness_px);
let (w, h) = rgb.dimensions();
let luma: Vec<u8> = rgb.pixels()
.map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
.collect();
let params = HullParams {
threshold: 253, min_area: 4, rdp_epsilon: 1.5,
connectivity: Connectivity::Four,
..HullParams::default()
};
extract_hulls(&luma, &rgb, w, h, &params)
}
#[test]
#[ignore]
fn topo_alphabet_report() {
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let p = TopoParams::default();
for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
println!("\n══ font={}mm, dpi={}, thickness={}px ══", font_mm, dpi, thick);
let mut total = 0;
let mut bad: Vec<(char, usize)> = Vec::new();
for ch in chars.chars() {
let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
let main = match hulls.iter().max_by_key(|h| h.area) {
Some(h) => h, None => continue
};
let r = topo_fill_with(main, &p);
let n = r.strokes.len();
total += n;
if n > 4 { bad.push((ch, n)); }
println!("'{}': {} strokes", ch, n);
}
println!("Total: {} / 62 chars (avg {:.2})", total, total as f32 / 62.0);
println!("Over-4-strokes: {:?}", bad);
}
}
#[test]
fn topo_letter_I_is_one_stroke() {
let hulls = rasterize_letter_at('I', 8.0, 200, 4);
let main = hulls.iter().max_by_key(|h| h.area).unwrap();
let r = topo_fill(main, 0.0);
assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'I', got {}", r.strokes.len());
}
#[test]
fn topo_letter_O_is_one_stroke() {
let hulls = rasterize_letter_at('O', 8.0, 200, 4);
let main = hulls.iter().max_by_key(|h| h.area).unwrap();
let r = topo_fill(main, 0.0);
assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'O' (closed loop), got {}",
r.strokes.len());
}
#[test]
fn topo_no_panic_for_any_printable_ascii() {
for b in 0x20u8..=0x7E {
let ch = b as char;
for h in rasterize_letter_at(ch, 8.0, 200, 4) {
let _ = topo_fill(&h, 0.0);
}
}
}
#[test]
fn topo_alphabet_max_5_strokes() {
// Strict bound: every alphanumeric should decompose to ≤5 strokes
// at typical font sizes. If something exceeds this, the user will
// see a fragmented glyph.
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let p = TopoParams::default();
let mut bad: Vec<(char, usize, f32, u32)> = Vec::new();
for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
for ch in chars.chars() {
let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
let main = match hulls.iter().max_by_key(|h| h.area) {
Some(h) => h, None => continue
};
let r = topo_fill_with(main, &p);
if r.strokes.len() > 5 {
bad.push((ch, r.strokes.len(), font_mm, dpi));
}
}
}
if !bad.is_empty() {
panic!("Glyphs over the 5-stroke bound: {:?}", bad);
}
}
}