diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx
index fe63973f47..7b654f3b53 100644
--- a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx
+++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx
@@ -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 (
+
+ );
+});
+
export const DecodeBase64ToImage = async (
base64String: string,
): Promise => {
@@ -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 = () => {
+ {/* 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 && (
+
+ )}
{editing3DNodeId &&
(() => {
const editingNode = nodes.find((n) => n.id === editing3DNodeId);
diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/components/Model3DOverlay.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/components/Model3DOverlay.tsx
index 7ce4ced98f..ed0beec19d 100644
--- a/frontend/apps/artcraft/app/src/pages/PageDraw/components/Model3DOverlay.tsx
+++ b/frontend/apps/artcraft/app/src/pages/PageDraw/components/Model3DOverlay.tsx
@@ -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,
);
diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/utilities/render3DModel.ts b/frontend/apps/artcraft/app/src/pages/PageDraw/utilities/render3DModel.ts
index 2aa44a1c4d..5906c1243e 100644
--- a/frontend/apps/artcraft/app/src/pages/PageDraw/utilities/render3DModel.ts
+++ b/frontend/apps/artcraft/app/src/pages/PageDraw/utilities/render3DModel.ts
@@ -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(): {