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:
@@ -22,7 +22,6 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] }
|
||||
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
||||
spade = "2"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 2
|
||||
@@ -44,11 +43,3 @@ path = "src/gen_test_assets.rs"
|
||||
[[bin]]
|
||||
name = "pipeline_bench"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -5,7 +5,6 @@ import TuningPanel from './components/TuningPanel.jsx'
|
||||
import CalibrationButtons from './components/CalibrationButtons.jsx'
|
||||
import CalibrationAxis from './components/CalibrationAxis.jsx'
|
||||
import TextEditOverlay from './components/TextEditOverlay.jsx'
|
||||
import PaintDebugView from './components/PaintDebugView.jsx'
|
||||
import NodeGraph from './components/NodeGraph.jsx'
|
||||
import PassPanel from './components/PassPanel.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 { 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() {
|
||||
const [image, setImage] = useState(null)
|
||||
@@ -615,8 +614,6 @@ export default function App() {
|
||||
/>
|
||||
) : viewMode === 'tuning' ? (
|
||||
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
|
||||
) : viewMode === 'paint' ? (
|
||||
<PaintDebugView passIdx={0} />
|
||||
) : viewMode === 'source' && sourceMode === 'text' ? (
|
||||
<TextEditOverlay
|
||||
paperWMm={gcodeConfig.paper_w_mm}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,47 +26,6 @@ export async function listHulls(passIdx = 0) {
|
||||
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() {
|
||||
return tracedInvoke('get_all_strokes', {})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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, ¶ms);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let mut iters = 0u32;
|
||||
let start = Instant::now();
|
||||
while Instant::now() < deadline {
|
||||
let _ = evaluate(&corpus, ¶ms);
|
||||
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);
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
3525
src/brush_paint.rs
3525
src/brush_paint.rs
File diff suppressed because it is too large
Load Diff
@@ -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, ¤t);
|
||||
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(¤t, 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)(¤t), 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(¤t);
|
||||
for _ in 0..max_passes {
|
||||
let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, ax)| {
|
||||
let (v, s) = golden(¤t, 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
|
||||
}
|
||||
940
src/fill.rs
940
src/fill.rs
@@ -533,74 +533,6 @@ pub fn spiral(hull: &Hull, spacing_px: f32) -> FillResult {
|
||||
|
||||
// ── 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).
|
||||
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());
|
||||
@@ -832,478 +764,6 @@ pub fn hilbert_fill(hull: &Hull, spacing_px: f32) -> FillResult {
|
||||
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(¤t, true);
|
||||
for p in &to_remove1 { current.remove(p); }
|
||||
let to_remove2 = zs_mark(¤t, 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 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, ¶ms)
|
||||
}
|
||||
|
||||
#[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 ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
173
src/lib.rs
173
src/lib.rs
@@ -3,9 +3,6 @@ pub mod hulls;
|
||||
pub mod fill;
|
||||
pub mod gcode;
|
||||
pub mod text;
|
||||
pub mod topo_strokes;
|
||||
pub mod brush_paint;
|
||||
pub mod brush_paint_opt;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -822,10 +819,6 @@ fn process_pass_work(
|
||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
||||
"voronoi" => fill::voronoi_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),
|
||||
"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)),
|
||||
@@ -854,10 +847,6 @@ fn process_pass_work(
|
||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
||||
"voronoi" => fill::voronoi_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),
|
||||
"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)),
|
||||
@@ -998,165 +987,6 @@ fn list_hulls(pass_idx: usize, state: State<Mutex<AppState>>) -> Result<Vec<Hull
|
||||
}).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, ¶ms))
|
||||
}
|
||||
|
||||
#[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(¤t);
|
||||
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]
|
||||
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
|
||||
let mut st = state.lock().unwrap();
|
||||
@@ -2976,9 +2806,6 @@ pub fn run() {
|
||||
get_images_dir,
|
||||
set_pass_count,
|
||||
list_hulls,
|
||||
load_test_letter,
|
||||
get_paint_debug,
|
||||
optimize_paint_params,
|
||||
process_pass,
|
||||
get_all_strokes,
|
||||
get_gcode_viz,
|
||||
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user