@expofp/renderer 1.2.1 → 1.3.2

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +385 -138
  2. package/dist/index.js +747 -237
  3. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
- import { DataTexture, FloatType, UnsignedIntType, IntType, RGBAFormat, RGBAIntegerFormat, RGFormat, RGIntegerFormat, RedFormat, RedIntegerFormat, BatchedMesh as BatchedMesh$1, BufferAttribute, StreamDrawUsage, Color, Matrix4, Vector3, Vector4, DoubleSide, MeshBasicMaterial, Texture, Quaternion, Group, PlaneGeometry, SRGBColorSpace, Vector2, BufferGeometry, LinearSRGBColorSpace, Mesh, Raycaster, Sphere, Box3, Spherical, PerspectiveCamera, Scene, Plane, Clock, WebGLRenderer } from "three";
4
+ import { DataTexture, FloatType, UnsignedIntType, IntType, RGBAFormat, RGBAIntegerFormat, RGFormat, RGIntegerFormat, RedFormat, RedIntegerFormat, BatchedMesh as BatchedMesh$1, BufferAttribute, StreamDrawUsage, Color, Matrix4, Vector3, Vector4, DoubleSide, MeshBasicMaterial, Texture, Quaternion, Group, PlaneGeometry, SRGBColorSpace, Vector2, BufferGeometry, LinearSRGBColorSpace, Mesh, Plane, Raycaster, Sphere, Box3, Spherical, PerspectiveCamera, Scene, Camera, MathUtils, Clock, WebGLRenderer } from "three";
5
5
  import { traverseAncestorsGenerator } from "three/examples/jsm/utils/SceneUtils.js";
6
6
  import { BatchedText as BatchedText$1, Text as Text$1 } from "troika-three-text";
7
7
  import { LineMaterial, LineSegmentsGeometry } from "three/examples/jsm/Addons.js";
8
8
  import { MaxRectsPacker, Rectangle } from "maxrects-packer";
9
9
  import { converter, parse } from "culori";
10
- import CameraController from "camera-controls";
11
- import { EventManager, Rotate } from "mjolnir.js";
12
- import { DEG2RAD, RAD2DEG } from "three/src/math/MathUtils.js";
10
+ import { RAD2DEG, DEG2RAD, MathUtils as MathUtils$1 } from "three/src/math/MathUtils.js";
11
+ import CameraControls from "camera-controls";
12
+ import { EventManager, Rotate, Pan } from "mjolnir.js";
13
13
  function isObject(item) {
14
14
  return !!item && typeof item === "object" && !Array.isArray(item);
15
15
  }
@@ -2190,18 +2190,43 @@ const subsetOfTHREE = {
2190
2190
  Spherical,
2191
2191
  Box3,
2192
2192
  Sphere,
2193
- Raycaster
2193
+ Raycaster,
2194
+ Plane
2194
2195
  };
