@selvajs/visualization 1.6.0 → 1.6.1-beta.0

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.
package/dist/render.js CHANGED
@@ -3809,6 +3809,15 @@ const SELECTION_MIN_OPACITY = 1;
3809
3809
  * color blindness, so what tells hover from selected there is brightness.
3810
3810
  */
3811
3811
  const HOVER_COLOR = "#ffb020";
3812
+ /**
3813
+ * A fingertip covers far more than the pixel under its centre, so a touch that lands just beside a
3814
+ * thin curve or a narrow part still picks it. Rings are sampled only when the centre misses.
3815
+ */
3816
+ const TOUCH_PICK_RADII_PX = [8, 16];
3817
+ const TOUCH_PICK_DIRECTIONS = 8;
3818
+ /** Two taps closer than this in time and space are a double-tap. */
3819
+ const DOUBLE_TAP_MS = 300;
3820
+ const DOUBLE_TAP_SLOP_PX = 30;
3812
3821
  /** Linear RGB distance. Closer than this, a tint barely changes the surface. */
3813
3822
  const MIN_TINT_DISTANCE = .35;
3814
3823
  /** Taken by a surface already close to the tint. Blue stays distinct under every common deficiency. */
@@ -3848,7 +3857,7 @@ function tintForSelection(material, color, isMesh) {
3848
3857
  }
3849
3858
  if ("color" in material) material.color = selectionColor.clone();
3850
3859
  }
