You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

554 lines
19 KiB

"use client";
import { useCallback, useRef, useState, useEffect, Suspense } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { Canvas, useThree, useFrame } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { useToast } from "@/components/Toast";
import { GLBViewerNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
type GLBViewerNodeType = Node<GLBViewerNodeData, "glbViewer">;
/**
* Renders a loaded GLB model using the raw GLTFLoader (avoids drei caching issues with blob URLs).
*/
function Model({ url, onError }: { url: string; onError?: () => void }) {
const groupRef = useRef<THREE.Group>(null);
const sceneRef = useRef<THREE.Group | null>(null);
const [loaded, setLoaded] = useState(false);
const { camera } = useThree();
useEffect(() => {
let cancelled = false;
setLoaded(false);
const loader = new GLTFLoader();
try {
loader.load(
url,
(gltf) => {
if (cancelled) return;
const loadedScene = gltf.scene;
// Compute bounding box and normalize
const box = new THREE.Box3().setFromObject(loadedScene);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
// Scale to fit in a ~2 unit box
const scale = 2 / maxDim;
loadedScene.scale.setScalar(scale);
// Re-center the model at origin
loadedScene.position.set(
-center.x * scale,
-center.y * scale,
-center.z * scale
);
}
sceneRef.current = loadedScene;
setLoaded(true);
// Fit camera to model
const dist = 3.5;
camera.position.set(dist, dist * 0.6, dist);
camera.lookAt(0, 0, 0);
},
undefined,
(error) => {
if (cancelled) return;
console.warn("GLB load failed (file may need re-upload):", error);
onError?.();
}
);
} catch {
if (!cancelled) onError?.();
}
return () => {
cancelled = true;
// Cleanup previous scene
if (sceneRef.current) {
sceneRef.current.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
try { obj.geometry?.dispose(); } catch (e) { console.warn("GLB geometry dispose failed:", e); }
try {
if (Array.isArray(obj.material)) {
obj.material.forEach((m) => { m.dispose(); });
} else {
obj.material?.dispose();
}
} catch (e) { console.warn("GLB material dispose failed:", e); }
}
});
sceneRef.current = null;
}
};
}, [url, camera, onError]);
if (!loaded || !sceneRef.current) return null;
return (
<group ref={groupRef}>
<primitive object={sceneRef.current} />
</group>
);
}
/**
* 3D grid floor and subtle environment — hidden during capture via ref.
*/
function SceneEnvironment({ groupRef }: { groupRef: React.RefObject<THREE.Group | null> }) {
return (
<group ref={groupRef}>
<spotLight
position={[5, 8, 5]}
angle={0.4}
penumbra={0.8}
intensity={1.5}
castShadow
/>
</group>
);
}
/**
* Helper component inside Canvas to expose the gl context for capture.
* Directly hides grid objects (via ref) before rendering the capture frame.
* Uses a ref-based approach so the capture function always has fresh references.
*/
function CaptureHelper({
captureRef,
envGroupRef,
}: {
captureRef: React.MutableRefObject<(() => string | null) | null>;
envGroupRef: React.RefObject<THREE.Group | null>;
}) {
const { gl, scene, camera } = useThree();
// Update the capture function every frame so it always has current gl/scene/camera
useFrame(() => {
captureRef.current = () => {
try {
// Directly hide environment objects in the Three.js scene graph (synchronous)
if (envGroupRef.current) {
envGroupRef.current.visible = false;
}
// Render without the grid
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL("image/png");
// Restore environment objects
if (envGroupRef.current) {
envGroupRef.current.visible = true;
}
return dataUrl;
} catch (err) {
console.warn("GLB capture failed:", err);
// Restore environment objects on failure
if (envGroupRef.current) {
envGroupRef.current.visible = true;
}
return null;
}
};
});
return null;
}
/**
* Auto-rotate the model slowly when not interacting.
*/
function AutoRotate({ enabled }: { enabled: boolean }) {
const { camera } = useThree();
useFrame((_, delta) => {
if (!enabled) return;
const angle = delta * 0.3;
const pos = camera.position.clone();
const cos = Math.cos(angle);
const sin = Math.sin(angle);
camera.position.x = pos.x * cos - pos.z * sin;
camera.position.z = pos.x * sin + pos.z * cos;
camera.lookAt(0, 0, 0);
});
return null;
}
/**
* Loading indicator (wireframe sphere) shown while GLB is parsing.
*/
function LoadingIndicator() {
return (
<mesh>
<sphereGeometry args={[0.2, 16, 16]} />
<meshBasicMaterial color="#666" wireframe />
</mesh>
);
}
export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeType>) {
const nodeData = data as GLBViewerNodeData;
const adaptiveCapturedImage = useAdaptiveImageSrc(nodeData.capturedImage, id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const fileInputRef = useRef<HTMLInputElement>(null);
const captureRef = useRef<(() => string | null) | null>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [autoRotate, setAutoRotate] = useState(false);
const [isInteracting, setIsInteracting] = useState(false);
const envGroupRef = useRef<THREE.Group>(null);
// Auto-resize node when capture image appears/disappears
const prevCapturedRef = useRef<string | null>(null);
useEffect(() => {
const hadCapture = prevCapturedRef.current != null;
const hasCapture = nodeData.capturedImage != null;
prevCapturedRef.current = nodeData.capturedImage ?? null;
// Only resize when capture state changes
if (hadCapture === hasCapture) return;
requestAnimationFrame(() => {
const storeState = useWorkflowStore.getState();
const thisNode = storeState.nodes.find((n) => n.id === id);
if (!thisNode) return;
const currentHeight = typeof thisNode.style?.height === "number"
? thisNode.style.height
: 380; // default node height
const currentWidth = typeof thisNode.style?.width === "number"
? thisNode.style.width
: 360;
// Get the viewport height to size the captured image area proportionally
const viewportH = viewportRef.current?.offsetHeight ?? 200;
// Extra space: controls bar (~30px) + label row (~20px) + captured image (~viewport height) + padding (~20px)
const captureExtraHeight = viewportH + 70;
const newHeight = hasCapture
? currentHeight + captureExtraHeight
: Math.max(380, currentHeight - captureExtraHeight);
useWorkflowStore.setState((state) => ({
nodes: state.nodes.map((node) => {
if (node.id !== id) return node;
return {
...node,
style: { ...node.style, width: currentWidth, height: newHeight },
};
}),
hasUnsavedChanges: true,
}));
});
}, [id, nodeData.capturedImage]);
// Revoke blob URL when it changes or when node is unmounted
const glbUrlRef = useRef<string | null>(nodeData.glbUrl);
useEffect(() => {
glbUrlRef.current = nodeData.glbUrl;
return () => {
if (glbUrlRef.current) {
URL.revokeObjectURL(glbUrlRef.current);
}
};
}, [nodeData.glbUrl]);
// Prevent wheel events from reaching React Flow (stop graph zoom/pan while scrolling to zoom 3D)
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const stopWheel = (e: WheelEvent) => e.stopPropagation();
el.addEventListener("wheel", stopWheel, { passive: false });
return () => el.removeEventListener("wheel", stopWheel);
}, [nodeData.glbUrl]);
// Shared file processing logic for both click-to-upload and drag-and-drop
const processFile = useCallback(
(file: File) => {
if (!file.name.toLowerCase().endsWith(".glb")) {
useToast.getState().show("Please upload a .GLB file", "warning");
return;
}
if (file.size > 100 * 1024 * 1024) {
useToast.getState().show("File too large. Maximum size is 100MB", "warning");
return;
}
// Revoke previous URL if exists
if (nodeData.glbUrl) {
URL.revokeObjectURL(nodeData.glbUrl);
}
const url = URL.createObjectURL(file);
updateNodeData(id, {
glbUrl: url,
filename: file.name,
capturedImage: null,
});
},
[id, nodeData.glbUrl, updateNodeData]
);
// If the blob URL becomes stale (e.g. after page reload), clear it so user can re-upload
const handleLoadError = useCallback(() => {
updateNodeData(id, { glbUrl: null, filename: null, capturedImage: null });
}, [id, updateNodeData]);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
processFile(file);
},
[processFile]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0];
if (!file) return;
processFile(file);
},
[processFile]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleRemove = useCallback(() => {
if (nodeData.glbUrl) {
URL.revokeObjectURL(nodeData.glbUrl);
}
updateNodeData(id, {
glbUrl: null,
filename: null,
capturedImage: null,
});
}, [id, nodeData.glbUrl, updateNodeData]);
const handleCapture = useCallback(() => {
if (captureRef.current) {
const base64 = captureRef.current();
if (base64) {
updateNodeData(id, { capturedImage: base64 });
} else {
useToast.getState().show("Failed to capture 3D view", "error");
}
}
}, [id, updateNodeData]);
return (
<BaseNode
id={id}
selected={selected}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 flex flex-col" : undefined}
aspectFitMedia={nodeData.capturedImage}
>
<input
ref={fileInputRef}
type="file"
accept=".glb"
onChange={handleFileChange}
className="hidden"
/>
{nodeData.glbUrl ? (
<div className="flex-1 flex flex-col min-h-0">
{/* 3D Viewport — fills node edge-to-edge */}
<div
ref={viewportRef}
className={`nodrag nopan nowheel relative w-full flex-1 min-h-[200px] overflow-hidden bg-neutral-900 ${nodeData.capturedImage ? "" : "rounded-b-[5px]"}`}
onPointerDown={() => setIsInteracting(true)}
onPointerUp={() => setIsInteracting(false)}
>
<Canvas
resize={{ offsetSize: true }}
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}
camera={{ position: [3.5, 2.1, 3.5], fov: 45, near: 0.01, far: 100 }}
onCreated={({ gl }) => {
gl.setClearColor(new THREE.Color("#1a1a1a"));
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.toneMappingExposure = 1.2;
}}
>
{/* Lighting */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 8, 5]} intensity={1} castShadow />
<directionalLight position={[-3, 2, -2]} intensity={0.3} />
<hemisphereLight args={["#b1e1ff", "#444444", 0.4]} />
{/* Environment grid (hidden during capture via ref) */}
<SceneEnvironment groupRef={envGroupRef} />
{/* Model */}
<Suspense fallback={<LoadingIndicator />}>
<Model url={nodeData.glbUrl} onError={handleLoadError} />
</Suspense>
{/* Controls */}
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
enablePan
enableZoom
target={[0, 0, 0]}
/>
<AutoRotate enabled={autoRotate && !isInteracting} />
<CaptureHelper captureRef={captureRef} envGroupRef={envGroupRef} />
</Canvas>
{/* Controls bar — overlaid on viewport */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 flex items-center justify-between gap-1 pointer-events-none bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-center gap-1.5 min-w-0 pointer-events-auto">
<span className="text-[10px] text-neutral-400 truncate max-w-[100px]">
{nodeData.filename}
</span>
<button
onClick={() => setAutoRotate(!autoRotate)}
title={autoRotate ? "Stop auto-rotate" : "Auto-rotate"}
className={`p-0.5 rounded transition-colors ${
autoRotate
? "text-cyan-400 bg-cyan-400/10"
: "text-neutral-500 hover:text-neutral-300"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
</button>
</div>
<div className="flex items-center gap-1 shrink-0 pointer-events-auto">
<button
onClick={handleCapture}
title="Capture current view as image"
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-neutral-300 hover:text-neutral-100 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
</svg>
Capture
</button>
<button
onClick={handleRemove}
title="Remove model"
className="p-0.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
{/* Captured image preview */}
{nodeData.capturedImage && (
<div className="px-3 py-1.5 shrink-0">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-green-400 flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Captured
</span>
<div className="flex items-center gap-2">
<button
onClick={() => downloadMedia(nodeData.capturedImage!, "image")}
className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Download
</button>
<button
onClick={() => updateNodeData(id, { capturedImage: null })}
className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Clear
</button>
</div>
</div>
<img
src={adaptiveCapturedImage ?? undefined}
alt="Captured 3D render"
className="w-full rounded border border-neutral-700 bg-neutral-900"
/>
</div>
)}
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="w-full flex-1 min-h-[150px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center cursor-pointer hover:border-neutral-500 hover:bg-neutral-700/50 transition-colors"
>
<svg className="w-8 h-8 text-neutral-500 mb-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
<span className="text-[10px] text-neutral-400">
Drop .GLB or click
</span>
</div>
)}
{/* 3D input handle - accepts generated 3D models */}
<Handle
type="target"
position={Position.Left}
id="3d"
style={{ top: "50%" }}
data-handletype="3d"
/>
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-3d)",
}}
>
3D
</div>
{/* Output handle - image (captured viewport) */}
<Handle
type="source"
position={Position.Right}
id="image"
style={{ top: "50%" }}
data-handletype="image"
/>
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style={{
left: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
</BaseNode>
);
}