This commit is contained in:
jack
2026-09-01 01:04:57 +01:00
parent d3d1c12b28
commit 65c6aaac5b
11 changed files with 1068 additions and 106 deletions
+141 -19
View File
@@ -20,44 +20,163 @@ async function fetchBuffer(url) {
return res.arrayBuffer();
}
function render(name, dffBuffer, txdBuffer) {
const dff = parseDFF(dffBuffer);
const txd = txdBuffer ? parseTXD(txdBuffer) : null;
const report = viewer.load(dff, txd);
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
return res.json();
}
// A vehicle clump is recognisable by its frame names: every one of them has a
// chassis and a set of wheel dummies.
function looksLikeVehicle(dff) {
return dff.frames.some((frame) => /^(chassis|wheel_[lr][fmb]_dummy)$/i.test(frame.name));
}
// Paint comes from the url (?c1=3&c2=1, carcols palette indices, the same
// numbers ChangeVehicleColor takes) and falls back to the vehicle's first
// factory combo. An index with no entry is left for the viewer to default.
function resolvePaint(catalogue, id) {
if (!catalogue) return null;
const params = new URLSearchParams(location.search);
const info = catalogue.vehicles && catalogue.vehicles[id];
const factory = (info && info.colors && info.colors[0]) || [];
return [0, 1, 2, 3].map((slot) => {
const given = params.get(`c${slot + 1}`);
const index = given !== null && given !== '' ? Number(given) : factory[slot];
return Number.isFinite(index) ? catalogue.palette[index] || null : null;
});
}
// Parts come from ?u=1010,1008 - component ids, the same numbers
// AddVehicleComponent takes. Fitting a left hand body kit part fits its right
// hand twin too, which is what carmods' link section is for.
async function resolveUpgrades(meta, catalogue) {
const raw = new URLSearchParams(location.search).get('u');
if (!raw || !catalogue || !catalogue.upgrades) return [];
const wanted = [];
for (const field of raw.split(',')) {
const id = Number(field.trim());
if (!Number.isFinite(id)) continue;
wanted.push(id);
if (catalogue.links[id] !== undefined) wanted.push(catalogue.links[id]);
}
const parts = await Promise.all(
[...new Set(wanted)]
.filter((id) => catalogue.upgrades[id])
.map(async (id) => ({
id,
name: catalogue.upgrades[id].name,
dff: parseDFF(await fetchBuffer(`${meta.base}/${id}.dff`)),
}))
);
return parts;
}
// Paintjobs are numbered from zero, the way ChangeVehiclePaintjob counts them.
// Anything outside the vehicle's range means no paintjob, which is what 3 does
// in game.
async function fetchPaintjob(meta, catalogue, id, index) {
const info = catalogue && catalogue.vehicles && catalogue.vehicles[id];
if (!info || !Number.isFinite(index) || index < 0 || index >= info.paintjobs) return null;
return parseTXD(await fetchBuffer(`${meta.base}/${id}_pj${index}.txd`));
}
function render(name, dffBuffer, txdBuffers, options = {}) {
const dff = parseDFF(dffBuffer);
const txds = txdBuffers.filter(Boolean).map((buffer) => parseTXD(buffer));
const report = viewer.load(dff, txds, options);
const textureCount = txds.reduce((total, txd) => total + txd.textures.size, 0);
console.log(
`${name}: rw ${dff.versionText}, ${report.meshes} mesh(es), ` +
`${report.vertices} verts, ${report.triangles} tris, ` +
`${txd ? txd.textures.size : 0} textures`
`${textureCount} textures${options.vehicle ? ', vehicle' : ''}` +
(options.paintjobs ? `, ${options.paintjobs} paintjob(s)` : '')
);
const warnings = [...dff.errors, ...(txd ? txd.errors : [])];
const warnings = [...dff.errors, ...txds.flatMap((txd) => txd.errors)];
if (report.missingTextures.size) {
warnings.push('not in txd: ' + [...report.missingTextures].join(', '));
}
warnings.forEach((warning) => console.warn(warning));
setError('');
return dff;
}
async function loadSkin(id) {
async function loadModel(kind, id) {
try {
const res = await fetch(`/api/skin/${encodeURIComponent(id)}`);
const res = await fetch(`/api/${kind}/${encodeURIComponent(id)}`);
const meta = await res.json();
if (!res.ok) {
setError(meta.error || `${res.status} loading ${id}`);
return;
}
const [dffBuffer, txdBuffer] = await Promise.all([
// The shared dictionary and the carcols catalogue are optional: a missing
// one costs textures or factory colours, not the render.
const [dffBuffer, txdBuffer, sharedBuffer, catalogue] = await Promise.all([
fetchBuffer(meta.dff),
meta.txd ? fetchBuffer(meta.txd) : Promise.resolve(null),
meta.txd ? fetchBuffer(meta.txd) : null,
meta.shared ? fetchBuffer(meta.shared).catch(() => null) : null,
meta.meta ? fetchJSON(meta.meta).catch(() => null) : null,
]);
render(id, dffBuffer, txdBuffer);
const vehicle = kind === 'vehicle';
const info = (vehicle && catalogue && catalogue.vehicles[id]) || null;
const wanted = new URLSearchParams(location.search).get('pj');
// The vehicle's own textures win over the shared ones.
render(id, dffBuffer, [txdBuffer, sharedBuffer], {
vehicle,
paint: vehicle ? resolvePaint(catalogue, id) : null,
upgrades: vehicle ? await resolveUpgrades(meta, catalogue) : null,
wheelScale: info ? info.wheelScale : 1,
paintjob:
vehicle && wanted !== null && wanted !== ''
? await fetchPaintjob(meta, catalogue, id, Number(wanted))
: null,
paintjobs: info ? info.paintjobs : 0,
});
if (vehicle && catalogue) {
// Repainting needs no reload, so leave the palette and the viewer within
// reach of the console: setColors(3, 1) is ChangeVehicleColor(v, 3, 1).
window.setColors = (...ids) =>
viewer.setPaint(ids.map((index) => catalogue.palette[index] || null));
window.carcols = catalogue;
window.viewer = viewer;
// setPaintjob(0) is ChangeVehiclePaintjob(v, 0); anything out of range
// takes it back off, the way 3 does in game.
window.setPaintjob = async (index) =>
viewer.applyPaintjob(await fetchPaintjob(meta, catalogue, id, index));
// What ?u= accepts here, as id -> part name. Wheels are listed for every
// vehicle because any wheel fits anything.
window.mods = Object.fromEntries(
((info && info.mods) || [])
.concat(catalogue.wheels || [])
.filter((partId) => catalogue.upgrades && catalogue.upgrades[partId])
.map((partId) => [partId, catalogue.upgrades[partId].name])
);
}
} catch (err) {
console.error(err);
setError(err.message);
}
}
// Dropped vehicles get the shared dictionary fetched for them, otherwise most
// of the body renders untextured.
async function sharedVehicleTxd() {
try {
const meta = await fetchJSON('/api/vehicle/411');
return meta.shared ? await fetchBuffer(meta.shared) : null;
} catch {
return null;
}
}
function setupDragAndDrop() {
document.body.addEventListener('dragover', (e) => {
e.preventDefault();
@@ -69,17 +188,20 @@ function setupDragAndDrop() {
document.body.classList.remove('dragging');
const files = [...e.dataTransfer.files];
const dffFile = files.find((f) => f.name.toLowerCase().endsWith('.dff'));
const txdFile = files.find((f) => f.name.toLowerCase().endsWith('.txd'));
const txdFiles = files.filter((f) => f.name.toLowerCase().endsWith('.txd'));
if (!dffFile) {
setError('drop a .dff (and its .txd) together');
return;
}
try {
render(
dffFile.name.replace(/\.dff$/i, ''),
await dffFile.arrayBuffer(),
txdFile ? await txdFile.arrayBuffer() : null
);
const name = dffFile.name.replace(/\.dff$/i, '');
const dffBuffer = await dffFile.arrayBuffer();
const txdBuffers = await Promise.all(txdFiles.map((f) => f.arrayBuffer()));
const vehicle = looksLikeVehicle(parseDFF(dffBuffer));
if (vehicle && txdBuffers.length < 2) txdBuffers.push(await sharedVehicleTxd());
render(name, dffBuffer, txdBuffers, { vehicle });
} catch (err) {
console.error(err);
setError(err.message);
@@ -89,5 +211,5 @@ function setupDragAndDrop() {
setupDragAndDrop();
const match = location.pathname.match(/^\/samp\/skin\/(.+)$/);
if (match) loadSkin(decodeURIComponent(match[1]));
const match = location.pathname.match(/^\/samp\/(skin|vehicle)\/(.+)$/);
if (match) loadModel(match[1], decodeURIComponent(match[2]));
+149
View File
@@ -0,0 +1,149 @@
// Vehicle specific rules for turning a clump into a scene.
//
// A car dff is not just "every atomic, drawn at its frame". It also carries the
// crash damage variants, a low detail chassis and a single wheel that the game
// instances four (or six) times. Rendering it literally gives you a z-fighting
// mess with one wheel on the front right.
// Paint is encoded in the material colour. The game looks for these exact
// values and swaps in the carcols entry for the vehicle's colour slot.
// Verified against the retail models: every car uses 60,255,0 for the primary,
// two thirds also use 255,0,175 for the secondary, and the car4 vehicles
// (camper, cement, squalo) add 0,255,255 for the third. Body kit parts carry
// the same masks, so they paint along with the car.
const PAINT_MASKS = [
[60, 255, 0],
[255, 0, 175],
[0, 255, 255],
[255, 0, 255],
];
// Near enough every vehicle also carries one material each in 255,175,0 /
// 185,255,0 / 0,255,200 / 255,60,0. Those mark light panels, not paint, and are
// deliberately absent from the list above so they keep their own colour.
export const DEFAULT_PAINT = [200, 200, 200];
// Material colours come straight from bytes, so the match is exact.
export function paintSlot(color) {
const r = Math.round(color[0] * 255);
const g = Math.round(color[1] * 255);
const b = Math.round(color[2] * 255);
for (let i = 0; i < PAINT_MASKS.length; i++) {
const mask = PAINT_MASKS[i];
if (mask[0] === r && mask[1] === g && mask[2] === b) return i;
}
return -1;
}
// wheel_lf_dummy, wheel_rb_dummy, and the middle axle a six wheeler adds.
const WHEEL_DUMMY = /^wheel_([lr])[fmb]_dummy$/i;
// An upgrade dff is a single atomic on a single root frame - it carries no
// attachment information at all. What holds it on is a dummy frame in the
// vehicle, and the prefix of the part's model name picks which. Longer
// prefixes come first so bntl_ is not eaten by bnt_.
const UPGRADE_SLOTS = [
[/^bntl_/, 'ug_bonnet_left'],
[/^bntr_/, 'ug_bonnet_right'],
[/^bnt_/, 'ug_bonnet'],
[/^spl_/, 'ug_spoiler'],
[/^rf_/, 'ug_roof'],
[/^wg_l_/, 'ug_wing_left'],
[/^wg_r_/, 'ug_wing_right'],
[/^nto_/, 'ug_nitro'],
[/^lgt_/, 'ug_lights'],
// Not the 'exhaust' frame - that one is the smoke emitter and sits about a
// metre behind the car. The stock exhaust component is the mount, and the
// upgrade takes its place: measured across the 31 vehicles that accept
// exh_b_l, hanging it there puts the pipe end within a few cm of the rear of
// the body every time.
[/^exh_/, 'exhaust_ok', /^exhaust/],
[/^misc_a_/, 'misc_a'],
[/^misc_c_/, 'misc_c'],
// The slamvan is the only vehicle with bull bar mounts, and they sit in
// front of the bumper rather than replacing it.
[/^fbb_/, 'ug_frontbullbar'],
[/^bbb_/, 'ug_backbullbar'],
// Bumpers take the stock one's place rather than sitting on top of it.
[/^fbmp_/, 'bump_front_dummy', /^bump_front/],
[/^rbmp_/, 'bump_rear_dummy', /^bump_rear/],
];
// The atomics of an upgrade clump worth drawing.
//
// Fitting a part re-frames its atomic onto the vehicle's dummy, which drops the
// part's own root transform - so callers place the geometry at the dummy and
// ignore the clump's frame. The bumpers are where this shows: fbmp_a_l's root
// frame is a metre off centre and its geometry cancels that out, exactly like
// the stock bumper does against bump_front_dummy.
//
// Parts carry their own crash variants too, so the _dam twin is skipped the
// same way the vehicle's own are.
export function upgradeAtomics(dff) {
return dff.atomics.filter(
(atomic) => !((dff.frames[atomic.frame].name || '').toLowerCase().endsWith('_dam'))
);
}
// null means the part has nothing to draw - stereo and hydralics are upgrades
// with a model id but no visible geometry.
export function upgradeSlot(name) {
const model = String(name).toLowerCase();
if (/^wheel_/.test(model)) return { wheel: true };
for (const [prefix, frame, replaces] of UPGRADE_SLOTS) {
if (prefix.test(model)) return { frame, replaces };
}
return null;
}
// upgrades is a list of { name, dff } - parts already fetched and parsed.
export function planVehicle(dff, upgrades = []) {
const hide = new Set();
const wheels = [];
const parts = [];
const replaced = [];
let wheelModel = null;
const frameName = (index) => ((dff.frames[index] && dff.frames[index].name) || '').toLowerCase();
for (const upgrade of upgrades) {
const slot = upgradeSlot(upgrade.name);
if (!slot) continue;
if (slot.wheel) { wheelModel = upgrade; continue; }
// A vehicle simply has no frame for a part it was never meant to take.
const frame = dff.frames.findIndex((f) => f.name.toLowerCase() === slot.frame);
if (frame < 0) continue;
parts.push({ upgrade, frame });
if (slot.replaces) replaced.push(slot.replaces);
}
dff.atomics.forEach((atomic, i) => {
const name = frameName(atomic.frame);
// bump_front_dam sits on top of bump_front_ok, chassis_vlo inside chassis.
if (name.endsWith('_dam') || name.endsWith('_vlo')) hide.add(i);
if (replaced.some((prefix) => prefix.test(name))) hide.add(i);
});
// The lone 'wheel' atomic hangs off whichever dummy the modeller happened to
// parent it to, and gets cloned to the rest. Left hand wheels are mirrored,
// because every dummy shares the same orientation and only differs in
// position - so without the flip the tyre wall faces inwards.
const wheel = dff.atomics.findIndex((atomic) => frameName(atomic.frame) === 'wheel');
if (wheel >= 0) {
hide.add(wheel);
const modelled = new Set(dff.atomics.map((atomic) => atomic.frame));
dff.frames.forEach((frame, i) => {
const match = WHEEL_DUMMY.exec(frame.name);
if (!match) return;
if (modelled.has(i)) return; // this corner models its own wheel
wheels.push({
atomic: wheel,
localFrame: dff.atomics[wheel].frame,
frame: i,
mirror: match[1].toLowerCase() === 'l',
});
});
}
return { hide, wheels, parts, wheelModel };
}
+194 -19
View File
@@ -1,5 +1,6 @@
import * as THREE from 'three';
import { OrbitControls } from '/vendor/OrbitControls.js';
import { planVehicle, upgradeAtomics, paintSlot, DEFAULT_PAINT } from './vehicle.js';
const MISSING_COLOR = 0xb0b0b0;
@@ -28,6 +29,7 @@ export class Viewer {
this.model = null;
this.textures = [];
this.txds = [];
window.addEventListener('resize', () => this.resize());
this.resize();
@@ -57,6 +59,9 @@ export class Viewer {
}
this.textures.forEach((t) => t.dispose());
this.textures = [];
this.txds = [];
this.paintjob = null;
this.paintColors = null;
}
makeTexture(source) {
@@ -74,19 +79,35 @@ export class Viewer {
return texture;
}
buildMaterial(material, txd, report) {
// Vehicles reference the shared generic/vehicle.txd as well as their own, so
// lookups walk the dictionaries in the order they were given.
findTexture(name) {
const key = name.toLowerCase();
for (const txd of this.txds) {
const source = txd.textures.get(key);
if (source) return source;
}
return null;
}
buildMaterial(material, report, paint) {
// A paint material keeps its colour when textured rather than being reset
// to white: the game multiplies the grunge map over the body colour, which
// is what map * color does here.
const slot = paint ? paintSlot(material.color) : -1;
const params = {
color: new THREE.Color(material.color[0], material.color[1], material.color[2]),
side: THREE.DoubleSide,
shininess: 8,
};
if (slot >= 0) params.color = paint[slot] || paint.fallback;
if (material.texture && material.texture.name) {
const key = material.texture.name.toLowerCase();
const source = txd && txd.textures.get(key);
const source = this.findTexture(material.texture.name);
if (source) {
params.map = this.makeTexture(source);
params.color = new THREE.Color(0xffffff);
if (slot < 0) params.color = new THREE.Color(0xffffff);
if (source.hasAlpha) {
params.transparent = true;
params.alphaTest = 0.35;
@@ -94,7 +115,7 @@ export class Viewer {
}
} else {
report.missingTextures.add(material.texture.name);
params.color = new THREE.Color(MISSING_COLOR);
if (slot < 0) params.color = new THREE.Color(MISSING_COLOR);
}
}
@@ -102,7 +123,12 @@ export class Viewer {
params.transparent = true;
params.opacity = material.color[3];
}
return new THREE.MeshPhongMaterial(params);
const built = new THREE.MeshPhongMaterial(params);
if (slot >= 0) built.userData.paintSlot = slot;
// A vehicle that takes paintjobs marks the panels one covers by giving them
// a remap* texture. Fitting a paintjob swaps that texture out.
if (material.texture && /^remap/i.test(material.texture.name)) built.userData.remap = true;
return built;
}
buildGeometry(geo) {
@@ -117,6 +143,23 @@ export class Viewer {
return geometry;
}
buildMesh(geo, report, paint) {
return new THREE.Mesh(
this.buildGeometry(geo),
geo.materials.length
? geo.materials.map((m) => this.buildMaterial(m, report, paint))
: new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide })
);
}
// Counted per instance rather than per build, because the wheel is built once
// and drawn four times.
countMesh(report, geo) {
report.meshes++;
report.vertices += geo.numVertices;
report.triangles += geo.indices.length / 3;
}
// Frames form a tree; an atomic is placed by its frame's world transform.
// Skinned geometry is the exception: its vertices are stored in the space of
// the atomic frame's *parent*, so that frame's own transform is skipped.
@@ -136,9 +179,33 @@ export class Viewer {
return matrix;
}
load(dff, txd) {
// colors is a list of [r, g, b] bytes, one per paint slot. They come from
// carcols, so they are sRGB rather than working space values.
makePaint(colors) {
// A slot with no colour is left undefined so it falls back below, which is
// what a vehicle that only paints two of its four slots wants.
const paint = (colors || []).map((c) =>
Array.isArray(c)
? new THREE.Color().setRGB(c[0] / 255, c[1] / 255, c[2] / 255, THREE.SRGBColorSpace)
: undefined
);
paint.fallback = new THREE.Color().setRGB(
DEFAULT_PAINT[0] / 255,
DEFAULT_PAINT[1] / 255,
DEFAULT_PAINT[2] / 255,
THREE.SRGBColorSpace
);
return paint;
}
// txd is one dictionary or a list of them, searched in order.
// options: { vehicle: true, paint: [[r, g, b], ...] }
load(dff, txd, options = {}) {
this.clear();
this.txds = [].concat(txd || []).filter(Boolean);
const report = { missingTextures: new Set(), meshes: 0, triangles: 0, vertices: 0 };
this.paintColors = options.paint || null;
const paint = options.vehicle ? this.makePaint(options.paint) : null;
const root = new THREE.Object3D();
// RenderWare is Z up; three.js is Y up.
@@ -148,29 +215,137 @@ export class Viewer {
? dff.atomics
: dff.geometries.map((_, i) => ({ geometry: i, frame: -1 }));
for (const atomic of atomics) {
const plan = options.vehicle ? planVehicle(dff, options.upgrades) : null;
atomics.forEach((atomic, index) => {
if (plan && plan.hide.has(index)) return;
const geo = dff.geometries[atomic.geometry];
if (!geo) continue;
const mesh = new THREE.Mesh(
this.buildGeometry(geo),
geo.materials.length
? geo.materials.map((m) => this.buildMaterial(m, txd, report))
: new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide })
);
if (!geo) return;
const mesh = this.buildMesh(geo, report, paint);
mesh.applyMatrix4(this.worldMatrix(dff.frames, atomic.frame, geo.skinned));
mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + report.meshes;
mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + index;
root.add(mesh);
report.meshes++;
report.vertices += geo.numVertices;
report.triangles += geo.indices.length / 3;
this.countMesh(report, geo);
});
if (plan) {
this.addWheels(root, dff, plan, report, paint, options);
this.addParts(root, dff, plan, report, paint);
}
this.scene.add(root);
this.model = root;
if (options.paintjob) this.applyPaintjob(options.paintjob);
this.frame();
return report;
}
// A paintjob is a one texture dictionary that stands in for the remap texture
// on the body panels. The artwork carries the colour, so those panels stop
// taking the primary paint while one is fitted. Pass null to strip it.
applyPaintjob(txd) {
if (!this.model) return;
this.paintjob = txd || null;
const source = txd ? [...txd.textures.values()][0] : null;
const map = source ? this.makeTexture(source) : null;
const paint = this.makePaint(this.paintColors);
this.model.traverse((obj) => {
[].concat(obj.material || []).forEach((material) => {
if (!material.userData.remap) return;
if (map) {
if (material.userData.baseMap === undefined) material.userData.baseMap = material.map;
material.map = map;
material.color.set(0xffffff);
} else {
if (material.userData.baseMap !== undefined) material.map = material.userData.baseMap;
const slot = material.userData.paintSlot;
if (slot !== undefined) material.color.copy(paint[slot] || paint.fallback);
}
material.needsUpdate = true;
});
});
}
// The wheel is built once and cloned per corner, so all four instances share
// one geometry and one material set.
addWheels(root, dff, plan, report, paint, options) {
if (!plan.wheels.length) return;
// A stock wheel already comes at the vehicle's own size; an upgrade wheel is
// modelled at unit diameter and the ide's wheel scale is what sizes it.
// Measured: infernus stock 0.70 with scale 0.70, linerun 1.10 with 1.10,
// every wheel_* upgrade 0.99.
let source = dff;
let atomic = dff.atomics[plan.wheels[0].atomic];
let scale = 1;
// A fitted wheel is re-framed onto the dummy like any other part, so its
// own root transform goes; the stock one is part of the vehicle's own
// hierarchy and keeps it.
let local = new THREE.Matrix4();
if (plan.wheelModel) {
source = plan.wheelModel.dff;
atomic = upgradeAtomics(source)[0];
scale = options.wheelScale || 1;
} else if (atomic) {
local = new THREE.Matrix4().fromArray(source.frames[atomic.frame].matrix);
}
if (!atomic) return;
const geo = source.geometries[atomic.geometry];
if (!geo) return;
// The base is never added to the scene and never transformed: every corner
// clones it, so no corner inherits another's placement.
const base = this.buildMesh(geo, report, paint);
for (const wheel of plan.wheels) {
const mesh = base.clone();
const matrix = this.worldMatrix(dff.frames, wheel.frame, false);
if (wheel.mirror) matrix.multiply(new THREE.Matrix4().makeScale(-1, 1, 1));
if (scale !== 1) matrix.multiply(new THREE.Matrix4().makeScale(scale, scale, scale));
matrix.multiply(local);
mesh.applyMatrix4(matrix);
mesh.name = dff.frames[wheel.frame].name;
root.add(mesh);
this.countMesh(report, geo);
}
}
// Upgrade parts are separate clumps hung off a dummy frame in the vehicle.
// Their textures all come from the shared vehicle.txd, which is already in
// this.txds, and their paint masks go through buildMaterial like any other.
addParts(root, dff, plan, report, paint) {
for (const part of plan.parts) {
const upgrade = part.upgrade.dff;
const attach = this.worldMatrix(dff.frames, part.frame, false);
for (const atomic of upgradeAtomics(upgrade)) {
const geo = upgrade.geometries[atomic.geometry];
if (!geo) continue;
const mesh = this.buildMesh(geo, report, paint);
mesh.applyMatrix4(attach);
mesh.name = part.upgrade.name;
root.add(mesh);
this.countMesh(report, geo);
}
}
}
// Repaint in place - no reparse, no rebuild.
setPaint(colors) {
if (!this.model) return;
this.paintColors = colors;
const paint = this.makePaint(colors);
this.model.traverse((obj) => {
[].concat(obj.material || []).forEach((material) => {
const slot = material.userData && material.userData.paintSlot;
if (slot === undefined) return;
// A panel under a paintjob shows the artwork, not the body colour.
if (this.paintjob && material.userData.remap) return;
material.color.copy(paint[slot] || paint.fallback);
});
});
}
frame() {
if (!this.model) return;
const box = new THREE.Box3().setFromObject(this.model);