Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e3bdbc7
Add material tile size block
tracygardner Dec 23, 2025
f745294
Fix AI marker for Spanish material tile translation
tracygardner Dec 23, 2025
c848db6
Let tile size block retile primitive shapes when requested
tracygardner Dec 23, 2025
d1aaa1f
Apply saved tile size to retile baked shapes
tracygardner Dec 23, 2025
ecda72e
Retile primitive meshes when material tile size changes
tracygardner Dec 23, 2025
823cd64
Allow setMaterial tileSize option and reuse in tile block
tracygardner Dec 23, 2025
cdf5b47
Return promises from material tiling and reuse setMaterial for tile b…
tracygardner Dec 23, 2025
bce99ed
Expose setMaterialTileSize to user scripts
tracygardner Dec 23, 2025
9c2ae5a
Avoid double-scaling when retiling primitives
tracygardner Dec 23, 2025
528aa5c
Preserve base tile size across setMaterial calls
tracygardner Dec 23, 2025
ae2b1b7
Keep existing texture scaling when no tile size is set
tracygardner Dec 23, 2025
a6a23a6
Use 4-unit default tile size and store base tile
tracygardner Dec 23, 2025
b5b6802
Tune default tile size to 3 units for objects
tracygardner Dec 23, 2025
cc282a3
Scale default tile size using mesh-specific factor
tracygardner Dec 23, 2025
4ad561f
Align default tile to 4 units so block matches initial tiling
tracygardner Dec 23, 2025
929a517
Use base tile and neutral scale when an explicit size is provided
tracygardner Dec 23, 2025
42aff9f
Neutral-scale explicit tile size for consistent object tiling
tracygardner Dec 23, 2025
e41d141
Apply mesh scaling only for primitives when tile is explicit
tracygardner Dec 23, 2025
805b845
Scale defaults for imports so 4 matches box tiling
tracygardner Dec 23, 2025
c0a5197
Align imported mesh tiling default with material updates
tracygardner Dec 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 227 additions & 72 deletions api/material.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,78 @@ export function setFlockReference(ref) {
flock = ref;
}

const PRIMITIVE_TILE_SCALE = 1;
const IMPORTED_TILE_SCALE = 0.25;
const DEFAULT_TILE_UNITS = 4;

function resolveTextureTileScale(mesh, fallbackScale = null) {
const meta = mesh.metadata || (mesh.metadata = {});
if (Number.isFinite(meta.textureTileScale)) return meta.textureTileScale;

const isPrimitive = !!meta.shapeType;
const isImported = !!meta.modelName;
const inferredScale =
fallbackScale != null
? fallbackScale
: isPrimitive
? PRIMITIVE_TILE_SCALE
: isImported
? IMPORTED_TILE_SCALE
: PRIMITIVE_TILE_SCALE;
meta.textureTileScale = inferredScale;
return inferredScale;
}

function retilePrimitiveMesh(mesh, tileSize) {
const shapeType = mesh?.metadata?.shapeType;
if (!shapeType || !Number.isFinite(tileSize) || tileSize <= 0) return;

const ext = mesh.getBoundingInfo?.()?.boundingBox?.extendSizeWorld;
if (!ext) return;

const width = ext.x * 2;
const height = ext.y * 2;
const depth = ext.z * 2;
const largestDiameter = Math.max(width, height, depth);

switch (shapeType) {
case "Box":
flock.setSizeBasedBoxUVs(mesh, width, height, depth, tileSize);
break;
case "Sphere":
flock.setSphereUVs(mesh, largestDiameter, tileSize);
break;
case "Cylinder":
flock.setSizeBasedCylinderUVs(mesh, height, width, width, tileSize);
break;
case "Capsule":
flock.setCapsuleUVs(mesh, width / 2, height, tileSize);
break;
case "Plane":
flock.setSizeBasedPlaneUVs(mesh, width, depth, tileSize);
break;
default:
break;
}
}

