// 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;ig.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}; }