app
Adom 3D Viewer
Public Made by Adomby adom
The Babylon 9.5 engine behind every component page's 3D tab on wiki.adom.inc. Versioned ESM bundle with GLB loading, view cube, layers toolbar, ground shadows, and Z-up CAD framing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
import { Engine } from "@babylonjs/core/Engines/engine";
import { WebGPUEngine } from "@babylonjs/core/Engines/webgpuEngine";
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial";
import { DynamicTexture } from "@babylonjs/core/Materials/Textures/dynamicTexture";
import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector'
import { Viewport } from "@babylonjs/core/Maths/math.viewport";
import { Mesh } from "@babylonjs/core/Meshes/mesh";
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
import { Scene } from "@babylonjs/core/scene";
import { Animation, Effect, EngineStore, PickingInfo, Ray, SolidParticle, VertexBuffer } from '@babylonjs/core'
import { SolidParticleSystem } from "@babylonjs/core/Particles/solidParticleSystem";
import { AdomStatics } from "../AdomStatics";
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { ShaderMaterial } from '@babylonjs/core/Materials/shaderMaterial'
import { BoundingInfo } from '@babylonjs/core/Culling/boundingInfo'
export interface ViewCubeUICallbacks {
onTooltip: (text: string | null, x: number, y: number) => void
onHomeClick: () => void
onFitClick: () => void
onViewModeClick: (x: number, y: number) => void
}
export class ViewCubeBuilder {
private static prevPick: number = -1
private static prevDarkened: number = -1
private static particleColorMap: Map<number, Color4> = new Map()
/**
* Creates a combined texture atlas for all six faces of the cube
* @internal
*/
static _createCombinedTexture(scene: Scene) {
const size = 512
const dynamicTexture = new DynamicTexture(
'CombinedTexture',
{ width: size * 3, height: size * 3 },
scene,
true
)
const context = dynamicTexture.getContext()
dynamicTexture.hasAlpha = true
// Define the face names in the texture atlas layout
const texts = [
['FRONT', 'BACK', 'RIGHT'],
['LEFT', 'BOT', 'TOP']
]
// Fill background with white
context.fillStyle = 'white'
context.fillRect(0, 0, size * 3, size * 2)
// Setup text style for face labels
context.font = '150px Arial'
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = 'black'
// Draw each face text
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 3; col++) {
context.fillText(texts[row][col], (col + 0.5) * size, (row + 0.5) * size)
}
}
// Setup text style for axis labels
context.font = 'bold 120px Arial'
context.fillStyle = 'red'
context.fillText('X', 0.5 * size, (2 + 0.5) * size)
context.fillStyle = 'green'
context.fillText('Y', (1 + 0.5) * size, (2 + 0.5) * size)
context.fillStyle = 'blue'
context.fillText('Z', (2 + 0.5) * size, (2 + 0.5) * size)
dynamicTexture.update()
return dynamicTexture
}
/**
* Normalizes an angle between -PI and PI
* @internal
*/
static _normalizeAngle(angle: number): number {
return angle - Math.PI * 2 * Math.floor((angle + Math.PI) / (2 * Math.PI))
}
/**
* Calculates the shortest angle distance between two angles
* @internal
*/
static _shortestAngleDistance(from: number, to: number): number {
const normalizedFrom = ViewCubeBuilder._normalizeAngle(from)
const normalizedTo = ViewCubeBuilder._normalizeAngle(to)
const difference = normalizedTo - normalizedFrom
if (difference > Math.PI) {
return difference - Math.PI * 2
} else if (difference < -Math.PI) {
return difference + Math.PI * 2
}
return difference
}
/**
* Animates camera to a new position
* @internal
*/
static _animateCameraToPosition(
camera: ArcRotateCamera,
newAlpha: number,
newBeta: number,
newRadius: number,
duration: number
) {
const fps = 60
const totalFrames = fps * duration
const targetAlpha =
camera.alpha + ViewCubeBuilder._shortestAngleDistance(camera.alpha, newAlpha)
const targetBeta =
camera.beta + ViewCubeBuilder._shortestAngleDistance(camera.beta, newBeta)
const alphaAnimation = new Animation(
'alphaAnimation',
'alpha',
fps,
Animation.ANIMATIONTYPE_FLOAT,
Animation.ANIMATIONLOOPMODE_CONSTANT
)
const alphaKeys = [
{ frame: 0, value: camera.alpha },
{ frame: totalFrames, value: targetAlpha }
]
alphaAnimation.setKeys(alphaKeys)
const betaAnimation = new Animation(
'betaAnimation',
'beta',
fps,
Animation.ANIMATIONTYPE_FLOAT,
Animation.ANIMATIONLOOPMODE_CONSTANT
)
const betaKeys = [
{ frame: 0, value: camera.beta },
{ frame: totalFrames, value: targetBeta }
]
betaAnimation.setKeys(betaKeys)
const radiusAnimation = new Animation(
'radiusAnimation',
'radius',
fps,
Animation.ANIMATIONTYPE_FLOAT,
Animation.ANIMATIONLOOPMODE_CONSTANT
)
const radiusKeys = [
{ frame: 0, value: camera.radius },
{ frame: totalFrames, value: newRadius }
]
radiusAnimation.setKeys(radiusKeys)
camera.animations = [alphaAnimation, betaAnimation, radiusAnimation]
camera.getScene().beginAnimation(camera, 0, totalFrames, false)
}
static createViewCube(
mainScene: Scene,
mainCamera: ArcRotateCamera,
engine: WebGPUEngine | Engine,
uiCallbacks?: ViewCubeUICallbacks
) {
const scene2 = new Scene(engine)
scene2.autoClear = false
scene2.useRightHandedSystem = true
const camera2 = new ArcRotateCamera('camera2', 0, Math.PI / 2, 30, Vector3.Zero(), scene2)
camera2.upVector = new Vector3(0, 0, 1) // Set Z as up
camera2.viewport = new Viewport(0.85, 0.85, 0.15, 0.15)
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene2)
light.intensity = 1
light.groundColor = new Color3(1, 1, 1)
light.specular = Color3.Black()
let homeButtonMesh: Mesh = null!
let fitButtonMesh: Mesh = null!
let viewModeButtonMesh: Mesh = null!
let homeMat: StandardMaterial = null!
let fitMat: StandardMaterial = null!
let viewModeMat: StandardMaterial = null!
let homeTexNormal: DynamicTexture = null!
let homeTexHover: DynamicTexture = null!
let fitTexNormal: DynamicTexture = null!
let fitTexHover: DynamicTexture = null!
let vmPerspNormal: DynamicTexture = null!
let vmPerspHover: DynamicTexture = null!
let vmOrthoNormal: DynamicTexture = null!
let vmOrthoHover: DynamicTexture = null!
let currentCameraMode = mainCamera.mode
let homeHovered = false
let fitHovered = false
let viewModeHovered = false
let buttonsAlpha = 0
mainScene.afterRender = () => {
// The cube scene is right-handed (scene2.useRightHandedSystem).
// When the MAIN scene is left-handed (zUp=false) the camera azimuth
// has the opposite sense, so copying mainCamera.alpha verbatim makes
// the cube orbit backwards. Reflect alpha (PI - alpha) only in that
// mismatched case — it reverses the horizontal direction and is the
// identity at alpha=PI/2 so the home FRONT face still lines up. When
// the main scene is also right-handed (zUp=true) the senses match
// and alpha is copied directly. Beta is unaffected by handedness.
camera2.alpha = mainScene.useRightHandedSystem
? mainCamera.alpha
: Math.PI - mainCamera.alpha
camera2.beta = mainCamera.beta
camera2.lowerAlphaLimit = null
camera2.lowerBetaLimit = null
camera2.upperBetaLimit = null
camera2.upperRadiusLimit = null
// Ensure the ViewCube viewport is at least minVpPx pixels so the
// cube stays readable at small canvas sizes. The center is kept
// at (0.925, 0.925) — same as the default — and only shifts when
// the viewport would exceed the canvas edge.
const canvasW = engine.getRenderWidth()
const canvasH = engine.getRenderHeight()
const minVpPx = 120
const defaultFrac = 0.15
const vpNormW = Math.min(Math.max(canvasW * defaultFrac, minVpPx) / canvasW, 0.3)
const vpNormH = Math.min(Math.max(canvasH * defaultFrac, minVpPx) / canvasH, 0.3)
const vpX = Math.max(0, Math.min(1 - vpNormW, 0.925 - vpNormW / 2))
const vpY = Math.max(0, Math.min(1 - vpNormH, 0.925 - vpNormH / 2))
camera2.viewport = new Viewport(vpX, vpY, vpNormW, vpNormH)
if (homeButtonMesh) {
const depth = 9
const fovHalf = (camera2.fov || 0.8) / 2
const halfH = depth * Math.tan(fovHalf)
const vpW = canvasW * camera2.viewport.width
const vpH = canvasH * camera2.viewport.height
const aspect = vpW / vpH
const halfW = halfH * aspect
// Button layout constants (at default scale, view-space at depth 9)
const btnDiam = 1.5
const btnGap = 0.05
const edgePad = 0.15
const cubePad = 0.3
// Everything (cube projection, button size, gaps) scales
// proportionally with 1/R, keeping the full-screen visual
// ratio at every viewport size.
const defaultR = 14
const cubeProj0 = 2.2 * depth / defaultR
const totalAtDefault = cubeProj0 + cubePad + btnDiam + edgePad
let R = defaultR
if (totalAtDefault > halfW) {
// Viewport too narrow — zoom out just enough to fit.
// total(R) = (2.2*depth + (cubePad+btnDiam+edgePad)*defaultR) / R
const k = 2.2 * depth + (cubePad + btnDiam + edgePad) * defaultR
R = Math.min(k / halfW, defaultR * 2.5)
}
camera2.radius = R
const scale = defaultR / R
const cubeProj = 2.2 * depth / R
const invViewMat = Matrix.Invert(camera2.getViewMatrix())
const sz = btnDiam * scale
const step = (btnDiam + btnGap) * scale
const cPad = cubePad * scale
const ePad = edgePad * scale
// Hard-floor: buttons are ALWAYS to the right of the cube.
const xMin = cubeProj + cPad + sz / 2
const xMax = halfW - ePad - sz / 2
let xCenter: number
if (xMin <= xMax) {
xCenter = (xMin + xMax) / 2
} else {
// Extreme narrow: let buttons clip at viewport edge,
// but never move into the cube.
xCenter = xMin
}
const yTop = halfH - ePad - sz / 2
// Hover fade: show buttons only when mouse is in the ViewCube viewport
const vpPixelLeft = camera2.viewport.x * canvasW
const vpPixelTop = (1 - camera2.viewport.y - camera2.viewport.height) * canvasH
const mx = mainScene.pointerX
const my = mainScene.pointerY
const inViewport = mx >= vpPixelLeft && mx <= vpPixelLeft + vpW &&
my >= vpPixelTop && my <= vpPixelTop + vpH
const fadeTarget = inViewport ? 1.0 : 0.0
buttonsAlpha += (fadeTarget - buttonsAlpha) * 0.12
if (buttonsAlpha < 0.01) buttonsAlpha = 0
if (buttonsAlpha > 0.99) buttonsAlpha = 1
// Dismiss tooltip when buttons fade out
if (!inViewport && uiCallbacks) {
uiCallbacks.onTooltip(null, 0, 0)
}
const pickable = buttonsAlpha > 0.1
homeButtonMesh.visibility = buttonsAlpha
fitButtonMesh.visibility = buttonsAlpha
viewModeButtonMesh.visibility = buttonsAlpha
homeButtonMesh.isPickable = pickable
fitButtonMesh.isPickable = pickable
viewModeButtonMesh.isPickable = pickable
homeButtonMesh.scaling.setAll(scale)
homeButtonMesh.position = Vector3.TransformCoordinates(
new Vector3(xCenter, yTop, -depth), invViewMat
)
fitButtonMesh.scaling.setAll(scale)
fitButtonMesh.position = Vector3.TransformCoordinates(
new Vector3(xCenter, yTop - step, -depth), invViewMat
)
viewModeButtonMesh.scaling.setAll(scale)
viewModeButtonMesh.position = Vector3.TransformCoordinates(
new Vector3(xCenter, yTop - step * 2, -depth), invViewMat
)
}
// Detect external camera mode changes (e.g. API calls)
if (viewModeMat && mainCamera.mode !== currentCameraMode) {
currentCameraMode = mainCamera.mode
const isOrtho = currentCameraMode === 1
viewModeMat.diffuseTexture = viewModeHovered
? (isOrtho ? vmOrthoHover : vmPerspHover)
: (isOrtho ? vmOrthoNormal : vmPerspNormal)
}
scene2.render()
}
const cr = 2 // Cube radius
const es = 0.75 // Edge sizing
const sps = this.generateSPS(scene2, cr, es)
sps.initParticles = () => {
this.initParticlePositions(sps, cr, es)
}
// Update the label planes to always face the camera
sps.updateParticle = particle => {
this.billboardLabelPlanes(particle, camera2)
return particle
}
const viewCubeMesh = sps.buildMesh()
//TODO - Refactor to add wanted colors to the atlas and then adjust uvs accordingly.
// Removes the need for the shader material and buffer which will save performance.
// Shader material is used selectively choose which meshes to apply the texture to.
const textureAtlas = ViewCubeBuilder._createCombinedTexture(scene2)
const shaderMaterial = this.createShaderMaterial(scene2)
shaderMaterial.setTexture('textureSampler', textureAtlas)
shaderMaterial.alphaMode = Engine.ALPHA_ADD
viewCubeMesh.material = shaderMaterial
//Buffer for particle IDs, used to ignore the applied texture
const NUM_VERTS_PER_PLANE = 4
const NUM_TEXTURED_PLANES = 9
const planeVertCount = NUM_VERTS_PER_PLANE * NUM_TEXTURED_PLANES
const particleIDs = new Float32Array(viewCubeMesh.getTotalVertices())
for (let i = 0; i < planeVertCount; i++) {
particleIDs[i] = 2
}
const idBuffer = new VertexBuffer(engine, particleIDs, 'particleID', false, false, 1)
viewCubeMesh.setVerticesBuffer(idBuffer)
sps.initParticles()
sps.setParticles()
// Visibility box is important for picking,
// Set to same size as cube to ignore axis and the labels planes
sps.setVisibilityBox(4)
sps.isVisibilityBoxLocked = true
// --- Home Button (Fusion 360-style) ---
homeButtonMesh = MeshBuilder.CreatePlane('homeButton', { size: 1.5 }, scene2)
homeButtonMesh.billboardMode = Mesh.BILLBOARDMODE_ALL
homeButtonMesh.isPickable = true
homeButtonMesh.enablePointerMoveEvents = true
homeTexNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._homeIconContent(false), false)
homeTexHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._homeIconContent(true), true)
homeMat = new StandardMaterial('homeButtonMat', scene2)
homeMat.diffuseTexture = homeTexNormal
homeMat.emissiveColor = new Color3(1, 1, 1)
homeMat.disableLighting = true
homeMat.useAlphaFromDiffuseTexture = true
homeMat.backFaceCulling = false
homeButtonMesh.material = homeMat
// --- Fit / Zoom-to-Fit Button ---
fitButtonMesh = MeshBuilder.CreatePlane('fitButton', { size: 1.5 }, scene2)
fitButtonMesh.billboardMode = Mesh.BILLBOARDMODE_ALL
fitButtonMesh.isPickable = true
fitButtonMesh.enablePointerMoveEvents = true
fitTexNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._fitIconContent(false), false)
fitTexHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._fitIconContent(true), true)
fitMat = new StandardMaterial('fitButtonMat', scene2)
fitMat.diffuseTexture = fitTexNormal
fitMat.emissiveColor = new Color3(1, 1, 1)
fitMat.disableLighting = true
fitMat.useAlphaFromDiffuseTexture = true
fitMat.backFaceCulling = false
fitButtonMesh.material = fitMat
// --- View Mode Button (Ortho/Perspective toggle) ---
viewModeButtonMesh = MeshBuilder.CreatePlane('viewModeButton', { size: 1.5 }, scene2)
viewModeButtonMesh.billboardMode = Mesh.BILLBOARDMODE_ALL
viewModeButtonMesh.isPickable = true
viewModeButtonMesh.enablePointerMoveEvents = true
const isOrthoInit = mainCamera.mode === 1
vmPerspNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(false, false), false)
vmPerspHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(true, false), true)
vmOrthoNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(false, true), false)
vmOrthoHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(true, true), true)
viewModeMat = new StandardMaterial('viewModeButtonMat', scene2)
viewModeMat.diffuseTexture = isOrthoInit ? vmOrthoNormal : vmPerspNormal
viewModeMat.emissiveColor = new Color3(1, 1, 1)
viewModeMat.disableLighting = true
viewModeMat.useAlphaFromDiffuseTexture = true
viewModeMat.backFaceCulling = false
viewModeButtonMesh.material = viewModeMat
scene2.onPointerDown = function (evt, pickResult) {
if (pickResult.pickedMesh === homeButtonMesh) {
uiCallbacks?.onTooltip(null, 0, 0)
if (uiCallbacks) {
uiCallbacks.onHomeClick()
} else {
const x = 1.625, y = -1.625, z = 1.625
const adjust = -Math.PI / 2
const targetAlpha = Math.atan2(x, y) + adjust
const targetBeta = Math.acos(z / Math.sqrt(x * x + y * y + z * z))
ViewCubeBuilder._animateCameraToPosition(
mainCamera, targetAlpha, targetBeta, mainCamera.radius, 0.5
)
}
return
}
if (pickResult.pickedMesh === fitButtonMesh) {
uiCallbacks?.onTooltip(null, 0, 0)
uiCallbacks?.onFitClick()
return
}
if (pickResult.pickedMesh === viewModeButtonMesh) {
uiCallbacks?.onTooltip(null, 0, 0)
if (uiCallbacks) {
uiCallbacks.onViewModeClick(evt.offsetX, evt.offsetY)
} else {
;(mainCamera as any).toggleCameraMode()
currentCameraMode = mainCamera.mode
const isOrtho = currentCameraMode === 1
viewModeMat.diffuseTexture = isOrtho ? vmOrthoHover : vmPerspHover
}
return
}
const id = cubeRayCollision(pickResult)
if (id) rotateToParticle(id);
}
camera2.onViewMatrixChangedObservable.add(() => {
darkenFromCameraPosition(getClosestParticleId())
sps.setParticles()
})
scene2.onPointerMove = function (evt, pickResult) {
const pickedMesh = pickResult.pickedMesh
const isOverHome = pickedMesh === homeButtonMesh
const isOverFit = pickedMesh === fitButtonMesh
const isOverViewMode = pickedMesh === viewModeButtonMesh
const isOverButton = isOverHome || isOverFit || isOverViewMode
if (isOverHome !== homeHovered) {
homeHovered = isOverHome
homeMat.diffuseTexture = homeHovered ? homeTexHover : homeTexNormal
}
if (isOverFit !== fitHovered) {
fitHovered = isOverFit
fitMat.diffuseTexture = fitHovered ? fitTexHover : fitTexNormal
}
if (isOverViewMode !== viewModeHovered) {
viewModeHovered = isOverViewMode
const isOrtho = currentCameraMode === 1
viewModeMat.diffuseTexture = viewModeHovered
? (isOrtho ? vmOrthoHover : vmPerspHover)
: (isOrtho ? vmOrthoNormal : vmPerspNormal)
}
if (uiCallbacks) {
if (isOverHome) {
uiCallbacks.onTooltip('Home', evt.offsetX, evt.offsetY)
} else if (isOverFit) {
uiCallbacks.onTooltip('Fit All', evt.offsetX, evt.offsetY)
} else if (isOverViewMode) {
uiCallbacks.onTooltip('View Mode', evt.offsetX, evt.offsetY)
} else {
uiCallbacks.onTooltip(null, 0, 0)
}
}
if (isOverButton) {
if (ViewCubeBuilder.prevPick !== -1) {
const clearColor = new Color4(0.8, 0.8, 1, 0.3)
const prevDiff = ViewCubeBuilder.particleColorMap
.get(ViewCubeBuilder.prevPick)
?.subtract(clearColor)
if (prevDiff) sps.particles[ViewCubeBuilder.prevPick].color!.addInPlace(prevDiff)
ViewCubeBuilder.prevPick = -1
sps.setParticles()
}
return
}
highlightFromPointer(evt.offsetX, evt.offsetY, pickResult)
}
const endMousePos = camera2.viewport.x + camera2.viewport.width
viewCubeMesh.enablePointerMoveEvents = true
EngineStore._LastCreatedScene = AdomStatics.scene
return scene2
function cubeRayCollision(pickResult: PickingInfo) {
if (pickResult.faceId === -1) return
const picked = sps.pickedParticle(pickResult)
if (!picked) return
//Recursive picking to only get the box particles
//We are guaranteed to hit the box because of the visibility box
//This is necessary to ignore the axis and the labels planes on the edge case
//where ray will pass through the box, but the other meshes are in the way
let id = picked.idx
let result = pickResult
while (id < 3 || 29 < id) {
const ray = new Ray(
result.pickedPoint!.add(
result.ray!.direction.multiplyByFloats(0.001, 0.001, 0.001)
),
result.ray!.direction,
10
)
result = scene2.pickWithRay(ray)!
id = sps.pickedParticle(result)!.idx
}
return id
}
function rotateToParticle(id: number) {
const position = sps.particles[id].position
const adjust = position.x === 0 && position.y === 0 ? Math.PI/2 : -Math.PI/2
// atan2 gives the cube-scene (right-handed) azimuth that faces the
// clicked particle. The MAIN camera alpha that lands on this face
// must use the SAME reflection as the display sync above: identity
// when the main scene is right-handed (zUp=true), PI-x when it's
// left-handed (zUp=false). Without matching the display, clicking a
// face navigated to its mirror (front-left jumped to front-right).
const cubeAlpha = Math.atan2(position.x, position.y) + adjust
const targetAlpha = mainScene.useRightHandedSystem
? cubeAlpha
: Math.PI - cubeAlpha
const targetBeta = Math.acos(
position.z /
Math.sqrt(
position.x * position.x + position.y * position.y + position.z * position.z
)
)
if (targetAlpha !== undefined && targetBeta !== undefined) {
camera2.onViewMatrixChangedObservable.clear()
const temp_casting = camera2.onViewMatrixChangedObservable.add(() => {
const result = scene2.pick(scene2.pointerX, scene2.pointerY)
highlightFromPointer(scene2.pointerX, scene2.pointerY, result)
sps.setParticles()
})
darkenFromCameraPosition(id)
sps.setParticles()
sps.particles[id].color!.addInPlace(new Color4(0, 0, 0, 0))
const animate_dur = 0.5
ViewCubeBuilder._animateCameraToPosition(
mainCamera,
targetAlpha,
targetBeta,
mainCamera.radius,
animate_dur
)
setTimeout(() => {
camera2.onViewMatrixChangedObservable.remove(temp_casting)
camera2.onViewMatrixChangedObservable.add(() => {
darkenFromCameraPosition(getClosestParticleId())
sps.setParticles()
})
sps.particles[id].color!.subtractInPlace(new Color4(0, 0, 0, 0))
}, animate_dur * 1000)
}
}
//TODO: Currently using minimum distance for camera -> particle selection, there should be a
// smarter way by projecting the cube faces onto the sphere and selecting the current region
function getClosestParticleId() {
const distances = []
for (let i = 3; i < 29; i++) {
distances.push(Vector3.Distance(camera2.position, sps.particles[i].position))
}
return distances.indexOf(Math.min(...distances)) + 3
}
function darkenFromCameraPosition(id: number) {
const targetColor = new Color4(0.4, 0.4, 0.4, 1)
if (id && id !== ViewCubeBuilder.prevDarkened) {
const prevDiff = ViewCubeBuilder.particleColorMap
.get(ViewCubeBuilder.prevDarkened)
?.subtract(targetColor)
if (prevDiff) sps.particles[ViewCubeBuilder.prevDarkened].color!.addInPlace(prevDiff)
ViewCubeBuilder.prevDarkened = id
const diffFromOriginal = ViewCubeBuilder.particleColorMap
.get(id)!
.subtract(targetColor)
sps.particles[id].color!.subtractInPlace(diffFromOriginal)
}
}
function highlightFromPointer(xOffset: number, yOffset: number, pickResult: PickingInfo) {
//Only ray-cast if pointer is in the viewcube viewport.
//Uncomment other conditions if the viewcube is moved from the top right corner.
const targetColor = new Color4(0.8, 0.8, 1, 0.3)
if (pickResult.faceId === -1) {
const prevDiff = ViewCubeBuilder.particleColorMap
.get(ViewCubeBuilder.prevPick)
?.subtract(targetColor)
if (prevDiff) sps.particles[ViewCubeBuilder.prevPick].color!.addInPlace(prevDiff)
ViewCubeBuilder.prevPick = -1
}
if (
// camera2.viewport.x < evt.offsetX / engine.getRenderWidth() &&
// 1 - endMousePosY < evt.offsetY / engine.getRenderHeight() &&
xOffset / engine.getRenderWidth() < endMousePos &&
yOffset / engine.getRenderHeight() < 1 - camera2.viewport.y
) {
const id = cubeRayCollision(pickResult)
if (id && id !== ViewCubeBuilder.prevPick) {
const prevDiff = ViewCubeBuilder.particleColorMap
.get(ViewCubeBuilder.prevPick)
?.subtract(targetColor)
if (prevDiff)
sps.particles[ViewCubeBuilder.prevPick].color!.addInPlace(prevDiff)
ViewCubeBuilder.prevPick = id
const diffFromOriginal = ViewCubeBuilder.particleColorMap
.get(id)!
.subtract(targetColor)
sps.particles[id].color!.subtractInPlace(diffFromOriginal)
sps.setParticles()
}
}
}
}
//TODO Fix the math so that the labels don't rotate when rotating overhead the cube
// alternatively, use a separate sps in billboard mode, but that will use a separate draw call.
/***
Manually rotates the labels to face the camera.
***/
private static billboardLabelPlanes(particle: SolidParticle, camera2: ArcRotateCamera) {
if (particle.idx <= 2) {
const cameraPosition = camera2.position
const particlePosition = particle.position
// Calculate direction vector from particle to camera
const direction = cameraPosition.subtract(particlePosition).normalize()
// Use the camera's up vector instead of a fixed (0,1,0)
const up = camera2.upVector.normalize()
const right = Vector3.Cross(up, direction).normalize()
const newUp = Vector3.Cross(direction, right).normalize()
// Apply rotation to align with camera
particle.rotationQuaternion = Quaternion.RotationQuaternionFromAxis(
right,
newUp,
direction
)
}
}
/***
Initialize particles with proper positions, rotations and UVs
***/
private static initParticlePositions(sps: SolidParticleSystem, cr: number, es: number) {
// Define UV mappings for the 6 sides of the box in the texture atlas
// Format: [u1, v1, u2, v2] represents the rectangle in the texture
const uvMapping: [number, number, number, number][] = [
[0, 2 / 3, 1 / 3, 1], // FRONT
[1 / 3, 2 / 3, 2 / 3, 1], // BACK
[2 / 3, 2 / 3, 1, 1], // RIGHT
[0, 1 / 3, 1 / 3, 2 / 3], // LEFT
[1 / 3, 1 / 3, 2 / 3, 2 / 3], // BOTTOM
[2 / 3, 1 / 3, 1, 2 / 3], // TOP
[0, 0, 1 / 3.1, 1 / 3.1], // X Label
[1 / 3.1, 0, 2 / 3.1, 1 / 3.1], // Y Label
[2 / 3.1, 0, 0.9, 1 / 3.1] // Z Label
]
const labelPlanePositions = [
[3.5, -2, -2],
[-2, 3.5, -2],
[-2, -2, 3.5]
]
const labelPlaneRotations = [
[0, -Math.PI / 2, Math.PI / 2],
[Math.PI / 2, 0, 0],
[0, 0, 0]
]
// Corner positions (relative to center, size = 4)
const cornerPositions = [
[-(cr - es / 2), -(cr - es / 2), -(cr - es / 2)],
[-(cr - es / 2), -(cr - es / 2), cr - es / 2],
[-(cr - es / 2), cr - es / 2, -(cr - es / 2)],
[-(cr - es / 2), cr - es / 2, cr - es / 2],
[cr - es / 2, -(cr - es / 2), -(cr - es / 2)],
[cr - es / 2, -(cr - es / 2), cr - es / 2],
[cr - es / 2, cr - es / 2, -(cr - es / 2)],
[cr - es / 2, cr - es / 2, cr - es / 2]
]
// Edge positions and rotations
const edgeBoxPositions = [
// X-aligned edges
[-(cr - es / 2), -(cr - es / 2), 0],
[-(cr - es / 2), cr - es / 2, 0],
[cr - es / 2, -(cr - es / 2), 0],
[cr - es / 2, cr - es / 2, 0],
// Y-aligned edges
[-(cr - es / 2), 0, -(cr - es / 2)],
[cr - es / 2, 0, -(cr - es / 2)],
[-(cr - es / 2), 0, cr - es / 2],
[cr - es / 2, 0, cr - es / 2],
// Z-aligned edges
[0, -(cr - es / 2), -(cr - es / 2)],
[0, cr - es / 2, -(cr - es / 2)],
[0, -(cr - es / 2), cr - es / 2],
[0, cr - es / 2, cr - es / 2]
]
const edgeBoxRotations = [
[0, Math.PI / 2, 0],
[0, Math.PI / 2, 0],
[0, Math.PI / 2, 0],
[0, Math.PI / 2, 0], // Z-aligned
[0, 0, Math.PI / 2],
[0, 0, Math.PI / 2],
[0, 0, Math.PI / 2],
[0, 0, Math.PI / 2], // Y-aligned
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
[0, 0, 0] // X-aligned
]
// Center face positions and rotations (similar to original)
const facePositions = [
[0, -cr, 0],
[0, cr, 0], // Front, Back
[cr, 0, 0],
[-cr, 0, 0], // Right, Left
[0, 0, -cr],
[0, 0, cr] // Bottom, Top
]
const faceRotations = [
[Math.PI / 2, 0, 0],
[-Math.PI / 2, Math.PI, 0],
[0, Math.PI / 2, Math.PI / 2],
[0, -Math.PI / 2, -Math.PI / 2],
[0, Math.PI, Math.PI],
[0, 0, 0]
]
const edgeCylinderPositions = [
[-cr, 0, cr],
[cr, 0, cr],
[cr, 0, -cr],
[0, cr, cr],
[0, -cr, cr],
[0, cr, -cr],
[-cr, cr, 0],
[cr, cr, 0],
[cr, -cr, 0]
]
const edgeCylinderRotations = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
[0, 0, Math.PI / 2],
[0, 0, Math.PI / 2],
[0, 0, Math.PI / 2],
[-Math.PI / 2, 0, 0],
[-Math.PI / 2, 0, 0],
[-Math.PI / 2, 0, 0]
]
const rgb = [new Color4(1, 0, 0, 1), new Color4(0, 1, 0, 1), new Color4(0, 0, 1, 1)]
const axisCylinderPositions = [
[0.5, -2, -2],
[-2, 0.5, -2],
[-2, -2, 0.5]
]
const axisCylinderRotations = [
[0, 0, Math.PI / 2],
[0, 0, 0],
[-Math.PI / 2, 0, 0]
]
const axisConePositions = [
[3, -2, -2],
[-2, 3, -2],
[-2, -2, 3]
]
const axisConeRotations = [
[0, 0, -Math.PI / 2],
[0, 0, 0],
[-Math.PI / 2, 0, Math.PI]
]
// Set label plane particles
for (let i = 0; i < 3; i++) {
const particle = sps.particles[i]
particle.scaling.set(2, 2, 2)
particle.position.set(...(labelPlanePositions[i] as [number, number, number]))
particle.rotation.set(...(labelPlaneRotations[i] as [number, number, number]))
particle.uvs.set(...uvMapping[i + 6])
}
// Set center face particles
for (let i = 0; i < 6; i++) {
const particle = sps.particles[i + 3]
particle.position.set(...(facePositions[i] as [number, number, number]))
particle.rotation.set(...(faceRotations[i] as [number, number, number]))
particle.uvs.set(...uvMapping[i])
this.particleColorMap.set(i + 3, particle.color!.clone())
}
// Set corner particles
for (let i = 0; i < 8; i++) {
const particle = sps.particles[i + 9]
particle.position.set(...(cornerPositions[i] as [number, number, number]))
const color = new Color4(0.7, 0.7, 0.7, 1)
particle.color = color
this.particleColorMap.set(i + 9, color.clone())
}
// Set edge box particles
for (let i = 0; i < 12; i++) {
const particle = sps.particles[i + 17]
particle.position.set(...(edgeBoxPositions[i] as [number, number, number]))
particle.rotation.set(...(edgeBoxRotations[i] as [number, number, number]))
const color = new Color4(0.8, 0.8, 0.8, 1)
particle.color = color
this.particleColorMap.set(i + 17, color.clone())
}
// Set edge cylinder particles
for (let i = 0; i < 9; i++) {
const particle = sps.particles[i + 29]
particle.position.set(...(edgeCylinderPositions[i] as [number, number, number]))
particle.rotation.set(...(edgeCylinderRotations[i] as [number, number, number]))
const color = new Color4(0, 0, 0, 1)
particle.color = color
this.particleColorMap.set(i + 29, color.clone())
}
// Set axis cylinder particles
for (let i = 0; i < 3; i++) {
const particle = sps.particles[i + 38]
particle.position.set(...(axisCylinderPositions[i] as [number, number, number]))
particle.rotation.set(...(axisCylinderRotations[i] as [number, number, number]))
particle.color = rgb[i]
}
// Set axis cone particles
for (let i = 0; i < 3; i++) {
const particle = sps.particles[i + 41]
particle.position.set(...(axisConePositions[i] as [number, number, number]))
particle.rotation.set(...(axisConeRotations[i] as [number, number, number]))
particle.color = rgb[i]
}
}
/*** Adds all the required meshes to the SPS ***/
private static generateSPS(scene2: Scene, cr: number, es: number) {
const cornerCubeMesh = MeshBuilder.CreateBox('cornerBase', { size: es }, scene2)
const edgeBoxMesh = MeshBuilder.CreateBox(
'edgeBase',
{
width: 2 * cr - 2 * es,
height: es,
depth: es
},
scene2
)
const centerPlaneMesh = MeshBuilder.CreatePlane('plane', { size: 2 * cr - 2 * es }, scene2)
centerPlaneMesh.flipFaces()
const cylinderMesh = MeshBuilder.CreateCylinder(
'cylinderBase',
{ height: 4, diameterTop: 0.015, diameterBottom: 0.015 },
scene2
)
const coneMesh = MeshBuilder.CreateCylinder(
'coneBase',
{ height: 1, diameterTop: 0, diameterBottom: 0.6 },
scene2
)
const axisCylinderMesh = MeshBuilder.CreateCylinder(
'axisCylinderBase',
{ height: 5, diameterTop: 0.3, diameterBottom: 0.3 },
scene2
)
// Create SPS for all the elements
const sps = new SolidParticleSystem('viewCubeSPS', scene2, { isPickable: true })
// Add shapes to SPS
const _labelPlanesIdx = sps.addShape(centerPlaneMesh, 3)
const _centerPlanesIdx = sps.addShape(centerPlaneMesh, 6) // 6 center faces
const _cornerCubesIdx = sps.addShape(cornerCubeMesh, 8) // 8 corners
const _edgeBoxesIdx = sps.addShape(edgeBoxMesh, 12) // 12 edges
const _edgeCylindersIdx = sps.addShape(cylinderMesh, 9)
const _axisCylindersIdx = sps.addShape(axisCylinderMesh, 3)
const _axisConesIdx = sps.addShape(coneMesh, 3)
// Dispose of base meshes as they're no longer needed
cornerCubeMesh.dispose()
edgeBoxMesh.dispose()
centerPlaneMesh.dispose()
cylinderMesh.dispose()
coneMesh.dispose()
axisCylinderMesh.dispose()
return sps
}
static createShaderMaterial(scene: Scene) {
// Load shaders into Babylon.js
Effect.ShadersStore['customVertexShader'] = `
precision highp float;
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
attribute vec4 color;
attribute float particleID;
uniform mat4 world;
uniform mat4 worldView;
uniform mat4 worldViewProjection;
varying vec2 vUV;
varying vec4 vColor;
varying float vParticleID;
void main() {
vUV = uv;
vColor = color;
vParticleID = particleID; // Pass the ID to fragment shader
gl_Position = worldViewProjection * vec4(position, 1.0);
}`
Effect.ShadersStore['customFragmentShader'] = `
precision highp float;
uniform sampler2D textureSampler;
varying vec2 vUV;
varying vec4 vColor;
varying float vParticleID;
void main() {
if (vParticleID < 1.0) {
gl_FragColor = vColor;
} else {
vec4 texColor = texture2D(textureSampler, vUV);
if (texColor.a < 0.1) discard;
gl_FragColor = texColor * vColor;
}
}`
// Create Shader Material
const shaderMaterial = new ShaderMaterial(
'shaderMat',
scene,
{
vertex: 'custom',
fragment: 'custom'
},
{
attributes: ['position', 'normal', 'uv', 'color', 'particleID'],
uniforms: ['world', 'worldView', 'worldViewProjection'],
samplers: ['textureSampler']
}
)
shaderMaterial.setColor3('emissiveColor', new Color3(1, 1, 1))
return shaderMaterial
}
/**
* Creates a DynamicTexture by rasterising an SVG via an Image element.
* 512x512 source with mipmaps for crisp quality at any display size.
* @internal
*/
private static _svgTexId = 0
private static _makeSvgTexture(scene: Scene, iconContent: string, hovered: boolean): DynamicTexture {
const bg = hovered ? '#505860' : '#373d43'
const bgOp = hovered ? '0.95' : '0.85'
const border = hovered ? '#00b8b0' : '#ffffff'
const borderOp = hovered ? '0.6' : '0.25'
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 128 128">` +
`<circle cx="64" cy="64" r="54" fill="${bg}" fill-opacity="${bgOp}" stroke="${border}" stroke-opacity="${borderOp}" stroke-width="2.5"/>` +
iconContent +
`</svg>`
const size = 1024
const tex = new DynamicTexture(`svgIcon_${ViewCubeBuilder._svgTexId++}`, size, scene, true)
tex.hasAlpha = true
tex.anisotropicFilteringLevel = 16
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
const img = new Image(size, size)
img.onload = () => {
const ctx = tex.getContext() as unknown as CanvasRenderingContext2D
ctx.clearRect(0, 0, size, size)
ctx.drawImage(img, 0, 0, size, size)
tex.update()
}
img.src = url
return tex
}
/** @internal */
private static _homeIconContent(hovered: boolean): string {
const c = hovered ? '#00b8b0' : '#ffffff'
const op = hovered ? '1' : '0.85'
const bg = hovered ? '#505860' : '#373d43'
// House: triangle roof + rectangular body + door cutout
return (
`<path d="M64 30 L34 54 L94 54 Z" fill="${c}" fill-opacity="${op}"/>` +
`<rect x="40" y="54" width="48" height="34" fill="${c}" fill-opacity="${op}"/>` +
`<rect x="55" y="66" width="18" height="22" rx="1" fill="${bg}"/>`
)
}
/** @internal */
private static _fitIconContent(hovered: boolean): string {
const c = hovered ? '#00b8b0' : '#ffffff'
const op = hovered ? '1' : '0.85'
// Four corner brackets + center square
return (
`<path d="M38 50 L38 38 L50 38" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
`<path d="M78 38 L90 38 L90 50" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
`<path d="M38 78 L38 90 L50 90" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
`<path d="M78 90 L90 90 L90 78" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
`<rect x="55" y="55" width="18" height="18" rx="2" fill="${c}" fill-opacity="${op}"/>`
)
}
/** @internal */
private static _viewModeIconContent(hovered: boolean, isOrtho: boolean): string {
const c = hovered ? '#00b8b0' : '#ffffff'
const op = hovered ? '1' : '0.85'
if (isOrtho) {
// Isometric cube — all depth lines parallel (orthographic)
return (
`<path d="M36 86 L74 86 L74 52 L36 52 Z" fill="${c}" fill-opacity="0.12" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
`<path d="M74 52 L88 38 L88 72 L74 86" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
`<path d="M36 52 L50 38 L88 38 L74 52" fill="${c}" fill-opacity="0.04" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>`
)
} else {
// Perspective cube — depth lines converge (back face smaller)
return (
`<path d="M34 88 L76 88 L76 52 L34 52 Z" fill="${c}" fill-opacity="0.12" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
`<path d="M76 52 L82 40 L82 68 L76 88" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
`<path d="M34 52 L46 40 L82 40 L76 52" fill="${c}" fill-opacity="0.04" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>`
)
}
}
}