2195
- CameraController.install({ THREE: subsetOfTHREE });
2196
+ CameraControls.install({ THREE: subsetOfTHREE });
2197
+ class CameraController extends CameraControls {
2198
+ /**
2199
+ * @param camera {@link PerspectiveCamera} instance
2200
+ * @param renderer {@link Renderer} instance
2201
+ */
2202
+ constructor(camera, renderer) {
2203
+ super(camera);
2204
+ this.renderer = renderer;
2205
+ }
2206
+ update(delta) {
2207
+ var _a;
2208
+ const needsUpdate = super.update(delta);
2209
+ if (needsUpdate && ((_a = this.renderer) == null ? void 0 : _a.debugLog)) {
2210
+ const position = this.camera.position.toArray().map((value) => value.toFixed(2)).join(", ");
2211
+ const target = this._target.toArray().map((value) => value.toFixed(2)).join(", ");
2212
+ const spherical = [this._spherical.theta * RAD2DEG, this._spherical.phi * RAD2DEG, this._spherical.radius].map((value) => value.toFixed(2)).join(", ");
2213
+ console.log(`position: [${position}]
2214
+ target: [${target}]
2215
+ spherical: [${spherical}]`);
2216
+ }
2217
+ return needsUpdate;
2218
+ }
2219
+ }
2196
2220
  class CameraSystem {
2197
2221
  /**
2198
2222
  * @param renderer {@link Renderer} instance
2199
2223
  */
2200
2224
  constructor(renderer) {
2225
+ /** External camera instance. Used to render the scene in external mode (e.g. Mapbox GL JS). */
2226
+ __publicField(this, "externalCamera");
2201
2227
  /** {@link CameraController} instance. Used to smoothly animate the camera. */
2202
2228
  __publicField(this, "controller");
2203
2229
  __publicField(this, "camera");
2204
- __publicField(this, "externalCamera");
2205
2230
  __publicField(this, "zoomIdentityDistance");
2206
2231
  __publicField(this, "zoomBounds");
2207
2232
  this.renderer = renderer;
@@ -2210,7 +2235,7 @@ class CameraSystem {
2210
2235
  this.camera.up.set(0, 0, -1);
2211
2236
  this.zoomIdentityDistance = h / 2;
2212
2237
  this.camera.position.y = this.zoomIdentityDistance;
2213
- this.controller = new CameraController(this.camera);
2238
+ this.controller = new CameraController(this.camera, renderer);
2214
2239
  void this.controller.rotatePolarTo(0);
2215
2240
  }
2216
2241
  /** Current camera instance. */
@@ -2219,7 +2244,16 @@ class CameraSystem {
2219
2244
  }
2220
2245
  /** Current camera zoom factor. */
2221
2246
  get zoomFactor() {
2222
- return this.camera.position.z ? this.zoomIdentityDistance / this.camera.position.z : 1;
2247
+ const distance = this.controller.distance;
2248
+ return distance ? this.zoomIdentityDistance / distance : 1;
2249
+ }
2250
+ /**
2251
+ * Calculates the camera distance from the scene's plane for a given zoom factor.
2252
+ * @param zoomFactor Zoom factor
2253
+ * @returns Corresponding camera distance on the Z axis
2254
+ */
2255
+ zoomFactorToDistance(zoomFactor) {
2256
+ return zoomFactor > 0 ? this.zoomIdentityDistance / zoomFactor : this.zoomIdentityDistance;
2223
2257
  }
2224
2258
  /**
2225
2259
  * Initializes the camera with the given zoom bounds.
@@ -2233,35 +2267,41 @@ class CameraSystem {
2233
2267
  updateCamera() {
2234
2268
  if (!this.zoomBounds) return;
2235
2269
  const [w, h] = this.renderer.size;
2236
- this.zoomIdentityDistance = -h / 2;
2270
+ const zoomFactor = this.zoomFactor;
2271
+ this.zoomIdentityDistance = h / 2;
2237
2272
  const maxDistance = Math.abs(this.zoomIdentityDistance / this.zoomBounds[0]);
2238
2273
  const minDistance = Math.abs(this.zoomIdentityDistance / this.zoomBounds[1]);
2239
2274
  this.camera.aspect = w / (h || 1);
2240
- this.camera.far = maxDistance + 1;
2241
- this.camera.near = minDistance - 1;
2275
+ this.computeCameraClipPlanes(minDistance, maxDistance);
2242
2276
  this.camera.updateProjectionMatrix();
2243
- }
2244
- /**
2245
- * Calculates the camera distance from the scene's plane for a given zoom factor.
2246
- * @param zoomFactor Zoom factor
2247
- * @returns Corresponding camera distance on the Z axis
2248
- */
2249
- zoomFactorToDistance(zoomFactor) {
2250
- return zoomFactor > 0 ? Math.abs(this.zoomIdentityDistance / zoomFactor) : this.zoomIdentityDistance;
2277
+ this.syncController(minDistance, maxDistance, zoomFactor);
2278
+ }
2279
+ computeCameraClipPlanes(minDistance, maxDistance, nearSafetyFactor = 0.5, farSafetyFactor = 1.5) {
2280
+ const fov = this.camera.fov * DEG2RAD;
2281
+ const aspect = this.camera.aspect;
2282
+ const maxPolarAngle = 85 * DEG2RAD;
2283
+ const halfFovY = fov / 2;
2284
+ const halfFovX = Math.atan(Math.tan(halfFovY) * aspect);
2285
+ const diagonalFov = 2 * Math.atan(Math.sqrt(Math.tan(halfFovX) ** 2 + Math.tan(halfFovY) ** 2));
2286
+ const minHeight = minDistance * Math.cos(maxPolarAngle);
2287
+ const near = Math.max(0.1, minHeight * nearSafetyFactor);
2288
+ const criticalHeight = minHeight;
2289
+ const horizontalDistToOrbit = minDistance * Math.sin(maxPolarAngle);
2290
+ const distToOrbit = minDistance;
2291
+ const visibleRadiusAtOrbit = distToOrbit * Math.tan(diagonalFov / 2);
2292
+ const planeExtent = Math.max(maxDistance, visibleRadiusAtOrbit);
2293
+ const horizontalDistToFarEdge = horizontalDistToOrbit + planeExtent;
2294
+ const maxViewDistance = Math.sqrt(criticalHeight ** 2 + horizontalDistToFarEdge ** 2);
2295
+ const far = maxViewDistance * farSafetyFactor;
2296
+ this.camera.near = near;
2297
+ this.camera.far = far;
2298
+ if (this.renderer.debugLog) console.log("camera clip planes", near, far);
2251
2299
  }
2252
2300
  syncController(minDistance, maxDistance, zoomFactor) {
2253
2301
  if (this.renderer.debugLog) console.log("syncController", minDistance, maxDistance, zoomFactor);
2254
2302
  this.controller.minDistance = minDistance;
2255
2303
  this.controller.maxDistance = maxDistance;
2256
- void this.controller.setLookAt(
2257
- this.camera.position.x,
2258
- this.camera.position.y,
2259
- -this.zoomFactorToDistance(zoomFactor),
2260
- this.camera.position.x,
2261
- this.camera.position.y,
2262
- 0,
2263
- false
2264
- );
2304
+ void this.controller.dollyTo(this.zoomFactorToDistance(zoomFactor), false);
2265
2305
  }
2266
2306
  }
2267
2307
  class SceneSystem {
@@ -2277,12 +2317,24 @@ class SceneSystem {
2277
2317
  __publicField(this, "inverseWorldMatrix", new Matrix4());
2278
2318
  __publicField(this, "translationMatrix", new Matrix4());
2279
2319
  __publicField(this, "scaleMatrix", new Matrix4());
2320
+ __publicField(this, "scaleVector", new Vector3());
2280
2321
  __publicField(this, "visibleRectOffsetMatrix", new Matrix4());
2281
2322
  __publicField(this, "viewbox");
2282
2323
  this.renderer = renderer;
2283
2324
  this.scene = new Scene();
2284
2325
  this.scene.matrixAutoUpdate = false;
2285
2326
  }
2327
+ /** Scene scale factor (SVG to pixel) */
2328
+ get scaleFactor() {
2329
+ this.scaleVector.setFromMatrixScale(this.scene.matrix);
2330
+ if (this.scaleVector.z === 1) {
2331
+ return this.scaleVector.x;
2332
+ } else {
2333
+ const perspectiveW = this.scene.matrix.elements[15];
2334
+ const halfViewportWidth = this.renderer.size[0] / 2;
2335
+ return halfViewportWidth * this.scaleVector.x / perspectiveW;
2336
+ }
2337
+ }
2286
2338
  /**
2287
2339
  * Initializes the scene with the given SVG viewbox.
2288
2340
  * @param viewbox {@link Rect} viewbox
@@ -2333,6 +2385,7 @@ class ViewportSystem {
2333
2385
  __publicField(this, "viewboxPlane", new Plane(new Vector3(0, 0, 1), 0));
2334
2386
  __publicField(this, "pxToSvgScaleThreshold", 1e-3);
2335
2387
  __publicField(this, "prevPxToSvgScale");
2388
+ __publicField(this, "externalStaticTransformMatrix", new Matrix4());
2336
2389
  this.renderer = renderer;
2337
2390
  this.eventSystem = eventSystem;
2338
2391
  this.sceneSystem = new SceneSystem(renderer);
@@ -2351,6 +2404,14 @@ class ViewportSystem {
2351
2404
  get cameraController() {
2352
2405
  return this.cameraSystem.controller;
2353
2406
  }
2407
+ /** Current camera zoom factor. */
2408
+ get zoomFactor() {
2409
+ return this.cameraSystem.zoomFactor;
2410
+ }
2411
+ /** Scene scale factor (SVG to pixel) */
2412
+ get scaleFactor() {
2413
+ return this.sceneSystem.scaleFactor;
2414
+ }
2354
2415
  /**
2355
2416
  * Initializes the viewport and zoom bounds with the given scene definition.
2356
2417
  * @param sceneDef {@link SceneDef} scene definition
@@ -2358,6 +2419,13 @@ class ViewportSystem {
2358
2419
  initViewport(sceneDef) {
2359
2420
  this.sceneSystem.initScene(sceneDef.viewbox);
2360
2421
  this.cameraSystem.initCamera([0.1, sceneDef.viewbox.size.width > 1e5 ? 100 : 35]);
2422
+ if (sceneDef.bounds) {
2423
+ const boundary = new Box3(
2424
+ new Vector3(sceneDef.bounds.min.x, sceneDef.bounds.min.y, 0),
2425
+ new Vector3(sceneDef.bounds.max.x, sceneDef.bounds.max.y, 0)
2426
+ ).applyMatrix4(this.sceneSystem.worldMatrix);
2427
+ this.cameraController.setBoundary(boundary);
2428
+ }
2361
2429
  }
2362
2430
  /** Updates the viewport when the renderer size changes. */
2363
2431
  updateViewport() {
@@ -2368,17 +2436,7 @@ class ViewportSystem {
2368
2436
  * Recalculates the svg to pixel scale factor and emits the event if necessary.
2369
2437
  */
2370
2438
  updatePtScale() {
2371
- const scaleVector = new Vector3();
2372
- scaleVector.setFromMatrixScale(this.scene.matrix);
2373
- let scaleFactor;
2374
- if (scaleVector.z === 1) {
2375
- scaleFactor = scaleVector.x;
2376
- } else {
2377
- const perspectiveW = this.scene.matrix.elements[15];
2378
- const halfViewportWidth = this.renderer.size[0] / 2;
2379
- scaleFactor = halfViewportWidth * scaleVector.x / perspectiveW;
2380
- }
2381
- const denominator = scaleFactor * this.cameraSystem.zoomFactor;
2439
+ const denominator = this.sceneSystem.scaleFactor * this.cameraSystem.zoomFactor;
2382
2440
  const pxToSvgScale = 1 / denominator;
2383
2441
  if (Math.abs(pxToSvgScale - (this.prevPxToSvgScale ?? 0)) < this.pxToSvgScaleThreshold) return;
2384
2442
  if (this.renderer.debugLog) console.log("pxToSvgScale", +pxToSvgScale.toFixed(3));
@@ -2396,6 +2454,16 @@ class ViewportSystem {
2396
2454
  const intersections = this.raycaster.intersectObject(scene, true).filter((i) => isVisible(i.object));
2397
2455
  return intersections;
2398
2456
  }
2457
+ /**
2458
+ * Converts a point from SVG coordinates to world coordinates.
2459
+ * @param svgCoords Point in SVG coordinates
2460
+ * @returns Point in world coordinates
2461
+ */
2462
+ svgToWorld(svgCoords) {
2463
+ const svg3D = new Vector3(...svgCoords, 0);
2464
+ svg3D.applyMatrix4(this.sceneSystem.worldMatrix);
2465
+ return new Vector2(svg3D.x, svg3D.y);
2466
+ }
2399
2467
  /**
2400
2468
  * Converts a point from screen coordinates to the given coordinate space.
2401
2469
  * @param space Space to convert to (either "svg" or "world")
@@ -2408,6 +2476,219 @@ class ViewportSystem {
2408
2476
  if (space === "svg") this.intersectionPoint.applyMatrix4(this.sceneSystem.inverseWorldMatrix);
2409
2477
  return { x: this.intersectionPoint.x, y: this.intersectionPoint.y };
2410
2478
  }
2479
+ /**
2480
+ * Calculates the camera distance from the scene's plane for a given zoom factor.
2481
+ * @param zoomFactor Zoom factor
2482
+ * @returns Corresponding camera distance on the Z axis
2483
+ */
2484
+ zoomFactorToDistance(zoomFactor) {
2485
+ return this.cameraSystem.zoomFactorToDistance(zoomFactor);
2486
+ }
2487
+ /**
2488
+ * Sets the external transform matrix.
2489
+ * @param staticTransformMatrix static transform matrix to apply to the scene
2490
+ */
2491
+ setExternalTransform(staticTransformMatrix) {
2492
+ this.cameraSystem.externalCamera = new Camera();
2493
+ this.externalStaticTransformMatrix.fromArray(staticTransformMatrix);
2494
+ }
2495
+ /**
2496
+ * Updates the external camera.
2497
+ * @param dynamicTransformMatrix dynamic transform matrix to apply to the scene
2498
+ */
2499
+ updateExternalCamera(dynamicTransformMatrix) {
2500
+ this.scene.matrix.fromArray(dynamicTransformMatrix).multiply(this.externalStaticTransformMatrix);
2501
+ this.scene.matrixWorldNeedsUpdate = true;
2502
+ }
2503
+ }
2504
+ class ControlsSystem {
2505
+ /**
2506
+ * @param renderer {@link Renderer} instance
2507
+ * @param viewportSystem {@link ViewportSystem} instance
2508
+ * @param interactionsSystem {@link InteractionsSystem} instance
2509
+ */
2510
+ constructor(renderer, viewportSystem, interactionsSystem) {
2511
+ __publicField(this, "controller");
2512
+ this.renderer = renderer;
2513
+ this.viewportSystem = viewportSystem;
2514
+ this.interactionsSystem = interactionsSystem;
2515
+ this.controller = viewportSystem.cameraController;
2516
+ }
2517
+ /** Gesture handlers for camera controls. */
2518
+ get handlers() {
2519
+ return this.interactionsSystem.handlers;
2520
+ }
2521
+ /**
2522
+ * Zooms the camera by the given factor.
2523
+ * @param zoom Zoom factor to apply (e.g., 1.5 for 50% zoom in, 0.5 for 50% zoom out)
2524
+ * @param immediate If true, applies the change immediately without animation
2525
+ * @returns Promise that resolves when the zoom animation completes
2526
+ */
2527
+ zoomBy(zoom, immediate) {
2528
+ const newZoomFactor = this.viewportSystem.zoomFactor * zoom;
2529
+ const newDistance = this.viewportSystem.zoomFactorToDistance(newZoomFactor);
2530
+ return this.controller.dollyTo(newDistance, !immediate);
2531
+ }
2532
+ /**
2533
+ * Zooms the camera to fit the given rectangle.
2534
+ * @param rect Rectangle to zoom to in SVG coordinates
2535
+ * @param opts Optional zoom configuration
2536
+ * @param immediate If true, applies the change immediately without animation
2537
+ * @returns Promise that resolves when the zoom animation completes
2538
+ */
2539
+ async zoomTo(rect, opts = {}, immediate) {
2540
+ const { maxZoom, paddingPercent } = opts;
2541
+ const dpr = this.renderer.context.getPixelRatio();
2542
+ const visibleRect = this.renderer.visibleRect;
2543
+ const sourceRect = new Rect(this.viewportSystem.svgToWorld(rect.min), this.viewportSystem.svgToWorld(rect.max));
2544
+ const targetRect = visibleRect ? new Rect(visibleRect.min.clone().multiplyScalar(dpr), visibleRect.max.clone().multiplyScalar(dpr)) : new Rect([0, 0], this.renderer.size);
2545
+ if (paddingPercent) targetRect.addPadding(targetRect.size.x * paddingPercent, targetRect.size.y * paddingPercent);
2546
+ const zoomByWidth = targetRect.size.x / sourceRect.size.x;
2547
+ const zoomByHeight = targetRect.size.y / sourceRect.size.y;
2548
+ const minZoom = Math.min(zoomByWidth, zoomByHeight);
2549
+ const zoom = maxZoom ? Math.min(minZoom, maxZoom) : minZoom;
2550
+ const translate = sourceRect.center;
2551
+ if (visibleRect) {
2552
+ const offset = new Vector2(...this.renderer.size).multiplyScalar(0.5).sub(targetRect.center).multiplyScalar(1 / zoom);
2553
+ translate.add(offset);
2554
+ }
2555
+ const enableTransition = !immediate;
2556
+ await Promise.all([
2557
+ this.controller.moveTo(translate.x, translate.y, 0, enableTransition),
2558
+ this.controller.dollyTo(this.viewportSystem.zoomFactorToDistance(zoom), enableTransition)
2559
+ ]);
2560
+ }
2561
+ /**
2562
+ * Pans the camera by the given offset in SVG space.
2563
+ * @param x X offset in SVG space
2564
+ * @param y Y offset in SVG space
2565
+ * @param immediate If true, applies the change immediately without animation
2566
+ * @returns Promise that resolves when the pan animation completes
2567
+ */
2568
+ panBy(x, y, immediate) {
2569
+ const svgOrigin = this.viewportSystem.svgToWorld(new Vector2(0, 0));
2570
+ const svgOffset = this.viewportSystem.svgToWorld(new Vector2(x, y));
2571
+ const worldOffset = new Vector3(svgOffset.x - svgOrigin.x, svgOffset.y - svgOrigin.y, 0);
2572
+ const currentTarget = this.controller.getTarget(new Vector3());
2573
+ const newTarget = currentTarget.add(worldOffset);
2574
+ return this.controller.moveTo(newTarget.x, newTarget.y, newTarget.z, !immediate);
2575
+ }
2576
+ /**
2577
+ * Pans the camera to the given coordinates in SVG space.
2578
+ * @param x X coordinate in SVG space
2579
+ * @param y Y coordinate in SVG space
2580
+ * @param immediate If true, applies the change immediately without animation
2581
+ * @returns Promise that resolves when the pan animation completes
2582
+ */
2583
+ panTo(x, y, immediate) {
2584
+ const worldCoords = this.viewportSystem.svgToWorld(new Vector2(x, y));
2585
+ return this.controller.moveTo(worldCoords.x, worldCoords.y, 0, !immediate);
2586
+ }
2587
+ /**
2588
+ * Rolls the camera by the given angle in degrees.
2589
+ * Positive angle rolls clockwise, negative angle rolls counterclockwise.
2590
+ * @param angle Angle in degrees to roll by
2591
+ * @param immediate If true, applies the change immediately without animation
2592
+ * @returns Promise that resolves when the roll animation completes
2593
+ */
2594
+ rollBy(angle, immediate) {
2595
+ const angleRad = -angle * DEG2RAD;
2596
+ const spherical = this.controller.getSpherical(new Spherical());
2597
+ const azimuthAngle = spherical.theta;
2598
+ const newAzimuthAngle = azimuthAngle + angleRad;
2599
+ return this.controller.rotateAzimuthTo(newAzimuthAngle, !immediate);
2600
+ }
2601
+ /**
2602
+ * Rolls the camera to the given angle in degrees.
2603
+ * Positive angles go clockwise.
2604
+ * @param angle Target angle in degrees
2605
+ * @param immediate If true, applies the change immediately without animation
2606
+ * @returns Promise that resolves when the roll animation completes
2607
+ */
2608
+ rollTo(angle, immediate) {
2609
+ const targetAngleRad = -angle * DEG2RAD;
2610
+ const spherical = this.controller.getSpherical(new Spherical());
2611
+ const azimuthAngle = spherical.theta;
2612
+ const deltaAngleRad = shortestRotationAngle(targetAngleRad, azimuthAngle);
2613
+ console.log("rollTo", deltaAngleRad * RAD2DEG, targetAngleRad * RAD2DEG, azimuthAngle * RAD2DEG);
2614
+ return this.rollBy(-deltaAngleRad * RAD2DEG, immediate);
2615
+ }
2616
+ /**
2617
+ * Pitches the camera by the given angle in degrees.
2618
+ * Positive angles increase pitch, negative angles decrease pitch.
2619
+ * Note: This method will not pitch the camera outside the pitch range set by the `handlers.pitch.configure()` method.
2620
+ * @param angle Angle in degrees to pitch by
2621
+ * @param immediate If true, applies the change immediately without animation
2622
+ * @returns Promise that resolves when the pitch animation completes
2623
+ */
2624
+ pitchBy(angle, immediate) {
2625
+ const angleRad = angle * DEG2RAD;
2626
+ const spherical = this.controller.getSpherical(new Spherical());
2627
+ const polarAngle = spherical.phi;
2628
+ const newPolarAngle = polarAngle + angleRad;
2629
+ return this.controller.rotatePolarTo(newPolarAngle, !immediate);
2630
+ }
2631
+ /**
2632
+ * Pitches the camera to the given angle in degrees.
2633
+ * The angle is clamped to the 0-90 degree range.
2634
+ * Note: This method will not pitch the camera outside the pitch range set by the `handlers.pitch.configure()` method.
2635
+ * @param angle Target angle in degrees
2636
+ * @param immediate If true, applies the change immediately without animation
2637
+ * @returns Promise that resolves when the pitch animation completes
2638
+ */
2639
+ pitchTo(angle, immediate) {
2640
+ const angleRad = angle * DEG2RAD;
2641
+ const clampedAngleRad = MathUtils.euclideanModulo(angleRad, Math.PI / 2);
2642
+ return this.controller.rotatePolarTo(clampedAngleRad, !immediate);
2643
+ }
2644
+ /**
2645
+ * Resets the camera to the starting state by resetting enabled handlers.
2646
+ * @param options Configuration for which handlers to reset (defaults to all handlers)
2647
+ * @param immediate If true, applies the change immediately without animation
2648
+ * @returns Promise that resolves when all reset animations complete
2649
+ */
2650
+ async resetCamera(options = { roll: true, zoom: true, pan: true, pitch: true }, immediate) {
2651
+ const resetPromises = [];
2652
+ const enableTransition = !immediate;
2653
+ for (const [key, handler] of Object.entries(this.handlers)) {
2654
+ if (options[key] && handler.isEnabled()) {
2655
+ resetPromises.push(handler.reset(enableTransition));
2656
+ }
2657
+ }
2658
+ await Promise.all(resetPromises);
2659
+ }
2660
+ /**
2661
+ * Configures smooth time values for camera operations.
2662
+ * All time values are in seconds.
2663
+ * @param options Partial configuration object with time values in seconds
2664
+ */
2665
+ configure(options) {
2666
+ const controller = this.controller;
2667
+ if (options.zoomTime !== void 0) controller.dollyTime = options.zoomTime;
2668
+ if (options.panTime !== void 0) controller.truckTime = options.panTime;
2669
+ if (options.rollTime !== void 0) controller.azimuthTime = options.rollTime;
2670
+ if (options.pitchTime !== void 0) controller.polarTime = options.pitchTime;
2671
+ }
2672
+ }
2673
+ function asControlsAPI(controlsSystem) {
2674
+ return {
2675
+ handlers: controlsSystem.handlers,
2676
+ zoomBy: controlsSystem.zoomBy.bind(controlsSystem),
2677
+ zoomTo: controlsSystem.zoomTo.bind(controlsSystem),
2678
+ panBy: controlsSystem.panBy.bind(controlsSystem),
2679
+ panTo: controlsSystem.panTo.bind(controlsSystem),
2680
+ rollBy: controlsSystem.rollBy.bind(controlsSystem),
2681
+ rollTo: controlsSystem.rollTo.bind(controlsSystem),
2682
+ pitchBy: controlsSystem.pitchBy.bind(controlsSystem),
2683
+ pitchTo: controlsSystem.pitchTo.bind(controlsSystem),
2684
+ resetCamera: controlsSystem.resetCamera.bind(controlsSystem),
2685
+ configure: controlsSystem.configure.bind(controlsSystem)
2686
+ };
2687
+ }
2688
+ function shortestRotationAngle(targetAngle, sourceAngle) {
2689
+ const angle = targetAngle - sourceAngle;
2690
+ const TAU = Math.PI * 2;
2691
+ return MathUtils.euclideanModulo(angle + Math.PI, TAU) - Math.PI;
2411
2692
  }
2412
2693
  class EventSystem {
2413
2694
  constructor() {
@@ -2478,29 +2759,36 @@ function asEventAPI(system) {
2478
2759
  clear: system.clear.bind(system)
2479
2760
  };
2480
2761
  }
2481
- function normalizeEventCoordinates(event, domElement, target) {
2762
+ function clientToCanvas(event, domElement, target) {
2763
+ target = target ?? new Vector2();
2482
2764
  const { left, top } = domElement.getBoundingClientRect();
2483
- return canvasToNDC({ x: event.clientX - left, y: event.clientY - top }, domElement, target);
2765
+ const clientX = "clientX" in event ? event.clientX : event.x;
2766
+ const clientY = "clientY" in event ? event.clientY : event.y;
2767
+ target.set(clientX - left, clientY - top);
2768
+ return target;
2484
2769
  }
2485
- function canvasToNDC(coordinates, domElement, target) {
2770
+ function canvasToNDC(coordinates, canvas, target) {
2486
2771
  target = target ?? new Vector2();
2487
- const { width, height } = domElement.getBoundingClientRect();
2772
+ const { width, height } = canvas.getBoundingClientRect();
2488
2773
  const uv = [coordinates.x / width, coordinates.y / height];
2489
2774
  target.set(uv[0] * 2 - 1, -uv[1] * 2 + 1);
2490
2775
  return target;
2491
2776
  }
2492
2777
  class Handler {
2493
2778
  /**
2494
- * @param controller The camera-controls instance for camera manipulation
2779
+ * @param viewportSystem The viewport system instance
2495
2780
  * @param domElement The DOM element to attach event listeners to
2496
2781
  * @param eventManager Shared Mjolnir EventManager for gesture recognition
2497
2782
  */
2498
- constructor(controller, domElement, eventManager) {
2783
+ constructor(viewportSystem, domElement, eventManager) {
2499
2784
  /** Whether this handler is enabled */
2500
2785
  __publicField(this, "enabled", false);
2501
- this.controller = controller;
2786
+ /** The camera-controls instance for camera manipulation */
2787
+ __publicField(this, "controller");
2788
+ this.viewportSystem = viewportSystem;
2502
2789
  this.domElement = domElement;
2503
2790
  this.eventManager = eventManager;
2791
+ this.controller = viewportSystem.cameraController;
2504
2792
  }
2505
2793
  /**
2506
2794
  * Per-frame update for this handler.
@@ -2532,7 +2820,7 @@ class Handler {
2532
2820
  }
2533
2821
  /**
2534
2822
  * Disable this handler.
2535
- * Calls onDisable() hook for subclass-specific disabling logic
2823
+ * Resets to initial state without transition, then calls onDisable() hook for subclass-specific disabling logic
2536
2824
  */
2537
2825
  disable() {
2538
2826
  if (this.enabled) {
@@ -2548,17 +2836,144 @@ class Handler {
2548
2836
  return this.enabled;
2549
2837
  }
2550
2838
  }
2839
+ class InertiaController {
2840
+ /**
2841
+ * @param controller {@link CameraController}
2842
+ * @param opts {@link InertiaOptions}
2843
+ */
2844
+ constructor(controller, opts = {}) {
2845
+ __publicField(this, "enabled", true);
2846
+ __publicField(this, "minSampleMs", 50);
2847
+ __publicField(this, "durationMs", 1e3);
2848
+ __publicField(this, "speedThreshold", 0.08);
2849
+ __publicField(this, "ease");
2850
+ __publicField(this, "samples", []);
2851
+ __publicField(this, "position", new Vector3());
2852
+ __publicField(this, "target", new Vector3());
2853
+ __publicField(this, "delta", new Vector3());
2854
+ __publicField(this, "velocity", new Vector3());
2855
+ __publicField(this, "active", false);
2856
+ __publicField(this, "elapsedMs", 0);
2857
+ __publicField(this, "prevFactor", 1);
2858
+ __publicField(this, "enableZ", false);
2859
+ __publicField(this, "isConnected", false);
2860
+ __publicField(this, "onControlStart", () => {
2861
+ this.active = false;
2862
+ this.samples.length = 0;
2863
+ });
2864
+ __publicField(this, "onControl", () => {
2865
+ this.controller.getTarget(this.target, false);
2866
+ this.samples.push({ t: performance.now(), target: this.target.clone() });
2867
+ const cutoff = performance.now() - 300;
2868
+ while (this.samples.length && this.samples[0].t < cutoff) this.samples.shift();
2869
+ });
2870
+ __publicField(this, "onControlEnd", () => {
2871
+ const now = performance.now();
2872
+ const minSampleTimestamp = now - this.minSampleMs;
2873
+ for (let i = this.samples.length - 1; i >= 0; i--) {
2874
+ const s = this.samples[i];
2875
+ if (s.t < minSampleTimestamp || i === 0) {
2876
+ const dt = now - s.t;
2877
+ if (dt <= 0) return;
2878
+ this.controller.getTarget(this.target, false);
2879
+ this.velocity.subVectors(this.target, s.target).divideScalar(dt);
2880
+ if (!this.enableZ) this.velocity.z = 0;
2881
+ const speed = this.velocity.length();
2882
+ if (speed <= this.speedThreshold) return;
2883
+ this.elapsedMs = 0;
2884
+ this.prevFactor = 1;
2885
+ this.active = true;
2886
+ return;
2887
+ }
2888
+ }
2889
+ });
2890
+ this.controller = controller;
2891
+ this.configure(opts);
2892
+ this.ease = opts.ease ?? ((t) => Math.pow(2, 10 * (t - 1)));
2893
+ }
2894
+ /**
2895
+ * Configure the inertia controller.
2896
+ * @param opts {@link InertiaOptions}
2897
+ */
2898
+ configure(opts) {
2899
+ if (opts.enabled !== void 0) {
2900
+ const prev = this.enabled;
2901
+ this.enabled = opts.enabled;
2902
+ if (prev !== this.enabled) {
2903
+ if (this.enabled) this.connect();
2904
+ else this.disconnect();
2905
+ }
2906
+ }
2907
+ if (opts.minSampleMs !== void 0) this.minSampleMs = opts.minSampleMs;
2908
+ if (opts.durationMs !== void 0) this.durationMs = opts.durationMs;
2909
+ if (opts.speedThreshold !== void 0) this.speedThreshold = opts.speedThreshold;
2910
+ if (opts.ease !== void 0) this.ease = opts.ease;
2911
+ }
2912
+ /**
2913
+ * Connect the inertia controller event listeners to the camera controls.
2914
+ */
2915
+ connect() {
2916
+ if (!this.enabled || this.isConnected) return;
2917
+ this.controller.addEventListener("controlstart", this.onControlStart);
2918
+ this.controller.addEventListener("control", this.onControl);
2919
+ this.controller.addEventListener("controlend", this.onControlEnd);
2920
+ this.isConnected = true;
2921
+ }
2922
+ /**
2923
+ * Disconnect the inertia controller event listeners from the camera controls.
2924
+ */
2925
+ disconnect() {
2926
+ if (!this.isConnected) return;
2927
+ this.active = false;
2928
+ this.controller.removeEventListener("controlstart", this.onControlStart);
2929
+ this.controller.removeEventListener("control", this.onControl);
2930
+ this.controller.removeEventListener("controlend", this.onControlEnd);
2931
+ this.isConnected = false;
2932
+ }
2933
+ /**
2934
+ * Per-frame update of camera properties during inertia.
2935
+ * @param delta delta time in seconds
2936
+ * @returns true if the camera has moved due to inertia
2937
+ */
2938
+ update(delta) {
2939
+ if (!this.active) return false;
2940
+ const deltaMs = delta * 1e3;
2941
+ this.elapsedMs += deltaMs;
2942
+ const t = Math.min(1, this.elapsedMs / this.durationMs);
2943
+ const factor = this.ease(1 - t);
2944
+ const avgFactor = (factor + this.prevFactor) * 0.5;
2945
+ this.prevFactor = factor;
2946
+ this.controller.getPosition(this.position, false);
2947
+ this.controller.getTarget(this.target, false);
2948
+ this.delta.copy(this.velocity).multiplyScalar(avgFactor * deltaMs);
2949
+ this.target.add(this.delta);
2950
+ this.position.add(this.delta);
2951
+ void this.controller.moveTo(this.target.x, this.target.y, this.target.z, false);
2952
+ if (factor <= 0.02 || t >= 1) {
2953
+ this.active = false;
2954
+ }
2955
+ return true;
2956
+ }
2957
+ }
2551
2958
  class PanHandler extends Handler {
2552
- reset() {
2553
- void this.controller.moveTo(0, 0, 0, true);
2959
+ constructor() {
2960
+ super(...arguments);
2961
+ __publicField(this, "inertia", new InertiaController(this.controller));
2962
+ }
2963
+ reset(enableTransition = true) {
2964
+ return this.controller.moveTo(0, 0, 0, enableTransition);
2965
+ }
2966
+ update(delta) {
2967
+ return this.inertia.update(delta);
2554
2968
  }
2555
2969
  /**
2556
2970
  * Enable pan gestures.
2557
2971
  * Configures camera-controls to handle left-click drag and single-touch drag
2558
2972
  */
2559
2973
  onEnable() {
2560
- this.controller.mouseButtons.left = CameraController.ACTION.TRUCK;
2561
- this.controller.touches.one = CameraController.ACTION.TOUCH_TRUCK;
2974
+ this.controller.mouseButtons.left = CameraController.ACTION.SCREEN_PAN;
2975
+ this.controller.touches.one = CameraController.ACTION.TOUCH_SCREEN_PAN;
2976
+ this.inertia.connect();
2562
2977
  }
2563
2978
  /**
2564
2979
  * Disable pan gestures.
@@ -2567,195 +2982,297 @@ class PanHandler extends Handler {
2567
2982
  onDisable() {
2568
2983
  this.controller.mouseButtons.left = CameraController.ACTION.NONE;
2569
2984
  this.controller.touches.one = CameraController.ACTION.NONE;
2985
+ this.inertia.disconnect();
2986
+ }
2987
+ }
2988
+ class PitchHandler extends Handler {
2989
+ /**
2990
+ * @param viewportSystem {@link ViewportSystem} instance
2991
+ * @param domElement {@link HTMLElement} instance
2992
+ * @param eventManager {@link EventManager} instance
2993
+ */
2994
+ constructor(viewportSystem, domElement, eventManager) {
2995
+ super(viewportSystem, domElement, eventManager);
2996
+ // Configuration
2997
+ __publicField(this, "minPitch", 0);
2998
+ __publicField(this, "maxPitch", 85);
2999
+ __publicField(this, "isValid");
3000
+ __publicField(this, "firstMove");
3001
+ __publicField(this, "lastPoints");
3002
+ __publicField(this, "prevTwoFingerAction");
3003
+ __publicField(this, "onPitchStart", (e) => {
3004
+ const pointers = e.pointers.sort((a, b) => a.pointerId - b.pointerId);
3005
+ const p0 = clientToCanvas(pointers[0], this.domElement);
3006
+ const p1 = clientToCanvas(pointers[1], this.domElement);
3007
+ this.lastPoints = [p0, p1];
3008
+ if (this.isVertical(p0.clone().sub(p1))) {
3009
+ this.isValid = false;
3010
+ }
3011
+ });
3012
+ __publicField(this, "onPitchEnd", () => {
3013
+ if (this.prevTwoFingerAction) this.controller.touches.two = this.prevTwoFingerAction;
3014
+ this.prevTwoFingerAction = void 0;
3015
+ this.isValid = void 0;
3016
+ this.firstMove = void 0;
3017
+ this.lastPoints = void 0;
3018
+ });
3019
+ __publicField(this, "onPitch", (e) => {
3020
+ const lastPoints = this.lastPoints;
3021
+ if (!lastPoints) return;
3022
+ const pointers = e.pointers.sort((a, b) => a.pointerId - b.pointerId);
3023
+ const p0 = clientToCanvas(pointers[0], this.domElement);
3024
+ const p1 = clientToCanvas(pointers[1], this.domElement);
3025
+ const vectorA = p0.clone().sub(lastPoints[0]);
3026
+ const vectorB = p1.clone().sub(lastPoints[1]);
3027
+ this.isValid = this.gestureBeginsVertically(vectorA, vectorB, e.timeStamp);
3028
+ if (!this.isValid) return;
3029
+ if (this.prevTwoFingerAction === void 0) {
3030
+ this.prevTwoFingerAction = this.controller.touches.two;
3031
+ this.controller.touches.two = CameraController.ACTION.NONE;
3032
+ }
3033
+ this.lastPoints = [p0, p1];
3034
+ const yDeltaAverage = (vectorA.y + vectorB.y) / 2;
3035
+ const degreesPerPixelMoved = -0.5;
3036
+ const deltaAngle = yDeltaAverage * degreesPerPixelMoved * DEG2RAD;
3037
+ void this.controller.rotatePolarTo(this.controller.polarAngle + deltaAngle, false);
3038
+ });
3039
+ this.updatePolarAngles();
3040
+ }
3041
+ reset(enableTransition = true) {
3042
+ const polarAngle = this.enabled ? this.minPitch * DEG2RAD : 0;
3043
+ return this.controller.rotatePolarTo(polarAngle, enableTransition);
3044
+ }
3045
+ /**
3046
+ * Configure pitch handler options
3047
+ * @param options Partial options to update
3048
+ */
3049
+ configure(options) {
3050
+ if (options.minPitch !== void 0) this.minPitch = options.minPitch;
3051
+ if (options.maxPitch !== void 0) this.maxPitch = options.maxPitch;
3052
+ this.updatePolarAngles();
3053
+ }
3054
+ /**
3055
+ * Enable pitch.
3056
+ * Configures camera-controls to allow polar angle changes within configured range
3057
+ */
3058
+ onEnable() {
3059
+ this.controller.mouseButtons.right |= CameraController.ACTION.ROTATE_POLAR;
3060
+ this.eventManager.on("pitchstart", this.onPitchStart);
3061
+ this.eventManager.on("pitch", this.onPitch);
3062
+ this.eventManager.on("pitchend", this.onPitchEnd);
3063
+ }
3064
+ /**
3065
+ * Disable pitch.
3066
+ */
3067
+ onDisable() {
3068
+ this.controller.mouseButtons.right ^= CameraController.ACTION.ROTATE_POLAR;
3069
+ this.eventManager.off("pitchstart", this.onPitchStart);
3070
+ this.eventManager.off("pitch", this.onPitch);
3071
+ this.eventManager.off("pitchend", this.onPitchEnd);
3072
+ }
3073
+ /**
3074
+ * Update controller polar angles based on current configuration
3075
+ */
3076
+ updatePolarAngles() {
3077
+ this.controller.minPolarAngle = this.minPitch * DEG2RAD;
3078
+ this.controller.maxPolarAngle = this.maxPitch * DEG2RAD;
3079
+ if (this.controller.polarAngle < this.controller.minPolarAngle) {
3080
+ void this.controller.rotatePolarTo(this.controller.minPolarAngle, false);
3081
+ }
3082
+ if (this.controller.polarAngle > this.controller.maxPolarAngle) {
3083
+ void this.controller.rotatePolarTo(this.controller.maxPolarAngle, false);
3084
+ }
3085
+ }
3086
+ gestureBeginsVertically(vectorA, vectorB, timeStamp) {
3087
+ if (this.isValid !== void 0) return this.isValid;
3088
+ const threshold = 2;
3089
+ const movedA = vectorA.length() >= threshold;
3090
+ const movedB = vectorB.length() >= threshold;
3091
+ if (!movedA && !movedB) return;
3092
+ if (!movedA || !movedB) {
3093
+ this.firstMove ?? (this.firstMove = timeStamp);
3094
+ if (timeStamp - this.firstMove < ALLOWED_SINGLE_TOUCH_TIME) {
3095
+ return void 0;
3096
+ } else {
3097
+ return false;
3098
+ }
3099
+ }
3100
+ const isSameDirection = vectorA.y > 0 === vectorB.y > 0;
3101
+ return this.isVertical(vectorA) && this.isVertical(vectorB) && isSameDirection;
3102
+ }
3103
+ isVertical(vector) {
3104
+ return Math.abs(vector.y) > Math.abs(vector.x);
2570
3105
  }
2571
- // FIXME: Add inertia
2572
3106
  }
2573
- const ROTATION_THRESHOLD = 25;
3107
+ const ALLOWED_SINGLE_TOUCH_TIME = 100;
2574
3108
  class RollHandler extends Handler {
2575
3109
  constructor() {
2576
3110
  super(...arguments);
2577
- __publicField(this, "rotating", false);
3111
+ __publicField(this, "isRolling", false);
3112
+ // Configuration
3113
+ __publicField(this, "rotationThreshold", 25);
3114
+ // Threshold tracking (Mapbox-style)
2578
3115
  __publicField(this, "startVector");
2579
3116
  __publicField(this, "vector");
2580
3117
  __publicField(this, "minDiameter", 0);
2581
- __publicField(this, "startBearing", 0);
2582
- __publicField(this, "raycaster", new Raycaster());
3118
+ __publicField(this, "prevAngle", 0);
3119
+ // Camera and pivot vectors
2583
3120
  __publicField(this, "pivotWorld", new Vector3());
2584
- __publicField(this, "tempVec2", new Vector2());
2585
- __publicField(this, "tempVec3", new Vector3());
2586
- __publicField(this, "onRotateStart", (event) => {
2587
- console.log("RollHandler: rotatestart", event);
2588
- if (!this.enabled) {
2589
- console.log("RollHandler: not enabled");
2590
- return;
2591
- }
2592
- const { pointers } = event;
2593
- console.log("RollHandler: pointers", pointers == null ? void 0 : pointers.length);
2594
- if (pointers.length !== 2) return;
2595
- const p0 = new Vector2(pointers[0].offsetX, pointers[0].offsetY);
2596
- const p1 = new Vector2(pointers[1].offsetX, pointers[1].offsetY);
2597
- this.startVector = this.vector = p0.clone().sub(p1);
2598
- this.minDiameter = p0.distanceTo(p1);
2599
- this.startBearing = 0;
2600
- this.rotating = false;
2601
- console.log("RollHandler: initialized rotation, diameter:", this.minDiameter);
2602
- });
2603
- __publicField(this, "onRotate", (event) => {
2604
- if (!this.enabled) return;
2605
- const { pointers, rotation } = event;
2606
- if (pointers.length !== 2 || !this.startVector) {
2607
- console.log("RollHandler: rotate ignored", pointers == null ? void 0 : pointers.length, !!this.startVector);
2608
- return;
2609
- }
2610
- const p0 = new Vector2(pointers[0].offsetX, pointers[0].offsetY);
2611
- const p1 = new Vector2(pointers[1].offsetX, pointers[1].offsetY);
2612
- this.vector = p0.clone().sub(p1);
2613
- if (!this.rotating && this.isBelowThreshold(this.vector)) {
2614
- console.log("RollHandler: below threshold");
2615
- return;
2616
- }
2617
- if (!this.rotating) {
2618
- this.rotating = true;
2619
- this.startBearing = rotation;
2620
- console.log("RollHandler: rotation started, bearing:", rotation);
2621
- }
2622
- const bearingDelta = -(rotation - this.startBearing);
2623
- this.startBearing = rotation;
2624
- if (Math.abs(bearingDelta) < 0.01) {
2625
- console.log("RollHandler: delta too small:", bearingDelta);
2626
- return;
2627
- }
2628
- console.log("RollHandler: rotating by", bearingDelta, "degrees");
2629
- const midpointPx = p0.clone().add(p1).multiplyScalar(0.5);
2630
- this.normalizeScreenCoords(midpointPx, this.tempVec2);
2631
- this.unprojectToWorldPlane(this.tempVec2, this.pivotWorld);
2632
- void this.controller.rotate(bearingDelta * DEG2RAD, 0, false);
2633
- this.projectWorldToScreen(this.pivotWorld, this.tempVec2);
2634
- const pivotNewPx = this.denormalizeScreenCoords(this.tempVec2, this.tempVec3);
2635
- const screenDeltaX = midpointPx.x - pivotNewPx.x;
2636
- const screenDeltaY = midpointPx.y - pivotNewPx.y;
2637
- const worldPan = this.screenDeltaToWorldDelta(screenDeltaX, screenDeltaY);
2638
- void this.controller.truck(worldPan.x, worldPan.y, false);
3121
+ __publicField(this, "targetWorld", new Vector3());
3122
+ __publicField(this, "cameraPosition", new Vector3());
3123
+ __publicField(this, "cameraForward", new Vector3());
3124
+ __publicField(this, "rotationMatrix", new Matrix4());
3125
+ __publicField(this, "onRotateStart", (e) => {
3126
+ console.log("onRotateStart");
3127
+ const pointers = e.pointers;
3128
+ const p0 = clientToCanvas(pointers[0], this.domElement);
3129
+ const p1 = clientToCanvas(pointers[1], this.domElement);
3130
+ this.startVector = p0.sub(p1);
3131
+ this.minDiameter = this.startVector.length();
2639
3132
  });
2640
3133
  __publicField(this, "onRotateEnd", () => {
2641
- this.rotating = false;
3134
+ this.isRolling = false;
2642
3135
  this.startVector = void 0;
2643
3136
  this.vector = void 0;
2644
3137
  this.minDiameter = 0;
2645
3138
  });
3139
+ __publicField(this, "onRotate", (e) => {
3140
+ const pointers = e.pointers;
3141
+ if (!this.isRolling) {
3142
+ const p0 = clientToCanvas(pointers[0], this.domElement);
3143
+ const p1 = clientToCanvas(pointers[1], this.domElement);
3144
+ this.vector = p0.sub(p1);
3145
+ if (this.isBelowThreshold(this.vector)) return;
3146
+ this.isRolling = true;
3147
+ this.prevAngle = e.rotation;
3148
+ }
3149
+ const deltaAngle = (e.rotation - this.prevAngle) * -DEG2RAD;
3150
+ this.prevAngle = e.rotation;
3151
+ if (Math.abs(deltaAngle) < 1e-3) return;
3152
+ this.setPivot(e);
3153
+ this.rotationMatrix.makeRotationZ(deltaAngle);
3154
+ this.cameraPosition.sub(this.pivotWorld).applyMatrix4(this.rotationMatrix).add(this.pivotWorld);
3155
+ this.cameraForward.applyMatrix4(this.rotationMatrix);
3156
+ this.targetWorld.copy(this.cameraPosition).add(this.cameraForward);
3157
+ void this.controller.setLookAt(
3158
+ this.cameraPosition.x,
3159
+ this.cameraPosition.y,
3160
+ this.cameraPosition.z,
3161
+ this.targetWorld.x,
3162
+ this.targetWorld.y,
3163
+ this.targetWorld.z,
3164
+ false
3165
+ );
3166
+ });
3167
+ }
3168
+ /**
3169
+ * Get bearing angle between current camera orientation and true north (in radians).
3170
+ * Angle is in range [0, 2π), going clockwise from north.
3171
+ */
3172
+ get bearing() {
3173
+ const tau = Math.PI * 2;
3174
+ return MathUtils$1.euclideanModulo(-this.controller.azimuthAngle, tau);
3175
+ }
3176
+ reset(enableTransition = true) {
3177
+ return this.controller.normalizeRotations().rotateAzimuthTo(0, enableTransition);
2646
3178
  }
2647
- reset() {
2648
- void this.controller.rotateAzimuthTo(0, true);
2649
- this.rotating = false;
2650
- this.startVector = void 0;
2651
- this.vector = void 0;
2652
- this.minDiameter = 0;
3179
+ /**
3180
+ * Configure roll handler options
3181
+ * @param options Partial options to update
3182
+ */
3183
+ configure(options) {
3184
+ if (options.rotationThreshold !== void 0) {
3185
+ this.rotationThreshold = options.rotationThreshold;
3186
+ }
2653
3187
  }
2654
3188
  /**
2655
3189
  * Enable roll gestures.
2656
- * - Mobile: custom two-finger rotation with pivot compensation
3190
+ * Configures camera-controls to allow any azimuth angle and two-finger touch rotate
2657
3191
  */
2658
3192
  onEnable() {
2659
- this.controller.maxAzimuthAngle = Infinity;
2660
- this.controller.minAzimuthAngle = -Infinity;
3193
+ this.controller.mouseButtons.right |= CameraController.ACTION.ROTATE_AZIMUTH;
2661
3194
  this.eventManager.on("rotatestart", this.onRotateStart);
2662
3195
  this.eventManager.on("rotate", this.onRotate);
2663
3196
  this.eventManager.on("rotateend", this.onRotateEnd);
2664
3197
  }
2665
3198
  /**
2666
3199
  * Disable roll gestures.
2667
- * Restricts azimuth angle to zero and removes event listeners
2668
3200
  */
2669
3201
  onDisable() {
2670
- this.controller.maxAzimuthAngle = 0;
2671
- this.controller.minAzimuthAngle = 0;
3202
+ this.controller.mouseButtons.right ^= CameraController.ACTION.ROTATE_AZIMUTH;
2672
3203
  this.eventManager.off("rotatestart", this.onRotateStart);
2673
3204
  this.eventManager.off("rotate", this.onRotate);
2674
3205
  this.eventManager.off("rotateend", this.onRotateEnd);
2675
- this.rotating = false;
3206
+ }
3207
+ setPivot(e) {
3208
+ const pivotScreen = e.center;
3209
+ const pivotNDC = canvasToNDC(pivotScreen, this.domElement);
3210
+ const pivotWorld2D = this.viewportSystem.screenTo("world", pivotNDC);
3211
+ this.pivotWorld.set(pivotWorld2D.x, pivotWorld2D.y, 0);
3212
+ this.controller.getPosition(this.cameraPosition);
3213
+ this.controller.getTarget(this.targetWorld);
3214
+ this.cameraForward.copy(this.targetWorld).sub(this.cameraPosition);
2676
3215
  }
2677
3216
  /**
2678
3217
  * Check if rotation is below threshold (Mapbox-style).
2679
- * Threshold is in pixels along circumference, scaled by touch circle diameter.
3218
+ * The threshold before a rotation actually happens is configured in
3219
+ * pixels along the circumference of the circle formed by the two fingers.
3220
+ * This makes the threshold in degrees larger when the fingers are close
3221
+ * together and smaller when the fingers are far apart.
3222
+ * Uses the smallest diameter from the whole gesture to reduce sensitivity
3223
+ * when pinching in and out.
2680
3224
  * @param vector Current vector between fingers
2681
3225
  * @returns true if below threshold, false otherwise
2682
3226
  */
2683
3227
  isBelowThreshold(vector) {
2684
3228
  this.minDiameter = Math.min(this.minDiameter, vector.length());
2685
3229
  const circumference = Math.PI * this.minDiameter;
2686
- const thresholdDegrees = ROTATION_THRESHOLD / circumference * 360;
2687
- if (!this.startVector) return true;
2688
- const bearingDeltaSinceStart = this.getBearingDelta(vector, this.startVector);
2689
- return Math.abs(bearingDeltaSinceStart) < thresholdDegrees;
3230
+ const threshold = this.rotationThreshold / circumference * 360;
3231
+ const startVector = this.startVector;
3232
+ if (!startVector) return false;
3233
+ const bearingDeltaSinceStart = this.getBearingDelta(vector, startVector);
3234
+ console.log("bearingDeltaSinceStart", vector, startVector);
3235
+ return Math.abs(bearingDeltaSinceStart) < threshold;
2690
3236
  }
2691
3237
  /**
2692
- * Get signed angle between two vectors in degrees
3238
+ * Get signed angle between two vectors in degrees (Mapbox pattern)
2693
3239
  * @param a First vector
2694
3240
  * @param b Second vector
2695
3241
  * @returns Angle difference in degrees
2696
3242
  */
2697
3243
  getBearingDelta(a, b) {
2698
- return a.angle() - b.angle();
3244
+ return a.angleTo(b) * RAD2DEG;
2699
3245
  }
2700
3246
  /**
2701
- * Normalize screen pixel coordinates to NDC [-1, 1]
2702
- * @param screenPos Screen position in pixels
2703
- * @param out Output vector for NDC coordinates
2704
- * @returns NDC coordinates
3247
+ * Normalize angle to be between and π
3248
+ * @param angle Angle in radians
3249
+ * @returns Normalized angle in radians
2705
3250
  */
2706
- normalizeScreenCoords(screenPos, out) {
2707
- const canvas = this.domElement;
2708
- return out.set(screenPos.x / canvas.width * 2 - 1, -(screenPos.y / canvas.height) * 2 + 1);
3251
+ normalizeAngle(angle) {
3252
+ while (angle > Math.PI) angle -= 2 * Math.PI;
3253
+ while (angle < -Math.PI) angle += 2 * Math.PI;
3254
+ return angle;
2709
3255
  }
2710
- /**
2711
- * Denormalize NDC coordinates to screen pixels
2712
- * @param ndc NDC coordinates [-1, 1]
2713
- * @param out Output vector for screen pixel coordinates
2714
- * @returns Screen pixel coordinates
2715
- */
2716
- denormalizeScreenCoords(ndc, out) {
2717
- const canvas = this.domElement;
2718
- return out.set((ndc.x + 1) * 0.5 * canvas.width, (1 - ndc.y) * 0.5 * canvas.height, 0);
2719
- }
2720
- /**
2721
- * Unproject screen NDC coordinates to world plane (Z=0)
2722
- * @param ndc NDC coordinates [-1, 1]
2723
- * @param out Output vector for world coordinates
2724
- * @returns World coordinates on Z=0 plane
2725
- */
2726
- unprojectToWorldPlane(ndc, out) {
2727
- const camera = this.controller.camera;
2728
- this.raycaster.setFromCamera(ndc, camera);
2729
- const ray = this.raycaster.ray;
2730
- const t = -ray.origin.z / ray.direction.z;
2731
- return out.copy(ray.origin).addScaledVector(ray.direction, t);
3256
+ }
3257
+ class ZoomHandler extends Handler {
3258
+ reset(enableTransition = true) {
3259
+ return this.controller.dollyTo(this.viewportSystem.zoomFactorToDistance(1), enableTransition);
2732
3260
  }
2733
3261
  /**
2734
- * Project world point to screen NDC coordinates
2735
- * @param worldPos World position
2736
- * @param out Output vector for NDC coordinates
2737
- * @returns NDC coordinates
3262
+ * Enable zoom gestures.
3263
+ * Configures camera-controls to handle mouse wheel and two-finger pinch
2738
3264
  */
2739
- projectWorldToScreen(worldPos, out) {
2740
- const camera = this.controller.camera;
2741
- const projected = this.tempVec3.copy(worldPos).project(camera);
2742
- return out.set(projected.x, projected.y);
3265
+ onEnable() {
3266
+ this.controller.mouseButtons.wheel = CameraController.ACTION.DOLLY;
3267
+ this.controller.touches.two = CameraController.ACTION.TOUCH_DOLLY_SCREEN_PAN;
2743
3268
  }
2744
3269
  /**
2745
- * Convert screen-space pixel delta to world-space translation.
2746
- * For top-down view, this is a simple perspective calculation.
2747
- * @param deltaX Screen delta X in pixels
2748
- * @param deltaY Screen delta Y in pixels
2749
- * @returns World-space translation vector
3270
+ * Disable zoom gestures.
3271
+ * Removes zoom actions from camera-controls
2750
3272
  */
2751
- screenDeltaToWorldDelta(deltaX, deltaY) {
2752
- const camera = this.controller.camera;
2753
- const canvas = this.domElement;
2754
- const distance = Math.abs(camera.position.z);
2755
- const fov = camera.getEffectiveFOV() * DEG2RAD;
2756
- const worldHeight = 2 * distance * Math.tan(fov / 2);
2757
- const worldWidth = worldHeight * camera.aspect;
2758
- return this.tempVec2.set(deltaX / canvas.width * worldWidth, -(deltaY / canvas.height) * worldHeight);
3273
+ onDisable() {
3274
+ this.controller.mouseButtons.wheel = CameraController.ACTION.NONE;
3275
+ this.controller.touches.two = CameraController.ACTION.NONE;
2759
3276
  }
2760
3277
  }
2761
3278
  class InteractionsSystem {
@@ -2775,25 +3292,28 @@ class InteractionsSystem {
2775
3292
  __publicField(this, "dragStart");
2776
3293
  __publicField(this, "dragThreshold", 15);
2777
3294
  __publicField(this, "isDragging", false);
2778
- this.renderer = renderer;
3295
+ __publicField(this, "prevBearing", 0);
2779
3296
  this.events = events;
2780
3297
  this.viewportSystem = viewportSystem;
2781
3298
  this.layerSystem = layerSystem;
2782
3299
  this.canvas = renderer.canvas;
2783
3300
  this.eventManager = new EventManager(this.canvas, {
2784
- recognizers: [[Rotate, { enable: true }]]
3301
+ recognizers: [Rotate, [Pan, { event: "pitch", pointers: 2 }, "rotate"]]
2785
3302
  });
2786
- console.log("EventManager created with Rotate recognizer");
2787
3303
  this.configureCameraControls();
2788
3304
  this.attachCanvasListeners();
2789
- const controller = viewportSystem.cameraController;
2790
- this.handlers = {
2791
- pan: new PanHandler(controller, this.canvas, this.eventManager),
2792
- roll: new RollHandler(controller, this.canvas, this.eventManager, viewportSystem)
3305
+ const handlers = {
3306
+ pan: new PanHandler(viewportSystem, this.canvas, this.eventManager),
3307
+ zoom: new ZoomHandler(viewportSystem, this.canvas, this.eventManager),
3308
+ roll: new RollHandler(viewportSystem, this.canvas, this.eventManager),
3309
+ pitch: new PitchHandler(viewportSystem, this.canvas, this.eventManager)
2793
3310
  };
2794
- this.handlerArray = Object.values(this.handlers);
3311
+ this.handlers = handlers;
3312
+ this.handlerArray = Object.values(handlers);
2795
3313
  this.handlers.pan.enable();
3314
+ this.handlers.zoom.enable();
2796
3315
  this.handlers.roll.enable();
3316
+ this.handlers.pitch.enable();
2797
3317
  }
2798
3318
  /**
2799
3319
  * Update camera position and directions.
@@ -2803,44 +3323,43 @@ class InteractionsSystem {
2803
3323
  */
2804
3324
  updateControls(delta) {
2805
3325
  let needsUpdate = this.viewportSystem.cameraController.update(delta);
2806
- if (needsUpdate) {
2807
- console.log(
2808
- // "azimuth angle",
2809
- // Math.round(this.viewportSystem.cameraController.azimuthAngle * RAD2DEG),
2810
- "polar angle",
2811
- Math.round(this.viewportSystem.cameraController.polarAngle * RAD2DEG),
2812
- "camera position",
2813
- this.viewportSystem.cameraController.camera.position.toArray().map(Math.round)
2814
- );
2815
- }
2816
3326
  for (const handler of this.handlerArray) {
2817
3327
  if (handler.isEnabled()) {
2818
3328
  needsUpdate = handler.update(delta) || needsUpdate;
2819
3329
  }
2820
3330
  }
3331
+ if (this.handlers.roll.bearing !== this.prevBearing) {
3332
+ this.prevBearing = this.handlers.roll.bearing;
3333
+ this.events.emit("navigation:roll", this.handlers.roll.bearing);
3334
+ }
2821
3335
  return needsUpdate;
2822
3336
  }
3337
+ /** Disconnect the interactions system. */
3338
+ disconnect() {
3339
+ this.viewportSystem.cameraController.disconnect();
3340
+ for (const handler of this.handlerArray) {
3341
+ handler.disable();
3342
+ }
3343
+ }
2823
3344
  configureCameraControls() {
2824
3345
  const controller = this.viewportSystem.cameraController;
2825
3346
  controller.draggingSmoothTime = 0;
2826
3347
  controller.dollyToCursor = true;
2827
- controller.restThreshold = 1;
2828
- controller.maxAzimuthAngle = 0;
2829
- controller.minAzimuthAngle = 0;
2830
- controller.maxPolarAngle = 0;
2831
3348
  controller.mouseButtons = {
2832
3349
  left: CameraController.ACTION.NONE,
2833
3350
  middle: CameraController.ACTION.NONE,
2834
- right: CameraController.ACTION.ROTATE,
3351
+ right: CameraController.ACTION.NONE,
2835
3352
  wheel: CameraController.ACTION.NONE
2836
3353
  };
2837
3354
  controller.touches = {
2838
3355
  one: CameraController.ACTION.NONE,
2839
3356
  two: CameraController.ACTION.NONE,
2840
- // Disabled - handlers implement custom gestures
2841
3357
  three: CameraController.ACTION.NONE
2842
3358
  };
2843
3359
  controller.connect(this.canvas);
3360
+ controller.addEventListener("transitionstart", () => {
3361
+ this.events.emit("navigation:change");
3362
+ });
2844
3363
  }
2845
3364
  attachCanvasListeners() {
2846
3365
  this.canvas.addEventListener("pointerdown", (event) => {
@@ -2866,7 +3385,8 @@ class InteractionsSystem {
2866
3385
  const isDragging = type === "click" && this.isDragging;
2867
3386
  const hasListeners = this.events.hasListeners(eventType);
2868
3387
  if (isDragging || !hasListeners) return;
2869
- normalizeEventCoordinates(event, this.canvas, this.mousePointer);
3388
+ clientToCanvas(event, this.canvas, this.mousePointer);
3389
+ canvasToNDC(this.mousePointer, this.canvas, this.mousePointer);
2870
3390
  const intersections = this.viewportSystem.getIntersectedObjects(this.mousePointer);
2871
3391
  const point = this.viewportSystem.screenTo("svg", this.mousePointer);
2872
3392
  const defs = this.layerSystem.getIntersectedDefs(intersections);
@@ -2885,23 +3405,24 @@ class Renderer {
2885
3405
  /** {@link HTMLCanvasElement} that this renderer is rendering to */
2886
3406
  __publicField(this, "canvas");
2887
3407
  __publicField(this, "ui");
2888
- __publicField(this, "clock");
2889
- __publicField(this, "renderer");
3408
+ __publicField(this, "gl");
2890
3409
  __publicField(this, "eventSystem");
2891
3410
  __publicField(this, "layerSystem");
2892
3411
  __publicField(this, "viewportSystem");
2893
3412
  __publicField(this, "interactionsSystem");
2894
- //private navigationSystem: NavigationSystem;
2895
- __publicField(this, "memoryInfoExtension");
2896
- __publicField(this, "memoryInfo", "");
3413
+ __publicField(this, "controlsSystem");
3414
+ __publicField(this, "clock");
3415
+ __publicField(this, "renderer");
2897
3416
  __publicField(this, "viewport");
2898
3417
  __publicField(this, "needsRedraw", true);
2899
- __publicField(this, "isExternalMode", false);
3418
+ __publicField(this, "memoryInfoExtension");
3419
+ __publicField(this, "memoryInfo", "");
2900
3420
  var _a, _b;
2901
3421
  const { canvas, gl, debugLog = false, ui } = opts;
2902
3422
  this.canvas = canvas;
2903
3423
  this.debugLog = debugLog;
2904
3424
  this.ui = ui;
3425
+ this.gl = gl;
2905
3426
  const rendererOptions = {
2906
3427
  antialias: true,
2907
3428
  context: gl,
@@ -2915,31 +3436,17 @@ class Renderer {
2915
3436
  this.viewportSystem = new ViewportSystem(this, this.eventSystem);
2916
3437
  this.layerSystem = new LayerSystem(this);
2917
3438
  this.interactionsSystem = new InteractionsSystem(this, this.eventSystem, this.viewportSystem, this.layerSystem);
3439
+ this.controlsSystem = new ControlsSystem(this, this.viewportSystem, this.interactionsSystem);
2918
3440
  this.memoryInfoExtension = this.renderer.getContext().getExtension("GMAN_webgl_memory");
2919
3441
  this.canvas.addEventListener("webglcontextlost", (e) => this.onContextLost(e), false);
2920
3442
  this.canvas.addEventListener("webglcontextrestored", (e) => this.onContextRestored(e), false);
2921
3443
  void ((_b = (_a = this.ui) == null ? void 0 : _a.stats) == null ? void 0 : _b.init(this.renderer.getContext()));
2922
3444
  }
2923
3445
  /**
2924
- * {@link NavigationAPI} instance for controlling the viewport
3446
+ * {@link ControlsAPI} instance for controlling the viewport
2925
3447
  */
2926
3448
  get controls() {
2927
- return {
2928
- // eslint-disable-next-line @typescript-eslint/no-empty-function
2929
- zoomBy: () => {
2930
- },
2931
- // eslint-disable-next-line @typescript-eslint/no-empty-function
2932
- zoomTo: () => {
2933
- },
2934
- // eslint-disable-next-line @typescript-eslint/no-empty-function
2935
- resetCamera: () => {
2936
- },
2937
- // eslint-disable-next-line @typescript-eslint/no-empty-function
2938
- configure: () => {
2939
- },
2940
- handlers: this.interactionsSystem.handlers,
2941
- controller: this.viewportSystem.cameraController
2942
- };
3449
+ return asControlsAPI(this.controlsSystem);
2943
3450
  }
2944
3451
  /**
2945
3452
  * {@link EventsAPI} instance for subscribing to internal events
@@ -2978,14 +3485,18 @@ class Renderer {
2978
3485
  * Sets the renderer to external mode, where parts of rendering process are not managed by the renderer (e.g. Mapbox GL JS).
2979
3486
  * @param staticTransformMatrix static transform matrix to apply to the scene
2980
3487
  */
2981
- // FIXME: Move to controls system
2982
- configureExternalMode(staticTransformMatrix) {
3488
+ // TODO: Move somewhere
3489
+ setExternalTransform(staticTransformMatrix) {
3490
+ this.renderer.autoClear = false;
3491
+ this.interactionsSystem.disconnect();
3492
+ this.viewportSystem.setExternalTransform(staticTransformMatrix);
2983
3493
  }
2984
3494
  /**
2985
3495
  * Update scene matrix from dynamic transform matrix.
2986
3496
  * @param dynamicTransformMatrix dynamic transform matrix (changes every frame)
2987
3497
  */
2988
- updateExternalTransformMatrix(dynamicTransformMatrix) {
3498
+ updateExternalCamera(dynamicTransformMatrix) {
3499
+ this.viewportSystem.updateExternalCamera(dynamicTransformMatrix);
2989
3500
  }
2990
3501
  /**
2991
3502
  * Initialize the scene and start the rendering loop
@@ -3006,10 +3517,9 @@ class Renderer {
3006
3517
  this.layerSystem.updateDefs(defs);
3007
3518
  this.needsRedraw = true;
3008
3519
  }
3009
- // FIXME: Move to viewport system?
3010
3520
  /**
3011
3521
  * Converts coordinates from canvas space to SVG space.
3012
- * @param point point in canvas space (relative to the canvas's top left corner)
3522
+ * @param point point in canvas space (relative to the canvas's top left corner), in css pixels
3013
3523
  * @returns point in SVG space
3014
3524
  */
3015
3525
  screenToSvg(point) {
@@ -3023,7 +3533,7 @@ class Renderer {
3023
3533
  render() {
3024
3534
  var _a, _b, _c, _d, _e, _f;
3025
3535
  (_b = (_a = this.ui) == null ? void 0 : _a.stats) == null ? void 0 : _b.begin();
3026
- if (this.isExternalMode) this.renderer.resetState();
3536
+ if (this.gl !== void 0) this.renderer.resetState();
3027
3537
  else this.resizeCanvasToDisplaySize();
3028
3538
  this.viewportSystem.updatePtScale();
3029
3539
  const delta = this.clock.getDelta();