@popaya/pgsg-viewer 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  // src/viewers/three/pgsg-three-viewer.ts
2
- import * as THREE4 from "three";
3
- import { GLTFLoader } from "three/examples/jsm/Addons.js";
2
+ import * as THREE5 from "three";
3
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4
+ import { TransformControls } from "three/addons/controls/TransformControls.js";
5
+ import * as RAPIER2 from "@dimforge/rapier3d-compat";
4
6
  import nipplejs from "nipplejs";
5
7
 
6
8
  // src/viewers/spark/SparkSplatRenderer.ts
@@ -40,119 +42,142 @@ var SparkSplatRenderer = class {
40
42
  }
41
43
  };
42
44
 
43
- // src/viewers/three/controls/WalkCapsuleController.ts
45
+ // src/viewers/three/controls/RapierWalkCapsuleController.ts
44
46
  import * as THREE from "three";
45
- var WalkCapsuleController = class {
47
+ import * as RAPIER from "@dimforge/rapier3d-compat";
48
+ var RapierWalkController = class {
46
49
  dom;
47
50
  camera;
48
- colliders;
49
- eyeHeight;
51
+ world;
52
+ body;
53
+ collider;
50
54
  radius;
51
- gravity;
52
- walkSpeed;
53
- runMultiplier;
55
+ halfHeight;
56
+ eyeHeight;
57
+ moveSpeed;
58
+ jumpSpeed;
54
59
  lookSensitivity;
60
+ usePointerLock;
55
61
  yaw = 0;
56
62
  pitch = 0;
57
- velocity = new THREE.Vector3();
58
- onGround = false;
59
- lastGroundY = null;
60
63
  keys = /* @__PURE__ */ new Set();
61
- raycaster = new THREE.Raycaster();
62
- usePointerLock = false;
63
- lastTouchX = 0;
64
- lastTouchY = 0;
65
- externalMove = new THREE.Vector2(0, 0);
66
- useGyro = false;
67
- gyroAlpha = 0;
68
- gyroBeta = 0;
69
- gyroGamma = 0;
64
+ moveVec = new THREE.Vector2(0, 0);
65
+ // joystick: x=forward, y=right
66
+ groundProbeExtra;
67
+ groundSnapMax;
68
+ // ---- bound handlers (so dispose works) ----
69
+ onClickBound = () => this.onClick();
70
+ onMouseMoveBound = (e) => this.onMouseMove(e);
71
+ onKeyDownBound = (e) => this.onKeyDown(e);
72
+ onKeyUpBound = (e) => this.onKeyUp(e);
73
+ onBlurBound = () => this.onBlur();
70
74
  constructor(opts) {
71
75
  this.dom = opts.dom;
72
76
  this.camera = opts.camera;
73
- this.colliders = opts.colliders;
74
- console.log("colliders", this.colliders.length);
77
+ this.world = opts.world;
78
+ this.radius = opts.radius ?? 0.2;
79
+ this.halfHeight = opts.halfHeight ?? 0.6;
75
80
  this.eyeHeight = opts.eyeHeight ?? 1.6;
76
- this.radius = opts.radius ?? 0.35;
77
- this.gravity = opts.gravity ?? 9.8;
78
- this.walkSpeed = opts.walkSpeed ?? 2.5;
79
- this.runMultiplier = opts.runMultiplier ?? 2;
81
+ this.moveSpeed = opts.moveSpeed ?? 3;
82
+ this.jumpSpeed = opts.jumpSpeed ?? 6;
80
83
  this.lookSensitivity = opts.lookSensitivity ?? 22e-4;
81
- this.usePointerLock = opts.usePointerLock ?? false;
84
+ this.usePointerLock = opts.usePointerLock ?? true;
85
+ this.groundProbeExtra = opts.groundProbeExtra ?? 0.25;
86
+ this.groundSnapMax = opts.groundSnapMax ?? 0.65;
82
87
  const euler = new THREE.Euler().setFromQuaternion(
83
88
  this.camera.quaternion,
84
89
  "YXZ"
85
90
  );
86
91
  this.pitch = euler.x;
87
92
  this.yaw = euler.y;
88
- if (this.usePointerLock === false) {
89
- this.bindTouch();
90
- } else {
91
- this.bind();
92
- }
93
+ const start = this.camera.position.clone();
94
+ const centerY = start.y - this.eyeHeight + (this.halfHeight + this.radius);
95
+ const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setTranslation(start.x, centerY, start.z).lockRotations().setLinearDamping(6).setCcdEnabled(true);
96
+ this.body = this.world.createRigidBody(bodyDesc);
97
+ const colDesc = RAPIER.ColliderDesc.capsule(this.halfHeight, this.radius).setFriction(0.8).setRestitution(0);
98
+ this.collider = this.world.createCollider(colDesc, this.body);
99
+ this.collider.setActiveEvents?.(RAPIER.ActiveEvents.COLLISION_EVENTS);
100
+ this.bindInput();
93
101
  }
94
102
  dispose() {
95
- if (this.usePointerLock === false) {
96
- this.unbindTouch();
97
- } else {
98
- this.unbind();
99
- if (this.usePointerLock) document.exitPointerLock();
103
+ this.unbindInput();
104
+ if (this.body) {
105
+ this.world.removeRigidBody(this.body);
100
106
  }
101
107
  }
108
+ // ---------------------------
109
+ // External movement (joystick)
110
+ // x = forward/back (-1..1), y = right/left (-1..1)
111
+ // ---------------------------
102
112
  setMovementVector(x, y) {
103
- this.externalMove.set(x, y);
113
+ this.moveVec.set(x, y);
104
114
  }
105
- enableGyro() {
106
- if (typeof DeviceOrientationEvent === "undefined") return;
107
- if (DeviceOrientationEvent.requestPermission) {
108
- DeviceOrientationEvent.requestPermission().then((response) => {
109
- if (response === "granted") {
110
- this.startGyro();
111
- }
112
- }).catch(console.error);
113
- } else {
114
- this.startGyro();
115
- }
116
- }
117
- // --------------------------------------------------
118
- // Input binding (SAFE for pointer lock)
119
- // --------------------------------------------------
120
- bind() {
121
- this.dom.addEventListener("click", this.onClick);
122
- window.addEventListener("mousemove", this.onMouseMove);
123
- window.addEventListener("keydown", this.onKeyDown);
124
- window.addEventListener("keyup", this.onKeyUp);
125
- window.addEventListener("blur", this.onBlur);
126
- this.dom.addEventListener("contextmenu", (e) => e.preventDefault());
127
- }
128
- bindTouch() {
129
- this.dom.addEventListener("touchstart", this.onTouchStart);
130
- this.dom.addEventListener("touchmove", this.onTouchMove);
115
+ setRotation(yaw, pitch) {
116
+ this.yaw = yaw;
117
+ this.pitch = THREE.MathUtils.clamp(
118
+ pitch,
119
+ -Math.PI / 2 + 0.01,
120
+ Math.PI / 2 - 0.01
121
+ );
122
+ this.camera.quaternion.setFromEuler(
123
+ new THREE.Euler(this.pitch, this.yaw, 0, "YXZ")
124
+ );
131
125
  }
132
- unbind() {
133
- this.dom.removeEventListener("click", this.onClick);
134
- window.removeEventListener("mousemove", this.onMouseMove);
135
- window.removeEventListener("keydown", this.onKeyDown);
136
- window.removeEventListener("keyup", this.onKeyUp);
137
- window.removeEventListener("blur", this.onBlur);
126
+ resetVelocity() {
127
+ this.body.setLinvel({ x: 0, y: 0, z: 0 }, true);
128
+ this.body.setAngvel({ x: 0, y: 0, z: 0 }, true);
138
129
  }
139
- unbindTouch() {
140
- this.dom.removeEventListener("touchstart", this.onTouchStart);
141
- this.dom.removeEventListener("touchmove", this.onTouchMove);
130
+ setPositionFromCamera() {
131
+ const p = this.camera.position;
132
+ const centerY = p.y - this.eyeHeight + (this.halfHeight + this.radius);
133
+ this.body.setTranslation({ x: p.x, y: centerY, z: p.z }, true);
134
+ this.resetVelocity();
142
135
  }
143
- onClick = () => {
144
- if (this.usePointerLock && document.pointerLockElement !== this.dom) {
145
- this.dom.requestPointerLock();
146
- }
147
- };
148
- onBlur = () => {
136
+ // Snap the capsule so its feet rest on the nearest floor below
137
+ snapToGround() {
138
+ const p = this.body.translation();
139
+ const origin = { x: p.x, y: p.y + 2, z: p.z };
140
+ const dir = { x: 0, y: -1, z: 0 };
141
+ const ray = new RAPIER.Ray(origin, dir);
142
+ const maxDist = 50;
143
+ const hit = this.castRayWithNormal(ray, maxDist);
144
+ if (!hit) return;
145
+ const toi = hit.timeOfImpact;
146
+ const hitY = origin.y - toi;
147
+ const newCenterY = hitY + (this.halfHeight + this.radius) + 0.02;
148
+ this.body.setTranslation({ x: p.x, y: newCenterY, z: p.z }, true);
149
+ this.resetVelocity();
150
+ }
151
+ // ---------------------------
152
+ // Input binding
153
+ // ---------------------------
154
+ bindInput() {
149
155
  if (this.usePointerLock) {
150
- document.exitPointerLock();
156
+ this.dom.addEventListener("click", this.onClickBound);
157
+ window.addEventListener("mousemove", this.onMouseMoveBound);
151
158
  }
159
+ window.addEventListener("keydown", this.onKeyDownBound);
160
+ window.addEventListener("keyup", this.onKeyUpBound);
161
+ window.addEventListener("blur", this.onBlurBound);
162
+ }
163
+ unbindInput() {
164
+ this.dom.removeEventListener("click", this.onClickBound);
165
+ window.removeEventListener("mousemove", this.onMouseMoveBound);
166
+ window.removeEventListener("keydown", this.onKeyDownBound);
167
+ window.removeEventListener("keyup", this.onKeyUpBound);
168
+ window.removeEventListener("blur", this.onBlurBound);
169
+ }
170
+ onClick() {
171
+ if (!this.usePointerLock) return;
172
+ if (document.pointerLockElement !== this.dom) this.dom.requestPointerLock();
173
+ }
174
+ onBlur() {
152
175
  this.keys.clear();
153
- };
154
- onMouseMove = (e) => {
155
- if (this.usePointerLock && document.pointerLockElement !== this.dom) return;
176
+ this.moveVec.set(0, 0);
177
+ }
178
+ onMouseMove(e) {
179
+ if (!this.usePointerLock) return;
180
+ if (document.pointerLockElement !== this.dom) return;
156
181
  this.yaw -= e.movementX * this.lookSensitivity;
157
182
  this.pitch -= e.movementY * this.lookSensitivity;
158
183
  this.pitch = THREE.MathUtils.clamp(
@@ -163,171 +188,86 @@ var WalkCapsuleController = class {
163
188
  this.camera.quaternion.setFromEuler(
164
189
  new THREE.Euler(this.pitch, this.yaw, 0, "YXZ")
165
190
  );
166
- };
167
- onKeyDown = (e) => {
191
+ }
192
+ onKeyDown(e) {
168
193
  this.keys.add(e.key.toLowerCase());
169
- };
170
- onKeyUp = (e) => {
194
+ }
195
+ onKeyUp(e) {
171
196
  this.keys.delete(e.key.toLowerCase());
172
- };
173
- onTouchStart = (e) => {
174
- const t = e.touches[0];
175
- this.lastTouchX = t.clientX;
176
- this.lastTouchY = t.clientY;
177
- };
178
- onTouchMove = (e) => {
179
- const t = e.touches[0];
180
- const deltaX = t.clientX - this.lastTouchX;
181
- const deltaY = t.clientY - this.lastTouchY;
182
- this.lastTouchX = t.clientX;
183
- this.lastTouchY = t.clientY;
184
- this.yaw -= deltaX * 2e-3;
185
- this.pitch -= deltaY * 2e-3;
186
- this.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.pitch));
187
- this.camera.quaternion.setFromEuler(
188
- new THREE.Euler(this.pitch, this.yaw, 0, "YXZ")
189
- );
190
- };
191
- startGyro() {
192
- this.useGyro = true;
193
- window.addEventListener("deviceorientation", (e) => {
194
- if (!e.alpha || !e.beta) return;
195
- this.gyroAlpha = THREE.MathUtils.degToRad(e.alpha);
196
- this.gyroBeta = THREE.MathUtils.degToRad(e.beta);
197
- this.gyroGamma = THREE.MathUtils.degToRad(e.gamma ?? 0);
198
- });
199
197
  }
200
- // --------------------------------------------------
201
- // Update loop
202
- // --------------------------------------------------
203
- update(dt) {
204
- if (this.useGyro) {
205
- this.yaw = THREE.MathUtils.lerp(this.yaw, -this.gyroAlpha, 0.1);
206
- this.pitch = THREE.MathUtils.lerp(
207
- this.pitch,
208
- this.gyroBeta - Math.PI / 2,
209
- 0.1
210
- );
211
- this.camera.quaternion.setFromEuler(
212
- new THREE.Euler(this.pitch, this.yaw, 0, "YXZ")
213
- );
214
- }
215
- if (!Number.isFinite(this.camera.position.y)) return;
216
- dt = Math.min(dt, 0.05);
217
- const forward = new THREE.Vector3();
218
- this.camera.getWorldDirection(forward);
219
- forward.y = 0;
220
- if (forward.lengthSq() < 0.01) {
221
- forward.set(0, 0, -1).applyQuaternion(new THREE.Quaternion().setFromEuler(new THREE.Euler(0, this.yaw, 0)));
222
- }
223
- forward.normalize();
224
- const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
198
+ // ---------------------------
199
+ // Update
200
+ // ---------------------------
201
+ update(_dt) {
202
+ const forward = new THREE.Vector3(0, 0, -1).applyAxisAngle(
203
+ new THREE.Vector3(0, 1, 0),
204
+ this.yaw
205
+ );
206
+ const right = new THREE.Vector3(1, 0, 0).applyAxisAngle(
207
+ new THREE.Vector3(0, 1, 0),
208
+ this.yaw
209
+ );
225
210
  const wish = new THREE.Vector3();
226
211
  if (this.keys.has("w")) wish.add(forward);
227
212
  if (this.keys.has("s")) wish.sub(forward);
228
213
  if (this.keys.has("d")) wish.add(right);
229
214
  if (this.keys.has("a")) wish.sub(right);
230
- if (this.externalMove.lengthSq() > 0) {
231
- wish.addScaledVector(forward, this.externalMove.x);
232
- wish.addScaledVector(right, this.externalMove.y);
233
- }
234
- if (wish.lengthSq() > 0) wish.normalize();
235
- const speed = this.walkSpeed * (this.keys.has("shift") ? this.runMultiplier : 1);
236
- this.velocity.x = wish.x * speed;
237
- this.velocity.z = wish.z * speed;
238
- this.velocity.y -= this.gravity * dt;
239
- const nextPos = this.camera.position.clone();
240
- nextPos.x += this.velocity.x * dt;
241
- nextPos.z += this.velocity.z * dt;
242
- nextPos.y += this.velocity.y * dt;
243
- const resolved = this.resolveGroundAndCollisions(nextPos);
244
- this.camera.position.copy(resolved);
245
- }
246
- snapToGround() {
247
- const origin = this.camera.position.clone();
248
- origin.y += 5;
249
- this.raycaster.set(origin, new THREE.Vector3(0, -1, 0));
250
- this.raycaster.far = 50;
251
- const hits = this.raycaster.intersectObjects(this.colliders, true);
252
- for (const hit of hits) {
253
- if (!hit.face) continue;
254
- const normalMatrix = new THREE.Matrix3().getNormalMatrix(
255
- hit.object.matrixWorld
256
- );
257
- const worldNormal = hit.face.normal.clone().applyMatrix3(normalMatrix).normalize();
258
- if (worldNormal.y > 0.5) {
259
- this.lastGroundY = hit.point.y;
260
- this.camera.position.y = this.lastGroundY + this.eyeHeight;
261
- this.velocity.y = 0;
262
- this.onGround = true;
263
- return;
264
- }
215
+ if (this.moveVec.lengthSq() > 1e-6) {
216
+ wish.addScaledVector(forward, this.moveVec.x);
217
+ wish.addScaledVector(right, this.moveVec.y);
265
218
  }
266
- this.lastGroundY = this.camera.position.y - this.eyeHeight;
267
- }
268
- // --------------------------------------------------
269
- // Collision & Ground
270
- // --------------------------------------------------
271
- resolveGroundAndCollisions(desiredCamPos) {
272
- const camPos = desiredCamPos.clone();
273
- const maxRayDown = 20;
274
- const origin = camPos.clone();
275
- origin.y += 5;
276
- this.raycaster.set(origin, new THREE.Vector3(0, -1, 0));
277
- this.raycaster.near = 0;
278
- this.raycaster.far = maxRayDown;
279
- const hits = this.raycaster.intersectObjects(this.colliders, true);
280
- let groundHit = null;
281
- for (const hit of hits) {
282
- if (!hit.face) continue;
283
- const normalMatrix = new THREE.Matrix3().getNormalMatrix(
284
- hit.object.matrixWorld
285
- );
286
- const worldNormal = hit.face.normal.clone().applyMatrix3(normalMatrix).normalize();
287
- if (worldNormal.y > 0.5) {
288
- groundHit = hit;
289
- break;
219
+ if (wish.lengthSq() > 1e-8) wish.normalize();
220
+ const vel = this.body.linvel();
221
+ const targetVX = wish.x * this.moveSpeed;
222
+ const targetVZ = wish.z * this.moveSpeed;
223
+ this.body.setLinvel(
224
+ {
225
+ x: targetVX,
226
+ y: vel.y,
227
+ z: targetVZ
228
+ },
229
+ true
230
+ // wake up
231
+ );
232
+ if (this.keys.has(" ")) {
233
+ if (this.isGrounded()) {
234
+ this.body.setLinvel(
235
+ { x: targetVX, y: this.jumpSpeed, z: targetVZ },
236
+ true
237
+ );
290
238
  }
291
239
  }
292
- if (groundHit) {
293
- const groundY = groundHit.point.y;
294
- const targetY = groundY + this.eyeHeight;
295
- this.lastGroundY = groundY;
296
- const falling = this.velocity.y <= 0;
297
- const maxSnap = 1;
298
- if (falling && camPos.y <= targetY + maxSnap) {
299
- camPos.y = targetY;
300
- this.velocity.y = 0;
301
- this.onGround = true;
302
- } else {
303
- this.onGround = false;
304
- }
305
- } else {
306
- if (this.lastGroundY !== null) {
307
- camPos.y = this.lastGroundY + this.eyeHeight;
308
- this.velocity.y = 0;
309
- this.onGround = true;
310
- } else {
311
- this.onGround = false;
240
+ const p = this.body.translation();
241
+ const feetY = p.y - (this.halfHeight + this.radius);
242
+ this.camera.position.set(p.x, feetY + this.eyeHeight, p.z);
243
+ }
244
+ // ---------------------------
245
+ // Grounding
246
+ // ---------------------------
247
+ isGrounded() {
248
+ const p = this.body.translation();
249
+ const origin = { x: p.x, y: p.y, z: p.z };
250
+ const dir = { x: 0, y: -1, z: 0 };
251
+ const ray = new RAPIER.Ray(origin, dir);
252
+ const maxDist = this.halfHeight + this.radius + this.groundProbeExtra;
253
+ const hit = this.castRayWithNormal(ray, maxDist);
254
+ if (!hit) return false;
255
+ const ny = hit.normal?.y ?? 0;
256
+ return ny > 0.35;
257
+ }
258
+ // Ray cast helper with “exclude self” when possible
259
+ castRayWithNormal(ray, maxDist) {
260
+ const anyRapier = RAPIER;
261
+ try {
262
+ if (anyRapier.QueryFilter) {
263
+ const filter = new anyRapier.QueryFilter();
264
+ if (filter.excludeCollider) filter.excludeCollider(this.collider);
265
+ else if (filter.excludeRigidBody) filter.excludeRigidBody(this.body);
266
+ return this.world.castRayAndGetNormal(ray, maxDist, true, filter);
312
267
  }
268
+ } catch {
313
269
  }
314
- const center = camPos.clone();
315
- center.y -= this.eyeHeight * 0.9;
316
- const dirs = [
317
- new THREE.Vector3(1, 0, 0),
318
- new THREE.Vector3(-1, 0, 0),
319
- new THREE.Vector3(0, 0, 1),
320
- new THREE.Vector3(0, 0, -1)
321
- ];
322
- for (const d of dirs) {
323
- this.raycaster.set(center, d);
324
- this.raycaster.far = this.radius + 0.25;
325
- const wallHits = this.raycaster.intersectObjects(this.colliders, true);
326
- if (!wallHits.length) continue;
327
- const overlap = this.radius + 0.25 - wallHits[0].distance;
328
- if (overlap > 0) camPos.addScaledVector(d, -overlap);
329
- }
330
- return camPos;
270
+ return this.world.castRayAndGetNormal(ray, maxDist, true);
331
271
  }
332
272
  };
333
273
 
@@ -904,78 +844,255 @@ var MeasurementController = class {
904
844
  }
905
845
  };
906
846
 
847
+ // src/viewers/three/controls/FloorplanController.ts
848
+ import * as THREE4 from "three";
849
+ var FloorplanController = class {
850
+ dom;
851
+ camera;
852
+ target;
853
+ height;
854
+ panSpeed;
855
+ zoomSpeed;
856
+ minHeight;
857
+ maxHeight;
858
+ isDragging = false;
859
+ lastX = 0;
860
+ lastY = 0;
861
+ // touch
862
+ touchMode = "none";
863
+ lastDist = 0;
864
+ constructor(opts) {
865
+ this.dom = opts.dom;
866
+ this.camera = opts.camera;
867
+ this.target = opts.target.clone();
868
+ this.height = opts.height;
869
+ this.panSpeed = opts.panSpeed ?? 0.01;
870
+ this.zoomSpeed = opts.zoomSpeed ?? 1.1;
871
+ this.minHeight = opts.minHeight ?? opts.height * 0.25;
872
+ this.maxHeight = opts.maxHeight ?? opts.height * 3;
873
+ this.applyCamera();
874
+ this.bind();
875
+ }
876
+ dispose() {
877
+ this.unbind();
878
+ }
879
+ update(_dt) {
880
+ this.applyCamera();
881
+ }
882
+ setTarget(t) {
883
+ this.target.copy(t);
884
+ this.applyCamera();
885
+ }
886
+ applyCamera() {
887
+ this.height = THREE4.MathUtils.clamp(
888
+ this.height,
889
+ this.minHeight,
890
+ this.maxHeight
891
+ );
892
+ this.camera.position.set(
893
+ this.target.x,
894
+ this.target.y + this.height,
895
+ this.target.z
896
+ );
897
+ this.camera.up.set(0, 0, -1);
898
+ this.camera.lookAt(this.target);
899
+ }
900
+ bind() {
901
+ this.dom.addEventListener("contextmenu", (e) => e.preventDefault());
902
+ this.dom.addEventListener("mousedown", this.onMouseDown);
903
+ window.addEventListener("mousemove", this.onMouseMove);
904
+ window.addEventListener("mouseup", this.onMouseUp);
905
+ this.dom.addEventListener("wheel", this.onWheel, { passive: false });
906
+ this.dom.addEventListener("touchstart", this.onTouchStart, {
907
+ passive: false
908
+ });
909
+ this.dom.addEventListener("touchmove", this.onTouchMove, {
910
+ passive: false
911
+ });
912
+ this.dom.addEventListener("touchend", this.onTouchEnd);
913
+ }
914
+ unbind() {
915
+ this.dom.removeEventListener("mousedown", this.onMouseDown);
916
+ window.removeEventListener("mousemove", this.onMouseMove);
917
+ window.removeEventListener("mouseup", this.onMouseUp);
918
+ this.dom.removeEventListener("wheel", this.onWheel);
919
+ this.dom.removeEventListener("touchstart", this.onTouchStart);
920
+ this.dom.removeEventListener("touchmove", this.onTouchMove);
921
+ this.dom.removeEventListener("touchend", this.onTouchEnd);
922
+ }
923
+ onMouseDown = (e) => {
924
+ if (e.button !== 0) return;
925
+ this.isDragging = true;
926
+ this.lastX = e.clientX;
927
+ this.lastY = e.clientY;
928
+ };
929
+ onMouseUp = () => {
930
+ this.isDragging = false;
931
+ };
932
+ onMouseMove = (e) => {
933
+ if (!this.isDragging) return;
934
+ const dx = e.clientX - this.lastX;
935
+ const dy = e.clientY - this.lastY;
936
+ this.lastX = e.clientX;
937
+ this.lastY = e.clientY;
938
+ this.target.x -= dx * this.panSpeed * this.height;
939
+ this.target.z += dy * this.panSpeed * this.height;
940
+ this.applyCamera();
941
+ };
942
+ onWheel = (e) => {
943
+ e.preventDefault();
944
+ const factor = Math.pow(this.zoomSpeed, e.deltaY / 100);
945
+ this.height *= factor;
946
+ this.applyCamera();
947
+ };
948
+ onTouchStart = (e) => {
949
+ if (e.touches.length === 1) {
950
+ this.touchMode = "pan";
951
+ this.lastX = e.touches[0].clientX;
952
+ this.lastY = e.touches[0].clientY;
953
+ } else if (e.touches.length === 2) {
954
+ this.touchMode = "pinch";
955
+ this.lastDist = this.touchDistance(e.touches[0], e.touches[1]);
956
+ }
957
+ };
958
+ onTouchMove = (e) => {
959
+ e.preventDefault();
960
+ if (this.touchMode === "pan" && e.touches.length === 1) {
961
+ const x = e.touches[0].clientX;
962
+ const y = e.touches[0].clientY;
963
+ const dx = x - this.lastX;
964
+ const dy = y - this.lastY;
965
+ this.lastX = x;
966
+ this.lastY = y;
967
+ this.target.x -= dx * this.panSpeed * this.height;
968
+ this.target.z += dy * this.panSpeed * this.height;
969
+ this.applyCamera();
970
+ }
971
+ if (this.touchMode === "pinch" && e.touches.length === 2) {
972
+ const dist = this.touchDistance(e.touches[0], e.touches[1]);
973
+ const ratio = this.lastDist / Math.max(1e-6, dist);
974
+ this.lastDist = dist;
975
+ this.height *= ratio;
976
+ this.applyCamera();
977
+ }
978
+ };
979
+ onTouchEnd = () => {
980
+ this.touchMode = "none";
981
+ };
982
+ touchDistance(a, b) {
983
+ const dx = a.clientX - b.clientX;
984
+ const dy = a.clientY - b.clientY;
985
+ return Math.sqrt(dx * dx + dy * dy);
986
+ }
987
+ };
988
+
907
989
  // src/viewers/three/pgsg-three-viewer.ts
908
990
  var PGSGThreeViewer = class {
909
991
  constructor(options) {
910
992
  this.options = options;
911
993
  this.container = options.container;
912
- this.options = options;
913
- if (!this.container) {
994
+ if (!this.container)
914
995
  throw new Error("[PGSGThreeViewer] Container is required");
915
- }
916
996
  }
917
- scene = new THREE4.Scene();
997
+ scene = new THREE5.Scene();
918
998
  camera;
919
999
  renderer;
920
1000
  splats;
1001
+ gltfLoader = new GLTFLoader();
1002
+ pendingPlacementUrl;
1003
+ ghostModel;
1004
+ raycaster = new THREE5.Raycaster();
1005
+ mouse = new THREE5.Vector2();
1006
+ placementRotation = 0;
1007
+ transformControls;
921
1008
  container;
922
1009
  resizeObserver = null;
923
1010
  controls;
924
1011
  running = false;
925
- orbitTarget = new THREE4.Vector3();
1012
+ orbitTarget = new THREE5.Vector3();
926
1013
  lastTime = performance.now();
927
- colliders = [];
928
1014
  persistentMeasurements = [];
929
1015
  measurementController = null;
930
- worldRoot = new THREE4.Object3D();
931
- splatPivot = new THREE4.Object3D();
1016
+ world;
1017
+ // Roots
1018
+ worldRoot = new THREE5.Object3D();
1019
+ // identity
1020
+ cvRoot = new THREE5.Object3D();
1021
+ // ONLY place where we convert OpenCV->OpenGL
1022
+ splatPivot = new THREE5.Object3D();
932
1023
  colliderMesh;
933
- modeButton;
1024
+ // For raycasting / measurement only
1025
+ colliderMeshes = [];
1026
+ currentMode = "orbit";
1027
+ acc = 0;
1028
+ fixedDt = 1 / 60;
1029
+ GLOBAL_SCALE = 0.7;
934
1030
  async load() {
935
1031
  this.initRenderer();
936
1032
  this.initCamera();
937
1033
  this.initScene();
938
1034
  this.bindResize();
939
1035
  this.resize();
1036
+ this.transformControls = new TransformControls(
1037
+ this.camera,
1038
+ this.renderer.domElement
1039
+ );
1040
+ this.scene.add(this.transformControls);
1041
+ console.log(this.transformControls instanceof THREE5.Object3D);
1042
+ this.transformControls.addEventListener("dragging-changed", (event) => {
1043
+ if (!this.controls) return;
1044
+ this.controls.enabled = !event.value;
1045
+ });
1046
+ this.transformControls.addEventListener("mouseDown", () => {
1047
+ document.exitPointerLock?.();
1048
+ });
1049
+ await RAPIER2.init();
1050
+ this.world = new RAPIER2.World({
1051
+ x: 0,
1052
+ y: -9.81 * this.GLOBAL_SCALE,
1053
+ z: 0
1054
+ });
940
1055
  this.scene.add(this.worldRoot);
1056
+ this.cvRoot.scale.set(1, -1, -1);
1057
+ this.worldRoot.add(this.cvRoot);
941
1058
  this.splats = new SparkSplatRenderer();
942
- const { mesh, bounds } = await this.splats.load(this.options.source);
1059
+ const { mesh } = await this.splats.load(this.options.source);
943
1060
  this.splatPivot.clear();
944
1061
  this.splatPivot.add(mesh);
945
- this.worldRoot.add(this.splatPivot);
946
- this.worldRoot.rotation.x = Math.PI;
947
- this.worldRoot.updateMatrixWorld(true);
1062
+ this.cvRoot.add(this.splatPivot);
948
1063
  if (this.options.colliderSource) {
949
1064
  await this.loadCollider(this.options.colliderSource);
950
1065
  }
951
- this.scene.updateMatrixWorld(true);
1066
+ this.cvRoot.updateMatrixWorld(true);
1067
+ this.placeInitialCamera();
952
1068
  if (this.isMobile()) {
953
1069
  this.initJoystick();
954
- this.initModeToggle();
955
1070
  this.switchToWalk();
956
- this.updateModeLabel("WALK");
957
- const walk = this.controls;
958
- window.addEventListener(
959
- "keydown",
960
- (e) => {
961
- if (e.key === "g") {
962
- walk.enableGyro();
963
- }
964
- },
965
- { once: true }
966
- );
967
1071
  } else {
968
1072
  this.switchToMeasure();
969
1073
  }
970
1074
  window.addEventListener("keydown", this.onKeyToggle);
1075
+ window.addEventListener("keydown", (e) => {
1076
+ if (!this.transformControls) return;
1077
+ if (e.key === "r") this.transformControls.setMode("rotate");
1078
+ if (e.key === "t") this.transformControls.setMode("translate");
1079
+ if (e.key === "s") this.transformControls.setMode("scale");
1080
+ });
1081
+ this.renderer.domElement.addEventListener("click", this.onSceneClick);
971
1082
  }
972
1083
  start() {
973
1084
  if (this.running) return;
974
1085
  this.running = true;
975
- this.renderer.setAnimationLoop((time) => {
1086
+ this.renderer.setAnimationLoop(() => {
976
1087
  const now = performance.now();
977
1088
  const delta = (now - this.lastTime) / 1e3;
978
1089
  this.lastTime = now;
1090
+ this.acc += delta;
1091
+ while (this.acc >= this.fixedDt) {
1092
+ this.world.timestep = this.fixedDt;
1093
+ this.world.step();
1094
+ this.acc -= this.fixedDt;
1095
+ }
979
1096
  this.controls?.update?.(delta);
980
1097
  this.renderer.render(this.scene, this.camera);
981
1098
  });
@@ -994,55 +1111,121 @@ var PGSGThreeViewer = class {
994
1111
  destroy() {
995
1112
  this.running = false;
996
1113
  this.renderer.setAnimationLoop(null);
1114
+ this.renderer.domElement.removeEventListener("click", this.onSceneClick);
997
1115
  this.resizeObserver?.disconnect();
998
1116
  this.resizeObserver = null;
999
1117
  this.controls?.dispose();
1000
1118
  this.renderer.dispose();
1119
+ window.removeEventListener("keydown", this.onKeyToggle);
1001
1120
  }
1002
1121
  setCameraMode(mode) {
1003
- console.log("[PGSGThreeViewer] setCameraMode:", mode);
1122
+ this.currentMode = mode;
1004
1123
  if (this.controls instanceof MeasurementController) {
1005
1124
  this.persistentMeasurements = this.controls.measurements;
1006
1125
  this.controls.setVisible(false);
1007
1126
  }
1008
1127
  switch (mode) {
1009
1128
  case "measure":
1010
- document.exitPointerLock();
1129
+ document.exitPointerLock?.();
1011
1130
  this.switchToMeasure();
1012
- if (this.controls instanceof MeasurementController) {
1131
+ if (this.controls instanceof MeasurementController)
1013
1132
  this.controls.setVisible(true);
1014
- }
1015
1133
  break;
1016
1134
  case "orbit":
1017
- document.exitPointerLock();
1135
+ document.exitPointerLock?.();
1018
1136
  this.switchToOrbit();
1019
1137
  break;
1020
1138
  case "walk":
1021
1139
  this.switchToWalk();
1022
1140
  break;
1023
- default:
1024
- this.switchToMeasure();
1141
+ case "floorplan":
1142
+ this.switchToFloorplan();
1025
1143
  break;
1026
1144
  }
1027
1145
  }
1028
1146
  toggleCameraMode() {
1029
- if (this.controls instanceof WalkCapsuleController) {
1030
- this.switchToOrbit();
1031
- this.updateModeLabel("ORBIT");
1032
- } else {
1033
- this.switchToWalk();
1034
- this.updateModeLabel("WALK");
1035
- }
1147
+ if (this.controls instanceof RapierWalkController) this.switchToOrbit();
1148
+ else this.switchToWalk();
1149
+ }
1150
+ getMeasurements() {
1151
+ return this.measurementController?.getMeasurements?.() ?? [];
1152
+ }
1153
+ clearMeasurements() {
1154
+ if (!this.measurementController) return;
1155
+ this.measurementController.clearAll();
1156
+ this.persistentMeasurements = [];
1157
+ }
1158
+ finalizePolyline() {
1159
+ if (this.controls instanceof MeasurementController)
1160
+ this.controls.finalizePolyline();
1036
1161
  }
1162
+ deleteMeasurement(index) {
1163
+ this.measurementController?.deleteMeasurement?.(index);
1164
+ }
1165
+ async enablePlacementMode(url) {
1166
+ this.pendingPlacementUrl = url;
1167
+ this.placementRotation = 0;
1168
+ console.log("[PGSG Viewer] Placement mode enabled");
1169
+ const gltf = await this.gltfLoader.loadAsync(url);
1170
+ this.ghostModel = gltf.scene;
1171
+ const box = new THREE5.Box3().setFromObject(this.ghostModel);
1172
+ const size = new THREE5.Vector3();
1173
+ box.getSize(size);
1174
+ const maxDim = Math.max(size.x, size.y, size.z);
1175
+ const scale = 1.5 / maxDim;
1176
+ this.ghostModel.scale.multiplyScalar(scale);
1177
+ this.ghostModel.traverse((child) => {
1178
+ if (!child.isMesh) return;
1179
+ child.material = child.material.clone();
1180
+ child.material.transparent = true;
1181
+ child.material.opacity = 0.4;
1182
+ });
1183
+ this.scene.add(this.ghostModel);
1184
+ this.renderer.domElement.addEventListener(
1185
+ "mousemove",
1186
+ this.onPlacementMove
1187
+ );
1188
+ this.renderer.domElement.addEventListener("wheel", this.onPlacementWheel);
1189
+ this.renderer.domElement.addEventListener("click", this.onSceneClick);
1190
+ window.addEventListener("keydown", this.onPlacementCancel);
1191
+ }
1192
+ async addModelFromUrl(url, position) {
1193
+ return new Promise((resolve, reject) => {
1194
+ this.gltfLoader.load(
1195
+ url,
1196
+ (gltf) => {
1197
+ const model = gltf.scene;
1198
+ const box = new THREE5.Box3().setFromObject(model);
1199
+ const size = new THREE5.Vector3();
1200
+ box.getSize(size);
1201
+ const maxDim = Math.max(size.x, size.y, size.z);
1202
+ const scale = 1.5 / maxDim;
1203
+ model.scale.multiplyScalar(scale);
1204
+ if (position) {
1205
+ model.position.copy(position);
1206
+ }
1207
+ const floorBox = new THREE5.Box3().setFromObject(model);
1208
+ model.position.y -= floorBox.min.y;
1209
+ this.cvRoot.add(model);
1210
+ resolve(model);
1211
+ },
1212
+ void 0,
1213
+ reject
1214
+ );
1215
+ });
1216
+ }
1217
+ // ---------------------------
1218
+ // Init
1219
+ // ---------------------------
1037
1220
  initRenderer() {
1038
1221
  if (this.renderer) return;
1039
- this.renderer = new THREE4.WebGLRenderer({
1222
+ this.renderer = new THREE5.WebGLRenderer({
1040
1223
  antialias: this.options.renderer?.antialias ?? false,
1041
1224
  alpha: false,
1042
1225
  powerPreference: "high-performance"
1043
1226
  });
1044
1227
  this.renderer.setClearColor(1118481, 1);
1045
- this.renderer.outputColorSpace = THREE4.SRGBColorSpace;
1228
+ this.renderer.outputColorSpace = THREE5.SRGBColorSpace;
1046
1229
  this.renderer.setSize(
1047
1230
  this.container.clientWidth,
1048
1231
  this.container.clientHeight
@@ -1050,21 +1233,17 @@ var PGSGThreeViewer = class {
1050
1233
  this.renderer.setPixelRatio(
1051
1234
  this.options.renderer?.pixelRatio ?? window.devicePixelRatio
1052
1235
  );
1236
+ this.renderer.localClippingEnabled = true;
1053
1237
  this.container.appendChild(this.renderer.domElement);
1054
- this.renderer.domElement.addEventListener("webglcontextlost", (e) => {
1055
- e.preventDefault();
1056
- console.log("[PGSG Viewer] WebGL context lost");
1057
- });
1058
1238
  }
1059
1239
  initScene() {
1060
- this.scene = new THREE4.Scene();
1061
- this.scene.background = new THREE4.Color(1118481);
1062
- const gridHelper = new THREE4.GridHelper(10, 10);
1063
- const light = new THREE4.HemisphereLight(16777215, 2236962, 1);
1240
+ this.scene = new THREE5.Scene();
1241
+ this.scene.background = new THREE5.Color(1118481);
1242
+ const light = new THREE5.HemisphereLight(16777215, 2236962, 1);
1064
1243
  this.scene.add(light);
1065
1244
  }
1066
1245
  initCamera() {
1067
- this.camera = new THREE4.PerspectiveCamera(
1246
+ this.camera = new THREE5.PerspectiveCamera(
1068
1247
  this.options.camera?.fov ?? 60,
1069
1248
  this.container.clientWidth / this.container.clientHeight,
1070
1249
  this.options.camera?.near ?? 0.01,
@@ -1073,109 +1252,105 @@ var PGSGThreeViewer = class {
1073
1252
  this.camera.position.set(2, 2, 5);
1074
1253
  this.camera.lookAt(0, 0, 0);
1075
1254
  }
1076
- initJoystick() {
1077
- const zone = document.createElement("div");
1078
- zone.style.position = "absolute";
1079
- zone.style.left = "20px";
1080
- zone.style.bottom = "20px";
1081
- zone.style.width = "150px";
1082
- zone.style.height = "150px";
1083
- zone.style.zIndex = "1000";
1084
- this.container.appendChild(zone);
1085
- const joystick = nipplejs.create({
1086
- zone,
1087
- mode: "static",
1088
- position: { left: "50px", bottom: "50px" },
1089
- color: "white"
1090
- });
1091
- joystick.on("move", (_, data) => {
1092
- const angle = data.angle.radian;
1093
- const force = data.force;
1094
- if (this.controls instanceof WalkCapsuleController || this.controls instanceof OrbitCameraController) {
1095
- this.controls.setMovementVector(
1096
- Math.sin(angle) * force,
1097
- Math.cos(angle) * force
1098
- );
1099
- }
1100
- });
1101
- joystick.on("end", () => {
1102
- if (this.controls instanceof WalkCapsuleController || this.controls instanceof OrbitCameraController) {
1103
- this.controls.setMovementVector(0, 0);
1104
- }
1105
- });
1106
- }
1107
- initModeToggle() {
1108
- }
1109
- updateModeLabel(mode) {
1110
- if (!this.modeButton) return;
1111
- this.modeButton.innerText = mode;
1112
- }
1113
1255
  bindResize() {
1114
1256
  this.resizeObserver?.disconnect();
1115
1257
  this.resizeObserver = new ResizeObserver(() => this.resize());
1116
1258
  this.resizeObserver.observe(this.container);
1117
1259
  }
1260
+ isMobile() {
1261
+ return "ontouchstart" in window || navigator.maxTouchPoints > 0 || window.innerWidth < 768;
1262
+ }
1263
+ // ---------------------------
1264
+ // Collider (visual + rapier)
1265
+ // ---------------------------
1118
1266
  async loadCollider(url) {
1119
1267
  console.log("[PGSG Viewer] Loading collider:", url);
1120
1268
  const loader = new GLTFLoader();
1121
1269
  const gltf = await loader.loadAsync(url);
1122
- this.colliderMesh = gltf.scene;
1123
- this.worldRoot.add(this.colliderMesh);
1124
- this.colliders.length = 0;
1125
- this.colliderMesh.traverse((child) => {
1126
- if (child.isMesh) {
1127
- child.material = new THREE4.MeshBasicMaterial({
1128
- visible: false,
1129
- side: THREE4.DoubleSide
1130
- });
1131
- child.updateMatrixWorld(true);
1132
- this.colliders.push(child);
1270
+ const colliderRoot = new THREE5.Object3D();
1271
+ colliderRoot.name = "colliderRoot";
1272
+ colliderRoot.add(gltf.scene);
1273
+ this.colliderMesh = colliderRoot;
1274
+ this.cvRoot.add(colliderRoot);
1275
+ this.cvRoot.updateMatrixWorld(true);
1276
+ colliderRoot.updateWorldMatrix(true, true);
1277
+ this.colliderMeshes.length = 0;
1278
+ colliderRoot.traverse((child) => {
1279
+ if (!child?.isMesh) return;
1280
+ const geom = child.geometry.clone();
1281
+ child.updateWorldMatrix(true, false);
1282
+ geom.applyMatrix4(child.matrixWorld);
1283
+ const vertices = new Float32Array(geom.attributes.position.array);
1284
+ let indices;
1285
+ if (geom.index) indices = new Uint32Array(geom.index.array);
1286
+ else {
1287
+ const count = geom.attributes.position.count;
1288
+ indices = new Uint32Array(count);
1289
+ for (let i = 0; i < count; i++) indices[i] = i;
1133
1290
  }
1291
+ const rb = this.world.createRigidBody(RAPIER2.RigidBodyDesc.fixed());
1292
+ const cd = RAPIER2.ColliderDesc.trimesh(vertices, indices);
1293
+ cd.setFriction(0.9);
1294
+ cd.setRestitution(0);
1295
+ this.world.createCollider(cd, rb);
1296
+ this.colliderMeshes.push(child);
1134
1297
  });
1135
- console.log("[PGSG Viewer] Colliders loaded:", this.colliders.length);
1136
- this.worldRoot.updateMatrixWorld(true);
1137
- }
1138
- isMobile() {
1139
- return "ontouchstart" in window || navigator.maxTouchPoints > 0 || window.innerWidth < 768;
1298
+ const rawBox = new THREE5.Box3().setFromObject(colliderRoot);
1299
+ console.log("OpenGL bounds Y:", rawBox.min.y, rawBox.max.y);
1300
+ console.log("[PGSG Viewer] Colliders loaded:", this.colliderMeshes.length);
1140
1301
  }
1302
+ // ---------------------------
1303
+ // Mode switching
1304
+ // ---------------------------
1141
1305
  onKeyToggle = (e) => {
1142
1306
  if (e.key !== "v") return;
1143
- if (this.controls instanceof WalkCapsuleController) {
1144
- this.switchToOrbit();
1145
- } else {
1146
- this.switchToWalk();
1147
- }
1307
+ if (this.controls instanceof RapierWalkController) this.switchToOrbit();
1308
+ else this.switchToWalk();
1148
1309
  };
1149
1310
  switchToMeasure() {
1150
- this.controls?.dispose();
1311
+ this.controls?.dispose?.();
1151
1312
  this.measurementController = new MeasurementController({
1152
1313
  dom: this.renderer.domElement,
1153
1314
  camera: this.camera,
1154
1315
  scene: this.scene,
1155
- colliders: this.colliders
1316
+ colliders: this.colliderMeshes
1156
1317
  });
1157
- this.measurementController.restoreMeasurements(this.persistentMeasurements);
1318
+ this.measurementController.restoreMeasurements?.(
1319
+ this.persistentMeasurements
1320
+ );
1158
1321
  this.controls = this.measurementController;
1159
1322
  }
1160
1323
  switchToWalk() {
1161
- this.controls?.dispose();
1162
- const walk = new WalkCapsuleController({
1324
+ this.controls?.dispose?.();
1325
+ const walk = new RapierWalkController({
1163
1326
  camera: this.camera,
1164
1327
  dom: this.renderer.domElement,
1165
- colliders: this.colliders,
1328
+ world: this.world,
1166
1329
  eyeHeight: 1.6,
1167
- radius: 0.35,
1168
- walkSpeed: 2.5,
1169
- runMultiplier: 2,
1170
- gravity: 9.8,
1330
+ radius: 0.2,
1331
+ halfHeight: 0.6,
1332
+ moveSpeed: 3 * this.GLOBAL_SCALE,
1333
+ jumpSpeed: 6 * this.GLOBAL_SCALE,
1171
1334
  usePointerLock: !this.isMobile()
1172
1335
  });
1173
1336
  this.controls = walk;
1174
- walk.snapToGround();
1337
+ const euler = new THREE5.Euler().setFromQuaternion(
1338
+ this.camera.quaternion,
1339
+ "YXZ"
1340
+ );
1341
+ walk.setRotation(euler.y, euler.x);
1342
+ walk.setPositionFromCamera();
1343
+ walk.resetVelocity();
1344
+ walk.snapToGround?.();
1175
1345
  }
1176
1346
  switchToOrbit() {
1177
- this.controls?.dispose();
1178
- const distance = 5;
1347
+ this.controls?.dispose?.();
1348
+ this.camera.up.set(0, 1, 0);
1349
+ this.camera.updateMatrixWorld(true);
1350
+ const box = new THREE5.Box3().setFromObject(this.cvRoot);
1351
+ const center = box.getCenter(new THREE5.Vector3());
1352
+ this.orbitTarget.copy(center);
1353
+ const distance = Math.max(this.camera.position.distanceTo(center), 1);
1179
1354
  this.controls = new OrbitCameraController(
1180
1355
  this.camera,
1181
1356
  this.renderer.domElement,
@@ -1186,30 +1361,199 @@ var PGSGThreeViewer = class {
1186
1361
  }
1187
1362
  );
1188
1363
  }
1189
- getMeasurements() {
1190
- if (this.measurementController) {
1191
- return this.measurementController.getMeasurements();
1192
- }
1193
- return [];
1364
+ switchToFloorplan() {
1365
+ this.controls?.dispose?.();
1366
+ const box = new THREE5.Box3().setFromObject(this.cvRoot);
1367
+ const center = box.getCenter(new THREE5.Vector3());
1368
+ const size = box.getSize(new THREE5.Vector3());
1369
+ const height = Math.max(size.x, size.z) * 1.2;
1370
+ this.orbitTarget.copy(center);
1371
+ this.controls = new FloorplanController({
1372
+ dom: this.renderer.domElement,
1373
+ camera: this.camera,
1374
+ target: center,
1375
+ height,
1376
+ panSpeed: 15e-4,
1377
+ zoomSpeed: 1.15
1378
+ });
1194
1379
  }
1195
- clearMeasurements() {
1196
- if (this.measurementController) {
1197
- this.measurementController.clearAll();
1198
- this.persistentMeasurements = [];
1380
+ // ---------------------------
1381
+ // Spawn
1382
+ // ---------------------------
1383
+ placeInitialCamera() {
1384
+ const targetObj = this.colliderMesh ?? this.cvRoot;
1385
+ const box = new THREE5.Box3().setFromObject(targetObj);
1386
+ const center = box.getCenter(new THREE5.Vector3());
1387
+ const size = box.getSize(new THREE5.Vector3());
1388
+ const startY = box.max.y + 10;
1389
+ const maxDist = box.max.y - box.min.y + 50;
1390
+ const samples = [];
1391
+ const sx = Math.max(0.2, size.x * 0.15);
1392
+ const sz = Math.max(0.2, size.z * 0.15);
1393
+ for (const dx of [-sx, 0, sx]) {
1394
+ for (const dz of [-sz, 0, sz]) {
1395
+ samples.push({ x: center.x + dx, z: center.z + dz });
1396
+ }
1199
1397
  }
1200
- }
1201
- finalizePolyline() {
1202
- if (this.controls instanceof MeasurementController) {
1203
- this.controls.finalizePolyline();
1398
+ let bestFloorY = null;
1399
+ let bestXZ = null;
1400
+ for (const s of samples) {
1401
+ const hits = this.raycastDownMulti(s.x, s.z, startY, maxDist, 10);
1402
+ if (hits.length === 0) continue;
1403
+ let candidate = null;
1404
+ for (let i = 1; i < hits.length; i++) {
1405
+ const h = hits[i];
1406
+ if (Math.abs(h.normalY) < 0.55) continue;
1407
+ candidate = h;
1408
+ }
1409
+ if (!candidate) continue;
1410
+ if (bestFloorY == null || candidate.y > bestFloorY) {
1411
+ bestFloorY = candidate.y;
1412
+ bestXZ = s;
1413
+ }
1204
1414
  }
1205
- }
1206
- deleteMeasurement(index) {
1207
- if (this.measurementController) {
1208
- this.measurementController.deleteMeasurement(index);
1415
+ const eye = 1.6;
1416
+ if (bestFloorY != null && bestXZ) {
1417
+ this.camera.position.set(bestXZ.x, bestFloorY + eye, bestXZ.z + 0.5);
1418
+ this.orbitTarget.set(bestXZ.x, bestFloorY, bestXZ.z);
1419
+ } else {
1420
+ this.camera.position.set(center.x, box.max.y + eye, center.z + 2);
1421
+ this.orbitTarget.copy(center);
1209
1422
  }
1423
+ this.camera.up.set(0, 1, 0);
1424
+ this.camera.lookAt(this.orbitTarget);
1425
+ this.camera.updateMatrixWorld(true);
1426
+ }
1427
+ getWorldNormalSafe(hit) {
1428
+ if (!hit.face) return null;
1429
+ const m3 = new THREE5.Matrix3().getNormalMatrix(hit.object.matrixWorld);
1430
+ return hit.face.normal.clone().applyMatrix3(m3).normalize();
1431
+ }
1432
+ // ---------------------------
1433
+ // Mobile joystick
1434
+ // ---------------------------
1435
+ initJoystick() {
1436
+ const zone = document.createElement("div");
1437
+ zone.style.position = "absolute";
1438
+ zone.style.left = "20px";
1439
+ zone.style.bottom = "20px";
1440
+ zone.style.width = "150px";
1441
+ zone.style.height = "150px";
1442
+ zone.style.zIndex = "1000";
1443
+ this.container.appendChild(zone);
1444
+ const joystick = nipplejs.create({
1445
+ zone,
1446
+ mode: "static",
1447
+ position: { left: "50px", bottom: "50px" },
1448
+ color: "white"
1449
+ });
1450
+ joystick.on("move", (_, data) => {
1451
+ const angle = data.angle.radian;
1452
+ const force = data.force;
1453
+ if (this.controls instanceof RapierWalkController || this.controls instanceof OrbitCameraController) {
1454
+ this.controls.setMovementVector(
1455
+ Math.sin(angle) * force,
1456
+ Math.cos(angle) * force
1457
+ );
1458
+ }
1459
+ });
1460
+ joystick.on("end", () => {
1461
+ if (this.controls instanceof RapierWalkController || this.controls instanceof OrbitCameraController) {
1462
+ this.controls.setMovementVector(0, 0);
1463
+ }
1464
+ });
1210
1465
  }
1466
+ raycastDownMulti(x, z, startY, maxDist, maxHits = 8) {
1467
+ const hits = [];
1468
+ let originY = startY;
1469
+ let remaining = maxDist;
1470
+ for (let i = 0; i < maxHits; i++) {
1471
+ const ray = new RAPIER2.Ray({ x, y: originY, z }, { x: 0, y: -1, z: 0 });
1472
+ const hit = this.world.castRayAndGetNormal(ray, remaining, true);
1473
+ if (!hit) break;
1474
+ const y = originY - hit.timeOfImpact;
1475
+ const normalY = hit.normal?.y ?? 0;
1476
+ hits.push({ y, normalY });
1477
+ const eps = 0.02;
1478
+ originY = y - eps;
1479
+ remaining = remaining - hit.timeOfImpact - eps;
1480
+ if (remaining <= 0) break;
1481
+ }
1482
+ return hits;
1483
+ }
1484
+ onSceneClick = async (event) => {
1485
+ if (!this.pendingPlacementUrl || !this.ghostModel) return;
1486
+ const position = this.ghostModel.position.clone();
1487
+ const placed = await this.addModelFromUrl(
1488
+ this.pendingPlacementUrl,
1489
+ position
1490
+ );
1491
+ if (placed && this.transformControls) {
1492
+ placed.rotation.y = this.placementRotation;
1493
+ this.transformControls.attach(placed);
1494
+ }
1495
+ this.scene.remove(this.ghostModel);
1496
+ this.ghostModel = void 0;
1497
+ this.pendingPlacementUrl = void 0;
1498
+ this.renderer.domElement.removeEventListener(
1499
+ "mousemove",
1500
+ this.onPlacementMove
1501
+ );
1502
+ this.renderer.domElement.removeEventListener(
1503
+ "wheel",
1504
+ this.onPlacementWheel
1505
+ );
1506
+ this.renderer.domElement.removeEventListener("click", this.onSceneClick);
1507
+ window.removeEventListener("keydown", this.onPlacementCancel);
1508
+ };
1509
+ onPlacementMove = (event) => {
1510
+ if (!this.pendingPlacementUrl || !this.ghostModel) return;
1511
+ const rect = this.renderer.domElement.getBoundingClientRect();
1512
+ this.mouse.set(
1513
+ (event.clientX - rect.left) / rect.width * 2 - 1,
1514
+ -((event.clientY - rect.top) / rect.height) * 2 + 1
1515
+ );
1516
+ this.raycaster.setFromCamera(this.mouse, this.camera);
1517
+ const intersects = this.raycaster.intersectObjects(
1518
+ this.colliderMeshes,
1519
+ true
1520
+ );
1521
+ if (!intersects.length) return;
1522
+ const point = intersects[0].point;
1523
+ this.ghostModel.position.copy(point);
1524
+ };
1525
+ onPlacementWheel = (event) => {
1526
+ if (!this.ghostModel) return;
1527
+ event.preventDefault();
1528
+ const step = Math.PI / 12;
1529
+ if (event.deltaY > 0) {
1530
+ this.placementRotation += step;
1531
+ } else {
1532
+ this.placementRotation -= step;
1533
+ }
1534
+ this.ghostModel.rotation.y = this.placementRotation;
1535
+ };
1536
+ onPlacementCancel = (event) => {
1537
+ if (event.key !== "Escape") return;
1538
+ if (!this.ghostModel) return;
1539
+ console.log("[PGSG Viewer] Placement cancelled");
1540
+ this.scene.remove(this.ghostModel);
1541
+ this.ghostModel = void 0;
1542
+ this.pendingPlacementUrl = void 0;
1543
+ this.renderer.domElement.style.cursor = "default";
1544
+ this.renderer.domElement.removeEventListener(
1545
+ "mousemove",
1546
+ this.onPlacementMove
1547
+ );
1548
+ this.renderer.domElement.removeEventListener("click", this.onSceneClick);
1549
+ this.renderer.domElement.removeEventListener(
1550
+ "wheel",
1551
+ this.onPlacementWheel
1552
+ );
1553
+ window.removeEventListener("keydown", this.onPlacementCancel);
1554
+ };
1211
1555
  };
1212
1556
  export {
1213
1557
  PGSGThreeViewer
1214
1558
  };
1215
- //# sourceMappingURL=pgsg-three-viewer-TOCXQMZQ.js.map
1559
+ //# sourceMappingURL=pgsg-three-viewer-KBOYGHT3.js.map