Skip to content

Commit 07f5e02

Browse files
committed
refactor: move orbit controls into a helper, trying to fix ephys coord systems
1 parent 1b3d681 commit 07f5e02

5 files changed

Lines changed: 262 additions & 159 deletions

File tree

web/probe-transform-debug.html

Lines changed: 90 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ <h1>Probe Transform Debugger</h1>
9898
applyExtrinsicRotation,
9999
applyTranslation,
100100
} from './src/lib/coord-systems.js';
101+
import { createOrbitControls } from './src/lib/orbit-controls.js';
101102

102103
// ── Probe examples ────────────────────────────────────────────────────────
103104
const EXAMPLES = [
@@ -190,23 +191,74 @@ <h1>Probe Transform Debugger</h1>
190191
{ type: 'Translation', translation: [0, 0, 5, 0], label: 'T=[0,0,5] — 5mm +S' },
191192
],
192193
},
193-
// ── Real probe (disabled by default) ────────────────────────────────
194+
// ── Depth (4th component) axis tests ────────────────────────────────
194195
{
195-
name: 'Probe 46121 (real)',
196+
name: '⑫ depth=+5 at rest — should move +A',
197+
color: '#ff44ff',
198+
target: 'expect: probe at rest points +A; depth=+5 moves tip 5mm AWAY from +A (−A / posterior)',
199+
transforms: [
200+
{ type: 'Translation', translation: [0, 0, 0, 5], label: 'depth=+5mm along dir (+A at rest)' },
201+
],
202+
},
203+
{
204+
name: '⑬ depth=−5 at rest — should move −A',
205+
color: '#aa00aa',
206+
target: 'expect: depth=−5 moves tip 5mm TOWARD +A arrow (anterior)',
207+
transforms: [
208+
{ type: 'Translation', translation: [0, 0, 0, -5], label: 'depth=−5mm along dir (−A at rest)' },
209+
],
210+
},
211+
{
212+
name: '⑭ Rx(+90) then depth=+5 — should move +S',
213+
color: '#ff88ff',
214+
target: 'expect: Rx pitches probe to +S; depth=+5 moves tip AWAY from +S (−S / inferior)',
215+
transforms: [
216+
{ type: 'Rotation', angles: [90, 0, 0], label: 'Rx(+90°) — pitch probe to +S' },
217+
{ type: 'Translation', translation: [0, 0, 0, 5], label: 'depth=+5mm along dir (now +S)' },
218+
],
219+
},
220+
// ── Real probes from example_metadata.json ──────────────────────────
221+
{
222+
name: 'Probe 46121 → PAL (Pallidum)',
196223
color: '#FF6B6B',
197-
target: 'Pallidum (PAL)',
224+
target: 'target: Pallidum (PAL)',
198225
transforms: [
199-
{ type: 'Rotation', angles: [90, 0, -90], label: 'R1 — initial reorientation' },
200-
{ type: 'Rotation', angles: [0, 0, 0], label: 'R2 — identity (no-op)' },
201-
{ type: 'Translation', translation: [7.502, 5.333, 11.386, 1], label: 'T1 — manipulator position (mm)' },
202-
{ type: 'Rotation', angles: [14.963, -4.141, -90], label: 'R3 — fine-tune direction' },
203-
{ type: 'Translation', translation: [3.962, -12.102, -4.041, 3.611], label: 'T2 — tip position (mm)' },
226+
{ type: 'Rotation', angles: [90, 0, -90], label: 'R1 — initial reorientation' },
227+
{ type: 'Rotation', angles: [0, 0, 0], label: 'R2 — identity (no-op)' },
228+
{ type: 'Translation', translation: [7.502, 5.333, 11.386, 1], label: 'T1 — manipulator position (mm)', intrinsic: true },
229+
{ type: 'Rotation', angles: [14.96260569495023, -4.140622262893658, -90], label: 'R3 — rotate around bregma' },
230+
{ type: 'Translation', translation: [3.9618575437063566, -12.101629723550062, -4.040916212626378, 3.611], label: 'T2 — tip position (mm)' },
231+
],
232+
},
233+
{
234+
name: 'Probe 46105 → MD (Mediodorsal thalamus)',
235+
color: '#4ECDC4',
236+
target: 'target: Mediodorsal nucleus of thalamus (MD)',
237+
transforms: [
238+
{ type: 'Rotation', angles: [90, 0, -90], label: 'R1 — initial reorientation' },
239+
{ type: 'Rotation', angles: [0, 0, 0], label: 'R2 — identity (no-op)' },
240+
{ type: 'Translation', translation: [8.754, 4.721, 11.133, 1], label: 'T1 — manipulator position (mm)', intrinsic: true },
241+
{ type: 'Rotation', angles: [4.371791013219473, -29.092718819262377, -90], label: 'R3 — rotate around bregma' },
242+
{ type: 'Translation', translation: [-3.2039830376214535, -9.58046494924959, -8.495415766802356, 3.862], label: 'T2 — tip position (mm)' },
243+
],
244+
},
245+
{
246+
name: 'Probe 46811 → PIR (Piriform area)',
247+
color: '#45B7D1',
248+
target: 'target: Piriform area (PIR)',
249+
transforms: [
250+
{ type: 'Rotation', angles: [90, 0, -90], label: 'R1 — initial reorientation' },
251+
{ type: 'Rotation', angles: [0, 0, 0], label: 'R2 — identity (no-op)' },
252+
{ type: 'Translation', translation: [7.925, 0.227, 10.681, 1], label: 'T1 — manipulator position (mm)', intrinsic: true },
253+
{ type: 'Rotation', angles: [-4.987789352538776, -4.015229468646872, -90], label: 'R3 — rotate around bregma' },
254+
{ type: 'Translation', translation: [1.7128800425290023, -7.173946069818659, -7.053089943226251, 3.46], label: 'T2 — tip position (mm)' },
204255
],
205256
},
206257
];
207258

