MikaFil commited on
Commit
bbcd39a
·
verified ·
1 Parent(s): 5b85944

Update deplacement_dans_env/viewer_pr_env.js

Browse files
Files changed (1) hide show
  1. deplacement_dans_env/viewer_pr_env.js +523 -1013
deplacement_dans_env/viewer_pr_env.js CHANGED
@@ -1,69 +1,69 @@
1
  // viewer_pr_env.js
2
  // ==============================
3
- // Version intégrant le contrôleur caméra en local (pas de <script src=...>)
4
- // + Fix "sortie du monde" : clamp dans la worldAabb et pas de culling de boîtes quasi-globales
5
- // + Logs détaillés
 
 
 
6
 
7
  /* -------------------------------------------
8
- Utils communs
9
  -------------------------------------------- */
10
 
11
- // (Conservé pour compat, même si .sog n'en a pas besoin)
12
  async function loadImageAsTexture(url, app) {
13
- return new Promise((resolve, reject) => {
14
- const img = new window.Image();
15
- img.crossOrigin = "anonymous";
16
- img.onload = function () {
17
- const tex = new pc.Texture(app.graphicsDevice, {
18
- width: img.width,
19
- height: img.height,
20
- format: pc.PIXELFORMAT_R8_G8_B8_A8
21
- });
22
- tex.setSource(img);
23
- resolve(tex);
24
- };
25
- img.onerror = reject;
26
- img.src = url;
27
- });
28
  }
29
 
30
- // Patch global Image -> force CORS
31
  (function () {
32
- const OriginalImage = window.Image;
33
- window.Image = function (...args) {
34
- const img = new OriginalImage(...args);
35
- img.crossOrigin = "anonymous";
36
- return img;
37
- };
38
  })();
39
 
40
  function hexToRgbaArray(hex) {
41
- try {
42
- hex = String(hex || "").replace("#", "");
43
- if (hex.length === 6) hex += "FF";
44
- if (hex.length !== 8) return [1, 1, 1, 1];
45
- const num = parseInt(hex, 16);
46
- return [
47
- ((num >> 24) & 0xff) / 255,
48
- ((num >> 16) & 0xff) / 255,
49
- ((num >> 8) & 0xff) / 255,
50
- (num & 0xff) / 255
51
- ];
52
- } catch (e) {
53
- console.warn("hexToRgbaArray error:", e);
54
- return [1, 1, 1, 1];
55
- }
56
  }
57
 
58
- // Parcours récursif d'une hiérarchie d'entités
59
  function traverse(entity, callback) {
60
- callback(entity);
61
- if (entity.children) {
62
- entity.children.forEach((child) => traverse(child, callback));
63
- }
64
  }
65
 
66
- // Helpers math pour le script caméra
67
  function vAdd(a,b){ return new pc.Vec3(a.x+b.x,a.y+b.y,a.z+b.z); }
68
  function vSub(a,b){ return new pc.Vec3(a.x-b.x,a.y-b.y,a.z-b.z); }
69
  function vScale(v,s){ return new pc.Vec3(v.x*s,v.y*s,v.z*s); }
@@ -72,999 +72,509 @@ function vDot(a,b){ return a.x*b.x+a.y*b.y+a.z*b.z; }
72
  function clamp(v,a,b){ return Math.max(a, Math.min(b,v)); }
73
 
74
  /* -------------------------------------------
75
- Scripts caméra intégrés (ex-ctrl_camera_pr_env.js)
76
- — collisions capsule vs AABBs + cage monde
77
  -------------------------------------------- */
