Spaces:
Running
Running
| // viewer.js | |
| // ============================== | |
| let pc; | |
| export let app = null; | |
| let cameraEntity = null; | |
| let modelEntity = null; | |
| let tubeEntity = null; | |
| let bouchonEntity = null; | |
| let viewerInitialized = false; | |
| let resizeObserver = null; | |
| // These will hold references to the material(s) you want to update. | |
| let matTransparent = null; | |
| let matOpaque = null; | |
| let tubeTransparent = null; | |
| let tubeOpaque = null; | |
| let chosenCameraX, chosenCameraY, chosenCameraZ; | |
| let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minPivotY, minY; | |
| let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ; | |
| let glbUrl, glbUrl2, glbUrl3, aoUrl, opacityUrl, thicknessUrl; | |
| // RECURSIVE ENTITY TRAVERSAL (needed for material assignment) | |
| function traverse(entity, callback) { | |
| callback(entity); | |
| if (entity.children) { | |
| entity.children.forEach(child => traverse(child, callback)); | |
| } | |
| } | |
| export async function initializeViewer(config, instanceId) { | |
| if (viewerInitialized) return; | |
| const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); | |
| const isMobile = isIOS || /Android/i.test(navigator.userAgent); | |
| glbUrl = config.glb_url; | |
| glbUrl2 = config.glb_url_2; | |
| glbUrl3 = config.glb_url_3; | |
| aoUrl = config.ao_url; | |
| thicknessUrl = config.thickness_url; | |
| opacityUrl = config.opacity_url; | |
| minZoom = 0.2; | |
| maxZoom = 2; | |
| minAngle = -Infinity; | |
| maxAngle = Infinity; | |
| minAzimuth = -Infinity; | |
| maxAzimuth = Infinity; | |
| minPivotY = 0; | |
| minY = -10; | |
| modelX = 0; | |
| modelY = 0; | |
| modelZ = 0; | |
| modelScale = 1; | |
| modelRotationX = 0; | |
| modelRotationY = 0; | |
| modelRotationZ = 0; | |
| const cameraX = 0; | |
| const cameraY = 0; | |
| const cameraZ = 1; | |
| const cameraXPhone = 0; | |
| const cameraYPhone = 0; | |
| const cameraZPhone = 1; | |
| chosenCameraX = isMobile ? cameraXPhone : cameraX; | |
| chosenCameraY = isMobile ? cameraYPhone : cameraY; | |
| chosenCameraZ = isMobile ? cameraZPhone : cameraZ; | |
| const canvasId = 'canvas-' + instanceId; | |
| const progressDialog = document.getElementById('progress-dialog-' + instanceId); | |
| const progressIndicator = document.getElementById('progress-indicator-' + instanceId); | |
| const viewerContainer = document.getElementById('viewer-container-' + instanceId); | |
| let oldCanvas = document.getElementById(canvasId); | |
| if (oldCanvas) oldCanvas.remove(); | |
| const canvas = document.createElement('canvas'); | |
| canvas.id = canvasId; | |
| canvas.className = 'ply-canvas'; | |
| canvas.style.width = "100%"; | |
| canvas.style.height = "100%"; | |
| canvas.setAttribute('tabindex', '0'); | |
| // Safer insert: | |
| if (progressDialog) { | |
| viewerContainer.insertBefore(canvas, progressDialog); | |
| } else { | |
| viewerContainer.appendChild(canvas); | |
| } | |
| canvas.style.touchAction = "none"; | |
| canvas.style.webkitTouchCallout = "none"; | |
| canvas.addEventListener('gesturestart', e => e.preventDefault()); | |
| canvas.addEventListener('gesturechange', e => e.preventDefault()); | |
| canvas.addEventListener('gestureend', e => e.preventDefault()); | |
| canvas.addEventListener('dblclick', e => e.preventDefault()); | |
| canvas.addEventListener('touchstart', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false }); | |
| canvas.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| }, { passive: false }); | |
| if (progressDialog) progressDialog.style.display = 'block'; | |
| if (!pc) { | |
| pc = await import("https://esm.run/playcanvas"); | |
| window.pc = pc; | |
| } | |
| // Create app first | |
| const device = await pc.createGraphicsDevice(canvas, { | |
| deviceTypes: ["webgl2"], | |
| glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js", | |
| twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js", | |
| antialias: false | |
| }); | |
| device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); | |
| const opts = new pc.AppOptions(); | |
| opts.graphicsDevice = device; | |
| opts.mouse = new pc.Mouse(canvas); | |
| opts.touch = new pc.TouchDevice(canvas); | |
| opts.componentSystems = [ | |
| pc.RenderComponentSystem, | |
| pc.CameraComponentSystem, | |
| pc.LightComponentSystem, | |
| pc.ScriptComponentSystem, | |
| pc.GSplatComponentSystem, | |
| pc.CollisionComponentSystem, | |
| pc.RigidbodyComponentSystem | |
| ]; | |
| opts.resourceHandlers = [ | |
| pc.TextureHandler, | |
| pc.ContainerHandler, | |
| pc.ScriptHandler, | |
| pc.GSplatHandler | |
| ]; | |
| app = new pc.Application(canvas, opts); | |
| app.setCanvasFillMode(pc.FILLMODE_NONE); | |
| app.setCanvasResolution(pc.RESOLUTION_AUTO); | |
| resizeObserver = new ResizeObserver(entries => { | |
| entries.forEach(entry => { | |
| app.resizeCanvas(entry.contentRect.width, entry.contentRect.height); | |
| }); | |
| }); | |
| resizeObserver.observe(viewerContainer); | |
| window.addEventListener('resize', () => app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight)); | |
| app.on('destroy', () => resizeObserver.disconnect()); | |
| // --- Assets --- | |
| const assets = { | |
| orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }), | |
| model: new pc.Asset('glb', 'container', { url: glbUrl }), | |
| tube: new pc.Asset('glb', 'container', { url: glbUrl2 }), | |
| bouchon: new pc.Asset('glb', 'container', { url: glbUrl3 }), | |
| ao_map: new pc.Asset('color', 'texture', { url: aoUrl }), | |
| op_map: new pc.Asset('color', 'texture', { url: opacityUrl }), | |
| bg_tex: new pc.Asset('color', 'texture', {url: "https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/banniere_earcare.png"}), | |
| thickness_map: new pc.Asset('color', 'texture', { url: thicknessUrl }), | |
| helipad: new pc.Asset( | |
| 'helipad-env-atlas', | |
| 'texture', | |
| { url: "https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/hdr/ciel_nuageux_1k.png" }, | |
| { type: pc.TEXTURETYPE_RGBP, mipmaps: false } | |
| ) | |
| }; | |
| for (const key in assets) app.assets.add(assets[key]); | |
| // Load HDR *before* loading any models | |
| assets.helipad.ready(() => { | |
| app.scene.envAtlas = assets.helipad.resource; | |
| app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(30, 20, 0); | |
| app.scene.skyboxIntensity = 4; | |
| app.scene.skyboxMip = 0; | |
| // Now load all GLBs and other assets | |
| const loader = new pc.AssetListLoader([ | |
| assets.orbit, | |
| assets.model, | |
| assets.tube, | |
| assets.bouchon, | |
| assets.ao_map, | |
| assets.op_map, | |
| assets.bg_tex, | |
| assets.thickness_map | |
| ], app.assets); | |
| loader.load(() => { | |
| app.start(); | |
| if (progressDialog) progressDialog.style.display = 'none'; | |
| // Reorder depth layer for transmission | |
| const depthLayer = app.scene.layers.getLayerById(pc.LAYERID_DEPTH); | |
| app.scene.layers.remove(depthLayer); | |
| app.scene.layers.insertOpaque(depthLayer, 2); | |
| // 1. Models | |
| modelEntity = assets.model.resource.instantiateRenderEntity(); | |
| tubeEntity = assets.tube.resource.instantiateRenderEntity(); | |
| bouchonEntity = assets.bouchon.resource.instantiateRenderEntity(); | |
| app.root.addChild(modelEntity); | |
| app.root.addChild(tubeEntity); | |
| //app.root.addChild(bouchonEntity); | |
| // 2. Materials for main model & tube | |
| //the 2 materials for the main model : | |
| const ao = assets.ao_map.resource; | |
| const op = assets.op_map.resource; | |
| const thickness = assets.thickness_map.resource; | |
| //mat transparent | |
| matTransparent = new pc.StandardMaterial(); | |
| matTransparent.blendType = pc.BLEND_NORMAL; | |
| matTransparent.diffuse = new pc.Color(0.7, 0.05, 0.05); | |
| matTransparent.specular = new pc.Color(0.01, 0.01, 0.01); | |
| matTransparent.shininess = 90; | |
| matTransparent.gloss = 1; | |
| matTransparent.metalness = 0.0; | |
| matTransparent.useMetalness = true; | |
| matTransparent.useDynamicRefraction = true; | |
| matTransparent.depthWrite = true; | |
| matTransparent.refraction= 0.8; | |
| matTransparent.refractionIndex = 1; | |
| matTransparent.thickness = 0.02; | |
| matTransparent.thicknessMap = thickness; | |
| matTransparent.opacityMap = op; | |
| matTransparent.opacityMapChannel = "r"; | |
| matTransparent.opacity = 0.97; | |
| matTransparent.emissive = new pc.Color(0.372, 0.03, 0.003); | |
| matTransparent.emissiveMap = ao; | |
| matTransparent.emissiveIntensity = 1; | |
| matTransparent.update(); | |
| //mat opaque | |
| matOpaque = new pc.StandardMaterial(); | |
| matOpaque.blendType = pc.BLEND_NORMAL; | |
| matOpaque.diffuse = new pc.Color(0.7, 0.05, 0.05); | |
| matOpaque.specular = new pc.Color(0.01, 0.01, 0.01); | |
| matOpaque.shininess = 90; | |
| matOpaque.gloss = 1; | |
| matOpaque.metalness = 0.0; | |
| matOpaque.opacityMap = op; | |
| matOpaque.opacityMapChannel = "r"; | |
| matOpaque.opacity = 1; | |
| matOpaque.emissive = new pc.Color(0.372, 0.03, 0.003); | |
| matOpaque.emissiveMap = ao; | |
| matOpaque.emissiveIntensity = 1; | |
| matOpaque.update(); | |
| //material for the tube | |
| //transparent | |
| tubeTransparent = new pc.StandardMaterial(); | |
| tubeTransparent.diffuse = new pc.Color(1, 1, 1); | |
| tubeTransparent.blendType = pc.BLEND_NORMAL; | |
| tubeTransparent.opacity = 0.1; | |
| tubeTransparent.depthTest = false; | |
| tubeTransparent.depthWrite = false; | |
| tubeTransparent.useMetalness = true; | |
| tubeTransparent.useDynamicRefraction = true; | |
| tubeTransparent.thickness = 4; | |
| tubeTransparent.update(); | |
| //opaque | |
| tubeOpaque = new pc.StandardMaterial(); | |
| tubeOpaque.diffuse = new pc.Color(1, 1, 1); | |
| tubeOpaque.opacity = 0.9; | |
| tubeOpaque.update(); | |
| traverse(modelEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| mi.material = matOpaque; | |
| } | |
| } | |
| }); | |
| traverse(tubeEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| mi.material = tubeOpaque; | |
| } | |
| } | |
| }); | |
| // Do NOT override materials for bouchonEntity! | |
| // 3. Position/scale/orientation | |
| modelEntity.setPosition(modelX, modelY, modelZ); | |
| modelEntity.setLocalScale(modelScale, modelScale, modelScale); | |
| modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ); | |
| tubeEntity.setPosition(modelX, modelY, modelZ); | |
| tubeEntity.setLocalScale(modelScale, modelScale, modelScale); | |
| tubeEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ); | |
| bouchonEntity.setPosition(modelX, modelY, modelZ); | |
| bouchonEntity.setLocalScale(modelScale, modelScale, modelScale); | |
| bouchonEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ); | |
| // 4. Camera & orbit | |
| cameraEntity = new pc.Entity('camera'); | |
| cameraEntity.addComponent('camera', { | |
| clearColor: new pc.Color(1, 1, 1, 1), | |
| toneMapping: pc.TONEMAP_NEUTRAL | |
| }); | |
| cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
| cameraEntity.lookAt(modelEntity.getPosition()); | |
| cameraEntity.addComponent('script'); | |
| // --- **CRITICAL** for KHR_materials_transmission! --- | |
| cameraEntity.camera.requestSceneColorMap(true); | |
| cameraEntity.script.create('orbitCamera', { | |
| attributes: { | |
| focusEntity: modelEntity, | |
| inertiaFactor: 0.2, | |
| distanceMax: maxZoom, | |
| distanceMin: minZoom, | |
| pitchAngleMax: maxAngle, | |
| pitchAngleMin: minAngle, | |
| yawAngleMax: maxAzimuth, | |
| yawAngleMin: minAzimuth, | |
| minY: minY, | |
| frameOnStart: false | |
| } | |
| }); | |
| cameraEntity.script.create('orbitCameraInputMouse'); | |
| cameraEntity.script.create('orbitCameraInputTouch'); | |
| app.root.addChild(cameraEntity); | |
| // Remove the Skybox layer from the camera | |
| const skyboxLayer = app.scene.layers.getLayerByName("Skybox"); | |
| const camLayers = cameraEntity.camera.layers.slice(); | |
| const idx = camLayers.indexOf(skyboxLayer.id); | |
| if (idx !== -1) { | |
| camLayers.splice(idx, 1); | |
| cameraEntity.camera.layers = camLayers; | |
| } | |
| // --- Add this block for a cube parented to the camera --- | |
| // Create the cube | |
| const bgTex = assets.bg_tex.resource; | |
| const bgPlane = new pc.Entity("Plane"); | |
| bgPlane.addComponent("model", { type: "plane" }); | |
| // Set the cube's local position relative to the camera | |
| bgPlane.setLocalPosition(0, 0, -10); // 2 units in front of camera | |
| // Optionally, scale or color the cube | |
| bgPlane.setLocalScale(11, 1, 5.5); | |
| bgPlane.setLocalEulerAngles(90, 0, 0); | |
| // Simple material for visibility | |
| const mat = new pc.StandardMaterial(); | |
| mat.diffuse = new pc.Color(1, 1, 1); | |
| mat.diffuseMap = bgTex; | |
| mat.emissive = new pc.Color(1, 1, 1); | |
| mat.emissiveMap = bgTex; | |
| mat.emissiveIntensity = 1; | |
| mat.useLighting = false; | |
| mat.update(); | |
| bgPlane.model.material = mat; | |
| // Parent to the camera | |
| cameraEntity.addChild(bgPlane); | |
| // 5. Light | |
| const light = new pc.Entity("mainLight"); | |
| light.addComponent('light',{ | |
| type: "directional", | |
| color: new pc.Color(1, 1, 1), | |
| intensity: 0.9, | |
| }); | |
| light.setPosition(0, 1, -1); | |
| light.lookAt(0, 0, 0); | |
| app.root.addChild(light); | |
| app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); | |
| app.once('update', () => resetViewerCamera()); | |
| // Tooltips supported if tooltips_url set | |
| try { | |
| if (config.tooltips_url) { | |
| import('./tooltips.js').then(tooltipsModule => { | |
| tooltipsModule.initializeTooltips({ | |
| app, | |
| cameraEntity, | |
| modelEntity, | |
| tooltipsUrl: config.tooltips_url, | |
| defaultVisible: !!config.showTooltipsDefault, | |
| moveDuration: config.tooltipMoveDuration || 0.6 | |
| }); | |
| }).catch(e => { | |
| // Tooltips optional: fail silently if missing | |
| }); | |
| } | |
| } catch (e) { | |
| // Tooltips optional, fail silently | |
| } | |
| viewerInitialized = true; | |
| }); | |
| }); | |
| app.assets.load(assets.helipad); // Start by loading the HDR | |
| } | |
| export function resetViewerCamera() { | |
| try { | |
| if (!cameraEntity || !modelEntity || !app) return; | |
| const orbitCam = cameraEntity.script.orbitCamera; | |
| if (!orbitCam) return; | |
| const modelPos = modelEntity.getPosition(); | |
| const tempEnt = new pc.Entity(); | |
| tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
| tempEnt.lookAt(modelPos); | |
| const dist = new pc.Vec3().sub2( | |
| new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ), | |
| modelPos | |
| ).length(); | |
| cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
| cameraEntity.lookAt(modelPos); | |
| orbitCam.pivotPoint = modelPos.clone(); | |
| orbitCam._targetDistance = dist; | |
| orbitCam._distance = dist; | |
| const rot = tempEnt.getRotation(); | |
| const fwd = new pc.Vec3(); | |
| rot.transformVector(pc.Vec3.FORWARD, fwd); | |
| const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG; | |
| const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0); | |
| const rotNoYaw = new pc.Quat().mul2(yawQuat, rot); | |
| const fNoYaw = new pc.Vec3(); | |
| rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw); | |
| const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG; | |
| orbitCam._targetYaw = yaw; | |
| orbitCam._yaw = yaw; | |
| orbitCam._targetPitch = pitch; | |
| orbitCam._pitch = pitch; | |
| if (orbitCam._updatePosition) orbitCam._updatePosition(); | |
| tempEnt.destroy(); | |
| } catch (e) { | |
| // Silent fail | |
| } | |
| } | |
| /** | |
| * Change the main model's diffuse color in real time. | |
| * r,g,b are floats [0,1]. This is a reusable function for new colors. | |
| */ | |
| export function changeColor(dr, dg, db, er, eg, eb, op, boolTrans) { | |
| //cameraEntity.camera.clearColor = new pc.Color(colorBgR, colorBgG, colorBgB); | |
| if(boolTrans){ | |
| if (!matTransparent) return; | |
| matTransparent.diffuse.set(dr, dg, db); | |
| matTransparent.emissive.set(er, eg, eb); | |
| matTransparent.opacity = op; | |
| matTransparent.update(); | |
| tubeTransparent.diffuse.set(dr, dg, db); | |
| tubeTransparent.update(); | |
| traverse(modelEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| mi.material = matTransparent; | |
| } | |
| } | |
| }); | |
| traverse(tubeEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| mi.material = tubeTransparent; | |
| } | |
| } | |
| }); | |
| }else{ | |
| if (!matOpaque) return; | |
| matOpaque.diffuse.set(dr, dg, db); | |
| matOpaque.emissive.set(er, eg, eb); | |
| matOpaque.update(); | |
| traverse(modelEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| mi.material = matOpaque; | |
| } | |
| } | |
| }); | |
| traverse(tubeEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| mi.material = tubeOpaque; | |
| } | |
| } | |
| }); | |
| } | |
| } | |