MikaFil commited on
Commit
4dbd43a
·
verified ·
1 Parent(s): 6f65f93

Update viewer.js

Browse files
Files changed (1) hide show
  1. viewer.js +140 -249
viewer.js CHANGED
@@ -1,8 +1,7 @@
1
  // viewer.js
2
  // ==============================
3
- // Unified PlayCanvas viewer with robust PLY (GSplat), GLB, HDR, tooltips, and cross-platform camera/controls.
4
 
5
- let pc;
6
  export let app = null;
7
  let cameraEntity = null;
8
  let modelEntity = null;
@@ -13,38 +12,36 @@ let chosenCameraX, chosenCameraY, chosenCameraZ;
13
  let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minPivotY, minY;
14
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
15
  let plyUrl, glbUrl;
 
16
 
17
- // ----------- Main Viewer Init ----------- //
18
  export async function initializeViewer(config, instanceId) {
19
  if (viewerInitialized) return;
20
 
21
- // -- Platform detection --
22
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
23
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
24
 
25
- // -- Parse config --
26
  plyUrl = config.ply_url;
27
  glbUrl = config.glb_url;
28
- minZoom = parseFloat(config.minZoom || "1");
29
- maxZoom = parseFloat(config.maxZoom || "20");
30
- minAngle = parseFloat(config.minAngle || "-45");
31
- maxAngle = parseFloat(config.maxAngle || "90");
32
- minAzimuth = (config.minAzimuth !== undefined) ? parseFloat(config.minAzimuth) : -360;
33
- maxAzimuth = (config.maxAzimuth !== undefined) ? parseFloat(config.maxAzimuth) : 360;
34
- minPivotY = parseFloat(config.minPivotY || "0");
35
- minY = (config.minY !== undefined) ? parseFloat(config.minY) : 0;
36
-
37
- modelX = (config.modelX !== undefined) ? parseFloat(config.modelX) : 0;
38
- modelY = (config.modelY !== undefined) ? parseFloat(config.modelY) : 0;
39
- modelZ = (config.modelZ !== undefined) ? parseFloat(config.modelZ) : 0;
40
- modelScale = (config.modelScale !== undefined) ? parseFloat(config.modelScale) : 1;
41
  modelRotationX = (config.modelRotationX !== undefined) ? parseFloat(config.modelRotationX) : 0;
42
  modelRotationY = (config.modelRotationY !== undefined) ? parseFloat(config.modelRotationY) : 0;
43
  modelRotationZ = (config.modelRotationZ !== undefined) ? parseFloat(config.modelRotationZ) : 0;
44
 
45
- const cameraX = (config.cameraX !== undefined) ? parseFloat(config.cameraX) : 0;
46
- const cameraY = (config.cameraY !== undefined) ? parseFloat(config.cameraY) : 2;
47
- const cameraZ = (config.cameraZ !== undefined) ? parseFloat(config.cameraZ) : 5;
48
  const cameraXPhone = (config.cameraXPhone !== undefined) ? parseFloat(config.cameraXPhone) : cameraX;
49
  const cameraYPhone = (config.cameraYPhone !== undefined) ? parseFloat(config.cameraYPhone) : cameraY;
50
  const cameraZPhone = (config.cameraZPhone !== undefined) ? parseFloat(config.cameraZPhone) : (cameraZ * 1.5);
@@ -53,27 +50,22 @@ export async function initializeViewer(config, instanceId) {
53
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
54
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
55
 
56
- // -- DOM elements --
57
- const canvasId = 'canvas-' + instanceId;
58
- const progressDialog = document.getElementById('progress-dialog-' + instanceId);
59
  const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
60
  const viewerContainer = document.getElementById('viewer-container-' + instanceId);
61
 
62
- // -- Remove any previous canvas --
63
  let oldCanvas = document.getElementById(canvasId);
64
  if (oldCanvas) oldCanvas.remove();
65
 
66
- // -- Create canvas --
67
  const canvas = document.createElement('canvas');
68
  canvas.id = canvasId;
69
  canvas.className = 'ply-canvas';
70
  canvas.style.width = "100%";
71
  canvas.style.height = "100%";
72
- canvas.style.zIndex = "1";
73
  canvas.setAttribute('tabindex', '0');
74
  viewerContainer.insertBefore(canvas, progressDialog);
75
 
76
- // -- Prevent touch issues on iOS/Safari --
77
  canvas.style.touchAction = "none";
78
  canvas.style.webkitTouchCallout = "none";
79
  canvas.addEventListener('gesturestart', e => e.preventDefault());
@@ -82,239 +74,139 @@ export async function initializeViewer(config, instanceId) {
82
  canvas.addEventListener('dblclick', e => e.preventDefault());
83
  canvas.addEventListener('touchstart', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false });
84
 
85
- // -- Custom wheel for orbit on desktop (works with PlayCanvas + pointer capture) --
86
- canvas.addEventListener('wheel', e => {
87
- if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
88
- const orbitCam = cameraEntity.script.orbitCamera;
89
- const sens = (cameraEntity.script.orbitCameraInputMouse?.distanceSensitivity) || 0.4;
90
- if (cameraEntity.camera.projection === pc.PROJECTION_PERSPECTIVE) {
91
- orbitCam.distance -= e.deltaY * 0.01 * sens * (orbitCam.distance * 0.1);
92
- } else {
93
- orbitCam.orthoHeight -= e.deltaY * 0.01 * sens * (orbitCam.orthoHeight * 0.1);
94
- }
95
- e.preventDefault();
96
- e.stopPropagation();
97
- }
98
- }, { passive: false });
99
-
100
- // -- Show progress dialog --
101
  progressDialog.style.display = 'block';
102
 
103
- // -- Import PlayCanvas if not loaded --
104
  if (!pc) {
105
  pc = await import("https://esm.run/playcanvas");
106
  window.pc = pc;
107
  }
108
 
109
- try {
110
- // -- Create graphics device and app --
111
- const device = await pc.createGraphicsDevice(canvas, {
112
- deviceTypes: ["webgl2"],
113
- glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
114
- twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
115
- antialias: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  });
117
- device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
118
-
119
- const opts = new pc.AppOptions();
120
- opts.graphicsDevice = device;
121
- opts.mouse = new pc.Mouse(canvas);
122
- opts.touch = new pc.TouchDevice(canvas);
123
- opts.componentSystems = [
124
- pc.RenderComponentSystem,
125
- pc.CameraComponentSystem,
126
- pc.LightComponentSystem,
127
- pc.ScriptComponentSystem,
128
- pc.GSplatComponentSystem,
129
- pc.CollisionComponentSystem,
130
- pc.RigidbodyComponentSystem
131
- ];
132
- opts.resourceHandlers = [
133
- pc.TextureHandler,
134
- pc.ContainerHandler,
135
- pc.ScriptHandler,
136
- pc.GSplatHandler
137
- ];
138
-
139
- app = new pc.Application(canvas, opts);
140
- app.setCanvasFillMode(pc.FILLMODE_NONE);
141
- app.setCanvasResolution(pc.RESOLUTION_AUTO);
142
-
143
- // -- Resize observer --
144
- resizeObserver = new ResizeObserver(entries => {
145
- for (const entry of entries) {
146
- const { width, height } = entry.contentRect;
147
- if (app) app.resizeCanvas(width, height);
148
- }
149
- });
150
- resizeObserver.observe(viewerContainer);
151
-
152
- window.addEventListener('resize', () => {
153
- if (app) app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
154
- });
155
-
156
- app.on('destroy', () => {
157
- if (resizeObserver) resizeObserver.disconnect();
158
- });
159
-
160
- // -- Asset setup --
161
- const assets = {
162
- model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
163
- orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
164
- galerie: glbUrl ? new pc.Asset('galerie', 'container', { url: glbUrl }) : null,
165
- hdr: new pc.Asset('hdr', 'texture', {
166
- url: "https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png"
167
- }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false })
168
- };
169
-
170
- // Remove nulls for AssetListLoader
171
- const loadAssets = Object.values(assets).filter(a => !!a);
172
-
173
- const loader = new pc.AssetListLoader(loadAssets, app.assets);
174
- let lastProg = 0;
175
- assets.model.on('load', () => progressDialog.style.display = 'none');
176
- assets.model.on('error', err => {
177
- progressDialog.innerHTML = `<p style="color: red">Error loading model: ${err}</p>`;
178
- });
179
-
180
- // Progress bar animation (optional, can customize)
181
- const progCheck = setInterval(() => {
182
- if (assets.model.resource) {
183
- progressIndicator.value = 100;
184
- clearInterval(progCheck);
185
- progressDialog.style.display = 'none';
186
- } else if (assets.model.loading) {
187
- lastProg = Math.min(lastProg + 2, 90);
188
- progressIndicator.value = lastProg;
189
- }
190
- }, 100);
191
-
192
- // -- Asset loading --
193
- loader.load(async () => {
194
- app.start();
195
-
196
- // -- Set HDR env if available --
197
- if (assets.hdr && assets.hdr.resource) {
198
- app.scene.envAtlas = assets.hdr.resource;
199
- }
200
 
201
- // -- PLY Model Entity (GSplat) --
202
- modelEntity = new pc.Entity('model');
203
- modelEntity.addComponent('gsplat', { asset: assets.model });
204
- modelEntity.setLocalPosition(modelX, modelY, modelZ);
205
- modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
206
- modelEntity.setLocalScale(modelScale, modelScale, modelScale);
207
- app.root.addChild(modelEntity);
208
-
209
- // -- GLB Gallery --
210
- if (assets.galerie && assets.galerie.resource && assets.galerie.resource.instantiateRenderEntity) {
211
- const galleryEntity = assets.galerie.resource.instantiateRenderEntity();
212
- app.root.addChild(galleryEntity);
213
- }
214
 
215
- // -- Directional Light --
216
- const dirLight = new pc.Entity('DirLight');
217
- dirLight.addComponent('light', {
218
- type: 'directional',
219
- color: pc.Color.WHITE,
220
- shadowBias: 0.3,
221
- normalOffsetBias: 0.2,
222
- intensity: 1.0,
223
- soft: true,
224
- shadowResolution: 2048,
225
- penumbraSize: 7,
226
- penumbraFalloff: 1.5,
227
- shadowSamples: 64,
228
- shadowBlockerSamples: 8,
229
- castShadows: true,
230
- shadowType: pc.SHADOW_PCSS_32F,
231
- shadowDistance: 1000
232
- });
233
- dirLight.setLocalEulerAngles(45, 30, 0);
234
- app.root.addChild(dirLight);
235
-
236
- // -- Camera setup --
237
- cameraEntity = new pc.Entity('camera');
238
- cameraEntity.addComponent('camera', {
239
- clearColor: config.canvas_background
240
- ? parseInt(config.canvas_background.substr(1, 2), 16) / 255
241
- : 0
242
- });
243
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
244
- cameraEntity.lookAt(modelEntity.getPosition());
245
- cameraEntity.addComponent('script');
246
- cameraEntity.script.create('orbitCamera', {
247
- attributes: {
248
- focusEntity: modelEntity,
249
- inertiaFactor: 0.2,
250
- distanceMax: maxZoom,
251
- distanceMin: minZoom,
252
- pitchAngleMax: maxAngle,
253
- pitchAngleMin: minAngle,
254
- yawAngleMax: maxAzimuth,
255
- yawAngleMin: minAzimuth,
256
- minPivotY: minPivotY,
257
- frameOnStart: false
258
- }
259
- });
260
- cameraEntity.script.create('orbitCameraInputMouse', {
261
- attributes: {
262
- orbitSensitivity: isMobile ? 0.6 : 0.3,
263
- distanceSensitivity: isMobile ? 0.5 : 0.4
264
- }
265
- });
266
- cameraEntity.script.create('orbitCameraInputTouch', {
267
- attributes: {
268
- orbitSensitivity: 0.6,
269
- distanceSensitivity: 0.5
270
- }
271
- });
272
- app.root.addChild(cameraEntity);
273
-
274
- // -- Fix for camera reset/orbit camera initial state --
275
- app.once('update', () => resetViewerCamera());
276
-
277
- // -- Enforce minY (avoid camera below floor) --
278
- app.on('update', dt => {
279
- if (cameraEntity) {
280
- const pos = cameraEntity.getPosition();
281
- if (pos.y < minY) {
282
- cameraEntity.setPosition(pos.x, minY, pos.z);
283
- }
284
- }
285
- });
286
-
287
- // -- Tooltips (if tooltips.js and tooltips_url provided) --
288
- try {
289
- if (config.tooltips_url) {
290
- const tooltipsModule = await import('./tooltips.js');
291
- tooltipsModule.initializeTooltips({
292
- app,
293
- cameraEntity,
294
- modelEntity,
295
- tooltipsUrl: config.tooltips_url,
296
- defaultVisible: !!config.showTooltipsDefault,
297
- moveDuration: config.tooltipMoveDuration || 0.6
298
- });
299
- }
300
- } catch (e) {
301
- // Tooltips optional, fail silently
302
  }
303
-
304
- // -- Final canvas resize --
305
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
306
-
307
- // -- Hide progress --
308
- progressDialog.style.display = 'none';
309
- viewerInitialized = true;
310
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
- } catch (error) {
313
- progressDialog.innerHTML = `<p style="color: red">Error loading viewer: ${error && error.message ? error.message : error}</p>`;
314
- }
315
  }
316
 
317
- // ----------- Camera Reset ----------- //
318
  export function resetViewerCamera() {
319
  try {
320
  if (!cameraEntity || !modelEntity || !app) return;
@@ -361,14 +253,13 @@ export function resetViewerCamera() {
361
  }
362
  }
363
 
364
- // ----------- Cleanup ----------- //
365
  export function cleanupViewer() {
366
  if (app) {
367
  try { app.destroy(); } catch {}
368
  app = null;
369
  }
370
  cameraEntity = null;
371
- modelEntity = null;
372
  viewerInitialized = false;
373
  if (resizeObserver) {
374
  resizeObserver.disconnect();
 
1
  // viewer.js
2
  // ==============================
 
3
 
4
+ let pc;
5
  export let app = null;
6
  let cameraEntity = null;
7
  let modelEntity = null;
 
12
  let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minPivotY, minY;
13
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
14
  let plyUrl, glbUrl;
15
+ let galleryEntity = null;
16
 
 
17
  export async function initializeViewer(config, instanceId) {
18
  if (viewerInitialized) return;
19
 
20
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
 
21
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
22
 
 
23
  plyUrl = config.ply_url;
24
  glbUrl = config.glb_url;
25
+ minZoom = parseFloat(config.minZoom || "1");
26
+ maxZoom = parseFloat(config.maxZoom || "20");
27
+ minAngle = parseFloat(config.minAngle || "-45");
28
+ maxAngle = parseFloat(config.maxAngle || "90");
29
+ minAzimuth = (config.minAzimuth !== undefined) ? parseFloat(config.minAzimuth) : -360;
30
+ maxAzimuth = (config.maxAzimuth !== undefined) ? parseFloat(config.maxAzimuth) : 360;
31
+ minPivotY = parseFloat(config.minPivotY || "0");
32
+ minY = (config.minY !== undefined) ? parseFloat(config.minY) : 0;
33
+
34
+ modelX = (config.modelX !== undefined) ? parseFloat(config.modelX) : 0;
35
+ modelY = (config.modelY !== undefined) ? parseFloat(config.modelY) : 0;
36
+ modelZ = (config.modelZ !== undefined) ? parseFloat(config.modelZ) : 0;
37
+ modelScale = (config.modelScale !== undefined) ? parseFloat(config.modelScale) : 1;
38
  modelRotationX = (config.modelRotationX !== undefined) ? parseFloat(config.modelRotationX) : 0;
39
  modelRotationY = (config.modelRotationY !== undefined) ? parseFloat(config.modelRotationY) : 0;
40
  modelRotationZ = (config.modelRotationZ !== undefined) ? parseFloat(config.modelRotationZ) : 0;
41
 
42
+ const cameraX = (config.cameraX !== undefined) ? parseFloat(config.cameraX) : 0;
43
+ const cameraY = (config.cameraY !== undefined) ? parseFloat(config.cameraY) : 2;
44
+ const cameraZ = (config.cameraZ !== undefined) ? parseFloat(config.cameraZ) : 5;
45
  const cameraXPhone = (config.cameraXPhone !== undefined) ? parseFloat(config.cameraXPhone) : cameraX;
46
  const cameraYPhone = (config.cameraYPhone !== undefined) ? parseFloat(config.cameraYPhone) : cameraY;
47
  const cameraZPhone = (config.cameraZPhone !== undefined) ? parseFloat(config.cameraZPhone) : (cameraZ * 1.5);
 
50
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
51
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
52
 
53
+ const canvasId = 'canvas-' + instanceId;
54
+ const progressDialog = document.getElementById('progress-dialog-' + instanceId);
 
55
  const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
56
  const viewerContainer = document.getElementById('viewer-container-' + instanceId);
57
 
 
58
  let oldCanvas = document.getElementById(canvasId);
59
  if (oldCanvas) oldCanvas.remove();
60
 
 
61
  const canvas = document.createElement('canvas');
62
  canvas.id = canvasId;
63
  canvas.className = 'ply-canvas';
64
  canvas.style.width = "100%";
65
  canvas.style.height = "100%";
 
66
  canvas.setAttribute('tabindex', '0');
67
  viewerContainer.insertBefore(canvas, progressDialog);
68
 
 
69
  canvas.style.touchAction = "none";
70
  canvas.style.webkitTouchCallout = "none";
71
  canvas.addEventListener('gesturestart', e => e.preventDefault());
 
74
  canvas.addEventListener('dblclick', e => e.preventDefault());
75
  canvas.addEventListener('touchstart', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false });
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  progressDialog.style.display = 'block';
78
 
 
79
  if (!pc) {
80
  pc = await import("https://esm.run/playcanvas");
81
  window.pc = pc;
82
  }
83
 
84
+ const device = await pc.createGraphicsDevice(canvas, {
85
+ deviceTypes: ["webgl2"],
86
+ glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
87
+ twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
88
+ antialias: false
89
+ });
90
+ device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
91
+
92
+ const opts = new pc.AppOptions();
93
+ opts.graphicsDevice = device;
94
+ opts.mouse = new pc.Mouse(document.body);
95
+ opts.touch = new pc.TouchDevice(document.body);
96
+ opts.componentSystems = [
97
+ pc.RenderComponentSystem,
98
+ pc.CameraComponentSystem,
99
+ pc.LightComponentSystem,
100
+ pc.ScriptComponentSystem,
101
+ pc.GSplatComponentSystem,
102
+ pc.CollisionComponentSystem,
103
+ pc.RigidbodyComponentSystem
104
+ ];
105
+ opts.resourceHandlers = [
106
+ pc.TextureHandler,
107
+ pc.ContainerHandler,
108
+ pc.ScriptHandler,
109
+ pc.GSplatHandler
110
+ ];
111
+
112
+ app = new pc.Application(canvas, opts);
113
+ app.setCanvasFillMode(pc.FILLMODE_NONE);
114
+ app.setCanvasResolution(pc.RESOLUTION_AUTO);
115
+
116
+ resizeObserver = new ResizeObserver(entries => {
117
+ entries.forEach(entry => {
118
+ app.resizeCanvas(entry.contentRect.width, entry.contentRect.height);
119
  });
120
+ });
121
+ resizeObserver.observe(viewerContainer);
122
+
123
+ window.addEventListener('resize', () => app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight));
124
+ app.on('destroy', () => resizeObserver.disconnect());
125
+
126
+ // --- NEW: Add GLB and HDR asset ---
127
+ const assets = {
128
+ model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
129
+ orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
130
+ galerie: glbUrl ? new pc.Asset('galerie', 'container', { url: glbUrl }) : null,
131
+ hdr: new pc.Asset('hdr', 'texture', { url: "https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png" }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false })
132
+ };
133
+ // filter out nulls for loader
134
+ const assetArray = Object.values(assets).filter(a => !!a);
135
+
136
+ const loader = new pc.AssetListLoader(assetArray, app.assets);
137
+ loader.load(async () => {
138
+ app.start();
139
+ progressDialog.style.display = 'none';
140
+
141
+ // HDR environment
142
+ if (assets.hdr && assets.hdr.resource) {
143
+ app.scene.envAtlas = assets.hdr.resource;
144
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
+ // Model
147
+ modelEntity = new pc.Entity('model');
148
+ modelEntity.addComponent('gsplat', { asset: assets.model });
149
+ modelEntity.setLocalPosition(modelX, modelY, modelZ);
150
+ modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
151
+ modelEntity.setLocalScale(modelScale, modelScale, modelScale);
152
+ app.root.addChild(modelEntity);
153
+
154
+ // GLB gallery
155
+ if (assets.galerie && assets.galerie.resource && assets.galerie.resource.instantiateRenderEntity) {
156
+ galleryEntity = assets.galerie.resource.instantiateRenderEntity();
157
+ app.root.addChild(galleryEntity);
158
+ }
159
 
160
+ // Camera
161
+ cameraEntity = new pc.Entity('camera');
162
+ cameraEntity.addComponent('camera', { clearColor: new pc.Color(0.2, 0.2, 0.2, 1) });
163
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
164
+ cameraEntity.lookAt(modelEntity.getPosition());
165
+ cameraEntity.addComponent('script');
166
+ cameraEntity.script.create('orbitCamera', {
167
+ attributes: {
168
+ focusEntity: modelEntity,
169
+ inertiaFactor: 0.2,
170
+ distanceMax: maxZoom,
171
+ distanceMin: minZoom,
172
+ pitchAngleMax: maxAngle,
173
+ pitchAngleMin: minAngle,
174
+ yawAngleMax: maxAzimuth,
175
+ yawAngleMin: minAzimuth,
176
+ minPivotY: minPivotY,
177
+ frameOnStart: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  }
 
 
 
 
 
 
 
179
  });
180
+ cameraEntity.script.create('orbitCameraInputMouse');
181
+ cameraEntity.script.create('orbitCameraInputTouch');
182
+ app.root.addChild(cameraEntity);
183
+
184
+ app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
185
+
186
+ // Use Script B's camera reset logic after everything is ready
187
+ app.once('update', () => resetViewerCamera());
188
+
189
+ // Tooltips (if config.tooltips_url and tooltips.js available)
190
+ try {
191
+ if (config.tooltips_url) {
192
+ const tooltipsModule = await import('./tooltips.js');
193
+ tooltipsModule.initializeTooltips({
194
+ app,
195
+ cameraEntity,
196
+ modelEntity,
197
+ tooltipsUrl: config.tooltips_url,
198
+ defaultVisible: !!config.showTooltipsDefault,
199
+ moveDuration: config.tooltipMoveDuration || 0.6
200
+ });
201
+ }
202
+ } catch (e) {
203
+ // Tooltips optional, fail silently
204
+ }
205
 
206
+ viewerInitialized = true;
207
+ });
 
208
  }
209
 
 
210
  export function resetViewerCamera() {
211
  try {
212
  if (!cameraEntity || !modelEntity || !app) return;
 
253
  }
254
  }
255
 
 
256
  export function cleanupViewer() {
257
  if (app) {
258
  try { app.destroy(); } catch {}
259
  app = null;
260
  }
261
  cameraEntity = null;
262
+ modelEntity = null;
263
  viewerInitialized = false;
264
  if (resizeObserver) {
265
  resizeObserver.disconnect();