diff --git a/api/shapes.js b/api/shapes.js index 89f70314..65c55c25 100644 --- a/api/shapes.js +++ b/api/shapes.js @@ -701,6 +701,7 @@ export const flockShapes = { text, font, color = "#FFFFFF", + alpha = 1, size = 50, depth = 1.0, position = { x: 0, y: 0, z: 0 }, @@ -721,6 +722,24 @@ export const flockShapes = { depth = toDim(depth, 1); const { x, y, z } = position; + let blockKey = modelId; + let meshId = modelId; + if (modelId.includes("__")) { + [meshId, blockKey] = modelId.split("__"); + } + + if (flock.scene.getMeshByName(meshId)) { + meshId = meshId + "_" + flock.scene.getUniqueId(); + } + + flock._recycleOldestByKey(blockKey); + + // Guard against overlapping builds for the same blockKey: stamp this build + // with a unique token and drop the result if a newer build supersedes it. + if (!flock._pendingTextBuilds) flock._pendingTextBuilds = new Map(); + const buildToken = Symbol(); + flock._pendingTextBuilds.set(blockKey, buildToken); + // Create the loading promise const loadPromise = new Promise(async (resolve, reject) => { try { @@ -746,7 +765,7 @@ export const flockShapes = { }); // Create Babylon.js mesh from manifold data - mesh = new flock.BABYLON.Mesh(modelId, flock.scene); + mesh = new flock.BABYLON.Mesh(meshId, flock.scene); const vertexData = new flock.BABYLON.VertexData(); vertexData.positions = meshData.positions; @@ -806,7 +825,7 @@ export const flockShapes = { const fontData = await (await fetch(font)).json(); mesh = flock.BABYLON.MeshBuilder.CreateText( - modelId, + meshId, text, fontData, { @@ -823,9 +842,12 @@ export const flockShapes = { return; } + mesh.metadata = mesh.metadata || {}; + mesh.metadata.blockKey = blockKey; + mesh.position.set(x, y, z); const material = new flock.BABYLON.StandardMaterial( - "textMaterial_" + modelId, + "textMaterial_" + meshId, flock.scene, ); @@ -834,31 +856,65 @@ export const flockShapes = { ); material.backFaceCulling = false; material.emissiveColor = material.diffuseColor.scale(0.2); + material.alpha = toAlpha(alpha); mesh.material = material; mesh.computeWorldMatrix(true); mesh.refreshBoundingInfo(); + + // Normalize mesh so bounding box height equals the size parameter. + // Font cap-height is typically ~70% of em-height, causing a mismatch + // between SIZE in the block and the visual height. Baking a uniform XY + // scale here ensures SIZE always equals the rendered height, which + // prevents a visual jump when the scale gizmo is released. + { + const bbExt = mesh.getBoundingInfo().boundingBox.extendSize; + const bbHeight = bbExt.y * 2; + if (bbHeight > 0 && Math.abs(bbHeight - size) > 0.001) { + const normScale = size / bbHeight; + const savedPos = mesh.position.clone(); + mesh.position = flock.BABYLON.Vector3.Zero(); + mesh.scaling.x = normScale; + mesh.scaling.y = normScale; + mesh.bakeCurrentTransformIntoVertices(); + mesh.scaling = flock.BABYLON.Vector3.One(); + mesh.position = savedPos; + mesh.computeWorldMatrix(true); + mesh.refreshBoundingInfo(); + } + } + mesh.setEnabled(true); mesh.visibility = 1; const textShape = new flock.BABYLON.PhysicsShapeMesh(mesh, flock.scene); flock.applyPhysics(mesh, textShape); + // Drop stale result if a newer build for this blockKey was started + if (flock._pendingTextBuilds.get(blockKey) !== buildToken) { + mesh.dispose(); + resolve(); + return; + } + flock._pendingTextBuilds.delete(blockKey); + + flock._registerInstance(blockKey, mesh.name); + if (callback) { requestAnimationFrame(callback); } resolve(); } catch (error) { - console.error(`Error creating 3D text '${modelId}':`, error); + console.error(`Error creating 3D text '${meshId}':`, error); reject(error); } }); // Store promise for whenModelReady coordination - flock.modelReadyPromises.set(modelId, loadPromise); + flock.modelReadyPromises.set(meshId, loadPromise); - return modelId; + return meshId; }, }; diff --git a/blocks/text.js b/blocks/text.js index e5cd0d00..5b9eb771 100644 --- a/blocks/text.js +++ b/blocks/text.js @@ -4,6 +4,7 @@ import { getHelpUrlFor, nextVariableIndexes, handleBlockCreateEvent, + handleBlockChange, registerBlockHandler, } from "./blocks.js"; import { @@ -456,12 +457,7 @@ export function defineTextBlocks() { this.setStyle("text_blocks"); registerBlockHandler(this, (changeEvent) => - handleBlockCreateEvent( - this, - changeEvent, - variableNamePrefix, - nextVariableIndexes, - ), + handleBlockChange(this, changeEvent, variableNamePrefix), ); }, }; diff --git a/generators/generators-text.js b/generators/generators-text.js index 2cd3c51d..eb959aea 100644 --- a/generators/generators-text.js +++ b/generators/generators-text.js @@ -4,6 +4,7 @@ import { getFieldValue, sanitizeForCode, emitSafeTextArg, + getVariableInfo, } from "./generators-utilities.js"; export function registerTextGenerators(javascriptGenerator) { @@ -361,9 +362,9 @@ export function registerTextGenerators(javascriptGenerator) { // Add 3D text -------------------------------------------- javascriptGenerator.forBlock["create_3d_text"] = function (block) { - const variableName = javascriptGenerator.nameDB_.getName( - block.getFieldValue("ID_VAR"), - Blockly.Names.NameType.VARIABLE, + const { generatedName: variableName, userVariableName } = getVariableInfo( + block, + "ID_VAR", ); let rawText = getFieldValue(block, "TEXT", "Hello World"); @@ -383,9 +384,9 @@ export function registerTextGenerators(javascriptGenerator) { if (fontKey === "__fonts_FreeSans_Bold_json") font = "./fonts/FreeSans_Bold.json"; - const meshId = "text_" + generateUniqueId(); - meshMap[meshId] = block; - meshBlockIdMap[meshId] = block.id; + const meshId = `${userVariableName}__${block.id}`; + meshMap[block.id] = block; + meshBlockIdMap[block.id] = block.id; let doCode = ""; if (block.getInput("DO")) { diff --git a/ui/addmeshes.js b/ui/addmeshes.js index 3b8cc775..c1916714 100644 --- a/ui/addmeshes.js +++ b/ui/addmeshes.js @@ -2,7 +2,6 @@ import * as Blockly from "blockly"; import { meshMap, meshBlockIdMap, - generateUniqueId, } from "../generators/generators.js"; import { flock } from "../flock.js"; import { @@ -25,6 +24,7 @@ export function createMeshOnCanvas(block) { "create_cylinder", "create_capsule", "create_plane", + "create_3d_text", ].includes(block.type); if (isShape) { @@ -628,6 +628,34 @@ function createShapeInternal(block) { }); break; + case "create_3d_text": { + ({ colorOrMaterial: color, alpha } = resolveColorOrMaterial("#FFFFFF")); + + const textInput = block.getInput("TEXT"); + const textTarget = textInput?.connection?.targetBlock?.(); + const textValue = textTarget + ? textTarget.getFieldValue("TEXT") ?? textTarget.getFieldValue("NUM") ?? "Hello World" + : "Hello World"; + + const fontSize = parseFloat(getConnectedFieldValue("SIZE", "NUM", "50")); + const textDepth = parseFloat(getConnectedFieldValue("DEPTH", "NUM", "1")); + + meshMap[block.id] = block; + meshBlockIdMap[block.id] = block.id; + + newMesh = flock.create3DText({ + text: String(textValue), + font: "fonts/FreeSansBold.ttf", + color, + alpha, + size: fontSize, + depth: textDepth, + position: { x: position.x, y: position.y, z: position.z }, + modelId: `3dtext__${block.id}`, + }); + break; + } + default: return; } diff --git a/ui/blockmesh.js b/ui/blockmesh.js index e7478abb..9d48a9bd 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -1074,6 +1074,28 @@ function handlePrimitiveGeometryChange(mesh, block, changed) { } break; } + + case "create_3d_text": { + if (["SIZE", "DEPTH"].includes(changed)) { + const newSize = parseFloat( + block.getInput("SIZE").connection.targetBlock().getFieldValue("NUM"), + ); + const newDepth = parseFloat( + block.getInput("DEPTH").connection.targetBlock().getFieldValue("NUM"), + ); + + mesh.computeWorldMatrix(true); + mesh.refreshBoundingInfo(); + const ext = mesh.getBoundingInfo().boundingBox.extendSize; + const currentW = ext.x * 2 * mesh.scaling.x; + const currentH = ext.y * 2 * mesh.scaling.y; + const newW = currentH > 0 ? currentW * (newSize / currentH) : currentW; + + setAbsoluteSize(mesh, newW, newSize, newDepth); + repositionPrimitiveFromBlock(); + } + break; + } } } diff --git a/ui/gizmos.js b/ui/gizmos.js index 86aa2f1a..2b331c6d 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -31,6 +31,10 @@ const orangeColor = flock.BABYLON.Color3.FromHexString("#D55E00"); // Colour for window.selectedColor = "#ffffff"; // Default color let colorPicker = null; +// 3D text scale gizmo axis tracking +let textScaleAxis = null; +let textOrigScaleZ = 1; + // Color picking keyboard mode variables let colorPickingKeyboardMode = false; let colorPickingCallback = null; @@ -648,8 +652,8 @@ export function toggleGizmo(gizmoType) { if (event.type === flock.BABYLON.PointerEventTypes.POINTERPICK) { if (gizmoManager.attachedMesh) { resetAttachedMesh(); - blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata - ?.blockKey; + blockKey = findParentWithBlockId(gizmoManager.attachedMesh) + ?.metadata?.blockKey; } let pickedMesh = event.pickInfo.pickedMesh; @@ -933,6 +937,24 @@ export function toggleGizmo(gizmoType) { case "scale": configureScaleGizmo(gizmoManager); + { + const sg = gizmoManager.gizmos.scaleGizmo; + if (!sg._textAxisObserversRegistered) { + sg.xGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "x"), + ); + sg.yGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "y"), + ); + sg.zGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "z"), + ); + sg.uniformScaleGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "uniform"), + ); + sg._textAxisObserversRegistered = true; + } + } gizmoManager.onAttachedToMeshObservable.add((mesh) => { if (!mesh) return; @@ -965,6 +987,21 @@ export function toggleGizmo(gizmoType) { case "create_cylinder": mesh.scaling.z = mesh.scaling.x; break; + case "create_3d_text": + if (textScaleAxis === "z") { + // Z handle: depth only — lock X and Y + mesh.scaling.x = 1; + mesh.scaling.y = 1; + } else if (textScaleAxis === "x" || textScaleAxis === "uniform") { + // X or uniform: size only — keep Y = X, lock Z + mesh.scaling.y = mesh.scaling.x; + mesh.scaling.z = textOrigScaleZ; + } else if (textScaleAxis === "y") { + // Y handle: size only — keep X = Y, lock Z + mesh.scaling.x = mesh.scaling.y; + mesh.scaling.z = textOrigScaleZ; + } + break; } } }); @@ -975,6 +1012,8 @@ export function toggleGizmo(gizmoType) { mesh.computeWorldMatrix(true); mesh.refreshBoundingInfo(); originalBottomY = mesh.getBoundingInfo().boundingBox.minimumWorld.y; + textOrigScaleZ = mesh.scaling.z; + textScaleAxis = null; const motionType = mesh.physics?.getMotionType(); mesh.savedMotionType = motionType; @@ -995,6 +1034,7 @@ export function toggleGizmo(gizmoType) { gizmoManager.gizmos.scaleGizmo.onDragEndObservable.add(() => { const mesh = gizmoManager.attachedMesh; const block = meshMap[mesh?.metadata?.blockKey]; + textScaleAxis = null; if (mesh.savedMotionType != null) { mesh.physics.setMotionType(mesh.savedMotionType); @@ -1075,6 +1115,16 @@ export function toggleGizmo(gizmoType) { }); break; + case "create_3d_text": { + const currentSize = getNumberInput(block, "SIZE"); + const currentDepth = getNumberInput(block, "DEPTH"); + setNumberInputs(block, { + SIZE: currentSize * mesh.scaling.y, + DEPTH: currentDepth * mesh.scaling.z, + }); + break; + } + case "load_model": case "load_multi_object": case "load_object": @@ -1410,8 +1460,8 @@ export function setGizmoManager(value) { // KeyCode for 'Delete' key is 46 // Handle delete action - const blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata - ?.blockKey; + const blockKey = findParentWithBlockId(gizmoManager.attachedMesh) + ?.metadata?.blockKey; const blockId = meshBlockIdMap[blockKey]; deleteBlockWithUndo(blockId);