78
- function registerFreeCamScripts() {
79
- if (window.__PLY_FREECAM_REG__) return;
80
- window.__PLY_FREECAM_REG__ = true;
81
-
82
- // ctrl_camera_pr_env.js corrigé
83
-
84
- var FreeCamera = pc.createScript('orbitCamera'); // garder le nom public pour compat viewer
85
-
86
- // ======================== Attributs ===========================
87
- // Look
88
- FreeCamera.attributes.add('inertiaFactor', { type: 'number', default: 0.10, title: 'Inertia (rotation)' });
89
- FreeCamera.attributes.add('pitchAngleMin', { type: 'number', default: -89, title: 'Pitch Min (deg)' });
90
- FreeCamera.attributes.add('pitchAngleMax', { type: 'number', default: 89, title: 'Pitch Max (deg)' });
91
-
92
- // Sol mini (filet de sécurité)
93
- FreeCamera.attributes.add('minY', { type: 'number', default: -10, title: 'Minimum camera Y' });
94
-
95
- // Vitesse (m/s)
96
- FreeCamera.attributes.add('moveSpeed', { type: 'number', default: 2.0, title: 'Move Speed' });
97
- FreeCamera.attributes.add('strafeSpeed', { type: 'number', default: 2.0, title: 'Strafe Speed' });
98
- FreeCamera.attributes.add('dollySpeed', { type: 'number', default: 2.0, title: 'Mouse/Pinch Dolly Speed' });
99
-
100
- // Capsule caméra
101
- FreeCamera.attributes.add('capsuleRadius', { type: 'number', default: 0.30, title: 'Capsule Radius (m)' });
102
- FreeCamera.attributes.add('capsuleHeight', { type: 'number', default: 1.60, title: 'Capsule Height (m) — yeux à ~0.9m au-dessus du centre' });
103
- FreeCamera.attributes.add('collisionEps', { type: 'number', default: 0.0005, title: 'Collision Epsilon' });
104
-
105
- // Mouvement "swept"
106
- FreeCamera.attributes.add('maxStepDistance', { type: 'number', default: 0.10, title: 'Max step distance (swept move)' }); // réduit (anti-tunnel)
107
- FreeCamera.attributes.add('maxResolveIters', { type: 'number', default: 8, title: 'Max resolve iterations per step' });
108
-
109
- // Escaliers
110
- FreeCamera.attributes.add('stepHeight', { type: 'number', default: 0.35, title: 'Max step-up height (m)' });
111
- FreeCamera.attributes.add('stepAhead', { type: 'number', default: 0.20, title: 'Probe distance ahead for step (m)' });
112
- FreeCamera.attributes.add('snapDownMax', { type: 'number', default: 0.60, title: 'Max snap-down (m)' });
113
- FreeCamera.attributes.add('enableGroundSnap', { type: 'boolean', default: true, title: 'Enable ground snap' });
114
-
115
- // AABBs (construction "indoor-safe")
116
- FreeCamera.attributes.add('inflateBias', { type: 'number', default: 0.0, title: 'Extra inflate AABB (m)' });
117
- FreeCamera.attributes.add('mergeGap', { type: 'number', default: 0.0, title: 'Merge AABBs gap (0: chevauchement réel seulement)' });
118
- FreeCamera.attributes.add('globalCullFrac',{ type: 'number', default: 0.0, title: 'Cull near-global AABBs (désactivé)' }); // désactivé
119
-
120
- // BBox globale optionnelle (filet additionnel)
121
- FreeCamera.attributes.add('Xmin', { type: 'number', default: -Infinity, title: 'BBox Xmin' });
122
- FreeCamera.attributes.add('Xmax', { type: 'number', default: Infinity, title: 'BBox Xmax' });
123
- FreeCamera.attributes.add('Ymin', { type: 'number', default: -Infinity, title: 'BBox Ymin' });
124
- FreeCamera.attributes.add('Ymax', { type: 'number', default: Infinity, title: 'BBox Ymax' });
125
- FreeCamera.attributes.add('Zmin', { type: 'number', default: -Infinity, title: 'BBox Zmin' });
126
- FreeCamera.attributes.add('Zmax', { type: 'number', default: Infinity, title: 'BBox Zmax' });
127
-
128
- // Compat (pour le viewer)
129
- FreeCamera.attributes.add('focusEntity', { type: 'entity', title: 'Collision Root (ENV GLB)' });
130
- FreeCamera.attributes.add('frameOnStart', { type: 'boolean', default: false, title: 'Compat: Frame on Start (unused)' });
131
- FreeCamera.attributes.add('yawAngleMin', { type: 'number', default: -360, title: 'Compat: Yaw Min (unused)' });
132
- FreeCamera.attributes.add('yawAngleMax', { type: 'number', default: 360, title: 'Compat: Yaw Max (unused)' });
133
- FreeCamera.attributes.add('distanceMin', { type: 'number', default: 0.1, title: 'Compat: Distance Min (unused)' });
134
-
135
- // ======================== Capsule util ===========================
136
- function capsuleSegment(p, height, radius) {
137
- var seg = Math.max(0, height - 2*radius);
138
- var half = seg * 0.5;
139
- return { a: new pc.Vec3(p.x, p.y - half, p.z), b: new pc.Vec3(p.x, p.y + half, p.z), len: seg };
140
- }
141
-
142
- function capsuleVsAabbPenetration(center, height, radius, aabb, outPush) {
143
- var seg = capsuleSegment(center, height, radius);
144
- var pts = [seg.a, seg.b, new pc.Vec3((seg.a.x+seg.b.x)/2,(seg.a.y+seg.b.y)/2,(seg.a.z+seg.b.z)/2)];
145
-
146
- var amin = aabb.getMin();
147
- var amax = aabb.getMax();
148
- var eps = 1e-9;
149
-
150
- var bestPen = 0;
151
- var bestPush = null;
152
-
153
- for (var i=0;i<pts.length;i++){
154
- var p = pts[i];
155
- var cx = clamp(p.x, amin.x, amax.x);
156
- var cy = clamp(p.y, amin.y, amax.y);
157
- var cz = clamp(p.z, amin.z, amax.z);
158
-
159
- var dx = p.x - cx, dy = p.y - cy, dz = p.z - cz;
160
- var d2 = dx*dx + dy*dy + dz*dz;
161
- var d = Math.sqrt(Math.max(d2, eps));
162
- var pen = radius - d;
163
-
164
- if (pen > bestPen) {
165
- if (d > 1e-6) {
166
- bestPush = new pc.Vec3(dx/d * pen, dy/d * pen, dz/d * pen);
167
- } else {
168
- var ex = Math.min(Math.abs(p.x - amin.x), Math.abs(amax.x - p.x));
169
- var ey = Math.min(Math.abs(p.y - amin.y), Math.abs(amax.y - p.y));
170
- var ez = Math.min(Math.abs(p.z - amin.z), Math.abs(amax.z - p.z));
171
- if (ex <= ey && ex <= ez) bestPush = new pc.Vec3((Math.abs(p.x - amin.x) < Math.abs(amax.x - p.x) ? -1:1)*pen,0,0);
172
- else if (ey <= ex && ey <= ez) bestPush = new pc.Vec3(0,(Math.abs(p.y - amin.y) < Math.abs(amax.y - p.y) ? -1:1)*pen,0);
173
- else bestPush = new pc.Vec3(0,0,(Math.abs(p.z - amin.z) < Math.abs(amax.z - p.z) ? -1:1)*pen);
174
- }
175
- bestPen = pen;
176
- }
177
- }
178
-
179
- if (bestPen > 0 && outPush) outPush.copy(bestPush);
180
- return bestPen;
181
- }
182
-
183
- // ======================== Getters pitch/yaw ===========================
184
- Object.defineProperty(FreeCamera.prototype, 'pitch', {
185
- get: function () { return this._targetPitch; },
186
- set: function (v) { this._targetPitch = pc.math.clamp(v, this.pitchAngleMin, this.pitchAngleMax); }
187
- });
188
- Object.defineProperty(FreeCamera.prototype, 'yaw', {
189
- get: function () { return this._targetYaw; },
190
- set: function (v) { this._targetYaw = v; }
191
- });
192
-
193
- // ======================== Init ===========================
194
- FreeCamera.prototype.initialize = function () {
195
- var q = this.entity.getRotation();
196
- var f = new pc.Vec3(); q.transformVector(pc.Vec3.FORWARD, f);
197
-
198
- this._yaw = Math.atan2(f.x, f.z) * pc.math.RAD_TO_DEG;
199
- var yawQuat = new pc.Quat().setFromEulerAngles(0, -this._yaw, 0);
200
- var noYawQ = new pc.Quat().mul2(yawQuat, q);
201
- var fNoYaw = new pc.Vec3(); noYawQ.transformVector(pc.Vec3.FORWARD, fNoYaw);
202
- this._pitch = pc.math.clamp(Math.atan2(-fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG, this.pitchAngleMin, this.pitchAngleMax);
203
-
204
- this._targetYaw = this._yaw;
205
- this._targetPitch = this._pitch;
206
- this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0);
207
-
208
- this.app.systems.script.app.freeCamState = this.app.systems.script.app.freeCamState || {};
209
- this.state = this.app.systems.script.app.freeCamState;
210
-
211
- // Colliders + cage monde
212
- this._buildAabbsFromFocus();
213
-
214
- var p0 = this.entity.getPosition().clone();
215
- var p1 = this._resolveCapsuleCollisions(p0, this.maxResolveIters);
216
- if (vLen(vSub(p1,p0)) > this.capsuleRadius * 0.25) {
217
- var dir = vSub(p1,p0); var L = vLen(dir)||1;
218
- p1 = vAdd(p0, vScale(dir, (this.capsuleRadius*0.25)/L));
219
- }
220
- this._clampBBoxMinY(p1);
221
- p1 = this._clampInsideWorld(p1); // <<< clamp dans la cage
222
- this.entity.setPosition(p1);
223
-
224
- var self = this;
225
- this._onResize = function(){ self._checkAspectRatio(); };
226
- window.addEventListener('resize', this._onResize, false);
227
- this._checkAspectRatio();
228
-
229
- console.log('[FREE-CAM:init] pos=', p1);
230
- };
231
-
232
- FreeCamera.prototype.update = function (dt) {
233
- var t = this.inertiaFactor === 0 ? 1 : Math.min(dt / this.inertiaFactor, 1);
234
- this._yaw = pc.math.lerp(this._yaw, this._targetYaw, t);
235
- this._pitch = pc.math.lerp(this._pitch, this._targetPitch, t);
236
- this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0);
237
-
238
- var pos = this.entity.getPosition().clone();
239
- pos = this._resolveCapsuleCollisions(pos, this.maxResolveIters);
240
- this._clampBBoxMinY(pos);
241
- pos = this._clampInsideWorld(pos); // <<< clamp dans la cage à chaque frame
242
- this.entity.setPosition(pos);
243
- };
244
-
245
- FreeCamera.prototype._checkAspectRatio = function () {
246
- var gd = this.app.graphicsDevice;
247
- if (!gd) return;
248
- this.entity.camera.horizontalFov = (gd.height > gd.width);
249
- };
250
-
251
- // ======================== Build colliders ===========================
252
- FreeCamera.prototype._buildAabbsFromFocus = function () {
253
- this._colliders = [];
254
- this._worldAabb = null;
255
- this._useCollision = false;
256
- this._containMin = null;
257
- this._containMax = null;
258
-
259
- var root = this.focusEntity;
260
- if (!root) {
261
- console.warn('[FREE-CAM] focusEntity manquant — pas de collisions.');
262
- return;
263
- }
264
-
265
- // Collecte des AABBs de mesh
266
- var boxes = [];
267
- var stack = [root];
268
- while (stack.length) {
269
- var e = stack.pop();
270
- var rc = e.render;
271
- if (rc && rc.meshInstances && rc.meshInstances.length) {
272
- for (var i=0;i<rc.meshInstances.length;i++){
273
- var bb = rc.meshInstances[i].aabb;
274
- boxes.push(new pc.BoundingBox(bb.center.clone(), bb.halfExtents.clone()));
275
- }
276
- }
277
- var ch = e.children;
278
- if (ch && ch.length) for (var j=0;j<ch.length;j++) stack.push(ch[j]);
279
- }
280
- if (boxes.length === 0) {
281
- console.warn('[FREE-CAM] Aucun meshInstance sous focusEntity — pas de collisions.');
282
- return;
283
- }
284
-
285
- // Monde
286
- var world = boxes[0].clone();
287
- for (var k=1;k<boxes.length;k++) world.add(boxes[k]);
288
-
289
- // Désactiver tout culling des quasi-globales : on garde tout
290
- var filtered = boxes;
291
-
292
- // Merge strict
293
- var merged = filtered;
294
-
295
- // Petit gonflage optionnel
296
- var inflate = Math.max(0, this.inflateBias||0);
297
- for (var n=0;n<merged.length;n++){
298
- merged[n].halfExtents.add(new pc.Vec3(inflate, inflate, inflate));
299
- }
300
-
301
- // Enregistrer
302
- if (merged.length>0) {
303
- for (var t=0;t<merged.length;t++) this._colliders.push({ aabb: merged[t] });
304
- this._worldAabb = world;
305
- this._useCollision = true;
306
-
307
- // Cage monde : world min/max moins le rayon pour éviter le clipping
308
- var R = Math.max(0, this.capsuleRadius || 0.3);
309
- var wmin = world.getMin().clone().add(new pc.Vec3(R, R, R));
310
- var wmax = world.getMax().clone().sub(new pc.Vec3(R, R, R));
311
- this._containMin = wmin;
312
- this._containMax = wmax;
313
- }
314
-
315
- console.log('[FREE-CAM] colliders=', this._colliders.length, '(raw=', boxes.length, ')');
316
- if (this._worldAabb) {
317
- console.log('[FREE-CAM] worldAabb min=', this._worldAabb.getMin(), 'max=', this._worldAabb.getMax());
318
- console.log('[FREE-CAM] contain box min=', this._containMin, 'max=', this._containMax);
319
- }
320
- };
321
-
322
- FreeCamera.prototype._mergeAabbs = function (boxes, gap) {
323
- if (!boxes || boxes.length<=1) return boxes.slice();
324
- var out = boxes.slice();
325
- var tol = Math.max(0, gap||0);
326
- var changed = true;
327
-
328
- function overlap(a,b,t){
329
- var amin=a.getMin(), amax=a.getMax();
330
- var bmin=b.getMin(), bmax=b.getMax();
331
- return !(
332
- amax.x < bmin.x - t || amin.x > bmax.x + t ||
333
- amax.y < bmin.y - t || amin.y > bmax.y + t ||
334
- amax.z < bmin.z - t || amin.z > bmax.z + t
335
- );
336
- }
337
-
338
- while (changed){
339
- changed=false;
340
- var next=[], used=new Array(out.length).fill(false);
341
- for (var i=0;i<out.length;i++){
342
- if (used[i]) continue;
343
- var acc = out[i];
344
- for (var j=i+1;j<out.length;j++){
345
- if (used[j]) continue;
346
- if (overlap(acc,out[j],tol)){
347
- var aMin=acc.getMin(), aMax=acc.getMax();
348
- var bMin=out[j].getMin(), bMax=out[j].getMax();
349
- var nMin=new pc.Vec3(Math.min(aMin.x,bMin.x), Math.min(aMin.y,bMin.y), Math.min(aMin.z,bMin.z));
350
- var nMax=new pc.Vec3(Math.max(aMax.x,bMax.x), Math.max(aMax.y,bMax.y), Math.max(aMax.z,bMax.z));
351
- var c=nMin.clone().add(nMax).mulScalar(0.5);
352
- var h=new pc.Vec3(Math.abs(nMax.x-c.x), Math.abs(nMax.y-c.y), Math.abs(nMax.z-c.z));
353
- acc = new pc.BoundingBox(c,h);
354
- used[j]=true; changed=true;
355
- }
356
- }
357
- used[i]=true; next.push(acc);
358
- }
359
- out=next;
360
- }
361
- return out;
362
- };
363
-
364
- // ======================== BBox globale + minY ===========================
365
- FreeCamera.prototype._bboxEnabled = function () {
366
- return (this.Xmin < this.Xmax) && (this.Ymin < this.Ymax) && (this.Zmin < this.Zmax);
367
- };
368
- FreeCamera.prototype._clampBBoxMinY = function (p) {
369
- if (p.y < this.minY) p.y = this.minY;
370
- if (!this._bboxEnabled()) return;
371
- p.x = clamp(p.x, this.Xmin, this.Xmax);
372
- p.y = clamp(p.y, Math.max(this.Ymin, this.minY), this.Ymax);
373
- p.z = clamp(p.z, this.Zmin, this.Zmax);
374
- };
375
-
376
- // >>> Cage monde : clamp interne <<<
377
- FreeCamera.prototype._clampInsideWorld = function(p){
378
- if (!this._containMin || !this._containMax) return p;
379
- p.x = Math.max(this._containMin.x, Math.min(this._containMax.x, p.x));
380
- p.y = Math.max(Math.max(this._containMin.y, this.minY), Math.min(this._containMax.y, p.y));
381
- p.z = Math.max(this._containMin.z, Math.min(this._containMax.z, p.z));
382
- return p;
383
- };
384
- FreeCamera.prototype._isOutsideWorld = function(p){
385
- if (!this._containMin || !this._containMax) return false;
386
- return (p.x < this._containMin.x || p.x > this._containMax.x ||
387
- p.y < Math.max(this._containMin.y, this.minY) || p.y > this._containMax.y ||
388
- p.z < this._containMin.z || p.z > this._containMax.z);
389
- };
390
-
391
- // ======================== Capsule vs AABBs : resolve ===========================
392
- FreeCamera.prototype._resolveCapsuleCollisions = function (pos, maxIters) {
393
- if (!this._useCollision) return pos.clone();
394
-
395
- var p = pos.clone();
396
- var R = Math.max(0, this.capsuleRadius);
397
- var H = Math.max(2*R, this.capsuleHeight);
398
- var eps = Math.max(1e-7, this.collisionEps);
399
-
400
- if (!this._colliders || this._colliders.length===0) return p;
401
-
402
- var iters = Math.max(1, maxIters||1);
403
- for (var iter=0; iter<iters; iter++){
404
- var moved = false;
405
-
406
- for (var i=0;i<this._colliders.length;i++){
407
- var aabb = this._colliders[i].aabb;
408
- var push = new pc.Vec3();
409
- var pen = capsuleVsAabbPenetration(p, H, R, aabb, push);
410
- if (pen > eps) {
411
- p.add(push);
412
- moved = true;
413
- }
414
- }
415
- if (!moved) break;
416
- }
417
- return p;
418
- };
419
-
420
- // ======================== Mvt principal : swept + step + snap ===========================
421
- FreeCamera.prototype._moveSwept = function (from, delta) {
422
- if (!this._useCollision) return vAdd(from, delta);
423
-
424
- var maxStep = Math.max(0.01, this.maxStepDistance||0.1);
425
- var dist = vLen(delta);
426
- if (dist <= maxStep) return this._moveStep(from, delta);
427
-
428
- var steps = Math.ceil(dist / maxStep);
429
- var step = vScale(delta, 1/steps);
430
- var cur = from.clone();
431
- for (var i=0;i<steps;i++) cur = this._moveStep(cur, step);
432
- return cur;
433
- };
434
-
435
- FreeCamera.prototype._moveStep = function (from, delta) {
436
- var target = vAdd(from, delta);
437
- var after = this._resolveCapsuleCollisions(target, this.maxResolveIters);
438
- var collided = (vLen(vSub(after, target)) > 0);
439
-
440
- if (!collided) {
441
- if (this.enableGroundSnap) after = this._snapDown(after);
442
- after = this._clampInsideWorld(after); // <<< clamp
443
- return after;
444
- }
445
-
446
- var n = this._estimateNormal(after);
447
- if (n) {
448
- var desire = delta.clone();
449
- var slide = vSub(desire, vScale(n, vDot(desire, n)));
450
- var slideTarget = vAdd(from, slide);
451
- var slideAfter = this._resolveCapsuleCollisions(slideTarget, this.maxResolveIters);
452
-
453
- // Step-up si slide insuffisant
454
- if (vLen(vSub(slideAfter, slideTarget)) > 0) {
455
- var stepped = this._tryStepUp(from, desire);
456
- if (stepped) {
457
- if (this.enableGroundSnap) stepped = this._snapDown(stepped);
458
- stepped = this._clampInsideWorld(stepped); // <<< clamp
459
- return stepped;
460
- }
461
- }
462
-
463
- if (this.enableGroundSnap) slideAfter = this._snapDown(slideAfter);
464
- slideAfter = this._clampInsideWorld(slideAfter); // <<< clamp
465
- return slideAfter;
466
- }
467
-
468
- if (this.enableGroundSnap) after = this._snapDown(after);
469
- after = this._clampInsideWorld(after); // <<< clamp
470
- return after;
471
- };
472
-
473
- // Normale approx via micro-probes
474
- FreeCamera.prototype._estimateNormal = function (p) {
475
- if (!this._colliders) return null;
476
- var probe = 0.02;
477
- var base = this._resolveCapsuleCollisions(p, this.maxResolveIters);
478
- var nx = this._resolveCapsuleCollisions(new pc.Vec3(p.x+probe,p.y,p.z), this.maxResolveIters);
479
- var px = this._resolveCapsuleCollisions(new pc.Vec3(p.x-probe,p.y,p.z), this.maxResolveIters);
480
- var ny = this._resolveCapsuleCollisions(new pc.Vec3(p.x,p.y+probe,p.z), this.maxResolveIters);
481
- var py = this._resolveCapsuleCollisions(new pc.Vec3(p.x,p.y-probe,p.z), this.maxResolveIters);
482
- var nz = this._resolveCapsuleCollisions(new pc.Vec3(p.x,p.y,p.z+probe), this.maxResolveIters);
483
- var pz = this._resolveCapsuleCollisions(new pc.Vec3(p.x,p.y,p.z-probe), this.maxResolveIters);
484
-
485
- function d(a,b){ return vLen(vSub(a,b)); }
486
- var dx = d(nx,base) - d(px,base);
487
- var dy = d(ny,base) - d(py,base);
488
- var dz = d(nz,base) - d(pz,base);
489
- var n = new pc.Vec3(dx,dy,dz);
490
- var L = vLen(n);
491
- if (L < 1e-5) return null;
492
- return vScale(n, 1/L);
493
- };
494
-
495
- // Step-up : monter une marche (jusqu'à stepHeight)
496
- FreeCamera.prototype._tryStepUp = function (from, wishDelta) {
497
- var R = Math.max(0, this.capsuleRadius);
498
- var H = Math.max(2*R, this.capsuleHeight);
499
- var maxH = Math.max(0, this.stepHeight || 0.35);
500
- var ahead = Math.max(0.05, this.stepAhead || 0.20);
501
-
502
- var horiz = new pc.Vec3(wishDelta.x, 0, wishDelta.z);
503
- var hLen = vLen(horiz);
504
- if (hLen < 1e-6) return null;
505
- horiz = vScale(horiz, 1/hLen);
506
-
507
- var probe = vAdd(from, vScale(horiz, Math.min(hLen, ahead)));
508
- var trials = 3;
509
- for (var i=1;i<=trials;i++){
510
- var up = maxH * (i/trials);
511
- var raised = new pc.Vec3(probe.x, probe.y + up, probe.z);
512
- raised = this._resolveCapsuleCollisions(raised, this.maxResolveIters);
513
- var stepped = this._resolveCapsuleCollisions(vAdd(raised, wishDelta), this.maxResolveIters);
514
-
515
- if (vLen(vSub(stepped, raised)) > 0.02) return stepped;
516
- }
517
- return null;
518
- };
519
-
520
- // Snap-down : redéposer sur le sol si on flotte un peu (descente d'escalier)
521
- FreeCamera.prototype._snapDown = function (p) {
522
- var R = Math.max(0, this.capsuleRadius);
523
- var H = Math.max(2*R, this.capsuleHeight);
524
- var maxDown = Math.max(0, this.snapDownMax || 0.6);
525
-
526
- var steps = 4;
527
- var step = maxDown / steps;
528
- var cur = p.clone();
529
- for (var i=0;i<steps;i++){
530
- var down = new pc.Vec3(cur.x, cur.y - step, cur.z);
531
- var resolved = this._resolveCapsuleCollisions(down, this.maxResolveIters);
532
- if (vLen(vSub(resolved, down)) > 0) {
533
- return this._resolveCapsuleCollisions(new pc.Vec3(resolved.x, resolved.y + 0.01, resolved.z), this.maxResolveIters);
534
- }
535
- cur.copy(down);
536
- }
537
- return p;
538
- };
539
-
540
- // ===================== INPUT SOURIS =====================
541
- var FreeCameraInputMouse = pc.createScript('orbitCameraInputMouse');
542
- FreeCameraInputMouse.attributes.add('lookSensitivity', { type: 'number', default: 0.28, title: 'Look Sensitivity' });
543
- FreeCameraInputMouse.attributes.add('wheelSensitivity',{ type: 'number', default: 0.8, title: 'Wheel Sensitivity (anti-tunnel)' });
544
-
545
- FreeCameraInputMouse.prototype.initialize = function () {
546
- this.freeCam = this.entity.script.orbitCamera;
547
- this.last = new pc.Vec2();
548
- this.isLooking = false;
549
-
550
- if (this.app.mouse) {
551
- this.app.mouse.on(pc.EVENT_MOUSEDOWN, this.onMouseDown, this);
552
- this.app.mouse.on(pc.EVENT_MOUSEUP, this.onMouseUp, this);
553
- this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
554
- this.app.mouse.on(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this);
555
- this.app.mouse.disableContextMenu();
556
- }
557
- var self = this;
558
- this._onOut = function(){ self.isLooking = false; };
559
- window.addEventListener('mouseout', this._onOut, false);
560
-
561
- this.on('destroy', () => {
562
- if (this.app.mouse) {
563
- this.app.mouse.off(pc.EVENT_MOUSEDOWN, this.onMouseDown, this);
564
- this.app.mouse.off(pc.EVENT_MOUSEUP, this.onMouseUp, this);
565
- this.app.mouse.off(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
566
- this.app.mouse.off(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this);
567
- }
568
- window.removeEventListener('mouseout', this._onOut, false);
569
- });
570
- };
571
-
572
- FreeCameraInputMouse.prototype.onMouseDown = function (e) {
573
- this.isLooking = true;
574
- this.last.set(e.x, e.y);
575
- };
576
-
577
- FreeCameraInputMouse.prototype.onMouseUp = function () {
578
- this.isLooking = false;
579
- };
580
-
581
- FreeCameraInputMouse.prototype.onMouseMove = function (e) {
582
- if (!this.isLooking || !this.freeCam) return;
583
- var sens = this.lookSensitivity;
584
- this.freeCam.yaw = this.freeCam.yaw - e.dx * sens;
585
- this.freeCam.pitch = this.freeCam.pitch - e.dy * sens;
586
- this.last.set(e.x, e.y);
587
- };
588
-
589
- FreeCameraInputMouse.prototype.onMouseWheel = function (e) {
590
- if (!this.freeCam) return;
591
- var cam = this.entity;
592
- var move = -e.wheelDelta * this.wheelSensitivity * this.freeCam.dollySpeed * 0.05;
593
- var fwd = cam.forward.clone(); if (fwd.lengthSq()>1e-8) fwd.normalize();
594
- var from = cam.getPosition().clone();
595
- var to = from.clone().add(fwd.mulScalar(move));
596
- var next = this.freeCam._moveSwept(from, to.sub(from));
597
- this.freeCam._clampBBoxMinY(next);
598
- next = this.freeCam._clampInsideWorld(next);
599
- cam.setPosition(next);
600
- e.event.preventDefault();
601
- };
602
-
603
- // ===================== INPUT TOUCH =====================
604
- var FreeCameraInputTouch = pc.createScript('orbitCameraInputTouch');
605
- FreeCameraInputTouch.attributes.add('lookSensitivity', { type: 'number', default: 0.5, title: 'Look Sensitivity' });
606
- FreeCameraInputTouch.attributes.add('pinchDollyFactor', { type: 'number', default: 0.02, title: 'Pinch Dolly Factor' });
607
-
608
- FreeCameraInputTouch.prototype.initialize = function () {
609
- this.freeCam = this.entity.script.orbitCamera;
610
- this.last = new pc.Vec2();
611
- this.isLooking = false;
612
- this.lastPinch = 0;
613
-
614
- if (this.app.touch) {
615
- this.app.touch.on(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this);
616
- this.app.touch.on(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this);
617
- this.app.touch.on(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this);
618
- this.app.touch.on(pc.EVENT_TOUCHMOVE, this.onTouchMove, this);
619
- }
620
-
621
- this.on('destroy', () => {
622
- if (this.app.touch) {
623
- this.app.touch.off(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this);
624
- this.app.touch.off(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this);
625
- this.app.touch.off(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this);
626
- this.app.touch.off(pc.EVENT_TOUCHMOVE, this.onTouchMove, this);
627
- }
628
- });
629
- };
630
-
631
- FreeCameraInputTouch.prototype.onTouchStartEndCancel = function (e) {
632
- var t = e.touches;
633
- if (t.length === 1) {
634
- this.isLooking = (e.event.type === 'touchstart');
635
- this.last.set(t[0].x, t[0].y);
636
- } else if (t.length === 2) {
637
- var dx = t[0].x - t[1].x, dy = t[0].y - t[1].y;
638
- this.lastPinch = Math.sqrt(dx*dx + dy*dy);
639
- } else {
640
- this.isLooking = false;
641
- }
642
- };
643
-
644
- FreeCameraInputTouch.prototype.onTouchMove = function (e) {
645
- var t = e.touches;
646
- if (!this.freeCam) return;
647
-
648
- if (t.length === 1 && this.isLooking) {
649
- var s = this.lookSensitivity;
650
- var dx = t[0].x - this.last.x, dy = t[0].y - this.last.y;
651
- this.freeCam.yaw = this.freeCam.yaw - dx * s;
652
- this.freeCam.pitch = this.freeCam.pitch - dy * s;
653
- this.last.set(t[0].x, t[0].y);
654
- } else if (t.length === 2) {
655
- var dx = t[0].x - t[1].x, dy = t[0].y - t[1].y;
656
- var dist = Math.sqrt(dx*dx + dy*dy);
657
- var delta = dist - this.lastPinch; this.lastPinch = dist;
658
-
659
- var cam = this.entity;
660
- var fwd = cam.forward.clone(); if (fwd.lengthSq()>1e-8) fwd.normalize();
661
- var from = cam.getPosition().clone();
662
- var to = from.clone().add(fwd.mulScalar(delta * this.pinchDollyFactor * this.freeCam.dollySpeed));
663
- var next = this.freeCam._moveSwept(from, to.sub(from));
664
- this.freeCam._clampBBoxMinY(next);
665
- next = this.freeCam._clampInsideWorld(next);
666
- cam.setPosition(next);
667
- }
668
- };
669
-
670
- // ===================== INPUT CLAVIER =====================
671
- var FreeCameraInputKeyboard = pc.createScript('orbitCameraInputKeyboard');
672
- FreeCameraInputKeyboard.attributes.add('acceleration', { type: 'number', default: 1.0, title: 'Accel (unused, future)' });
673
-
674
- FreeCameraInputKeyboard.prototype.initialize = function () {
675
- this.freeCam = this.entity.script.orbitCamera;
676
- this.kb = this.app.keyboard || null;
677
- };
678
-
679
- FreeCameraInputKeyboard.prototype.update = function (dt) {
680
- if (!this.freeCam || !this.kb) return;
681
-
682
- var fwd = (this.kb.isPressed(pc.KEY_UP) || this.kb.isPressed(pc.KEY_Z) || this.kb.isPressed(pc.KEY_W)) ? 1 :
683
- (this.kb.isPressed(pc.KEY_DOWN) || this.kb.isPressed(pc.KEY_S)) ? -1 : 0;
684
-
685
- var strf = (this.kb.isPressed(pc.KEY_RIGHT) || this.kb.isPressed(pc.KEY_D)) ? 1 :
686
- (this.kb.isPressed(pc.KEY_LEFT) || this.kb.isPressed(pc.KEY_Q) || this.kb.isPressed(pc.KEY_A)) ? -1 : 0;
687
-
688
- if (fwd !== 0 || strf !== 0) {
689
- var cam = this.entity;
690
- var from = cam.getPosition().clone();
691
-
692
- var forward = cam.forward.clone(); if (forward.lengthSq()>1e-8) forward.normalize();
693
- var right = cam.right.clone(); if (right.lengthSq()>1e-8) right.normalize();
694
-
695
- var delta = new pc.Vec3()
696
- .add(forward.mulScalar(fwd * this.freeCam.moveSpeed * dt))
697
- .add(right .mulScalar(strf * this.freeCam.strafeSpeed * dt));
698
-
699
- var next = this.freeCam._moveSwept(from, delta);
700
- this.freeCam._clampBBoxMinY(next);
701
- next = this.freeCam._clampInsideWorld(next);
702
- cam.setPosition(next);
703
- }
704
-
705
- var shift = this.kb.isPressed(pc.KEY_SHIFT);
706
- if (shift) {
707
- var yawDir = (this.kb.isPressed(pc.KEY_LEFT) ? 1 : 0) - (this.kb.isPressed(pc.KEY_RIGHT) ? 1 : 0);
708
- var pitchDir = (this.kb.isPressed(pc.KEY_UP) ? 1 : 0) - (this.kb.isPressed(pc.KEY_DOWN) ? 1 : 0);
709
- var yawSpeed = 120, pitchSpeed = 90;
710
- if (yawDir !== 0) this.freeCam.yaw = this.freeCam.yaw + yawDir * yawSpeed * dt;
711
- if (pitchDir !== 0) this.freeCam.pitch = this.freeCam.pitch + pitchDir * pitchSpeed * dt;
712
- }
713
- };
714
  }
