Spaces:
Running
Running
| // viewer.js | |
| // ----- Helper to load image textures into PlayCanvas ----- | |
| async function loadImageAsTexture(url, app) { | |
| return new Promise((resolve, reject) => { | |
| const img = new window.Image(); | |
| img.crossOrigin = "anonymous"; | |
| img.onload = function() { | |
| const tex = new pc.Texture(app.graphicsDevice, { | |
| width: img.width, | |
| height: img.height, | |
| format: pc.PIXELFORMAT_R8_G8_B8_A8, | |
| }); | |
| tex.setSource(img); | |
| resolve(tex); | |
| }; | |
| img.onerror = reject; | |
| img.src = url; | |
| }); | |
| } | |
| // ----- Global handles ----- | |
| let pc; | |
| export let app = null; | |
| let cameraEntity = null; | |
| let modelEntity = null; | |
| let tubeEntity = null; | |
| let filtreEntity = null; | |
| let viewerInitialized = false; | |
| let resizeObserver = null; | |
| // Materials (for real-time switching) | |
| let matTransparent = null; | |
| let matOpaque = null; | |
| let tubeTransparent = null; | |
| let tubeOpaque = null; | |
| // Configurable globals | |
| 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; | |
| // ----- Utility: Recursive scene traversal ----- | |
| function traverse(entity, callback) { | |
| callback(entity); | |
| if (entity.children) { | |
| entity.children.forEach(child => traverse(child, callback)); | |
| } | |
| } | |
| // ----- Main Viewer Initialization ----- | |
| export async function initializeViewer(config, instanceId) { | |
| if (viewerInitialized) return; // Prevent double-init | |
| // ----- Config setup ----- | |
| glbUrl = config.glb_url; | |
| glbUrl2 = config.glb_url_2; | |
| glbUrl3 = config.glb_url_3; | |
| minZoom = 0.5; maxZoom = 1; | |
| minAngle = -Infinity; maxAngle = Infinity; | |
| minAzimuth = -Infinity; maxAzimuth = Infinity; | |
| minPivotY = 0; minY = -10; | |
| modelX = modelY = modelZ = 0; | |
| modelScale = 1; | |
| modelRotationX = modelRotationY = modelRotationZ = 0; | |
| // Camera setup for desktop/mobile | |
| chosenCameraX = chosenCameraY = 0; | |
| chosenCameraZ = 1; | |
| // ----- DOM: Canvas and progress ----- | |
| const canvasId = 'canvas-' + instanceId; | |
| const progressDialog = document.getElementById('progress-dialog-' + 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'); | |
| if (progressDialog) { | |
| viewerContainer.insertBefore(canvas, progressDialog); | |
| } else { | |
| viewerContainer.appendChild(canvas); | |
| } | |
| // Touch and scroll safety | |
| 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'; | |
| // ----- Load PlayCanvas ----- | |
| if (!pc) { | |
| pc = await import("https://esm.run/playcanvas"); | |
| window.pc = pc; | |
| } | |
| // ----- PlayCanvas App Setup ----- | |
| const device = await pc.createGraphicsDevice(canvas, { | |
| deviceTypes: ["webgl2", "webgl1"], | |
| 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); | |
| // Canvas responsive resize | |
| 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()); | |
| // ----- Load all images as Textures (async, safe for CORS) ----- | |
| // All of these can fail (network, CORS), so wrap with try/catch if needed. | |
| const hdrTex = await loadImageAsTexture('https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/hdr/ciel_nuageux_1k.png', app); | |
| const emitTex = await loadImageAsTexture('https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/textures/emit_map_1k.png', app); | |
| const opTex = await loadImageAsTexture('https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/textures/op_map_1k.png', app); | |
| const thicknessTex= await loadImageAsTexture('https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/textures/thickness_map_1k.png', app); | |
| const bgTex = await loadImageAsTexture('https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/images/banniere_earcare.png', app); | |
| const opTubetex = await loadImageAsTexture('https://huggingface.co/datasets/MikaFil/3D_models/resolve/main/EARCARE/textures/op_mask_tube.png', app); | |
| // ----- GLB asset definition ----- | |
| const assets = { | |
| orbit: new pc.Asset('script', 'script', { url: "https://mikafil-visualiseur-EARCARE.static.hf.space/orbit-camera.js" }), | |
| model: new pc.Asset('model_glb', 'container', { url: glbUrl }), | |
| tube: new pc.Asset('tube_glb', 'container', { url: glbUrl2 }), | |
| filtre: new pc.Asset('filtre_glb', 'container', { url: glbUrl3 }), | |
| }; | |
| for (const key in assets) app.assets.add(assets[key]); | |
| // ----- Environment / Skybox ----- | |
| app.scene.envAtlas = hdrTex; | |
| app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, -90, 0); | |
| app.scene.skyboxIntensity = 4; | |
| app.scene.skyboxMip = 0; | |
| // ----- Load GLBs ----- | |
| const loader = new pc.AssetListLoader(Object.values(assets), 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); | |
| // Instantiate GLB entities | |
| modelEntity = assets.model.resource.instantiateRenderEntity(); | |
| tubeEntity = assets.tube.resource.instantiateRenderEntity(); | |
| filtreEntity = assets.filtre.resource.instantiateRenderEntity(); | |
| app.root.addChild(modelEntity); | |
| app.root.addChild(tubeEntity); | |
| app.root.addChild(filtreEntity); | |
| // ----- Materials Setup ----- | |
| // Transparent material (main model) | |
| matTransparent = new pc.StandardMaterial(); | |
| matTransparent.blendType = pc.BLEND_NORMAL; | |
| matTransparent.diffuse = new pc.Color(1, 1, 1); | |
| matTransparent.specular = new pc.Color(0.01, 0.01, 0.01); | |
| matTransparent.gloss = 1; | |
| matTransparent.metalness = 0; | |
| matTransparent.useMetalness = true; | |
| matTransparent.useDynamicRefraction = true; | |
| matTransparent.depthWrite = true; | |
| matTransparent.refraction = 0.8; | |
| matTransparent.refractionIndex = 1; | |
| matTransparent.thickness = 0.02; | |
| matTransparent.thicknessMap = thicknessTex; | |
| matTransparent.opacityMap = opTex; | |
| matTransparent.opacityMapChannel = "r"; | |
| matTransparent.opacity = 0.97; | |
| matTransparent.emissive = new pc.Color(1, 1, 1); | |
| matTransparent.emissiveMap = emitTex; | |
| matTransparent.emissiveIntensity = 0.1; | |
| matTransparent.update(); | |
| // Opaque material (main model) | |
| 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.specularityFactor = 1; | |
| matOpaque.gloss = 1; | |
| matOpaque.metalness = 0; | |
| matOpaque.opacityMap = opTex; | |
| matOpaque.opacityMapChannel = "r"; | |
| matOpaque.opacity = 1; | |
| matOpaque.emissive = new pc.Color(0.372, 0.03, 0.003); | |
| matOpaque.emissiveMap = emitTex; | |
| matOpaque.emissiveIntensity = 2; | |
| matOpaque.update(); | |
| // Transparent material (tube) | |
| tubeTransparent = new pc.StandardMaterial(); | |
| tubeTransparent.diffuse = new pc.Color(1, 1, 1); | |
| tubeTransparent.blendType = pc.BLEND_NORMAL; | |
| tubeTransparent.opacityMap = opTubetex; | |
| tubeTransparent.opacityMapChannel = "r"; | |
| tubeTransparent.opacity = 0.03; | |
| tubeTransparent.depthTest = false; | |
| tubeTransparent.depthWrite = false; | |
| tubeTransparent.useMetalness = true; | |
| tubeTransparent.useDynamicRefraction = true; | |
| tubeTransparent.thickness = 4; | |
| tubeTransparent.update(); | |
| // Opaque material (tube) | |
| tubeOpaque = new pc.StandardMaterial(); | |
| tubeOpaque.diffuse = new pc.Color(1, 1, 1); | |
| tubeOpaque.opacity = 0.9; | |
| tubeOpaque.update(); | |
| // ----- Assign materials to meshes ----- | |
| 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; | |
| } | |
| } | |
| }); | |
| // ----- Modification du material "transparent" du filtre | |
| /*traverse(filtreEntity, node => { | |
| if (node.render && node.render.meshInstances) { | |
| for (let mi of node.render.meshInstances) { | |
| if (mi.material && mi.material.name === "transparant") { | |
| // Ici tu as ton matériau | |
| const mat = mi.material; | |
| mat.opacity = 0.01; | |
| mat.blendType = pc.BLEND_NONE; | |
| mat.depthTest = false; | |
| mat.depthWrite = false; | |
| mat.useMetalness = true; | |
| mat.useDynamicRefraction = true; | |
| mat.thickness = 1; | |
| //mat.specular = new pc.Color(0.01, 0.01, 0.01); | |
| mat.gloss = 1; | |
| mat.glossInvert = true; | |
| mat.metalness = 0; | |
| mat.update(); | |
| } | |
| } | |
| } | |
| });*/ | |
| // ----- Model, Tube, Filtre transforms ----- | |
| 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); | |
| filtreEntity.setPosition(modelX, modelY, modelZ); | |
| filtreEntity.setLocalScale(modelScale, modelScale, modelScale); | |
| filtreEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ); | |
| // ----- Camera + Orbit Controls ----- | |
| cameraEntity = new pc.Entity('camera'); | |
| cameraEntity.addComponent('camera', { | |
| clearColor: new pc.Color(0.8, 0.8, 0.8, 1), | |
| toneMapping: pc.TONEMAP_NEUTRAL | |
| }); | |
| cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
| cameraEntity.lookAt(modelEntity.getPosition()); | |
| cameraEntity.addComponent('script'); | |
| // KHR_materials_transmission support | |
| 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 Skybox layer from camera | |
| const skyboxLayer = app.scene.layers.getLayerByName("Skybox"); | |
| if (skyboxLayer) { | |
| const camLayers = cameraEntity.camera.layers.slice(); | |
| const idx = camLayers.indexOf(skyboxLayer.id); | |
| if (idx !== -1) { | |
| camLayers.splice(idx, 1); | |
| cameraEntity.camera.layers = camLayers; | |
| } | |
| } | |
| /* | |
| // ----- Camera-attached background plane ----- | |
| const bgPlane = new pc.Entity("Plane"); | |
| bgPlane.addComponent("model", { type: "plane" }); | |
| bgPlane.setLocalPosition(0, 0, -10); | |
| bgPlane.setLocalScale(11, 1, 5.5); | |
| bgPlane.setLocalEulerAngles(90, 0, 0); | |
| // Simple material for the banner | |
| 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; | |
| cameraEntity.addChild(bgPlane); | |
| */ | |
| // ----- Lighting ----- | |
| const light = new pc.Entity("mainLight"); | |
| light.addComponent('light', { | |
| type: "directional", | |
| color: new pc.Color(1, 1, 1), | |
| intensity: 1, | |
| }); | |
| light.setPosition(1, 1, -1); | |
| light.lookAt(0, 0, 0); | |
| app.root.addChild(light); | |
| app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); | |
| app.once('update', () => resetViewerCamera()); | |
| // ----- Optional: Tooltips ----- | |
| 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 => { /* fail silently */ }); | |
| } | |
| } catch (e) { /* fail silently */ } | |
| viewerInitialized = true; | |
| }); | |
| } | |
| // ----- Camera Utility: Resets camera to default view ----- | |
| 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, ei, op, boolTrans) { | |
| if (boolTrans) { | |
| if (!matTransparent) return; | |
| matTransparent.diffuse.set(dr, dg, db); | |
| matTransparent.emissive.set(er, eg, eb); | |
| matTransparent.emissiveIntensity = ei; | |
| 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.emissiveIntensity = ei; | |
| 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; | |
| } | |
| } | |
| }); | |
| } | |
| } | |