Label the phone keys with runtime-drawn legends

The keypad keys had no glyphs. Stamp each key with a legend rendered into a
canvas (THREE.CanvasTexture) on a thin unlit plane just proud of the key face,
so it reads crisply and rides the key-press dip — no texture assets needed. The
canvas aspect matches the key face and the font auto-fits, so a word sits as
neatly as a digit. Digits/*/# show their glyph; call/end/nav read do/don't/?.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:33:41 +02:00
co-authored by Claude Opus 4.8
parent 035cc57e4a
commit a3a9b0a9ec
+37 -6
View File
@@ -44,6 +44,29 @@ const sfx = {
// ---- scene -------------------------------------------------------------------- // ---- scene --------------------------------------------------------------------
// Draw a key legend into a canvas and hand it back as a texture. The canvas aspect
// matches the key face (w:h) so glyphs aren't stretched when mapped onto the plane;
// the font shrinks to fit so words ("don't") sit as comfortably as single digits.
function makeKeyLabel(text: string, w: number, h: number): THREE.Texture {
const H = 220
const W = Math.max(64, Math.round(H * (w / h)))
const canvas = document.createElement('canvas')
canvas.width = W; canvas.height = H
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#0e140a'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
let size = Math.round(H * 0.66)
const setFont = () => { ctx.font = `700 ${size}px "IBM Plex Mono", ui-monospace, monospace` }
setFont()
while (ctx.measureText(text).width > W * 0.84 && size > 12) { size -= 4; setFont() }
ctx.fillText(text, W / 2, H / 2 + size * 0.04)
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
texture.anisotropy = 4
return texture
}
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] } type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
export function buildPhone(): Built { export function buildPhone(): Built {
@@ -81,21 +104,29 @@ export function buildPhone(): Built {
lid.add(glass) lid.add(glass)
// Chunky protruding keys (LOCAL to the keypad, whose body spans local y [-0.46,0.46]). // Chunky protruding keys (LOCAL to the keypad, whose body spans local y [-0.46,0.46]).
const key = (w: number, h: number, x: number, y: number, id: string) => { // `label` is stamped as a thin, unlit legend plane just proud of the key's +z face,
// so it reads crisply and rides along with the key-press dip.
const key = (w: number, h: number, x: number, y: number, id: string, label?: string) => {
const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.09), keyMat) const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.09), keyMat)
k.position.set(x, y, 0.10) k.position.set(x, y, 0.10)
k.userData = { key: id, baseZ: 0.10 } k.userData = { key: id, baseZ: 0.10 }
k.castShadow = true k.castShadow = true
keypad.add(k) keypad.add(k)
keys.push(k) keys.push(k)
if (label) {
const legend = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.82, h * 0.82),
new THREE.MeshBasicMaterial({ map: makeKeyLabel(label, w, h), transparent: true }))
legend.position.z = 0.047 // half-depth is 0.045; sit just above to avoid z-fighting
k.add(legend)
} }
key(0.16, 0.10, -0.20, 0.40, 'call') // left soft key }
key(0.16, 0.10, 0.20, 0.40, 'end') // right soft key key(0.16, 0.10, -0.20, 0.40, 'call', 'do') // left soft key
key(0.20, 0.16, 0, 0.22, 'nav') // nav pad key(0.16, 0.10, 0.20, 0.40, 'end', "don't") // right soft key
key(0.20, 0.16, 0, 0.22, 'nav', '?') // nav pad
const rows = [0.04, -0.10, -0.24, -0.38] const rows = [0.04, -0.10, -0.24, -0.38]
const cols = [-0.20, 0, 0.20] const cols = [-0.20, 0, 0.20]
const digits = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['*', '0', '#']] const digits = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['*', '0', '#']]
rows.forEach((y, r) => cols.forEach((x, c) => key(0.15, 0.10, x, y, digits[r][c]))) rows.forEach((y, r) => cols.forEach((x, c) => key(0.15, 0.10, x, y, digits[r][c], digits[r][c])))
// Hinge barrel: parented to the hinge group at its local origin, so it sits // Hinge barrel: parented to the hinge group at its local origin, so it sits
// EXACTLY on the rotation axis (the pivot passes through the cylinder centre). // EXACTLY on the rotation axis (the pivot passes through the cylinder centre).
@@ -221,7 +252,7 @@ function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => voi
renderer.domElement.removeEventListener('pointerdown', onPointer) renderer.domElement.removeEventListener('pointerdown', onPointer)
renderer.dispose() renderer.dispose()
renderer.domElement.remove() renderer.domElement.remove()
scene.traverse(obj => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); (obj.material as THREE.Material).dispose() } }) scene.traverse(obj => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); const material = obj.material as THREE.Material & { map?: THREE.Texture | null }; material.map?.dispose(); material.dispose() } })
} }
}, []) }, [])