715
 
716
  /* -------------------------------------------
717
- State (par module = par instance importée)
718
  -------------------------------------------- */
719
 
720
  let pc;
721
  export let app = null;
722
- let cameraEntity = null;
 
723
  let modelEntity = null;
724
- let envEntity = null; // <<< GLB instancié (focusEntity collisions)
725
  let viewerInitialized = false;
726
  let resizeObserver = null;
727
  let resizeTimeout = null;
728
 
729
- // paramètres courants de l'instance
730
  let chosenCameraX, chosenCameraY, chosenCameraZ;
731
  let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minY;
732
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
733
  let presentoirScaleX, presentoirScaleY, presentoirScaleZ;
734
  let sogUrl, glbUrl, presentoirUrl;
735
  let color_bg_hex, color_bg, espace_expo_bool;
736
-
737
- // perf dynamique
738
- let maxDevicePixelRatio = 1.75; // plafond par défaut (configurable)
739
- let interactDpr = 1.0; // DPR pendant interaction
740
- let idleRestoreDelay = 350; // ms avant de restaurer le DPR
741
  let idleTimer = null;
742
 
743
  /* -------------------------------------------
744
- Initialisation
745
  -------------------------------------------- */
746
 
747
  export async function initializeViewer(config, instanceId) {
748
- if (viewerInitialized) return;
749
-
750
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
751
- const isMobile = isIOS || /Android/i.test(navigator.userAgent);
752
-
753
- console.log(`[VIEWER] A: initializeViewer begin`, { instanceId });
754
-
755
- // --- Configuration ---
756
- sogUrl = config.sog_url || config.sogs_json_url;
757
- glbUrl = config.glb_url ?? "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/ressources/espace_expo/sol_blanc_2.glb";
758
- presentoirUrl = config.presentoir_url ?? "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/ressources/espace_expo/sol_blanc_2.glb";
759
-
760
- minZoom = parseFloat(config.minZoom || "1");
761
- maxZoom = parseFloat(config.maxZoom || "20");
762
- minAngle = parseFloat(config.minAngle || "-2000");
763
- maxAngle = parseFloat(config.maxAngle || "2000");
764
- minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
765
- maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
766
- minY = config.minY !== undefined ? parseFloat(config.minY) : 0;
767
-
768
- modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0;
769
- modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0;
770
- modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0;
771
- modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1;
772
- modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0;
773
- modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
774
- modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
775
-
776
- presentoirScaleX = config.presentoirScaleX !== undefined ? parseFloat(config.presentoirScaleX) : 0;
777
- presentoirScaleY = config.presentoirScaleY !== undefined ? parseFloat(config.presentoirScaleY) : 0;
778
- presentoirScaleZ = config.presentoirScaleZ !== undefined ? parseFloat(config.presentoirScaleZ) : 0;
779
-
780
- const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
781
- const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2;
782
- const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5;
783
-
784
- const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX;
785
- const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY;
786
- const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5;
787
-
788
- color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF";
789
- espace_expo_bool = config.espace_expo_bool !== undefined ? config.espace_expo_bool : false;
790
- color_bg = hexToRgbaArray(color_bg_hex);
791
-
792
- chosenCameraX = isMobile ? cameraXPhone : cameraX;
793
- chosenCameraY = isMobile ? cameraYPhone : cameraY;
794
- chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
795
-
796
- // Options perf configurables
797
- if (config.maxDevicePixelRatio !== undefined) {
798
- maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio);
799
- }
800
- if (config.interactionPixelRatio !== undefined) {
801
- interactDpr = Math.max(0.75, parseFloat(config.interactionPixelRatio) || interactDpr);
802
- }
803
- if (config.idleRestoreDelayMs !== undefined) {
804
- idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
805
- }
806
-
807
- // --- Prépare le canvas unique à cette instance ---
808
- const canvasId = "canvas-" + instanceId;
809
- const progressDialog = document.getElementById("progress-dialog-" + instanceId);
810
- const viewerContainer = document.getElementById("viewer-container-" + instanceId);
811
-
812
- const old = document.getElementById(canvasId);
813
- if (old) old.remove();
814
-
815
- const canvas = document.createElement("canvas");
816
- canvas.id = canvasId;
817
- canvas.className = "ply-canvas";
818
- canvas.style.width = "100%";
819
- canvas.style.height = "100%";
820
- canvas.setAttribute("tabindex", "0");
821
- viewerContainer.insertBefore(canvas, progressDialog);
822
-
823
- // interactions de base
824
- canvas.style.touchAction = "none";
825
- canvas.style.webkitTouchCallout = "none";
826
- canvas.addEventListener("gesturestart", (e) => e.preventDefault());
827
- canvas.addEventListener("gesturechange", (e) => e.preventDefault());
828
- canvas.addEventListener("gestureend", (e) => e.preventDefault());
829
- canvas.addEventListener("dblclick", (e) => e.preventDefault());
830
- canvas.addEventListener(
831
- "touchstart",
832
- (e) => { if (e.touches.length > 1) e.preventDefault(); },
833
- { passive: false }
834
- );
835
- canvas.addEventListener(
836
- "wheel",
837
- (e) => { e.preventDefault(); },
838
- { passive: false }
839
- );
840
-
841
- // Bloque le scroll page uniquement quand le pointeur est sur le canvas
842
- const scrollKeys = new Set([
843
- "ArrowUp","ArrowDown","ArrowLeft","ArrowRight",
844
- "PageUp","PageDown","Home","End"," ","Space","Spacebar"
845
- ]);
846
- let isPointerOverCanvas = false;
847
- const focusCanvas = () => canvas.focus({ preventScroll: true });
848
-
849
- const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); };
850
- const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); };
851
- const onCanvasBlur = () => { isPointerOverCanvas = false; };
852
-
853
- canvas.addEventListener("pointerenter", onPointerEnter);
854
- canvas.addEventListener("pointerleave", onPointerLeave);
855
- canvas.addEventListener("mouseenter", onPointerEnter);
856
- canvas.addEventListener("mouseleave", onPointerLeave);
857
- canvas.addEventListener("mousedown", focusCanvas);
858
- canvas.addEventListener("touchstart", () => { focusCanvas(); }, { passive: false });
859
- canvas.addEventListener("blur", onCanvasBlur);
860
-
861
- const onKeyDownCapture = (e) => {
862
- if (!isPointerOverCanvas) return;
863
- if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault();
864
- };
865
- window.addEventListener("keydown", onKeyDownCapture, true);
866
-
867
- progressDialog.style.display = "block";
868
-
869
- // --- Charge PlayCanvas lib ESM (une par module/instance) ---
870
- if (!pc) {
871
- pc = await import("https://esm.run/playcanvas");
872
- window.pc = pc; // debug
873
- }
874
- console.log('[VIEWER] PlayCanvas ESM chargé:', !!pc);
875
-
876
- // --- Crée l'Application ---
877
- const device = await pc.createGraphicsDevice(canvas, {
878
- deviceTypes: ["webgl2"],
879
- glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
880
- twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
881
- antialias: false
882
- });
883
- device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
884
-
885
- const opts = new pc.AppOptions();
886
- opts.graphicsDevice = device;
887
- opts.mouse = new pc.Mouse(canvas);
888
- opts.touch = new pc.TouchDevice(canvas);
889
- opts.keyboard = new pc.Keyboard(canvas);
890
- opts.componentSystems = [
891
- pc.RenderComponentSystem,
892
- pc.CameraComponentSystem,
893
- pc.LightComponentSystem,
894
- pc.ScriptComponentSystem,
895
- pc.GSplatComponentSystem,
896
- pc.CollisionComponentSystem,
897
- pc.RigidbodyComponentSystem
898
- ];
899
- // GSplatHandler gère nativement les .sog
900
- opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
901
-
902
- app = new pc.Application(canvas, opts);
903
- app.setCanvasFillMode(pc.FILLMODE_NONE);
904
- app.setCanvasResolution(pc.RESOLUTION_AUTO);
905
-
906
- // --- Debounce resize (moins de rafales) ---
907
- resizeObserver = new ResizeObserver((entries) => {
908
- if (!entries || !entries.length) return;
909
- if (resizeTimeout) clearTimeout(resizeTimeout);
910
- resizeTimeout = setTimeout(() => {
911
- app.resizeCanvas(entries[0].contentRect.width, entries[0].contentRect.height);
912
- }, 60);
913
- });
914
- resizeObserver.observe(viewerContainer);
915
-
916
- window.addEventListener("resize", () => {
917
- if (resizeTimeout) clearTimeout(resizeTimeout);
918
- resizeTimeout = setTimeout(() => {
919
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
920
- }, 60);
921
- });
922
-
923
- app.on("destroy", () => {
924
- try { resizeObserver.disconnect(); } catch {}
925
- if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
926
- window.removeEventListener("keydown", onKeyDownCapture, true);
927
-
928
- canvas.removeEventListener("pointerenter", onPointerEnter);
929
- canvas.removeEventListener("pointerleave", onPointerLeave);
930
- canvas.removeEventListener("mouseenter", onPointerEnter);
931
- canvas.removeEventListener("mouseleave", onPointerLeave);
932
- canvas.removeEventListener("mousedown", focusCanvas);
933
- canvas.removeEventListener("touchstart", focusCanvas);
934
- canvas.removeEventListener("blur", onCanvasBlur);
935
- });
936
-
937
- // --- Enregistre et charge les assets en 1 phase pour SOG + GLB (focusEntity prêt avant la caméra) ---
938
- const sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl });
939
- const glbAsset = new pc.Asset("glb", "container", { url: glbUrl });
940
- app.assets.add(sogAsset);
941
- app.assets.add(glbAsset);
942
-
943
- // >>> Scripts caméra en local (aucune requête réseau) <<<
944
- registerFreeCamScripts();
945
-
946
- // ---------- CHARGEMENT SOG + GLB AVANT CREATION CAMERA ----------
947
- await new Promise((resolve, reject) => {
948
- const loader = new pc.AssetListLoader([sogAsset, glbAsset], app.assets);
949
- loader.load(() => resolve());
950
- loader.on('error', (e)=>{ console.error('[VIEWER] Asset load error:', e); reject(e); });
951
- });
952
-
953
- app.start(); // démarre l'update loop dès que possible
954
- progressDialog.style.display = "none";
955
- console.log("[VIEWER] app.start OK — assets chargés");
956
-
957
- // --- Modèle principal (GSplat via .sog) ---
958
- modelEntity = new pc.Entity("model");
959
- modelEntity.addComponent("gsplat", { asset: sogAsset });
960
- modelEntity.setLocalPosition(modelX, modelY, modelZ);
961
- modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
962
- modelEntity.setLocalScale(modelScale, modelScale, modelScale);
963
- app.root.addChild(modelEntity);
964
-
965
- // --- Instancier le GLB d’environnement (collision) ---
966
- envEntity = glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
967
- if (envEntity) {
968
- envEntity.name = "ENV_GLTF";
969
- app.root.addChild(envEntity);
970
- // Log du nombre de meshInstances pour vérifier la construction des colliders
971
- let meshCount = 0;
972
- traverse(envEntity, (node) => {
973
- if (node.render && node.render.meshInstances) meshCount += node.render.meshInstances.length;
974
- });
975
- console.log("[VIEWER] env ready:", true, "meshInstances=", meshCount);
976
- } else {
977
- console.warn("[VIEWER] GLB resource missing: collisions fallback sur GSplat (moins précis).");
978
- }
979
-
980
- // --- Caméra + scripts d’input (free-cam nommée 'orbitCamera' pour compat) ---
981
- cameraEntity = new pc.Entity("camera");
982
- cameraEntity.addComponent("camera", {
983
- clearColor: new pc.Color(color_bg),
984
- nearClip: 0.02,
985
- farClip: 250
986
- });
987
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
988
- cameraEntity.lookAt(modelEntity.getPosition());
989
- cameraEntity.addComponent("script");
990
-
991
- cameraEntity.script.create("orbitCamera", {
992
- attributes: {
993
- focusEntity: envEntity || modelEntity,
994
- inertiaFactor: 0.18,
995
- distanceMax: maxZoom,
996
- distanceMin: minZoom,
997
- pitchAngleMax: maxAngle,
998
- pitchAngleMin: minAngle,
999
- yawAngleMax: maxAzimuth,
1000
- yawAngleMin: minAzimuth,
1001
- minY: minY,
1002
- frameOnStart: false,
1003
- capsuleRadius: 0.30,
1004
- capsuleHeight: 1.60,
1005
- maxStepDistance: 0.10,
1006
- maxResolveIters: 8,
1007
- globalCullFrac: 0.0 // désactivé
1008
- }
1009
- });
1010
- cameraEntity.script.create("orbitCameraInputMouse", {
1011
- attributes: { lookSensitivity: 0.28, wheelSensitivity: 0.8 }
1012
- });
1013
- cameraEntity.script.create("orbitCameraInputTouch", {
1014
- attributes: { lookSensitivity: 0.5, pinchDollyFactor: 0.02 }
1015
- });
1016
- cameraEntity.script.create("orbitCameraInputKeyboard", {
1017
- attributes: { acceleration: 1.0 }
1018
- });
1019
- app.root.addChild(cameraEntity);
1020
-
1021
- // Taille initiale
1022
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
1023
-
1024
- // IMPORTANT : si la free-cam est active, ne pas "forcer" un reset d'orbite.
1025
- app.once("update", () => resetViewerCamera());
1026
-
1027
- // ---------- Perf dynamique : DPR temporairement réduit pendant interaction ----------
1028
- const setDpr = (val) => {
1029
- const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio));
1030
- if (app.graphicsDevice.maxPixelRatio !== clamped) {
1031
- app.graphicsDevice.maxPixelRatio = clamped;
1032
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
1033
- }
1034
- };
1035
-
1036
- const bumpInteraction = () => {
1037
- setDpr(interactDpr);
1038
- if (idleTimer) clearTimeout(idleTimer);
1039
- idleTimer = setTimeout(() => {
1040
- setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio));
1041
- }, idleRestoreDelay);
1042
- };
1043
-
1044
- const interactionEvents = ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"];
1045
- interactionEvents.forEach((ev) => {
1046
- canvas.addEventListener(ev, bumpInteraction, { passive: true });
1047
- });
1048
-
1049
- viewerInitialized = true;
1050
- console.log("[VIEWER] READY — physics=OFF (AABB), env=", !!envEntity, "sog=", !!modelEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1051
  }
