improve 3D model editing in PageDraw: stretch preservation, live button tracking, perf

- Add `nativeWidth`/`nativeHeight` to `Model3DParams` so the 3D overlay
  always renders at the model's original undistorted dimensions. When a node
  is stretched via the Konva transformer, the overlay CSS-scales the native
  framebuffer to fill the stretched frame, giving a live stretch preview.
  The committed PNG is at native size; Konva applies the visual stretch,
  preserving it across re-edits.

- Fix `renderer.setSize(..., false)` so Three.js does not overwrite the
  canvas inline CSS, allowing Tailwind `h-full w-full` to control display
  size and produce the stretch effect.

- Extract "Edit 3D" button into an isolated `memo`-wrapped `Edit3DButton`
  component that owns its own `forceUpdate` state and Konva event listeners
  (stage pan/zoom + transformer drag). Konva events now only re-render the
  small button element, not the entire PageDraw tree.

- Button position reads directly from the live Konva node via
  `stage.findOne()` so it tracks transformer handle movement in real-time,
  before `onTransformEnd` bakes the values back into the store.
This commit is contained in:
Tanish Manku committed 2026-03-13 12:43:18 -07:00
1 parent 853f1d2b69
commit 8bb8c186a3
3 files changed
+117 -18

No files matched your search

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { useState, useRef, useEffect, useCallback, useMemo, memo } from "react";
import { useShallow } from "zustand/react/shallow";
import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface";
import "./App.css";
@@ -54,6 +54,73 @@ import { Model3DOverlay } from "./components/Model3DOverlay";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
// ─── Edit3DButton ─────────────────────────────────────────────────────────────
// Isolated memoized component so Konva-driven position updates (stage pan/zoom,
// transformer drag) only re-render this small button — not the entire PageDraw
// tree. It owns the forceUpdate state and Konva event listeners internally.
interface Edit3DButtonProps {
nodeId: string;
stageRef: { current: Konva.Stage };
onEdit: (nodeId: string) => void;
}
const Edit3DButton = memo(function Edit3DButton({
nodeId,
stageRef,
onEdit,
}: Edit3DButtonProps) {
const [, forceUpdate] = useState(0);
const bump = useCallback(() => forceUpdate((t) => t + 1), []);
// Attach Konva event listeners so the button repositions on every stage
// pan/zoom and on every transformer handle move (before onTransformEnd fires).
// Re-runs when nodeId changes so fresh transformers are picked up.
useEffect(() => {
const stage = stageRef.current;
if (!stage?.on) return;
const ns = ".edit3dbtn";
stage.on(
`dragmove${ns} xChange${ns} yChange${ns} scaleXChange${ns} scaleYChange${ns}`,
bump,
);
const transformers = stage.find("Transformer");
transformers.forEach((tr) => tr.on(`transform${ns}`, bump));
return () => {
stage.off(".edit3dbtn");
transformers.forEach((tr) => tr.off(".edit3dbtn"));
};
}, [stageRef, bump, nodeId]);
const stage = stageRef.current;
if (!stage?.container) return null;
const stageRect = stage.container().getBoundingClientRect();
const sx = stage.scaleX();
const sy = stage.scaleY();
// Read live Konva node geometry so the button tracks transformer handles
// in real-time (the store value is stale until onTransformEnd commits it).
const konvaNode = stage.findOne("#" + nodeId) as Konva.Shape | undefined;
if (!konvaNode) return null;
const screenLeft = stageRect.left + stage.x() + konvaNode.x() * sx;
const screenTop = stageRect.top + stage.y() + konvaNode.y() * sy;
const screenW = konvaNode.width() * konvaNode.scaleX() * sx;
return (
<button
className="pointer-events-auto fixed z-40 -translate-x-1/2 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white shadow-lg hover:bg-blue-500"
style={{ left: screenLeft + screenW / 2, top: screenTop - 36 }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => onEdit(nodeId)}
>
Edit 3D
</button>
);
});
export const DecodeBase64ToImage = async (
base64String: string,
): Promise<ImageBitmap> => {
@@ -358,17 +425,15 @@ const PageDraw = () => {
};
}, [createImageFromUrl, createImageFrom3DModel]);
// Open the 3D overlay automatically when a single 3D-model node is selected
// Auto-close the 3D overlay when the editing node is no longer selected
// (e.g. user clicks the canvas background or selects a different node).
// We do NOT auto-open here — opening is gated behind the "Edit 3D" button
// so the Konva transformer can work normally on selection.
useEffect(() => {
if (selectedNodeIds.length === 1) {
const node = nodes.find((n) => n.id === selectedNodeIds[0]);
if (node?.modelUrl) {
setEditing3DNodeId(node.id);
return;
}
if (editing3DNodeId && !selectedNodeIds.includes(editing3DNodeId)) {
setEditing3DNodeId(null);
}
setEditing3DNodeId(null);
}, [selectedNodeIds, nodes]);
}, [selectedNodeIds, editing3DNodeId]);
const handle3DOverlayCommit = useCallback(
(dataUrl: string, params: Model3DParams) => {
@@ -1049,6 +1114,18 @@ const PageDraw = () => {
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</div>
{/* Floating "Edit 3D" button — shown above the selected 3D-model node
when the overlay is not already open. Rendered as an isolated memoized
component so Konva-driven position updates don't re-render PageDraw. */}
{!editing3DNodeId &&
selectedNodeIds.length === 1 &&
nodes.find((n) => n.id === selectedNodeIds[0])?.modelUrl && (
<Edit3DButton
nodeId={selectedNodeIds[0]}
stageRef={stageRef}
onEdit={setEditing3DNodeId}
/>
)}
{editing3DNodeId &&
(() => {
const editingNode = nodes.find((n) => n.id === editing3DNodeId);
@@ -65,7 +65,8 @@ export function Model3DOverlay({
const captureParams = useCallback((): Model3DParams => {
const cam = cameraRef.current;
const ctrl = controlsRef.current;
if (!cam || !ctrl) return node.model3dParams ?? DEFAULT_MODEL3D_PARAMS;
const baseParams = node.model3dParams ?? DEFAULT_MODEL3D_PARAMS;
if (!cam || !ctrl) return baseParams;
return {
cameraPosition: {
x: cam.position.x,
@@ -79,6 +80,9 @@ export function Model3DOverlay({
},
fov: cam.fov,
modelScale: modelScaleRef.current,
// Preserve native dims — they are fixed for the lifetime of this 3D model node
nativeWidth: baseParams.nativeWidth,
nativeHeight: baseParams.nativeHeight,
};
}, [node.model3dParams]);
@@ -95,11 +99,13 @@ export function Model3DOverlay({
return;
}
// Render at the node's native pixel dimensions for the committed bitmap
const pixelWidth = Math.round(node.width * (node.scaleX ?? 1));
const pixelHeight = Math.round(node.height * (node.scaleY ?? 1));
renderer.setSize(pixelWidth, pixelHeight);
camera.aspect = pixelWidth / pixelHeight;
// Render at native (undistorted) dimensions. Konva will stretch the committed
// PNG to fill the node's current frame, preserving any user-applied stretch.
const baseParams = node.model3dParams ?? DEFAULT_MODEL3D_PARAMS;
const nativeW = baseParams.nativeWidth ?? Math.round(node.width * (node.scaleX ?? 1));
const nativeH = baseParams.nativeHeight ?? Math.round(node.height * (node.scaleY ?? 1));
renderer.setSize(nativeW, nativeH);
camera.aspect = nativeW / nativeH;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
@@ -114,6 +120,11 @@ export function Model3DOverlay({
if (!canvas) return;
const params = node.model3dParams ?? DEFAULT_MODEL3D_PARAMS;
// Render Three.js at native (undistorted) dimensions. The canvas element
// uses h-full w-full so the browser CSS-stretches the framebuffer to fill
// the overlay div, giving a live preview of how the stretch will look.
const nativeW = params.nativeWidth ?? Math.round(screenRect.width);
const nativeH = params.nativeHeight ?? Math.round(screenRect.height);
const scene = new THREE.Scene();
scene.background = null;
@@ -125,12 +136,15 @@ export function Model3DOverlay({
canvas,
});
renderer.setClearColor(0x000000, 0);
renderer.setSize(Math.round(screenRect.width), Math.round(screenRect.height));
// Pass false so Three.js does NOT update canvas.style.width/height — we let
// Tailwind's h-full w-full CSS scale the native framebuffer to the overlay
// div's dimensions, giving a live stretched/squished preview.
renderer.setSize(nativeW, nativeH, false);
rendererRef.current = renderer;
const camera = new THREE.PerspectiveCamera(
params.fov,
screenRect.width / screenRect.height,
nativeW / nativeH,
0.1,
1000,
);
@@ -6,6 +6,12 @@ export interface Model3DParams {
cameraTarget: { x: number; y: number; z: number };
fov: number;
modelScale: number;
/** Native render dimensions — the undistorted pixel size for this 3D model.
* Stored once on first drop and preserved across re-edits. When the Konva
* node is stretched, the overlay renders at these native dims and Konva
* CSS-stretches the result to match the node's frame, preserving the stretch. */
nativeWidth: number;
nativeHeight: number;
}
export const DEFAULT_MODEL3D_PARAMS: Model3DParams = {
@@ -13,6 +19,8 @@ export const DEFAULT_MODEL3D_PARAMS: Model3DParams = {
cameraTarget: { x: 0, y: 0.4, z: 0 },
fov: 50,
modelScale: 1,
nativeWidth: 512,
nativeHeight: 512,
};
function buildScene(): {