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"
|
env_logger = "0.11"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] }
|
reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] }
|
||||||
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
||||||
spade = "2"
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
@@ -44,11 +43,3 @@ path = "src/gen_test_assets.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pipeline_bench"
|
name = "pipeline_bench"
|
||||||
path = "src/pipeline_bench.rs"
|
path = "src/pipeline_bench.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "paint_opt_worker"
|
|
||||||
path = "src/bin/paint_opt_worker.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "paint_meta_opt_worker"
|
|
||||||
path = "src/bin/paint_meta_opt_worker.rs"
|
|
||||||
|
|||||||
@@ -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 CalibrationButtons from './components/CalibrationButtons.jsx'
|
||||||
import CalibrationAxis from './components/CalibrationAxis.jsx'
|
import CalibrationAxis from './components/CalibrationAxis.jsx'
|
||||||
import TextEditOverlay from './components/TextEditOverlay.jsx'
|
import TextEditOverlay from './components/TextEditOverlay.jsx'
|
||||||
import PaintDebugView from './components/PaintDebugView.jsx'
|
|
||||||
import NodeGraph from './components/NodeGraph.jsx'
|
import NodeGraph from './components/NodeGraph.jsx'
|
||||||
import PassPanel from './components/PassPanel.jsx'
|
import PassPanel from './components/PassPanel.jsx'
|
||||||
import PerfPanel from './components/PerfPanel.jsx'
|
import PerfPanel from './components/PerfPanel.jsx'
|
||||||
@@ -15,7 +14,7 @@ import * as tauri from './hooks/useTauri.js'
|
|||||||
import { serialize, deserialize } from './project.js'
|
import { serialize, deserialize } from './project.js'
|
||||||
import { useFps } from './hooks/useFps.js'
|
import { useFps } from './hooks/useFps.js'
|
||||||
|
|
||||||
const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'paint', 'printer', 'tuning']
|
const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'printer', 'tuning']
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [image, setImage] = useState(null)
|
const [image, setImage] = useState(null)
|
||||||
@@ -615,8 +614,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : viewMode === 'tuning' ? (
|
) : viewMode === 'tuning' ? (
|
||||||
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
|
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
|
||||||
) : viewMode === 'paint' ? (
|
|
||||||
<PaintDebugView passIdx={0} />
|
|
||||||
) : viewMode === 'source' && sourceMode === 'text' ? (
|
) : viewMode === 'source' && sourceMode === 'text' ? (
|
||||||
<TextEditOverlay
|
<TextEditOverlay
|
||||||
paperWMm={gcodeConfig.paper_w_mm}
|
paperWMm={gcodeConfig.paper_w_mm}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,47 +26,6 @@ export async function listHulls(passIdx = 0) {
|
|||||||
return tracedInvoke('list_hulls', { passIdx })
|
return tracedInvoke('list_hulls', { passIdx })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the hulls of a pass with a freshly-rasterized test letter. Used
|
|
||||||
// by the paint debug viewer's "Test letters" picker so any character/scale
|
|
||||||
// is one click away — no full pipeline run required.
|
|
||||||
export async function loadTestLetter(passIdx, ch, fontMm, dpi, thicknessPx) {
|
|
||||||
return tracedInvoke('load_test_letter', {
|
|
||||||
passIdx, ch, fontMm, dpi, thicknessPx,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default PaintParams must match Rust's `impl Default for PaintParams`.
|
|
||||||
export const DEFAULT_PAINT_PARAMS = {
|
|
||||||
brush_radius_factor: 0.88,
|
|
||||||
brush_radius_offset_px: 0.53,
|
|
||||||
brush_radius_percentile: 0.93,
|
|
||||||
step_size_factor: 0.40,
|
|
||||||
n_directions: 48,
|
|
||||||
lookahead_steps: 3,
|
|
||||||
momentum_weight: 0.20,
|
|
||||||
overpaint_penalty: 0.10,
|
|
||||||
walk_bg_penalty: 0.69,
|
|
||||||
min_score_factor: 0.20,
|
|
||||||
back_dir_cutoff: -0.7,
|
|
||||||
min_component_factor: 1.49,
|
|
||||||
max_steps_per_stroke: 4000,
|
|
||||||
max_strokes: 12,
|
|
||||||
output_rdp_eps: 1.98,
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_PARAMS) {
|
|
||||||
return tracedInvoke('get_paint_debug', { passIdx, hullIdx, params })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run coordinate-descent optimization on the current hull's paint params.
|
|
||||||
// While it runs the backend emits `optimizer-progress` events; subscribe
|
|
||||||
// via `import { listen } from '@tauri-apps/api/event'` then
|
|
||||||
// `listen('optimizer-progress', e => …)`. Resolves with the final best
|
|
||||||
// PaintParams.
|
|
||||||
export async function optimizePaintParams(passIdx, hullIdx, base = DEFAULT_PAINT_PARAMS) {
|
|
||||||
return tracedInvoke('optimize_paint_params', { passIdx, hullIdx, base })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllStrokes() {
|
export async function getAllStrokes() {
|
||||||
return tracedInvoke('get_all_strokes', {})
|
return tracedInvoke('get_all_strokes', {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────
|
// ── Circle packing ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Chamfer 3-4 distance transform: cheaper than full Euclidean, but the
|
|
||||||
/// 3:4 weights closely approximate (1:√2), so contours are near-circular
|
|
||||||
/// instead of L-shaped. Returns scaled distances (units of 1/3 pixel).
|
|
||||||
pub(crate) fn chamfer_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
|
|
||||||
if hull.pixels.is_empty() { return HashMap::new(); }
|
|
||||||
let inf = i32::MAX / 4;
|
|
||||||
let mut bx = u32::MAX;
|
|
||||||
let mut by = u32::MAX;
|
|
||||||
let mut bx_max = 0u32;
|
|
||||||
let mut by_max = 0u32;
|
|
||||||
for &(x, y) in &hull.pixels {
|
|
||||||
bx = bx.min(x); by = by.min(y);
|
|
||||||
bx_max = bx_max.max(x); by_max = by_max.max(y);
|
|
||||||
}
|
|
||||||
let w = (bx_max - bx + 1) as usize;
|
|
||||||
let h = (by_max - by + 1) as usize;
|
|
||||||
let mut grid: Vec<i32> = vec![inf; w * h];
|
|
||||||
let idx = |x: u32, y: u32| -> usize { ((y - by) as usize) * w + (x - bx) as usize };
|
|
||||||
|
|
||||||
// Boundary pixels: any pixel whose 4-neighbor is outside the hull.
|
|
||||||
for &(x, y) in &hull.pixels {
|
|
||||||
let on_boundary = !pixel_set.contains(&(x.wrapping_sub(1), y))
|
|
||||||
|| !pixel_set.contains(&(x + 1, y))
|
|
||||||
|| !pixel_set.contains(&(x, y.wrapping_sub(1)))
|
|
||||||
|| !pixel_set.contains(&(x, y + 1));
|
|
||||||
if on_boundary { grid[idx(x, y)] = 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward pass: top-left → bottom-right, examines NW/N/NE/W neighbors.
|
|
||||||
for j in 0..h {
|
|
||||||
for i in 0..w {
|
|
||||||
let here = j * w + i;
|
|
||||||
if grid[here] == 0 { continue; }
|
|
||||||
let mut best = grid[here];
|
|
||||||
if j > 0 {
|
|
||||||
if i > 0 { best = best.min(grid[(j-1)*w + i-1].saturating_add(4)); }
|
|
||||||
best = best.min(grid[(j-1)*w + i ].saturating_add(3));
|
|
||||||
if i+1 < w { best = best.min(grid[(j-1)*w + i+1].saturating_add(4)); }
|
|
||||||
}
|
|
||||||
if i > 0 { best = best.min(grid[j*w + i-1].saturating_add(3)); }
|
|
||||||
grid[here] = best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Backward pass: bottom-right → top-left, examines SE/S/SW/E neighbors.
|
|
||||||
for j in (0..h).rev() {
|
|
||||||
for i in (0..w).rev() {
|
|
||||||
let here = j * w + i;
|
|
||||||
if grid[here] == 0 { continue; }
|
|
||||||
let mut best = grid[here];
|
|
||||||
if j+1 < h {
|
|
||||||
if i+1 < w { best = best.min(grid[(j+1)*w + i+1].saturating_add(4)); }
|
|
||||||
best = best.min(grid[(j+1)*w + i ].saturating_add(3));
|
|
||||||
if i > 0 { best = best.min(grid[(j+1)*w + i-1].saturating_add(4)); }
|
|
||||||
}
|
|
||||||
if i+1 < w { best = best.min(grid[j*w + i+1].saturating_add(3)); }
|
|
||||||
grid[here] = best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
|
|
||||||
for &(x, y) in &hull.pixels {
|
|
||||||
let v = grid[idx(x, y)];
|
|
||||||
// Convert chamfer units (3 per orthogonal step) to ~Euclidean pixels.
|
|
||||||
dist.insert((x, y), if v >= inf { 0.0 } else { v as f32 / 3.0 });
|
|
||||||
}
|
|
||||||
dist
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BFS distance transform from the hull boundary (Manhattan approximation).
|
/// BFS distance transform from the hull boundary (Manhattan approximation).
|
||||||
fn boundary_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
|
fn boundary_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
|
||||||
let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
|
let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
|
||||||
@@ -832,478 +764,6 @@ pub fn hilbert_fill(hull: &Hull, spacing_px: f32) -> FillResult {
|
|||||||
FillResult { hull_id: hull.id, strokes }
|
FillResult { hull_id: hull.id, strokes }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Skeleton (medial axis) fill ─────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Designed for text-shaped hulls: extract each glyph's centerline as one or
|
|
||||||
// more polylines so the pen draws each stroke once instead of hatching/
|
|
||||||
// outlining it. Two-stage pipeline:
|
|
||||||
// 1. Zhang-Suen iterative thinning erodes the hull boundary symmetrically
|
|
||||||
// until only a 1-pixel-wide skeleton remains. Topology is preserved
|
|
||||||
// (no holes are punched, no strokes are severed).
|
|
||||||
// 2. Graph traversal — endpoints (1 neighbor) and junctions (≥3
|
|
||||||
// neighbors) split the skeleton into "branches"; we walk each branch
|
|
||||||
// end-to-end. Paired junction continuations could be a follow-up to
|
|
||||||
// reduce pen lifts at crossings; this version stops at every junction.
|
|
||||||
//
|
|
||||||
// The output is jaggy at 1-px resolution; the caller's RDP+Chaikin smoothing
|
|
||||||
// (`smooth_fill_result`) cleans it up. `_spacing_px` is unused but matches
|
|
||||||
// the dispatch signature shared by all fill strategies.
|
|
||||||
|
|
||||||
/// Distance-transform medial axis fill — robust raster→centerline conversion.
|
|
||||||
///
|
|
||||||
/// Unlike `skeleton_fill` (which uses Zhang-Suen morphological thinning),
|
|
||||||
/// this computes the medial axis from the boundary-distance field. A pixel
|
|
||||||
/// is part of the medial axis if its distance to the boundary is a local
|
|
||||||
/// maximum along at least one axis — exactly the points equidistant from
|
|
||||||
/// at least two boundary points.
|
|
||||||
///
|
|
||||||
/// The key advantage for text: thin tails (e.g. the descender on 'a' or
|
|
||||||
/// '9') survive because their centerlines are local distance maxima, even
|
|
||||||
/// when very short. ZS thinning, by contrast, erodes such features into
|
|
||||||
/// "Y" spurs that subsequently get pruned away.
|
|
||||||
///
|
|
||||||
/// Pipeline:
|
|
||||||
/// 1. Distance transform via `boundary_distance`.
|
|
||||||
/// 2. Local-maximum extraction along the four axes (E-W, N-S, NE-SW, NW-SE).
|
|
||||||
/// 3. Light ZS thinning to collapse plateaus to 1-px width. We don't
|
|
||||||
/// apply spur pruning here — every ridge branch is meaningful.
|
|
||||||
/// 4. Same junction-cluster pairing + walk machinery as `skeleton_fill`.
|
|
||||||
pub fn centerline_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
|
|
||||||
if hull.pixels.is_empty() {
|
|
||||||
return FillResult { hull_id: hull.id, strokes: vec![] };
|
|
||||||
}
|
|
||||||
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
|
|
||||||
let dist = chamfer_distance(hull, &pixel_set);
|
|
||||||
|
|
||||||
// Extract ridge pixels: local max along at least one of 4 axes.
|
|
||||||
// Pixels touching the boundary (distance ~0) are excluded.
|
|
||||||
let mut ridge: HashSet<(u32, u32)> = HashSet::new();
|
|
||||||
let eps = 1e-3;
|
|
||||||
for &p in &pixel_set {
|
|
||||||
let dp = match dist.get(&p) { Some(&d) => d, None => continue };
|
|
||||||
if dp < 1.0 { continue; } // skip boundary-adjacent pixels
|
|
||||||
|
|
||||||
let axes: [((u32, u32), (u32, u32)); 4] = [
|
|
||||||
((p.0 + 1, p.1), (p.0.wrapping_sub(1), p.1)),
|
|
||||||
((p.0, p.1 + 1), (p.0, p.1.wrapping_sub(1))),
|
|
||||||
((p.0 + 1, p.1 + 1), (p.0.wrapping_sub(1), p.1.wrapping_sub(1))),
|
|
||||||
((p.0 + 1, p.1.wrapping_sub(1)), (p.0.wrapping_sub(1), p.1 + 1)),
|
|
||||||
];
|
|
||||||
for (n1, n2) in axes {
|
|
||||||
let d1 = dist.get(&n1).copied().unwrap_or(0.0);
|
|
||||||
let d2 = dist.get(&n2).copied().unwrap_or(0.0);
|
|
||||||
if dp + eps >= d1 && dp + eps >= d2 {
|
|
||||||
ridge.insert(p);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plateaus can produce thicker-than-1-pixel ridges. Thin to ensure a
|
|
||||||
// clean 1-px graph for the walk.
|
|
||||||
let ridge_vec: Vec<(u32, u32)> = ridge.into_iter().collect();
|
|
||||||
let mut thinned = zhang_suen_thin(&ridge_vec);
|
|
||||||
// Junction artifacts are short (1-3 px); real tails (a, 9, etc.) are
|
|
||||||
// much longer. Prune the artifacts so X-style intersections collapse
|
|
||||||
// to clean crossings instead of fragmenting into many short stubs.
|
|
||||||
prune_skeleton_spurs(&mut thinned, /* max_spur_len */ 4);
|
|
||||||
|
|
||||||
let pixel_strokes = extract_skeleton_strokes(&thinned);
|
|
||||||
// Medial-axis paths are sequences of integer pixel coords that
|
|
||||||
// stair-step along diagonals — inherent to the algorithm, not the
|
|
||||||
// user's problem. Pre-smooth here so the output is a clean curve
|
|
||||||
// before the fill node's user-controlled RDP/Chaikin runs on top.
|
|
||||||
let strokes: Vec<Vec<(f32, f32)>> = pixel_strokes.into_iter()
|
|
||||||
.filter(|s| s.len() >= 3)
|
|
||||||
.map(|s| {
|
|
||||||
let f: Vec<(f32, f32)> = s.into_iter().map(|(x, y)| (x as f32, y as f32)).collect();
|
|
||||||
smooth_stroke(&f, /* rdp_eps */ 1.0, /* chaikin_iters */ 3)
|
|
||||||
})
|
|
||||||
.filter(|s| s.len() >= 2)
|
|
||||||
.collect();
|
|
||||||
FillResult { hull_id: hull.id, strokes }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn skeleton_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
|
|
||||||
if hull.pixels.is_empty() {
|
|
||||||
return FillResult { hull_id: hull.id, strokes: vec![] };
|
|
||||||
}
|
|
||||||
let mut skeleton = zhang_suen_thin(&hull.pixels);
|
|
||||||
// ZS leaves 1-2 pixel "Y" artifacts at every corner of a thick stroke.
|
|
||||||
// Pruning them removes the false junctions so the walk only stops at
|
|
||||||
// real glyph crossings.
|
|
||||||
prune_skeleton_spurs(&mut skeleton, /* max_spur_len */ 3);
|
|
||||||
let pixel_strokes = extract_skeleton_strokes(&skeleton);
|
|
||||||
// Filter sub-pixel junction artifacts. A 2-point stroke is a single
|
|
||||||
// pixel step (~0.17mm at 150 dpi); never a real glyph stroke and
|
|
||||||
// always either a cluster-exit fragment or a tiny dangling spur.
|
|
||||||
let strokes: Vec<Vec<(f32, f32)>> = pixel_strokes.into_iter()
|
|
||||||
.filter(|s| s.len() >= 3)
|
|
||||||
.map(|s| s.into_iter().map(|(x, y)| (x as f32, y as f32)).collect())
|
|
||||||
.collect();
|
|
||||||
FillResult { hull_id: hull.id, strokes }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Iteratively remove dead-end branches up to `max_spur_len` pixels long.
|
|
||||||
/// Pruning a spur can turn its parent junction into an endpoint, exposing
|
|
||||||
/// further removable spurs — so we loop until no further removals.
|
|
||||||
pub(crate) fn prune_skeleton_spurs(skeleton: &mut HashSet<(u32, u32)>, max_spur_len: usize) {
|
|
||||||
fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
|
|
||||||
zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
|
|
||||||
}
|
|
||||||
loop {
|
|
||||||
let endpoints: Vec<(u32, u32)> = skeleton.iter().copied()
|
|
||||||
.filter(|p| nbrs_in(*p, skeleton).len() == 1)
|
|
||||||
.collect();
|
|
||||||
let mut to_remove: HashSet<(u32, u32)> = HashSet::new();
|
|
||||||
for ep in endpoints {
|
|
||||||
let mut path = vec![ep];
|
|
||||||
let mut prev: Option<(u32, u32)> = None;
|
|
||||||
let mut cur = ep;
|
|
||||||
for _ in 0..=max_spur_len {
|
|
||||||
let nbrs = nbrs_in(cur, skeleton);
|
|
||||||
if nbrs.len() >= 3 {
|
|
||||||
// Hit a junction within the spur budget — drop the path
|
|
||||||
// (excluding the junction pixel).
|
|
||||||
if path.len() <= max_spur_len {
|
|
||||||
for &p in &path { to_remove.insert(p); }
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if nbrs.is_empty() { break; }
|
|
||||||
let next = nbrs.into_iter().find(|n| Some(*n) != prev);
|
|
||||||
match next {
|
|
||||||
Some(n) => { prev = Some(cur); cur = n; path.push(cur); }
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if to_remove.is_empty() { break; }
|
|
||||||
for p in to_remove { skeleton.remove(&p); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zhang-Suen 8-neighbor positions in clockwise order starting from north:
|
|
||||||
/// index 0..7 == P2, P3, P4, P5, P6, P7, P8, P9.
|
|
||||||
/// Underflow on the edges is fine — those positions just won't be in the set.
|
|
||||||
pub(crate) fn zs_neighbors(x: u32, y: u32) -> [(u32, u32); 8] {
|
|
||||||
[
|
|
||||||
(x, y.wrapping_sub(1)),
|
|
||||||
(x + 1, y.wrapping_sub(1)),
|
|
||||||
(x + 1, y),
|
|
||||||
(x + 1, y + 1),
|
|
||||||
(x, y + 1),
|
|
||||||
(x.wrapping_sub(1), y + 1),
|
|
||||||
(x.wrapping_sub(1), y),
|
|
||||||
(x.wrapping_sub(1), y.wrapping_sub(1)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run Zhang-Suen thinning until idempotent. Two sub-iterations per round
|
|
||||||
/// with mirrored conditions keep erosion symmetric.
|
|
||||||
pub(crate) fn zhang_suen_thin(pixels: &[(u32, u32)]) -> HashSet<(u32, u32)> {
|
|
||||||
let mut current: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
|
||||||
loop {
|
|
||||||
let to_remove1 = zs_mark(¤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 ──────────────────────────────────────────────────────────
|
// ── Wave interference ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Concentric ring systems from `num_sources` hull interior points.
|
/// Concentric ring systems from `num_sources` hull interior points.
|
||||||
@@ -1960,406 +1420,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── skeleton_fill tests ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Build a horizontal bar `width` × `thickness` pixels at (x0, y0).
|
|
||||||
fn make_bar_hull(x0: u32, y0: u32, width: u32, thickness: u32) -> Hull {
|
|
||||||
let mut pixels = Vec::with_capacity((width * thickness) as usize);
|
|
||||||
for y in 0..thickness { for x in 0..width {
|
|
||||||
pixels.push((x0 + x, y0 + y));
|
|
||||||
}}
|
|
||||||
Hull {
|
|
||||||
id: 0,
|
|
||||||
pixels,
|
|
||||||
contour: vec![], simplified: vec![],
|
|
||||||
area: width * thickness, avg_luminance: 0.0, avg_color: [0, 0, 0],
|
|
||||||
bounds: crate::hulls::Bounds {
|
|
||||||
x_min: x0, y_min: y0,
|
|
||||||
x_max: x0 + width - 1, y_max: y0 + thickness - 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an L-shape: a vertical bar with a horizontal bar at its bottom.
|
|
||||||
fn make_l_hull(x0: u32, y0: u32, height: u32, width: u32, thick: u32) -> Hull {
|
|
||||||
let mut pixels = Vec::new();
|
|
||||||
// Vertical part
|
|
||||||
for y in 0..height { for x in 0..thick {
|
|
||||||
pixels.push((x0 + x, y0 + y));
|
|
||||||
}}
|
|
||||||
// Horizontal part (bottom), skipping the overlap with the vertical
|
|
||||||
for y in 0..thick { for x in thick..width {
|
|
||||||
pixels.push((x0 + x, y0 + height - thick + y));
|
|
||||||
}}
|
|
||||||
let area = pixels.len() as u32;
|
|
||||||
Hull {
|
|
||||||
id: 0,
|
|
||||||
pixels,
|
|
||||||
contour: vec![], simplified: vec![],
|
|
||||||
area, avg_luminance: 0.0, avg_color: [0, 0, 0],
|
|
||||||
bounds: crate::hulls::Bounds {
|
|
||||||
x_min: x0, y_min: y0,
|
|
||||||
x_max: x0 + width - 1, y_max: y0 + height - 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skeleton_empty_hull_returns_no_strokes() {
|
|
||||||
let hull = Hull {
|
|
||||||
id: 0, pixels: vec![], contour: vec![], simplified: vec![],
|
|
||||||
area: 0, avg_luminance: 0.0, avg_color: [0, 0, 0],
|
|
||||||
bounds: crate::hulls::Bounds { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
|
|
||||||
};
|
|
||||||
let r = skeleton_fill(&hull, 1.0);
|
|
||||||
assert!(r.strokes.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skeleton_of_thick_bar_is_a_horizontal_polyline() {
|
|
||||||
// A 60-px-wide × 9-px-thick horizontal bar should skeletonise to one
|
|
||||||
// mostly-horizontal stroke roughly through its center line.
|
|
||||||
let hull = make_bar_hull(0, 0, 60, 9);
|
|
||||||
let r = skeleton_fill(&hull, 1.0);
|
|
||||||
assert_eq!(r.strokes.len(), 1, "expected exactly 1 stroke, got {}", r.strokes.len());
|
|
||||||
let s = &r.strokes[0];
|
|
||||||
|
|
||||||
// Span should cover most of the bar's length (allow some boundary erosion).
|
|
||||||
let xs: Vec<f32> = s.iter().map(|&(x, _)| x).collect();
|
|
||||||
let span = xs.iter().cloned().fold(f32::MIN, f32::max)
|
|
||||||
- xs.iter().cloned().fold(f32::MAX, f32::min);
|
|
||||||
assert!(span >= 50.0, "skeleton span {span} too short for 60-px bar");
|
|
||||||
|
|
||||||
// Skeleton should sit near the bar's middle row (y ≈ 4 ± a bit).
|
|
||||||
for &(_, y) in s {
|
|
||||||
assert!(y >= 2.0 && y <= 6.0, "skeleton y={y} outside expected band 2..=6");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skeleton_of_thick_l_has_a_junction_or_two_branches() {
|
|
||||||
// Thick L → skeleton has a corner. Stop-at-junction policy means we
|
|
||||||
// expect either one stroke that bends through the corner (when no
|
|
||||||
// junction forms — possible for sharp L's) or two strokes meeting.
|
|
||||||
let hull = make_l_hull(0, 0, /*h*/ 40, /*w*/ 40, /*t*/ 7);
|
|
||||||
let r = skeleton_fill(&hull, 1.0);
|
|
||||||
assert!(!r.strokes.is_empty(), "L-shape produced no skeleton strokes");
|
|
||||||
// Any skeleton output points must lie inside the original hull.
|
|
||||||
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
|
|
||||||
for stroke in &r.strokes {
|
|
||||||
for &(sx, sy) in stroke {
|
|
||||||
let inside = (-1i32..=1).any(|dy| (-1i32..=1).any(|dx| {
|
|
||||||
let px = (sx.round() as i32 + dx).max(0) as u32;
|
|
||||||
let py = (sy.round() as i32 + dy).max(0) as u32;
|
|
||||||
pixel_set.contains(&(px, py))
|
|
||||||
}));
|
|
||||||
assert!(inside, "skeleton point ({sx},{sy}) outside hull");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skeleton_of_solid_square_terminates() {
|
|
||||||
// A solid blob skeletonises to a small connected fragment near the
|
|
||||||
// centre. Important: this must not loop forever or produce empty.
|
|
||||||
let hull = make_square_hull(0, 0, 30);
|
|
||||||
let r = skeleton_fill(&hull, 1.0);
|
|
||||||
// Either some strokes, or a single point (which is dropped). Either
|
|
||||||
// is acceptable — the contract is "doesn't hang".
|
|
||||||
for stroke in &r.strokes {
|
|
||||||
assert!(stroke.len() >= 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── skeleton_fill on real letter shapes ───────────────────────────────────
|
|
||||||
//
|
|
||||||
// Renders Hershey text → rasterises with a thick stroke → extracts hulls
|
|
||||||
// → runs the full skeleton pipeline. Pins expected stroke counts for
|
|
||||||
// simple glyphs the user would type into an envelope. Stroke counts here
|
|
||||||
// are the contract: a healthy skeleton fill draws "O" in ONE pen-down,
|
|
||||||
// "I" in ONE, "T" in two or three (depending on junction handling), etc.
|
|
||||||
//
|
|
||||||
// If the user's gcode is fragmenting every glyph into many 2-point
|
|
||||||
// segments, these tests will fail loudly with the actual number observed.
|
|
||||||
|
|
||||||
/// Rasterise `text` at `font_size_mm`/`dpi`/`thickness` and run hull
|
|
||||||
/// detection with the same params the front-end's default text mode uses.
|
|
||||||
fn rasterize_text_to_hulls(text: &str, font_size_mm: f32, dpi: u32, thickness_px: u32)
|
|
||||||
-> Vec<crate::hulls::Hull>
|
|
||||||
{
|
|
||||||
use crate::text::{TextBlockSpec, rasterize_blocks};
|
|
||||||
use crate::hulls::{extract_hulls, HullParams, Connectivity};
|
|
||||||
let block = TextBlockSpec {
|
|
||||||
text: text.to_string(),
|
|
||||||
font_size_mm, line_spacing_mm: None,
|
|
||||||
x_mm: 5.0, y_mm: 5.0,
|
|
||||||
};
|
|
||||||
let rgb = rasterize_blocks(&[block], 60.0, 30.0, dpi, thickness_px);
|
|
||||||
let (w, h) = rgb.dimensions();
|
|
||||||
let luma: Vec<u8> = rgb.pixels()
|
|
||||||
.map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
|
|
||||||
.collect();
|
|
||||||
let params = HullParams {
|
|
||||||
threshold: 253, min_area: 4, rdp_epsilon: 0.1,
|
|
||||||
connectivity: Connectivity::Four,
|
|
||||||
..HullParams::default()
|
|
||||||
};
|
|
||||||
extract_hulls(&luma, &rgb, w, h, ¶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 ────────────────────────────────────────────────────
|
// ── hilbert_fill tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
173
src/lib.rs
173
src/lib.rs
@@ -3,9 +3,6 @@ pub mod hulls;
|
|||||||
pub mod fill;
|
pub mod fill;
|
||||||
pub mod gcode;
|
pub mod gcode;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod topo_strokes;
|
|
||||||
pub mod brush_paint;
|
|
||||||
pub mod brush_paint_opt;
|
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -822,10 +819,6 @@ fn process_pass_work(
|
|||||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
||||||
"voronoi" => fill::voronoi_fill(hull, spacing),
|
"voronoi" => fill::voronoi_fill(hull, spacing),
|
||||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||||
"skeleton" => fill::skeleton_fill(hull, spacing),
|
|
||||||
"centerline" => fill::centerline_fill(hull, spacing),
|
|
||||||
"topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
|
|
||||||
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
|
|
||||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
||||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
||||||
@@ -854,10 +847,6 @@ fn process_pass_work(
|
|||||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
||||||
"voronoi" => fill::voronoi_fill(hull, spacing),
|
"voronoi" => fill::voronoi_fill(hull, spacing),
|
||||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||||
"skeleton" => fill::skeleton_fill(hull, spacing),
|
|
||||||
"centerline" => fill::centerline_fill(hull, spacing),
|
|
||||||
"topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
|
|
||||||
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
|
|
||||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
||||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
||||||
@@ -998,165 +987,6 @@ fn list_hulls(pass_idx: usize, state: State<Mutex<AppState>>) -> Result<Vec<Hull
|
|||||||
}).collect())
|
}).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the hulls in `pass_idx` with a freshly-rasterized test letter.
|
|
||||||
/// Lets the debug viewer load any character at any scale on demand without
|
|
||||||
/// having to push an image through the full pipeline. Returns the updated
|
|
||||||
/// hull list.
|
|
||||||
#[tauri::command]
|
|
||||||
fn load_test_letter(
|
|
||||||
pass_idx: usize,
|
|
||||||
ch: String,
|
|
||||||
font_mm: f32,
|
|
||||||
dpi: u32,
|
|
||||||
thickness_px: u32,
|
|
||||||
state: State<Mutex<AppState>>,
|
|
||||||
) -> Result<Vec<HullSummary>, String> {
|
|
||||||
let c = ch.chars().next().ok_or("empty character")?;
|
|
||||||
let hulls = brush_paint::rasterize_test_letter(c, font_mm, dpi, thickness_px);
|
|
||||||
let mut st = state.lock().unwrap();
|
|
||||||
if pass_idx >= st.passes.len() {
|
|
||||||
st.passes.resize_with(pass_idx + 1, PassState::default);
|
|
||||||
}
|
|
||||||
let ps = &mut st.passes[pass_idx];
|
|
||||||
ps.hulls = hulls;
|
|
||||||
Ok(ps.hulls.iter().enumerate().map(|(i, h)| HullSummary {
|
|
||||||
index: i,
|
|
||||||
area: h.area,
|
|
||||||
bounds: [h.bounds.x_min, h.bounds.y_min, h.bounds.x_max, h.bounds.y_max],
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_paint_debug(
|
|
||||||
pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams,
|
|
||||||
state: State<Mutex<AppState>>,
|
|
||||||
) -> Result<brush_paint::PaintDebug, String> {
|
|
||||||
let st = state.lock().unwrap();
|
|
||||||
let ps = st.passes.get(pass_idx)
|
|
||||||
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
|
|
||||||
let h = ps.hulls.get(hull_idx)
|
|
||||||
.ok_or_else(|| format!("hull {hull_idx} out of range (pass has {})", ps.hulls.len()))?;
|
|
||||||
Ok(brush_paint::paint_fill_debug(h, ¶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]
|
#[tauri::command]
|
||||||
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
|
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
|
||||||
let mut st = state.lock().unwrap();
|
let mut st = state.lock().unwrap();
|
||||||
@@ -2976,9 +2806,6 @@ pub fn run() {
|
|||||||
get_images_dir,
|
get_images_dir,
|
||||||
set_pass_count,
|
set_pass_count,
|
||||||
list_hulls,
|
list_hulls,
|
||||||
load_test_letter,
|
|
||||||
get_paint_debug,
|
|
||||||
optimize_paint_params,
|
|
||||||
process_pass,
|
process_pass,
|
||||||
get_all_strokes,
|
get_all_strokes,
|
||||||
get_gcode_viz,
|
get_gcode_viz,
|
||||||
|
|||||||
@@ -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