Skip to content

Instantly share code, notes, and snippets.

@mozeryansky
Last active May 11, 2026 06:47
Show Gist options
  • Select an option

  • Save mozeryansky/fc3855d6a52937572c550e51d3acfabc to your computer and use it in GitHub Desktop.

Select an option

Save mozeryansky/fc3855d6a52937572c550e51d3acfabc to your computer and use it in GitHub Desktop.
3D Scene Gizmo
<!-- https://gistpreview.github.io/?fc3855d6a52937572c550e51d3acfabc -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D scene with ViewCube-style gizmo</title>
<style>
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #1f2228;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
#view-host {
position: relative;
width: 100vw; height: 100vh;
cursor: grab;
}
#view-host canvas { display: block; position: absolute; inset: 0; }
.proj-toggle {
position: absolute;
top: 144px; right: 16px;
width: 110px;
z-index: 5;
background: rgba(20,22,28,0.6);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 8px;
padding: 7px 10px;
font-size: 11px; font-weight: 500;
letter-spacing: 0.3px;
color: rgba(255,255,255,0.85);
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.proj-toggle:hover {
background: rgba(255,255,255,0.13);
border-color: rgba(255,255,255,0.32);
color: #fff;
}
.view-status {
position: absolute;
left: 14px; bottom: 12px;
font-size: 12px;
color: rgba(255,255,255,0.55);
z-index: 5;
pointer-events: none;
}
.hint {
position: absolute;
right: 16px; bottom: 12px;
font-size: 11px;
color: rgba(255,255,255,0.42);
z-index: 5;
pointer-events: none;
text-align: right;
line-height: 1.5;
}
</style>
</head>
<body>
<div id="view-host">
<button class="proj-toggle" id="projBtn">Perspective</button>
<div class="view-status" id="vs">Click face / edge / corner · drag the gizmo to orbit</div>
<div class="hint">
Front-facing edges and corners are outlined<br>
Hover to highlight, click to snap, drag to orbit
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function () {
const host = document.getElementById('view-host');
let W = host.clientWidth, H = host.clientHeight;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.autoClear = false;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(W, H);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
host.insertBefore(renderer.domElement, host.firstChild);
const canvas = renderer.domElement;
// ============== Main scene ==============
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1f2228);
scene.fog = new THREE.Fog(0x1f2228, 28, 80);
const persp = new THREE.PerspectiveCamera(45, W / H, 0.1, 200);
const orthoSize = 9;
const ortho = new THREE.OrthographicCamera(
-orthoSize * (W / H), orthoSize * (W / H),
orthoSize, -orthoSize, 0.1, 200
);
let activeCam = persp;
let projMode = 'persp';
scene.add(new THREE.AmbientLight(0x556677, 0.55));
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
sun.position.set(8, 14, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -14; sun.shadow.camera.right = 14;
sun.shadow.camera.top = 14; sun.shadow.camera.bottom = -14;
sun.shadow.camera.near = 0.1; sun.shadow.camera.far = 50;
sun.shadow.bias = -0.0005;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x6a8cb8, 0.32);
fill.position.set(-6, 4, -8);
scene.add(fill);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(80, 80),
new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.95 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(40, 40, 0x4a5160, 0x363a44);
grid.position.y = 0.005;
scene.add(grid);
function makeWorldAxis(color, axis) {
const len = 2.6;
const m = new THREE.Mesh(
new THREE.CylinderGeometry(0.045, 0.045, len, 12),
new THREE.MeshBasicMaterial({ color })
);
if (axis === 'x') { m.rotation.z = -Math.PI / 2; m.position.x = len / 2; }
if (axis === 'y') { m.position.y = len / 2; }
if (axis === 'z') { m.rotation.x = Math.PI / 2; m.position.z = len / 2; }
return m;
}
scene.add(makeWorldAxis(0xe35d5b, 'x'));
scene.add(makeWorldAxis(0x5dd47a, 'y'));
scene.add(makeWorldAxis(0x5b9be3, 'z'));
function box(x, y, z, w, h, d, color, rot) {
const m = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({ color, roughness: 0.5, metalness: 0.18 })
);
m.position.set(x, y, z);
m.rotation.y = rot || 0;
m.castShadow = true; m.receiveShadow = true;
scene.add(m);
}
box(0, 1, 0, 2, 2, 2, 0xc97a4a);
box(3.4, 0.6, -1.8, 1.4, 1.2, 1.4, 0x4a8fc9, 0.32);
box(-3.2, 1.4, 1.5, 1, 2.8, 1, 0x9d4ec9);
box(2.4, 0.36, 3.2, 1.6, 0.7, 1.6, 0x4ac98c, -0.2);
box(-2.6, 0.5, -2.9, 1.8, 1, 0.8, 0xc9ad4a, 0.15);
const sph = new THREE.Mesh(
new THREE.SphereGeometry(1.1, 32, 24),
new THREE.MeshStandardMaterial({ color: 0xc94a8c, roughness: 0.25, metalness: 0.45 })
);
sph.position.set(-1.6, 1.1, 3.4);
sph.castShadow = true; sph.receiveShadow = true;
scene.add(sph);
const cyl = new THREE.Mesh(
new THREE.CylinderGeometry(0.7, 0.7, 3.2, 28),
new THREE.MeshStandardMaterial({ color: 0xeeb840, roughness: 0.4, metalness: 0.3 })
);
cyl.position.set(4.6, 1.6, 1.5);
cyl.castShadow = true; cyl.receiveShadow = true;
scene.add(cyl);
const cone = new THREE.Mesh(
new THREE.ConeGeometry(0.95, 2, 28),
new THREE.MeshStandardMaterial({ color: 0x5dc9c2, roughness: 0.45 })
);
cone.position.set(-4.2, 1, -1);
cone.castShadow = true; cone.receiveShadow = true;
scene.add(cone);
const torus = new THREE.Mesh(
new THREE.TorusGeometry(0.7, 0.22, 16, 48),
new THREE.MeshStandardMaterial({ color: 0xb3b8c2, roughness: 0.3, metalness: 0.7 })
);
torus.position.set(1, 2.3, -3.5);
torus.rotation.x = Math.PI / 3;
torus.castShadow = true;
scene.add(torus);
// ============== Gizmo ==============
const GIZMO_SIZE = 120;
const GIZMO_MARGIN = 14;
const HOVER_COLOR = 0xc8d0dc;
const OUTLINE_OPACITY = 0.42;
const HOVER_OPACITY = 0.95;
const OUTLINE_RADIUS = 0.014; // ~1pt
const HOVER_RADIUS = 0.050; // ~4pt
const FRONT_FACING_THRESHOLD = 0.18; // dot-product cutoff for outline visibility
const gizmoScene = new THREE.Scene();
const gizmoCam = new THREE.PerspectiveCamera(40, 1, 0.1, 50);
function makeLetterTexture(letter) {
const c = document.createElement('canvas');
c.width = c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = '#fff';
ctx.font = '600 44px -apple-system, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(letter, 32, 35);
const tex = new THREE.CanvasTexture(c);
tex.minFilter = THREE.LinearFilter;
return tex;
}
function makeGizmoLine(dir, color) {
const len = 0.78;
const m = new THREE.Mesh(
new THREE.CylinderGeometry(0.025, 0.025, len, 8),
new THREE.MeshBasicMaterial({ color })
);
const up = new THREE.Vector3(0, 1, 0);
const t = new THREE.Vector3(dir.x, dir.y, dir.z).normalize();
const q = new THREE.Quaternion().setFromUnitVectors(up, t);
m.quaternion.copy(q);
m.position.copy(t).multiplyScalar(len / 2);
return m;
}
// Center hub
const hub = new THREE.Mesh(
new THREE.SphereGeometry(0.13, 16, 12),
new THREE.MeshBasicMaterial({ color: 0x9097a3 })
);
gizmoScene.add(hub);
const FACE_LABELS = {
right: 'Right', left: 'Left',
top: 'Top', bottom: 'Bottom',
front: 'Front', back: 'Back'
};
// Slerp helper (returns a unit Vector3 on the great-circle arc between v1 and v2)
function slerp(v1, v2, t) {
const dot = Math.max(-1, Math.min(1, v1.dot(v2)));
const omega = Math.acos(dot);
const sinOmega = Math.sin(omega);
if (Math.abs(sinOmega) < 1e-6) return v1.clone().lerp(v2, t).normalize();
const a = Math.sin((1 - t) * omega) / sinOmega;
const b = Math.sin(t * omega) / sinOmega;
return v1.clone().multiplyScalar(a).add(v2.clone().multiplyScalar(b)).normalize();
}
// Proper THREE.Curve subclass so TubeGeometry sweeps the true great-circle arc
// (CatmullRomCurve3 was distorting the tangent and causing visible rotation)
class GreatCircleArc extends THREE.Curve {
constructor(start, end) {
super();
this.start = start.clone().normalize();
this.end = end.clone().normalize();
const dot = Math.max(-1, Math.min(1, this.start.dot(this.end)));
this.omega = Math.acos(dot);
this.sinOmega = Math.sin(this.omega);
}
getPoint(t, optionalTarget) {
const result = optionalTarget || new THREE.Vector3();
if (Math.abs(this.sinOmega) < 1e-6) {
return result.copy(this.start).lerp(this.end, t);
}
const a = Math.sin((1 - t) * this.omega) / this.sinOmega;
const b = Math.sin(t * this.omega) / this.sinOmega;
return result.copy(this.start).multiplyScalar(a).addScaledVector(this.end, b);
}
}
function makeArcTube(v1, v2, radius, color, opacity) {
const curve = new GreatCircleArc(v1, v2);
const geo = new THREE.TubeGeometry(curve, 32, radius, 10, false);
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity, depthWrite: false
});
return new THREE.Mesh(geo, mat);
}
// ===== Face zones (6) — colored axis spheres =====
const FACE_ZONES = [];
const FACE_DEFS = [
{ view: 'right', dir: [ 1, 0, 0], color: 0xe35d5b, label: 'X', positive: true },
{ view: 'left', dir: [-1, 0, 0], color: 0xe35d5b, label: 'X', positive: false },
{ view: 'top', dir: [ 0, 1, 0], color: 0x5dd47a, label: 'Y', positive: true },
{ view: 'bottom', dir: [ 0,-1, 0], color: 0x5dd47a, label: 'Y', positive: false },
{ view: 'front', dir: [ 0, 0, 1], color: 0x5b9be3, label: 'Z', positive: true },
{ view: 'back', dir: [ 0, 0,-1], color: 0x5b9be3, label: 'Z', positive: false }
];
FACE_DEFS.forEach(d => {
const dirVec = new THREE.Vector3(d.dir[0], d.dir[1], d.dir[2]);
if (d.positive) gizmoScene.add(makeGizmoLine(dirVec, d.color));
const r = d.positive ? 0.24 : 0.18;
const baseOpacity = d.positive ? 1.0 : 0.42;
const mat = new THREE.MeshBasicMaterial({
color: d.color, transparent: true, opacity: baseOpacity
});
const sphere = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 18), mat);
sphere.position.copy(dirVec);
sphere.userData = { baseOpacity, hoverScale: 1.3 };
gizmoScene.add(sphere);
if (d.positive) {
const tex = makeLetterTexture(d.label);
const spriteMat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(0.34, 0.34, 1);
sphere.add(sprite);
}
FACE_ZONES.push({
type: 'face',
dirVec: dirVec.clone().normalize(),
view: d.view,
label: FACE_LABELS[d.view] + ' view',
marker: sphere
});
});
// ===== Edge zones (12) — thin outline tube + thick hover tube =====
function edgeLabel(d) {
const parts = [];
if (d[1] === 1) parts.push('top');
else if (d[1] === -1) parts.push('bottom');
if (d[2] === 1) parts.push('front');
else if (d[2] === -1) parts.push('back');
if (d[0] === 1) parts.push('right');
else if (d[0] === -1) parts.push('left');
return parts.join('-') + ' edge';
}
function edgeEndpoints(d) {
// Return the two adjacent FACE CENTERS this edge sits between.
// For dir (1,1,0) the edge connects face +X (1,0,0) and face +Y (0,1,0).
const ends = [];
if (d[0] !== 0) ends.push(new THREE.Vector3(d[0], 0, 0));
if (d[1] !== 0) ends.push(new THREE.Vector3(0, d[1], 0));
if (d[2] !== 0) ends.push(new THREE.Vector3(0, 0, d[2]));
return ends;
}
const EDGE_ZONES = [];
const EDGE_DIRS = [
[ 1, 1, 0], [-1, 1, 0], [ 1,-1, 0], [-1,-1, 0],
[ 1, 0, 1], [-1, 0, 1], [ 1, 0,-1], [-1, 0,-1],
[ 0, 1, 1], [ 0,-1, 1], [ 0, 1,-1], [ 0,-1,-1]
];
EDGE_DIRS.forEach(d => {
const v = new THREE.Vector3(d[0], d[1], d[2]).normalize();
const [c1, c2] = edgeEndpoints(d);
// Trim aggressively so the visible arc floats between the face balls without touching them
const trimStart = slerp(c1, c2, 0.32);
const trimEnd = slerp(c1, c2, 0.68);
// Thin outline (always shown when front-facing)
const outline = makeArcTube(trimStart, trimEnd, OUTLINE_RADIUS, HOVER_COLOR, OUTLINE_OPACITY);
gizmoScene.add(outline);
// Thick hover overlay (only shown when hovered)
const hover = makeArcTube(trimStart, trimEnd, HOVER_RADIUS, HOVER_COLOR, HOVER_OPACITY);
hover.visible = false;
gizmoScene.add(hover);
EDGE_ZONES.push({
type: 'edge',
dirVec: v.clone(),
label: edgeLabel(d) + ' (45°)',
outlineMarker: outline,
hoverMarker: hover
});
});
// ===== Corner zones (8) — triangular outline + curved triangular fill on hover =====
function cornerLabel(d) {
const parts = [];
parts.push(d[1] > 0 ? 'top' : 'bottom');
parts.push(d[2] > 0 ? 'front' : 'back');
parts.push(d[0] > 0 ? 'right' : 'left');
return parts.join('-') + ' corner';
}
function makeCornerPatch(triVerts, color) {
// Tessellate the spherical triangle with barycentric subdivision,
// projecting interior points onto the unit sphere for true curvature.
const n = 5;
const positions = [];
const indices = [];
const vIndex = (i, j) => i * (2 * n + 3 - i) / 2 + j;
for (let i = 0; i <= n; i++) {
for (let j = 0; j <= n - i; j++) {
const k = n - i - j;
const b1 = i / n, b2 = j / n, b3 = k / n;
const p = new THREE.Vector3()
.addScaledVector(triVerts[0], b1)
.addScaledVector(triVerts[1], b2)
.addScaledVector(triVerts[2], b3)
.normalize();
positions.push(p.x, p.y, p.z);
}
}
for (let i = 0; i < n; i++) {
for (let j = 0; j < n - i; j++) {
const a = vIndex(i, j);
const b = vIndex(i + 1, j);
const c = vIndex(i, j + 1);
indices.push(a, b, c);
if (j < n - i - 1) {
indices.push(b, vIndex(i + 1, j + 1), c);
}
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: HOVER_OPACITY,
side: THREE.DoubleSide, depthWrite: false
});
const mesh = new THREE.Mesh(geo, mat);
mesh.visible = false;
return mesh;
}
const CORNER_ZONES = [];
for (let sx = -1; sx <= 1; sx += 2) {
for (let sy = -1; sy <= 1; sy += 2) {
for (let sz = -1; sz <= 1; sz += 2) {
const cornerDir = new THREE.Vector3(sx, sy, sz).normalize();
// Slerp toward the three adjacent FACE CENTERS so the triangle's points
// aim at the colored axis balls (and the sides face the edges between them).
const adjacentFaces = [
new THREE.Vector3(sx, 0, 0),
new THREE.Vector3(0, sy, 0),
new THREE.Vector3(0, 0, sz)
];
const t = 0.27;
const triVerts = adjacentFaces.map(f => slerp(cornerDir, f, t));
// Outline: 3 thin arc tubes connecting the 3 triangle vertices
const outline = new THREE.Group();
for (let i = 0; i < 3; i++) {
const v1 = triVerts[i];
const v2 = triVerts[(i + 1) % 3];
const arc = makeArcTube(v1, v2, OUTLINE_RADIUS, HOVER_COLOR, OUTLINE_OPACITY);
outline.add(arc);
}
gizmoScene.add(outline);
// Hover: filled curved triangular patch
const hover = makeCornerPatch(triVerts, HOVER_COLOR);
gizmoScene.add(hover);
CORNER_ZONES.push({
type: 'corner',
dirVec: cornerDir.clone(),
label: cornerLabel([sx, sy, sz]) + ' (isometric)',
outlineMarker: outline,
hoverMarker: hover
});
}
}
}
// ============== Camera state ==============
const target = new THREE.Vector3(0, 1, 0);
// Camera orientation as a quaternion. Rotating the reference direction (0,0,1)
// by this quaternion gives the unit vector from `target` toward the camera, and
// rotating (0,1,0) by it gives the camera's up vector. Using a quaternion avoids
// the pole singularity that spherical (azimuth/elevation) coordinates have.
const REFERENCE_DIR = new THREE.Vector3(0, 0, 1);
const REFERENCE_UP = new THREE.Vector3(0, 1, 0);
const orientation = new THREE.Quaternion();
// Initial pose: yaw ~45° around world Y, then pitch ~-30° around the (now-rotated) X.
(function initOrientation() {
const yaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 4);
const camRight = new THREE.Vector3(1, 0, 0).applyQuaternion(yaw);
const pitch = new THREE.Quaternion().setFromAxisAngle(camRight, -Math.PI / 6);
orientation.multiplyQuaternions(pitch, yaw);
})();
let radius = 16;
let orthoZoom = 1;
const ORTHO_DIST = 50;
function updateCameras() {
const dir = REFERENCE_DIR.clone().applyQuaternion(orientation);
const up = REFERENCE_UP.clone().applyQuaternion(orientation);
persp.position.copy(target).addScaledVector(dir, radius);
persp.up.copy(up);
persp.lookAt(target);
ortho.position.copy(target).addScaledVector(dir, ORTHO_DIST);
ortho.up.copy(up);
ortho.lookAt(target);
const a = W / H;
const s = orthoSize / orthoZoom;
ortho.left = -s * a; ortho.right = s * a; ortho.top = s; ortho.bottom = -s;
ortho.updateProjectionMatrix();
}
updateCameras();
function updateGizmoCam() {
const cam = activeCam;
const dir = new THREE.Vector3().subVectors(cam.position, target).normalize();
gizmoCam.position.copy(dir).multiplyScalar(4);
// Use the main camera's actual up so the gizmo orientation matches the scene,
// even when the main camera is tilted past the world-up plane.
gizmoCam.up.copy(cam.up);
gizmoCam.lookAt(0, 0, 0);
}
// Walk a marker (Mesh or Group) and set opacity on every material we find
function setMarkerOpacity(marker, opacity) {
marker.traverse(obj => {
if (obj.material) obj.material.opacity = opacity;
});
}
// Show/hide edge & corner outlines based on facing, and fade the centered one out
function updateFrontFacing() {
const camDir = gizmoCam.position.clone().normalize();
const FADE_START = 0.90; // ~26° off-axis: fade begins
const FADE_END = 0.99; // ~8° off-axis: fully invisible
const all = EDGE_ZONES.concat(CORNER_ZONES);
for (const zone of all) {
const facing = zone.dirVec.dot(camDir);
const isFront = facing > FRONT_FACING_THRESHOLD;
// Sharper center-fade: cubic fall-off so the marker holds near-full opacity
// through most of the approach, then drops fast right before centering.
let centerFade = 1.0;
if (facing > FADE_START) {
let t = (facing - FADE_START) / (FADE_END - FADE_START);
if (t > 1) t = 1;
centerFade = 1 - t * t * t; // cubic ease-in for the fade-out
}
if (zone.outlineMarker) {
zone.outlineMarker.visible = isFront && centerFade > 0.001;
setMarkerOpacity(zone.outlineMarker, OUTLINE_OPACITY * centerFade);
}
// Hover marker is NOT faded — when the user mouses over a centered zone the
// bright highlight still appears at full opacity, so it stays clearly clickable
// (hit detection is dot-product based, so it works regardless of visibility).
if (zone.hoverMarker) {
zone.hoverMarker.visible = (zone === hoveredZone) && isFront;
setMarkerOpacity(zone.hoverMarker, HOVER_OPACITY);
}
}
}
// ============== Hit-testing ==============
const raycaster = new THREE.Raycaster();
const hitSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1.0);
const tmpHit = new THREE.Vector3();
const CORNER_COS = 0.93;
const EDGE_COS = 0.92;
function gizmoNDC(mx, my) {
const x0 = W - GIZMO_SIZE - GIZMO_MARGIN;
const y0 = GIZMO_MARGIN;
if (mx < x0 || mx > x0 + GIZMO_SIZE || my < y0 || my > y0 + GIZMO_SIZE) return null;
return {
x: ((mx - x0) / GIZMO_SIZE) * 2 - 1,
y: -(((my - y0) / GIZMO_SIZE) * 2 - 1)
};
}
function pickGizmoZone(mx, my) {
const ndc = gizmoNDC(mx, my);
if (!ndc) return null;
raycaster.setFromCamera(ndc, gizmoCam);
if (!raycaster.ray.intersectSphere(hitSphere, tmpHit)) return null;
const p = tmpHit.clone().normalize();
let bestCorner = null, bestCornerDot = -Infinity;
for (const z of CORNER_ZONES) {
const d = p.dot(z.dirVec);
if (d > bestCornerDot) { bestCornerDot = d; bestCorner = z; }
}
if (bestCornerDot > CORNER_COS) return bestCorner;
let bestEdge = null, bestEdgeDot = -Infinity;
for (const z of EDGE_ZONES) {
const d = p.dot(z.dirVec);
if (d > bestEdgeDot) { bestEdgeDot = d; bestEdge = z; }
}
if (bestEdgeDot > EDGE_COS) return bestEdge;
let bestFace = null, bestFaceDot = -Infinity;
for (const z of FACE_ZONES) {
const d = p.dot(z.dirVec);
if (d > bestFaceDot) { bestFaceDot = d; bestFace = z; }
}
return bestFace;
}
// ============== Hover ==============
let hoveredZone = null;
function setHoveredZone(z) {
if (hoveredZone === z) return;
// Reset old face zone (only faces use scale/opacity tweaks; edges/corners are gated in render loop)
if (hoveredZone && hoveredZone.type === 'face') {
hoveredZone.marker.scale.setScalar(1);
hoveredZone.marker.material.opacity = hoveredZone.marker.userData.baseOpacity;
}
hoveredZone = z;
if (z && z.type === 'face') {
z.marker.scale.setScalar(z.marker.userData.hoverScale);
z.marker.material.opacity = 1.0;
}
canvas.style.cursor = z ? 'pointer' : (dragging ? 'grabbing' : 'grab');
}
// ============== Mouse handling (click vs drag) ==============
let dragging = false;
let prevX = 0, prevY = 0;
let mouseDownX = 0, mouseDownY = 0;
let pendingClickZone = null;
let isAnimating = false;
const CLICK_THRESHOLD = 4;
canvas.addEventListener('mousemove', (e) => {
if (dragging) return;
const r = canvas.getBoundingClientRect();
setHoveredZone(pickGizmoZone(e.clientX - r.left, e.clientY - r.top));
});
canvas.addEventListener('mousedown', (e) => {
const r = canvas.getBoundingClientRect();
pendingClickZone = pickGizmoZone(e.clientX - r.left, e.clientY - r.top);
mouseDownX = e.clientX;
mouseDownY = e.clientY;
prevX = e.clientX;
prevY = e.clientY;
dragging = true;
canvas.style.cursor = 'grabbing';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging || isAnimating) return;
const dx = e.clientX - prevX;
const dy = e.clientY - prevY;
prevX = e.clientX;
prevY = e.clientY;
if (pendingClickZone) {
const tdx = e.clientX - mouseDownX;
const tdy = e.clientY - mouseDownY;
if (Math.sqrt(tdx * tdx + tdy * tdy) > CLICK_THRESHOLD) pendingClickZone = null;
}
if (dx === 0 && dy === 0) return;
// Decomposed orbit rotation: yaw around world +Y, pitch around the camera's
// right axis (recomputed AFTER the yaw is applied). Why not a single trackball
// rotation? Because trackball rotations around a diagonal axis in the screen
// plane are path-dependent: consecutive diagonal moves leave small residuals
// that accumulate. Even a consistent ~0.3px horizontal bias in a "vertical"
// drag produces visible drift over a few up/down cycles. Decomposing into
// independent yaw + pitch eliminates that — pure vertical drag never touches
// the yaw axis, so horizontal jitter can only manifest as horizontal orbit,
// not as a slow twist of the scene.
const SENSITIVITY = 0.008;
const worldUp = new THREE.Vector3(0, 1, 0);
const yawQuat = new THREE.Quaternion().setFromAxisAngle(worldUp, -dx * SENSITIVITY);
orientation.premultiply(yawQuat).normalize();
// camRight after yaw — this is what pitch should rotate around so the camera's
// current "right" direction stays put while the view tilts up/down through it.
const camRight = new THREE.Vector3(1, 0, 0).applyQuaternion(orientation);
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(camRight, -dy * SENSITIVITY);
orientation.premultiply(pitchQuat).normalize();
updateCameras();
});
window.addEventListener('mouseup', () => {
if (pendingClickZone) animateToZone(pendingClickZone);
pendingClickZone = null;
dragging = false;
canvas.style.cursor = hoveredZone ? 'pointer' : 'grab';
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
if (activeCam === persp) {
radius = Math.max(4, Math.min(45, radius * (1 + e.deltaY * 0.0012)));
} else {
orthoZoom = Math.max(0.3, Math.min(5, orthoZoom * (1 - e.deltaY * 0.0012)));
}
updateCameras();
}, { passive: false });
// ============== View animation ==============
// Helper: build a quaternion from a desired camera-back direction and an approximate
// camera-up direction. The result rotates REFERENCE_DIR (0,0,1) to `dir` and aligns
// REFERENCE_UP (0,1,0) with the component of `up` perpendicular to `dir`.
function quatFromAxes(dir, up) {
const camZ = dir.clone().normalize();
const camX = new THREE.Vector3().crossVectors(up, camZ).normalize();
const camY = new THREE.Vector3().crossVectors(camZ, camX);
const m = new THREE.Matrix4().makeBasis(camX, camY, camZ);
return new THREE.Quaternion().setFromRotationMatrix(m);
}
// Canonical (zero-roll) quaternion for an axis-aligned face view.
function canonicalQuaternionForFace(view) {
let dir, up;
switch (view) {
case 'right': dir = new THREE.Vector3( 1, 0, 0); up = new THREE.Vector3(0, 1, 0); break;
case 'left': dir = new THREE.Vector3(-1, 0, 0); up = new THREE.Vector3(0, 1, 0); break;
case 'top': dir = new THREE.Vector3( 0, 1, 0); up = new THREE.Vector3(0, 0, -1); break;
case 'bottom': dir = new THREE.Vector3( 0,-1, 0); up = new THREE.Vector3(0, 0, 1); break;
case 'front': dir = new THREE.Vector3( 0, 0, 1); up = new THREE.Vector3(0, 1, 0); break;
case 'back': dir = new THREE.Vector3( 0, 0, -1); up = new THREE.Vector3(0, 1, 0); break;
default: return null;
}
return quatFromAxes(dir, up);
}
// Canonical quaternion for an edge view. Two flavors based on whether the edge
// involves a Y face: edges in the X-Z plane (Y component of midpoint = 0) keep
// world +Y as camera up so the edge projects horizontally across the screen;
// edges connecting a Y face use the line between the two face centers as up
// so the edge projects vertically. Together this means world Y is always
// treated as "up" and the X/Z axes are flat.
function canonicalQuaternionForEdge(zone) {
const d = zone.dirVec;
const faces = [];
if (Math.abs(d.x) > 1e-4) faces.push(new THREE.Vector3(Math.sign(d.x), 0, 0));
if (Math.abs(d.y) > 1e-4) faces.push(new THREE.Vector3(0, Math.sign(d.y), 0));
if (Math.abs(d.z) > 1e-4) faces.push(new THREE.Vector3(0, 0, Math.sign(d.z)));
let up;
if (Math.abs(d.y) < 1e-4) {
// X-Z plane edge: keep world +Y as up so the edge stays horizontal on screen.
up = new THREE.Vector3(0, 1, 0);
} else {
// Edge connects a Y face: use the line direction (which has a Y component)
// as up so the edge projects vertically. Stable sign: prefer positive Y.
up = new THREE.Vector3().subVectors(faces[0], faces[1]).normalize();
if (up.y < 0) up.negate();
}
return quatFromAxes(d, up);
}
// Canonical quaternion for a corner view: world +Y projected onto the plane
// perpendicular to the corner direction. The three adjacent face balls land at
// 120° intervals automatically (their 3D angles from the corner are equal).
function canonicalQuaternionForCorner(zone) {
const camZ = zone.dirVec.clone().normalize();
const worldUp = new THREE.Vector3(0, 1, 0);
const camY = worldUp.clone().sub(camZ.clone().multiplyScalar(worldUp.dot(camZ)));
if (camY.lengthSq() < 1e-6) camY.set(0, 0, -1); // safety; shouldn't trigger for corners
camY.normalize();
return quatFromAxes(camZ, camY);
}
function animateToZone(zone) {
// Every click snaps to a canonical (zero-roll) orientation for its zone type:
// faces → axis-aligned view with the conventional up vector
// edges → the edge's line projects vertically on screen
// corners → the three adjacent axes land at 120° around the view center
let targetQuat;
if (zone.type === 'face') targetQuat = canonicalQuaternionForFace(zone.view);
else if (zone.type === 'edge') targetQuat = canonicalQuaternionForEdge(zone);
else /* corner */ targetQuat = canonicalQuaternionForCorner(zone);
const startQuat = orientation.clone();
const t0 = performance.now(), dur = 600;
isAnimating = true;
function step() {
const tt = Math.min(1, (performance.now() - t0) / dur);
const e = tt < 0.5 ? 4 * tt * tt * tt : 1 - Math.pow(-2 * tt + 2, 3) / 2;
orientation.slerpQuaternions(startQuat, targetQuat, e);
updateCameras();
if (tt < 1) requestAnimationFrame(step);
else isAnimating = false;
}
step();
document.getElementById('vs').textContent =
zone.label + ' · ' + (projMode === 'persp' ? 'Perspective' : 'Orthographic');
}
// ============== Projection toggle ==============
const projBtn = document.getElementById('projBtn');
projBtn.addEventListener('click', () => {
projMode = (projMode === 'persp') ? 'ortho' : 'persp';
activeCam = (projMode === 'persp') ? persp : ortho;
projBtn.textContent = (projMode === 'persp') ? 'Perspective' : 'Orthographic';
const s = document.getElementById('vs');
s.textContent = s.textContent.replace(/Perspective|Orthographic/, projMode === 'persp' ? 'Perspective' : 'Orthographic');
});
// ============== Resize ==============
window.addEventListener('resize', () => {
W = host.clientWidth; H = host.clientHeight;
persp.aspect = W / H; persp.updateProjectionMatrix();
updateCameras();
renderer.setSize(W, H);
});
// ============== Render loop ==============
canvas.style.cursor = 'grab';
function loop() {
requestAnimationFrame(loop);
updateGizmoCam();
updateFrontFacing();
renderer.setViewport(0, 0, W, H);
renderer.setScissor(0, 0, W, H);
renderer.setScissorTest(true);
renderer.clear();
renderer.render(scene, activeCam);
const gx = W - GIZMO_SIZE - GIZMO_MARGIN;
const gyGL = H - GIZMO_MARGIN - GIZMO_SIZE;
renderer.setViewport(gx, gyGL, GIZMO_SIZE, GIZMO_SIZE);
renderer.setScissor(gx, gyGL, GIZMO_SIZE, GIZMO_SIZE);
renderer.clearDepth();
renderer.render(gizmoScene, gizmoCam);
}
loop();
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment