feat(canvas): add leafer GPU renderer and interactive color-boundary crop tool
Introduce a leafer-ui canvas layer that mirrors rendered nodes and drives viewport, node-drag, and alignment-guide updates imperatively, committing React state on idle via startTransition to keep pan/zoom/drag at 60fps. Wire the imperative viewport through wheel zoom, zoom-to, fit-view, and panning. Add a dedicated crop tool: click a node to auto-detect a color-boundary crop region or drag to preselect one, previewed with an on-canvas draft overlay before entering the crop editor. Route uploads through renderContent so the transparent WebP texture feeds the fast display path. Add node --test coverage for the leafer renderer and color-boundary detection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createVectorCropSiblingNode, croppedSvgViewBox, vectorCropRasterSize, vectorCropSiblingSize } from "../src/ui/pages/CanvasWorkspace/canvas/cadCrop.ts";
|
||||
import { cropSelectionScreenBounds, visibleCropSelectionForBounds } from "../src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts";
|
||||
import { cropSelectionFromScreenDrag, cropSelectionScreenBounds, visibleCropSelectionForBounds } from "../src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts";
|
||||
|
||||
const source = {
|
||||
id: "cad-source",
|
||||
@@ -74,3 +74,17 @@ test("keeps the full crop when the image already fits the safe viewport", () =>
|
||||
|
||||
assert.deepEqual(visibleCropSelectionForBounds(imageBounds, { width: 1200, height: 800 }), { x: 0, y: 0, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("clamps a manually drawn crop to the currently visible image region", () => {
|
||||
const imageBounds = { left: -200, top: -100, width: 1000, height: 800 };
|
||||
const visibleSelection = { x: 0.2, y: 0.125, width: 0.6, height: 0.75 };
|
||||
|
||||
assert.deepEqual(
|
||||
cropSelectionFromScreenDrag(imageBounds, visibleSelection, { x: 0, y: 0 }, { x: 900, y: 700 }),
|
||||
visibleSelection
|
||||
);
|
||||
assert.deepEqual(
|
||||
cropSelectionFromScreenDrag(imageBounds, visibleSelection, { x: 100, y: 140 }, { x: 500, y: 460 }),
|
||||
{ x: 0.3, y: 0.3, width: 0.4, height: 0.4 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { findColorBoundaryCrop, mapColorBoundaryCropToLimit } from "../src/ui/pages/CanvasWorkspace/canvas/colorBoundaryCrop.ts";
|
||||
|
||||
function imageFixture(width, height, background, region) {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
const inside = x >= region.left && x < region.right && y >= region.top && y < region.bottom;
|
||||
const color = inside ? region.color(x, y) : background;
|
||||
data[offset] = color[0];
|
||||
data[offset + 1] = color[1];
|
||||
data[offset + 2] = color[2];
|
||||
data[offset + 3] = color[3] ?? 255;
|
||||
}
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
test("finds the connected color-bounded region around a crop click", () => {
|
||||
const image = imageFixture(12, 10, [235, 235, 235, 255], {
|
||||
left: 3,
|
||||
top: 2,
|
||||
right: 9,
|
||||
bottom: 7,
|
||||
color: (x, y) => [64 + x, 72 + y, 80, 255]
|
||||
});
|
||||
|
||||
assert.deepEqual(findColorBoundaryCrop(image, { x: 0.5, y: 0.4 }), {
|
||||
x: 0.25,
|
||||
y: 0.2,
|
||||
width: 0.5,
|
||||
height: 0.5
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the full analyzable area when no color boundary encloses the click", () => {
|
||||
const image = imageFixture(8, 6, [250, 250, 250, 255], {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
color: () => [0, 0, 0, 255]
|
||||
});
|
||||
|
||||
assert.deepEqual(findColorBoundaryCrop(image, { x: 0.5, y: 0.5 }), { x: 0, y: 0, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("never expands automatic selection beyond the currently visible crop limit", () => {
|
||||
const visibleLimit = { x: 0.18, y: 0.12, width: 0.64, height: 0.7 };
|
||||
|
||||
assert.deepEqual(mapColorBoundaryCropToLimit({ x: 0, y: 0, width: 1, height: 1 }, visibleLimit), visibleLimit);
|
||||
assert.deepEqual(mapColorBoundaryCropToLimit({ x: 0.25, y: 0.2, width: 0.5, height: 0.4 }, visibleLimit), {
|
||||
x: 0.34,
|
||||
y: 0.26,
|
||||
width: 0.32,
|
||||
height: 0.28
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { leaferViewSize, leaferVisualDescriptor, visualInput } from "../src/ui/pages/CanvasWorkspace/canvas/leaferRenderer.ts";
|
||||
|
||||
const cadNode = {
|
||||
id: "cad-1",
|
||||
type: "image",
|
||||
title: "drawing.dwg",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 640,
|
||||
height: 480,
|
||||
content: "https://assets.example/drawing.svg",
|
||||
renderContent: "https://assets.example/drawing.render.webp",
|
||||
tone: "transparent-image",
|
||||
status: "success",
|
||||
layerRole: "cad-vector",
|
||||
semanticContext: "CAD semantic context: layer ROOMS"
|
||||
};
|
||||
|
||||
test("renders CAD through its WebP texture without changing source or semantics", () => {
|
||||
const descriptor = leaferVisualDescriptor(cadNode);
|
||||
const input = visualInput(descriptor);
|
||||
|
||||
assert.equal(descriptor.kind, "image");
|
||||
assert.match(descriptor.url, /drawing\.render\.webp/);
|
||||
assert.equal(input.x, cadNode.x + cadNode.width / 2);
|
||||
assert.equal(input.y, cadNode.y + cadNode.height / 2);
|
||||
assert.equal(cadNode.content, "https://assets.example/drawing.svg");
|
||||
assert.match(cadNode.semanticContext, /ROOMS/);
|
||||
});
|
||||
|
||||
test("keeps vector nodes on the DOM fallback until a render texture exists", () => {
|
||||
assert.equal(leaferVisualDescriptor({ ...cadNode, renderContent: undefined }), null);
|
||||
});
|
||||
|
||||
test("maps manual frames to retained Leafer rectangles", () => {
|
||||
const descriptor = leaferVisualDescriptor({
|
||||
...cadNode,
|
||||
id: "frame-1",
|
||||
type: "frame",
|
||||
layerRole: "manual-frame",
|
||||
fillColor: "#ffffff",
|
||||
strokeColor: "#101010",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dashed"
|
||||
});
|
||||
|
||||
assert.equal(descriptor.kind, "frame");
|
||||
assert.deepEqual(descriptor.dashPattern, [12, 8]);
|
||||
});
|
||||
|
||||
test("sizes the Leafer surface from the mounted stage instead of stale defaults", () => {
|
||||
assert.deepEqual(leaferViewSize({ clientWidth: 1685, clientHeight: 1670 }, { width: 1100, height: 760 }), {
|
||||
width: 1685,
|
||||
height: 1670
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user