1052
 
1053
  /* -------------------------------------------
1054
- Reset caméra (API)
1055
  -------------------------------------------- */
1056
 
1057
  export function resetViewerCamera() {
1058
- try {
1059
- if (!cameraEntity || !modelEntity || !app) return;
1060
- const camScript = cameraEntity.script && cameraEntity.script.orbitCamera;
1061
- if (!camScript) return;
1062
-
1063
- const modelPos = modelEntity.getPosition();
1064
-
1065
- // Ici c'est une "free-cam", on ne remet pas une orbite : juste réaligner le regard
1066
- cameraEntity.lookAt(modelPos);
1067
- } catch (e) {
1068
- console.error("[viewer.js] resetViewerCamera error:", e);
1069
- }
1070
- }
 
 
 
 
 
 
 
 
 
1
  // viewer_pr_env.js
2
  // ==============================
3
+ // Version 2.0 : Utilisation du MOTEUR PHYSIQUE de PlayCanvas
4
+ // - Remplace la détection de collision AABB manuelle
5
+ // - Utilise RigidBody (dynamique) pour le joueur (capsule)
6
+ // - Utilise RigidBody (statique) pour l'environnement (mesh)
7
+ // - Gère la gravité, le "vrai" mesh des portes, etc.
8
+ // - L'ancien fichier camera.js n'est plus nécessaire.
9
 
