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(): {