import { Mesh, PerspectiveCamera, PlaneGeometry, Scene, WebGLRenderer, AmbientLight, DirectionalLight, PCFSoftShadowMap, GridHelper, ArrowHelper, Vector3, FogExp2, CanvasTexture, type ColorRepresentation, type WebGLRendererParameters, MeshPhongMaterial, EquirectangularReflectionMapping, ACESFilmicToneMapping, MathUtils, MeshStandardMaterial } from 'three'; import { Sky } from 'three/addons/objects/Sky.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; import { TransformControls } from 'three/examples/jsm/controls/TransformControls'; import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'; import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'; export const addScene = () => new Scene(); interface position { x?: number; y?: number; z?: number; } interface light { color?: ColorRepresentation; intensity?: number; } interface gridOptions { divisions?: number; size?: number; } interface arrowOptions { origin: position; direction: position; length?: number; color?: ColorRepresentation; } type directionalLight = position & light; type gridHelperOptions = gridOptions & position; function calculateCurrentSunElevation() { let now = new Date(); let decimalTime = now.getHours() + now.getMinutes() / 60; let normalizedTime = (decimalTime % 12) / 6 - 1; return 10 * Math.sin(normalizedTime * Math.PI); } export default class SceneBuilder { public scene: Scene; public camera!: PerspectiveCamera; public ground!: Mesh; public renderer!: WebGLRenderer; public orbit: OrbitControls; public callback: Function | undefined; public gridHelper!: GridHelper; public model!: URDFRobot; public liveStreamTexture!: CanvasTexture; private fog!: FogExp2; private isLoaded: boolean = false; public isDragging: boolean = false; highlightMaterial: any; sky!: Sky; transformControl: TransformControls; constructor() { this.scene = new Scene(); if (this.scene.environment?.mapping) { this.scene.environment.mapping = EquirectangularReflectionMapping; } return this; } public addRenderer = (parameters?: WebGLRendererParameters) => { this.renderer = new WebGLRenderer(parameters); this.renderer.outputColorSpace = 'srgb'; this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = PCFSoftShadowMap; this.renderer.toneMapping = ACESFilmicToneMapping; this.renderer.toneMappingExposure = 0.85; if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement); return this; }; public addSky = () => { this.sky = new Sky(); this.sky.scale.setScalar(450000); this.scene.add(this.sky); const effectController = { turbidity: 10, rayleigh: 3, mieCoefficient: 0.005, mieDirectionalG: 0.7, elevation: calculateCurrentSunElevation(), azimuth: 180, exposure: this.renderer.toneMappingExposure }; const uniforms = this.sky.material.uniforms; uniforms['turbidity'].value = effectController.turbidity; uniforms['rayleigh'].value = effectController.rayleigh; uniforms['mieCoefficient'].value = effectController.mieCoefficient; uniforms['mieDirectionalG'].value = effectController.mieDirectionalG; this.renderer.toneMappingExposure = 0.5; const phi = MathUtils.degToRad(90 - effectController.elevation); const theta = MathUtils.degToRad(effectController.azimuth); const sun = new Vector3(); sun.setFromSphericalCoords(1, phi, theta); uniforms['sunPosition'].value.copy(sun); return this; }; public addPerspectiveCamera = (options: position) => { this.camera = new PerspectiveCamera(); this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0); this.scene.add(this.camera); return this; }; public addGroundPlane = (options?: position) => { var planeMaterial = new MeshStandardMaterial({ color: 0x808080, side: 2, opacity: 0.5 }); this.ground = new Mesh(new PlaneGeometry(), planeMaterial); this.ground.rotation.x = -Math.PI / 2; this.ground.scale.setScalar(30); this.ground.position.set(options?.x ?? 0, options?.y ?? 0, options?.z ?? 0); this.ground.receiveShadow = true; this.scene.add(this.ground); return this; }; public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => { this.orbit = new OrbitControls(this.camera, this.renderer.domElement); this.orbit.minDistance = minDistance; this.orbit.maxDistance = maxDistance; this.orbit.autoRotate = autoRotate; this.orbit.update(); return this; }; public addAmbientLight = (options: light) => { const ambientLight = new AmbientLight(options.color, options.intensity); this.scene.add(ambientLight); return this; }; public addDirectionalLight = (options: directionalLight) => { const directionalLight = new DirectionalLight(options.color, options.intensity); directionalLight.castShadow = true; directionalLight.shadow.camera.top = 10; directionalLight.shadow.camera.bottom = -10; directionalLight.shadow.camera.right = 10; directionalLight.shadow.camera.left = -10; directionalLight.shadow.mapSize.set(4096, 4096); directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0); this.scene.add(directionalLight); return this; }; public addGridHelper = (options: gridHelperOptions) => { this.gridHelper = new GridHelper(options.size, options.divisions); this.gridHelper.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0); this.gridHelper.material.opacity = 0.2; this.gridHelper.material.depthWrite = false; this.gridHelper.material.transparent = true; this.scene.add(this.gridHelper); return this; }; public addFogExp2 = (color: ColorRepresentation, density?: number) => { this.scene.fog = new FogExp2(color, density); return this; }; public fillParent = () => { const parentElement = this.renderer.domElement.parentElement; if (parentElement) { const width = parentElement.clientWidth; const height = parentElement.clientHeight; this.handleResize(width, height); } return this; }; public handleResize = (width = window.innerWidth, height = window.innerHeight) => { this.renderer.setSize(width, height); this.renderer.setPixelRatio(window.devicePixelRatio); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); return this; }; public addRenderCb = (callback: Function) => { this.callback = callback; return this; }; public startRenderLoop = () => { this.renderer.setAnimationLoop(() => { this.renderer.render(this.scene, this.camera); this.orbit.update(); this.handleRobotShadow(); if (this.callback) this.callback(); if (!this.liveStreamTexture) return; }); return this; }; public addArrowHelper = (options?: arrowOptions) => { const dir = new Vector3( options?.direction.x ?? 0, options?.direction.y ?? 0, options?.direction.z ?? 0 ); const origin = new Vector3( options?.origin.x ?? 0, options?.origin.y ?? 0, options?.origin.z ?? 0 ); const arrowHelper = new ArrowHelper( dir, origin, options?.length ?? 1.5, options?.color ?? 0xff0000 ); this.scene.add(arrowHelper); return this; }; private setJointValue(jointName: string, angle: number) { if (!this.model) return; if (!this.model.joints[jointName]) return; this.model.joints[jointName].setJointValue(angle); } isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'; highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => { const traverse = (c: any) => { if (c.type === 'Mesh') { if (revert) { c.material = c.__origMaterial; delete c.__origMaterial; } else { c.__origMaterial = c.material; c.material = material; } } if (c === m || !this.isJoint(c)) { for (let i = 0; i < c.children.length; i++) { const child = c.children[i]; if (!child.isURDFCollider) { traverse(c.children[i]); } } } }; traverse(m); }; public addTransformControls = (model: any) => { this.transformControl = new TransformControls(this.camera, this.renderer.domElement); this.transformControl.addEventListener('dragging-changed', (event: any) => { this.orbit.enabled = !event.value; this.isDragging = !event.value; }); this.transformControl.attach(model); this.scene.add(this.transformControl); this.transformControl.setMode('rotate'); return this; }; public addModel = (model: any) => { this.model = model; this.scene.add(model); return this; }; public addDragControl = (updateAngle: any) => { const highlightColor = '#FFFFFF'; const highlightMaterial = new MeshPhongMaterial({ shininess: 10, color: highlightColor, emissive: highlightColor, emissiveIntensity: 0.25 }); const dragControls = new PointerURDFDragControls( this.scene, this.camera, this.renderer.domElement ); dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => { this.setJointValue(joint.name, angle); updateAngle(joint.name, angle); }; dragControls.onDragStart = () => { this.orbit.enabled = false; this.isDragging = true; }; dragControls.onDragEnd = () => { this.orbit.enabled = true; this.isDragging = false; }; dragControls.onHover = (joint: URDFMimicJoint) => this.highlightLinkGeometry(joint, false, highlightMaterial); dragControls.onUnhover = (joint: URDFMimicJoint) => this.highlightLinkGeometry(joint, true, highlightMaterial); this.renderer.domElement.addEventListener('touchstart', (data) => dragControls._mouseDown(data.touches[0]) ); this.renderer.domElement.addEventListener('touchmove', (data) => dragControls._mouseMove(data.touches[0]) ); this.renderer.domElement.addEventListener('touchend', (data) => dragControls._mouseUp(data.touches[0]) ); return this; }; public toggleFog = () => { this.scene.fog = this.scene.fog ? null : this.fog; }; private handleRobotShadow = () => { if (this.isLoaded) return; const intervalId = setInterval(() => { this.model?.traverse((c) => (c.castShadow = true)); }, 10); setTimeout(() => { clearInterval(intervalId); }, 1000); this.isLoaded = true; }; }