10
  /* -------------------------------------------
11
+    Utils communs (Inchangés)
12
  -------------------------------------------- */
13
 
 
14
  async function loadImageAsTexture(url, app) {
15
+   return new Promise((resolve, reject) => {
16
+     const img = new window.Image();
17
+     img.crossOrigin = "anonymous";
18
+     img.onload = function () {
19
+       const tex = new pc.Texture(app.graphicsDevice, {
20
+         width: img.width,
21
+         height: img.height,
22
+         format: pc.PIXELFORMAT_R8_G8_B8_A8
23
+       });
24
+       tex.setSource(img);
25
+       resolve(tex);
26
+     };
27
+     img.onerror = reject;
28
+     img.src = url;
29
+   });
30
  }
31
 
 
32
  (function () {
33
+   const OriginalImage = window.Image;
34
+   window.Image = function (...args) {
35
+     const img = new OriginalImage(...args);
36
+     img.crossOrigin = "anonymous";
37
+     return img;
38
+   };
39
  })();
40
 
41
  function hexToRgbaArray(hex) {
42
+   try {
43
+     hex = String(hex || "").replace("#", "");
44
+     if (hex.length === 6) hex += "FF";
45
+     if (hex.length !== 8) return [1, 1, 1, 1];
46
+     const num = parseInt(hex, 16);
47
+     return [
48
+       ((num >> 24) & 0xff) / 255,
49
+       ((num >> 16) & 0xff) / 255,
50
+       ((num >> 8) & 0xff) / 255,
51
+       (num & 0xff) / 255
52
+     ];
53
+   } catch (e) {
54
+     console.warn("hexToRgbaArray error:", e);
55
+     return [1, 1, 1, 1];
56
+   }
57
  }
