MikaFil commited on
Commit
236a054
Β·
verified Β·
1 Parent(s): d5897af

Update viewer.js

Browse files
Files changed (1) hide show
  1. viewer.js +157 -58
viewer.js CHANGED
@@ -15,30 +15,37 @@ let plyUrl, glbUrl;
15
 
16
  /**
17
  * initializeViewer(config, instanceId)
 
 
 
 
 
 
18
  */
19
  export async function initializeViewer(config, instanceId) {
20
  if (viewerInitialized) return;
21
 
22
- // Device detection
23
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
24
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
25
 
26
- // 1. Read all config values
27
  plyUrl = config.ply_url;
28
  glbUrl = config.glb_url;
29
- minZoom = parseFloat(config.minZoom || "1");
30
- maxZoom = parseFloat(config.maxZoom || "20");
31
- minAngle = parseFloat(config.minAngle || "-45");
32
- maxAngle = parseFloat(config.maxAngle || "90");
33
- minAzimuth = (config.minAzimuth !== undefined) ? parseFloat(config.minAzimuth) : -360;
34
- maxAzimuth = (config.maxAzimuth !== undefined) ? parseFloat(config.maxAzimuth) : 360;
35
- minPivotY = parseFloat(config.minPivotY || "0");
36
- minY = (config.minY !== undefined) ? parseFloat(config.minY) : 0;
37
-
38
- modelX = (config.modelX !== undefined) ? parseFloat(config.modelX) : 0;
39
- modelY = (config.modelY !== undefined) ? parseFloat(config.modelY) : 0;
40
- modelZ = (config.modelZ !== undefined) ? parseFloat(config.modelZ) : 0;
41
- modelScale = (config.modelScale !== undefined) ? parseFloat(config.modelScale) : 1;
 
42
  modelRotationX = (config.modelRotationX !== undefined) ? parseFloat(config.modelRotationX) : 0;
43
  modelRotationY = (config.modelRotationY !== undefined) ? parseFloat(config.modelRotationY) : 0;
44
  modelRotationZ = (config.modelRotationZ !== undefined) ? parseFloat(config.modelRotationZ) : 0;
@@ -54,27 +61,30 @@ export async function initializeViewer(config, instanceId) {
54
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
55
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
56
 
57
- // 2. Grab DOM elements
58
- const canvasId = 'canvas-' + instanceId;
59
- const progressDialog = document.getElementById('progress-dialog-' + instanceId);
60
  const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
61
- const viewerContainer = document.getElementById('viewer-container-' + instanceId);
62
 
63
- // 3. Create <canvas>
64
  let oldCanvas = document.getElementById(canvasId);
65
- if (oldCanvas) oldCanvas.remove();
 
 
66
  const newCanvas = document.createElement('canvas');
67
  newCanvas.id = canvasId;
68
  newCanvas.className = 'ply-canvas';
69
  newCanvas.style.zIndex = "1";
70
- viewerContainer.insertBefore(newCanvas, progressDialog);
71
  const canvas = newCanvas;
72
 
73
- // 4. Wheel-zoom listener
74
  canvas.addEventListener('wheel', function(e) {
75
  if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
76
  const orbitCam = cameraEntity.script.orbitCamera;
77
  const sensitivity = (cameraEntity.script.orbitCameraInputMouse && cameraEntity.script.orbitCameraInputMouse.distanceSensitivity) || 0.4;
 
78
  if (cameraEntity.camera.projection === pc.PROJECTION_PERSPECTIVE) {
79
  orbitCam.distance -= e.deltaY * 0.01 * sensitivity * (orbitCam.distance * 0.1);
80
  } else {
@@ -87,14 +97,14 @@ export async function initializeViewer(config, instanceId) {
87
 
88
  progressDialog.style.display = 'block';
89
 
90
- // 5. Import PlayCanvas
91
  if (!pc) {
92
  pc = await import("https://esm.run/playcanvas");
93
  window.pc = pc;
94
  }
95
 
96
  try {
97
- // 6. Create graphics device & app
98
  const deviceType = "webgl2";
99
  const gfxOptions = {
100
  deviceTypes: [deviceType],
@@ -110,7 +120,7 @@ export async function initializeViewer(config, instanceId) {
110
  createOptions.mouse = new pc.Mouse(canvas);
111
  createOptions.touch = new pc.TouchDevice(canvas);
112
 
113
- // Physics & collision for picking
114
  createOptions.componentSystems = [
115
  pc.RenderComponentSystem,
116
  pc.CameraComponentSystem,
@@ -127,60 +137,69 @@ export async function initializeViewer(config, instanceId) {
127
  pc.GSplatHandler
128
  ];
129
 
 
130
  app = new pc.Application(canvas, createOptions);
 
 
131
  app.setCanvasFillMode(pc.FILLMODE_NONE);
132
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
133
  app.scene.exposure = 0.5;
134
  app.scene.toneMapping = pc.TONEMAP_ACES;
135
 
136
- // 7. Window resize β†’ resizeCanvas
137
  const resizeCanvas = () => {
138
- if (app) app.resizeCanvas(canvas.clientWidth, canvas.clientHeight);
 
 
139
  };
140
  window.addEventListener('resize', resizeCanvas);
141
  app.on('destroy', () => {
142
  window.removeEventListener('resize', resizeCanvas);
143
  });
144
 
145
- // 8. Assets: PLY, orbit-camera.js, GLB, HDR
146
  const assets = {
147
  model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
148
- orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
149
  galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
150
  hdr: new pc.Asset(
151
  'hdr',
152
  'texture',
153
- { url: `https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png` },
154
  { type: pc.TEXTURETYPE_RGBP, mipmaps: false }
155
  )
156
  };
157
 
158
- const loader = new pc.AssetListLoader(Object.values(assets), app.assets);
159
  let lastProgress = 0;
160
 
161
- assets.model.on('load', () => { progressDialog.style.display = 'none'; });
162
- assets.model.on('error', err => {
 
 
163
  console.error("Error loading PLY file:", err);
164
- progressDialog.innerHTML = `<p style="color: red">Error loading model: ${err}</p>`;
165
  });
166
 
167
- const progressChecker = setInterval(() => {
168
  if (assets.model.resource) {
169
  progressIndicator.value = 100;
170
  clearInterval(progressChecker);
171
  progressDialog.style.display = 'none';
172
  } else if (assets.model.loading) {
173
- lastProgress = Math.min(lastProgress + 2, 90);
 
174
  progressIndicator.value = lastProgress;
175
  }
176
- }, 100);
 
177
 
178
- // 9. Build scene when loaded
179
- loader.load(async () => {
180
  app.start();
181
  app.scene.envAtlas = assets.hdr.resource;
182
 
183
- // a) Model entity
184
  modelEntity = new pc.Entity('model');
185
  modelEntity.addComponent('gsplat', { asset: assets.model });
186
  modelEntity.setLocalPosition(modelX, modelY, modelZ);
@@ -188,7 +207,7 @@ export async function initializeViewer(config, instanceId) {
188
  modelEntity.setLocalScale(modelScale, modelScale, modelScale);
189
  app.root.addChild(modelEntity);
190
 
191
- // b) Light
192
  const dirLight = new pc.Entity('Cascaded Light');
193
  dirLight.addComponent('light', {
194
  type: 'directional',
@@ -209,11 +228,11 @@ export async function initializeViewer(config, instanceId) {
209
  app.root.addChild(dirLight);
210
  dirLight.setLocalEulerAngles(0, 0, 0);
211
 
212
- // c) GLB gallery
213
  const galleryEntity = assets.galerie.resource.instantiateRenderEntity();
214
  app.root.addChild(galleryEntity);
215
 
216
- // d) Camera + orbit scripts
217
  cameraEntity = new pc.Entity('camera');
218
  cameraEntity.addComponent('camera', {
219
  clearColor: config.canvas_background
@@ -221,14 +240,16 @@ export async function initializeViewer(config, instanceId) {
221
  : 0,
222
  toneMapping: pc.TONEMAP_ACES
223
  });
 
224
  cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
225
  cameraEntity.lookAt(modelEntity.getPosition());
226
 
227
- const distCheck = new pc.Vec3().sub2(
 
228
  cameraEntity.getPosition(),
229
  modelEntity.getPosition()
230
  ).length();
231
- if (distCheck > 100) {
232
  console.warn("Warning: Camera is far from model; it might not be visible");
233
  }
234
 
@@ -253,6 +274,7 @@ export async function initializeViewer(config, instanceId) {
253
  distanceSensitivity: isMobile ? 0.5 : 0.4
254
  }
255
  });
 
256
  if (cameraEntity.script.orbitCameraInputMouse) {
257
  cameraEntity.script.orbitCameraInputMouse.onMouseWheel = function() {};
258
  }
@@ -265,13 +287,15 @@ export async function initializeViewer(config, instanceId) {
265
 
266
  app.root.addChild(cameraEntity);
267
 
268
- // Reset camera once before first frame
269
- app.once('update', () => {
 
 
270
  resetViewerCamera();
271
  });
272
 
273
- // Constrain Y
274
- app.on('update', dt => {
275
  if (cameraEntity) {
276
  const pos = cameraEntity.getPosition();
277
  if (pos.y < minY) {
@@ -280,19 +304,19 @@ export async function initializeViewer(config, instanceId) {
280
  }
281
  });
282
 
283
- // Final canvas resize
284
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
285
 
286
- // Tooltips module
287
  try {
288
  const tooltipsModule = await import('./tooltips.js');
289
  tooltipsModule.initializeTooltips({
290
  app,
291
  cameraEntity,
292
  modelEntity,
293
- tooltipsUrl: config.points_url,
294
- defaultVisible: !!config.showPointsDefault,
295
- moveDuration: config.tooltipMoveDuration || 0.6
296
  });
297
  } catch (e) {
298
  console.error("Error loading tooltips.js:", e);
@@ -304,8 +328,83 @@ export async function initializeViewer(config, instanceId) {
304
 
305
  } catch (error) {
306
  console.error("Error initializing PlayCanvas viewer:", error);
307
- progressDialog.innerHTML = `<p style="color: red">Error loading viewer: ${error.message}</p>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  }
309
  }
310
 
311
- // ... resetViewerCamera() and cleanupViewer() unchanged ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  /**
17
  * initializeViewer(config, instanceId)
18
+ *
19
+ * - Dynamically imports PlayCanvas (once).
20
+ * - Creates a new <canvas id="canvas-<instanceId>"> inside the viewer container.
21
+ * - Sets up lights, model loading, camera + orbit controls, HDR, etc.
22
+ * - Exposes app, cameraEntity, modelEntity, so that interface.js can resize or reset.
23
+ * - Imports & initializes tooltip hotspots from tooltips.js.
24
  */
25
  export async function initializeViewer(config, instanceId) {
26
  if (viewerInitialized) return;
27
 
28
+ // Detect device type again (for camera coordinates & input sensitivity)
29
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
30
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
31
 
32
+ // ─── 1. Read all relevant config values ───────────────────────────────────────
33
  plyUrl = config.ply_url;
34
  glbUrl = config.glb_url;
35
+
36
+ minZoom = parseFloat(config.minZoom || "1");
37
+ maxZoom = parseFloat(config.maxZoom || "20");
38
+ minAngle = parseFloat(config.minAngle || "-45");
39
+ maxAngle = parseFloat(config.maxAngle || "90");
40
+ minAzimuth = (config.minAzimuth !== undefined) ? parseFloat(config.minAzimuth) : -360;
41
+ maxAzimuth = (config.maxAzimuth !== undefined) ? parseFloat(config.maxAzimuth) : 360;
42
+ minPivotY = parseFloat(config.minPivotY || "0");
43
+ minY = (config.minY !== undefined) ? parseFloat(config.minY) : 0;
44
+
45
+ modelX = (config.modelX !== undefined) ? parseFloat(config.modelX) : 0;
46
+ modelY = (config.modelY !== undefined) ? parseFloat(config.modelY) : 0;
47
+ modelZ = (config.modelZ !== undefined) ? parseFloat(config.modelZ) : 0;
48
+ modelScale = (config.modelScale !== undefined) ? parseFloat(config.modelScale) : 1;
49
  modelRotationX = (config.modelRotationX !== undefined) ? parseFloat(config.modelRotationX) : 0;
50
  modelRotationY = (config.modelRotationY !== undefined) ? parseFloat(config.modelRotationY) : 0;
51
  modelRotationZ = (config.modelRotationZ !== undefined) ? parseFloat(config.modelRotationZ) : 0;
 
61
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
62
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
63
 
64
+ // ─── 2. Grab DOM elements inside interface.js’s container ─────────────────────
65
+ const canvasId = 'canvas-' + instanceId;
66
+ const progressDialog = document.getElementById('progress-dialog-' + instanceId);
67
  const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
68
+ const viewerContainerElem = document.getElementById('viewer-container-' + instanceId);
69
 
70
+ // ─── 3. Create a fresh <canvas> (removing any old one) ────────────────────────
71
  let oldCanvas = document.getElementById(canvasId);
72
+ if (oldCanvas) {
73
+ oldCanvas.remove();
74
+ }
75
  const newCanvas = document.createElement('canvas');
76
  newCanvas.id = canvasId;
77
  newCanvas.className = 'ply-canvas';
78
  newCanvas.style.zIndex = "1";
79
+ viewerContainerElem.insertBefore(newCanvas, progressDialog);
80
  const canvas = newCanvas;
81
 
82
+ // ─── 4. Add mouse‐wheel listener for zoom adjustments ────────────────────────
83
  canvas.addEventListener('wheel', function(e) {
84
  if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
85
  const orbitCam = cameraEntity.script.orbitCamera;
86
  const sensitivity = (cameraEntity.script.orbitCameraInputMouse && cameraEntity.script.orbitCameraInputMouse.distanceSensitivity) || 0.4;
87
+
88
  if (cameraEntity.camera.projection === pc.PROJECTION_PERSPECTIVE) {
89
  orbitCam.distance -= e.deltaY * 0.01 * sensitivity * (orbitCam.distance * 0.1);
90
  } else {
 
97
 
98
  progressDialog.style.display = 'block';
99
 
100
+ // ─── 5. Import PlayCanvas (only once) ─────────────────────────────────────────
101
  if (!pc) {
102
  pc = await import("https://esm.run/playcanvas");
103
  window.pc = pc;
104
  }
105
 
106
  try {
107
+ // ─── 6. Create a GraphicsDevice + AppOptions ────────────────────────────────
108
  const deviceType = "webgl2";
109
  const gfxOptions = {
110
  deviceTypes: [deviceType],
 
120
  createOptions.mouse = new pc.Mouse(canvas);
121
  createOptions.touch = new pc.TouchDevice(canvas);
122
 
123
+ // ─── ADD: include physics systems for collision-based picking ───────────────
124
  createOptions.componentSystems = [
125
  pc.RenderComponentSystem,
126
  pc.CameraComponentSystem,
 
137
  pc.GSplatHandler
138
  ];
139
 
140
+ // ─── REPLACED: use pc.Application (instead of deprecated / removed AppBase) ──
141
  app = new pc.Application(canvas, createOptions);
142
+
143
+ // Configure application
144
  app.setCanvasFillMode(pc.FILLMODE_NONE);
145
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
146
  app.scene.exposure = 0.5;
147
  app.scene.toneMapping = pc.TONEMAP_ACES;
148
 
149
+ // ─── 7. Handle window‐resize for the PlayCanvas canvas ─────────────────────
150
  const resizeCanvas = () => {
151
+ if (app) {
152
+ app.resizeCanvas(canvas.clientWidth, canvas.clientHeight);
153
+ }
154
  };
155
  window.addEventListener('resize', resizeCanvas);
156
  app.on('destroy', () => {
157
  window.removeEventListener('resize', resizeCanvas);
158
  });
159
 
160
+ // ─── 8. Declare assets: PLY + orbit‐camera.js + GLB + HDR ───────────────────
161
  const assets = {
162
  model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
163
+ orbit: new pc.Asset('script', 'script', { url: "./orbit-camera.js" }),
164
  galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
165
  hdr: new pc.Asset(
166
  'hdr',
167
  'texture',
168
+ { url: https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png },
169
  { type: pc.TEXTURETYPE_RGBP, mipmaps: false }
170
  )
171
  };
172
 
173
+ const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
174
  let lastProgress = 0;
175
 
176
+ assets.model.on('load', () => {
177
+ progressDialog.style.display = 'none';
178
+ });
179
+ assets.model.on('error', (err) => {
180
  console.error("Error loading PLY file:", err);
181
+ progressDialog.innerHTML = <p style="color: red">Error loading model: ${err}</p>;
182
  });
183
 
184
+ const checkProgress = () => {
185
  if (assets.model.resource) {
186
  progressIndicator.value = 100;
187
  clearInterval(progressChecker);
188
  progressDialog.style.display = 'none';
189
  } else if (assets.model.loading) {
190
+ lastProgress += 2;
191
+ if (lastProgress > 90) lastProgress = 90;
192
  progressIndicator.value = lastProgress;
193
  }
194
+ };
195
+ const progressChecker = setInterval(checkProgress, 100);
196
 
197
+ // ─── 9. Once assets are loaded, build the scene ────────────────────────────
198
+ assetListLoader.load(async () => {
199
  app.start();
200
  app.scene.envAtlas = assets.hdr.resource;
201
 
202
+ // 9a. Create & position the model
203
  modelEntity = new pc.Entity('model');
204
  modelEntity.addComponent('gsplat', { asset: assets.model });
205
  modelEntity.setLocalPosition(modelX, modelY, modelZ);
 
207
  modelEntity.setLocalScale(modelScale, modelScale, modelScale);
208
  app.root.addChild(modelEntity);
209
 
210
+ // 9b. Add a directional light
211
  const dirLight = new pc.Entity('Cascaded Light');
212
  dirLight.addComponent('light', {
213
  type: 'directional',
 
228
  app.root.addChild(dirLight);
229
  dirLight.setLocalEulerAngles(0, 0, 0);
230
 
231
+ // 9c. Instantiate the GLB β€œgallery” if any
232
  const galleryEntity = assets.galerie.resource.instantiateRenderEntity();
233
  app.root.addChild(galleryEntity);
234
 
235
+ // 9d. Create & configure the camera + orbit‐camera scripts
236
  cameraEntity = new pc.Entity('camera');
237
  cameraEntity.addComponent('camera', {
238
  clearColor: config.canvas_background
 
240
  : 0,
241
  toneMapping: pc.TONEMAP_ACES
242
  });
243
+ // *** Set initial position/orientation from config immediately ***
244
  cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
245
  cameraEntity.lookAt(modelEntity.getPosition());
246
 
247
+ // Warn if camera is very far
248
+ const distanceCheck = new pc.Vec3().sub2(
249
  cameraEntity.getPosition(),
250
  modelEntity.getPosition()
251
  ).length();
252
+ if (distanceCheck > 100) {
253
  console.warn("Warning: Camera is far from model; it might not be visible");
254
  }
255
 
 
274
  distanceSensitivity: isMobile ? 0.5 : 0.4
275
  }
276
  });
277
+ // Disable default onMouseWheel in orbitCameraInputMouse so we handle it ourselves
278
  if (cameraEntity.script.orbitCameraInputMouse) {
279
  cameraEntity.script.orbitCameraInputMouse.onMouseWheel = function() {};
280
  }
 
287
 
288
  app.root.addChild(cameraEntity);
289
 
290
+ // ─── NEW: Ensure camera is reset on the first frame ───────────────────────
291
+ app.once('update', function() {
292
+ // Call existing resetViewerCamera() so that pivot, yaw/pitch, and distance get
293
+ // recalculated correctly before the first render.
294
  resetViewerCamera();
295
  });
296
 
297
+ // 9e. Constrain camera’s Y so it never dips below minY
298
+ app.on('update', function(dt) {
299
  if (cameraEntity) {
300
  const pos = cameraEntity.getPosition();
301
  if (pos.y < minY) {
 
304
  }
305
  });
306
 
307
+ // 9f. Resize canvas one last time to fit container
308
+ app.resizeCanvas(viewerContainerElem.clientWidth, viewerContainerElem.clientHeight);
309
 
310
+ // ─── NEW: Load & initialize the tooltips module ──────────────────────
311
  try {
312
  const tooltipsModule = await import('./tooltips.js');
313
  tooltipsModule.initializeTooltips({
314
  app,
315
  cameraEntity,
316
  modelEntity,
317
+ tooltipsUrl: config.tooltips_url,
318
+ defaultVisible: !!config.showTooltipsDefault,
319
+ moveDuration: config.tooltipMoveDuration || 0.6 // optional smooth‐move duration
320
  });
321
  } catch (e) {
322
  console.error("Error loading tooltips.js:", e);
 
328
 
329
  } catch (error) {
330
  console.error("Error initializing PlayCanvas viewer:", error);
331
+ progressDialog.innerHTML = <p style="color: red">Error loading viewer: ${error.message}</p>;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * resetViewerCamera()
337
+ *
338
+ * Re‐centers the camera on the model, using the chosenCameraXYZ from config.
339
+ * Exactly the same math as in the old index.jsβ€˜s resetCamera().
340
+ */
341
+ export function resetViewerCamera() {
342
+ try {
343
+ if (!cameraEntity || !modelEntity || !app) return;
344
+ const orbitCam = cameraEntity.script.orbitCamera;
345
+ if (!orbitCam) return;
346
+
347
+ const modelPos = modelEntity.getPosition();
348
+ // Create a temporary entity at chosenCameraX,Y,Z to compute yaw/pitch
349
+ const tempEntity = new pc.Entity();
350
+ tempEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
351
+ tempEntity.lookAt(modelPos);
352
+
353
+ // Compute distance
354
+ const distance = new pc.Vec3().sub2(
355
+ new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ),
356
+ modelPos
357
+ ).length();
358
+
359
+ // Reposition camera
360
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
361
+ cameraEntity.lookAt(modelPos);
362
+
363
+ orbitCam.pivotPoint = new pc.Vec3(modelPos.x, modelPos.y, modelPos.z);
364
+ orbitCam._targetDistance = distance;
365
+ orbitCam._distance = distance;
366
+
367
+ // Compute yaw & pitch from the β€œtempEntity”
368
+ const rotation = tempEntity.getRotation();
369
+ const tempForward = new pc.Vec3();
370
+ rotation.transformVector(pc.Vec3.FORWARD, tempForward);
371
+
372
+ const yaw = Math.atan2(-tempForward.x, -tempForward.z) * pc.math.RAD_TO_DEG;
373
+ const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
374
+ const rotWithoutYaw = new pc.Quat().mul2(yawQuat, rotation);
375
+ const forwardWithoutYaw = new pc.Vec3();
376
+ rotWithoutYaw.transformVector(pc.Vec3.FORWARD, forwardWithoutYaw);
377
+ const pitch = Math.atan2(forwardWithoutYaw.y, -forwardWithoutYaw.z) * pc.math.RAD_TO_DEG;
378
+
379
+ orbitCam._targetYaw = yaw;
380
+ orbitCam._yaw = yaw;
381
+ orbitCam._targetPitch = pitch;
382
+ orbitCam._pitch = pitch;
383
+ if (typeof orbitCam._updatePosition === 'function') {
384
+ orbitCam._updatePosition();
385
+ }
386
+
387
+ tempEntity.destroy();
388
+ } catch (error) {
389
+ console.error("Error resetting camera:", error);
390
  }
391
  }
392
 
393
+ /**
394
+ * cleanupViewer()
395
+ *
396
+ * Destroys the PlayCanvas app (if it exists) and clears references. Called when the user clicks β€œX”.
397
+ */
398
+ export function cleanupViewer() {
399
+ if (app) {
400
+ try {
401
+ app.destroy();
402
+ } catch (e) {
403
+ console.error("Error destroying PlayCanvas app:", e);
404
+ }
405
+ app = null;
406
+ }
407
+ cameraEntity = null;
408
+ modelEntity = null;
409
+ viewerInitialized = false;
410
+ }