mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
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:
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(): {
|
||||
|
||||
Reference in new issue
Block a user