Last active
May 11, 2026 06:47
-
-
Save mozeryansky/fc3855d6a52937572c550e51d3acfabc to your computer and use it in GitHub Desktop.
3D Scene Gizmo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!-- 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