3851
- function setupEventHandlers(canvas, scene, cameraController, config) {
3860
+ function setupEventHandlers(canvas, scene, cameraController, config, taps) {
3852
3861
  const selectedObjects = /* @__PURE__ */ new Set();
3853
3862
  const originalMaterials = /* @__PURE__ */ new Map();
3854
3863
  const memberHighlights = [];
@@ -3857,16 +3866,7 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3857
3866
  let selectedByEntries = false;
3858
3867
  const raycaster = new THREE.Raycaster();
3859
3868
  const mouse = new THREE.Vector2();
3860
- const mouseDownPosition = new THREE.Vector2();
3861
3869
  const getActiveCamera = () => cameraController.getActiveCamera();
3862
- const isFullyVisible = (object) => {
3863
- let current = object;
3864
- while (current) {
3865
- if (!current.visible) return false;
3866
- current = current.parent;
3867
- }
3868
- return true;
3869
- };
3870
3870
  const fitToView = () => {
3871
3871
  const box = computeContentBounds(scene);
3872
3872
  if (box.isEmpty()) {
@@ -4004,29 +4004,61 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
4004
4004
  }
4005
4005
  raycaster.params.Points.threshold = cachedDiagonal * .01;
4006
4006
  };
4007
- /**
4008
- * Nearest visible, pickable object under the pointer, or null. Viewer aids are excluded: the grid
4009
- * is a huge camera-following plane, so double-clicking through a thin curve onto it would frame a
4010
- * box the size of the fade radius and fling the camera out.
4011
- */
4012
- const pickAt = (clientX, clientY) => {
4007
+ const setPointerNdc = (clientX, clientY) => {
4013
4008
  const rect = canvas.getBoundingClientRect();
4014
4009
  mouse.x = (clientX - rect.left) / rect.width * 2 - 1;
4015
4010
  mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
4011
+ };
4012
+ /**
4013
+ * Nearest visible, pickable object under the pointer, or null. Goes through the BVH pick, which
4014
+ * also skips viewer aids: the grid is a huge camera-following plane, so double-clicking through
4015
+ * a thin curve onto it would frame a box the size of the fade radius and fling the camera out.
4016
+ */
4017
+ const pickAt = (clientX, clientY) => {
4018
+ setPointerNdc(clientX, clientY);
4016
4019
  updatePickThresholds();
4017
4020
  raycaster.setFromCamera(mouse, getActiveCamera());
4018
- for (const hit of raycaster.intersectObjects(scene.children, true)) if (isFullyVisible(hit.object) && !isViewerAid(hit.object)) return hit;
4021
+ return raycastVisible(scene, raycaster)[0] ?? null;
4022
+ };
4023
+ const pickNear = (clientX, clientY, touch) => {
4024
+ const centre = pickAt(clientX, clientY);
4025
+ if (centre || !touch) return centre;
4026
+ for (const radius of TOUCH_PICK_RADII_PX) {
4027
+ let nearest = null;
4028
+ for (let i = 0; i < TOUCH_PICK_DIRECTIONS; i++) {
4029
+ const angle = i / TOUCH_PICK_DIRECTIONS * Math.PI * 2;
4030
+ const hit = pickAt(clientX + Math.cos(angle) * radius, clientY + Math.sin(angle) * radius);
4031
+ if (hit && (!nearest || hit.distance < nearest.distance)) nearest = hit;
4032
+ }
4033
+ if (nearest) return nearest;
4034
+ }
4019
4035
  return null;
4020
4036
  };
4021
- const handleMouseDown = (event) => {
4022
- mouseDownPosition.set(event.clientX, event.clientY);
4037
+ let lastTap = null;
4038
+ /** iOS fires no `dblclick` for a double-tap, so touch pairs its own taps. */
4039
+ const isSecondTap = (event) => {
4040
+ const previous = lastTap;
4041
+ lastTap = {
4042
+ time: event.timeStamp,
4043
+ x: event.clientX,
4044
+ y: event.clientY
4045
+ };
4046
+ if (!previous) return false;
4047
+ const isDouble = event.timeStamp - previous.time <= DOUBLE_TAP_MS && Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= DOUBLE_TAP_SLOP_PX;
4048
+ if (isDouble) lastTap = null;
4049
+ return isDouble;
4023
4050
  };
4024
4051
  const handleCanvasClick = (event) => {
4025
- const currentMousePosition = new THREE.Vector2(event.clientX, event.clientY);
4026
- if (mouseDownPosition.distanceTo(currentMousePosition) > 5) return;
4027
- const hit = pickAt(event.clientX, event.clientY);
4052
+ if (!taps.wasTap()) return;
4053
+ const touch = taps.pointerType() !== "mouse";
4054
+ if (touch && isSecondTap(event)) {
4055
+ handleDoubleTap(event.clientX, event.clientY, true);
4056
+ return;
4057
+ }
4058
+ const hit = pickNear(event.clientX, event.clientY, touch);
4028
4059
  if (!hit) {
4029
4060
  clearSelection();
4061
+ setPointerNdc(event.clientX, event.clientY);
4030
4062
  config.events?.onBackgroundClicked?.({
4031
4063
  x: mouse.x,
4032
4064
  y: mouse.y
@@ -4053,7 +4085,11 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
4053
4085
  else if (clickedObject instanceof THREE.Mesh && Object.keys(clickedObject.userData).length > 0) config.events?.onMeshMetadataClicked?.(clickedObject.userData);
4054
4086
  };
4055
4087
  const handleDoubleClick = (event) => {
4056
- const hit = pickAt(event.clientX, event.clientY);
4088
+ if (!taps.wasTap() || taps.pointerType() !== "mouse") return;
4089
+ handleDoubleTap(event.clientX, event.clientY, false);
4090
+ };
4091
+ const handleDoubleTap = (clientX, clientY, touch) => {
4092
+ const hit = pickNear(clientX, clientY, touch);
4057
4093
  if (!hit) {
4058
4094
  if (config.events?.enableDoubleClickZoom) cameraController.frameBounds(computeContentBounds(scene), true);
4059
4095
  return;
@@ -4083,7 +4119,6 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
4083
4119
  }
4084
4120
  };
4085
4121
  if (config.events?.enableClickToFocus) {
4086
- canvas.addEventListener("mousedown", handleMouseDown);
4087
4122
  canvas.addEventListener("click", handleCanvasClick);
4088
4123
  canvas.addEventListener("dblclick", handleDoubleClick);
4089
4124
  }
@@ -4092,7 +4127,6 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
4092
4127
  canvas.addEventListener("keydown", handleKeydown);
4093
4128
  }
4094
4129
  const dispose = () => {
4095
- canvas.removeEventListener("mousedown", handleMouseDown);
4096
4130
  canvas.removeEventListener("click", handleCanvasClick);
4097
4131
  canvas.removeEventListener("dblclick", handleDoubleClick);
4098
4132
  canvas.removeEventListener("keydown", handleKeydown);
@@ -4107,6 +4141,69 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
4107
4141
  setHover
4108
4142
  };
4109
4143
  }
4144
+ // src/render/scene-setup/tap-gesture.ts
4145
+ /** A mouse press is precise; past this it was a drag to orbit or pan. */
4146
+ const MOUSE_SLOP_PX = 5;
4147
+ /** A fingertip wobbles a few pixels on a deliberate tap. */
4148
+ const TOUCH_SLOP_PX = 10;
4149
+ /** A touch held this long was the start of an orbit that never moved, not a tap. */
4150
+ const TOUCH_LONG_PRESS_MS = 500;
4151
+ function trackTaps(canvas) {
4152
+ const down = /* @__PURE__ */ new Map();
4153
+ let pointerType = "mouse";
4154
+ let startTime = 0;
4155
+ let maxPointers = 0;
4156
+ let maxTravel = 0;
4157
+ let cancelled = false;
4158
+ let lastWasTap = false;
4159
+ const travel = (event) => {
4160
+ const start = down.get(event.pointerId);
4161
+ if (!start) return;
4162
+ maxTravel = Math.max(maxTravel, Math.hypot(event.clientX - start.x, event.clientY - start.y));
4163
+ };
4164
+ const onDown = (event) => {
4165
+ if (down.size === 0) {
4166
+ pointerType = event.pointerType;
4167
+ startTime = event.timeStamp;
4168
+ maxPointers = 0;
4169
+ maxTravel = 0;
4170
+ cancelled = false;
4171
+ }
4172
+ down.set(event.pointerId, {
4173
+ x: event.clientX,
4174
+ y: event.clientY
4175
+ });
4176
+ maxPointers = Math.max(maxPointers, down.size);
4177
+ };
4178
+ const onMove = (event) => travel(event);
4179
+ const onEnd = (event) => {
4180
+ if (!down.has(event.pointerId)) return;
4181
+ travel(event);
4182
+ if (event.type === "pointercancel") cancelled = true;
4183
+ down.delete(event.pointerId);
4184
+ if (down.size > 0) return;
4185
+ const isMouse = pointerType === "mouse";
4186
+ lastWasTap = !cancelled && maxPointers === 1 && maxTravel <= (isMouse ? MOUSE_SLOP_PX : TOUCH_SLOP_PX) && (isMouse || event.timeStamp - startTime <= TOUCH_LONG_PRESS_MS);
4187
+ };
4188
+ const options = {
4189
+ capture: true,
4190
+ passive: true
4191
+ };
4192
+ canvas.addEventListener("pointerdown", onDown, options);
4193
+ canvas.addEventListener("pointermove", onMove, options);
4194
+ canvas.addEventListener("pointerup", onEnd, options);
4195
+ canvas.addEventListener("pointercancel", onEnd, options);
4196
+ return {
4197
+ wasTap: () => lastWasTap,
4198
+ pointerType: () => pointerType,
4199
+ dispose: () => {
4200
+ canvas.removeEventListener("pointerdown", onDown, options);
4201
+ canvas.removeEventListener("pointermove", onMove, options);
4202
+ canvas.removeEventListener("pointerup", onEnd, options);
4203
+ canvas.removeEventListener("pointercancel", onEnd, options);
4204
+ }
4205
+ };
4206
+ }
4110
4207
  // src/render/scene-setup/setup-renderer.ts
4111
4208
  function setupRenderer(canvas, config, pixelRatio) {
4112
4209
  const renderer = new THREE.WebGLRenderer({
@@ -4215,7 +4312,8 @@ const initThree = function(canvas, options) {
4215
4312
  format: config.measure.format
4216
4313
  }
4217
4314
  }) : null;
4218
- const eventHandlers = config.events.enableEventHandlers !== false ? setupEventHandlers(canvas, scene, cameraController, config) : {
4315
+ const taps = trackTaps(canvas);
4316
+ const eventHandlers = config.events.enableEventHandlers !== false ? setupEventHandlers(canvas, scene, cameraController, config, taps) : {
4219
4317
  dispose: () => {},
4220
4318
  fitToView: () => {},
4221
4319
  clearSelection: () => {},
@@ -4233,19 +4331,10 @@ const initThree = function(canvas, options) {
4233
4331
  tool: gizmo,
4234
4332
  priority: -100
4235
4333
  });
4236
- const DRAG_SLOP_PX = 5;
4237
- let pressX = 0;
4238
- let pressY = 0;
4239
- const handlePointerDown = (event) => {
4240
- pressX = event.clientX;
4241
- pressY = event.clientY;
4242
- };
4243
- const wasDrag = (event) => Math.hypot(event.clientX - pressX, event.clientY - pressY) > DRAG_SLOP_PX;
4244
4334
  const handleToolClick = (event) => {
4245
- if (wasDrag(event)) return;
4335
+ if (!taps.wasTap()) return;
4246
4336
  if (tools.handleClick(event)) event.stopImmediatePropagation();
4247
4337
  };
4248
- canvas.addEventListener("mousedown", handlePointerDown, { capture: true });
4249
4338
  canvas.addEventListener("click", handleToolClick, { capture: true });
4250
4339
  const handleToolMove = (event) => tools.handleMove(event);
4251
4340
  canvas.addEventListener("mousemove", handleToolMove, { passive: true });
@@ -4354,7 +4443,7 @@ const initThree = function(canvas, options) {
4354
4443
  disposed = true;
4355
4444
  disposeAnimation();
4356
4445
  eventHandlers.dispose();
4357
- canvas.removeEventListener("mousedown", handlePointerDown, { capture: true });
4446
+ taps.dispose();
4358
4447
  canvas.removeEventListener("click", handleToolClick, { capture: true });
4359
4448
  canvas.removeEventListener("mousemove", handleToolMove);
4360
4449
  measureTool?.dispose();