loadPedFleet gains dance:!CLASSIC (mirrors the sit/look gate) → fleet.danceClips = 4 mesh-free mixamorig club dances (party/medium/drink/sway). _make picks one per dancer off a fresh gigdance seeded stream (zero impact on existing draws / the same-seed-same-crowd covenant), spawnRig plays it, and update() drops the fake bounce for real-clip dancers (procedural sway stays as the classic/noassets fallback). ?classic fetches nothing, byte-identical. Verified: default boot fetches all 4 dance GLBs (gated load runs), both modules parse + export clean, page boots. Same clips + _canon/_rotOnly/spawnRig proven animating in 90sDJsim (its crowd/rigs.js is this file ported). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
327 lines
20 KiB
JavaScript
327 lines
20 KiB
JavaScript
// PROCITY Lane D — the gig band + crowd (v3.0-beta, behind ?gigs=1).
|
||
//
|
||
// A Friday night at the venue: a 4-piece band on Lane C's stage (guitar / vocal / bass across the
|
||
// front, a seated drummer on the up-stage riser) and a mixed crowd at C's watch points — some
|
||
// standing, some dancing (John's "a bit of both"). Built on the gate-protected rig path
|
||
// (spawnRig → buildFigure, so no giants), with a placeholder fallback when the fleet is absent
|
||
// (?noassets — a gig with no GLBs is still a gig).
|
||
//
|
||
// Instruments: primitives show immediately (asset law), then E's manifest GLBs swap in async where
|
||
// present (placeholder-persists, like interiors/glb.js). guitar/bass held at the chest, mic stand +
|
||
// drum kit planted, an amp as backline dressing. Gated on fleet.ready so ?noassets never fetches.
|
||
//
|
||
// Seam: F builds one GigCrew per venue interior and calls spawn() when the gig state is doors/on,
|
||
// update(dt) each interior frame, disposeAll() on gig-off / room exit. C owns the stage + watch
|
||
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs). Deterministic from (citySeed, gigId).
|
||
|
||
import * as THREE from 'three';
|
||
import { rng, shuffle } from '../core/prng.js';
|
||
import { pickRig, spawnRig, seatedLean } from './rigs.js';
|
||
import { makePlaceholder } from './placeholder.js';
|
||
import { loadGLB } from '../core/loaders.js';
|
||
|
||
const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
|
||
// holds — F's smoke asserts it). RSL fields the most (8–12 watch points).
|
||
const MIC = 0x222226, POLE = 0x9a9a9e, KIT = 0x8f2b24, CHROME = 0xc8c8cc, AMP = 0x15151a, AMPCLOTH = 0x3a3020;
|
||
const SEAT_DROP = 0.52; // the drummer sits: sink the rig so the kit hides the straight legs.
|
||
// Reads seated from the front (the crowd POV / money shot); a true
|
||
// sit is a clip job — see the LANE_D_NOTES clip wishlist.
|
||
|
||
// ---- primitive instruments (E's GLBs swap in over these; a gig with no GLBs is still a gig) ----
|
||
// Each returns an Object3D in the actor's OUTER-fig local frame. After the R13 facing-normalise the
|
||
// rig's front (its chest) is fig-local -Z, so instruments sit on the -Z side and ride the sway.
|
||
// Primitives own their geometry/material (tagged ownGeo) so disposeAll frees them; GLB clones share
|
||
// the cached gltf's geo/mats and are only detached.
|
||
function tagOwn(g) { g.userData.ownGeo = true; return g; }
|
||
|
||
function guitarPrim(bodyCol, big = false) {
|
||
const g = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 });
|
||
const neckMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
|
||
const bw = big ? 0.30 : 0.34, bh = big ? 0.44 : 0.40;
|
||
const body = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, 0.06), bodyMat);
|
||
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.05, big ? 0.85 : 0.72, 0.03), neckMat);
|
||
neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
|
||
g.add(body, neck);
|
||
g.position.set(0.02, 1.02, -0.19); // across the chest (fig-local -Z = the audience-facing front)
|
||
g.rotation.set(0.15, 0, big ? -0.7 : -0.62); // slung diagonally, body angled out to the crowd
|
||
return tagOwn(g);
|
||
}
|
||
function micStandPrim() {
|
||
const g = new THREE.Group();
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 1.42, 6),
|
||
new THREE.MeshStandardMaterial({ color: POLE, roughness: 0.4, metalness: 0.6 }));
|
||
pole.position.y = 0.71;
|
||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 6),
|
||
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
|
||
head.position.y = 1.44;
|
||
g.add(pole, head);
|
||
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
|
||
return tagOwn(g);
|
||
}
|
||
// R16: the backline amp fallback (C dropped its primitive ampStacks; this stands in when the guitar_amp
|
||
// GLB is absent / ?noassets). A room prop (not fig-local): base at y=0, grille on the +Z (crowd) face.
|
||
function ampPrim() {
|
||
const g = new THREE.Group();
|
||
const cab = new THREE.MeshStandardMaterial({ color: AMP, roughness: 0.7, metalness: 0.05 });
|
||
const cloth = new THREE.MeshStandardMaterial({ color: AMPCLOTH, roughness: 0.9 });
|
||
const box = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.29), cab); box.position.y = 0.3;
|
||
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.46, 0.02), cloth); grille.position.set(0, 0.3, 0.15);
|
||
g.add(box, grille);
|
||
return tagOwn(g);
|
||
}
|
||
// a cheap kit: kick drum + snare + a floor tom + two cymbals on stands. Reads as "drum kit" behind the
|
||
// seated drummer; the GLB replaces it when present. Sits on the riser (y = SEAT_DROP compensates the sink).
|
||
function drumKitPrim() {
|
||
const g = new THREE.Group();
|
||
const shell = new THREE.MeshStandardMaterial({ color: KIT, roughness: 0.4, metalness: 0.1 });
|
||
const chrome = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.3, metalness: 0.7 });
|
||
const skin = new THREE.MeshStandardMaterial({ color: 0xe8e4d8, roughness: 0.6 });
|
||
const kick = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.30, 0.42, 16), shell);
|
||
kick.rotation.x = Math.PI / 2; kick.position.set(0, 0.30, 0);
|
||
const kickHead = new THREE.Mesh(new THREE.CircleGeometry(0.29, 16), skin);
|
||
kickHead.position.set(0, 0.30, -0.215); kickHead.rotation.y = Math.PI;
|
||
const snare = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, 0.16, 14), chrome);
|
||
snare.position.set(-0.32, 0.58, -0.10);
|
||
const tom = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.22, 14), shell);
|
||
tom.position.set(0.34, 0.52, -0.06);
|
||
const mkCymbal = (x, y, r) => {
|
||
const c = new THREE.Group();
|
||
const stand = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, y, 6), chrome);
|
||
stand.position.y = y / 2;
|
||
const disc = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.01, 16), chrome);
|
||
disc.position.y = y;
|
||
c.add(stand, disc); c.position.x = x; return c;
|
||
};
|
||
g.add(kick, kickHead, snare, tom, mkCymbal(-0.44, 0.86, 0.18), mkCymbal(0.50, 0.94, 0.20));
|
||
g.position.set(0, SEAT_DROP, -0.60); // on the riser, in front of the seated drummer (fig-local -Z)
|
||
return tagOwn(g);
|
||
}
|
||
function instrumentFor(role) {
|
||
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
|
||
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
|
||
if (role === 'vocal') return micStandPrim();
|
||
if (role === 'drums') return drumKitPrim();
|
||
return null;
|
||
}
|
||
|
||
// role → E's manifest fitting id + its transform in the actor's fig-local frame (front/chest = -Z).
|
||
// GLBs are base-origin metres (glb_law): guitar/bass stand upright (long axis +Y = neck, thin face along
|
||
// Z), so a +Z roll slings them diagonally across the chest; the mic stand is upright as-is; the kit is
|
||
// scaled to fit C's tight riser gap (vocalist 0.73 m in front of the drummer) and planted in front of the
|
||
// sunk drummer. Tuned on-screen. Body-origin → position is where the strap-button / base sits.
|
||
const GLB_FIT = {
|
||
guitar: { id: 'electric_guitar', pos: [0.14, 0.60, -0.15], rot: [0.22, 0.0, 0.72], scale: 1 },
|
||
bass: { id: 'bass_guitar', pos: [0.14, 0.58, -0.15], rot: [0.22, 0.0, 0.66], scale: 1 },
|
||
vocal: { id: 'mic_stand', pos: [0.00, 0.00, -0.30], rot: [0, 0, 0], scale: 0.9 },
|
||
drums: { id: 'drum_kit', pos: [0.00, SEAT_DROP, -0.30], rot: [0, Math.PI, 0], scale: 0.66 },
|
||
};
|
||
|
||
// manifest (self-fetched, promise-cached, fail-soft). ONLY called behind a fleet.ready gate, so under
|
||
// ?noassets (fleet === null) this never runs → zero asset fetch, primitives everywhere. Decoupled from
|
||
// Lane C's interiors/glb.js on purpose (band.js is asset-law self-contained).
|
||
let _manifestP = null;
|
||
function loadManifest() {
|
||
return (_manifestP ||= fetch('assets/manifest.json').then(r => (r.ok ? r.json() : null)).catch(() => null));
|
||
}
|
||
|
||
export class GigCrew {
|
||
constructor({ citySeed = 20261990, fleet = null } = {}) {
|
||
this.citySeed = citySeed >>> 0;
|
||
this.fleet = fleet;
|
||
this.members = []; // { actor, kind, part, role, dance, seated, base:{x,y,z,ry}, phase, extra:[] }
|
||
this.props = []; // planted dressing added straight to the room group (amp) — freed on disposeAll
|
||
this._gen = 0; // bumped every spawn/dispose; async GLB swaps bail if the gen moved (room gone)
|
||
this.t = 0;
|
||
}
|
||
|
||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
||
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
||
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) {
|
||
const r = rng(this.citySeed, 'gig', key);
|
||
const h = height != null ? height : 1.6 + r() * 0.3;
|
||
let actor, kind, usedIndex = null;
|
||
if (this.fleet && this.fleet.ready) {
|
||
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
||
const rig = pk && this.fleet.all[pk.index];
|
||
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
|
||
// dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
|
||
const dcs = this.fleet.danceClips || [];
|
||
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null;
|
||
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit });
|
||
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
||
}
|
||
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
||
actor.fig.position.set(x, y, z);
|
||
target.add(actor.fig);
|
||
return { actor, kind, pedIndex: usedIndex };
|
||
}
|
||
|
||
// spawn(roomGroup, { stage, watchPoints, gig, roster }) — the band on the deck + crowd at the watch points.
|
||
// roster (R14): [{pedIndex,height}] of the punters who entered this venue tonight (D's citizens.tonightRoster);
|
||
// the crowd's front slots become them (continuity), seeded strangers fill the rest.
|
||
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, roster = [], instrumentFor: instFor = instrumentFor } = {}) {
|
||
this.disposeAll();
|
||
const gid = (gig && gig.gigId) | 0; // null-safe (a quiet-night call would pass no gig)
|
||
this._gen++;
|
||
const gen = this._gen;
|
||
|
||
// ── band: C's bandPoses (guitar / vocal / bass across the front, + a seated `drums` on the riser) ──
|
||
if (stage && stage.bandPoses) {
|
||
// distinct fleet peds per gig (a band with two identical members reads wrong)
|
||
const distinct = (this.fleet && this.fleet.ready)
|
||
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
|
||
stage.bandPoses.forEach((p, i) => {
|
||
const seated = p.role === 'drums' || !!p.seated;
|
||
const poseY = p.y != null ? p.y : (stage.deckY || 0); // C tags per-pose y (front line deckY, drums riser)
|
||
// R16: a real sit clip seats the rig on the riser (spawnRig re-plants the feet) → no sink; only the
|
||
// no-clip fallback still sinks SEAT_DROP so the kit hides the standing legs.
|
||
const seatedRig = seated && !!(this.fleet && this.fleet.sitClip);
|
||
const y = seated ? poseY - (seatedRig ? 0 : SEAT_DROP) : poseY;
|
||
const { actor, kind, pedIndex } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
|
||
{ x: p.x, y, z: p.z, ry: p.ry, seated, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
|
||
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
|
||
const member = { actor, kind, pedIndex, part: 'band', role: p.role, dance: false, seated,
|
||
base: { x: p.x, y, z: p.z, ry: p.ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra: [] };
|
||
const inst = instFor(p.role);
|
||
if (inst) { actor.fig.add(inst); member.extra.push(inst); }
|
||
this.members.push(member);
|
||
this._upgradeInstrument(member, gen); // async GLB swap over the primitive (fleet-gated)
|
||
});
|
||
this._addBackline(roomGroup, stage, gen); // amp dressing beside the riser (async, dressing)
|
||
}
|
||
|
||
// ── crowd: R14 identity continuity — the front slots become tonight's roster (the punters who came in
|
||
// via the queue / surge), seeded strangers fill the remainder. The roster override passes {pedIndex,height}
|
||
// for that slot only; _make's existing branches then skip that slot's own height+pickRig draws. Because
|
||
// every crowd slot is an INDEPENDENTLY keyed stream (`gig`/`crowd:<gid>:<slot>`), this can't shift any
|
||
// other slot, the sway-phase stream (`gigp`), or the band — and an EMPTY roster is byte-identical to R13
|
||
// (no override → the exact R13 draws). Roster heights are the sim's 1.55–1.95 m, inside the [1.4,2.0] gate.
|
||
const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length));
|
||
let fromRoster = 0;
|
||
pts.forEach((w, i) => {
|
||
const who = roster[i] || null;
|
||
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
||
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance }
|
||
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance });
|
||
if (who) fromRoster++;
|
||
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
||
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
||
});
|
||
return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length, fromRoster };
|
||
}
|
||
|
||
// Swap a band member's primitive instrument for E's manifest GLB, placeholder-persists style. Fail-soft
|
||
// at every step (no manifest / no entry / depot unreachable → the primitive stays), and generation-guarded
|
||
// so a GLB that resolves after the room is gone (or re-spawned) is dropped, never attached to a dead rig.
|
||
_upgradeInstrument(member, gen) {
|
||
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
|
||
const cfg = GLB_FIT[member.role];
|
||
if (!cfg) return;
|
||
loadManifest().then((mani) => {
|
||
if (!mani || !mani.fittings || this._gen !== gen) return;
|
||
const entry = mani.fittings[cfg.id];
|
||
if (!entry || !entry.file) return;
|
||
return loadGLB(`depot:${entry.file}`).then((gltf) => {
|
||
if (!gltf || this._gen !== gen || !member.actor.fig.parent) return; // room rebuilt / disposed mid-flight
|
||
const inst = gltf.scene.clone(true); // shares cached geo/mats — detach only, never dispose
|
||
inst.position.set(...cfg.pos);
|
||
inst.rotation.set(...cfg.rot);
|
||
inst.scale.setScalar(cfg.scale || 1);
|
||
inst.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
|
||
for (const ex of member.extra) { if (ex.parent) ex.parent.remove(ex); disposePrim(ex); }
|
||
member.extra.length = 0;
|
||
member.actor.fig.add(inst);
|
||
member.extra.push(inst);
|
||
});
|
||
});
|
||
}
|
||
|
||
// The backline amp, planted at C's up-stage `stage.backline[0]` (R16 ledger #5 — C dropped its primitive
|
||
// ampStacks; D owns the whole backline now). Placeholder-persists: a primitive amp shows immediately (the
|
||
// ?noassets fallback too), then E's guitar_amp GLB swaps in when it resolves. Falls back to the R15
|
||
// hardcoded up-stage-right pose if C's backline isn't present. Room-local coords; grille to the crowd.
|
||
_addBackline(roomGroup, stage, gen) {
|
||
const slot = (stage.backline && stage.backline[0]) ||
|
||
{ x: stage.x + (stage.w || 3) * 0.36, z: stage.z - (stage.d || 3) * 0.22, ry: 0 };
|
||
const place = (obj) => {
|
||
obj.position.set(slot.x, stage.deckY || 0, slot.z);
|
||
if (slot.ry != null) obj.rotation.y = slot.ry;
|
||
roomGroup.add(obj); this.props.push(obj); return obj;
|
||
};
|
||
const prim = place(ampPrim()); // primitive amp immediately (asset-free backline)
|
||
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
|
||
loadManifest().then((mani) => {
|
||
if (!mani || !mani.fittings || this._gen !== gen) return;
|
||
const entry = mani.fittings.guitar_amp;
|
||
if (!entry || !entry.file) return;
|
||
return loadGLB(`depot:${entry.file}`).then((gltf) => {
|
||
if (!gltf || this._gen !== gen || !prim.parent) return; // room rebuilt / disposed mid-flight
|
||
const amp = gltf.scene.clone(true);
|
||
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
|
||
place(amp); // GLB swaps in over the primitive
|
||
const i = this.props.indexOf(prim); if (i >= 0) this.props.splice(i, 1);
|
||
disposePrim(prim); // free the primitive's own geo/mats
|
||
});
|
||
});
|
||
}
|
||
|
||
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
|
||
update(dt) {
|
||
this.t += dt;
|
||
const t = this.t;
|
||
for (const m of this.members) {
|
||
if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
|
||
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
|
||
if (m.seated) seatedLean(m.actor.seatBone); // R17: tilt the drummer forward into the kit (post-mix)
|
||
const f = m.actor.fig, b = m.base, ph = m.phase;
|
||
if (m.part === 'band') {
|
||
if (m.seated) { // drummer: seated bob + a little shoulder work, planted
|
||
f.position.y = b.y + Math.abs(Math.sin(t * 3.4 + ph)) * 0.03;
|
||
f.rotation.z = Math.sin(t * 3.4 + ph) * 0.04;
|
||
} else if (m.role === 'vocal') { // singer: gentle sway toward the mic
|
||
f.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
|
||
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
|
||
} else { // guitar / bass: rhythmic strum-sway + tiny bob
|
||
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
|
||
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
|
||
}
|
||
} else if (m.dance) { // dancer
|
||
if (this.fleet && this.fleet.danceClips && this.fleet.danceClips.length) {
|
||
f.rotation.y = b.ry + Math.sin(t * 0.6 + ph) * 0.10; // real dance clip drives the body; just a slow facing drift
|
||
} else { // fallback (classic/noassets): procedural bounce + sway
|
||
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
|
||
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
|
||
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
|
||
}
|
||
} else { // stander: slow weight shift, feet planted
|
||
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
||
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
||
}
|
||
}
|
||
return this.members.length;
|
||
}
|
||
|
||
disposeAll() {
|
||
for (const m of this.members) {
|
||
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
|
||
for (const ex of m.extra) disposePrim(ex); // GLB clones (shared geo/mats) are skipped inside
|
||
m.actor.dispose && m.actor.dispose();
|
||
}
|
||
for (const p of this.props) disposePrim(p); // GLB dressing → detach only; the ?noassets primitive amp → freed (ownGeo)
|
||
this.members.length = 0;
|
||
this.props.length = 0;
|
||
this._gen++; // invalidate any GLB load still in flight
|
||
this.t = 0;
|
||
}
|
||
}
|
||
|
||
// Free a primitive instrument's own geometry/materials. GLB clones share the cached gltf's geo/mats,
|
||
// so they are detached from the scene by disposeAll but NEVER disposed here (tagged: no ownGeo).
|
||
function disposePrim(obj) {
|
||
if (obj.parent) obj.parent.remove(obj);
|
||
if (!obj.userData || !obj.userData.ownGeo) return; // GLB clone → shared resources, leave them cached
|
||
obj.traverse((o) => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
|
||
}
|