diff --git a/src/moto/space.js b/src/moto/space.js index 310f1806..2d127d18 100644 --- a/src/moto/space.js +++ b/src/moto/space.js @@ -3,6 +3,7 @@ import { broker } from './broker.js'; import { THREE } from '../ext/three.js'; import { Orbit } from './orbit.js'; +import { Text3D } from './text3d.js'; import '../ext/tween.js'; const { @@ -445,127 +446,15 @@ function canvasInMesh(w, h, textAlign, textBaseline, color, size) { } /** - * Font atlas for 3D text labels. - * Generates a texture atlas with characters 0-9, -, X, Y at startup. + * 3D text renderer for grid labels. + * Uses bitmap font atlas with shared geometries for memory efficiency. */ -let fontAtlas = null; - -/** - * Create bitmap font atlas for grid labels. - * Generates texture with characters: 0-9, -, X, Y - * @returns {object} Atlas data with texture and UV mapping - */ -function createFontAtlas() { - const chars = '0123456789-XY'; - const canvas = DOC.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const charSize = 64; - - canvas.width = charSize * chars.length; - canvas.height = charSize; - - // Clear to transparent - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Load Russo One font or fallback to sans-serif - ctx.font = `bold ${charSize * 0.8}px 'Russo One', sans-serif`; - ctx.fillStyle = 'white'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - const uvMap = {}; - for (let i = 0; i < chars.length; i++) { - ctx.fillText(chars[i], i * charSize + charSize/2, charSize/2); - uvMap[chars[i]] = { - uStart: i / chars.length, - uEnd: (i + 1) / chars.length - }; - } - - const texture = new THREE.CanvasTexture(canvas); - texture.minFilter = THREE.LinearFilter; - texture.magFilter = THREE.LinearFilter; - texture.needsUpdate = true; - - console.log('Font atlas created', { width: canvas.width, height: canvas.height, chars: chars.length }); - - return { texture, uvMap, charSize }; -} - -/** - * Create 3D text label using bitmap font atlas. - * @param {string} text - Text to render - * @param {number} size - Character size - * @param {string|number} color - Text color (CSS string or hex number) - * @param {string} align - Horizontal alignment ('left', 'center', 'right') - * @returns {THREE.Group} Group containing character meshes - */ -function create3DTextLabel(text, size, color, align = 'center') { - if (!fontAtlas) { - fontAtlas = createFontAtlas(); - } - - const { texture, uvMap, charSize } = fontAtlas; - const group = new THREE.Group(); - const charWidth = size * 0.6; // Narrower spacing for better appearance - const totalWidth = text.length * charWidth; - - // Convert CSS color string to THREE.Color if needed - let threeColor = 0x333333; // default gray - if (color) { - if (typeof color === 'string') { - threeColor = new THREE.Color(color); - } else { - threeColor = color; - } - } - - // Calculate starting X based on alignment - let startX = 0; - if (align === 'center') { - startX = -totalWidth / 2; - } else if (align === 'right') { - startX = -totalWidth; - } - - // Create a mesh for each character - for (let i = 0; i < text.length; i++) { - const char = text[i]; - const uvData = uvMap[char]; - - if (!uvData) { - console.warn('Character not in atlas:', char); - continue; - } - - // Create plane geometry for this character - const geometry = new THREE.PlaneGeometry(charWidth, size); - const uvAttr = geometry.attributes.uv; - - // Set UV coordinates to show only this character from atlas - // PlaneGeometry UVs: bottom-left, bottom-right, top-left, top-right - uvAttr.setXY(0, uvData.uStart, 0); // bottom-left - uvAttr.setXY(1, uvData.uEnd, 0); // bottom-right - uvAttr.setXY(2, uvData.uStart, 1); // top-left - uvAttr.setXY(3, uvData.uEnd, 1); // top-right - - const material = new THREE.MeshBasicMaterial({ - map: texture, - transparent: true, - opacity: 0.9, - color: threeColor, - depthWrite: false, - side: THREE.DoubleSide - }); - - const mesh = new THREE.Mesh(geometry, material); - mesh.position.x = startX + i * charWidth + charWidth / 2; - - group.add(mesh); - } - - return group; -} +let text3d = new Text3D({ + chars: '0123456789-XY', + atlasCharSize: 64, + kerning: 0.5, // Tighter spacing than default + fontFamily: "'Russo One', sans-serif" +}); function setRulers( @@ -598,7 +487,7 @@ function updateRulers() { oldView = ruler.view, view = ruler.view = new THREE.Group(); - console.log('updateRulers called', { + if (false) console.log('updateRulers called', { xon, yon, axesOn, labelSize, rulerColor, factor, x1: ruler.x1, x2: ruler.x2, y1: ruler.y1, y2: ruler.y2, xo: ruler.xo, yo: ruler.yo, @@ -614,7 +503,7 @@ function updateRulers() { if (platformBelt) { for (let i = 0; i <= ruler.x2; i += grid.unitMajor) { const value = (i * factor).round(1).toString(); - const label = create3DTextLabel(value, labelSize, rulerColor, 'center'); + const label = text3d.createLabel(value, labelSize, rulerColor, 'center'); label.position.set(ruler.x2 - i, -h - labelSize * 2, zp); label.rotation.x = Math.PI; @@ -623,7 +512,7 @@ function updateRulers() { } else { for (let i = 0; i >= ruler.x1; i -= grid.unitMajor) { const value = (i * factor).round(1).toString(); - const label = create3DTextLabel(value, labelSize, rulerColor, 'center'); + const label = text3d.createLabel(value, labelSize, rulerColor, 'center'); label.position.set(ruler.xo + i - w, -h - labelSize * 2, zp); label.rotation.x = Math.PI; @@ -631,7 +520,7 @@ function updateRulers() { } for (let i = 0; i <= ruler.x2; i += grid.unitMajor) { const value = (i * factor).round(1).toString(); - const label = create3DTextLabel(value, labelSize, rulerColor, 'center'); + const label = text3d.createLabel(value, labelSize, rulerColor, 'center'); label.position.set(ruler.xo + i - w, -h - labelSize * 2, zp); label.rotation.x = Math.PI; @@ -640,7 +529,7 @@ function updateRulers() { } // Create X-axis label - const xLabel = create3DTextLabel(xlabel, labelSize * 0.75, rulerColor, 'center'); + const xLabel = text3d.createLabel(xlabel, labelSize * 0.75, rulerColor, 'center'); xLabel.position.set(0, -h - labelSize * 3.5, zp); xLabel.rotation.x = Math.PI; view.add(xLabel); @@ -650,27 +539,27 @@ function updateRulers() { // Create Y-axis numeric labels // Original canvas code draws at canvas Y: y - (ruler.yo + i) + yPadding/2 // Canvas is centered at world Y=0, canvas height = y + yPadding - // So world Y = (canvasY - canvasHeight/2) = [y - (ruler.yo + i) + yPadding/2] - (y + yPadding)/2 - // = y/2 - ruler.yo - i = h - ruler.yo - i + // Canvas Y goes down, but world Y goes up, so we need to negate + // World Y = -(canvasY - canvasHeight/2) = -(h - ruler.yo - i) = -h + ruler.yo + i for (let i = 0; i >= ruler.y1; i -= grid.unitMajor) { const value = (i * factor).round(1).toString(); - const label = create3DTextLabel(value, labelSize, rulerColor, 'right'); + const label = text3d.createLabel(value, labelSize, rulerColor, 'right'); - label.position.set(-w - labelSize * 2 - 5, h - ruler.yo - i, zp); + label.position.set(-w - labelSize * 2 - 5, -h + ruler.yo + i, zp); label.rotation.x = Math.PI; view.add(label); } for (let i = 0; i <= ruler.y2; i += grid.unitMajor) { const value = (i * factor).round(1).toString(); - const label = create3DTextLabel(value, labelSize, rulerColor, 'right'); + const label = text3d.createLabel(value, labelSize, rulerColor, 'right'); - label.position.set(-w - labelSize * 2 - 5, h - ruler.yo - i, zp); + label.position.set(-w - labelSize * 2 - 5, -h + ruler.yo + i, zp); label.rotation.x = Math.PI; view.add(label); } // Create Y-axis label - const yLabel = create3DTextLabel(ylabel, labelSize * 0.75, rulerColor, 'center'); + const yLabel = text3d.createLabel(ylabel, labelSize * 0.75, rulerColor, 'center'); yLabel.position.set(-w - labelSize * 5, 0, zp); yLabel.rotation.x = Math.PI; view.add(yLabel); diff --git a/src/moto/text3d.js b/src/moto/text3d.js new file mode 100644 index 00000000..4a4f6aff --- /dev/null +++ b/src/moto/text3d.js @@ -0,0 +1,213 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../ext/three.js'; + +/** + * 3D Bitmap Text Renderer using font atlas. + * Creates a texture atlas from specified characters and renders text using + * individual character meshes with shared geometries and material. + */ +class Text3D { + /** + * Create a 3D text renderer. + * @param {object} options - Configuration options + * @param {string} options.chars - Characters to include in atlas (default: '0123456789-XY') + * @param {number} options.atlasCharSize - Size of each character in atlas texture (default: 64) + * @param {number} options.kerning - Character spacing multiplier (default: 0.5, lower = tighter) + * @param {string} options.fontFamily - CSS font family string (default: 'Russo One, sans-serif') + */ + constructor(options = {}) { + this.chars = options.chars || '0123456789-XY'; + this.atlasCharSize = options.atlasCharSize || 64; + this.kerning = options.kerning !== undefined ? options.kerning : 0.5; + this.fontFamily = options.fontFamily || "'Russo One', sans-serif"; + + this.atlas = null; + this.geometries = {}; // Cached geometries per character + this.material = null; // Shared material for all characters + + this._initialize(); + } + + /** + * Initialize atlas, geometries, and material. + * @private + */ + _initialize() { + this._createAtlas(); + this._createGeometries(); + this._createMaterial(); + } + + /** + * Create bitmap font atlas texture. + * @private + */ + _createAtlas() { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const charSize = this.atlasCharSize; + + canvas.width = charSize * this.chars.length; + canvas.height = charSize; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.font = `bold ${charSize * 0.8}px ${this.fontFamily}`; + ctx.fillStyle = 'white'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const uvMap = {}; + for (let i = 0; i < this.chars.length; i++) { + const char = this.chars[i]; + ctx.fillText(char, i * charSize + charSize/2, charSize/2); + uvMap[char] = { + uStart: i / this.chars.length, + uEnd: (i + 1) / this.chars.length + }; + } + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + + this.atlas = { texture, uvMap, canvas }; + + if (self.debug) { + console.log('Text3D atlas created', { + width: canvas.width, + height: canvas.height, + chars: this.chars.length + }); + } + } + + /** + * Create cached geometries for each character. + * Each character gets one geometry with UVs mapped to atlas position. + * These geometries are reused across all labels. + * @private + */ + _createGeometries() { + const { uvMap } = this.atlas; + + for (let char of this.chars) { + const uvData = uvMap[char]; + if (!uvData) continue; + + // Create unit-sized plane (will be scaled when used) + const geometry = new THREE.PlaneGeometry(1, 1); + const uvAttr = geometry.attributes.uv; + + // Set UV coordinates to sample this character from atlas + uvAttr.setXY(0, uvData.uStart, 0); // bottom-left + uvAttr.setXY(1, uvData.uEnd, 0); // bottom-right + uvAttr.setXY(2, uvData.uStart, 1); // top-left + uvAttr.setXY(3, uvData.uEnd, 1); // top-right + + this.geometries[char] = geometry; + } + } + + /** + * Create shared material for all characters. + * @private + */ + _createMaterial() { + this.material = new THREE.MeshBasicMaterial({ + map: this.atlas.texture, + transparent: true, + opacity: 0.9, + depthWrite: false, + side: THREE.DoubleSide + }); + } + + /** + * Create a 3D text label. + * @param {string} text - Text to render + * @param {number} size - Character height in world units + * @param {string|number} color - Text color (CSS string or hex number) + * @param {string} align - Horizontal alignment ('left', 'center', 'right') + * @param {number} kerning - Optional kerning override for this label + * @returns {THREE.Group} Group containing character meshes + */ + createLabel(text, size, color = 0x333333, align = 'center', kerning) { + const group = new THREE.Group(); + const spacing = kerning !== undefined ? kerning : this.kerning; + const charWidth = size * spacing; + const totalWidth = text.length * charWidth; + + // Convert CSS color string to THREE.Color if needed + let threeColor = color; + if (typeof color === 'string') { + threeColor = new THREE.Color(color); + } + + // Calculate starting X based on alignment + let startX = 0; + if (align === 'center') { + startX = -totalWidth / 2; + } else if (align === 'right') { + startX = -totalWidth; + } + + // Create mesh for each character using cached geometry + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const geometry = this.geometries[char]; + + if (!geometry) { + if (self.debug) { + console.warn('Text3D: Character not in atlas:', char); + } + continue; + } + + // Clone material to set per-label color + const material = this.material.clone(); + material.color = threeColor; + + const mesh = new THREE.Mesh(geometry, material); + + // Scale and position the unit-sized geometry + mesh.scale.set(charWidth, size, 1); + mesh.position.x = startX + i * charWidth + charWidth / 2; + + group.add(mesh); + } + + return group; + } + + /** + * Update kerning for future labels. + * @param {number} kerning - New kerning value + */ + setKerning(kerning) { + this.kerning = kerning; + } + + /** + * Clean up resources. + */ + dispose() { + // Dispose texture + if (this.atlas && this.atlas.texture) { + this.atlas.texture.dispose(); + } + + // Dispose geometries + for (let char in this.geometries) { + this.geometries[char].dispose(); + } + + // Dispose material + if (this.material) { + this.material.dispose(); + } + } +} + +export { Text3D };