Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 30 additions & 136 deletions src/assets/components/model/model-detail-pane/ModelDetailPane.tsx
Original file line number Diff line number Diff line change
@@ -1,157 +1,51 @@
import * as THREE from 'three'
import { Canvas, useLoader, useThree } from '@react-three/fiber'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import { Suspense, useContext, useLayoutEffect, useRef, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import { Asset } from "../../../entities/Assets.ts";
import { Center, GizmoHelper, GizmoViewport, Grid, Html, OrbitControls, useProgress } from '@react-three/drei'
import { useElementSize } from "@mantine/hooks";
import { Alert, lighten } from "@mantine/core";
import { SettingsContext } from '@/core/settings/settingsContext.ts';

import { Stack } from "@mantine/core";
import { SettingsContext } from "@/core/settings/settingsContext.ts";
import { Viewer3D, createViewer3D } from './Viewer3D.ts';

type ModelProps = {
color: string,
model: Asset,
projectUuid: string
}

function Model({ color, model, projectUuid }: ModelProps) {
const { settings } = useContext(SettingsContext);
const geom = useLoader(STLLoader, `${settings.localBackend}/projects/${projectUuid}/assets/${model.id}/file`);
const meshRef = useRef<THREE.Mesh>(null!)

const [active, setActive] = useState(false)

// Subscribe this component to the render-loop, rotate the mesh every frame
//useFrame((state, delta) => (meshRef.current.rotation.z += delta))
return (
<>
<mesh
name={model.id}
onClick={() => setActive(!active)}
ref={meshRef}
rotation={[-Math.PI / 2, 0, 0]}
scale={0.1}>
<primitive object={geom} attach="geometry" />
<meshStandardMaterial color={color} />
</mesh>
</>

)
}


type SceneProps = {
type ModelDetailPaneProps = {
models: Asset[],
projectUuid: string
}

function Scene({ models, projectUuid }: SceneProps) {
const colors = ["#9d4b4b", "#4C5897", "#5474B4", "#504C97", "#6B31B2", "#C91A52"]
return (
<>
<ambientLight intensity={0.5} />
<directionalLight castShadow position={[2.5, 5, 5]} intensity={1.5} shadow-mapSize={[1024, 1024]}>
<orthographicCamera attach="shadow-camera" args={[-5, 5, 5, -5, 1, 50]} />
</directionalLight>
<Center>
<Suspense fallback={<Progress />}>
<MoveCamera models={models}>
{models.map((model, i) => (
<Model key={model.id} color={colors[i % colors.length]} model={model} projectUuid={projectUuid} />
))}
</MoveCamera>
</Suspense>
</Center>
<GizmoHelper alignment="bottom-right" margin={[100, 100]}>
<GizmoViewport labelColor="white" axisHeadScale={1} />
</GizmoHelper>
<OrbitControls makeDefault />
</>
)
projectUuid: string,
onClose: () => void
}

function MoveCamera({ children, models }: { children: JSX.Element[], models: Asset[] }) {
const group = useRef<THREE.Group>()
const { camera } = useThree()
useLayoutEffect(() => {
if (!group.current) return;
const box = new THREE.Box3();
box.setFromObject(group.current);



const size = new THREE.Vector3();
box.getSize(size);
const fov = camera.fov * (Math.PI / 180);
const fovh = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect);
let dx = size.z / 2 + Math.abs(size.x / 2 / Math.tan(fovh / 2));
let dy = size.z / 2 + Math.abs(size.y / 2 / Math.tan(fov / 2));
let cameraZ = Math.max(dx, dy);

// offset the camera, if desired (to avoid filling the whole canvas)
cameraZ *= 1.25;

camera.position.set(0, 0, cameraZ);

const newX = camera.position.x - (size.x / 2);
const newY = camera.position.y - (size.y / 2);
group.current.position.set(newX, newY, group.current.position.z)

// set the far plane of the camera so that it easily encompasses the whole object
const minZ = box.min.z;
const cameraToFarEdge = (minZ < 0) ? -minZ + cameraZ : cameraZ - minZ;

export function ModelDetailPane({ models, projectUuid, onClose }: ModelDetailPaneProps) {
const parent = useRef<HTMLElement>();
const { settings } = useContext(SettingsContext);
const [viewer3D, setViewer3D] = useState<Viewer3D>();

useEffect(()=>{
if(!parent.current) return;
if(!viewer3D) return;
viewer3D.setModels(models);
}, [models, viewer3D]);

const box3Helper = new THREE.Box3Helper(box, 0x00ff00);
box3Helper.material.linewidth = 3;
group.current.add(box3Helper);
useEffect(() => {
if(!parent.current) return;

const axesHelper = new THREE.AxesHelper(5);
const center = new THREE.Vector3();
box.getCenter(center)
axesHelper.position.set(center.x, center.y, center.z)
group.current.add(axesHelper);
let viewer = createViewer3D(parent.current, settings.localBackend);
setViewer3D(viewer);

camera.far = cameraToFarEdge * 3;
camera.updateProjectionMatrix();
return () => {
if (group.current) {
group.current.remove(box3Helper);
group.current.remove(axesHelper);
}
viewer.destroy();
}
}, [models]);
return (
<group ref={group}>
{children}
</group>
);
}

function Progress() {
const { progress, loaded } = useProgress()
return <Html center>{progress} % loaded {loaded}</Html>
}
}, [])

type ModelDetailPaneProps = {
models: Asset[],
projectUuid: string
onClose: () => void;
}

export function ModelDetailPane({ models, projectUuid, onClose }: ModelDetailPaneProps) {
console.log(models);
const { ref, width } = useElementSize();
return (
<Alert variant="filled" color="gray" withCloseButton onClose={onClose} title={' '} ref={ref} style={{ height: width * (9 / 16) }}>
<Canvas shadows raycaster={{ params: { Line: { threshold: 0.15 } } }}
camera={{ position: [0, 0, 0], fov: 20 }}
style={{ height: (width * (9 / 16)) - 20 }}
>
<Scene models={models} projectUuid={projectUuid} />
</Canvas>
</Alert>
<Stack
h={600}
bg="var(--mantine-color-body)"
justify="flex-start"
ref={parent}
/>
);
}

Expand All @@ -168,5 +62,5 @@ function Ground() {
followCamera: false,
infiniteGrid: true
}
return <Grid position={[0, -0.01, 0]} args={[10.5, 10.5]} {...gridConfig} />
return <Grid position={[0, 0, 0]} args={[10.5, 10.5]} {...gridConfig} />
}
171 changes: 171 additions & 0 deletions src/assets/components/model/model-detail-pane/Viewer3D.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Asset } from "@/assets/entities/Assets";
import * as THREE from 'three';
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import {ViewHelper} from 'three/addons/helpers/ViewHelper.js'
import { Box } from "@mantine/core";