function computeEffectiveTile(mesh, baseTile, fallbackScale = null, { neutralScale = false } = {}) {
if (!Number.isFinite(baseTile) || baseTile <= 0) return { base: null, effective: null };
const scale = neutralScale ? 1 : resolveTextureTileScale(mesh, fallbackScale);
return { base: baseTile, effective: baseTile * scale };
}

export const flockMaterial = {
adjustMaterialTilingToMesh(mesh, material, unitsPerTile = null) {
adjustMaterialTilingToMesh(
mesh,
material,
unitsPerTile = null,
{ unitsAlreadyScaled = false, baseTileOverride = null } = {},
) {
if (!mesh || !material) return;

if (mesh.metadata?.skipAutoTiling) return;

const shapeType = mesh?.metadata?.shapeType;
const bakedShapes = new Set(["Box", "Sphere", "Cylinder", "Capsule", "Plane"]);
if (shapeType && bakedShapes.has(shapeType)) return;

const tex =
material.diffuseTexture ||
material.albedoTexture ||
Expand All @@ -33,21 +95,57 @@ export const flockMaterial = {
const extend = mesh.getBoundingInfo?.()?.boundingBox?.extendSizeWorld;
if (!extend) return;

const existingTile = mesh.metadata?.textureTileSize;
const tile =
Number.isFinite(unitsPerTile) && unitsPerTile > 0
? unitsPerTile
: Number.isFinite(existingTile) && existingTile > 0
? existingTile
: 2;
const existingTile = mesh.metadata?.textureTileBaseSize ?? mesh.metadata?.textureTileSize;
const shapeType = mesh?.metadata?.shapeType;
const bakedShapes = new Set(["Box", "Sphere", "Cylinder", "Capsule", "Plane"]);

if (
!Number.isFinite(unitsPerTile) &&
!Number.isFinite(existingTile) &&
(!shapeType || !bakedShapes.has(shapeType))
) {
// No explicit tile size and no stored tile size -> keep current texture scaling.
return;
}

const providedTile =
Number.isFinite(unitsPerTile) && unitsPerTile > 0 ? unitsPerTile : null;

const baseTile =
Number.isFinite(baseTileOverride) && baseTileOverride > 0
? baseTileOverride
: providedTile ??
(Number.isFinite(existingTile) && existingTile > 0
? existingTile
: DEFAULT_TILE_UNITS);

mesh.metadata = mesh.metadata || {};
mesh.metadata.textureTileSize = tile;
if (Number.isFinite(baseTileOverride) && baseTileOverride > 0) {
mesh.metadata.textureTileBaseSize = baseTileOverride;
} else if (!Number.isFinite(mesh.metadata.textureTileBaseSize)) {
mesh.metadata.textureTileBaseSize = baseTile;
}

const { effective } = computeEffectiveTile(mesh, baseTile, null, {
neutralScale: unitsAlreadyScaled || !!shapeType, // primitives neutral, imports scaled
});
const effectiveTile =
unitsAlreadyScaled && providedTile != null ? providedTile : effective ?? baseTile;
mesh.metadata.textureTileSize = effectiveTile;

if (shapeType && bakedShapes.has(shapeType)) {
retilePrimitiveMesh(mesh, effectiveTile);
tex.uScale = 1;
tex.vScale = 1;
return;
}

const worldWidth = extend.x * 2;
const worldHeight = extend.y * 2;
const worldDepth = extend.z * 2;

const newUScale = worldWidth / tile;
const newVScale = Math.max(worldHeight, worldDepth) / tile;
const newUScale = worldWidth / effectiveTile;
const newVScale = Math.max(worldHeight, worldDepth) / effectiveTile;

if (Number.isFinite(newUScale) && newUScale > 0) tex.uScale = newUScale;
if (Number.isFinite(newVScale) && newVScale > 0) tex.vScale = newVScale;
Expand Down Expand Up @@ -320,6 +418,12 @@ export const flockMaterial = {
});
});
},
setMaterialTileSize(meshName, tileUnits) {
const requestedSize = Number(tileUnits);
if (!Number.isFinite(requestedSize) || requestedSize <= 0)
return Promise.resolve();
return flock.setMaterial(meshName, [], { tileSize: requestedSize });
},
clearEffects(meshName) {
return flock.whenModelReady(meshName, (mesh) => {
if (flock.materialsDebug) console.log(`Clear effects from ${meshName}:`);
Expand Down Expand Up @@ -768,75 +872,126 @@ export const flockMaterial = {
return material;
},

setMaterial(meshName, materials) {
materials = materials.map((material) => {
if (material instanceof flock.BABYLON.Material) {
return material;
} else {
material = flock.createMaterial(material);
material.metadata = material.metadata || {};
material.metadata.internal = true;
return material;
}
});
setMaterial(meshName, materials, options = {}) {
const normalizedMaterials = Array.isArray(materials)
? materials.map((material) => {
if (material instanceof flock.BABYLON.Material) {
return material;
} else {
material = flock.createMaterial(material);
material.metadata = material.metadata || {};
material.metadata.internal = true;
return material;
}
})
: [];

const tasks = [];
tasks.push(flock.setMaterialInternal(meshName, normalizedMaterials, options));

tasks.push(
flock.whenModelReady(meshName, (mesh) => {
if (flock.materialsDebug) console.log(mesh.metadata.clones);
const clonePromises =
mesh.metadata?.clones?.map((cloneName) =>
flock.setMaterialInternal(cloneName, normalizedMaterials, options),
) || [];
return Promise.all(clonePromises);
}),
);

flock.setMaterialInternal(meshName, materials);
flock.whenModelReady(meshName, (mesh) => {
if (flock.materialsDebug) console.log(mesh.metadata.clones);
mesh.metadata?.clones?.forEach((cloneName) => {
flock.setMaterialInternal(cloneName, materials);
});
});
return Promise.all(tasks);
},
setMaterialInternal(meshName, materials) {
setMaterialInternal(meshName, materials = [], { tileSize = null } = {}) {
return flock.whenModelReady(meshName, (mesh) => {
const allMeshes = [mesh].concat(mesh.getDescendants());
allMeshes.forEach((part) => {
if (part.material?.metadata?.internal) {
part.material.dispose();
}
});
const hasMaterials = Array.isArray(materials) && materials.length > 0;
const rootScale = resolveTextureTileScale(mesh);
const baseTile =
Number.isFinite(tileSize) && tileSize > 0
? tileSize
: Number.isFinite(mesh.metadata?.textureTileBaseSize)
? mesh.metadata.textureTileBaseSize
: Number.isFinite(mesh.metadata?.textureTileSize)
? mesh.metadata.textureTileSize
: DEFAULT_TILE_UNITS;

if (hasMaterials) {
allMeshes.forEach((part) => {
if (part.material?.metadata?.internal) {
part.material.dispose();
}
});

if (flock.materialsDebug)
console.log(`Setting material of ${meshName} to ${materials}:`);
const validMeshes = allMeshes.filter(
(part) => part instanceof flock.BABYLON.Mesh,
);
if (flock.materialsDebug)
console.log(`Setting material of ${meshName} to ${materials}:`);
const validMeshes = allMeshes.filter(
(part) => part instanceof flock.BABYLON.Mesh,
);

// Sort meshes alphabetically by name
const sortedMeshes = validMeshes.sort((a, b) =>
a.name.localeCompare(b.name),
);
// Sort meshes alphabetically by name
const sortedMeshes = validMeshes.sort((a, b) =>
a.name.localeCompare(b.name),
);

if (flock.materialsDebug)
console.log(`Setting material of ${sortedMeshes.length} meshes`);
sortedMeshes.forEach((part, index) => {
const material = Array.isArray(materials)
? materials[index % materials.length]
: materials;
if (flock.materialsDebug)
console.log(`Setting material of ${sortedMeshes.length} meshes`);
sortedMeshes.forEach((part, index) => {
const material = Array.isArray(materials)
? materials[index % materials.length]
: materials;

if (material instanceof flock.GradientMaterial) {
mesh.computeWorldMatrix(true);
if (material instanceof flock.GradientMaterial) {
mesh.computeWorldMatrix(true);

const boundingInfo = mesh.getBoundingInfo();
const boundingInfo = mesh.getBoundingInfo();

const yDimension = boundingInfo.boundingBox.extendSizeWorld.y;
const yDimension = boundingInfo.boundingBox.extendSizeWorld.y;

material.scale = yDimension > 0 ? 1 / yDimension : 1;
}
if (!(material instanceof flock.BABYLON.Material)) {
console.error(
`Invalid material provided for mesh ${part.name}:`,
material,
);
return;
}
material.scale = yDimension > 0 ? 1 / yDimension : 1;
}
if (!(material instanceof flock.BABYLON.Material)) {
console.error(
`Invalid material provided for mesh ${part.name}:`,
material,
);
return;
}

if (flock.materialsDebug)
console.log(`Setting material of ${part.name} to ${material.name}`);
// Apply the material to the mesh
part.material = material;
flock.adjustMaterialTilingToMesh(part, material);
if (flock.materialsDebug)
console.log(`Setting material of ${part.name} to ${material.name}`);
// Apply the material to the mesh
part.material = material;
const { base, effective } = computeEffectiveTile(part, baseTile, rootScale, {
neutralScale: !!part.metadata?.shapeType && Number.isFinite(tileSize),
});
flock.adjustMaterialTilingToMesh(part, material, effective, {
unitsAlreadyScaled: true,
baseTileOverride: base,
});
});
}

const targets = allMeshes.filter((part) => part instanceof flock.BABYLON.Mesh);
targets.forEach((part) => {
const { base, effective } = computeEffectiveTile(part, baseTile, rootScale, {
neutralScale: !!part.metadata?.shapeType && Number.isFinite(tileSize),
});
if (!base || !effective) return;
part.metadata.textureTileBaseSize = base;
part.metadata.textureTileSize = effective;
retilePrimitiveMesh(part, effective);
const material =
part.material ||
(part.getClassName?.() === "InstancedMesh"
? part.sourceMesh?.material
: null);
if (material) {
flock.adjustMaterialTilingToMesh(part, material, effective, {
unitsAlreadyScaled: true,
baseTileOverride: base,
});
}
});

if (mesh.metadata?.glow) {
Expand Down
28 changes: 28 additions & 0 deletions blocks/materials.js
Original file line number Diff line number Diff line change
Expand Up @@ -748,4 +748,32 @@ export function defineMaterialsBlocks() {
attachSetMaterialOnChange(this);
},
};

Blockly.Blocks["set_material_tile_size"] = {
init: function () {
this.jsonInit({
type: "set_material_tile_size",
message0: translate("material_tile_size"),
args0: [
{
type: "field_variable",
name: "MESH",
variable: window.currentMesh,
},
{
type: "input_value",
name: "TILE_SIZE",
check: "Number",
},
],
previousStatement: null,
nextStatement: null,
inputsInline: true,
colour: categoryColours["Materials"],
tooltip: getTooltip("material_tile_size"),
});
this.setHelpUrl(getHelpUrlFor(this.type));
this.setStyle("materials_blocks");
},
};
}
2 changes: 2 additions & 0 deletions flock.js
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,8 @@ export const flock = {
changeColorMesh: this.changeColorMesh?.bind(this),
changeMaterial: this.changeMaterial?.bind(this),
setMaterial: this.setMaterial?.bind(this),
setMaterialTileSize:
this.setMaterialTileSize?.bind(this),
createMaterial: this.createMaterial?.bind(this),
moveForward: this.moveForward?.bind(this),
moveSideways: this.moveSideways?.bind(this),
Expand Down
Loading