viewer_glb / viewer.js
MikaFil's picture
Update viewer.js
b81d6ba verified
// 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;
}
}
});
}
}