208259
// ── State: probeEnabled[pi], enabled[pi][ti] ─────────────────────────────
209-
const probeEnabled = EXAMPLES.map(() => false);
260+
// Enable the last 3 entries (real probes from example_metadata.json) by default.
261+
const probeEnabled = EXAMPLES.map((_, i) => i >= EXAMPLES.length - 3);
210262
const enabled = EXAMPLES.map(e => e.transforms.map(() => true));
211263

212264
// ── Coordinate basis from coord-systems.js ────────────────────────────────
@@ -239,8 +291,32 @@ <h1>Probe Transform Debugger</h1>
239291
if (t.type === 'Rotation') {
240292
dir = applyExtrinsicRotation(dir, t.angles, BASIS);
241293
wid = applyExtrinsicRotation(wid, t.angles, BASIS);
294+
if (!t.intrinsic) {
295+
// All rotations pivot around bregma (world origin) by default.
296+
// Set intrinsic: true on a rotation to skip pivoting pos.
297+
pos = applyExtrinsicRotation(pos, t.angles, BASIS);
298+
}
242299
} else if (t.type === 'Translation') {
243-
pos = applyTranslation(pos, t.translation ?? [], BASIS);
300+
if (t.intrinsic) {
301+
// Apply translation in the probe's current local frame.
302+
// Local axes: wid = axis-0 (R), dir = axis-1 (A), wid×dir = axis-2 (S).
303+
const localS = [
304+
wid[1]*dir[2] - wid[2]*dir[1],
305+
wid[2]*dir[0] - wid[0]*dir[2],
306+
wid[0]*dir[1] - wid[1]*dir[0],
307+
];
308+
pos = applyTranslation(pos, t.translation ?? [], [wid, dir, localS]);
309+
} else {
310+
pos = applyTranslation(pos, t.translation ?? [], BASIS);
311+
}
312+
// 4th component = depth: move tip along the probe's current long axis (dir).
313+
// Negative sign: positive depth moves the tip in the −dir direction (insertion).
314+
const depth = (t.translation ?? [])[3];
315+
if (depth != null && depth !== 0) {
316+
pos[0] -= depth * dir[0];
317+
pos[1] -= depth * dir[1];
318+
pos[2] -= depth * dir[2];
319+
}
244320
}
245321
}
246322
steps.push({ pos: [...pos], dir: norm(dir), wid: norm(wid), type: t.type, label: t.label, active });
@@ -520,38 +596,10 @@ <h1>Probe Transform Debugger</h1>
520596

