encapsulate new axis labels into text3 class
This commit is contained in:
parent
fc6ea03f8d
commit
dda980b100
2 changed files with 234 additions and 132 deletions
|
|
@ -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
213
src/moto/text3d.js
Normal 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 };
|
||||
Loading…
Reference in a new issue