Files
last-hearth/scripts/flora-art.mjs
T
2026-11-05 09:57:11 -05:00

251 lines
13 KiB
JavaScript
Executable File

// Original static broadleaf sculptures. Pure, bounded, deterministic; no MTL,
// external assets, random state or filesystem access. Ten model units per node.
import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from './world-art.mjs';
// Deprecated compatibility exports only. New trees are built from cube nodes;
// do not spend geometry on new OBJ files or infer runtime usage from this list.
export const LEGACY_MESH_SPECIES = Object.freeze(['oak','birch','pine','willow','alder']);
export const SPECIES_MESH_NAMES = Object.freeze(LEGACY_MESH_SPECIES.flatMap(s=>[
`${s}_seedling`,`${s}_young`,`${s}_mature`,...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]));
export const FLORA_NAMES = Object.freeze(['tree_seedling', 'young_tree', 'mature_tree', 'deadwood',
'young_tree_winter', 'mature_tree_winter',...SPECIES_MESH_NAMES]);
export const TREE_SEASONS = Object.freeze(['summer','spring','autumn','winter']);
export const SPECIES_ATLAS_NAMES = Object.freeze(SPECIES_NAMES.flatMap(s=>[
`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(season=>`${s}_atlas_${season}`))]));
const sub = (a,b) => a.map((n,i)=>n-b[i]);
const cross = (a,b) => [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const dot = (a,b) => a.reduce((s,n,i)=>s+n*b[i],0);
const unit = a => a.map(n=>n/Math.hypot(...a));
const directions = [[1,0],[Math.SQRT1_2,Math.SQRT1_2],[0,1],[-Math.SQRT1_2,Math.SQRT1_2],
[-1,0],[-Math.SQRT1_2,-Math.SQRT1_2],[0,-1],[Math.SQRT1_2,-Math.SQRT1_2]];
const number = n => Number(n.toFixed(6));
class Sculpture {
vertices=[]; uv=[]; normals=[]; faces=[]; groups=[];
solid(name, points, triangles, material) {
const center=[0,1,2].map(i=>points.reduce((s,p)=>s+p[i],0)/points.length);
const start=this.vertices.length;
this.groups.push({name, start, count:points.length, faceStart:this.faces.length});
this.vertices.push(...points.map(p=>p.map(number)));
// Each face has an explicit flat normal and its own inset UV triangle.
// OBJ V is bottom-up; both halves use the full 32px material height.
const u0=(material==='bark'?0.5:32.5)/64, u1=(material==='bark'?31.5:63.5)/64;
for(let ids of triangles) {
ids=[...ids];
let [a,b,c]=ids.map(i=>this.vertices[start+i]);
let normal=cross(sub(b,a),sub(c,a));
if(dot(normal,sub(a,center))<0) {
[ids[1],ids[2]]=[ids[2],ids[1]];
[a,b,c]=ids.map(i=>this.vertices[start+i]); normal=cross(sub(b,a),sub(c,a));
}
if(Math.hypot(...normal)<1e-8) throw new Error('Degenerate flora triangle');
const uvStart=this.uv.length;
this.uv.push([u0,0.5/32],[u1,0.5/32],[(u0+u1)/2,31.5/32]);
this.normals.push(unit(normal).map(number));
this.faces.push(ids.map((id,i)=>[start+id+1,uvStart+i+1,this.normals.length]));
}
}
branch(name, a,b,r0,r1) {
const axis=unit(sub(b,a)), side=unit(cross(axis,Math.abs(axis[1])<.9?[0,1,0]:[0,0,1]));
const up=cross(axis,side), points=[];
for(const [center,r] of [[a,r0],[b,r1]]) for(const [x,z] of directions)
points.push(center.map((n,i)=>n+r*(side[i]*x+up[i]*z)));
// A leaning stem still seats on a horizontal node-bottom cut, not below it.
if(a[1]===-5) for(let i=0;i<8;i++) points[i][1]=-5;
const triangles=[];
for(let i=0;i<8;i++) {
const j=(i+1)%8;
triangles.push([i,j,8+j],[i,8+j,8+i]);
}
for(let i=1;i<7;i++) triangles.push([0,i+1,i],[8,8+i,8+i+1]);
this.solid(name,points,triangles,'bark');
}
crown(name, center, radii, phase=0, shape='broad') {
// Three uneven eight-sided rings and off-axis tips make a lobed volume,
// not a cube, crossed plane or radially identical stack of spheres.
const profile=[1,.88,.94,1,.9,.98,1,.86], points=[];
const rings=shape==='conifer'?[[-.65,.88],[-.4,1],[.25,.48]]:[[-.55,.72],[0,1],[.55,.65]];
for(const [level,width] of rings) directions.forEach(([x,z],i)=> {
const bulge=profile[(i+phase)%8];
points.push([center[0]+x*radii[0]*width*bulge,center[1]+level*radii[1],center[2]+z*radii[2]*width*bulge]);
});
points.push([center[0]-.12*radii[0],center[1]-radii[1],center[2]+.08*radii[2]],
[center[0]+.08*radii[0],center[1]+radii[1],center[2]-.1*radii[2]]);
const triangles=[];
for(let i=0;i<8;i++) {
const j=(i+1)%8;
triangles.push([24,j,i],[25,16+i,16+j]);
for(let ring=0;ring<2;ring++) {
const k=ring*8; triangles.push([k+i,k+j,k+8+j],[k+i,k+8+j,k+8+i]);
}
}
this.solid(name,points,triangles,'leaf');
}
obj() {
const lines=['# Original Last Hearth volumetric ecology; 10 units/node; ground Y=-5','s off'];
for(const v of this.vertices) lines.push(`v ${v.join(' ')}`);
for(const t of this.uv) lines.push(`vt ${t.join(' ')}`);
for(const n of this.normals) lines.push(`vn ${n.join(' ')}`);
for(let i=0;i<this.faces.length;i++) {
const group=this.groups.find(g=>g.faceStart===i);
if(group) lines.push(`g ${group.name}`);
lines.push(`f ${this.faces[i].map(p=>p.join('/')).join(' ')}`);
}
return lines.join('\n')+'\n';
}
}
// Every species has an authored branching architecture, not a rescaled generic
// crown. Mature bounds fit the ecosystem's ±1.5 by five-node clearance. Young
// forms are half-size around ground Y=-5, hence ≤±.75 by 2.5 nodes.
function speciesSculpture(species,stage,winter) {
const m=new Sculpture(), conifer=species==='pine';
if(stage==='seedling') {
const index=SPECIES_NAMES.indexOf(species), lean=(index-2)*.22;
m.branch('stem',[0,-5,0],[lean,.8+index*.2,.2],.22,.07);
m.branch('twig',[0,-2.5,0],[-1.3,-.2,index*.2],.12,.04);
if(conifer) for(let i=0;i<3;i++)
m.crown(`leaf_tier_${i}`,[0,-1.7+i*1.6,0],[2-i*.5,1.6,1.9-i*.5],i,'conifer');
else {
m.crown('leaf_left',[-1.2,-.4,index*.15],[1.3,1.2,1.1],index);
m.crown('leaf_right',[.9,.7+index*.2,-.3],[1.2,conifer?2:1.5,1],index+2);
}
return m;
}
let limbs,clusters;
if(species==='oak') {
m.branch('trunk',[0,-5,0],[.7,23,.4],1.65,.65);
limbs=[[[0,8,0],[-9,24,2]],[[.2,12,0],[9,27,-2]],[[.5,18,0],[-2,35,3]],
[[.3,13,0],[2,25,-9]],[[.3,15,.2],[3,27,9]]];
clusters=[[[-8,26,2],[6.7,7,6]],[[8,28,-2],[6.3,7,6]],[[0,35,2],[7,8.5,6.7]],
[[1,26,-8],[6,6.5,6]],[[3,28,8],[6.2,6,6.4]],[[0,28,0],[6,7,6]]];
} else if(species==='birch') {
m.branch('trunk',[0,-5,0],[1.2,38,-.7],.85,.22);
limbs=[[[.3,16,0],[-4.8,27,1]],[[.6,23,-.2],[5.3,33,-2]],
[[1,29,-.5],[-3,38,2.4]],[[.5,21,-.3],[1.5,29,-5]]];
clusters=[[[-4.5,29,1],[3.6,5.3,3.8]],[[5.2,34,-2],[3.5,5,3.3]],
[[-3,38,2],[3.5,5.3,3.5]],[[1,40,-.8],[3.2,4,3]],[[1,30,-5],[3.5,5,3.3]],
[[2,25,4],[3.3,4,3.4]]];
} else if(conifer) {
m.branch('trunk',[0,-5,0],[.25,43,.1],1.05,.08);
limbs=[];
for(let i=0;i<4;i++) for(const [j,[x,z]] of [[-1,.2],[.6,.8],[.4,-1]].entries()) {
const length=10-i*2.2, y=8+i*8;
limbs.push([[0,y,0],[x*length,y+1,z*length]]);
// Separate tapered whorls, daylight gaps, and drooped lower skirts.
m.crown(`foliage_tier_${i}_${j}`,[x*length*.45,y+3,z*length*.45],
[length*.68,5.8-i*.3,length*.64],i+j,'conifer');
}
m.crown('foliage_leader',[.2,39,.1],[3.3,5.3,3.1],2,'conifer');
clusters=[];
} else if(species==='willow') {
m.branch('trunk',[0,-5,0],[-1,20,.7],1.4,.75);
limbs=[[[0,7,0],[-9,27,1]],[[0,10,0],[9,29,-2]],[[0,14,0],[0,34,3]],
[[0,12,0],[1,26,-9]],[[0,13,0],[3,26,9]]];
clusters=[[[-7,28,1],[6,5,6]],[[7,30,-2],[6,5,6]],[[0,34,2],[6,6,5]],
[[0,27,-7],[6,5,6]],[[3,28,7],[6,5,6]]];
for(const [i,[x,y,z]] of [[-11,23,1],[11,25,-2],[-5,24,-10],[3,23,11],[8,24,8],[-9,23,8]].entries()) {
m.branch(`pendant_${i}`,[x*.75,y+3,z*.75],[x,y-11,z],.17,.045);
if(!winter) m.crown(`foliage_curtain_${i}`,[x,y-4,z],[2.5,9,2.3],i);
}
} else { // Alder: upright multiple leaders and a compact rounded riparian crown.
m.branch('trunk',[0,-5,0],[.3,18,.2],1.15,.5);
limbs=[[[0,2,0],[-5,27,1]],[[0,5,0],[5.8,28,-1]],[[0,10,0],[0,32,3]],
[[0,9,0],[1,25,-5]]];
clusters=[[[-5,27,1],[5,7,4.7]],[[5.5,28,-1],[5,7,5]],[[0,32,3],[5.5,7,5]],
[[1,26,-5],[5,6,5]],[[0,24,1],[6,6,5]]];
}
limbs.forEach(([a,b],i)=> {
m.branch(`bough_${i}`,a,b,conifer?.28:species==='birch'?.3:.6,conifer?.08:.15);
if(!conifer) {
const start=a.map((n,j)=>n+(b[j]-n)*.7), sign=Math.sign(b[0]) || (i%2?-1:1);
m.branch(`twig_${i}`,start,[b[0]+sign*1.8,b[1]+1,b[2]+(i%2?-1.5:1.5)],.15,.035);
}
});
if(!winter) clusters.forEach(([c,r],i)=>m.crown(`foliage_${i}`,c,r,i+SPECIES_NAMES.indexOf(species)));
if(stage==='young') m.vertices=m.vertices.map(([x,y,z])=>[number(x*.5),number((y+5)*.5-5),number(z*.5)]);
return m;
}
function sculpture(name) {
if(!FLORA_NAMES.includes(name)) throw new Error('Unknown flora mesh');
const match=name.match(/^(oak|birch|pine|willow|alder)_(seedling|young|mature)(_winter)?$/);
if(match) return speciesSculpture(match[1],match[2],Boolean(match[3]));
const m=new Sculpture();
if(name==='deadwood') {
m.branch('fallen_trunk',[0,-3.4,-8],[.2,-3.4,8],1.6,1.3);
m.branch('broken_left',[0,-3.1,-2],[-3.9,-1.4,-4.8],.6,.24);
m.branch('broken_right',[.1,-3,2],[3.9,-.6,4.7],.55,.2);
m.branch('fork',[2.2,-1.7,3.5],[3.3,-1.2,6.6],.3,.12);
return m;
}
if(name==='tree_seedling') {
m.branch('stem',[0,-5,0],[.12,.1,-.05],.22,.09);
m.branch('twig', [0,-2.8,0],[-1.2,-1,0],.12,.06);
m.crown('leaf_left',[-1.15,-1.1,.2],[1.5,1.4,1.25]);
m.crown('leaf_right',[1,-.1,-.35],[1.5,2.1,1.4],2);
return m;
}
const winter=name.endsWith('_winter'), mature=name.startsWith('mature_tree');
m.branch('trunk',[0,-5,0],[.15,mature?15:7,.1],mature?1.2:.85,mature?.7:.35);
const limbs=mature ? [
[[0,5,0],[-5,16,1],.75,.28],[[0,8,0],[5.7,19,-1],.65,.24],
[[0,11,0],[-1,23,2.5],.55,.2],[[0,8,0],[1,17,-5],.55,.2],
] : [
[[0,0,0],[-2.8,6,.5],.36,.13],[[0,2,0],[3,8,-.4],.3,.12],
[[0,4,0],[0,10,1.2],.24,.09],
];
limbs.forEach(([a,b,r0,r1],i)=>m.branch(`bough_${i}`,a,b,r0,r1));
if(winter) {
// Preserve every original trunk/bough vertex, UV and normal. Add woody
// lateral forks along the boughs, never a recoloured canopy or leaf planes.
limbs.forEach(([a,b],i)=> {
const start=a.map((n,j)=>n+(b[j]-n)*.62), sign=Math.sign(b[0]-a[0]) || (i%2?-1:1);
const tip=[b[0]+sign*(mature?2.2:1.3),b[1]-.8,b[2]+sign*(mature?1.8:1)];
m.branch(`winter_fork_${i}`,start,tip,mature?.22:.11,mature?.06:.035);
});
return m;
}
const clusters=mature ? [
[[-5,16,1],[6,6,6]],[[5,19,-1],[6,6,6]],[[0,23,1],[6,7,6]],
[[0,16,-5],[6,5.5,6]],[[1,15,5],[6,5,6]],
] : [
[[-2.5,6,.5],[4,4,4]],[[2.5,8,-.4],[4,4.5,4]],
[[0,10.5,1],[3.5,4.5,3.5]],[[.3,6,-2],[3.5,3.5,4]],
];
clusters.forEach(([c,r],i)=>m.crown(`foliage_${i}`,c,r,i*2));
return m;
}
export function floraOBJ(name) { return sculpture(name).obj(); }
export function floraBounds(name) {
const {vertices}=sculpture(name);
return {min:[0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10),
max:[0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)};
}
export function treeAtlas(art=worldArt(), season='summer', species=null) {
if(!TREE_SEASONS.includes(season)) throw new Error('Unknown tree season');
if(species!==null && !SPECIES_NAMES.includes(species)) throw new Error('Unknown tree species');
if(EVERGREEN_NAMES.includes(species) && season!=='summer') throw new Error('Conifer uses its evergreen summer atlas');
// Two 32px patches, no external MTL. Leaves keep a dark opaque underpaint:
// silhouette holes come from gaps between 3D lobes, never torn polygon faces.
const data=new Uint8Array(64*32*4);
const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`;
const underpaint=season==='summer' && species?
{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade',
crab_apple:'leafDark',wild_cherry:'leafDark',field_maple:'leafDark'}[species]:
{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
for(const [half,name] of [`${prefix}log_side`,leaves].entries()) {
const image=art.get(`hearth_${name}.png`);
if(!image || image.width!==32 || image.height!==32 || image.data.length!==4096)
throw new Error('Tree atlas requires 32px RGBA bark and leaves');
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const p=image.data.subarray((y*32+x)*4,(y*32+x+1)*4);
data.set(p[3]===255?p:[...PALETTE[underpaint],255],(y*64+half*32+x)*4);
}
}
return {width:64,height:32,data};
}