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