MikaFil commited on
Commit
43cde96
·
verified ·
1 Parent(s): b42c09b

Update viewer.js

Browse files
Files changed (1) hide show
  1. viewer.js +317 -214
viewer.js CHANGED
@@ -1,12 +1,13 @@
1
  // viewer.js
2
  // ==============================
3
- // Complete patched viewer.js for Space B
4
  // ==============================
5
 
6
- let pc; // will hold the PlayCanvas module once imported
 
7
  export let app = null;
8
- let cameraEntity = null;
9
- let modelEntity = null;
10
  let viewerInitialized = false;
11
  let resizeObserver = null;
12
 
@@ -21,60 +22,63 @@ let plyUrl, glbUrl;
21
  export async function initializeViewer(config, instanceId) {
22
  if (viewerInitialized) return;
23
 
24
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 
25
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
26
 
27
- // 1. Read config -------------------------------------------------------------
28
  plyUrl = config.ply_url;
29
  glbUrl = config.glb_url;
30
- minZoom = parseFloat(config.minZoom || "1");
31
- maxZoom = parseFloat(config.maxZoom || "20");
32
- minAngle = parseFloat(config.minAngle || "-45");
33
- maxAngle = parseFloat(config.maxAngle || "90");
34
- minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
35
- maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
36
- minPivotY = parseFloat(config.minPivotY || "0");
37
- minY = config.minY !== undefined ? parseFloat(config.minY) : 0;
38
-
39
- modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0;
40
- modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0;
41
- modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0;
42
- modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1;
43
- modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0;
44
- modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
45
- modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
46
-
47
- const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
48
- const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2;
49
- const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5;
50
- const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX;
51
- const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY;
52
- const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5;
53
 
54
  chosenCameraX = isMobile ? cameraXPhone : cameraX;
55
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
56
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
57
 
58
- // 2. Grab DOM ---------------------------------------------------------------
59
- const canvasId = 'canvas-' + instanceId;
60
- const progressDialog = document.getElementById('progress-dialog-' + instanceId);
61
  const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
62
- const viewerContainer = document.getElementById('viewer-container-' + instanceId);
63
 
64
- // 3. Create <canvas> --------------------------------------------------------
65
  let oldCanvas = document.getElementById(canvasId);
66
  if (oldCanvas) oldCanvas.remove();
67
  const canvas = document.createElement('canvas');
68
  canvas.id = canvasId;
69
  canvas.className = 'ply-canvas';
70
  canvas.style.zIndex = "1";
 
 
 
 
71
  viewerContainer.insertBefore(canvas, progressDialog);
72
 
73
- // 4. Wheel listener ---------------------------------------------------------
74
  canvas.addEventListener('wheel', e => {
75
  if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
76
  const orbitCam = cameraEntity.script.orbitCamera;
77
- const sens = cameraEntity.script.orbitCameraInputMouse?.distanceSensitivity || 0.4;
78
  if (cameraEntity.camera.projection === pc.PROJECTION_PERSPECTIVE) {
79
  orbitCam.distance -= e.deltaY * 0.01 * sens * (orbitCam.distance * 0.1);
80
  } else {
@@ -87,214 +91,313 @@ 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
- // 6. Setup device & app ----------------------------------------------------
97
- // --- NEW: WebGL2 → WebGL1 fallback (Space A style) ---
98
- async function getGraphicsDevice(preferWebgl2 = true) {
99
- const baseOpts = {
100
- glslangUrl: config.glslangUrl || "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
101
- twgslUrl: config.twgslUrl || "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
102
- antialias: false
103
- };
104
- try {
105
- return await pc.createGraphicsDevice(canvas, {
106
- ...baseOpts,
107
- deviceTypes: preferWebgl2 ? ["webgl2"] : ["webgl1"]
108
- });
109
- } catch (err) {
110
- if (preferWebgl2) {
111
- console.warn("[viewer] WebGL2 unavailable, falling back to WebGL1…", err);
112
- return await getGraphicsDevice(false);
113
- }
114
- throw err;
115
- }
116
- }
117
-
118
- let device;
119
  try {
120
- device = await getGraphicsDevice(true);
121
- } catch (e) {
122
- console.error("Failed to create WebGL context:", e);
123
- progressDialog.innerHTML = `<p style="color:red">WebGL init failed: ${e.message}</p>`;
124
- return;
125
- }
126
- device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
127
-
128
- const opts = new pc.AppOptions();
129
- opts.graphicsDevice = device;
130
- opts.mouse = new pc.Mouse(canvas);
131
- opts.touch = new pc.TouchDevice(canvas);
132
- opts.componentSystems = [
133
- pc.RenderComponentSystem,
134
- pc.CameraComponentSystem,
135
- pc.LightComponentSystem,
136
- pc.ScriptComponentSystem,
137
- pc.GSplatComponentSystem,
138
- pc.CollisionComponentSystem,
139
- pc.RigidbodyComponentSystem
140
- ];
141
- opts.resourceHandlers = [
142
- pc.TextureHandler,
143
- pc.ContainerHandler,
144
- pc.ScriptHandler,
145
- pc.GSplatHandler
146
- ];
147
-
148
- app = new pc.Application(canvas, opts);
149
- app.setCanvasFillMode(pc.FILLMODE_NONE);
150
- app.setCanvasResolution(pc.RESOLUTION_AUTO);
151
-
152
- // Resize observer to keep canvas in sync
153
- resizeObserver = new ResizeObserver(entries => {
154
- for (const entry of entries) {
155
- const { width, height } = entry.contentRect;
156
- if (app) app.resizeCanvas(width, height);
157
- }
158
- });
159
- resizeObserver.observe(viewerContainer);
160
- window.addEventListener('resize', () => {
161
- if (app) app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
162
- });
163
-
164
- // 7. Assets ----------------------------------------------------------------
165
- const assets = {
166
- model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
167
- orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
168
- galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
169
- hdr: new pc.Asset('hdr', 'texture', { url: config.hdrUrl || "https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png" }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false })
170
- };
171
-
172
- const loader = new pc.AssetListLoader(Object.values(assets), app.assets);
173
- let lastProg = 0;
174
- assets.model.on('load', () => progressDialog.style.display = 'none');
175
- assets.model.on('error', err => {
176
- console.error("Error loading PLY file:", err);
177
- progressDialog.innerHTML = `<p style="color:red">Model load error: ${err}</p>`;
178
- });
179
-
180
- const progCheck = setInterval(() => {
181
- if (assets.model.resource) {
182
- progressIndicator.value = 100;
183
- clearInterval(progCheck);
184
- progressDialog.style.display = 'none';
185
- } else if (assets.model.loading) {
186
- lastProg = Math.min(lastProg + 2, 90);
187
- progressIndicator.value = lastProg;
188
  }
189
- }, 100);
190
 
191
- // 8. Start loading
192
- loader.load(() => {
193
- // 8a. Start the app
194
- app.start();
195
- app.scene.envAtlas = assets.hdr.resource;
 
 
 
 
 
 
 
196
 
197
- // 8b. **iOS-only** confirm first draw call
198
- if (isIOS) {
199
- app.once('update', () => alert("✅ First frame rendered on iOS"));
200
- }
201
 
202
- // 9. Scene ----------------------------------------------------------------
203
- modelEntity = new pc.Entity('model');
204
- modelEntity.addComponent('gsplat', { asset: assets.model });
205
- modelEntity.setLocalPosition(modelX, modelY, modelZ);
206
- modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
207
- modelEntity.setLocalScale(modelScale, modelScale, modelScale);
208
- app.root.addChild(modelEntity);
209
-
210
- // Light
211
- const dirLight = new pc.Entity('Cascaded Light');
212
- dirLight.addComponent('light', {
213
- type: 'directional',
214
- color: pc.Color.WHITE,
215
- shadowBias: 0.3,
216
- normalOffsetBias: 0.2,
217
- intensity: 1.0,
218
- soft: true,
219
- shadowResolution: 4096,
220
- penumbraSize: 7,
221
- penumbraFalloff: 1.5,
222
- shadowSamples: 128,
223
- shadowBlockerSamples: 16,
224
- castShadows: true,
225
- shadowType: pc.SHADOW_PCSS_32F,
226
- shadowDistance: 1000
227
  });
228
- dirLight.setLocalEulerAngles(0, 0, 0);
229
- app.root.addChild(dirLight);
230
-
231
- // GLB gallery
232
- const galleryEntity = assets.galerie.resource.instantiateRenderEntity();
233
- app.root.addChild(galleryEntity);
234
-
235
- // 10. Camera --------------------------------------------------------------
236
- cameraEntity = new pc.Entity('camera');
237
- cameraEntity.addComponent('camera', {
238
- clearColor: config.canvas_background
239
- ? parseInt(config.canvas_background.substr(1, 2), 16) / 255
240
- : 0
241
  });
242
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
243
- cameraEntity.lookAt(modelEntity.getPosition());
244
-
245
- cameraEntity.addComponent('script');
246
- cameraEntity.script.create('orbitCamera', {
247
- attributes: {
248
- inertiaFactor: 0.2,
249
- focusEntity: modelEntity,
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
- // 11. Clamp camera y every frame ----------------------------------------
275
- app.on('update', () => {
276
- const p = cameraEntity.getPosition();
277
- if (p.y < minY) cameraEntity.setPosition(p.x, minY, p.z);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  });
279
 
280
- viewerInitialized = true;
281
- });
 
282
  }
283
 
284
- // Optional: resetViewerCamera() and cleanupViewer() remain unchanged below…
285
-
 
286
  export function resetViewerCamera() {
287
- // … your existing reset logic …
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
289
 
290
  export function cleanupViewer() {
291
  if (app) {
292
- try { app.destroy(); } catch {}
 
 
293
  app = null;
294
  }
295
  cameraEntity = null;
296
  modelEntity = null;
297
  viewerInitialized = false;
 
298
  if (resizeObserver) {
299
  resizeObserver.disconnect();
300
  resizeObserver = null;
 
1
  // viewer.js
2
  // ==============================
3
+ // viewer.js
4
  // ==============================
5
 
6
+ // -- Module/global state --
7
+ let pc; // PlayCanvas module
8
  export let app = null;
9
+ let cameraEntity = null;
10
+ let modelEntity = null;
11
  let viewerInitialized = false;
12
  let resizeObserver = null;
13
 
 
22
  export async function initializeViewer(config, instanceId) {
23
  if (viewerInitialized) return;
24
 
25
+ // Device detection (match Space A)
26
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
27
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
28
 
29
+ // 1. Read config (match Space A variable names/behavior)
30
  plyUrl = config.ply_url;
31
  glbUrl = config.glb_url;
32
+ minZoom = parseFloat(config.minZoom || "1");
33
+ maxZoom = parseFloat(config.maxZoom || "20");
34
+ minAngle = parseFloat(config.minAngle || "-45");
35
+ maxAngle = parseFloat(config.maxAngle || "90");
36
+ minAzimuth = (config.minAzimuth !== undefined) ? parseFloat(config.minAzimuth) : -360;
37
+ maxAzimuth = (config.maxAzimuth !== undefined) ? parseFloat(config.maxAzimuth) : 360;
38
+ minPivotY = parseFloat(config.minPivotY || "0");
39
+ minY = (config.minY !== undefined) ? parseFloat(config.minY) : 0;
40
+ modelX = (config.modelX !== undefined) ? parseFloat(config.modelX) : 0;
41
+ modelY = (config.modelY !== undefined) ? parseFloat(config.modelY) : 0;
42
+ modelZ = (config.modelZ !== undefined) ? parseFloat(config.modelZ) : 0;
43
+ modelScale = (config.modelScale !== undefined) ? parseFloat(config.modelScale) : 1;
44
+ modelRotationX = (config.modelRotationX !== undefined) ? parseFloat(config.modelRotationX) : 0;
45
+ modelRotationY = (config.modelRotationY !== undefined) ? parseFloat(config.modelRotationY) : 0;
46
+ modelRotationZ = (config.modelRotationZ !== undefined) ? parseFloat(config.modelRotationZ) : 0;
47
+ const cameraX = (config.cameraX !== undefined) ? parseFloat(config.cameraX) : 0;
48
+ const cameraY = (config.cameraY !== undefined) ? parseFloat(config.cameraY) : 2;
49
+ const cameraZ = (config.cameraZ !== undefined) ? parseFloat(config.cameraZ) : 5;
50
+ const cameraXPhone = (config.cameraXPhone !== undefined) ? parseFloat(config.cameraXPhone) : cameraX;
51
+ const cameraYPhone = (config.cameraYPhone !== undefined) ? parseFloat(config.cameraYPhone) : cameraY;
52
+ const cameraZPhone = (config.cameraZPhone !== undefined) ? parseFloat(config.cameraZPhone) : (cameraZ * 1.5);
 
 
53
 
54
  chosenCameraX = isMobile ? cameraXPhone : cameraX;
55
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
56
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
57
 
58
+ // 2. Grab DOM
59
+ const canvasId = 'canvas-' + instanceId;
60
+ const progressDialog = document.getElementById('progress-dialog-' + instanceId);
61
  const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
62
+ const viewerContainer = document.getElementById('viewer-container-' + instanceId);
63
 
64
+ // 3. Create <canvas>
65
  let oldCanvas = document.getElementById(canvasId);
66
  if (oldCanvas) oldCanvas.remove();
67
  const canvas = document.createElement('canvas');
68
  canvas.id = canvasId;
69
  canvas.className = 'ply-canvas';
70
  canvas.style.zIndex = "1";
71
+ // --- Space A style: block, 100% size for iOS layout bugs ---
72
+ canvas.style.display = "block";
73
+ canvas.style.width = "100%";
74
+ canvas.style.height = "100%";
75
  viewerContainer.insertBefore(canvas, progressDialog);
76
 
77
+ // 4. Wheel listener
78
  canvas.addEventListener('wheel', e => {
79
  if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
80
  const orbitCam = cameraEntity.script.orbitCamera;
81
+ const sens = (cameraEntity.script.orbitCameraInputMouse?.distanceSensitivity) || 0.4;
82
  if (cameraEntity.camera.projection === pc.PROJECTION_PERSPECTIVE) {
83
  orbitCam.distance -= e.deltaY * 0.01 * sens * (orbitCam.distance * 0.1);
84
  } else {
 
91
 
92
  progressDialog.style.display = 'block';
93
 
94
+ // 5. Import PlayCanvas
95
  if (!pc) {
96
  pc = await import("https://esm.run/playcanvas");
97
  window.pc = pc;
98
  }
99
 
100
+ // -- Defer creation of app until all assets are loaded (Space A pattern) --
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  try {
102
+ // 6. Robust WebGL2 → WebGL1 fallback (for iPhone)
103
+ async function getGraphicsDevice(preferWebgl2 = true) {
104
+ const baseOpts = {
105
+ glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
106
+ twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
107
+ antialias: false
108
+ };
109
+ try {
110
+ return await pc.createGraphicsDevice(canvas, {
111
+ ...baseOpts,
112
+ deviceTypes: preferWebgl2 ? ["webgl2"] : ["webgl1"]
113
+ });
114
+ } catch (err) {
115
+ if (preferWebgl2) {
116
+ return getGraphicsDevice(false);
117
+ }
118
+ throw err;
119
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  }
 
121
 
122
+ const device = await getGraphicsDevice(true);
123
+ device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
124
+
125
+ // -- Asset declaration: match structure/order to Space A --
126
+ const assets = {
127
+ model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
128
+ orbit: new pc.Asset('script', 'script', { url: "https://mikafil-viewer-gs.static.hf.space/orbit-camera.js" }),
129
+ galerie: new pc.Asset('galerie', 'container', { url: glbUrl }),
130
+ hdr: new pc.Asset('hdr', 'texture', {
131
+ url: `https://huggingface.co/datasets/bilca/ply_files/resolve/main/galeries/blanc.png`
132
+ }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false })
133
+ };
134
 
135
+ const loader = new pc.AssetListLoader(Object.values(assets), pc.app && pc.app.assets ? pc.app.assets : undefined);
136
+ let lastProg = 0;
 
 
137
 
138
+ assets.model.on('load', () => {
139
+ progressDialog.style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  });
141
+ assets.model.on('error', err => {
142
+ progressDialog.innerHTML = `<p style="color: red">Error loading model: ${err}</p>`;
 
 
 
 
 
 
 
 
 
 
 
143
  });
144
+
145
+ const progCheck = setInterval(() => {
146
+ if (assets.model.resource) {
147
+ progressIndicator.value = 100;
148
+ clearInterval(progCheck);
149
+ progressDialog.style.display = 'none';
150
+ } else if (assets.model.loading) {
151
+ lastProg = Math.min(lastProg + 2, 90);
152
+ progressIndicator.value = lastProg;
 
 
 
 
 
 
 
153
  }
154
+ }, 100);
155
+
156
+ // -- Load all assets, then initialize the App (Space A sequence) --
157
+ loader.load(async () => {
158
+ // -- AppOptions: match Space A (do not add extra physics/collision if not used) --
159
+ const opts = new pc.AppOptions();
160
+ opts.graphicsDevice = device;
161
+ opts.mouse = new pc.Mouse(canvas);
162
+ opts.touch = new pc.TouchDevice(canvas);
163
+ opts.componentSystems = [
164
+ pc.RenderComponentSystem,
165
+ pc.CameraComponentSystem,
166
+ pc.LightComponentSystem,
167
+ pc.ScriptComponentSystem,
168
+ pc.GSplatComponentSystem
169
+ ];
170
+ opts.resourceHandlers = [
171
+ pc.TextureHandler,
172
+ pc.ContainerHandler,
173
+ pc.ScriptHandler,
174
+ pc.GSplatHandler
175
+ ];
176
+
177
+ // -- Actually create and start the App --
178
+ app = new pc.Application(canvas, opts);
179
+ app.setCanvasFillMode(pc.FILLMODE_NONE);
180
+ app.setCanvasResolution(pc.RESOLUTION_AUTO);
181
+ app.scene.exposure = 0.5;
182
+ app.scene.toneMapping = pc.TONEMAP_ACES;
183
+
184
+ // -- Resize observer (Space A logic) --
185
+ resizeObserver = new ResizeObserver(entries => {
186
+ for (const entry of entries) {
187
+ const { width, height } = entry.contentRect;
188
+ if (app) {
189
+ app.resizeCanvas(width, height);
190
+ }
191
+ }
192
+ });
193
+ resizeObserver.observe(viewerContainer);
194
+
195
+ window.addEventListener('resize', () => {
196
+ if (app) app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
197
+ });
198
+ app.on('destroy', () => {
199
+ window.removeEventListener('resize', resizeCanvas);
200
+ });
201
+
202
+ // -- Load HDR environment --
203
+ app.scene.envAtlas = assets.hdr.resource;
204
+
205
+ // -- Build the scene graph --
206
+ modelEntity = new pc.Entity('model');
207
+ modelEntity.addComponent('gsplat', { asset: assets.model });
208
+ modelEntity.setLocalPosition(modelX, modelY, modelZ);
209
+ modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
210
+ modelEntity.setLocalScale(modelScale, modelScale, modelScale);
211
+ app.root.addChild(modelEntity);
212
+
213
+ // Directional light (same as Space A)
214
+ const dirLight = new pc.Entity('Cascaded Light');
215
+ dirLight.addComponent('light', {
216
+ type: 'directional',
217
+ color: pc.Color.WHITE,
218
+ shadowBias: 0.3,
219
+ normalOffsetBias: 0.2,
220
+ intensity: 1.0,
221
+ soft: true,
222
+ shadowResolution: 4096,
223
+ penumbraSize: 7,
224
+ penumbraFalloff: 1.5,
225
+ shadowSamples: 128,
226
+ shadowBlockerSamples: 16,
227
+ castShadows: true,
228
+ shadowType: pc.SHADOW_PCSS_32F,
229
+ shadowDistance: 1000
230
+ });
231
+ dirLight.setLocalEulerAngles(0, 0, 0);
232
+ app.root.addChild(dirLight);
233
+
234
+ // Gallery container (GLB)
235
+ if (assets.galerie && assets.galerie.resource && assets.galerie.resource.instantiateRenderEntity) {
236
+ const galleryEntity = assets.galerie.resource.instantiateRenderEntity();
237
+ app.root.addChild(galleryEntity);
238
  }
239
+
240
+ // Camera entity (Space A logic, with true color init)
241
+ cameraEntity = new pc.Entity('camera');
242
+ // Color: set to black by default unless config.canvas_background is hex
243
+ let clearColor = new pc.Color(0, 0, 0);
244
+ if (config.canvas_background && config.canvas_background[0] === "#") {
245
+ // #RRGGBB
246
+ const hex = config.canvas_background;
247
+ clearColor = new pc.Color(
248
+ parseInt(hex.substr(1, 2), 16) / 255,
249
+ parseInt(hex.substr(3, 2), 16) / 255,
250
+ parseInt(hex.substr(5, 2), 16) / 255
251
+ );
252
+ }
253
+ cameraEntity.addComponent('camera', {
254
+ clearColor: clearColor,
255
+ toneMapping: pc.TONEMAP_ACES
256
+ });
257
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
258
+ cameraEntity.lookAt(modelEntity.getPosition());
259
+
260
+ cameraEntity.addComponent('script');
261
+ cameraEntity.script.create('orbitCamera', {
262
+ attributes: {
263
+ inertiaFactor: 0.2,
264
+ focusEntity: modelEntity,
265
+ distanceMax: maxZoom,
266
+ distanceMin: minZoom,
267
+ pitchAngleMax: maxAngle,
268
+ pitchAngleMin: minAngle,
269
+ yawAngleMax: maxAzimuth,
270
+ yawAngleMin: minAzimuth,
271
+ minPivotY: minPivotY,
272
+ frameOnStart: false
273
+ }
274
+ });
275
+ cameraEntity.script.create('orbitCameraInputMouse', {
276
+ attributes: {
277
+ orbitSensitivity: isMobile ? 0.6 : 0.3,
278
+ distanceSensitivity: isMobile ? 0.5 : 0.4
279
+ }
280
+ });
281
+ if (cameraEntity.script.orbitCameraInputMouse) {
282
+ cameraEntity.script.orbitCameraInputMouse.onMouseWheel = function() {};
283
+ }
284
+ cameraEntity.script.create('orbitCameraInputTouch', {
285
+ attributes: {
286
+ orbitSensitivity: 0.6,
287
+ distanceSensitivity: 0.5
288
+ }
289
+ });
290
+ app.root.addChild(cameraEntity);
291
+
292
+ // -- Per-frame update guard for minY (same as Space A) --
293
+ app.on('update', dt => {
294
+ if (cameraEntity) {
295
+ const pos = cameraEntity.getPosition();
296
+ if (pos.y < minY) {
297
+ cameraEntity.setPosition(pos.x, minY, pos.z);
298
+ }
299
+ }
300
+ });
301
+
302
+ // -- Camera reset on first frame --
303
+ app.once('update', () => resetViewerCamera());
304
+
305
+ // -- Insert iOS-only first-draw debug popup (one-shot) --
306
+ if (isIOS) {
307
+ let iosAlertShown = false;
308
+ app.on('frameend', () => {
309
+ if (!iosAlertShown) {
310
+ iosAlertShown = true;
311
+ alert('First draw call reached (iOS)');
312
+ }
313
+ });
314
  }
 
 
315
 
316
+ // -- Start PlayCanvas app last --
317
+ app.start();
318
+
319
+ // -- Tooltips support, try/catch required (unchanged) --
320
+ try {
321
+ const tooltipsModule = await import('./tooltips.js');
322
+ tooltipsModule.initializeTooltips({
323
+ app,
324
+ cameraEntity,
325
+ modelEntity,
326
+ tooltipsUrl: config.tooltips_url,
327
+ defaultVisible: !!config.showTooltipsDefault,
328
+ moveDuration: config.tooltipMoveDuration || 0.6
329
+ });
330
+ } catch (e) {
331
+ // Silent fail: tooltips are optional
332
+ }
333
+
334
+ progressDialog.style.display = 'none';
335
+ viewerInitialized = true;
336
  });
337
 
338
+ } catch (error) {
339
+ progressDialog.innerHTML = `<p style="color: red">Error loading viewer: ${error.message}</p>`;
340
+ }
341
  }
342
 
343
+ // -----------------------------------------------------------------------------
344
+ // resetViewerCamera / cleanupViewer remain UNCHANGED
345
+ // -----------------------------------------------------------------------------
346
  export function resetViewerCamera() {
347
+ try {
348
+ if (!cameraEntity || !modelEntity || !app) return;
349
+ const orbitCam = cameraEntity.script.orbitCamera;
350
+ if (!orbitCam) return;
351
+
352
+ const modelPos = modelEntity.getPosition();
353
+ const tempEnt = new pc.Entity();
354
+ tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
355
+ tempEnt.lookAt(modelPos);
356
+
357
+ const dist = new pc.Vec3().sub2(
358
+ new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ),
359
+ modelPos
360
+ ).length();
361
+
362
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
363
+ cameraEntity.lookAt(modelPos);
364
+
365
+ orbitCam.pivotPoint = modelPos.clone();
366
+ orbitCam._targetDistance = dist;
367
+ orbitCam._distance = dist;
368
+
369
+ const rot = tempEnt.getRotation();
370
+ const fwd = new pc.Vec3();
371
+ rot.transformVector(pc.Vec3.FORWARD, fwd);
372
+
373
+ const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG;
374
+ const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
375
+ const rotNoYaw = new pc.Quat().mul2(yawQuat, rot);
376
+ const fNoYaw = new pc.Vec3();
377
+ rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
378
+ const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
379
+
380
+ orbitCam._targetYaw = yaw;
381
+ orbitCam._yaw = yaw;
382
+ orbitCam._targetPitch = pitch;
383
+ orbitCam._pitch = pitch;
384
+ orbitCam._updatePosition && orbitCam._updatePosition();
385
+
386
+ tempEnt.destroy();
387
+ } catch (e) {}
388
  }
389
 
390
  export function cleanupViewer() {
391
  if (app) {
392
+ try {
393
+ app.destroy();
394
+ } catch {}
395
  app = null;
396
  }
397
  cameraEntity = null;
398
  modelEntity = null;
399
  viewerInitialized = false;
400
+
401
  if (resizeObserver) {
402
  resizeObserver.disconnect();
403
  resizeObserver = null;