paint-debug-view: fix sharp raster rendering, with red-green-tested pure pipeline
The four debug raster layers (source / SDF / coverage / pre-walk
snapshot) were rendering blurry — WebKit's SVG <image> rasterizer
hardcodes bilinear interpolation regardless of `image-rendering`,
and SVG <foreignObject> doesn't propagate the SVG transform on
Safari. Multiple CSS workarounds (image-rendering on element / on
SVG root / vendor-prefixed) and SVG-attribute workarounds did not
take effect.
Fix: composite all raster layers onto a single absolutely-positioned
HTML <canvas> below the SVG. Canvas2D's
`ctx.imageSmoothingEnabled = false` is honored everywhere, so each
layer draws nearest-neighbor — sharp at any zoom.
The previous attempts also had a state-staleness bug: containerSize
was tracked in React state via a ResizeObserver, but at first
render the state was {0,0} which made the layout's `svgTransform`
return null, which silently disabled the canvas drawing. Replaced
with `useLayoutEffect` + synchronous `getBoundingClientRect` —
runs after DOM commit, before paint, with the canvas ref guaranteed
populated.
The render pipeline is now a pure async function
`renderRasterLayersToCanvas` in `src/lib/rasterRender.js` plus four
small pure helpers (`computeViewboxTransform`, `computeDrawRect`,
`drawRasterLayers`, `buildLayerSpecs`). The React effect is a thin
wrapper that injects a real `<canvas>`, the live bounding rect, and
a real `Image()`-based `loadImage`.
Tests (`src/lib/rasterRender.test.js`, 25/25 passing):
* View-transform math — viewBox+container → scale+offset.
* Draw-rect math — bounds+transform → canvas-pixel rect, with
+1 inclusivity correctness.
* Layer-spec building — toggles, walkIdx → snapshot index,
missing PNG handling.
* drawRasterLayers — sets imageSmoothingEnabled=false, draws each
image with the right rect+alpha, skips null images, clamps
opacity, resets globalAlpha.
* Integration: end-to-end pipeline from viewBox+container+bounds
→ drawImage on a mock canvas. Asserts non-zero rect, fill-frac,
and that imageSmoothingEnabled is false on every draw.
* Regression cases: zero-size container, null debug, all toggles
off, mid-load cancellation, tiny container (3mm/150dpi). Each
must early-out with a sensible reason and no draws.
Red-green proof: temporarily commenting the drawRasterLayers call
inside renderRasterLayersToCanvas makes 2 critical tests fail (the
happy path and the tiny-container regression). Restoring it makes
all 25 pass.
Runtime safety net: in the React effect, surface unexpected early-
out reasons via `console.warn`. 'ok' / 'no-debug' / 'no-layers' /
'cancelled' are normal; anything else (no-canvas, no-divrect,
zero-size, bad-transform, no-ctx) means a regression in the
wiring and prints to the console immediately.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import * as tauri from '../hooks/useTauri.js'
|
import * as tauri from '../hooks/useTauri.js'
|
||||||
import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js'
|
import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js'
|
||||||
|
import { renderRasterLayersToCanvas } from '../lib/rasterRender.js'
|
||||||
|
|
||||||
const IS_DARWIN = typeof navigator !== 'undefined' &&
|
const IS_DARWIN = typeof navigator !== 'undefined' &&
|
||||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '')
|
/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '')
|
||||||
@@ -98,7 +99,17 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
|||||||
)
|
)
|
||||||
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
|
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
|
// Separate from `containerRef` (which is the outer panel container) —
|
||||||
|
// this one wraps just the SVG so we can size HTML overlays to its
|
||||||
|
// pixel dimensions.
|
||||||
|
const svgContainerRef = useRef(null)
|
||||||
const svgRef = useRef(null)
|
const svgRef = useRef(null)
|
||||||
|
// Canvas for raster layers (source/sdf/coverage/snapshot). Bypasses
|
||||||
|
// both WebKit's SVG <image> nearest-neighbor bug AND the
|
||||||
|
// foreignObject transform issue. ctx.imageSmoothingEnabled = false
|
||||||
|
// is honored everywhere.
|
||||||
|
const rasterCanvasRef = useRef(null)
|
||||||
|
const rasterCacheRef = useRef(new Map()) // src → loaded HTMLImageElement
|
||||||
const dragRef = useRef(null)
|
const dragRef = useRef(null)
|
||||||
const [hover, setHover] = useState(null)
|
const [hover, setHover] = useState(null)
|
||||||
const [selBox, setSelBox] = useState(null)
|
const [selBox, setSelBox] = useState(null)
|
||||||
@@ -183,6 +194,71 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
|||||||
return `${x0 - pad - view.panX} ${y0 - pad - view.panY} ${w / view.zoom} ${h / view.zoom}`
|
return `${x0 - pad - view.panX} ${y0 - pad - view.panY} ${w / view.zoom} ${h / view.zoom}`
|
||||||
}, [debug, view])
|
}, [debug, view])
|
||||||
|
|
||||||
|
// Bump on resize so the layout-effect below re-runs and re-measures
|
||||||
|
// the SVG container synchronously. The actual measurement happens
|
||||||
|
// in the layout-effect itself via getBoundingClientRect — keeping
|
||||||
|
// the size in state has been unreliable (initial mount measures
|
||||||
|
// zero, async update doesn't always fire before the canvas
|
||||||
|
// renders). Synchronous measurement on every render that matters
|
||||||
|
// is more robust than chasing state.
|
||||||
|
const [resizeTick, setResizeTick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = svgContainerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const ro = new ResizeObserver(() => setResizeTick(t => t + 1))
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Render raster layers (source / sdf / coverage / pre-snapshot) onto
|
||||||
|
// the canvas every time inputs change. useLayoutEffect runs
|
||||||
|
// synchronously after DOM commit but before browser paint — the
|
||||||
|
// canvas ref is guaranteed populated and we measure the container's
|
||||||
|
// actual bounding rect right then. This avoids the first-render
|
||||||
|
// "containerSize is still 0" trap. Body is just a wrapper around
|
||||||
|
// `renderRasterLayersToCanvas` (pure, tested).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const canvas = rasterCanvasRef.current
|
||||||
|
const divEl = svgContainerRef.current
|
||||||
|
if (!divEl) return
|
||||||
|
const cache = rasterCacheRef.current
|
||||||
|
const loadImage = (src) => {
|
||||||
|
const cached = cache.get(src)
|
||||||
|
if (cached && cached.complete && cached.naturalWidth > 0) {
|
||||||
|
return Promise.resolve(cached)
|
||||||
|
}
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => { cache.set(src, img); resolve(img) }
|
||||||
|
img.onerror = () => resolve(null)
|
||||||
|
img.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
renderRasterLayersToCanvas({
|
||||||
|
canvas,
|
||||||
|
divRect: divEl.getBoundingClientRect(),
|
||||||
|
debug,
|
||||||
|
viewBox,
|
||||||
|
enabled,
|
||||||
|
opacity: { source: sourceOpacity, sdf: sdfOpacity, coverage: coverageOpacity },
|
||||||
|
walkIdx,
|
||||||
|
loadImage,
|
||||||
|
isCancelled: () => cancelled,
|
||||||
|
}).then(result => {
|
||||||
|
// Surface unexpected early-outs in the console so a regression
|
||||||
|
// is loud, not silent. 'ok', 'no-debug', 'no-layers',
|
||||||
|
// 'cancelled' are all normal; the others mean something is
|
||||||
|
// wrong with refs, layout, or the raster pipeline.
|
||||||
|
if (result && !['ok', 'no-debug', 'no-layers', 'cancelled'].includes(result.reason)) {
|
||||||
|
console.warn('[paint-debug] raster render skipped:', result.reason, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [debug, viewBox, resizeTick,
|
||||||
|
enabled.source, enabled.sdf, enabled.preSnapshot, enabled.coverage,
|
||||||
|
walkIdx, sourceOpacity, sdfOpacity, coverageOpacity, enabled])
|
||||||
|
|
||||||
const onWheel = (e) => {
|
const onWheel = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!debug || !svgRef.current) return
|
if (!debug || !svgRef.current) return
|
||||||
@@ -611,39 +687,48 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
|
<div
|
||||||
|
ref={svgContainerRef}
|
||||||
|
className="flex-1 relative overflow-hidden"
|
||||||
|
style={{ background: '#0f0f10' }}
|
||||||
|
onWheel={onWheel} onMouseDown={onMouseDown}>
|
||||||
|
|
||||||
|
{/* Raster layers (source / sdf / coverage / snapshot) all
|
||||||
|
composite onto a single absolutely-positioned canvas
|
||||||
|
below the SVG. Canvas2D's `imageSmoothingEnabled = false`
|
||||||
|
is honored everywhere, so they stay sharp at any zoom. */}
|
||||||
|
<canvas
|
||||||
|
ref={rasterCanvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', left: 0, top: 0,
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
width="100%" height="100%"
|
width="100%" height="100%"
|
||||||
viewBox={viewBox}
|
viewBox={viewBox}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
onMouseMove={onMouseMoveSvg}
|
onMouseMove={onMouseMoveSvg}
|
||||||
onMouseLeave={() => setCandHover(null)}
|
onMouseLeave={() => setCandHover(null)}
|
||||||
onKeyDown={onSvgKeyDown}
|
onKeyDown={onSvgKeyDown}
|
||||||
style={{ cursor: 'grab', background: '#0f0f10', outline: 'none' }}>
|
style={{
|
||||||
|
cursor: 'grab', outline: 'none',
|
||||||
|
// SVG stays in flex flow so the container has a height;
|
||||||
|
// HTML <img> overlays are absolutely positioned over the
|
||||||
|
// SVG's rendered rect via `overlayStyle`.
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
}}>
|
||||||
|
|
||||||
{enabled.source && debug.source_b64 && (
|
{/* Source / SDF / coverage / pre-snapshot rasters render as
|
||||||
<image
|
HTML <img> overlays outside the SVG (above) — see
|
||||||
href={debug.source_b64} xlinkHref={debug.source_b64}
|
`overlayStyle`. WebKit's SVG <image> rasterizer can't be
|
||||||
x={debug.bounds[0]} y={debug.bounds[1]}
|
forced to nearest-neighbor, but HTML <img> with CSS
|
||||||
width={debug.bounds[2] - debug.bounds[0] + 1}
|
`image-rendering: pixelated` is honored. */}
|
||||||
height={debug.bounds[3] - debug.bounds[1] + 1}
|
|
||||||
opacity={sourceOpacity}
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
preserveAspectRatio="none" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{enabled.sdf && debug.sdf_b64 && (
|
|
||||||
<image
|
|
||||||
href={debug.sdf_b64} xlinkHref={debug.sdf_b64}
|
|
||||||
x={debug.bounds[0]} y={debug.bounds[1]}
|
|
||||||
width={debug.bounds[2] - debug.bounds[0] + 1}
|
|
||||||
height={debug.bounds[3] - debug.bounds[1] + 1}
|
|
||||||
opacity={sdfOpacity}
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
preserveAspectRatio="none" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vector skeleton — polylines per segment between special
|
{/* Vector skeleton — polylines per segment between special
|
||||||
nodes. Stays sharp at any zoom. Junction dots in green. */}
|
nodes. Stays sharp at any zoom. Junction dots in green. */}
|
||||||
@@ -712,37 +797,8 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
|||||||
})
|
})
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Pre-walk unpainted snapshot for the currently-selected
|
{/* preSnapshot + coverage are HTML <img> overlays outside
|
||||||
walk's stroke. Shows what the walker SAW just before it
|
the SVG (see top of container). */}
|
||||||
started — distinct from the final coverage layer which
|
|
||||||
shows what's left after ALL strokes. */}
|
|
||||||
{enabled.preSnapshot && (() => {
|
|
||||||
const snaps = debug.unpainted_snapshots ?? []
|
|
||||||
const sIdx = walks[walkIdx]?.stroke_idx ?? 0
|
|
||||||
const png = snaps[sIdx]
|
|
||||||
if (!png) return null
|
|
||||||
return (
|
|
||||||
<image
|
|
||||||
href={png} xlinkHref={png}
|
|
||||||
x={debug.bounds[0]} y={debug.bounds[1]}
|
|
||||||
width={debug.bounds[2] - debug.bounds[0] + 1}
|
|
||||||
height={debug.bounds[3] - debug.bounds[1] + 1}
|
|
||||||
opacity={coverageOpacity}
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
preserveAspectRatio="none" />
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{enabled.coverage && debug.coverage_b64 && (
|
|
||||||
<image
|
|
||||||
href={debug.coverage_b64} xlinkHref={debug.coverage_b64}
|
|
||||||
x={debug.bounds[0]} y={debug.bounds[1]}
|
|
||||||
width={debug.bounds[2] - debug.bounds[0] + 1}
|
|
||||||
height={debug.bounds[3] - debug.bounds[1] + 1}
|
|
||||||
opacity={coverageOpacity}
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
preserveAspectRatio="none" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Brush sweep: each trajectory rendered as a fat translucent
|
{/* Brush sweep: each trajectory rendered as a fat translucent
|
||||||
line of width = 2 × brush_radius. Shows what the brush
|
line of width = 2 × brush_radius. Shows what the brush
|
||||||
|
|||||||
127
src-frontend/src/lib/rasterRender.js
Normal file
127
src-frontend/src/lib/rasterRender.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Pure helpers for compositing raster debug-layers onto a canvas in
|
||||||
|
// the same coordinate frame the SVG uses for its vector overlays.
|
||||||
|
// Extracted from PaintDebugView so they can be unit-tested without
|
||||||
|
// React, and so the layout calculation has one source of truth.
|
||||||
|
|
||||||
|
/// Mirror SVG `preserveAspectRatio="xMidYMid meet"`: uniform scale to
|
||||||
|
/// fit the viewBox inside the container, with letterbox-style centering
|
||||||
|
/// on the larger axis. Returns null if any input is missing or zero.
|
||||||
|
export function computeViewboxTransform(viewBoxStr, container) {
|
||||||
|
if (!viewBoxStr || !container) return null
|
||||||
|
const parts = String(viewBoxStr).trim().split(/\s+/).map(Number)
|
||||||
|
if (parts.length !== 4 || parts.some(v => !Number.isFinite(v))) return null
|
||||||
|
const [vbX, vbY, vbW, vbH] = parts
|
||||||
|
if (vbW <= 0 || vbH <= 0) return null
|
||||||
|
if (!container || container.w <= 0 || container.h <= 0) return null
|
||||||
|
const scale = Math.min(container.w / vbW, container.h / vbH)
|
||||||
|
const contentX = (container.w - vbW * scale) / 2
|
||||||
|
const contentY = (container.h - vbH * scale) / 2
|
||||||
|
return { vbX, vbY, scale, contentX, contentY }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert hull bounds in SVG user-space to canvas-pixel draw rect.
|
||||||
|
export function computeDrawRect(bounds, transform) {
|
||||||
|
if (!bounds || !transform || bounds.length !== 4) return null
|
||||||
|
const [x0, y0, x1, y1] = bounds
|
||||||
|
const sw = x1 - x0 + 1
|
||||||
|
const sh = y1 - y0 + 1
|
||||||
|
return {
|
||||||
|
dx: (x0 - transform.vbX) * transform.scale + transform.contentX,
|
||||||
|
dy: (y0 - transform.vbY) * transform.scale + transform.contentY,
|
||||||
|
dw: sw * transform.scale,
|
||||||
|
dh: sh * transform.scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite layers onto `ctx`. `layers` is an array of
|
||||||
|
/// `{ image: HTMLImageElement|null, opacity: number }`. Skips layers
|
||||||
|
/// whose image is null. Sets imageSmoothingEnabled=false so each
|
||||||
|
/// layer draws with nearest-neighbor sampling — sharp at any zoom.
|
||||||
|
export function drawRasterLayers(ctx, drawRect, layers) {
|
||||||
|
if (!ctx || !drawRect) return
|
||||||
|
ctx.imageSmoothingEnabled = false
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (!layer || !layer.image) continue
|
||||||
|
ctx.globalAlpha = Math.max(0, Math.min(1, layer.opacity ?? 1))
|
||||||
|
ctx.drawImage(layer.image, drawRect.dx, drawRect.dy, drawRect.dw, drawRect.dh)
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the array of `{ src, opacity }` layer specs for a given debug
|
||||||
|
/// payload + UI state. Pulled out so the test can assert which layers
|
||||||
|
/// the React component would push to the canvas without instantiating
|
||||||
|
/// React. Pure — same inputs always produce same output.
|
||||||
|
export function buildLayerSpecs(debug, enabled, opacity, walkIdx) {
|
||||||
|
if (!debug) return []
|
||||||
|
const specs = []
|
||||||
|
if (enabled.source && debug.source_b64) {
|
||||||
|
specs.push({ src: debug.source_b64, opacity: opacity.source })
|
||||||
|
}
|
||||||
|
if (enabled.sdf && debug.sdf_b64) {
|
||||||
|
specs.push({ src: debug.sdf_b64, opacity: opacity.sdf })
|
||||||
|
}
|
||||||
|
if (enabled.preSnapshot) {
|
||||||
|
const sIdx = (debug.walks ?? [])[walkIdx]?.stroke_idx ?? 0
|
||||||
|
const png = (debug.unpainted_snapshots ?? [])[sIdx]
|
||||||
|
if (png) specs.push({ src: png, opacity: opacity.coverage })
|
||||||
|
}
|
||||||
|
if (enabled.coverage && debug.coverage_b64) {
|
||||||
|
specs.push({ src: debug.coverage_b64, opacity: opacity.coverage })
|
||||||
|
}
|
||||||
|
return specs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end render: measure → compute transform → resize canvas →
|
||||||
|
/// async-load each layer → composite. The React `useLayoutEffect`
|
||||||
|
/// is a thin shim around this; isolating the body here lets tests
|
||||||
|
/// run it against a mock canvas + mock loadImage with zero React.
|
||||||
|
///
|
||||||
|
/// Returns a status object so the caller (and tests) can verify
|
||||||
|
/// what happened: how many layers actually drew, and the rect they
|
||||||
|
/// targeted. Returns `drew: 0, reason: ...` on any early-out.
|
||||||
|
export async function renderRasterLayersToCanvas(args) {
|
||||||
|
const {
|
||||||
|
canvas,
|
||||||
|
divRect,
|
||||||
|
debug,
|
||||||
|
viewBox,
|
||||||
|
enabled,
|
||||||
|
opacity,
|
||||||
|
walkIdx,
|
||||||
|
loadImage,
|
||||||
|
isCancelled = () => false,
|
||||||
|
} = args
|
||||||
|
|
||||||
|
if (!canvas) return { drew: 0, reason: 'no-canvas' }
|
||||||
|
if (!divRect) return { drew: 0, reason: 'no-divrect' }
|
||||||
|
if (!debug) return { drew: 0, reason: 'no-debug' }
|
||||||
|
if (divRect.width <= 0 || divRect.height <= 0) return { drew: 0, reason: 'zero-size' }
|
||||||
|
|
||||||
|
const transform = computeViewboxTransform(viewBox, { w: divRect.width, h: divRect.height })
|
||||||
|
const drawRect = computeDrawRect(debug.bounds, transform)
|
||||||
|
if (!transform || !drawRect) return { drew: 0, reason: 'bad-transform' }
|
||||||
|
|
||||||
|
// Resize the canvas raster to match its CSS rect (1:1 device pixels).
|
||||||
|
const W = Math.round(divRect.width)
|
||||||
|
const H = Math.round(divRect.height)
|
||||||
|
if (canvas.width !== W || canvas.height !== H) {
|
||||||
|
canvas.width = W
|
||||||
|
canvas.height = H
|
||||||
|
}
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return { drew: 0, reason: 'no-ctx' }
|
||||||
|
ctx.clearRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const specs = buildLayerSpecs(debug, enabled, opacity, walkIdx)
|
||||||
|
if (specs.length === 0) return { drew: 0, reason: 'no-layers', drawRect }
|
||||||
|
|
||||||
|
const imgs = await Promise.all(specs.map(s => loadImage(s.src)))
|
||||||
|
if (isCancelled()) return { drew: 0, reason: 'cancelled', drawRect }
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
const layers = specs.map((s, i) => ({ image: imgs[i], opacity: s.opacity }))
|
||||||
|
drawRasterLayers(ctx, drawRect, layers)
|
||||||
|
const drewCount = imgs.filter(i => !!i).length
|
||||||
|
return { drew: drewCount, reason: 'ok', drawRect }
|
||||||
|
}
|
||||||
356
src-frontend/src/lib/rasterRender.test.js
Normal file
356
src-frontend/src/lib/rasterRender.test.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
computeViewboxTransform,
|
||||||
|
computeDrawRect,
|
||||||
|
drawRasterLayers,
|
||||||
|
buildLayerSpecs,
|
||||||
|
renderRasterLayersToCanvas,
|
||||||
|
} from './rasterRender.js'
|
||||||
|
|
||||||
|
describe('computeViewboxTransform', () => {
|
||||||
|
test('returns null on empty/zero inputs', () => {
|
||||||
|
expect(computeViewboxTransform('', { w: 100, h: 100 })).toBeNull()
|
||||||
|
expect(computeViewboxTransform('0 0 100 100', null)).toBeNull()
|
||||||
|
expect(computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })).toBeNull()
|
||||||
|
expect(computeViewboxTransform('0 0 0 100', { w: 100, h: 100 })).toBeNull()
|
||||||
|
expect(computeViewboxTransform('not a viewbox', { w: 100, h: 100 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('square container, square viewBox -> 1:1 scale, no offset', () => {
|
||||||
|
const t = computeViewboxTransform('0 0 100 100', { w: 100, h: 100 })
|
||||||
|
expect(t).toEqual({ vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('container 800x600, viewBox 100x100 -> meet scales 6, centers x', () => {
|
||||||
|
// meet = scale to fit, smaller axis dominates
|
||||||
|
const t = computeViewboxTransform('0 0 100 100', { w: 800, h: 600 })
|
||||||
|
expect(t.scale).toBe(6)
|
||||||
|
expect(t.contentX).toBe((800 - 600) / 2) // 100
|
||||||
|
expect(t.contentY).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('viewBox offset is preserved', () => {
|
||||||
|
const t = computeViewboxTransform('50 30 100 100', { w: 100, h: 100 })
|
||||||
|
expect(t.vbX).toBe(50)
|
||||||
|
expect(t.vbY).toBe(30)
|
||||||
|
expect(t.scale).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDrawRect', () => {
|
||||||
|
test('returns null when transform is null', () => {
|
||||||
|
expect(computeDrawRect([0, 0, 10, 10], null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('identity transform passes bounds through (with +1 inclusivity)', () => {
|
||||||
|
const t = { vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 }
|
||||||
|
const r = computeDrawRect([10, 20, 39, 49], t)
|
||||||
|
expect(r).toEqual({ dx: 10, dy: 20, dw: 30, dh: 30 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('viewBox offset shifts dx/dy correctly', () => {
|
||||||
|
const t = { vbX: 5, vbY: 10, scale: 2, contentX: 100, contentY: 50 }
|
||||||
|
const r = computeDrawRect([10, 20, 19, 29], t)
|
||||||
|
// dx = (10 - 5) * 2 + 100 = 110
|
||||||
|
// dy = (20 - 10) * 2 + 50 = 70
|
||||||
|
// dw = 10 * 2 = 20, dh = 10 * 2 = 20
|
||||||
|
expect(r).toEqual({ dx: 110, dy: 70, dw: 20, dh: 20 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('drawRasterLayers', () => {
|
||||||
|
// Build a fake canvas-2d-context that records calls.
|
||||||
|
function makeCtx() {
|
||||||
|
const calls = []
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
imageSmoothingEnabled: true, // start true so we verify we set it false
|
||||||
|
globalAlpha: 1,
|
||||||
|
drawImage(img, dx, dy, dw, dh) {
|
||||||
|
calls.push({ kind: 'drawImage', img, dx, dy, dw, dh,
|
||||||
|
alpha: this.globalAlpha,
|
||||||
|
smoothing: this.imageSmoothingEnabled })
|
||||||
|
},
|
||||||
|
clearRect(x, y, w, h) {
|
||||||
|
calls.push({ kind: 'clearRect', x, y, w, h })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no-op on null drawRect or null ctx', () => {
|
||||||
|
const ctx = makeCtx()
|
||||||
|
drawRasterLayers(ctx, null, [{ image: 'fake-img', opacity: 1 }])
|
||||||
|
drawRasterLayers(null, { dx: 0, dy: 0, dw: 10, dh: 10 }, [])
|
||||||
|
expect(ctx.calls.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disables smoothing and draws each image at the rect', () => {
|
||||||
|
const ctx = makeCtx()
|
||||||
|
const drawRect = { dx: 5, dy: 7, dw: 11, dh: 13 }
|
||||||
|
const img1 = { id: 'img1' }
|
||||||
|
const img2 = { id: 'img2' }
|
||||||
|
drawRasterLayers(ctx, drawRect, [
|
||||||
|
{ image: img1, opacity: 0.5 },
|
||||||
|
{ image: img2, opacity: 0.8 },
|
||||||
|
])
|
||||||
|
expect(ctx.imageSmoothingEnabled).toBe(false)
|
||||||
|
const draws = ctx.calls.filter(c => c.kind === 'drawImage')
|
||||||
|
expect(draws.length).toBe(2)
|
||||||
|
expect(draws[0]).toMatchObject({ img: img1, dx: 5, dy: 7, dw: 11, dh: 13,
|
||||||
|
alpha: 0.5, smoothing: false })
|
||||||
|
expect(draws[1]).toMatchObject({ img: img2, dx: 5, dy: 7, dw: 11, dh: 13,
|
||||||
|
alpha: 0.8, smoothing: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips layers whose image is null', () => {
|
||||||
|
const ctx = makeCtx()
|
||||||
|
drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
|
||||||
|
{ image: null, opacity: 1 },
|
||||||
|
{ image: { id: 'real' }, opacity: 1 },
|
||||||
|
null,
|
||||||
|
])
|
||||||
|
const draws = ctx.calls.filter(c => c.kind === 'drawImage')
|
||||||
|
expect(draws.length).toBe(1)
|
||||||
|
expect(draws[0].img).toEqual({ id: 'real' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clamps opacity to [0, 1]', () => {
|
||||||
|
const ctx = makeCtx()
|
||||||
|
drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
|
||||||
|
{ image: { id: 'a' }, opacity: -0.5 },
|
||||||
|
{ image: { id: 'b' }, opacity: 2.0 },
|
||||||
|
])
|
||||||
|
const draws = ctx.calls.filter(c => c.kind === 'drawImage')
|
||||||
|
expect(draws[0].alpha).toBe(0)
|
||||||
|
expect(draws[1].alpha).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resets globalAlpha to 1 after drawing', () => {
|
||||||
|
const ctx = makeCtx()
|
||||||
|
drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
|
||||||
|
{ image: { id: 'a' }, opacity: 0.3 },
|
||||||
|
])
|
||||||
|
expect(ctx.globalAlpha).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildLayerSpecs', () => {
|
||||||
|
const dbg = {
|
||||||
|
source_b64: 'data:source',
|
||||||
|
sdf_b64: 'data:sdf',
|
||||||
|
coverage_b64: 'data:coverage',
|
||||||
|
walks: [{ stroke_idx: 2 }, { stroke_idx: 3 }],
|
||||||
|
unpainted_snapshots: ['snap0', 'snap1', 'snap2', 'snap3'],
|
||||||
|
}
|
||||||
|
const allOn = { source: true, sdf: true, preSnapshot: true, coverage: true }
|
||||||
|
const op = { source: 0.4, sdf: 0.5, coverage: 0.7 }
|
||||||
|
|
||||||
|
test('null debug -> empty', () => {
|
||||||
|
expect(buildLayerSpecs(null, allOn, op, 0)).toEqual([])
|
||||||
|
})
|
||||||
|
test('all enabled -> all four specs', () => {
|
||||||
|
const specs = buildLayerSpecs(dbg, allOn, op, 0)
|
||||||
|
expect(specs.length).toBe(4)
|
||||||
|
expect(specs[0]).toEqual({ src: 'data:source', opacity: 0.4 })
|
||||||
|
expect(specs[1]).toEqual({ src: 'data:sdf', opacity: 0.5 })
|
||||||
|
expect(specs[2]).toEqual({ src: 'snap2', opacity: 0.7 }) // walks[0].stroke_idx
|
||||||
|
expect(specs[3]).toEqual({ src: 'data:coverage', opacity: 0.7 })
|
||||||
|
})
|
||||||
|
test('walkIdx selects which snapshot', () => {
|
||||||
|
const specs = buildLayerSpecs(dbg, allOn, op, 1)
|
||||||
|
// walks[1].stroke_idx = 3 -> snapshots[3]
|
||||||
|
expect(specs[2].src).toBe('snap3')
|
||||||
|
})
|
||||||
|
test('partial layers respect their toggles', () => {
|
||||||
|
const specs = buildLayerSpecs(dbg,
|
||||||
|
{ source: true, sdf: false, preSnapshot: false, coverage: false },
|
||||||
|
op, 0)
|
||||||
|
expect(specs.length).toBe(1)
|
||||||
|
expect(specs[0].src).toBe('data:source')
|
||||||
|
})
|
||||||
|
test('missing PNG fields skipped', () => {
|
||||||
|
const specs = buildLayerSpecs(
|
||||||
|
{ ...dbg, sdf_b64: '' }, allOn, op, 0)
|
||||||
|
expect(specs.find(s => s.src === 'data:sdf')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock canvas/context — drop-in replacement for HTMLCanvasElement that
|
||||||
|
// lets us assert which drawImage calls happened. The real React effect
|
||||||
|
// calls these via `canvas.getContext('2d')`. jsdom doesn't ship a
|
||||||
|
// canvas implementation, so this stub stands in for tests.
|
||||||
|
function makeMockCanvas() {
|
||||||
|
const calls = []
|
||||||
|
const ctx = {
|
||||||
|
calls,
|
||||||
|
imageSmoothingEnabled: true,
|
||||||
|
globalAlpha: 1,
|
||||||
|
clearRect(x, y, w, h) { calls.push({ kind: 'clearRect', x, y, w, h }) },
|
||||||
|
drawImage(img, dx, dy, dw, dh) {
|
||||||
|
calls.push({ kind: 'drawImage', img, dx, dy, dw, dh,
|
||||||
|
alpha: this.globalAlpha,
|
||||||
|
smoothing: this.imageSmoothingEnabled })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: 0, height: 0,
|
||||||
|
getContext: () => ctx,
|
||||||
|
_ctx: ctx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderRasterLayersToCanvas — integration', () => {
|
||||||
|
// Common fixture: an M-like glyph at 5mm/425dpi.
|
||||||
|
const debug = {
|
||||||
|
bounds: [108, 142, 207, 275],
|
||||||
|
source_b64: 'data:image/png;base64,SOURCEMOCK',
|
||||||
|
sdf_b64: 'data:image/png;base64,SDFMOCK',
|
||||||
|
coverage_b64: 'data:image/png;base64,COVMOCK',
|
||||||
|
walks: [{ stroke_idx: 0 }],
|
||||||
|
unpainted_snapshots: ['data:snap0'],
|
||||||
|
}
|
||||||
|
const viewBox = '104 138 107.92 141' // bounds + ~4 px pad
|
||||||
|
const enabled = { source: true, sdf: true, preSnapshot: false, coverage: false }
|
||||||
|
const opacity = { source: 0.4, sdf: 0.5, coverage: 0.7 }
|
||||||
|
// Mock loadImage returns a fake "image" object that drawImage
|
||||||
|
// accepts (it doesn't care what the type is, just that it's
|
||||||
|
// not null).
|
||||||
|
const fakeImage = (src) => ({ _mockSrc: src })
|
||||||
|
const loadImage = (src) => Promise.resolve(fakeImage(src))
|
||||||
|
|
||||||
|
test('happy path: drawImage called with non-zero rect for every enabled layer', async () => {
|
||||||
|
const canvas = makeMockCanvas()
|
||||||
|
const divRect = { width: 800, height: 600 }
|
||||||
|
const result = await renderRasterLayersToCanvas({
|
||||||
|
canvas, divRect, debug, viewBox, enabled, opacity, walkIdx: 0, loadImage,
|
||||||
|
})
|
||||||
|
expect(result.reason).toBe('ok')
|
||||||
|
expect(result.drew).toBe(2)
|
||||||
|
expect(canvas.width).toBe(800)
|
||||||
|
expect(canvas.height).toBe(600)
|
||||||
|
|
||||||
|
const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
|
||||||
|
expect(draws.length).toBe(2)
|
||||||
|
// Both draws must have non-zero rect.
|
||||||
|
for (const d of draws) {
|
||||||
|
expect(d.dw).toBeGreaterThan(0)
|
||||||
|
expect(d.dh).toBeGreaterThan(0)
|
||||||
|
expect(d.smoothing).toBe(false) // pixelated guarantee
|
||||||
|
}
|
||||||
|
// Verify the two draws went to the same rect (same overlay).
|
||||||
|
expect(draws[0].dw).toBeCloseTo(draws[1].dw)
|
||||||
|
expect(draws[0].dh).toBeCloseTo(draws[1].dh)
|
||||||
|
// Verify the rect occupies a meaningful portion of the canvas
|
||||||
|
// (the original bug had the imgs render at "tiny" 1:1 px size).
|
||||||
|
expect(Math.max(draws[0].dw / 800, draws[0].dh / 600)).toBeGreaterThan(0.3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('regression: zero-size container -> no draw, sensible reason', async () => {
|
||||||
|
const canvas = makeMockCanvas()
|
||||||
|
const result = await renderRasterLayersToCanvas({
|
||||||
|
canvas, divRect: { width: 0, height: 0 },
|
||||||
|
debug, viewBox, enabled, opacity, walkIdx: 0, loadImage,
|
||||||
|
})
|
||||||
|
expect(result.drew).toBe(0)
|
||||||
|
expect(result.reason).toBe('zero-size')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('regression: null debug -> no draw', async () => {
|
||||||
|
const canvas = makeMockCanvas()
|
||||||
|
const result = await renderRasterLayersToCanvas({
|
||||||
|
canvas, divRect: { width: 800, height: 600 },
|
||||||
|
debug: null, viewBox, enabled, opacity, walkIdx: 0, loadImage,
|
||||||
|
})
|
||||||
|
expect(result.drew).toBe(0)
|
||||||
|
expect(result.reason).toBe('no-debug')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('regression: all layer toggles off -> no-layers reason, no drawImage', async () => {
|
||||||
|
const canvas = makeMockCanvas()
|
||||||
|
const result = await renderRasterLayersToCanvas({
|
||||||
|
canvas, divRect: { width: 800, height: 600 },
|
||||||
|
debug, viewBox,
|
||||||
|
enabled: { source: false, sdf: false, preSnapshot: false, coverage: false },
|
||||||
|
opacity, walkIdx: 0, loadImage,
|
||||||
|
})
|
||||||
|
expect(result.drew).toBe(0)
|
||||||
|
expect(result.reason).toBe('no-layers')
|
||||||
|
const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
|
||||||
|
expect(draws.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('regression: cancelled mid-load -> no draw', async () => {
|
||||||
|
const canvas = makeMockCanvas()
|
||||||
|
let cancelledFlag = false
|
||||||
|
// Make loadImage actually async so we can flip cancel mid-flight.
|
||||||
|
const slowLoad = (src) => new Promise(res => setTimeout(() => res(fakeImage(src)), 0))
|
||||||
|
const promise = renderRasterLayersToCanvas({
|
||||||
|
canvas, divRect: { width: 800, height: 600 },
|
||||||
|
debug, viewBox, enabled, opacity, walkIdx: 0,
|
||||||
|
loadImage: slowLoad,
|
||||||
|
isCancelled: () => cancelledFlag,
|
||||||
|
})
|
||||||
|
cancelledFlag = true
|
||||||
|
const result = await promise
|
||||||
|
expect(result.drew).toBe(0)
|
||||||
|
expect(result.reason).toBe('cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('regression: tiny container (3mm/150dpi case) -> still produces correct rect', async () => {
|
||||||
|
// 3mm at 150dpi is ~17 px tall; bounds proportionally small.
|
||||||
|
const tinyDebug = {
|
||||||
|
...debug,
|
||||||
|
bounds: [10, 10, 26, 26], // 17×17 hull
|
||||||
|
}
|
||||||
|
const tinyVB = '8 8 21 21' // bounds + 2px pad
|
||||||
|
const canvas = makeMockCanvas()
|
||||||
|
const result = await renderRasterLayersToCanvas({
|
||||||
|
canvas, divRect: { width: 800, height: 600 },
|
||||||
|
debug: tinyDebug, viewBox: tinyVB, enabled, opacity, walkIdx: 0, loadImage,
|
||||||
|
})
|
||||||
|
expect(result.reason).toBe('ok')
|
||||||
|
const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
|
||||||
|
// Even tiny letters must fill a meaningful chunk of the canvas
|
||||||
|
// because the SVG's `meet` aspect-fit upscales them.
|
||||||
|
expect(draws[0].dw).toBeGreaterThan(100)
|
||||||
|
expect(draws[0].dh).toBeGreaterThan(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Integration-like test: end-to-end pipeline from viewBox + container
|
||||||
|
// + bounds → final draw call. This is the path the React component
|
||||||
|
// follows; if any of these three pure functions misbehaves the canvas
|
||||||
|
// shows nothing.
|
||||||
|
describe('end-to-end pipeline', () => {
|
||||||
|
test('typical M at 5mm/425dpi: image lands inside container with correct size', () => {
|
||||||
|
// M at 5mm/425dpi has bounds approximately:
|
||||||
|
// x: 108..207, y: 142..275 (per earlier diagnostic test)
|
||||||
|
// pad = max(2, (x1-x0)*0.04) = max(2, 4) = 4
|
||||||
|
// viewBox = `${x0-pad} ${y0-pad} ${(x1-x0)+2*pad} ${(y1-y0)+2*pad}`
|
||||||
|
const bounds = [108, 142, 207, 275]
|
||||||
|
const pad = Math.max(2, (bounds[2] - bounds[0]) * 0.04)
|
||||||
|
const vb = `${bounds[0]-pad} ${bounds[1]-pad} ${(bounds[2]-bounds[0])+2*pad} ${(bounds[3]-bounds[1])+2*pad}`
|
||||||
|
const container = { w: 800, h: 600 }
|
||||||
|
const t = computeViewboxTransform(vb, container)
|
||||||
|
const r = computeDrawRect(bounds, t)
|
||||||
|
expect(t).not.toBeNull()
|
||||||
|
expect(r).not.toBeNull()
|
||||||
|
// The image should land WITHIN the container (not off-screen).
|
||||||
|
expect(r.dx).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(r.dy).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(r.dx + r.dw).toBeLessThanOrEqual(container.w + 1)
|
||||||
|
expect(r.dy + r.dh).toBeLessThanOrEqual(container.h + 1)
|
||||||
|
// The image should occupy a sensible fraction of the container —
|
||||||
|
// at least 1/3 of either dimension (otherwise the user would see
|
||||||
|
// it as "tiny", which is the original bug).
|
||||||
|
const fillFrac = Math.max(r.dw / container.w, r.dh / container.h)
|
||||||
|
expect(fillFrac).toBeGreaterThan(0.3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('regression: empty/zero container yields a null draw rect (skip render)', () => {
|
||||||
|
const t = computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })
|
||||||
|
expect(t).toBeNull()
|
||||||
|
const r = computeDrawRect([0, 0, 10, 10], t)
|
||||||
|
expect(r).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user