Skip to content

Commit ad14340

Browse files
Max Carlsonclaude
andcommitted
feat: add zero-config pointer/raycaster system and pinch-to-zoom gestures
- Implemented complete pointer interaction system (hover, click, multitouch) - Added pinch-to-zoom and two-finger pan gesture controls - Removed manual raycasting boilerplate from all examples - Fixed multi-selection to support contiguous range with Shift+Click - Added Vite plugin for automatic playground rebuild on snippet changes Pointer System: - PointerController: Event-driven raycasting (no per-frame overhead) - DOMPointerAdapter: DOM event → abstract pointer state - Auto-highlight on hover, auto-select on click (configurable) - Multi-pointer support with per-pointer highlight tracking - Transport-agnostic (works with local and remote pointers) - 13 tests passing Gesture System: - GestureController: Pinch-to-zoom and two-finger pan - Touch point tracking with distance calculation - Configurable zoom/pan speeds, auto-clamped zoom (0.1x-10x) - Passive:false event listeners for preventDefault support - 8 tests passing New Examples: - 18-custom-click-handling.md: preventDefault pattern, modifier keys - 19-multitouch.md: Multi-pointer interaction, pressure sensitivity - 20-remote-pointer.md: Transport-based pointer sync architecture - 21-pinch-to-zoom.md: Touch gesture controls for camera Updated Examples: - 12-basic-selection.md: Zero-config click selection - 13-multi-selection.md: Proper contiguous Shift+Click selection - 15-presence-tracking.md: Simplified with auto-click - 16-hover-highlighting.md: Removed ~50 lines of raycasting code Developer Experience: - Vite plugin watches snippets/ and auto-rebuilds playground - Full HMR support for snippet changes - Console logging with emoji indicators (📝 changed, ➕ added, ➖ removed) Breaking Changes: None (backwards compatible via legacy pointer ID) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent ed4614c commit ad14340

17 files changed

+1415
-179
lines changed

WebSites/spacecraft-viewer/examples/index.html

Lines changed: 41 additions & 5 deletions
Large diffs are not rendered by default.

WebSites/spacecraft-viewer/examples/snippets/12-basic-selection.md

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ The viewer provides a `state` object with selection methods:
1616
- `clearSelection()` - clears all selections
1717

1818
```js
19-
const { Viewer, THREE } = SpaceCraftViewer;
19+
const { Viewer } = SpaceCraftViewer;
2020

2121
const canvas = document.getElementById('canvas');
22-
const viewer = new Viewer(canvas);
22+
const viewer = new Viewer(canvas); // Click selection enabled by default!
2323

2424
// Sample data
2525
const items = Array.from({ length: 20 }, (_, i) => ({
@@ -36,43 +36,16 @@ viewer
3636
spacing: 3
3737
});
3838

39-
// Setup raycasting for click selection
40-
const raycaster = new THREE.Raycaster();
41-
const mouse = new THREE.Vector2();
39+
// Click selection works automatically!
40+
// By default, clicking toggles selection
4241

43-
canvas.addEventListener('click', (event) => {
44-
const rect = canvas.getBoundingClientRect();
45-
46-
// Convert mouse position to normalized device coordinates (-1 to +1)
47-
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
48-
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
49-
50-
// Raycast from camera through mouse position
51-
raycaster.setFromCamera(mouse, viewer.camera);
52-
53-
// Find intersected objects
54-
const intersects = raycaster.intersectObjects(viewer.scene.children, true);
55-
56-
if (intersects.length > 0) {
57-
// Find the first mesh with an itemId
58-
for (const intersect of intersects) {
59-
const itemId = intersect.object.userData.itemId;
60-
if (itemId) {
61-
// Clear previous selection and select clicked item
62-
viewer.state.clearSelection();
63-
viewer.state.selectItem(itemId);
64-
console.log('Selected:', itemId);
65-
break;
66-
}
67-
}
68-
} else {
69-
// Clicked empty space - clear selection
70-
viewer.state.clearSelection();
71-
}
42+
// Listen to click events
43+
viewer.on('item-clicked', ({ itemId }) => {
44+
console.log('Clicked:', itemId);
7245
});
7346

7447
// Listen to selection changes
75-
viewer.state.on('selection-changed', (selectedItems) => {
48+
viewer.on('selection-changed', (selectedItems) => {
7649
console.log('Selection changed:', Array.from(selectedItems));
7750
});
7851

WebSites/spacecraft-viewer/examples/snippets/13-multi-selection.md

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,21 @@ description: Select multiple items with Shift/Ctrl modifiers
1010
This example demonstrates multi-item selection using keyboard modifiers:
1111
- **Click** - Select single item (clears previous selection)
1212
- **Ctrl/Cmd + Click** - Toggle item in selection
13-
- **Shift + Click** - Add item to selection
13+
- **Shift + Click** - Select range from last selected item to clicked item
1414
- **Esc** - Clear all selections
1515

1616
```js
17-
const { Viewer, THREE } = SpaceCraftViewer;
17+
const { Viewer } = SpaceCraftViewer;
1818

