MikaFil commited on
Commit
b6a8b79
·
verified ·
1 Parent(s): 7dc0daa

Update viewer.js

Browse files
Files changed (1) hide show
  1. viewer.js +257 -41
viewer.js CHANGED
@@ -1,7 +1,7 @@
1
  // viewer.js
2
  // ==============================
3
 
4
- let pc;
5
  export let app = null;
6
  let cameraEntity = null;
7
  let modelEntity = null;
@@ -13,34 +13,39 @@ 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);
 
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 = (config.cameraX !== undefined) ? parseFloat(config.cameraX) : 0;
42
- const cameraY = (config.cameraY !== undefined) ? parseFloat(config.cameraY) : 2;
43
- const cameraZ = (config.cameraZ !== undefined) ? parseFloat(config.cameraZ) : 5;
44
  const cameraXPhone = (config.cameraXPhone !== undefined) ? parseFloat(config.cameraXPhone) : cameraX;
45
  const cameraYPhone = (config.cameraYPhone !== undefined) ? parseFloat(config.cameraYPhone) : cameraY;
46
  const cameraZPhone = (config.cameraZPhone !== undefined) ? parseFloat(config.cameraZPhone) : (cameraZ * 1.5);
@@ -49,49 +54,89 @@ export async function initializeViewer(config, instanceId) {
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 progressIndicator = document.getElementById('progress-indicator-' + instanceId);
55
- const viewerContainer = document.getElementById('viewer-container-' + instanceId);
56
 
 
57
  let oldCanvas = document.getElementById(canvasId);
58
  if (oldCanvas) oldCanvas.remove();
59
-
60
  const canvas = document.createElement('canvas');
61
  canvas.id = canvasId;
62
  canvas.className = 'ply-canvas';
 
63
  canvas.style.width = "100%";
64
  canvas.style.height = "100%";
65
  canvas.setAttribute('tabindex', '0');
66
  viewerContainer.insertBefore(canvas, progressDialog);
67
 
 
68
  canvas.style.touchAction = "none";
69
  canvas.style.webkitTouchCallout = "none";
70
  canvas.addEventListener('gesturestart', e => e.preventDefault());
71
  canvas.addEventListener('gesturechange', e => e.preventDefault());
72
  canvas.addEventListener('gestureend', e => e.preventDefault());
73
  canvas.addEventListener('dblclick', e => e.preventDefault());
74
- canvas.addEventListener('touchstart', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  progressDialog.style.display = 'block';
77
 
 
78
  if (!pc) {
79
  pc = await import("https://esm.run/playcanvas");
80
  window.pc = pc;
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  const device = await pc.createGraphicsDevice(canvas, {
84
  deviceTypes: ["webgl2"],
85
- glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
86
- twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
87
- antialias: false
88
  });
89
  device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
90
 
91
  const opts = new pc.AppOptions();
92
  opts.graphicsDevice = device;
 
 
 
93
  opts.mouse = new pc.Mouse(document.body);
94
  opts.touch = new pc.TouchDevice(document.body);
 
 
95
  opts.componentSystems = [
96
  pc.RenderComponentSystem,
97
  pc.CameraComponentSystem,
@@ -112,28 +157,56 @@ export async function initializeViewer(config, instanceId) {
112
  app.setCanvasFillMode(pc.FILLMODE_NONE);
113
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
114
 
 
115
  resizeObserver = new ResizeObserver(entries => {
116
- entries.forEach(entry => {
117
  app.resizeCanvas(entry.contentRect.width, entry.contentRect.height);
118
- });
119
  });
120
  resizeObserver.observe(viewerContainer);
121
 
122
- window.addEventListener('resize', () => app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight));
123
- app.on('destroy', () => resizeObserver.disconnect());
 
 
 
 
 
124
 
 
125
  const assets = {
126
  model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
127
  orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
128
  galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
129
- 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 })
 
 
130
  };
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  const loader = new pc.AssetListLoader(Object.values(assets), app.assets);
133
- loader.load(() => {
134
  app.start();
135
- progressDialog.style.display = 'none';
136
 
 
137
  modelEntity = new pc.Entity('model');
138
  modelEntity.addComponent('gsplat', { asset: assets.model });
139
  modelEntity.setLocalPosition(modelX, modelY, modelZ);
@@ -141,28 +214,171 @@ export async function initializeViewer(config, instanceId) {
141
  modelEntity.setLocalScale(modelScale, modelScale, modelScale);
142
  app.root.addChild(modelEntity);
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  cameraEntity = new pc.Entity('camera');
145
- cameraEntity.addComponent('camera', { clearColor: new pc.Color(0.2, 0.2, 0.2, 1) });
 
 
 
 
 
 
 
 
 
146
  cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
147
  cameraEntity.lookAt(modelEntity.getPosition());
148
  cameraEntity.addComponent('script');
149
  cameraEntity.script.create('orbitCamera', {
150
  attributes: {
151
- focusEntity: modelEntity, inertiaFactor: 0.2, distanceMax: maxZoom, distanceMin: minZoom,
152
- pitchAngleMax: maxAngle, pitchAngleMin: minAngle, frameOnStart: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
  });
155
- cameraEntity.script.create('orbitCameraInputMouse');
156
- cameraEntity.script.create('orbitCameraInputTouch');
157
  app.root.addChild(cameraEntity);
158
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  viewerInitialized = true;
161
  });
162
  }
163
 
 
164
  export function resetViewerCamera() {
165
- if (!cameraEntity || !modelEntity || !app) return;
166
- const orbitCam = cameraEntity.script.orbitCamera;
167
- if (orbitCam) orbitCam.reset();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
 
1
  // viewer.js
2
  // ==============================
3
 
4
+ let pc; // PlayCanvas module
5
  export let app = null;
6
  let cameraEntity = null;
7
  let modelEntity = null;
 
13
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
14
  let plyUrl, glbUrl;
15
 
16
+ /**
17
+ * initializeViewer(config, instanceId)
18
+ */
19
  export async function initializeViewer(config, instanceId) {
20
  if (viewerInitialized) return;
21
 
22
+ // Platform detection
23
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
24
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
25
 
26
+ // Config/params
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;
45
 
46
+ const cameraX = (config.cameraX !== undefined) ? parseFloat(config.cameraX) : 0;
47
+ const cameraY = (config.cameraY !== undefined) ? parseFloat(config.cameraY) : 2;
48
+ const cameraZ = (config.cameraZ !== undefined) ? parseFloat(config.cameraZ) : 5;
49
  const cameraXPhone = (config.cameraXPhone !== undefined) ? parseFloat(config.cameraXPhone) : cameraX;
50
  const cameraYPhone = (config.cameraYPhone !== undefined) ? parseFloat(config.cameraYPhone) : cameraY;
51
  const cameraZPhone = (config.cameraZPhone !== undefined) ? parseFloat(config.cameraZPhone) : (cameraZ * 1.5);
 
54
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
55
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
56
 
57
+ // DOM
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
+ // Create canvas
64
  let oldCanvas = document.getElementById(canvasId);
65
  if (oldCanvas) oldCanvas.remove();
 
66
  const canvas = document.createElement('canvas');
67
  canvas.id = canvasId;
68
  canvas.className = 'ply-canvas';
69
+ canvas.style.zIndex = "1";
70
  canvas.style.width = "100%";
71
  canvas.style.height = "100%";
72
  canvas.setAttribute('tabindex', '0');
73
  viewerContainer.insertBefore(canvas, progressDialog);
74
 
75
+ // iOS touch fixes
76
  canvas.style.touchAction = "none";
77
  canvas.style.webkitTouchCallout = "none";
78
  canvas.addEventListener('gesturestart', e => e.preventDefault());
79
  canvas.addEventListener('gesturechange', e => e.preventDefault());
80
  canvas.addEventListener('gestureend', e => e.preventDefault());
81
  canvas.addEventListener('dblclick', e => e.preventDefault());
82
+ canvas.addEventListener('touchstart', function(e) {
83
+ if (e.touches.length > 1) e.preventDefault();
84
+ }, { passive: false });
85
+
86
+ // Wheel: allow orbit zoom on desktop (Script B)
87
+ canvas.addEventListener('wheel', e => {
88
+ if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
89
+ const orbitCam = cameraEntity.script.orbitCamera;
90
+ const sens = (cameraEntity.script.orbitCameraInputMouse?.distanceSensitivity) || 0.4;
91
+ if (cameraEntity.camera.projection === pc.PROJECTION_PERSPECTIVE) {
92
+ orbitCam.distance -= e.deltaY * 0.01 * sens * (orbitCam.distance * 0.1);
93
+ } else {
94
+ orbitCam.orthoHeight -= e.deltaY * 0.01 * sens * (orbitCam.orthoHeight * 0.1);
95
+ }
96
+ e.preventDefault();
97
+ e.stopPropagation();
98
+ }
99
+ }, { passive: false });
100
 
101
  progressDialog.style.display = 'block';
102
 
103
+ // Import PlayCanvas
104
  if (!pc) {
105
  pc = await import("https://esm.run/playcanvas");
106
  window.pc = pc;
107
  }
108
 
109
+ // ----------- iOS PLY loader patch --------------
110
+ // If on iOS, or window.PLY_FORCE_ARRAYBUFFER, force .ply load as arraybuffer (Script A trick)
111
+ if (isIOS || window.PLY_FORCE_ARRAYBUFFER) {
112
+ const origRequest = pc.Http.request;
113
+ pc.Http.request = function(options) {
114
+ if (options && options.url && options.url.endsWith('.ply')) {
115
+ options.responseType = 'arraybuffer';
116
+ }
117
+ return origRequest.call(this, options);
118
+ };
119
+ }
120
+ // -----------------------------------------------
121
+
122
+ // Graphics device & AppOptions
123
  const device = await pc.createGraphicsDevice(canvas, {
124
  deviceTypes: ["webgl2"],
125
+ glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
126
+ twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
127
+ antialias: false
128
  });
129
  device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
130
 
131
  const opts = new pc.AppOptions();
132
  opts.graphicsDevice = device;
133
+
134
+ // --- Input: Script B logic on desktop/Android, iOS input on document.body (Script A fix) ---
135
+ // Always use document.body for mouse/touch (Script A), fixes iOS input propagation issues!
136
  opts.mouse = new pc.Mouse(document.body);
137
  opts.touch = new pc.TouchDevice(document.body);
138
+ // ------------------------------------------------------------------------------------------
139
+
140
  opts.componentSystems = [
141
  pc.RenderComponentSystem,
142
  pc.CameraComponentSystem,
 
157
  app.setCanvasFillMode(pc.FILLMODE_NONE);
158
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
159
 
160
+ // Resize observer
161
  resizeObserver = new ResizeObserver(entries => {
162
+ for (const entry of entries) {
163
  app.resizeCanvas(entry.contentRect.width, entry.contentRect.height);
164
+ }
165
  });
166
  resizeObserver.observe(viewerContainer);
167
 
168
+ window.addEventListener('resize', () => {
169
+ if (app) app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
170
+ });
171
+
172
+ app.on('destroy', () => {
173
+ if (resizeObserver) resizeObserver.disconnect();
174
+ });
175
 
176
+ // Assets
177
  const assets = {
178
  model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
179
  orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
180
  galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
181
+ hdr: new pc.Asset('hdr', 'texture', {
182
+ url: "https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png"
183
+ }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false })
184
  };
185
 
186
+ // Progress bar
187
+ let lastProg = 0;
188
+ assets.model.on('load', () => { progressDialog.style.display = 'none'; });
189
+ assets.model.on('error', err => {
190
+ progressDialog.innerHTML = `<p style="color: red">Error loading model: ${err}</p>`;
191
+ });
192
+ const progCheck = setInterval(() => {
193
+ if (assets.model.resource) {
194
+ progressIndicator.value = 100;
195
+ clearInterval(progCheck);
196
+ progressDialog.style.display = 'none';
197
+ } else if (assets.model.loading) {
198
+ lastProg = Math.min(lastProg + 2, 90);
199
+ progressIndicator.value = lastProg;
200
+ }
201
+ }, 100);
202
+
203
+ // Asset loader
204
  const loader = new pc.AssetListLoader(Object.values(assets), app.assets);
205
+ loader.load(async () => {
206
  app.start();
207
+ app.scene.envAtlas = assets.hdr.resource;
208
 
209
+ // Model entity (PLY)
210
  modelEntity = new pc.Entity('model');
211
  modelEntity.addComponent('gsplat', { asset: assets.model });
212
  modelEntity.setLocalPosition(modelX, modelY, modelZ);
 
214
  modelEntity.setLocalScale(modelScale, modelScale, modelScale);
215
  app.root.addChild(modelEntity);
216
 
217
+ // Light
218
+ const dirLight = new pc.Entity('Cascaded Light');
219
+ dirLight.addComponent('light', {
220
+ type: 'directional',
221
+ color: pc.Color.WHITE,
222
+ shadowBias: 0.3,
223
+ normalOffsetBias: 0.2,
224
+ intensity: 1.0,
225
+ soft: true,
226
+ shadowResolution: 4096,
227
+ penumbraSize: 7,
228
+ penumbraFalloff: 1.5,
229
+ shadowSamples: 128,
230
+ shadowBlockerSamples: 16,
231
+ castShadows: true,
232
+ shadowType: pc.SHADOW_PCSS_32F,
233
+ shadowDistance: 1000
234
+ });
235
+ dirLight.setLocalEulerAngles(0, 0, 0);
236
+ app.root.addChild(dirLight);
237
+
238
+ // Gallery GLB (optional)
239
+ if (assets.galerie && assets.galerie.resource && assets.galerie.resource.instantiateRenderEntity) {
240
+ const galleryEntity = assets.galerie.resource.instantiateRenderEntity();
241
+ app.root.addChild(galleryEntity);
242
+ }
243
+
244
+ // Camera
245
  cameraEntity = new pc.Entity('camera');
246
+ cameraEntity.addComponent('camera', {
247
+ clearColor: config.canvas_background
248
+ ? new pc.Color(
249
+ parseInt(config.canvas_background.substr(1, 2), 16) / 255,
250
+ parseInt(config.canvas_background.substr(3, 2), 16) / 255,
251
+ parseInt(config.canvas_background.substr(5, 2), 16) / 255,
252
+ 1
253
+ )
254
+ : new pc.Color(0.2, 0.2, 0.2, 1)
255
+ });
256
  cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
257
  cameraEntity.lookAt(modelEntity.getPosition());
258
  cameraEntity.addComponent('script');
259
  cameraEntity.script.create('orbitCamera', {
260
  attributes: {
261
+ inertiaFactor: 0.2,
262
+ focusEntity: modelEntity,
263
+ distanceMax: maxZoom,
264
+ distanceMin: minZoom,
265
+ pitchAngleMax: maxAngle,
266
+ pitchAngleMin: minAngle,
267
+ yawAngleMax: maxAzimuth,
268
+ yawAngleMin: minAzimuth,
269
+ minPivotY: minPivotY,
270
+ minY: minY,
271
+ frameOnStart: false
272
+ }
273
+ });
274
+ cameraEntity.script.create('orbitCameraInputMouse', {
275
+ attributes: {
276
+ orbitSensitivity: isMobile ? 0.6 : 0.3,
277
+ distanceSensitivity: isMobile ? 0.5 : 0.4
278
+ }
279
+ });
280
+ cameraEntity.script.create('orbitCameraInputTouch', {
281
+ attributes: {
282
+ orbitSensitivity: 0.6,
283
+ distanceSensitivity: 0.5
284
  }
285
  });
 
 
286
  app.root.addChild(cameraEntity);
287
 
288
+ // On first update, reset camera (Script B)
289
+ app.once('update', () => { resetViewerCamera(); });
290
+
291
+ // Clamp Y so camera never goes below floor
292
+ app.on('update', dt => {
293
+ if (cameraEntity) {
294
+ const pos = cameraEntity.getPosition();
295
+ if (pos.y < minY) cameraEntity.setPosition(pos.x, minY, pos.z);
296
+ }
297
+ });
298
+
299
+ // Final resize
300
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
301
+
302
+ // Tooltips
303
+ if (config.tooltips_url) {
304
+ try {
305
+ const tooltipsModule = await import('./tooltips.js');
306
+ await tooltipsModule.initializeTooltips({
307
+ app,
308
+ cameraEntity,
309
+ modelEntity,
310
+ tooltipsUrl: config.tooltips_url,
311
+ defaultVisible: !!config.showTooltipsDefault,
312
+ moveDuration: config.tooltipMoveDuration || 0.6
313
+ });
314
+ } catch (e) { /* silent */ }
315
+ }
316
+
317
+ progressDialog.style.display = 'none';
318
  viewerInitialized = true;
319
  });
320
  }
321
 
322
+ // Reset camera as in Script B (accurate positioning!)
323
  export function resetViewerCamera() {
324
+ try {
325
+ if (!cameraEntity || !modelEntity || !app) return;
326
+ const orbitCam = cameraEntity.script.orbitCamera;
327
+ if (!orbitCam) return;
328
+
329
+ const modelPos = modelEntity.getPosition();
330
+ const tempEnt = new pc.Entity();
331
+ tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
332
+ tempEnt.lookAt(modelPos);
333
+
334
+ const dist = new pc.Vec3().sub2(
335
+ new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ),
336
+ modelPos
337
+ ).length();
338
+
339
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
340
+ cameraEntity.lookAt(modelPos);
341
+
342
+ orbitCam.pivotPoint = modelPos.clone();
343
+ orbitCam._targetDistance = dist;
344
+ orbitCam._distance = dist;
345
+
346
+ const rot = tempEnt.getRotation();
347
+ const fwd = new pc.Vec3();
348
+ rot.transformVector(pc.Vec3.FORWARD, fwd);
349
+
350
+ const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG;
351
+ const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
352
+ const rotNoYaw = new pc.Quat().mul2(yawQuat, rot);
353
+ const fNoYaw = new pc.Vec3();
354
+ rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
355
+ const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
356
+
357
+ orbitCam._targetYaw = yaw;
358
+ orbitCam._yaw = yaw;
359
+ orbitCam._targetPitch = pitch;
360
+ orbitCam._pitch = pitch;
361
+ if (orbitCam._updatePosition) orbitCam._updatePosition();
362
+
363
+ tempEnt.destroy();
364
+ } catch (e) {
365
+ // Silent fail
366
+ }
367
+ }
368
+
369
+ // Optional cleanup for hot-reload
370
+ export function cleanupViewer() {
371
+ if (app) {
372
+ try {
373
+ app.destroy();
374
+ } catch {}
375
+ app = null;
376
+ }
377
+ cameraEntity = null;
378
+ modelEntity = null;
379
+ viewerInitialized = false;
380
+ if (resizeObserver) {
381
+ resizeObserver.disconnect();
382
+ resizeObserver = null;
383
+ }
384
  }