mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c4af5bd7 | ||
|
|
3f596e351a | ||
|
|
afd3012405 |
+16
-15
@@ -8,6 +8,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities";
|
||||
import { WebGLRenderer } from "three";
|
||||
import { upload3DObjects } from "../UploadModal3D/utilities/upload3DObjects";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -74,22 +75,22 @@ export const UploadFilesSplat = ({
|
||||
// Use the filename as the title
|
||||
const title = assetFile.value.name.split(".")[0];
|
||||
|
||||
const shouldFlip = title.endsWith("ceramic");
|
||||
|
||||
assetFile.value.arrayBuffer().then((arrayBuffer) => {
|
||||
onLocalBytes(arrayBuffer, shouldFlip);
|
||||
}).catch((error) => {
|
||||
console.error("Error reading file as ArrayBuffer:", error);
|
||||
});
|
||||
|
||||
// upload3DObjects({
|
||||
// title: title,
|
||||
// assetFile: assetFile.value,
|
||||
// thumbnailSnapshot: thumbnailFile,
|
||||
// engineCategory: engineCategory,
|
||||
// animationType: subtype,
|
||||
// progressCallback: onUploadProgress,
|
||||
// const shouldFlip = title.search("ceramic") != -1;
|
||||
//
|
||||
// assetFile.value.arrayBuffer().then((arrayBuffer) => {
|
||||
// onLocalBytes(arrayBuffer, shouldFlip);
|
||||
// }).catch((error) => {
|
||||
// console.error("Error reading file as ArrayBuffer:", error);
|
||||
// });
|
||||
|
||||
upload3DObjects({
|
||||
title: title,
|
||||
assetFile: assetFile.value,
|
||||
thumbnailSnapshot: thumbnailFile,
|
||||
engineCategory: engineCategory,
|
||||
animationType: subtype,
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum AssetType {
|
||||
OBJECT = "object",
|
||||
SKYBOX = "skybox",
|
||||
SHAPE = "shape",
|
||||
SPLAT = "splat",
|
||||
}
|
||||
|
||||
@@ -96,24 +96,20 @@ class DndAsset {
|
||||
// if (dragItem.value.type === AssetType.CAMERA) {
|
||||
// console.log("Dragged In Camera Type")
|
||||
// }
|
||||
if (dragItem.value.type === AssetType.OBJECT) {
|
||||
addObject(dragItem.value);
|
||||
}
|
||||
|
||||
if (dragItem.value.type === AssetType.SHAPE) {
|
||||
addShape(dragItem.value);
|
||||
}
|
||||
|
||||
/*
|
||||
FIXME:
|
||||
THIS IS A TEMPARARY SOLUTION TO A LONG PROBLEM WITH SKYBOXES
|
||||
UPDATE THIS WHEN UPLOADING SKYBOXES ARE FULLY IMPLEMENTED.
|
||||
THIS IS JUST TEMPARARY!!!
|
||||
|
||||
*/
|
||||
if (dragItem.value.type === AssetType.SKYBOX) {
|
||||
addObject(dragItem.value);
|
||||
if (mediaItem.type === AssetType.OBJECT || mediaItem.type === AssetType.SPLAT || mediaItem.type === AssetType.SKYBOX) {
|
||||
addObject(mediaItem);
|
||||
}
|
||||
|
||||
if (mediaItem.type === AssetType.SHAPE) {
|
||||
addShape(mediaItem);
|
||||
}
|
||||
|
||||
this.endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -676,8 +676,8 @@ class Editor {
|
||||
this.mouseOverEventHandler = this.mouseOverEventHandler.bind(this);
|
||||
|
||||
// Add spark renderer as a child of the camera
|
||||
this.activeScene.scene.add(this.sparkRenderer);
|
||||
this.camera?.add(this.sparkRenderer);
|
||||
// this.activeScene.scene.add(this.sparkRenderer);
|
||||
// this.camera?.add(this.sparkRenderer);
|
||||
|
||||
const onloadCallback = () => {
|
||||
console.log("Setting Scene is loaded");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { InfiniteGridHelper } from "./InfiniteGridHelper";
|
||||
import { cameras, selectedCameraId } from "../signals/camera";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { SplatMesh } from "@sparkjsdev/spark";
|
||||
|
||||
class Scene {
|
||||
name: string;
|
||||
@@ -597,7 +598,17 @@ class Scene {
|
||||
obj.userData["media_file_type"] = MediaFileType.Image;
|
||||
}
|
||||
return obj;
|
||||
} else if (
|
||||
url.includes(".spz")
|
||||
) {
|
||||
return await this.loadSplatWithPlaceholder(
|
||||
media_id,
|
||||
name,
|
||||
auto_add,
|
||||
position,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.loadGlbWithPlaceholder(
|
||||
media_id,
|
||||
name,
|
||||
@@ -629,6 +640,43 @@ class Scene {
|
||||
return false;
|
||||
}
|
||||
|
||||
async loadSplatWithPlaceholder(
|
||||
media_id: string,
|
||||
name: string,
|
||||
auto_add: boolean = true,
|
||||
position: THREE.Vector3 = new THREE.Vector3(-0.5, 1.5, 0),
|
||||
): Promise<THREE.Object3D> {
|
||||
if (this.placeholder_manager === undefined) {
|
||||
throw Error("Place holder Manager is undefined");
|
||||
}
|
||||
|
||||
const url = await this.getMediaURL(media_id);
|
||||
const key = media_id + name + generateUUID();
|
||||
await this.placeholder_manager.add(key, `Loading: ${name}`, position);
|
||||
|
||||
const splat: SplatMesh = await this.load_splat_wrapped_no_cors(url, async () => {
|
||||
if (this.placeholder_manager === undefined) {
|
||||
throw Error("Place holder Manager is undefined");
|
||||
}
|
||||
await this.placeholder_manager.remove(key);
|
||||
}).catch(async (error) => {
|
||||
if (this.placeholder_manager !== undefined) {
|
||||
await this.placeholder_manager.remove(key);
|
||||
}
|
||||
toast.error("Failed to load splat.");
|
||||
throw error;
|
||||
});
|
||||
|
||||
splat.userData["media_id"] = media_id;
|
||||
splat.userData["media_file_type"] = MediaFileType.SPZ;
|
||||
|
||||
if (auto_add) {
|
||||
this.scene.add(splat);
|
||||
}
|
||||
this.updateSurfaceIdAttributeToMesh(this.scene);
|
||||
return splat;
|
||||
}
|
||||
|
||||
async loadGlbWithPlaceholder(
|
||||
media_id: string,
|
||||
name: string,
|
||||
@@ -809,6 +857,35 @@ class Scene {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a full media_url via Tauri (without browser cors)
|
||||
* @param media_url
|
||||
* @param onComplete
|
||||
* @returns
|
||||
*/
|
||||
private async load_splat_wrapped_no_cors(
|
||||
media_url: string,
|
||||
onComplete: () => void,
|
||||
): Promise<SplatMesh> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await LoadWithoutCors(media_url); // Load via Tauri!
|
||||
} catch (error) {
|
||||
console.error("load Splat from Tauri error:", error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
new SplatMesh({
|
||||
fileBytes: buffer, onLoad: (mesh) => {
|
||||
resolve(mesh);
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Using a constructed media_url load a glb, grab the progress.
|
||||
* @param media_url
|
||||
|
||||
@@ -190,7 +190,7 @@ export class TimeLine {
|
||||
this.queueNewObjectMessage(
|
||||
newObject,
|
||||
data.data as MediaItem,
|
||||
AssetType.OBJECT,
|
||||
newObject.type as AssetType
|
||||
);
|
||||
const result = this.editorEngine.sceneManager?.render_outliner(
|
||||
this.characters,
|
||||
@@ -321,7 +321,7 @@ export class TimeLine {
|
||||
queueNewObjectMessage(
|
||||
item: THREE.Object3D<THREE.Object3DEventMap>,
|
||||
data: MediaItem,
|
||||
asset_type: AssetType.OBJECT | AssetType.CHARACTER | AssetType.SHAPE,
|
||||
asset_type: AssetType.OBJECT | AssetType.CHARACTER | AssetType.SHAPE | AssetType.SPLAT,
|
||||
) {
|
||||
Queue.publish({
|
||||
queueName: QueueNames.FROM_ENGINE,
|
||||
|
||||
@@ -16,6 +16,9 @@ export enum MediaFileType {
|
||||
// open source 3D file format.
|
||||
GLB = "glb",
|
||||
|
||||
// SPLAT file type. SPZ format is a compressed and smaller format.
|
||||
SPZ = "spz",
|
||||
|
||||
// MMD is the format for anime characters.
|
||||
// open source 3D file format.
|
||||
MMD = "mmd",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { SplatMesh } from "@sparkjsdev/spark";
|
||||
|
||||
export interface Viewer3DProps {
|
||||
modelUrl?: string;
|
||||
@@ -27,7 +28,7 @@ export function Viewer3D({
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const cubeRef = useRef<THREE.Mesh | null>(null);
|
||||
const loadedModelRef = useRef<THREE.Group | null>(null);
|
||||
const loadedModelRef = useRef<THREE.Object3D | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
const [isModelLoaded, setIsModelLoaded] = useState(false);
|
||||
const thumbnailCapturedRef = useRef(false);
|
||||
@@ -191,7 +192,6 @@ export function Viewer3D({
|
||||
thumbnailCapturedRef.current = false;
|
||||
|
||||
const scene = sceneRef.current;
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
if (loadedModelRef.current) {
|
||||
console.log("[Viewer3D] Removing previous model");
|
||||
@@ -203,95 +203,112 @@ export function Viewer3D({
|
||||
cubeRef.current.visible = false;
|
||||
}
|
||||
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
console.log("[Viewer3D] Model loaded successfully");
|
||||
const model = gltf.scene;
|
||||
const onModelLoaded = (model: THREE.Object3D) => {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 2 / maxDim;
|
||||
model.scale.multiplyScalar(scale);
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 2 / maxDim;
|
||||
model.scale.multiplyScalar(scale);
|
||||
const scaledBox = new THREE.Box3().setFromObject(model);
|
||||
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
|
||||
const scaledSize = scaledBox.getSize(new THREE.Vector3());
|
||||
|
||||
const scaledBox = new THREE.Box3().setFromObject(model);
|
||||
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
|
||||
const scaledSize = scaledBox.getSize(new THREE.Vector3());
|
||||
model.position.x = -scaledCenter.x;
|
||||
model.position.z = -scaledCenter.z;
|
||||
model.position.y = -scaledBox.min.y;
|
||||
|
||||
model.position.x = -scaledCenter.x;
|
||||
model.position.z = -scaledCenter.z;
|
||||
model.position.y = -scaledBox.min.y;
|
||||
scene.add(model);
|
||||
loadedModelRef.current = model;
|
||||
setIsModelLoaded(true);
|
||||
|
||||
scene.add(model);
|
||||
loadedModelRef.current = model;
|
||||
setIsModelLoaded(true);
|
||||
const modelHeight = scaledSize.y;
|
||||
const maxModelDim = Math.max(scaledSize.x, scaledSize.y, scaledSize.z);
|
||||
|
||||
const modelHeight = scaledSize.y;
|
||||
const maxModelDim = Math.max(scaledSize.x, scaledSize.y, scaledSize.z);
|
||||
// Calculate distance needed to fit the model based on camera FOV (50 degrees)
|
||||
const fov = 50;
|
||||
const fovRad = (fov * Math.PI) / 180;
|
||||
const fitDistance = maxModelDim / 2 / Math.tan(fovRad / 2);
|
||||
|
||||
// Calculate distance needed to fit the model based on camera FOV (50 degrees)
|
||||
const fov = 50;
|
||||
const fovRad = (fov * Math.PI) / 180;
|
||||
const fitDistance = maxModelDim / 2 / Math.tan(fovRad / 2);
|
||||
// Add padding (1.4x) to ensure model isn't touching edges
|
||||
const cameraDistance = fitDistance * 1.4;
|
||||
|
||||
// Add padding (1.4x) to ensure model isn't touching edges
|
||||
const cameraDistance = fitDistance * 1.4;
|
||||
|
||||
if (cameraRef.current && controlsRef.current) {
|
||||
// Position camera at 45-degree angle
|
||||
const angle = Math.PI / 4;
|
||||
cameraRef.current.position.set(
|
||||
Math.sin(angle) * cameraDistance,
|
||||
modelHeight * 0.5 + cameraDistance * 0.35,
|
||||
Math.cos(angle) * cameraDistance
|
||||
);
|
||||
controlsRef.current.target.set(0, modelHeight * 0.4, 0);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
|
||||
// Capture thumbnail after a short delay to ensure rendering is complete
|
||||
if (onThumbnailCapture && !thumbnailCapturedRef.current) {
|
||||
thumbnailCapturedRef.current = true;
|
||||
setTimeout(() => {
|
||||
if (rendererRef.current && sceneRef.current && cameraRef.current) {
|
||||
// Temporarily hide grid for thumbnail
|
||||
if (gridRef.current) {
|
||||
gridRef.current.visible = false;
|
||||
}
|
||||
// Also hide placeholder cube
|
||||
if (cubeRef.current) {
|
||||
cubeRef.current.visible = false;
|
||||
}
|
||||
|
||||
// Render and capture
|
||||
rendererRef.current.render(sceneRef.current, cameraRef.current);
|
||||
const dataUrl =
|
||||
rendererRef.current.domElement.toDataURL("image/png");
|
||||
onThumbnailCapture(dataUrl);
|
||||
|
||||
// Restore grid visibility
|
||||
if (gridRef.current) {
|
||||
gridRef.current.visible = showGrid;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
(progress) => {
|
||||
console.log(
|
||||
"[Viewer3D] Loading progress:",
|
||||
((progress.loaded / progress.total) * 100).toFixed(2) + "%"
|
||||
if (cameraRef.current && controlsRef.current) {
|
||||
// Position camera at 45-degree angle
|
||||
const angle = Math.PI / 4;
|
||||
cameraRef.current.position.set(
|
||||
Math.sin(angle) * cameraDistance,
|
||||
modelHeight * 0.5 + cameraDistance * 0.35,
|
||||
Math.cos(angle) * cameraDistance
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
console.error("[Viewer3D] Error loading model:", error);
|
||||
if (cubeRef.current) {
|
||||
cubeRef.current.visible = true;
|
||||
}
|
||||
controlsRef.current.target.set(0, modelHeight * 0.4, 0);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
);
|
||||
|
||||
// Capture thumbnail after a short delay to ensure rendering is complete
|
||||
if (onThumbnailCapture && !thumbnailCapturedRef.current) {
|
||||
thumbnailCapturedRef.current = true;
|
||||
setTimeout(() => {
|
||||
if (rendererRef.current && sceneRef.current && cameraRef.current) {
|
||||
// Temporarily hide grid for thumbnail
|
||||
if (gridRef.current) {
|
||||
gridRef.current.visible = false;
|
||||
}
|
||||
// Also hide placeholder cube
|
||||
if (cubeRef.current) {
|
||||
cubeRef.current.visible = false;
|
||||
}
|
||||
|
||||
// Render and capture
|
||||
rendererRef.current.render(sceneRef.current, cameraRef.current);
|
||||
const dataUrl =
|
||||
rendererRef.current.domElement.toDataURL("image/png");
|
||||
onThumbnailCapture(dataUrl);
|
||||
|
||||
// Restore grid visibility
|
||||
if (gridRef.current) {
|
||||
gridRef.current.visible = showGrid;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
if (modelUrl.endsWith(".spz")) {
|
||||
console.log("[Viewer3D] .spz format detected");
|
||||
new SplatMesh({
|
||||
url: modelUrl, onLoad: (mesh) => {
|
||||
scene.add(mesh);
|
||||
setIsModelLoaded(true);
|
||||
// onModelLoaded(mesh);
|
||||
console.log("Splat loaded")
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
console.log("[Viewer3D] Model loaded successfully");
|
||||
const model = gltf.scene;
|
||||
onModelLoaded(model);
|
||||
},
|
||||
(progress) => {
|
||||
console.log(
|
||||
"[Viewer3D] Loading progress:",
|
||||
((progress.loaded / progress.total) * 100).toFixed(2) + "%"
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
console.error("[Viewer3D] Error loading model:", error);
|
||||
if (cubeRef.current) {
|
||||
cubeRef.current.visible = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loadedModelRef.current && sceneRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user