58
 
 
59
  function traverse(entity, callback) {
60
+   callback(entity);
61
+   if (entity.children) {
62
+     entity.children.forEach((child) => traverse(child, callback));
63
+   }
64
  }
65
 
66
+ // Helpers math (Inchangés, mais moins utilisés)
67
  function vAdd(a,b){ return new pc.Vec3(a.x+b.x,a.y+b.y,a.z+b.z); }
68
  function vSub(a,b){ return new pc.Vec3(a.x-b.x,a.y-b.y,a.z-b.z); }
69
  function vScale(v,s){ return new pc.Vec3(v.x*s,v.y*s,v.z*s); }
 
72
  function clamp(v,a,b){ return Math.max(a, Math.min(b,v)); }
73
 
74
  /* -------------------------------------------
75
+    NOUVEAU Script Caméra (basé sur le moteur physique)
 
76
  -------------------------------------------- */
77
+ function registerFirstPersonScripts() {
78
+   if (window.__PLY_FIRSTPERSON_REG__) return;
79
+   window.__PLY_FIRSTPERSON_REG__ = true;
80
+
81
+   // Remarque : 'orbitCamera' est conservé pour la compatibilité avec l'ancien nom,
82
+   // mais c'est maintenant un script de caméra 'first-person' (FPS).
83
+   var FirstPersonCamera = pc.createScript('orbitCamera');
84
+
85
+   // --- Attributs ---
86
+   FirstPersonCamera.attributes.add('cameraEntity', {
87
+     type: 'entity',
88
+     title: 'Camera Entity',
89
+     description: 'Entité caméra enfant à contrôler pour le pitch (regard haut/bas)'
90
+   });
91
+   FirstPersonCamera.attributes.add('moveSpeed', {
92
+     type: 'number',
93
+     default: 5, // Vitesse en m/s (ajustez au besoin)
94
+     title: 'Move Speed'
95
+   });
96
+   FirstPersonCamera.attributes.add('lookSpeed', {
97
+     type: 'number',
98
+     default: 0.25,
99
+     title: 'Look Sensitivity'
100
+   });
101
+   FirstPersonCamera.attributes.add('pitchAngleMin', { type: 'number', default: -89, title: 'Pitch Min (deg)' });
102
+   FirstPersonCamera.attributes.add('pitchAngleMax', { type: 'number', default:  89, title: 'Pitch Max (deg)' });
103
+
104
+   // --- Variables internes ---
105
+   FirstPersonCamera.prototype.initialize = function () {
106
+     this.yaw = 0;   // Rotation gauche/droite (autour de Y)
107
+     this.pitch = 0; // Rotation haut/bas (autour de X)
108
+     this.velocity = new pc.Vec3();
109
+     this.force = new pc.Vec3();
110
+
111
+     // Gestion de la souris
112
+     if (this.app.mouse) {
113
+       this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
114
+       // Pointer Lock (cliquer pour bouger)
115
+       this.app.mouse.on(pc.EVENT_MOUSEDOWN, () => {
116
+         if (!pc.Mouse.isPointerLocked()) {
117
+           this.app.mouse.enablePointerLock();
118
+         }
119
+       }, this);
120
+     }
121
+
122
+     // Angles initiaux
123
+     var angles = this.entity.getLocalEulerAngles();
124
+     this.yaw = angles.y;
125
+     this.pitch = this.cameraEntity ? this.cameraEntity.getLocalEulerAngles().x : 0;
126
+   };
127
+
128
+   FirstPersonCamera.prototype.onMouseMove = function (e) {
129
+     // Uniquement si le pointeur est verrouillé
130
+     if (!pc.Mouse.isPointerLocked()) {
131
+       return;
132
+     }
133
+
134
+     this.yaw -= e.dx * this.lookSpeed;
135
+     this.pitch -= e.dy * this.lookSpeed;
136
+     this.pitch = pc.math.clamp(this.pitch, this.pitchAngleMin, this.pitchAngleMax);
137
+   };
138
+
139
+   FirstPersonCamera.prototype.update = function (dt) {
140
+     // --- 1. Rotation (Look) ---
141
+     // L'entité "Player" (capsule) tourne sur Y (gauche/droite)
142
+     this.entity.setLocalEulerAngles(0, this.yaw, 0);
143
+     // L'entité "Camera" (enfant) tourne sur X (haut/bas)
144
+     if (this.cameraEntity) {
145
+       this.cameraEntity.setLocalEulerAngles(this.pitch, 0, 0);
146
+     }
147
+
148
+     // --- 2. Mouvement (Clavier) ---
149
+     var fwd = 0;
150
+     var strf = 0;
151
+
152
+     if (this.app.keyboard.isPressed(pc.KEY_Z) || this.app.keyboard.isPressed(pc.KEY_W)) {
153
+       fwd += 1;
154
+     }
155
+     if (this.app.keyboard.isPressed(pc.KEY_S)) {
156
+       fwd -= 1;
157
+     }
158
+     if (this.app.keyboard.isPressed(pc.KEY_Q) || this.app.keyboard.isPressed(pc.KEY_A)) {
159
+       strf -= 1;
160
+     }
161
+     if (this.app.keyboard.isPressed(pc.KEY_D)) {
162
+       strf += 1;
163
+     }
164
+
165
+     if (fwd !== 0 || strf !== 0) {
166
+       // On utilise les vecteurs de l'entité (qui a le yaw)
167
+       var forward = this.entity.forward;
168
+       var right = this.entity.right;
169
+
170
+       // Calcule la force de déplacement
171
+       this.force.set(0, 0, 0);
172
+       this.force.add(forward.mulScalar(fwd));
173
+       this.force.add(right.mulScalar(strf));
174
+      
175
+       // Normalise pour éviter un mouvement diagonal plus rapide
176
+       if (this.force.length() > 1) {
177
+         this.force.normalize();
178
+       }
179
+     }
180
+
181
+     // --- 3. Appliquer au Moteur Physique ---
182
+     if (this.entity.rigidbody) {
183
+       // Récupère la vélocité actuelle (pour la gravité)
184
+       this.velocity.copy(this.entity.rigidbody.linearVelocity);
185
+      
186
+       // Définit la vélocité XZ (mouvement)
187
+       this.velocity.x = this.force.x * this.moveSpeed;
188
+       this.velocity.z = this.force.z * this.moveSpeed;
189
+      
190
+       // Applique la nouvelle vélocité (conserve Y pour la gravité)
191
+       this.entity.rigidbody.linearVelocity = this.velocity;
192
+     }
193
+    
194
+     // Reset la force pour la prochaine frame
195
+     this.force.set(0, 0, 0);
196
+   };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  }
198
 
