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); | |
// ----- GLB asset definition ----- | |
const assets = { | |
orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-glb.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.opacity = 0.15; | |
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 = matTransparent; | |
} | |
} | |
}); | |
traverse(tubeEntity, node => { | |
if (node.render && node.render.meshInstances) { | |
for (let mi of node.render.meshInstances) { | |
mi.material = tubeTransparent; | |
} | |
} | |
}); | |
// ----- 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(1, 1, 1, 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; | |
} | |
} | |
}); | |
} | |
} | |