MikaFil commited on
Commit
d7f7910
·
verified ·
1 Parent(s): 5010d47

Update deplacement_dans_env/ctrl_camera_pr_env.js

Browse files
deplacement_dans_env/ctrl_camera_pr_env.js CHANGED
@@ -1,482 +1,549 @@
1
- // viewer_pr_env.js
2
- // ==============================
3
-
4
- /* -------------------------------------------
5
- Utils
6
- -------------------------------------------- */
7
-
8
- async function loadImageAsTexture(url, app) {
9
- return new Promise((resolve, reject) => {
10
- const img = new window.Image();
11
- img.crossOrigin = "anonymous";
12
- img.onload = function () {
13
- const tex = new pc.Texture(app.graphicsDevice, {
14
- width: img.width,
15
- height: img.height,
16
- format: pc.PIXELFORMAT_R8_G8_B8_A8
17
- });
18
- tex.setSource(img);
19
- resolve(tex);
20
- };
21
- img.onerror = reject;
22
- img.src = url;
23
- });
24
- }
25
-
26
- // Patch global Image -> force CORS
27
- (function () {
28
- const OriginalImage = window.Image;
29
- window.Image = function (...args) {
30
- const img = new OriginalImage(...args);
31
- img.crossOrigin = "anonymous";
32
- return img;
33
- };
34
- })();
35
-
36
- function hexToRgbaArray(hex) {
37
- try {
38
- hex = String(hex || "").replace("#", "");
39
- if (hex.length === 6) hex += "FF";
40
- if (hex.length !== 8) return [1, 1, 1, 1];
41
- const num = parseInt(hex, 16);
42
- return [
43
- ((num >> 24) & 0xff) / 255,
44
- ((num >> 16) & 0xff) / 255,
45
- ((num >> 8) & 0xff) / 255,
46
- (num & 0xff) / 255
47
- ];
48
- } catch (e) {
49
- console.warn("hexToRgbaArray error:", e);
50
- return [1, 1, 1, 1];
51
- }
52
- }
53
-
54
- // Parcours récursif d'une hiérarchie d'entités
55
- function traverse(entity, callback) {
56
- callback(entity);
57
- if (entity.children) {
58
- entity.children.forEach((child) => traverse(child, callback));
59
- }
60
- }
61
-
62
- /* -------------------------------------------
63
- Chargement unique de ctrl_camera_pr_env.js
64
- -------------------------------------------- */
65
-
66
- async function ensureOrbitScriptsLoaded() {
67
- if (window.__PLY_ORBIT_LOADED__) return;
68
- if (window.__PLY_ORBIT_LOADING__) {
69
- await window.__PLY_ORBIT_LOADING__;
70
- return;
71
- }
72
-
73
- window.__PLY_ORBIT_LOADING__ = new Promise((resolve, reject) => {
74
- const s = document.createElement("script");
75
- // Script caméra libre + collisions (garde le nom public "orbitCamera")
76
- s.src = "https://mikafil-viewer-sgos.static.hf.space/deplacement_dans_env/ctrl_camera_pr_env.js";
77
- s.async = true;
78
- s.onload = () => {
79
- window.__PLY_ORBIT_LOADED__ = true;
80
- resolve();
81
- };
82
- s.onerror = (e) => {
83
- console.error("[viewer.js] Failed to load orbit-camera script", e);
84
- reject(e);
85
- };
86
- document.head.appendChild(s);
87
- });
88
-
89
- await window.__PLY_ORBIT_LOADING__;
90
- }
91
-
92
- /* -------------------------------------------
93
- State (par module = par instance importée)
94
- -------------------------------------------- */
95
-
96
- let pc;
97
- export let app = null;
98
- let cameraEntity = null;
99
- let modelEntity = null; // gsplat principal (oeuvre)
100
- let envEntity = null; // GLB d'environnement / présentoir
101
- let viewerInitialized = false;
102
- let resizeObserver = null;
103
-
104
- // paramètres courants de l'instance
105
- let chosenCameraX, chosenCameraY, chosenCameraZ;
106
- let distanceMin, minAngle, maxAngle, minAzimuth, maxAzimuth, minY;
107
- let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
108
- let presentoirScaleX, presentoirScaleY, presentoirScaleZ;
109
- let sogsUrl, glbUrl;
110
- let color_bg_hex, color_bg, espace_expo_bool;
111
-
112
- /* -------------------------------------------
113
- Initialisation
114
- -------------------------------------------- */
115
-
116
- export async function initializeViewer(config, instanceId) {
117
- // une seule initialisation par import
118
- if (viewerInitialized) return;
119
-
120
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
121
- const isMobile = isIOS || /Android/i.test(navigator.userAgent);
122
-
123
- // --- Configuration ---
124
- sogsUrl = config.sogs_json_url || null;
125
- glbUrl = config.glb_url || null;
126
-
127
- // rétro-compat minZoom => distanceMin (conservé pour compat mais non utilisé par la free cam)
128
- distanceMin = config.minZoom !== undefined
129
- ? parseFloat(config.minZoom)
130
- : (config.distanceMin !== undefined ? parseFloat(config.distanceMin) : 1);
131
-
132
- minAngle = parseFloat(config.minAngle ?? "-45");
133
- maxAngle = parseFloat(config.maxAngle ?? "90");
134
- minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
135
- maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
136
- minY = config.minY !== undefined ? parseFloat(config.minY) : 0;
137
-
138
- modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0;
139
- modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0;
140
- modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0;
141
- modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1;
142
- modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0;
143
- modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
144
- modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
145
-
146
- // défauts à 1 (et non 0) pour éviter l'invisibilité si manquants
147
- presentoirScaleX = config.presentoirScaleX !== undefined ? parseFloat(config.presentoirScaleX) : 1;
148
- presentoirScaleY = config.presentoirScaleY !== undefined ? parseFloat(config.presentoirScaleY) : 1;
149
- presentoirScaleZ = config.presentoirScaleZ !== undefined ? parseFloat(config.presentoirScaleZ) : 1;
150
-
151
- const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
152
- const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2;
153
- const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5;
154
-
155
- const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX;
156
- const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY;
157
- const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5;
158
-
159
- color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF";
160
- espace_expo_bool = config.espace_expo_bool !== undefined ? config.espace_expo_bool : false;
161
- color_bg = hexToRgbaArray(color_bg_hex);
162
-
163
- chosenCameraX = isMobile ? cameraXPhone : cameraX;
164
- chosenCameraY = isMobile ? cameraYPhone : cameraY;
165
- chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
166
-
167
- // --- Prépare le canvas unique à cette instance ---
168
- const canvasId = "canvas-" + instanceId;
169
- const progressDialog = document.getElementById("progress-dialog-" + instanceId);
170
- const viewerContainer = document.getElementById("viewer-container-" + instanceId);
171
-
172
- const old = document.getElementById(canvasId);
173
- if (old) old.remove();
174
-
175
- const canvas = document.createElement("canvas");
176
- canvas.id = canvasId;
177
- canvas.className = "ply-canvas";
178
- canvas.style.width = "100%";
179
- canvas.style.height = "100%";
180
- canvas.setAttribute("tabindex", "0");
181
- viewerContainer.insertBefore(canvas, progressDialog);
182
-
183
- // interactions de base (éviter scroll/gestes par défaut sur le canvas)
184
- canvas.style.touchAction = "none";
185
- canvas.style.webkitTouchCallout = "none";
186
- canvas.addEventListener("gesturestart", (e) => e.preventDefault());
187
- canvas.addEventListener("gesturechange", (e) => e.preventDefault());
188
- canvas.addEventListener("gestureend", (e) => e.preventDefault());
189
- canvas.addEventListener("dblclick", (e) => e.preventDefault());
190
- canvas.addEventListener(
191
- "touchstart",
192
- (e) => {
193
- if (e.touches.length > 1) e.preventDefault();
194
- },
195
- { passive: false }
196
- );
197
- canvas.addEventListener(
198
- "wheel",
199
- (e) => { e.preventDefault(); },
200
- { passive: false }
201
- );
202
-
203
- // Bloque le scroll page uniquement quand le pointeur est sur le canvas
204
- const scrollKeys = new Set([ "ArrowUp","ArrowDown","ArrowLeft","ArrowRight","PageUp","PageDown","Home","End"," ","Space","Spacebar" ]);
205
- let isPointerOverCanvas = false;
206
- const focusCanvas = () => canvas.focus({ preventScroll: true });
207
-
208
- const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); };
209
- const onPointerLeave = () => {
210
- isPointerOverCanvas = false;
211
- if (document.activeElement === canvas) canvas.blur();
212
- };
213
- const onCanvasBlur = () => { isPointerOverCanvas = false; };
214
-
215
- canvas.addEventListener("pointerenter", onPointerEnter);
216
- canvas.addEventListener("pointerleave", onPointerLeave);
217
- canvas.addEventListener("mouseenter", onPointerEnter);
218
- canvas.addEventListener("mouseleave", onPointerLeave);
219
- canvas.addEventListener("mousedown", focusCanvas);
220
- canvas.addEventListener("touchstart", () => { focusCanvas(); }, { passive: false });
221
- canvas.addEventListener("blur", onCanvasBlur);
222
-
223
- const onKeyDownCapture = (e) => {
224
- if (!isPointerOverCanvas) return;
225
- if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault();
226
- };
227
- window.addEventListener("keydown", onKeyDownCapture, true);
228
-
229
- progressDialog.style.display = "block";
230
-
231
- // --- Charge PlayCanvas lib ESM (une par module/instance) ---
232
- if (!pc) {
233
- pc = await import("https://esm.run/playcanvas");
234
- window.pc = pc; // utile pour tooltips.js et debug
235
- }
236
-
237
- // --- Crée l'Application ---
238
- const device = await pc.createGraphicsDevice(canvas, {
239
- deviceTypes: ["webgl2"],
240
- glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
241
- twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
242
- antialias: false
243
- });
244
- device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
245
-
246
- const opts = new pc.AppOptions();
247
- opts.graphicsDevice = device;
248
- opts.mouse = new pc.Mouse(canvas);
249
- opts.touch = new pc.TouchDevice(canvas);
250
- opts.keyboard = new pc.Keyboard(canvas); // scoping clavier au canvas
251
- opts.componentSystems = [
252
- pc.RenderComponentSystem,
253
- pc.CameraComponentSystem,
254
- pc.LightComponentSystem,
255
- pc.ScriptComponentSystem,
256
- pc.GSplatComponentSystem,
257
- pc.CollisionComponentSystem,
258
- pc.RigidbodyComponentSystem
259
- ];
260
- opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
261
-
262
- app = new pc.Application(canvas, opts);
263
- app.setCanvasFillMode(pc.FILLMODE_NONE);
264
- app.setCanvasResolution(pc.RESOLUTION_AUTO);
265
-
266
- resizeObserver = new ResizeObserver((entries) => {
267
- entries.forEach((entry) => {
268
- app.resizeCanvas(entry.contentRect.width, entry.contentRect.height);
269
- });
270
- });
271
- resizeObserver.observe(viewerContainer);
272
-
273
- window.addEventListener("resize", () =>
274
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight)
275
- );
276
-
277
- // Nettoyage complet
278
- app.on("destroy", () => {
279
- try { resizeObserver.disconnect(); } catch {}
280
- if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
281
- window.removeEventListener("keydown", onKeyDownCapture, true);
282
-
283
- canvas.removeEventListener("pointerenter", onPointerEnter);
284
- canvas.removeEventListener("pointerleave", onPointerLeave);
285
- canvas.removeEventListener("mouseenter", onPointerEnter);
286
- canvas.removeEventListener("mouseleave", onPointerLeave);
287
- canvas.removeEventListener("mousedown", focusCanvas);
288
- canvas.removeEventListener("touchstart", focusCanvas);
289
- canvas.removeEventListener("blur", onCanvasBlur);
290
- });
291
-
292
- // --- Enregistre les assets (SAUF orbit script : chargé globalement) ---
293
- const assets = {};
294
- if (sogsUrl) assets.sogs = new pc.Asset("gsplat", "gsplat", { url: sogsUrl });
295
- if (glbUrl) assets.env = new pc.Asset("env", "container", { url: glbUrl });
296
-
297
- for (const k in assets) app.assets.add(assets[k]);
298
-
299
- const loader = new pc.AssetListLoader(Object.values(assets), app.assets);
300
-
301
- // Assure le chargement unique des scripts de caméra
302
- await ensureOrbitScriptsLoaded();
303
-
304
- loader.load(() => {
305
- app.start();
306
- progressDialog.style.display = "none";
307
-
308
- // --- Modèle principal (gsplat) ---
309
- if (assets.sogs) {
310
- modelEntity = new pc.Entity("model");
311
- modelEntity.addComponent("gsplat", { asset: assets.sogs });
312
- modelEntity.setLocalPosition(modelX, modelY, modelZ);
313
- modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
314
- modelEntity.setLocalScale(modelScale, modelScale, modelScale);
315
- app.root.addChild(modelEntity);
316
  }
317
 
318
- // --- GLB Environnement / Présentoir (optionnel) ---
319
- if (assets.env && assets.env.resource) {
320
- const container = assets.env.resource; // pc.ContainerResource
321
- envEntity = container.instantiateRenderEntity({
322
- castShadows: false,
323
- receiveShadows: true
324
- });
 
325
 
326
- // Position/rotation/échelle (adaptables)
327
- envEntity.setLocalPosition(0, 0, 0);
328
- envEntity.setLocalEulerAngles(0, 0, 0);
329
- envEntity.setLocalScale(presentoirScaleX, presentoirScaleY, presentoirScaleZ);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
- app.root.addChild(envEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
333
 
334
- // Entité qui sert d'ancrage visuel (lookAt) : l'oeuvre si dispo, sinon l'env
335
- const focusVisual = modelEntity || envEntity;
 
336
 
337
- // Racine de collision pour la free cam : l'environnement GLB de préférence
338
- const collisionRoot = envEntity || modelEntity || null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
- // --- Caméra + scripts d’input (free cam + collisions) ---
341
- cameraEntity = new pc.Entity("camera");
342
- cameraEntity.addComponent("camera", {
343
- clearColor: new pc.Color(color_bg[0], color_bg[1], color_bg[2], color_bg[3]),
344
- nearClip: 0.001,
345
- farClip: 100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  });
347
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
348
- if (focusVisual) cameraEntity.lookAt(focusVisual.getPosition());
349
- cameraEntity.addComponent("script");
350
-
351
- // Prépare les attributs : les champs inconnus sont ignorés par le script
352
- const orbitAttrs = {
353
- // Collision root : active le blocage contre le GLB
354
- focusEntity: collisionRoot || undefined,
355
-
356
- // Inertie/angles (pitch clampé), yaw libre
357
- inertiaFactor: 0.2,
358
- pitchAngleMax: maxAngle,
359
- pitchAngleMin: minAngle,
360
-
361
- // Compat (ignorés par le free cam, mais gardés pour ne pas casser la config)
362
- distanceMin: distanceMin,
363
- yawAngleMax: maxAzimuth,
364
- yawAngleMin: minAzimuth,
365
- frameOnStart: false,
366
-
367
- // Contraintes position
368
- minY: minY
369
- };
370
-
371
- // Injecte la BBox uniquement si présente dans le config (évite Infinity explicite)
372
- const maybeNum = (v) => (v === undefined || v === null || v === "" ? undefined : parseFloat(v));
373
- const Xmin = maybeNum(config.Xmin);
374
- const Xmax = maybeNum(config.Xmax);
375
- const Ymin = maybeNum(config.Ymin);
376
- const Ymax = maybeNum(config.Ymax);
377
- const Zmin = maybeNum(config.Zmin);
378
- const Zmax = maybeNum(config.Zmax);
379
-
380
- if (Xmin !== undefined) orbitAttrs.Xmin = Xmin;
381
- if (Xmax !== undefined) orbitAttrs.Xmax = Xmax;
382
- if (Ymin !== undefined) orbitAttrs.Ymin = Ymin;
383
- if (Ymax !== undefined) orbitAttrs.Ymax = Ymax;
384
- if (Zmin !== undefined) orbitAttrs.Zmin = Zmin;
385
- if (Zmax !== undefined) orbitAttrs.Zmax = Zmax;
386
-
387
- // Paramètres collision optionnels depuis la config (sinon valeurs par défaut du script)
388
- if (config.collisionRadius !== undefined) orbitAttrs.collisionRadius = parseFloat(config.collisionRadius);
389
- if (config.collisionEpsilon !== undefined) orbitAttrs.collisionEpsilon = parseFloat(config.collisionEpsilon);
390
- if (config.moveSpeed !== undefined) orbitAttrs.moveSpeed = parseFloat(config.moveSpeed);
391
- if (config.strafeSpeed !== undefined) orbitAttrs.strafeSpeed = parseFloat(config.strafeSpeed);
392
- if (config.dollySpeed !== undefined) orbitAttrs.dollySpeed = parseFloat(config.dollySpeed);
393
-
394
- cameraEntity.script.create("orbitCamera", { attributes: orbitAttrs });
395
- cameraEntity.script.create("orbitCameraInputMouse");
396
- cameraEntity.script.create("orbitCameraInputTouch");
397
- cameraEntity.script.create("orbitCameraInputKeyboard", { attributes: { acceleration: 1.0 } });
398
- app.root.addChild(cameraEntity);
399
-
400
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
401
-
402
- // Reset caméra une fois la première frame prête
403
- app.once("update", () => resetViewerCamera());
404
-
405
- // --- Tooltips (optionnels) ---
406
- try {
407
- if (config.tooltips_url) {
408
- import("./tooltips.js")
409
- .then((tooltipsModule) => {
410
- tooltipsModule.initializeTooltips({
411
- app,
412
- cameraEntity,
413
- modelEntity: focusVisual,
414
- tooltipsUrl: config.tooltips_url,
415
- defaultVisible: !!config.showTooltipsDefault,
416
- moveDuration: config.tooltipMoveDuration || 0.6
417
- });
418
- })
419
- .catch(() => { /* optional */ });
420
- }
421
- } catch (e) { /* optional */ }
422
-
423
- viewerInitialized = true;
424
- });
425
- }
426
-
427
- /* -------------------------------------------
428
- Reset caméra (API)
429
- -------------------------------------------- */
430
-
431
- export function resetViewerCamera() {
432
- try {
433
- if (!cameraEntity || !app) return;
434
-
435
- // cible visuelle : gsplat prioritaire, sinon glb, sinon (0,0,0)
436
- const targetEntity = modelEntity || envEntity;
437
- const targetPos = targetEntity ? targetEntity.getPosition().clone() : new pc.Vec3(0, 0, 0);
438
-
439
- const orbitCam = cameraEntity.script && cameraEntity.script.orbitCamera;
440
- if (!orbitCam) return;
441
-
442
- const tempEnt = new pc.Entity();
443
- tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
444
- tempEnt.lookAt(targetPos);
445
-
446
- const dist = new pc.Vec3()
447
- .sub2(new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ), targetPos)
448
- .length();
449
-
450
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
451
- cameraEntity.lookAt(targetPos);
452
-
453
- // Ces champs existent dans la version "orbit" historique ; notre free cam les ignore,
454
- // mais on conserve l'initialisation pour compat.
455
- if (orbitCam) {
456
- orbitCam._targetDistance = Math.max(distanceMin, dist);
457
- orbitCam._distance = Math.max(distanceMin, dist);
458
  }
 
459
 
460
- // Recalcule yaw/pitch cibles pour aligner l'orientation
461
- const rot = tempEnt.getRotation();
462
- const fwd = new pc.Vec3();
463
- rot.transformVector(pc.Vec3.FORWARD, fwd);
464
-
465
- const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG;
466
- const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
467
- const rotNoYaw = new pc.Quat().mul2(yawQuat, rot);
468
- const fNoYaw = new pc.Vec3();
469
- rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
470
- const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
471
-
472
- orbitCam._targetYaw = yaw;
473
- orbitCam._yaw = yaw;
474
- orbitCam._targetPitch = pitch;
475
- orbitCam._pitch = pitch;
476
- if (orbitCam._updatePosition) orbitCam._updatePosition();
477
-
478
- tempEnt.destroy();
479
- } catch (e) {
480
- console.error("[viewer.js] resetViewerCamera error:", e);
481
- }
482
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ctrl_camera_pr_env.js
2
+ // ============================================================================
3
+ // FREE CAMERA + COLLISION ROBUSTE (type FPS léger) pour PlayCanvas (sans Ammo)
4
+ // - Souris : rotation (look around)
5
+ // - ZQSD / Flèches : déplacement local (avant/arrière & strafe)
6
+ // - Molette / Pinch : dolly (avance/recul le long du regard)
7
+ // - Collisions : sphère (caméra) vs AABBs "épaissies & fusionnées" du GLB (focusEntity)
8
+ // - Déplacements "swept" (sous-division en pas) pour éviter le tunneling
9
+ // - minY + BBox globale optionnelle (Xmin..Zmax) restent actives
10
+ // - Conserve les noms publics "orbitCamera*" pour compatibilité avec l'existant
11
+ // ============================================================================
12
+
13
+ var FreeCamera = pc.createScript('orbitCamera'); // garder le nom public "orbitCamera"
14
+
15
+ // ======================== Attributs ===========================
16
+ FreeCamera.attributes.add('inertiaFactor', { type: 'number', default: 0.12, title: 'Inertia (rotation)' });
17
+
18
+ // Limites de pitch
19
+ FreeCamera.attributes.add('pitchAngleMin', { type: 'number', default: -89, title: 'Pitch Min (deg)' });
20
+ FreeCamera.attributes.add('pitchAngleMax', { type: 'number', default: 89, title: 'Pitch Max (deg)' });
21
+
22
+ // minY (altitude min du point CAMÉRA)
23
+ FreeCamera.attributes.add('minY', { type: 'number', default: 0, title: 'Minimum camera Y' });
24
+
25
+ // Vitesse de déplacement (m/s)
26
+ FreeCamera.attributes.add('moveSpeed', { type: 'number', default: 2.2, title: 'Move Speed' });
27
+ FreeCamera.attributes.add('strafeSpeed', { type: 'number', default: 2.2, title: 'Strafe Speed' });
28
+ FreeCamera.attributes.add('dollySpeed', { type: 'number', default: 2.0, title: 'Mouse/Pinch Dolly Speed' });
29
+
30
+ // Collision (caméra sphère)
31
+ FreeCamera.attributes.add('collisionRadius', { type: 'number', default: 0.30, title: 'Camera Sphere Radius' });
32
+ FreeCamera.attributes.add('collisionEpsilon', { type: 'number', default: 0.001, title: 'Collision Epsilon' });
33
+
34
+ // Sous-division du déplacement (swept)
35
+ FreeCamera.attributes.add('maxStepDistance', { type: 'number', default: 0.20, title: 'Max step distance (swept move)' });
36
+ FreeCamera.attributes.add('maxResolveIters', { type: 'number', default: 6, title: 'Max resolve iterations per step' });
37
+
38
+ // Construction des colliders
39
+ FreeCamera.attributes.add('mergeGap', { type: 'number', default: 0.03, title: 'Merge AABBs gap tolerance (m)' });
40
+ FreeCamera.attributes.add('inflateBias',{ type: 'number', default: 0.02, title: 'Extra inflate beyond radius (m)' });
41
+
42
+ // Bounding Box globale (active si Xmin<Xmax etc.)
43
+ FreeCamera.attributes.add('Xmin', { type: 'number', default: -Infinity, title: 'BBox Xmin' });
44
+ FreeCamera.attributes.add('Xmax', { type: 'number', default: Infinity, title: 'BBox Xmax' });
45
+ FreeCamera.attributes.add('Ymin', { type: 'number', default: -Infinity, title: 'BBox Ymin' });
46
+ FreeCamera.attributes.add('Ymax', { type: 'number', default: Infinity, title: 'BBox Ymax' });
47
+ FreeCamera.attributes.add('Zmin', { type: 'number', default: -Infinity, title: 'BBox Zmin' });
48
+ FreeCamera.attributes.add('Zmax', { type: 'number', default: Infinity, title: 'BBox Zmax' });
49
+
50
+ // Compat (certaines non utilisées, gardées pour le viewer)
51
+ FreeCamera.attributes.add('focusEntity', { type: 'entity', title: 'Collision Root (ENV GLB)' });
52
+ FreeCamera.attributes.add('frameOnStart', { type: 'boolean', default: false, title: 'Compat: Frame on Start (unused)' });
53
+ FreeCamera.attributes.add('yawAngleMin', { type: 'number', default: -360, title: 'Compat: Yaw Min (unused)' });
54
+ FreeCamera.attributes.add('yawAngleMax', { type: 'number', default: 360, title: 'Compat: Yaw Max (unused)' });
55
+ FreeCamera.attributes.add('distanceMin', { type: 'number', default: 0.1, title: 'Compat: Distance Min (unused)' });
56
+
57
+ // ======================== Initialisation ===========================
58
+ Object.defineProperty(FreeCamera.prototype, 'pitch', {
59
+ get: function () { return this._targetPitch; },
60
+ set: function (v) { this._targetPitch = pc.math.clamp(v, this.pitchAngleMin, this.pitchAngleMax); }
61
+ });
62
+ Object.defineProperty(FreeCamera.prototype, 'yaw', {
63
+ get: function () { return this._targetYaw; },
64
+ set: function (v) { this._targetYaw = v; } // yaw libre (pas de clamp)
65
+ });
66
+
67
+ FreeCamera.prototype.initialize = function () {
68
+ // angles init depuis l'orientation actuelle
69
+ var q = this.entity.getRotation();
70
+ var f = new pc.Vec3();
71
+ q.transformVector(pc.Vec3.FORWARD, f);
72
+
73
+ this._yaw = Math.atan2(f.x, f.z) * pc.math.RAD_TO_DEG;
74
+ var yawQuat = new pc.Quat().setFromEulerAngles(0, -this._yaw, 0);
75
+ var noYawQ = new pc.Quat().mul2(yawQuat, q);
76
+ var fNoYaw = new pc.Vec3();
77
+ noYawQ.transformVector(pc.Vec3.FORWARD, fNoYaw);
78
+ this._pitch = Math.atan2(-fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
79
+ this._pitch = pc.math.clamp(this._pitch, this.pitchAngleMin, this.pitchAngleMax);
80
+
81
+ this._targetYaw = this._yaw;
82
+ this._targetPitch = this._pitch;
83
+
84
+ // Appliquer orientation immédiatement
85
+ this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0);
86
+
87
+ // Etat input partagé
88
+ this.app.systems.script.app.freeCamState = this.app.systems.script.app.freeCamState || {};
89
+ this.state = this.app.systems.script.app.freeCamState;
90
+
91
+ // Construire colliders robustes depuis le GLB (focusEntity)
92
+ this._buildRobustColliders();
93
+
94
+ // S’assure que la position courante respecte collisions + minY + bbox
95
+ var p = this.entity.getPosition().clone();
96
+ p = this._moveSweptTo(p, p); // passe par le résolveur même si delta nul
97
+ this._clampPosition(p);
98
+ this.entity.setPosition(p);
99
+
100
+ // Aspect ratio (comme avant)
101
+ var self = this;
102
+ this._onResize = function(){ self._checkAspectRatio(); };
103
+ window.addEventListener('resize', this._onResize, false);
104
+ this._checkAspectRatio();
105
+ };
106
+
107
+ FreeCamera.prototype.update = function (dt) {
108
+ // Rotation inertielle
109
+ var t = this.inertiaFactor === 0 ? 1 : Math.min(dt / this.inertiaFactor, 1);
110
+ this._yaw = pc.math.lerp(this._yaw, this._targetYaw, t);
111
+ this._pitch = pc.math.lerp(this._pitch, this._targetPitch, t);
112
+ this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0);
113
+
114
+ // Recadrage léger
115
+ var pos = this.entity.getPosition().clone();
116
+ pos = this._resolveCollisions(pos, this.maxResolveIters);
117
+ this._clampPosition(pos);
118
+ this.entity.setPosition(pos);
119
+ };
120
+
121
+ FreeCamera.prototype._checkAspectRatio = function () {
122
+ var gd = this.app.graphicsDevice;
123
+ if (!gd) return;
124
+ this.entity.camera.horizontalFov = (gd.height > gd.width);
125
+ };
126
+
127
+ // ======================== Colliders robustes ===========================
128
+ FreeCamera.prototype._buildRobustColliders = function () {
129
+ this._colliders = []; // { aabb: BoundingBox }
130
+ this._worldAabb = null; // BoundingBox globale du décor (utile debug / clamp)
131
+
132
+ if (!this.focusEntity) return;
133
+
134
+ // 1) Récupère toutes les AABBs world des meshInstances du GLB
135
+ var tmp = [];
136
+ var stack = [this.focusEntity];
137
+ while (stack.length) {
138
+ var e = stack.pop();
139
+ var rc = e.render;
140
+ if (rc && rc.meshInstances && rc.meshInstances.length) {
141
+ for (var i = 0; i < rc.meshInstances.length; i++) {
142
+ var aabb = rc.meshInstances[i].aabb;
143
+ // clone
144
+ tmp.push(new pc.BoundingBox(aabb.center.clone(), aabb.halfExtents.clone()));
145
+ }
146
+ }
147
+ var ch = e.children;
148
+ if (ch && ch.length) for (var c = 0; c < ch.length; c++) stack.push(ch[c]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  }
150
 
151
+ if (tmp.length === 0) return;
152
+
153
+ // 2) Gonfle chaque AABB du rayon de la caméra + biais
154
+ var inflate = Math.max(0, this.collisionRadius) + Math.max(0, this.inflateBias);
155
+ for (var k = 0; k < tmp.length; k++) {
156
+ var bb = tmp[k];
157
+ bb.halfExtents.add(new pc.Vec3(inflate, inflate, inflate));
158
+ }
159
 
160
+ // 3) Fusionne les AABBs qui se touchent/quasi-se touchent (mergeGap)
161
+ var merged = this._mergeAabbs(tmp, this.mergeGap);
162
+
163
+ // 4) Enregistre
164
+ for (var m = 0; m < merged.length; m++) {
165
+ this._colliders.push({ aabb: merged[m] });
166
+ if (!this._worldAabb) {
167
+ this._worldAabb = new pc.BoundingBox(merged[m].center.clone(), merged[m].halfExtents.clone());
168
+ } else {
169
+ this._worldAabb.add(merged[m]);
170
+ }
171
+ }
172
+ };
173
+
174
+ // Fusion simple O(n^2) jusqu’à stabilisation
175
+ FreeCamera.prototype._mergeAabbs = function (boxes, gap) {
176
+ var out = boxes.slice();
177
+ var changed = true;
178
+ var g = Math.max(0, gap || 0);
179
+
180
+ function overlapsOrTouches(a, b, tol) {
181
+ var amin = a.getMin(), amax = a.getMax();
182
+ var bmin = b.getMin(), bmax = b.getMax();
183
+ return !(
184
+ amax.x < bmin.x - tol || amin.x > bmax.x + tol ||
185
+ amax.y < bmin.y - tol || amin.y > bmax.y + tol ||
186
+ amax.z < bmin.z - tol || amin.z > bmax.z + tol
187
+ );
188
+ }
189
+
190
+ while (changed) {
191
+ changed = false;
192
+ var next = [];
193
+ var used = new Array(out.length).fill(false);
194
+
195
+ for (var i = 0; i < out.length; i++) {
196
+ if (used[i]) continue;
197
+ var acc = out[i];
198
+ var mergedAny = false;
199
+
200
+ for (var j = i + 1; j < out.length; j++) {
201
+ if (used[j]) continue;
202
+ if (overlapsOrTouches(acc, out[j], g)) {
203
+ // fusion : créer une nouvelle AABB couvrant acc et out[j]
204
+ var accMin = acc.getMin(), accMax = acc.getMax();
205
+ var bMin = out[j].getMin(), bMax = out[j].getMax();
206
+
207
+ var nMin = new pc.Vec3(
208
+ Math.min(accMin.x, bMin.x),
209
+ Math.min(accMin.y, bMin.y),
210
+ Math.min(accMin.z, bMin.z)
211
+ );
212
+ var nMax = new pc.Vec3(
213
+ Math.max(accMax.x, bMax.x),
214
+ Math.max(accMax.y, bMax.y),
215
+ Math.max(accMax.z, bMax.z)
216
+ );
217
+
218
+ var nCenter = nMin.clone().add(nMax).mulScalar(0.5);
219
+ var nHalf = nMax.clone().sub(nCenter).abs();
220
+ acc = new pc.BoundingBox(nCenter, nHalf);
221
+
222
+ used[j] = true;
223
+ mergedAny = true;
224
+ changed = true;
225
+ }
226
+ }
227
+
228
+ used[i] = true;
229
+ next.push(acc);
230
+ }
231
+
232
+ out = next;
233
+ }
234
 
235
+ return out;
236
+ };
237
+
238
+ // ======================== Contraintes génériques ===========================
239
+ FreeCamera.prototype._bboxEnabled = function () {
240
+ return (this.Xmin < this.Xmax) && (this.Ymin < this.Ymax) && (this.Zmin < this.Zmax);
241
+ };
242
+
243
+ FreeCamera.prototype._clampPosition = function (p) {
244
+ // minY prioritaire
245
+ if (p.y < this.minY) p.y = this.minY;
246
+ if (!this._bboxEnabled()) return;
247
+
248
+ p.x = pc.math.clamp(p.x, this.Xmin, this.Xmax);
249
+ p.y = pc.math.clamp(p.y, Math.max(this.Ymin, this.minY), this.Ymax);
250
+ p.z = pc.math.clamp(p.z, this.Zmin, this.Zmax);
251
+ };
252
+
253
+ // ======================== Mouvement swept + résolution =====================
254
+ // Déplace "de -> to" par petits pas pour éviter le tunneling.
255
+ FreeCamera.prototype._moveSweptTo = function (from, to) {
256
+ var maxStep = Math.max(0.01, this.maxStepDistance || 0.2);
257
+ var delta = to.clone().sub(from);
258
+ var dist = delta.length();
259
+
260
+ if (dist <= maxStep) {
261
+ var p = from.clone().add(delta);
262
+ p = this._resolveCollisions(p, this.maxResolveIters);
263
+ return p;
264
  }
265
 
266
+ var steps = Math.ceil(dist / maxStep);
267
+ var stepVec = delta.divScalar(steps);
268
+ var cur = from.clone();
269
 
270
+ for (var i = 0; i < steps; i++) {
271
+ cur.add(stepVec);
272
+ cur = this._resolveCollisions(cur, this.maxResolveIters);
273
+ }
274
+ return cur;
275
+ };
276
+
277
+ // Résolution itérative sphère (caméra) vs AABBs "épaissies"
278
+ FreeCamera.prototype._resolveCollisions = function (pos, maxIters) {
279
+ var p = pos.clone();
280
+ var eps = Math.max(1e-7, this.collisionEpsilon);
281
+
282
+ if (!this._colliders || this._colliders.length === 0) return p;
283
+
284
+ var iters = Math.max(1, maxIters || 1);
285
+ for (var iter = 0; iter < iters; iter++) {
286
+ var moved = false;
287
+
288
+ for (var i = 0; i < this._colliders.length; i++) {
289
+ var aabb = this._colliders[i].aabb;
290
+
291
+ // Point le plus proche de p sur l'AABB
292
+ var min = aabb.getMin();
293
+ var max = aabb.getMax();
294
+
295
+ var cx = pc.math.clamp(p.x, min.x, max.x);
296
+ var cy = pc.math.clamp(p.y, min.y, max.y);
297
+ var cz = pc.math.clamp(p.z, min.z, max.z);
298
+
299
+ var dx = p.x - cx, dy = p.y - cy, dz = p.z - cz;
300
+ var distSq = dx*dx + dy*dy + dz*dz;
301
+
302
+ // Comme les AABBs sont déjà "épaissies" du rayon, ici on traite une sphère ~ de rayon ~0
303
+ // => il suffit de sortir du volume (point dans AABB élargie)
304
+ if (distSq < eps*eps && // p quasi dedans et très proche
305
+ (p.x > min.x - eps && p.x < max.x + eps &&
306
+ p.y > min.y - eps && p.y < max.y + eps &&
307
+ p.z > min.z - eps && p.z < max.z + eps)) {
308
+
309
+ // Choisir l'axe de moindre déplacement pour sortir
310
+ var ex = Math.min(Math.abs(p.x - min.x), Math.abs(max.x - p.x));
311
+ var ey = Math.min(Math.abs(p.y - min.y), Math.abs(max.y - p.y));
312
+ var ez = Math.min(Math.abs(p.z - min.z), Math.abs(max.z - p.z));
313
+
314
+ if (ex <= ey && ex <= ez) {
315
+ // pousser sur X
316
+ if (Math.abs(p.x - min.x) < Math.abs(max.x - p.x)) p.x = min.x - eps; else p.x = max.x + eps;
317
+ } else if (ey <= ex && ey <= ez) {
318
+ // pousser sur Y (gère bord/nez de marche)
319
+ if (Math.abs(p.y - min.y) < Math.abs(max.y - p.y)) p.y = Math.max(this.minY, min.y - eps); else p.y = max.y + eps;
320
+ } else {
321
+ // pousser sur Z
322
+ if (Math.abs(p.z - min.z) < Math.abs(max.z - p.z)) p.z = min.z - eps; else p.z = max.z + eps;
323
+ }
324
+ moved = true;
325
+ continue;
326
+ }
327
+
328
+ // Cas général : p dans l'AABB "épaissie" (ou très proche) -> projeter vers l'extérieur
329
+ var inside =
330
+ (p.x > min.x - eps && p.x < max.x + eps &&
331
+ p.y > min.y - eps && p.y < max.y + eps &&
332
+ p.z > min.z - eps && p.z < max.z + eps);
333
+
334
+ if (inside) {
335
+ var dxMin = Math.abs(p.x - min.x), dxMax = Math.abs(max.x - p.x);
336
+ var dyMin = Math.abs(p.y - min.y), dyMax = Math.abs(max.y - p.y);
337
+ var dzMin = Math.abs(p.z - min.z), dzMax = Math.abs(max.z - p.z);
338
+
339
+ var ax = Math.min(dxMin, dxMax);
340
+ var ay = Math.min(dyMin, dyMax);
341
+ var az = Math.min(dzMin, dzMax);
342
+
343
+ if (ax <= ay && ax <= az) {
344
+ // sortir par X
345
+ if (dxMin < dxMax) p.x = min.x - eps; else p.x = max.x + eps;
346
+ } else if (ay <= ax && ay <= az) {
347
+ // sortir par Y
348
+ if (dyMin < dyMax) p.y = Math.max(this.minY, min.y - eps); else p.y = max.y + eps;
349
+ } else {
350
+ // sortir par Z
351
+ if (dzMin < dzMax) p.z = min.z - eps; else p.z = max.z + eps;
352
+ }
353
+ moved = true;
354
+ }
355
+ }
356
+
357
+ if (!moved) break;
358
+ }
359
 
360
+ return p;
361
+ };
362
+
363
+ // ===================== INPUT SOURIS (rotation + molette dolly) =====================
364
+ var FreeCameraInputMouse = pc.createScript('orbitCameraInputMouse'); // garder le nom
365
+ FreeCameraInputMouse.attributes.add('lookSensitivity', { type: 'number', default: 0.3, title: 'Look Sensitivity' });
366
+ FreeCameraInputMouse.attributes.add('wheelSensitivity',{ type: 'number', default: 1.0, title: 'Wheel Sensitivity' });
367
+
368
+ FreeCameraInputMouse.prototype.initialize = function () {
369
+ this.freeCam = this.entity.script.orbitCamera; // instance FreeCamera
370
+ this.last = new pc.Vec2();
371
+ this.isLooking = false;
372
+
373
+ if (this.app.mouse) {
374
+ this.app.mouse.on(pc.EVENT_MOUSEDOWN, this.onMouseDown, this);
375
+ this.app.mouse.on(pc.EVENT_MOUSEUP, this.onMouseUp, this);
376
+ this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
377
+ this.app.mouse.on(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this);
378
+ this.app.mouse.disableContextMenu();
379
+ }
380
+ var self = this;
381
+ this._onOut = function(){ self.isLooking = false; };
382
+ window.addEventListener('mouseout', this._onOut, false);
383
+
384
+ this.on('destroy', () => {
385
+ if (this.app.mouse) {
386
+ this.app.mouse.off(pc.EVENT_MOUSEDOWN, this.onMouseDown, this);
387
+ this.app.mouse.off(pc.EVENT_MOUSEUP, this.onMouseUp, this);
388
+ this.app.mouse.off(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
389
+ this.app.mouse.off(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this);
390
+ }
391
+ window.removeEventListener('mouseout', this._onOut, false);
392
  });
393
+ };
394
+
395
+ FreeCameraInputMouse.prototype.onMouseDown = function (e) {
396
+ this.isLooking = true;
397
+ this.last.set(e.x, e.y);
398
+ };
399
+
400
+ FreeCameraInputMouse.prototype.onMouseUp = function () { this.isLooking = false; };
401
+
402
+ FreeCameraInputMouse.prototype.onMouseMove = function (e) {
403
+ if (!this.isLooking || !this.freeCam) return;
404
+ var sens = this.lookSensitivity;
405
+ this.freeCam.yaw = this.freeCam.yaw - e.dx * sens;
406
+ this.freeCam.pitch = this.freeCam.pitch - e.dy * sens;
407
+ this.last.set(e.x, e.y);
408
+ };
409
+
410
+ FreeCameraInputMouse.prototype.onMouseWheel = function (e) {
411
+ if (!this.freeCam) return;
412
+
413
+ var cam = this.entity;
414
+ var move = -e.wheelDelta * this.wheelSensitivity * this.freeCam.dollySpeed * 0.05;
415
+ var forward = cam.forward.clone(); if (forward.lengthSq() > 1e-8) forward.normalize();
416
+
417
+ var from = cam.getPosition().clone();
418
+ var to = from.clone().add(forward.mulScalar(move));
419
+
420
+ var next = this.freeCam._moveSweptTo(from, to);
421
+ this.freeCam._clampPosition(next);
422
+ cam.setPosition(next);
423
+
424
+ e.event.preventDefault();
425
+ };
426
+
427
+ // ===================== INPUT TOUCH (look + pinch dolly) =====================
428
+ var FreeCameraInputTouch = pc.createScript('orbitCameraInputTouch');
429
+ FreeCameraInputTouch.attributes.add('lookSensitivity', { type: 'number', default: 0.5, title: 'Look Sensitivity' });
430
+ FreeCameraInputTouch.attributes.add('pinchDollyFactor', { type: 'number', default: 0.02, title: 'Pinch Dolly Factor' });
431
+
432
+ FreeCameraInputTouch.prototype.initialize = function () {
433
+ this.freeCam = this.entity.script.orbitCamera;
434
+ this.last = new pc.Vec2();
435
+ this.isLooking = false;
436
+ this.lastPinch = 0;
437
+
438
+ if (this.app.touch) {
439
+ this.app.touch.on(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this);
440
+ this.app.touch.on(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this);
441
+ this.app.touch.on(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this);
442
+ this.app.touch.on(pc.EVENT_TOUCHMOVE, this.onTouchMove, this);
443
+ }
444
+
445
+ this.on('destroy', () => {
446
+ if (this.app.touch) {
447
+ this.app.touch.off(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this);
448
+ this.app.touch.off(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this);
449
+ this.app.touch.off(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this);
450
+ this.app.touch.off(pc.EVENT_TOUCHMOVE, this.onTouchMove, this);
451
+ }
452
+ });
453
+ };
454
+
455
+ FreeCameraInputTouch.prototype.onTouchStartEndCancel = function (e) {
456
+ var t = e.touches;
457
+ if (t.length === 1) {
458
+ this.isLooking = (e.event.type === 'touchstart');
459
+ this.last.set(t[0].x, t[0].y);
460
+ } else if (t.length === 2) {
461
+ var dx = t[0].x - t[1].x;
462
+ var dy = t[0].y - t[1].y;
463
+ this.lastPinch = Math.sqrt(dx*dx + dy*dy);
464
+ } else {
465
+ this.isLooking = false;
466
+ }
467
+ };
468
+
469
+ FreeCameraInputTouch.prototype.onTouchMove = function (e) {
470
+ var t = e.touches;
471
+ if (!this.freeCam) return;
472
+
473
+ if (t.length === 1 && this.isLooking) {
474
+ var sens = this.lookSensitivity;
475
+ var dx = t[0].x - this.last.x;
476
+ var dy = t[0].y - this.last.y;
477
+
478
+ this.freeCam.yaw = this.freeCam.yaw - dx * sens;
479
+ this.freeCam.pitch = this.freeCam.pitch - dy * sens;
480
+
481
+ this.last.set(t[0].x, t[0].y);
482
+ } else if (t.length === 2) {
483
+ // Pinch dolly
484
+ var dx = t[0].x - t[1].x;
485
+ var dy = t[0].y - t[1].y;
486
+ var dist = Math.sqrt(dx*dx + dy*dy);
487
+ var delta = dist - this.lastPinch;
488
+ this.lastPinch = dist;
489
+
490
+ var cam = this.entity;
491
+ var forward = cam.forward.clone(); if (forward.lengthSq() > 1e-8) forward.normalize();
492
+ var from = cam.getPosition().clone();
493
+ var to = from.clone().add(forward.mulScalar(delta * this.pinchDollyFactor * this.freeCam.dollySpeed));
494
+
495
+ var next = this.freeCam._moveSweptTo(from, to);
496
+ this.freeCam._clampPosition(next);
497
+ cam.setPosition(next);
 
 
 
 
 
 
498
  }
499
+ };
500
 
501
+ // ===================== INPUT CLAVIER (ZQSD + flèches) =====================
502
+ var FreeCameraInputKeyboard = pc.createScript('orbitCameraInputKeyboard');
503
+
504
+ FreeCameraInputKeyboard.attributes.add('acceleration', { type: 'number', default: 1.0, title: 'Accel (unused, future)' });
505
+
506
+ FreeCameraInputKeyboard.prototype.initialize = function () {
507
+ this.freeCam = this.entity.script.orbitCamera;
508
+ this.kb = this.app.keyboard || null;
509
+ };
510
+
511
+ FreeCameraInputKeyboard.prototype.update = function (dt) {
512
+ if (!this.freeCam || !this.kb) return;
513
+
514
+ // Déplacements : flèches OU ZQSD (AZERTY)
515
+ var fwd = (this.kb.isPressed(pc.KEY_UP) || this.kb.isPressed(pc.KEY_Z)) ? 1 :
516
+ (this.kb.isPressed(pc.KEY_DOWN) || this.kb.isPressed(pc.KEY_S)) ? -1 : 0;
517
+
518
+ var strf = (this.kb.isPressed(pc.KEY_RIGHT) || this.kb.isPressed(pc.KEY_D)) ? 1 :
519
+ (this.kb.isPressed(pc.KEY_LEFT) || this.kb.isPressed(pc.KEY_Q)) ? -1 : 0;
520
+
521
+ if (fwd !== 0 || strf !== 0) {
522
+ var cam = this.entity;
523
+ var from = cam.getPosition().clone();
524
+
525
+ var forward = cam.forward.clone(); if (forward.lengthSq() > 1e-8) forward.normalize();
526
+ var right = cam.right.clone(); if (right.lengthSq() > 1e-8) right.normalize();
527
+
528
+ var delta = new pc.Vec3();
529
+ delta.add(forward.mulScalar(fwd * this.freeCam.moveSpeed * dt));
530
+ delta.add(right .mulScalar(strf * this.freeCam.strafeSpeed * dt));
531
+
532
+ var to = from.clone().add(delta);
533
+ var next = this.freeCam._moveSweptTo(from, to);
534
+ this.freeCam._clampPosition(next);
535
+ cam.setPosition(next);
536
+ }
537
+
538
+ // Rotation au clavier (Shift + flèches)
539
+ var shift = this.kb.isPressed(pc.KEY_SHIFT);
540
+ if (shift) {
541
+ var yawDir = (this.kb.isPressed(pc.KEY_LEFT) ? 1 : 0) - (this.kb.isPressed(pc.KEY_RIGHT) ? 1 : 0); // ← CCW / → CW
542
+ var pitchDir = (this.kb.isPressed(pc.KEY_UP) ? 1 : 0) - (this.kb.isPressed(pc.KEY_DOWN) ? 1 : 0); // ↑ up / ↓ down
543
+
544
+ var yawSpeed = 120; // deg/s
545
+ var pitchSpeed = 90; // deg/s
546
+ if (yawDir !== 0) this.freeCam.yaw = this.freeCam.yaw + yawDir * yawSpeed * dt;
547
+ if (pitchDir !== 0) this.freeCam.pitch = this.freeCam.pitch + pitchDir * pitchSpeed * dt;
548
+ }
549
+ };