199
  /* -------------------------------------------
200
+    State (Inchangé)
201
  -------------------------------------------- */
202
 
203
  let pc;
204
  export let app = null;
205
+ let playerEntity = null; // Renommé pour plus de clarté
206
+ let cameraEntity = null; // C'est maintenant un enfant du player
207
  let modelEntity = null;
208
+ let envEntity = null;
209
  let viewerInitialized = false;
210
  let resizeObserver = null;
211
  let resizeTimeout = null;
212
 
213
+ // ... (toutes les variables de config sont conservées) ...
214
  let chosenCameraX, chosenCameraY, chosenCameraZ;
215
  let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minY;
216
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
217
  let presentoirScaleX, presentoirScaleY, presentoirScaleZ;
218
  let sogUrl, glbUrl, presentoirUrl;
219
  let color_bg_hex, color_bg, espace_expo_bool;
220
+ let maxDevicePixelRatio = 1.75;
221
+ let interactDpr = 1.0;
222
+ let idleRestoreDelay = 350;
 
 
223
  let idleTimer = null;
224
 
225
  /* -------------------------------------------
226
+    Initialisation (MODIFIÉE)
227
  -------------------------------------------- */
228
 
229
  export async function initializeViewer(config, instanceId) {
230
+   if (viewerInitialized) return;
231
+
232
+   const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
233
+   const isMobile = isIOS || /Android/i.test(navigator.userAgent);
234
+
235
+   console.log(`[VIEWER] A: initializeViewer begin`, { instanceId });
236
+
237
+   // --- Configuration (Inchangée) ---
238
+   sogUrl = config.sog_url || config.sogs_json_url;
239
+   glbUrl = config.glb_url ?? "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/ressources/espace_expo/sol_blanc_2.glb";
240
+   presentoirUrl = config.presentoir_url ?? "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/ressources/espace_expo/sol_blanc_2.glb";
241
+
242
+   minZoom = parseFloat(config.minZoom || "1");
243
+   maxZoom = parseFloat(config.maxZoom || "20");
244
+   minAngle = parseFloat(config.minAngle || "-2000");
245
+   maxAngle = parseFloat(config.maxAngle || "2000");
246
+   minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
247
+   maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
248
+   minY = config.minY !== undefined ? parseFloat(config.minY) : 0;
249
+
250
+   modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0;
251
+   modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0;
252
+   modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0;
253
+   modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1;
254
+   modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0;
255
+   modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
256
+   modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
257
+
258
+   presentoirScaleX = config.presentoirScaleX !== undefined ? parseFloat(config.presentoirScaleX) : 0;
259
+   presentoirScaleY = config.presentoirScaleY !== undefined ? parseFloat(config.presentoirScaleY) : 0;
260
+   presentoirScaleZ = config.presentoirScaleZ !== undefined ? parseFloat(config.presentoirScaleZ) : 0;
261
+
262
+   // *** Point de spawn du JOUEUR (pas juste la caméra) ***
263
+   const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
264
+   const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2; // Hauteur de spawn
265
+   const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5;
266
+
267
+   const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX;
268
+   const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY;
269
+   const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5;
270
+
271
+   color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF";
272
+   espace_expo_bool = config.espace_expo_bool !== undefined ? config.espace_expo_bool : false;
273
+   color_bg = hexToRgbaArray(color_bg_hex);
274
+
275
+   chosenCameraX = isMobile ? cameraXPhone : cameraX;
276
+   chosenCameraY = isMobile ? cameraYPhone : cameraY;
277
+   chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
278
+  
279
+   // ... (config perf inchangée) ...
280
+   if (config.maxDevicePixelRatio !== undefined) {
281
+     maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio);
282
+   }
283
+   if (config.interactionPixelRatio !== undefined) {
284
+     interactDpr = Math.max(0.75, parseFloat(config.interactionPixelRatio) || interactDpr);
285
+   }
286
+   if (config.idleRestoreDelayMs !== undefined) {
287
+     idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
288
+   }
289
+
290
+   // --- Prépare le canvas (Inchangé) ---
291
+   const canvasId = "canvas-" + instanceId;
292
+   const progressDialog = document.getElementById("progress-dialog-" + instanceId);
293
+   const viewerContainer = document.getElementById("viewer-container-" + instanceId);
294
+
295
+   const old = document.getElementById(canvasId);
296
+   if (old) old.remove();
297
+
298
+   const canvas = document.createElement("canvas");
299
+   canvas.id = canvasId;
300
+   // ... (tous les listeners de canvas sont conservés) ...
301
+   canvas.className = "ply-canvas";
302
+   canvas.style.width = "100%";
303
+   canvas.style.height = "100%";
304
+   canvas.setAttribute("tabindex", "0");
305
+   viewerContainer.insertBefore(canvas, progressDialog);
306
+   canvas.style.touchAction = "none";
307
+   canvas.style.webkitTouchCallout = "none";
308
+   canvas.addEventListener("gesturestart", (e) => e.preventDefault());
309
+   canvas.addEventListener("gesturechange", (e) => e.preventDefault());
310
+   canvas.addEventListener("gestureend", (e) => e.preventDefault());
311
+   canvas.addEventListener("dblclick", (e) => e.preventDefault());
312
+   canvas.addEventListener(
313
+     "touchstart",
314
+     (e) => { if (e.touches.length > 1) e.preventDefault(); },
315
+     { passive: false }
316
+   );
317
+   canvas.addEventListener(
318
+     "wheel",
319
+     (e) => { e.preventDefault(); },
320
+     { passive: false }
321
+   );
322
+   const scrollKeys = new Set([
323
+     "ArrowUp","ArrowDown","ArrowLeft","ArrowRight",
324
+     "PageUp","PageDown","Home","End"," ","Space","Spacebar"
325
+   ]);
326
+   let isPointerOverCanvas = false;
327
+   const focusCanvas = () => canvas.focus({ preventScroll: true });
328
+   const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); };
329
+   const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); };
330
+   const onCanvasBlur = () => { isPointerOverCanvas = false; };
331
+   canvas.addEventListener("pointerenter", onPointerEnter);
332
+   canvas.addEventListener("pointerleave", onPointerLeave);
333
+   canvas.addEventListener("mouseenter", onPointerEnter);
334
+   canvas.addEventListener("mouseleave", onPointerLeave);
335
+   canvas.addEventListener("mousedown", focusCanvas);
336
+   canvas.addEventListener("touchstart", () => { focusCanvas(); }, { passive: false });
337
+   canvas.addEventListener("blur", onCanvasBlur);
338
+   const onKeyDownCapture = (e) => {
339
+     if (!isPointerOverCanvas) return;
340
+     if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault();
341
+   };
342
+   window.addEventListener("keydown", onKeyDownCapture, true);
343
+   progressDialog.style.display = "block";
344
+
345
+   // --- Charge PlayCanvas lib ESM (Inchangé) ---
346
+   if (!pc) {
347
+     pc = await import("https://esm.run/playcanvas");
348
+     window.pc = pc; // debug
349
+   }
350
+   console.log('[VIEWER] PlayCanvas ESM chargé:', !!pc);
351
+
352
+   // --- Crée l'Application ---
353
+   const device = await pc.createGraphicsDevice(canvas, {
354
+     deviceTypes: ["webgl2"],
355
+     glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
356
+     twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
357
+     antialias: false
358
+   });
359
+   device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
360
+
361
+   const opts = new pc.AppOptions();
362
+   opts.graphicsDevice = device;
363
+   opts.mouse = new pc.Mouse(canvas);
364
+   opts.touch = new pc.TouchDevice(canvas);
365
+   opts.keyboard = new pc.Keyboard(canvas);
366
+ Note: This component system list *must* include RigidBody and Collision.
367
+   opts.componentSystems = [
368
+     pc.RenderComponentSystem,
369
+     pc.CameraComponentSystem,
370
+     pc.LightComponentSystem,
371
+     pc.ScriptComponentSystem,
372
+     pc.GSplatComponentSystem,
373
+     pc.CollisionComponentSystem, // <--- NÉCESSAIRE POUR LA PHYSIQUE
374
+     pc.RigidbodyComponentSystem  // <--- NÉCESSAIRE POUR LA PHYSIQUE
375
+   ];
376
+   opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
377
+
378
+   app = new pc.Application(canvas, opts);
379
+   app.setCanvasFillMode(pc.FILLMODE_NONE);
380
+   app.setCanvasResolution(pc.RESOLUTION_AUTO);
381
+
382
+   // *** NOUVEAU : Définir la gravité pour le monde physique ***
383
+   app.systems.rigidbody.gravity.set(0, -9.81, 0);
384
+
385
+   // --- Debounce resize (Inchangé) ---
386
+   resizeObserver = new ResizeObserver((entries) => {
387
+     if (!entries || !entries.length) return;
388
+     if (resizeTimeout) clearTimeout(resizeTimeout);
389
+     resizeTimeout = setTimeout(() => {
390
+       app.resizeCanvas(entries[0].contentRect.width, entries[0].contentRect.height);
391
+     }, 60);
392
+   });
393
+   resizeObserver.observe(viewerContainer);
394
+   window.addEventListener("resize", () => {
395
+     if (resizeTimeout) clearTimeout(resizeTimeout);
396
+     resizeTimeout = setTimeout(() => {
397
+       app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
398
+     }, 60);
399
+   });
400
+   app.on("destroy", () => {
401
+     try { resizeObserver.disconnect(); } catch {}
402
+     if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
403
+     window.removeEventListener("keydown", onKeyDownCapture, true);
404
+     // ... (listeners canvas inchangés) ...
405
+     canvas.removeEventListener("pointerenter", onPointerEnter);
406
+     canvas.removeEventListener("pointerleave", onPointerLeave);
407
+     canvas.removeEventListener("mouseenter", onPointerEnter);
408
+     canvas.removeEventListener("mouseleave", onPointerLeave);
409
+     canvas.removeEventListener("mousedown", focusCanvas);
410
+     canvas.removeEventListener("touchstart", focusCanvas);
411
+     canvas.removeEventListener("blur", onCanvasBlur);
412
+   });
413
+
414
+   // --- Chargement des assets (Inchangé) ---
415
+   const sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl });
416
+   const glbAsset = new pc.Asset("glb", "container", { url: glbUrl });
417
+   app.assets.add(sogAsset);
418
+   app.assets.add(glbAsset);
419
+
420
+   // *** NOUVEAU : Enregistre le script FPS ***
421
+   registerFirstPersonScripts();
422
+
423
+   // --- CHARGEMENT SOG + GLB (Inchangé) ---
424
+   await new Promise((resolve, reject) => {
425
+     const loader = new pc.AssetListLoader([sogAsset, glbAsset], app.assets);
426
+     loader.load(() => resolve());
427
+     loader.on('error', (e)=>{ console.error('[VIEWER] Asset load error:', e); reject(e); });
428
+   });
429
+
430
+   app.start();
431
+   progressDialog.style.display = "none";
432
+   console.log("[VIEWER] app.start OK assets chargés");
433
+
434
+   // --- Modèle GSplat (Inchangé) ---
435
+   modelEntity = new pc.Entity("model");
436
+   modelEntity.addComponent("gsplat", { asset: sogAsset });
437
+   modelEntity.setLocalPosition(modelX, modelY, modelZ);
438
+   modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
439
+   modelEntity.setLocalScale(modelScale, modelScale, modelScale);
440
+   app.root.addChild(modelEntity);
441
+
442
+   // --- *** NOUVEAU : Configuration de l'environnement GLB (Physique) *** ---
443
+   envEntity = glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
444
+   if (envEntity) {
445
+     envEntity.name = "ENV_GLTF";
446
+     app.root.addChild(envEntity);
447
+
448
+     let meshCount = 0;
449
+     // Parcours le GLB et ajoute des colliders PHYSIQUES à chaque mesh
450
+     traverse(envEntity, (node) => {
451
+       // Si le nœud est un mesh visible (a un 'render')
452
+       if (node.render) {
453
+         meshCount++;
454
+         // 1. Le rend statique (il ne bouge pas)
455
+         node.addComponent('rigidbody', {
456
+           type: pc.BODYTYPE_STATIC,
457
+           restitution: 0.0
458
+         });
459
+         // 2. Lui donne un collider de type 'mesh' (utilise sa vraie géométrie)
460
+         node.addComponent('collision', {
461
+           type: 'mesh',
462
+           asset: node.render.asset // Utilise l'asset du mesh pour la collision
463
+         });
464
+       }
465
+     });
466
+     console.log("[VIEWER] Environnement GLB (physique) prêt. Meshs collidables:", meshCount);
467
+   } else {
468
+     console.warn("[VIEWER] GLB resource missing. Aucune collision possible.");
469
+   }
470
+
471
+   // --- *** NOUVEAU : Configuration du Joueur (Physique) *** ---
472
+  
473
+   // 1. L'entité "Player" (le corps physique, la capsule)
474
+   playerEntity = new pc.Entity("Player");
475
+   playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
476
+  
477
+   // Ajoute un corps rigide DYNAMIQUE (il bouge et tombe)
478
+   playerEntity.addComponent('rigidbody', {
479
+     type: pc.BODYTYPE_DYNAMIC,
480
+     mass: 70, // Poids d'un humain
481
+     friction: 0.1,
482
+     restitution: 0.0,
483
+     angularFactor: new pc.Vec3(0, 0, 0) // Empêche la capsule de basculer
484
+   });
485
+  
486
+   // Ajoute une forme de collision CAPSULE
487
+   playerEntity.addComponent('collision', {
488
+     type: 'capsule',
489
+     radius: 0.35,
490
+     height: 1.7
491
+   });
492
+
493
+   // 2. L'entité "Camera" (les yeux)
494
+   cameraEntity = new pc.Entity("Camera");
495
+   cameraEntity.addComponent("camera", {
496
+     clearColor: new pc.Color(color_bg),
497
+     nearClip: 0.02,
498
+     farClip: 250
499
+   });
500
+   // Positionne la caméra à hauteur des "yeux" dans la capsule
501
+   cameraEntity.setLocalPosition(0, 0.7, 0); // (0, height/2 - radius, 0) approx.
502
+
503
+   // 3. Attache la caméra au joueur
504
+   playerEntity.addChild(cameraEntity);
505
+
506
+   // 4. Ajoute le script de contrôle au joueur
507
+   playerEntity.addComponent("script");
508
+   playerEntity.script.create("orbitCamera", {
509
+     attributes: {
510
+       cameraEntity: cameraEntity,
511
+       moveSpeed: 4.0, // Ajustez cette vitesse
512
+       lookSpeed: 0.25,
513
+       pitchAngleMax: maxAngle, // Utilise vos configs
514
+       pitchAngleMin: minAngle
515
+     }
516
+   });
517
+  
518
+   // 5. Ajoute le joueur à la scène
519
+   app.root.addChild(playerEntity);
520
+  
521
+   // Note: On n'ajoute plus les scripts 'InputMouse/Touch/Keyboard'
522
+   // car le nouveau script 'orbitCamera' (FPS) gère tout.
523
+
524
+   // Taille initiale
525
+   app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
526
+
527
+   // --- Reset (Inchangé, mais ne fait plus grand chose) ---
528
+   app.once("update", () => resetViewerCamera());
529
+
530
+   // --- Perf dynamique (Inchangé) ---
531
+   const setDpr = (val) => {
532
+     const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio));
533
+     if (app.graphicsDevice.maxPixelRatio !== clamped) {
534
+       app.graphicsDevice.maxPixelRatio = clamped;
535
+       app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
536
+   ---
537
+     }
538
+   };
539
+   const bumpInteraction = () => {
540
+     setDpr(interactDpr);
541
+     if (idleTimer) clearTimeout(idleTimer);
542
+     idleTimer = setTimeout(() => {
543
+       setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio));
544
+     }, idleRestoreDelay);
545
+   };
546
+   const interactionEvents = ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"];
547
+   interactionEvents.forEach((ev) => {
548
+     canvas.addEventListener(ev, bumpInteraction, { passive: true });
549
+   });
550
+
551
+   viewerInitialized = true;
552
+   console.log("[VIEWER] READY — physics=ON, env=", !!envEntity, "sog=", !!modelEntity);
553
  }
554
 
555
  /* -------------------------------------------
556
+    Reset caméra (Modifié pour le joueur)
557
  -------------------------------------------- */
558
 
559
  export function resetViewerCamera() {
560
+   try {
561
+     if (!playerEntity || !modelEntity || !app) return;
562
+     const camScript = playerEntity.script && playerEntity.script.orbitCamera;
563
+     if (!camScript) return;
564
+
565
+     const modelPos = modelEntity.getPosition();
566
+    
567
+     // Téléporte le JOUEUR (pas la caméra)
568
+     playerEntity.rigidbody.teleport(chosenCameraX, chosenCameraY, chosenCameraZ);
569
+     // Réinitialise le regard
570
+     playerEntity.lookAt(modelPos.x, playerEntity.getPosition().y, modelPos.z);
571
+    
572
+     // Met à jour les angles dans le script
573
+     var angles = playerEntity.getLocalEulerAngles();
574
+     camScript.yaw = angles.y;
575
+     camScript.pitch = 0; // Regarde droit devant
576
+    
577
+   } catch (e) {
578
+     console.error("[viewer.js] resetViewerCamera error:", e);
579
+   }
580
+ }