521597
// ── Orbit controls ────────────────────────────────────────────────────────
522598
const TARGET = new THREE.Vector3(0, -3.668, -1.2);
523-
const SPEED = 0.007;
524-
const axZ = new THREE.Vector3(0, 0, 1);
525-
const axX = new THREE.Vector3(1, 0, 0);
526-
let dragging = false, sx = 0, sy = 0;
527-
const camOff = new THREE.Vector3(), camUp = new THREE.Vector3();
528-
529-
function startDrag(x, y) {
530-
dragging = true; sx = x; sy = y;
531-
camOff.copy(camera.position).sub(TARGET);
532-
camUp.copy(camera.up);
533-
}
534-
function stopDrag() { dragging = false; }
535-
function doDrag(x, y) {
536-
if (!dragging) return;
537-
const q = new THREE.Quaternion()
538-
.setFromAxisAngle(axZ, -(x-sx)*SPEED)
539-
.multiply(new THREE.Quaternion().setFromAxisAngle(axX, (y-sy)*SPEED));
540-
camera.position.copy(TARGET).add(camOff.clone().applyQuaternion(q));
541-
camera.up.copy(camUp).applyQuaternion(q);
542-
camera.lookAt(TARGET);
543-
}
544-
545-
renderer.domElement.addEventListener('mousedown', e => startDrag(e.clientX, e.clientY));
546-
window.addEventListener('mouseup', stopDrag);
547-
window.addEventListener('mousemove', e => doDrag(e.clientX, e.clientY));
548-
renderer.domElement.addEventListener('wheel', e => {
549-
e.preventDefault();
550-
const dir = camera.position.clone().sub(TARGET).normalize();
551-
const d = Math.max(3, Math.min(80, camera.position.distanceTo(TARGET) + e.deltaY * 0.03));
552-
camera.position.copy(TARGET).addScaledVector(dir, d);
553-
camera.lookAt(TARGET);
554-
}, { passive: false });
599+
const initCamUp = camera.up.clone();
600+
const orbitControls = createOrbitControls(camera, TARGET, initCamUp, renderer.domElement, {
601+
rotateSpeed: 0.007,
602+
});
555603

