mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-28 15:01:44 +01:00
- assPod Model on the main menu switches between Video Gen 1 and Nano Gen 2; Color lists the current body's finishes (the nano: silver, green, blue, pink, black, (PRODUCT)RED). The choice is kept in ~/.config/glass-shell/asspod.json beside the glass tune. - The nano: a procedural 2nd-generation model (tools/nano2-model) exported as render models, drawn by podd with anodized aluminum (tinted, bead-blasted, the laser-etched back paler), white end caps and a clickable wheel. Same menus on a 240 x 180 logical screen, so its text is larger, as on the real one; it plays no video. Shown 52.5 mm wide. - podd: one box layout that holds either body, so a switch needs no restart; 64 px between atlas tiles and a 4 px clear band around each eye's image, which removes the hairline rectangle around the body (filtering at the stereo split took the other eye's edge); texture dump and debug modes. - The left stick acts like the right: Steam's d-pad directions are no longer relayed (tilting the left stick down or left pressed the centre button or Menu).
231 lines
11 KiB
HTML
231 lines
11 KiB
HTML
<!doctype html>
|
|
<!-- Exports the assPod nano's 3D body (nano2-model.js) as SteamVR render models: one folder per part
|
|
under device/asspod/model/, each <name>.obj + <name>.mtl + texture (and raw RGBA where podd
|
|
samples it). SteamVR draws them in the hand without podd; podd (native/podd) draws the same
|
|
meshes with its own shaders (the anodized aluminum, the etched back, the white caps).
|
|
|
|
Run: python tools/build_asspod_model.py --page tools/nano2-model/export.html
|
|
|
|
Parts: nano_shell_<finish> (the tube: front, rolled sides and the cut rims), nano_back_<finish>
|
|
(the flat back, planar-mapped onto the engraving), nano_center_<finish> (the aluminum center
|
|
button), nano_wheel_<white|black>, nano_cap_top (white cap with the hold slider and HOLD),
|
|
nano_cap_bottom, nano_ports (slot, dock connector, jack: a palette texture), nano_well (the
|
|
dark well behind the wheel and the inside of the tube at the caps), nano_back_mask (the
|
|
engraving as a mask for podd). -->
|
|
<html><head><meta charset="utf-8"><title>assPod nano model export</title>
|
|
<script type="importmap">
|
|
{ "imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
|
} }
|
|
</script>
|
|
</head><body>
|
|
<pre id="log">exporting…</pre>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
|
import { createNano2Model, GEOMETRY, FINISHES, drawWheel, engravingCanvas } from './nano2-model.js';
|
|
|
|
const { BODY, CAP } = GEOMETRY;
|
|
const FILES = {};
|
|
const log = (s) => { document.getElementById('log').textContent += '\n' + s; };
|
|
const DIR = 'device/asspod/model/';
|
|
|
|
// ---- textures
|
|
function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
|
|
const b64 = (c) => c.toDataURL('image/png').split(',')[1];
|
|
// raw RGBA for podd: "RGBA", width, height (u32 little-endian), rows top first
|
|
function rgba(c) {
|
|
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
|
|
const out = new Uint8Array(12 + d.length);
|
|
out.set([82, 71, 66, 65]);
|
|
new DataView(out.buffer).setUint32(4, c.width, true);
|
|
new DataView(out.buffer).setUint32(8, c.height, true);
|
|
out.set(d, 12);
|
|
let bin = '';
|
|
for (let i = 0; i < out.length; i += 0x8000) bin += String.fromCharCode.apply(null, out.subarray(i, i + 0x8000));
|
|
return btoa(bin);
|
|
}
|
|
function solid(hex) {
|
|
const c = canvas(8, 8);
|
|
const g = c.getContext('2d');
|
|
g.fillStyle = hex;
|
|
g.fillRect(0, 0, 8, 8);
|
|
return c;
|
|
}
|
|
function scaled(src, w, h) {
|
|
const c = canvas(w, h);
|
|
const g = c.getContext('2d');
|
|
g.imageSmoothingQuality = 'high';
|
|
g.drawImage(src, 0, 0, w, h);
|
|
return c;
|
|
}
|
|
|
|
// ---- OBJ writer (as tools/asspod-model/export.html): non-indexed, positions/uvs/normals in model metres
|
|
function objText(name, geo) {
|
|
const g = geo.index ? geo.toNonIndexed() : geo;
|
|
const p = g.attributes.position, n = g.attributes.normal, t = g.attributes.uv;
|
|
const out = [`# ${name}: the assPod nano body (tools/nano2-model/export.html)`, `mtllib ${name}.mtl`, `usemtl ${name}`];
|
|
const f = (v) => v.toFixed(6);
|
|
for (let i = 0; i < p.count; i++) out.push(`v ${f(p.getX(i))} ${f(p.getY(i))} ${f(p.getZ(i))}`);
|
|
for (let i = 0; i < p.count; i++) out.push(`vt ${f(t ? t.getX(i) : 0.5)} ${f(t ? t.getY(i) : 0.5)}`);
|
|
for (let i = 0; i < p.count; i++) out.push(`vn ${f(n.getX(i))} ${f(n.getY(i))} ${f(n.getZ(i))}`);
|
|
for (let i = 0; i < p.count; i += 3) out.push(`f ${i + 1}/${i + 1}/${i + 1} ${i + 2}/${i + 2}/${i + 2} ${i + 3}/${i + 3}/${i + 3}`);
|
|
return out.join('\n') + '\n';
|
|
}
|
|
function mtlText(name, { ks = 0.04, ns = 256 } = {}) {
|
|
return `newmtl ${name}\nKd 1 1 1\nNs ${ns}\nd 1\nillum 2\nKa 0 0 0\nKs ${ks} ${ks} ${ks}\nmap_Kd ${name}_color.png\n`;
|
|
}
|
|
function part(name, geo, tex, mtl, { raw = false } = {}) {
|
|
const dir = DIR + name + '/';
|
|
FILES[dir + name + '.obj'] = { text: objText(name, geo) };
|
|
FILES[dir + name + '.mtl'] = { text: mtlText(name, mtl) };
|
|
FILES[dir + name + '_color.png'] = { b64: b64(tex) };
|
|
if (raw) FILES[dir + name + '_color.rgba'] = { b64: rgba(tex) };
|
|
log(`${name}: ${(geo.index ? geo.index.count : geo.attributes.position.count) / 3} triangles`);
|
|
}
|
|
|
|
// ---- geometry helpers
|
|
function baked(mesh, root) {
|
|
root.updateMatrixWorld(true);
|
|
const g = mesh.geometry.clone();
|
|
if (!g.attributes.normal) g.computeVertexNormals();
|
|
g.applyMatrix4(mesh.matrixWorld);
|
|
return g.index ? g.toNonIndexed() : g;
|
|
}
|
|
function keep(g, names) {
|
|
for (const k of Object.keys(g.attributes)) if (!names.includes(k)) g.deleteAttribute(k);
|
|
return g;
|
|
}
|
|
/** Splits a non-indexed geometry by a per-triangle test (positions of its three corners). */
|
|
function split(geo, test) {
|
|
const p = geo.attributes.position;
|
|
const lists = [[], []];
|
|
for (let i = 0; i < p.count; i += 3) {
|
|
const tri = [0, 1, 2].map((k) => new THREE.Vector3().fromBufferAttribute(p, i + k));
|
|
lists[test(tri) ? 0 : 1].push(i);
|
|
}
|
|
return lists.map((list) => {
|
|
const out = new THREE.BufferGeometry();
|
|
for (const [key, src] of Object.entries(geo.attributes)) {
|
|
const arr = new Float32Array(list.length * 3 * src.itemSize);
|
|
list.forEach((i, j) => { for (let k = 0; k < 3; k++) for (let c = 0; c < src.itemSize; c++) arr[(j * 3 + k) * src.itemSize + c] = src.array[(i + k) * src.itemSize + c]; });
|
|
out.setAttribute(key, new THREE.BufferAttribute(arr, src.itemSize));
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
function setUV(geo, fn) {
|
|
const p = geo.attributes.position;
|
|
const uv = new Float32Array(p.count * 2);
|
|
for (let i = 0; i < p.count; i++) {
|
|
const [u, v] = fn(p.getX(i), p.getY(i), p.getZ(i));
|
|
uv[i * 2] = u; uv[i * 2 + 1] = v;
|
|
}
|
|
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
return geo;
|
|
}
|
|
const merge = (list) => mergeGeometries(list.map((g) => keep(g.index ? g.toNonIndexed() : g, ['position', 'normal', 'uv'])));
|
|
const withUv = (g) => (g.attributes.uv ? g : setUV(g, () => [0.5, 0.5]));
|
|
|
|
// ---- the body, once (the geometry is the same for every finish)
|
|
const model = createNano2Model({ envMap: null, finish: 'silver', screenCanvas: canvas(176, 132) });
|
|
const P = model.parts;
|
|
const root = model.root;
|
|
const BACK_Z = -BODY.d / 2;
|
|
const onBack = (tri) => tri.every((v) => Math.abs(v.z - BACK_Z) < 1e-6);
|
|
const [backGeo, rollGeo] = split(baked(P.shell, root), onBack);
|
|
const shellGeo = merge([rollGeo, baked(P.front, root), baked(P.rim, root)]);
|
|
// the back, seen from behind: +x of the model is the viewer's left, so u runs from +x to -x
|
|
setUV(backGeo, (x, y) => [0.5 - x / BODY.w, 0.5 + y / BODY.h]);
|
|
const centerGeo = keep(baked(P.button, root), ['position', 'normal', 'uv']);
|
|
const wheelGeo = keep(baked(P.wheel, root), ['position', 'normal', 'uv']);
|
|
// caps: top (with the hold slider) and bottom, planar over x and z (seen from above, -z up)
|
|
const [capTop, capBottom] = split(baked(P.cap, root), (tri) => tri[0].y > 0);
|
|
const capUv = (g) => setUV(g, (x, y, z) => [0.5 + x / BODY.w, 0.5 - z / BODY.d]);
|
|
const capTopGeo = capUv(merge([withUv(capTop), withUv(baked(P.knob, root))]));
|
|
const capBottomGeo = capUv(merge([withUv(capBottom)]));
|
|
const wellGeo = merge([withUv(baked(P.well, root)), withUv(baked(P.wall, root))]);
|
|
|
|
// ports: their vertex colours become a palette texture, each vertex pointing at its colour's texel
|
|
const portsGeo = baked(P.ports, root);
|
|
const palette = [];
|
|
{
|
|
const col = portsGeo.attributes.color;
|
|
const uv = new Float32Array(col.count * 2);
|
|
const keyOf = (i) => [col.getX(i), col.getY(i), col.getZ(i)].map((v) => Math.round(v * 255)).join(',');
|
|
for (let i = 0; i < col.count; i++) {
|
|
const k = keyOf(i);
|
|
let idx = palette.indexOf(k);
|
|
if (idx < 0) { palette.push(k); idx = palette.length - 1; }
|
|
uv[i * 2] = idx; // fixed below, once the palette's size is known
|
|
uv[i * 2 + 1] = 0.5;
|
|
}
|
|
for (let i = 0; i < col.count; i++) uv[i * 2] = (uv[i * 2] + 0.5) / palette.length;
|
|
portsGeo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
keep(portsGeo, ['position', 'normal', 'uv']);
|
|
}
|
|
const paletteTex = canvas(palette.length * 4, 4);
|
|
palette.forEach((k, i) => {
|
|
const g = paletteTex.getContext('2d');
|
|
g.fillStyle = `rgb(${k})`;
|
|
g.fillRect(i * 4, 0, 4, 4);
|
|
});
|
|
|
|
// ---- textures shared by every finish
|
|
const BACK_W = 256, BACK_H = Math.round(256 * BODY.h / BODY.w);
|
|
const mask = scaled(engravingCanvas(), BACK_W, BACK_H); // white = engraved
|
|
FILES[DIR + 'nano_back_mask/nano_back_mask.rgba'] = { b64: rgba(mask) };
|
|
// the top cap: white plastic with HOLD printed right of the slot (nano2-model.js holdLabel)
|
|
const CAP_W = 512, CAP_H = Math.round(512 * BODY.d / BODY.w);
|
|
const capTex = canvas(CAP_W, CAP_H);
|
|
{
|
|
const g = capTex.getContext('2d');
|
|
const k = CAP_W / (BODY.w * 1000); // px per mm
|
|
g.fillStyle = '#f1f1ef';
|
|
g.fillRect(0, 0, CAP_W, CAP_H);
|
|
g.fillStyle = '#93969a';
|
|
g.font = `600 ${0.8 * k}px "Helvetica Neue", Helvetica, Arial, sans-serif`;
|
|
g.textBaseline = 'middle';
|
|
g.fillText('HOLD', (BODY.w * 1000 / 2 - 5.1) * k, CAP_H / 2);
|
|
}
|
|
|
|
for (const [key, fin] of Object.entries(FINISHES)) {
|
|
part(`nano_shell_${key}`, shellGeo, solid(fin.metal), { ks: 0.3, ns: 64 });
|
|
part(`nano_center_${key}`, centerGeo, solid(fin.metal), { ks: 0.3, ns: 64 });
|
|
// the back with its engraving: the bare aluminum shows paler through the laser mark
|
|
const back = canvas(BACK_W, BACK_H);
|
|
{
|
|
const g = back.getContext('2d');
|
|
g.fillStyle = fin.metal;
|
|
g.fillRect(0, 0, BACK_W, BACK_H);
|
|
const pale = new THREE.Color(fin.metal).lerp(new THREE.Color(0xffffff), key === 'black' ? 0.55 : 0.4);
|
|
const img = g.getImageData(0, 0, BACK_W, BACK_H);
|
|
const m = mask.getContext('2d').getImageData(0, 0, BACK_W, BACK_H).data;
|
|
const pr = [pale.r, pale.g, pale.b].map((v) => v * 255);
|
|
for (let i = 0; i < img.data.length; i += 4) {
|
|
const a = m[i] / 255;
|
|
for (let c = 0; c < 3; c++) img.data[i + c] = Math.round(img.data[i + c] * (1 - a) + pr[c] * a);
|
|
}
|
|
g.putImageData(img, 0, 0);
|
|
}
|
|
part(`nano_back_${key}`, backGeo, back, { ks: 0.3, ns: 64 });
|
|
}
|
|
for (const [name, fin] of [['white', FINISHES.silver], ['black', FINISHES.black]]) {
|
|
const c = canvas(1024, 1024);
|
|
drawWheel(c, fin);
|
|
part(`nano_wheel_${name}`, wheelGeo, scaled(c, 512, 512), { ks: 0.08, ns: 64 }, { raw: true });
|
|
}
|
|
part('nano_cap_top', capTopGeo, capTex, { ks: 0.1, ns: 64 }, { raw: true });
|
|
part('nano_cap_bottom', capBottomGeo, solid('#f1f1ef'), { ks: 0.1, ns: 64 }, { raw: true });
|
|
part('nano_ports', portsGeo, paletteTex, { ks: 0.2 }, { raw: true });
|
|
part('nano_well', wellGeo, solid('#050505'), { ks: 0 });
|
|
|
|
window.FILES = FILES;
|
|
log('exported ' + Object.keys(FILES).length + ' files');
|
|
fetch('/asspod-model', { method: 'POST', body: JSON.stringify(FILES) })
|
|
.then((r) => r.text()).then((t) => { log('saved: ' + t); window.DONE = true; })
|
|
.catch((e) => { log('not saved (open this page through tools/build_asspod_model.py): ' + e); window.DONE = true; });
|
|
</script>
|
|
</body></html>
|