MikaFil commited on
Commit
6f65f93
·
verified ·
1 Parent(s): 5ad9c19

Update viewer.js

Browse files
Files changed (1) hide show
  1. viewer.js +312 -104
viewer.js CHANGED
@@ -1,5 +1,6 @@
1
  // viewer.js
2
  // ==============================
 
3
 
4
  let pc;
5
  export let app = null;
@@ -13,157 +14,364 @@ let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minPivotY, min
13
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
14
  let plyUrl, glbUrl;
15
 
 
16
  export async function initializeViewer(config, instanceId) {
17
  if (viewerInitialized) return;
18
 
19
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 
20
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
21
 
 
22
  plyUrl = config.ply_url;
23
  glbUrl = config.glb_url;
24
- minZoom = parseFloat(config.minZoom || "1");
25
- maxZoom = parseFloat(config.maxZoom || "20");
26
- minAngle = parseFloat(config.minAngle || "-45");
27
- maxAngle = parseFloat(config.maxAngle || "90");
28
- minAzimuth = (config.minAzimuth !== undefined) ? parseFloat(config.minAzimuth) : -360;
29
- maxAzimuth = (config.maxAzimuth !== undefined) ? parseFloat(config.maxAzimuth) : 360;
30
- minPivotY = parseFloat(config.minPivotY || "0");
31
- minY = (config.minY !== undefined) ? parseFloat(config.minY) : 0;
32
-
33
- modelX = (config.modelX !== undefined) ? parseFloat(config.modelX) : 0;
34
- modelY = (config.modelY !== undefined) ? parseFloat(config.modelY) : 0;
35
- modelZ = (config.modelZ !== undefined) ? parseFloat(config.modelZ) : 0;
36
- modelScale = (config.modelScale !== undefined) ? parseFloat(config.modelScale) : 1;
37
  modelRotationX = (config.modelRotationX !== undefined) ? parseFloat(config.modelRotationX) : 0;
38
  modelRotationY = (config.modelRotationY !== undefined) ? parseFloat(config.modelRotationY) : 0;
39
  modelRotationZ = (config.modelRotationZ !== undefined) ? parseFloat(config.modelRotationZ) : 0;
40
 
41
- const cameraX = parseFloat(config.cameraX || "0");
42
- const cameraY = parseFloat(config.cameraY || "2");
43
- const cameraZ = parseFloat(config.cameraZ || "5");
44
- const cameraXPhone = parseFloat(config.cameraXPhone || cameraX);
45
- const cameraYPhone = parseFloat(config.cameraYPhone || cameraY);
46
- const cameraZPhone = parseFloat(config.cameraZPhone || cameraZ * 1.5);
47
 
48
  chosenCameraX = isMobile ? cameraXPhone : cameraX;
49
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
50
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
51
 
52
- const canvasId = 'canvas-' + instanceId;
53
- const progressDialog = document.getElementById('progress-dialog-' + instanceId);
 
 
54
  const viewerContainer = document.getElementById('viewer-container-' + instanceId);
55
 
 
56
  let oldCanvas = document.getElementById(canvasId);
57
  if (oldCanvas) oldCanvas.remove();
58
 
 
59
  const canvas = document.createElement('canvas');
60
  canvas.id = canvasId;
61
  canvas.className = 'ply-canvas';
62
  canvas.style.width = "100%";
63
  canvas.style.height = "100%";
 
 
64
  viewerContainer.insertBefore(canvas, progressDialog);
65
 
 
66
  canvas.style.touchAction = "none";
67
  canvas.style.webkitTouchCallout = "none";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
 
69
  progressDialog.style.display = 'block';
70
 
 
71
  if (!pc) {
72
  pc = await import("https://esm.run/playcanvas");
73
  window.pc = pc;
74
  }
75
 
76
- // Explicitly ensure arraybuffer for PLY on iOS Safari
77
- const plyAsset = new pc.Asset('gsplat', 'gsplat', {
78
- url: plyUrl,
79
- responseType: 'arraybuffer' // Crucial explicit fix
80
- });
81
-
82
- const assets = {
83
- model: plyAsset,
84
- galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
85
- hdr: new pc.Asset('hdr', 'texture', {
86
- url: "https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png"
87
- }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false })
88
- };
89
-
90
- const device = await pc.createGraphicsDevice(canvas, {
91
- deviceTypes: ["webgl2"],
92
- antialias: false
93
- });
94
- device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
95
-
96
- const opts = new pc.AppOptions();
97
- opts.graphicsDevice = device;
98
- opts.mouse = new pc.Mouse(document.body);
99
- opts.touch = new pc.TouchDevice(document.body);
100
-
101
- app = new pc.Application(canvas, opts);
102
- app.setCanvasFillMode(pc.FILLMODE_NONE);
103
- app.setCanvasResolution(pc.RESOLUTION_AUTO);
104
-
105
- resizeObserver = new ResizeObserver(entries => {
106
- for (const entry of entries) {
107
- app.resizeCanvas(entry.contentRect.width, entry.contentRect.height);
108
- }
109
- });
110
- resizeObserver.observe(viewerContainer);
111
-
112
- app.assets.add(assets.model);
113
- app.assets.add(assets.galerie);
114
- app.assets.add(assets.hdr);
115
-
116
- assets.model.ready(() => {
117
- modelEntity = new pc.Entity('model');
118
- modelEntity.addComponent('gsplat', { asset: assets.model });
119
- modelEntity.setLocalPosition(modelX, modelY, modelZ);
120
- modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
121
- modelEntity.setLocalScale(modelScale, modelScale, modelScale);
122
- app.root.addChild(modelEntity);
123
- progressDialog.style.display = 'none';
124
- });
125
-
126
- app.start();
127
-
128
- cameraEntity = new pc.Entity('camera');
129
- cameraEntity.addComponent('camera', { clearColor: new pc.Color(0.2, 0.2, 0.2, 1) });
130
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
131
- cameraEntity.lookAt(pc.Vec3.ZERO);
132
-
133
- cameraEntity.addComponent('script');
134
- cameraEntity.script.create('orbitCamera', {
135
- attributes: {
136
- inertiaFactor: 0.2,
137
- focusEntity: modelEntity,
138
- distanceMax: maxZoom,
139
- distanceMin: minZoom,
140
- pitchAngleMax: maxAngle,
141
- pitchAngleMin: minAngle,
142
- yawAngleMax: maxAzimuth,
143
- yawAngleMin: minAzimuth,
144
- minPivotY: minPivotY,
145
- minY: minY,
146
- frameOnStart: true
147
- }
148
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
- cameraEntity.script.create('orbitCameraInputMouse');
151
- cameraEntity.script.create('orbitCameraInputTouch');
 
 
 
152
 
153
- app.root.addChild(cameraEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
- viewerInitialized = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  }
157
 
 
158
  export function resetViewerCamera() {
159
- if (cameraEntity && cameraEntity.script.orbitCamera) {
160
- cameraEntity.script.orbitCamera.reset();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  }
162
  }
163
 
 
164
  export function cleanupViewer() {
165
- if (app) app.destroy();
166
- app = null;
 
 
 
 
167
  viewerInitialized = false;
168
- if (resizeObserver) resizeObserver.disconnect();
 
 
 
169
  }
 
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;
 
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);
51
 
52
  chosenCameraX = isMobile ? cameraXPhone : cameraX;
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());
80
+ canvas.addEventListener('gesturechange', e => e.preventDefault());
81
+ canvas.addEventListener('gestureend', e => e.preventDefault());
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;
321
+ const orbitCam = cameraEntity.script.orbitCamera;
322
+ if (!orbitCam) return;
323
+
324
+ const modelPos = modelEntity.getPosition();
325
+ const tempEnt = new pc.Entity();
326
+ tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
327
+ tempEnt.lookAt(modelPos);
328
+
329
+ const dist = new pc.Vec3().sub2(
330
+ new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ),
331
+ modelPos
332
+ ).length();
333
+
334
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
335
+ cameraEntity.lookAt(modelPos);
336
+
337
+ orbitCam.pivotPoint = modelPos.clone();
338
+ orbitCam._targetDistance = dist;
339
+ orbitCam._distance = dist;
340
+
341
+ const rot = tempEnt.getRotation();
342
+ const fwd = new pc.Vec3();
343
+ rot.transformVector(pc.Vec3.FORWARD, fwd);
344
+
345
+ const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG;
346
+ const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
347
+ const rotNoYaw = new pc.Quat().mul2(yawQuat, rot);
348
+ const fNoYaw = new pc.Vec3();
349
+ rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
350
+ const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
351
+
352
+ orbitCam._targetYaw = yaw;
353
+ orbitCam._yaw = yaw;
354
+ orbitCam._targetPitch = pitch;
355
+ orbitCam._pitch = pitch;
356
+ if (orbitCam._updatePosition) orbitCam._updatePosition();
357
+
358
+ tempEnt.destroy();
359
+ } catch (e) {
360
+ // Silent fail
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();
375
+ resizeObserver = null;
376
+ }
377
  }