CREATORJD's picture
Publish MASSFRONT web 1.33.41 (part 2)
8e1d8e7 verified
Raw
History Blame Contribute Delete
239 kB
;
;
/* ============================================================================
MODEL LIBRARY — every unit, structure and world object as real geometry.
----------------------------------------------------------------------------
Models are welded from primitives and authored around the origin with their
feet on y=0, facing +X (which matches the sim's angle 0). A model's own
colours are near-neutral livery values; the per-instance tint multiplies
them, so the same mesh serves both armies and still shows its own panel
lines, shadow gaps and warning markings.
Turreted vehicles are two meshes — hull and turret — instanced separately so
the turret can track a target while the hull drives somewhere else, which is
the whole reason tanks read as tanks.
============================================================================ */
const C=(r,g,b)=>[r/255,g/255,b/255];
const MET =C(158,166,176), MET_D=C(96,104,114), MET_L=C(206,214,222);
const DARK =C(52,58,66), DARKER=C(32,36,42);
const TREAD =C(44,46,50), RUBBER=C(60,62,68);
const GLASS =C(120,200,255), LAMP =C(255,226,150);
const HOT =C(255,150,70), ENERGY=C(140,230,255);
const CONC =C(150,146,138), CONC_D=C(104,101,95);
const RUST =C(140,102,72), BONE =C(206,198,182);
/* Warm bone-amber, not leaf green. The reference material's whole character is
a pale amber plate field falling to an almost-black wet seam; a mid-green
base has neither the value range nor the warmth to get there, and no amount
of lighting recovers it. CLAW stays near-white so the tips read at 40px. */
const CHITIN=C(196,178,124), CHIT_D=C(96,86,58), CLAW=C(232,226,206);
/* Biological livery and flexible tissue never inherit vehicle plate or servo
textures. BIO_TEAM stays faction-tinted on chitin; BIO_LEG/BIO_MEM use an
organic relief and mark appendages for procedural secondary motion. */
const BIO_TEAM=C(198,174,220), BIO_LEG=C(148,132,88), BIO_MEM=C(150,138,96);
/* TEAM_* entries are the only surfaces that take the full faction colour:
hull flanks, turret cheeks, wing roots, roof panels. Keeping livery to a few
deliberate panels is what makes a unit read as "blue army" without erasing
its metal, glass and rubber. */
const TEAM_A=C(214,222,230), TEAM_B=C(160,170,182), TEAM_T=C(236,242,248);
/* Architectural palette. Structures are not vehicles: they want a wall
material and a roof material, both far calmer than hull plating. */
const WALL =C(176,180,188), WALL_D=C(124,128,136);
/* Anything painted SERVO is treated as a leg by the vertex stage and animates
through the walk cycle. Use it for shins, thighs, feet and hip rams only. */
const SERVO =C(132,138,148);
const ROOFC =C(150,154,160), ROOF_T=C(196,202,210);
/* Tower-only hard-surface palette. These references deliberately do not
replace MET/DARK globally: vehicles and legacy structures keep their current
finishes while defensive towers receive clearly separated armor, mechanism,
trim, livery, light, foundation and bore materials. */
const TWR_ARM=C(190,198,208), TWR_ARM_D=C(118,128,140);
const TWR_MACH=C(145,154,166), TWR_TRIM=C(220,226,234);
const TWR_COAT=C(68,76,88), TWR_TEAM=C(218,226,236);
const TWR_GLOW=C(92,214,255), TWR_PAD=C(116,120,128), TWR_BORE=C(13,15,18);
/* Landmark glowOk only passes MAT.TWR_GLOW. This brighter vertex is the
same tile, so HQ points read at command distance without a rail. */
const HQ_LAMP=C(236,250,255);
const LIT_WIN=C(255,230,170), COOL_WIN=C(170,230,255), FLICK_WIN=C(255,200,140);
const SHOP_WIN=C(255,245,220), NEON_CYAN=C(0,240,255), SOLAR_PANEL=C(35,50,80);
const COPPER_PAT=C(82,132,114), BRICK_RED=C(140,76,64);
const NOVA_MET=C(132,142,156), NOVA_CARB=C(44,48,56);
/* Stage S1 bespoke structure semantics. These are deliberately palette-local
selectors, rather than a renderer switch: HQ and Techlab can establish their
own material hierarchy now while the renderer still falls back to the live
V2 atlas. They are semantic-bake prototypes, NOT UV-authored map packs. */
const NOVA_HQ_ARM=C(152,158,166), NOVA_HQ_STRUCT=C(46,50,56);
const NOVA_HQ_ROOF=C(116,132,152), NOVA_HQ_GLASS=C(44,130,196);
const NOVA_RESEARCH_ARM=C(136,154,178), NOVA_RESEARCH_CORE=C(40,60,82);
const NOVA_RESEARCH_ROOF=C(104,122,144), NOVA_RESEARCH_GLASS=C(50,148,212);
/* Stage S2 keeps production landmarks as individual semantic packs. These are
palette selectors for the live V2 material atlas, not a claim that custom
UV-authored maps already exist. Their separation is intentional: when the
authored BaseAO/NRE/mask pack lands, it can replace just one landmark's
surfaces without accidentally recolouring another Nova building. */
const NOVA_FACTORY_ARM=C(132,154,180), NOVA_FACTORY_CORE=C(34,46,62);
const NOVA_FACTORY_ROOF=C(100,122,148), NOVA_FACTORY_GLASS=C(48,136,202);
const NOVA_REACTOR_ARM=C(124,150,176), NOVA_REACTOR_CORE=C(30,46,64);
const NOVA_REACTOR_TRIM=C(156,178,202), NOVA_REACTOR_GLOW=C(72,208,255);
const NOVA_AIR_DECK=C(58,76,100), NOVA_AIR_ARM=C(128,150,176);
const NOVA_AIR_ROOF=C(104,126,150), NOVA_AIR_GLASS=C(48,142,210);
const NOVA_FAB_ARM=C(130,152,176), NOVA_FAB_CORE=C(38,50,66);
const NOVA_FAB_ROOF=C(102,124,148), NOVA_FAB_GLASS=C(50,138,204);
/* Stage S3 extends the same isolated semantic treatment to real Nova economy
and support structures. These are still palette selectors for the live V2
material atlas, not a claim that bespoke UV-authored maps are complete. */
const NOVA_MEX_ARM=C(126,148,174), NOVA_MEX_CORE=C(34,50,68);
const NOVA_MEX_TRIM=C(160,180,202), NOVA_MEX_GLOW=C(22,70,92);
/* Service-deck plating. Its own palette entry so retiring it from a pad
cannot disturb TWR_COAT, which many other surfaces still use. */
const NOVA_DECK=C(111,119,131);
const NOVA_GEO_ARM=C(122,146,170), NOVA_GEO_CORE=C(30,46,62);
const NOVA_GEO_TRIM=C(154,178,202), NOVA_GEO_GLOW=C(76,210,255);
const NOVA_SILO_ARM=C(130,150,174), NOVA_SILO_CORE=C(36,48,62);
const NOVA_SILO_ROOF=C(104,124,148), NOVA_SILO_GLOW=C(64,196,248);
const NOVA_UPLINK_ARM=C(128,152,178), NOVA_UPLINK_CORE=C(32,48,66);
const NOVA_UPLINK_TRIM=C(162,184,208), NOVA_UPLINK_GLOW=C(70,210,255);
const NOVA_HARBOR_ARM=C(124,148,174), NOVA_HARBOR_CORE=C(32,48,66);
const NOVA_HARBOR_DECK=C(78,98,124), NOVA_HARBOR_GLASS=C(46,136,202);
/* Stage S4 gives the common Nova defenses distinct semantic palettes. These
remain live-V2 selectors, deliberately isolated per building family, so a
later authored bake can replace one asset without recolouring another. */
const NOVA_SENTINEL_ARM=C(138,160,186), NOVA_SENTINEL_CORE=C(36,48,64);
const NOVA_SENTINEL_TRIM=C(164,188,214), NOVA_SENTINEL_GLOW=C(72,212,255);
const NOVA_BUNKER_ARM=C(132,150,174), NOVA_BUNKER_CORE=C(34,44,58);
const NOVA_BUNKER_TRIM=C(156,176,200), NOVA_BUNKER_GLOW=C(68,198,246);
const NOVA_RAIL_ARM=C(144,166,194), NOVA_RAIL_CORE=C(30,42,60);
const NOVA_RAIL_TRIM=C(174,198,224), NOVA_RAIL_GLOW=C(84,224,255);
const DROP_CANOPY_GLASS=C(34,112,176); // dark pressure glazing stays blue under the overhead world light
const LEG_IRON=C(88,80,78), LEG_VENT=C(255,100,20);
const SYN_NANO_C=C(120,132,148), SYN_GOLD_C=C(212,175,55), SYN_FLOW=C(0,220,240);
const BROOD_MEMB=C(184,152,120);
const HEADLIGHT_C=C(255,250,220), TAILLIGHT_C=C(255,34,17);
/* Colour constants double as MATERIAL selectors: the builder tracks a current
material, and this wrapper sets it from whichever palette entry a primitive
was already using. That upgrades the entire model library to textured
surfaces without touching a single call site. */
const COL_MAT=new Map();
const COL_TEAM=new Set(); // palette entries painted in team livery
function bindMat(){
COL_MAT.set(MET,matResolve('hull')); COL_MAT.set(MET_D,matResolve('hull.dark')); COL_MAT.set(MET_L,matResolve('hull.trim'));
COL_MAT.set(DARK,matResolve('hull.dark'));COL_MAT.set(DARKER,matResolve('hull.dark'));
COL_MAT.set(TREAD,matResolve('hull.track')); COL_MAT.set(RUBBER,matResolve('hull.track'));
COL_MAT.set(GLASS,matResolve('glass.canopy')); COL_MAT.set(LAMP,matResolve('emissive.light'));
COL_MAT.set(HOT,matResolve('emissive.light')); COL_MAT.set(ENERGY,matResolve('emissive.energy'));
COL_MAT.set(CONC,MAT.CONC); COL_MAT.set(CONC_D,MAT.CONC);
COL_MAT.set(RUST,MAT.RUST); COL_MAT.set(BONE,MAT.CONC);
COL_MAT.set(CHITIN,matResolve('bio.shell')); COL_MAT.set(CHIT_D,matResolve('bio.shell')); COL_MAT.set(CLAW,matResolve('hull.trim'));
COL_MAT.set(BIO_TEAM,matResolve('bio.shell')); COL_MAT.set(BIO_LEG,matResolve('bio.limb')); COL_MAT.set(BIO_MEM,matResolve('bio.membrane'));
COL_MAT.set(TEAM_A,matResolve('hull')); COL_MAT.set(TEAM_B,matResolve('hull')); COL_MAT.set(TEAM_T,matResolve('hull.trim'));
COL_MAT.set(WALL,matResolve('structure.wall')); COL_MAT.set(WALL_D,matResolve('structure.wall'));
COL_MAT.set(SERVO,MAT.SERVO);
COL_MAT.set(ROOFC,matResolve('structure.roof')); COL_MAT.set(ROOF_T,matResolve('structure.roof'));
COL_MAT.set(TWR_ARM,MAT.TWR_ARMOR); COL_MAT.set(TWR_ARM_D,MAT.TWR_ARMOR);
COL_MAT.set(TWR_MACH,MAT.TWR_MACH); COL_MAT.set(TWR_TRIM,MAT.TWR_MACH);
COL_MAT.set(TWR_COAT,MAT.TWR_COAT); COL_MAT.set(TWR_TEAM,MAT.TWR_ARMOR);
COL_MAT.set(TWR_GLOW,MAT.TWR_GLOW); COL_MAT.set(HQ_LAMP,MAT.TWR_GLOW); COL_MAT.set(TWR_PAD,MAT.TWR_PAD);
COL_MAT.set(TWR_BORE,MAT.TWR_BORE);
COL_MAT.set(LIT_WIN,matResolve('window.warm')); COL_MAT.set(COOL_WIN,matResolve('window.cool'));
COL_MAT.set(FLICK_WIN,matResolve('window.flicker')); COL_MAT.set(SHOP_WIN,matResolve('window.shop'));
COL_MAT.set(NEON_CYAN,MAT.NEON_FACADE); COL_MAT.set(SOLAR_PANEL,matResolve('roof.solar'));
COL_MAT.set(COPPER_PAT,matResolve('roof.copper')); COL_MAT.set(BRICK_RED,MAT.BRICK_MASONRY);
COL_MAT.set(NOVA_MET,matResolve('faction.nova')); COL_MAT.set(NOVA_CARB,MAT.NOVA_CARBON);
/* HQ/Techlab own semantic surface sets until authored UV packs arrive.
Keeping the mappings local prevents a generic Nova-material edit from
accidentally changing every production building in the faction. */
COL_MAT.set(NOVA_HQ_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_HQ_STRUCT,MAT.TWR_COAT);
COL_MAT.set(NOVA_HQ_ROOF,matResolve('structure.roof')); COL_MAT.set(NOVA_HQ_GLASS,matResolve('glass.canopy'));
COL_MAT.set(NOVA_RESEARCH_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_RESEARCH_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_RESEARCH_ROOF,matResolve('structure.roof')); COL_MAT.set(NOVA_RESEARCH_GLASS,matResolve('glass.canopy'));
COL_MAT.set(NOVA_FACTORY_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_FACTORY_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_FACTORY_ROOF,matResolve('structure.roof')); COL_MAT.set(NOVA_FACTORY_GLASS,matResolve('glass.canopy'));
COL_MAT.set(NOVA_REACTOR_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_REACTOR_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_REACTOR_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_REACTOR_GLOW,matResolve('emissive.energy'));
COL_MAT.set(NOVA_AIR_DECK,matResolve('structure.roof')); COL_MAT.set(NOVA_AIR_ARM,matResolve('faction.nova'));
COL_MAT.set(NOVA_AIR_ROOF,matResolve('structure.roof')); COL_MAT.set(NOVA_AIR_GLASS,matResolve('glass.canopy'));
COL_MAT.set(NOVA_FAB_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_FAB_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_FAB_ROOF,matResolve('structure.roof')); COL_MAT.set(NOVA_FAB_GLASS,matResolve('glass.canopy'));
COL_MAT.set(NOVA_MEX_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_MEX_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_MEX_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_MEX_GLOW,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_DECK,MAT.DECK_PLATE);
COL_MAT.set(NOVA_GEO_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_GEO_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_GEO_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_GEO_GLOW,matResolve('emissive.energy'));
COL_MAT.set(NOVA_SILO_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_SILO_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_SILO_ROOF,matResolve('structure.roof')); COL_MAT.set(NOVA_SILO_GLOW,matResolve('emissive.energy'));
COL_MAT.set(NOVA_UPLINK_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_UPLINK_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_UPLINK_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_UPLINK_GLOW,matResolve('emissive.energy'));
COL_MAT.set(NOVA_HARBOR_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_HARBOR_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_HARBOR_DECK,matResolve('structure.roof')); COL_MAT.set(NOVA_HARBOR_GLASS,matResolve('glass.canopy'));
COL_MAT.set(NOVA_SENTINEL_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_SENTINEL_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_SENTINEL_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_SENTINEL_GLOW,matResolve('emissive.energy'));
COL_MAT.set(NOVA_BUNKER_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_BUNKER_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_BUNKER_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_BUNKER_GLOW,matResolve('emissive.energy'));
COL_MAT.set(NOVA_RAIL_ARM,matResolve('faction.nova')); COL_MAT.set(NOVA_RAIL_CORE,MAT.NOVA_CARBON);
COL_MAT.set(NOVA_RAIL_TRIM,matResolve('hull.trim')); COL_MAT.set(NOVA_RAIL_GLOW,matResolve('emissive.energy'));
COL_MAT.set(DROP_CANOPY_GLASS,matResolve('glass.canopy'));
COL_MAT.set(LEG_IRON,matResolve('faction.legion')); COL_MAT.set(LEG_VENT,MAT.LEGION_THERMITE);
COL_MAT.set(SYN_NANO_C,matResolve('faction.syndicate')); COL_MAT.set(SYN_GOLD_C,MAT.SYN_GOLD);
COL_MAT.set(SYN_FLOW,matResolve('emissive.energy')); COL_MAT.set(BROOD_MEMB,matResolve('bio.membrane'));
COL_MAT.set(HEADLIGHT_C,MAT.UNIT_HEADLIGHT); COL_MAT.set(TAILLIGHT_C,MAT.UNIT_TAILLIGHT);
}
bindMat();
COL_TEAM.add(TEAM_A); COL_TEAM.add(TEAM_B); COL_TEAM.add(TEAM_T);
COL_TEAM.add(TWR_TEAM);
COL_TEAM.add(BIO_TEAM);
/* A named role is preferred, but legacy models still pass palette colours.
The fallback heuristics prevent an unregistered pale cyan face from becoming
dull steel when an experimental model is added before its palette binding. */
function matDetect(col,role){
if(role) return matResolve(role);
const known=COL_MAT.get(col); if(known!=null) return known;
if(!Array.isArray(col)) return MAT.PLATE;
const r=col[0]||0,g=col[1]||0,b=col[2]||0;
if(b>r*1.35&&b>g*1.08&&b>.42) return matResolve('glass');
if(r>.82&&g>.82&&b>.82) return matResolve('hull.trim');
if(r<.24&&g<.24&&b<.24) return matResolve('hull.dark');
return matResolve('hull');
}
/* Patch the builder so every legacy primitive picks its material from its
palette while new factories can request semantic material roles directly. */
for(const fn of ['box','cyl','sphere','wedge','extrude','ring','bevelBox','inset','tube','greeble']){
const orig=MeshBuilder.prototype[fn];
MeshBuilder.prototype[fn]=function(...args){
/* The colour argument sits at a different index per primitive. Scan from
the end: position vectors come first, while colour is always the last
normalized RGB tuple before optional yaw/flags. This lets new palette
colours get a sensible material fallback instead of silently inheriting
the previous part's material. */
for(let k=args.length-1;k>=0;k--){
const a=args[k];
if(Array.isArray(a)&&a.length===3&&a.every(v=>typeof v==='number'&&v>=0&&v<=1)){
this.m=matDetect(a); this.tm=COL_TEAM.has(a); break;
}
}
return orig.apply(this,args);
};
}
/* Tread bank: two blocks with road wheels between them. Repeated on nearly
every ground vehicle, so it earns its own function. */
function treads(m,len,wid,h,gauge,wheels){
for(const s of [-1,1]){
m.box(0,0,s*gauge,len,h,wid,TREAD);
m.box(0,h*0.34,s*gauge,len*1.03,h*0.34,wid*1.16,DARKER); // track links
const n=wheels||4;
for(let k=0;k<n;k++){
const x=-len/2+len*(k+0.5)/n;
m.cyl(x,h*0.5,s*gauge,h*0.42,h*0.42,wid*0.5,7,RUBBER);
}
}
}
/* Rectangular hull with chamfered nose — the base of most vehicles. */
function hullPlate(m,len,wid,h,y,col){
m.extrude(0,y,0,[[-len*0.5,-wid*0.5],[len*0.30,-wid*0.5],[len*0.5,-wid*0.26],
[len*0.5,wid*0.26],[len*0.30,wid*0.5],[-len*0.5,wid*0.5]],h,col);
}
function barrel(m,x,y,z,len,r,col){
m.cyl(x,y,z,r,r*0.86,0,8,col); // placeholder (kept for clarity)
}
/* Append a temporary +Y primitive after rotating it onto +X. Keeping this in
one helper matters: positions AND normals must move while UV/material data
stays untouched, and every index must be offset by the receiving mesh. */
function mergeAxisX(m,mm,x,y,z){
for(let i=0;i<mm.v.length;i+=VFLOATS){
const px=mm.v[i], py=mm.v[i+1], pz=mm.v[i+2];
mm.v[i]=py+x; mm.v[i+1]=-px+y; mm.v[i+2]=pz+z;
const nx=mm.v[i+3], ny=mm.v[i+4];
mm.v[i+3]=ny; mm.v[i+4]=-nx;
}
const o=m.n;
for(let i=0;i<mm.v.length;i++) m.v.push(mm.v[i]);
for(const ix of mm.i) m.i.push(ix+o);
m.n+=mm.n;
/* Match a native primitive call: the colour wrapper also leaves the active
material/team state on the receiving builder for any direct faces next. */
m.m=mm.m; m.tm=mm.tm;
return m;
}
/* Cylinders and hollow tubes along the weapon-forward +X axis. The native
primitives stand on +Y, which made callers that passed a yaw build antennae
instead of barrels. These explicit helpers make that mistake impossible. */
function cylX(m,x,y,z,len,r1,r2,seg,col,cap){
const mm=MB(); mm.m=m.m; mm.tm=m.tm;
mm.cyl(0,0,0,r1,r2,len,seg,col,cap);
return mergeAxisX(m,mm,x,y,z);
}
function tubeX(m,x,y,z,len,rOut,rIn,seg,col){
const mm=MB(); mm.m=m.m; mm.tm=m.tm;
mm.tube(0,0,0,rOut,rIn,len,seg,col);
return mergeAxisX(m,mm,x,y,z);
}
function ringX(m,x,y,z,r0,r1,seg,col){
const mm=MB(); mm.m=m.m; mm.tm=m.tm;
mm.ring(0,0,0,r0,r1,seg,col);
return mergeAxisX(m,mm,x,y,z);
}
/* A gun barrel pointing along +X, with an optional lateral offset. */
function gunX(m,x0,y,len,r,col,z){
const seg=8, zz=z||0;
/* No front cap: the following tube exposes a real shadowed bore instead of
painting a dark circle over a flat barrel end. */
cylX(m,x0,y,zz,len,r,r*0.88,seg,col,false);
m.box(x0+len*0.92,y-r*1.25,zz,len*0.16,r*2.5,r*2.6,col); // muzzle brake
tubeX(m,x0+len-.10,y,zz,Math.max(.48,r*.82),r*1.18,r*.56,10,TWR_BORE);
return m;
}
/* ============================================================================
DETAIL KIT — the vocabulary that makes a hull look manufactured
----------------------------------------------------------------------------
Reference points are C&C3 Tiberium Wars and Supreme Commander 2: heavy
faceted armour with visible plate breaks, chamfered edges everywhere, and a
dense scatter of small functional hardware — vents, hydraulics, sensor
masts, stowage, tow points, exhaust banks. Neither game relies on texture
for that read; it is all silhouette and surface break-up, which is exactly
what survives at command-view zoom.
Everything below is a compound of primitives so a unit can be assembled from
twenty-odd recognisable parts instead of six boxes.
============================================================================ */
/* Faceted armour hull: an N-sided tapered shell rather than a box. Two stacked
extrusions with the upper one inset give the plate break that reads as
layered armour from above. */
function hullShell(m,len,wid,hLow,hHigh,col,colTop,taperNose,taperTail){
const tn=taperNose===undefined?0.42:taperNose, tt=taperTail===undefined?0.62:taperTail;
const L=len*0.5, W=wid*0.5;
const prof=[
[-L, -W*tt],
[-L*0.55, -W],
[ L*0.42, -W],
[ L, -W*tn],
[ L, W*tn],
[ L*0.42, W],
[-L*0.55, W],
[-L, W*tt],
];
m.extrude(0,0,0,prof,hLow,col);
const k=0.80;
m.extrude(0,hLow,0,prof.map(p=>[p[0]*k,p[1]*k]),hHigh,colTop||col);
return m;
}
/* Angled armour plate with a lip — spaced armour, skirts, glacis add-ons. */
function armorPlate(m,x,y,z,w,h,thick,tilt,col,yaw){
const c=Math.cos(yaw||0), s=Math.sin(yaw||0);
m.box(x,y,z,w,h,thick,col,yaw||0);
m.box(x - Math.sin(yaw||0)*0, y+h, z, w*0.96, thick*0.7, thick*1.9, col, yaw||0);
for(const o of [-0.34,0.34]){ // bolt strip down the plate
const ox=w*o;
m.box(x+ox*c, y+h*0.5, z+ox*s, thick*0.9, h*0.7, thick*0.9, col, yaw||0);
}
return m;
}
/* Hydraulic ram: a pair of polished pistons in a housing. Legs, elevation
gear, suspension arms — anything that should look like it moves. */
function hydraulic(m,x,y,z,len,r,col,yaw){
const c=Math.cos(yaw||0), s=Math.sin(yaw||0);
m.cyl(x,y,z,r,r*0.95,len*0.6,8,col);
m.cyl(x,y+len*0.6,z,r*0.55,r*0.55,len*0.45,8,MET_L); // bright ram
m.cyl(x,y+len*0.55,z,r*1.18,r*1.18,len*0.10,8,DARKER); // gland nut
return m;
}
/* Louvred vent bank — engine decks, radiator faces, intake grilles. */
function ventBank(m,x,y,z,w,d,n,col,yaw){
const c=Math.cos(yaw||0), s=Math.sin(yaw||0);
m.inset(x,y,z,w,d,0.36,0.10,DARKER,yaw||0);
for(let k=0;k<n;k++){
const t=(k+0.5)/n-0.5, oz=t*d;
m.box(x - oz*s, y+0.22, z + oz*c, w*0.92, 0.28, d/n*0.45, col, yaw||0);
}
return m;
}
/* Sensor mast: pole, dish and a blade antenna. The single most effective way
to break a flat roofline and make a vehicle look crewed. */
function sensorMast(m,x,y,z,h,col){
m.cyl(x,y,z,0.20,0.14,h,6,DARKER);
m.cyl(x,y+h,z,0.9,0.22,0.18,9,col); // small dish
m.box(x,y+h*0.55,z,0.10,h*0.42,0.5,MET_L); // blade antenna
/* HQ_LAMP is TWR_GLOW, so landmark glowOk keeps the point. LAMP here
was a leftover orange cube that failed the gate on HQ and bloomed
on every factory/defense mast. */
m.cyl(x,y+h+0.22,z,.16,.16,.18,8,HQ_LAMP);
return m;
}
/* Exhaust bank: stacks with hollow bores and a heat shroud. */
function exhaust(m,x,y,z,n,r,h,col,spread){
for(let k=0;k<n;k++){
const oz=(k-(n-1)/2)*(spread||r*2.6);
m.cyl(x,y,z+oz,r*1.25,r*1.15,h*0.35,8,DARKER); // shroud
m.cyl(x,y+h*0.3,z+oz,r,r*0.92,h*0.7,8,col);
m.tube(x,y+h*0.98,z+oz,r*0.92,r*0.5,h*0.16,8,DARKER);
}
return m;
}
/* Stowage: tarps, crates and spare track links strapped to a hull. Clutter is
what stops a large flat panel reading as an untextured slab. */
function stowage(m,x,y,z,len,wid,col,yaw,seed){
let sd=seed||7;
const rr2=()=>{ sd=(sd*1664525+1013904223)&0x7fffffff; return (sd>>>10)/2097152; };
const c=Math.cos(yaw||0), s=Math.sin(yaw||0);
for(let k=0;k<4;k++){
const t=(k+0.5)/4-0.5, px=t*len, pz=(rr2()-0.5)*wid*0.6;
const bw=len/4*(0.55+rr2()*0.35), bh=0.5+rr2()*0.7, bd=wid*(0.3+rr2()*0.3);
m.bevelBox(x+px*c-pz*s, y, z+px*s+pz*c, bw,bh,bd, Math.min(bw,bd)*0.22, col, yaw||0);
}
return m;
}
/* Ammunition / equipment box with a lid lip and latches. */
function kitBox(m,x,y,z,w,h,d,col,yaw){
m.bevelBox(x,y,z,w,h,d,Math.min(w,d)*0.14,col,yaw||0);
m.box(x,y+h,z,w*1.04,h*0.14,d*1.04,DARKER,yaw||0);
for(const o of [-0.3,0.3]) m.box(x+w*o*Math.cos(yaw||0),y+h*0.55,z+w*o*Math.sin(yaw||0),
w*0.08,h*0.5,d*1.06,MET_L,yaw||0);
return m;
}
/* SupCom-style emissive strip: a thin glowing line along a hull edge. */
/* ---------------------------------------------------------------------------
ROOF EDGE / PARAPET
A roof that ends in a bare chamfer has nothing for the light to catch, so
from overhead the building's outline dissolves into the ground. A raised lip
with a darker reveal under it gives every roof a hard bright edge and a line
of shade beneath — the single cheapest piece of architectural detail there
is, and the one the eye uses to read a top-down building's extent.
--------------------------------------------------------------------------- */
function roofEdge(m,x,y,z,w,d,lip,col,trim){
const hw=w/2, hd=d/2, th=lip||1.6;
for(const s of [-1,1]){
m.box(x,y,z+s*(hd-th*0.5), w, th*1.5, th, col); // side parapets
m.box(x,y+th*1.5,z+s*(hd-th*0.5), w*0.995, th*0.36, th*1.25, trim||col);
m.box(x+s*(hw-th*0.5),y,z, th, th*1.5, d-th*2, col); // end parapets
m.box(x+s*(hw-th*0.5),y+th*1.5,z, th*1.25, th*0.36, d-th*2, trim||col);
}
}
function glowStrip(m,x,y,z,len,col,yaw){
m.box(x,y,z,len,0.22,0.34,col||ENERGY,yaw||0);
return m;
}
/* ---------------------------------------------------------------------------
DECK CROWN — roofEdge, but for vehicles.
roofEdge above exists because "from overhead the building's outline dissolves
into the ground". That is just as true of a tank, and nothing was doing it:
this camera looks almost straight down, so a hull's DECK is the surface a
player actually identifies a unit by, and an extrude cap gives it nothing to
catch light on. The raised rail draws a hard outline against terrain, the
nose chevrons state facing without needing a turret, and the rear kick plate
stops the back edge melting into the tracks. Trim is team colour and it lands
in the SAME PLACE on every chassis — that consistency is the other half of
the read, and it is why this is a shared helper and not per-model geometry.
--------------------------------------------------------------------------- */
function deckCrown(m,x,y,z,len,wid,col,trim){
const L=len*0.5, W=wid*0.5, th=Math.min(len,wid)*0.09;
for(const sd of [-1,1]){
m.box(x,y,z+sd*(W-th*0.5),len*0.94,th*0.85,th,col||MET_D); // deck rail
m.box(x,y+th*0.85,z+sd*(W-th*0.5),len*0.90,th*0.30,th*0.8,trim||MET_L); // bright reveal
m.box(x+L*0.44,y+th*0.2,z+sd*W*0.46,len*0.32,th*0.45,th*1.3,trim||MET_L,sd*0.44); // nose chevron
}
m.box(x-L*0.88,y,z,th,th*0.85,wid*0.88,col||MET_D); // rear kick plate
return m;
}
/* ============================================================================
HARD-SURFACE COMPONENT KIT
The roster's tell was never polygon count — it was that everything was built
from PRIMARY shapes: a box is a box, a cylinder is a cylinder, and the eye
reads that as a logo, not a machine. Real hardware is components: a wheel is
tire + rim + spokes + hub; a thruster is bell + throat + glow + gimbal; a
window is frame + mullions + glass. Each helper below builds the COMPONENT,
with the chamfers and material breaks already in it, so a model assembled
from these starts at "machine" instead of earning it one box at a time.
============================================================================ */
/* Chamfered cylinder — the single most-used hard-surface move. A straight
cylinder has a razor rim that catches no light; a 45-degree chamfer ring at
each end gives every drum, hub and housing a lit edge. */
function chamferCyl(m,x,y,z,r,h,seg,col,colRim,yaw){
const rim=Math.min(h*0.22,r*0.30);
m.cyl(x,y,z,r-rim,r,rim,seg,colRim||MET_L,yaw);
m.cyl(x,y+rim,z,r,r,h-rim*2,seg,col,yaw);
m.cyl(x,y+h-rim,z,r,r-rim,rim,seg,colRim||MET_L,yaw);
return m;
}
/* A real wheel: treaded tire, rim dish, spokes, hub cap. Axis is Z (side
mount) — the only orientation ground vehicles use. */
function wheelAsm(m,x,y,z,r,w,col){
const sd=z>=0?1:-1;
m.cyl(x,y,z,r,r,w,12,TREAD,Math.PI/2);
/* tread lugs around the face — reads even at 20px */
for(let k=0;k<8;k++){
const a=k/8*TAU;
m.box(x+Math.cos(a)*r*0.98,y+Math.sin(a)*r*0.98,z,w*0.9,r*0.22,r*0.20,DARKER,a);
}
m.cyl(x,y,z+sd*w*0.5,r*0.62,r*0.62,w*0.14,10,col||MET_L,Math.PI/2); // rim dish
for(let k=0;k<5;k++){
const a=k/5*TAU;
m.box(x+Math.cos(a)*r*0.34,y+Math.sin(a)*r*0.34,z+sd*w*0.56,r*0.5,r*0.16,w*0.10,MET_D,a);
}
m.cyl(x,y,z+sd*w*0.60,r*0.22,r*0.18,w*0.16,8,MET_D,Math.PI/2); // hub cap
return m;
}
/* Thruster: gimbal collar, bell that WIDENS toward the exit, recessed throat,
and a glow disc set inside the bell so the emissive reads as heat coming
from within, not paint on the back face. Points -X (exhaust rearward). */
function thrusterBell(m,x,y,z,r,len,col){
m.cyl(x,y,z,r*0.74,r*0.62,len*0.30,10,MET_D,Math.PI/2); // gimbal collar
m.cyl(x-len*0.30,y,z,r*0.62,r,len*0.70,12,col||MET_L,Math.PI/2); // bell (flares out)
m.tube(x-len,y,z,r*0.98,r*0.80,len*0.14,12,DARKER,Math.PI/2); // rim ring
m.cyl(x-len*0.86,y,z,r*0.72,r*0.72,len*0.10,10,HOT,Math.PI/2); // glow, recessed
return m;
}
/* Cockpit glazing: raised frame, glass panes, and mullions crossing them.
Glass with no frame reads as a blue sticker; the frame is what sells it. */
function canopyGlass(m,x,y,z,len,wid,hgt,col){
m.bevelBox(x,y,z,len,hgt*0.35,wid,0.24,col||MET_L); // sill
m.bevelBox(x,y+hgt*0.30,z,len*0.92,hgt*0.70,wid*0.88,0.30,GLASS); // panes
m.box(x,y+hgt*0.32,z,len*0.94,hgt*0.66,wid*0.10,col||MET_L); // centre mullion
m.box(x,y+hgt*0.32,z,len*0.10,hgt*0.66,wid*0.90,col||MET_L); // cross mullion
m.wedge(x+len*0.55,y+hgt*0.9,z,len*0.35,hgt*0.25,wid*0.9,col||MET_L,0,true); // brow
return m;
}
/* Recessed panel with a lip — the inner-extrusion move as a one-liner, for
flat faces that need a fabricated break without restructuring the hull. */
function insetPanel(m,x,y,z,len,wid,depth,col,yaw){
m.box(x,y,z,len,depth,wid,DARKER,yaw);
m.box(x,y+depth*0.55,z,len*0.86,depth*0.55,wid*0.86,col||MET_D,yaw);
return m;
}
/* Road wheels with return rollers and a drive sprocket — a proper running
gear instead of a plain slab, visible along the whole flank.
WEIGHT CLASS, NOT JUST SIZE. Every ground vehicle from a size-16 Lancer to a
size-26 Basilisk used the same proportions and the same wheel count, so a
light and a heavy were the identical machine viewed at two zoom levels — and
with nothing on screen to measure against, the player had no way to tell
which one was the expensive one. `cls` (0 light / 1 medium / 2 heavy) widens
the tracks, adds or removes a road wheel, and gives a heavy a second skirt
tier, so weight reads at a glance from the running gear alone. Defaults to
medium, which is what every existing caller was already getting. */
function runningGear(m,len,wid,h,gauge,wheels,col,cls){
const K=cls==null?1:cls;
wid*=[0.82,1.0,1.26][K]; wheels=(wheels||5)+[-1,0,1][K];
for(const s of [-1,1]){
m.box(0,0,s*gauge,len,h*0.86,wid,TREAD);
m.box(0,h*0.30,s*gauge,len*1.04,h*0.30,wid*1.20,DARKER); // track links
const n=wheels||5;
for(let k=0;k<n;k++){
const x=-len/2+len*(k+0.5)/n;
m.cyl(x,h*0.46,s*gauge,h*0.40,h*0.40,wid*0.52,8,RUBBER);
m.cyl(x,h*0.46,s*(gauge+wid*0.30),h*0.17,h*0.17,wid*0.12,7,MET_L); // hub
hydraulic(m,x,h*0.86,s*gauge*0.72,h*0.7,0.20,col||MET_D,0); // suspension arm
}
m.cyl( len*0.52,h*0.72,s*gauge,h*0.34,h*0.34,wid*0.55,9,MET_D); // drive sprocket
m.cyl(-len*0.52,h*0.72,s*gauge,h*0.30,h*0.30,wid*0.55,9,MET_D); // idler
for(let k=0;k<3;k++) // return rollers
m.cyl(-len*0.3+k*len*0.3,h*1.02,s*gauge,h*0.14,h*0.14,wid*0.42,7,MET_D);
if(K===2) armorPlate(m,0,h*1.3,s*(gauge+wid*0.5),len*0.94,h*0.8,0.42,0,col||MET_D,0);
}
return m;
}
/* ---------------------------------------------------------------------------
LEAN TRACK UNIT — the same read as runningGear(), for a quarter of the cost.
Measured, not guessed: runningGear() builds 1,136 / 1,680 / 2,320 triangles
for light / medium / heavy, and on a medium 840 of those are the per-wheel
hydraulic() suspension arms — three 8-segment cylinders each, hidden BEHIND
the track skirt where this camera never looks. A tier-1 unit's entire budget
is 1,400. That arithmetic is the real reason every tracked vehicle in the
roster ended up as a flat slab with a stick on it: there was nothing left to
spend on the part of the silhouette the player actually sees.
Same components, 6-segment wheels and flat arms: track run, links, road
wheels with hub faces, drive sprocket, idler, return run, and a chamfered
armour skirt. The livery stripe rides the TOP EDGE of that skirt, because
that edge is the one piece of running gear an overhead camera sees and the
deck is where a turret sits.
`cls` matches runningGear's contract (0 light / 1 medium / 2 heavy) so the
two are interchangeable at a call site.
--------------------------------------------------------------------------- */
function trackUnit(m,len,wid,h,gauge,wheels,col,cls,trim){
const K=cls==null?1:cls;
wid*=[0.84,1.0,1.24][K];
const n=Math.max(3,(wheels||5)+[-1,0,1][K]), C=col||MET_D;
for(const s of [-1,1]){
const z=s*gauge;
/* The run is the TOP and BOTTOM straps of the track, not a solid slab, so
the road wheels show through the gap between them — the single detail
that separates a track from a black rubber band at any zoom. */
m.box(0,0,z,len,h*0.20,wid,TREAD); // ground run
m.box(0,h*0.66,z,len*0.98,h*0.18,wid,TREAD); // return run
m.box(0,h*0.70,z,len*1.02,h*0.12,wid*1.16,DARKER); // links, proud of it
for(let k=0;k<n;k++){
const x=-len/2+len*(k+0.5)/n;
m.cyl(x,h*0.14,z,h*0.30,h*0.30,wid*0.56,6,RUBBER); // road wheel
m.box(x,h*0.30,z+s*wid*0.60,h*0.26,h*0.24,wid*0.14,MET_L); // hub face, outboard
}
m.cyl( len*0.50,h*0.30,z,h*0.40,h*0.40,wid*0.58,6,MET_D); // drive sprocket
m.cyl(-len*0.50,h*0.30,z,h*0.36,h*0.36,wid*0.58,6,MET_D); // idler
/* SKIRT. A chamfered plate spanning the upper run is what reads as armour
rather than as machinery, and its top edge is the only part of any
running gear an overhead camera sees — so the livery stripe lands there
and not on the deck, where a turret sits on top of it. */
m.bevelBox(0,h*0.86,z+s*wid*0.12,len*0.94,h*0.56,wid*0.86,h*0.16,C);
/* Two blocks, not a racing stripe down the whole skirt: livery has to be
readable AND deliberate, and a continuous band along both tracks is most
of the visible surface of the vehicle. */
for(const f of [-1,1])
m.box(f*len*0.28,h*1.42,z+s*wid*0.30,len*0.26,h*0.13,wid*0.26,trim||TEAM_A);
if(K===2) m.box(0,h*0.24,z+s*wid*0.62,len*0.88,h*0.40,wid*0.12,C); // heavy side plate
}
return m;
}
/* ---------------------------------------------------------------------------
GLACIS — a sloped nose plate that actually slopes forward.
MeshBuilder.wedge() ramps across its THIRD axis, so a wedge dropped in at
yaw 0 slopes SIDEWAYS. Every "glacis" authored in this library before this
helper is therefore a lateral A-frame on a hull whose armour was meant to
rake front-to-back, and it never got caught because a sloped shape still
looks sloped in a screenshot — you have to look at the SIDE elevation to see
that the nose is square. Yawing a quarter turn puts the ridge at the rear
and runs the slope down to the nose, which is what a glacis is.
Arguments are in vehicle axes: len along +X, wid across Z.
--------------------------------------------------------------------------- */
function glacisX(m,x,y,z,len,hgt,wid,col){
m.wedge(x,y,z,wid,hgt,len,col,Math.PI/2);
return m;
}
/* Layered turret: faceted body, mantlet, chamfered roof, bustle and hatch. */
function turretBody(m,len,wid,h,col,colTop){
const L=len*0.5, W=wid*0.5;
const prof=[[-L,-W*0.86],[-L*0.5,-W],[L*0.5,-W*0.9],[L,-W*0.44],
[L,W*0.44],[L*0.5,W*0.9],[-L*0.5,W],[-L,W*0.86]];
m.extrude(0,0,0,prof,h*0.72,col);
m.extrude(0,h*0.72,0,prof.map(p=>[p[0]*0.82,p[1]*0.80]),h*0.28,colTop||col);
m.box(L*0.86,h*0.30,0,len*0.16,h*0.62,wid*0.52,MET_D); // mantlet
return m;
}
/* ---------------------------------------------------------------------------
UNITS — indexed to match TYPES[]. Each entry returns {hull, turret?}.
Authored at roughly 10 world units per "size 10" so a unit's `size` field
scales it directly.
--------------------------------------------------------------------------- */
const UNIT_MESH=[]; // [type] = {hull:InstMesh, tur:InstMesh|null, s:scale}
const UNIT_GEO=[];
/* ============================================================================
INFANTRY — soldiers that look like soldiers.
The production tab says INFANTRY and the models were tiny mechs, which is a
promise broken twice: the player expects a trooper, and the battlefield
loses its scale reference — a tank only reads as big when a HUMAN stands
next to it. One shared builder, varied per kit, so every infantry unit is
the same species: helmet with a glass visor, armoured torso over a narrower
waist, pauldrons carrying the livery, a backpack, and a weapon held ACROSS
the body — the classic soldier read at any pixel size.
Legs are SERVO deliberately: the walk shader swings SERVO geometry below hip
height, so these troopers march with the same system that walks the mechs.
Arms and weapon stay rigid so the gun does not wobble. */
function mfTrooper(m,o){
o=o||{};
const H=o.h||8.6, W=o.w||1.0; // height, bulk factor
const hip=H*0.46, sh=H*0.74, hd=H*0.86;
/* legs — boot, shin, thigh, with a knee step between them */
for(const sd of [-1,1]){
m.bevelBox(0.25,0,sd*0.62*W,1.5*W,0.55,0.95*W,0.20,DARK); // boot
m.bevelBox(0.05,0.55,sd*0.62*W,0.80*W,hip*0.42,0.80*W,0.18,SERVO); // shin
m.cyl(0.05,hip*0.50,sd*0.62*W,0.46*W,0.46*W,0.5,7,MET_D,Math.PI/2); // knee
m.bevelBox(0.0,hip*0.55,sd*0.62*W,0.92*W,hip*0.48,0.92*W,0.20,SERVO); // thigh
m.bevelBox(0.0,hip*1.02,sd*0.62*W,1.0*W,0.5,1.0*W,0.16,MET_D); // hip guard
}
/* torso — armour OVER a waist, so there is a silhouette pinch */
m.bevelBox(0,hip,0,1.3*W,(sh-hip)*0.36,1.5*W,0.22,DARK); // waist
m.bevelBox(0.1,hip+(sh-hip)*0.30,0,1.9*W,(sh-hip)*0.74,2.1*W,0.30,o.chest||MET); // chest plate
/* RAKED GLACIS over the chest — the armour angle is what stops the torso
reading as a box. Sharp edge running downward, like a breastplate. */
m.wedge(0.95*W,hip+(sh-hip)*0.36,0,0.8*W,(sh-hip)*0.52,1.9*W,o.chest||MET_L);
insetPanel(m,0.95*W,hip+(sh-hip)*0.62,0,0.9*W,1.1*W,0.16,o.chest||MET_D,0); // chest vent
m.bevelBox(-0.55*W,hip+(sh-hip)*0.5,0,1.0*W,(sh-hip)*0.62,1.9*W,0.24,o.pack||MET_D); // backpack
/* pauldrons — the livery lands here, at the widest point */
for(const sd of [-1,1]){
m.bevelBox(0,sh-0.25,sd*1.25*W,1.35*W,0.62,0.95*W,0.30,TEAM_A);
m.wedge(0,sh+0.30,sd*1.25*W,1.30*W,0.42,0.92*W,TEAM_A,0,sd<0); // angled pauldron cap
m.bevelBox(0.15,sh-1.15,sd*1.30*W,0.72*W,1.1,0.62*W,0.20,SERVO); // upper arm
m.bevelBox(0.85*W,sh-1.55,sd*1.05*W,1.3*W,0.60,0.55*W,0.18,DARK); // forearm, reaching to the gun
}
/* head — helmet dome with a GLASS visor slot and a brow ridge */
m.bevelBox(0.1,sh+0.15,0,1.15*W,(hd-sh)*0.9,1.25*W,0.34,o.helm||MET_L);
m.box(0.62*W,sh+0.42,0,0.34,0.42,0.95*W,GLASS); // visor
m.box(0.55*W,sh+0.85,0,0.5,0.18,1.1*W,MET_D); // brow
if(o.antenna){ m.cyl(-0.35*W,hd,0.5*W,0.05,0.03,0.9,5,DARKER); }
return {hip,sh,hd};
}
function mdlStriker(){ // 0 — line rifleman
const m=MB();
const b=mfTrooper(m,{h:8.6,w:1.0,antenna:1});
/* rifle held across the chest: receiver, barrel with muzzle, magazine,
stock — pointed +X so the unit faces where it shoots */
const gy=b.hip+1.6;
m.bevelBox(1.35,gy,0.15,2.6,0.55,0.45,0.14,MET_D); // receiver
m.cyl(2.7,gy+0.12,0.15,0.14,0.12,1.7,6,DARKER,Math.PI/2); // barrel
m.tube(4.4,gy+0.12,0.15,0.20,0.10,0.28,6,MET_L,Math.PI/2); // muzzle
m.box(1.6,gy-0.55,0.15,0.4,0.6,0.35,MET_D); // magazine
m.box(0.3,gy+0.05,0.15,0.7,0.4,0.35,DARK); // stock
glowStrip(m,-0.55,b.sh+0.4,0,0.8,ENERGY,0); // pack light
return {hull:m.build(),tur:null,s:0.92};
}
function mdlRhino(){ // 1 — medium battle tank
const m=MB();
runningGear(m,13,3.2,2.8,3.5,5,MET_D);
/* Faceted hull with a distinct upper plate break, then hardware bolted all
over it: skirts, stowage, vents, a mast. The plate break plus the clutter
is what stops an overhead view reading it as a rounded rectangle. */
hullShell(m,12.4,7.4,2.2,1.5,MET,TEAM_A);
m.wedge(4.4,3.7,0,3.4,1.5,6.4,MET_L); // glacis
for(const sd of [-1,1]){
armorPlate(m,0.6,2.2,sd*4.1,9.6,1.9,0.42,0,MET_D,0); // spaced side skirts
m.box(-4.9,3.9,sd*2.0,2.0,0.5,1.0,TEAM_A); // livery flash
}
ventBank(m,-4.3,3.72,0,3.6,4.6,5,MET_D,0); // engine deck louvres
exhaust(m,-5.9,3.0,0,2,0.42,2.0,MET_D,1.9);
stowage(m,-1.4,3.72,3.0,5.0,1.6,RUST,0,3);
kitBox(m,-3.0,3.72,-2.5,2.2,0.9,1.5,MET_D,0);
sensorMast(m,-5.2,3.9,1.6,2.6,MET_L);
m.box(4.9,3.5,-2.4,1.0,0.7,1.0,GLASS); // driver's vision block
glowStrip(m,0.5,3.76,0,7.0,ENERGY,0);
const t=MB();
turretBody(t,7.2,6.0,2.9,MET,TEAM_T);
for(const sd of [-1,1]){
m.tm=0;
t.box(-0.4,1.1,sd*3.0,4.6,1.5,0.42,TEAM_A); // cheek livery
t.box(-2.2,2.1,sd*2.1,1.5,0.9,0.9,DARKER); // smoke launchers
t.cyl(-2.2,3.0,sd*2.1,0.26,0.24,0.9,6,DARKER);
}
gunX(t,2.6,1.5,7.6,0.52,MET_D);
t.cyl(4.6,1.5,0,0.72,0.66,1.2,9,MET_D); // fume extractor
t.tube(10.0,1.5,0,0.54,0.30,0.8,8,DARKER); // bore
t.cyl(0.2,2.9,-1.5,0.95,0.86,0.8,9,MET_D); // cupola
t.tube(0.2,3.7,-1.5,0.95,0.62,0.30,9,MET_L);
t.box(1.1,3.4,-1.5,1.9,0.34,0.34,DARK); // pintle MG
kitBox(t,-3.3,1.0,0,1.5,1.9,3.2,MET_D,0); // bustle rack
stowage(t,-3.3,2.9,0,2.6,2.6,RUST,0,9);
sensorMast(t,-1.2,3.2,1.9,1.9,MET_L);
return {hull:m.build(),tur:t.build(),s:1.0,turH:5.2,muzzle:10.2};
}
function mdlGoliath(){ // 2 — heavy assault tank
const m=MB();
runningGear(m,17,4.4,3.6,4.7,6,MET_D,2);
hullShell(m,16.4,9.8,2.8,1.9,MET,TEAM_A);
m.wedge(5.8,4.7,0,4.4,1.9,8.6,MET_L); // heavy glacis
armorPlate(m,6.0,4.7,0,3.0,1.5,0.5,0,MET_D,0); // applique brow
for(const sd of [-1,1]){
armorPlate(m,0.8,2.8,sd*5.4,13.0,2.4,0.5,0,MET_D,0); // full-length skirts
m.bevelBox(3.4,4.9,sd*4.4,4.6,1.5,2.0,0.3,MET_D); // sponson blisters
m.box(-6.4,5.1,sd*2.6,2.6,0.6,1.2,TEAM_A);
m.cyl(6.6,4.9,sd*3.6,0.30,0.26,1.1,6,MET_L); // tow points
}
ventBank(m,-5.6,4.72,0,4.6,6.4,7,MET_D,0);
exhaust(m,-7.6,3.6,0,3,0.50,2.6,MET_D,2.4);
stowage(m,-2.0,4.72,4.0,6.4,2.0,RUST,0,17);
kitBox(m,-4.2,4.72,-3.4,2.8,1.1,2.0,MET_D,0);
sensorMast(m,-6.6,5.1,2.4,3.2,MET_L);
glowStrip(m,0.5,4.78,0,9.5,ENERGY,0);
const t=MB();
turretBody(t,9.6,8.0,3.9,MET,TEAM_T);
for(const sd of [-1,1]){
t.box(-0.4,1.5,sd*4.0,6.6,2.0,0.5,TEAM_A);
t.box(-3.0,2.9,sd*2.9,2.0,1.1,1.1,DARKER);
for(let k=0;k<3;k++) t.cyl(-3.0+k*0.7,4.0,sd*2.9,0.24,0.22,1.0,6,DARKER);
armorPlate(t,1.6,0.2,sd*4.1,5.0,2.6,0.42,0,MET_D,0);
}
gunX(t,3.6,2.0,10.8,0.70,MET_D);
t.cyl(6.2,2.0,0,0.95,0.88,1.6,9,MET_D);
t.tube(14.3,2.0,0,0.72,0.40,1.0,8,DARKER);
t.cyl(0.6,3.9,-2.4,1.15,1.05,0.9,9,MET_D);
t.tube(0.6,4.8,-2.4,1.15,0.74,0.34,9,MET_L);
t.box(1.8,4.5,-2.4,2.4,0.4,0.4,DARK);
kitBox(t,-4.4,1.2,0,1.9,2.4,4.4,MET_D,0);
stowage(t,-4.4,3.6,0,3.4,3.6,RUST,0,21);
sensorMast(t,-1.8,4.3,2.6,2.3,MET_L);
return {hull:m.build(),tur:t.build(),s:1.0,turH:6.3,muzzle:14.4};
}
function mdlThumper(){ // 3 — self-propelled artillery
const m=MB();
runningGear(m,13,3.4,3.0,3.7,5,MET_D);
hullShell(m,12.4,7.6,2.0,1.3,MET,TEAM_A);
ventBank(m,-3.6,3.32,0,3.4,5.2,5,MET_D,0);
exhaust(m,-5.4,2.6,0,2,0.40,1.8,MET_D,2.0);
for(const sd of [-1,1]){
/* Deployed recoil spades are the artillery tell in plan view. */
m.box(-5.8,1.4,sd*4.0,2.2,2.8,0.9,MET_D);
hydraulic(m,-5.0,2.6,sd*3.6,2.2,0.28,MET_D,0);
m.box(0.4,3.36,sd*3.6,7.0,0.5,0.7,TEAM_A);
kitBox(m,2.6,3.32,sd*2.6,2.4,1.0,1.4,MET_D,0); // shell lockers
}
sensorMast(m,-4.6,3.5,1.8,2.4,MET_L);
const t=MB();
turretBody(t,5.6,5.8,2.6,MET,TEAM_T);
gunX(t,1.6,2.2,14.5,0.60,MET_D); // very long tube
t.cyl(4.2,2.2,0,1.05,0.95,2.6,9,MET_D); // recuperator
t.cyl(7.0,2.2,0,0.80,0.74,1.4,9,MET_D);
t.tube(16.4,2.2,0,0.62,0.34,1.0,8,DARKER);
for(const sd of [-1,1]){
t.cyl(1.4,1.1,sd*1.8,1.4,1.3,0.9,9,DARK); // trunnions
hydraulic(t,-1.4,1.1,sd*2.0,2.6,0.32,MET_D,0); // elevation rams
t.box(-0.6,2.7,sd*2.7,4.2,0.5,0.5,TEAM_A);
}
kitBox(t,-2.8,1.2,0,1.7,1.6,3.6,MET_D,0);
return {hull:m.build(),tur:t.build(),s:1.0,turH:5.4,muzzle:16.1};
}
function mdlCommander(){ // 4 — hero mech
const m=MB();
/* SupCom-style bot proportions: broad pauldrons, deep chest, chunky
digitigrade legs with visible hydraulics, and glowing power conduits. */
for(const sd of [-1,1]){
m.bevelBox(-1.0,0,sd*3.6,5.6,1.8,3.2,0.5,SERVO); // foot
m.box(-1.0,1.8,sd*3.6,4.0,0.6,2.6,SERVO);
m.extrude(-0.4,2.4,sd*3.6,[[-1.6,-1.7],[1.6,-1.7],[2.0,0],[1.6,1.7],[-1.6,1.7],[-2.0,0]],4.8,SERVO);
hydraulic(m,-1.6,2.4,sd*3.6,4.4,0.42,SERVO,0); // shin ram
m.bevelBox(0.2,7.2,sd*3.6,4.0,2.4,4.0,0.6,SERVO); // knee block
hydraulic(m,-1.4,9.6,sd*3.6,3.0,0.36,SERVO,0);
m.box(0.2,9.6,sd*3.6,3.4,2.6,3.4,SERVO); // thigh
}
m.extrude(0,12.2,0,[[-4.2,-4.6],[2.6,-5.0],[4.6,-2.4],[4.6,2.4],[2.6,5.0],[-4.2,4.6]],5.4,MET);
m.extrude(0,17.6,0,[[-3.4,-3.8],[2.2,-4.2],[3.8,-2.0],[3.8,2.0],[2.2,4.2],[-3.4,3.8]],2.2,TEAM_A);
m.wedge(3.2,19.8,0,4.0,2.4,7.6,TEAM_T); // chest plate
glowStrip(m,1.0,19.9,0,6.0,ENERGY,0);
for(const sd of [-1,1]){
m.bevelBox(0.4,17.0,sd*6.4,4.4,3.6,3.6,0.7,TEAM_A); // pauldron
armorPlate(m,0.4,20.6,sd*6.4,4.0,1.4,0.5,0,MET_D,0);
m.box(0.0,13.6,sd*6.6,3.0,4.0,3.0,MET_D); // upper arm
hydraulic(m,-1.2,13.6,sd*6.6,3.4,0.34,MET_L,0);
gunX(m,1.6,13.6,6.6,0.62,DARK,sd*6.6); // arm cannon
tubeX(m,8.4,13.6,sd*6.6,0.8,0.58,0.32,8,DARKER); // lateral muzzle collar
m.cyl(-2.6,19.4,sd*2.6,0.66,0.50,3.4,8,MET_D); // back vents
m.tube(-2.6,22.8,sd*2.6,0.62,0.36,0.4,8,ENERGY);
}
m.bevelBox(-3.6,17.6,0,3.2,3.4,6.4,0.6,DARK); // backpack
m.bevelBox(2.2,22.2,0,3.6,2.8,4.4,0.6,TEAM_A); // head
m.box(4.0,23.2,0,0.5,1.0,3.0,GLASS); // visor
sensorMast(m,-0.6,25.0,1.4,2.6,MET_L);
m.box(-1.2,25.0,0,0.9,2.6,0.9,LAMP); // command beacon
/* Nova's commander is the clean-energy flagship, not the generic biped the
three enemy bios branch from. A suspended command halo and twin capacitor
towers keep that identity legible even when the weapon is occluded. */
m.ring(-3.4,23.7,0,2.4,3.4,18,ENERGY);
for(const sd of [-1,1]){
m.cyl(-5.0,18.8,sd*3.1,1.05,.82,5.8,10,MET_D);
m.cyl(-5.0,24.7,sd*3.1,.78,.62,1.0,10,ENERGY);
m.box(-5.0,22.0,sd*3.1,.45,5.2,1.0,ENERGY);
}
return {hull:m.build(),tur:null,s:0.62};
}
function mdlWasp(){ // 5 — light air
const m=MB();
const low=[[-5.0,-1.55],[-3.0,-2.05],[2.5,-2.15],[5.5,-.72],[6.0,0],
[5.5,.72],[2.5,2.15],[-3.0,2.05],[-5.0,1.55]];
const mid=[[-4.5,-1.35],[-2.4,-1.72],[2.7,-1.66],[5.0,-.55],[5.4,0],
[5.0,.55],[2.7,1.66],[-2.4,1.72],[-4.5,1.35]];
m.extrude(0,0,0,low,1.35,MET_D); // armoured keel
m.extrude(0,1.35,0,mid,1.15,MET); // faceted fuselage
m.extrude(.15,2.50,0,mid.map(p=>[p[0]*.70,p[1]*.63]),.58,TEAM_A);
canopyGlass(m,2.55,2.88,0,3.5,2.45,1.35,MET_L); // framed canopy
m.box(-.65,3.10,0,4.25,.34,1.08,TEAM_T); // dorsal identity stripe
glowStrip(m,-.35,3.47,0,3.05,ENERGY,0);
ventBank(m,-1.9,3.12,0,1.6,1.30,3,DARK,0);
/* TWIN THRUSTERS — an aircraft with no visible propulsion reads as a paper
dart. Bells flare toward the exhaust with the glow recessed inside. */
for(const sd of [-1,1]) thrusterBell(m,-4.6,1.9,sd*1.05,0.62,1.7,MET_L);
for(const sd of [-1,1]){
const wing=sd>0
? [[-3.45,1.18],[2.40,1.34],[.85,6.95],[-4.15,6.08]]
: [[-3.45,-1.18],[-4.15,-6.08],[.85,-6.95],[2.40,-1.34]];
const panel=sd>0
? [[-2.72,1.72],[1.72,1.75],[.72,5.48],[-3.18,5.03]]
: [[-2.72,-1.72],[-3.18,-5.03],[.72,-5.48],[1.72,-1.75]];
m.extrude(0,1.02,0,wing,.62,MET_D); // one-piece load-bearing wing
m.extrude(0,1.64,0,panel,.24,TEAM_A); // restrained wing livery
m.box(-1.65,1.02,sd*3.55,4.50,.75,2.25,DARK); // engine pylon joins wing
cylX(m,-4.65,1.34,sd*3.75,5.55,1.13,.94,10,DARK,false); // long nacelle
ringX(m,-4.69,1.34,sd*3.75,.78,1.18,12,MET_L);
tubeX(m,-5.12,1.34,sd*3.75,.82,.92,.43,10,TWR_BORE); // actual hollow exhaust
ringX(m,-5.15,1.34,sd*3.75,.31,.48,10,HOT);
ringX(m,.43,1.34,sd*3.75,.67,1.00,10,MET_L); // intake lip
m.box(-1.10,1.96,sd*3.75,2.20,.42,1.26,TEAM_T); // nacelle livery saddle
ventBank(m,-1.05,2.39,sd*3.75,1.62,.90,3,MET_D,0);
m.bevelBox(-.30,.48,sd*5.35,2.85,.92,1.28,.24,DARKER); // attached missile rail
for(let k=0;k<3;k++)
cylX(m,-.92+k*.84,.58,sd*5.35,1.25,.24,.18,7,HOT,false);
m.box(2.20,.54,sd*1.55,2.15,.78,.86,DARK); // fixed gun receiver
gunX(m,2.75,.82,3.15,.27,MET_D,sd*1.55); // visible hollow cannon
m.box(-4.10,2.20,sd*1.45,2.25,1.85,.36,MET_L); // twin tail fins touch hull
m.box(-3.65,3.46,sd*.86,1.50,.28,.34,LAMP); // formation lamps
}
m.bevelBox(-4.15,1.58,0,2.35,1.25,3.55,.30,DARKER); // tail gearbox links fins
sensorMast(m,-3.85,2.82,0,1.55,MET_L);
m.greeble(-.40,3.10,0,3.3,1.05,.34,5,MET_D,0,51);
return {hull:m.build(),tur:null,s:1.0,air:1};
}
function mdlLongbow(){ // 6 — beam sniper
const m=MB();
runningGear(m,12.0,3.0,2.5,3.25,5,MET_D);
hullShell(m,11.8,7.0,1.9,1.25,MET,TEAM_A,0.34,0.72);
m.wedge(4.3,3.16,0,3.2,1.15,5.6,MET_L);
for(const sd of [-1,1]){
armorPlate(m,0.2,1.9,sd*3.72,8.6,1.6,0.38,0,MET_D,0);
m.box(-1.0,3.18,sd*3.18,6.2,0.44,0.62,TEAM_A);
/* Long capacitors keep the sniper role readable when the tracking weapon
is turned away from the camera. */
m.cyl(-3.8,3.24,sd*2.15,0.72,0.72,2.3,9,TWR_MACH);
m.ring(-3.8,5.0,sd*2.15,0.76,0.95,10,TWR_GLOW);
}
ventBank(m,-4.1,3.18,0,2.8,3.2,4,MET_D,0);
exhaust(m,-5.3,2.35,0,2,0.34,1.8,MET_D,1.7);
sensorMast(m,-4.7,3.28,1.15,2.2,MET_L);
glowStrip(m,0.0,3.24,0,6.4,ENERGY,0);
const t=MB();
turretBody(t,5.6,4.7,2.35,TWR_ARM,TWR_TEAM);
t.bevelBox(-2.3,0.65,0,2.1,1.5,3.8,0.35,TWR_COAT);
for(const sd of [-1,1]){
t.box(-0.3,1.0,sd*2.32,3.8,1.0,0.38,TWR_TEAM);
hydraulic(t,-1.4,0.15,sd*1.75,1.6,0.23,TWR_MACH,0);
}
gunX(t,1.75,1.25,9.7,0.42,TWR_MACH);
for(const x of [4.4,6.15,7.9]) ringX(t,x,1.25,0,0.60,0.88,12,TWR_GLOW);
cylX(t,0.7,1.25,0,2.15,0.92,0.72,10,TWR_COAT,false);
tubeX(t,11.0,1.25,0,0.72,0.60,0.31,10,TWR_BORE);
t.cyl(-0.9,2.36,-1.15,0.62,0.54,0.50,9,MET_L);
t.box(-0.9,2.86,-1.15,0.22,0.75,0.22,LAMP);
return {hull:m.build(),tur:t.build(),s:1.0,turH:4.45};
}
function mdlHornet(){ // 7 — rocket vehicle
const m=MB();
runningGear(m,11.6,3.0,2.55,3.25,5,MET_D);
hullShell(m,11.4,7.0,1.95,1.25,MET,TEAM_A,0.38,0.70);
m.wedge(4.1,3.2,0,3.1,1.2,5.5,MET_L);
ventBank(m,-3.9,3.22,0,2.8,3.8,4,MET_D,0);
exhaust(m,-5.1,2.4,0,2,0.34,1.8,MET_D,1.7);
for(const sd of [-1,1]){
armorPlate(m,0.0,1.95,sd*3.7,8.2,1.6,0.38,0,MET_D,0);
m.box(0.3,3.24,sd*3.15,6.0,0.45,0.62,TEAM_A);
kitBox(m,-2.4,3.22,sd*2.35,1.8,0.9,1.25,MET_D,0);
}
sensorMast(m,-4.5,3.3,1.1,2.0,MET_L);
const t=MB();
t.cyl(-1.5,0,0,2.5,2.15,1.2,12,TWR_PAD);
t.bevelBox(-0.9,1.2,0,3.2,2.2,5.5,0.45,TWR_COAT);
for(const sd of [-1,1]){
t.cyl(-0.7,1.5,sd*2.72,1.05,0.92,0.62,10,TWR_MACH);
hydraulic(t,-1.8,1.2,sd*2.15,2.0,0.28,TWR_MACH,0);
t.box(-1.0,3.35,sd*2.72,3.6,0.44,0.40,TWR_TEAM);
}
/* Six genuinely open launch tubes replace closed cargo-box silhouettes. */
for(let row=0;row<2;row++) for(let col=0;col<3;col++){
const y=2.55+row*1.55, z=-1.9+col*1.9;
cylX(t,-0.1,y,z,4.45,0.69,0.63,10,TWR_MACH,false);
ringX(t,4.28,y,z,0.66,0.84,10,TWR_TEAM);
tubeX(t,4.32,y,z,0.72,0.70,0.39,10,TWR_BORE);
t.box(-0.45,y+0.73,z,3.7,0.20,0.18,TWR_TRIM);
}
t.bevelBox(-2.6,2.0,0,1.5,3.4,4.5,0.32,MET_D);
return {hull:m.build(),tur:t.build(),s:1.0,turH:4.45};
}
function mdlTitan(){ // 8 — superheavy walker
const m=MB();
for(const sd of [-1,1]){
/* Every part below the hip uses SERVO. Mixing ordinary armour into a leg
leaves those plates behind when the vertex-stage gait moves the foot. */
m.bevelBox(-1.35,0,sd*6.10,9.6,2.15,5.4,.62,SERVO); // broad planted foot
m.box(2.95,.42,sd*6.10,2.25,1.25,4.75,SERVO); // reinforced toe
m.box(-5.55,.30,sd*6.10,1.25,1.05,4.40,SERVO); // heel counterweight
m.cyl(-1.15,2.15,sd*6.10,1.78,1.48,1.05,10,SERVO); // ankle bearing
m.extrude(-.55,3.10,sd*6.10,[[-2.45,-2.25],[1.65,-2.25],[2.35,-1.15],
[2.35,1.15],[1.65,2.25],[-2.45,2.25]],6.20,SERVO);
m.bevelBox(-.35,4.20,sd*6.10,3.65,3.80,4.95,.48,SERVO); // shin armour mass
hydraulic(m,-2.22,3.15,sd*6.10,5.65,.48,SERVO,0);
hydraulic(m,1.18,3.25,sd*6.10,5.35,.38,SERVO,0);
m.cyl(.35,9.30,sd*6.10,2.70,2.45,2.25,12,SERVO); // articulated knee drum
m.bevelBox(.25,10.15,sd*6.10,6.15,2.65,5.65,.68,SERVO);
m.extrude(-.15,11.10,sd*6.10,[[-2.20,-2.15],[1.90,-2.15],[2.40,-1.05],
[2.40,1.05],[1.90,2.15],[-2.20,2.15]],3.95,SERVO);
m.box(-1.45,11.65,sd*4.35,2.35,3.10,1.35,SERVO); // inner hip brace, connected
}
m.extrude(0,14.35,0,[[-6.7,-7.25],[3.8,-7.8],[7.1,-4.3],[7.1,4.3],
[3.8,7.8],[-6.7,7.25]],4.45,MET_D); // pelvis / hip bridge
m.extrude(.15,18.80,0,[[-6.0,-6.45],[3.6,-6.9],[6.15,-3.7],[6.15,3.7],
[3.6,6.9],[-6.0,6.45]],2.05,TEAM_A);
for(const sd of [-1,1]){
armorPlate(m,.20,18.88,sd*6.30,8.6,1.55,.58,0,MET_L,0); // hip skirt
m.cyl(-4.20,16.35,sd*5.15,.62,.54,2.05,8,DARKER);
}
m.bevelBox(-4.55,16.35,0,4.65,3.25,10.75,.72,DARKER); // reactor bridge
ventBank(m,-4.45,19.76,0,3.30,7.65,6,MET_D,0);
m.extrude(0,20.85,0,[[-5.9,-6.25],[3.4,-7.15],[6.15,-4.25],[6.15,4.25],
[3.4,7.15],[-5.9,6.25]],6.30,MET); // high faceted torso
m.extrude(.35,27.15,0,[[-4.9,-5.35],[3.0,-5.95],[5.2,-3.35],[5.2,3.35],
[3.0,5.95],[-4.9,5.35]],2.15,TEAM_A); // shoulder deck livery
m.wedge(4.65,23.25,0,4.25,4.15,10.2,TEAM_T,0,true); // sloped chest glacis
m.bevelBox(1.35,27.15,0,6.80,1.30,8.65,.42,MET_L); // collar armour
glowStrip(m,4.85,27.48,0,5.0,ENERGY,0);
for(const sd of [-1,1]){
m.bevelBox(.65,23.05,sd*7.55,6.45,4.35,4.45,.72,MET_D); // attached shoulder receiver
m.box(-2.60,23.75,sd*7.55,1.55,2.45,4.05,DARKER); // ammunition bustle
m.greeble(-2.60,26.18,sd*7.55,1.20,3.15,.56,4,MET_L,0,sd>0?81:82);
m.box(3.05,24.28,sd*7.55,3.65,2.20,2.50,DARK); // trunnion housing
gunX(m,4.70,25.15,11.25,.82,MET_D,sd*7.55); // correctly separated cannon
m.box(.50,27.48,sd*7.55,5.55,.42,3.55,TEAM_A); // shoulder ownership panel
m.cyl(-5.10,23.15,sd*4.60,.92,.72,5.35,9,MET_D); // rear heat exchanger
m.tube(-5.10,28.42,sd*4.60,.72,.38,.52,9,TWR_BORE);
m.box(-4.55,21.45,sd*6.00,2.10,4.80,1.10,DARK); // torso side grille
for(let k=0;k<4;k++)
m.box(-4.05,22.00+k*.88,sd*6.58,1.45,.36,.32,MET_L);
}
m.bevelBox(-4.80,21.75,0,3.25,5.35,7.30,.65,DARK); // attached power backpack
m.greeble(-4.80,27.10,0,2.40,5.20,.70,6,MET_D,0,91);
m.bevelBox(3.05,29.30,0,5.15,3.65,6.30,.65,MET_L); // command head
m.wedge(5.75,30.18,0,1.55,1.70,5.10,GLASS,0,true); // visor block
m.box(6.55,30.45,0,.48,1.08,4.35,ENERGY); // readable visor strip
sensorMast(m,-.30,32.15,1.65,2.50,MET_L);
m.cyl(-1.65,32.20,-1.55,.62,.40,2.85,8,MET_D);
m.tube(-1.65,34.95,-1.55,.44,.22,.38,8,LAMP);
kitBox(m,-4.80,20.65,0,2.05,1.28,3.60,MET_D,0);
return {hull:m.build(),tur:null,s:0.62};
}
function mdlPyro(){ // 10 — flame trooper
const m=MB();
/* Same species as the Striker, bulked out: a flame suit is sealed and
heavy, so the trooper is wider, the helmet is a full hood, and the
backpack is the fuel plant — twin tanks with caps and bands, plumbed to
the projector. The pilot light is the tell at range. */
const b=mfTrooper(m,{h:8.2,w:1.22,chest:RUST,helm:MET_D,pack:MET_D});
for(const sd of [-1,1]){ // fuel tanks
m.cyl(-1.15,b.hip+0.4,sd*0.62,0.52,0.52,3.4,8,RUST);
m.cyl(-1.15,b.hip+3.8,sd*0.62,0.52,0.30,0.5,8,MET_D); // caps
m.cyl(-1.15,b.hip+1.9,sd*0.62,0.56,0.56,0.22,8,MET_L); // bands
}
m.cyl(-0.6,b.sh-0.4,0,0.16,0.14,1.9,5,DARKER,Math.PI/2); // feed hose
/* projector: short, fat, with a shroud and a pilot flame */
const gy=b.hip+1.5;
m.bevelBox(1.5,gy,0.15,2.0,0.7,0.6,0.16,MET_D);
m.tube(3.1,gy+0.1,0.15,0.34,0.20,0.9,7,MET_L,Math.PI/2); // shroud
m.cyl(3.9,gy+0.1,0.15,0.12,0.10,0.5,5,HOT,Math.PI/2); // pilot light
m.box(1.0,gy-0.5,0.15,0.5,0.5,0.4,DARK); // grip
glowStrip(m,3.4,gy+0.45,0.15,0.7,HOT,0);
return {hull:m.build(),tur:null,s:0.95};
}
function mdlVulture(){ // 10 — anti-air gun vehicle
const m=MB();
runningGear(m,11,2.8,2.6,3.1,4,MET_D);
hullShell(m,11.0,6.8,2.0,1.2,MET,TEAM_A);
ventBank(m,-3.2,3.22,0,2.8,4.4,4,MET_D,0);
exhaust(m,-4.8,2.4,0,2,0.36,1.6,MET_D,1.8);
for(const sd of [-1,1]) m.box(0.2,3.26,sd*3.2,6.4,0.5,0.6,TEAM_A);
kitBox(m,3.0,3.22,0,2.2,0.9,3.4,MET_D,0);
const t=MB();
t.cyl(0,0,0,2.8,2.4,2.4,12,MET);
t.extrude(0,2.4,0,[[-2.4,-2.2],[1.4,-2.6],[2.8,-1.2],[2.8,1.2],[1.4,2.6],[-2.4,2.2]],1.0,TEAM_T);
for(const sd of [-1,1]){
/* Twin short high-elevation barrels plus a tracking dish: an AA mount has
to be unmistakable from above or it gets misused as a gun tank. */
t.box(2.6,2.4,sd*1.4,5.6,1.0,1.0,MET_D);
t.tube(5.6,2.9,sd*1.4,0.48,0.26,0.8,7,DARKER);
t.box(0.6,3.6,sd*2.2,2.8,0.42,0.5,TEAM_A);
hydraulic(t,-0.8,2.4,sd*2.0,1.8,0.26,MET_D,0);
kitBox(t,-2.0,2.4,sd*2.0,1.5,1.0,1.1,DARKER,0); // ammo feed boxes
}
t.box(-2.9,2.4,0,1.6,2.2,2.6,DARK);
t.cyl(-2.9,4.6,0,2.9,0.7,0.45,12,MET_L); // tracking dish
t.cyl(-2.9,5.05,0,0.30,0.22,1.0,6,DARKER);
t.box(-2.9,6.05,0,0.28,0.34,0.28,LAMP);
return {hull:m.build(),tur:t.build(),s:1.0,turH:4.4};
}
function mdlBulwark(){ // 11 — shield generator vehicle
const m=MB();
/* The only vehicle still calling the legacy treads()/hullPlate() pair, so it
was the only one with no road wheels, no suspension, no hubs and a flat
unchamfered deck — eight primitives for a 90-mass tier-2 shield platform
that is meant to look like the toughest thing on the field. */
runningGear(m,13,3.6,2.8,3.8,5,MET_D,2);
hullShell(m,13.0,8.4,2.4,1.6,MET,TEAM_A);
deckCrown(m,0,4.06,0,12.4,8.0,MET_D,TEAM_T);
m.bevelBox(0,5.8,0,6.2,3.0,7.2,0.5,TEAM_T); // livery reactor housing
for(const s of [-1,1]) m.box(0,4.4,s*4.3,11.0,1.6,0.7,TEAM_A);
for(const s of [-1,1]) for(const f of [-1,1])
m.cyl(f*2.4,8.8,s*2.4,0.6,0.5,3.4,7,MET_D); // emitter pylons
m.sphere(0,10.4,0,3.0,10,ENERGY,0.7,false); // field core
m.box(-5.0,5.4,0,2.0,1.4,5.0,DARK);
return {hull:m.build(),tur:null,s:1.0};
}
/* ============================================================================
ARTHROPOD KIT — the Brood is grown, and it has to look grown.
The swarm was three spheres and six boxes. Read from the game's near-top-down
camera that is a green blob with twigs, and no amount of colour work fixes a
silhouette that has no anatomy in it. Real arthropods read the way they do
because of three things, and none of them were present:
JOINTED LIMBS WITH THE KNEE ABOVE THE BODY. This is the single strongest
signal. A spider's femur angles UP and OUT from the hip to a raised knee,
and only then does the tibia come down to the ground — so the body hangs
slung between its legs rather than sitting on them. A straight peg leg
reads as furniture; a raised knee reads as alive and as carrying weight.
SEGMENTED, OVERLAPPING CARAPACE. Chitin is not one smooth surface, it is a
stack of plates each slightly proud of the one behind, so every segment
catches a separate highlight and throws a shadow onto its neighbour.
That stack is what makes the mass look hard and heavy instead of soft.
TAPER EVERYWHERE. Nothing on a living thing is a constant-radius tube.
All limb geometry is built in BIO_LEG on purpose: the vertex stage keys its
two-oscillator spring bend off that material id (see VS3D in mesh.js), so
legs, antennae and tendrils get weighted follow-through with no bones, no CPU
skinning and no extra draw calls. Body masses are CHITIN, which the same
shader gives a breathing pulse. Building the anatomy correctly is therefore
also what animates it.
============================================================================ */
/* A tapered segment between two arbitrary points — the primitive the rest of
this kit is made of. cyl() only builds Y-axis frustums, which cannot express
a limb that goes out, up, and then down again. */
function boneSeg(m,a,b,r0,r1,seg,col){
const dx=b[0]-a[0], dy=b[1]-a[1], dz=b[2]-a[2];
const len=Math.hypot(dx,dy,dz)||1e-4;
const ax=dx/len, ay=dy/len, az=dz/len;
/* Any vector not parallel to the axis gives a stable basis. Picking the axis
we are LEAST aligned with avoids the degenerate cross product. */
const up = Math.abs(ay)<0.9 ? [0,1,0] : [1,0,0];
let ux=up[1]*az-up[2]*ay, uy=up[2]*ax-up[0]*az, uz=up[0]*ay-up[1]*ax;
let l=Math.hypot(ux,uy,uz)||1; ux/=l; uy/=l; uz/=l;
const vx=ay*uz-az*uy, vy=az*ux-ax*uz, vz=ax*uy-ay*ux;
const n=seg||6, A=[], B=[];
for(let k=0;k<n;k++){
const th=k/n*Math.PI*2, c=Math.cos(th), s=Math.sin(th);
A.push([a[0]+(ux*c+vx*s)*r0, a[1]+(uy*c+vy*s)*r0, a[2]+(uz*c+vz*s)*r0]);
B.push([b[0]+(ux*c+vx*s)*r1, b[1]+(uy*c+vy*s)*r1, b[2]+(uz*c+vz*s)*r1]);
}
for(let k=0;k<n;k++){ const j=(k+1)%n; m.quad(A[k],A[j],B[j],B[k],col); }
return m;
}
/* One walking leg: coxa out from the body, femur out and UP to a raised knee,
tibia down and further out, tarsus tapering to a point on the ground.
`side` is -1/+1, `k` staggers each leg's splay so they fan instead of
marching in parallel. Everything is BIO_LEG so the shader flexes it. */
function broodLeg(m,x,y,z,side,reach,drop,knee,th,col,gaitPhase,hip){
const c=col||BIO_LEG;
/* SEVEN SEGMENTS ON SEVEN BONES.
The count matters because each joint is a place the limb can change
direction, and it is that repeated change of direction — with a knuckle
swelling at every one — that the eye reads as a leg carrying weight rather
than as a bent rod. But the count alone is not enough: with the segments
welded rigidly to the body they still swung as one hook. Each is now a
RIGID BODY hinged to its parent, so the limb protracts from the hip, flexes
at the knee and plants its foot in sequence, the way a hinge chain does.
`gaitPhase` puts diagonal legs in antiphase — the alternating-tripod gait
every six- and eight-legged animal actually walks. */
const R=reach, D=drop, S=side;
/* THE HIP HAS TO BE INSIDE THE SHELL.
Every socket used to be a fraction of REACH — 0.10R for the socket, 0.34R
for the coxa — which silently assumed the body was as wide as a third of
the leg's span. It was, but only by accident: carapace() was bulging its
keel and segment plates out the +Z FLANK, and that bulge happened to reach
the coxa. Correcting the keel to the spine took ~2.2 units of half-width
off the flank and the legs came away from the body, worst at the waist
where the shell is narrowest — which is exactly the middle pair.
`hip` is the real answer: the lateral distance from the centreline where
this animal's shell actually is. The socket buries itself inside that, the
coxa sits on it, and the rest of the chain measures its span from there
instead of from a number that was never about the body at all. */
const H=(hip==null? 0.34*R : hip)*S; // shell surface, signed
const P=[
[x, y, z+H*0.30], // socket, inside the shell
[x+R*0.05, y+knee*0.34, z+H], // coxa — emerges AT the shell
[x+R*0.10, y+knee*0.82, z+H+S*0.34*R], // trochanter
[x+R*0.12, y+knee*1.06, z+H+S*0.76*R], // femur head — the high knee
[x+R*0.02, y+knee*0.58, z+H+S*1.10*R], // patella
[x-R*0.08, y-D*0.30, z+H+S*1.32*R], // tibia foot
[x-R*0.18, y-D*0.80, z+H+S*1.42*R], // metatarsus
[x-R*0.32, y-D, z+H+S*1.48*R], // tarsus tip
];
const RD=[th*1.20, th*1.34, th*1.26, th*1.10, th*0.86, th*0.62, th*0.40, th*0.07];
/* Hip sweeps fore/aft about the vertical; every joint below it flexes about
the horizontal, which is how a real limb folds. Amplitude falls off down
the chain — a hip travels, a tarsus only flicks — and the phase lag is what
makes the leg unfurl along its length instead of folding as one piece. */
const g=gaitPhase||0;
const AX=[[0,1,0],[1,0,0],[1,0,0],[1,0,0],[1,0,0],[1,0,0],[1,0,0]];
const AMP=[0.26,0.10,0.15,0.21,0.17,0.12,0.09];
const LAG=[0.00,0.22,0.42,0.66,0.92,1.16,1.38];
let parent=null; const bone=[];
for(let k=0;k<7;k++){
/* Sign the swing by side so both flanks step outward together rather than
the whole animal crabbing sideways. */
const ax=AX[k][0]?[S,0,0]:[0,1,0];
parent=m.joint(P[k],parent,ax,g+LAG[k],AMP[k]*(k?1:S),0);
bone.push(parent);
}
/* FOUR SIDES, NOT SEVEN. A limb segment is a couple of pixels wide on a phone
at play zoom, and seven-sided tubes on seven segments across eight legs is
where this model's triangle budget actually went — the first version cost
5,860 triangles, nearly three times the TITAN, on the unit that spawns in
the largest numbers in the game. Silhouette is set by the JOINTS, not by
the roundness of the tubes between them, so this costs nothing visually. */
for(let k=0;k<P.length-1;k++){
m.bone(bone[k]);
const claw=k>=P.length-2;
boneSeg(m,P[k],P[k+1],RD[k],RD[k+1],claw?4:5,claw?CLAW:c);
}
/* Knuckles ride the bone BELOW them, so a joint stays welded to the segment
it drives and never tears open as the limb folds. Only the three that
actually read — hip, knee and ankle — get a sphere. */
for(const k of [3,5]){
m.bone(bone[k]);
m.sphere(P[k][0],P[k][1],P[k][2],RD[k]*1.14,4,c,0.86,false);
}
/* One spur, on the knee, and only when the limb is big enough for it to be
more than three pixels. On chaff it is pure cost. */
if(th>0.66){
m.bone(bone[3]);
chitinBlade(m,P[3][0],P[3][1],P[3][2],R*0.20,th*0.36,S*0.30,Math.PI*0.88,CLAW,-0.24);
}
m.bone(-1);
return m;
}
/* ---------------------------------------------------------------------------
SCULPTED CARAPACE.
A stack of overlapping spheres was the first attempt and it is wrong for the
same reason it is wrong in any sculpting package: the intersections show, the
cross-section is stuck circular, and the silhouette is made of arcs. This is
one continuous skin instead, with the anatomy written into it as
displacement — so the segment ridges, the tubercles and the dorsal keel are
part of the surface and light like it.
`profile(v)` gives the body's half-width down its length; the section is
flattened dorsoventrally and given a flat-ish underside, because an insect
is wider than it is tall and rides low over its legs.
--------------------------------------------------------------------------- */
function carapace(m,x,y,z,len,wid,hgt,col,o){
o=o||{};
const segs=o.segs==null?5:o.segs, ridge=o.ridge==null?0.055:o.ridge;
const bump=o.bump==null?0.05:o.bump, keel=o.keel||0, seed=(o.seed||11)>>>0;
const taperF=o.nose==null?0.55:o.nose, taperB=o.tail==null?0.30:o.tail;
/* Cheap deterministic value noise — the tubercle field. Sampled in surface
space so the bumps travel with the form instead of sliding across it. */
const h=(a,b)=>{ let n=(a*374761393+b*668265263+seed)>>>0;
n=(n^(n>>>13))*1274126177>>>0; return ((n^(n>>>16))>>>0)/4294967296; };
const noise=(a,b)=>{
const ia=Math.floor(a), ib=Math.floor(b), fa=a-ia, fb=b-ib;
const sa=fa*fa*(3-2*fa), sb=fb*fb*(3-2*fb);
return (h(ia,ib)*(1-sa)+h(ia+1,ib)*sa)*(1-sb)+(h(ia,ib+1)*(1-sa)+h(ia+1,ib+1)*sa)*sb;
};
/* DECLARE THE MATERIAL BEFORE SCULPTING.
`mat()` tracks a CURRENT material and the bindMat patch list only rewrites
the primitives it names — sculpt() and quad() are not on it, so every
carapace inherited whatever the previous primitive happened to leave
behind. Measured: the Ravager's entire body, 1,112 vertices, was MAT.PLATE.
That is the one material id the organic path keys off, so the animal that
was rebuilt for subsurface translucency and a breathing pulse was getting
neither — it was shading as painted metal. One line, six organisms. */
const CMAT=(typeof COL_MAT!=='undefined'&&COL_MAT.get(col));
if(CMAT!=null) m.mat(CMAT);
m.sculpt(x,y,z,o.u||18,o.v||16,(u,v)=>{
const th=u*Math.PI*2, ct=Math.cos(th), st=Math.sin(th);
/* Body profile: a fat ellipse biased toward the tail, pinched at both ends
by different amounts so the head is blunt and the abdomen is full. */
const s=Math.sin(Math.PI*Math.pow(v,0.86));
let r=Math.pow(s,0.62)*(1-taperF*Math.pow(1-v,2.2))*(1-taperB*Math.pow(v,3.0));
/* WAIST. The single most important line on an arthropod: the pinch between
cephalothorax and abdomen is what makes two body masses out of one lump,
and without it any amount of surface detail still reads as a bean. */
if(o.waist) r*=1-o.waist*Math.exp(-Math.pow((v-(o.waistAt||0.45))/(o.waistW||0.10),2));
/* SEGMENT PLATES, not ripples. A smoothstepped sawtooth gives each segment
a flat field and a hard step down to the next, which is what casts the
shadow that makes chitin look like overlapping armour rather than a
corrugated tube. Deepest along the spine, gone under the belly. */
/* DORSAL IS +Y, WHICH IS `st`. This mask was `ct` — the +Z flank — so every
organism's segment plates, second noise octave and "dorsal keel" were
displaced onto ONE SIDE of the body instead of along the spine. Measured
on the Sovereign's body params at keel 0.40: z ran -5.94..+9.30, a
3.4-unit bulge out the right flank, while the back stayed smooth. The
relief was all still there; it was just ninety degrees from where the
silhouette needed it, which is why these animals read as beans from the
side and as a lopsided ridge from above. Six organisms, one axis. */
const dorsal=Math.max(0,st);
const seg=v*segs, f=seg-Math.floor(seg);
const plate=f*f*(3-2*f);
r+=ridge*(plate-0.5)*2*dorsal;
r+=bump*(noise(u*13,v*17)-0.5)*2*(0.30+0.70*dorsal);
r+=bump*0.55*(noise(u*29,v*37)-0.5)*2*dorsal; // finer second octave
r+=keel*Math.pow(Math.max(0,st),2.4)*Math.sin(Math.PI*v);
r=Math.max(0.04,r);
/* Flattened section with a broad flat underside: |cos| shaping on the
lower half keeps the belly from ballooning into a circle. */
const py=st*hgt*r*(st<0?0.48:1.0);
return [(v-0.5)*len, py, ct*wid*r];
},
/* THE MATERIAL IS A VALUE BREAK, NOT A HUE.
What makes reference chitin read as chitin is not its colour, it is the
range: pale bone-amber plate FIELDS against seams so dark they are almost
black, with the seam reading wet. One flat vertex colour cannot express
that, and no amount of atlas texture puts the dark in the right place
because the atlas does not know where this body's segments are. Painted
from the SAME plate function that displaced the surface, so the dark can
never drift off the crease it belongs in. */
(u,v)=>{
const th=u*Math.PI*2, st=Math.sin(th);
const seg=v*segs, f=seg-Math.floor(seg);
/* Distance from the nearest seam, 0 at the seam and 1 mid-plate. */
const d=Math.min(f,1-f)*2;
/* Bias the mask WIDE: the pale field should own most of each segment and
the dark should be confined to the crease itself, or the animal just
reads as uniformly dark and all the sculpting is lost. */
let k=Math.pow(d,0.42); k=k*k*(3-2*k);
k*=0.70+0.30*Math.max(0,st); // belly stays darker
k*=0.80+0.20*noise(u*23,v*31); // pore stipple
k=0.18+0.82*k; // never fully black
const lo=o.seam||[0.10,0.11,0.09], hi=col;
return [lo[0]+(hi[0]-lo[0])*k, lo[1]+(hi[1]-lo[1])*k, lo[2]+(hi[2]-lo[2])*k];
},false,false);
return m;
}
/* Tubercles — the bumpy relief all over the reference carapaces. Deterministic
from `seed` so a model is identical every build. */
function tubercles(m,x,y,z,rx,rz,n,r,col,seed){
let s=(seed||7)>>>0;
const rnd=()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; };
for(let k=0;k<n;k++){
const a=rnd()*Math.PI*2, d=Math.sqrt(rnd());
m.sphere(x+Math.cos(a)*rx*d, y+r*0.5*(0.6+rnd()*0.8), z+Math.sin(a)*rz*d,
r*(0.55+rnd()*0.7),4,col,0.62,true);
}
return m;
}
/* A curved blade: mandible, raptorial claw, dorsal spike. Built as a chain so
it actually curves instead of being a straight cone. */
function chitinBlade(m,x,y,z,len,th,curve,yaw,col,rise){
/* `rise` is pitch, and it is not optional decoration: without it every blade
lies flat in the XZ plane, so a rank of dorsal spines renders as a set of
horizontal rods lying along the back like scaffolding instead of raking
upward off it. Positive rise sweeps the tip up; the curve term still bends
it sideways, so a mandible and a spine come off the same builder. */
const c=col||CLAW, cs=Math.cos(yaw||0), sn=Math.sin(yaw||0), N=3, up=rise==null?0.10:rise;
let px=x, py=y, pz=z, pr=th;
for(let k=1;k<=N;k++){
const t=k/N, sweep=curve*t*t*len;
const nx=x+(len*t)*cs-sweep*sn, nz=z+(len*t)*sn+sweep*cs;
const ny=y+len*up*t*(1.35-t*0.35);
const nr=th*(1-t*0.94);
boneSeg(m,[px,py,pz],[nx,ny,nz],pr,nr,5,c);
px=nx; py=ny; pz=nz; pr=nr;
}
return m;
}
/* Antenna / tendril: long, very thin, swept back and up. Pure BIO_LEG, so the
shader's spring bend is what actually sells it — these are the parts that
trail and settle as the creature moves. */
function tendril(m,x,y,z,len,th,side,rise,col,gaitPhase){
/* Three hinged links rather than one swept curve. A tendril's whole character
is that it LAGS — the base follows the head, the tip follows the base a
beat later — and that only happens if the links are separate rigid bodies
hinged to each other. Amplitude climbs toward the tip because a free end
travels furthest, which is the opposite of a weight-bearing leg. */
const c=col||BIO_LEG, N=4, g=gaitPhase||0;
const pt=k=>{ const t=k/N;
return [x-len*t*0.55, y+rise*Math.sin(t*1.35), z+side*len*t*0.26]; };
let parent=null; const bone=[];
for(let k=0;k<3;k++){
parent=m.joint(pt(k*2),parent,[side*0.35,0.30,0.88],g+k*0.55,0.16+k*0.10,0);
bone.push(parent);
}
for(let k=0;k<N;k++){
m.bone(bone[Math.min(2,k>>1)]);
boneSeg(m,pt(k),pt(k+1),th*(1-k/N*0.88),th*(1-(k+1)/N*0.88),3,c);
}
m.bone(-1);
return m;
}
/* The red eye cluster from the reference — small, numerous, dead ahead. */
function eyeCluster(m,x,y,z,r,rows,col){
for(let a=0;a<rows;a++) for(const sd of [-1,1]){
m.sphere(x+a*r*0.22, y+r*(0.55-a*0.85), z+sd*r*(1.05-a*0.28), r*0.42,5,col||HOT,0.85,false);
}
return m;
}
function mdlRavager(){ // 12/13 — the swarm's line animal
const m=MB();
/* Body slung between the legs, not sitting on them: the thorax rides at 3.4
while the knees reach 5.0, which is the arachnid read. Two masses, not
three spheres in a row — cephalothorax carrying the head and jaws, and a
heavier plated abdomen behind it. */
/* ONE sculpted skin from jaw to tail, not three spheres in a row. The nose
taper, the segment ridges, the tubercle field and the dorsal keel are all
displacement inside the same surface, so nothing intersects and the whole
body reads as one grown thing. */
carapace(m,-0.7,3.3,0,11.4,3.6,2.1,CHITIN,
{segs:6,ridge:0.20,bump:0.115,keel:0.30,nose:0.60,tail:0.18,
waist:0.34,waistAt:0.52,waistW:0.075,seed:1201,u:13,v:16});
/* Head capsule sits proud of the front of that skin rather than inside it. */
carapace(m,4.0,3.0,0,3.4,2.1,1.6,CHIT_D,
{segs:3,ridge:0.16,bump:0.14,keel:0.22,nose:0.40,tail:0.44,seed:83,u:10,v:7});
eyeCluster(m,5.0,3.3,0,0.66,2,HOT);
/* Six legs, fanned. The stagger in reach/knee stops them marching in
parallel, which is what made the old boxes read as a table. */
for(let k=0;k<3;k++) for(const sd of [-1,1])
broodLeg(m, 1.9-k*2.2, 3.0, 0, sd, 3.4+k*0.20, 3.3, 2.30-k*0.16, 0.52, null,
((k+(sd<0?1:0))&1)*Math.PI, [2.35,1.85,2.40][k]);
/* Raptorial jaws — curved, opposed, and the widest thing on the animal. */
for(const sd of [-1,1]){
chitinBlade(m,4.2,2.7,sd*1.0,3.2,0.42,-sd*0.42,sd*0.30,CLAW);
chitinBlade(m,4.0,3.3,sd*0.5,2.1,0.26,-sd*0.30,sd*0.14,CLAW);
tendril(m,4.4,4.3,sd*0.8,4.2,0.20,sd,2.1,BIO_LEG); // sensory tendrils
}
/* Dorsal spines raked back along the abdomen, tallest over the shoulders. */
for(let k=0;k<3;k++)
chitinBlade(m,-0.4-k*1.7,4.4-k*0.18,0,2.3-k*0.28,0.36,0.16,Math.PI*0.94,CLAW,0.78-k*0.08);
/* Paired shoulder horns, the widest point of the carapace in plan — this is
the part that actually reads from the game's overhead camera. */
for(const sd of [-1,1]) chitinBlade(m,0.8,4.2,sd*1.7,3.0,0.34,sd*0.34,Math.PI*0.82,CLAW,0.52);
tubercles(m,1.2,3.9,0,1.6,2.4,4,0.56,CHIT_D,4404);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlAlphaRavager(){ // 13 — boss silhouette, never a scaled drone
const m=MB();
/* Same anatomy as the drone, half again the mass and carried higher: the
Alpha has to read as the SAME ANIMAL grown large, not as a different one,
so it uses the same sculpt and the same seven-jointed limbs with the
proportions pushed — deeper plates, a heavier abdomen, a taller stance. */
carapace(m,-0.9,4.4,0,17.0,5.2,3.1,CHITIN,
{segs:7,ridge:0.24,bump:0.125,keel:0.38,nose:0.58,tail:0.16,
waist:0.36,waistAt:0.54,waistW:0.070,seed:5501,u:15,v:18});
carapace(m,6.0,4.0,0,5.2,3.0,2.3,CHIT_D,
{segs:3,ridge:0.18,bump:0.15,keel:0.26,nose:0.38,tail:0.42,seed:311,u:11,v:8});
eyeCluster(m,7.6,4.5,0,1.05,3,HOT);
m.sphere(5.0,6.2,0,1.0,7,HOT,1,false); // exposed brood core
for(let k=0;k<4;k++) for(const sd of [-1,1])
broodLeg(m, 3.4-k*2.7, 4.0, 0, sd, 4.6+k*0.22, 4.4, 3.10-k*0.18, 0.78, null,
((k+(sd<0?1:0))&1)*Math.PI, [3.30,2.60,3.35,3.05][k]);
for(const sd of [-1,1]){
chitinBlade(m,6.8,3.9,sd*1.7,5.6,0.72,-sd*0.44,sd*0.28,CLAW,0.10); // oversized jaws
chitinBlade(m,6.6,4.7,sd*0.9,3.8,0.44,-sd*0.32,sd*0.13,CLAW,0.14);
tendril(m,7.0,5.9,sd*1.2,7.0,0.30,sd,3.2,BIO_LEG);
m.sphere(-1.2,7.0,sd*3.0,2.0,8,BIO_TEAM,0.9,false); // brood sacs
chitinBlade(m,1.2,5.6,sd*2.4,5.0,0.52,sd*0.30,Math.PI*0.84,CLAW,0.56);
}
/* Crown of spines, tallest over the shoulders — the boss read at 40px. */
for(let k=0;k<7;k++)
chitinBlade(m,-5.0+k*1.55,6.2,0,3.2+Math.sin(k/6*Math.PI)*2.6,0.42,0.12,Math.PI*0.96,CLAW,0.95);
tubercles(m,-4.0,5.6,0,3.4,2.8,9,0.92,CHIT_D,7);
return {hull:m.build(),tur:null,s:1.18};
}
function mdlCorvette(){ // 14 — naval light
const m=MB();
const waterline=[[-7.75,-2.18],[-6.45,-3.02],[3.65,-3.42],[7.25,-2.05],[8.25,-.72],
[8.25,.72],[7.25,2.05],[3.65,3.42],[-6.45,3.02],[-7.75,2.18]];
const deck=[[-7.05,-2.02],[-5.85,-2.62],[3.55,-2.92],[6.85,-1.72],[7.55,-.58],
[7.55,.58],[6.85,1.72],[3.55,2.92],[-5.85,2.62],[-7.05,2.02]];
m.extrude(0,0,0,waterline,2.28,MET_D); // flared displacement hull
m.extrude(0,2.28,0,deck,1.34,MET); // armoured weather deck
m.extrude(.20,3.62,0,deck.map(p=>[p[0]*.94,p[1]*.88]),.30,TEAM_A);
for(const sd of [-1,1]){
m.box(-.25,1.18,sd*3.04,10.8,1.02,.52,DARK); // continuous waterline belt
m.box(.60,2.54,sd*2.78,8.55,.42,.62,TEAM_A); // readable fleet stripe
for(let k=0;k<5;k++)
m.box(-4.10+k*2.18,1.56,sd*3.33,1.15,.36,.28,MET_L); // rub rail breaks
m.box(-5.20,1.02,sd*1.52,4.15,1.45,1.02,DARKER); // propulsion tunnel attached aft
tubeX(m,-8.08,1.12,sd*1.52,1.02,.72,.34,10,TWR_BORE); // hollow waterjet outlet
ringX(m,-8.12,1.12,sd*1.52,.68,.96,12,MET_L);
ringX(m,-8.15,1.12,sd*1.52,.25,.37,10,HOT);
m.bevelBox(1.75,3.92,sd*2.18,3.55,.68,1.15,.22,MET_D); // deck-edge equipment lockers
m.box(1.75,4.58,sd*2.18,2.75,.22,.86,TEAM_T);
}
m.bevelBox(-1.25,3.84,0,5.65,2.12,4.35,.52,MET_L); // integrated superstructure
m.extrude(-1.05,5.96,0,[[-2.15,-1.72],[1.45,-1.82],[2.42,-.86],[2.42,.86],
[1.45,1.82],[-2.15,1.72]],1.48,DARK);
m.bevelBox(-.62,7.44,0,3.25,1.08,2.62,.34,TEAM_A); // bridge roof / livery
for(const sd of [-1,1]){
m.box(1.30,6.43,sd*.91,.42,.72,1.35,GLASS); // wraparound bridge glazing
m.box(-1.08,6.84,sd*1.78,2.65,.38,.18,GLASS);
}
m.box(1.48,6.43,0,.32,.72,.62,GLASS);
ventBank(m,-3.42,4.06,0,2.25,3.35,5,MET_D,0);
kitBox(m,3.95,3.95,0,2.35,.94,2.45,MET_D,0);
for(const sd of [-1,1]) for(let k=0;k<3;k++)
m.tube(-4.65+k*.82,3.94,sd*.80,.30,.15,.48,8,DARKER); // six recessed VLS cells
sensorMast(m,-1.62,8.48,0,2.42,MET_L);
m.cyl(-1.62,9.72,0,1.72,.30,.30,12,MET_L); // search radar dish
m.box(-1.62,10.02,0,.24,.84,2.65,DARKER);
glowStrip(m,-.35,8.53,0,2.55,ENERGY,0);
m.greeble(.45,3.94,0,2.85,3.35,.34,6,MET_D,0,141);
const t=MB();
t.cyl(0,0,0,2.12,1.78,.78,12,DARKER);
turretBody(t,4.05,3.55,1.78,MET,TEAM_A);
t.bevelBox(-1.15,1.78,0,1.55,.68,2.60,.22,TEAM_T); // compact bustle marking
t.box(1.62,.62,0,1.18,1.12,1.65,DARK); // mantlet overlap
gunX(t,1.92,1.12,5.35,.40,MET_D);
t.box(-1.52,1.82,0,.82,.72,1.18,MET_L);
t.tube(-1.52,2.52,0,.31,.15,.44,8,LAMP);
t.greeble(-.38,1.83,0,1.65,2.25,.28,4,MET_D,0,151);
return {hull:m.build(),tur:t.build(),s:1.0,turH:3.8,naval:1};
}
function mdlDread(){ // 15 — naval heavy
const m=MB();
const waterline=[[-13.35,-4.15],[-11.55,-5.05],[6.55,-5.65],[11.75,-3.55],[13.35,-1.38],
[13.35,1.38],[11.75,3.55],[6.55,5.65],[-11.55,5.05],[-13.35,4.15]];
const deck=[[-12.55,-3.72],[-10.75,-4.52],[6.15,-5.05],[10.85,-3.08],[12.15,-1.12],
[12.15,1.12],[10.85,3.08],[6.15,5.05],[-10.75,4.52],[-12.55,3.72]];
m.extrude(0,0,0,waterline,3.58,MET_D); // deep dreadnought hull
m.extrude(0,3.58,0,deck,1.82,MET);
m.extrude(.25,5.40,0,deck.map(p=>[p[0]*.95,p[1]*.90]),.38,TEAM_A);
for(const sd of [-1,1]){
m.box(-.55,1.52,sd*5.05,19.2,1.18,.66,DARK); // full-length armour belt
m.box(.25,3.52,sd*4.72,16.4,.58,.82,TEAM_A); // broad faction stripe
for(let k=0;k<5;k++)
m.bevelBox(-7.85+k*3.60,2.18,sd*5.43,1.58,.62,.34,.10,MET_L);
const ez=2.48;
m.box(-9.65,1.40,sd*ez,5.25,2.05,1.42,DARKER); // one large turbine tunnel per side
tubeX(m,-13.72,1.55,sd*ez,1.28,.98,.46,10,TWR_BORE);
ringX(m,-13.76,1.55,sd*ez,.88,1.22,10,MET_L);
m.bevelBox(2.65,5.80,sd*3.75,5.25,.82,1.48,.26,MET_D); // deck-edge secondary battery deck
m.box(2.65,6.60,sd*3.75,4.15,.26,1.02,TEAM_T);
m.cyl(2.65,6.86,sd*3.75,.72,.58,.72,9,DARKER);
gunX(m,3.02,7.18,2.75,.22,MET_D,sd*3.75); // one readable secondary per beam
}
m.bevelBox(-2.45,5.78,0,9.15,3.12,7.85,.74,MET_L); // broad armoured island
m.extrude(-2.20,8.90,0,[[-3.75,-3.25],[2.55,-3.45],[4.05,-1.70],[4.05,1.70],
[2.55,3.45],[-3.75,3.25]],2.55,DARK);
m.extrude(-1.75,11.45,0,[[-2.85,-2.38],[1.85,-2.55],[3.05,-1.18],[3.05,1.18],
[1.85,2.55],[-2.85,2.38]],1.35,TEAM_A);
for(const sd of [-1,1]){
m.box(.72,9.64,sd*2.33,.48,1.02,1.55,GLASS); // bridge windows
m.box(-2.30,10.15,sd*3.32,3.35,.42,.22,GLASS);
exhaust(m,-5.70,8.92,sd*1.62,1,.48,2.20,MET_D,.96);
m.bevelBox(-7.45,5.82,sd*2.15,2.45,.78,1.58,.24,MET_D);
}
m.box(.95,9.64,0,.40,1.02,.88,GLASS);
ventBank(m,-6.45,5.82,0,3.15,5.45,7,MET_D,0);
for(let xk=0;xk<3;xk++) for(const sd of [-1,1])
m.tube(-8.75+xk*1.15,5.80,sd*1.18,.38,.19,.62,8,DARKER); // six armoured VLS cells
m.bevelBox(7.05,5.80,0,3.65,.76,5.30,.25,DARKER); // fore sensor / magazine block
m.greeble(7.05,6.58,0,2.60,4.20,.46,3,MET_D,0,161);
sensorMast(m,-2.65,12.80,0,3.20,MET_L);
m.cyl(-2.65,14.58,0,2.45,.42,.34,14,MET_L); // main air-search dish
m.box(-2.65,14.90,0,.32,1.22,3.82,DARKER);
sensorMast(m,-5.25,11.05,2.18,2.25,MET_D);
glowStrip(m,-.85,12.84,0,4.25,ENERGY,0);
m.greeble(-.30,5.80,0,5.25,5.25,.42,3,MET_D,0,171);
const t=MB();
t.cyl(0,0,0,3.65,3.05,1.12,14,DARKER);
turretBody(t,7.35,6.20,2.72,MET,TEAM_A);
t.bevelBox(-2.55,2.05,0,2.65,1.18,5.10,.34,TEAM_T); // turret bustle ownership panel
t.box(2.85,.78,0,2.20,1.72,3.80,DARK); // shared mantlet joins both barrels
for(const sd of [-1,1]){
gunX(t,3.42,1.62,9.45,.56,MET_D,sd*1.12); // true twin, not coincident geometry
t.box(-1.55,2.75,sd*2.35,2.75,.32,.54,MET_L);
}
t.bevelBox(-2.75,2.78,0,1.35,.92,2.35,.25,MET_L);
t.tube(-2.75,3.68,0,.38,.18,.56,8,LAMP);
t.greeble(-.25,2.74,0,3.65,3.72,.36,3,MET_D,0,181);
return {hull:m.build(),tur:t.build(),s:1.0,turH:5.6,naval:1};
}
function mdlBombard(){ // 16 — siege artillery
const m=MB();
runningGear(m,15.4,4.0,3.15,4.25,6,MET_D);
hullShell(m,14.8,9.2,2.35,1.45,MET,TEAM_A,0.34,0.74);
m.wedge(5.1,3.82,0,4.0,1.35,7.4,MET_L);
for(const sd of [-1,1]){
armorPlate(m,0.0,2.35,sd*4.9,11.0,2.0,0.48,0,MET_D,0);
m.box(-6.2,1.25,sd*5.35,3.2,2.5,1.15,MET_D);
m.wedge(-7.1,0,sd*5.35,3.2,1.0,3.0,TWR_PAD);
hydraulic(m,-5.2,2.7,sd*4.4,2.8,0.31,TWR_MACH,0);
kitBox(m,-2.2,3.84,sd*3.5,2.7,1.05,1.7,MET_D,0);
m.box(0.5,3.86,sd*4.15,7.5,0.50,0.70,TEAM_A);
}
ventBank(m,-5.0,3.84,0,3.8,4.8,5,MET_D,0);
exhaust(m,-7.0,3.0,0,3,0.43,2.2,MET_D,2.0);
sensorMast(m,-6.1,4.0,2.0,2.7,MET_L);
const t=MB();
turretBody(t,7.3,6.8,3.25,TWR_ARM,TWR_TEAM);
t.bevelBox(-3.0,0.9,0,2.6,2.8,5.4,0.45,TWR_COAT);
for(const sd of [-1,1]){
t.cyl(0.4,0.65,sd*2.75,1.32,1.15,1.0,10,TWR_MACH);
hydraulic(t,-1.5,0.2,sd*2.5,2.8,0.34,TWR_MACH,0);
t.box(-0.3,2.7,sd*3.35,5.2,0.52,0.42,TEAM_A);
cylX(t,0.6,2.35,sd*0.94,6.2,0.34,0.30,8,TWR_MACH,false);
}
gunX(t,1.25,1.75,15.4,0.78,TWR_MACH);
cylX(t,1.2,1.75,0,3.2,1.42,1.08,12,TWR_COAT,false);
for(const x of [7.4,12.2]) ringX(t,x,1.75,0,0.82,1.08,12,TWR_TRIM);
t.box(14.7,0.72,0,1.4,2.2,3.4,TWR_MACH);
tubeX(t,16.05,1.75,0,1.1,0.98,0.49,12,TWR_BORE);
kitBox(t,-3.2,3.35,0,2.0,1.25,4.4,MET_D,0);
return {hull:m.build(),tur:t.build(),s:1.0,turH:5.95};
}
function mdlRaptor(){ // 17 — gunship
const m=MB();
const keel=[[-6.35,-1.92],[-3.25,-2.55],[2.75,-2.72],[6.15,-1.18],[7.05,0],
[6.15,1.18],[2.75,2.72],[-3.25,2.55],[-6.35,1.92]];
const deck=[[-5.75,-1.68],[-2.8,-2.12],[2.65,-2.22],[5.55,-.92],[6.15,0],
[5.55,.92],[2.65,2.22],[-2.8,2.12],[-5.75,1.68]];
m.extrude(0,0,0,keel,1.55,MET_D);
m.extrude(0,1.55,0,deck,1.62,MET); // deep troop-carrying fuselage
m.extrude(-.25,3.17,0,deck.map(p=>[p[0]*.76,p[1]*.70]),.70,TEAM_A);
m.bevelBox(3.25,3.36,0,3.95,1.36,2.85,.46,GLASS); // armoured cockpit
m.wedge(5.30,3.80,0,1.85,.72,2.50,MET_L,0,true);
m.box(-1.20,3.90,0,4.35,.42,1.05,TEAM_T);
glowStrip(m,-.65,4.31,0,2.65,ENERGY,0);
ventBank(m,-3.55,3.86,0,2.45,1.48,4,DARK,0);
for(const sd of [-1,1]){
const wing=sd>0
? [[-3.95,1.55],[2.65,1.80],[1.25,6.05],[-4.70,5.30]]
: [[-3.95,-1.55],[-4.70,-5.30],[1.25,-6.05],[2.65,-1.80]];
m.extrude(0,1.20,0,wing,.78,MET_D); // thick gunship wing
m.box(-1.45,1.36,sd*3.45,5.35,1.12,2.55,DARK); // load-bearing engine pylon
cylX(m,-5.15,1.62,sd*3.78,5.95,1.42,1.15,11,DARK,false); // armoured turbine nacelle
ringX(m,-5.18,1.62,sd*3.78,1.03,1.48,12,MET_L);
tubeX(m,-5.67,1.62,sd*3.78,1.02,1.12,.55,12,TWR_BORE); // shadowed exhaust throat
ringX(m,-5.71,1.62,sd*3.78,.37,.58,12,HOT);
ringX(m,.35,1.62,sd*3.78,.88,1.20,12,MET_L);
m.bevelBox(-1.55,2.56,sd*3.78,2.75,.82,1.75,.28,TEAM_A); // nacelle livery fairing
ventBank(m,-1.55,3.39,sd*3.78,1.95,1.18,3,MET_D,0);
m.bevelBox(-.65,.55,sd*5.28,3.40,1.00,1.42,.28,DARKER); // attached stores pylon
for(let k=0;k<3;k++)
tubeX(m,-1.72+k*1.05,.73,sd*5.28,.85,.30,.14,7,HOT);
m.box(1.95,.48,sd*1.48,2.55,1.02,.95,DARK); // chin gun receiver
gunX(m,2.72,.82,3.55,.31,MET_D,sd*1.48);
m.box(-4.95,2.55,sd*2.25,2.25,2.80,.42,MET_L); // twin vertical tails
m.box(-4.55,4.75,sd*2.25,1.45,.38,.48,TEAM_A); // tail-tip livery
}
m.bevelBox(-5.08,1.82,0,2.75,1.62,3.80,.42,DARKER); // tail gearbox joins fins
m.box(-6.42,2.00,0,.60,1.20,2.75,MET_L); // rear cargo ramp edge
sensorMast(m,-4.20,3.38,0,1.52,MET_L);
m.greeble(-.90,3.88,0,3.05,1.10,.40,5,MET_D,0,117);
return {hull:m.build(),tur:null,s:1.0,air:1};
}
function mdlScorcher(){ // 18 — heavy flamer
const m=MB();
runningGear(m,15.2,4.0,3.25,4.25,6,MET_D);
hullShell(m,14.6,9.0,2.45,1.55,MET,TEAM_A,0.32,0.76);
m.wedge(5.0,4.02,0,4.2,1.45,7.4,MET_L);
for(const sd of [-1,1]){
armorPlate(m,0.2,2.45,sd*4.85,11.0,2.1,0.5,0,MET_D,0);
m.cyl(-4.2,4.05,sd*2.65,2.0,2.0,4.8,11,RUST);
m.ring(-4.2,5.1,sd*2.65,1.98,2.24,12,TWR_MACH);
m.ring(-4.2,7.5,sd*2.65,1.98,2.24,12,TWR_MACH);
m.box(0.5,4.04,sd*4.15,7.8,0.52,0.72,TEAM_A);
m.tube(-2.2,6.0,sd*4.2,0.38,0.20,1.5,8,TWR_BORE);
}
m.box(-4.2,8.92,0,5.4,0.65,6.2,DARKER);
ventBank(m,-6.1,4.05,0,3.0,4.8,5,MET_D,0);
exhaust(m,-7.0,3.1,0,3,0.45,2.4,MET_D,2.1);
const t=MB();
turretBody(t,6.2,5.8,3.0,TWR_ARM,TWR_TEAM);
t.bevelBox(-2.1,0.8,0,2.4,2.2,4.7,0.42,TWR_COAT);
for(const sd of [-1,1]){
t.box(-0.5,1.25,sd*2.9,4.2,1.0,0.42,TEAM_A);
hydraulic(t,-1.2,0.2,sd*2.2,2.2,0.30,TWR_MACH,0);
cylX(t,1.55,1.35,sd*1.05,5.4,0.72,0.62,10,TWR_MACH,false);
for(const x of [2.3,4.5]) ringX(t,x,1.35,sd*1.05,0.72,0.92,10,TWR_TRIM);
cylX(t,6.55,1.35,sd*1.05,1.1,0.92,1.28,10,TWR_COAT,false);
tubeX(t,7.25,1.35,sd*1.05,0.95,1.30,0.69,12,TWR_BORE);
t.sphere(8.05,1.35,sd*1.05,0.34,7,HOT,1,false);
}
t.box(-2.3,3.02,0,1.8,1.4,3.8,MET_D);
return {hull:m.build(),tur:t.build(),s:1.0,turH:6.1};
}
/* ---------------------------------------------------------------------------
FACTION CHASSIS KITS
Recolouring an identical model is not a faction. These are alternate builds
of the same three roles, so an enemy army is recognisable by SHAPE before
you read its colour:
legion — tracked, slab-armoured, blunt: the standard line, up-armoured
syndicate — hover skirts instead of treads, raked prow, exposed coils
horde — grown carapace, no plating, claws and spines
--------------------------------------------------------------------------- */
/* Nova's line tank rides on four clean electromagnetic runner pods. The
separated drive blocks, arrow hull and capacitor-fed accelerator preserve a
conventional battle-tank role without borrowing the old shared tread box. */
function mdlNovaRhino(){
const m=MB();
for(const sd of [-1,1]){
m.bevelBox(-.5,0,sd*4.0,12.2,1.7,2.15,.40,TWR_COAT);
m.bevelBox(-.3,1.7,sd*4.0,10.8,1.15,1.75,.30,TWR_ARM_D);
for(const x of [-4.0,0,4.0]){
m.cyl(x,.18,sd*4.0,.78,.68,1.05,10,TWR_MACH);
m.ring(x,1.25,sd*4.0,.55,.84,12,TWR_GLOW);
}
m.box(-.2,2.88,sd*4.9,6.8,.20,.34,sd>0?TEAM_A:TWR_GLOW);
}
m.extrude(0,2.1,0,[[-6.2,-3.15],[2.2,-3.45],[6.4,-1.55],[6.9,0],
[6.4,1.55],[2.2,3.45],[-6.2,3.15]],2.15,TWR_ARM_D);
m.extrude(-.2,4.25,0,[[-5.2,-2.45],[2.0,-2.75],[5.6,-1.15],[6.1,0],
[5.6,1.15],[2.0,2.75],[-5.2,2.45]],1.25,TWR_ARM);
m.wedge(4.2,4.6,0,3.7,1.35,5.2,MET_L,0,true);
m.bevelBox(-3.8,4.5,0,3.1,1.2,4.7,.30,TWR_COAT);
for(const sd of [-1,1]){
m.box(-1.0,5.52,sd*2.48,6.8,.22,.55,TEAM_A);
m.cyl(-4.5,5.0,sd*1.7,.62,.50,2.0,9,TWR_MACH);
m.ring(-4.5,6.72,sd*1.7,.56,.80,10,TWR_GLOW);
}
glowStrip(m,.6,5.58,0,7.4,ENERGY,0);
const t=MB();
t.cyl(-.8,0,0,3.6,3.1,1.2,14,TWR_PAD);
t.extrude(0,1.2,0,[[-3.8,-2.65],[1.7,-3.0],[3.9,-1.25],[4.3,0],
[3.9,1.25],[1.7,3.0],[-3.8,2.65]],2.2,TWR_ARM);
t.extrude(-.2,3.4,0,[[-3.0,-2.05],[1.4,-2.3],[3.2,-.9],[3.45,0],
[3.2,.9],[1.4,2.3],[-3.0,2.05]],.62,TEAM_T);
cylX(t,2.0,2.05,0,7.6,.66,.54,12,TWR_MACH,false);
for(const x of [3.0,5.2,7.4]) ringX(t,x,2.05,0,.72,1.00,12,x===7.4?TWR_GLOW:TWR_TRIM);
tubeX(t,9.45,2.05,0,.85,.76,.37,12,TWR_BORE);
for(const sd of [-1,1]){
t.bevelBox(-1.5,2.0,sd*2.65,3.6,1.4,.52,.20,TWR_ARM_D);
t.box(-1.5,3.34,sd*2.93,2.0,.18,.42,sd>0?TEAM_A:TWR_GLOW);
}
t.ring(-1.7,4.25,0,1.35,2.05,16,TWR_GLOW);
t.sphere(-1.7,4.25,0,.82,8,ENERGY,1,false);
return {hull:m.build(),tur:t.build(),s:1.0,turH:5.45,muzzle:9.8};
}
/* Ascendancy doctrine makes the same battlefield role a broad assault block:
full tracks, layered slabs, visible exhaust heat and a long recoil cannon. */
function mdlLegionRhino(){
const m=MB();
treads(m,14.0,3.25,3.15,4.25,6);
m.extrude(-.3,2.1,0,[[-7.0,-4.2],[3.3,-4.2],[6.8,-2.8],[7.2,-1.2],
[7.2,1.2],[6.8,2.8],[3.3,4.2],[-7.0,4.2]],2.7,TWR_ARM_D);
m.bevelBox(-1.0,4.8,0,11.8,2.0,6.9,.65,TWR_ARM);
m.wedge(4.6,4.85,0,4.3,1.8,7.0,TWR_ARM,0,true);
for(const sd of [-1,1]){
armorPlate(m,-.1,2.4,sd*4.55,11.8,2.1,.54,0,TWR_COAT,0);
m.box(-1.8,6.78,sd*3.1,5.8,.24,.68,TEAM_A);
m.cyl(-5.2,4.85,sd*2.55,.82,.72,2.7,10,TWR_MACH);
m.ring(-5.2,6.9,sd*2.55,.78,1.04,12,HOT);
}
ventBank(m,-4.8,6.82,0,3.6,4.8,5,TWR_MACH,0);
m.bevelBox(-6.0,3.2,0,2.0,2.8,5.4,.42,DARKER);
m.box(3.8,6.85,0,3.8,.24,2.2,TWR_TRIM);
const t=MB();
t.cyl(-1.0,0,0,4.1,3.65,1.25,14,TWR_PAD);
t.bevelBox(-.8,1.25,0,8.6,3.2,6.9,.80,TWR_ARM_D);
t.bevelBox(-.4,4.45,0,7.5,1.0,5.8,.34,TWR_ARM);
for(const sd of [-1,1]){
t.bevelBox(-1.8,1.8,sd*3.2,4.2,2.1,.58,.24,TWR_COAT);
t.box(-1.8,3.82,sd*3.51,2.5,.20,.54,sd>0?TEAM_A:HOT);
hydraulic(t,-2.2,.55,sd*2.55,2.4,.28,TWR_MACH,0);
}
gunX(t,2.7,2.3,9.2,.72,TWR_MACH);
cylX(t,2.0,2.3,0,3.3,1.28,1.02,12,TWR_COAT,false);
for(const x of [5.5,8.7]) ringX(t,x,2.3,0,.82,1.12,12,x>8?HOT:TWR_TRIM);
t.box(10.5,1.35,0,1.4,2.2,3.6,TWR_MACH);
tubeX(t,11.8,2.3,0,.92,.84,.42,12,TWR_BORE);
t.bevelBox(-4.1,1.4,0,2.5,2.6,5.0,.42,DARKER);
return {hull:m.build(),tur:t.build(),s:1.0,turH:6.0,muzzle:12.2};
}
/* A role-specific Syndicate skimmer. Three exposed lift banks and a forked
coil weapon replace both tracks and a conventional gun tube. */
function mdlSyndicateRhino(){
const m=MB();
m.extrude(0,0,0,[[-6.8,-3.5],[-2.4,-4.5],[3.1,-4.2],[6.8,-1.7],[7.2,0],
[6.8,1.7],[3.1,4.2],[-2.4,4.5],[-6.8,3.5]],1.35,TWR_COAT);
for(const sd of [-1,1]) for(const x of [-4.4,0,4.4]){
m.cyl(x,.15,sd*3.55,.75,.62,1.0,10,TWR_MACH);
m.ring(x,1.18,sd*3.55,.54,.88,12,ENERGY);
m.sphere(x,1.20,sd*3.55,.42,7,TWR_GLOW,.72,false);
}
m.extrude(-.2,1.4,0,[[-6.1,-3.05],[-1.8,-3.7],[3.0,-3.25],[6.4,-1.25],[6.9,0],
[6.4,1.25],[3.0,3.25],[-1.8,3.7],[-6.1,3.05]],2.05,TWR_ARM_D);
m.extrude(.2,3.45,0,[[-4.9,-2.35],[-1.0,-2.85],[3.2,-2.45],[5.7,-.9],[6.1,0],
[5.7,.9],[3.2,2.45],[-1.0,2.85],[-4.9,2.35]],1.4,TWR_ARM);
for(const sd of [-1,1]){
m.wedge(3.9,3.55,sd*3.3,4.2,1.2,1.5,TWR_ARM,sd*.10,sd<0);
m.box(-.4,4.88,sd*2.78,6.8,.20,.52,TEAM_A);
m.bevelBox(-4.7,3.0,sd*2.1,2.4,1.4,1.5,.25,DARKER);
}
m.ring(-3.7,5.45,0,1.4,2.3,16,ENERGY);
m.box(-3.7,5.38,0,4.8,.20,.58,TEAM_T);
const t=MB();
t.cyl(-.8,0,0,3.6,3.15,1.0,14,TWR_PAD);
t.extrude(0,1.0,0,[[-3.9,-2.5],[1.6,-3.0],[4.0,-1.0],[4.4,0],
[4.0,1.0],[1.6,3.0],[-3.9,2.5]],2.1,TWR_ARM_D);
t.extrude(.1,3.1,0,[[-3.0,-1.9],[1.3,-2.3],[3.2,-.75],[3.5,0],
[3.2,.75],[1.3,2.3],[-3.0,1.9]],.65,TEAM_T);
for(const sd of [-1,1]){
const z=sd*1.35;
cylX(t,2.0,2.05,z,5.6,.64,.50,11,TWR_MACH,false);
for(const x of [2.8,4.4,6.0]) ringX(t,x,2.05,z,.70,.98,12,sd>0?TWR_GLOW:ENERGY);
tubeX(t,7.45,2.05,z,.85,.72,.35,12,TWR_BORE);
t.bevelBox(-.5,1.5,sd*2.6,4.5,1.6,.55,.22,TWR_COAT);
}
t.sphere(-2.3,3.8,0,1.5,9,TWR_GLOW,1,false);
ringX(t,-2.3,3.8,0,1.75,2.35,16,TWR_TRIM);
return {hull:m.build(),tur:t.build(),s:.98,turH:5.05,muzzle:7.8,muzzleZ:1.35};
}
function mdlSynTank(){
const m=MB();
// hover skirt: a continuous chamfered plenum where treads would be
m.extrude(0,0,0,[[-6.6,-3.8],[3.0,-4.2],[6.6,-2.0],[6.6,2.0],[3.0,4.2],[-6.6,3.8]],1.5,DARKER);
m.extrude(0,1.5,0,[[-6.2,-3.4],[2.8,-3.8],[6.2,-1.8],[6.2,1.8],[2.8,3.8],[-6.2,3.4]],1.3,MET_D);
for(const sd of [-1,1]) for(let k=0;k<4;k++) // lift coils
m.cyl(-4.2+k*3.0,0.3,sd*3.4,0.7,0.62,1.0,8,ENERGY);
m.extrude(0,2.8,0,[[-5.4,-2.8],[2.4,-3.2],[6.0,-1.2],[6.0,1.2],[2.4,3.2],[-5.4,2.8]],2.0,MET);
m.wedge(4.0,4.8,0,3.6,1.4,5.6,TEAM_A); // raked prow
m.extrude(0,4.8,0,[[-4.4,-2.2],[2.0,-2.6],[4.6,-1.0],[4.6,1.0],[2.0,2.6],[-4.4,2.2]],0.6,TEAM_T);
glowStrip(m,0,5.45,0,7.0,ENERGY,0);
for(const sd of [-1,1]){
m.cyl(-5.2,3.4,sd*2.2,0.55,0.45,2.6,8,MET_D); // vent stacks
m.tube(-5.2,6.0,sd*2.2,0.52,0.30,0.4,8,ENERGY);
}
m.bevelBox(-4.0,4.8,0,2.6,1.2,4.4,0.3,DARK);
sensorMast(m,-4.0,6.0,1.4,2.2,MET_L);
const t=MB();
t.extrude(0,0,0,[[-3.0,-2.4],[1.8,-2.8],[3.6,-1.2],[3.6,1.2],[1.8,2.8],[-3.0,2.4]],2.2,MET);
t.extrude(0,2.2,0,[[-2.4,-1.9],[1.4,-2.2],[3.0,-0.9],[3.0,0.9],[1.4,2.2],[-2.4,1.9]],0.7,TEAM_T);
// twin coil emitters instead of a barrel: energy weapon, visibly
for(const sd of [-1,1]){
t.cyl(2.0,1.1,sd*1.1,0.55,0.42,4.4,8,MET_D);
t.cyl(6.2,1.1,sd*1.1,0.62,0.30,0.9,8,ENERGY);
for(let k=0;k<3;k++) t.cyl(2.6+k*1.2,1.1,sd*1.1,0.78,0.78,0.30,8,ENERGY);
}
t.cyl(-2.4,2.9,0,1.0,0.5,1.4,9,ENERGY);
return {hull:m.build(),tur:t.build(),s:1.0,turH:5.0,muzzle:7.1,muzzleZ:1.1};
}
function bioLegBank(m,n,span,reach,y){
for(let k=0;k<n;k++) for(const sd of [-1,1]){
const x=span*(.5-k/Math.max(1,n-1)), z=sd*(reach*.58+k%2*.22);
m.sphere(x,y+.2,z,.72,7,CHIT_D,.72,false);
m.box(x,y,sd*reach,3.2,.58,.72,BIO_LEG,sd*(.16+k*.035));
m.box(x-.25,.18,sd*(reach+1.45),2.2,.48,.62,BIO_LEG,sd*(.34+k*.025));
m.sphere(x+.65,.12,sd*(reach+2.25),.44,6,CLAW,.72,false);
}
}
function bioCrown(m,x,y,count,spread,height){
for(let k=0;k<count;k++){
const q=count<2?0:k/(count-1)-.5;
m.cyl(x+q*spread,y,0,.48-Math.abs(q)*.14,.04,height*(1-Math.abs(q)*.25),6,BIO_LEG);
}
}
function mdlHordeBeast(){
const m=MB();
bioLegBank(m,4,6.6,4.8,1.55);
m.sphere(0,2.2,0,4.6,10,CHITIN,.80,false);
m.sphere(-4.4,2.6,0,3.8,9,CHIT_D,.74,false);
m.sphere(4.0,2.0,0,3.0,9,CHIT_D,.88,false);
for(const sd of [-1,1]){
m.box(6.4,2.0,sd*1.2,3.4,.66,.66,BIO_LEG,sd*.12);
m.box(8.0,1.8,sd*.7,2.0,.5,.5,CLAW,sd*.18);
}
bioCrown(m,-.2,5.5,5,6.4,3.2);
m.sphere(0,5.0,0,1.4,7,BIO_TEAM,1,false);
m.sphere(-4.4,5.0,0,1.1,7,BIO_TEAM,1,false);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlHordeSpitter(){
const m=MB(); bioLegBank(m,3,5.7,4.5,1.35);
m.sphere(-1.5,2.0,0,4.2,10,CHITIN,.76,false);
m.sphere(-4.8,2.5,0,3.8,9,CHIT_D,.92,false);
for(const sd of [-1,1]){
m.sphere(-3.8,4.8,sd*1.9,1.25,7,BIO_TEAM,.92,false);
m.box(2.6,1.2,sd*2.7,4.2,.58,.72,BIO_LEG,sd*.16);
}
bioCrown(m,-3.8,5.7,4,4.8,2.5);
const t=MB();
t.sphere(-1.0,.1,0,3.0,9,CHITIN,.72,false);
t.sphere(.8,.5,0,2.1,8,CHIT_D,.82,false);
/* A telescoping throat, not a barrel. Overlapping unequal lobes break every
straight manufactured edge while retaining a readable forward weapon. */
for(let k=0;k<5;k++){
const r=1.34-k*.17;
t.sphere(2.0+k*1.05,.48+Math.sin(k*.9)*.15,0,r,7,k&1?CHITIN:CHIT_D,.72,false);
}
t.sphere(6.6,.46,0,.82,7,LAMP,.62,false); // wet acid lumen
for(const sd of [-1,1]){
for(let k=0;k<3;k++) t.sphere(1.5+k*1.4,.1,sd*(1.18-k*.20),.48-k*.07,6,BIO_LEG,.68,false);
t.box(5.7,.12,sd*.76,2.0,.30,.30,CLAW,sd*.18); // soft mouth hook
t.sphere(-.4,2.0,sd*1.45,.72,6,BIO_TEAM,1,false);
}
return {hull:m.build(),tur:t.build(),s:1.0,turH:4.7};
}
function mdlHordeBombardier(){
const m=MB(); bioLegBank(m,4,7.0,5.2,1.45);
m.sphere(-2.5,2.6,0,5.3,11,CHIT_D,.92,false);
m.sphere(2.8,2.0,0,3.6,9,CHITIN,.76,false);
for(const sd of [-1,1]){
m.sphere(-4.2,5.9,sd*2.2,1.6,8,BIO_TEAM,1,false);
m.box(-.4,1.6,sd*4.0,6.2,.72,.82,BIO_LEG,sd*.1);
}
bioCrown(m,-2.6,7.1,7,8.0,4.2);
const t=MB();
t.sphere(-1.6,.1,0,3.4,10,CHITIN,.74,false);
t.sphere(1.0,.55,0,2.4,8,CHIT_D,.84,false);
for(let k=0;k<7;k++){
const r=1.65-k*.15;
t.sphere(2.5+k*1.25,.62+Math.sin(k*.7)*.20,0,r,8,k%2?CHITIN:CHIT_D,.72,false);
if(k<5) for(const sd of [-1,1])
t.sphere(2.6+k*1.25,.35,sd*(r*.82),.34,6,BIO_LEG,.70,false);
}
t.sphere(10.2,.62,0,.90,7,LAMP,.65,false);
for(const sd of [-1,1]) t.sphere(-1.0,2.5,sd*1.7,.82,7,BIO_TEAM,1,false);
return {hull:m.build(),tur:t.build(),s:1.06,turH:5.7};
}
function mdlHordeSupport(){
const m=MB(); bioLegBank(m,3,5.6,4.8,1.25);
m.sphere(-1.8,2.0,0,4.4,10,CHITIN,.80,false);
m.sphere(-5.0,2.8,0,4.0,10,CHIT_D,1.08,false);
for(let k=0;k<6;k++){
const a=k/6*TAU;
m.sphere(-4.8+Math.cos(a)*2.8,5.2,Math.sin(a)*2.8,1.05,7,BIO_TEAM,1,false);
}
for(const sd of [-1,1]){
m.box(1.0,1.0,sd*4.4,5.4,.56,.68,BIO_LEG,sd*.2);
m.cyl(1.8,3.5,sd*2.3,.42,.06,3.8,6,BIO_LEG);
}
bioCrown(m,-3.4,6.2,5,6.2,3.8);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlHordeFlyer(){
const m=MB();
m.sphere(-.4,.5,0,3.3,10,CHITIN,.52,false);
m.sphere(-3.4,.65,0,2.7,9,CHIT_D,.68,false);
m.sphere(2.5,.35,0,2.1,8,CHIT_D,.74,false);
for(const sd of [-1,1]){
/* Two asymmetrical membrane lobes per side read as an insect wing from
above. The perimeter is curved/segmented; there is no rectangular panel. */
const fore=[[-5,sd*1.0],[-2,sd*3.5],[2,sd*7.4],[5,sd*10.7],[3,sd*13.0],[-1,sd*11.4],[-5,sd*5.0]];
const hind=[[-5,sd*1.0],[-4,sd*4.2],[-2,sd*8.7],[1,sd*10.0],[2,sd*7.5],[-.5,sd*3.0]];
m.extrude(-.8,.28,0,fore,.22,BIO_MEM);
m.extrude(-3.8,.14,0,hind,.18,BIO_MEM);
m.box(0,.55,sd*5.7,10.0,.16,.24,BIO_LEG,sd*1.15);
m.box(-3.0,.42,sd*4.7,7.6,.15,.21,BIO_LEG,sd*1.30);
for(let k=0;k<5;k++) m.sphere(-4+k*1.65,.48,sd*(1.2+k*2.15),.38-k*.035,6,BIO_LEG,.62,false);
for(let k=0;k<3;k++) m.sphere(1.8+k*.9,.12,sd*(1.0-k*.22),.44-k*.08,6,BIO_LEG,.68,false);
m.sphere(-2.4,1.8,sd*1.65,.76,7,BIO_TEAM,1,false);
}
bioCrown(m,-3.4,2.4,3,3.6,2.3);
return {hull:m.build(),tur:null,s:1.0,air:1};
}
function mdlHordeSwimmer(){
const m=MB();
m.sphere(-1.0,1.0,0,5.2,12,CHITIN,.48,false);
m.sphere(-5.2,1.1,0,3.8,10,CHIT_D,.60,false);
m.sphere(4.0,.8,0,3.0,9,CHIT_D,.58,false);
for(const sd of [-1,1]){
m.wedge(-.8,.55,sd*5.0,9.8,.32,3.4,BIO_MEM,sd*.18,sd<0);
m.box(-5.8,.25,sd*2.5,5.0,.42,.72,BIO_LEG,sd*.25);
m.box(5.5,.35,sd*1.7,4.6,.48,.58,BIO_LEG,sd*.28);
m.sphere(-1.5,3.2,sd*2.6,1.05,7,BIO_TEAM,1,false);
}
bioCrown(m,-1.5,4.2,5,6.2,2.8);
return {hull:m.build(),tur:null,s:1.06};
}
function mdlHordeLeviathan(){
const m=MB(); bioLegBank(m,5,9.2,6.1,2.0);
m.sphere(-1.2,3.2,0,6.4,12,CHITIN,.80,false);
m.sphere(-6.2,3.8,0,5.6,11,CHIT_D,.98,false);
m.sphere(4.8,2.8,0,4.2,10,CHIT_D,.78,false);
for(const sd of [-1,1]){
m.box(7.0,2.6,sd*1.8,5.6,.9,.9,BIO_LEG,sd*.12);
m.box(9.7,2.1,sd*.9,3.2,.68,.68,CLAW,sd*.2);
m.sphere(-3.2,7.8,sd*3.1,2.2,9,BIO_TEAM,1,false);
}
bioCrown(m,-2.0,8.4,9,10.0,6.0);
return {hull:m.build(),tur:null,s:1.16};
}
/* ============================================================================
FACTION INFANTRY DOCTRINE
Only the RED and BLUE armies field humans. Nova (blue) runs the standard
rifleman; the Crimson Dominion (red) runs the same species in siege plate —
visibly heavier, shielded, slower-looking even at 15px. The Syndicate is a
CYBERNETIC force and fields no humanoids at all: its infantry slot is a
strider drone, all raked edges and hover mass, because a machine culture
sending flesh into small-arms fire would contradict everything else on its
roster. The Brood was already animals. Doctrine lives in the KIT, so both
the player's army and the AI's obey it automatically.
============================================================================ */
function mdlLegionTrooper(){ // red — breacher in siege plate
const m=MB();
const b=mfTrooper(m,{h:8.4,w:1.38,chest:MET_D,helm:MET_D,pack:MET_D});
/* full-face helm: visor slit only, plus a crest ridge */
m.box(0.72,b.sh+0.30,0,0.30,0.20,1.05,GLASS);
m.wedge(0.1,b.hd+0.05,0,1.0,0.5,0.5,TEAM_A);
/* TOWER SHIELD on the left arm — the one-item read for "more armored".
Bevelled slab, raked back, with a vision notch. */
m.bevelBox(1.15,b.hip+0.3,-1.95,0.55,4.6,2.6,0.4,MET);
m.box(1.45,b.hip+3.3,-1.95,0.2,0.7,1.2,DARKER); // vision slit
m.wedge(1.15,b.hip+4.9,-1.95,0.5,0.8,2.5,MET_L,0,true); // shield crown
/* stub carbine on the right — short, because the shield is the weapon */
const gy=b.hip+1.7;
m.bevelBox(1.5,gy,0.7,1.9,0.6,0.5,0.16,MET_D);
m.cyl(2.6,gy+0.1,0.7,0.16,0.13,1.1,6,DARKER,Math.PI/2);
m.box(1.7,gy-0.5,0.7,0.4,0.55,0.4,MET_D);
glowStrip(m,-0.6,b.sh+0.4,0,0.8,HOT,0);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlLegionPyro(){ // red — furnace trooper
const m=MB();
const b=mfTrooper(m,{h:8.2,w:1.5,chest:RUST,helm:MET_D,pack:MET_D});
m.cyl(-1.3,b.hip+0.4,0,0.85,0.85,3.6,9,RUST); // single fat boiler tank
m.cyl(-1.3,b.hip+4.0,0,0.85,0.4,0.6,9,MET_D);
m.cyl(-1.3,b.hip+2.0,0,0.9,0.9,0.26,9,MET_L); // band
m.cyl(-0.7,b.sh-0.5,0.3,0.18,0.15,2.1,5,DARKER,Math.PI/2); // hose
const gy=b.hip+1.5;
m.bevelBox(1.6,gy,0.2,2.3,0.85,0.75,0.2,MET_D); // heavy projector
m.tube(3.4,gy+0.1,0.2,0.45,0.26,1.1,8,MET_L,Math.PI/2);
m.cyl(4.4,gy+0.1,0.2,0.15,0.12,0.5,5,HOT,Math.PI/2);
m.wedge(2.4,gy+0.75,0.2,1.6,0.5,1.0,MET_D); // heat shield
glowStrip(m,3.6,gy+0.5,0.2,0.8,HOT,0);
return {hull:m.build(),tur:null,s:1.05};
}
function mdlSynStrider(){ // cybernetic — infantry-slot drone
const m=MB();
/* No box anywhere: a raked diamond chassis, a halo, and three blade legs.
The profile is one continuous line front to back — this is the "flows
cleanly to the eye" read, and it is what separates the machine culture
from the humans at a glance. */
m.extrude(0,3.4,0,[[-3.4,0],[-1.8,-1.6],[1.6,-1.9],[3.8,-0.7],[4.6,0],
[3.8,0.7],[1.6,1.9],[-1.8,1.6]],1.5,MET);
m.extrude(0.2,4.9,0,[[-2.6,0],[-1.3,-1.2],[1.3,-1.4],[3.0,-0.5],[3.5,0],
[3.0,0.5],[1.3,1.4],[-1.3,1.2]],0.8,TEAM_A);
m.wedge(3.2,5.7,0,1.6,0.6,1.2,MET_L); // brow blade
m.sphere(1.9,5.9,0,0.55,8,ENERGY,1,false); // optic
m.tube(-1.2,5.6,0,1.5,1.15,0.4,14,MET_L); // dorsal halo
m.sphere(-1.2,5.6,0,0.7,8,ENERGY,0.8,false); // core in the halo
for(const k of [0,1,2]){ // three blade legs
const a=k/3*TAU+Math.PI/6, cx=Math.cos(a), cz=Math.sin(a);
m.wedge(cx*1.6,1.8,cz*1.6,0.5,2.2,0.9,MET_D,a);
m.cyl(cx*2.3,0.2,cz*2.3,0.34,0.05,1.6,5,MET_L); // blade tip
}
for(const sd of [-1,1])
m.wedge(-2.6,4.0,sd*1.1,2.2,0.5,0.7,TEAM_A,sd*0.35,sd<0); // swept vanes
thrusterBell(m,-3.4,3.9,0,0.5,1.2,MET_L); // rear lifter
m.cyl(0.8,4.6,0,0.2,0.16,2.2,5,DARKER,Math.PI/2); // underslung emitter
m.cyl(3.0,4.6,0,0.3,0.14,0.5,6,ENERGY,Math.PI/2);
return {hull:m.build(),tur:null,s:0.95};
}
function mdlSynIncinerator(){ // cybernetic — plasma scorcher drone
const m=MB();
m.extrude(0,3.2,0,[[-3.8,0],[-2.0,-2.0],[2.2,-2.2],[4.2,0],[2.2,2.2],[-2.0,2.0]],1.7,MET);
m.extrude(-0.2,4.9,0,[[-2.8,0],[-1.4,-1.5],[1.6,-1.7],[3.1,0],[1.6,1.7],[-1.4,1.5]],0.8,TEAM_A);
m.tube(0.2,5.8,0,1.8,1.4,0.5,14,MET_L); // containment ring
m.sphere(0.2,5.8,0,0.9,9,HOT,0.8,false); // plasma core
for(const sd of [-1,1]){
m.wedge(-2.8,4.2,sd*1.3,2.6,0.6,0.8,MET_D,sd*0.3,sd<0);
thrusterBell(m,-3.8,3.6,sd*0.9,0.42,1.0,MET_L);
}
m.cyl(3.4,4.0,0,0.5,0.24,1.6,7,MET_L,Math.PI/2); // projector throat
m.cyl(4.8,4.0,0,0.20,0.16,0.6,5,HOT,Math.PI/2);
for(const k of [0,1,2]){
const a=k/3*TAU+Math.PI/6, cx=Math.cos(a), cz=Math.sin(a);
m.wedge(cx*1.7,1.7,cz*1.7,0.55,2.0,1.0,MET_D,a);
}
return {hull:m.build(),tur:null,s:1.0};
}
const FAC_KIT={
nova:{1:mdlNovaRhino},
legion:{0:mdlLegionTrooper,9:mdlLegionPyro,1:mdlLegionRhino},
syndicate:{0:mdlSynStrider,9:mdlSynIncinerator,1:mdlSyndicateRhino,2:mdlSynTank,6:mdlSynTank},
/* Every type an old save, factory, airfield, harbor or transit gate can place
for the Brood resolves to grown tissue. There is deliberately no shared
mechanical fallback hidden under an organic decoration layer.
THIS TABLE IS NOW A FALLBACK, NOT THE ROSTER.
It mapped 28 slots onto nine builders called with NO arguments, so twelve
of them were not merely similar — mdlHordeSpitter served slots 1, 2, 7,
10, 20 and 21 and mdlHordeBombardier served 3, 6, 16, 22, 26 and 27, each
rendering ONE shared vertex buffer. A tier-1 line brawler and a tier-3
siege caster were the same animal.
Those twelve are now split into twelve builders in models-units-brood.js
and registered in UNIT_MDL_BROOD, which mergeFactionUnitKits() copies over
this table at init — so for slots 1,2,3,6,7,10,12,13,16,20,21,22,26,27 and
30..32 the entry below never runs. They stay here on purpose: they are the
only thing standing between a failed load of that file and the Brood
fielding somebody else's tanks. Edit the split units THERE, not here; a
forward reference to them from this literal would be a ReferenceError,
because in dev the manifest loads each src file as its own script and this
one executes first. */
horde:{
0:mdlHordeBeast,1:mdlHordeSpitter,2:mdlHordeSpitter,3:mdlHordeBombardier,
4:mdlHordeLeviathan,5:mdlHordeFlyer,6:mdlHordeBombardier,7:mdlHordeSpitter,
8:mdlHordeLeviathan,9:mdlHordeBeast,10:mdlHordeSpitter,11:mdlHordeSupport,
12:mdlRavager,13:mdlAlphaRavager,14:mdlHordeSwimmer,15:mdlHordeSwimmer,
16:mdlHordeBombardier,17:mdlHordeFlyer,18:mdlHordeLeviathan,19:mdlHordeSupport,
20:mdlHordeSpitter,21:mdlHordeSpitter,22:mdlHordeBombardier,23:mdlHordeSupport,
24:mdlHordeSupport,25:mdlHordeFlyer,26:mdlHordeBombardier,27:mdlHordeBombardier
},
};
const FAC_MESH={}; // kit -> {type -> {hull,tur,...}}
/* A faction replacement cannot sensibly turn artillery, aircraft and support
vehicles into the same hover tank or beetle. These light doctrine shells
sit over any role chassis that does NOT have a bespoke replacement: the role
remains readable, while armour profile, propulsion language and biology make
the owner readable too. They are geometry (not a colour wash), because the
user's complaint is still valid in greyscale and at minimap-adjacent zoom. */
function mdlNovaDoctrine(air){
const m=MB();
if(air){
for(const sd of [-1,1]){
m.box(-.4,.52,sd*4.45,10.2,.42,1.05,ENERGY,sd*.06);
for(const x of [-3.0,.2,3.2]){
m.ring(x,.78,sd*4.45,.62,1.08,12,ENERGY);
m.sphere(x,.82,sd*4.45,.50,7,ENERGY,.74,false);
}
m.wedge(2.3,.68,sd*5.25,5.5,.38,1.25,TEAM_T,sd*.1,sd<0);
}
m.sphere(-2.8,1.28,0,1.25,9,ENERGY,1,false);
}else{
for(const sd of [-1,1]){
m.box(-.3,2.45,sd*4.65,10.4,.50,1.24,ENERGY);
for(const x of [-3.0,0,3.0]){
m.ring(x,2.72,sd*4.65,.66,1.16,12,ENERGY);
m.sphere(x,2.76,sd*4.65,.52,7,ENERGY,.76,false);
}
m.wedge(3.8,2.15,sd*5.0,3.8,.45,1.35,TEAM_T,0,sd<0);
}
m.ring(-2.9,5.1,0,1.28,2.12,16,ENERGY);
m.sphere(-2.9,5.1,0,1.05,9,ENERGY,1,false);
}
return m.build();
}
function mdlLegionDoctrine(air){
const m=MB();
if(air){
for(const sd of [-1,1]){
m.wedge(-0.4,0.5,sd*5.0,8.8,0.75,2.2,TWR_ARM_D,0,sd<0);
m.box(-0.6,1.28,sd*5.75,5.8,0.26,1.45,TEAM_A);
m.bevelBox(-2.7,0.1,sd*3.3,2.9,1.7,2.0,.28,TWR_COAT);
m.box(-4.2,1.82,sd*3.3,1.2,.22,.72,HOT);
}
m.wedge(3.8,0.45,0,4.4,1.0,3.0,TWR_ARM,0,false);
}else{
for(const sd of [-1,1]){
m.bevelBox(-0.6,1.0,sd*5.2,10.8,2.5,1.35,.30,TWR_ARM_D);
m.box(-0.2,3.55,sd*5.85,6.8,.24,1.55,TEAM_A);
m.wedge(4.8,1.05,sd*5.2,2.8,2.35,1.35,TWR_ARM,0,sd<0);
m.bevelBox(-4.9,3.2,sd*3.25,1.35,3.6,1.25,.24,TWR_COAT);
m.box(-4.9,6.84,sd*3.25,.72,.28,.72,HOT);
}
m.bevelBox(-3.2,4.5,0,3.4,1.0,5.0,.22,TWR_ARM_D);
}
return m.build();
}
function mdlSyndicateDoctrine(air){
const m=MB();
if(air){
for(const sd of [-1,1]){
m.wedge(-0.5,0.35,sd*5.35,9.8,.52,2.0,TWR_COAT,sd*.10,sd<0);
m.box(0,0.91,sd*6.15,5.6,.18,.72,TEAM_T);
m.cyl(-2.7,.30,sd*4.35,.72,.62,.46,10,TWR_MACH);
m.ring(-2.7,.78,sd*4.35,.76,1.08,12,ENERGY);
}
m.wedge(4.2,.4,0,4.8,.72,2.6,TWR_ARM,0,false);
}else{
/* The side plena hide the shared running gear at command zoom; exposed
lift coils and a raked fork carry the Coalition's contact-sheet design. */
for(const sd of [-1,1]){
m.bevelBox(-.4,.18,sd*4.85,11.4,1.75,1.65,.34,TWR_COAT);
m.box(-.2,1.96,sd*5.05,7.2,.20,1.24,TEAM_T);
for(const x of [-3.6,0,3.6]){
m.cyl(x,.16,sd*5.0,.62,.56,.48,10,TWR_MACH);
m.ring(x,.66,sd*5.0,.68,1.02,12,ENERGY);
}
m.wedge(4.9,1.1,sd*4.7,3.1,2.1,1.5,TWR_ARM,0,sd<0);
}
m.ring(-2.7,3.8,0,1.2,2.05,16,ENERGY);
m.box(-2.7,3.75,0,4.8,.22,.62,TEAM_T);
}
return m.build();
}
const FAC_DOCTRINE_MDL={
nova:{ground:()=>mdlNovaDoctrine(false),air:()=>mdlNovaDoctrine(true)},
legion:{ground:()=>mdlLegionDoctrine(false),air:()=>mdlLegionDoctrine(true)},
syndicate:{ground:()=>mdlSyndicateDoctrine(false),air:()=>mdlSyndicateDoctrine(true)},
};
const FAC_DOCTRINE_MESH={};
/* THE FACTION KITS WERE THREE UNITS DEEP.
Buildings were 27/27 for every army, but FAC_KIT — the UNIT overrides — held
three entries for the Dominion and five for the Coalition against a 33-slot
roster. Every other chassis fell through to the Nova hull with a thin
doctrine shell over it, which is exactly what "still using the blue faction's
models" looks like from the outside. The per-faction unit kits live in their
own files and are merged here rather than at their own top level, because
FAC_KIT is declared in this file and they load after it. */
function mergeFactionUnitKits(){
const add=(kit,map)=>{ if(!map||!FAC_KIT[kit]) return 0;
let n=0; for(const k in map){ FAC_KIT[kit][k]=map[k]; n++; } return n; };
const n=[
add('legion', typeof UNIT_MDL_LEGION !=='undefined'?UNIT_MDL_LEGION :null),
add('syndicate',typeof UNIT_MDL_SYNDICATE!=='undefined'?UNIT_MDL_SYNDICATE:null),
add('nova', typeof UNIT_MDL_NOVA !=='undefined'?UNIT_MDL_NOVA :null),
add('horde', typeof UNIT_MDL_BROOD !=='undefined'?UNIT_MDL_BROOD :null)
];
return n;
}
/* The four kits keep their bespoke packs in their own tables; this is the only
place that needs to read across all four, so it asks rather than importing a
registry that does not exist. Asset maps are a bounded visual pilot, not a
switch that turns every generated template into live battle art. A pack must
opt in with a named token before the query may unwrap and reupload its mesh.
That keeps an unfinished map set from silently changing a whole faction. */
function mfPackMaps(kit,slot){
try{
const T=kit==='nova'?(typeof TFC_NOVA_BESPOKE_PACKS!=='undefined'&&TFC_NOVA_BESPOKE_PACKS)
:kit==='legion'?(typeof DOM_LEGION_BESPOKE_PACKS!=='undefined'&&DOM_LEGION_BESPOKE_PACKS)
:kit==='horde'?(typeof BRD_BESPOKE_PACKS!=='undefined'&&BRD_BESPOKE_PACKS)
:(typeof COA_SYN_BESPOKE_PACKS!=='undefined'&&COA_SYN_BESPOKE_PACKS);
const p=T&&T[slot];
if(!p||!p.maps||!p.assetSkin) return null;
const q=new URLSearchParams(location.search).get('assetskin');
return (q==='1'||q===p.assetSkin)?p.maps:null;
}catch(e){ return null; }
}
function initFactionKits(){
mergeFactionUnitKits();
for(const k in FAC_KIT){
FAC_MESH[k]={};
const cache={};
for(const ty in FAC_KIT[k]){
const fn=FAC_KIT[k][ty];
if(!cache[fn.name]){
const g=fn();
cache[fn.name]={hull:new InstMesh(gl,g.hull,900), tur:g.tur?new InstMesh(gl,g.tur,900):null,
s:g.s||1, turH:g.turH||0, muzzle:g.muzzle||0, muzzleZ:g.muzzleZ||0};
/* Per-asset baked maps, when this slot declares a triplet AND the flag
is on. Async and self-cancelling: if any of the three fails to decode
the mesh simply stays on the shared atlas, which is why this can be
attempted for any pack without checking what exists on disk. */
if(typeof mfAssetSkinEnabled==='function'&&mfAssetSkinEnabled()){
const maps=(typeof mfPackMaps==='function')?mfPackMaps(k,ty):null;
if(maps&&typeof mfAssetSkin==='function') mfAssetSkin(gl,cache[fn.name].hull,maps);
}
}
FAC_MESH[k][ty]=cache[fn.name];
}
}
for(const k in FAC_DOCTRINE_MDL){
FAC_DOCTRINE_MESH[k]={
ground:new InstMesh(gl,FAC_DOCTRINE_MDL[k].ground(),1600),
air:new InstMesh(gl,FAC_DOCTRINE_MDL[k].air(),800)
};
}
}
function mdlConstructor(){ // 19 — engineer
const m=MB();
/* The engineer is the only unarmed thing the player drives around a battle,
so it has to look like PLANT — a works vehicle — rather than an unarmed
tank. Four bare wheels under a bevelled box was not carrying that: no
suspension, no bed, no load, and the "arm" was a stick with a ball on it.
Rebuilt around what a builder actually is: a flat load bed with cargo
strapped to it, a proper wheeled chassis with hubs and arches, and a
two-stage telescopic boom with a fabricator head at the end. */
m.bevelBox(0,1.9,0,8.2,2.4,5.2,0.45,MET); // chassis rail
m.wedge(3.6,4.3,0,2.6,1.0,4.6,MET_L); // sloped nose
/* LOAD BED: a recessed inner extrusion with a lip, which is what makes a
flatbed read as a bed rather than as the top of a box. */
const BB=[[-4.0,-2.4],[1.2,-2.4],[1.2,2.4],[-4.0,2.4]];
m.extrude(0,4.3,0,BB,0.35,MET_D);
m.extrude(0,4.65,0,BB.map(q=>[q[0]*0.90,q[1]*0.84]),0.22,DARKER);
for(const sd of [-1,1]) m.box(-1.4,4.65,sd*2.3,5.2,0.55,0.22,MET_L); // bed rails
for(const sd of [-1,1]){
for(const fx of [2.5,-2.5]){
m.cyl(fx,1.0,sd*3.0,1.55,1.55,1.5,10,RUBBER,Math.PI/2); // wheel
m.cyl(fx,1.0,sd*3.5,0.62,0.62,0.5,8,MET_L,Math.PI/2); // hub
hydraulic(m,fx,2.2,sd*2.6,1.1,0.22,MET_D,0); // suspension
m.wedge(fx,3.3,sd*2.9,3.4,0.9,1.5,MET_D); // wheel arch
}
m.bevelBox(0.6,4.9,sd*2.0,3.0,1.1,0.9,0.22,TEAM_A); // side livery
}
/* TELESCOPIC BOOM: shoulder, lower stage, upper stage stepped in, then the
fabricator head. The step is what says telescopic. */
m.bevelBox(1.7,4.7,0,2.0,2.0,2.2,0.32,MET_D); // turntable
m.cyl(1.7,6.7,0,0.90,0.80,0.6,10,MET_L);
m.box(2.5,7.0,0,3.0,0.85,0.85,MET_L,0.34); // lower stage
m.box(4.4,8.0,0,3.0,0.62,0.62,MET_D,0.34); // upper stage
m.bevelBox(6.2,8.8,0,1.3,1.0,1.1,0.24,MET_D); // head housing
m.cyl(6.9,8.8,0,0.85,0.42,0.9,10,ENERGY,Math.PI/2); // fabricator emitter
hydraulic(m,2.4,5.5,0,2.6,0.28,MET_L,0);
/* Cargo actually strapped to the bed, and a rotating beacon — the two things
that read "works vehicle" from any angle. */
kitBox(m,-2.4,4.87,0.9,2.2,1.3,2.6,MET_D,0);
kitBox(m,-3.4,4.87,-1.4,1.5,0.9,1.6,RUST,0.3);
stowage(m,-0.4,4.87,-1.8,2.4,1.0,RUST,0,41);
m.cyl(-3.9,4.9,1.9,0.26,0.20,1.9,6,DARKER);
m.cyl(-3.9,6.8,1.9,0.40,0.34,0.42,8,LAMP); // beacon
glowStrip(m,-1.2,4.92,0,3.8,ENERGY,0);
deckCrown(m,-1.0,4.88,0,7.0,5.2,MET_D,TEAM_T);
return {hull:m.build(),tur:null,s:1.0};
}
const UNIT_MDL=[mdlStriker,mdlRhino,mdlGoliath,mdlThumper,mdlCommander,mdlWasp,mdlLongbow,
mdlHornet,mdlTitan,mdlPyro,mdlVulture,mdlBulwark,mdlRavager,mdlAlphaRavager,mdlCorvette,
mdlDread,mdlBombard,mdlRaptor,mdlScorcher,mdlConstructor,
/* second wave — indices 20..27 */
mdlReaper,mdlCinder,mdlLancer,mdlResonator,mdlWarden,mdlKestrel,mdlBasilisk,mdlHarbinger,
/* faction heroes — 28..30 */
mdlPraetor,mdlArchon,mdlBroodmother];
/* WHAT A MODEL IS MADE OF, answered by the model tables themselves.
Salvage needs to know whether the thing that just died was grown or built,
and the only honest source is the SAME resolution the renderer uses:
kit override first, base roster second. Keeping this here - beside FAC_KIT
and UNIT_MDL - means editing a faction's kit automatically updates what its
dead drop; a separate list in sim.js would drift the first time a slot was
reassigned, and the failure (a strider drone bleeding, a rifleman shedding
plate) would be invisible until someone happened to reclaim the wrong pile. */
const HUMAN_MDL=new Set([
mdlStriker,mdlPyro, // the base humans (Nova fields them)
mdlLegionTrooper,mdlLegionPyro, // Dominion siege infantry - heavier, still people
]);
const ORGANIC_MDL=new Set([
...HUMAN_MDL,
mdlRavager,mdlAlphaRavager,mdlBroodmother, // wildlife and the Sovereign
mdlHordeBeast,mdlHordeSpitter,mdlHordeBombardier,mdlHordeLeviathan,
mdlHordeFlyer,mdlHordeSupport,mdlHordeSwimmer,
]);
function unitModelOrganic(kit,type){
const fn=(FAC_KIT[kit]&&FAC_KIT[kit][type])||UNIT_MDL[type];
return ORGANIC_MDL.has(fn)?1:0;
}
/* A person, specifically - the distinction dropRemains needs: fallen soldiers
leave no salvage pile at all, while a grown warbeast of any size renders
into biomass. */
function unitModelHuman(kit,type){
const fn=(FAC_KIT[kit]&&FAC_KIT[kit][type])||UNIT_MDL[type];
return HUMAN_MDL.has(fn)?1:0;
}
/* ---------------------------------------------------------------------------
BUILDINGS — authored to their real footprint so they sit inside the square
build plates rather than floating on top of them.
--------------------------------------------------------------------------- */
const BLD_GEO={}, BLD_MESH={}, BLD_FACTION_MESH={};
/* Shared tower foundations. Contact-sheet structures need a common construction
language without becoming palette-swapped copies: this handles only the
footprint, bearing reveal and sparse status strips. Each weapon/function
still owns its silhouette above the bearing. */
function towerOct(r){
return [[-r*.55,-r],[r*.55,-r],[r,-r*.55],[r,r*.55],
[r*.55,r],[-r*.55,r],[-r,r*.55],[-r,-r*.55]];
}
function towerPad(m,r,tier,bearing){
tier=tier||1;
m.extrude(0,0,0,towerOct(r),3,TWR_PAD);
m.extrude(0,3,0,towerOct(r*.86),5,TWR_ARM_D);
m.extrude(0,8,0,towerOct(r*.70),3,TWR_ARM);
for(const s of [-1,1]){
m.bevelBox(s*r*.68,3,0,r*.22,4.4,r*.44,.5,TWR_COAT);
m.box(s*r*.68,7.4,0,r*.08,.22,r*.24,s>0?TWR_GLOW:TWR_TEAM);
}
if(tier>=2) for(const s of [-1,1]){
m.bevelBox(0,5,s*r*.72,r*.42,3.4,r*.18,.4,TWR_ARM_D);
m.box(0,8.4,s*r*.72,r*.24,.22,r*.06,TWR_GLOW);
}
if(tier>=3) for(const a of [0,Math.PI/2,Math.PI,Math.PI*1.5]){
const x=Math.cos(a)*r*.69,z=Math.sin(a)*r*.69;
m.cyl(x,8,z,r*.075,r*.06,2.8,8,TWR_MACH);
}
if(bearing){
m.cyl(0,10.7,0,r*.52,r*.52,1.0,16,TWR_COAT);
m.cyl(0,11.7,0,r*.44,r*.40,.8,16,TWR_TRIM);
}
return m;
}
/* Nova economy/production architecture uses the same quiet physical finishes
as the rebuilt defense kit, but not its octagonal weapon silhouette. A
shallow bevel, dark reveal and sparse pair of status rails make each model
feel planted and give baked AO somewhere useful to collect. Keeping this
shared layer restrained is important: function-specific machinery above it
should identify a building before colour or surface noise does. */
function novaServicePad(m,w,d,h,quiet){
h=h||2.4;
m.bevelBox(0,0,0,w,h,d,Math.min(1.35,w*.04,d*.04),TWR_PAD);
m.bevelBox(0,h,0,w-2.2,1.0,d-2.2,.45,NOVA_DECK);
/* Status rails must stay short. w*.30 on the Command HQ (87) wrote a
26-unit TWR_GLOW bar — the glowing artifact, not a lamp. HQ passes
quiet so restoring TWR_GLOW points cannot revive that rail. */
const mark=quiet?TWR_TRIM:TWR_TEAM;
m.box(0,h+.98,-d*.43,Math.min(w*.30,8.0),.20,.34,mark);
m.box(0,h+.98, d*.43,Math.min(w*.18,6.2),.20,.34,quiet?TWR_TRIM:TWR_GLOW);
return h+1.0;
}
function mdlMex(){
const m=MB();
const y=novaServicePad(m,33,33,2.6,true);
/* A four-way ore intake is legible from any camera facing. The old derrick
was four identical thin posts around a box, so command view reduced it to
visual grit and never communicated extraction. */
m.cyl(0,y,0,9.4,8.2,4.0,12,NOVA_MEX_CORE);
m.cyl(0,y+4.0,0,7.5,6.0,3.0,12,NOVA_MEX_ARM);
m.cyl(0,y+7.0,0,4.2,3.5,12.0,12,NOVA_MEX_CORE);
m.tube(0,y+18.8,0,3.6,2.0,1.3,12,TWR_BORE);
for(let k=0;k<4;k++){
const a=k/4*TAU,x=Math.cos(a)*11.2,z=Math.sin(a)*11.2;
m.bevelBox(x,y+.2,z,9.2,4.2,6.0,.75,NOVA_MEX_CORE,a);
m.cyl(x,y+4.4,z,2.6,2.1,3.0,10,NOVA_MEX_TRIM);
m.box(x,y+7.3,z,2.3,.24,2.3,NOVA_MEX_CORE,a);
m.box(Math.cos(a)*6.7,y+2.7,Math.sin(a)*6.7,8.2,1.2,1.6,NOVA_MEX_ARM,a);
}
ringX(m,0,y+12.2,0,4.3,5.1,14,NOVA_MEX_TRIM);
/* A 1.15 emissive.light sphere sat past the 0.936 bloom thresh and became
the white disc on every occupied node after deposit sprites were cut. */
m.sphere(0,y+13.0,0,1.15,8,NOVA_MEX_CORE,1,false);
return m.build();
}
function mdlPgen(){
const m=MB();
const y=novaServicePad(m,37,29,2.5);
/* Twin shielded reactor cans and one exposed capacitor give this a broad,
low energy-plant silhouette without repeating access panels on every
face. Material changes, not tiny geometry, carry the PBR detail. */
for(const s of [-1,1]){
m.cyl(s*6.6,y,0,5.6,5.2,12.0,12,NOVA_REACTOR_CORE);
m.cyl(s*6.6,y+12.0,0,5.25,4.3,2.2,12,NOVA_REACTOR_ARM);
m.cyl(s*6.6,y+4.8,0,5.75,5.75,.8,12,NOVA_REACTOR_TRIM);
m.box(s*6.6,y+9.0,-5.25,5.6,1.0,.38,s<0?NOVA_REACTOR_ARM:NOVA_REACTOR_GLOW);
}
m.cyl(0,y+2.0,0,3.2,2.5,15.0,12,NOVA_REACTOR_CORE);
m.sphere(0,y+17.5,0,3.2,10,NOVA_REACTOR_GLOW,1,false);
ringX(m,0,y+17.5,0,4.1,4.8,14,NOVA_REACTOR_TRIM);
for(const s of [-1,1]){
m.bevelBox(s*15.0,y,0,4.6,8.0,18.0,.7,NOVA_REACTOR_CORE);
/* Keep the long radiator axis on Z. Rotating this bank made its 13-unit
depth become width and silently overhang the 38-unit build plot. */
ventBank(m,s*15.0,y+8.0,0,3.0,13.0,4,TWR_MACH,0);
}
return m.build();
}
function mdlFac(){
const m=MB();
const y=novaServicePad(m,63,49,3.0);
/* The production lane is a single large read: calm shed, dark open bay and
a control tower. Roof panels and two vent cassettes replace the old
saw-tooth/greeble field, cutting both noise and vertices. */
m.bevelBox(-7.0,y,0,43.0,17.0,42.0,2.2,NOVA_FACTORY_ARM);
m.bevelBox(-7.0,y+17.0,0,40.0,2.0,39.0,.8,NOVA_FACTORY_ROOF);
roofEdge(m,-7.0,y+19.0,0,40.0,39.0,1.0,NOVA_FACTORY_CORE,NOVA_FACTORY_ROOF);
for(const s of [-1,1]){
m.bevelBox(-7.0,y+20.8,s*10.0,15.0,2.7,8.0,.6,NOVA_FACTORY_CORE);
ventBank(m,-7.0,y+23.5,s*10.0,10.5,5.5,4,TWR_MACH,0);
}
/* Deep assembly opening on +X, framed by blue identification and guide
lights. The dark surface is deliberately behind the wall plane. */
m.box(14.55,y+2.0,0,.7,12.5,22.0,TWR_BORE);
for(const s of [-1,1]){
m.bevelBox(15.3,y+1.0,s*12.0,2.6,15.0,3.2,.45,NOVA_FACTORY_CORE);
m.box(15.8,y+6.0,s*10.15,.35,7.5,.35,TWR_GLOW);
}
m.bevelBox(15.3,y+13.5,0,2.6,3.2,27.0,.5,NOVA_FACTORY_ARM);
m.box(15.8,y+16.4,0,.35,.26,16.0,TWR_TEAM);
/* Offset command/assembly tower keeps the footprint directional. */
m.bevelBox(23.0,y,0,15.0,24.0,28.0,1.6,NOVA_FACTORY_CORE);
m.bevelBox(23.0,y+24.0,0,16.5,2.0,29.5,.7,NOVA_FACTORY_ROOF);
for(const s of [-1,1]) m.box(23.0,y+15.0,s*14.1,10.0,5.0,.55,NOVA_FACTORY_GLASS);
m.box(15.45,y+15.0,0,.5,5.0,20.0,NOVA_FACTORY_GLASS);
sensorMast(m,25.0,y+26.0,-8.5,7.0,TWR_MACH);
for(const s of [-1,1]) exhaust(m,-25.5,y+15.0,s*13.0,1,1.4,10.0,TWR_MACH,2.0);
return m.build();
}
function mdlTurretBase(tier){
const m=MB();
tier=tier||1;
const oct=r=>[[-r*.55,-r],[r*.55,-r],[r,-r*.55],[r,r*.55],
[r*.55,r],[-r*.55,r],[-r,r*.55],[-r,-r*.55]];
m.extrude(0,0,0,oct(13),3,NOVA_SENTINEL_CORE); // quiet coated foundation
m.extrude(0,3,0,oct(11.4),6,NOVA_SENTINEL_CORE); // armoured lower plinth
m.extrude(0,9,0,oct(9.7),3,NOVA_SENTINEL_ARM); // recessed upper tier
for(const sx of [-1,1]) for(const sz of [-1,1]){
m.bevelBox(sx*9.3,3,sz*9.3,4.2,4.6,4.2,.65,NOVA_SENTINEL_CORE); // anchor housings
m.cyl(sx*9.3,7.72,sz*9.3,.28,.28,.22,8,HQ_LAMP);
}
for(const s of [-1,1]){
m.box(0,11.75,s*8.5,7.4,.25,.42,TWR_TEAM); // sparse faction livery
m.box(s*8.5,11.75,0,.42,.25,7.4,NOVA_SENTINEL_GLOW); // mount status guides
}
if(tier>=2) for(const s of [-1,1]){
m.bevelBox(s*10.1,7.0,0,3.3,3.6,7.4,.45,NOVA_SENTINEL_CORE); // Mk2 side power pods
m.box(s*10.1,10.6,0,1.1,.22,4.5,NOVA_SENTINEL_GLOW);
}
if(tier>=3) for(const s of [-1,1]){
m.cyl(0,8.8,s*9.7,1.75,1.45,3.1,10,NOVA_SENTINEL_TRIM); // Mk3 reserve capacitors
m.box(0,11.75,s*9.7,2.3,.24,.8,TWR_GLOW);
}
m.cyl(0,12,0,8.25,8.25,1.15,16,NOVA_SENTINEL_CORE); // azimuth shadow gap
m.cyl(0,13.15,0,7.15,6.65,.85,16,NOVA_SENTINEL_TRIM); // y=14 bearing surface
return m.build();
}
function mdlTurretGun(tier){
const m=MB();
tier=tier||1;
m.cyl(0,0,0,6.5,6.1,2.7,16,NOVA_SENTINEL_TRIM); // rotating race
m.cyl(0,2.7,0,5.9,5.4,1.0,16,NOVA_SENTINEL_TRIM);
m.bevelBox(-1.4,3.4,0,13.8,6.2,11.4,1.05,NOVA_SENTINEL_CORE); // receiver
m.bevelBox(-2.6,9.6,0,6.4,1.7,5.8,.52,NOVA_SENTINEL_ARM); // quiet armour roof
m.box(-2.6,11.3,0,1.35,.18,4.2,TWR_TEAM); // narrow livery inset
m.bevelBox(-7.1,4.2,0,4.8,5.4,9.2,.75,NOVA_SENTINEL_CORE); // capacitor bustle
for(const s of [-1,1]){
m.bevelBox(-3.3,5.0,s*6.15,5.8,3.2,1.65,.35,TWR_COAT); // cooling cheeks
m.box(-3.3,8.2,s*6.2,3.5,.26,1.75,NOVA_SENTINEL_GLOW);
m.cyl(2.2,2.8,s*4.4,.66,.5,4.6,9,NOVA_SENTINEL_TRIM); // elevation rams
m.box(8.6,6.9,s*2.55,4.8,1.45,1.0,NOVA_SENTINEL_CORE); // compact barrel braces
m.box(8.5,9.35,s*1.45,3.8,.28,.36,NOVA_SENTINEL_GLOW); // short recoil guides
}
m.bevelBox(4.35,4.8,0,4.5,6.0,8.0,.75,NOVA_SENTINEL_ARM); // mantlet
cylX(m,4.6,8.0,0,10.7,1.30,1.02,12,NOVA_SENTINEL_TRIM,false);
for(const x of [6.2,9.2,12.2]) cylX(m,x,8.0,0,.75,1.68,1.68,12,NOVA_SENTINEL_TRIM);
m.bevelBox(15.2,6.0,0,3.15,4.0,4.8,.52,NOVA_SENTINEL_CORE); // muzzle shroud
tubeX(m,15.9,8.0,0,1.15,1.58,.80,12,TWR_BORE); // bore ends at x=17.05
m.cyl(-5.7,7.55,4.7,.22,.22,.28,8,HQ_LAMP); // service indicator
if(tier>=2) for(const s of [-1,1]){
m.bevelBox(-7.4,7.2,s*5.0,4.2,3.8,1.25,.34,TWR_ARM); // Mk2 capacitor cassettes
m.box(-7.4,10.8,s*5.05,2.5,.25,1.35,TWR_GLOW);
}
if(tier>=3){
m.bevelBox(-8.0,10.2,0,4.8,3.3,7.2,.55,TWR_COAT); // Mk3 reactor crown
m.box(-8.0,13.35,0,1.2,.24,3.6,TWR_TEAM);
for(const s of [-1,1]){
m.box(11.0,10.5,s*3.15,8.0,.72,.76,TWR_ARM_D); // heavy outer accelerator rails
m.box(11.0,11.24,s*3.15,5.4,.20,.34,TWR_GLOW);
}
}
return m.build();
}
function mdlBunkerBase(tier){
const m=MB(); tier=tier||1;
towerPad(m,20,tier,false);
/* Low, broad casemate: the armor is calm and the dark mechanism is confined
to service recesses instead of coating the entire structure. */
m.extrude(0,11,0,[[-15,-14],[12,-14],[17,-8],[17,8],[12,14],[-15,14],[-18,0]],5,NOVA_BUNKER_ARM);
for(const s of [-1,1]){
m.bevelBox(-11,11,s*14.2,8.5,5.0,2.3,.45,NOVA_BUNKER_CORE);
m.box(-11,15.8,s*14.4,5.3,.25,2.45,s>0?TWR_TEAM:TWR_GLOW);
m.bevelBox(11,11,s*12.7,6.0,4.2,5.0,.65,NOVA_BUNKER_CORE);
}
ventBank(m,-15.4,15.9,0,1.0,13,5,TWR_MACH,Math.PI/2);
if(tier>=2) for(const s of [-1,1]){
m.bevelBox(-2,15.8,s*11.2,10,2.0,3.2,.45,TWR_ARM_D);
m.box(-2,17.7,s*11.25,6.5,.24,1.1,NOVA_BUNKER_GLOW);
}
if(tier>=3){
m.bevelBox(-12,16,0,5.5,4.3,11,.6,NOVA_BUNKER_CORE);
m.box(-12,20.1,0,1.2,.22,6.4,TWR_TEAM);
}
m.cyl(0,16,0,9.2,9.2,1.1,16,NOVA_BUNKER_CORE);
m.cyl(0,17.1,0,8.0,7.4,.9,16,NOVA_BUNKER_TRIM); // y=18 turret bearing
return m.build();
}
function mdlBunkerGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,7.4,6.9,2.6,16,NOVA_BUNKER_TRIM);
m.bevelBox(-1.4,2.6,0,15.5,6.5,14.2,1.1,NOVA_BUNKER_CORE);
m.bevelBox(-5.8,4.3,0,6.5,5.2,11.8,.8,NOVA_BUNKER_CORE);
m.bevelBox(-1.4,9.1,0,7.8,1.8,8.2,.5,NOVA_BUNKER_ARM);
m.box(-1.4,10.85,0,1.4,.18,5.2,TWR_TEAM);
for(const s of [-1,1]){
m.bevelBox(5.0,5.0,s*3.35,5.8,4.2,2.2,.45,NOVA_BUNKER_ARM);
gunX(m,6.8,7.1,14.8,.78,NOVA_BUNKER_TRIM,s*3.35); // twin recessed cannon bores
m.box(-3.6,7.6,s*7.5,5.4,2.6,1.3,NOVA_BUNKER_CORE);
m.box(-3.6,10.1,s*7.55,3.0,.22,1.4,NOVA_BUNKER_GLOW);
}
if(tier>=2) for(const s of [-1,1]){
m.bevelBox(-8.0,7.0,s*5.6,4.0,3.2,2.0,.4,TWR_ARM);
m.box(-8.0,10.1,s*5.65,2.2,.20,2.1,TWR_GLOW);
}
if(tier>=3){
m.bevelBox(-7.0,9.4,0,5.0,3.0,8.5,.5,TWR_COAT);
sensorMast(m,-8.0,12.4,0,4.5,TWR_MACH);
}
return m.build();
}
function mdlRailBase(tier){
const m=MB(); tier=tier||1;
const oct=r=>[[-r*.55,-r],[r*.55,-r],[r,-r*.55],[r,r*.55],
[r*.55,r],[-r*.55,r],[-r,r*.55],[-r,-r*.55]];
m.extrude(0,0,0,oct(21),4,NOVA_RAIL_CORE); // compact quiet foundation
m.extrude(0,4,0,oct(18.2),8,NOVA_RAIL_CORE); // heavy lower armour
m.extrude(0,12,0,oct(15.2),5,NOVA_RAIL_ARM); // inset second tier
for(let k=0;k<4;k++){
const a=k/4*TAU, x=Math.cos(a)*15.6, z=Math.sin(a)*15.6;
m.bevelBox(x,4,z,7.2,7.6,9.2,.75,NOVA_RAIL_CORE); // radial capacitor pod
m.cyl(x,11.6,z,2.4,1.9,3.2,10,NOVA_RAIL_TRIM);
m.box(x,14.6,z,3.2,.28,1.0,k%2?NOVA_RAIL_GLOW:TWR_TEAM,a);// restrained status strip
}
for(const s of [-1,1]){
m.box(0,16.7,s*11.8,10,.26,.46,TWR_TEAM);
m.box(s*11.8,16.7,0,.46,.26,10,NOVA_RAIL_GLOW);
}
m.cyl(0,17,0,12.5,12.5,2.0,18,NOVA_RAIL_CORE); // recessed rotation race
m.cyl(0,19,0,11.0,9.7,2.0,18,NOVA_RAIL_TRIM);
if(tier>=2) for(const s of [-1,1]) m.cyl(s*16.4,4,0,2.15,1.75,8.0,10,NOVA_RAIL_TRIM);
if(tier>=3) m.box(0,16.9,0,14,.22,.5,TWR_TEAM);
m.cyl(0,21,0,9.8,9.0,1.0,18,NOVA_RAIL_TRIM); // y=22 bearing surface
return m.build();
}
function mdlRailGun(tier){
const m=MB();
m.cyl(0,0,0,8.7,8.0,3.2,18,NOVA_RAIL_TRIM); // azimuth drum
m.cyl(0,3.2,0,7.7,7.1,1.2,18,NOVA_RAIL_TRIM);
m.bevelBox(-2.4,4.0,0,18.0,8.0,15.6,1.35,NOVA_RAIL_CORE); // armoured receiver
m.bevelBox(-4.2,12.0,0,7.2,2.2,8.4,.62,NOVA_RAIL_ARM); // quiet armour roof
m.box(-4.2,14.2,0,1.55,.18,5.8,TWR_TEAM); // narrow livery inset
m.bevelBox(-10.0,4.7,0,5.8,7.0,12.8,.9,NOVA_RAIL_CORE); // reactor bustle
for(const s of [-1,1]){
m.bevelBox(-8.2,7.0,s*7.15,4.8,4.2,1.9,.4,TWR_ARM); // capacitor cheeks
m.box(-8.2,10.4,s*7.2,2.9,.30,2.0,NOVA_RAIL_GLOW);
m.cyl(1.2,3.0,s*6.2,.82,.58,5.4,10,NOVA_RAIL_TRIM); // elevation pistons
m.bevelBox(13.2,7.55,s*2.9,20.0,2.8,1.85,.38,NOVA_RAIL_TRIM); // twin accelerator rails
}
m.bevelBox(4.5,5.7,0,5.4,6.8,10.2,.85,NOVA_RAIL_ARM); // reinforced mantlet
cylX(m,4.0,9.0,0,19.1,1.48,1.12,14,NOVA_RAIL_TRIM,false); // open central penetrator
for(const x of [6.8,12.5,18.2]){
m.bevelBox(x,6.45,0,1.35,5.15,8.5,.34,TWR_ARM_D); // heavy barrel braces
cylX(m,x-.32,9.0,0,.65,2.12,2.12,14,NOVA_RAIL_GLOW); // energized coil faces
}
for(const s of [-1,1]) m.box(13.2,10.85,s*1.45,17.4,.30,.38,NOVA_RAIL_GLOW);
m.bevelBox(23.15,6.35,0,3.0,5.3,8.1,.55,NOVA_RAIL_CORE); // rectangular muzzle casing
tubeX(m,23.1,9.0,0,1.72,2.38,1.02,14,TWR_BORE); // bore ends at x=24.82
m.cyl(-10.8,12.3,4.8,.22,.22,.28,8,HQ_LAMP);
if((tier||1)>=2) for(const s of [-1,1]) m.box(-8.2,12.0,s*7.2,2.2,.24,1.6,TWR_GLOW);
if((tier||1)>=3) cylX(m,4.0,6.4,0,17.0,.7,.5,10,NOVA_RAIL_GLOW,false);
return m.build();
}
function mdlSgen(tier){
const m=MB(); tier=tier||1;
towerPad(m,16,tier,false);
m.cyl(0,11,0,8.8,7.2,7.0,14,TWR_ARM);
m.cyl(0,18,0,6.9,5.5,3.0,14,TWR_MACH);
for(let k=0;k<4;k++){
const a=k/4*TAU+Math.PI/4,x=Math.cos(a)*10.8,z=Math.sin(a)*10.8;
m.bevelBox(x,10.0,z,4.2,7.5,4.2,.6,TWR_COAT,a);
m.cyl(x,17.5,z,1.35,.85,9.0+(tier-1)*1.5,8,TWR_MACH);
m.box(x,25.6+(tier-1)*1.5,z,1.2,.26,1.2,TWR_GLOW,a);
}
m.sphere(0,21.0,0,5.3+(tier-1)*.55,12,TWR_GLOW,.9,false);
ringX(m,0,21.0,0,7.0+(tier-1)*1.1,8.3+(tier-1)*1.1,18,TWR_TRIM);
if(tier>=2) ringX(m,0,21.0,0,9.2,9.8,18,TWR_GLOW);
if(tier>=3){
m.cyl(0,27.5,0,1.2,.6,6.5,8,TWR_MACH);
m.sphere(0,34.0,0,1.8,9,TWR_GLOW,1,false);
}
m.box(0,18.4,7.2,5.5,.24,.8,TWR_TEAM);
return m.build();
}
function mdlTgate(){
const m=MB();
m.box(0,0,0,72,5,60,CONC_D);
for(const s of [-1,1]){ // gantry arches
m.box(0,5,s*24,60,6,10,MET);
m.box(-26,11,s*24,8,30,10,MET_D);
m.box(26,11,s*24,8,30,10,MET_D);
}
m.box(0,41,0,66,7,58,MET); // overhead crane deck
m.box(0,5,0,44,3,34,DARKER); // build pad
m.cyl(0,8,0,16,15,4,14,ENERGY);
for(const s of [-1,1]) m.cyl(s*30,53.15,0,.36,.36,.28,8,HQ_LAMP);
/* A single identification rail on each crane face is enough for ownership
at command zoom; colouring the whole gantry would bury its steel finish. */
for(const s of [-1,1]) m.box(0,46.8,s*28.8,34,.28,.36,TWR_TEAM);
return m.build();
}
function mdlNest(){
/* Nest is Brood-owned at runtime (bldFactionKey forces horde). This Nova
fallback used to be a chitin mound — the exact cross-faction leak the
kit resolver exists to prevent if a preview or old save ever asks the
Frontline catalogue for `nest`. A containment bunker keeps the slot
readable as Nova without growing tissue. */
const m=MB();
const y=novaServicePad(m,41,35,2.4);
m.bevelBox(0,y,0,28.0,8.5,24.0,1.4,NOVA_BUNKER_ARM);
m.bevelBox(0,y+8.5,0,24.0,1.6,20.0,.55,NOVA_BUNKER_TRIM);
roofEdge(m,0,y+10.1,0,24.0,20.0,.7,NOVA_BUNKER_CORE,TWR_TRIM);
m.cyl(0,y+10.1,0,7.2,6.0,6.5,12,NOVA_BUNKER_CORE);
m.cyl(0,y+16.6,0,5.4,3.2,2.2,12,NOVA_BUNKER_TRIM);
m.tube(0,y+18.8,0,3.4,1.6,1.4,10,TWR_BORE);
m.ring(0,y+20.1,0,2.2,3.6,14,NOVA_BUNKER_GLOW);
for(const s of [-1,1]){
m.bevelBox(s*11.5,y+1.0,0,6.0,7.2,8.5,.7,NOVA_BUNKER_ARM);
m.box(s*11.5,y+8.15,0,3.4,.22,2.2,s>0?TWR_TEAM:TWR_GLOW);
sensorMast(m,s*11.5,y+8.4,s*6.2,5.2,NOVA_BUNKER_ARM);
}
return m.build();
}
function mdlHarbor(){
const m=MB();
const y=novaServicePad(m,73,43,2.5);
/* A U-shaped drydock survives the top-down camera much better than the old
shed plus flat rectangle. The empty dark slip is the focal shape; the
service block and crane make its launch direction (+X) unambiguous. */
m.bevelBox(-20.0,y,0,29.0,15.0,36.0,1.6,NOVA_HARBOR_ARM);
m.bevelBox(-20.0,y+15.0,0,27.0,1.8,34.0,.65,NOVA_HARBOR_DECK);
roofEdge(m,-20.0,y+16.8,0,27.0,34.0,.9,NOVA_HARBOR_CORE,NOVA_HARBOR_DECK);
m.bevelBox(14.0,y+.1,0,39.0,1.5,28.0,.45,TWR_BORE); // recessed ship cradle
for(const s of [-1,1]){
m.bevelBox(12.0,y+1.0,s*16.8,43.0,6.0,6.0,.8,NOVA_HARBOR_CORE);
m.box(12.0,y+6.9,s*16.8,31.0,.24,2.0,s>0?TWR_GLOW:TWR_TEAM);
for(const x of [-4,10,24]) m.cyl(x,y+7.0,s*16.8,1.2,.85,3.6,8,NOVA_HARBOR_ARM);
}
/* Twin launch rails are intentionally long, simple emissive strokes. */
for(const s of [-1,1]){
m.box(14.0,y+1.65,s*5.8,37.0,.45,.85,NOVA_UPLINK_GLOW);
m.box(14.0,y+1.55,s*7.2,37.0,.65,.65,NOVA_HARBOR_CORE);
}
m.bevelBox(-29.0,y+16.8,-9.5,10.0,13.0,12.0,1.0,NOVA_HARBOR_CORE);
m.bevelBox(-29.0,y+29.8,-9.5,11.0,2.0,13.0,.5,NOVA_HARBOR_DECK);
m.box(-23.7,y+23.2,-9.5,.5,5.5,8.5,NOVA_HARBOR_GLASS);
sensorMast(m,-29.0,y+31.8,-9.5,6.5,NOVA_HARBOR_ARM);
m.box(-5.0,y+18.6,15.4,11.0,.24,.45,TWR_TEAM);
return m.build();
}
function mdlBastionBase(tier){
const m=MB(); tier=tier||1;
towerPad(m,21,tier,false);
m.extrude(0,11,0,towerOct(17.2),6,TWR_ARM_D);
m.extrude(0,17,0,towerOct(14.4),4,TWR_ARM);
for(const a of [0,Math.PI/2,Math.PI,Math.PI*1.5]){
const x=Math.cos(a)*15.0,z=Math.sin(a)*15.0;
m.bevelBox(x,12,z,6.8,7.4,8.2,.7,TWR_COAT,a);
m.box(x,19.2,z,3.4,.25,1.0,a===0?TWR_TEAM:TWR_GLOW,a);
}
if(tier>=2) for(const s of [-1,1]){
m.cyl(-7.5,18,s*10.2,2.4,2.0,4.1,10,TWR_MACH);
m.box(-7.5,22.0,s*10.2,3.1,.24,1.0,TWR_GLOW);
}
if(tier>=3) m.bevelBox(-10.5,18,0,5.5,4.2,11.0,.6,TWR_ARM);
m.cyl(0,20.6,0,12.2,12.2,1.2,18,TWR_COAT);
m.cyl(0,21.8,0,10.8,9.8,1.2,18,TWR_TRIM); // y=23 bearing
return m.build();
}
function mdlBastionGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,9.2,8.5,3.0,18,TWR_MACH);
m.bevelBox(-2.5,3.0,0,19.0,8.8,17.2,1.35,TWR_ARM_D);
m.bevelBox(-8.8,4.3,0,6.3,7.0,13.5,.9,TWR_COAT); // shell hoist
for(const s of [-1,1]){
m.cyl(-7.8,5.0,s*7.8,2.7,2.4,5.2,10,TWR_MACH); // magazine drums
m.box(-7.8,10.0,s*7.8,3.2,.25,1.2,TWR_GLOW);
m.cyl(2.5,2.8,s*6.3,.9,.65,5.0,10,TWR_MACH); // elevation rams
}
m.bevelBox(4.0,5.0,0,7.0,7.0,12.5,.9,TWR_ARM); // mantlet
/* A short, unmistakably broad mortar with an actual inner tube. The old gun
was a capped pencil and read as a generic cannon from command view. */
cylX(m,4.6,9.5,0,11.0,3.45,2.75,14,TWR_MACH,false);
for(const x of [6.3,10.6]) cylX(m,x,9.5,0,.75,4.05,4.05,14,TWR_TRIM);
m.bevelBox(14.2,6.0,0,3.2,7.0,8.8,.55,TWR_ARM_D);
tubeX(m,14.3,9.5,0,2.0,4.15,2.45,14,TWR_BORE);
m.bevelBox(-3.0,11.8,0,8.5,2.1,9.0,.55,TWR_ARM);
m.box(-3.0,13.8,0,1.5,.22,5.5,TWR_TEAM);
if(tier>=2) for(const s of [-1,1]){
m.bevelBox(-10.4,9.3,s*5.6,4.1,4.2,2.0,.4,TWR_ARM);
m.box(-10.4,13.3,s*5.65,2.4,.22,2.1,TWR_GLOW);
}
if(tier>=3){
m.bevelBox(-9.0,12.0,0,5.5,3.4,10.2,.6,TWR_COAT);
sensorMast(m,-10.2,15.4,0,5.2,TWR_MACH);
}
return m.build();
}
function mdlTechlab(){
const m=MB();
const y=novaServicePad(m,47,39,2.5);
/* The old hemisphere dominated every material underneath it. A stepped
data vault with a small, exposed analysis core keeps the science identity
while leaving broad wall/roof planes for the architectural PBR kit. */
/* Research is a Nova landmark, not a recoloured utility shed. Its unique
semantic selectors keep armour, recesses and roof response separated even
before its authored BaseAO/NRE/mask pack is baked. */
m.bevelBox(-3.0,y,0,34.0,13.0,29.0,1.8,NOVA_RESEARCH_ARM);
m.bevelBox(-3.0,y+13.0,0,31.0,1.8,26.0,.7,NOVA_RESEARCH_ROOF);
roofEdge(m,-3.0,y+14.8,0,31.0,26.0,.85,NOVA_RESEARCH_CORE,TWR_TRIM);
for(const s of [-1,1]){
m.bevelBox(-15.5,y+3.0,s*14.8,6.5,12.0,7.0,.9,NOVA_RESEARCH_CORE);
m.box(-15.5,y+14.7,s*14.8,3.4,.24,3.5,s>0?TWR_GLOW:TWR_TEAM);
}
m.cyl(7.0,y+14.9,0,7.2,6.2,3.0,14,NOVA_RESEARCH_ARM);
m.cyl(7.0,y+17.9,0,4.8,3.5,4.0,12,NOVA_RESEARCH_CORE);
m.sphere(7.0,y+22.8,0,3.5,11,TWR_GLOW,1,false);
ringX(m,7.0,y+22.8,0,5.0,5.8,16,TWR_TRIM);
m.bevelBox(17.0,y,0,10.0,10.0,18.0,1.0,NOVA_RESEARCH_CORE);
for(const s of [-1,1]) m.box(17.0,y+6.0,s*9.1,6.0,2.6,.42,NOVA_RESEARCH_GLASS);
m.cyl(-8.5,y+16.6,0,1.0,.7,8.5,8,TWR_MACH);
ringX(m,-8.5,y+25.1,0,3.5,5.4,16,TWR_ARM);
ringX(m,-8.45,y+25.1,0,.9,1.5,12,TWR_GLOW);
return m.build();
}
function mdlAA(tier){
const m=MB(); tier=tier||1;
const oct=r=>[[-r*.55,-r],[r*.55,-r],[r,-r*.55],[r,r*.55],
[r*.55,r],[-r*.55,r],[-r,r*.55],[-r,-r*.55]];
m.extrude(0,0,0,oct(15),3,TWR_PAD); // compact stabilised pad
m.extrude(0,3,0,oct(12.5),5,TWR_ARM_D); // faceted armour skirt
m.extrude(0,8,0,oct(10.2),2,TWR_ARM); // upper equipment tier
for(const s of [-1,1]){
m.bevelBox(s*10.6,3,0,4.2,4.0,8.2,.55,TWR_COAT); // power housings
m.box(s*10.6,7.0,0,1.3,.24,4.4,s>0?TWR_GLOW:TWR_TEAM);
}
if(tier>=2) for(const s of [-1,1]){
m.cyl(s*11.4,8.2,0,1.55,1.25,2.6,10,TWR_MACH);
m.box(s*11.4,10.9,0,1.0,.20,3.0,TWR_GLOW);
}
if(tier>=3){
m.box(0,10.35,0,1.05,.18,7.2,TWR_TEAM);
m.sphere(0,12.2,8.4,1.3,8,GLASS,.72,true);
}
m.cyl(0,9.55,0,7.6,7.0,.6,14,TWR_COAT); // azimuth shadow gap
m.cyl(0,10.15,0,6.8,6.25,.85,14,TWR_TRIM); // y=11 bearing surface
return m.build();
}
function mdlAAGun(tier){
const m=MB();
m.cyl(0,0,0,6.5,6.0,2.2,14,TWR_MACH); // rotating race
m.cyl(0,2.2,0,5.9,5.35,.9,14,TWR_TRIM);
m.bevelBox(-1.0,3.0,0,10.8,5.0,10.0,.82,TWR_ARM_D); // gun director body
m.bevelBox(-3.0,8.0,0,5.8,1.7,6.2,.42,TWR_ARM); // quiet IFF roof
m.box(-3.0,9.7,0,1.15,.16,4.3,TWR_TEAM); // narrow team identifier
for(const s of [-1,1]){
m.bevelBox(-2.6,4.2,s*5.35,4.8,3.0,1.45,.32,TWR_COAT); // feed boxes
m.box(-2.6,7.2,s*5.4,2.8,.24,1.55,TWR_GLOW); // ammunition status
m.box(7.0,5.9,s*2.4,8.2,1.0,.72,TWR_MACH); // barrel cradle
gunX(m,1.8,7.0,12.8,.44,TWR_MACH,s*2.4); // separated autocannons
}
m.bevelBox(2.0,4.8,0,3.6,4.2,7.0,.55,TWR_ARM); // armoured mantlet
m.cyl(-4.5,9.7,0,1.15,.82,2.8,10,TWR_MACH); // radar pedestal
m.sphere(-4.5,12.5,0,1.65,10,GLASS,.72,true); // tracking radar dome
m.cyl(-4.5,9.55,3.8,.20,.20,.24,8,HQ_LAMP); // acquisition lamp
if((tier||1)>=2) m.box(0,9.2,0,8.4,.18,1.0,TWR_GLOW);
if((tier||1)>=3) for(const s of [-1,1]) gunX(m,1.8,5.2,11.2,.36,TWR_MACH,s*4.4);
return m.build();
}
function mdlAirfield(){
const m=MB();
const y=novaServicePad(m,91,41,2.0);
/* Broad uninterrupted deck bands read as a runway at gameplay zoom. Seven
raised centreline blocks used to turn it into a ladder and added collision-
looking clutter despite being purely decorative. */
m.bevelBox(4.0,y,0,74.0,1.2,25.0,.45,NOVA_AIR_DECK);
m.box(4.0,y+1.2,0,68.0,.20,1.2,TWR_TEAM);
for(const s of [-1,1]){
m.box(5.0,y+1.22,s*10.5,70.0,.20,.42,TWR_GLOW);
for(const x of [-24,0,24,38]) m.cyl(x,y+1.32,s*10.5,.22,.22,.20,8,HQ_LAMP);
}
/* One deep hangar and a distinct glazed flight-control tower form an
asymmetrical silhouette while preserving most of the deck as negative
space. */
m.bevelBox(-30.5,y,-10.0,28.0,14.0,18.0,1.5,NOVA_AIR_ARM);
m.bevelBox(-30.5,y+14.0,-10.0,29.5,2.0,19.5,.7,NOVA_AIR_ROOF);
roofEdge(m,-30.5,y+16.0,-10.0,29.5,19.5,.8,NOVA_AIR_DECK,NOVA_AIR_ROOF);
m.box(-15.9,y+2.0,-10.0,.6,10.0,13.0,TWR_BORE);
m.box(-15.5,y+12.0,-10.0,.45,.25,10.0,TWR_TEAM);
m.bevelBox(34.0,y,13.0,12.0,20.0,13.0,1.0,NOVA_AIR_DECK);
m.bevelBox(34.0,y+20.0,13.0,15.0,5.5,15.0,.8,NOVA_AIR_ARM);
for(const s of [-1,1]) m.box(34.0,y+21.0,13.0+s*7.35,10.0,3.2,.45,NOVA_AIR_GLASS);
m.box(26.7,y+21.0,13.0,.45,3.2,10.0,NOVA_AIR_GLASS);
sensorMast(m,34.0,y+25.5,13.0,7.0,TWR_MACH);
return m.build();
}
function mdlUplink(tier){
const m=MB(); tier=tier||1;
towerPad(m,15,tier,false);
m.bevelBox(-4.2,11,0,12,9,18,.9,NOVA_UPLINK_CORE); // electronics bunker
m.bevelBox(-4.2,20,0,9.5,2.0,13,.5,NOVA_UPLINK_ARM);
m.box(-4.2,21.9,0,1.3,.20,8.0,TWR_TEAM);
for(const s of [-1,1]){
m.cyl(5.0,11,s*5.0,1.1,.72,19+(tier-1)*2.0,7,NOVA_UPLINK_TRIM);
m.box(5.0,29+(tier-1)*2.0,s*5.0,1.0,.22,1.0,NOVA_UPLINK_GLOW);
}
m.cyl(5.0,11,0,2.0,1.55,16+(tier-1)*2.0,10,NOVA_UPLINK_CORE);
m.bevelBox(5.0,27+(tier-1)*2.0,0,8.2,4.6,6.2,.7,NOVA_UPLINK_CORE);
/* The contact-sheet Targeting Array is defined by its dish, so the dish is
larger than the mast and visibly hollow even in the 32 px build thumbnail. */
ringX(m,5.0,35+(tier-1)*2.0,0,5.4+(tier-1)*.8,8.7+(tier-1)*1.0,20,NOVA_UPLINK_ARM);
ringX(m,5.05,35+(tier-1)*2.0,0,1.1,2.0,14,NOVA_UPLINK_GLOW);
cylX(m,5.0,35+(tier-1)*2.0,0,3.4,1.0,.6,10,NOVA_UPLINK_TRIM,false);
if(tier>=2) for(const s of [-1,1]) sensorMast(m,-8.5,22,s*5.0,8,NOVA_UPLINK_TRIM);
if(tier>=3) m.sphere(8.6,35+(tier-1)*2.0,0,1.6,10,NOVA_UPLINK_GLOW,1,false);
return m.build();
}
/* ============================================================================
HEADQUARTERS
----------------------------------------------------------------------------
The first version was the carrier hull parked on the ground: a single
extruded arrowhead in vehicle plating. From a top-down command camera that
reads as a dark blob, because a building seen from above is almost entirely
ROOF, and an arrowhead has no roof plan to read.
This is authored as architecture instead. A stepped mass — apron, main
block, inset roof deck — with four corner buttress towers that give the
silhouette its square footprint, a command tower at the back, and a landing
pad at the front. Everything is laid out on the plan the player is actually
looking down at: pad, walkway, vent banks, mast. Wall and roof use the
architectural materials, whose texture frequency is a third of vehicle
plating, so a facade shows two or three real bays instead of dissolving.
============================================================================ */
function mdlHQ(){
const m=MB();
const y=novaServicePad(m,87,73,3.2,true);
/* Three large masses replace the former collection of corner towers,
greebles and transformer boxes. The HQ remains the broadest Nova
silhouette while now fitting its real 88x74 collision plot. */
/* High-value command deck: the HQ is the player's visual anchor and needs
to separate from grass at a glance. Dark material is reserved for seams,
vents and the open bay, never the whole roof. */
m.bevelBox(-5.0,y,0,68.0,18.0,55.0,2.8,NOVA_HQ_ARM);
m.bevelBox(-5.0,y+18.0,0,61.0,3.0,49.0,1.0,NOVA_HQ_ROOF);
roofEdge(m,-5.0,y+21.0,0,61.0,49.0,1.2,NOVA_HQ_STRUCT,NOVA_HQ_ROOF);
for(const s of [-1,1]){
m.bevelBox(-33.5,y,s*25.5,14.0,23.0,17.0,1.5,NOVA_HQ_STRUCT);
m.bevelBox(-33.5,y+23.0,s*25.5,15.5,1.8,18.5,.65,NOVA_HQ_ROOF);
m.box(-33.5,y+15.0,s*34.2,8.0,4.5,.45,NOVA_HQ_GLASS);
m.box(-33.5,y+24.65,s*25.5,2.4,.16,2.4,TWR_COAT);
/* Both wings get a point lamp. TWR_TEAM here was a dark socket after
glowOk, and a cyan team bevel before it. */
m.cyl(-33.5,y+24.85,s*25.5,.44,.44,.30,8,HQ_LAMP);
}
/* Raised command block and glazing band anchor the rear half. */
m.bevelBox(-23.0,y+21.0,0,25.0,17.0,31.0,2.0,NOVA_HQ_ARM);
m.bevelBox(-23.0,y+38.0,0,27.0,2.2,33.0,.8,NOVA_HQ_ROOF);
roofEdge(m,-23.0,y+40.2,0,27.0,33.0,.9,NOVA_HQ_STRUCT,NOVA_HQ_ROOF);
for(const s of [-1,1]) m.box(-23.0,y+31.0,s*15.7,18.0,5.0,.50,NOVA_HQ_GLASS);
m.box(-10.2,y+31.0,0,.50,5.0,23.0,NOVA_HQ_GLASS);
ventBank(m,-23.0,y+42.1,0,10.0,10.0,4,TWR_MACH,0);
sensorMast(m,-31.0,y+42.0,-8.0,9.0,TWR_MACH);
for(const s of [-1,1]) m.cyl(-23.0+s*11.2,y+40.85,s*14.6,.38,.38,.26,8,HQ_LAMP);
for(const s of [-1,1]) m.cyl(-5.0+s*28.0,y+22.15,s*22.6,.34,.34,.22,8,HQ_LAMP);
/* The front deck stays intentionally open. Two rings and cross hairs are
enough to read as a landing pad without a field of lamps. TWR_TEAM on
the inner ring was Nova livery — the cyan crush at HIGH. */
m.cyl(17.0,y+21.0,0,15.0,15.0,1.0,20,NOVA_HQ_ROOF);
m.ring(17.0,y+22.0,0,12.7,14.6,22,NOVA_HQ_STRUCT);
m.ring(17.0,y+22.1,0,9.2,10.4,22,TWR_TRIM);
m.box(17.0,y+22.18,0,16.4,.16,1.15,TWR_TRIM);
m.box(17.0,y+22.18,0,1.15,.16,16.4,TWR_TRIM);
m.cyl(17.0,y+22.32,0,.62,.62,.28,10,HQ_LAMP);
for(const s of [-1,1]){
m.cyl(17.0+s*7.4,y+22.30,0,.38,.38,.22,8,HQ_LAMP);
m.cyl(17.0,y+22.30,s*7.4,.38,.38,.22,8,HQ_LAMP);
}
/* Recessed deployment bay and ramp make +X the readable front. */
m.box(29.3,y+2.0,0,.65,13.0,27.0,TWR_BORE);
for(const s of [-1,1]){
m.bevelBox(30.3,y+1.0,s*14.5,3.0,16.0,3.2,.45,TWR_ARM_D);
/* Door lamps, not 8-unit glow slabs. The old strip bloomed into a
cyan column at the bay cheek. */
m.cyl(30.8,y+10.2,s*12.7,.22,.22,.28,8,HQ_LAMP);
m.cyl(30.8,y+4.4,s*12.7,.22,.22,.28,8,TWR_TRIM);
}
m.bevelBox(30.3,y+14.0,0,3.0,4.0,32.0,.55,TWR_ARM);
m.box(30.8,y+17.8,0,.30,.24,22.0,TWR_TRIM);
m.bevelBox(36.5,y-.3,0,13.0,1.4,27.0,.4,TWR_PAD);
return m.build();
}
/* ---------------------------------------------------------------------------
ORBITAL DROPSHIP — a spacecraft, not a building
The pre-deployment carrier was borrowing the DEPLOYED HQ's mesh, which is
why it read as a bunker sliding around the map instead of the ship its own
icon promises. This is a proper hull: long spine, swept lifting body, engine
nacelles with lit bells, cargo pods slung underneath, and gear that only
comes down at touchdown. It flies nose-first and banks into its turns.
--------------------------------------------------------------------------- */
function mdlDropship(){
const m=MB();
/* One load-bearing lifting body. The previous craft intentionally left
daylight around its nacelles, but on a phone those islands read as broken
model pieces. The shoulders now include the engines inside the same plan
silhouette; only retractable landing gear is a separate draw. */
const hull=[[-54,-7],[-42,-14],[-19,-21],[12,-20],[38,-12],[56,0],[38,12],[12,20],[-19,21],[-42,14],[-54,7]];
const upper=[[-46,-5],[-31,-11],[-8,-14],[20,-13],[41,-7],[50,0],[41,7],[20,13],[-8,14],[-31,11],[-46,5]];
const crown=[[-33,-4],[-18,-8],[10,-9],[31,-6],[43,0],[31,6],[10,9],[-18,8],[-33,4]];
m.extrude(0,1,0,hull,5.8,NOVA_MET); // continuous light steel keel
m.extrude(0,6.8,0,upper,4.8,MET_L); // bright lifting-body shoulder
m.extrude(6,11.6,0,crown,2.0,MET_L); // command deck
m.extrude(5,13.7,0,crown.map(p=>[p[0]*.82,p[1]*.72]),.42,TEAM_A);
/* The old bridge was still a half-height sphere parked on the hull. From the
command camera it read as a flattened bubble, not pressure glazing. Build
the canopy as a raked recessed volume with an armoured sill and mullions;
every glass edge now physically disappears into metal. */
m.wedge(32.5,13.9,0,13.8,2.0,21.5,NOVA_CARB,Math.PI/2,true); // armoured tub
m.wedge(32.8,15.1,0,11.3,4.7,18.2,DROP_CANOPY_GLASS,Math.PI/2,true); // faceted pressure canopy
for(const sd of [-1,1]){
m.box(32.0,15.0,sd*5.45,18.0,1.15,.85,NOVA_MET); // side sill
m.box(30.0,18.7,sd*4.6,1.05,.78,8.6,NOVA_MET); // rear frame
}
m.box(37.2,17.0,0,1.05,.72,10.4,NOVA_MET); // forward frame
m.wedge(43.0,13.7,0,11.8,3.0,12.0,NOVA_MET,Math.PI/2,true); // nose closure
for(const sd of [-1,1]){
/* One continuous side sponson replaces the old stack of shoulder, pod,
wedge, cylinder and loose nozzle. It overlaps the fuselage by a full
eight units and terminates in one integrated cruise bell. The four VTOL
units attach to the sockets below as genuinely animated sub-meshes. */
m.bevelBox(-9,5.8,sd*10.4,55,7.2,8.8,1.15,NOVA_MET);
m.wedge(15,11.4,sd*11.0,27,4.8,8.6,NOVA_MET,0,sd<0);
m.box(-7,12.1,sd*10.2,34,.9,2.1,NOVA_CARB);
m.bevelBox(-34,7.0,sd*10.5,19,8.2,9.2,1.15,MET_D);
tubeX(m,-45.2,8.4,sd*10.5,6.2,4.8,3.6,16,DARKER);
tubeX(m,-49.4,8.4,sd*10.5,3.2,3.5,1.7,16,ENERGY);
m.box(-6,13.1,sd*14.6,28,.34,1.25,TEAM_A);
for(const x of [-15,16]){
m.bevelBox(x,4.8,sd*13.2,11.8,5.4,7.8,.85,NOVA_CARB); // VTOL load-bearing socket
m.box(x,10.0,sd*13.2,7.2,.34,5.0,TWR_MACH); // visible rotation bearing
}
}
/* Cargo keel and recessed lift apertures make the eventual base deployment
mechanically plausible without reading as additional loose vehicles. */
m.bevelBox(4,.1,0,48,3.8,16,1.2,DARKER);
m.box(20,-1.85,0,14,.5,12,ENERGY);
for(const x of [-17,12])for(const sd of [-1,1]){
m.bevelBox(x,1.0,sd*9.7,10.5,4.0,7.8,.85,NOVA_CARB); // turbine fairing
m.cyl(x,1.8,sd*10.4,4.0,3.6,1.0,16,TWR_MACH);
m.ring(x,2.85,sd*10.4,3.2,4.5,18,ENERGY);
}
glowStrip(m,3,14.25,0,43,ENERGY,0);
sensorMast(m,-28,14.0,0,5.5,MET_L);
return m.build();
}
/* Four tilting ducted-lift units. The hinge is evaluated by the existing GPU
bone path: high-altitude cruise rotates the ducts aft; descent brings them
upright. This restores the original lander's transforming-VTOL idea without
another CPU animation system or four extra per-frame model rebuilds. */
function mdlDropVtol(){
const m=MB(),hinge=m.joint([0,8,0],null,[0,0,1],0,1.04,0);
m.bone(hinge);
/* An open rectangular duct reads at phone zoom and leaves the fan visible;
a solid cylinder looked like another disconnected barrel. */
m.bevelBox(0,3.2,-5.0,14.2,8.4,2.2,.65,NOVA_MET);
m.bevelBox(0,3.2, 5.0,14.2,8.4,2.2,.65,NOVA_MET);
m.bevelBox(-6.0,3.2,0,2.2,8.4,8.0,.65,NOVA_MET);
m.bevelBox( 6.0,3.2,0,2.2,8.4,8.0,.65,NOVA_MET);
m.ring(0,11.7,0,4.2,6.5,20,TWR_MACH);
m.ring(0,2.8,0,4.6,6.1,20,DARKER);
m.box(0,8.0,-6.2,8.0,.34,.75,TEAM_A);
m.box(0,8.0, 6.2,8.0,.34,.75,TEAM_A);
m.box(-6.9,7.4,0,.45,2.4,5.2,ENERGY);
m.box( 6.9,7.4,0,.45,2.4,5.2,ENERGY);
m.bone(null);
return m.build();
}
/* Visible rotor, separate so its yaw can spin continuously instead of merely
oscillating with the tilt hinge. It is only shown while the duct is near
vertical; in cruise the fan is edge-on and the aft bells carry the thrust. */
function mdlDropRotor(){
const m=MB();
m.cyl(0,0,0,1.15,1.15,1.0,12,TWR_MACH,true);
for(let k=0;k<4;k++)m.box(3.4,.28,0,6.0,.34,1.05,TWR_TRIM,k*Math.PI*.5);
m.ring(0,.72,0,5.7,6.25,20,ENERGY);
return m.build();
}
/* Landing gear, drawn only once the ship is on the deck. */
function mdlDropGear(){
const m=MB();
/* Two continuous landing skids replace four isolated black feet. Each skid
overlaps the belly fairing for most of its length, while paired oleos make
the load path visible at the near-vertical mobile camera. */
for(const sd of [-1,1]){
const z=sd*10.2;
m.bevelBox(-2,.05,z,51,1.7,4.8,.6,DARKER);
m.bevelBox(-2,1.55,z,45,2.0,4.2,.6,NOVA_CARB);
for(const x of [-18,14]){
m.wedge(x,3.35,z,8.6,4.7,4.3,MET_D,0,sd<0);
m.cyl(x,2.15,z,1.45,1.2,4.7,8,TWR_MACH);
m.box(x,7.65,z,7.2,1.35,4.6,NOVA_MET);
}
}
return m.build();
}
/* A deployer is the first full-screen faction silhouette a player sees. Reusing
the Nova carrier for every army made the later faction geometry feel like a
skin swap before the match had even begun. These craft share no hull: the
Legion lands a slab-armoured assault brick, the Syndicate rides a thin hover
delta, and the Brood arrives inside a living drop-organism. */
function mdlLegionDropship(){
const m=MB();
m.extrude(-2,0,0,[[-45,-17],[-35,-24],[27,-24],[44,-14],[50,0],[44,14],[27,24],[-35,24],[-45,17]],13,TWR_ARM_D);
m.bevelBox(-8,13,0,68,12,34,2.2,TWR_ARM);
m.wedge(34,20,0,26,8,25,TWR_COAT); // breaching prow
m.bevelBox(25,28,0,18,7,18,1.4,TWR_ARM);
m.box(35,31,0,1.2,3.4,12,GLASS);
for(const sd of [-1,1]){
m.bevelBox(-2,8,sd*24,52,15,12,1.8,TWR_COAT); // troop cassettes
m.box(2,23.2,sd*24,29,.35,10,TEAM_A);
m.wedge(19,13,sd*24,18,12,12,TWR_ARM,0,sd<0);
m.bevelBox(-34,12,sd*21,18,18,15,1.5,TWR_ARM_D);
for(const z of [sd*17,sd*25]){
m.cyl(-45,12,z,5.0,4.2,6.0,12,TWR_MACH);
m.cyl(-51.2,12,z,3.8,2.8,2.2,12,HOT);
}
}
m.bevelBox(-38,23,0,18,12,20,1.6,TWR_COAT); // armoured reactor keep
m.bevelBox(-11,-7,0,55,6,28,1.4,TWR_ARM_D); // siege landing keel
m.box(16,-10.2,0,22,.28,16,HOT); // hot deploy-ramp seam
for(let k=-1;k<=1;k++){
m.tube(-48,29,k*6.2,1.35,.78,5.0,9,TWR_BORE,0);
m.box(-42,33,k*6.2,10,.5,2.1,HOT);
}
sensorMast(m,-14,35,0,9,TWR_MACH); // assault command mast
return m.build();
}
function mdlLegionDropGear(){
const m=MB();
for(const sd of [-1,1]) for(const f of [-1,1]){
const x=f*30,z=sd*23;
m.bevelBox(x,0,z,14,3.0,10,.75,TWR_PAD);
m.cyl(x,3,z,2.4,1.8,11,9,TWR_MACH);
m.box(x,12,z,4.2,8,4.2,TWR_ARM_D);
}
return m.build();
}
function mdlSyndicateDropship(){
const m=MB();
/* A shallow manta plan keeps the machine visibly agile even when stationary;
the separated lift rings replace landing struts entirely. */
m.extrude(2,1,0,[[-38,-8],[-20,-16],[10,-28],[43,-11],[52,0],[43,11],[10,28],[-20,16],[-38,8]],6,TWR_COAT);
m.extrude(5,7,0,[[-31,-5],[-13,-10],[21,-16],[43,-7],[49,0],[43,7],[21,16],[-13,10],[-31,5]],4,TEAM_A);
m.wedge(31,12,0,28,5,14,TWR_ARM,0,false);
m.box(43,15,0,.8,2.5,8,GLASS);
for(const sd of [-1,1]){
m.wedge(1,8,sd*19,44,3,13,TWR_ARM_D,sd*.08,sd<0);
m.box(3,11.2,sd*23,25,.28,2.0,TEAM_T,sd*.08);
for(const x of [-22,0,22]){
m.cyl(x,2.4,sd*18,2.2,1.8,1.0,14,TWR_MACH);
m.ring(x,3.6,sd*18,2.5,4.4,18,ENERGY);
}
}
m.ring(-22,12,0,3.6,6.8,20,ENERGY);
m.sphere(-22,12,0,3.2,10,GLASS,.92,false);
m.ring(7,3,0,8.0,11.8,22,ENERGY); // active teleport keel
m.box(13,1.6,0,22,.45,15,GLASS); // luminous cargo aperture
for(const sd of [-1,1]) m.tube(-38,7,sd*7,2.2,1.2,2.2,12,ENERGY,0);
return m.build();
}
function mdlHordeDropship(){
const m=MB();
/* No pad, plate, glass, bolt, nozzle or manufactured edge: this is an animal
that survives atmospheric entry and disgorges a hive, not a ship wearing a
biological decal. BIO_MEM/BIO_LEG also opt into the spring shader. */
m.sphere(-4,7,0,18,16,CHITIN,.62,false); // central thorax
m.sphere(-27,7,0,14,14,CHIT_D,.78,false); // brood abdomen
m.sphere(18,8,0,11,13,CHITIN,.72,false); // head shield
m.sphere(4,1,0,11,12,BIO_MEM,.44,false); // hanging brood womb
m.ring(4,0,0,5.8,9.2,16,BIO_TEAM);
for(const sd of [-1,1]){
m.sphere(-19,12,sd*10,7,10,BIO_TEAM,.72,false); // luminous brood sacs
/* Three separated, tapered petals leave daylight between their outlines.
That negative space and the beaded veins make them read as living wings
rather than the former single aircraft-like planar panel. */
/* Overlapping flattened lobes produce scalloped, curved membranes with no
planar face large enough to be mistaken for a metal wing. */
for(let k=0;k<8;k++){
const q=k/7,r=4.8-q*2.5;
m.sphere(-15+q*25,8.2+Math.sin(q*Math.PI)*2.0,sd*(5+q*40),r,8,BIO_MEM,.20,false);
m.sphere(-15+q*25,9.0,sd*(5+q*40),.88-q*.42,7,BIO_LEG,.56,false);
}
for(let k=0;k<7;k++){
const q=k/6,r=4.2-q*2.3;
m.sphere(-23+q*17,6.7+Math.sin(q*Math.PI)*1.5,sd*(5+q*31),r,8,BIO_MEM,.20,false);
m.sphere(-23+q*17,7.5,sd*(5+q*31),.76-q*.34,7,BIO_LEG,.56,false);
}
for(let k=0;k<6;k++){
const q=k/5,r=3.7-q*2.0;
m.sphere(-29+q*11,5.5+Math.sin(q*Math.PI),sd*(4+q*25),r,8,BIO_MEM,.20,false);
m.sphere(-29+q*11,6.2,sd*(4+q*25),.66-q*.28,6,BIO_LEG,.56,false);
}
for(let k=0;k<6;k++) m.sphere(8+k*2.2,7.5-k*.18,sd*(7-k*.8),1.0-k*.10,7,k<4?BIO_LEG:CLAW,.62,false);
m.sphere(24,12,sd*5,2.2,8,LAMP,1,false); // compound eye
for(let k=0;k<3;k++){
for(let j=0;j<5;j++)
m.sphere(-7-k*6-j*2.3,4-j*1.25,sd*(8+k*3+j*.8),.82-j*.09,6,BIO_LEG,.64,false);
}
}
for(let k=0;k<7;k++){
const a=-1.1+k*.36;
m.cyl(-20-k*1.2,18+Math.sin(a)*2,Math.cos(a)*5,.78,.03,7+k*.6,7,CLAW);
}
for(const sd of [-1,1]) for(let k=0;k<4;k++)
m.sphere(4-k*4.3,1.5-k*.9,sd*(3+k*2.2),1.15-k*.12,7,BIO_LEG,.60,false);
return m.build();
}
function mdlHordeDropGear(){
const m=MB();
for(const sd of [-1,1]) for(const f of [-1,1]){
const x=f*20,z=sd*12;
for(let j=0;j<5;j++)
m.sphere(x+f*j*2.2,7-j*1.55,z+sd*j*1.7,1.15-j*.12,7,BIO_LEG,.68,false);
m.sphere(x+f*9,.5,z+sd*8,2.1,7,CLAW,.7,false);
}
return m.build();
}
const DROP_MDL={
nova:mdlDropship,
legion:mdlLegionDropship,
syndicate:mdlSyndicateDropship,
horde:mdlHordeDropship
};
const DROP_GEAR_MDL={nova:mdlDropGear,legion:mdlLegionDropGear,horde:mdlHordeDropGear};
const DROP_VTOL_MDL={nova:mdlDropVtol};
const DROP_ROTOR_MDL={nova:mdlDropRotor};
/* Profiles keep exhaust/hover cues attached to authored hardpoints. Aliases
consolidate Brood/Swarm/Infestation into one biology without presenting
neutral wildlife as a fifth art faction. */
const DROP_PROFILE={
/* The command camera views the lander through terrain shadow and UI bloom.
A deeper blue multiplication looked nearly black there, so this is a
light steel-blue hull tint; dedicated team panels still carry the banner. */
nova:{scale:1.08,team:[112,124,136],glow:[142,228,255],ring:[170,224,255],eng:[[-49,-10],[-49,10]],vtol:[[-15,-15],[-15,15],[16,-15],[16,15]],lights:[[42,0],[15,-15],[15,15],[-30,0]]},
legion:{scale:1.12,team:[255,120,90],glow:[255,116,62],ring:[255,142,82],eng:[[-51,-25],[-51,-17],[-51,17],[-51,25]],lights:[[39,0],[15,-25],[15,25],[-35,0]]},
syndicate:{scale:1.02,team:[150,235,95],glow:[138,255,112],ring:[152,255,126],eng:[[-38,-7],[-38,7],[-22,-18],[-22,18]],lights:[[45,0],[12,-24],[12,24],[-27,0]],hover:true},
horde:{scale:1.04,team:[186,120,255],glow:[188,92,255],ring:[140,238,92],eng:[[-27,-10],[-27,10],[-16,-15],[-16,15]],lights:[[23,0],[-5,-20],[-5,20],[-25,0]],bio:true}
};
const DROP_ALIASES={
terran:'nova',frontline:'nova',federation:'nova',
ascendancy:'legion',red:'legion',
machine:'syndicate',coalition:'syndicate',
brood:'horde',swarm:'horde',infestation:'horde'
};
const DROP_MESH={};
function dropFactionKey(k){
k=String(k||'nova').trim().toLowerCase();
if(DROP_MDL[k]) return k;
if(DROP_ALIASES[k]) return DROP_ALIASES[k];
/* Resolve full lore/doctrine labels as well as compact simulation IDs. Keep
Machine Ascendancy ahead of the generic Ascendancy alias. */
if(k.includes('machine ascendancy')||k.includes('syndicate')||k.includes('coalition')) return 'syndicate';
if(k.includes('infestation')||k.includes('brood')||k.includes('swarm')) return 'horde';
if(k.includes('terran frontline')||k.includes('nova federation')||k.includes('frontline')||k.includes('federation')) return 'nova';
if(k.includes('red ascendancy')||k.includes('bloodward')||k.includes('legion')||k.includes('ascendancy')) return 'legion';
return 'nova';
}
function mdlHellstormBase(tier){
const m=MB(); tier=tier||1;
towerPad(m,17,tier,false);
m.cyl(0,11,0,9.2,8.0,5.8,14,TWR_ARM_D);
for(const s of [-1,1]){
m.bevelBox(-3.0,12.0,s*11.5,7.0,8.0,3.5,.55,TWR_COAT); // ammunition boxes
m.box(-3.0,19.8,s*11.55,4.2,.25,3.6,TWR_GLOW);
}
m.cyl(0,16.8,0,8.1,8.1,.7,16,TWR_COAT);
m.cyl(0,17.5,0,7.2,6.7,.5,16,TWR_TRIM); // y=18 tracking bearing
return m.build();
}
function mdlHellstormGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,6.8,6.2,2.0,16,TWR_MACH);
m.bevelBox(-1.0,2.0,0,15.5,5.5,13.5,.9,TWR_ARM);
m.bevelBox(-6.3,1.0,0,6.4,7.5,11.0,.7,TWR_COAT); // rotating feed housing
/* Horizontal rotary cluster with open bores. Barrel count grows by tier, but
each tube remains thick enough to survive normal phone zoom. Keeping this
assembly separate is required: projectiles track through a full turn and
the authored barrels must follow the same azimuth. */
const count=tier===1?4:tier===2?6:8;
for(let k=0;k<count;k++){
const a=k/count*TAU, z=Math.cos(a)*2.7, y=7.0+Math.sin(a)*2.7;
gunX(m,2.5,y,13.5,.45,TWR_MACH,z);
}
cylX(m,1.2,7.0,0,5.0,3.8,3.6,12,TWR_COAT,false);
m.bevelBox(-1.0,9.6,0,8.0,1.8,7.5,.45,TWR_ARM_D);
m.box(-1.0,11.3,0,1.3,.20,4.8,TWR_TEAM);
if(tier>=3) sensorMast(m,-6.8,6.8,0,6.0,TWR_MACH);
return m.build();
}
function mdlArc(tier){
const m=MB(); tier=tier||1;
towerPad(m,16,tier,false);
m.cyl(0,11,0,9.0,7.3,8.0,14,TWR_ARM);
m.cyl(0,19,0,6.5,5.5,4.0,14,TWR_MACH);
for(let k=0;k<3+(tier-1);k++){
const y=23+k*4.0, ro=6.6-k*.72;
m.cyl(0,y,0,ro,ro*.80,2.0,14,k%2?TWR_TRIM:TWR_MACH);
m.cyl(0,y+2.0,0,ro*.80,ro*.72,2.0,14,TWR_COAT);
}
const crown=31+(tier-1)*4;
m.cyl(0,23,0,1.35,.72,crown-20,9,TWR_MACH);
m.sphere(0,crown+2.0,0,4.7+(tier-1)*.65,12,TWR_GLOW,1,false);
ringX(m,0,crown+2.0,0,5.3+(tier-1)*.65,6.1+(tier-1)*.65,18,TWR_TRIM);
for(let k=0;k<3;k++){
const a=k/3*TAU,x=Math.cos(a)*10.8,z=Math.sin(a)*10.8;
m.cyl(x,11,z,1.35,.72,crown-15,7,TWR_COAT);
m.box(x,crown-4,z,1.0,.24,1.0,k===0?TWR_TEAM:TWR_GLOW,a);
}
if(tier>=3) for(const a of [0,Math.PI/2,Math.PI,Math.PI*1.5]){
m.cyl(Math.cos(a)*7.6,crown-2,Math.sin(a)*7.6,.55,.18,7.5,7,TWR_MACH);
}
return m.build();
}
function mdlNovaBase(tier){
const m=MB(); tier=tier||1;
/* r=24.5 keeps the complete base inside Nova's asymmetric 58x50 plot. The
previous r=27 pad silently occupied four units of neighbouring ground. */
towerPad(m,24.5,tier,false);
m.extrude(0,11,0,towerOct(20.0),6.5,TWR_ARM_D);
m.extrude(0,17.5,0,towerOct(16.5),3.2,TWR_ARM);
for(const s of [-1,1]){
m.bevelBox(-9.0,13.0,s*17.0,10.0,7.0,4.0,.6,TWR_COAT);
m.box(-9.0,19.8,s*17.05,5.5,.24,4.1,s>0?TWR_TEAM:TWR_GLOW);
}
m.bevelBox(14.5,11.0,0,9.0,8.0,16.0,.8,TWR_COAT); // fixed fire-control bunker
m.box(14.5,18.8,0,1.3,.22,9.0,TWR_TEAM);
m.cyl(0,20.4,0,11.0,11.0,.8,18,TWR_COAT);
m.cyl(0,21.2,0,9.8,9.0,.8,18,TWR_TRIM); // y=22 tracking bearing
return m.build();
}
function mdlNovaGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,8.8,8.2,2.2,18,TWR_MACH);
m.bevelBox(-1.5,2.2,0,21.0,7.0,17.0,1.0,TWR_ARM_D);
m.bevelBox(-5.5,8.8,0,11.0,2.0,14.0,.55,TWR_ARM);
const rows=tier===1?2:tier===2?3:4;
for(let r=0;r<rows;r++) for(const s of [-1,1]){
const y=4.8+r*4.2,z=s*4.3;
m.bevelBox(5.2,y-2.0,z,4.2,4.0,5.2,.5,TWR_MACH);
tubeX(m,6.6,y,z,3.0,2.0,1.22,10,TWR_BORE); // open tracking launch cell
m.box(2.9,y+1.85,z,4.0,.20,2.6,r===0&&s>0?TWR_TEAM:TWR_GLOW);
}
m.bevelBox(-9.0,3.0,0,5.0,7.0,13.0,.7,TWR_COAT); // reload mechanism
if(tier>=2) sensorMast(m,-8.5,10.0,-4.5,5.0,TWR_MACH);
if(tier>=3) m.box(-5.5,20.0,0,1.2,.22,7.0,TWR_TEAM);
return m.build();
}
/* Contact-sheet additions that have no legacy placeholder. These are live
structures, not gallery props, and therefore share the exact runtime model
contract (base plus optional tracking assembly) used by every other tower. */
function mdlMiningLaserBase(tier){
const m=MB(); tier=tier||1;
towerPad(m,18,tier,false);
m.extrude(0,11,0,towerOct(14.2),3.6,TWR_ARM_D);
for(const s of [-1,1]){
m.bevelBox(-5.0,11,s*11.8,8.0,6.0,3.8,.55,TWR_COAT);
m.box(-5.0,16.8,s*11.85,4.5,.24,4.0,s>0?TWR_GLOW:TWR_TEAM);
}
m.cyl(0,14.2,0,9.4,9.4,1.0,16,TWR_COAT);
m.cyl(0,15.2,0,8.2,7.5,.8,16,TWR_TRIM); // y=16 bearing
return m.build();
}
function mdlMiningLaserGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,7.6,7.0,2.5,16,TWR_MACH);
m.bevelBox(-2.5,2.5,0,17.0,7.4,14.0,1.15,TWR_ARM_D);
m.bevelBox(-8.7,4.1,0,5.8,6.5,11.8,.8,TWR_COAT);
for(const s of [-1,1]){
m.cyl(-6.8,6.1,s*6.5,2.3,2.0,4.4,10,TWR_MACH);
m.box(-6.8,10.3,s*6.55,2.8,.22,2.0,TWR_GLOW);
m.box(10.0,7.1,s*2.8,12.0,1.1,.78,TWR_MACH);
}
m.bevelBox(4.2,5.0,0,6.5,6.4,10.5,.8,TWR_ARM);
cylX(m,4.5,8.2,0,17.0,1.8,1.35,14,TWR_MACH,false);
for(const x of [7.0,12.0,17.0]){
cylX(m,x,8.2,0,.72,2.6,2.6,14,TWR_TRIM);
m.box(x,10.8,0,.7,.20,3.0,TWR_GLOW);
}
m.bevelBox(20.0,5.4,0,3.5,5.6,7.2,.5,TWR_ARM_D);
tubeX(m,20.4,8.2,0,2.0,2.8,1.15,14,TWR_BORE); // optical emitter throat
m.bevelBox(-3.0,9.9,0,8.0,1.8,8.0,.45,TWR_ARM);
m.box(-3.0,11.6,0,1.3,.20,4.8,TWR_TEAM);
if(tier>=2) for(const s of [-1,1]) m.bevelBox(-9.8,8.4,s*4.5,4.0,3.3,2.0,.4,TWR_ARM);
if(tier>=3) sensorMast(m,-10.5,11.7,0,5.0,TWR_MACH);
return m.build();
}
function mdlMissileBastionBase(tier){
const m=MB(); tier=tier||1;
towerPad(m,22,tier,false);
m.extrude(0,11,0,towerOct(17.8),5.0,TWR_ARM_D);
for(const s of [-1,1]){
m.bevelBox(-2.0,11.0,s*15.7,10.0,6.5,3.0,.5,TWR_COAT);
m.box(-2.0,17.3,s*15.75,6.0,.22,3.1,s>0?TWR_TEAM:TWR_GLOW);
}
m.cyl(0,16.0,0,11.8,11.8,1.0,18,TWR_COAT);
m.cyl(0,17.0,0,10.5,9.6,1.0,18,TWR_TRIM); // y=18 tracking bearing
return m.build();
}
function mdlMissileBastionGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,9.7,9.1,2.2,18,TWR_MACH);
m.bevelBox(-3.0,2.2,0,25.0,9.0,28.0,1.4,TWR_ARM);
m.bevelBox(-8.5,5.2,0,9.0,6.2,23.0,.9,TWR_COAT); // rotating feed / fire-control block
const rows=tier===1?2:tier===2?3:4, cols=2;
for(let r=0;r<rows;r++) for(let c=0;c<cols;c++){
const y=6.2+r*4.0,z=(c-.5)*8.2;
tubeX(m,7.6,y,z,2.4,2.05,1.30,10,TWR_BORE); // visible recessed missile cell
m.bevelBox(5.8,y-2.2,z,4.0,4.4,5.2,.55,TWR_MACH);
}
m.bevelBox(-3.0,11.2+(tier-1)*2.0,0,16.0,2.0,18.0,.55,TWR_ARM_D);
m.box(-3.0,13.1+(tier-1)*2.0,0,1.5,.22,10.0,TWR_TEAM);
if(tier>=2) sensorMast(m,-11.0,11.2,0,6.5,TWR_MACH);
if(tier>=3) ringX(m,-11.0,18.2,0,1.8,2.7,14,TWR_GLOW);
return m.build();
}
function mdlPlasmaCharger(tier){
const m=MB(); tier=tier||1;
towerPad(m,20,tier,false);
m.extrude(0,11,0,towerOct(16.0),6.0,TWR_ARM_D);
m.cyl(0,17,0,11.6,9.8,4.5,16,TWR_ARM);
for(let k=0;k<4;k++){
const a=k/4*TAU+Math.PI/4,x=Math.cos(a)*12.4,z=Math.sin(a)*12.4;
m.bevelBox(x,14.0,z,5.0,10.0,5.0,.7,TWR_COAT,a);
m.cyl(x,23.5,z,1.45,.72,7.0+(tier-1)*1.4,8,TWR_MACH);
m.box(x,30.2+(tier-1)*1.4,z,1.2,.24,1.2,TWR_GLOW,a);
}
m.cyl(0,21.5,0,7.5,5.6,6.0,16,TWR_MACH);
m.sphere(0,29.0+(tier-1)*1.5,0,6.0+(tier-1)*1.0,14,TWR_GLOW,1,false);
for(let k=0;k<2+(tier-1);k++){
const y=25.5+k*3.2, r=8.2+k*.45;
m.ring(0,y,0,r-.6,r,20,k%2?TWR_TRIM:TWR_MACH);
}
if(tier>=3){
ringX(m,0,30.0,0,8.2,9.2,20,TWR_TRIM);
m.cyl(0,36.0,0,1.1,.5,5.5,8,TWR_MACH);
}
m.box(0,21.2,9.6,5.0,.24,.9,TWR_TEAM);
return m.build();
}
/* SEAFORT used to draw the land Concussion Mortar. A floating dual-purpose
battery has a hull, a pontoon deck and a surface+AA mount — none of which
a poured oct pad can fake. 48x48 plot. Bearing at y=16. */
function mdlSeafortBase(tier){
const m=MB(); tier=tier||1;
const hull=[[-20,-16],[-12,-21],[12,-21],[20,-16],[20,16],[12,21],[-12,21],[-20,16]];
m.extrude(0,0,0,hull,3.2,NOVA_HARBOR_CORE);
m.extrude(0,3.2,0,hull.map(p=>[p[0]*.92,p[1]*.90]),1.4,NOVA_HARBOR_DECK);
for(const s of [-1,1]){
m.bevelBox(-6.0,0,s*18.2,26,2.6,4.0,.5,NOVA_HARBOR_ARM);
m.box(-6.0,2.62,s*18.3,16,.20,1.5,s>0?TWR_TEAM:TWR_GLOW);
m.cyl(-15.0,0,s*14.2,1.55,1.35,3.2,8,TWR_MACH);
}
m.bevelBox(-7.0,4.6,0,16,8.2,18,.9,TWR_ARM_D);
m.extrude(0,12.8,0,towerOct(11.2),2.2,TWR_ARM);
m.cyl(0,14.6,0,9.4,9.4,.8,16,TWR_COAT);
m.cyl(0,15.4,0,8.2,7.4,.6,16,TWR_TRIM); // y=16 bearing
m.bevelBox(14.5,4.6,-8.0,10,6.8,10,.7,TWR_COAT); // AA director
m.sphere(14.5,13.0,-8.0,2.35,10,GLASS,.72,true);
if(tier>=2) sensorMast(m,-15.5,4.6,10.0,6.4,TWR_MACH);
if(tier>=3) m.box(0,13.0,0,1.2,.20,8.0,TWR_TEAM);
return m.build();
}
function mdlSeafortGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,7.2,6.6,2.2,16,TWR_MACH);
m.bevelBox(-2.2,2.2,0,15.5,6.6,13.5,1.0,TWR_ARM_D);
cylX(m,3.2,6.2,0,11.5,2.25,1.75,12,TWR_MACH,false); // surface battery
for(const x of [6.4,10.8]) cylX(m,x,6.2,0,.7,2.75,2.75,12,TWR_TRIM);
m.bevelBox(13.6,4.4,0,2.8,5.6,7.2,.45,TWR_ARM);
tubeX(m,14.2,6.2,0,1.7,2.55,1.25,12,TWR_BORE);
for(const s of [-1,1]){ // twin AA
m.box(5.2,9.2,s*3.15,8.4,.72,.55,TWR_MACH);
gunX(m,2.4,9.2,10.5,.36,TWR_MACH,s*3.15);
}
m.box(-2.6,8.7,0,1.15,.20,5.8,TWR_TEAM);
if(tier>=2) for(const s of [-1,1]) m.bevelBox(-6.4,4.0,s*5.0,4.0,3.1,2.0,.35,TWR_ARM);
if(tier>=3) sensorMast(m,-7.0,8.6,0,4.0,TWR_MACH);
return m.build();
}
/* STORMCALLER used the NOVA silo mesh. A banked-charge volley battery is a
capacitor farm with a 16-cell rack, not an orbital superweapon. 46x46. */
function mdlStormcallerBase(tier){
const m=MB(); tier=tier||1;
towerPad(m,20,tier,false);
m.extrude(0,11,0,towerOct(15.2),4.0,TWR_ARM_D);
for(const s of [-1,1]){
m.bevelBox(-1.5,11,s*13.4,18,6.8,3.8,.5,TWR_COAT);
for(const x of [-6,0,6]){
m.cyl(x,17.8,s*13.4,1.28,1.05,3.0+(tier-1)*.7,8,TWR_MACH);
m.tube(x,20.7+(tier-1)*.7,s*13.4,1.08,.52,.5,8,TWR_BORE);
m.ring(x,21.3+(tier-1)*.7,s*13.4,.58,1.0,10,TWR_GLOW);
}
m.box(-1.5,17.7,s*13.5,10,.20,2.1,s>0?TWR_TEAM:TWR_GLOW);
}
m.cyl(0,15.0,0,8.4,7.6,2.0,16,TWR_MACH);
m.sphere(0,18.2,0,3.4+(tier-1)*.35,12,TWR_GLOW,1,false); // banked charge
m.cyl(0,20.4,0,9.0,9.0,.8,16,TWR_COAT);
m.cyl(0,21.2,0,7.8,7.0,.8,16,TWR_TRIM); // y=22 bearing
return m.build();
}
function mdlStormcallerGun(tier){
const m=MB(); tier=tier||1;
m.cyl(0,0,0,7.4,6.8,2.0,16,TWR_MACH);
m.bevelBox(-2.4,2.0,0,17,7.2,15.5,.95,TWR_ARM_D);
for(let r=0;r<4;r++) for(let c=0;c<4;c++){
const y=4.15+(r-1.5)*2.0,z=(c-1.5)*3.05;
m.bevelBox(4.0,y-1.05,z,3.2,2.1,2.3,.26,TWR_MACH);
tubeX(m,5.4,y,z,2.15,1.08,.68,8,TWR_BORE); // 16-cell volley rack
}
m.bevelBox(-8.0,3.0,0,4.8,6.0,11.5,.55,TWR_COAT);
if(tier>=2) sensorMast(m,-8.8,9.0,0,4.4,TWR_MACH);
if(tier>=3) m.box(-3.0,9.4,0,1.15,.20,7.6,TWR_TEAM);
return m.build();
}
function mdlWall(){
const m=MB();
m.box(0,0,0,28,3,20,CONC_D);
m.box(0,3,0,26,13,15,CONC); // rampart body
m.box(0,16,0,27,3,17,CONC_D); // parapet cap
for(const s of [-1,1]) m.box(0,19,s*7,26,3,3,CONC); // crenellation
for(const f of [-1,1]) m.box(f*12,3,0,4,17,17,MET_D); // buttress posts
m.box(0,21.8,0,11,.24,1.0,TWR_TEAM); // restrained ownership rail
return m.build();
}
function mdlGate(){
const m=MB();
m.box(0,0,0,28,3,20,CONC_D);
for(const f of [-1,1]) m.box(f*11,3,0,6,20,18,CONC); // gate towers
m.box(0,19,0,26,4,17,CONC_D);
m.box(0,3,0,16,15,4,ENERGY); // energy curtain
for(const f of [-1,1]) m.cyl(f*11,24.62,0,.32,.32,.24,8,HQ_LAMP);
m.box(0,22.8,0,9,.24,1.0,TWR_TEAM); // gatehouse livery, not curtain tint
return m.build();
}
function mdlGeo(){
const m=MB();
const y=novaServicePad(m,37,31,2.5);
/* The geothermal plant is a pressure vessel, not a second small reactor:
broad condenser wings and a hollow central steam throat distinguish it
from the Pgen before colour or animation is considered. */
m.cyl(0,y,0,8.5,7.7,8.0,12,NOVA_GEO_CORE);
m.cyl(0,y+8.0,0,6.7,5.0,9.0,12,NOVA_GEO_ARM);
m.cyl(0,y+17.0,0,5.1,4.2,3.0,12,NOVA_GEO_TRIM);
m.tube(0,y+19.8,0,4.3,2.4,1.4,12,TWR_BORE);
for(const s of [-1,1]){
m.bevelBox(s*13.3,y,0,7.0,12.0,18.0,.9,NOVA_GEO_CORE);
ventBank(m,s*13.3,y+12.0,0,4.2,13.0,4,NOVA_GEO_TRIM,0);
m.box(s*13.3,y+12.2,-9.1,3.6,.22,.45,s>0?NOVA_GEO_GLOW:TWR_TEAM);
m.box(s*8.8,y+5.5,0,5.5,1.3,1.8,NOVA_GEO_ARM);
}
ringX(m,0,y+12.5,0,5.4,6.0,14,NOVA_GEO_TRIM);
m.sphere(0,y+15.0,0,1.7,9,NOVA_GEO_GLOW,1,false);
return m.build();
}
function mdlSilo(){
const m=MB();
m.box(0,0,0,26,3,26,NOVA_SILO_CORE);
for(const [dx,dz,r] of [[-5,-5,7],[6,-4,6],[0,7,6.5]])
m.cyl(dx,3,dz,r,r*0.94,20,10,NOVA_SILO_ARM);
for(const [dx,dz,r] of [[-5,-5,7],[6,-4,6],[0,7,6.5]]){
m.cyl(dx,23,dz,r*0.96,r*0.5,4,10,NOVA_SILO_ROOF);
m.cyl(dx,11,dz,r*1.04,r*1.04,1.4,10,NOVA_SILO_CORE); // banding
}
m.box(11,3,0,5,9,5,NOVA_SILO_CORE);
m.box(13.55,7.0,0,.24,4.0,3.0,TWR_TEAM); // control-house faction plate
m.box(13.72,11.1,0,.18,.22,2.2,NOVA_SILO_GLOW); // silo status line
return m.build();
}
function mdlFab(){
const m=MB();
const y=novaServicePad(m,37,29,2.5);
/* A compact conversion line: feed hoppers on the rear, one calm processing
hall, and an unmistakable energized output stack. Rusted duplicate drums
made the former asset look abandoned rather than high-tech. */
m.bevelBox(-5.0,y,0,22.0,14.0,23.0,1.5,NOVA_FAB_ARM);
m.bevelBox(-5.0,y+14.0,0,20.0,1.8,21.0,.65,NOVA_FAB_ROOF);
roofEdge(m,-5.0,y+15.8,0,20.0,21.0,.75,NOVA_FAB_CORE,NOVA_FAB_ROOF);
for(const s of [-1,1]){
m.cyl(-14.0,y,s*7.0,3.2,2.7,10.0,10,NOVA_FAB_CORE);
m.cyl(-14.0,y+10.0,s*7.0,2.7,1.8,2.0,10,NOVA_FAB_ARM);
m.box(-11.0,y+5.5,s*7.0,4.5,1.2,1.4,NOVA_FAB_CORE);
}
m.cyl(11.0,y,0,5.0,4.4,19.0,12,NOVA_FAB_CORE);
m.cyl(11.0,y+5.0,0,5.2,5.2,.8,12,NOVA_FAB_ROOF);
m.cyl(11.0,y+19.0,0,4.5,3.1,3.4,12,NOVA_FAB_ARM);
m.tube(11.0,y+22.3,0,3.2,1.7,1.0,12,TWR_BORE);
for(const s of [-1,1]) m.box(11.0,y+12.0,s*4.5,3.8,.24,.45,s>0?TWR_GLOW:TWR_TEAM);
m.box(-5.0,y+8.0,11.65,13.0,2.5,.4,NOVA_FAB_GLASS);
m.box(2.0,y+3.0,0,8.0,1.4,1.8,TWR_GLOW);
return m.build();
}
const BLD_MDL={
mex:mdlMex, pgen:mdlPgen, fac:mdlFac, turret:mdlTurretBase, bunker:mdlBunkerBase, sgen:mdlSgen,
tgate:mdlTgate, nest:mdlNest, harbor:mdlHarbor, bastion:mdlBastionBase,
techlab:mdlTechlab, aatower:mdlAA, airfield:mdlAirfield, uplink:mdlUplink,
hq:mdlHQ, hellstorm:mdlHellstormBase, arc:mdlArc, rail:mdlRailBase, nova:mdlNovaBase, wall:mdlWall,
minelaser:mdlMiningLaserBase, missilebastion:mdlMissileBastionBase, plasma:mdlPlasmaCharger,
gate:mdlGate, geo:mdlGeo, silo:mdlSilo, fab:mdlFab,
seafort:mdlSeafortBase, stormcaller:mdlStormcallerBase,
};
const BLD_TUR_MDL={
turret:mdlTurretGun, bunker:mdlBunkerGun, bastion:mdlBastionGun,
aatower:mdlAAGun, hellstorm:mdlHellstormGun, rail:mdlRailGun,
nova:mdlNovaGun, minelaser:mdlMiningLaserGun, missilebastion:mdlMissileBastionGun,
seafort:mdlSeafortGun, stormcaller:mdlStormcallerGun
};
/* Sentinel is the production template for visual Mk1/Mk2/Mk3 geometry. Other
faction/weapon families are catalogued in design/tower-factions and can join
this registry without changing the renderer again. */
const BLD_TIER_MDL={
turret:[1,2,3].map(tier=>({base:()=>mdlTurretBase(tier),tur:()=>mdlTurretGun(tier)})),
bunker:[1,2,3].map(tier=>({base:()=>mdlBunkerBase(tier),tur:()=>mdlBunkerGun(tier)})),
bastion:[1,2,3].map(tier=>({base:()=>mdlBastionBase(tier),tur:()=>mdlBastionGun(tier)})),
sgen:[1,2,3].map(tier=>({base:()=>mdlSgen(tier)})),
uplink:[1,2,3].map(tier=>({base:()=>mdlUplink(tier)})),
hellstorm:[1,2,3].map(tier=>({base:()=>mdlHellstormBase(tier),tur:()=>mdlHellstormGun(tier)})),
arc:[1,2,3].map(tier=>({base:()=>mdlArc(tier)})),
nova:[1,2,3].map(tier=>({base:()=>mdlNovaBase(tier),tur:()=>mdlNovaGun(tier)})),
minelaser:[1,2,3].map(tier=>({base:()=>mdlMiningLaserBase(tier),tur:()=>mdlMiningLaserGun(tier)})),
missilebastion:[1,2,3].map(tier=>({base:()=>mdlMissileBastionBase(tier),tur:()=>mdlMissileBastionGun(tier)})),
plasma:[1,2,3].map(tier=>({base:()=>mdlPlasmaCharger(tier)})),
aatower:[1,2,3].map(tier=>({base:()=>mdlAA(tier),tur:()=>mdlAAGun(tier)})),
rail:[1,2,3].map(tier=>({base:()=>mdlRailBase(tier),tur:()=>mdlRailGun(tier)})),
seafort:[1,2,3].map(tier=>({base:()=>mdlSeafortBase(tier),tur:()=>mdlSeafortGun(tier)})),
stormcaller:[1,2,3].map(tier=>({base:()=>mdlStormcallerBase(tier),tur:()=>mdlStormcallerGun(tier)}))
};
/* Height at which each turret meets its mount, in the model's own units. */
const BLD_TUR_H={ turret:14, bunker:18, bastion:23, aatower:11, hellstorm:18,
rail:22, nova:22, minelaser:16, missilebastion:18, seafort:16, stormcaller:22 };
/* Weapon assemblies are deliberately oversized for command-view readability;
their bases retain the real collision footprint while the moving mass reads
clearly at phone scale. Keep projectile/beam muzzle offsets in sim.js in
step with these values. */
const BLD_TUR_S={ turret:1.20, bunker:1.12, bastion:1.14, aatower:1.18, hellstorm:1,
rail:1.15, nova:1, minelaser:1.14, missilebastion:1, seafort:1.10, stormcaller:1.06 };
/* ---------------------------------------------------------------------------
WORLD OBJECTS — ruins, scenery, resources, effects.
--------------------------------------------------------------------------- */
function mdlCityTower(){ return loadWorldModel('mdlCityTower'); }
function mdlCityDome(){ return loadWorldModel('mdlCityDome'); }
function mdlCityHall(){ return loadWorldModel('mdlCityHall'); }
function mdlCityTank(){ return loadWorldModel('mdlCityTank'); }
function mdlRock(){ return loadWorldModel('mdlRock'); }
function mdlTree(){ return loadWorldModel('mdlTree'); }
/* Region flora. One imported broadleaf was the "one tree everywhere" tell.
These stay procedural and cheap so MEDIUM can instance fewer of the same
silhouette instead of a second atlas. Authored near-neutral; instance tint
carries the kit colour. */
function mdlTreePine(){
const m=MB();
m.mat(MAT.EARTH);
m.cyl(0,0,0,1.15,0.72,8.2,6,C(72,56,40));
m.mat(MAT.DENSE_LEAF);
m.cyl(0,6.2,0,6.4,0.18,8.0,7,C(46,72,48),false);
m.cyl(0,10.4,0,4.7,0.14,7.0,7,C(40,68,44),false);
m.cyl(0,15.0,0,2.9,0.04,5.8,7,C(36,64,40),false);
return m.build();
}
function mdlTreePalm(){
const m=MB();
m.mat(MAT.EARTH);
m.cyl(0,0,0,0.92,0.52,14.2,6,C(118,92,58));
m.mat(MAT.LEAF);
for(let k=0;k<6;k++){
const a=k/6*TAU;
m.box(Math.cos(a)*4.3,15.4,Math.sin(a)*4.3,7.6,0.34,1.35,C(70,110,48),a);
}
return m.build();
}
function mdlTreeDead(){
const m=MB();
m.mat(MAT.EARTH);
m.cyl(0,0,0,1.02,0.44,12.4,6,C(58,48,40));
m.box(2.3,9.2,0,5.6,0.68,0.68,C(64,52,42),0.42);
m.box(-1.7,11.1,1.2,4.3,0.52,0.52,C(60,50,40),-0.72);
return m.build();
}
function mdlTreeSpore(){
const m=MB();
m.mat(MAT.BROOD_CHITIN);
m.cyl(0,0,0,1.45,0.88,10.2,7,C(88,48,72));
m.sphere(0,12.1,0,4.3,8,C(160,70,140),0.72);
m.mat(MAT.BROOD_SLIME);
m.sphere(0,13.3,0,2.35,7,C(210,90,170),0.80);
return m.build();
}
function mdlBush(){
const m=MB();
m.mat(MAT.LEAF);
m.sphere(0,1.55,0,2.75,7,C(62,96,44),0.72);
m.sphere(1.55,1.15,0.55,1.75,6,C(70,104,48),0.70);
m.sphere(-1.25,1.05,-0.75,1.55,6,C(54,88,40),0.70);
return m.build();
}
function mdlRockIce(){
const m=MB();
m.mat(MAT.ICE_PACK);
m.sphere(0,2.15,0,4.15,7,C(200,220,236),0.55);
m.box(-1.15,0,1.35,3.4,2.75,2.15,C(170,190,210),0.38);
return m.build();
}
function mdlRockSlag(){
const m=MB();
m.mat(MAT.BASALT_CRUST);
m.sphere(0,2.0,0,4.0,7,C(52,44,42),0.50);
m.box(1.35,0,-0.75,3.15,2.35,2.55,C(40,36,34),0.48);
m.mat(MAT.BLAST_SLAG);
m.box(0,2.35,0,1.55,0.48,1.55,C(80,48,36),0.18);
return m.build();
}
/* A crystal is not four cones. Real mineral habit is a CLUSTER: hexagonal
prisms sharing a root, leaning apart, each ending in a pointed
termination - and the faces are FLAT, because flat facets are what catch
the light as a unit and flash when the camera orbits. Every face here is
its own plane with its own normal (no smoothing), the shafts kink at a
mid-ring the way natural columns do, and the whole cluster wears the CRYST
material so the shader's transmission/fresnel path lights it from inside.
Authored near-white: the instance tint carries the deposit band colour
(cyan/green/pink), and a saturated authored hue would fight it. */
function crystalSpike(m,bx,bz,r,h,lx,lz,ph,BASE,MID,TIP){
const seg=6, mh=h*0.60, A=[], B=[];
const N=(a,b,c)=>{ const ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2],
vx=c[0]-a[0],vy=c[1]-a[1],vz=c[2]-a[2];
let nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx;
const l=Math.hypot(nx,ny,nz)||1; return [nx/l,ny/l,nz/l]; };
for(let k=0;k<seg;k++){
const a=ph+k/seg*TAU;
A.push([bx+Math.cos(a)*r, -1.5, bz+Math.sin(a)*r]);
/* The mid ring twists 0.12 rad and steps toward the lean: the kink that
separates "grown column" from "extruded prop". */
B.push([bx+lx*0.55+Math.cos(a+0.12)*r*0.74, mh, bz+lz*0.55+Math.sin(a+0.12)*r*0.74]);
}
const apex=[bx+lx,h,bz+lz];
for(let k=0;k<seg;k++){
const j=(k+1)%seg;
m.quad(A[k],A[j],B[j],B[k],(k%3)?((k%2)?MID:BASE):C(160,190,210));
const n=N(B[k],apex,B[j]);
m.tri(B[k],apex,B[j],n[0],n[1],n[2],(k%2)?TIP:MID);
}
}
function mdlCrystal(){
const m=MB(); m.mat(MAT.CRYST);
const BASE=C(120,148,168), MID=C(196,222,238), TIP=C(255,255,255);
crystalSpike(m, 0.0, 0.0, 3.1, 17.5, 1.7, 0.9, 0.30, BASE,MID,TIP);
crystalSpike(m, 3.7, 1.1, 2.1, 11.0, 2.3, 1.1, 1.70, BASE,MID,TIP);
crystalSpike(m,-3.1, 2.3, 1.8, 9.5, -2.1, 1.5, 0.90, BASE,MID,TIP);
crystalSpike(m,-1.9,-3.3, 1.6, 8.0, -1.1,-2.3, 2.40, BASE,MID,TIP);
crystalSpike(m, 2.3,-2.9, 1.3, 6.5, 1.5,-1.7, 4.00, BASE,MID,TIP);
crystalSpike(m,-4.3,-0.7, 1.1, 5.0, -2.5,-0.5, 5.20, BASE,MID,TIP);
return m.build();
}
function mdlDeposit(){
/* Shards only. The two STONE cylinders (r 32 / r 17) were the dark
disk under every crystal node — same hole the terrain scar painted.
Veins mark the bed; FX.crystal owns the outcrop. */
const m=MB();
m.mat(MAT.CRYST);
const BASE=C(150,178,198), MID=C(214,236,248), TIP=C(255,255,255);
for(let k=0;k<6;k++){
const a=k/6*TAU, d=6+(k%2)*4;
crystalSpike(m, Math.cos(a)*d, Math.sin(a)*d, 3.0-(k%3)*0.6, 9.5+(k%3)*3.5,
Math.cos(a)*2.0, Math.sin(a)*2.0, k*1.13, BASE,MID,TIP);
}
crystalSpike(m, 0,0,3.8,14.5,0.5,-0.4,0.55,BASE,MID,TIP);
return m.build();
}
function mdlGeyser(){
/* Natural vent. Two swaps made the phone-shot hatch:
1) mdlGeyser() → loadWorldModel('mdlGeyser') (12-gon CONC frustum);
today's civic ROOF/TRIM remap painted its +Y as a metal iris.
2) hud.js still stamped sprites.geyser (dark ellipses + 5 rim arcs)
on top in 3D. A sphere pile from look-down is the same cone.
Rock spires + offset steam — STONE, not CRYST, so it is not a mass node. */
const m=MB();
const ROCK=C(118,108,92), ASH=C(86,80,70), RIM=C(148,138,118), GLOW=C(210,236,248);
m.mat(MAT.STONE);
/* Irregular boulders only. crystalSpike facets read as iris plates from
look-down — that is the hatch again. */
m.sphere(0,2.8,0,6.2,7,ROCK,0.48);
m.sphere(-5.6,2.2,3.8,4.4,6,ASH,0.55);
m.sphere(5.2,2.0,-4.2,4.1,6,RIM,0.52);
m.sphere(-3.4,1.8,-5.0,3.6,6,ASH,0.60);
m.sphere(4.8,1.6,4.6,3.4,6,ROCK,0.58);
m.sphere(1.2,2.4,5.8,3.0,6,RIM,0.62);
m.sphere(-0.4,11.5,0.6,3.2,7,ROCK,0.78);
m.sphere(0.8,16.8,-0.4,2.6,7,ASH,0.82);
m.sphere(-0.5,21.4,0.8,2.0,7,RIM,0.80);
m.mat(MAT.CRYST);
m.sphere(0.2,24.6,0.3,3.2,7,GLOW,0.88);
m.sphere(-0.8,27.8,0.7,2.4,6,GLOW,1.02);
m.sphere(0.9,30.2,-0.3,1.7,6,GLOW,0.72);
return m.build();
}
function mdlCrate(){ return loadWorldModel('mdlCrate'); }
function mdlWreck(){ return loadWorldModel('mdlWreck'); }
function mdlBerm(){ return loadWorldModel('mdlBerm'); }
/* Effect geometry — all real solids, no camera-facing quads anywhere. */
function mdlShell(){ return MB().sphere(0,0,0,1,10,C(255,255,255),1,false).build(); }
function mdlBolt(){ return MB().cyl(0,-0.5,0,0.32,0.10,1.6,6,C(255,255,255)).build(); }
function mdlShard(){ return MB().box(0,-0.5,0,1,1,1,C(255,255,255)).build(); }
function mdlBeamSeg(){ // unit cylinder along +Y, scaled in the shader
return MB().cyl(0,0,0,1,1,1,7,C(255,255,255),false).build();
}
function mdlCone(){ return MB().cyl(0,0,0,1,0.06,1,10,C(255,255,255),false).build(); }
function mdlRing(){ return MB().ring(0,0,0,0.82,1,30,C(255,255,255)).build(); }
function mdlDisc(){ return MB().ring(0,0,0,0,1,26,C(255,255,255)).build(); }
/* ---------------------------------------------------------------------------
FLASHLIGHT KIT
The old headlamp was a camera-facing ribbon: a thin streak that never read
as light FALLING ON THE GROUND. What sells a flashlight in a top-down view
is the lit floor, not the beam in the air — a wedge of brightness widening
from the lamp across the terrain, ending in a pool. Both meshes lie flat on
the ground and draw in the additive pass, so they brighten whatever they
cover; the depth test stops them painting across rocks and walls, which
doubles as free occlusion.
Gradients are baked as constant-colour bands: under additive blending black
adds nothing, so a band fading toward black IS the soft edge — no texture.
Instance scale = reach along +X, the cross-axis lane = cone width, so the
beam keeps a fixed angle at any length, exactly as a real cone does.
--------------------------------------------------------------------------- */
function mdlLightWedge(){
const m=MB();
const bands=[[0.03,0.28,1.00],[0.28,0.56,0.62],[0.56,0.82,0.30],[0.82,1.00,0.10]];
for(const [x0,x1,v] of bands){
const z0=x0*0.5, z1=x1*0.5; // half-angle baked: width tracks length
const cz0=z0*0.42, cz1=z1*0.42; // bright core strip
m.quad([x0,0,-cz0],[x1,0,-cz1],[x1,0,cz1],[x0,0,cz0],[v,v,v]);
const e=v*0.32; // soft flanks
m.quad([x0,0,-z0],[x1,0,-z1],[x1,0,-cz1],[x0,0,-cz0],[e,e,e]);
m.quad([x0,0,cz0],[x1,0,cz1],[x1,0,z1],[x0,0,z0],[e,e,e]);
}
return m.build();
}
/* Radial pool: bright core falling off to nothing. Serves as the beam's end
splash and as machine underglow — the hull sitting in its own worklight. */
function mdlLightPool(){
const m=MB();
const steps=[[0,0.34,1.00],[0.34,0.60,0.66],[0.60,0.84,0.32],[0.84,1.0,0.09]];
for(const [r0,r1,v] of steps) m.ring(0,0,0,r0,r1,26,[v,v,v]);
return m.build();
}
/* ---------------------------------------------------------------------------
SHADOW DECAL
The single largest difference between this and the reference art was not
geometry or texture — it was that nothing cast a shadow, so every building
and vehicle looked pasted onto the ground rather than standing on it. This
is the receiver: a flat disc whose vertex colour runs from dark at the core
to white at the rim. Drawn through the unlit program with a MULTIPLY blend,
white leaves the ground untouched and dark multiplies it down, so the rings
read as one soft-edged shadow rather than a stack of decals.
--------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
MODULE MARK — the field badge a crafted module puts on the machine carrying
it. A ring with a floating core, tinted to the module. Its whole job is to be
noticed when it stops being there: when a module wears out, the mark simply
is not drawn, and the player sees that a boost is gone rather than reading
it in a menu later.
--------------------------------------------------------------------------- */
function mdlModMark(){
const m=MB();
m.ring(0,0,0,0.62,1.0,18,C(255,255,255));
m.ring(0,0.05,0,1.12,1.30,18,C(255,255,255));
m.sphere(0,0.34,0,0.34,8,C(255,255,255),1,false);
for(let k=0;k<3;k++){
const a=k/3*TAU;
m.box(Math.cos(a)*0.86,0.02,Math.sin(a)*0.86,0.30,0.10,0.16,C(255,255,255),a);
}
return m.build();
}
function mdlShadow(){
const m=MB();
const steps=[[0,0.40,0.42],[0.40,0.58,0.55],[0.58,0.74,0.68],[0.74,0.88,0.82],[0.88,1.0,0.94]];
for(const [r0,r1,v] of steps) m.ring(0,0,0,r0,r1,26,[v,v,v]);
return m.build();
}
function mdlPlate(){ // flat unit square on the ground
const m=MB();
m.quad([-0.5,0,-0.5],[0.5,0,-0.5],[0.5,0,0.5],[-0.5,0,0.5],C(255,255,255)); // faces up
return m.build();
}
/* A unit-LENGTH, hairline-WIDTH strip. Instances carry a single uniform scale,
so a thin line can't be made by squashing a square — it needs its own mesh
with the thinness baked in. Everything rectilinear on the ground (build-zone
borders, range squares, guides) is drawn from this. */
function mdlLine(){
const m=MB();
m.quad([-0.5,0,-0.5],[0.5,0,-0.5],[0.5,0,0.5],[-0.5,0,0.5],C(255,255,255)); // 1 x 1; width comes from the instance
return m.build();
}
/* ---------------------------------------------------------------------------
Upload everything once, at boot. Caps are sized to what can realistically be
on screen at once, not to MAXU — culling keeps the instance streams small.
--------------------------------------------------------------------------- */
const FX={};
/* ============================================================================
SECOND-WAVE CHASSIS
Each of these exists to make a role readable at a glance. A crowd-control
launcher has to look like a launcher from directly overhead, an anti-tank
gun has to read as one long barrel, and a hero has to be unmistakable in a
press of two hundred units.
============================================================================ */
/* THESE SIX SHARED ONE HEXAGON.
Reaper, Cinder, Lancer, Resonator, Warden and Harbinger were all a 6-point
extrude whose profiles differed by under 0.6 world units — the same plan-view
shape in six colours. Top-down, the hull footprint IS the silhouette, so at
40px of screen they were the same vehicle. Each now gets a genuinely
different outline: octagon, drum, arrowhead, spade. */
function mdlReaper(){ // saturation launcher — wide octagonal carrier
const m=MB();
runningGear(m,12,3.0,2.6,3.2,5,MET_D,1);
m.extrude(0,3.4,0,[[-6.2,-5.0],[-3.6,-6.3],[3.6,-6.3],[6.2,-5.0],
[6.2,5.0],[3.6,6.3],[-3.6,6.3],[-6.2,5.0]],2.6,MET);
/* The hull was a bare octagonal slab with a box cradle on it — correct
silhouette, no fabrication. A launcher is a BOX OF TUBES on a frame, so
the frame has to be visible: a recessed deck, a cradle that is a trapezoid
with end cheeks rather than a slab, elevation rams under it, and the tubes
bedded into a real backplate instead of floating. */
m.extrude(0,6.0,0,[[-5.4,-4.3],[-3.1,-5.4],[3.1,-5.4],[5.4,-4.3],
[5.4,4.3],[3.1,5.4],[-3.1,5.4],[-5.4,4.3]],0.6,MET_D);
m.wedge(4.4,6.0,0,2.4,0.9,7.6,MET_L); // glacis
/* CRADLE: trapezoid body, cheek plates at each end, and the whole thing
pitched up so it reads as elevated rather than as a lid. */
m.extrude(-0.6,6.6,0,[[-4.2,-3.4],[3.4,-2.9],[4.2,-1.5],[4.2,1.5],
[3.4,2.9],[-4.2,3.4]],2.6,TEAM_A);
for(const sd of [-1,1]){
m.bevelBox(-0.6,6.6,sd*3.3,8.0,2.7,0.7,0.24,MET_D); // cheek plates
m.cyl(-3.0,6.2,sd*2.4,0.42,0.34,2.0,7,MET_L); // elevation ram
m.bevelBox(2.6,6.4,sd*4.5,3.0,1.3,1.1,0.26,MET_D); // sponson pods
m.cyl(5.0,6.5,sd*3.0,0.30,0.26,1.1,6,MET_L); // tie-downs
}
m.box(-4.6,7.0,0,1.0,2.4,6.2,MET_D); // backplate the tubes bed into
for(let r=0;r<3;r++) for(const sd of [-1,1]){
m.tube(1.2+r*0.2,7.4+r*1.5,sd*(1.1+r*0.9),0.85,0.55,5.6,8,DARKER,0);
m.ring(4.0+r*0.2,7.4+r*1.5,sd*(1.1+r*0.9),0.58,0.90,8,MET_L); // muzzle collars
}
m.bevelBox(-4.4,7.6,0,2.4,2.0,4.6,0.32,MET_D); // reload gantry
ventBank(m,-3.0,6.62,0,2.4,3.0,4,MET_D,0);
stowage(m,-1.2,6.62,4.0,3.0,1.1,RUST,0,53);
exhaust(m,-5.6,4.8,0,2,0.40,2.0,MET_D,2.0);
glowStrip(m,-0.6,9.9,0,6.4,HOT,0);
deckCrown(m,0,6.02,0,12.4,12.6,MET_D,TEAM_T);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlCinder(){ // incendiary sprayer — tanks and nozzle
const m=MB();
runningGear(m,11,2.8,2.4,3.0,5,MET_D,1);
/* Squared-off tanker plan, and the fuel drums below now break its outline
rather than hiding inside a hex the same width as everyone else's. */
m.extrude(0,3.2,0,[[-5.8,-4.4],[4.2,-4.4],[5.4,-2.2],[5.4,2.2],[4.2,4.4],[-5.8,4.4]],2.4,MET);
/* The drums were doing all the work and the body under them was bare. A
flame vehicle is PLUMBING: tanks need end caps, bands and a cradle; the
nozzle needs a pump, a feed line running back to the tanks, and a heat
shield. Every one of those also breaks the silhouette. */
m.extrude(0,5.6,0,[[-5.2,-3.7],[3.8,-3.7],[4.8,-1.9],[4.8,1.9],[3.8,3.7],[-5.2,3.7]],0.5,MET_D);
m.wedge(4.2,5.6,0,2.2,0.9,6.4,MET_L); // glacis
for(const sd of [-1,1]){
m.cyl(-2.6,5.9,sd*2.2,1.9,1.9,5.4,10,RUST,Math.PI/2); // fuel drum
m.cyl(-5.3,5.9,sd*2.2,2.0,1.6,0.5,10,MET_D,Math.PI/2); // end caps
m.cyl( 0.1,5.9,sd*2.2,1.6,2.0,0.5,10,MET_D,Math.PI/2);
for(let k=0;k<2;k++) m.cyl(-4.0+k*2.4,5.9,sd*2.2,2.02,2.02,0.30,10,MET_L,Math.PI/2); // bands
m.bevelBox(-2.6,4.9,sd*2.2,5.6,1.0,1.5,0.24,MET_D); // tank cradle
m.cyl(1.4,6.6,sd*1.5,0.26,0.24,4.6,6,DARKER,Math.PI/2); // feed line to the pump
}
m.bevelBox(2.6,5.9,0,3.2,1.9,2.6,0.36,MET_D); // pump housing
m.cyl(2.6,7.8,0,0.75,0.60,0.9,9,MET_L); // pressure vessel
m.bevelBox(4.8,6.1,0,1.5,1.3,1.9,0.28,MET_L); // nozzle mount
m.tube(6.4,6.1,0,0.9,0.55,3.2,8,HOT,Math.PI/2); // nozzle
m.wedge(5.6,7.1,0,1.8,0.7,2.6,MET_D); // heat shield over the nozzle
m.bevelBox(-5.4,6.1,0,1.5,1.5,4.2,0.3,DARKER);
ventBank(m,-1.0,5.68,0,2.0,2.2,3,MET_D,0);
exhaust(m,-5.6,4.6,0,2,0.36,1.8,MET_D,1.8);
glowStrip(m,-2.6,8.4,0,4.6,HOT,0);
deckCrown(m,-0.2,5.62,0,11.2,8.8,MET_D,TEAM_T);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlLancer(){ // gauss rifle — one very long barrel
const m=MB();
runningGear(m,12,2.8,2.4,3.2,5,MET_D,0);
m.extrude(0,3.2,0,[[-6.6,-1.9],[1.8,-2.5],[7.4,-1.0],[8.8,0],
[7.4,1.0],[1.8,2.5],[-6.6,1.9]],2.2,MET);
m.bevelBox(-1.0,5.4,0,6.0,2.4,4.4,0.5,TEAM_A);
m.cyl(4.0,6.4,0,0.75,0.62,13.0,10,MET_D,Math.PI/2); // rail
for(let k=0;k<4;k++) m.tube(5.0+k*2.6,6.4,0,1.05,0.78,0.55,10,ENERGY,Math.PI/2); // coils
m.box(-4.4,6.2,0,2.4,2.6,3.6,DARKER); // capacitor bank
m.wedge(4.6,5.42,0,3.6,1.1,3.2,MET_L); // nose spine
for(const sd of [-1,1]) m.bevelBox(-1.6,4.9,sd*2.1,7.0,1.3,0.9,.24,MET_D); // side rails
exhaust(m,-6.2,4.9,0,2,.40,2.0,MET_D,1.8);
glowStrip(m,-4.4,7.6,0,3.0,ENERGY,0);
deckCrown(m,-0.4,5.42,0,12.0,5.0,MET_D,TEAM_T);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlResonator(){ // sonic — dish and ribs
const m=MB();
/* The circular plan is the right call and it stays — nothing else in the
roster is round from above, so it is identifiable in one glance. What was
missing is everything that makes a round thing look ENGINEERED: the drum
had no rim, no ribs, no seam, so it read as a barrel someone left on a
tank. Chamfered rim, radial ribs, and an emitter stack that is obviously a
resonator rather than a turret. */
runningGear(m,11,2.8,2.4,3.0,5,MET_D,1);
m.cyl(0,3.2,0,4.7,4.4,1.9,16,MET); // drum body
m.cyl(0,5.1,0,4.4,4.0,0.55,16,MET_L); // chamfered rim
m.cyl(0,5.65,0,3.7,3.7,0.30,16,MET_D); // recessed deck
/* RADIAL RIBS. Eight spokes turn a smooth cylinder into a fabricated housing,
and from overhead they are what breaks the circle up. */
for(let k=0;k<8;k++){
const a=k/8*TAU;
m.box(Math.cos(a)*3.0,5.66,Math.sin(a)*3.0,2.6,0.34,0.52,MET_L,a);
}
m.box(-4.4,3.2,0,2.4,2.2,5.8,MET_D); // rear counterweight
armorPlate(m,-4.4,4.2,0,4.6,1.2,0.38,0,TEAM_A,0);
/* EMITTER STACK — three rings on a short column, widest at the base, so the
shape reads as a horn projecting upward. */
m.cyl(1.4,5.95,0,2.2,1.7,1.1,14,MET_D);
for(let k=0;k<3;k++) m.ring(1.4,7.05+k*0.52,0,1.9-k*0.46,2.5-k*0.46,18,k?ENERGY:MET_L);
m.cyl(1.4,8.7,0,0.9,0.5,0.7,12,ENERGY);
for(const sd of [-1,1]){
m.bevelBox(-3.0,5.9,sd*1.9,2.6,1.5,1.3,0.30,TEAM_A); // capacitor pods
m.box(-3.0,7.4,sd*1.6,2.4,0.42,0.42,ENERGY);
m.cyl(3.6,5.5,sd*2.4,0.30,0.26,1.1,6,MET_D); // tie-downs
}
ventBank(m,-2.0,5.68,2.4,2.0,1.6,3,MET_D,0);
glowStrip(m,0,5.98,0,3.2,ENERGY,0);
return {hull:m.build(),tur:null,s:1.0};
}
function mdlWarden(){ // field repair — crane and drum
const m=MB();
/* A support vehicle has to read as a TOOL, not as a gun with the barrel taken
off. It was a plain hex slab with a drum on top; the drum was the only
thing saying "repair", and from overhead a drum is a circle like any other.
Rebuilt around the working end: a hose reel with visible spooled line, a
folding gantry that reaches OVER the thing being repaired, and hazard
chevrons on the deck. Hard-surface throughout — bevels, plate breaks and an
inner extrusion — because it is a machine. */
runningGear(m,10,2.6,2.2,2.8,4,MET_D,0);
const HB=[[-4.8,-2.9],[-2.4,-3.3],[2.6,-3.1],[4.5,-1.6],[4.5,1.6],[2.6,3.1],[-2.4,3.3],[-4.8,2.9]];
m.extrude(0,3.0,0,HB,2.0,MET);
m.extrude(0,5.0,0,HB.map(q=>[q[0]*0.82,q[1]*0.82]),0.7,MET_D); // recessed deck
armorPlate(m,-0.4,5.1,0,4.4,1.1,0.36,0,TEAM_A,0);
/* HOSE REEL. Two cheeks with a spooled drum between them reads as a reel at a
glance; a bare cylinder does not. */
for(const sd of [-1,1]) m.cyl(-1.4,5.7,sd*1.9,2.0,2.0,0.42,12,MET_L,Math.PI/2);
for(let k=0;k<5;k++) m.cyl(-1.4,5.7,-1.5+k*0.75,1.62-k*0.06,1.62-k*0.06,0.62,10,DARKER,Math.PI/2);
m.tube(-1.4,7.5,0,1.5,0.9,0.5,12,ENERGY); // emitter head
/* FOLDING GANTRY — the arm reaches forward and DOWN over the casualty, which
is the shape that says "working on something" rather than "aiming". */
m.bevelBox(2.2,5.6,0,1.7,1.9,2.0,0.32,MET_D); // shoulder
m.cyl(2.2,7.4,0,0.40,0.36,1.5,7,MET_L);
m.box(3.8,8.5,0,3.8,0.62,0.62,MET_L); // upper boom
m.box(5.6,7.6,0,0.58,2.0,0.58,MET_D); // drop link
m.cyl(5.6,6.5,0,0.85,0.55,0.8,9,ENERGY); // repair emitter
hydraulic(m,2.9,5.9,0,2.4,0.26,MET_L,0);
/* Hazard chevrons on the rear deck — the visual language of a service
vehicle, and it lands where the top-down camera actually looks. */
for(let k=0;k<3;k++) m.box(-3.4+k*0.9,5.74,0,0.44,0.14,4.2,k&1?MET_D:LAMP,0.42);
ventBank(m,-4.0,5.72,0,1.6,3.0,3,MET_D,0);
stowage(m,0.8,5.72,2.3,2.4,1.0,RUST,0,23);
glowStrip(m,2.2,6.66,0,2.6,ENERGY,0);
deckCrown(m,0,5.72,0,8.6,6.2,MET_D,TEAM_T);
return {hull:m.build(),tur:null,s:0.95};
}
function mdlKestrel(){ // scout aircraft — thin delta
const m=MB();
const keel=[[-7.25,-1.30],[-4.25,-1.72],[2.35,-2.05],[6.45,-.82],[7.65,0],
[6.45,.82],[2.35,2.05],[-4.25,1.72],[-7.25,1.30]];
const spine=[[-6.30,-1.02],[-3.65,-1.30],[2.40,-1.48],[5.85,-.58],[6.65,0],
[5.85,.58],[2.40,1.48],[-3.65,1.30],[-6.30,1.02]];
m.extrude(0,0,0,keel,1.12,MET_D); // shallow sensor-aircraft keel
m.extrude(0,1.12,0,spine,1.18,MET);
m.extrude(.30,2.30,0,spine.map(p=>[p[0]*.76,p[1]*.65]),.48,TEAM_A);
m.bevelBox(3.60,2.46,0,2.85,.88,1.72,.32,GLASS); // low-profile sensor canopy
m.wedge(5.43,2.68,0,1.48,.44,1.48,MET_L,0,true);
m.box(-.72,2.80,0,4.25,.28,.76,TEAM_T); // dorsal livery rail
glowStrip(m,-.15,3.07,0,2.60,ENERGY,0);
for(const sd of [-1,1]){
const wing=sd>0
? [[-4.65,1.05],[2.70,1.28],[.55,6.10],[-5.45,5.02]]
: [[-4.65,-1.05],[-5.45,-5.02],[.55,-6.10],[2.70,-1.28]];
const panel=sd>0
? [[-3.72,1.55],[1.72,1.60],[.35,5.02],[-4.28,4.42]]
: [[-3.72,-1.55],[-4.28,-4.42],[.35,-5.02],[1.72,-1.60]];
m.extrude(0,.64,0,wing,.60,MET_D); // continuous delta wing
m.extrude(0,1.24,0,panel,.23,TEAM_A); // team panel follows silhouette
m.box(-3.55,1.26,sd*4.78,3.35,.55,1.10,DARK); // attached wing spar housing
m.bevelBox(-3.90,1.80,sd*4.78,2.35,.62,.88,.22,MET_L);
m.box(-4.60,2.36,sd*4.78,2.15,1.55,.34,TEAM_A); // canted-looking wingtip fin
m.box(-4.18,3.64,sd*4.78,1.25,.26,.42,LAMP); // navigation lamp on fin
m.box(-4.90,.78,sd*1.14,3.05,.88,1.32,DARKER); // twin micro-turbines merge into tail
cylX(m,-7.12,.92,sd*1.14,3.42,.72,.61,10,DARK,false);
tubeX(m,-7.66,.92,sd*1.14,.88,.58,.27,10,TWR_BORE); // real hollow exhaust
ringX(m,-7.70,.92,sd*1.14,.52,.78,12,MET_L);
ringX(m,-7.73,.92,sd*1.14,.20,.30,10,HOT);
ringX(m,-3.74,.92,sd*1.14,.44,.65,10,MET_L); // intake rim
m.box(3.72,.42,sd*.83,2.15,.72,.64,DARK); // attached nose-gun receiver
gunX(m,4.45,.68,2.58,.22,MET_D,sd*.83);
m.bevelBox(-.95,.38,sd*4.92,2.50,.76,.92,.22,DARKER); // sensor / countermeasure pod
for(let k=0;k<3;k++)
m.box(-1.72+k*.78,1.15,sd*4.92,.42,.30,.48,MET_L);
}
m.bevelBox(-5.48,1.62,0,2.55,1.02,2.45,.28,DARKER); // tail join prevents floating fins
ventBank(m,-2.62,2.80,0,1.95,1.10,4,MET_D,0);
m.greeble(.05,2.80,0,2.55,.82,.28,5,MET_D,0,251);
sensorMast(m,-4.48,2.60,0,1.12,MET_L);
return {hull:m.build(),tur:null,s:0.92};
}
function mdlBasilisk(){ // heavy siege tank, turreted
const m=MB();
/* THE DETAIL BUDGET RAN BACKWARDS. This is a tier-3 experimental at 260 mass
and 1050 energy, and its hull was five primitive calls — against roughly
twenty on the Goliath, a tier-2 costing a quarter as much. A player who
pays four times the price should not receive a model with a quarter of the
surface break-up. Brought up to the Goliath's vocabulary and past it:
that is what "experimental" has to look like. */
runningGear(m,17,4.4,3.6,4.6,7,MET_D,2);
m.extrude(0,4.2,0,[[-8.4,-4.8],[4.6,-5.4],[7.4,-2.6],[7.4,2.6],[4.6,5.4],[-8.4,4.8]],3.4,MET);
m.wedge(6.4,7.6,0,4.6,2.2,9.2,MET_L); // heavy glacis
for(const sd of [-1,1]){
armorPlate(m,0.5,7.6,sd*4.6,9.0,2.0,0.7,0,TEAM_A,0);
m.bevelBox(3.2,8.0,sd*3.8,5.0,1.6,2.2,.35,MET_D); // sponson blisters
m.cyl(6.8,7.9,sd*3.2,.32,.28,1.2,6,MET_L); // tow points
}
ventBank(m,-5.4,7.62,0,4.8,6.8,7,MET_D,0);
exhaust(m,-7.8,6.2,0,3,.52,2.8,MET_D,2.6);
stowage(m,-2.2,7.62,4.2,6.6,2.0,RUST,0,29);
sensorMast(m,-6.8,7.9,2.6,3.4,MET_L);
deckCrown(m,0,7.66,0,14.0,9.4,MET_D,TEAM_T);
m.bevelBox(-6.0,7.8,0,3.6,2.4,7.0,0.6,DARK);
return {hull:m.build(), tur:(()=>{
const t=MB();
t.bevelBox(-0.6,0,0,7.4,3.4,6.4,0.8,TEAM_A);
t.cyl(3.0,1.6,0,0.95,0.80,11.0,10,MET_D,Math.PI/2);
t.tube(13.4,1.6,0,1.25,0.90,1.0,10,DARKER,Math.PI/2); // muzzle brake
t.box(-3.6,2.6,0,2.4,1.6,4.0,MET_L);
return t.build();
})(), s:1.05, turH:7.6};
}
function mdlHarbinger(){ // long-range cluster artillery
const m=MB();
runningGear(m,16,4.0,3.2,4.2,6,MET_D,2);
m.extrude(0,3.8,0,[[-9.0,-3.2],[-6.2,-5.6],[4.2,-5.6],[7.2,-2.2],
[7.2,2.2],[4.2,5.6],[-6.2,5.6],[-9.0,3.2]],3.0,MET);
m.bevelBox(-1.0,6.8,0,9.0,2.6,8.0,0.7,TEAM_A);
for(let r=0;r<2;r++) for(let c=-1;c<=1;c++)
m.tube(0.0,8.4+r*2.0,c*2.6,1.15,0.78,7.2,8,DARKER,0);
for(const sd of [-1,1]){
m.box(-6.0,5.4,sd*4.6,3.0,1.4,1.4,MET_L); // outriggers
m.bevelBox(-6.2,4.9,sd*5.0,2.4,1.2,1.0,.28,MET_D); // outrigger feet
armorPlate(m,0.0,6.4,sd*5.2,10.0,2.2,0.6,0,TEAM_A,0); // flank skirts
m.cyl(5.6,6.5,sd*2.8,.34,.30,1.2,6,MET_L); // tow points
}
ventBank(m,-4.6,6.84,0,4.2,6.0,6,MET_D,0);
exhaust(m,-7.4,5.6,0,3,.48,2.6,MET_D,2.4);
stowage(m,-2.0,6.84,3.9,5.6,1.8,RUST,0,17);
glowStrip(m,-1.0,8.2,0,7.0,HOT,0);
deckCrown(m,-0.4,6.82,0,16.0,11.0,MET_D,TEAM_T);
return {hull:m.build(),tur:null,s:1.05};
}
/* ---- FACTION HEROES ------------------------------------------------------- */
function mdlPraetor(){ // Legion — a walking siege battery
const m=MB();
for(const sd of [-1,1]){
m.bevelBox(-1.2,0,sd*4.4,7.0,2.2,4.0,0.6,SERVO);
m.extrude(-0.4,2.2,sd*4.4,[[-2.0,-2.1],[2.0,-2.1],[2.4,0],[2.0,2.1],[-2.0,2.1],[-2.4,0]],5.6,SERVO);
hydraulic(m,-2.0,2.2,sd*4.4,5.2,0.52,SERVO,0);
m.bevelBox(0.2,7.8,sd*4.4,4.8,3.0,4.8,0.7,SERVO);
m.box(0.2,10.8,sd*4.4,4.2,3.2,4.2,SERVO);
}
m.extrude(0,13.6,0,[[-5.4,-5.8],[3.2,-6.2],[5.8,-3.0],[5.8,3.0],[3.2,6.2],[-5.4,5.8]],6.4,MET);
m.extrude(0,20.0,0,[[-4.4,-4.8],[2.6,-5.2],[4.8,-2.4],[4.8,2.4],[2.6,5.2],[-4.4,4.8]],2.4,TEAM_A);
for(const sd of [-1,1]){
m.bevelBox(0.2,19.0,sd*7.4,5.4,4.4,4.4,0.8,TEAM_A); // pauldrons
armorPlate(m,0.2,23.4,sd*7.4,5.0,1.8,0.6,0,MET_D,0);
for(let r=0;r<2;r++) m.tube(-1.0,21.0+r*1.8,sd*7.4,1.0,0.68,5.4,8,DARKER,0); // shoulder mortars
}
cylX(m,4.6,16.4,0,12.0,1.15,0.95,10,MET_D,false); // siege cannon
tubeX(m,16.0,16.4,0,1.2,1.5,1.05,10,DARKER); // open muzzle collar
m.bevelBox(-4.6,20.2,0,4.0,3.2,7.0,0.7,DARK);
m.bevelBox(2.6,24.6,0,4.0,3.0,5.0,0.6,TEAM_A); // head
m.box(4.6,25.6,0,0.6,1.2,3.4,HOT);
glowStrip(m,0,22.6,0,7.0,HOT,0);
return {hull:m.build(),tur:null,s:1.10};
}
function mdlArchon(){ // Syndicate — hovering, shielded
const m=MB();
/* NINE PRIMITIVES, and one of them was wrong in a way no screenshot catches:
the plenum skirt was an OPEN cyl(), so from any angle below the horizon the
Broker had a black hole where its underside should be — cyl() only caps
when its ninth argument is truthy, and this one passed nothing. The rest
was a slab, a glass ball, three flat rings and two wedges that read as
signboards, on the faction hero of the cybernetic army.
Rebuilt as Syndicate hardware. Three things do the work: a CLOSED plenum
ring carrying six discrete lift pods, so the round base reads as engineered
repetition instead of as a disc; a chassis with a real plate break and
applique armour over it; and the sonic weapon actually PRESENT at the nose,
because TYPES gives this thing 70 damage on a 0.55 cooldown and the old
model had nowhere for it to come from. */
/* ---- PLENUM. Solid and chamfered, not a bucket: an open ring is a well the
camera looks straight down into, and the old skirt was worse than that
because cyl() left it uncapped at both ends. --------------------------- */
chamferCyl(m,0,0.35,0,5.4,1.75,10,MET_D,MET_L); // lift drum
m.ring(0,0.30,0,1.6,4.6,10,ENERGY); // under-wash, seen at low angles
/* SIX LIFT PODS. Repetition at a fixed angular interval is what makes a round
thing read as machined; a smooth ring reads as a coin. */
for(let k=0;k<6;k++){
const a=k/6*TAU, cx=Math.cos(a)*4.9, cz=Math.sin(a)*4.9;
m.bevelBox(cx,1.05,cz,2.5,1.35,2.1,0.34,MET,a);
m.box(cx,0.72,cz,1.7,0.34,1.3,HOT,a); // pod throat, underslung
}
/* ---- CHASSIS. A DART in plan, not an octagon. This camera looks almost
straight down, so PLAN VIEW IS THE SILHOUETTE, and a hero that cannot state
which way it faces from directly above is unreadable exactly when it
matters. Prow at +X, shoulders at the back, two extrusions stepped so there
is a real plate break all the way round.
The body stays BRIGHT (MET / MET_L) and only the plinth and the recesses go
dark: the reference chassis in this file read the way they do because of
that value split, and a hull painted MET_D everywhere loses its own panel
breaks before the tint is even applied. ------------------------------- */
const LO=[[-5.0,-3.4],[-2.8,-4.3],[1.4,-3.9],[5.4,-1.5],[7.0,0],
[5.4,1.5],[1.4,3.9],[-2.8,4.3],[-5.0,3.4]];
m.extrude(0,2.30,0,LO,2.2,MET_D); // dark plinth
m.extrude(0,4.50,0,LO.map(p=>[p[0]*0.93,p[1]*0.90]),1.5,MET);
m.extrude(0,6.00,0,LO.map(p=>[p[0]*0.80,p[1]*0.72]),0.55,MET_L);
/* THE LIVERY IS ONE PANEL, and it is the one the camera looks straight at:
the whole upper deck. Scattering five thin stripes over a hull reads as
pinstriping at 60px; a single top-facing plate reads as an army. */
m.extrude(0,6.55,0,LO.map(p=>[p[0]*0.66,p[1]*0.58]),0.34,TEAM_A);
for(const sd of [-1,1]){
armorPlate(m,-0.4,2.7,sd*4.3,6.6,1.8,0.42,0,MET_D,0); // applique belt
m.box(0.2,4.44,sd*3.55,7.0,0.42,0.70,TEAM_B); // flank accent
insetPanel(m,-2.6,6.06,sd*2.2,3.0,1.6,0.28,MET_D,0); // recessed deck panel
m.wedge(3.8,6.02,sd*1.7,3.0,0.8,2.2,MET_L,0,sd<0); // prow shoulder fairings
}
ventBank(m,-3.6,6.06,0,2.0,3.2,4,MET_D,0);
glacisX(m,4.8,6.02,0,3.2,0.9,3.2,MET_L); // raked nose deck
/* ---- SONIC PROJECTORS. Two horns carried OUTSIDE the prow and projecting
past it, so the weapon is part of the OUTLINE rather than a detail sitting
on top of it. A sound weapon wants a flared throat, not gunX's bore. --- */
for(const sd of [-1,1]){
m.bevelBox(4.0,3.2,sd*3.1,3.6,2.4,2.4,0.42,MET); // trunnion housing
cylX(m,4.6,4.4,sd*3.1,3.8,0.62,1.45,8,MET_L,false); // flared horn
ringX(m,8.2,4.4,sd*3.1,0.44,1.54,8,ENERGY); // emitter face
hydraulic(m,2.6,3.2,sd*3.1,1.8,0.26,MET_L,0); // elevation ram
m.box(2.6,5.66,sd*3.1,2.8,0.34,1.8,TEAM_B); // feed cover livery
}
/* ---- CORTEX. A naked glass ball is a marble. Set the glass INSIDE a
bevelled cowl so only a band of it shows and the sensor reads as armoured
hardware carrying an eye. -------------------------------------------- */
m.bevelBox(-0.6,6.89,0,4.4,1.7,4.0,0.55,MET); // cortex housing
m.sphere(-0.6,8.05,0,1.6,8,GLASS,0.62,true); // glazed dome
m.bevelBox(-0.6,8.55,0,3.4,0.65,3.2,0.40,MET_L); // cowl cap over it
m.box(1.2,8.05,0,0.55,0.5,1.9,LAMP); // forward eye slit
/* ---- DORSAL VANES. Swept delta fins, root fairing to tip cap, raked BACK so
they point away from the prow and reinforce the facing. --------------- */
for(const sd of [-1,1]){
m.extrude(0,6.30,sd*2.9,[[-4.6,-0.36],[-1.2,-0.36],[0.9,0.36],[-3.4,0.36]],3.0,MET);
m.box(-2.6,9.30,sd*2.9,4.0,0.34,0.80,MET_L); // vane cap
m.wedge(-0.4,6.30,sd*2.9,2.0,1.7,0.82,MET_L,0,sd<0); // root fairing
glowStrip(m,-2.6,7.20,sd*3.35,3.2,ENERGY,0); // coil conduit
}
m.bevelBox(-4.9,6.06,0,2.6,2.4,4.2,0.5,MET_D); // reactor bustle
exhaust(m,-6.0,6.06,0,2,0.44,1.5,MET_D,2.0);
return {hull:m.build(),tur:null,s:1.06};
}
function mdlBroodmother(){ // Horde — a walking hive
const m=MB();
/* The progenitor. Same kit as the drone and the Alpha, at hive scale — the
player has to recognise it instantly as the thing all the others came out
of, and that only works if it is built from the same anatomy rather than
from a different set of shapes that happen to be purple. Eight legs, a
swollen egg-bearing abdomen, and a crown. */
carapace(m,-1.2,6.2,0,22.0,6.6,4.2,CHITIN,
{segs:8,ridge:0.26,bump:0.13,keel:0.44,nose:0.52,tail:0.12,
waist:0.38,waistAt:0.56,waistW:0.065,seed:9091,u:18,v:22});
carapace(m,8.0,5.9,0,6.4,3.8,3.0,CHIT_D,
{segs:3,ridge:0.20,bump:0.16,keel:0.30,nose:0.36,tail:0.42,seed:404,u:12,v:9});
eyeCluster(m,10.0,6.5,0,1.35,3,LAMP);
for(let k=0;k<4;k++) for(const sd of [-1,1])
broodLeg(m, 4.0-k*3.0, 5.6, 0, sd, 5.8+k*0.26, 5.6, 3.90-k*0.20, 0.96, null,
((k+(sd<0?1:0))&1)*Math.PI, [4.05,3.15,4.10,3.70][k]);
for(const sd of [-1,1]){
chitinBlade(m,9.0,5.6,sd*2.0,7.2,0.90,-sd*0.46,sd*0.26,CLAW,0.08); // hive jaws
chitinBlade(m,8.8,6.6,sd*1.1,4.8,0.54,-sd*0.34,sd*0.12,CLAW,0.12);
tendril(m,9.2,7.9,sd*1.5,9.5,0.38,sd,4.2,BIO_LEG);
tendril(m,7.4,8.4,sd*2.4,7.6,0.30,sd,3.4,BIO_LEG);
/* Brood-lumen lobes — the egg sacs. Lit tissue, so they carry the faction
colour and glow through the translucency term. */
m.sphere(-7.0,8.0,sd*2.7,2.3,9,BIO_TEAM,0.74,false);
m.sphere(-3.8,8.6,sd*2.9,1.6,8,BIO_TEAM,0.74,false);
chitinBlade(m,1.6,7.6,sd*3.0,6.4,0.62,sd*0.28,Math.PI*0.84,CLAW,0.60);
}
/* Dorsal crown, and a ring of spiracle vents around the abdomen. */
for(let k=0;k<8;k++)
chitinBlade(m,-6.4+k*1.75,8.4,0,3.4+Math.sin(k/7*Math.PI)*3.0,0.50,0.10,Math.PI*0.96,CLAW,1.00);
for(let k=0;k<6;k++){
const a=k/6*TAU;
m.tube(-6.0+Math.cos(a)*3.4,8.9,Math.sin(a)*3.4,0.78,0.40,0.66,8,CHIT_D);
}
tubercles(m,-5.4,7.8,0,4.4,3.6,12,1.15,CHIT_D,61);
return {hull:m.build(),tur:null,s:1.16};
}
/* Build an instancing registry from a faction model map. A faction may replace
only the silhouettes it owns; bldMeshFor() falls back to Nova geometry for
old saves or intentionally shared structures. A complete single-mesh weapon
omits a turret map, preventing a legacy Nova barrel from being drawn over it. */
function initBldMeshSet(modelMap,turMap,tierMap,turH,turS,cap){
const out={}; cap=cap||260;
for(const k in BLD_MDL){
if(!modelMap||typeof modelMap[k]!=='function') continue;
const tiers=tierMap&&tierMap[k];
const variants=tiers?tiers.map(V=>({
base:new InstMesh(gl,V.base(),cap),tur:V.tur?new InstMesh(gl,V.tur(),cap):null
})):null;
out[k]=variants?{base:variants[0].base,tur:variants[0].tur,variants}: {
base:new InstMesh(gl,modelMap[k](),cap),
tur:turMap&&turMap[k]?new InstMesh(gl,turMap[k](),cap):null,
variants:null
};
out[k].turH=turH&&turH[k]!=null?turH[k]:(BLD_TUR_H[k]||14);
out[k].turS=turS&&turS[k]!=null?turS[k]:(BLD_TUR_S[k]||1);
}
return out;
}
/* THE PREVIEWS DISAGREED WITH THE BATTLEFIELD.
Every faction has a complete bespoke building kit (27/27) and its own unit
chassis, and the battlefield resolves both correctly. But three surfaces went
straight to the base maps instead: the build-menu and intel "LIVE 3D MODEL"
thumbnails, the main-menu model carousel, and the placement ghost. A Brood
player therefore designed their base out of a Nova catalogue, watched a Nova
ghost slide across the ground, and only saw grown tissue after the structure
finished. These two are the one seam every preview should ask. */
function factionKitKey(raw){
const r=String(raw||'').trim().toLowerCase();
if(!r) return (typeof playerKitKey==='function')?playerKitKey():'nova';
if(r==='nova'||r==='legion'||r==='syndicate'||r==='horde') return r;
if(typeof FACTIONS!=='undefined'&&FACTIONS[r]&&FACTIONS[r].kit) return FACTIONS[r].kit;
/* Saves and authored missions predate the short runtime keys. Resolve their
doctrinal names here so a Coalition building can never fall through into
another faction's already-resident GPU registry. */
if(r.includes('machine')||r.includes('syndicate')||r.includes('coalition')) return 'syndicate';
if(r.includes('brood')||r.includes('swarm')||r.includes('infestation')||r.includes('horde')) return 'horde';
if(r.includes('dominion')||r.includes('red ascendancy')||r.includes('legion')) return 'legion';
if(r.includes('frontline')||r.includes('federation')||r.includes('terran')) return 'nova';
/* Preserve an unknown key so strict preview callers can reject it. Turning
malformed data into Nova would silently reintroduce the cross-faction art
leak this resolver exists to expose. Battlefield callers still retain
their explicit legacy fallback in bldMeshFor(). */
return r;
}
/* FACTION MODEL OWNERSHIP IS CLOSED, NOT A FALLBACK CHAIN.
The shared TYPES table is a save-compatible rules vocabulary; it is not an
art catalogue. Every live faction model must therefore exist in that
faction's exact kit. Slots 12/13/30/31 are Brood organisms and may never
leak through UNIT_MESH as a blue, red or green substitute. */
function factionUnitModelAllowed(ty,kit){
const k=factionKitKey(kit||((typeof playerKitKey==='function')?playerKitKey():'nova'));
return !!(typeof FAC_KIT!=='undefined'&&FAC_KIT[k]&&typeof FAC_KIT[k][ty]==='function');
}
function factionUnitMeshFor(ty,kit){
const k=factionKitKey(kit||((typeof playerKitKey==='function')?playerKitKey():'nova'));
if(!factionUnitModelAllowed(ty,k)) return null;
return (typeof FAC_MESH!=='undefined'&&FAC_MESH[k]&&FAC_MESH[k][ty])||null;
}
function factionUnitGeo(ty,kit,strict){
const k=factionKitKey(kit||((typeof playerKitKey==='function')?playerKitKey():'nova'));
const fn=(typeof FAC_KIT!=='undefined'&&FAC_KIT[k]&&FAC_KIT[k][ty])||null;
if(fn){ try{ return fn(); }catch(e){} }
/* Returning the base registry here was the leak: Nova was exempt from the
old strict check, so Blue slot 12 or 30 resolved to a Ravager or Brood
Sovereign. Missing faction art must be visible as missing, never disguised
as another faction's successfully rendered model. */
return null;
}
function factionBldMdlSet(kit,strict){
const k=factionKitKey(kit||((typeof playerKitKey==='function')?playerKitKey():'nova'));
if(k==='legion'&&typeof BLD_MDL_LEGION!=='undefined')
return {mdl:BLD_MDL_LEGION,tur:BLD_TUR_MDL_LEGION,h:BLD_TUR_H_LEGION,sc:BLD_TUR_S_LEGION};
if(k==='syndicate'&&typeof BLD_MDL_MACHINE!=='undefined')
return {mdl:BLD_MDL_MACHINE,tur:BLD_TUR_MDL_MACHINE,h:BLD_TUR_H_MACHINE,sc:BLD_TUR_S_MACHINE};
if(k==='horde'&&typeof BLD_MDL_INFESTATION!=='undefined')
return {mdl:BLD_MDL_INFESTATION,tur:BLD_TUR_MDL_INFESTATION,h:BLD_TUR_H_INFESTATION,sc:BLD_TUR_S_INFESTATION};
if(strict&&k!=='nova') return null;
return {mdl:BLD_MDL,tur:BLD_TUR_MDL,h:BLD_TUR_H,sc:BLD_TUR_S};
}
function bldFactionKey(B){
/* Live ownership is authoritative. Older sessions could persist the wrong
`fac` value while the opponent picker was still resolving, and the old
early return below made that stale Brood tag permanently override a
Coalition player's structures. Team 0/1 now follow the currently selected
armies; the saved tag remains only for neutral/replay objects. */
if(!B||B.team===0) return factionKitKey((typeof playerKitKey==='function')?playerKitKey():'nova');
if(B.team===1){
const f=(typeof AI!=='undefined'&&AI&&AI.fac)||(B&&B.fac)||'legion';
return factionKitKey(f);
}
if(B.team===2||B.type==='nest') return 'horde';
return factionKitKey(B.fac||'nova');
}
/* Alternate architecture kits are large enough that uploading all three to
the GPU on the main menu wastes both start-up time and mobile memory. A
skirmish uses one enemy faction, so create that registry the first time it
is requested and keep it for the rest of the session. The JavaScript model
factories remain cheap; only WebGL vertex/index buffers are deferred. */
function ensureBldFactionMeshes(fac){
fac=factionKitKey(fac);
if(!fac||fac==='nova') return BLD_MESH;
if(BLD_FACTION_MESH[fac]) return BLD_FACTION_MESH[fac];
let set=null;
if(fac==='legion'&&typeof BLD_MDL_LEGION!=='undefined')
set=initBldMeshSet(BLD_MDL_LEGION,
typeof BLD_TUR_MDL_LEGION!=='undefined'?BLD_TUR_MDL_LEGION:null,
typeof BLD_TIER_MDL_LEGION!=='undefined'?BLD_TIER_MDL_LEGION:null,
typeof BLD_TUR_H_LEGION!=='undefined'?BLD_TUR_H_LEGION:null,
typeof BLD_TUR_S_LEGION!=='undefined'?BLD_TUR_S_LEGION:null,260);
else if(fac==='syndicate'&&typeof BLD_MDL_MACHINE!=='undefined')
set=initBldMeshSet(BLD_MDL_MACHINE,
typeof BLD_TUR_MDL_MACHINE!=='undefined'?BLD_TUR_MDL_MACHINE:null,
typeof BLD_TIER_MDL_MACHINE!=='undefined'?BLD_TIER_MDL_MACHINE:null,
typeof BLD_TUR_H_MACHINE!=='undefined'?BLD_TUR_H_MACHINE:null,
typeof BLD_TUR_S_MACHINE!=='undefined'?BLD_TUR_S_MACHINE:null,260);
else if(fac==='horde'&&typeof BLD_MDL_INFESTATION!=='undefined')
set=initBldMeshSet(BLD_MDL_INFESTATION,
typeof BLD_TUR_MDL_INFESTATION!=='undefined'?BLD_TUR_MDL_INFESTATION:null,
typeof BLD_TIER_MDL_INFESTATION!=='undefined'?BLD_TIER_MDL_INFESTATION:null,
typeof BLD_TUR_H_INFESTATION!=='undefined'?BLD_TUR_H_INFESTATION:null,
typeof BLD_TUR_S_INFESTATION!=='undefined'?BLD_TUR_S_INFESTATION:null,260);
if(set) BLD_FACTION_MESH[fac]=set;
return set;
}
function bldMeshFor(B){
const fac=bldFactionKey(B),set=ensureBldFactionMeshes(fac);
const type=B.type;
/* A missing non-Nova mesh is a visible content error, not permission to put
a blue building in another faction. Complete-kit audits gate releases;
returning null here keeps a regression honest in the live game too. */
if(fac!=='nova')return (set&&set[type])||null;
return BLD_MESH[type]||null;
}
/* Imported geometry (Blender exports, VRoid soldiers) lands here, keyed by
UNIT_MDL slot. assets/data/meshes.js assigns into it at load; initModels
prefers an imported body over the procedural builder, so hand-written and
imported models are interchangeable per unit with no other change. */
const MF_BLENDER_GEO={};
function initModels(){
for(let t=0;t<UNIT_MDL.length;t++){
const ext=MF_BLENDER_GEO[t];
const g=ext?{hull:ext,tur:null,s:1}:UNIT_MDL[t]();
UNIT_GEO[t]=g;
UNIT_MESH[t]={
hull:new InstMesh(gl,g.hull, t===12||t===13?4200:1400),
tur: g.tur? new InstMesh(gl,g.tur, 1400):null,
s:g.s||1, turH:g.turH||0, air:g.air||0, muzzle:g.muzzle||0, muzzleZ:g.muzzleZ||0
};
}
Object.assign(BLD_MESH,initBldMeshSet(BLD_MDL,BLD_TUR_MDL,BLD_TIER_MDL,BLD_TUR_H,BLD_TUR_S,260));
FX.cityT =new InstMesh(gl,mdlCityTower(),420);
FX.cityD =new InstMesh(gl,mdlCityDome(),420);
FX.cityH =new InstMesh(gl,mdlCityHall(),160);
FX.cityK =new InstMesh(gl,mdlCityTank(),160);
FX.rock =new InstMesh(gl,mdlRock(),900);
FX.tree =new InstMesh(gl,mdlTree(),900);
FX.treePine=new InstMesh(gl,mdlTreePine(),700);
FX.treePalm=new InstMesh(gl,mdlTreePalm(),500);
FX.treeDead=new InstMesh(gl,mdlTreeDead(),500);
FX.treeSpore=new InstMesh(gl,mdlTreeSpore(),600);
FX.bush =new InstMesh(gl,mdlBush(),800);
FX.rockIce=new InstMesh(gl,mdlRockIce(),700);
FX.rockSlag=new InstMesh(gl,mdlRockSlag(),700);
FX.crystal=new InstMesh(gl,mdlCrystal(),220);
FX.dep =new InstMesh(gl,mdlDeposit(),120);
FX.geyser=new InstMesh(gl,mdlGeyser(),80);
FX.crate =new InstMesh(gl,mdlCrate(),60);
for(const k in DROP_MDL){
DROP_MESH[k]={
body:new InstMesh(gl,DROP_MDL[k](),4),
gear:DROP_GEAR_MDL[k]?new InstMesh(gl,DROP_GEAR_MDL[k](),4):null,
vtol:DROP_VTOL_MDL[k]?new InstMesh(gl,DROP_VTOL_MDL[k](),16):null,
rotor:DROP_ROTOR_MDL[k]?new InstMesh(gl,DROP_ROTOR_MDL[k](),16):null
};
}
/* Compatibility aliases keep old capture/release tooling working; live
rendering selects through DROP_MESH and never assumes the Nova body. */
FX.drop=DROP_MESH.nova.body;
FX.dropGear=DROP_MESH.nova.gear;
FX.wreck =new InstMesh(gl,mdlWreck(),480);
FX.berm =new InstMesh(gl,mdlBerm(),420);
FX.shell =new InstMesh(gl,mdlShell(),1400);
FX.bolt =new InstMesh(gl,mdlBolt(),3000);
FX.shard =new InstMesh(gl,mdlShard(),5000);
FX.beam =new InstMesh(gl,mdlBeamSeg(),1200);
FX.cone =new InstMesh(gl,mdlCone(),700);
FX.ring =new InstMesh(gl,mdlRing(),900);
FX.disc =new InstMesh(gl,mdlDisc(),900);
initFactionKits();
FX.plate =new InstMesh(gl,mdlPlate(),2600);
FX.shadow=new InstMesh(gl,mdlShadow(),4000);
FX.modMark=new InstMesh(gl,mdlModMark(),64);
FX.line =new InstMesh(gl,mdlLine(),9000);
FX.wedge =new InstMesh(gl,mdlLightWedge(),200); // flashlight cones on the ground
FX.pool =new InstMesh(gl,mdlLightPool(),480); // beam splashes + machine underglow
}