import { parseDFF } from './rw/dff.js'; import { parseTXD } from './rw/txd.js'; import { Viewer } from './viewer.js'; const canvas = document.getElementById('view'); const statusEl = document.getElementById('status'); const viewer = new Viewer(canvas); // The page shows nothing but the model, so a failure is the only thing worth // putting on screen. Everything else goes to the console. function setError(text) { statusEl.textContent = text || ''; statusEl.style.display = text ? 'block' : 'none'; } async function fetchBuffer(url) { const res = await fetch(url); if (!res.ok) throw new Error(`${res.status} fetching ${url}`); return res.arrayBuffer(); } 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, ` + `${textureCount} textures${options.vehicle ? ', vehicle' : ''}` + (options.paintjobs ? `, ${options.paintjobs} paintjob(s)` : '') ); 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 loadModel(kind, id) { try { const res = await fetch(`/api/${kind}/${encodeURIComponent(id)}`); const meta = await res.json(); if (!res.ok) { setError(meta.error || `${res.status} loading ${id}`); return; } // 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) : null, meta.shared ? fetchBuffer(meta.shared).catch(() => null) : null, meta.meta ? fetchJSON(meta.meta).catch(() => null) : null, ]); 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(); document.body.classList.add('dragging'); }); document.body.addEventListener('dragleave', () => document.body.classList.remove('dragging')); document.body.addEventListener('drop', async (e) => { e.preventDefault(); document.body.classList.remove('dragging'); const files = [...e.dataTransfer.files]; const dffFile = files.find((f) => f.name.toLowerCase().endsWith('.dff')); const txdFiles = files.filter((f) => f.name.toLowerCase().endsWith('.txd')); if (!dffFile) { setError('drop a .dff (and its .txd) together'); return; } try { 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); } }); } setupDragAndDrop(); const match = location.pathname.match(/^\/samp\/(skin|vehicle)\/(.+)$/); if (match) loadModel(match[1], decodeURIComponent(match[2]));