1919
const canvas = document.getElementById('canvas');
20-
const viewer = new Viewer(canvas);
20+
21+
// Disable default click behavior, handle it manually
22+
const viewer = new Viewer(canvas, {
23+
interaction: {
24+
hover: true, // Keep hover highlights
25+
click: false // Disable auto-toggle, we'll handle clicks manually
26+
}
27+
});
2128

2229
// Sample data
2330
const items = Array.from({ length: 30 }, (_, i) => ({
@@ -34,58 +41,55 @@ viewer
3441
spacing: 2.5
3542
});
3643

37-
// Setup raycasting for click selection
38-
const raycaster = new THREE.Raycaster();
39-
const mouse = new THREE.Vector2();
44+
// Track modifier keys and last selected item
45+
let ctrlPressed = false;
46+
let shiftPressed = false;
47+
let lastSelectedItemId = null;
48+
49+
document.addEventListener('keydown', (event) => {
50+
if (event.key === 'Control' || event.key === 'Meta') ctrlPressed = true;
51+
if (event.key === 'Shift') shiftPressed = true;
52+
if (event.key === 'Escape') {
53+
viewer.state.clearSelection();
54+
lastSelectedItemId = null;
55+
}
56+
});
4057

41-
canvas.addEventListener('click', (event) => {
42-
const rect = canvas.getBoundingClientRect();
58+
document.addEventListener('keyup', (event) => {
59+
if (event.key === 'Control' || event.key === 'Meta') ctrlPressed = false;
60+
if (event.key === 'Shift') shiftPressed = false;
61+
});
4362

44-
// Convert mouse position to normalized device coordinates
45-
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
46-
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
63+
// Handle clicks with modifier keys
64+
viewer.on('item-clicked', ({ itemId }) => {
65+
if (ctrlPressed) {
66+
// Ctrl/Cmd+Click: Toggle selection
67+
viewer.state.toggleSelection(itemId);
68+
lastSelectedItemId = itemId;
69+
} else if (shiftPressed && lastSelectedItemId) {
70+
// Shift+Click: Select range from last selected to current
71+
const lastIndex = items.findIndex(item => item.id === lastSelectedItemId);
72+
const currentIndex = items.findIndex(item => item.id === itemId);
4773

48-
// Raycast from camera
49-
raycaster.setFromCamera(mouse, viewer.camera);
50-
const intersects = raycaster.intersectObjects(viewer.scene.children, true);
74+
if (lastIndex !== -1 && currentIndex !== -1) {
75+
const start = Math.min(lastIndex, currentIndex);
76+
const end = Math.max(lastIndex, currentIndex);
5177

52-
if (intersects.length > 0) {
53-
// Find the first mesh with an itemId
54-
for (const intersect of intersects) {
55-
const itemId = intersect.object.userData.itemId;
56-
if (itemId) {
57-
// Check keyboard modifiers
58-
if (event.ctrlKey || event.metaKey) {
59-
// Ctrl/Cmd+Click: Toggle selection
60-
viewer.state.toggleSelection(itemId);
61-
} else if (event.shiftKey) {
62-
// Shift+Click: Add to selection
63-
viewer.state.selectItem(itemId);
64-
} else {
65-
// Plain click: Replace selection
66-
viewer.state.clearSelection();
67-
viewer.state.selectItem(itemId);
68-
}
69-
break;
78+
// Select all items in range
79+
for (let i = start; i <= end; i++) {
80+
viewer.state.selectItem(items[i].id);
7081
}
7182
}
7283
} else {
73-
// Clicked empty space - clear selection (unless modifier key)
74-
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {
75-
viewer.state.clearSelection();
76-
}
77-
}
78-
});
79-
80-
// Keyboard shortcuts
81-
document.addEventListener('keydown', (event) => {
82-
if (event.key === 'Escape') {
84+
// Plain click: Replace selection
8385
viewer.state.clearSelection();
86+
viewer.state.selectItem(itemId);
87+
lastSelectedItemId = itemId;
8488
}
8589
});
8690

8791
// Display selection count
88-
viewer.state.on('selection-changed', (selectedItems) => {
92+
viewer.on('selection-changed', (selectedItems) => {
8993
const count = selectedItems.size;
9094
console.log(`${count} item${count !== 1 ? 's' : ''} selected`);
9195
});

WebSites/spacecraft-viewer/examples/snippets/15-presence-tracking.md

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Open this example in multiple tabs with the same channel to see presence indicat
1818
- Presence state synchronization
1919

2020
```js
21-
const { Viewer, SupabaseTransport, THREE } = SpaceCraftViewer;
21+
const { Viewer, SupabaseTransport } = SpaceCraftViewer;
2222
const { getChannelFromUrl } = PlaygroundUtils;
2323

2424
(async () => {
@@ -61,35 +61,16 @@ const { getChannelFromUrl } = PlaygroundUtils;
6161
spacing: 2.5
6262
});
6363

64-
// Setup raycasting for selection
65-
const raycaster = new THREE.Raycaster();
66-
const mouse = new THREE.Vector2();
67-
68-
canvas.addEventListener('click', (event) => {
69-
const rect = canvas.getBoundingClientRect();
70-
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
71-
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
72-
73-
raycaster.setFromCamera(mouse, viewer.camera);
74-
const intersects = raycaster.intersectObjects(viewer.scene.children, true);
75-
76-
if (intersects.length > 0) {
77-
for (const intersect of intersects) {
78-
const itemId = intersect.object.userData.itemId;
79-
if (itemId) {
80-
viewer.state.clearSelection();
81-
viewer.state.selectItem(itemId);
82-
83-
// Broadcast selection to other users
84-
transport.broadcast('user-selected', {
85-
userId,
86-
itemId,
87-
timestamp: Date.now()
88-
});
89-
break;
90-
}
91-
}
92-
}
64+
// Click selection works automatically!
65+
// Listen for clicks and broadcast to other users
66+
viewer.on('item-clicked', ({ itemId }) => {
67+
// Auto-selection happens automatically
68+
// Broadcast selection to other users
69+
transport.broadcast('user-selected', {
70+
userId,
71+
itemId,
72+
timestamp: Date.now()
73+
});
9374
});
9475

9576
// Track other users' selections

WebSites/spacecraft-viewer/examples/snippets/16-hover-highlighting.md

Lines changed: 15 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ The visual feedback is updated automatically whenever `state.isDirty` is true, w
4242
Like selection, highlights can be synchronized across devices using a transport. The highlight state automatically syncs via StateSync when a transport is configured.
4343

4444
```js
45-
const { Viewer, THREE } = SpaceCraftViewer;
45+
const { Viewer } = SpaceCraftViewer;
4646

4747
const canvas = document.getElementById('canvas');
48-
const viewer = new Viewer(canvas);
48+
const viewer = new Viewer(canvas); // Interaction enabled by default!
4949

5050
// Sample data
5151
const items = Array.from({ length: 24 }, (_, i) => ({
@@ -62,81 +62,33 @@ viewer
6262
spacing: 2.5
6363
});
6464

65-
// Setup raycasting for hover detection
66-
const raycaster = new THREE.Raycaster();
67-
const mouse = new THREE.Vector2();
68-
let lastHighlightedId = null;
69-
70-
// Mousemove for hover highlighting
71-
canvas.addEventListener('mousemove', (event) => {
72-
const rect = canvas.getBoundingClientRect();
73-
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
74-
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
75-
76-
raycaster.setFromCamera(mouse, viewer.camera);
77-
const intersects = raycaster.intersectObjects(viewer.scene.children, true);
78-
79-
let hoveredId = null;
80-
81-
// Find the first intersected item
82-
if (intersects.length > 0) {
83-
for (const intersect of intersects) {
84-
const itemId = intersect.object.userData.itemId;
85-
if (itemId) {
86-
hoveredId = itemId;
87-
break;
88-
}
89-
}
90-
}
91-
92-
// Update highlights only if hover changed
93-
if (hoveredId !== lastHighlightedId) {
94-
// Clear previous highlight
95-
if (lastHighlightedId) {
96-
viewer.state.unhighlightItem(lastHighlightedId);
97-
}
65+
// Hover highlighting and click selection work automatically!
66+
// No manual raycasting needed.
9867

99-
// Set new highlight
100-
if (hoveredId) {
101-
viewer.state.highlightItem(hoveredId);
102-
}
68+
// Listen to hover events
69+
viewer.on('item-hover-enter', ({ itemId }) => {
70+
console.log('Hovered:', itemId);
71+
});
10372

104-
lastHighlightedId = hoveredId;
105-
}
73+
viewer.on('item-hover-exit', ({ itemId }) => {
74+
console.log('Unhovered:', itemId);
10675
});
10776

108-
// Click for selection (independent of highlighting)
109-
canvas.addEventListener('click', (event) => {
110-
const rect = canvas.getBoundingClientRect();
111-
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
112-
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
113-
114-
raycaster.setFromCamera(mouse, viewer.camera);
115-
const intersects = raycaster.intersectObjects(viewer.scene.children, true);
116-
117-
if (intersects.length > 0) {
118-
for (const intersect of intersects) {
119-
const itemId = intersect.object.userData.itemId;
120-
if (itemId) {
121-
// Toggle selection
122-
viewer.state.toggleSelection(itemId);
123-
console.log('Toggled selection:', itemId);
124-
break;
125-
}
126-
}
127-
}
77+
// Listen to click events
78+
viewer.on('item-clicked', ({ itemId }) => {
79+
console.log('Clicked:', itemId);
12880
});
12981

13082
// Listen to highlight changes
131-
viewer.state.on('highlight-changed', (highlightedItems) => {
83+
viewer.on('highlight-changed', (highlightedItems) => {
13284
if (highlightedItems.size > 0) {
13385
const ids = Array.from(highlightedItems);
13486
console.log('Highlighted:', ids);
13587
}
13688
});
13789

13890
// Listen to selection changes
139-
viewer.state.on('selection-changed', (selectedItems) => {
91+
viewer.on('selection-changed', (selectedItems) => {
14092
console.log('Selected:', Array.from(selectedItems));
14193
});
14294

0 commit comments

Comments
 (0)