type State = {
models: Asset[] | null,
modelsRendered: ModelMesh[],
parent: HTMLElement,
scene: THREE.Scene,
camera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer
loader: STLLoader,
local_backend: string,
controls: OrbitControls | null,
group: THREE.Group,
dirty: boolean,
boxHelper: THREE.Box3Helper | null,
}

type ModelMesh = {
id: String,
mesh: THREE.Mesh,
model: Asset
}

export type Viewer3D = {
destroy(): void;
setModels(models: Asset[]): void;
}

export const createViewer3D = (parent: HTMLElement, local_backend: string): Viewer3D => {
const state: State = {
models: [],
modelsRendered: [],
parent: parent,
camera: new THREE.PerspectiveCamera(20, parent.offsetWidth / parent.offsetHeight, 1, 1000),
scene: new THREE.Scene(),
local_backend: local_backend,
renderer: new THREE.WebGLRenderer({ antialias: true }),
loader: new STLLoader(),
controls: null,
group: new THREE.Group(),
dirty: false,
boxHelper: null
}

state.scene = new THREE.Scene();
state.scene.background = new THREE.Color( 0x333333 );

state.renderer.setPixelRatio( parent.offsetWidth / parent.offsetHeight );
state.renderer.setSize( parent.offsetWidth , parent.offsetHeight );
parent.appendChild( state.renderer.domElement );

// state.camera = new THREE.PerspectiveCamera( 60, parent.offsetWidth / parent.offsetHeight, 1, 1000 );
state.camera.position.set( 400, 200, 0 );
state.camera.lookAt( 0,0,0 );

// controls

state.controls = new OrbitControls( state.camera, state.renderer.domElement );

state.controls.enableDamping = true;
state.controls.dampingFactor = 0.05;
state.controls.screenSpacePanning = false;
state.controls.minDistance = 100;
state.controls.maxDistance = 500;
state.controls.maxPolarAngle = Math.PI / 2;

// lights
const dirLight1 = new THREE.DirectionalLight( 0xffffff, 3 );
dirLight1.position.set( 1, 1, 1 );
state.scene.add( dirLight1 );

const dirLight2 = new THREE.DirectionalLight( 0x002288, 3 );
dirLight2.position.set( -1, -1, -1 );
state.scene.add( dirLight2 );

const dirLight3 = new THREE.DirectionalLight( 0xffffff, 1 );
dirLight3.position.set( -1, -1, -1 );
state.scene.add( dirLight3 );

const ambientLight = new THREE.AmbientLight( 0x555555 );
// state.scene.add( ambientLight );

state.scene.add(state.group)


function onParentResize() {
state.camera.aspect = parent.offsetWidth / parent.offsetHeight;
state.renderer.setSize(state.parent.offsetWidth, state.parent.offsetHeight);
state.camera.updateProjectionMatrix();
}

function drawHelpers(){
state.scene.updateWorldMatrix(true, true); //make sure all transforms are updated after loading models

const groupCenter = new THREE.Vector3();
const box = new THREE.Box3();
box.setFromObject(state.group);
box.getCenter(groupCenter);

if(state.boxHelper){
state.scene.remove(state.boxHelper);
}
state.boxHelper = new THREE.Box3Helper( box, 0x719CD6 );
state.scene.add( state.boxHelper );

//set camera and controls to look to center of boundingbox
state.camera.lookAt(groupCenter);
state.controls?.target.set(groupCenter.x, groupCenter.y, groupCenter.z);
}

function animate() {
if(state.dirty){
drawHelpers();
state.dirty = false;
}
state.renderer.clear();
state.controls.update();

// helper.render(state.renderer);
state.renderer.render(state.scene, state.camera);
requestAnimationFrame(animate);
}

window.addEventListener( 'resize', onParentResize );
animate();


return {
destroy() {
state.renderer.dispose();
state.renderer.forceContextLoss();
console.log("WebGL Context Destroyed");
},
setModels(models: Asset[]) {
state.models = models;
const material = new THREE.MeshPhongMaterial({ color: 0xd5d5d5, specular: 0x494949, shininess: 10, flatShading : true});
state.group.clear();
state.models.forEach((model) => {
const renderedModel = state.modelsRendered.find(mr => {return model.id == mr.id});
if(renderedModel){
state.group.add(renderedModel.mesh);
state.dirty = true;
return;
}

state.loader.load(`${state.local_backend}/projects/${model.project_uuid}/assets/${model.id}/file`, function (geometry) {

const mesh = new THREE.Mesh(geometry, material);

mesh.position.set(0, 0, 0);
mesh.rotation.set(-Math.PI / 2, 0, Math.PI / 2);
mesh.scale.set(1,1,1);

mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.geometry.computeBoundingBox();

state.group.add(mesh);
const id:String = model.id;
state.modelsRendered.push({mesh: mesh, id: id, model: model});
state.dirty = true;
});
});
}
}
}