From 737e3cbae65c3c3b3fd5cb84511b0e4209ea85f5 Mon Sep 17 00:00:00 2001 From: olav Date: Tue, 17 Feb 2026 15:22:15 -0800 Subject: [PATCH 1/3] Add "Line Trace" image import algorithm for line drawings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new image import mode that traces actual drawn lines from line drawing images, instead of raster-scanning with brightness modulation. Uses skeleton-tracing-js (Zhang-Suen thinning + polyline extraction) to extract centerlines, then connects them into a single continuous path via nearest-neighbor ordering — suitable for sand table output. New settings: Threshold, Min segment length, Simplify tolerance. Also fixes imageAmplitude missing isVisible guard (was shown for Waves). Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 7 + package.json | 1 + .../shapes/image_import/ImageImport.js | 37 ++++ src/features/shapes/image_import/linetrace.js | 174 ++++++++++++++++++ src/features/shapes/image_import/subtypes.js | 9 + 5 files changed, 228 insertions(+) create mode 100644 src/features/shapes/image_import/linetrace.js diff --git a/package-lock.json b/package-lock.json index 8d44f6c3..13c006d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "reselect": "^5.1.1", "sass": "^1.96.0", "seedrandom": "^3.0.5", + "skeleton-tracing-js": "^1.0.2", "uuid": "^13.0.0", "victor": "^1.1.0" }, @@ -12978,6 +12979,12 @@ "simple-concat": "^1.0.0" } }, + "node_modules/skeleton-tracing-js": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/skeleton-tracing-js/-/skeleton-tracing-js-1.0.4.tgz", + "integrity": "sha512-fn2LYYTCiTW8fIRQx1GBT24cUR1wTyRTx9CmFLVgahZndgILnxCo+15NlSWAZW6mLo3xX9a8xeWbgUaajofbFg==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index b97336fd..e8e0f9d6 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "reselect": "^5.1.1", "sass": "^1.96.0", "seedrandom": "^3.0.5", + "skeleton-tracing-js": "^1.0.2", "uuid": "^13.0.0", "victor": "^1.1.0" }, diff --git a/src/features/shapes/image_import/ImageImport.js b/src/features/shapes/image_import/ImageImport.js index 4cdb3431..016b741a 100644 --- a/src/features/shapes/image_import/ImageImport.js +++ b/src/features/shapes/image_import/ImageImport.js @@ -82,6 +82,9 @@ const options = { min: 0.1, max: 5, step: 0.1, + isVisible: (layer, state) => { + return hasSetting(state, "imageAmplitude") + }, }, imageSampling: { title: "Sampling", @@ -134,6 +137,31 @@ const options = { return hasSetting(state, "imageAngle") }, }, + imageThreshold: { + title: "Threshold", + min: 1, + max: 254, + isVisible: (layer, state) => { + return hasSetting(state, "imageThreshold") + }, + }, + imageMinSegmentLength: { + title: "Min segment length", + min: 0, + max: 50, + isVisible: (layer, state) => { + return hasSetting(state, "imageMinSegmentLength") + }, + }, + imageSimplifyTolerance: { + title: "Simplify tolerance", + min: 0, + max: 10, + step: 0.5, + isVisible: (layer, state) => { + return hasSetting(state, "imageSimplifyTolerance") + }, + }, imageBrightness: { title: "Brightness", type: "slider", @@ -193,6 +221,9 @@ export default class ImageImport extends Shape { imageDirection: "clockwise", imageStepSize: 5, imageAngle: 0, + imageThreshold: 128, + imageMinSegmentLength: 5, + imageSimplifyTolerance: 1, imageMinClippedBrightness: 0, imageMaxClippedBrightness: 255, imageBrightnessFilter: [0, 255], @@ -270,6 +301,9 @@ export default class ImageImport extends Shape { imageDirection, imageStepSize, imageAngle, + imageThreshold, + imageMinSegmentLength, + imageSimplifyTolerance, } = state.shape const imageMinClippedBrightness = state.shape.imageBrightnessFilter[0] @@ -300,6 +334,9 @@ export default class ImageImport extends Shape { Direction: imageDirection, Angle: imageAngle, StepSize: imageStepSize, + Threshold: imageThreshold, + MinSegmentLength: imageMinSegmentLength, + SimplifyTolerance: imageSimplifyTolerance, MaxClippedBrightness: imageMaxClippedBrightness, MinClippedBrightness: imageMinClippedBrightness, width: canvas.width, diff --git a/src/features/shapes/image_import/linetrace.js b/src/features/shapes/image_import/linetrace.js new file mode 100644 index 00000000..02bbc87e --- /dev/null +++ b/src/features/shapes/image_import/linetrace.js @@ -0,0 +1,174 @@ +import TraceSkeleton from "skeleton-tracing-js/trace_skeleton.vanilla.js" +import Victor from "victor" +import { pixelProcessor } from "./helpers" + +const linetrace = (config, data) => { + const w = config.width + const h = config.height + const getPixel = pixelProcessor(config, data) + const threshold = config.Threshold + const minSegLen = config.MinSegmentLength + const simplifyTol = config.SimplifyTolerance + + // Phase 1: build grayscale from pixelProcessor (handles brightness/contrast/inversion) + const grayscale = new Float32Array(w * h) + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + grayscale[y * w + x] = getPixel(x, y) + } + } + + // 3x3 box blur to smooth JPEG artifacts before thresholding + const blurred = boxBlur3x3(grayscale, w, h) + + // Threshold to binary. pixelProcessor returns higher values for darker pixels + // (ink), so >= threshold captures ink. + const boolArr = new Array(w * h) + for (let i = 0; i < w * h; i++) { + boolArr[i] = blurred[i] >= threshold ? 1 : 0 + } + + // Phase 2: skeleton tracing + const result = TraceSkeleton.fromBoolArray(boolArr, w, h) + let polylines = result.polylines + + // Phase 3: filter short polylines + if (minSegLen > 0) { + polylines = polylines.filter((pl) => polylineLength(pl) >= minSegLen) + } + + if (polylines.length === 0) { + return [] + } + + // Phase 4: simplify polylines (Douglas-Peucker) + if (simplifyTol > 0) { + polylines = polylines.map((pl) => douglasPeucker(pl, simplifyTol)) + } + + // Phase 5: connect into single continuous path (nearest-neighbor) + const connected = connectPolylines(polylines) + + // Phase 6: convert to Victor array with Y-flip + return connected.map(([x, y]) => new Victor(x, h - y)) +} + +// ---- Utilities ---- + +function boxBlur3x3(src, w, h) { + const dst = new Float32Array(w * h) + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let sum = 0 + let count = 0 + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = x + dx + const ny = y + dy + if (nx >= 0 && nx < w && ny >= 0 && ny < h) { + sum += src[ny * w + nx] + count++ + } + } + } + dst[y * w + x] = sum / count + } + } + return dst +} + +function polylineLength(pl) { + let len = 0 + for (let i = 1; i < pl.length; i++) { + const dx = pl[i][0] - pl[i - 1][0] + const dy = pl[i][1] - pl[i - 1][1] + len += Math.sqrt(dx * dx + dy * dy) + } + return len +} + +function distSq(a, b) { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + return dx * dx + dy * dy +} + +function connectPolylines(polylines) { + if (polylines.length === 0) return [] + if (polylines.length === 1) return [...polylines[0]] + + const used = new Array(polylines.length).fill(false) + const result = [...polylines[0]] + used[0] = true + + for (let count = 1; count < polylines.length; count++) { + const end = result[result.length - 1] + let bestIdx = -1 + let bestDist = Infinity + let bestReverse = false + + for (let i = 0; i < polylines.length; i++) { + if (used[i]) continue + const pl = polylines[i] + const dStart = distSq(end, pl[0]) + const dEnd = distSq(end, pl[pl.length - 1]) + + if (dStart < bestDist) { + bestDist = dStart + bestIdx = i + bestReverse = false + } + if (dEnd < bestDist) { + bestDist = dEnd + bestIdx = i + bestReverse = true + } + } + + used[bestIdx] = true + const seg = bestReverse ? [...polylines[bestIdx]].reverse() : polylines[bestIdx] + for (const pt of seg) { + result.push(pt) + } + } + + return result +} + +function perpendicularDist(pt, a, b) { + const dx = b[0] - a[0] + const dy = b[1] - a[1] + const lenSq = dx * dx + dy * dy + if (lenSq === 0) { + const ex = pt[0] - a[0] + const ey = pt[1] - a[1] + return Math.sqrt(ex * ex + ey * ey) + } + return Math.abs((pt[0] - a[0]) * dy - (pt[1] - a[1]) * dx) / Math.sqrt(lenSq) +} + +function douglasPeucker(points, tolerance) { + if (points.length <= 2) return points + + const first = points[0] + const last = points[points.length - 1] + let maxDist = 0 + let maxIdx = 0 + + for (let i = 1; i < points.length - 1; i++) { + const d = perpendicularDist(points[i], first, last) + if (d > maxDist) { + maxDist = d + maxIdx = i + } + } + + if (maxDist > tolerance) { + const left = douglasPeucker(points.slice(0, maxIdx + 1), tolerance) + const right = douglasPeucker(points.slice(maxIdx), tolerance) + return left.slice(0, -1).concat(right) + } + return [first, last] +} + +export default linetrace diff --git a/src/features/shapes/image_import/subtypes.js b/src/features/shapes/image_import/subtypes.js index 215027d9..7f043c5d 100644 --- a/src/features/shapes/image_import/subtypes.js +++ b/src/features/shapes/image_import/subtypes.js @@ -1,3 +1,4 @@ +import linetrace from "./linetrace" import sawtooth from "./sawtooth" import spiral from "./spiral" import springs from "./springs" @@ -56,6 +57,14 @@ export const subtypes = { algorithm: waves, settings: ["imageAngle", "imageStepSize"], }, + "Line Trace": { + algorithm: linetrace, + settings: [ + "imageThreshold", + "imageMinSegmentLength", + "imageSimplifyTolerance", + ], + }, } // some protection against bad data From 33315be57d2c5de6492c4e446bacd9836989efd9 Mon Sep 17 00:00:00 2001 From: olav Date: Tue, 17 Feb 2026 16:24:09 -0800 Subject: [PATCH 2/3] Add free drawing mode for freehand canvas input Allow users to draw directly on the preview canvas with mouse or touch. A pencil toggle button activates draw mode; mouse-up finalizes the stroke as a new Drawing layer with Douglas-Peucker simplification and Chaikin corner-cutting smoothing. Layers support free scaling and all existing transforms/effects. Co-Authored-By: Claude Opus 4.6 --- src/features/preview/PreviewManager.js | 29 ++++- src/features/preview/PreviewWindow.js | 95 ++++++++++++++- src/features/preview/previewSlice.js | 35 +++++- src/features/preview/previewSlice.spec.js | 2 + src/features/shapes/Drawing.js | 138 ++++++++++++++++++++++ src/features/shapes/shapeFactory.js | 2 + 6 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 src/features/shapes/Drawing.js diff --git a/src/features/preview/PreviewManager.js b/src/features/preview/PreviewManager.js index 71a19c46..30944ffc 100644 --- a/src/features/preview/PreviewManager.js +++ b/src/features/preview/PreviewManager.js @@ -4,7 +4,9 @@ import React, { useRef } from "react" import { useSelector, useDispatch } from "react-redux" import Select from "react-select" import Slider from "rc-slider" +import Button from "react-bootstrap/Button" import "rc-slider/assets/index.css" +import { FaPencilAlt } from "react-icons/fa" import { updateEffect, selectCurrentEffect, @@ -12,12 +14,17 @@ import { import { selectPreviewSliderValue, selectPreviewZoom, + selectDrawingMode, } from "@/features/preview/previewSlice" import { updateLayer, selectCurrentLayer } from "@/features/layers/layersSlice" import { getShape } from "@/features/shapes/shapeFactory" import { getEffect } from "@/features/effects/effectFactory" import "./PreviewManager.scss" -import { updatePreview } from "./previewSlice" +import { + updatePreview, + toggleDrawingMode, + exitDrawingMode, +} from "./previewSlice" import PreviewWindow from "./PreviewWindow" const PreviewManager = ({ isActive }) => { @@ -26,6 +33,7 @@ const PreviewManager = ({ isActive }) => { const currentEffectLayer = useSelector(selectCurrentEffect) const sliderValue = useSelector(selectPreviewSliderValue) const zoom = useSelector(selectPreviewZoom) + const drawingMode = useSelector(selectDrawingMode) const wrapperRef = useRef() const currentShape = getShape(currentLayer?.type || "polygon") @@ -48,6 +56,10 @@ const PreviewManager = ({ isActive }) => { dispatch(updatePreview({ zoom: option.value })) } + const handleDrawToggle = () => { + dispatch(toggleDrawingMode()) + } + const arrowKeyChange = (layer, event) => { if (!layer) { return @@ -77,6 +89,11 @@ const PreviewManager = ({ isActive }) => { } const handleKeyDown = (event) => { + if (event.key === "Escape" && drawingMode) { + dispatch(exitDrawingMode()) + return + } + if (currentLayer) { if (currentShape.canMove(currentLayer)) { const attrs = arrowKeyChange(currentLayer, event) @@ -115,6 +132,16 @@ const PreviewManager = ({ isActive }) => {
+ +
+