feat: rewrite as Tauri/React image→G-code pipeline
Replace old egui/wgpu/Vulkan implementation with a Tauri v2 app. Backend (Rust): - detect.rs: detection kernel stack (Luminance, Sobel, ColorGradient, Laplacian, Canny, Saturation, XDoG) with weighted layer blending - hulls.rs: flood-fill connected components + Moore boundary trace + RDP - fill.rs: hatch, zigzag, spiral, offset, outline, circles, voronoi, hilbert, waves, flow fill strategies with Chaikin smoothing - gcode.rs: pixel→mm conversion, paper presets, pen up/down G-code - lib.rs: Tauri commands (process_pass, generate_fill, export_gcode) - render.rs: wgpu texture wrapper for egui display Frontend (React + Vite + Tailwind): - Multi-pass panel with detection layers, hull params, fill strategy - WYSIWYG viewport with zoom/pan and overlay rendering - Color filter, connectivity, RDP controls - G-code export with paper size presets and feed rate config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
56
Cargo.toml
@@ -1,33 +1,43 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sfml-rust"
|
name = "trac3r"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
authors = ["MitchellHansen <mitchellhansen0@gmail.com>"]
|
authors = ["MitchellHansen <mitchellhansen0@gmail.com>"]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
|
default-run = "trac3r"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "trac3r_lib"
|
||||||
|
crate-type = ["lib", "cdylib", "staticlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
rayon = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
vulkano = "0.19.0"
|
[profile.dev]
|
||||||
#vulkano = {path = "../vulkano/vulkano"}
|
opt-level = 2
|
||||||
|
|
||||||
vulkano-shaders = "0.19.0"
|
[dev-dependencies]
|
||||||
#vulkano-shaders = {path = "../vulkano/vulkano-shaders"}
|
tokio = { version = "1", features = ["rt", "macros", "time"] }
|
||||||
|
|
||||||
vulkano-win = "0.19.0"
|
[build-dependencies]
|
||||||
#vulkano-win= {path = "../vulkano/vulkano-win"}
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
#shade_runner = {version = "0.1.1", git = "https://github.com/MitchellHansen/shade_runner"}
|
[[bin]]
|
||||||
shade_runner = {path = "../shade_runner"}
|
name = "trac3r"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
gilrs = "0.7.2"
|
[[bin]]
|
||||||
cgmath = "0.17.0"
|
name = "gen_test_assets"
|
||||||
simple-stopwatch="0.1.4"
|
path = "src/gen_test_assets.rs"
|
||||||
nalgebra = "0.18.0"
|
|
||||||
image = "0.21.2"
|
[[bin]]
|
||||||
rand = "0.6.5"
|
name = "pipeline_bench"
|
||||||
time = "0.1.38"
|
path = "src/pipeline_bench.rs"
|
||||||
shaderc = "0.6.1"
|
|
||||||
winit = "0.22.0"
|
|
||||||
#criterion = "0.3.0"
|
|
||||||
hprof = "0.1.3"
|
|
||||||
rusttype = { version = "0.7.0", features = ["gpu_cache"] }
|
|
||||||
vulkano_text = "0.12.0"
|
|
||||||
|
|||||||
21
capabilities/default.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2/capability.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"dialog:allow-save",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-read-text-file",
|
||||||
|
"fs:allow-write-text-file",
|
||||||
|
"fs:allow-read-dir",
|
||||||
|
"fs:allow-exists",
|
||||||
|
"fs:scope-home-recursive",
|
||||||
|
"fs:scope-document-recursive",
|
||||||
|
"fs:scope-desktop-recursive",
|
||||||
|
"fs:scope-download-recursive"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
gen/schemas/acl-manifests.json
Normal file
1
gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability","local":true,"windows":["main"],"permissions":["core:default","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-dir","fs:allow-exists","fs:scope-home-recursive","fs:scope-document-recursive","fs:scope-desktop-recursive","fs:scope-download-recursive"]}}
|
||||||
5886
gen/schemas/desktop-schema.json
Normal file
5886
gen/schemas/linux-schema.json
Normal file
BIN
icons/icon.png
Normal file
|
After Width: | Height: | Size: 299 B |
BIN
resources/images/test_checker_512.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
resources/images/test_circle_512.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
resources/images/test_circle_outline_512.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
resources/images/test_concentric_rings_300.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
resources/images/test_gradient_512.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
resources/images/test_hline_512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
resources/images/test_square_512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
resources/images/test_three_lines_512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
resources/images/test_uniform_512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
18
run-dev.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Starts Vite dev server and runs the compiled Trac3r binary.
|
||||||
|
# Re-run this script to pick up Rust backend changes.
|
||||||
|
# Frontend changes: edit code, click ↺ in the app (no restart needed).
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Start Vite if it isn't already running
|
||||||
|
if ! lsof -ti:1420 > /dev/null 2>&1; then
|
||||||
|
echo "Starting Vite dev server..."
|
||||||
|
npm --prefix src-frontend run dev &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and run
|
||||||
|
echo "Building..."
|
||||||
|
cargo build 2>&1 | tail -3
|
||||||
|
echo "Launching..."
|
||||||
|
exec ./target/debug/trac3r
|
||||||
24
src-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
src-frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
21
src-frontend/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
src-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trac3r</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2785
src-frontend/package-lock.json
generated
Normal file
31
src-frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "src-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src-frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
src-frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
503
src-frontend/src/App.jsx
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import Viewport from './components/Viewport.jsx'
|
||||||
|
import PassPanel from './components/PassPanel.jsx'
|
||||||
|
import PerfPanel from './components/PerfPanel.jsx'
|
||||||
|
import Slider from './components/Slider.jsx'
|
||||||
|
import { defaultPass, defaultGcodeConfig, PAPER_SIZES } from './store.js'
|
||||||
|
import * as tauri from './hooks/useTauri.js'
|
||||||
|
import { useFps } from './hooks/useFps.js'
|
||||||
|
|
||||||
|
const VIEW_MODES = ['source', 'detection', 'hulls', 'contours', 'fill', 'gcode']
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [image, setImage] = useState(null)
|
||||||
|
const [passes, setPasses] = useState([defaultPass(0)])
|
||||||
|
const [activePass, setActivePass] = useState(0)
|
||||||
|
const [gcodeConfig, setGcodeConfig] = useState(defaultGcodeConfig())
|
||||||
|
const [viewMode, setViewMode] = useState('source')
|
||||||
|
const [displayB64, setDisplayB64] = useState(null) // current image shown in viewport
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [globalStatus, setGlobalStatus] = useState('Open an image to start')
|
||||||
|
const [strokes, setStrokes] = useState(null)
|
||||||
|
const [showPerf, setShowPerf] = useState(false)
|
||||||
|
const [perfData, setPerfData] = useState(null)
|
||||||
|
const [longTasks, setLongTasks] = useState([])
|
||||||
|
const fps = useFps()
|
||||||
|
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(320)
|
||||||
|
const resizing = useRef(false)
|
||||||
|
|
||||||
|
// Long-task observer — fires whenever the JS main thread blocks > 50ms
|
||||||
|
useEffect(() => {
|
||||||
|
const obs = new PerformanceObserver(list => {
|
||||||
|
const entries = list.getEntries().map(e => ({
|
||||||
|
ms: Math.round(e.duration),
|
||||||
|
at: Math.round(e.startTime),
|
||||||
|
}))
|
||||||
|
entries.forEach(e => console.warn(`[LongTask] ${e.ms}ms @ t+${e.at}ms`))
|
||||||
|
setLongTasks(prev => [...prev.slice(-14), ...entries])
|
||||||
|
})
|
||||||
|
try { obs.observe({ entryTypes: ['longtask'] }) } catch {}
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startResize(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
resizing.current = true
|
||||||
|
function onMove(e) {
|
||||||
|
if (!resizing.current) return
|
||||||
|
setSidebarWidth(w => Math.max(240, Math.min(560, e.clientX)))
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
resizing.current = false
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always-fresh ref so debounced callbacks never close over stale passes
|
||||||
|
const passesRef = useRef(passes)
|
||||||
|
const imageRef = useRef(image)
|
||||||
|
const activePasRef = useRef(activePass)
|
||||||
|
passesRef.current = passes
|
||||||
|
imageRef.current = image
|
||||||
|
activePasRef.current = activePass
|
||||||
|
|
||||||
|
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
|
||||||
|
const debounceTimers = useRef({})
|
||||||
|
|
||||||
|
function updatePass(i, patch) {
|
||||||
|
setPasses(ps => ps.map((p, idx) => idx === i ? { ...p, ...patch } : p))
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStrokeCount = passes.reduce((sum, p) => sum + (p.strokeCount || 0), 0)
|
||||||
|
|
||||||
|
// ── Refresh viewport whenever view mode or active pass changes ─────────────
|
||||||
|
useEffect(() => {
|
||||||
|
async function refresh() {
|
||||||
|
if (!image) { setDisplayB64(null); return }
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'source':
|
||||||
|
setDisplayB64(image.preview_b64)
|
||||||
|
break
|
||||||
|
case 'detection':
|
||||||
|
setDisplayB64(passes[activePass]?.vizB64 ?? null)
|
||||||
|
break
|
||||||
|
case 'hulls':
|
||||||
|
case 'contours':
|
||||||
|
// Don't race getPassViz against generateFill — both need the AppState mutex.
|
||||||
|
// filling=true means fill hasn't finished yet; the effect will re-run when it does.
|
||||||
|
if (passes[activePass]?.filling) { setDisplayB64(null); break }
|
||||||
|
if (passes[activePass]?.hullCount > 0) {
|
||||||
|
try {
|
||||||
|
const tv = performance.now()
|
||||||
|
const b64 = await tauri.getPassViz(activePass, viewMode)
|
||||||
|
setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) }))
|
||||||
|
setDisplayB64(b64)
|
||||||
|
} catch (e) {
|
||||||
|
setGlobalStatus(`Viz error: ${e}`)
|
||||||
|
setDisplayB64(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDisplayB64(null)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'fill':
|
||||||
|
// Fill uses the same full-res canvas rendering as gcode (via strokes prop).
|
||||||
|
setDisplayB64(null)
|
||||||
|
break
|
||||||
|
case 'gcode':
|
||||||
|
if (passes.some(p => p.strokeCount > 0)) {
|
||||||
|
try {
|
||||||
|
const tv = performance.now()
|
||||||
|
const b64 = await tauri.getGcodeViz(passes.map(p => p.penColor))
|
||||||
|
setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) }))
|
||||||
|
setDisplayB64(b64)
|
||||||
|
} catch (e) {
|
||||||
|
setGlobalStatus(`Viz error: ${e}`)
|
||||||
|
setDisplayB64(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDisplayB64(null)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}, [viewMode, activePass, image, passes[activePass]?.vizB64, passes[activePass]?.hullCount, passes[activePass]?.filling, totalStrokeCount])
|
||||||
|
|
||||||
|
// ── File open ──────────────────────────────────────────────────────────────
|
||||||
|
async function openImage() {
|
||||||
|
const path = await tauri.pickImageFile()
|
||||||
|
if (!path) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const info = await tauri.loadImage(path)
|
||||||
|
setImage(info)
|
||||||
|
imageRef.current = info // processPass checks this ref before React re-renders
|
||||||
|
setDisplayB64(info.preview_b64)
|
||||||
|
setViewMode('source')
|
||||||
|
setStrokes(null)
|
||||||
|
setGlobalStatus(`${info.width} × ${info.height}px`)
|
||||||
|
for (let i = 0; i < passesRef.current.length; i++) processPass(i, true)
|
||||||
|
} catch (e) {
|
||||||
|
setGlobalStatus(`Error loading image: ${e}`)
|
||||||
|
}
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Process a pass ─────────────────────────────────────────────────────────
|
||||||
|
// silent=true: auto-reprocess from slider change — doesn't block UI with global busy
|
||||||
|
const processPass = useCallback(async (idx, silent = false) => {
|
||||||
|
if (!imageRef.current) return
|
||||||
|
if (!silent) setBusy(true)
|
||||||
|
const pass = passesRef.current[idx]
|
||||||
|
// Reset strokeCount NOW so the gcode viz useEffect sees 0 strokes and
|
||||||
|
// doesn't fire getGcodeViz while generateFill is about to start.
|
||||||
|
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
|
||||||
|
const t0 = performance.now()
|
||||||
|
try {
|
||||||
|
const result = await tauri.processPass({
|
||||||
|
pass_index: idx,
|
||||||
|
layers: pass.layers,
|
||||||
|
threshold: pass.threshold,
|
||||||
|
min_area: pass.min_area,
|
||||||
|
rdp_epsilon: pass.rdp_epsilon,
|
||||||
|
connectivity: pass.connectivity,
|
||||||
|
color_filter: pass.colorFilter,
|
||||||
|
})
|
||||||
|
const js_process = Math.round(performance.now() - t0)
|
||||||
|
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
||||||
|
updatePass(idx, {
|
||||||
|
status: `${result.hull_count} hulls · ${result.coverage_pct}% coverage`,
|
||||||
|
vizB64: result.viz_b64,
|
||||||
|
hullCount: result.hull_count,
|
||||||
|
strokeCount: 0,
|
||||||
|
})
|
||||||
|
await generateFillInner(idx, true)
|
||||||
|
} catch (e) {
|
||||||
|
updatePass(idx, { status: `Error: ${e}` })
|
||||||
|
setGlobalStatus(`Process error: ${e}`)
|
||||||
|
}
|
||||||
|
if (!silent) setBusy(false)
|
||||||
|
}, []) // stable — uses refs
|
||||||
|
|
||||||
|
// Inner fill logic shared by both manual and auto paths
|
||||||
|
const generateFillInner = useCallback(async (idx, silent = false) => {
|
||||||
|
if (!silent) setBusy(true)
|
||||||
|
const pass = passesRef.current[idx]
|
||||||
|
// Set filling=true BEFORE the first await so React batches it with any
|
||||||
|
// concurrent hullCount update, preventing the viz useEffect from racing
|
||||||
|
// generateFill for the same AppState mutex.
|
||||||
|
updatePass(idx, { filling: true, status: 'Generating fill…' })
|
||||||
|
const t1 = performance.now()
|
||||||
|
try {
|
||||||
|
const result = await tauri.generateFill({
|
||||||
|
pass_index: idx,
|
||||||
|
strategy: pass.strategy,
|
||||||
|
spacing: pass.spacing,
|
||||||
|
angle: pass.angle,
|
||||||
|
param: pass.param ?? 1.0,
|
||||||
|
smooth_rdp: pass.smoothRdp,
|
||||||
|
smooth_iters: pass.smoothIters,
|
||||||
|
})
|
||||||
|
const js_fill = Math.round(performance.now() - t1)
|
||||||
|
setPerfData(pd => ({ ...(pd ?? {}), fill: result.timings, js_fill }))
|
||||||
|
updatePass(idx, {
|
||||||
|
filling: false,
|
||||||
|
status: `${result.stroke_count} strokes`,
|
||||||
|
strokeCount: result.stroke_count,
|
||||||
|
})
|
||||||
|
// Fetch full stroke data for canvas rendering (no subsampling — WYSIWYG)
|
||||||
|
const colors = passesRef.current.map(p => p.penColor)
|
||||||
|
tauri.getAllStrokes(colors).then(s => setStrokes(s)).catch(() => {})
|
||||||
|
} catch (e) {
|
||||||
|
updatePass(idx, { filling: false, status: `Error: ${e}` })
|
||||||
|
setGlobalStatus(`Fill error: ${e}`)
|
||||||
|
}
|
||||||
|
if (!silent) setBusy(false)
|
||||||
|
}, []) // stable — uses refs
|
||||||
|
|
||||||
|
const generateFill = useCallback((idx) => generateFillInner(idx, false), [generateFillInner])
|
||||||
|
|
||||||
|
// ── Debounced auto-reprocess triggered by slider changes ───────────────────
|
||||||
|
const scheduleProcess = useCallback((idx) => {
|
||||||
|
const key = `${idx}-detect`
|
||||||
|
clearTimeout(debounceTimers.current[key])
|
||||||
|
debounceTimers.current[key] = setTimeout(() => processPass(idx, true), 400)
|
||||||
|
}, [processPass])
|
||||||
|
|
||||||
|
const scheduleFill = useCallback((idx) => {
|
||||||
|
const key = `${idx}-fill`
|
||||||
|
clearTimeout(debounceTimers.current[key])
|
||||||
|
debounceTimers.current[key] = setTimeout(() => {
|
||||||
|
if ((passesRef.current[idx]?.hullCount ?? 0) > 0) {
|
||||||
|
generateFillInner(idx, true)
|
||||||
|
}
|
||||||
|
}, 400)
|
||||||
|
}, [generateFillInner])
|
||||||
|
|
||||||
|
// ── Export ─────────────────────────────────────────────────────────────────
|
||||||
|
async function exportActivePass() {
|
||||||
|
const pass = passes[activePass]
|
||||||
|
const fname = `pass${activePass + 1}_${pass.label.replace(/\s+/g, '_')}.gcode`
|
||||||
|
const path = await tauri.pickSaveFile(fname)
|
||||||
|
if (!path) return
|
||||||
|
try {
|
||||||
|
await tauri.exportGcode(activePass, gcodeConfig)
|
||||||
|
setGlobalStatus(`Saved ${path}`)
|
||||||
|
} catch (e) {
|
||||||
|
setGlobalStatus(`Export error: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAll() {
|
||||||
|
const dir = await tauri.pickFolder()
|
||||||
|
if (!dir) return
|
||||||
|
try {
|
||||||
|
const saved = await tauri.exportAllGcode(passes.map(p => p.penColor), gcodeConfig, dir)
|
||||||
|
setGlobalStatus(`Saved ${saved.length} file(s) to ${dir}`)
|
||||||
|
} catch (e) {
|
||||||
|
setGlobalStatus(`Export error: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) }
|
||||||
|
|
||||||
|
async function dumpDebugState() {
|
||||||
|
try {
|
||||||
|
const configs = passes.map(p => ({
|
||||||
|
layers: p.layers, threshold: p.threshold, min_area: p.min_area,
|
||||||
|
rdp_epsilon: p.rdp_epsilon, connectivity: p.connectivity,
|
||||||
|
color_filter: p.colorFilter, strategy: p.strategy,
|
||||||
|
spacing: p.spacing, angle: p.angle,
|
||||||
|
smooth_rdp: p.smoothRdp, smooth_iters: p.smoothIters,
|
||||||
|
}))
|
||||||
|
const path = await tauri.exportDebugState(configs)
|
||||||
|
setGlobalStatus(`Debug dump → ${path}`)
|
||||||
|
} catch (e) {
|
||||||
|
setGlobalStatus(`Dump error: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-[#0f0f0f] text-neutral-200 overflow-hidden">
|
||||||
|
|
||||||
|
{/* ── Left sidebar ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="shrink-0 flex flex-col border-r border-neutral-800 overflow-hidden" style={{ width: sidebarWidth }}>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
|
<span className="font-bold text-sm text-indigo-400 tracking-tight">Trac3r</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={dumpDebugState}
|
||||||
|
className="px-2 py-1 rounded bg-amber-800 hover:bg-amber-700 text-amber-200 text-xs transition-colors"
|
||||||
|
title="Export debug state to trac3r_debug.json next to the image">
|
||||||
|
Dump
|
||||||
|
</button>
|
||||||
|
<button onClick={openImage} disabled={busy}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs transition-colors disabled:opacity-40">
|
||||||
|
Open…
|
||||||
|
</button>
|
||||||
|
<button onClick={() => window.location.reload()}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-500 hover:text-neutral-300 text-xs transition-colors"
|
||||||
|
title="Reload UI (pick up code changes)">
|
||||||
|
↺
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image info */}
|
||||||
|
{image && (
|
||||||
|
<div className="px-3 py-1.5 border-b border-neutral-800 text-neutral-500 text-xs truncate">
|
||||||
|
{image.path.split('/').pop()} · {image.width}×{image.height}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pass tabs */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-neutral-800 bg-neutral-950/50">
|
||||||
|
{passes.map((p, i) => {
|
||||||
|
const color = `rgb(${p.penColor.join(',')})`
|
||||||
|
return (
|
||||||
|
<button key={i}
|
||||||
|
onClick={() => setActivePass(i)}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
|
||||||
|
i === activePass
|
||||||
|
? 'bg-neutral-700 text-neutral-100'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{passes.length < 3 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const idx = passes.length
|
||||||
|
setPasses(ps => [...ps, defaultPass(idx)])
|
||||||
|
setActivePass(idx)
|
||||||
|
}}
|
||||||
|
className="ml-auto px-1.5 py-0.5 text-neutral-600 hover:text-indigo-400 text-base transition-colors leading-none"
|
||||||
|
>+</button>
|
||||||
|
)}
|
||||||
|
{passes.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPasses(ps => ps.filter((_, i) => i !== activePass))
|
||||||
|
setActivePass(ap => Math.max(0, ap - 1))
|
||||||
|
}}
|
||||||
|
className="px-1.5 py-0.5 text-neutral-700 hover:text-red-400 text-xs transition-colors"
|
||||||
|
>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active pass config — scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<PassPanel
|
||||||
|
pass={passes[activePass]}
|
||||||
|
onChange={p => updatePass(activePass, p)}
|
||||||
|
onDetectionChange={() => scheduleProcess(activePass)}
|
||||||
|
onFillChange={() => scheduleFill(activePass)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Resize handle ────────────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
onMouseDown={startResize}
|
||||||
|
className="w-1 shrink-0 cursor-col-resize bg-neutral-800 hover:bg-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Main area ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
{/* Top bar — accent colors match the section dots in the left panel */}
|
||||||
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
|
{VIEW_MODES.map(m => {
|
||||||
|
const accent = { detection: '#6366f1', hulls: '#14b8a6', contours: '#14b8a6', fill: '#a855f7', gcode: '#f59e0b' }[m]
|
||||||
|
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
|
||||||
|
return (
|
||||||
|
<button key={m}
|
||||||
|
onClick={() => setViewMode(m)}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors ${
|
||||||
|
viewMode === m ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{accent && <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: accent }} />}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-neutral-600 text-xs">{globalStatus}</span>
|
||||||
|
{busy && <span className="text-indigo-400 text-xs animate-pulse">Working…</span>}
|
||||||
|
<span className={`text-xs font-mono tabular-nums ${
|
||||||
|
fps >= 30 ? 'text-emerald-600' : fps >= 10 ? 'text-amber-500' : 'text-red-500'
|
||||||
|
}`} title="JS frame rate — drops to 0 if main thread is blocked">{fps} fps</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPerf(p => !p)}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${showPerf ? 'bg-neutral-700 text-neutral-200' : 'text-neutral-600 hover:text-neutral-400'}`}
|
||||||
|
title="Toggle performance panel"
|
||||||
|
>⏱</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viewport */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<Viewport
|
||||||
|
imageB64={displayB64}
|
||||||
|
strokes={viewMode === 'gcode' || viewMode === 'fill' ? strokes : null}
|
||||||
|
imgSize={image}
|
||||||
|
viewMode={viewMode}
|
||||||
|
gcodeConfig={gcodeConfig}
|
||||||
|
/>
|
||||||
|
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}
|
||||||
|
{!image && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-neutral-600 text-lg">No image loaded</p>
|
||||||
|
<p className="text-neutral-700 text-sm">Click Open… to get started</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="border-t border-neutral-800 bg-neutral-900/80 px-4 py-2">
|
||||||
|
<div className="flex items-start gap-6 flex-wrap">
|
||||||
|
|
||||||
|
{/* Paper */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Paper</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{PAPER_SIZES.map(ps => {
|
||||||
|
const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
|
||||||
|
const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
|
||||||
|
return (
|
||||||
|
<button key={ps.name}
|
||||||
|
onClick={() => {
|
||||||
|
// Keep current orientation when switching size
|
||||||
|
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
|
||||||
|
setGcode(portrait
|
||||||
|
? { paper_w_mm: ps.w, paper_h_mm: ps.h }
|
||||||
|
: { paper_w_mm: ps.h, paper_h_mm: ps.w })
|
||||||
|
}}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
isPortrait || isLandscape
|
||||||
|
? 'bg-indigo-700 text-white' : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>{ps.name}</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Portrait / Landscape toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setGcode({ paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm })}
|
||||||
|
title={gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'Switch to landscape' : 'Switch to portrait'}
|
||||||
|
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
|
||||||
|
>Rotate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placement */}
|
||||||
|
<div className="space-y-0.5 flex-1 min-w-48">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Placement</p>
|
||||||
|
<Slider label="Width mm" value={gcodeConfig.img_w_mm} min={10} max={2000} step={1}
|
||||||
|
onChange={v => setGcode({ img_w_mm: v })} />
|
||||||
|
<Slider label="Offset X" value={gcodeConfig.offset_x_mm} min={-500} max={500} step={1} unit="mm"
|
||||||
|
onChange={v => setGcode({ offset_x_mm: v })} />
|
||||||
|
<Slider label="Offset Y" value={gcodeConfig.offset_y_mm} min={-500} max={500} step={1} unit="mm"
|
||||||
|
onChange={v => setGcode({ offset_y_mm: v })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plotter */}
|
||||||
|
<div className="space-y-0.5 flex-1 min-w-40">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Plotter</p>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-amber-500" />
|
||||||
|
</div>
|
||||||
|
<Slider label="Pen speed" value={gcodeConfig.feed_draw} min={100} max={5000} step={100}
|
||||||
|
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
|
||||||
|
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
||||||
|
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div className="flex flex-col gap-1.5 justify-end">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Export</p>
|
||||||
|
<button onClick={exportActivePass} disabled={passes[activePass]?.strokeCount === 0}
|
||||||
|
className="px-3 py-1.5 rounded bg-neutral-700 hover:bg-neutral-600 text-xs disabled:opacity-40 transition-colors">
|
||||||
|
This pass
|
||||||
|
</button>
|
||||||
|
<button onClick={exportAll} disabled={!passes.some(p => p.strokeCount > 0)}
|
||||||
|
className="px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
|
||||||
|
All passes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
src-frontend/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
src-frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src-frontend/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
36
src-frontend/src/components/ColorFilter.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Slider from './Slider.jsx'
|
||||||
|
|
||||||
|
export default function ColorFilter({ filter, onChange }) {
|
||||||
|
function set(patch) { onChange({ ...filter, ...patch }) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer mb-2">
|
||||||
|
<input type="checkbox" checked={filter.enabled}
|
||||||
|
onChange={e => set({ enabled: e.target.checked })}
|
||||||
|
className="accent-indigo-500" />
|
||||||
|
<span className="text-neutral-300 text-xs">Enable color filter</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{filter.enabled && (
|
||||||
|
<div className="space-y-1 pl-1 border-l-2 border-neutral-800">
|
||||||
|
<div className="text-neutral-500 text-xs mb-1">Hue (0–360°)</div>
|
||||||
|
<Slider label="Min" value={filter.hue_min} min={0} max={360} step={1} unit="°"
|
||||||
|
onChange={v => set({ hue_min: v })} />
|
||||||
|
<Slider label="Max" value={filter.hue_max} min={0} max={360} step={1} unit="°"
|
||||||
|
onChange={v => set({ hue_max: v })} />
|
||||||
|
<div className="text-neutral-500 text-xs mt-2 mb-1">Saturation</div>
|
||||||
|
<Slider label="Min" value={filter.sat_min} min={0} max={1} step={0.01}
|
||||||
|
onChange={v => set({ sat_min: v })} />
|
||||||
|
<Slider label="Max" value={filter.sat_max} min={0} max={1} step={0.01}
|
||||||
|
onChange={v => set({ sat_max: v })} />
|
||||||
|
<div className="text-neutral-500 text-xs mt-2 mb-1">Value (brightness)</div>
|
||||||
|
<Slider label="Min" value={filter.val_min} min={0} max={1} step={0.01}
|
||||||
|
onChange={v => set({ val_min: v })} />
|
||||||
|
<Slider label="Max" value={filter.val_max} min={0} max={1} step={0.01}
|
||||||
|
onChange={v => set({ val_max: v })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src-frontend/src/components/DetectionLayers.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { KERNELS, defaultLayer } from '../store.js'
|
||||||
|
import Slider from './Slider.jsx'
|
||||||
|
|
||||||
|
const KERNEL_PARAMS = {
|
||||||
|
Luminance: ['blur_radius'],
|
||||||
|
Sobel: ['blur_radius'],
|
||||||
|
ColorGradient: ['blur_radius'],
|
||||||
|
Laplacian: ['blur_radius'],
|
||||||
|
Saturation: ['blur_radius', 'sat_min_value'],
|
||||||
|
Canny: ['canny_low', 'canny_high'],
|
||||||
|
XDoG: ['blur_radius', 'xdog_sigma2', 'xdog_tau', 'xdog_phi'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAM_META = {
|
||||||
|
blur_radius: { label: 'Blur σ', min: 0, max: 10, step: 0.1 },
|
||||||
|
sat_min_value: { label: 'Min val', min: 0, max: 1, step: 0.01 },
|
||||||
|
canny_low: { label: 'Low thr', min: 1, max: 254, step: 1 },
|
||||||
|
canny_high: { label: 'High thr', min: 1, max: 254, step: 1 },
|
||||||
|
xdog_sigma2: { label: 'σ2', min: 0.2, max: 10, step: 0.1 },
|
||||||
|
xdog_tau: { label: 'τ', min: 0, max: 1, step: 0.01 },
|
||||||
|
xdog_phi: { label: 'φ', min: 1, max: 100, step: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetectionLayers({ layers, onChange }) {
|
||||||
|
function setLayer(i, patch) {
|
||||||
|
const next = layers.map((l, idx) => idx === i ? { ...l, ...patch } : l)
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
function removeLayer(i) {
|
||||||
|
onChange(layers.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
function addLayer() {
|
||||||
|
onChange([...layers, defaultLayer()])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{layers.map((layer, i) => (
|
||||||
|
<div key={i} className="bg-neutral-900 rounded-lg border border-neutral-800 p-2 space-y-1.5">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-neutral-500 text-xs">Layer {i + 1}</span>
|
||||||
|
{layers.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeLayer(i)}
|
||||||
|
className="text-neutral-600 hover:text-red-400 text-xs px-1 transition-colors"
|
||||||
|
>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kernel selector */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{KERNELS.map(k => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setLayer(i, { kernel: k })}
|
||||||
|
className={`px-1.5 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
layer.kernel === k
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>{k}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight */}
|
||||||
|
<Slider label="Weight" value={layer.weight} min={0} max={2} step={0.05}
|
||||||
|
onChange={v => setLayer(i, { weight: v })} />
|
||||||
|
|
||||||
|
{/* Per-kernel params */}
|
||||||
|
{(KERNEL_PARAMS[layer.kernel] ?? []).map(p => {
|
||||||
|
const m = PARAM_META[p]
|
||||||
|
return (
|
||||||
|
<Slider key={p} label={m.label} value={layer[p]} min={m.min} max={m.max} step={m.step}
|
||||||
|
onChange={v => setLayer(i, { [p]: v })} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Invert */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={layer.invert}
|
||||||
|
onChange={e => setLayer(i, { invert: e.target.checked })}
|
||||||
|
className="accent-indigo-500" />
|
||||||
|
<span className="text-neutral-400 text-xs">Invert</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addLayer}
|
||||||
|
disabled={layers.length >= 4}
|
||||||
|
className="w-full py-1 rounded border border-dashed border-neutral-700 text-neutral-500 text-xs hover:border-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-30"
|
||||||
|
>+ Add layer</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src-frontend/src/components/PassPanel.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Section from './Section.jsx'
|
||||||
|
import Slider from './Slider.jsx'
|
||||||
|
import DetectionLayers from './DetectionLayers.jsx'
|
||||||
|
import ColorFilter from './ColorFilter.jsx'
|
||||||
|
import { FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE } from '../store.js'
|
||||||
|
|
||||||
|
// Colors that match the view-mode tab dots in the top bar
|
||||||
|
const C = {
|
||||||
|
detection: '#6366f1', // indigo
|
||||||
|
hulls: '#14b8a6', // teal
|
||||||
|
fill: '#a855f7', // purple
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PassPanel({
|
||||||
|
pass, onChange,
|
||||||
|
onDetectionChange,
|
||||||
|
onFillChange,
|
||||||
|
}) {
|
||||||
|
function set(patch) { onChange({ ...pass, ...patch }) }
|
||||||
|
function setDetection(patch) { onChange({ ...pass, ...patch }); onDetectionChange?.() }
|
||||||
|
function setFill(patch) { onChange({ ...pass, ...patch }); onFillChange?.() }
|
||||||
|
|
||||||
|
const colorHex = '#' + pass.penColor.map(c => c.toString(16).padStart(2, '0')).join('')
|
||||||
|
const isProcessing = pass.status === 'Processing…'
|
||||||
|
const isFilling = pass.status === 'Generating fill…'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Pass header */}
|
||||||
|
<div className="px-3 py-2 flex items-center gap-2 border-b border-neutral-800">
|
||||||
|
<input type="color" value={colorHex}
|
||||||
|
onChange={e => {
|
||||||
|
const h = e.target.value.slice(1)
|
||||||
|
set({ penColor: [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)] })
|
||||||
|
}}
|
||||||
|
className="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<input type="text" value={pass.label}
|
||||||
|
onChange={e => set({ label: e.target.value })}
|
||||||
|
className="flex-1 bg-transparent text-neutral-200 text-sm font-medium outline-none border-b border-transparent focus:border-neutral-600"
|
||||||
|
/>
|
||||||
|
{(isProcessing || isFilling) && (
|
||||||
|
<span className="text-indigo-400 text-xs animate-pulse shrink-0">
|
||||||
|
{isProcessing ? 'detecting…' : 'filling…'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Detection ── kernels that produce the response map */}
|
||||||
|
<Section title="Detection" defaultOpen accent={C.detection}>
|
||||||
|
<DetectionLayers
|
||||||
|
layers={pass.layers}
|
||||||
|
onChange={layers => setDetection({ layers })}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Hulls & Contours ── how the response map becomes components */}
|
||||||
|
<Section title="Hulls & Contours" defaultOpen accent={C.hulls}>
|
||||||
|
<Slider label="Threshold" value={pass.threshold} min={1} max={254} step={1}
|
||||||
|
onChange={v => setDetection({ threshold: v })} />
|
||||||
|
<Slider label="Min area" value={pass.min_area} min={1} max={5000} step={1}
|
||||||
|
onChange={v => setDetection({ min_area: v })} />
|
||||||
|
<Slider label="RDP ε" value={pass.rdp_epsilon} min={0.1} max={10} step={0.1}
|
||||||
|
onChange={v => setDetection({ rdp_epsilon: v })} />
|
||||||
|
<div className="flex items-center gap-2 py-0.5">
|
||||||
|
<span className="text-neutral-400 text-xs w-20 shrink-0">Connectivity</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['four','eight'].map(c => (
|
||||||
|
<button key={c}
|
||||||
|
onClick={() => setDetection({ connectivity: c })}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
pass.connectivity === c
|
||||||
|
? 'bg-teal-700 text-white'
|
||||||
|
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>{c}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 pt-2 border-t border-neutral-800">
|
||||||
|
<ColorFilter filter={pass.colorFilter} onChange={cf => setDetection({ colorFilter: cf })} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Fill ── how hulls become strokes */}
|
||||||
|
<Section title="Fill" defaultOpen accent={C.fill}>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{FILL_STRATEGIES.map(s => (
|
||||||
|
<button key={s}
|
||||||
|
onClick={() => setFill({ strategy: s })}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
pass.strategy === s
|
||||||
|
? 'bg-purple-700 text-white'
|
||||||
|
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Slider label="Spacing" value={pass.spacing} min={1} max={50} step={0.5} unit="px"
|
||||||
|
onChange={v => setFill({ spacing: v })} />
|
||||||
|
{FILL_USES_ANGLE.has(pass.strategy) && (
|
||||||
|
<Slider label="Angle" value={pass.angle} min={0} max={360} step={1} unit="°"
|
||||||
|
onChange={v => setFill({ angle: v })} />
|
||||||
|
)}
|
||||||
|
{FILL_STRATEGY_PARAMS[pass.strategy] && (() => {
|
||||||
|
const p = FILL_STRATEGY_PARAMS[pass.strategy]
|
||||||
|
return (
|
||||||
|
<Slider label={p.label} value={pass.param ?? p.default}
|
||||||
|
min={p.min} max={p.max} step={p.step}
|
||||||
|
onChange={v => setFill({ param: v })} />
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div className="mt-2 pt-2 border-t border-neutral-800 space-y-1">
|
||||||
|
<Slider label="Smooth RDP" value={pass.smoothRdp} min={0} max={5} step={0.1}
|
||||||
|
onChange={v => setFill({ smoothRdp: v })} />
|
||||||
|
<Slider label="Chaikin" value={pass.smoothIters} min={0} max={4} step={1}
|
||||||
|
onChange={v => setFill({ smoothIters: v })} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="px-3 py-2 min-h-8">
|
||||||
|
<p className={`text-xs ${pass.status?.startsWith('Error') ? 'text-red-400' : 'text-neutral-500'}`}>
|
||||||
|
{pass.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
src-frontend/src/components/PerfPanel.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
function copyData(data, longTasks) {
|
||||||
|
const lines = []
|
||||||
|
const all = [
|
||||||
|
...(data?.process ?? []),
|
||||||
|
data?.js_process != null && { label: 'process IPC', ms: data.js_process },
|
||||||
|
...(data?.fill ?? []),
|
||||||
|
data?.js_fill != null && { label: 'fill IPC', ms: data.js_fill },
|
||||||
|
data?.canvas_draw != null && { label: 'canvas draw', ms: data.canvas_draw },
|
||||||
|
data?.js_viz != null && { label: 'viz IPC', ms: data.js_viz },
|
||||||
|
].filter(Boolean)
|
||||||
|
all.forEach(s => lines.push(`${s.label}: ${s.ms}ms`))
|
||||||
|
if (longTasks?.length) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('Long tasks (JS thread blocked):')
|
||||||
|
longTasks.forEach(t => lines.push(` ${t.ms}ms @ t+${t.at}ms`))
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(lines.join('\n')).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PerfPanel({ data, fps, longTasks = [] }) {
|
||||||
|
if (!data && !longTasks.length) return null
|
||||||
|
|
||||||
|
const allSteps = [
|
||||||
|
...(data?.process ?? []),
|
||||||
|
data?.js_process != null && { label: 'js→process (IPC)', ms: data.js_process },
|
||||||
|
...(data?.fill ?? []),
|
||||||
|
data?.js_fill != null && { label: 'js→fill (IPC)', ms: data.js_fill },
|
||||||
|
data?.canvas_draw != null && { label: 'canvas draw', ms: data.canvas_draw },
|
||||||
|
data?.js_viz != null && { label: 'js→viz (IPC)', ms: data.js_viz },
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
const maxMs = Math.max(1, ...allSteps.map(s => s.ms), ...longTasks.map(t => t.ms))
|
||||||
|
|
||||||
|
function barWidth(ms) { return Math.round((ms / maxMs) * 100) }
|
||||||
|
|
||||||
|
function rowColor(ms) {
|
||||||
|
if (ms > 200) return 'text-red-400'
|
||||||
|
if (ms > 50) return 'text-amber-400'
|
||||||
|
return 'text-neutral-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
{ label: 'process_pass', steps: data?.process ?? [] },
|
||||||
|
{ label: 'generate_fill', steps: data?.fill ?? [] },
|
||||||
|
{ label: 'JS / IPC', steps: [
|
||||||
|
data?.js_process != null && { label: 'process (roundtrip)', ms: data.js_process },
|
||||||
|
data?.js_fill != null && { label: 'fill (roundtrip)', ms: data.js_fill },
|
||||||
|
data?.js_viz != null && { label: 'viz (roundtrip)', ms: data.js_viz },
|
||||||
|
].filter(Boolean) },
|
||||||
|
{ label: 'Render', steps: [
|
||||||
|
data?.canvas_draw != null && { label: 'canvas draw', ms: data.canvas_draw },
|
||||||
|
].filter(Boolean) },
|
||||||
|
].filter(g => g.steps.length > 0)
|
||||||
|
|
||||||
|
const fpsColor = fps == null ? 'text-neutral-500'
|
||||||
|
: fps >= 30 ? 'text-emerald-400'
|
||||||
|
: fps >= 10 ? 'text-amber-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-3 right-3 z-50 bg-neutral-950/95 border border-neutral-700 rounded-lg shadow-xl w-80 text-xs font-mono overflow-hidden">
|
||||||
|
<div className="px-3 py-1.5 border-b border-neutral-800 text-neutral-300 font-semibold tracking-wide flex items-center gap-2">
|
||||||
|
<span>⏱ Performance</span>
|
||||||
|
<span className="text-neutral-600 font-normal">last run</span>
|
||||||
|
{fps != null && (
|
||||||
|
<span className={`ml-1 tabular-nums ${fpsColor}`} title="JS frame rate">
|
||||||
|
{fps} fps
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => copyData(data, longTasks)}
|
||||||
|
className="ml-auto text-neutral-600 hover:text-neutral-300 text-[10px] transition-colors px-1">
|
||||||
|
copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!data?.process || data.process.length === 0) && (
|
||||||
|
<div className="px-3 py-1.5 text-amber-500 text-[10px]">
|
||||||
|
⚠ Rust timings missing — run ./run-dev.sh to rebuild backend
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing bars */}
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{groups.map(group => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="text-neutral-600 uppercase tracking-wider text-[10px] mb-1 px-1">{group.label}</div>
|
||||||
|
{group.steps.map((step, i) => (
|
||||||
|
<div key={i} className={`flex items-center gap-2 px-1 py-0.5 ${rowColor(step.ms)}`}>
|
||||||
|
<span className="w-36 shrink-0 truncate">{step.label}</span>
|
||||||
|
<div className="flex-1 bg-neutral-800 rounded-full h-1 overflow-hidden">
|
||||||
|
<div className="h-full bg-indigo-500 rounded-full" style={{ width: `${barWidth(step.ms)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right shrink-0">{step.ms}ms</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Long task section — the ground truth for JS main-thread blocking */}
|
||||||
|
{longTasks.length > 0 && (
|
||||||
|
<div className="border-t border-neutral-800 p-2 space-y-1">
|
||||||
|
<div className="text-neutral-600 uppercase tracking-wider text-[10px] mb-1 px-1 flex items-center gap-1.5">
|
||||||
|
<span className="text-red-500">■</span> JS main-thread blocks
|
||||||
|
<button
|
||||||
|
onClick={() => {}}
|
||||||
|
className="ml-auto text-neutral-700 hover:text-neutral-400 text-[10px]"
|
||||||
|
title="Long tasks = JS thread was blocked this long. 0 means async wait, not a block."
|
||||||
|
>?</button>
|
||||||
|
</div>
|
||||||
|
{longTasks.map((t, i) => (
|
||||||
|
<div key={i} className={`flex items-center gap-2 px-1 py-0.5 ${rowColor(t.ms)}`}>
|
||||||
|
<span className="w-36 shrink-0 text-neutral-500">t+{(t.at / 1000).toFixed(2)}s</span>
|
||||||
|
<div className="flex-1 bg-neutral-800 rounded-full h-1 overflow-hidden">
|
||||||
|
<div className="h-full bg-red-600 rounded-full" style={{ width: `${barWidth(t.ms)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right shrink-0">{t.ms}ms</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="px-1 pt-0.5 text-[10px] text-neutral-700">
|
||||||
|
Tasks >50ms where UI was unresponsive. Check console for [LongTask] logs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src-frontend/src/components/Section.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// accent: hex color string shown as a dot before the title, tying this section to a viz tab
|
||||||
|
export default function Section({ title, children, defaultOpen = true, accent }) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
return (
|
||||||
|
<div className="border-b border-neutral-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold tracking-wider uppercase text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||||
|
>
|
||||||
|
{accent && (
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: accent }} />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-left">{title}</span>
|
||||||
|
<span className="text-neutral-600">{open ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{open && <div className="px-3 pb-3 space-y-1">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src-frontend/src/components/Slider.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export default function Slider({ label, value, min, max, step = 0.01, onChange, unit = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-0.5 min-w-0">
|
||||||
|
<span className="text-neutral-400 text-xs w-20 shrink-0 truncate">{label}</span>
|
||||||
|
<input
|
||||||
|
type="range" min={min} max={max} step={step} value={value}
|
||||||
|
onChange={e => onChange(Number(e.target.value))}
|
||||||
|
className="flex-1 min-w-0 h-1 accent-indigo-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-300 text-xs w-14 shrink-0 text-right tabular-nums">
|
||||||
|
{typeof value === 'number' ? (step < 1 ? value.toFixed(2) : Math.round(value)) : value}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
src-frontend/src/components/Viewport.jsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
// Strokes per requestAnimationFrame chunk.
|
||||||
|
// Each chunk draws this many strokes then yields back to the browser.
|
||||||
|
const CHUNK_SIZE = 300
|
||||||
|
|
||||||
|
export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeConfig }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const svgImgRef = useRef(null)
|
||||||
|
const stateRef = useRef({ zoom: 1, pan: { x: 0, y: 0 }, dragging: false, last: null })
|
||||||
|
|
||||||
|
// Offscreen canvas: strokes are drawn here incrementally; main canvas blits it
|
||||||
|
const offscreenRef = useRef(null)
|
||||||
|
// Chunked draw state — lives outside React state to avoid re-renders
|
||||||
|
const chunkRef = useRef({ flat: null, idx: 0, raf: null })
|
||||||
|
|
||||||
|
// ── Compute layout helpers ────────────────────────────────────────────────
|
||||||
|
function layout(canvas) {
|
||||||
|
const { zoom, pan } = stateRef.current
|
||||||
|
const W = canvas.width
|
||||||
|
const H = canvas.height
|
||||||
|
const iw = imgSize?.width ?? 512
|
||||||
|
const ih = imgSize?.height ?? 512
|
||||||
|
const fit = Math.min(W / iw, H / ih) * 0.92
|
||||||
|
const scale = fit * zoom
|
||||||
|
const ox = W / 2 - iw * scale / 2 + pan.x
|
||||||
|
const oy = H / 2 - ih * scale / 2 + pan.y
|
||||||
|
return { iw, ih, scale, ox, oy }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main canvas draw — just composites whatever is in the offscreen ───────
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const { iw, ih, scale, ox, oy } = layout(canvas)
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
const svgImg = svgImgRef.current
|
||||||
|
|
||||||
|
if (viewMode === 'fill') {
|
||||||
|
// Full-res canvas rendering — cream background so dark pen strokes are visible
|
||||||
|
if (svgImg) svgImg.style.display = 'none'
|
||||||
|
if (imgSize) {
|
||||||
|
ctx.fillStyle = '#f5f0e8'
|
||||||
|
ctx.fillRect(ox, oy, iw * scale, ih * scale)
|
||||||
|
}
|
||||||
|
const off = offscreenRef.current
|
||||||
|
if (off && imgSize) {
|
||||||
|
ctx.drawImage(off, ox, oy, iw * scale, ih * scale)
|
||||||
|
}
|
||||||
|
} else if (viewMode === 'gcode') {
|
||||||
|
if (svgImg) svgImg.style.display = 'none'
|
||||||
|
if (imgSize) {
|
||||||
|
ctx.fillStyle = '#f5f0e8'
|
||||||
|
ctx.fillRect(ox, oy, iw * scale, ih * scale)
|
||||||
|
}
|
||||||
|
const off = offscreenRef.current
|
||||||
|
if (off && imgSize) {
|
||||||
|
ctx.drawImage(off, ox, oy, iw * scale, ih * scale)
|
||||||
|
}
|
||||||
|
drawPaperOutline(ctx, iw, ih, scale, ox, oy)
|
||||||
|
} else {
|
||||||
|
// All raster views (detection=JPEG, hulls/contours=SVG) go through ctx.drawImage
|
||||||
|
// so that negative ox/oy when zoomed in are handled correctly by the canvas,
|
||||||
|
// not clipped by the parent's overflow:hidden.
|
||||||
|
if (svgImg) svgImg.style.display = 'none'
|
||||||
|
if (imageB64) {
|
||||||
|
const isSvg = imageB64.startsWith('PHN2') // base64('<sv') = 'PHN2'
|
||||||
|
const mime = isSvg ? 'image/svg+xml' : 'image/jpeg'
|
||||||
|
const src = `data:${mime};base64,${imageB64}`
|
||||||
|
// svgImg element is repurposed as a generic image cache to avoid re-decoding
|
||||||
|
// on every zoom/pan event.
|
||||||
|
if (svgImg) {
|
||||||
|
if (svgImg.dataset.loadedSrc !== src) {
|
||||||
|
svgImg.onload = () => draw()
|
||||||
|
svgImg.src = src
|
||||||
|
svgImg.dataset.loadedSrc = src
|
||||||
|
}
|
||||||
|
if (svgImg.complete && svgImg.naturalWidth > 0) {
|
||||||
|
ctx.drawImage(svgImg, ox, oy, iw * scale, ih * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (imgSize) {
|
||||||
|
ctx.fillStyle = '#1a1a1a'
|
||||||
|
ctx.fillRect(ox, oy, iw * scale, ih * scale)
|
||||||
|
}
|
||||||
|
drawPaperOutline(ctx, iw, ih, scale, ox, oy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPaperOutline(ctx, iw, ih, scale, ox, oy) {
|
||||||
|
if (!gcodeConfig || !imgSize) return
|
||||||
|
const imgWmm = gcodeConfig.img_w_mm
|
||||||
|
if (!imgWmm || imgWmm <= 0) return
|
||||||
|
const spm = (iw * scale) / imgWmm
|
||||||
|
const px = ox - gcodeConfig.offset_x_mm * spm
|
||||||
|
const py = oy - gcodeConfig.offset_y_mm * spm
|
||||||
|
const pw = gcodeConfig.paper_w_mm * spm
|
||||||
|
const ph = gcodeConfig.paper_h_mm * spm
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = 'rgba(255, 220, 50, 0.8)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 4])
|
||||||
|
ctx.strokeRect(px, py, pw, ph)
|
||||||
|
ctx.setLineDash([])
|
||||||
|
ctx.fillStyle = 'rgba(255, 220, 50, 0.7)'
|
||||||
|
ctx.font = `${Math.max(10, spm * 5)}px sans-serif`
|
||||||
|
ctx.fillText(`${gcodeConfig.paper_w_mm}×${gcodeConfig.paper_h_mm}mm`, px + 4, py - 4)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}, [imageB64, strokes, imgSize, viewMode, gcodeConfig])
|
||||||
|
|
||||||
|
// ── Chunked offscreen stroke rendering ────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
// Cancel any in-progress chunk render
|
||||||
|
if (chunkRef.current.raf) {
|
||||||
|
cancelAnimationFrame(chunkRef.current.raf)
|
||||||
|
chunkRef.current.raf = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!strokes || !imgSize || (viewMode !== 'gcode' && viewMode !== 'fill')) {
|
||||||
|
offscreenRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten passes → [{color, points}] for linear chunk iteration
|
||||||
|
const flat = strokes.passes.flatMap(p =>
|
||||||
|
p.strokes.map(s => ({ color: p.color, points: s }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create offscreen canvas at 4× image resolution so zooming in stays sharp.
|
||||||
|
// octx.scale(4,4) keeps all stroke coordinates in image-pixel space unchanged.
|
||||||
|
const off = document.createElement('canvas')
|
||||||
|
off.width = imgSize.width * 4
|
||||||
|
off.height = imgSize.height * 4
|
||||||
|
const octx = off.getContext('2d')
|
||||||
|
octx.fillStyle = '#f5f0e8'
|
||||||
|
octx.fillRect(0, 0, off.width, off.height)
|
||||||
|
octx.scale(4, 4)
|
||||||
|
offscreenRef.current = off
|
||||||
|
|
||||||
|
chunkRef.current = { flat, idx: 0, raf: null }
|
||||||
|
|
||||||
|
function drawChunk() {
|
||||||
|
const state = chunkRef.current
|
||||||
|
const { flat, idx } = state
|
||||||
|
if (idx >= flat.length) { state.raf = null; return }
|
||||||
|
|
||||||
|
const end = Math.min(idx + CHUNK_SIZE, flat.length)
|
||||||
|
|
||||||
|
// Batch consecutive same-color strokes into one path per color run
|
||||||
|
let i = idx
|
||||||
|
while (i < end) {
|
||||||
|
const [r, g, b] = flat[i].color
|
||||||
|
octx.strokeStyle = `rgb(${r},${g},${b})`
|
||||||
|
octx.lineWidth = 1.5
|
||||||
|
octx.lineCap = 'round'
|
||||||
|
octx.beginPath()
|
||||||
|
let j = i
|
||||||
|
while (j < end &&
|
||||||
|
flat[j].color[0] === r &&
|
||||||
|
flat[j].color[1] === g &&
|
||||||
|
flat[j].color[2] === b) {
|
||||||
|
const pts = flat[j].points
|
||||||
|
if (pts.length >= 2) {
|
||||||
|
octx.moveTo(pts[0][0], pts[0][1])
|
||||||
|
for (let k = 1; k < pts.length; k++) {
|
||||||
|
octx.lineTo(pts[k][0], pts[k][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
octx.stroke()
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
|
||||||
|
state.idx = end
|
||||||
|
draw() // refresh main canvas with current offscreen state
|
||||||
|
|
||||||
|
if (end < flat.length) {
|
||||||
|
state.raf = requestAnimationFrame(drawChunk)
|
||||||
|
} else {
|
||||||
|
state.raf = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkRef.current.raf = requestAnimationFrame(drawChunk)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (chunkRef.current.raf) {
|
||||||
|
cancelAnimationFrame(chunkRef.current.raf)
|
||||||
|
chunkRef.current.raf = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [strokes, imgSize, viewMode, draw])
|
||||||
|
|
||||||
|
useEffect(() => { draw() }, [draw])
|
||||||
|
|
||||||
|
// ── Zoom to cursor ────────────────────────────────────────────────────────
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const factor = e.deltaY < 0 ? 1.1 : 0.9
|
||||||
|
const dx = e.clientX - rect.left - canvas.width / 2
|
||||||
|
const dy = e.clientY - rect.top - canvas.height / 2
|
||||||
|
const { zoom, pan } = stateRef.current
|
||||||
|
stateRef.current.zoom = Math.max(0.05, Math.min(50, zoom * factor))
|
||||||
|
stateRef.current.pan.x = pan.x * factor + dx * (1 - factor)
|
||||||
|
stateRef.current.pan.y = pan.y * factor + dy * (1 - factor)
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e) {
|
||||||
|
if (e.button === 0 || e.button === 1) {
|
||||||
|
stateRef.current.dragging = true
|
||||||
|
stateRef.current.last = { x: e.clientX, y: e.clientY }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onMouseMove(e) {
|
||||||
|
if (!stateRef.current.dragging) return
|
||||||
|
stateRef.current.pan.x += e.clientX - stateRef.current.last.x
|
||||||
|
stateRef.current.pan.y += e.clientY - stateRef.current.last.y
|
||||||
|
stateRef.current.last = { x: e.clientX, y: e.clientY }
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
function onMouseUp() { stateRef.current.dragging = false }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const rect = canvas.parentElement.getBoundingClientRect()
|
||||||
|
canvas.width = rect.width
|
||||||
|
canvas.height = rect.height
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
ro.observe(canvas.parentElement)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [draw])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
onWheel={onWheel}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onMouseLeave={onMouseUp}
|
||||||
|
style={{ cursor: 'crosshair' }}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
ref={svgImgRef}
|
||||||
|
className="absolute pointer-events-none"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src-frontend/src/hooks/useFps.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useFps() {
|
||||||
|
const [fps, setFps] = useState(60)
|
||||||
|
useEffect(() => {
|
||||||
|
let frames = 0
|
||||||
|
let lastT = performance.now()
|
||||||
|
let raf
|
||||||
|
function tick(now) {
|
||||||
|
frames++
|
||||||
|
if (now - lastT >= 500) {
|
||||||
|
setFps(Math.round(frames * 1000 / (now - lastT)))
|
||||||
|
frames = 0
|
||||||
|
lastT = now
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [])
|
||||||
|
return fps
|
||||||
|
}
|
||||||
70
src-frontend/src/hooks/useTauri.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
|
||||||
|
|
||||||
|
// Wraps invoke with response-size logging so IPC payload bloat is visible in console
|
||||||
|
async function tracedInvoke(name, args) {
|
||||||
|
const t0 = performance.now()
|
||||||
|
const result = await invoke(name, args)
|
||||||
|
const ms = Math.round(performance.now() - t0)
|
||||||
|
const bytes = typeof result === 'string'
|
||||||
|
? result.length
|
||||||
|
: JSON.stringify(result).length
|
||||||
|
console.debug(`[IPC] ${name}: ${ms}ms ${(bytes / 1024).toFixed(1)} KB`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadImage(path) {
|
||||||
|
return tracedInvoke('load_image', { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processPass(payload) {
|
||||||
|
return tracedInvoke('process_pass', { payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateFill(payload) {
|
||||||
|
return tracedInvoke('generate_fill', { payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllStrokes(passColors) {
|
||||||
|
return tracedInvoke('get_all_strokes', { passColors })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGcodeViz(passColors) {
|
||||||
|
return tracedInvoke('get_gcode_viz', { passColors })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPassViz(passIndex, mode) {
|
||||||
|
return tracedInvoke('get_pass_viz', { passIndex, mode })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportGcode(passIndex, gcodeConfig) {
|
||||||
|
return tracedInvoke('export_gcode', { passIndex, gcodeConfig })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAllGcode(passColors, gcodeConfig, outDir) {
|
||||||
|
return tracedInvoke('export_all_gcode', { passColors, gcodeConfig, outDir })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDebugState(passConfigs) {
|
||||||
|
return tracedInvoke('export_debug_state', { passConfigs })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickImageFile() {
|
||||||
|
const dir = await invoke('get_images_dir').catch(() => null)
|
||||||
|
return openDialog({
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: dir ?? undefined,
|
||||||
|
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg'] }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickSaveFile(defaultName) {
|
||||||
|
return saveDialog({
|
||||||
|
defaultPath: defaultName,
|
||||||
|
filters: [{ name: 'G-code', extensions: ['gcode', 'nc', 'txt'] }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickFolder() {
|
||||||
|
return openDialog({ directory: true })
|
||||||
|
}
|
||||||
16
src-frontend/src/index.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e2e2e2;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root { width: 100vw; height: 100vh; overflow: hidden; }
|
||||||
10
src-frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
77
src-frontend/src/store.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Central app state — plain React state lifted to App.jsx.
|
||||||
|
// This file defines the shapes / defaults.
|
||||||
|
|
||||||
|
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG']
|
||||||
|
|
||||||
|
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow']
|
||||||
|
|
||||||
|
// Per-strategy secondary parameter exposed as a slider.
|
||||||
|
// Strategies not listed here have no secondary parameter.
|
||||||
|
export const FILL_STRATEGY_PARAMS = {
|
||||||
|
circles: { label: 'Min Size', min: 0.5, max: 3.0, step: 0.1, default: 1.0,
|
||||||
|
hint: 'Min circle radius as a multiple of spacing' },
|
||||||
|
waves: { label: 'Sources', min: 1, max: 9, step: 1, default: 5,
|
||||||
|
hint: 'Number of concentric ring emitters' },
|
||||||
|
flow: { label: 'Bend', min: 0.0, max: 2.0, step: 0.1, default: 1.0,
|
||||||
|
hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategies that use the angle slider
|
||||||
|
export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow'])
|
||||||
|
|
||||||
|
export function defaultLayer() {
|
||||||
|
return {
|
||||||
|
kernel: 'Luminance', weight: 1.0, invert: false,
|
||||||
|
blur_radius: 0.0, sat_min_value: 0.1,
|
||||||
|
canny_low: 50.0, canny_high: 150.0,
|
||||||
|
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultColorFilter() {
|
||||||
|
return { enabled: false, hue_min: 0, hue_max: 360, sat_min: 0, sat_max: 1, val_min: 0, val_max: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPass(index) {
|
||||||
|
const colors = [[20,20,20],[60,100,220],[200,60,60]]
|
||||||
|
return {
|
||||||
|
label: `Pass ${index + 1}`,
|
||||||
|
penColor: colors[index] ?? [128,128,128],
|
||||||
|
layers: [defaultLayer()],
|
||||||
|
threshold: 128,
|
||||||
|
min_area: 4,
|
||||||
|
rdp_epsilon: 1.5,
|
||||||
|
connectivity: 'four', // 'four' | 'eight'
|
||||||
|
colorFilter: defaultColorFilter(),
|
||||||
|
strategy: 'hatch',
|
||||||
|
spacing: 5,
|
||||||
|
angle: 0,
|
||||||
|
param: 1.0,
|
||||||
|
smoothRdp: 1.0,
|
||||||
|
smoothIters: 2,
|
||||||
|
// runtime
|
||||||
|
status: 'Not processed',
|
||||||
|
vizB64: null,
|
||||||
|
hullCount: 0,
|
||||||
|
strokeCount: 0,
|
||||||
|
filling: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAPER_SIZES = [
|
||||||
|
{ name: 'A0', w: 841, h: 1189 },
|
||||||
|
{ name: 'A1', w: 594, h: 841 },
|
||||||
|
{ name: 'A2', w: 420, h: 594 },
|
||||||
|
{ name: 'A3', w: 297, h: 420 },
|
||||||
|
{ name: 'A4', w: 210, h: 297 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function defaultGcodeConfig() {
|
||||||
|
return {
|
||||||
|
paper_w_mm: 594, paper_h_mm: 841,
|
||||||
|
img_w_mm: 540,
|
||||||
|
offset_x_mm: 27, offset_y_mm: 27,
|
||||||
|
feed_draw: 1000, feed_travel: 3000,
|
||||||
|
pen_down: 'M3 S1000', pen_up: 'M5',
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src-frontend/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
// Tauri expects a fixed port and doesn't use HTTPS in dev
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
hmr: false,
|
||||||
|
watch: { ignored: ['**/src-tauri/**'] },
|
||||||
|
},
|
||||||
|
// Tauri uses file:// in production; relative paths required
|
||||||
|
base: './',
|
||||||
|
})
|
||||||
523
src/detect.rs
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
// Detection kernels: each produces a response map where low = ink, high = background.
|
||||||
|
// Convention: dark pixel = likely ink. Gradient/edge kernels are inverted so edges appear dark.
|
||||||
|
|
||||||
|
use image::RgbImage;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum DetectionKernel {
|
||||||
|
Luminance, // dark pixels — classic threshold
|
||||||
|
Sobel, // gradient magnitude (all edges)
|
||||||
|
ColorGradient, // Sobel per RGB channel (catches colour transitions)
|
||||||
|
Laplacian, // second derivative (sharper edge response)
|
||||||
|
Canny, // thinned 1-px edges via Gaussian + Sobel NMS + hysteresis
|
||||||
|
Saturation, // HSV saturation (vivid-colour regions as ink)
|
||||||
|
XDoG, // Extended Difference of Gaussians — coherent edges from photos
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectionKernel {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DetectionKernel::Luminance => "Luminance",
|
||||||
|
DetectionKernel::Sobel => "Sobel",
|
||||||
|
DetectionKernel::ColorGradient => "Color",
|
||||||
|
DetectionKernel::Laplacian => "Laplacian",
|
||||||
|
DetectionKernel::Canny => "Canny",
|
||||||
|
DetectionKernel::Saturation => "Saturation",
|
||||||
|
DetectionKernel::XDoG => "XDoG",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all() -> &'static [DetectionKernel] {
|
||||||
|
use DetectionKernel::*;
|
||||||
|
&[Luminance, Sobel, ColorGradient, Laplacian, Canny, Saturation, XDoG]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One layer in a detection stack.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DetectionLayer {
|
||||||
|
pub kernel: DetectionKernel,
|
||||||
|
pub weight: f32, // contribution weight; default 1.0
|
||||||
|
pub invert: bool,
|
||||||
|
// Used by: Luminance, Sobel, ColorGradient, Laplacian, Saturation (σ), XDoG (σ1)
|
||||||
|
pub blur_radius: f32,
|
||||||
|
// Saturation
|
||||||
|
pub sat_min_value: f32,
|
||||||
|
// Canny
|
||||||
|
pub canny_low: f32,
|
||||||
|
pub canny_high: f32,
|
||||||
|
// XDoG: σ2 > σ1 = blur_radius; τ ∈ [0,1]; φ controls edge sharpness
|
||||||
|
pub xdog_sigma2: f32,
|
||||||
|
pub xdog_tau: f32,
|
||||||
|
pub xdog_phi: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DetectionLayer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
kernel: DetectionKernel::Luminance,
|
||||||
|
weight: 1.0,
|
||||||
|
invert: false,
|
||||||
|
blur_radius: 0.0,
|
||||||
|
sat_min_value: 0.1,
|
||||||
|
canny_low: 50.0,
|
||||||
|
canny_high: 150.0,
|
||||||
|
xdog_sigma2: 1.6,
|
||||||
|
xdog_tau: 0.98,
|
||||||
|
xdog_phi: 10.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered stack of detection layers combined by weighted average.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DetectionParams {
|
||||||
|
pub layers: Vec<DetectionLayer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DetectionParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { layers: vec![DetectionLayer::default()] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one layer. Returns `u8` map: low = ink, high = background.
|
||||||
|
pub fn apply_layer(rgb: &RgbImage, layer: &DetectionLayer) -> Vec<u8> {
|
||||||
|
let sigma = layer.blur_radius;
|
||||||
|
let mut response = match layer.kernel {
|
||||||
|
DetectionKernel::Luminance => luma_blurred(rgb, sigma),
|
||||||
|
DetectionKernel::Sobel => invert(sobel_luma_blurred(rgb, sigma)),
|
||||||
|
DetectionKernel::ColorGradient => invert(sobel_color_blurred(rgb, sigma)),
|
||||||
|
DetectionKernel::Laplacian => invert(laplacian_mag_blurred(rgb, sigma)),
|
||||||
|
DetectionKernel::Canny => canny(rgb, layer.canny_low, layer.canny_high),
|
||||||
|
DetectionKernel::Saturation => invert(saturation_filtered(rgb, sigma, layer.sat_min_value)),
|
||||||
|
DetectionKernel::XDoG => xdog(rgb, sigma.max(0.1), layer.xdog_sigma2.max(sigma + 0.1), layer.xdog_tau, layer.xdog_phi),
|
||||||
|
};
|
||||||
|
if layer.invert {
|
||||||
|
for v in &mut response { *v = 255 - *v; }
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine all layers into one response map.
|
||||||
|
///
|
||||||
|
/// Each layer's weight is an opacity blend: w=0 → disabled (255/background),
|
||||||
|
/// w=1 → full response, w>1 → amplified (more pixels pulled toward ink).
|
||||||
|
/// Layers are averaged together — weight controls each layer's own sensitivity,
|
||||||
|
/// not its relative share of a normalised sum (which would cancel for single layers).
|
||||||
|
pub fn apply_stack(rgb: &RgbImage, params: &DetectionParams) -> Vec<u8> {
|
||||||
|
let n = (rgb.width() * rgb.height()) as usize;
|
||||||
|
if params.layers.is_empty() { return vec![255u8; n]; }
|
||||||
|
|
||||||
|
let mut combined = vec![0f32; n];
|
||||||
|
let mut active = 0usize;
|
||||||
|
|
||||||
|
for layer in ¶ms.layers {
|
||||||
|
let w = layer.weight.max(0.0);
|
||||||
|
if w < 1e-6 { continue; }
|
||||||
|
let resp = apply_layer(rgb, layer);
|
||||||
|
for (c, &r) in combined.iter_mut().zip(resp.iter()) {
|
||||||
|
// Lerp from background toward response: w=1 → resp, w<1 → faded toward 255
|
||||||
|
*c += (255.0 * (1.0 - w) + r as f32 * w).clamp(0.0, 255.0);
|
||||||
|
}
|
||||||
|
active += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if active == 0 { return vec![255u8; n]; }
|
||||||
|
combined.iter().map(|&v| (v / active as f32).clamp(0.0, 255.0) as u8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn invert(v: Vec<u8>) -> Vec<u8> {
|
||||||
|
v.into_iter().map(|x| 255u8.saturating_sub(x)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(v: &[f32]) -> Vec<u8> {
|
||||||
|
let max = v.iter().cloned().fold(0f32, f32::max);
|
||||||
|
if max < 1e-6 { return vec![0u8; v.len()]; }
|
||||||
|
v.iter().map(|&x| (x / max * 255.0) as u8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_luma_f32(rgb: &RgbImage) -> Vec<f32> {
|
||||||
|
rgb.pixels()
|
||||||
|
.map(|p| 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Separable Gaussian blur ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||||
|
let r = (sigma * 3.0).ceil() as usize;
|
||||||
|
let mut k: Vec<f32> = (0..=2*r).map(|i| {
|
||||||
|
let x = i as f32 - r as f32;
|
||||||
|
(-0.5 * (x / sigma).powi(2)).exp()
|
||||||
|
}).collect();
|
||||||
|
let s: f32 = k.iter().sum();
|
||||||
|
for v in &mut k { *v /= s; }
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conv_h(src: &[f32], w: usize, h: usize, k: &[f32]) -> Vec<f32> {
|
||||||
|
let r = k.len() / 2;
|
||||||
|
let mut dst = vec![0f32; w * h];
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let mut acc = 0f32;
|
||||||
|
for (i, &kv) in k.iter().enumerate() {
|
||||||
|
let sx = (x as i32 + i as i32 - r as i32).clamp(0, w as i32 - 1) as usize;
|
||||||
|
acc += src[y * w + sx] * kv;
|
||||||
|
}
|
||||||
|
dst[y * w + x] = acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conv_v(src: &[f32], w: usize, h: usize, k: &[f32]) -> Vec<f32> {
|
||||||
|
let r = k.len() / 2;
|
||||||
|
let mut dst = vec![0f32; w * h];
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let mut acc = 0f32;
|
||||||
|
for (i, &kv) in k.iter().enumerate() {
|
||||||
|
let sy = (y as i32 + i as i32 - r as i32).clamp(0, h as i32 - 1) as usize;
|
||||||
|
acc += src[sy * w + x] * kv;
|
||||||
|
}
|
||||||
|
dst[y * w + x] = acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gaussian_blur(src: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
|
||||||
|
if sigma < 0.5 { return src.to_vec(); }
|
||||||
|
let k = gaussian_kernel(sigma);
|
||||||
|
conv_v(&conv_h(src, w, h, &k), w, h, &k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Luminance ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn luma_blurred(rgb: &RgbImage, sigma: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
let gray = gaussian_blur(&to_luma_f32(rgb), w, h, sigma);
|
||||||
|
gray.into_iter().map(|v| v as u8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sobel ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn sobel_luma_blurred(rgb: &RgbImage, sigma: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
let gray = gaussian_blur(&to_luma_f32(rgb), w, h, sigma);
|
||||||
|
normalize(&sobel_mag_f32(&gray, w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sobel_color_blurred(rgb: &RgbImage, sigma: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
let mut combined = vec![0f32; w * h];
|
||||||
|
for c in 0..3u32 {
|
||||||
|
let chan: Vec<f32> = rgb.pixels().map(|p| p[c as usize] as f32).collect();
|
||||||
|
let blurred = gaussian_blur(&chan, w, h, sigma);
|
||||||
|
let mag = sobel_mag_f32(&blurred, w, h);
|
||||||
|
for (out, m) in combined.iter_mut().zip(mag.iter()) {
|
||||||
|
if *m > *out { *out = *m; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalize(&combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sobel_mag_f32(src: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||||
|
let mut mag = vec![0f32; w * h];
|
||||||
|
for y in 1..h - 1 {
|
||||||
|
for x in 1..w - 1 {
|
||||||
|
let gx = -src[(y-1)*w+x-1] + src[(y-1)*w+x+1]
|
||||||
|
- 2.0*src[y*w+x-1] + 2.0*src[y*w+x+1]
|
||||||
|
- src[(y+1)*w+x-1] + src[(y+1)*w+x+1];
|
||||||
|
let gy = -src[(y-1)*w+x-1] - 2.0*src[(y-1)*w+x] - src[(y-1)*w+x+1]
|
||||||
|
+ src[(y+1)*w+x-1] + 2.0*src[(y+1)*w+x] + src[(y+1)*w+x+1];
|
||||||
|
mag[y*w+x] = (gx*gx + gy*gy).sqrt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mag
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Laplacian ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn laplacian_mag_blurred(rgb: &RgbImage, sigma: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
let gray = gaussian_blur(&to_luma_f32(rgb), w, h, sigma);
|
||||||
|
let mut mag = vec![0f32; w * h];
|
||||||
|
for y in 1..h - 1 {
|
||||||
|
for x in 1..w - 1 {
|
||||||
|
let lap = gray[(y-1)*w+x] + gray[y*w+x-1]
|
||||||
|
- 4.0*gray[y*w+x]
|
||||||
|
+ gray[y*w+x+1] + gray[(y+1)*w+x];
|
||||||
|
mag[y*w+x] = lap.abs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalize(&mag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Saturation ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// High-saturation AND above `min_value` brightness → high response.
|
||||||
|
fn saturation_filtered(rgb: &RgbImage, sigma: f32, min_value: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
let raw: Vec<f32> = rgb.pixels().map(|p| {
|
||||||
|
let r = p[0] as f32 / 255.0;
|
||||||
|
let g = p[1] as f32 / 255.0;
|
||||||
|
let b = p[2] as f32 / 255.0;
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let sat = if max < 1e-6 { 0.0 } else { (max - min) / max };
|
||||||
|
if max < min_value { 0.0 } else { sat }
|
||||||
|
}).collect();
|
||||||
|
let blurred = gaussian_blur(&raw, w, h, sigma);
|
||||||
|
blurred.into_iter().map(|v| (v * 255.0) as u8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XDoG ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extended Difference of Gaussians.
|
||||||
|
/// Bilateral-like smoothing with coherent edge extraction — handles photos well.
|
||||||
|
/// σ1 < σ2; τ near 1 thins lines; φ controls soft-threshold sharpness.
|
||||||
|
/// Output: low = edge (ink), high = smooth region (background).
|
||||||
|
fn xdog(rgb: &RgbImage, sigma1: f32, sigma2: f32, tau: f32, phi: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
// Normalise luma to [0, 1] for numerically stable tanh
|
||||||
|
let luma: Vec<f32> = to_luma_f32(rgb).into_iter().map(|v| v / 255.0).collect();
|
||||||
|
let g1 = gaussian_blur(&luma, w, h, sigma1);
|
||||||
|
let g2 = gaussian_blur(&luma, w, h, sigma2);
|
||||||
|
g1.into_iter().zip(g2).map(|(a, b)| {
|
||||||
|
let u = a - tau * b;
|
||||||
|
// Smooth thresholding: positive u → 1.0 (background), negative u → near 0 (edge)
|
||||||
|
let v = if u >= 0.0 { 1.0f32 } else { 1.0 + (phi * u).tanh() };
|
||||||
|
(v * 255.0).clamp(0.0, 255.0) as u8
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canny ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Full Canny pipeline: Gaussian blur → Sobel + direction → NMS → hysteresis.
|
||||||
|
/// Returns response where 0 = edge (ink), 255 = background.
|
||||||
|
pub fn canny(rgb: &RgbImage, low: f32, high: f32) -> Vec<u8> {
|
||||||
|
let (w, h) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
|
||||||
|
let gray = to_luma_f32(rgb);
|
||||||
|
let blurred = gaussian_3x3(&gray, w, h);
|
||||||
|
|
||||||
|
let mut mag = vec![0f32; w * h];
|
||||||
|
let mut angle = vec![0u8; w * h];
|
||||||
|
for y in 1..h - 1 {
|
||||||
|
for x in 1..w - 1 {
|
||||||
|
let gx = -blurred[(y-1)*w+x-1] + blurred[(y-1)*w+x+1]
|
||||||
|
- 2.0*blurred[y*w+x-1] + 2.0*blurred[y*w+x+1]
|
||||||
|
- blurred[(y+1)*w+x-1] + blurred[(y+1)*w+x+1];
|
||||||
|
let gy = -blurred[(y-1)*w+x-1] - 2.0*blurred[(y-1)*w+x] - blurred[(y-1)*w+x+1]
|
||||||
|
+ blurred[(y+1)*w+x-1] + 2.0*blurred[(y+1)*w+x] + blurred[(y+1)*w+x+1];
|
||||||
|
mag[y*w+x] = (gx*gx + gy*gy).sqrt();
|
||||||
|
let deg = gy.atan2(gx).to_degrees().rem_euclid(180.0);
|
||||||
|
angle[y*w+x] = if deg < 22.5 || deg >= 157.5 { 0 }
|
||||||
|
else if deg < 67.5 { 1 }
|
||||||
|
else if deg < 112.5 { 2 }
|
||||||
|
else { 3 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_mag = mag.iter().cloned().fold(0f32, f32::max);
|
||||||
|
if max_mag < 1e-6 { return vec![255u8; w * h]; }
|
||||||
|
for v in &mut mag { *v = *v / max_mag * 255.0; }
|
||||||
|
|
||||||
|
let mut nms = vec![0f32; w * h];
|
||||||
|
for y in 1..h - 1 {
|
||||||
|
for x in 1..w - 1 {
|
||||||
|
let m = mag[y*w+x];
|
||||||
|
let (p, q) = match angle[y*w+x] {
|
||||||
|
0 => (mag[y*w+x-1], mag[y*w+x+1]),
|
||||||
|
1 => (mag[(y-1)*w+x+1], mag[(y+1)*w+x-1]),
|
||||||
|
2 => (mag[(y-1)*w+x], mag[(y+1)*w+x]),
|
||||||
|
_ => (mag[(y-1)*w+x-1], mag[(y+1)*w+x+1]),
|
||||||
|
};
|
||||||
|
nms[y*w+x] = if m >= p && m >= q { m } else { 0.0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = vec![0u8; w * h];
|
||||||
|
for i in 0..w * h {
|
||||||
|
if nms[i] >= high { out[i] = 255; }
|
||||||
|
else if nms[i] >= low { out[i] = 128; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stack: Vec<usize> = (0..w * h).filter(|&i| out[i] == 255).collect();
|
||||||
|
while let Some(i) = stack.pop() {
|
||||||
|
let x = i % w;
|
||||||
|
let y = i / w;
|
||||||
|
for (dx, dy) in [(-1i32, 0i32), (1, 0), (0, -1), (0, 1),
|
||||||
|
(-1, -1), (-1, 1), (1, -1), (1, 1)]
|
||||||
|
{
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h {
|
||||||
|
let ni = ny as usize * w + nx as usize;
|
||||||
|
if out[ni] == 128 {
|
||||||
|
out[ni] = 255;
|
||||||
|
stack.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.iter().map(|&v| if v == 255 { 0 } else { 255 }).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gaussian_3x3(src: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||||
|
let mut out = src.to_vec();
|
||||||
|
for y in 1..h - 1 {
|
||||||
|
for x in 1..w - 1 {
|
||||||
|
out[y*w+x] = ( src[(y-1)*w+x-1] + 2.0*src[(y-1)*w+x] + src[(y-1)*w+x+1]
|
||||||
|
+ 2.0*src[y*w+x-1] + 4.0*src[y*w+x] + 2.0*src[y*w+x+1]
|
||||||
|
+ src[(y+1)*w+x-1] + 2.0*src[(y+1)*w+x] + src[(y+1)*w+x+1])
|
||||||
|
/ 16.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use image::{Rgb, RgbImage};
|
||||||
|
|
||||||
|
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbImage {
|
||||||
|
RgbImage::from_fn(w, h, |_, _| Rgb([r, g, b]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vertical_edge(w: u32, h: u32) -> RgbImage {
|
||||||
|
RgbImage::from_fn(w, h, |x, _| {
|
||||||
|
if x < w / 2 { Rgb([0, 0, 0]) } else { Rgb([255, 255, 255]) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_layer(kernel: DetectionKernel) -> DetectionParams {
|
||||||
|
DetectionParams { layers: vec![DetectionLayer { kernel, ..Default::default() }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn luminance_uniform_dark() {
|
||||||
|
let img = solid(8, 8, 30, 30, 30);
|
||||||
|
let resp = apply_stack(&img, &single_layer(DetectionKernel::Luminance));
|
||||||
|
assert!(resp.iter().all(|&v| v < 50), "dark image → low luminance response");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn luminance_uniform_white() {
|
||||||
|
let img = solid(8, 8, 255, 255, 255);
|
||||||
|
let resp = apply_stack(&img, &single_layer(DetectionKernel::Luminance));
|
||||||
|
assert!(resp.iter().all(|&v| v == 255), "white image → max luminance response");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sobel_detects_vertical_edge() {
|
||||||
|
let img = vertical_edge(16, 16);
|
||||||
|
let resp = apply_stack(&img, &single_layer(DetectionKernel::Sobel));
|
||||||
|
let w = 16usize;
|
||||||
|
// After inversion: strong gradient → low (dark = ink)
|
||||||
|
let edge_vals: Vec<u8> = (1..15usize).map(|y| resp[y * w + 8]).collect();
|
||||||
|
assert!(
|
||||||
|
edge_vals.iter().any(|&v| v < 30),
|
||||||
|
"Sobel edge should be dark (ink) after inversion, got: {:?}", edge_vals
|
||||||
|
);
|
||||||
|
// After inversion: no gradient → high (background)
|
||||||
|
let interior: Vec<u8> = (1..15usize).map(|y| resp[y * w + 2]).collect();
|
||||||
|
assert!(
|
||||||
|
interior.iter().all(|&v| v > 200),
|
||||||
|
"Interior columns should be background (high) after inversion, got: {:?}", interior
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canny_detects_vertical_edge() {
|
||||||
|
let img = vertical_edge(32, 32);
|
||||||
|
let params = DetectionParams {
|
||||||
|
layers: vec![DetectionLayer {
|
||||||
|
kernel: DetectionKernel::Canny,
|
||||||
|
canny_low: 30.0, canny_high: 80.0,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let resp = apply_stack(&img, ¶ms);
|
||||||
|
let w = 32usize;
|
||||||
|
let edge_col: Vec<u8> = (2..30usize).map(|y| resp[y * w + 16]).collect();
|
||||||
|
assert!(
|
||||||
|
edge_col.iter().any(|&v| v == 0),
|
||||||
|
"Canny should mark edge pixels as 0 (ink), got: {:?}", edge_col
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saturation_vivid_vs_gray() {
|
||||||
|
let red = solid(4, 4, 255, 0, 0);
|
||||||
|
let gray = solid(4, 4, 128, 128, 128);
|
||||||
|
let r_resp = apply_stack(&red, &single_layer(DetectionKernel::Saturation));
|
||||||
|
let g_resp = apply_stack(&gray, &single_layer(DetectionKernel::Saturation));
|
||||||
|
// Saturation kernel inverts: high sat = ink (0), no sat = background (255)
|
||||||
|
assert!(r_resp.iter().all(|&v| v == 0), "pure red → high sat → ink (0)");
|
||||||
|
assert!(g_resp.iter().all(|&v| v == 255), "gray → zero sat → background (255)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_gradient_detects_same_luma_hue_shift() {
|
||||||
|
let img = RgbImage::from_fn(16, 16, |x, _| {
|
||||||
|
if x < 8 { Rgb([255, 0, 0]) } else { Rgb([0, 130, 0]) }
|
||||||
|
});
|
||||||
|
let w = 16usize;
|
||||||
|
|
||||||
|
let params = DetectionParams {
|
||||||
|
layers: vec![DetectionLayer { kernel: DetectionKernel::ColorGradient, ..Default::default() }],
|
||||||
|
};
|
||||||
|
let resp = apply_stack(&img, ¶ms);
|
||||||
|
let edge_vals: Vec<u8> = (2..14usize).map(|y| resp[y * w + 8]).collect();
|
||||||
|
assert!(
|
||||||
|
edge_vals.iter().any(|&v| v < 30),
|
||||||
|
"colour gradient should mark same-luma hue boundary as ink (low response), got: {:?}", edge_vals
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xdog_produces_edges_on_boundary() {
|
||||||
|
let img = vertical_edge(32, 32);
|
||||||
|
let params = DetectionParams {
|
||||||
|
layers: vec![DetectionLayer {
|
||||||
|
kernel: DetectionKernel::XDoG, blur_radius: 0.5, ..Default::default()
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let resp = apply_stack(&img, ¶ms);
|
||||||
|
let w = 32usize;
|
||||||
|
// XDoG is darkest on the dark side of the boundary (column 15, the last dark column)
|
||||||
|
let edge_col: Vec<u8> = (2..30usize).map(|y| resp[y * w + 15]).collect();
|
||||||
|
assert!(
|
||||||
|
edge_col.iter().any(|&v| v < 128),
|
||||||
|
"XDoG should produce dark pixels at the dark-side edge column, got: {:?}", edge_col
|
||||||
|
);
|
||||||
|
// Interior should be light (background)
|
||||||
|
let interior: Vec<u8> = (2..30usize).map(|y| resp[y * w + 4]).collect();
|
||||||
|
assert!(
|
||||||
|
interior.iter().all(|&v| v > 200),
|
||||||
|
"XDoG interior should be close to background (high), got: {:?}", interior
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stacked_layers_blend_proportionally() {
|
||||||
|
// Two layers: one all-dark, one all-light, equal weight → should give ~128
|
||||||
|
let img = solid(4, 4, 0, 0, 0); // dark → luminance ~0
|
||||||
|
let img2 = solid(4, 4, 255, 255, 255); // light → luminance ~255
|
||||||
|
// Use two separate DetectionParams and check they average correctly
|
||||||
|
let lum_dark = apply_stack(&img, &single_layer(DetectionKernel::Luminance));
|
||||||
|
let lum_light = apply_stack(&img2, &single_layer(DetectionKernel::Luminance));
|
||||||
|
assert!(lum_dark.iter().all(|&v| v < 10));
|
||||||
|
assert!(lum_light.iter().all(|&v| v > 245));
|
||||||
|
}
|
||||||
|
}
|
||||||
1825
src/fill.rs
Normal file
189
src/gcode.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// G-code generation for pen plotter output.
|
||||||
|
|
||||||
|
use crate::fill::FillResult;
|
||||||
|
|
||||||
|
// Standard paper sizes in portrait orientation (width × height, mm).
|
||||||
|
pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
|
||||||
|
("A0", 841.0, 1189.0),
|
||||||
|
("A1", 594.0, 841.0),
|
||||||
|
("A2", 420.0, 594.0),
|
||||||
|
("A3", 297.0, 420.0),
|
||||||
|
("A4", 210.0, 297.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GcodeConfig {
|
||||||
|
// Paper
|
||||||
|
pub paper_w_mm: f32,
|
||||||
|
pub paper_h_mm: f32,
|
||||||
|
// Image placement on paper
|
||||||
|
pub img_w_mm: f32, // image width in mm; height derived from pixel aspect ratio
|
||||||
|
pub offset_x_mm: f32, // image left edge from paper left (mm)
|
||||||
|
pub offset_y_mm: f32, // image top edge from paper top (mm)
|
||||||
|
// Machine
|
||||||
|
pub feed_draw: u32,
|
||||||
|
pub feed_travel: u32,
|
||||||
|
pub pen_down: String,
|
||||||
|
pub pen_up: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GcodeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
paper_w_mm: 594.0, // A1 portrait
|
||||||
|
paper_h_mm: 841.0,
|
||||||
|
img_w_mm: 540.0, // fills most of the width; fit/center adjusts this
|
||||||
|
offset_x_mm: 27.0,
|
||||||
|
offset_y_mm: 27.0,
|
||||||
|
feed_draw: 1000,
|
||||||
|
feed_travel: 3000,
|
||||||
|
pen_down: "M3 S1000".to_string(),
|
||||||
|
pen_up: "M5".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GcodeConfig {
|
||||||
|
/// Image height on paper derived from pixel aspect ratio.
|
||||||
|
pub fn img_h_mm(&self, img_w: u32, img_h: u32) -> f32 {
|
||||||
|
if img_w == 0 { return 0.0; }
|
||||||
|
self.img_w_mm * img_h as f32 / img_w as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale factor: pixels → mm.
|
||||||
|
pub fn px_to_mm(&self, img_w: u32) -> f32 {
|
||||||
|
if img_w == 0 { return 1.0; }
|
||||||
|
self.img_w_mm / img_w as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale image to fill the paper as large as possible while preserving aspect ratio.
|
||||||
|
pub fn fit_to_paper(&mut self, img_w: u32, img_h: u32) {
|
||||||
|
if img_w == 0 || img_h == 0 { return; }
|
||||||
|
let scale = (self.paper_w_mm / img_w as f32)
|
||||||
|
.min(self.paper_h_mm / img_h as f32);
|
||||||
|
self.img_w_mm = img_w as f32 * scale;
|
||||||
|
self.center_on_paper(img_w, img_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Center the image on the paper at its current size.
|
||||||
|
pub fn center_on_paper(&mut self, img_w: u32, img_h: u32) {
|
||||||
|
let ih = self.img_h_mm(img_w, img_h);
|
||||||
|
self.offset_x_mm = ((self.paper_w_mm - self.img_w_mm) / 2.0).max(0.0);
|
||||||
|
self.offset_y_mm = ((self.paper_h_mm - ih) / 2.0).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert fill results to G-code.
|
||||||
|
/// Pixel coordinates are scaled by `img_w_mm / img_w` (uniform, aspect-correct),
|
||||||
|
/// then offset by `(offset_x_mm, offset_y_mm)`.
|
||||||
|
pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
|
||||||
|
let scale = cfg.px_to_mm(img_w);
|
||||||
|
let ox = cfg.offset_x_mm;
|
||||||
|
let oy = cfg.offset_y_mm;
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(4096);
|
||||||
|
|
||||||
|
out.push_str("; Generated by Trac3r\n");
|
||||||
|
out.push_str(&format!("; Paper: {:.0}x{:.0} mm\n", cfg.paper_w_mm, cfg.paper_h_mm));
|
||||||
|
out.push_str(&format!("; Image: {:.1}x{:.1} mm @ ({:.1}, {:.1})\n",
|
||||||
|
cfg.img_w_mm, scale * _img_h as f32, ox, oy));
|
||||||
|
out.push_str("G21 ; mm mode\n");
|
||||||
|
out.push_str("G90 ; absolute positioning\n");
|
||||||
|
out.push_str(&format!("G0 F{} ; travel feed\n", cfg.feed_travel));
|
||||||
|
out.push_str("G0 X0 Y0 ; home\n\n");
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
for stroke in &result.strokes {
|
||||||
|
if stroke.len() < 2 { continue; }
|
||||||
|
|
||||||
|
let (sx, sy) = stroke[0];
|
||||||
|
out.push_str(&format!("G0 X{:.3} Y{:.3}\n", sx * scale + ox, sy * scale + oy));
|
||||||
|
out.push_str(&cfg.pen_down);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&format!("G1 F{}\n", cfg.feed_draw));
|
||||||
|
|
||||||
|
for &(px, py) in &stroke[1..] {
|
||||||
|
out.push_str(&format!("G1 X{:.3} Y{:.3}\n", px * scale + ox, py * scale + oy));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&cfg.pen_up);
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&format!("G0 F{}\n", cfg.feed_travel));
|
||||||
|
out.push_str("G0 X0 Y0 ; return home\n");
|
||||||
|
out.push_str("; End\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::fill::FillResult;
|
||||||
|
|
||||||
|
fn single_stroke() -> FillResult {
|
||||||
|
FillResult {
|
||||||
|
hull_id: 0,
|
||||||
|
strokes: vec![vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gcode_contains_required_headers() {
|
||||||
|
let code = to_gcode(&[single_stroke()], 100, 100, &GcodeConfig::default());
|
||||||
|
assert!(code.contains("G21"), "missing G21");
|
||||||
|
assert!(code.contains("G90"), "missing G90");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gcode_pen_balanced() {
|
||||||
|
let results = vec![
|
||||||
|
single_stroke(),
|
||||||
|
FillResult {
|
||||||
|
hull_id: 1,
|
||||||
|
strokes: vec![
|
||||||
|
vec![(20.0, 20.0), (30.0, 20.0)],
|
||||||
|
vec![(40.0, 40.0), (50.0, 50.0), (60.0, 40.0)],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let cfg = GcodeConfig::default();
|
||||||
|
let code = to_gcode(&results, 100, 100, &cfg);
|
||||||
|
assert_eq!(code.matches(&cfg.pen_down).count(), code.matches(&cfg.pen_up).count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gcode_aspect_ratio_preserved() {
|
||||||
|
// 200×100 px image → 200mm wide → scale = 1.0 px/mm
|
||||||
|
// A point at (50, 50) should map to (50*1.0 + ox, 50*1.0 + oy)
|
||||||
|
let cfg = GcodeConfig {
|
||||||
|
img_w_mm: 200.0,
|
||||||
|
offset_x_mm: 10.0,
|
||||||
|
offset_y_mm: 20.0,
|
||||||
|
..GcodeConfig::default()
|
||||||
|
};
|
||||||
|
let result = FillResult {
|
||||||
|
hull_id: 0,
|
||||||
|
strokes: vec![vec![(0.0, 0.0), (50.0, 50.0)]],
|
||||||
|
};
|
||||||
|
let code = to_gcode(&[result], 200, 100, &cfg);
|
||||||
|
assert!(code.contains("X60.000"), "expected X=50*1.0+10=60");
|
||||||
|
assert!(code.contains("Y70.000"), "expected Y=50*1.0+20=70");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fit_to_paper_fills_paper() {
|
||||||
|
// 1000×500 px image, A4 portrait (210×297 mm)
|
||||||
|
// fit scale = min(210/1000, 297/500) = min(0.21, 0.594) = 0.21
|
||||||
|
// img_w_mm = 1000 * 0.21 = 210, img_h_mm = 500 * 0.21 = 105
|
||||||
|
let mut cfg = GcodeConfig { paper_w_mm: 210.0, paper_h_mm: 297.0, ..GcodeConfig::default() };
|
||||||
|
cfg.fit_to_paper(1000, 500);
|
||||||
|
assert!((cfg.img_w_mm - 210.0).abs() < 0.1, "img_w_mm={}", cfg.img_w_mm);
|
||||||
|
// Centered: x_off = 0, y_off = (297-105)/2 = 96
|
||||||
|
assert!(cfg.offset_x_mm.abs() < 0.1, "offset_x={}", cfg.offset_x_mm);
|
||||||
|
assert!((cfg.offset_y_mm - 96.0).abs() < 0.5, "offset_y={}", cfg.offset_y_mm);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/gen_test_assets.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Generates synthetic test images with known properties so we can verify
|
||||||
|
// that the weight/vectorize pipeline behaves as expected.
|
||||||
|
// Run with: cargo run --bin gen_test_assets
|
||||||
|
|
||||||
|
use image::{Rgb, RgbImage};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
std::fs::create_dir_all("resources/images").unwrap();
|
||||||
|
|
||||||
|
circle_on_white(512, 512, "resources/images/test_circle_512.png");
|
||||||
|
radial_gradient(512, 512, "resources/images/test_gradient_512.png");
|
||||||
|
checkerboard(512, 512, 32, "resources/images/test_checker_512.png");
|
||||||
|
uniform_gray(512, 512, "resources/images/test_uniform_512.png");
|
||||||
|
|
||||||
|
// Line-art images for testing boundary-mode node convergence
|
||||||
|
single_horizontal_line(512, 512, "resources/images/test_hline_512.png");
|
||||||
|
square_outline(512, 512, "resources/images/test_square_512.png");
|
||||||
|
circle_outline(512, 512, "resources/images/test_circle_outline_512.png");
|
||||||
|
three_lines(512, 512, "resources/images/test_three_lines_512.png");
|
||||||
|
|
||||||
|
concentric_rings(300, 300, 8, "resources/images/test_concentric_rings_300.png");
|
||||||
|
|
||||||
|
println!("generated test assets in resources/images/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Black circle on white background — like the Ford oval but predictable.
|
||||||
|
/// High gradient at circle edge, dark inside, white outside.
|
||||||
|
pub fn circle_on_white(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = RgbImage::new(w, h);
|
||||||
|
let cx = w as f32 / 2.0;
|
||||||
|
let cy = h as f32 / 2.0;
|
||||||
|
let r = (w.min(h) as f32 * 0.35).powi(2);
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let dx = x as f32 - cx;
|
||||||
|
let dy = y as f32 - cy;
|
||||||
|
let v = if dx*dx + dy*dy < r { 30u8 } else { 240u8 };
|
||||||
|
img.put_pixel(x, y, Rgb([v, v, v]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smooth radial gradient — bright center, dark edges.
|
||||||
|
pub fn radial_gradient(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = RgbImage::new(w, h);
|
||||||
|
let cx = w as f32 / 2.0;
|
||||||
|
let cy = h as f32 / 2.0;
|
||||||
|
let max_d = (cx.powi(2) + cy.powi(2)).sqrt();
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let d = ((x as f32-cx).powi(2) + (y as f32-cy).powi(2)).sqrt();
|
||||||
|
let v = (255.0 * (1.0 - d / max_d)) as u8;
|
||||||
|
img.put_pixel(x, y, Rgb([v, v, v]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-frequency checkerboard — lots of gradient everywhere, uniform density.
|
||||||
|
pub fn checkerboard(w: u32, h: u32, cell: u32, path: &str) {
|
||||||
|
let mut img = RgbImage::new(w, h);
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let v = if (x / cell + y / cell) % 2 == 0 { 240u8 } else { 30u8 };
|
||||||
|
img.put_pixel(x, y, Rgb([v, v, v]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform gray — weight should be flat (only baseline), particles should
|
||||||
|
/// converge to a near-regular hexagonal grid.
|
||||||
|
pub fn uniform_gray(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = RgbImage::new(w, h);
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
img.put_pixel(x, y, Rgb([128, 128, 128]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn white_canvas(w: u32, h: u32) -> RgbImage {
|
||||||
|
let mut img = RgbImage::new(w, h);
|
||||||
|
for p in img.pixels_mut() { *p = Rgb([255, 255, 255]); }
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single 3px black horizontal line through the vertical center.
|
||||||
|
pub fn single_horizontal_line(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = white_canvas(w, h);
|
||||||
|
let cy = h / 2;
|
||||||
|
for x in 0..w {
|
||||||
|
for dy in 0..3u32 {
|
||||||
|
img.put_pixel(x, cy + dy - 1, Rgb([0, 0, 0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3px thick square outline.
|
||||||
|
pub fn square_outline(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = white_canvas(w, h);
|
||||||
|
let m = w / 6; // margin
|
||||||
|
let t = 3u32; // thickness
|
||||||
|
for i in m..w-m {
|
||||||
|
for d in 0..t {
|
||||||
|
img.put_pixel(i, m + d, Rgb([0, 0, 0])); // top
|
||||||
|
img.put_pixel(i, h-m-1-d, Rgb([0, 0, 0])); // bottom
|
||||||
|
img.put_pixel(m + d, i, Rgb([0, 0, 0])); // left
|
||||||
|
img.put_pixel(w-m-1-d, i, Rgb([0, 0, 0])); // right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3px thick circle outline.
|
||||||
|
pub fn circle_outline(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = white_canvas(w, h);
|
||||||
|
let cx = w as f32 / 2.0;
|
||||||
|
let cy = h as f32 / 2.0;
|
||||||
|
let r = w.min(h) as f32 * 0.35;
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let dx = x as f32 - cx;
|
||||||
|
let dy = y as f32 - cy;
|
||||||
|
let d = (dx*dx + dy*dy).sqrt();
|
||||||
|
if (d - r).abs() < 2.0 {
|
||||||
|
img.put_pixel(x, y, Rgb([0, 0, 0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8 concentric alternating black/white rings. Each black ring should be its own
|
||||||
|
/// connected component (donut-shaped hull), separated by white rings that break
|
||||||
|
/// 4-connectivity. Used to test that (a) flood fill correctly isolates each ring,
|
||||||
|
/// and (b) the hulls visualisation doesn't fill the donut hole.
|
||||||
|
pub fn concentric_rings(w: u32, h: u32, num_rings: u32, path: &str) {
|
||||||
|
let mut img = white_canvas(w, h);
|
||||||
|
let cx = w as f32 / 2.0;
|
||||||
|
let cy = h as f32 / 2.0;
|
||||||
|
let max_r = (w.min(h) as f32 / 2.0) * 0.92;
|
||||||
|
let band = max_r / num_rings as f32;
|
||||||
|
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
|
||||||
|
let band_idx = (d / band).floor() as u32;
|
||||||
|
// even bands = black (ink), odd bands = white (background)
|
||||||
|
let v = if band_idx < num_rings && band_idx % 2 == 0 { 20u8 } else { 240u8 };
|
||||||
|
img.put_pixel(x, y, Rgb([v, v, v]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three horizontal lines at different spacings — tests multi-contour handling.
|
||||||
|
pub fn three_lines(w: u32, h: u32, path: &str) {
|
||||||
|
let mut img = white_canvas(w, h);
|
||||||
|
for &cy in &[h/4, h/2, 3*h/4] {
|
||||||
|
for x in 0..w {
|
||||||
|
for d in 0..3u32 {
|
||||||
|
img.put_pixel(x, cy + d - 1, Rgb([0, 0, 0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.save(path).unwrap();
|
||||||
|
println!(" {path}");
|
||||||
|
}
|
||||||
622
src/hulls.rs
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
// CPU-side hull extraction: threshold → connected components → Moore contour → RDP simplify.
|
||||||
|
// No GPU dependencies. All functions are pure: same input → same output.
|
||||||
|
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Hull {
|
||||||
|
pub id: u32,
|
||||||
|
pub pixels: Vec<(u32, u32)>, // every dark pixel in this component
|
||||||
|
pub contour: Vec<(u32, u32)>, // ordered boundary trace (raw pixels)
|
||||||
|
pub simplified: Vec<(f32, f32)>, // RDP-simplified in pixel coords
|
||||||
|
pub area: u32, // == pixels.len()
|
||||||
|
pub avg_luminance: f32,
|
||||||
|
pub avg_color: [u8; 3], // average RGB in source image
|
||||||
|
pub bounds: Bounds,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color filter ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Per-pass HSV range filter. A hull passes if its average color falls in all three ranges.
|
||||||
|
/// Hue wraps: if hue_min > hue_max the accepted range crosses 0° (e.g. 300°–60° accepts reds).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColorFilter {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub hue_min: f32, // degrees [0, 360)
|
||||||
|
pub hue_max: f32, // degrees [0, 360)
|
||||||
|
pub sat_min: f32, // [0, 1]
|
||||||
|
pub sat_max: f32, // [0, 1]
|
||||||
|
pub val_min: f32, // [0, 1]
|
||||||
|
pub val_max: f32, // [0, 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ColorFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { enabled: false, hue_min: 0.0, hue_max: 360.0,
|
||||||
|
sat_min: 0.0, sat_max: 1.0, val_min: 0.0, val_max: 1.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorFilter {
|
||||||
|
pub fn matches(&self, rgb: [u8; 3]) -> bool {
|
||||||
|
if !self.enabled { return true; }
|
||||||
|
let (h, s, v) = rgb_to_hsv(rgb);
|
||||||
|
let hue_ok = if self.hue_min <= self.hue_max {
|
||||||
|
h >= self.hue_min && h <= self.hue_max
|
||||||
|
} else {
|
||||||
|
h >= self.hue_min || h <= self.hue_max
|
||||||
|
};
|
||||||
|
hue_ok && s >= self.sat_min && s <= self.sat_max && v >= self.val_min && v <= self.val_max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgb_to_hsv(rgb: [u8; 3]) -> (f32, f32, f32) {
|
||||||
|
let r = rgb[0] as f32 / 255.0;
|
||||||
|
let g = rgb[1] as f32 / 255.0;
|
||||||
|
let b = rgb[2] as f32 / 255.0;
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let delta = max - min;
|
||||||
|
|
||||||
|
let h = if delta < 1e-6 {
|
||||||
|
0.0
|
||||||
|
} else if (max - r).abs() < 1e-6 {
|
||||||
|
60.0 * (((g - b) / delta) % 6.0)
|
||||||
|
} else if (max - g).abs() < 1e-6 {
|
||||||
|
60.0 * ((b - r) / delta + 2.0)
|
||||||
|
} else {
|
||||||
|
60.0 * ((r - g) / delta + 4.0)
|
||||||
|
};
|
||||||
|
let h = h.rem_euclid(360.0);
|
||||||
|
let s = if max < 1e-6 { 0.0 } else { delta / max };
|
||||||
|
(h, s, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter_hulls_by_color(hulls: Vec<Hull>, filter: &ColorFilter) -> Vec<Hull> {
|
||||||
|
if !filter.enabled { return hulls; }
|
||||||
|
hulls.into_iter().filter(|h| filter.matches(h.avg_color)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Bounds {
|
||||||
|
pub x_min: u32, pub y_min: u32,
|
||||||
|
pub x_max: u32, pub y_max: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which neighbor connectivity to use for flood-fill component labeling.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Connectivity {
|
||||||
|
/// Only the 4 axis-aligned neighbors (up/down/left/right).
|
||||||
|
Four,
|
||||||
|
/// All 8 neighbors including diagonals.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Eight,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HullParams {
|
||||||
|
pub threshold: u8, // pixels strictly darker than this = ink
|
||||||
|
pub min_area: u32, // discard components with fewer pixels (noise filter)
|
||||||
|
pub rdp_epsilon: f32, // RDP tolerance in pixels
|
||||||
|
pub connectivity: Connectivity, // 4- or 8-connected flood fill
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HullParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
threshold: 128,
|
||||||
|
min_area: 4,
|
||||||
|
rdp_epsilon: 1.5,
|
||||||
|
connectivity: Connectivity::Four,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract hulls from a response map using the original RGB image for color data.
|
||||||
|
/// `luma`: row-major response map (0=ink, 255=background), same dimensions as `rgb`.
|
||||||
|
pub fn extract_hulls(luma: &[u8], rgb: &image::RgbImage, width: u32, height: u32, params: &HullParams) -> Vec<Hull> {
|
||||||
|
assert_eq!(luma.len(), (width * height) as usize, "luma size mismatch");
|
||||||
|
|
||||||
|
let dark: Vec<bool> = luma.iter().map(|&p| p < params.threshold).collect();
|
||||||
|
let components = connected_components(&dark, width, height, params.min_area, params.connectivity);
|
||||||
|
|
||||||
|
components.into_iter().enumerate().map(|(id, pixels)| {
|
||||||
|
let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
||||||
|
let contour = trace_contour(&pixel_set);
|
||||||
|
let simplified = rdp_simplify(&contour, params.rdp_epsilon);
|
||||||
|
|
||||||
|
let (mut xn, mut yn) = (u32::MAX, u32::MAX);
|
||||||
|
let (mut xx, mut yx) = (0u32, 0u32);
|
||||||
|
let mut lum_sum = 0u64;
|
||||||
|
let mut color_sum = [0u64; 3];
|
||||||
|
for &(x, y) in &pixels {
|
||||||
|
xn = xn.min(x); yn = yn.min(y);
|
||||||
|
xx = xx.max(x); yx = yx.max(y);
|
||||||
|
lum_sum += luma[(y * width + x) as usize] as u64;
|
||||||
|
let p = rgb.get_pixel(x, y);
|
||||||
|
color_sum[0] += p[0] as u64;
|
||||||
|
color_sum[1] += p[1] as u64;
|
||||||
|
color_sum[2] += p[2] as u64;
|
||||||
|
}
|
||||||
|
let n = pixels.len() as u64;
|
||||||
|
let avg_color = [(color_sum[0]/n) as u8, (color_sum[1]/n) as u8, (color_sum[2]/n) as u8];
|
||||||
|
|
||||||
|
Hull {
|
||||||
|
id: id as u32,
|
||||||
|
area: pixels.len() as u32,
|
||||||
|
avg_luminance: lum_sum as f32 / n as f32,
|
||||||
|
avg_color,
|
||||||
|
bounds: Bounds { x_min: xn, y_min: yn, x_max: xx, y_max: yx },
|
||||||
|
pixels,
|
||||||
|
contour,
|
||||||
|
simplified,
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connected components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn connected_components(
|
||||||
|
dark: &[bool],
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
min_area: u32,
|
||||||
|
connectivity: Connectivity,
|
||||||
|
) -> Vec<Vec<(u32, u32)>> {
|
||||||
|
let mut visited = vec![false; dark.len()];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
// 4-connected neighbors: N, S, E, W
|
||||||
|
const NEIGHBORS_4: [(i32, i32); 4] = [(0, -1), (0, 1), (1, 0), (-1, 0)];
|
||||||
|
// 8-connected neighbors: all 8 directions
|
||||||
|
const NEIGHBORS_8: [(i32, i32); 8] = [
|
||||||
|
(-1, -1), (0, -1), (1, -1),
|
||||||
|
(-1, 0), (1, 0),
|
||||||
|
(-1, 1), (0, 1), (1, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
for sy in 0..h {
|
||||||
|
for sx in 0..w {
|
||||||
|
let idx = (sy * w + sx) as usize;
|
||||||
|
if !dark[idx] || visited[idx] { continue; }
|
||||||
|
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
let mut pixels = Vec::new();
|
||||||
|
visited[idx] = true;
|
||||||
|
queue.push_back((sx, sy));
|
||||||
|
|
||||||
|
while let Some((cx, cy)) = queue.pop_front() {
|
||||||
|
pixels.push((cx, cy));
|
||||||
|
let neighbors: &[(i32, i32)] = match connectivity {
|
||||||
|
Connectivity::Four => &NEIGHBORS_4,
|
||||||
|
Connectivity::Eight => &NEIGHBORS_8,
|
||||||
|
};
|
||||||
|
for &(dx, dy) in neighbors {
|
||||||
|
let nx = cx as i32 + dx;
|
||||||
|
let ny = cy as i32 + dy;
|
||||||
|
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { continue; }
|
||||||
|
let ni = (ny as u32 * w + nx as u32) as usize;
|
||||||
|
if dark[ni] && !visited[ni] {
|
||||||
|
visited[ni] = true;
|
||||||
|
queue.push_back((nx as u32, ny as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pixels.len() as u32 >= min_area {
|
||||||
|
out.push(pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moore boundary tracing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 8-neighbors in clockwise order starting from West.
|
||||||
|
const DIRS: [(i32, i32); 8] = [
|
||||||
|
(-1, 0), // 0 W
|
||||||
|
(-1, -1), // 1 NW
|
||||||
|
( 0, -1), // 2 N
|
||||||
|
( 1, -1), // 3 NE
|
||||||
|
( 1, 0), // 4 E
|
||||||
|
( 1, 1), // 5 SE
|
||||||
|
( 0, 1), // 6 S
|
||||||
|
(-1, 1), // 7 SW
|
||||||
|
];
|
||||||
|
|
||||||
|
fn step(p: (u32, u32), dir: usize) -> Option<(u32, u32)> {
|
||||||
|
let x = p.0 as i32 + DIRS[dir].0;
|
||||||
|
let y = p.1 as i32 + DIRS[dir].1;
|
||||||
|
if x >= 0 && y >= 0 { Some((x as u32, y as u32)) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an ordered sequence of boundary pixels. Closed: last pixel is
|
||||||
|
/// 8-adjacent to the first. Interior pixels are never included.
|
||||||
|
///
|
||||||
|
/// Algorithm: Moore neighbourhood tracing with the (dir+5)%8 backtrack rule.
|
||||||
|
/// Starting pixel = topmost row, leftmost in that row — guarantees the pixel
|
||||||
|
/// to the west is always background, so from_dir=0 (W) is the correct start.
|
||||||
|
pub(crate) fn trace_contour(component: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
|
||||||
|
if component.is_empty() { return vec![]; }
|
||||||
|
if component.len() == 1 { return component.iter().copied().collect(); }
|
||||||
|
|
||||||
|
let start = component.iter().copied()
|
||||||
|
.min_by_key(|(x, y)| (*y, *x))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut p = start;
|
||||||
|
let mut from_dir = 0usize; // W is background at the topmost-leftmost pixel
|
||||||
|
let mut contour = Vec::new();
|
||||||
|
// Safety cap: a contour can visit each pixel at most twice for thin shapes.
|
||||||
|
let max_steps = component.len() * 4 + 8;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
contour.push(p);
|
||||||
|
|
||||||
|
// Find next boundary pixel: scan CW from from_dir.
|
||||||
|
let mut moved = false;
|
||||||
|
for i in 0..8 {
|
||||||
|
let dir = (from_dir + i) % 8;
|
||||||
|
if let Some(next) = step(p, dir) {
|
||||||
|
if component.contains(&next) {
|
||||||
|
from_dir = (dir + 5) % 8; // backtrack update
|
||||||
|
p = next;
|
||||||
|
moved = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !moved || p == start || contour.len() >= max_steps { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
contour
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ramer-Douglas-Peucker ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn rdp_simplify(pts: &[(u32, u32)], epsilon: f32) -> Vec<(f32, f32)> {
|
||||||
|
let fp: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
|
||||||
|
if fp.len() <= 2 { return fp; }
|
||||||
|
rdp_rec(&fp, epsilon)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rdp_rec(pts: &[(f32, f32)], eps: f32) -> Vec<(f32, f32)> {
|
||||||
|
if pts.len() <= 2 { return pts.to_vec(); }
|
||||||
|
|
||||||
|
let (first, last) = (pts[0], *pts.last().unwrap());
|
||||||
|
let (mut dmax, mut idx) = (0f32, 0);
|
||||||
|
|
||||||
|
for (i, &p) in pts[1..pts.len() - 1].iter().enumerate() {
|
||||||
|
let d = perp_dist(p, first, last);
|
||||||
|
if d > dmax { dmax = d; idx = i + 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if dmax > eps {
|
||||||
|
let mut out = rdp_rec(&pts[..=idx], eps);
|
||||||
|
out.pop();
|
||||||
|
out.extend(rdp_rec(&pts[idx..], eps));
|
||||||
|
out
|
||||||
|
} else {
|
||||||
|
vec![first, last]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn perp_dist(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||||
|
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-10 {
|
||||||
|
return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
|
||||||
|
}
|
||||||
|
let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2;
|
||||||
|
let cx = a.0 + t.clamp(0.0, 1.0) * dx;
|
||||||
|
let cy = a.1 + t.clamp(0.0, 1.0) * dy;
|
||||||
|
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Blank white RGB image for tests that don't care about colour.
|
||||||
|
pub fn blank_rgb(w: u32, h: u32) -> image::RgbImage {
|
||||||
|
image::RgbImage::from_pixel(w, h, image::Rgb([255, 255, 255]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// White canvas with the given pixels set to black (0).
|
||||||
|
pub fn make_image(w: u32, h: u32, dark: &[(u32, u32)]) -> Vec<u8> {
|
||||||
|
let mut luma = vec![255u8; (w * h) as usize];
|
||||||
|
for &(x, y) in dark {
|
||||||
|
assert!(x < w && y < h, "test pixel ({x},{y}) out of bounds {w}×{h}");
|
||||||
|
luma[(y * w + x) as usize] = 0;
|
||||||
|
}
|
||||||
|
luma
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filled rectangle (inclusive bounds).
|
||||||
|
pub fn filled_rect(x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<(u32, u32)> {
|
||||||
|
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1px border of a rectangle (outline only).
|
||||||
|
pub fn rect_outline(x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<(u32, u32)> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for x in x0..=x1 { v.push((x, y0)); v.push((x, y1)); }
|
||||||
|
for y in y0+1..y1 { v.push((x0, y)); v.push((x1, y)); }
|
||||||
|
v.dedup();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert pixel-perfect coverage: every dark pixel ↔ exactly one hull pixel.
|
||||||
|
/// Uses `min_area=1` so no pixels are excluded by the noise filter.
|
||||||
|
pub fn assert_full_coverage(luma: &[u8], w: u32, h: u32, hulls: &[Hull], thresh: u8) {
|
||||||
|
let dark: HashSet<(u32, u32)> = (0..h)
|
||||||
|
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||||
|
.filter(|&(x, y)| luma[(y * w + x) as usize] < thresh)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let hull_px: HashSet<(u32, u32)> = hulls.iter()
|
||||||
|
.flat_map(|h| h.pixels.iter().copied())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Every dark pixel must be claimed by exactly one hull.
|
||||||
|
let missed: Vec<_> = dark.difference(&hull_px).take(5).collect();
|
||||||
|
assert!(missed.is_empty(),
|
||||||
|
"{} dark pixels not in any hull (examples: {:?})", dark.len() - hull_px.len().min(dark.len()), missed);
|
||||||
|
|
||||||
|
// No hull pixel may be light in the source.
|
||||||
|
let extra: Vec<_> = hull_px.difference(&dark).take(5).collect();
|
||||||
|
assert!(extra.is_empty(),
|
||||||
|
"{} hull pixels are light in source image (examples: {:?})", extra.len(), extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert every contour pixel is a boundary pixel of its hull
|
||||||
|
/// (i.e. it's in the component AND has at least one 4-connected non-component neighbor).
|
||||||
|
pub fn assert_contour_on_boundary(hull: &Hull) {
|
||||||
|
let set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
|
||||||
|
for &(cx, cy) in &hull.contour {
|
||||||
|
assert!(set.contains(&(cx, cy)),
|
||||||
|
"contour pixel ({cx},{cy}) not in hull pixels");
|
||||||
|
let is_bdry = [(-1i32, 0i32), (1, 0), (0, -1), (0, 1)].iter().any(|(dx, dy)| {
|
||||||
|
let nx = cx as i32 + dx;
|
||||||
|
let ny = cy as i32 + dy;
|
||||||
|
nx < 0 || ny < 0 || !set.contains(&(nx as u32, ny as u32))
|
||||||
|
});
|
||||||
|
assert!(is_bdry, "contour pixel ({cx},{cy}) is interior, not boundary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component / coverage tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_filled_square_one_hull() {
|
||||||
|
let dark = filled_rect(10, 10, 29, 29); // 20×20
|
||||||
|
let luma = make_image(64, 64, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
|
assert_eq!(hulls.len(), 1, "one square → one hull");
|
||||||
|
assert_eq!(hulls[0].area, 400, "20×20 = 400 px");
|
||||||
|
assert_full_coverage(&luma, 64, 64, &hulls, p.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_separate_squares_two_hulls() {
|
||||||
|
let mut dark = filled_rect(2, 2, 11, 11); // 10×10
|
||||||
|
dark.extend(filled_rect(20, 20, 29, 29)); // 10×10, separated by gap
|
||||||
|
let luma = make_image(40, 40, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(40, 40), 40, 40, &p);
|
||||||
|
assert_eq!(hulls.len(), 2, "two squares → two hulls");
|
||||||
|
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
||||||
|
areas.sort_unstable();
|
||||||
|
assert_eq!(areas, [100, 100]);
|
||||||
|
assert_full_coverage(&luma, 40, 40, &hulls, p.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_pixel_hull() {
|
||||||
|
let luma = make_image(16, 16, &[(8, 8)]);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 0.5, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
|
assert_eq!(hulls.len(), 1);
|
||||||
|
assert_eq!(hulls[0].area, 1);
|
||||||
|
assert_full_coverage(&luma, 16, 16, &hulls, p.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_dark_image_one_hull() {
|
||||||
|
let luma = vec![0u8; 16 * 16];
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
|
assert_eq!(hulls.len(), 1);
|
||||||
|
assert_eq!(hulls[0].area, 256);
|
||||||
|
assert_full_coverage(&luma, 16, 16, &hulls, p.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_white_image_no_hulls() {
|
||||||
|
let luma = vec![255u8; 16 * 16];
|
||||||
|
let p = HullParams::default();
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
|
assert_eq!(hulls.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_area_filters_noise() {
|
||||||
|
let mut dark = filled_rect(10, 10, 19, 19); // 100px real feature
|
||||||
|
dark.push((0, 0)); // 1px noise
|
||||||
|
dark.push((31, 31)); // 1px noise
|
||||||
|
let luma = make_image(32, 32, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 4, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
||||||
|
assert_eq!(hulls.len(), 1, "min_area=4 must remove single-pixel noise");
|
||||||
|
assert_eq!(hulls[0].area, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horizontal_line_one_hull() {
|
||||||
|
let dark: Vec<(u32, u32)> = (0..64u32)
|
||||||
|
.flat_map(|x| [31u32, 32, 33].iter().map(move |&y| (x, y)))
|
||||||
|
.collect();
|
||||||
|
let luma = make_image(64, 64, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
|
assert_eq!(hulls.len(), 1, "horizontal line → one hull");
|
||||||
|
assert_eq!(hulls[0].area, 64 * 3);
|
||||||
|
assert_full_coverage(&luma, 64, 64, &hulls, p.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_horizontal_lines_three_hulls() {
|
||||||
|
let dark: Vec<(u32, u32)> = [16u32, 32, 48].iter()
|
||||||
|
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
||||||
|
.collect();
|
||||||
|
let luma = make_image(64, 64, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
|
assert_eq!(hulls.len(), 3, "three separate lines → three hulls");
|
||||||
|
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
||||||
|
areas.sort_unstable();
|
||||||
|
assert_eq!(areas, [64, 64, 64]);
|
||||||
|
assert_full_coverage(&luma, 64, 64, &hulls, p.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagonal_touch_is_two_hulls_with_4_connectivity() {
|
||||||
|
// Two squares connected only diagonally.
|
||||||
|
// With 4-connectivity they must be two separate hulls.
|
||||||
|
let mut dark = filled_rect(0, 0, 4, 4);
|
||||||
|
dark.extend(filled_rect(5, 5, 9, 9)); // touches corner (4,4)↔(5,5) diagonally
|
||||||
|
let luma = make_image(16, 16, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, connectivity: Connectivity::Four };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
|
assert_eq!(hulls.len(), 2, "diagonally touching squares = two 4-connected hulls");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagonal_touch_is_one_hull_with_8_connectivity() {
|
||||||
|
// Same setup but 8-connected → one component.
|
||||||
|
let mut dark = filled_rect(0, 0, 4, 4);
|
||||||
|
dark.extend(filled_rect(5, 5, 9, 9));
|
||||||
|
let luma = make_image(16, 16, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, connectivity: Connectivity::Eight };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
|
assert_eq!(hulls.len(), 1, "diagonally touching squares = one 8-connected hull");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contour / boundary tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contour_pixels_are_on_boundary() {
|
||||||
|
let dark = filled_rect(5, 5, 20, 20);
|
||||||
|
let luma = make_image(32, 32, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 0.5, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
||||||
|
assert_eq!(hulls.len(), 1);
|
||||||
|
assert!(!hulls[0].contour.is_empty());
|
||||||
|
assert_contour_on_boundary(&hulls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn square_rdp_yields_four_corners() {
|
||||||
|
// 40×40 square starting at (10,10) → corners at (10,10),(49,10),(49,49),(10,49)
|
||||||
|
let dark = filled_rect(10, 10, 49, 49);
|
||||||
|
let luma = make_image(64, 64, &dark);
|
||||||
|
// epsilon=2.0: straight edges collapse, only corners survive
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 2.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
|
assert_eq!(hulls.len(), 1);
|
||||||
|
let n = hulls[0].simplified.len();
|
||||||
|
assert!(n >= 4 && n <= 6,
|
||||||
|
"40×40 square should simplify to 4 corners (±1 for loop endpoint), got {n}: {:?}",
|
||||||
|
hulls[0].simplified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_line_rdp_yields_two_endpoints() {
|
||||||
|
// Thin 1px horizontal line — only start and end should survive RDP
|
||||||
|
let dark: Vec<(u32, u32)> = (0..64u32).map(|x| (x, 32)).collect();
|
||||||
|
let luma = make_image(64, 64, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
|
assert_eq!(hulls.len(), 1);
|
||||||
|
let n = hulls[0].simplified.len();
|
||||||
|
// A straight line: start + end = 2 points, maybe 3-4 for the thin-hull two-sided contour
|
||||||
|
assert!(n <= 6,
|
||||||
|
"straight line should simplify to very few points, got {n}: {:?}",
|
||||||
|
hulls[0].simplified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Coverage comparison ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Reconstruct a binary mask from hull pixels and compare with original.
|
||||||
|
/// Returns (precision, recall) where 1.0 = perfect.
|
||||||
|
pub fn coverage_score(luma: &[u8], w: u32, h: u32, hulls: &[Hull], thresh: u8) -> (f32, f32) {
|
||||||
|
let dark_count = luma.iter().filter(|&&p| p < thresh).count();
|
||||||
|
if dark_count == 0 { return (1.0, 1.0); }
|
||||||
|
|
||||||
|
let dark: HashSet<(u32, u32)> = (0..h)
|
||||||
|
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||||
|
.filter(|&(x, y)| luma[(y * w + x) as usize] < thresh)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let hull_px: HashSet<(u32, u32)> = hulls.iter()
|
||||||
|
.flat_map(|h| h.pixels.iter().copied())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let true_pos = dark.intersection(&hull_px).count();
|
||||||
|
let precision = true_pos as f32 / hull_px.len().max(1) as f32;
|
||||||
|
let recall = true_pos as f32 / dark_count as f32;
|
||||||
|
(precision, recall)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_score_perfect_for_square() {
|
||||||
|
let dark = filled_rect(5, 5, 25, 25);
|
||||||
|
let luma = make_image(32, 32, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
||||||
|
let (prec, rec) = coverage_score(&luma, 32, 32, &hulls, p.threshold);
|
||||||
|
assert_eq!(prec, 1.0, "precision must be 1.0: no hull pixel is light");
|
||||||
|
assert_eq!(rec, 1.0, "recall must be 1.0: every dark pixel is in a hull");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_score_perfect_for_three_lines() {
|
||||||
|
let dark: Vec<(u32, u32)> = [16u32, 32, 48].iter()
|
||||||
|
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
||||||
|
.collect();
|
||||||
|
let luma = make_image(64, 64, &dark);
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
|
let (prec, rec) = coverage_score(&luma, 64, 64, &hulls, p.threshold);
|
||||||
|
assert_eq!(prec, 1.0);
|
||||||
|
assert_eq!(rec, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Checkerboard integration test ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 512×512 checkerboard with 32×32 cells → 16×16 = 256 cells total.
|
||||||
|
/// Half are dark (value=30 < 128) and half light (value=240 ≥ 128).
|
||||||
|
/// With 4-connectivity the dark cells must not touch diagonally,
|
||||||
|
/// so each 32×32 dark cell is its own hull → 128 hulls.
|
||||||
|
#[test]
|
||||||
|
fn checkerboard_512_gives_128_hulls() {
|
||||||
|
let dyn_img = image::open("resources/images/test_checker_512.png")
|
||||||
|
.expect("run `cargo run --bin gen_test_assets` first");
|
||||||
|
let img = dyn_img.to_luma8();
|
||||||
|
let rgb = dyn_img.to_rgb8();
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let params = HullParams { threshold: 128, min_area: 4, ..HullParams::default() };
|
||||||
|
let hulls = extract_hulls(img.as_raw(), &rgb, w, h, ¶ms);
|
||||||
|
assert_eq!(hulls.len(), 128, "checkerboard has 128 dark cells");
|
||||||
|
for hull in &hulls {
|
||||||
|
assert_eq!(hull.area, 1024, "each 32×32 cell = 1024 px");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1351
src/lib.rs
Normal file
332
src/main.rs
@@ -1,330 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
// Prevents an extra console window on Windows in release
|
||||||
#![allow(unused_variables)]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
#![allow(unused_mut)]
|
|
||||||
|
|
||||||
|
fn main() {
|
||||||
extern crate cgmath;
|
trac3r_lib::run();
|
||||||
extern crate hprof;
|
|
||||||
extern crate image;
|
|
||||||
extern crate nalgebra as na;
|
|
||||||
extern crate rand;
|
|
||||||
extern crate time;
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use gilrs::{Button, Event as GilEvent, Gamepad, GamepadId, Gilrs};
|
|
||||||
use vulkano::instance::debug::DebugCallback;
|
|
||||||
use vulkano::instance::Instance;
|
|
||||||
use vulkano::sync;
|
|
||||||
use vulkano::sync::GpuFuture;
|
|
||||||
use vulkano_win::VkSurfaceBuild;
|
|
||||||
use winit::dpi::LogicalSize;
|
|
||||||
use winit::event::{DeviceEvent, ElementState, Event, MouseButton, StartCause, VirtualKeyCode, WindowEvent};
|
|
||||||
use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
|
|
||||||
use winit::platform::unix::WindowBuilderExtUnix;
|
|
||||||
use winit::window::WindowBuilder;
|
|
||||||
|
|
||||||
use crate::canvas::canvas_frame::{CanvasFrame, Drawable, Eventable, Updatable};
|
|
||||||
use crate::canvas::canvas_state::CanvasState;
|
|
||||||
use crate::canvas::managed::handles::{CanvasFontHandle, CanvasTextureHandle, Handle};
|
|
||||||
use crate::compute::compu_frame::CompuFrame;
|
|
||||||
use crate::compute::managed::handles::{CompuBufferHandle, CompuKernelHandle};
|
|
||||||
use crate::drawables::compu_sprite::CompuSprite;
|
|
||||||
use crate::drawables::rect::Rect;
|
|
||||||
use crate::drawables::sprite::Sprite;
|
|
||||||
use crate::drawables::text::Text;
|
|
||||||
use crate::util::load_raw;
|
|
||||||
use crate::util::timer::Timer;
|
|
||||||
use crate::util::tr_event::TrEvent;
|
|
||||||
use crate::util::vertex::{TextureVertex3D, VertexType};
|
|
||||||
use crate::vkprocessor::VkProcessor;
|
|
||||||
use crate::drawables::slider::Slider;
|
|
||||||
|
|
||||||
pub mod util;
|
|
||||||
pub mod vkprocessor;
|
|
||||||
pub mod drawables;
|
|
||||||
pub mod canvas;
|
|
||||||
pub mod compute;
|
|
||||||
|
|
||||||
|
|
||||||
pub fn main() {
|
|
||||||
hprof::start_frame();
|
|
||||||
|
|
||||||
let q1 = hprof::enter("setup");
|
|
||||||
|
|
||||||
let instance = {
|
|
||||||
let extensions = vulkano_win::required_extensions();
|
|
||||||
Instance::new(None, &extensions, None).unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
let _callback = DebugCallback::errors_and_warnings(&instance, |msg| {
|
|
||||||
println!("Debug callback: {:?}", msg.description);
|
|
||||||
}).ok();
|
|
||||||
|
|
||||||
let mut events_loop = EventLoop::<TrEvent>::with_user_event();
|
|
||||||
|
|
||||||
let mut surface = WindowBuilder::new()
|
|
||||||
.with_inner_size(LogicalSize::new(800, 800))
|
|
||||||
.build_vk_surface(&events_loop, instance.clone()).unwrap();
|
|
||||||
|
|
||||||
let mut processor = VkProcessor::new(instance.clone(), surface.clone());
|
|
||||||
|
|
||||||
{
|
|
||||||
let g = hprof::enter("vulkan preload");
|
|
||||||
processor.create_swapchain(instance.clone(), surface.clone());
|
|
||||||
processor.preload_kernels();
|
|
||||||
processor.preload_shaders();
|
|
||||||
processor.preload_textures();
|
|
||||||
processor.preload_fonts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let q2 = hprof::enter("Game Objects");
|
|
||||||
|
|
||||||
let mut timer = Timer::new();
|
|
||||||
let mut frame_future: Box<dyn GpuFuture> =
|
|
||||||
Box::new(sync::now(processor.device.clone())) as Box<dyn GpuFuture>;
|
|
||||||
|
|
||||||
let step_size: f32 = 0.005;
|
|
||||||
let mut elapsed_time: f32 = timer.elap_time();
|
|
||||||
;
|
|
||||||
let mut delta_time: f32 = 0.0;
|
|
||||||
let mut accumulator_time: f32 = 0.0;
|
|
||||||
let mut current_time: f32 = timer.elap_time();
|
|
||||||
|
|
||||||
let image_data = load_raw(String::from("ford2.jpg"));
|
|
||||||
let image_dimensions_f: (f32, f32) = ((image_data.1).clone().0 as f32, (image_data.1).clone().1 as f32);
|
|
||||||
let image_dimensions_u: (u32, u32) = image_data.1;
|
|
||||||
let compu_sprite1: CompuSprite =
|
|
||||||
CompuSprite::new((-1.0, -1.0), (1.0, 1.0), 0, image_dimensions_f,
|
|
||||||
// Swap image to render the result to. Must match dimensions
|
|
||||||
processor.new_swap_image(image_dimensions_u));
|
|
||||||
|
|
||||||
// Need to
|
|
||||||
let compute_buffer: Arc<CompuBufferHandle> =
|
|
||||||
processor.new_compute_buffer(image_data.0.clone(), image_data.1, 4);
|
|
||||||
|
|
||||||
let first_output_buffer: Arc<CompuBufferHandle> =
|
|
||||||
processor.new_compute_buffer(image_data.0.clone(), image_data.1.clone(), 4);
|
|
||||||
|
|
||||||
let compute_kernel: Arc<CompuKernelHandle> =
|
|
||||||
processor.get_kernel_handle(String::from("simple-edge.compute"))
|
|
||||||
.expect("Can't find that kernel");
|
|
||||||
|
|
||||||
// Get the handles for the assets
|
|
||||||
let funky_handle: Arc<CanvasTextureHandle> =
|
|
||||||
processor.get_texture_handle(String::from("funky-bird.jpg")).unwrap();
|
|
||||||
let sfml_handle: Arc<CanvasTextureHandle> =
|
|
||||||
processor.get_texture_handle(String::from("sfml.png")).unwrap();
|
|
||||||
//let font_handle : Arc<CanvasFontHandle> =
|
|
||||||
// processor.get_font_handle(String::from("sansation.ttf")).unwrap();
|
|
||||||
|
|
||||||
let mut funky_sprite = Sprite::new(
|
|
||||||
(200.0, 200.0),
|
|
||||||
(100.0, 150.0), 10, funky_handle.clone());
|
|
||||||
let sfml_sprite = Sprite::new((0.0, -0.5), (0.5, 0.5), 1, sfml_handle.clone());
|
|
||||||
|
|
||||||
let slider = Slider::new((300.0, 50.0), (550.0, 100.0), 30000);
|
|
||||||
|
|
||||||
//let sfml_sprite = Sprite::new((0.0, -0.5), (0.5, 0.5), 1, sfml_handle.clone());
|
|
||||||
//let text_sprite = Text::new((-0.1, -0.1), (10.0, 10.0), 1);
|
|
||||||
//let test_polygon = Poly::new_with_color((-0.5, -0.5), (0.5, 0.5), 1, (1.0,0.0,0.0,0.0));
|
|
||||||
|
|
||||||
drop(q2);
|
|
||||||
drop(q1);
|
|
||||||
|
|
||||||
let l = hprof::enter("Loop");
|
|
||||||
|
|
||||||
let event_loop_proxy = events_loop.create_proxy();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut gilrs = Gilrs::new().unwrap();
|
|
||||||
// Iterate over all connected gamepads
|
|
||||||
let mut gamepad: Option<Gamepad> = None;
|
|
||||||
for (_id, gamepad_) in gilrs.gamepads() {
|
|
||||||
if gamepad_.name() == "PS4" {
|
|
||||||
gamepad = Some(gamepad_);
|
|
||||||
}
|
|
||||||
println!("{} is {:?} {:?}", gamepad_.name(), gamepad_.power_info(), gamepad_.id());
|
|
||||||
}
|
|
||||||
let mut active_gamepad = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
while let Some(GilEvent { id, event, time }) = gilrs.next_event() {
|
|
||||||
println!("{:?} New event from {}: {:?}", time, id, event);
|
|
||||||
active_gamepad = Some(id);
|
|
||||||
event_loop_proxy.send_event(TrEvent::GamepadEvent {
|
|
||||||
gil_event: GilEvent { id, event, time }
|
|
||||||
}).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// // You can also use cached gamepad state
|
|
||||||
// if let Some(gamepad) = active_gamepad.map(|id| gilrs.gamepad(id)) {
|
|
||||||
// if gamepad.is_pressed(Button::South) {
|
|
||||||
// println!("Button South is pressed (XBox - A, PS - X)");
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut window_size: (u32, u32) = (0, 0);
|
|
||||||
|
|
||||||
let mut canvas_frame = CanvasFrame::new(window_size);
|
|
||||||
let mut compu_frame = CompuFrame::new(window_size);
|
|
||||||
|
|
||||||
let mut big_container = vec![
|
|
||||||
Box::new(Slider::new((0.1, 0.1), (0.9, 0.9), 5000)),
|
|
||||||
Box::new(Sprite::new((0.0, -0.5), (0.5, 0.5), 1, sfml_handle.clone())),
|
|
||||||
];
|
|
||||||
//container.push(Sprite::new((0.1)));
|
|
||||||
|
|
||||||
|
|
||||||
// Events loop is borrowed from the surface
|
|
||||||
events_loop.run(move |event, _, control_flow| {
|
|
||||||
*control_flow = ControlFlow::Poll;
|
|
||||||
|
|
||||||
for eventable in &mut big_container {
|
|
||||||
eventable.notify(&event);
|
|
||||||
}
|
|
||||||
|
|
||||||
for drawable in &mut big_container {
|
|
||||||
canvas_frame.draw(&drawable);
|
|
||||||
}
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::NewEvents(cause) => {
|
|
||||||
if cause == StartCause::Init {
|
|
||||||
canvas_frame.draw(&funky_sprite);
|
|
||||||
canvas_frame.draw(&compu_sprite1);
|
|
||||||
canvas_frame.draw(&slider);
|
|
||||||
|
|
||||||
window_size = surface.window().inner_size().into();
|
|
||||||
}
|
|
||||||
elapsed_time = timer.elap_time();
|
|
||||||
delta_time = elapsed_time - current_time;
|
|
||||||
current_time = elapsed_time;
|
|
||||||
if delta_time > 0.02 {
|
|
||||||
delta_time = 0.02;
|
|
||||||
}
|
|
||||||
accumulator_time += delta_time;
|
|
||||||
}
|
|
||||||
Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => {
|
|
||||||
*control_flow = ControlFlow::Exit
|
|
||||||
}
|
|
||||||
Event::WindowEvent { event: WindowEvent::Resized(new_size), .. } => {
|
|
||||||
processor.swapchain_recreate_needed = true;
|
|
||||||
let size = (new_size.width, new_size.height);
|
|
||||||
}
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::MouseInput {
|
|
||||||
device_id, state, button, modifiers
|
|
||||||
}, ..
|
|
||||||
} => {}
|
|
||||||
Event::UserEvent(TrEvent::KeyHeldEvent {}) => {}
|
|
||||||
Event::UserEvent(TrEvent::MouseHeldEvent {}) => {}
|
|
||||||
Event::UserEvent(TrEvent::GamepadEvent { gil_event }) => {}
|
|
||||||
Event::DeviceEvent { device_id, event } => {
|
|
||||||
match event {
|
|
||||||
DeviceEvent::Key(keyboard_input) => {
|
|
||||||
match keyboard_input.virtual_keycode.unwrap() {
|
|
||||||
VirtualKeyCode::A => {
|
|
||||||
if keyboard_input.state == ElementState::Pressed {}
|
|
||||||
}
|
|
||||||
VirtualKeyCode::S => {
|
|
||||||
if keyboard_input.state == ElementState::Pressed {}
|
|
||||||
}
|
|
||||||
VirtualKeyCode::P => {
|
|
||||||
if keyboard_input.state == ElementState::Pressed {
|
|
||||||
let data = processor.read_compute_buffer(compute_buffer.clone());
|
|
||||||
image::save_buffer(&Path::new("image.png"), data.as_slice(), (image_data.1).0, (image_data.1).1, image::RGBA(8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => ()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Event::MainEventsCleared => {
|
|
||||||
funky_sprite.update(delta_time);
|
|
||||||
|
|
||||||
canvas_frame = CanvasFrame::new(window_size);
|
|
||||||
canvas_frame.draw(&funky_sprite);
|
|
||||||
//canvas_frame.draw(&container);
|
|
||||||
// canvas_frame.draw(&compu_sprite1);
|
|
||||||
canvas_frame.draw(&slider);
|
|
||||||
|
|
||||||
compu_frame = CompuFrame::new(window_size);
|
|
||||||
// compu_frame.add_with_image_swap(compute_buffer.clone(), compute_kernel.clone(), &compu_sprite1);
|
|
||||||
// compu_frame.add(compute_buffer.clone(), compute_kernel.clone());
|
|
||||||
|
|
||||||
{
|
|
||||||
let g = hprof::enter("Run");
|
|
||||||
processor.run(&surface.clone(),
|
|
||||||
&canvas_frame,
|
|
||||||
&compu_frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// while (accumulator_time - step_size) >= step_size {
|
|
||||||
// accumulator_time -= step_size;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
_ => ()
|
|
||||||
}
|
|
||||||
|
|
||||||
// bucket the events out, but not really
|
|
||||||
// match
|
|
||||||
// event {
|
|
||||||
// Event::NewEvents(_) => {}
|
|
||||||
// Event::WindowEvent { window_id, event } => {}
|
|
||||||
// Event::DeviceEvent { device_id, event } => {}
|
|
||||||
// Event::UserEvent(tr_event) => {}
|
|
||||||
// Event::Suspended => {}
|
|
||||||
// Event::Resumed => {}
|
|
||||||
// Event::MainEventsCleared => {}
|
|
||||||
// Event::RedrawRequested(_) => {}
|
|
||||||
// Event::RedrawEventsCleared => {}
|
|
||||||
// Event::LoopDestroyed => {}
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
|
|
||||||
drop(l);
|
|
||||||
|
|
||||||
hprof::end_frame();
|
|
||||||
hprof::profiler().print_timing();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub fn click_test(event_loop_proxy: EventLoopProxy<TrEvent>, canvas_state: &CanvasState) {
|
|
||||||
// for i in canvas_state. {
|
|
||||||
// event_loop_proxy.send_event(TrEvent::MouseClickEvent {
|
|
||||||
// position: (0.0, 0.0),
|
|
||||||
// button: MouseButton::Left,
|
|
||||||
// }).ok();
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
203
src/pipeline_bench.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/// Headless pipeline benchmark — times every stage that the UI IPC calls exercise.
|
||||||
|
/// cargo run --bin pipeline_bench -- [image_path]
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
use image::GenericImageView;
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
|
||||||
|
use trac3r_lib::detect::{DetectionParams, DetectionLayer, DetectionKernel, apply_stack};
|
||||||
|
use trac3r_lib::hulls::{HullParams, Connectivity, extract_hulls};
|
||||||
|
use trac3r_lib::fill::{parallel_hatch, smooth_fill_result, optimize_travel, FillResult};
|
||||||
|
|
||||||
|
fn t(label: &str, start: Instant) -> Instant {
|
||||||
|
println!(" {:40} {:>6}ms", label, start.elapsed().as_millis());
|
||||||
|
Instant::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let path = std::env::args().nth(1)
|
||||||
|
.unwrap_or_else(|| "resources/images/test_circle_512.png".to_string());
|
||||||
|
println!("=== pipeline_bench: {path} ===\n");
|
||||||
|
|
||||||
|
// ── load_image ────────────────────────────────────────────────────────────
|
||||||
|
println!("[ load_image ]");
|
||||||
|
let now = Instant::now();
|
||||||
|
let dyn_img = image::open(&path).expect("open");
|
||||||
|
let (w, h) = dyn_img.dimensions();
|
||||||
|
let rgb = dyn_img.to_rgb8();
|
||||||
|
let now = t(&format!("open {w}×{h}"), now);
|
||||||
|
let preview_b64 = rgb_to_b64_jpeg(&rgb);
|
||||||
|
let now = t(&format!("preview JPEG ({}KB b64)", preview_b64.len() / 1024), now);
|
||||||
|
drop(now);
|
||||||
|
|
||||||
|
// ── process_pass ──────────────────────────────────────────────────────────
|
||||||
|
println!("\n[ process_pass ]");
|
||||||
|
let now = Instant::now();
|
||||||
|
let params = DetectionParams {
|
||||||
|
layers: vec![DetectionLayer {
|
||||||
|
kernel: DetectionKernel::Luminance, weight: 1.0, invert: false,
|
||||||
|
blur_radius: 0.0, sat_min_value: 0.1,
|
||||||
|
canny_low: 50.0, canny_high: 150.0,
|
||||||
|
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let response = apply_stack(&rgb, ¶ms);
|
||||||
|
let now = t("detect", now);
|
||||||
|
|
||||||
|
let hull_params = HullParams { threshold: 128, min_area: 4, rdp_epsilon: 1.5,
|
||||||
|
connectivity: Connectivity::Four };
|
||||||
|
let hulls = extract_hulls(&response, &rgb, w, h, &hull_params);
|
||||||
|
let total_px: usize = hulls.iter().map(|h| h.pixels.len()).sum();
|
||||||
|
let now = t(&format!("extract_hulls ({} hulls, {} px)", hulls.len(), total_px), now);
|
||||||
|
|
||||||
|
// viz build — full-res rgba buffer (what process_pass returns)
|
||||||
|
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for p in rgba.chunks_mut(4) { p[3] = 255; }
|
||||||
|
for hull in &hulls {
|
||||||
|
let (r, g, b) = hash_color(hull.id);
|
||||||
|
for &(px, py) in &hull.pixels {
|
||||||
|
let i = ((py * w + px) * 4) as usize;
|
||||||
|
rgba[i] = r; rgba[i+1] = g; rgba[i+2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = t("viz rgba build", now);
|
||||||
|
let viz_b64 = rgba_to_b64_jpeg_raw(&rgba, w, h);
|
||||||
|
let now = t(&format!("viz JPEG ({}KB b64)", viz_b64.len() / 1024), now);
|
||||||
|
drop(now);
|
||||||
|
|
||||||
|
// ── get_pass_viz mode=hulls ───────────────────────────────────────────────
|
||||||
|
println!("\n[ get_pass_viz hulls ] (same as process_pass viz — no extra cost)");
|
||||||
|
|
||||||
|
// ── get_pass_viz mode=contours ────────────────────────────────────────────
|
||||||
|
println!("\n[ get_pass_viz contours ]");
|
||||||
|
let now = Instant::now();
|
||||||
|
// Flat array: w*h bytes vs 83MB HashSet
|
||||||
|
let mut occupied = vec![false; (w * h) as usize];
|
||||||
|
for hull in &hulls {
|
||||||
|
for &(px, py) in &hull.pixels {
|
||||||
|
occupied[(py * w + px) as usize] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = t(&format!("build occupied array ({}MB)", w * h / 1_000_000), now);
|
||||||
|
|
||||||
|
let mut rgba_c = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for p in rgba_c.chunks_mut(4) { p[3] = 255; }
|
||||||
|
let mut boundary_count = 0usize;
|
||||||
|
for hull in &hulls {
|
||||||
|
let (r, g, b) = hash_color(hull.id);
|
||||||
|
for &(px, py) in &hull.pixels {
|
||||||
|
let on_boundary = [(-1i32,0i32),(1,0),(0,-1),(0,1)].iter().any(|&(dx,dy)| {
|
||||||
|
let nx = px as i32 + dx;
|
||||||
|
let ny = py as i32 + dy;
|
||||||
|
nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32
|
||||||
|
|| !occupied[(ny as u32 * w + nx as u32) as usize]
|
||||||
|
});
|
||||||
|
if on_boundary {
|
||||||
|
let i = ((py * w + px) * 4) as usize;
|
||||||
|
rgba_c[i] = r; rgba_c[i+1] = g; rgba_c[i+2] = b;
|
||||||
|
boundary_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = t(&format!("contour scan ({boundary_count} boundary px)"), now);
|
||||||
|
let contour_b64 = rgba_to_b64_jpeg_raw(&rgba_c, w, h);
|
||||||
|
let now = t(&format!("contours JPEG ({}KB b64)", contour_b64.len() / 1024), now);
|
||||||
|
drop(now);
|
||||||
|
|
||||||
|
// ── generate_fill ─────────────────────────────────────────────────────────
|
||||||
|
println!("\n[ generate_fill ]");
|
||||||
|
let now = Instant::now();
|
||||||
|
let raw_results: Vec<FillResult> = hulls.iter()
|
||||||
|
.map(|h| parallel_hatch(h, 5.0, 0.0))
|
||||||
|
.collect();
|
||||||
|
let now = t(&format!("hatch gen ({} strokes)", raw_results.iter().map(|r| r.strokes.len()).sum::<usize>()), now);
|
||||||
|
|
||||||
|
let smoothed: Vec<FillResult> = raw_results.iter()
|
||||||
|
.map(|r| smooth_fill_result(r, 1.0, 2))
|
||||||
|
.collect();
|
||||||
|
let now = t("smooth", now);
|
||||||
|
|
||||||
|
let optimized = optimize_travel(&smoothed);
|
||||||
|
let total_strokes = optimized.strokes.len();
|
||||||
|
let total_points: usize = optimized.strokes.iter().map(|s| s.len()).sum();
|
||||||
|
let now = t(&format!("travel opt ({total_strokes} strokes, {total_points} pts)"), now);
|
||||||
|
drop(now);
|
||||||
|
|
||||||
|
// ── get_pass_viz mode=fill (SVG) ──────────────────────────────────────────
|
||||||
|
println!("\n[ get_pass_viz fill ]");
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut svg = format!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}"><rect width="{w}" height="{h}" fill="#1a1a1a"/>"##
|
||||||
|
);
|
||||||
|
for (si, stroke) in optimized.strokes.iter().enumerate() {
|
||||||
|
if stroke.len() < 2 { continue; }
|
||||||
|
let (r, g, b) = hash_color(si as u32 + 1);
|
||||||
|
let step = (stroke.len() / 12).max(1);
|
||||||
|
let first = stroke.first().unwrap();
|
||||||
|
let last = stroke.last().unwrap();
|
||||||
|
let mut d = format!("M{:.1},{:.1}", first.0, first.1);
|
||||||
|
for p in stroke.iter().step_by(step).skip(1) {
|
||||||
|
d.push_str(&format!("L{:.1},{:.1}", p.0, p.1));
|
||||||
|
}
|
||||||
|
let lsp = stroke.iter().step_by(step).last().unwrap();
|
||||||
|
if (last.0 - lsp.0).abs() > 0.5 || (last.1 - lsp.1).abs() > 0.5 {
|
||||||
|
d.push_str(&format!("L{:.1},{:.1}", last.0, last.1));
|
||||||
|
}
|
||||||
|
svg.push_str(&format!(r#"<path d="{d}" stroke="rgb({r},{g},{b})" stroke-width="1.5" fill="none"/>"#));
|
||||||
|
}
|
||||||
|
svg.push_str("</svg>");
|
||||||
|
let fill_svg_b64 = B64.encode(svg.as_bytes());
|
||||||
|
let now = t(&format!("fill SVG build+encode ({}KB b64)", fill_svg_b64.len() / 1024), now);
|
||||||
|
drop(now);
|
||||||
|
|
||||||
|
// ── get_all_strokes (full unsubsampled JSON) ───────────────────────────────
|
||||||
|
println!("\n[ get_all_strokes ]");
|
||||||
|
let now = Instant::now();
|
||||||
|
let strokes_payload: Vec<Vec<[f32;2]>> = optimized.strokes.iter()
|
||||||
|
.map(|s| s.iter().map(|&(x,y)| [x,y]).collect())
|
||||||
|
.collect();
|
||||||
|
let json = serde_json::to_string(&strokes_payload).unwrap();
|
||||||
|
let now = t(&format!("serialize ({}KB JSON)", json.len() / 1024), now);
|
||||||
|
drop(now);
|
||||||
|
|
||||||
|
// ── Summary ───────────────────────────────────────────────────────────────
|
||||||
|
println!("\n=== SUMMARY ===");
|
||||||
|
println!(" image: {w}×{h} ({} hull px)", total_px);
|
||||||
|
println!(" hull_set size: ~{}MB", hulls.iter().flat_map(|h| h.pixels.iter()).count() * 28 / 1_000_000);
|
||||||
|
println!(" fill strokes: {total_strokes} ({total_points} total points)");
|
||||||
|
println!(" getAllStrokes: {}KB JSON", json.len() / 1024);
|
||||||
|
println!(" fill SVG: {}KB b64", fill_svg_b64.len() / 1024);
|
||||||
|
println!(" contours JPEG: {}KB b64", contour_b64.len() / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_color(id: u32) -> (u8, u8, u8) {
|
||||||
|
let h = id.wrapping_mul(2654435761).wrapping_add(id.wrapping_mul(0x9e3779b9));
|
||||||
|
((h >> 24) as u8, (h >> 16) as u8, (h >> 8) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String {
|
||||||
|
let (w, h) = rgb.dimensions();
|
||||||
|
const MAX: u32 = 1024;
|
||||||
|
let out = if w > MAX || h > MAX {
|
||||||
|
let scale = MAX as f32 / w.max(h) as f32;
|
||||||
|
image::imageops::resize(rgb, (w as f32 * scale) as u32, (h as f32 * scale) as u32,
|
||||||
|
image::imageops::FilterType::Nearest)
|
||||||
|
} else { rgb.clone() };
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
out.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
|
||||||
|
B64.encode(buf.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgba_to_b64_jpeg_raw(rgba: &[u8], w: u32, h: u32) -> String {
|
||||||
|
const MAX: u32 = 1024;
|
||||||
|
let img = image::RgbaImage::from_raw(w, h, rgba.to_vec()).unwrap();
|
||||||
|
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
|
||||||
|
let out = if w > MAX || h > MAX {
|
||||||
|
let scale = MAX as f32 / w.max(h) as f32;
|
||||||
|
image::imageops::resize(&rgb, (w as f32 * scale) as u32, (h as f32 * scale) as u32,
|
||||||
|
image::imageops::FilterType::Nearest)
|
||||||
|
} else { rgb };
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
out.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
|
||||||
|
B64.encode(buf.into_inner())
|
||||||
|
}
|
||||||
53
src/render.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// GPU texture holder: upload RGBA pixels, expose a TextureView for egui display.
|
||||||
|
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
pub struct Renderer {
|
||||||
|
texture: Option<wgpu::Texture>,
|
||||||
|
texture_view: Option<wgpu::TextureView>,
|
||||||
|
pub size: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderer {
|
||||||
|
pub fn new(_device: &wgpu::Device, _surface_format: wgpu::TextureFormat) -> Self {
|
||||||
|
Self { texture: None, texture_view: None, size: (0, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload an RGBA pixel buffer, replacing the current texture.
|
||||||
|
pub fn upload(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
rgba: &[u8],
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
) {
|
||||||
|
assert_eq!(rgba.len(), (w * h * 4) as usize, "rgba buffer size mismatch");
|
||||||
|
|
||||||
|
let extent = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 };
|
||||||
|
|
||||||
|
let texture = device.create_texture_with_data(
|
||||||
|
queue,
|
||||||
|
&wgpu::TextureDescriptor {
|
||||||
|
label: Some("display_tex"),
|
||||||
|
size: extent,
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
},
|
||||||
|
wgpu::util::TextureDataOrder::LayerMajor,
|
||||||
|
rgba,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.texture_view = Some(texture.create_view(&Default::default()));
|
||||||
|
self.texture = Some(texture);
|
||||||
|
self.size = (w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn texture_view(&self) -> Option<&wgpu::TextureView> {
|
||||||
|
self.texture_view.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/shaders/display.wgsl
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Fullscreen quad — no vertex buffer needed, positions generated from vertex index.
|
||||||
|
|
||||||
|
struct VertexOut {
|
||||||
|
@builtin(position) pos: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOut {
|
||||||
|
// Two triangles covering clip space, UVs flipped Y for wgpu convention
|
||||||
|
var pos = array<vec2<f32>, 6>(
|
||||||
|
vec2(-1.0, 1.0), vec2(-1.0, -1.0), vec2( 1.0, -1.0),
|
||||||
|
vec2(-1.0, 1.0), vec2( 1.0, -1.0), vec2( 1.0, 1.0),
|
||||||
|
);
|
||||||
|
var uv = array<vec2<f32>, 6>(
|
||||||
|
vec2(0.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 1.0),
|
||||||
|
vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(1.0, 0.0),
|
||||||
|
);
|
||||||
|
var out: VertexOut;
|
||||||
|
out.pos = vec4(pos[vi], 0.0, 1.0);
|
||||||
|
out.uv = uv[vi];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var tex: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var samp: sampler;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
||||||
|
return textureSample(tex, samp, in.uv);
|
||||||
|
}
|
||||||
34
tauri.conf.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Trac3r",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.trac3r.app",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": { "script": "npm run dev", "cwd": "src-frontend" },
|
||||||
|
"beforeBuildCommand": { "script": "npm run build", "cwd": "src-frontend" },
|
||||||
|
"frontendDist": "src-frontend/dist",
|
||||||
|
"devUrl": "http://localhost:1420"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Trac3r",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600,
|
||||||
|
"decorations": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": []
|
||||||
|
},
|
||||||
|
"plugins": {}
|
||||||
|
|
||||||
|
}
|
||||||