556604
// Resize
557605
new ResizeObserver(() => {

web/src/lib/coord-systems.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,26 @@ export function computeProbeDirectionSteps(transforms, coordinateSystem = null)
286286
if (type === 'Rotation') {
287287
dir = applyExtrinsicRotation(dir, t.angles ?? [], columns);
288288
wid = applyExtrinsicRotation(wid, t.angles ?? [], columns);
289+
// All rotations pivot around Bregma (world origin) — rotate pos too.
290+
pos = applyExtrinsicRotation(pos, t.angles ?? [], columns);
289291
} else if (type === 'Translation') {
290-
pos = applyTranslation(pos, t.translation ?? [], columns);
292+
if (t.intrinsic) {
293+
// Apply translation in the probe's current local frame.
294+
// Local axes: wid = axis-0 (R), dir = axis-1 (A), localS = wid × dir (S).
295+
const localS = [
296+
wid[1] * dir[2] - wid[2] * dir[1],
297+
wid[2] * dir[0] - wid[0] * dir[2],
298+
wid[0] * dir[1] - wid[1] * dir[0],
299+
];
300+
pos = applyTranslation(pos, t.translation ?? [], [wid, dir, localS]);
301+
} else {
302+
pos = applyTranslation(pos, t.translation ?? [], columns);
303+
}
304+
// 4th component = depth: positive depth moves tip along −dir (insertion).
305+
const depth = (t.translation ?? [])[3];
306+
if (depth != null && depth !== 0) {
307+
pos = [pos[0] - depth * dir[0], pos[1] - depth * dir[1], pos[2] - depth * dir[2]];
308+
}
291309
}
292310

293311
steps.push({ dir: norm(dir), wid: norm(wid), pos: [...pos], type });

web/src/lib/orbit-controls.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* orbit-controls.js — Simple mouse/touch orbit camera controller.
3+
*
4+
* Accumulates pitch and roll angles separately, then reconstructs the
5+
* quaternion each frame. This ensures clean, predictable rotation with no
6+
* gimbal lock or axis coupling.
7+
*/
8+
9+
import * as THREE from 'three';
10+
11+
/**
12+
* Create and attach an orbit camera controller.
13+
*
14+
* @param {THREE.Camera} camera - The camera to control.
15+
* @param {THREE.Vector3} target - The world point to orbit around.
16+
* @param {THREE.Vector3} initCamUp - The initial camera up vector.
17+
* @param {HTMLElement} domElement - The renderer DOM element.
18+
* @param {object} options - Configuration options.
19+
* - rotateSpeed: rotation speed factor (default 0.007)
20+
* - zoomSpeed: zoom speed factor (default 0.03)
21+
* - minRadius: minimum camera distance (default 3)
22+
* - maxRadius: maximum camera distance (default 80)
23+
* @returns {object} Controller object with no public methods (manages itself).
24+
*/
25+
export function createOrbitControls(camera, target, initCamUp, domElement, options = {}) {
26+
const ROTATE_SPEED = options.rotateSpeed ?? 0.007;
27+
const ZOOM_SPEED = options.zoomSpeed ?? 0.03;
28+
const MIN_RADIUS = options.minRadius ?? 3;
29+
const MAX_RADIUS = options.maxRadius ?? 80;
30+
31+
const axisX = new THREE.Vector3(1, 0, 0);
32+
const axisZ = new THREE.Vector3(0, 0, 1);
33+
34+
const initCamDir = camera.position.clone().sub(target).normalize();
35+
let camRadius = camera.position.distanceTo(target);
36+
37+
let totalPitch = 0, totalRoll = 0;
38+
let dragging = false, sx = 0, sy = 0;
39+
40+
function updateCamera() {
41+
const qRoll = new THREE.Quaternion().setFromAxisAngle(axisZ, totalRoll);
42+
const qPitch = new THREE.Quaternion().setFromAxisAngle(axisX, totalPitch);
43+
const totalQ = new THREE.Quaternion().multiplyQuaternions(qRoll, qPitch);
44+
45+
camera.position.copy(target).addScaledVector(initCamDir.clone().applyQuaternion(totalQ), camRadius);
46+
camera.up.copy(initCamUp).applyQuaternion(totalQ);
47+
camera.lookAt(target);
48+
}
49+
50+
function startDrag(x, y) {
51+
dragging = true;
52+
sx = x;
53+
sy = y;
54+
}
55+
56+
function stopDrag() {
57+
dragging = false;
58+
}
59+
60+
function doDrag(x, y) {
61+
if (!dragging) return;
62+
totalPitch += (y - sy) * ROTATE_SPEED;
63+
totalRoll -= (x - sx) * ROTATE_SPEED;
64+
sx = x;
65+
sy = y;
66+
updateCamera();
67+
}
68+
69+
function doZoom(delta) {
70+
camRadius = Math.max(MIN_RADIUS, Math.min(MAX_RADIUS, camRadius + delta * ZOOM_SPEED));
71+
updateCamera();
72+
}
73+
74+
// Mouse events
75+
domElement.addEventListener('mousedown', (e) => startDrag(e.clientX, e.clientY));
76+
window.addEventListener('mouseup', stopDrag);
77+
window.addEventListener('mousemove', (e) => doDrag(e.clientX, e.clientY));
78+
79+
// Touch events
80+
domElement.addEventListener(
81+
'touchstart',
82+
(e) => {
83+
startDrag(e.touches[0].clientX, e.touches[0].clientY);
84+
},
85+
{ passive: true },
86+
);
87+
window.addEventListener('touchend', stopDrag);
88+
window.addEventListener(
89+
'touchmove',
90+
(e) => {
91+
e.preventDefault();
92+
doDrag(e.touches[0].clientX, e.touches[0].clientY);
93+
},
94+
{ passive: false },
95+
);
96+
97+
// Wheel zoom
98+
domElement.addEventListener(
99+
'wheel',
100+
(e) => {
101+
e.preventDefault();
102+
doZoom(e.deltaY);
103+
},
104+
{ passive: false },
105+
);
106+
107+
return {
108+
updateCamera,
109+
dispose: () => {
110+
// Clean up listeners if needed
111+
},
112+
};
113+
}

web/src/subject/brain-viz-3d.js

Lines changed: 5 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import structuresData from './allen_mouse_100um_v1.2/structures.json';
2828
import surfaceDepthData from './allen_mouse_100um_v1.2/surface_depth.json';
2929
import { parseTranslation } from '../lib/coord-systems.js';
3030
import { ITEM_COLORS } from './brain-viz.js';
31+
import { createOrbitControls } from '../lib/orbit-controls.js';
3132

3233
// ── Surface depth lookup ──────────────────────────────────────────────────
3334
// surface_depth.json: depth_um[AP_idx][ML_idx] = DV µm of first brain voxel
@@ -269,55 +270,10 @@ async function _init3D(container, statusEl, infoEl, surgeryData, proceduresCoord
269270

270271
// ── Camera orbit controls ─────────────────────────────────────────────
271272
const TARGET_V = new THREE.Vector3(TARGET_X, TARGET_Y, TARGET_Z);
272-
const ROTATE_SPEED = 0.007;
273-
const axisZ = new THREE.Vector3(0, 0, 1); // AP / roll
274-
const axisX = new THREE.Vector3(1, 0, 0); // ML / pitch
275-
276-
let dragging = false;
277-
let startDragX = 0, startDragY = 0;
278-
const startCamOffset = new THREE.Vector3();
279-
const startCamUp = new THREE.Vector3();
280-
281-
function startDrag(cx, cy) {
282-
dragging = true;
283-
startDragX = cx; startDragY = cy;
284-
startCamOffset.copy(camera.position).sub(TARGET_V);
285-
startCamUp.copy(camera.up);
286-
}
287-
function stopDrag() { dragging = false; }
288-
function moveDrag(cx, cy) {
289-
if (!dragging) return;
290-
const dx = cx - startDragX;
291-
const dy = cy - startDragY;
292-
const qRoll = new THREE.Quaternion().setFromAxisAngle(axisZ, -dx * ROTATE_SPEED);
293-
const qPitch = new THREE.Quaternion().setFromAxisAngle(axisX, dy * ROTATE_SPEED);
294-
const q = qRoll.multiply(qPitch);
295-
camera.position.copy(TARGET_V).add(startCamOffset.clone().applyQuaternion(q));
296-
camera.up.copy(startCamUp).applyQuaternion(q);
297-
camera.lookAt(TARGET_V);
298-
}
299-
300-
renderer.domElement.addEventListener('mousedown', e => startDrag(e.clientX, e.clientY));
301-
window.addEventListener('mouseup', stopDrag);
302-
window.addEventListener('mousemove', e => moveDrag(e.clientX, e.clientY));
303-
304-
renderer.domElement.addEventListener('touchstart', e => {
305-
startDrag(e.touches[0].clientX, e.touches[0].clientY);
306-
}, { passive: true });
307-
window.addEventListener('touchend', stopDrag);
308-
window.addEventListener('touchmove', e => {
309-
e.preventDefault();
310-
moveDrag(e.touches[0].clientX, e.touches[0].clientY);
311-
}, { passive: false });
312-
313-
renderer.domElement.addEventListener('wheel', (e) => {
314-
e.preventDefault();
315-
const dir = camera.position.clone().sub(TARGET_V).normalize();
316-
const dist = camera.position.distanceTo(TARGET_V);
317-
const nd = Math.max(3, Math.min(80, dist + e.deltaY * 0.03));
318-
camera.position.copy(TARGET_V).addScaledVector(dir, nd);
319-
camera.lookAt(TARGET_V);
320-
}, { passive: false });
273+
const initCamUp = camera.up.clone();
274+
createOrbitControls(camera, TARGET_V, initCamUp, renderer.domElement, {
275+
rotateSpeed: 0.007,
276+
});
321277

322278
// ── Resize ────────────────────────────────────────────────────────────
323279
const ro = new ResizeObserver(() => {

0 commit comments

Comments
 (0)