feat: implement cursor-centric zoom functionality in viewer

This commit is contained in:
CNCKitchen
2026-03-26 10:42:29 +01:00
parent 656371c3e3
commit fca353a142
2 changed files with 50 additions and 0 deletions
+24
View File
@@ -292,6 +292,7 @@ function wireEvents() {
clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
}; };
scaleUSlider.addEventListener('input', () => applyScaleU(posToScale(parseFloat(scaleUSlider.value)))); scaleUSlider.addEventListener('input', () => applyScaleU(posToScale(parseFloat(scaleUSlider.value))));
scaleUSlider.addEventListener('dblclick', () => applyScaleU(posToScale(parseFloat(scaleUSlider.defaultValue))));
scaleUVal.addEventListener('change', () => applyScaleU(parseFloat(scaleUVal.value))); scaleUVal.addEventListener('change', () => applyScaleU(parseFloat(scaleUVal.value)));
// Scale V — when lock is on, mirror to U // Scale V — when lock is on, mirror to U
@@ -304,6 +305,7 @@ function wireEvents() {
clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
}; };
scaleVSlider.addEventListener('input', () => applyScaleV(posToScale(parseFloat(scaleVSlider.value)))); scaleVSlider.addEventListener('input', () => applyScaleV(posToScale(parseFloat(scaleVSlider.value))));
scaleVSlider.addEventListener('dblclick', () => applyScaleV(posToScale(parseFloat(scaleVSlider.defaultValue))));
scaleVVal.addEventListener('change', () => applyScaleV(parseFloat(scaleVVal.value))); scaleVVal.addEventListener('change', () => applyScaleV(parseFloat(scaleVVal.value)));
// Lock toggle // Lock toggle
@@ -412,6 +414,11 @@ function wireEvents() {
brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2; brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2;
exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value); exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value);
}); });
exclBrushRadiusSlider.addEventListener('dblclick', () => {
exclBrushRadiusSlider.value = exclBrushRadiusSlider.defaultValue;
brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2;
exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value);
});
exclBrushRadiusVal.addEventListener('change', () => { exclBrushRadiusVal.addEventListener('change', () => {
let diam = Math.max(0.2, Math.min(100, parseFloat(exclBrushRadiusVal.value) || 10)); let diam = Math.max(0.2, Math.min(100, parseFloat(exclBrushRadiusVal.value) || 10));
brushRadius = diam / 2; brushRadius = diam / 2;
@@ -424,6 +431,12 @@ function wireEvents() {
exclThresholdVal.value = bucketThreshold; exclThresholdVal.value = bucketThreshold;
_lastHoverTriIdx = -1; // invalidate hover so next mousemove re-computes _lastHoverTriIdx = -1; // invalidate hover so next mousemove re-computes
}); });
exclThresholdSlider.addEventListener('dblclick', () => {
exclThresholdSlider.value = exclThresholdSlider.defaultValue;
bucketThreshold = parseFloat(exclThresholdSlider.value);
exclThresholdVal.value = bucketThreshold;
_lastHoverTriIdx = -1;
});
exclThresholdVal.addEventListener('change', () => { exclThresholdVal.addEventListener('change', () => {
bucketThreshold = Math.max(0, Math.min(180, parseFloat(exclThresholdVal.value) || 20)); bucketThreshold = Math.max(0, Math.min(180, parseFloat(exclThresholdVal.value) || 20));
exclThresholdSlider.value = bucketThreshold; exclThresholdSlider.value = bucketThreshold;
@@ -907,6 +920,17 @@ function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
previewDebounce = setTimeout(updatePreview, 80); previewDebounce = setTimeout(updatePreview, 80);
} }
}); });
// Double-click resets to default value
slider.addEventListener('dblclick', () => {
slider.value = slider.defaultValue;
const v = parseFloat(slider.value);
const display = onChangeFn(v);
if (isSpan) valInput.textContent = display; else valInput.value = display;
if (livePreview) {
clearTimeout(previewDebounce);
previewDebounce = setTimeout(updatePreview, 80);
}
});
if (!isSpan) { if (!isSpan) {
valInput.addEventListener('change', () => { valInput.addEventListener('change', () => {
const raw = parseFloat(valInput.value); const raw = parseFloat(valInput.value);
+26
View File
@@ -183,6 +183,32 @@ export function initViewer(canvas) {
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.08;
controls.screenSpacePanning = true; controls.screenSpacePanning = true;
controls.enableZoom = false; // we handle zoom ourselves for cursor-centric behaviour
// Cursor-centric zoom: zoom toward the mouse pointer instead of screen centre
renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = renderer.domElement.getBoundingClientRect();
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
// World position under cursor before zoom
const before = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera);
// Apply zoom
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor));
camera.updateProjectionMatrix();
// World position under cursor after zoom
const after = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera);
// Shift camera + target so the world point stays under the cursor
const delta = before.clone().sub(after);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
}, { passive: false });
// Resize observer // Resize observer
const resizeObserver = new ResizeObserver(() => onResize()); const resizeObserver = new ResizeObserver(() => onResize());