encapsulate new axis labels into text3 class

This commit is contained in:
Stewart Allen 2025-12-26 10:19:27 -05:00
commit dda980b100
2 changed files with 234 additions and 132 deletions

View file

@ -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);

213
src/moto/text3d.js Normal file
View file

@ -0,0 +1,213 @@
/** Copyright Stewart Allen <sa@grid.space> -- 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 };