agents/openai.yaml
interface:
display_name: "Three.js Procedural Geometry"
short_description: "Craft and audit production procedural meshes"
default_prompt: "Use $threejs-procedural-geometry to model a well-crafted procedural assembly or humanoid robot with real profiles, lofts, panels, thickness, bevels, semantic joins, topology checks, and assembly diagnostics."
assets/geometry-quality-kit/assembly.js
/**
* Build one mesh per named part for quality gates, then merge by material slot
* only after those gates pass. Part identity is intentionally retained in the
* audit hierarchy and intentionally discarded in the draw-call hierarchy.
*/
import { Group, Mesh } from "three";
import { buildGroup, cleanMesh, toGeometry } from "./procedural-mesh.js";
function normalizePart(part, index) {
if (!part?.meshData) throw new TypeError(`assembly part ${index} needs meshData`);
if (!Array.isArray(part.meshData.verts) || !Array.isArray(part.meshData.faces)) throw new TypeError(`assembly part ${index} meshData needs verts and faces arrays`);
if (!part.name) throw new TypeError(`assembly part ${index} needs a stable name`);
if (!part.slot) throw new TypeError(`assembly part ${part.name} needs a material slot`);
return part;
}
function preparedMeshData(part, clean) {
const source = part.meshData;
const meshData = typeof source.clone === "function" ? source.clone() : {
...source,
verts: source.verts.map((vertex) => [...vertex]),
faces: source.faces.map((face) => [...face]),
uvs: source.uvs ? source.uvs.map((face) => face ? face.map((uv) => [...uv]) : null) : null,
colors: source.colors ? source.colors.map((color) => [...color]) : null,
shading: source.shading ? { ...source.shading } : { mode: "flat" },
faceMat: source.faceMat ? [...source.faceMat] : null,
provenance: null
};
return clean ? cleanMesh(meshData) : meshData;
}
export function buildNamedAuditGroup(parts, materials, options = {}) {
const group = new Group();
group.name = options.name ?? "geometry-audit-parts";
const clean = options.clean !== false;
const normalized = parts.map(normalizePart);
const names = new Set();
for (const part of normalized) {
if (names.has(part.name)) throw new Error(`duplicate assembly part name "${part.name}"`);
names.add(part.name);
}
normalized.forEach((part) => {
const material = materials[part.slot];
if (!material) throw new Error(`no material bound for slot "${part.slot}"`);
const geometry = toGeometry(preparedMeshData(part, clean), options.uvScale ?? 1);
const mesh = new Mesh(geometry, material);
mesh.name = part.name;
mesh.castShadow = part.castShadow ?? options.castShadow ?? true;
mesh.receiveShadow = part.receiveShadow ?? options.receiveShadow ?? true;
mesh.userData.geometryPart = {
slot: part.slot,
contract: part.contract ?? null,
};
group.add(mesh);
});
return group;
}
export function buildMergedAssembly(parts, materials, options = {}) {
const slots = {};
for (const part of parts.map(normalizePart)) {
(slots[part.slot] ??= []).push(preparedMeshData(part, options.clean !== false));
}
return buildGroup(slots, materials, { ...options, clean: false });
}
export function disposeHierarchy(root, disposeMaterials = false) {
const geometries = new Set();
const materials = new Set();
root.traverse((node) => {
if (!node.isMesh) return;
if (node.geometry) geometries.add(node.geometry);
if (!disposeMaterials) return;
const list = Array.isArray(node.material) ? node.material : [node.material];
for (const material of list) if (material) materials.add(material);
});
for (const geometry of geometries) geometry.dispose?.();
for (const material of materials) material.dispose?.();
}
assets/geometry-quality-kit/geometry-audit.js
/**
* Copyable world-space quality gate for a built Three.js Object3D hierarchy.
*
* Audit named parts before material merging, then audit the merged result too.
* Coplanar detection uses all sixteen neighboring cells of a four-dimensional
* plane grid and measures clipped triangle overlap, not bounding-box overlap.
* Clash detection requires real triangle crossings after a scale-aware shared-
* volume test. Default tolerances assume metres.
*/
const ANG = 25e-4;
const DIST = 15e-4;
const OVERLAP_A = 2e-4;
const CELL_N = 0.02;
const CELL_D = 0.02;
const CLASH_DEPTH = 0.03;
function qualifiedName(o) {
const parts = [];
let cur = o;
while (cur) {
if (cur.name) parts.unshift(cur.name);
cur = cur.parent;
}
if (parts.length === 0) return `${o.type}#${o.id}`;
return parts.join("/");
}
const GEOMETRY_AUDIT_DEFAULTS = Object.freeze({
angleRadians: ANG,
planeDistance: DIST,
overlapArea: OVERLAP_A,
planeCellNormal: CELL_N,
planeCellDistance: CELL_D,
clashDepth: CLASH_DEPTH,
clashCellSize: 0.4,
maxCellsPerTriangle: 256,
minimumClashCrossings: 3,
normalLengthTolerance: 1e-3,
maxTriangles: 6e5,
unit: "metre"
});
const DEFAULT_BOUNDS = null;
function multiplyMatrixElements(a, b, offset = 0) {
const out = new Float64Array(16);
for (let column = 0; column < 4; column++) {
for (let row = 0; row < 4; row++) {
out[column * 4 + row] =
a[row] * b[offset + column * 4] +
a[4 + row] * b[offset + column * 4 + 1] +
a[8 + row] * b[offset + column * 4 + 2] +
a[12 + row] * b[offset + column * 4 + 3];
}
}
return out;
}
function worldTransforms(o, name, instanced) {
if (!instanced) return [{ name, matrix: o.matrixWorld.elements }];
const array = o.instanceMatrix?.array;
const capacity = Math.floor((array?.length ?? 0) / 16);
const count = Math.min(capacity, Math.max(0, Math.floor(o.count ?? 0)));
if (!array || count === 0) return [];
const out = [];
for (let i = 0; i < count; i++) {
out.push({
name: `${name}[${i}]`,
matrix: multiplyMatrixElements(o.matrixWorld.elements, array, i * 16)
});
}
return out;
}
function collect(root, opts) {
const minArea = opts.minArea ?? opts.overlapArea;
const bounds = opts.bounds === void 0 ? DEFAULT_BOUNDS : opts.bounds;
const cx = bounds?.centerX ?? 0;
const cz = bounds?.centerZ ?? 0;
const r2 = bounds ? bounds.radius * bounds.radius : 0;
const names = [];
const defects = [];
const noMaterial = [];
const pos = [];
const nrm = [];
const dist = [];
const area = [];
const box = [];
const mesh = [];
const meshBoxes = [];
let total = 0;
root.updateMatrixWorld(true);
const visit = (node) => {
const flags = node;
if (flags.isMesh !== true) return;
const o = node;
if (!o.visible) return;
if (o.userData?.auditSkip) return;
const name = qualifiedName(o);
if (opts.skip?.(o, name)) return;
if (flags.isInstancedMesh === true && !opts.includeInstanced) return;
const geometry = o.geometry;
const position = geometry.getAttribute("position");
if (!position) return;
const index = geometry.getIndex();
const cornerCount = index ? index.count : position.count;
const triCount = Math.floor(cornerCount / 3);
const incompleteCorners = cornerCount % 3;
if (cornerCount === 0) return;
const material = o.material;
const hasMaterial = Array.isArray(material) ? material.length > 0 && material.some(Boolean) : !!material;
const normalAttr = geometry.getAttribute("normal");
for (const entry of worldTransforms(o, name, flags.isInstancedMesh === true)) {
if (!hasMaterial) noMaterial.push(entry.name);
const meshIndex = names.length;
names.push(entry.name);
const m = entry.matrix;
let degenerate = 0;
let nonFinite = 0;
let badNormals = 0;
let bx0 = Infinity;
let by0 = Infinity;
let bz0 = Infinity;
let bx1 = -Infinity;
let by1 = -Infinity;
let bz1 = -Infinity;
const wx = new Float64Array(3);
const wy = new Float64Array(3);
const wz = new Float64Array(3);
for (let t = 0; t < triCount; t++) {
let ok = true;
for (let c = 0; c < 3; c++) {
const vi = index ? index.getX(t * 3 + c) : t * 3 + c;
const lx = position.getX(vi);
const ly = position.getY(vi);
const lz = position.getZ(vi);
if (!Number.isFinite(lx) || !Number.isFinite(ly) || !Number.isFinite(lz)) ok = false;
wx[c] = m[0] * lx + m[4] * ly + m[8] * lz + m[12];
wy[c] = m[1] * lx + m[5] * ly + m[9] * lz + m[13];
wz[c] = m[2] * lx + m[6] * ly + m[10] * lz + m[14];
if (normalAttr) {
const nxv = normalAttr.getX(vi);
const nyv = normalAttr.getY(vi);
const nzv = normalAttr.getZ(vi);
const l2 = nxv * nxv + nyv * nyv + nzv * nzv;
const nl = Math.sqrt(l2);
if (!Number.isFinite(nl) || nl < 1e-5 || Math.abs(nl - 1) > opts.normalLengthTolerance) badNormals++;
}
}
total++;
if (!ok) {
nonFinite++;
continue;
}
const ux = wx[1] - wx[0];
const uy = wy[1] - wy[0];
const uz = wz[1] - wz[0];
const vx = wx[2] - wx[0];
const vy = wy[2] - wy[0];
const vz = wz[2] - wz[0];
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const l = Math.hypot(nx, ny, nz);
const a = l * 0.5;
if (a < 1e-9) {
degenerate++;
continue;
}
const minx = Math.min(wx[0], wx[1], wx[2]);
const miny = Math.min(wy[0], wy[1], wy[2]);
const minz = Math.min(wz[0], wz[1], wz[2]);
const maxx = Math.max(wx[0], wx[1], wx[2]);
const maxy = Math.max(wy[0], wy[1], wy[2]);
const maxz = Math.max(wz[0], wz[1], wz[2]);
if (minx < bx0) bx0 = minx;
if (miny < by0) by0 = miny;
if (minz < bz0) bz0 = minz;
if (maxx > bx1) bx1 = maxx;
if (maxy > by1) by1 = maxy;
if (maxz > bz1) bz1 = maxz;
if (a < minArea) continue;
if (bounds) {
const mx = (wx[0] + wx[1] + wx[2]) / 3 - cx;
const my = (wy[0] + wy[1] + wy[2]) / 3;
const mz = (wz[0] + wz[1] + wz[2]) / 3 - cz;
if (mx * mx + mz * mz > r2 || my < bounds.minY || my > bounds.maxY) continue;
}
nx /= l;
ny /= l;
nz /= l;
pos.push(wx[0], wy[0], wz[0], wx[1], wy[1], wz[1], wx[2], wy[2], wz[2]);
nrm.push(nx, ny, nz);
dist.push(nx * wx[0] + ny * wy[0] + nz * wz[0]);
area.push(a);
box.push(minx, miny, minz, maxx, maxy, maxz);
mesh.push(meshIndex);
}
meshBoxes.push(bx0, by0, bz0, bx1, by1, bz1);
if (degenerate || nonFinite || badNormals || incompleteCorners) {
defects.push({ name: entry.name, triangles: triCount, degenerate, nonFinite, badNormals, incompleteCorners });
}
}
};
if (typeof root.traverseVisible === "function") root.traverseVisible(visit);
else root.traverse(visit);
return {
tris: {
pos: Float64Array.from(pos),
nrm: Float64Array.from(nrm),
dist: Float64Array.from(dist),
area: Float64Array.from(area),
box: Float64Array.from(box),
mesh: Int32Array.from(mesh),
count: area.length
},
names,
meshBox: Float64Array.from(meshBoxes),
defects,
noMaterial,
total
};
}
function clipArea(P, Q) {
let out = P;
let s = 0;
for (let i = 0; i < 3; i++) {
const j = (i + 1) % 3;
s += Q[i * 2] * Q[j * 2 + 1] - Q[j * 2] * Q[i * 2 + 1];
}
const q = s < 0 ? [Q[4], Q[5], Q[2], Q[3], Q[0], Q[1]] : Q;
for (let i = 0; i < 3; i++) {
const ax = q[i * 2];
const ay = q[i * 2 + 1];
const bx = q[(i + 1) % 3 * 2];
const by = q[(i + 1) % 3 * 2 + 1];
const ex = bx - ax;
const ey = by - ay;
const inp = out;
if (inp.length < 6) return 0;
out = [];
let px = inp[inp.length - 2];
let py = inp[inp.length - 1];
let dprev = ex * (py - ay) - ey * (px - ax);
for (let k = 0; k < inp.length; k += 2) {
const cxp = inp[k];
const cyp = inp[k + 1];
const dcur = ex * (cyp - ay) - ey * (cxp - ax);
if (dcur >= 0) {
if (dprev < 0) {
const t = dprev / (dprev - dcur);
out.push(px + (cxp - px) * t, py + (cyp - py) * t);
}
out.push(cxp, cyp);
} else if (dprev >= 0) {
const t = dprev / (dprev - dcur);
out.push(px + (cxp - px) * t, py + (cyp - py) * t);
}
px = cxp;
py = cyp;
dprev = dcur;
}
}
if (out.length < 6) return 0;
let a = 0;
for (let i = 0; i < out.length; i += 2) {
const j = (i + 2) % out.length;
a += out[i] * out[j + 1] - out[j] * out[i + 1];
}
return Math.abs(a) * 0.5;
}
function coplanarPass(tris, names, top, opts) {
const n = tris.count;
const { nrm, dist, box, pos, mesh } = tris;
const grid = /* @__PURE__ */ new Map();
const eps = 1e-9;
const angleCosine = Math.cos(Math.max(0, Math.min(Math.PI, opts.angleRadians)));
const planeDistance = opts.planeDistance;
const overlapArea = opts.overlapArea;
const normalCell = opts.planeCellNormal;
const distanceCell = opts.planeCellDistance;
for (let i = 0; i < n; i++) {
let cx = nrm[i * 3];
let cy = nrm[i * 3 + 1];
let cz = nrm[i * 3 + 2];
let cd = dist[i];
if (cz < -eps || Math.abs(cz) <= eps && (cy < -eps || Math.abs(cy) <= eps && cx < 0)) {
cx = -cx;
cy = -cy;
cz = -cz;
cd = -cd;
}
const f0 = Math.floor(cx / normalCell);
const f1 = Math.floor(cy / normalCell);
const f2 = Math.floor(cz / normalCell);
const f3 = Math.floor(cd / distanceCell);
for (let a = 0; a < 2; a++) {
for (let b = 0; b < 2; b++) {
for (let c = 0; c < 2; c++) {
for (let e = 0; e < 2; e++) {
const key = `${f0 + a},${f1 + b},${f2 + c},${f3 + e}`;
const list = grid.get(key);
if (list) list.push(i);
else grid.set(key, [i]);
}
}
}
}
}
const seen = /* @__PURE__ */ new Set();
const hits = /* @__PURE__ */ new Map();
let totalCm2 = 0;
let backToBack = 0;
const A = [0, 0, 0, 0, 0, 0];
const B = [0, 0, 0, 0, 0, 0];
const test = (i, j) => {
if (box[i * 6 + 3] + planeDistance < box[j * 6] || box[j * 6 + 3] + planeDistance < box[i * 6] || box[i * 6 + 4] + planeDistance < box[j * 6 + 1] || box[j * 6 + 4] + planeDistance < box[i * 6 + 1] || box[i * 6 + 5] + planeDistance < box[j * 6 + 2] || box[j * 6 + 5] + planeDistance < box[i * 6 + 2]) {
return;
}
const nix = nrm[i * 3];
const niy = nrm[i * 3 + 1];
const niz = nrm[i * 3 + 2];
const d = nix * nrm[j * 3] + niy * nrm[j * 3 + 1] + niz * nrm[j * 3 + 2];
if (Math.abs(d) < angleCosine) return;
if (Math.abs(nix * pos[j * 9] + niy * pos[j * 9 + 1] + niz * pos[j * 9 + 2] - dist[i]) > planeDistance) return;
const pair = i < j ? `${i}_${j}` : `${j}_${i}`;
if (seen.has(pair)) return;
seen.add(pair);
let ux;
let uy;
let uz;
if (Math.abs(niz) < 0.9) {
ux = niy * 1 - niz * 0;
uy = niz * 0 - nix * 1;
uz = 0;
} else {
ux = 0;
uy = niz;
uz = -niy;
}
const ul = Math.hypot(ux, uy, uz) || 1;
ux /= ul;
uy /= ul;
uz /= ul;
const vx = niy * uz - niz * uy;
const vy = niz * ux - nix * uz;
const vz = nix * uy - niy * ux;
for (let c = 0; c < 3; c++) {
const ix = pos[i * 9 + c * 3];
const iy = pos[i * 9 + c * 3 + 1];
const iz = pos[i * 9 + c * 3 + 2];
A[c * 2] = ix * ux + iy * uy + iz * uz;
A[c * 2 + 1] = ix * vx + iy * vy + iz * vz;
const jx = pos[j * 9 + c * 3];
const jy = pos[j * 9 + c * 3 + 1];
const jz = pos[j * 9 + c * 3 + 2];
B[c * 2] = jx * ux + jy * uy + jz * uz;
B[c * 2 + 1] = jx * vx + jy * vy + jz * vz;
}
const ov = clipArea(A, B);
if (ov < overlapArea) return;
if (d < 0) {
backToBack++;
return;
}
const na = names[mesh[i]];
const nb = names[mesh[j]];
const key = na < nb ? na + " :: " + nb : nb + " :: " + na;
totalCm2 += ov * 1e4;
const cur = hits.get(key);
if (cur) {
cur.areaCm2 += ov * 1e4;
cur.pairs++;
} else {
hits.set(key, {
a: na,
b: nb,
areaCm2: ov * 1e4,
at: [
Math.round((pos[i * 9] + pos[i * 9 + 3] + pos[i * 9 + 6]) / 3 * 100) / 100,
Math.round((pos[i * 9 + 1] + pos[i * 9 + 4] + pos[i * 9 + 7]) / 3 * 100) / 100,
Math.round((pos[i * 9 + 2] + pos[i * 9 + 5] + pos[i * 9 + 8]) / 3 * 100) / 100
],
pairs: 1
});
}
};
const order = [];
for (const items of grid.values()) {
if (items.length < 2) continue;
order.length = 0;
for (const it of items) order.push(it);
order.sort((a, b) => box[a * 6] - box[b * 6]);
for (let a = 0; a < order.length; a++) {
const i = order[a];
const maxx = box[i * 6 + 3] + planeDistance;
for (let b = a + 1; b < order.length; b++) {
const j = order[b];
if (box[j * 6] > maxx) break;
test(i, j);
}
}
}
const out = [...hits.values()].sort((x, y) => y.areaCm2 - x.areaCm2);
for (const h of out) h.areaCm2 = Math.round(h.areaCm2 * 10) / 10;
return { hits: out.slice(0, top), totalCm2: Math.round(totalCm2 * 10) / 10, backToBack };
}
function segmentHitsTri(ox, oy, oz, dx, dy, dz, p, t) {
const b = t * 9;
const e1x = p[b + 3] - p[b];
const e1y = p[b + 4] - p[b + 1];
const e1z = p[b + 5] - p[b + 2];
const e2x = p[b + 6] - p[b];
const e2y = p[b + 7] - p[b + 1];
const e2z = p[b + 8] - p[b + 2];
const hx = dy * e2z - dz * e2y;
const hy = dz * e2x - dx * e2z;
const hz = dx * e2y - dy * e2x;
const a = e1x * hx + e1y * hy + e1z * hz;
if (a > -1e-12 && a < 1e-12) return false;
const f = 1 / a;
const sx = ox - p[b];
const sy = oy - p[b + 1];
const sz = oz - p[b + 2];
const u = f * (sx * hx + sy * hy + sz * hz);
if (u < 0 || u > 1) return false;
const qx = sy * e1z - sz * e1y;
const qy = sz * e1x - sx * e1z;
const qz = sx * e1y - sy * e1x;
const v = f * (dx * qx + dy * qy + dz * qz);
if (v < 0 || u + v > 1) return false;
const tt = f * (e2x * qx + e2y * qy + e2z * qz);
return tt > 1e-9 && tt < 1 - 1e-9;
}
function edgesCross(p, a, b) {
const base = a * 9;
for (let e = 0; e < 3; e++) {
const o = base + e * 3;
const q = base + (e + 1) % 3 * 3;
if (segmentHitsTri(p[o], p[o + 1], p[o + 2], p[q] - p[o], p[q + 1] - p[o + 1], p[q + 2] - p[o + 2], p, b)) {
return true;
}
}
return false;
}
function trisCross(tris, i, j) {
return edgesCross(tris.pos, i, j) || edgesCross(tris.pos, j, i);
}
function clashPass(tris, names, meshBox, opts) {
const allow = opts.clashAllow ?? [];
const matchesPrefix = (name, prefix) => name.startsWith(prefix) || name.split("/").some((segment) => segment.startsWith(prefix));
const allowed = (a, b) => {
for (const [p, q] of allow) {
if (matchesPrefix(a, p) && matchesPrefix(b, q) || matchesPrefix(a, q) && matchesPrefix(b, p)) return true;
}
return false;
};
const byMesh = /* @__PURE__ */ new Map();
for (let i = 0; i < tris.count; i++) {
const m = tris.mesh[i];
const list = byMesh.get(m);
if (list) list.push(i);
else byMesh.set(m, [i]);
}
const ids = [...byMesh.keys()].sort((a, b) => a - b);
const out = [];
for (let x = 0; x < ids.length; x++) {
const a = ids[x];
const ab = a * 6;
for (let y = x + 1; y < ids.length; y++) {
const b = ids[y];
const bb = b * 6;
const lo0 = Math.max(meshBox[ab], meshBox[bb]);
const lo1 = Math.max(meshBox[ab + 1], meshBox[bb + 1]);
const lo2 = Math.max(meshBox[ab + 2], meshBox[bb + 2]);
const hi0 = Math.min(meshBox[ab + 3], meshBox[bb + 3]);
const hi1 = Math.min(meshBox[ab + 4], meshBox[bb + 4]);
const hi2 = Math.min(meshBox[ab + 5], meshBox[bb + 5]);
if (hi0 <= lo0 || hi1 <= lo1 || hi2 <= lo2) continue;
if (allowed(names[a], names[b])) continue;
const ta = Math.min(meshBox[ab + 3] - meshBox[ab], meshBox[ab + 4] - meshBox[ab + 1], meshBox[ab + 5] - meshBox[ab + 2]);
const tb = Math.min(meshBox[bb + 3] - meshBox[bb], meshBox[bb + 4] - meshBox[bb + 1], meshBox[bb + 5] - meshBox[bb + 2]);
const lim = Math.min(opts.clashDepth, Math.max(4e-3, 0.34 * Math.min(ta, tb)));
if (Math.min(hi0 - lo0, hi1 - lo1, hi2 - lo2) < lim) continue;
const cell = opts.clashCellSize;
const maxCells = opts.maxCellsPerTriangle;
const hash = /* @__PURE__ */ new Map();
const inShared = (i) => tris.box[i * 6 + 3] >= lo0 && tris.box[i * 6] <= hi0 && tris.box[i * 6 + 4] >= lo1 && tris.box[i * 6 + 1] <= hi1 && tris.box[i * 6 + 5] >= lo2 && tris.box[i * 6 + 2] <= hi2;
const key = (ix, iy, iz) => `${ix},${iy},${iz}`;
const candidatesA = [];
const oversizedA = [];
let inserted = 0;
for (const i of byMesh.get(a)) {
if (!inShared(i)) continue;
candidatesA.push(i);
const x0 = Math.floor(Math.max(tris.box[i * 6], lo0) / cell);
const y0 = Math.floor(Math.max(tris.box[i * 6 + 1], lo1) / cell);
const z0 = Math.floor(Math.max(tris.box[i * 6 + 2], lo2) / cell);
const x1 = Math.floor(Math.min(tris.box[i * 6 + 3], hi0) / cell);
const y1 = Math.floor(Math.min(tris.box[i * 6 + 4], hi1) / cell);
const z1 = Math.floor(Math.min(tris.box[i * 6 + 5], hi2) / cell);
if ((x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1) > maxCells) {
oversizedA.push(i);
continue;
}
for (let ix = x0; ix <= x1; ix++) {
for (let iy = y0; iy <= y1; iy++) {
for (let iz = z0; iz <= z1; iz++) {
const k = key(ix, iy, iz);
const l = hash.get(k);
if (l) l.push(i);
else hash.set(k, [i]);
inserted++;
}
}
}
}
if (inserted === 0 && oversizedA.length === 0) continue;
let crossings = 0;
const probed = /* @__PURE__ */ new Set();
const probe = (i, j) => {
if (probed.has(i)) return false;
probed.add(i);
if (!trisCross(tris, i, j)) return false;
crossings++;
return crossings > 512;
};
outer: for (const j of byMesh.get(b)) {
if (!inShared(j)) continue;
const x0 = Math.floor(Math.max(tris.box[j * 6], lo0) / cell);
const y0 = Math.floor(Math.max(tris.box[j * 6 + 1], lo1) / cell);
const z0 = Math.floor(Math.max(tris.box[j * 6 + 2], lo2) / cell);
const x1 = Math.floor(Math.min(tris.box[j * 6 + 3], hi0) / cell);
const y1 = Math.floor(Math.min(tris.box[j * 6 + 4], hi1) / cell);
const z1 = Math.floor(Math.min(tris.box[j * 6 + 5], hi2) / cell);
const cells = (x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1);
probed.clear();
if (cells > maxCells) {
for (const i of candidatesA) if (probe(i, j)) break outer;
continue;
}
for (const i of oversizedA) if (probe(i, j)) break outer;
for (let ix = x0; ix <= x1; ix++) {
for (let iy = y0; iy <= y1; iy++) {
for (let iz = z0; iz <= z1; iz++) {
const l = hash.get(key(ix, iy, iz));
if (!l) continue;
for (const i of l) {
if (probe(i, j)) break outer;
}
}
}
}
}
if (crossings >= opts.minimumClashCrossings) out.push({ a: names[a], b: names[b], crossings });
}
}
return out.sort((p, q) => q.crossings - p.crossings);
}
function auditGeometry(root, opts = {}) {
const settings = { ...GEOMETRY_AUDIT_DEFAULTS, ...opts };
for (const key of ["planeDistance", "overlapArea", "planeCellNormal", "planeCellDistance", "clashCellSize", "maxCellsPerTriangle", "normalLengthTolerance"]) {
if (!Number.isFinite(settings[key]) || settings[key] <= 0) throw new RangeError(`geometry audit option ${key} must be finite and greater than zero`);
}
if (!Number.isFinite(settings.angleRadians) || settings.angleRadians < 0 || settings.angleRadians > Math.PI) throw new RangeError("geometry audit option angleRadians must be between zero and pi");
if (!Number.isFinite(settings.clashDepth) || settings.clashDepth < 0) throw new RangeError("geometry audit option clashDepth must be finite and non-negative");
if (settings.planeCellNormal < 2 * Math.sin(settings.angleRadians * 0.5)) throw new RangeError("geometry audit option planeCellNormal must cover angleRadians for neighboring-cell lookup");
if (settings.planeCellDistance < settings.planeDistance) throw new RangeError("geometry audit option planeCellDistance must be at least planeDistance");
if (!Number.isInteger(settings.maxCellsPerTriangle)) throw new RangeError("geometry audit option maxCellsPerTriangle must be a positive integer");
if (!Number.isInteger(settings.minimumClashCrossings) || settings.minimumClashCrossings < 1) throw new RangeError("geometry audit option minimumClashCrossings must be a positive integer");
if (!(settings.maxTriangles === Infinity || Number.isInteger(settings.maxTriangles) && settings.maxTriangles >= 0)) throw new RangeError("geometry audit option maxTriangles must be a non-negative integer or Infinity");
if (settings.top !== undefined && (!Number.isInteger(settings.top) || settings.top < 0)) throw new RangeError("geometry audit option top must be a non-negative integer");
if (settings.minArea !== undefined && (!Number.isFinite(settings.minArea) || settings.minArea <= 0)) throw new RangeError("geometry audit option minArea must be finite and greater than zero");
if (settings.clashAllow !== undefined && (!Array.isArray(settings.clashAllow) || settings.clashAllow.some((pair) => !Array.isArray(pair) || pair.length !== 2 || pair.some((prefix) => typeof prefix !== "string" || prefix.length === 0)))) throw new TypeError("geometry audit option clashAllow must contain pairs of non-empty string prefixes");
const t0 = performance.now();
const top = settings.top ?? 25;
const collected = collect(root, settings);
const tris = collected.tris;
let truncated = false;
const cap = settings.maxTriangles;
if (tris.count > cap) {
truncated = true;
tris.count = cap;
}
const co = coplanarPass(tris, collected.names, top, settings);
const clash = settings.clash === false ? [] : clashPass(tris, collected.names, collected.meshBox, settings);
return {
ms: Math.round(performance.now() - t0),
meshes: collected.names.length,
triangles: collected.total,
compared: tris.count,
truncated,
zfight: co.hits,
zfightTotalCm2: co.totalCm2,
backToBack: co.backToBack,
defects: collected.defects.sort((a, b) => b.degenerate + b.nonFinite + b.badNormals + b.incompleteCorners - (a.degenerate + a.nonFinite + a.badNormals + a.incompleteCorners)).slice(0, top),
noMaterial: collected.noMaterial,
clash: clash.slice(0, top)
};
}
function logAuditReport(report) {
const head = `GEOMETRY AUDIT ${report.meshes} meshes / ${report.triangles} tris (${report.compared} compared${report.truncated ? ", TRUNCATED" : ""}) ${report.ms} ms`;
console.log(head);
console.log(
` zfight ${report.zfight.length} pairs / ${report.zfightTotalCm2} cm\xB2 back-to-back ${report.backToBack} (informational) defects ${report.defects.length} nomat ${report.noMaterial.length} clash ${report.clash.length}`
);
if (report.zfight.length) console.table(report.zfight);
if (report.defects.length) console.table(report.defects);
if (report.clash.length) console.table(report.clash);
if (report.noMaterial.length) console.log(" no material:", report.noMaterial);
return report;
}
export {
GEOMETRY_AUDIT_DEFAULTS,
auditGeometry,
logAuditReport
};
assets/geometry-quality-kit/geometry-contract.js
/**
* Minimal runner for object-specific geometry assertions. Keep measurements
* beside the generator that owns their dimensions; use this module only for
* consistent reporting and failure behavior.
*/
function normalizeCheck(check, index) {
if (!check || typeof check.measure !== "function" || typeof check.accept !== "function") {
throw new TypeError(`geometry contract check ${index} needs measure() and accept()`);
}
const severity = check.severity ?? "error";
if (severity !== "error" && severity !== "warning") throw new TypeError(`geometry contract check ${index} severity must be "error" or "warning"`);
return {
id: check.id ?? `check-${index}`,
label: check.label ?? check.id ?? `check ${index}`,
severity,
expected: check.expected ?? "custom predicate",
measure: check.measure,
accept: check.accept,
};
}
export function runGeometryContract(contract, context) {
const name = contract?.name ?? "geometry";
const checks = (contract?.checks ?? []).map(normalizeCheck);
const ids = new Set();
for (const check of checks) {
if (ids.has(check.id)) throw new TypeError(`geometry contract "${name}" has duplicate check id "${check.id}"`);
ids.add(check.id);
}
const results = [];
for (const check of checks) {
try {
const value = check.measure(context);
results.push({
id: check.id,
label: check.label,
severity: check.severity,
expected: check.expected,
value,
ok: check.accept(value, context) === true,
});
} catch (error) {
results.push({
id: check.id,
label: check.label,
severity: check.severity,
expected: check.expected,
value: undefined,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
const failures = results.filter((result) => !result.ok && result.severity !== "warning");
const warnings = results.filter((result) => !result.ok && result.severity === "warning");
return { name, ok: failures.length === 0, failures, warnings, results };
}
export function assertGeometryContract(report) {
if (report.ok) return report;
const printable = (value) => {
try {
const json = JSON.stringify(value);
return json === undefined ? String(value) : json;
} catch {
return String(value);
}
};
const detail = report.failures
.map((result) => `${result.id}: got ${printable(result.value)}, expected ${result.expected}${result.error ? ` (${result.error})` : ""}`)
.join("\n- ");
throw new Error(`${report.name} failed geometry contract:\n- ${detail}`);
}
export function rangeCheck(id, measure, min, max, options = {}) {
return {
id,
label: options.label ?? id,
severity: options.severity,
expected: `${min} <= value <= ${max}`,
measure,
accept: (value) => Number.isFinite(value) && value >= min && value <= max,
};
}
export function minimumCheck(id, measure, min, options = {}) {
return {
id,
label: options.label ?? id,
severity: options.severity,
expected: `value >= ${min}`,
measure,
accept: (value) => Number.isFinite(value) && value >= min,
};
}
export function maximumCheck(id, measure, max, options = {}) {
return {
id,
label: options.label ?? id,
severity: options.severity,
expected: `value <= ${max}`,
measure,
accept: (value) => Number.isFinite(value) && value <= max,
};
}
export function nearCheck(id, measure, target, tolerance, options = {}) {
return {
id,
label: options.label ?? id,
severity: options.severity,
expected: `|value - ${target}| <= ${tolerance}`,
measure,
accept: (value) => Number.isFinite(value) && Math.abs(value - target) <= tolerance,
};
}
export function predicateCheck(id, measure, accept, expected, options = {}) {
return { id, label: options.label ?? id, severity: options.severity, expected, measure, accept };
}
export function intervalOverlap(a0, a1, b0, b1) {
return Math.max(0, Math.min(a1, b1) - Math.max(a0, b0));
}
export function boundsIntersectionDepth(a, b) {
return [
intervalOverlap(a.min.x, a.max.x, b.min.x, b.max.x),
intervalOverlap(a.min.y, a.max.y, b.min.y, b.max.y),
intervalOverlap(a.min.z, a.max.z, b.min.z, b.max.z),
];
}
export function boundsGapAlongAxis(a, b, axis) {
if (a.max[axis] < b.min[axis]) return b.min[axis] - a.max[axis];
if (b.max[axis] < a.min[axis]) return a.min[axis] - b.max[axis];
return 0;
}
export function boundsContains(outer, inner, tolerance = 0) {
return inner.min.x >= outer.min.x - tolerance && inner.max.x <= outer.max.x + tolerance &&
inner.min.y >= outer.min.y - tolerance && inner.max.y <= outer.max.y + tolerance &&
inner.min.z >= outer.min.z - tolerance && inner.max.z <= outer.max.z + tolerance;
}
assets/geometry-quality-kit/mesh-topology-audit.js
/**
* Audit MeshData before Three.js emission, while polygon topology and vertex
* ownership are still visible. The default contract is one closed solid.
* Pass { closed: false, maxComponents: Infinity, checkOrientation: false }
* for an intentionally open surface.
*/
const DEFAULT_TOPOLOGY_OPTIONS = Object.freeze({
closed: true,
maxComponents: 1,
duplicateDistance: 2e-5,
degenerateArea: 1e-12,
checkOrientation: true,
});
function issue(code, message, detail = {}) {
return { code, message, ...detail };
}
function finiteVec3(v) {
return Array.isArray(v) && v.length >= 3 &&
Number.isFinite(v[0]) && Number.isFinite(v[1]) && Number.isFinite(v[2]);
}
function triangleArea2(a, b, c) {
const ux = b[0] - a[0];
const uy = b[1] - a[1];
const uz = b[2] - a[2];
const vx = c[0] - a[0];
const vy = c[1] - a[1];
const vz = c[2] - a[2];
return Math.hypot(
uy * vz - uz * vy,
uz * vx - ux * vz,
ux * vy - uy * vx,
);
}
function faceArea(verts, face) {
if (face.length < 3) return 0;
const a = verts[face[0]];
if (!finiteVec3(a)) return NaN;
let area = 0;
for (let i = 1; i + 1 < face.length; i++) {
const b = verts[face[i]];
const c = verts[face[i + 1]];
if (!finiteVec3(b) || !finiteVec3(c)) return NaN;
area += triangleArea2(a, b, c) * 0.5;
}
return area;
}
function signedVolume(verts, faces) {
let volume6 = 0;
for (const face of faces) {
if (face.length < 3) continue;
const a = verts[face[0]];
for (let i = 1; i + 1 < face.length; i++) {
const b = verts[face[i]];
const c = verts[face[i + 1]];
volume6 += a[0] * (b[1] * c[2] - b[2] * c[1]);
volume6 += a[1] * (b[2] * c[0] - b[0] * c[2]);
volume6 += a[2] * (b[0] * c[1] - b[1] * c[0]);
}
}
return volume6 / 6;
}
function duplicateVertexPairs(verts, distance) {
if (!(distance > 0)) return [];
const grid = new Map();
const pairs = [];
const distance2 = distance * distance;
const key = (x, y, z) => `${x},${y},${z}`;
for (let i = 0; i < verts.length; i++) {
const v = verts[i];
if (!finiteVec3(v)) continue;
const ix = Math.floor(v[0] / distance);
const iy = Math.floor(v[1] / distance);
const iz = Math.floor(v[2] / distance);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
for (let dz = -1; dz <= 1; dz++) {
const bucket = grid.get(key(ix + dx, iy + dy, iz + dz));
if (!bucket) continue;
for (const j of bucket) {
const u = verts[j];
const d0 = v[0] - u[0];
const d1 = v[1] - u[1];
const d2 = v[2] - u[2];
if (d0 * d0 + d1 * d1 + d2 * d2 <= distance2) pairs.push([j, i]);
}
}
}
}
const own = key(ix, iy, iz);
const bucket = grid.get(own);
if (bucket) bucket.push(i);
else grid.set(own, [i]);
}
return pairs;
}
function connectedFaceComponents(faces, vertexCount) {
if (faces.length === 0) return [];
const byEdge = new Map();
for (let fi = 0; fi < faces.length; fi++) {
const face = faces[fi];
if (!Array.isArray(face)) continue;
for (let i = 0; i < face.length; i++) {
const a = face[i];
const b = face[(i + 1) % face.length];
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0 || a >= vertexCount || b >= vertexCount || a === b) continue;
const key = a < b ? `${a}_${b}` : `${b}_${a}`;
const list = byEdge.get(key);
if (list) list.push(fi);
else byEdge.set(key, [fi]);
}
}
const adjacent = Array.from({ length: faces.length }, () => []);
for (const list of byEdge.values()) {
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
adjacent[list[i]].push(list[j]);
adjacent[list[j]].push(list[i]);
}
}
}
const seen = new Uint8Array(faces.length);
const components = [];
for (let seed = 0; seed < faces.length; seed++) {
if (seen[seed]) continue;
const component = [];
const stack = [seed];
seen[seed] = 1;
while (stack.length) {
const fi = stack.pop();
component.push(fi);
for (const next of adjacent[fi]) {
if (seen[next]) continue;
seen[next] = 1;
stack.push(next);
}
}
components.push(component);
}
return components;
}
export function auditMeshData(meshData, options = {}) {
const opts = { ...DEFAULT_TOPOLOGY_OPTIONS, ...options };
const verts = meshData?.verts ?? [];
const faces = meshData?.faces ?? [];
const issues = [];
const vertexUse = new Uint32Array(verts.length);
const edgeUse = new Map();
let invalidVertices = 0;
let invalidIndices = 0;
let degenerateFaces = 0;
for (let i = 0; i < verts.length; i++) {
if (!finiteVec3(verts[i])) invalidVertices++;
}
for (let fi = 0; fi < faces.length; fi++) {
const face = faces[fi];
if (!Array.isArray(face) || face.length < 3) {
degenerateFaces++;
continue;
}
let valid = true;
let finite = true;
const unique = new Set();
for (const vi of face) {
if (!Number.isInteger(vi) || vi < 0 || vi >= verts.length) {
invalidIndices++;
valid = false;
continue;
}
vertexUse[vi]++;
unique.add(vi);
if (!finiteVec3(verts[vi])) finite = false;
}
const area = valid && finite ? faceArea(verts, face) : NaN;
if (!valid || !finite || unique.size < 3 || !Number.isFinite(area) || area <= opts.degenerateArea) {
degenerateFaces++;
}
if (!valid) continue;
for (let i = 0; i < face.length; i++) {
const a = face[i];
const b = face[(i + 1) % face.length];
if (a === b) continue;
const key = a < b ? `${a}_${b}` : `${b}_${a}`;
let edge = edgeUse.get(key);
if (!edge) {
edge = { count: 0, direction: 0 };
edgeUse.set(key, edge);
}
edge.count++;
edge.direction += a < b ? 1 : -1;
}
}
const looseVertices = [];
for (let i = 0; i < vertexUse.length; i++) if (vertexUse[i] === 0) looseVertices.push(i);
const duplicateVertices = duplicateVertexPairs(verts, opts.duplicateDistance);
let boundaryEdges = 0;
let nonManifoldEdges = 0;
let inconsistentWindingEdges = 0;
for (const edge of edgeUse.values()) {
if (edge.count === 1) boundaryEdges++;
else if (edge.count !== 2) nonManifoldEdges++;
else if (edge.direction !== 0) inconsistentWindingEdges++;
}
const faceComponents = connectedFaceComponents(faces, verts.length);
const components = faceComponents.length;
const componentVolumes = invalidVertices || invalidIndices || degenerateFaces ? [] : faceComponents.map((component) => signedVolume(verts, component.map((fi) => faces[fi])));
const volume = componentVolumes.length === faceComponents.length ? componentVolumes.reduce((sum, value) => sum + value, 0) : NaN;
if (invalidVertices) issues.push(issue("non-finite-vertex", `${invalidVertices} vertices are non-finite`, { count: invalidVertices }));
if (invalidIndices) issues.push(issue("invalid-index", `${invalidIndices} face indices are invalid`, { count: invalidIndices }));
if (degenerateFaces) issues.push(issue("degenerate-face", `${degenerateFaces} faces have fewer than three usable corners or zero area`, { count: degenerateFaces }));
if (looseVertices.length) issues.push(issue("loose-vertex", `${looseVertices.length} vertices are unused`, { count: looseVertices.length, sample: looseVertices.slice(0, 20) }));
if (duplicateVertices.length) issues.push(issue("duplicate-vertex", `${duplicateVertices.length} vertex pairs are within ${opts.duplicateDistance}`, { count: duplicateVertices.length, sample: duplicateVertices.slice(0, 20) }));
if (opts.closed && boundaryEdges) issues.push(issue("open-boundary", `${boundaryEdges} edges belong to only one face`, { count: boundaryEdges }));
if (nonManifoldEdges) issues.push(issue("non-manifold-edge", `${nonManifoldEdges} edges do not belong to one or two faces`, { count: nonManifoldEdges }));
if (inconsistentWindingEdges) issues.push(issue("inconsistent-winding", `${inconsistentWindingEdges} shared edges run in the same direction on both incident faces`, { count: inconsistentWindingEdges }));
if (components > opts.maxComponents) issues.push(issue("detached-component", `${components} disconnected face components exceed the allowed ${opts.maxComponents}`, { count: components }));
if (faces.length === 0) issues.push(issue("empty-mesh", "mesh has no faces"));
const closedTopology = boundaryEdges === 0 && nonManifoldEdges === 0 && inconsistentWindingEdges === 0;
if (opts.closed && opts.checkOrientation && closedTopology && componentVolumes.length === faceComponents.length) {
const insideOut = componentVolumes.filter((value) => value <= 0);
if (insideOut.length) issues.push(issue("inside-out", `${insideOut.length} closed components do not have positive signed volume`, { count: insideOut.length, signedVolume: volume, componentVolumes: insideOut.slice(0, 20) }));
}
if (meshData?.faceMat && meshData.faceMat.length !== faces.length) {
issues.push(issue("material-face-count", `faceMat has ${meshData.faceMat.length} entries for ${faces.length} faces`));
}
if (meshData?.faceMat) {
const invalidMaterialIndices = meshData.faceMat.filter((value) => !Number.isInteger(value) || value < 0).length;
if (invalidMaterialIndices) issues.push(issue("invalid-material-index", `${invalidMaterialIndices} faceMat entries are not non-negative integers`, { count: invalidMaterialIndices }));
}
if (meshData?.colors && meshData.colors.length !== verts.length) {
issues.push(issue("color-vertex-count", `colors has ${meshData.colors.length} entries for ${verts.length} vertices`));
}
if (meshData?.colors) {
const invalidColors = meshData.colors.filter((color) => !finiteVec3(color)).length;
if (invalidColors) issues.push(issue("invalid-vertex-color", `${invalidColors} vertex colors are non-finite`, { count: invalidColors }));
}
if (meshData?.uvs && meshData.uvs.length !== faces.length) {
issues.push(issue("uv-face-count", `uvs has ${meshData.uvs.length} entries for ${faces.length} faces`));
}
if (meshData?.uvs) {
let invalidUvFaces = 0;
for (let fi = 0; fi < Math.min(meshData.uvs.length, faces.length); fi++) {
const faceUv = meshData.uvs[fi];
if (faceUv === null) continue;
if (!Array.isArray(faceUv) || !Array.isArray(faces[fi]) || faceUv.length !== faces[fi].length || faceUv.some((uv) => !Array.isArray(uv) || uv.length < 2 || !Number.isFinite(uv[0]) || !Number.isFinite(uv[1]))) invalidUvFaces++;
}
if (invalidUvFaces) issues.push(issue("invalid-face-uv", `${invalidUvFaces} faces do not have one finite UV per corner`, { count: invalidUvFaces }));
}
return {
ok: issues.length === 0,
issues,
counts: {
vertices: verts.length,
faces: faces.length,
edges: edgeUse.size,
components,
looseVertices: looseVertices.length,
duplicateVertexPairs: duplicateVertices.length,
boundaryEdges,
nonManifoldEdges,
inconsistentWindingEdges,
degenerateFaces,
},
signedVolume: volume,
componentVolumes,
};
}
export function auditTriangleSoup(soup, options = {}) {
const positions = soup?.positions ?? [];
const normals = soup?.normals ?? [];
const unitTolerance = options.unitNormalTolerance ?? 1e-5;
const issues = [];
let nonFinitePositions = 0;
let badNormals = 0;
let degenerateTriangles = 0;
let opposedNormals = 0;
for (const value of positions) if (!Number.isFinite(value)) nonFinitePositions++;
if (positions.length % 9 !== 0) issues.push(issue("triangle-position-count", "position count is not divisible by nine"));
if (normals.length !== positions.length) issues.push(issue("normal-count", "normal count does not match position count"));
for (let i = 0; i + 2 < normals.length; i += 3) {
const length = Math.hypot(normals[i], normals[i + 1], normals[i + 2]);
if (!Number.isFinite(length) || Math.abs(length - 1) > unitTolerance) badNormals++;
}
for (let i = 0; i + 8 < positions.length; i += 9) {
const a = positions.slice(i, i + 3);
const b = positions.slice(i + 3, i + 6);
const c = positions.slice(i + 6, i + 9);
const ux = b[0] - a[0];
const uy = b[1] - a[1];
const uz = b[2] - a[2];
const vx = c[0] - a[0];
const vy = c[1] - a[1];
const vz = c[2] - a[2];
const gx = uy * vz - uz * vy;
const gy = uz * vx - ux * vz;
const gz = ux * vy - uy * vx;
const area2 = Math.hypot(gx, gy, gz);
if (area2 <= (options.degenerateArea ?? DEFAULT_TOPOLOGY_OPTIONS.degenerateArea) * 2) {
degenerateTriangles++;
continue;
}
if (options.checkNormalDirection !== false && normals.length >= i + 9) {
const nx = normals[i] + normals[i + 3] + normals[i + 6];
const ny = normals[i + 1] + normals[i + 4] + normals[i + 7];
const nz = normals[i + 2] + normals[i + 5] + normals[i + 8];
if (Number.isFinite(nx + ny + nz) && gx * nx + gy * ny + gz * nz <= 0) opposedNormals++;
}
}
if (nonFinitePositions) issues.push(issue("non-finite-position", `${nonFinitePositions} emitted position values are non-finite`, { count: nonFinitePositions }));
if (badNormals) issues.push(issue("bad-normal", `${badNormals} emitted normals are non-finite, zero, or non-unit`, { count: badNormals }));
if (degenerateTriangles) issues.push(issue("degenerate-triangle", `${degenerateTriangles} emitted triangles have zero area`, { count: degenerateTriangles }));
if (opposedNormals) issues.push(issue("opposed-normal", `${opposedNormals} emitted triangles have normals opposed to their winding`, { count: opposedNormals }));
return { ok: issues.length === 0, issues, triangles: Math.floor(positions.length / 9) };
}
export function assertMeshAudit(report, label = "mesh") {
if (report.ok) return report;
const detail = report.issues.map((entry) => `${entry.code}: ${entry.message}`).join("\n- ");
throw new Error(`${label} failed geometry audit:\n- ${detail}`);
}
export { DEFAULT_TOPOLOGY_OPTIONS };
assets/geometry-quality-kit/procedural-mesh.js
/**
* Copyable polygon-first modeling kernel for Three.js.
*
* Keep quads and n-gons in MeshData while designing a part. Triangulate only
* in toTriangles()/toGeometry(), after solidify, subdivision, bevel, cleanup,
* winding repair, and smooth-angle selection. Author in Z-up coordinates;
* emission converts once to Three.js Y-up coordinates and reverses winding to
* preserve outward normals.
*
* All dimensions share the caller's unit. The craft constants below are in
* metres and should be scaled together when a project uses another unit.
*/
import { BufferAttribute, BufferGeometry, Group, Mesh, ShapeUtils, Vector2 } from "three";
const TAU = Math.PI * 2;
const BEVEL = {
/** hinges, hardware, casings, door leaves */
hardware: 2e-3,
/** shelves, slats, panel edges, drawer fronts */
panel: 4e-3,
/** plinths, carcasses, cornices, cast stone */
carcass: 7e-3,
/** frames, aprons, machine bodies, tops */
frame: 0.013,
/** upholstered arms and backs */
soft: 0.045
};
const SMOOTH = {
/** lathes, tubes, turned parts (the default) */
turned: 40,
/** moulded slabs, slats, extruded sections */
moulded: 34,
/** upholstery and shells */
shell: 45,
/** cast iron, cast mineral */
cast: 38,
/** buttons, tight rolls */
tight: 50,
/** table and counter tops */
top: 32
};
const PROUD_MIN = 8e-4;
const REVEAL_MIN = 15e-4;
const REVEAL_MAX = 6e-3;
const GAP_MIN = 4e-3;
class MeshData {
/** polygon vertices in world space (see the axis note in the file header) */
verts = [];
/** polygons — quads and n-gons, NOT triangles */
faces = [];
/** per-face-corner uvs, parallel to `faces` (or null for a face without) */
uvs = null;
/** per-vertex colour attribute (baked surface parameters / masks) */
colors = null;
colorName = "";
shading = { mode: "flat" };
/** per-face material slot index, resolved against a slot-name list at emit */
faceMat = null;
/** which authoring frame the vertices are in; emit converts 'z-up' -> Y-up */
frame = "z-up";
/** lets `bevel()` regenerate the part rounded instead of chamfering it */
provenance = null;
static from(verts, faces) {
const m = new MeshData();
m.verts = verts;
m.faces = faces;
return m;
}
clone() {
const m = new MeshData();
m.verts = this.verts.map((v) => [...v]);
m.faces = this.faces.map((f) => [...f]);
m.uvs = this.uvs ? this.uvs.map((u) => u ? u.map((p) => [...p]) : null) : null;
m.colors = this.colors ? this.colors.map((c) => [...c]) : null;
m.colorName = this.colorName;
m.shading = { ...this.shading };
m.faceMat = this.faceMat ? [...this.faceMat] : null;
m.frame = this.frame;
m.provenance = null;
return m;
}
}
function sub(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function cross(a, b) {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function len(a) {
return Math.hypot(a[0], a[1], a[2]);
}
function norm(a) {
const l = len(a) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
}
function faceNormal(verts, face) {
let nx = 0;
let ny = 0;
let nz = 0;
for (let i = 0; i < face.length; i++) {
const a = verts[face[i]];
const b = verts[face[(i + 1) % face.length]];
nx += (a[1] - b[1]) * (a[2] + b[2]);
ny += (a[2] - b[2]) * (a[0] + b[0]);
nz += (a[0] - b[0]) * (a[1] + b[1]);
}
return [nx, ny, nz];
}
function triangulateFace(verts, face) {
const n = face.length;
if (n === 3) return [[0, 1, 2]];
if (n === 4) {
const d02 = len(sub(verts[face[0]], verts[face[2]]));
const d13 = len(sub(verts[face[1]], verts[face[3]]));
return d02 <= d13 ? [
[0, 1, 2],
[0, 2, 3]
] : [
[1, 2, 3],
[1, 3, 0]
];
}
const nrm = norm(faceNormal(verts, face));
let u = Math.abs(nrm[0]) > 0.9 ? [0, 1, 0] : [1, 0, 0];
const w = norm(cross(nrm, u));
u = norm(cross(w, nrm));
const pts2 = face.map((vi) => {
const p = verts[vi];
return new Vector2(dot(p, u), dot(p, w));
});
const tris = ShapeUtils.triangulateShape(pts2, []);
if (tris.length === 0) {
const out = [];
for (let i = 1; i < n - 1; i++) out.push([0, i, i + 1]);
return out;
}
return tris;
}
function translate(m, d) {
for (const v of m.verts) {
v[0] += d[0];
v[1] += d[1];
v[2] += d[2];
}
m.provenance = null;
return m;
}
function rotateZ(m, ang, pivot = [0, 0]) {
const c = Math.cos(ang);
const s = Math.sin(ang);
for (const v of m.verts) {
const x = v[0] - pivot[0];
const y = v[1] - pivot[1];
v[0] = pivot[0] + x * c - y * s;
v[1] = pivot[1] + x * s + y * c;
}
m.provenance = null;
return m;
}
function rotX(m, ang, pivot = [0, 0, 0]) {
const c = Math.cos(ang);
const s = Math.sin(ang);
for (const v of m.verts) {
const y = v[1] - pivot[1];
const z = v[2] - pivot[2];
v[1] = pivot[1] + y * c - z * s;
v[2] = pivot[2] + y * s + z * c;
}
m.provenance = null;
return m;
}
function rotY(m, ang, pivot = [0, 0, 0]) {
const c = Math.cos(ang);
const s = Math.sin(ang);
for (const v of m.verts) {
const x = v[0] - pivot[0];
const z = v[2] - pivot[2];
v[0] = pivot[0] + x * c + z * s;
v[2] = pivot[2] - x * s + z * c;
}
m.provenance = null;
return m;
}
function scaleMesh(m, s, pivot = [0, 0, 0]) {
const sv = typeof s === "number" ? [s, s, s] : s;
for (const v of m.verts) {
v[0] = pivot[0] + (v[0] - pivot[0]) * sv[0];
v[1] = pivot[1] + (v[1] - pivot[1]) * sv[1];
v[2] = pivot[2] + (v[2] - pivot[2]) * sv[2];
}
m.provenance = null;
return m;
}
function transform4(m, M) {
for (const v of m.verts) {
const x = v[0];
const y = v[1];
const z = v[2];
v[0] = M[0][0] * x + M[0][1] * y + M[0][2] * z + M[0][3];
v[1] = M[1][0] * x + M[1][1] * y + M[1][2] * z + M[1][3];
v[2] = M[2][0] * x + M[2][1] * y + M[2][2] * z + M[2][3];
}
m.provenance = null;
return m;
}
function placeYaw(m, center, yaw) {
const c = Math.cos(yaw);
const s = Math.sin(yaw);
for (const v of m.verts) {
const x = v[0];
const z = v[2];
v[0] = center[0] + x * c + z * s;
v[1] = center[1] + v[1];
v[2] = center[2] - x * s + z * c;
}
m.provenance = null;
return m;
}
function join(parts) {
const list = parts.filter((p) => !!p);
const out = new MeshData();
if (list.length === 0) return out;
const anyUv = list.some((p) => p.uvs);
const anyCol = list.some((p) => p.colors);
const anyMat = list.some((p) => p.faceMat);
if (anyUv) out.uvs = [];
if (anyCol) out.colors = [];
if (anyMat) out.faceMat = [];
out.frame = list[0].frame;
const colorNames = new Set(list.filter((p) => p.colors).map((p) => p.colorName || "color"));
if (colorNames.size > 1) throw new Error(`join: colored parts use conflicting attribute names: ${[...colorNames].join(", ")}`);
if (colorNames.size === 1) out.colorName = [...colorNames][0];
let shading = { mode: "flat" };
for (const p of list) {
if (p.frame !== out.frame) throw new Error("join: parts must share an authoring frame");
const base = out.verts.length;
for (const v of p.verts) out.verts.push([...v]);
for (let fi = 0; fi < p.faces.length; fi++) {
out.faces.push(p.faces[fi].map((i) => i + base));
if (anyUv) out.uvs.push(p.uvs ? p.uvs[fi] ?? null : null);
if (anyMat) out.faceMat.push(p.faceMat ? p.faceMat[fi] : 0);
}
if (anyCol) {
const cols = p.colors ?? p.verts.map(() => [1, 1, 1]);
for (const c of cols) out.colors.push([...c]);
}
if (p.shading.mode === "smooth") shading = p.shading;
}
out.shading = shading;
return out;
}
function recalcNormals(m, flip = false) {
const edgeMap = /* @__PURE__ */ new Map();
const key = (a, b) => a < b ? a + "_" + b : b + "_" + a;
m.faces.forEach((f, fi) => {
for (let i = 0; i < f.length; i++) {
const a = f[i];
const b = f[(i + 1) % f.length];
if (a === b) continue;
const k = key(a, b);
let list = edgeMap.get(k);
if (!list) {
list = [];
edgeMap.set(k, list);
}
list.push({ face: fi, fwd: a < b });
}
});
const flipped = new Array(m.faces.length).fill(false);
const visited = new Array(m.faces.length).fill(false);
const comps = [];
for (let start = 0; start < m.faces.length; start++) {
if (visited[start]) continue;
const comp = [];
const stack = [start];
visited[start] = true;
while (stack.length) {
const fi = stack.pop();
comp.push(fi);
const f = m.faces[fi];
for (let i = 0; i < f.length; i++) {
const a = f[i];
const b = f[(i + 1) % f.length];
if (a === b) continue;
const pair = edgeMap.get(key(a, b));
if (pair.length !== 2) continue;
for (const o of pair) {
if (o.face === fi || visited[o.face]) continue;
const self = pair.find((p) => p.face === fi);
const selfFwd = self.fwd !== flipped[fi];
const otherFwd = o.fwd !== flipped[o.face];
if (selfFwd === otherFwd) flipped[o.face] = !flipped[o.face];
visited[o.face] = true;
stack.push(o.face);
}
}
}
comps.push(comp);
}
for (const comp of comps) {
let closed = true;
for (const fi of comp) {
const f = m.faces[fi];
for (let i = 0; i < f.length; i++) {
const a = f[i];
const b = f[(i + 1) % f.length];
if (a === b) continue;
if (edgeMap.get(key(a, b)).length !== 2) {
closed = false;
break;
}
}
if (!closed) break;
}
let volume = 0;
let keepScore = 0;
for (const fi of comp) {
const f = flipped[fi] ? [...m.faces[fi]].reverse() : m.faces[fi];
const tris = triangulateFace(m.verts, f);
for (const [i, j, k] of tris) {
volume += dot(m.verts[f[i]], cross(m.verts[f[j]], m.verts[f[k]]));
}
keepScore += flipped[fi] ? -1 : 1;
}
const flipComp = closed ? volume < 0 : keepScore < 0;
for (const fi of comp) {
let doFlip = flipped[fi];
if (flipComp) doFlip = !doFlip;
if (flip) doFlip = !doFlip;
if (doFlip) {
m.faces[fi].reverse();
if (m.uvs && m.uvs[fi]) m.uvs[fi].reverse();
}
}
}
return m;
}
function solidify(m, thickness) {
const half = thickness / 2;
const vnormals = m.verts.map(() => [0, 0, 0]);
m.faces.forEach((f) => {
const n = faceNormal(m.verts, f);
for (const vi of f) {
vnormals[vi][0] += n[0];
vnormals[vi][1] += n[1];
vnormals[vi][2] += n[2];
}
});
const nrm = vnormals.map((v) => norm(v));
const nv = m.verts.length;
const outer = m.verts.map((v, i) => [
v[0] + nrm[i][0] * half,
v[1] + nrm[i][1] * half,
v[2] + nrm[i][2] * half
]);
const inner = m.verts.map((v, i) => [
v[0] - nrm[i][0] * half,
v[1] - nrm[i][1] * half,
v[2] - nrm[i][2] * half
]);
const counts = /* @__PURE__ */ new Map();
const key = (a, b) => a < b ? a + "_" + b : b + "_" + a;
m.faces.forEach((f) => {
for (let i = 0; i < f.length; i++) {
const a = f[i];
const b = f[(i + 1) % f.length];
const k = key(a, b);
const e = counts.get(k);
if (e) e[2]++;
else counts.set(k, [a, b, 1]);
}
});
const faces = [];
for (const f of m.faces) faces.push([...f]);
for (const f of m.faces) faces.push([...f].reverse().map((i) => i + nv));
for (const [a, b, c] of counts.values()) {
if (c === 1) faces.push([b, a, a + nv, b + nv]);
}
m.verts = outer.concat(inner);
m.faces = faces;
m.uvs = null;
m.faceMat = null;
m.provenance = null;
recalcNormals(m);
return m;
}
function subsurf(m, levels = 1) {
for (let l = 0; l < levels; l++) ccOnce(m);
m.provenance = null;
return m;
}
function ccOnce(m) {
const nv = m.verts.length;
const facePts = [];
for (const f of m.faces) {
const p = [0, 0, 0];
for (const vi of f) {
p[0] += m.verts[vi][0];
p[1] += m.verts[vi][1];
p[2] += m.verts[vi][2];
}
facePts.push([p[0] / f.length, p[1] / f.length, p[2] / f.length]);
}
const edges = /* @__PURE__ */ new Map();
const key = (a, b) => a < b ? a + "_" + b : b + "_" + a;
m.faces.forEach((f, fi) => {
for (let i = 0; i < f.length; i++) {
const a = f[i];
const b = f[(i + 1) % f.length];
const k = key(a, b);
let e = edges.get(k);
if (!e) {
e = { a, b, faces: [], idx: -1 };
edges.set(k, e);
}
e.faces.push(fi);
}
});
const edgePts = [];
let ei = 0;
for (const e of edges.values()) {
e.idx = ei++;
const va = m.verts[e.a];
const vb = m.verts[e.b];
if (e.faces.length === 2) {
const fa = facePts[e.faces[0]];
const fb = facePts[e.faces[1]];
edgePts.push([
(va[0] + vb[0] + fa[0] + fb[0]) / 4,
(va[1] + vb[1] + fa[1] + fb[1]) / 4,
(va[2] + vb[2] + fa[2] + fb[2]) / 4
]);
} else {
edgePts.push([(va[0] + vb[0]) / 2, (va[1] + vb[1]) / 2, (va[2] + vb[2]) / 2]);
}
}
const vFaces = Array.from({ length: nv }, () => []);
m.faces.forEach((f, fi) => {
for (const vi of f) vFaces[vi].push(fi);
});
const vEdges = Array.from({ length: nv }, () => []);
for (const e of edges.values()) {
vEdges[e.a].push(e);
vEdges[e.b].push(e);
}
const newVerts = m.verts.map((v, vi) => {
const boundary = vEdges[vi].filter((e) => e.faces.length === 1);
if (boundary.length > 0) {
let sx = v[0] * 6;
let sy = v[1] * 6;
let sz = v[2] * 6;
let cnt = 6;
for (const e of boundary) {
const o = e.a === vi ? m.verts[e.b] : m.verts[e.a];
sx += o[0];
sy += o[1];
sz += o[2];
cnt += 1;
}
return [sx / cnt, sy / cnt, sz / cnt];
}
const nf = vFaces[vi].length;
if (nf === 0) return [...v];
const F = [0, 0, 0];
for (const fi of vFaces[vi]) {
F[0] += facePts[fi][0];
F[1] += facePts[fi][1];
F[2] += facePts[fi][2];
}
F[0] /= nf;
F[1] /= nf;
F[2] /= nf;
const R = [0, 0, 0];
const ne = vEdges[vi].length;
for (const e of vEdges[vi]) {
R[0] += (m.verts[e.a][0] + m.verts[e.b][0]) / 2;
R[1] += (m.verts[e.a][1] + m.verts[e.b][1]) / 2;
R[2] += (m.verts[e.a][2] + m.verts[e.b][2]) / 2;
}
R[0] /= ne;
R[1] /= ne;
R[2] /= ne;
const n = ne;
return [
(F[0] + 2 * R[0] + (n - 3) * v[0]) / n,
(F[1] + 2 * R[1] + (n - 3) * v[1]) / n,
(F[2] + 2 * R[2] + (n - 3) * v[2]) / n
];
});
const verts = [...newVerts, ...facePts, ...edgePts];
const faceBase = nv;
const edgeBase = nv + facePts.length;
const faces = [];
m.faces.forEach((f, fi) => {
const n = f.length;
for (let i = 0; i < n; i++) {
const v0 = f[i];
const ePrev = edges.get(key(f[(i - 1 + n) % n], v0));
const eNext = edges.get(key(v0, f[(i + 1) % n]));
faces.push([v0, edgeBase + eNext.idx, faceBase + fi, edgeBase + ePrev.idx]);
}
});
m.verts = verts;
m.faces = faces;
m.uvs = null;
m.colors = null;
m.faceMat = null;
}
function roundedRect(w, h, r, seg = 6) {
const rr = Math.min(r, Math.min(w, h) * 0.5 - 1e-6);
const hw = w * 0.5 - rr;
const hh = h * 0.5 - rr;
const pts = [];
const corners = [
[hw, hh, 0],
[-hw, hh, Math.PI * 0.5],
[-hw, -hh, Math.PI],
[hw, -hh, Math.PI * 1.5]
];
for (const [cx, cy, a0] of corners) {
for (let k = 0; k <= seg; k++) {
const a = a0 + Math.PI * 0.5 * (k / seg);
pts.push([cx + rr * Math.cos(a), cy + rr * Math.sin(a)]);
}
}
return pts;
}
function chamferRect(w, h, c) {
const cc = Math.min(c, Math.min(w, h) * 0.5 - 1e-6);
const hw = w * 0.5;
const hh = h * 0.5;
return [
[hw, hh - cc],
[hw - cc, hh],
[-hw + cc, hh],
[-hw, hh - cc],
[-hw, -hh + cc],
[-hw + cc, -hh],
[hw - cc, -hh],
[hw, -hh + cc]
];
}
function circle(r, seg = 32, cx = 0, cy = 0, phase = 0) {
return Array.from(
{ length: seg },
(_, i) => [cx + r * Math.cos(phase + TAU * i / seg), cy + r * Math.sin(phase + TAU * i / seg)]
);
}
function arcPts(cx, cy, r, a0, a1, n = 8, skipFirst = false) {
const out = [];
for (let i = skipFirst ? 1 : 0; i <= n; i++) {
const a = a0 + (a1 - a0) * i / n;
out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
}
return out;
}
function bez(p0, p1, p2, p3, n = 8, skipFirst = false) {
const out = [];
for (let i = skipFirst ? 1 : 0; i <= n; i++) {
const t = i / n;
const mt = 1 - t;
out.push([
mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0],
mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1]
]);
}
return out;
}
function polyArea(poly) {
let s = 0;
const n = poly.length;
for (let i = 0; i < n; i++) {
const [x0, y0] = poly[i];
const [x1, y1] = poly[(i + 1) % n];
s += x0 * y1 - x1 * y0;
}
return s * 0.5;
}
function ccw(poly) {
return polyArea(poly) > 0 ? [...poly] : [...poly].reverse();
}
function polyOffset(poly, d) {
const n = poly.length;
const out = [];
for (let i = 0; i < n; i++) {
const a = poly[(i - 1 + n) % n];
const b = poly[i];
const c = poly[(i + 1) % n];
let e0x = b[0] - a[0];
let e0y = b[1] - a[1];
let e1x = c[0] - b[0];
let e1y = c[1] - b[1];
const l0 = Math.hypot(e0x, e0y) || 1;
const l1 = Math.hypot(e1x, e1y) || 1;
e0x /= l0;
e0y /= l0;
e1x /= l1;
e1y /= l1;
const n0 = [e0y, -e0x];
const n1 = [e1y, -e1x];
let mx = n0[0] + n1[0];
let my = n0[1] + n1[1];
const ml = Math.hypot(mx, my);
if (ml < 1e-9) {
mx = n0[0];
my = n0[1];
} else {
mx /= ml;
my /= ml;
}
const scale = 1 / Math.max(0.25, mx * n0[0] + my * n0[1]);
out.push([b[0] + mx * d * scale, b[1] + my * d * scale]);
}
return out;
}
function insetPoly(poly, d) {
return polyOffset(poly, -d);
}
function offsetPolyline(pts, d, closed = false) {
const isect = (a0, u0, b0, u1) => {
const den = u0[0] * -u1[1] - u0[1] * -u1[0];
if (Math.abs(den) < 1e-9) return b0;
const wx = b0[0] - a0[0];
const wy = b0[1] - a0[1];
const tt = (wx * -u1[1] - wy * -u1[0]) / den;
return [a0[0] + u0[0] * tt, a0[1] + u0[1] * tt];
};
const P = pts;
const n = P.length;
const segs = [];
const m = closed ? n : n - 1;
for (let i = 0; i < m; i++) {
const a = P[i];
const b = P[(i + 1) % n];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const l = Math.hypot(dx, dy) || 1;
const u = [dx / l, dy / l];
const nn = [u[1], -u[0]];
segs.push([
[a[0] + nn[0] * d, a[1] + nn[1] * d],
[b[0] + nn[0] * d, b[1] + nn[1] * d],
u
]);
}
const out = [];
if (closed) {
for (let i = 0; i < n; i++) {
const sp = segs[(i - 1 + m) % m];
const sc = segs[i % m];
out.push(isect(sp[0], sp[2], sc[0], sc[2]));
}
} else {
out.push(segs[0][0]);
for (let i = 0; i < segs.length - 1; i++) out.push(isect(segs[i][0], segs[i][2], segs[i + 1][0], segs[i + 1][2]));
out.push(segs[segs.length - 1][1]);
}
return out;
}
function densify(pts, d = 0.05) {
const out = [[...pts[0]]];
for (let i = 1; i < pts.length - 1; i++) {
const a = pts[i - 1];
const b = pts[i];
const c = pts[i + 1];
const din = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const dout = [c[0] - b[0], c[1] - b[1], c[2] - b[2]];
const li = len(din);
const lo = len(dout);
if (li > 2.5 * d) out.push([b[0] - din[0] / li * d, b[1] - din[1] / li * d, b[2] - din[2] / li * d]);
out.push([...b]);
if (lo > 2.5 * d) out.push([b[0] + dout[0] / lo * d, b[1] + dout[1] / lo * d, b[2] + dout[2] / lo * d]);
}
out.push([...pts[pts.length - 1]]);
return out;
}
function meshObj(verts, faces) {
return MeshData.from(
verts.map((v) => [...v]),
faces.map((f) => [...f])
);
}
function box(x0, y0, z0, x1, y1, z1) {
const v = [
[x0, y0, z0],
[x1, y0, z0],
[x1, y1, z0],
[x0, y1, z0],
[x0, y0, z1],
[x1, y0, z1],
[x1, y1, z1],
[x0, y1, z1]
];
const f = [
[3, 2, 1, 0],
[4, 5, 6, 7],
[0, 1, 5, 4],
[1, 2, 6, 5],
[2, 3, 7, 6],
[3, 0, 4, 7]
];
const m = MeshData.from(v, f);
markBox(m, [x0, y0, z0, x1, y1, z1]);
return m;
}
function prism(poly, z0, z1, flip = false) {
const n = poly.length;
const verts = [];
for (const p of poly) verts.push([p[0], p[1], z0]);
for (const p of poly) verts.push([p[0], p[1], z1]);
const faces = [
Array.from({ length: n }, (_, i) => n - 1 - i),
Array.from({ length: n }, (_, i) => n + i)
];
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
faces.push([i, j, j + n, i + n]);
}
const m = MeshData.from(verts, faces);
recalcNormals(m, flip);
markPrism(m, poly, z0, z1);
return m;
}
function prismXZ(poly, y0, y1) {
const n = poly.length;
const verts = [];
for (const p of poly) verts.push([p[0], y0, p[1]]);
for (const p of poly) verts.push([p[0], y1, p[1]]);
const faces = [
Array.from({ length: n }, (_, i) => i),
Array.from({ length: n }, (_, i) => 2 * n - 1 - i)
];
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
faces.push([i, j, j + n, i + n]);
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
return m;
}
function prismYZ(poly, x0, x1) {
const n = poly.length;
const verts = [];
for (const p of poly) verts.push([x0, p[0], p[1]]);
for (const p of poly) verts.push([x1, p[0], p[1]]);
const faces = [
Array.from({ length: n }, (_, i) => i),
Array.from({ length: n }, (_, i) => 2 * n - 1 - i)
];
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
faces.push([i, j, j + n, i + n]);
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
return m;
}
function panelWithHoles(w, h, thickness, holes = []) {
const xsSet = /* @__PURE__ */ new Set([0, w]);
const zsSet = /* @__PURE__ */ new Set([0, h]);
for (const hh of holes) {
xsSet.add(hh[0]);
xsSet.add(hh[2]);
zsSet.add(hh[1]);
zsSet.add(hh[3]);
}
const xs = [...xsSet].sort((a, b) => a - b);
const zs = [...zsSet].sort((a, b) => a - b);
const nx = xs.length - 1;
const nz = zs.length - 1;
const solid = (i, j) => {
if (i < 0 || j < 0 || i >= nx || j >= nz) return false;
const cx0 = xs[i];
const cx1 = xs[i + 1];
const cz0 = zs[j];
const cz1 = zs[j + 1];
for (const [a, b, c, d] of holes) {
if (a - 1e-6 <= cx0 && cx1 <= c + 1e-6 && b - 1e-6 <= cz0 && cz1 <= d + 1e-6) return false;
}
return true;
};
const idx = /* @__PURE__ */ new Map();
const verts = [];
const vid = (i, j, side) => {
const k = `${i}_${j}_${side}`;
let r = idx.get(k);
if (r === void 0) {
r = verts.length;
verts.push([xs[i], side ? thickness : 0, zs[j]]);
idx.set(k, r);
}
return r;
};
const faces = [];
for (let i = 0; i < nx; i++) {
for (let j = 0; j < nz; j++) {
if (!solid(i, j)) continue;
const a = vid(i, j, 0);
const b = vid(i + 1, j, 0);
const c = vid(i + 1, j + 1, 0);
const d = vid(i, j + 1, 0);
faces.push([a, b, c, d]);
const a2 = vid(i, j, 1);
const b2 = vid(i + 1, j, 1);
const c2 = vid(i + 1, j + 1, 1);
const d2 = vid(i, j + 1, 1);
faces.push([d2, c2, b2, a2]);
if (!solid(i, j - 1)) faces.push([b, a, a2, b2]);
if (!solid(i, j + 1)) faces.push([d, c, c2, d2]);
if (!solid(i - 1, j)) faces.push([a, d, d2, a2]);
if (!solid(i + 1, j)) faces.push([c, b, b2, c2]);
}
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
return m;
}
function wallRun(p0, p1, t, z0, z1, holes = [], side = 1, cap0 = true, cap1 = true) {
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
const length = Math.hypot(dx, dy);
const ux = dx / length;
const uy = dy / length;
const nx = -uy * t * side;
const ny = ux * t * side;
const q = (v) => Math.round(v * 1e5) / 1e5;
const snapped = holes.map((hh) => hh.map(q));
const us = [.../* @__PURE__ */ new Set([q(0), q(length), ...snapped.flatMap((hh) => [hh[0], hh[1]])])].sort((a, b) => a - b);
const zs = [.../* @__PURE__ */ new Set([q(z0), q(z1), ...snapped.flatMap((hh) => [hh[2], hh[3]])])].sort((a, b) => a - b);
const nu = us.length - 1;
const nz = zs.length - 1;
const solid = (i, j) => {
if (i < 0 || j < 0 || i >= nu || j >= nz) return false;
const u0 = us[i];
const u1 = us[i + 1];
const c0 = zs[j];
const c1 = zs[j + 1];
for (const [a, b, e, f] of snapped) {
if (a - 1e-6 <= u0 && u1 <= b + 1e-6 && e - 1e-6 <= c0 && c1 <= f + 1e-6) return false;
}
return true;
};
const verts = [];
const idx = /* @__PURE__ */ new Map();
const vid = (i, j, s) => {
const k = `${i}_${j}_${s}`;
let r = idx.get(k);
if (r === void 0) {
r = verts.length;
verts.push([p0[0] + ux * us[i] + (s ? nx : 0), p0[1] + uy * us[i] + (s ? ny : 0), zs[j]]);
idx.set(k, r);
}
return r;
};
const faces = [];
for (let i = 0; i < nu; i++) {
for (let j = 0; j < nz; j++) {
if (!solid(i, j)) continue;
const a = vid(i, j, 0);
const b = vid(i + 1, j, 0);
const c = vid(i + 1, j + 1, 0);
const e = vid(i, j + 1, 0);
faces.push([a, b, c, e]);
const a2 = vid(i, j, 1);
const b2 = vid(i + 1, j, 1);
const c2 = vid(i + 1, j + 1, 1);
const e2 = vid(i, j + 1, 1);
faces.push([e2, c2, b2, a2]);
if (!solid(i, j - 1)) faces.push([b, a, a2, b2]);
if (!solid(i, j + 1)) faces.push([e, c, c2, e2]);
if (!solid(i - 1, j) && (i > 0 || cap0)) faces.push([a, e, e2, a2]);
if (!solid(i + 1, j) && (i < nu - 1 || cap1)) faces.push([c, b, b2, c2]);
}
}
const md = MeshData.from(verts, faces);
recalcNormals(md);
return md;
}
function loft(rings, opts = {}) {
const { closeU = false, closeV = false, weldPoles = false, capStart = false, capEnd = false } = opts;
const nu = rings.length;
const nv = rings[0].length;
const verts = [];
const grid = [];
let poleA = -1;
let poleB = -1;
for (let i = 0; i < nu; i++) {
const row = [];
for (let j = 0; j < nv; j++) {
const p = rings[i][j];
if (weldPoles && j === 0 && Math.abs(p[0]) < 1e-9 && Math.abs(p[1]) < 1e-9) {
if (poleA < 0) {
poleA = verts.length;
verts.push([...p]);
}
row.push(poleA);
continue;
}
if (weldPoles && j === nv - 1 && Math.abs(p[0]) < 1e-9 && Math.abs(p[1]) < 1e-9) {
if (poleB < 0) {
poleB = verts.length;
verts.push([...p]);
}
row.push(poleB);
continue;
}
row.push(verts.length);
verts.push([...p]);
}
grid.push(row);
}
const faces = [];
const ulim = closeU ? nu : nu - 1;
const vlim = closeV ? nv : nv - 1;
for (let i = 0; i < ulim; i++) {
const i2 = (i + 1) % nu;
for (let j = 0; j < vlim; j++) {
const j2 = (j + 1) % nv;
const q = [grid[i][j], grid[i2][j], grid[i2][j2], grid[i][j2]];
const uq = [];
for (const k of q) if (!uq.includes(k)) uq.push(k);
if (uq.length >= 3) faces.push(uq);
}
}
if (closeU && !closeV) {
if (capStart && grid[0][0] !== grid[1][0]) faces.push(Array.from({ length: nu }, (_, k) => grid[nu - 1 - k][0]));
if (capEnd && grid[0][nv - 1] !== grid[1][nv - 1]) faces.push(Array.from({ length: nu }, (_, k) => grid[k][nv - 1]));
} else if (closeV && !closeU) {
if (capStart) faces.push(Array.from({ length: nv }, (_, j) => grid[0][nv - 1 - j]));
if (capEnd) faces.push(Array.from({ length: nv }, (_, j) => grid[nu - 1][j]));
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
return m;
}
function revolve(profile, segments = 32, opts = {}) {
const { arc = TAU, capStart = true, capEnd = true, axis = "z", smooth = SMOOTH.turned } = opts;
const close = opts.close ?? Math.abs(arc - TAU) < 1e-6;
const nseg = close ? segments : segments + 1;
const rings = [];
for (let s = 0; s < nseg; s++) {
const a = arc * (s / segments);
const ca = Math.cos(a);
const sa = Math.sin(a);
rings.push(profile.map(([r, z]) => [r * ca, r * sa, z]));
}
const m = loft(rings, { closeU: close, weldPoles: true, capStart, capEnd });
if (axis === "x") rotY(m, Math.PI / 2);
else if (axis === "y") rotX(m, -Math.PI / 2);
smoothShade(m, smooth);
return m;
}
function tubeAlong(path, profile, opts = {}) {
const { closePath = false, up = [0, 0, 1], cap = true, miter = false, roll, scale } = opts;
const P = path;
const n = P.length;
const tangents = [];
for (let i = 0; i < n; i++) {
let t;
if (i === 0) {
t = closePath ? [P[1][0] - P[n - 1][0], P[1][1] - P[n - 1][1], P[1][2] - P[n - 1][2]] : [P[1][0] - P[0][0], P[1][1] - P[0][1], P[1][2] - P[0][2]];
} else if (i === n - 1) {
t = closePath ? [P[0][0] - P[n - 2][0], P[0][1] - P[n - 2][1], P[0][2] - P[n - 2][2]] : [P[n - 1][0] - P[n - 2][0], P[n - 1][1] - P[n - 2][1], P[n - 1][2] - P[n - 2][2]];
} else {
t = [P[i + 1][0] - P[i - 1][0], P[i + 1][1] - P[i - 1][1], P[i + 1][2] - P[i - 1][2]];
}
const tl = len(t) || 1;
tangents.push([t[0] / tl, t[1] / tl, t[2] / tl]);
}
const sides = [];
{
let u = [...up];
if (Math.abs(dot(tangents[0], u)) > 0.999) u = [1, 0, 0];
sides.push(norm(cross(tangents[0], u)));
}
const reflect = (v, mirror) => {
const c = dot(mirror, mirror);
if (c < 1e-12) return v;
const k = 2 / c * dot(mirror, v);
return [v[0] - mirror[0] * k, v[1] - mirror[1] * k, v[2] - mirror[2] * k];
};
for (let i = 1; i < n; i++) {
const v1 = [P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1], P[i][2] - P[i - 1][2]];
const sL = reflect(sides[i - 1], v1);
const tL = reflect(tangents[i - 1], v1);
const v2 = [tangents[i][0] - tL[0], tangents[i][1] - tL[1], tangents[i][2] - tL[2]];
sides.push(norm(reflect(sL, v2)));
}
const twist = new Array(n).fill(0);
if (closePath && n > 2) {
const v1 = [P[0][0] - P[n - 1][0], P[0][1] - P[n - 1][1], P[0][2] - P[n - 1][2]];
const sL = reflect(sides[n - 1], v1);
const tL = reflect(tangents[n - 1], v1);
const v2 = [tangents[0][0] - tL[0], tangents[0][1] - tL[1], tangents[0][2] - tL[2]];
const sWrap = norm(reflect(sL, v2));
const uRef = norm(cross(sides[0], tangents[0]));
const angle = Math.atan2(dot(sWrap, uRef), dot(sWrap, sides[0]));
for (let i = 0; i < n; i++) twist[i] = -angle * (i / n);
}
const rings = [];
for (let i = 0; i < n; i++) {
const t = tangents[i];
let s = sides[i];
let u2 = norm(cross(s, t));
const rl = (typeof roll === "number" ? roll : roll ? roll[Math.min(i, roll.length - 1)] : 0) + twist[i];
if (rl) {
const c = Math.cos(rl);
const sn = Math.sin(rl);
const s2 = [s[0] * c + u2[0] * sn, s[1] * c + u2[1] * sn, s[2] * c + u2[2] * sn];
u2 = [u2[0] * c - s[0] * sn, u2[1] * c - s[1] * sn, u2[2] * c - s[2] * sn];
s = s2;
}
let ka = 1;
let kb = 1;
if (scale) {
const sc = scale[Math.min(i, scale.length - 1)];
ka = sc[0];
kb = sc[1];
}
if (miter && (closePath || i > 0 && i < n - 1)) {
const p = P[(i - 1 + n) % n];
const e = [P[i][0] - p[0], P[i][1] - p[1], P[i][2] - p[2]];
const el = len(e);
if (el > 1e-9) ka /= Math.max(0.4, (e[0] * t[0] + e[1] * t[1] + e[2] * t[2]) / el);
}
rings.push(
profile.map(
([a, b]) => [
P[i][0] + s[0] * a * ka + u2[0] * b * kb,
P[i][1] + s[1] * a * ka + u2[1] * b * kb,
P[i][2] + s[2] * a * ka + u2[2] * b * kb
]
)
);
}
return loft(rings, {
closeU: closePath,
closeV: true,
capStart: cap && !closePath,
capEnd: cap && !closePath
});
}
function sweepRectFrame(w, h, profile) {
const hw = w * 0.5;
const hh = h * 0.5;
const corners = [
[-hw, -hh],
[hw, -hh],
[hw, hh],
[-hw, hh]
];
const dirs = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1]
];
const rings = [];
for (let k = 0; k < 4; k++) {
const [cx, cz] = corners[k];
const [sx, sz] = dirs[k];
rings.push(profile.map(([a, b]) => [cx + sx * a, b, cz + sz * a]));
}
return loft(rings, { closeU: true, closeV: true });
}
function sweepPlanarLoop(path, profile, close = true) {
const n = path.length;
if (n < (close ? 3 : 2)) throw new RangeError(`sweepPlanarLoop: ${close ? "closed" : "open"} path needs at least ${close ? 3 : 2} points`);
const rings = [];
for (let i = 0; i < n; i++) {
const a = !close && i === 0 ? path[0] : path[(i - 1 + n) % n];
const b = !close && i === n - 1 ? path[n - 1] : path[(i + 1) % n];
let tx = b[0] - a[0];
let ty = b[1] - a[1];
const tl = Math.hypot(tx, ty) || 1;
tx /= tl;
ty /= tl;
const nx = ty;
const ny = -tx;
rings.push(profile.map(([aa, bb]) => [path[i][0] + nx * aa, path[i][1] + ny * aa, bb]));
}
return loft(rings, { closeU: close, closeV: true });
}
function runMolding(path, profile, cap = true, closed = false) {
const offs = /* @__PURE__ */ new Map();
for (const [, d] of profile) {
if (!offs.has(d)) offs.set(d, offsetPolyline(path, d, closed));
}
const rings = [];
for (let i = 0; i < path.length; i++) {
rings.push(
profile.map(([z, d]) => {
const p = offs.get(d)[i];
return [p[0], p[1], z];
})
);
}
return loft(rings, {
closeU: closed,
closeV: true,
capStart: cap && !closed,
capEnd: cap && !closed
});
}
function roundedBoxMesh(bounds, radius, segments) {
const [x0, y0, z0, x1, y1, z1] = bounds;
const h = [(x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2];
const c = [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2];
const r = Math.min(radius, Math.min(h[0], h[1], h[2]) * 0.999);
const s = Math.max(1, segments);
const knots = (hh) => {
const inner2 = hh - r;
const out = [];
for (let k = s; k >= 0; k--) out.push(-(inner2 + r * Math.tan(Math.PI / 4 * (k / s))));
for (let k = 0; k <= s; k++) out.push(inner2 + r * Math.tan(Math.PI / 4 * (k / s)));
return out;
};
const ax = [knots(h[0]), knots(h[1]), knots(h[2])];
const inner = [Math.max(h[0] - r, 0), Math.max(h[1] - r, 0), Math.max(h[2] - r, 0)];
const project = (p) => {
const q = [
Math.min(Math.max(p[0], -inner[0]), inner[0]),
Math.min(Math.max(p[1], -inner[1]), inner[1]),
Math.min(Math.max(p[2], -inner[2]), inner[2])
];
const d = sub(p, q);
const l = len(d);
if (l < 1e-12) return p;
return [q[0] + d[0] / l * r, q[1] + d[1] / l * r, q[2] + d[2] / l * r];
};
const vmap = /* @__PURE__ */ new Map();
const verts = [];
const faces = [];
const vid = (p) => {
const k = p.map((x) => Math.round(x * 1e7)).join("_");
let idx = vmap.get(k);
if (idx === void 0) {
idx = verts.length;
verts.push([p[0] + c[0], p[1] + c[1], p[2] + c[2]]);
vmap.set(k, idx);
}
return idx;
};
const AXES = [
[0, 1, 2],
[1, 2, 0],
[2, 0, 1]
];
for (const [a, u, v] of AXES) {
for (const sign of [-1, 1]) {
const gu = ax[u];
const gv = ax[v];
for (let i = 0; i < gu.length - 1; i++) {
for (let j = 0; j < gv.length - 1; j++) {
const mk = (uu, vv) => {
const p = [0, 0, 0];
p[a] = sign * h[a];
p[u] = uu;
p[v] = vv;
return vid(project(p));
};
const q = [mk(gu[i], gv[j]), mk(gu[i + 1], gv[j]), mk(gu[i + 1], gv[j + 1]), mk(gu[i], gv[j + 1])];
if (sign < 0) q.reverse();
const uq = q.filter((x, k) => q.indexOf(x) === k);
if (uq.length >= 3) faces.push(uq);
}
}
}
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
m.shading = { mode: "smooth", angle: SMOOTH.turned };
return m;
}
function beveledPrismMesh(poly, z0, z1, r, segments) {
const s = Math.max(1, segments);
const rr = Math.min(r, (z1 - z0) / 2 - 1e-5);
const rings = [];
const ringAt = (inset, z) => insetPoly(poly, inset).map((p) => [p[0], p[1], z]);
for (let k = 0; k <= s; k++) {
const a = Math.PI / 2 * (k / s);
rings.push(ringAt(rr * (1 - Math.sin(a)), z0 + rr * (1 - Math.cos(a))));
}
for (let k = 0; k <= s; k++) {
const a = Math.PI / 2 * (k / s);
rings.push(ringAt(rr * (1 - Math.cos(a)), z1 - rr * (1 - Math.sin(a))));
}
const n = poly.length;
const verts = [];
const faces = [];
for (const ring of rings) for (const p of ring) verts.push(p);
const nr = rings.length;
for (let i = 0; i < nr - 1; i++) {
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([i * n + j, i * n + j2, (i + 1) * n + j2, (i + 1) * n + j]);
}
}
faces.push(Array.from({ length: n }, (_, j) => n - 1 - j));
faces.push(Array.from({ length: n }, (_, j) => (nr - 1) * n + j));
const m = MeshData.from(verts, faces);
recalcNormals(m);
m.shading = { mode: "smooth", angle: SMOOTH.turned };
return m;
}
function bevel(m, amount = BEVEL.panel, segments = 2) {
if (m.provenance?.kind === "box") {
const nm = roundedBoxMesh(m.provenance.bounds, amount, segments);
m.verts = nm.verts;
m.faces = nm.faces;
m.shading = nm.shading;
m.provenance = null;
} else if (m.provenance?.kind === "prism" && amount >= 5e-4) {
const p = m.provenance;
const nm = beveledPrismMesh(p.poly, p.z0, p.z1, amount, segments);
m.verts = nm.verts;
m.faces = nm.faces;
m.shading = nm.shading;
m.provenance = null;
}
return m;
}
function markBox(m, bounds) {
m.provenance = { kind: "box", bounds };
return m;
}
function markPrism(m, poly, z0, z1) {
m.provenance = { kind: "prism", poly: poly.map((p) => [...p]), z0, z1 };
return m;
}
function roundedBox(bounds, radius = BEVEL.panel, segments = 2) {
return roundedBoxMesh(bounds, radius, segments);
}
function plate(cx, cy, w, d, z0, t, radius = BEVEL.panel, segments = 2) {
return roundedBoxMesh([cx - w / 2, cy - d / 2, z0, cx + w / 2, cy + d / 2, z0 + t], radius, segments);
}
function hollowPrism(outerPoly, z0, z1, innerPoly, cavityZ, rimBevel = 0.012) {
const n = outerPoly.length;
if (innerPoly.length !== n) throw new Error("hollowPrism: outline vertex counts must match");
const verts = [];
const rings = [];
const push = (poly, z) => rings.push(poly.map((p) => [p[0], p[1], z]));
push(outerPoly, z0);
push(outerPoly, z1 - rimBevel);
push(insetPoly(outerPoly, rimBevel), z1);
push(insetPoly(innerPoly, -rimBevel), z1);
push(innerPoly, z1 - rimBevel);
push(innerPoly, cavityZ + rimBevel);
push(insetPoly(innerPoly, rimBevel), cavityZ);
const nr = rings.length;
for (const ring of rings) for (const p of ring) verts.push(p);
const faces = [];
for (let i = 0; i < nr - 1; i++) {
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([i * n + j, i * n + j2, (i + 1) * n + j2, (i + 1) * n + j]);
}
}
faces.push(Array.from({ length: n }, (_, j) => n - 1 - j));
faces.push(Array.from({ length: n }, (_, j) => (nr - 1) * n + j));
const m = MeshData.from(verts, faces);
recalcNormals(m);
smoothShade(m, SMOOTH.turned);
return m;
}
function annularPrism(outerPoly, innerPoly, z0, z1, bevelRadius = 0, bevelSegments = 1) {
const n = outerPoly.length;
if (innerPoly.length !== n) throw new Error("annularPrism: outline vertex counts must match");
const radius = Math.max(0, Math.min(bevelRadius, (z1 - z0) / 2 - 1e-5));
const segments = Math.max(1, bevelSegments);
const levels = [];
if (radius > 0) {
for (let k = 0; k <= segments; k++) {
const a = Math.PI / 2 * (k / segments);
levels.push({ inset: radius * (1 - Math.sin(a)), z: z0 + radius * (1 - Math.cos(a)) });
}
for (let k = 0; k <= segments; k++) {
const a = Math.PI / 2 * (k / segments);
levels.push({ inset: radius * (1 - Math.cos(a)), z: z1 - radius * (1 - Math.sin(a)) });
}
} else {
levels.push({ inset: 0, z: z0 }, { inset: 0, z: z1 });
}
const verts = [];
for (const level of levels) {
for (const [x, y] of insetPoly(outerPoly, level.inset)) verts.push([x, y, level.z]);
for (const [x, y] of insetPoly(innerPoly, -level.inset)) verts.push([x, y, level.z]);
}
const faces = [];
const stride = n * 2;
for (let level = 0; level < levels.length - 1; level++) {
const a = level * stride;
const b = (level + 1) * stride;
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([a + j, a + j2, b + j2, b + j]);
faces.push([a + n + j2, a + n + j, b + n + j, b + n + j2]);
}
}
const bottom = 0;
const top = (levels.length - 1) * stride;
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([bottom + j2, bottom + j, bottom + n + j, bottom + n + j2]);
faces.push([top + j, top + j2, top + n + j2, top + n + j]);
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
smoothShade(m, SMOOTH.turned);
return m;
}
function aperturedPrism(outerPoly, innerPoly, z0, z1, outerBevel = 0, bevelSegments = 1) {
const n = outerPoly.length;
if (innerPoly.length !== n) throw new Error("aperturedPrism: outline vertex counts must match");
const radius = Math.max(0, Math.min(outerBevel, (z1 - z0) / 2 - 1e-5));
const segments = Math.max(1, bevelSegments);
const outerLevels = [];
if (radius > 0) {
for (let k = 0; k <= segments; k++) {
const a = Math.PI / 2 * (k / segments);
outerLevels.push({ inset: radius * (1 - Math.sin(a)), z: z0 + radius * (1 - Math.cos(a)) });
}
for (let k = 0; k <= segments; k++) {
const a = Math.PI / 2 * (k / segments);
outerLevels.push({ inset: radius * (1 - Math.cos(a)), z: z1 - radius * (1 - Math.sin(a)) });
}
} else {
outerLevels.push({ inset: 0, z: z0 }, { inset: 0, z: z1 });
}
const verts = [];
for (const level of outerLevels) {
for (const [x, y] of insetPoly(outerPoly, level.inset)) verts.push([x, y, level.z]);
}
const innerBottom = verts.length;
for (const [x, y] of innerPoly) verts.push([x, y, z0]);
const innerTop = verts.length;
for (const [x, y] of innerPoly) verts.push([x, y, z1]);
const faces = [];
for (let level = 0; level < outerLevels.length - 1; level++) {
const a = level * n;
const b = (level + 1) * n;
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([a + j, a + j2, b + j2, b + j]);
}
}
const outerBottom = 0;
const outerTop = (outerLevels.length - 1) * n;
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([innerBottom + j2, innerBottom + j, innerTop + j, innerTop + j2]);
faces.push([outerBottom + j2, outerBottom + j, innerBottom + j, innerBottom + j2]);
faces.push([outerTop + j, outerTop + j2, innerTop + j2, innerTop + j]);
}
const m = MeshData.from(verts, faces);
recalcNormals(m);
if (radius > 0) smoothShade(m, SMOOTH.turned);
return m;
}
function smoothShade(m, angle = SMOOTH.top) {
m.shading = { mode: "smooth", angle };
return m;
}
function flatShade(m) {
m.shading = { mode: "flat" };
return m;
}
function cleanMesh(m, dist = 2e-5) {
if (!Number.isFinite(dist) || dist <= 0) throw new RangeError("cleanMesh: weld distance must be finite and greater than zero");
const remap = new Array(m.verts.length);
const grid = /* @__PURE__ */ new Map();
const verts = [];
const colors = m.colors ? [] : null;
const key = (x, y, z) => `${x},${y},${z}`;
const dist2 = dist * dist;
for (let i = 0; i < m.verts.length; i++) {
const v = m.verts[i];
const gx = Math.floor(v[0] / dist);
const gy = Math.floor(v[1] / dist);
const gz = Math.floor(v[2] / dist);
let idx;
search: for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
for (let dz = -1; dz <= 1; dz++) {
const bucket = grid.get(key(gx + dx, gy + dy, gz + dz));
if (!bucket) continue;
for (const candidate of bucket) {
const u = verts[candidate];
const x = v[0] - u[0];
const y = v[1] - u[1];
const z = v[2] - u[2];
if (x * x + y * y + z * z <= dist2) {
idx = candidate;
break search;
}
}
}
}
}
if (idx === void 0) {
idx = verts.length;
verts.push(v);
if (colors && m.colors) colors.push(m.colors[i]);
const own = key(gx, gy, gz);
const bucket = grid.get(own);
if (bucket) bucket.push(idx);
else grid.set(own, [idx]);
}
remap[i] = idx;
}
const faces = [];
const uvs = m.uvs ? [] : null;
const faceMat = m.faceMat ? [] : null;
m.faces.forEach((face, fi) => {
const ff = [];
const fuv = [];
face.forEach((vi, c) => {
const mi = remap[vi];
if (!ff.includes(mi)) {
ff.push(mi);
const src = m.uvs?.[fi];
if (src) fuv.push(src[c]);
}
});
if (ff.length < 3) return;
if (len(faceNormal(verts, ff)) < 1e-12) return;
faces.push(ff);
if (uvs) uvs.push(m.uvs?.[fi] ? fuv : null);
if (faceMat && m.faceMat) faceMat.push(m.faceMat[fi]);
});
m.verts = verts;
m.faces = faces;
m.uvs = uvs;
m.colors = colors;
m.faceMat = faceMat;
m.provenance = null;
return m;
}
function toYUp(m) {
if (m.frame === "y-up") return m;
for (const v of m.verts) {
const y = v[1];
v[1] = v[2];
v[2] = y;
}
for (let i = 0; i < m.faces.length; i++) {
m.faces[i].reverse();
if (m.uvs?.[i]) m.uvs[i].reverse();
}
m.frame = "y-up";
m.provenance = null;
return m;
}
function toTriangles(m, uvScale = 1) {
const fNormals = m.faces.map((f) => norm(faceNormal(m.verts, f)));
const smooth = m.shading.mode === "smooth";
const cosLimit = smooth ? Math.cos(m.shading.angle * Math.PI / 180) : 2;
const vFaces = Array.from({ length: m.verts.length }, () => []);
if (smooth) {
m.faces.forEach((f, fi) => {
for (const vi of f) vFaces[vi].push(fi);
});
}
const cornerNormal = (vi, fi) => {
if (!smooth) return fNormals[fi];
const fn = fNormals[fi];
let nx = 0;
let ny = 0;
let nz = 0;
for (const ofi of vFaces[vi]) {
const on = fNormals[ofi];
if (dot(fn, on) >= cosLimit - 1e-9) {
nx += on[0];
ny += on[1];
nz += on[2];
}
}
const l = Math.hypot(nx, ny, nz);
if (l < 1e-9) return fn;
return [nx / l, ny / l, nz / l];
};
const flip = m.frame === "z-up";
const positions = [];
const normals = [];
const uvs = [];
const cols = m.colors ? [] : null;
const triMat = m.faceMat ? [] : null;
const hasUv = !!m.uvs;
m.faces.forEach((f, fi) => {
const tris = triangulateFace(m.verts, f);
const uvFace = hasUv ? m.uvs[fi] : null;
const fn = fNormals[fi];
const wn = flip ? [fn[0], fn[2], fn[1]] : fn;
const ax = Math.abs(wn[0]);
const ay = Math.abs(wn[1]);
const az = Math.abs(wn[2]);
const dominant = ay >= ax && ay >= az ? 1 : ax >= az ? 0 : 2;
for (const t of tris) {
const order = flip ? [t[2], t[1], t[0]] : t;
for (const ci of order) {
const vi = f[ci];
const p = m.verts[vi];
const n = cornerNormal(vi, fi);
const px = p[0];
const py = flip ? p[2] : p[1];
const pz = flip ? p[1] : p[2];
positions.push(px, py, pz);
normals.push(n[0], flip ? n[2] : n[1], flip ? n[1] : n[2]);
if (uvFace) uvs.push(uvFace[ci][0], uvFace[ci][1]);
else if (dominant === 1) uvs.push(px * uvScale, pz * uvScale);
else if (dominant === 0) uvs.push(pz * uvScale, py * uvScale);
else uvs.push(px * uvScale, py * uvScale);
if (cols && m.colors) {
const c = m.colors[vi];
cols.push(c[0], c[1], c[2]);
}
}
if (triMat && m.faceMat) triMat.push(m.faceMat[fi]);
}
});
return { positions, normals, uvs, colors: cols, triMat };
}
function toGeometry(m, uvScale = 1) {
const soup = toTriangles(m, uvScale);
const g = new BufferGeometry();
g.setAttribute("position", new BufferAttribute(new Float32Array(soup.positions), 3));
g.setAttribute("normal", new BufferAttribute(new Float32Array(soup.normals), 3));
g.setAttribute("uv", new BufferAttribute(new Float32Array(soup.uvs), 2));
if (soup.colors) {
g.setAttribute(m.colorName || "color", new BufferAttribute(new Float32Array(soup.colors), 3));
}
if (soup.triMat?.length) {
let startTriangle = 0;
let materialIndex = soup.triMat[0];
for (let triangle = 1; triangle <= soup.triMat.length; triangle++) {
const next = soup.triMat[triangle];
if (triangle < soup.triMat.length && next === materialIndex) continue;
g.addGroup(startTriangle * 3, (triangle - startTriangle) * 3, materialIndex);
startTriangle = triangle;
materialIndex = next;
}
}
return g;
}
function slotList(parts) {
const out = [];
for (const [slot, value] of Object.entries(parts)) {
if (!value) continue;
const list = Array.isArray(value) ? value.filter((m) => !!m) : [value];
if (list.length) out.push([slot, list]);
}
return out;
}
function writeInto(writer, slot, part, opts = {}) {
const m = opts.clean === false ? part : cleanMesh(part);
const soup = toTriangles(m, opts.uvScale ?? 1);
if (soup.positions.length === 0) return;
writer.raw(slot, soup.positions, soup.normals, soup.uvs);
}
function writeAll(writer, parts, opts = {}) {
for (const [slot, list] of slotList(parts)) {
for (const part of list) writeInto(writer, slot, part, opts);
}
}
function buildGroup(parts, materials, opts = {}) {
const group = new Group();
if (opts.name) group.name = opts.name;
for (const [slot, list] of slotList(parts)) {
const material = materials[slot];
if (!material) throw new Error(`meshdata: no material bound for slot "${slot}"`);
const positions = [];
const normals = [];
const uvs = [];
const anyColors = list.some((part) => !!part.colors);
const colorNames = new Set(list.filter((part) => part.colors).map((part) => part.colorName || "color"));
if (colorNames.size > 1) throw new Error(`meshdata: slot "${slot}" uses conflicting color attribute names: ${[...colorNames].join(", ")}`);
const colors = anyColors ? [] : null;
for (const part of list) {
const m = opts.clean === false ? part : cleanMesh(part);
const soup = toTriangles(m, opts.uvScale ?? 1);
for (const v of soup.positions) positions.push(v);
for (const v of soup.normals) normals.push(v);
for (const v of soup.uvs) uvs.push(v);
if (colors) {
if (soup.colors) for (const value of soup.colors) colors.push(value);
else for (let i = 0; i < soup.positions.length; i += 3) colors.push(1, 1, 1);
}
}
if (positions.length === 0) continue;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3));
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(uvs), 2));
if (colors) geometry.setAttribute([...colorNames][0] ?? "color", new BufferAttribute(new Float32Array(colors), 3));
const mesh = new Mesh(geometry, material);
mesh.castShadow = opts.castShadow ?? true;
mesh.receiveShadow = opts.receiveShadow ?? true;
mesh.name = `part:${slot}`;
group.add(mesh);
}
return group;
}
export {
BEVEL,
GAP_MIN,
MeshData,
PROUD_MIN,
REVEAL_MAX,
REVEAL_MIN,
SMOOTH,
TAU,
annularPrism,
aperturedPrism,
arcPts,
bevel,
beveledPrismMesh,
bez,
box,
buildGroup,
ccw,
chamferRect,
circle,
cleanMesh,
densify,
faceNormal,
flatShade,
hollowPrism,
insetPoly,
join,
loft,
markBox,
markPrism,
meshObj,
offsetPolyline,
panelWithHoles,
placeYaw,
plate,
polyArea,
polyOffset,
prism,
prismXZ,
prismYZ,
recalcNormals,
revolve,
rotX,
rotY,
rotateZ,
roundedBox,
roundedBoxMesh,
roundedRect,
runMolding,
scaleMesh,
smoothShade,
solidify,
subsurf,
sweepPlanarLoop,
sweepRectFrame,
toGeometry,
toTriangles,
toYUp,
transform4,
translate,
tubeAlong,
wallRun,
writeAll,
writeInto
};
assets/geometry-quality-kit/selftest.js
/** Run after copying this folder: `node selftest.js`. */
import {
BufferAttribute,
BufferGeometry,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
} from "three";
import {
annularPrism,
aperturedPrism,
arcPts,
bevel,
beveledPrismMesh,
bez,
box,
ccw,
chamferRect,
circle,
cleanMesh,
densify,
faceNormal,
flatShade,
hollowPrism,
insetPoly,
join,
loft,
meshObj,
offsetPolyline,
panelWithHoles,
placeYaw,
plate,
polyArea,
polyOffset,
prism,
prismXZ,
prismYZ,
recalcNormals,
revolve,
rotX,
rotY,
rotateZ,
roundedBox,
roundedBoxMesh,
roundedRect,
runMolding,
scaleMesh,
smoothShade,
solidify,
subsurf,
sweepPlanarLoop,
sweepRectFrame,
toGeometry,
toTriangles,
toYUp,
transform4,
translate,
tubeAlong,
wallRun,
writeAll,
writeInto,
} from "./procedural-mesh.js";
import { auditGeometry } from "./geometry-audit.js";
import { auditMeshData, auditTriangleSoup } from "./mesh-topology-audit.js";
import {
assertGeometryContract,
boundsContains,
boundsGapAlongAxis,
boundsIntersectionDepth,
maximumCheck,
minimumCheck,
nearCheck,
predicateCheck,
rangeCheck,
runGeometryContract,
} from "./geometry-contract.js";
import {
buildMergedAssembly,
buildNamedAuditGroup,
disposeHierarchy,
} from "./assembly.js";
let checks = 0;
let failures = 0;
function check(name, pass, detail = "") {
checks++;
if (pass) return;
failures++;
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ""}`);
}
function inspectSolid(name, meshData) {
const prepared = cleanMesh(meshData.clone());
const topology = auditMeshData(prepared);
const emitted = auditTriangleSoup(toTriangles(prepared));
check(`${name}: topology`, topology.ok, JSON.stringify(topology.issues.slice(0, 3)));
check(`${name}: emitted triangles`, emitted.triangles > 0, String(emitted.triangles));
check(`${name}: emitted data`, emitted.ok, JSON.stringify(emitted.issues.slice(0, 3)));
}
function inspectSurface(name, meshData) {
const prepared = cleanMesh(meshData.clone());
const topology = auditMeshData(prepared, {
closed: false,
maxComponents: 1,
checkOrientation: false,
});
const emitted = auditTriangleSoup(toTriangles(prepared));
check(`${name}: surface topology`, topology.ok, JSON.stringify(topology.issues.slice(0, 3)));
check(`${name}: surface emits triangles`, emitted.triangles > 0, String(emitted.triangles));
check(`${name}: surface emitted data`, emitted.ok, JSON.stringify(emitted.issues.slice(0, 3)));
}
console.log("procedural geometry quality-kit self-test");
inspectSolid("box", box(0, 0, 0, 1, 1, 1));
inspectSolid("rounded box", roundedBoxMesh([0, 0, 0, 1, 0.6, 0.4], 0.05, 2));
inspectSolid(
"hollow prism",
hollowPrism(
roundedRect(0.6, 0.4, 0.05, 3),
0,
0.2,
roundedRect(0.5, 0.3, 0.04, 3),
0.05,
),
);
inspectSolid(
"apertured prism",
aperturedPrism(
roundedRect(1, 0.6, 0.05, 3),
roundedRect(0.4, 0.3, 0.04, 3),
0,
0.04,
0.006,
2,
),
);
inspectSolid(
"profile tube",
tubeAlong(
[[0, 0, 0], [1, 0, 0], [1, 1, 0]],
roundedRect(0.05, 0.03, 0.008, 2),
),
);
inspectSolid(
"revolve with welded poles",
smoothShade(revolve([[0, 0], [0.3, 0.15], [0.4, 0.4], [0.3, 0.65], [0, 0.8]], 16), 40),
);
const unitSquare = [[0, 0], [1, 0], [1, 1], [0, 1]];
inspectSolid("prism", prism(unitSquare, 0, 1));
inspectSolid("prism XZ", prismXZ(unitSquare, 0, 1));
inspectSolid("prism YZ", prismYZ(unitSquare, 0, 1));
inspectSolid(
"beveled prism",
beveledPrismMesh(roundedRect(0.4, 0.3, 0.04, 3), 0, 0.06, 0.008, 2),
);
inspectSolid("rounded plate", plate(0, 0, 0.4, 0.3, 0, 0.02));
inspectSolid("panel with aperture", panelWithHoles(1, 1, 0.1, [[0.25, 0.25, 0.75, 0.75]]));
inspectSolid("wall with opening", wallRun([0, 0], [4, 0], 0.2, 0, 2.6, [[1, 2, 0, 2.1]]));
inspectSolid(
"annular prism",
annularPrism(circle(0.3, 24), circle(0.2, 24), 0, 0.04, 0.006, 2),
);
inspectSolid(
"mitred profile tube",
tubeAlong(densify([[0, 0, 0], [1, 0, 0], [1, 1, 0]], 0.06), circle(0.03, 8), { miter: true }),
);
inspectSolid("swept rectangular frame", sweepRectFrame(1, 0.8, roundedRect(0.04, 0.03, 0.006, 2)));
inspectSolid("swept planar loop", sweepPlanarLoop(circle(1, 16), roundedRect(0.06, 0.05, 0.01, 2)));
inspectSurface("open swept planar path", sweepPlanarLoop([[0, 0], [1, 0], [1, 1]], roundedRect(0.06, 0.05, 0.01, 2), false));
inspectSolid("closed molding", runMolding(circle(1, 12), roundedRect(0.05, 0.04, 0.008, 2), true, true));
inspectSolid("subdivision", subsurf(box(0, 0, 0, 1, 1, 1), 1));
inspectSolid("rounded-box convenience", roundedBox([0, 0, 0, 1, 1, 1], 0.04, 2));
inspectSolid(
"solidified surface",
solidify(loft([
[[0, 0, 0], [1, 0, 0], [2, 0, 0]],
[[0, 1, 0.1], [1, 1, 0.2], [2, 1, 0.1]],
]), 0.02),
);
inspectSurface("open loft", loft([
[[0, 0, 0], [1, 0, 0], [1, 1, 0]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1]],
]));
{
const bad = box(0, 0, 0, 1, 1, 1);
bad.verts.push([...bad.verts[0]], [20, 20, 20]);
const report = auditMeshData(bad);
check("topology: catches duplicate vertices", report.counts.duplicateVertexPairs > 0);
check("topology: catches loose vertices", report.counts.looseVertices === 2, String(report.counts.looseVertices));
}
{
const open = box(0, 0, 0, 1, 1, 1);
open.faces.pop();
const report = auditMeshData(open);
check("topology: catches open boundaries", report.counts.boundaryEdges === 4, String(report.counts.boundaryEdges));
}
{
const nonManifold = box(0, 0, 0, 1, 1, 1);
nonManifold.faces.push([...nonManifold.faces[0]]);
const report = auditMeshData(nonManifold);
check("topology: catches non-manifold edges", report.counts.nonManifoldEdges === 4, String(report.counts.nonManifoldEdges));
}
{
const detached = join([
box(0, 0, 0, 1, 1, 1),
box(2, 0, 0, 3, 1, 1),
]);
const report = auditMeshData(detached);
check("topology: catches detached face components", report.counts.components === 2, String(report.counts.components));
check("topology: detached components fail by default", !report.ok);
}
{
const insideOut = box(0, 0, 0, 1, 1, 1);
for (const face of insideOut.faces) face.reverse();
const report = auditMeshData(insideOut);
check("topology: catches inside-out closed volume", report.issues.some((entry) => entry.code === "inside-out"), String(report.signedVolume));
}
{
const outward = box(0, 0, 0, 3, 3, 3);
const inward = box(5, 0, 0, 6, 1, 1);
for (const face of inward.faces) face.reverse();
const report = auditMeshData(join([outward, inward]), { maxComponents: 2 });
check("topology: orientation is checked per disconnected component", report.issues.some((entry) => entry.code === "inside-out") && report.componentVolumes.some((value) => value < 0), JSON.stringify(report.componentVolumes));
}
{
const invalid = box(0, 0, 0, 1, 1, 1);
invalid.faces[0][0] = 999;
const report = auditMeshData(invalid);
check("topology: catches invalid face indices", report.issues.some((entry) => entry.code === "invalid-index"));
}
{
const malformed = box(0, 0, 0, 1, 1, 1);
malformed.verts[0] = null;
malformed.faces.push(null);
let report;
try {
report = auditMeshData(malformed);
} catch {
report = null;
}
check("topology: malformed data reports instead of throwing", !!report && report.issues.some((entry) => entry.code === "non-finite-vertex"));
}
{
const inconsistent = box(0, 0, 0, 1, 1, 1);
inconsistent.faces[0].reverse();
const report = auditMeshData(inconsistent);
check("topology: catches locally inconsistent winding", report.counts.inconsistentWindingEdges === 4 && report.issues.some((entry) => entry.code === "inconsistent-winding"), JSON.stringify(report.issues));
}
{
const pointTouch = meshObj(
[[0, 0, 0], [1, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0]],
[[0, 1, 2], [0, 3, 4]],
);
const report = auditMeshData(pointTouch, { closed: false, checkOrientation: false });
check("topology: vertex-only contact remains two components", report.counts.components === 2, String(report.counts.components));
}
{
const materialMismatch = box(0, 0, 0, 1, 1, 1);
materialMismatch.faceMat = [0];
const report = auditMeshData(materialMismatch);
check("topology: catches material-face mismatch", report.issues.some((entry) => entry.code === "material-face-count"));
materialMismatch.faceMat = new Array(materialMismatch.faces.length).fill(0);
materialMismatch.faceMat[0] = -1;
const invalidSlot = auditMeshData(materialMismatch);
check("topology: catches invalid material index", invalidSlot.issues.some((entry) => entry.code === "invalid-material-index"));
}
{
const attributes = box(0, 0, 0, 1, 1, 1);
attributes.colors = [[1, 1, Number.NaN]];
attributes.uvs = [attributes.faces[0].map(() => [0, 0])];
const report = auditMeshData(attributes);
check("topology: catches color count and finite-value defects", report.issues.some((entry) => entry.code === "color-vertex-count") && report.issues.some((entry) => entry.code === "invalid-vertex-color"));
check("topology: catches UV face count defects", report.issues.some((entry) => entry.code === "uv-face-count"));
}
{
const open = loft([
[[0, 0, 0], [1, 0, 0], [1, 1, 0]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1]],
]);
const report = auditMeshData(open, { closed: false, maxComponents: 1, checkOrientation: false });
check("topology: intentional open surface can opt out", report.ok, JSON.stringify(report.issues));
}
{
const badSoup = {
positions: [0, 0, 0, 0, 0, 0, 0, 0, 0],
normals: [0, 0, 0, 0, 0, 0, 0, 0, Number.NaN],
};
const report = auditTriangleSoup(badSoup);
check("emission: catches degenerate triangle", report.issues.some((entry) => entry.code === "degenerate-triangle"));
check("emission: catches invalid normals", report.issues.some((entry) => entry.code === "bad-normal"));
}
{
const report = auditTriangleSoup({
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
normals: [0, 0, -1, 0, 0, -1, 0, 0, -1],
});
check("emission: catches normals opposed to triangle winding", report.issues.some((entry) => entry.code === "opposed-normal"));
}
{
const ring = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]];
const collapsed = loft([
ring,
ring.map((point) => [point[0], point[1], 1]),
ring.map((point) => [point[0], point[1], 1]),
], { closeV: true });
cleanMesh(collapsed);
const zeroFaces = collapsed.faces.filter((face) => faceNormal(collapsed.verts, face).every((value) => Math.abs(value) < 1e-12));
check("cleanup: removes collapsed-ring zero-area faces", zeroFaces.length === 0, String(zeroFaces.length));
}
{
const distance = 2e-5;
const seam = meshObj(
[[0.49 * distance, 0, 0], [0.51 * distance, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]],
[[0, 2, 3], [1, 3, 4]],
);
cleanMesh(seam, distance);
check("cleanup: welds close vertices across spatial-cell boundaries", seam.verts.length === 4, String(seam.verts.length));
}
{
const report = runGeometryContract({
name: "seat",
checks: [
rangeCheck("seat-height", (value) => value.height, 0.43, 0.46),
minimumCheck("slat-gap", (value) => value.gap, 0.004),
],
}, { height: 0.45, gap: 0.008 });
check("contract: passing measurements", report.ok, JSON.stringify(report.failures));
const failed = runGeometryContract({
name: "seat",
checks: [rangeCheck("seat-height", (value) => value.height, 0.43, 0.46)],
}, { height: 0.6 });
check("contract: rejects bad measurements", !failed.ok);
}
{
const report = runGeometryContract({
name: "mechanism",
checks: [
maximumCheck("maximum-gap", (value) => value.gap, 0.01),
nearCheck("closed-contact", (value) => value.closed, 0, 0.0005),
predicateCheck("opening-clear", (value) => value.blocked, (blocked) => blocked === false, "false"),
minimumCheck("warning-only", (value) => value.margin, 0.1, { severity: "warning" }),
],
}, { gap: 0.004, closed: 0.0002, blocked: false, margin: 0.05 });
check("contract: maximum/near/predicate helpers pass", report.ok, JSON.stringify(report.failures));
check("contract: warnings do not fail the contract", report.warnings.length === 1, String(report.warnings.length));
let threw = false;
try {
assertGeometryContract(runGeometryContract({
name: "throws",
checks: [predicateCheck("exception", () => { throw new Error("measurement failed"); }, () => true, "no exception")],
}, {}));
} catch (error) {
threw = error instanceof Error && error.message.includes("measurement failed");
}
check("contract: measurement exceptions become actionable failures", threw);
let duplicateRejected = false;
let severityRejected = false;
try {
runGeometryContract({
name: "duplicates",
checks: [minimumCheck("same", () => 1, 0), minimumCheck("same", () => 1, 0)],
}, {});
} catch (error) {
duplicateRejected = error instanceof TypeError && error.message.includes("duplicate check id");
}
try {
runGeometryContract({
name: "severity",
checks: [minimumCheck("value", () => 1, 0, { severity: "warn" })],
}, {});
} catch (error) {
severityRejected = error instanceof TypeError && error.message.includes("severity");
}
check("contract: duplicate check identifiers are rejected", duplicateRejected);
check("contract: severity typos are rejected", severityRejected);
}
{
const outer = { min: { x: 0, y: 0, z: 0 }, max: { x: 2, y: 2, z: 2 } };
const inner = { min: { x: 0.5, y: 0.5, z: 0.5 }, max: { x: 1.5, y: 1.5, z: 1.5 } };
const separate = { min: { x: 3, y: 0, z: 0 }, max: { x: 4, y: 1, z: 1 } };
check("contract: bounds containment", boundsContains(outer, inner));
check("contract: bounds gap", boundsGapAlongAxis(outer, separate, "x") === 1);
check("contract: intersection depth", boundsIntersectionDepth(outer, inner).every((value) => value === 1));
}
function quad(y, flip = false) {
const positions = flip
? [0, y, 0, 0, y, 1, 1, y, 1, 0, y, 0, 1, y, 1, 1, y, 0]
: [0, y, 0, 1, y, 1, 0, y, 1, 0, y, 0, 1, y, 0, 1, y, 1];
const normals = [];
for (let i = 0; i < 6; i++) normals.push(0, flip ? -1 : 1, 0);
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3));
return new Mesh(geometry, new MeshBasicMaterial());
}
function solidMesh(meshData, name) {
const mesh = new Mesh(toGeometry(cleanMesh(meshData.clone())), new MeshBasicMaterial());
mesh.name = name;
return mesh;
}
function triangleMesh(points, name = "triangle") {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(points.flat()), 3));
const a = points[0];
const b = points[1];
const c = points[2];
const ux = b[0] - a[0];
const uy = b[1] - a[1];
const uz = b[2] - a[2];
const vx = c[0] - a[0];
const vy = c[1] - a[1];
const vz = c[2] - a[2];
const nx = uy * vz - uz * vy;
const ny = uz * vx - ux * vz;
const nz = ux * vy - uy * vx;
const length = Math.hypot(nx, ny, nz) || 1;
const normals = [];
for (let i = 0; i < 3; i++) normals.push(nx / length, ny / length, nz / length);
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3));
const mesh = new Mesh(geometry, new MeshBasicMaterial());
mesh.name = name;
return mesh;
}
{
const root = new Group();
const a = quad(1, false);
const b = quad(1.0005, false);
const c = quad(1, true);
a.name = "A";
b.name = "B";
c.name = "C";
root.add(a, b, c);
const report = auditGeometry(root, { clash: false });
check("scene audit: planted z-fight", report.zfight.length === 1, JSON.stringify(report.zfight));
check("scene audit: measures one square metre", report.zfightTotalCm2 > 9000, String(report.zfightTotalCm2));
check("scene audit: opposed faces are informational", report.backToBack >= 2, String(report.backToBack));
disposeHierarchy(root, true);
}
{
const root = new Group();
root.add(
triangleMesh([[0, 0, 0], [2, 0, 0], [0, 0, 2]], "lower-left"),
triangleMesh([[2, 0.0005, 2], [2, 0.0005, 1.1], [1.1, 0.0005, 2]], "upper-right"),
);
check("scene audit: overlapping AABBs without triangle overlap stay clean", auditGeometry(root, { clash: false }).zfight.length === 0);
disposeHierarchy(root, true);
}
{
const a = quad(1, false);
const b = quad(1.0005, false);
const positions = [...a.geometry.getAttribute("position").array, ...b.geometry.getAttribute("position").array];
const normals = [...a.geometry.getAttribute("normal").array, ...b.geometry.getAttribute("normal").array];
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3));
const merged = new Mesh(geometry, new MeshBasicMaterial());
merged.name = "merged-slot";
const root = new Group();
root.add(merged);
const report = auditGeometry(root, { clash: false });
check("scene audit: same-mesh z-fight after slot merge", report.zfight.length === 1 && report.zfight[0].a === "merged-slot" && report.zfight[0].b === "merged-slot", JSON.stringify(report.zfight));
a.geometry.dispose();
a.material.dispose();
b.geometry.dispose();
b.material.dispose();
disposeHierarchy(root, true);
}
{
const root = new Group();
const a = quad(1, false);
const b = quad(1.0005, false);
a.name = "kept";
b.name = "skipped";
b.userData.auditSkip = true;
root.add(a, b);
check("scene audit: userData auditSkip", auditGeometry(root, { clash: false }).meshes === 1);
b.userData.auditSkip = false;
check("scene audit: skip callback", auditGeometry(root, { clash: false, skip: (_mesh, name) => name === "skipped" }).meshes === 1);
disposeHierarchy(root, true);
}
{
const root = new Group();
const a = quad(1, false);
const b = quad(1.0005, false);
a.position.x = 20;
b.position.x = 20;
root.add(a, b);
const bounded = auditGeometry(root, {
clash: false,
bounds: { radius: 1, minY: -2, maxY: 2, centerX: 0, centerZ: 0 },
});
check("scene audit: bounds exclude comparison triangles", bounded.compared === 0 && bounded.zfight.length === 0, JSON.stringify(bounded));
disposeHierarchy(root, true);
}
{
const root = new Group();
root.add(quad(1, false), quad(1.0005, false));
const report = auditGeometry(root, { clash: false, maxTriangles: 1 });
check("scene audit: triangle cap reports truncation", report.truncated && report.compared === 1);
disposeHierarchy(root, true);
}
{
const root = new Group();
const a = quad(0, false);
const b = quad(0, false);
b.rotation.z = 0.01;
root.add(a, b);
check("scene audit: angleRadians is a true angular tolerance", auditGeometry(root, { clash: false }).zfight.length === 0 && auditGeometry(root, { clash: false, angleRadians: 0.02 }).zfight.length === 1);
disposeHierarchy(root, true);
}
{
const root = new Group();
root.add(quad(0, false), quad(0.003, false));
check("scene audit: planeDistance option controls separation", auditGeometry(root, { clash: false }).zfight.length === 0 && auditGeometry(root, { clash: false, planeDistance: 0.004 }).zfight.length === 1);
check("scene audit: overlapArea option controls reportable area", auditGeometry(root, { clash: false, planeDistance: 0.004, overlapArea: 2 }).zfight.length === 0);
disposeHierarchy(root, true);
}
{
const hiddenParent = new Group();
hiddenParent.visible = false;
hiddenParent.add(quad(0, false), quad(0.0005, false));
const root = new Group();
root.add(hiddenParent);
check("scene audit: invisible ancestors suppress descendants", auditGeometry(root, { clash: false }).meshes === 0);
disposeHierarchy(root, true);
}
{
const bad = quad(0, false);
const normals = bad.geometry.getAttribute("normal");
for (let i = 0; i < normals.count; i++) normals.setXYZ(i, 0, 2, 0);
const root = new Group();
root.add(bad);
const report = auditGeometry(root, { clash: false });
check("scene audit: non-unit normals are defects", report.defects.length === 1 && report.defects[0].badNormals === 6, JSON.stringify(report.defects));
disposeHierarchy(root, true);
}
{
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0]), 3));
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(9), 3));
const bad = new Mesh(geometry, new MeshBasicMaterial());
bad.name = "bad-mesh";
bad.material.dispose();
bad.material = null;
const root = new Group();
root.add(bad);
const report = auditGeometry(root, { clash: false });
check("scene audit: degenerate and bad normals", report.defects.length === 1 && report.defects[0].degenerate === 1 && report.defects[0].badNormals === 3, JSON.stringify(report.defects));
check("scene audit: missing material", report.noMaterial.length === 1 && report.noMaterial[0] === "bad-mesh", JSON.stringify(report.noMaterial));
disposeHierarchy(root);
}
{
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 2, 2,
]), 3));
geometry.setAttribute("normal", new BufferAttribute(new Float32Array([
0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1,
]), 3));
const root = new Group();
root.add(new Mesh(geometry, new MeshBasicMaterial()));
const report = auditGeometry(root, { clash: false });
check("scene audit: incomplete triangle tails are defects", report.defects.length === 1 && report.defects[0].incompleteCorners === 1, JSON.stringify(report.defects));
disposeHierarchy(root, true);
}
{
const geometry = toGeometry(cleanMesh(box(0, 0, 0, 1, 1, 1)));
const material = new MeshBasicMaterial();
const instances = new InstancedMesh(geometry, material, 2);
instances.name = "fastener";
instances.position.x = 10;
instances.setMatrixAt(0, new Matrix4().makeTranslation(0, 0, 0));
instances.setMatrixAt(1, new Matrix4().makeTranslation(3, 0, 0));
const root = new Group();
root.add(instances);
check("scene audit: instances excluded by default", auditGeometry(root, { clash: false }).meshes === 0);
instances.count = 3;
const included = auditGeometry(root, { clash: false, includeInstanced: true });
check("scene audit: every available instance expands with its world transform", included.meshes === 2 && included.triangles === 24, JSON.stringify(included));
const secondOnly = auditGeometry(root, {
clash: false,
includeInstanced: true,
bounds: { radius: 1, minY: -0.1, maxY: 1.1, centerX: 13.5, centerZ: 0.5 },
});
check("scene audit: parent and instance transforms compose", secondOnly.compared === 12, JSON.stringify(secondOnly));
disposeHierarchy(root, true);
}
{
const root = new Group();
const a = quad(1, false);
const b = quad(1.01, false);
a.name = "A";
b.name = "B";
root.add(a, b);
check("scene audit: ten millimetres is clean", auditGeometry(root, { clash: false }).zfight.length === 0);
disposeHierarchy(root, true);
}
{
const root = new Group();
root.name = "assembly";
root.add(
solidMesh(box(0, 0, 0, 1, 1, 1), "P"),
solidMesh(box(0.94, 0.3, 0.3, 1.6, 0.7, 0.7), "Q"),
);
check("scene audit: sixty-millimetre clash", auditGeometry(root).clash.length === 1);
check("scene audit: narrow named clash allowance", auditGeometry(root, { clashAllow: [["P", "Q"]] }).clash.length === 0);
check("scene audit: clash pass can be disabled", auditGeometry(root, { clash: false }).clash.length === 0);
disposeHierarchy(root, true);
const touching = new Group();
touching.add(
solidMesh(box(0, 0, 0, 1, 1, 1), "P"),
solidMesh(box(1, 0.3, 0.3, 1.6, 0.7, 0.7), "Q"),
);
check("scene audit: butt joint is not a clash", auditGeometry(touching).clash.length === 0);
disposeHierarchy(touching, true);
}
{
const root = new Group();
root.add(
solidMesh(box(0, 0, 0, 1, 1, 1), "shallow-a"),
solidMesh(box(0.994, 0.3, 0.3, 1.6, 0.7, 0.7), "shallow-b"),
);
check("scene audit: clashDepth option admits explicitly requested shallow penetrations", auditGeometry(root).clash.length === 0 && auditGeometry(root, { clashDepth: 0.004 }).clash.length === 1);
disposeHierarchy(root, true);
}
{
const root = new Group();
root.add(
solidMesh(box(0, 0, 0, 100, 100, 1), "large-slab"),
solidMesh(box(49, 49, -1, 51, 51, 2), "piercing-column"),
);
const report = auditGeometry(root);
check("scene audit: oversized hash triangles retain clash coverage", report.clash.length === 1, JSON.stringify(report.clash));
disposeHierarchy(root, true);
}
{
const material = new MeshBasicMaterial();
const parts = [
{ name: "frame:left", slot: "metal", meshData: roundedBoxMesh([-1, 0, 0, -0.8, 1, 1], 0.02, 2), contract: { support: "ground" } },
{ name: "frame:right", slot: "metal", meshData: roundedBoxMesh([0.8, 0, 0, 1, 1, 1], 0.02, 2) },
];
const before = JSON.stringify(parts.map((part) => ({ verts: part.meshData.verts, faces: part.meshData.faces })));
const named = buildNamedAuditGroup(parts, { metal: material });
const merged = buildMergedAssembly(parts, { metal: material });
check("assembly: named audit parts survive", named.children.map((child) => child.name).join(",") === "frame:left,frame:right");
check("assembly: contract metadata survives", named.children[0].userData.geometryPart.contract.support === "ground");
check("assembly: one merged mesh per slot", merged.children.length === 1, String(merged.children.length));
check("assembly: builders do not mutate caller MeshData", JSON.stringify(parts.map((part) => ({ verts: part.meshData.verts, faces: part.meshData.faces }))) === before);
check("assembly: clean before and after merge", auditGeometry(named).zfight.length === 0 && auditGeometry(merged).zfight.length === 0);
const plainSource = JSON.parse(JSON.stringify(box(0, 0, 0, 1, 1, 1)));
const plainBefore = JSON.stringify(plainSource);
const plainGroup = buildNamedAuditGroup([{ name: "plain", slot: "metal", meshData: plainSource }], { metal: material });
check("assembly: plain mesh-data objects are cloned before cleanup", JSON.stringify(plainSource) === plainBefore);
const colored = box(4, 0, 0, 5, 1, 1);
colored.colors = colored.verts.map(() => [0.2, 0.4, 0.6]);
colored.colorName = "surfaceMask";
const coloredAssembly = buildMergedAssembly([
{ name: "colored", slot: "metal", meshData: colored },
{ name: "default-color", slot: "metal", meshData: box(6, 0, 0, 7, 1, 1) },
], { metal: material });
const colorAttribute = coloredAssembly.children[0].geometry.getAttribute("surfaceMask");
check("assembly: merged slots preserve and default vertex-color attributes", colorAttribute?.count === coloredAssembly.children[0].geometry.getAttribute("position").count);
disposeHierarchy(named);
disposeHierarchy(merged);
disposeHierarchy(plainGroup);
disposeHierarchy(coloredAssembly);
material.dispose();
}
{
const material = new MeshBasicMaterial();
let missingName = false;
let missingMaterial = false;
let duplicateName = false;
try {
buildNamedAuditGroup([{ slot: "metal", meshData: box(0, 0, 0, 1, 1, 1) }], { metal: material });
} catch (error) {
missingName = error instanceof TypeError && error.message.includes("stable name");
}
try {
buildNamedAuditGroup([{ name: "part", slot: "metal", meshData: box(0, 0, 0, 1, 1, 1) }], {});
} catch (error) {
missingMaterial = error instanceof Error && error.message.includes("no material");
}
try {
buildNamedAuditGroup([
{ name: "part", slot: "metal", meshData: box(0, 0, 0, 1, 1, 1) },
{ name: "part", slot: "metal", meshData: box(2, 0, 0, 3, 1, 1) },
], { metal: material });
} catch (error) {
duplicateName = error instanceof Error && error.message.includes("duplicate assembly part name");
}
check("assembly: rejects unnamed parts", missingName);
check("assembly: rejects unbound material slots", missingMaterial);
check("assembly: rejects duplicate stable names", duplicateName);
material.dispose();
}
{
const clockwise = [...unitSquare].reverse();
check("profiles: polygon area sign", polyArea(unitSquare) === 1 && polyArea(clockwise) === -1);
check("profiles: ccw normalizes winding", polyArea(ccw(clockwise)) > 0);
check("profiles: outward offset grows area", polyArea(polyOffset(unitSquare, 0.1)) > 1);
check("profiles: inset shrinks area", polyArea(insetPoly(unitSquare, 0.1)) < 1);
const offset = offsetPolyline([[0, 0], [1, 0], [1, 1]], 0.1);
check("profiles: open polyline offset preserves endpoints", offset.length === 3 && Math.abs(offset[0][1] + 0.1) < 1e-9);
check("profiles: chamfer rectangle has eight corners", chamferRect(1, 0.5, 0.05).length === 8);
const arc = arcPts(0, 0, 1, 0, Math.PI / 2, 4);
check("profiles: arc endpoints", Math.abs(arc[0][0] - 1) < 1e-9 && Math.abs(arc.at(-1)[1] - 1) < 1e-9);
const curve = bez([0, 0], [0, 1], [1, 1], [1, 0], 8);
check("profiles: bezier endpoints", curve[0][0] === 0 && curve[0][1] === 0 && curve.at(-1)[0] === 1 && curve.at(-1)[1] === 0);
check("profiles: rounded rectangle and circle segment counts", roundedRect(1, 0.5, 0.05, 3).length === 16 && circle(1, 12).length === 12);
check("profiles: densify adds corner guards", densify([[0, 0, 0], [1, 0, 0], [1, 1, 0]], 0.05).length === 5);
}
{
const point = () => meshObj([[1, 0, 0]], []);
check("transform: translate", translate(point(), [1, 2, 3]).verts[0].join(",") === "2,2,3");
const rz = rotateZ(point(), Math.PI / 2).verts[0];
check("transform: rotate Z", Math.abs(rz[0]) < 1e-9 && Math.abs(rz[1] - 1) < 1e-9);
const rx = rotX(meshObj([[0, 1, 0]], []), Math.PI / 2).verts[0];
check("transform: rotate X", Math.abs(rx[1]) < 1e-9 && Math.abs(rx[2] - 1) < 1e-9);
const ry = rotY(point(), Math.PI / 2).verts[0];
check("transform: rotate Y", Math.abs(ry[0]) < 1e-9 && Math.abs(ry[2] + 1) < 1e-9);
check("transform: scale", scaleMesh(point(), [2, 3, 4]).verts[0].join(",") === "2,0,0");
const matrixPoint = transform4(point(), [[1, 0, 0, 2], [0, 1, 0, 3], [0, 0, 1, 4], [0, 0, 0, 1]]).verts[0];
check("transform: matrix", matrixPoint.join(",") === "3,3,4");
const placed = placeYaw(point(), [2, 3, 4], Math.PI / 2).verts[0];
check("transform: yaw placement", Math.abs(placed[0] - 2) < 1e-9 && placed[1] === 3 && Math.abs(placed[2] - 3) < 1e-9);
}
{
const rawBox = box(0, 0, 0, 1, 1, 1);
const before = rawBox.verts.length;
bevel(rawBox, 0.04, 2);
check("modifier: provenance-aware box bevel", rawBox.verts.length > before);
const rawPrism = prism(unitSquare, 0, 1);
const prismBefore = rawPrism.verts.length;
bevel(rawPrism, 0.04, 2);
check("modifier: provenance-aware prism bevel", rawPrism.verts.length > prismBefore);
flatShade(rawPrism);
check("shading: flat mode", rawPrism.shading.mode === "flat");
smoothShade(rawPrism, 37);
check("shading: smooth mode and angle", rawPrism.shading.mode === "smooth" && rawPrism.shading.angle === 37);
for (const face of rawPrism.faces) face.reverse();
recalcNormals(rawPrism);
check("winding: reconstruction restores positive volume", auditMeshData(rawPrism).signedVolume > 0);
}
{
const authored = box(0, 0, 0, 1, 2, 3);
const once = toYUp(authored.clone());
const snapshot = JSON.stringify({ verts: once.verts, faces: once.faces });
toYUp(once);
check("emission: Y-up conversion is idempotent", JSON.stringify({ verts: once.verts, faces: once.faces }) === snapshot);
}
{
const swept = sweepPlanarLoop(
[[0, 0], [1, 0], [1, 1]],
[[-0.1, 0], [0.1, 0], [0.1, 0.1], [-0.1, 0.1]],
false,
);
const firstRing = swept.verts.slice(0, 4);
const lastRing = swept.verts.slice(-4);
check("sweep: open-path first frame uses first segment", firstRing.every((point) => Math.abs(point[0]) < 1e-9), JSON.stringify(firstRing));
check("sweep: open-path last frame uses last segment", lastRing.every((point) => Math.abs(point[1] - 1) < 1e-9), JSON.stringify(lastRing));
}
{
const multi = box(0, 0, 0, 1, 1, 1);
multi.faceMat = [0, 0, 1, 1, 2, 2];
const geometry = toGeometry(multi);
check("emission: face material slots become geometry groups", geometry.groups.length === 3 && geometry.groups.every((group, index) => group.materialIndex === index && group.count === 12), JSON.stringify(geometry.groups));
geometry.dispose();
}
{
const calls = [];
const writer = { raw: (...args) => calls.push(args) };
writeInto(writer, "metal", box(0, 0, 0, 1, 1, 1));
writeAll(writer, {
metal: [box(0, 0, 0, 1, 1, 1), box(2, 0, 0, 3, 1, 1)],
glass: box(4, 0, 0, 5, 1, 1),
});
check("writer: writeInto/writeAll preserve slots", calls.length === 4 && calls[0][0] === "metal" && calls.at(-1)[0] === "glass");
check("writer: emitted arrays stay aligned", calls.every(([, positions, normals, uvs]) => positions.length === normals.length && uvs.length * 3 === positions.length * 2));
}
{
let state = 0x51f15e;
const random = () => {
state = Math.imul(state ^ state >>> 15, 1 | state);
state ^= state + Math.imul(state ^ state >>> 7, 61 | state);
return ((state ^ state >>> 14) >>> 0) / 4294967296;
};
const failures = [];
const validate = (label, meshData) => {
const prepared = cleanMesh(meshData);
const topology = auditMeshData(prepared);
const emission = auditTriangleSoup(toTriangles(prepared));
if (!topology.ok || !emission.ok) failures.push({ label, topology: topology.issues, emission: emission.issues });
};
for (let i = 0; i < 24; i++) {
const sides = 3 + i % 10;
const radius = 0.15 + random() * 0.85;
const height = 0.05 + random() * 1.5;
const polygon = circle(radius, sides, 0, 0, random() * Math.PI);
validate(`sweep prism ${i}`, prism(polygon, -height * 0.5, height * 0.5));
validate(`sweep loft ${i}`, loft([
polygon.map(([x, y]) => [x * 0.7, y * 0.7, 0]),
polygon.map(([x, y]) => [x, y, height * 0.5]),
polygon.map(([x, y]) => [x * 0.8, y * 0.8, height]),
], { closeV: true, capStart: true, capEnd: true }));
validate(`sweep rounded box ${i}`, roundedBoxMesh(
[-radius, -radius * 0.6, 0, radius, radius * 0.6, height],
Math.min(radius * 0.2, height * 0.2),
1 + i % 3,
));
validate(`sweep tube ${i}`, tubeAlong([
[0, 0, 0],
[0.3 + random(), random() * 0.2, 0.1 + random() * 0.2],
[0.8 + random(), 0.4 + random() * 0.3, 0.3 + random() * 0.2],
], circle(0.02 + random() * 0.08, 6 + i % 10), { miter: i % 2 === 0 }));
}
check("deterministic sweep: 96 generated solids pass topology and emission", failures.length === 0, JSON.stringify(failures.slice(0, 2)));
}
{
const failures = [];
for (let i = 0; i < 24; i++) {
const root = new Group();
root.rotation.set(i * 0.173, i * 0.271, i * 0.119);
const a = quad(0, false);
const b = quad(0.0005, false);
a.name = `rotated-a-${i}`;
b.name = `rotated-b-${i}`;
root.add(a, b);
const report = auditGeometry(root, { clash: false });
if (report.zfight.length !== 1) failures.push({ i, report });
disposeHierarchy(root, true);
}
check("deterministic sweep: rotated coplanar pairs survive plane-grid boundaries", failures.length === 0, JSON.stringify(failures.slice(0, 2)));
}
{
const failures = [];
for (let i = 0; i < 12; i++) {
const penetration = 0.04 + i * 0.005;
const clashed = new Group();
clashed.add(
solidMesh(box(0, 0, 0, 1, 1, 1), `solid-a-${i}`),
solidMesh(box(1 - penetration, 0.3, 0.3, 1.6, 0.7, 0.7), `solid-b-${i}`),
);
const clashReport = auditGeometry(clashed);
if (clashReport.clash.length !== 1) failures.push({ i, kind: "missed", penetration, clashReport });
disposeHierarchy(clashed, true);
const separated = new Group();
separated.add(
solidMesh(box(0, 0, 0, 1, 1, 1), `clean-a-${i}`),
solidMesh(box(1 + i * 0.002, 0.3, 0.3, 1.6, 0.7, 0.7), `clean-b-${i}`),
);
const cleanReport = auditGeometry(separated);
if (cleanReport.clash.length !== 0) failures.push({ i, kind: "false-positive", cleanReport });
disposeHierarchy(separated, true);
}
check("deterministic sweep: clash threshold separates penetration from touching", failures.length === 0, JSON.stringify(failures.slice(0, 2)));
}
{
const poleMesh = revolve([[0, 0], [0.3, 0.4], [0, 0.8]], 24, { capStart: false, capEnd: false });
const onAxis = poleMesh.verts.filter((v) => Math.hypot(v[0], v[1]) < 1e-9).length;
check("revolve: one vertex per pole", onAxis === 2, String(onAxis));
check("circle helper remains usable", circle(1, 16).length === 16);
}
console.log(`${checks - failures}/${checks} checks passed`);
if (failures) process.exitCode = 1;
examples/formula-one-race-car/race-car-model.js
export { D as RACE_CAR_DIMENSIONS, PAL as RACE_CAR_PALETTE, PROJ as LIVERY_PROJECTOR, floorY } from "./source/design-contract.js";
export { createFormulaOneRaceCar } from "./source/race-car-model.js";
examples/formula-one-race-car/source/design-contract.js
import { clamp01, smooth01 } from "./mesh-kit.js";
/*
DESIGN CONTRACT — 2026 Formula 1 Technical Regulations, in metres.
Frame: +X right, +Y up, +Z forward (nose). Ground plane y = 0.
Every subassembly is positioned from this table or from a sample of a surface built
from it. There are no late visual nudges and no global repair scale.
*/
export const D = (() => {
const wheelbase = 3.400;
const frontAxleZ = 1.720;
const rearAxleZ = frontAxleZ - wheelbase; // -1.680
const frontTyre = { od: 0.7200, width: 0.3720, rimOD: 0.4572 }; // 18" rim
const rearTyre = { od: 0.7300, width: 0.4060, rimOD: 0.4572 };
const overallWidth = 1.900;
// hub centres derived from the legal envelope, not chosen independently
const frontHubX = overallWidth * 0.5 - frontTyre.width * 0.5 - 0.006; // 0.708
const rearHubX = overallWidth * 0.5 - rearTyre.width * 0.5 - 0.010; // 0.687
return {
wheelbase, frontAxleZ, rearAxleZ, overallWidth,
frontTyre, rearTyre, frontHubX, rearHubX,
frontR: frontTyre.od * 0.5, rearR: rearTyre.od * 0.5,
/* longitudinal stations */
noseTipZ: 2.755,
bulkheadZ: 1.905, // front of the survival cell
cockpitFrontZ: 1.075, // forward cockpit-opening edge
cockpitRearZ: 0.115, // rear cockpit-opening edge / headrest
rollHoopZ: -0.235,
sidepodInletZ: 0.640,
sidepodEndZ: -1.190,
engineCoverEndZ: -1.640,
rearCrashZ: -2.130,
diffuserExitZ:-2.045,
rearWingLEZ: -1.845,
rearWingTEZ: -2.360,
frontWingLEZ: 2.965,
frontWingTEZ: 2.235,
/* heights */
plankY: 0.0345, // reference-plane / plank underside at the front axle
rakeRise: 0.0455, // extra floor height at the diffuser throat (rake)
chassisTopY: 0.610, // top deck of the survival cell at the bulkhead
cockpitRimY: 0.585,
rollHoopY: 0.955, // regulation maximum height
haloTopY: 0.935,
beamWingY: 0.310,
rearWingLowY: 0.605,
rearWingTopY: 0.900,
/* lateral */
floorHalfW: 0.500,
sidepodHalfW: 0.740,
monoHalfW: 0.390,
/* aero spans */
frontWingSpan: 1.800,
rearWingSpan: 0.955,
beamWingSpan: 0.760,
seed: 20260127,
};
})();
/* Livery + material palette. Deep graphite base with the crimson/white flash set. */
export const PAL = {
carbon: 0x0b0b0e,
carbonWarm: 0x131318,
bodyBlack: 0x131418,
bodyGrey: 0x2e3036,
bodyGloss: 0x1a1b20,
red: 0xa8122c,
redBright: 0xd4142f,
redDeep: 0x6e0a1c,
white: 0xe8ebee,
whiteDim: 0xbfc4ca,
silver: 0x8d939b,
titanium: 0x7b8087,
steel: 0x9aa1a9,
goldTint: 0xb98f3c,
tyre: 0x121214,
tyreShoulder:0x18181b,
rim: 0x1b1c20,
brakeDisc: 0x2b2724,
brakeGlow: 0x581206,
visor: 0x120a06,
helmetWhite: 0xdfe3e7,
rainLight: 0xff1420,
};
/* Derived ride-height plane: the floor rakes upward toward the rear. */
export const floorY = (z) => {
const t = clamp01((D.frontAxleZ - z) / (D.frontAxleZ - D.rearAxleZ));
return D.plankY + D.rakeRise * smooth01(t);
};
/* projector frame — shared by the painters and by the material nodes */
export const PROJ = (() => {
const Z1 = 3.10, Z0 = -2.60; // longitudinal span of the paint plane
const zSpan = Z1 - Z0; // 5.70 m
const SIDE_W = 3584, SIDE_LAYER_H = 700; // two stacked layers: +X then -X
const sidePPM = SIDE_W / zSpan; // 628.77 px/m
const hSpan = (SIDE_LAYER_H * 2) / sidePPM; // full-texture vertical span
const Y1 = 1.0580, Y0 = Y1 - SIDE_LAYER_H / sidePPM;
const TOP_W = 2560, TOP_H = 900;
const topPPM = TOP_W / zSpan; // 449.1 px/m
const X1 = (TOP_H / topPPM) * 0.5, X0 = -X1; // ±1.002 m
return { Z1, Z0, zSpan, SIDE_W, SIDE_LAYER_H, sidePPM, hSpan, Y1, Y0, TOP_W, TOP_H, topPPM, X1, X0 };
})();
examples/formula-one-race-car/source/mesh-kit.js
import * as THREE from "three/webgpu";
/*
GEOMETRY KERNEL
Reusable emitters shared by every subassembly. Nothing in the model constructs a
BufferGeometry directly; each part supplies a *plan* (sampled sections, paths,
outlines) and one of these compilers emits triangles, normals, UVs and winding.
*/
const TAU = Math.PI * 2;
const PI = Math.PI;
const V2 = (x = 0, y = 0) => new THREE.Vector2(x, y);
const V3 = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const lerp = THREE.MathUtils.lerp;
const clamp = THREE.MathUtils.clamp;
const smooth01 = (t) => { t = clamp01(t); return t * t * (3 - 2 * t); };
const smoother01 = (t) => { t = clamp01(t); return t * t * t * (t * (t * 6 - 15) + 10); };
const deg = (d) => d * PI / 180;
function mulberry32(seed){
let a = seed >>> 0;
return function(){
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* --- geometry accounting: every emitted part is recorded ---------------------------- */
const PART_LOG = [];
function logPart(name, geometry){
const tris = geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3;
PART_LOG.push({ part: name, tris: Math.round(tris), verts: geometry.attributes.position.count });
return geometry;
}
/* --- 1D / 2D interpolation --------------------------------------------------------- */
/** Monotone-ish Catmull-Rom through 2D control points, resampled to `count` points. */
function spline2(points, count, { closed = false, tension = 0.5 } = {}){
const pts = points.map(p => (p.isVector2 ? p.clone() : V2(p[0], p[1])));
const n = pts.length;
const at = (i) => closed ? pts[((i % n) + n) % n] : pts[clamp(i, 0, n - 1)];
const out = [];
const spans = closed ? n : n - 1;
for (let s = 0; s < count; s++){
const g = (s / (closed ? count : count - 1)) * spans;
const i = Math.min(Math.floor(g), spans - 1);
const t = g - i;
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
const t2 = t * t, t3 = t2 * t;
const m1x = tension * (p2.x - p0.x), m1y = tension * (p2.y - p0.y);
const m2x = tension * (p3.x - p1.x), m2y = tension * (p3.y - p1.y);
out.push(V2(
(2*t3 - 3*t2 + 1) * p1.x + (t3 - 2*t2 + t) * m1x + (-2*t3 + 3*t2) * p2.x + (t3 - t2) * m2x,
(2*t3 - 3*t2 + 1) * p1.y + (t3 - 2*t2 + t) * m1y + (-2*t3 + 3*t2) * p2.y + (t3 - t2) * m2y,
));
}
return out;
}
/**
* Shape-preserving monotone cubic (Fritsch-Carlson) through (x,y) knots.
* This must be C1: an ease-per-segment curve has zero slope at every knot, which prints
* a visible ripple onto a lofted body — the surface reads as a stack of terraces under
* grazing studio light. Monotone tangents keep it smooth AND overshoot-free, so a
* knot never bulges the panel between it and its neighbour.
*/
function curve1(knots){
const k = knots.slice().sort((a, b) => a[0] - b[0]);
const n = k.length;
if (n === 1) return () => k[0][1];
const d = new Array(n - 1), m = new Array(n);
for (let i = 0; i < n - 1; i++) d[i] = (k[i+1][1] - k[i][1]) / (k[i+1][0] - k[i][0]);
m[0] = d[0]; m[n-1] = d[n-2];
for (let i = 1; i < n - 1; i++) m[i] = (d[i-1] * d[i] <= 0) ? 0 : (d[i-1] + d[i]) * 0.5;
for (let i = 0; i < n - 1; i++){
if (d[i] === 0){ m[i] = 0; m[i+1] = 0; continue; }
const a = m[i] / d[i], b = m[i+1] / d[i], s = a*a + b*b;
if (s > 9){ const f = 3 / Math.sqrt(s); m[i] = f * a * d[i]; m[i+1] = f * b * d[i]; }
}
return function(x){
if (x <= k[0][0]) return k[0][1];
if (x >= k[n-1][0]) return k[n-1][1];
let i = 0; while (i < n - 2 && x > k[i+1][0]) i++;
const h = k[i+1][0] - k[i][0], t = (x - k[i][0]) / h;
const t2 = t * t, t3 = t2 * t;
return (2*t3 - 3*t2 + 1) * k[i][1] + (t3 - 2*t2 + t) * h * m[i]
+ (-2*t3 + 3*t2) * k[i+1][1] + (t3 - t2) * h * m[i+1];
};
}
/* --- core compiler: sampled row grid ----------------------------------------------- */
/**
* rows[r][c] -> Vector3. Emits an explicit seam column when closeU.
* Geometric normals from central differences; pole fallback from row centroids.
* `flip` reverses BOTH normal direction and index winding.
*/
function gridGeometry(rows, { closeU = true, flip = false, vRow = null, uRow = null, uScale = 1, uOffset = 0 } = {}){
const R = rows.length, C = rows[0].length;
const EC = closeU ? C + 1 : C;
const positions = new Float32Array(R * EC * 3);
const normals = new Float32Array(R * EC * 3);
const uvs = new Float32Array(R * EC * 2);
const wrap = (c) => ((c % C) + C) % C;
const P = (r, c) => rows[clamp(r, 0, R - 1)][closeU ? wrap(c) : clamp(c, 0, C - 1)];
const dU = V3(), dV = V3(), nrm = V3();
for (let r = 0; r < R; r++){
for (let c = 0; c < EC; c++){
const p = P(r, c), v = r * EC + c;
positions[v*3] = p.x; positions[v*3+1] = p.y; positions[v*3+2] = p.z;
dU.subVectors(P(r, c + 1), P(r, c - 1));
dV.subVectors(P(Math.min(R - 1, r + 1), c), P(Math.max(0, r - 1), c));
nrm.crossVectors(dU, dV);
if (nrm.lengthSq() < 1e-14){
const a = V3(), b = V3();
const pr = Math.max(0, r - 1), nr = Math.min(R - 1, r + 1);
for (const q of rows[pr]) a.add(q); a.divideScalar(C);
for (const q of rows[nr]) b.add(q); b.divideScalar(C);
nrm.subVectors(a, b);
if (nrm.lengthSq() < 1e-14) nrm.set(0, 1, 0);
}
nrm.normalize(); if (flip) nrm.negate();
normals[v*3] = nrm.x; normals[v*3+1] = nrm.y; normals[v*3+2] = nrm.z;
uvs[v*2] = uOffset + (uRow ? uRow[Math.min(c, uRow.length - 1)] : c / (EC - 1)) * uScale;
uvs[v*2+1] = vRow ? vRow[r] : r / (R - 1);
}
}
const idx = [];
for (let r = 0; r < R - 1; r++){
for (let c = 0; c < EC - 1; c++){
const a = r * EC + c, b = a + 1, cc = a + EC, d = cc + 1;
if (flip) idx.push(a, cc, b, b, cc, d);
else idx.push(a, b, cc, b, d, cc);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(positions, 3));
g.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
g.setIndex(idx);
g.computeBoundingSphere();
return g;
}
/** Longitudinal v from real accumulated centreline distance, so UV density is
* independent of output ring count. */
function distanceV(rows){
const v = [0];
const c0 = V3(), c1 = V3();
const centroid = (row, out) => { out.set(0,0,0); for (const p of row) out.add(p); return out.divideScalar(row.length); };
centroid(rows[0], c0);
let acc = 0;
for (let i = 1; i < rows.length; i++){
centroid(rows[i], c1);
acc += c0.distanceTo(c1);
v.push(acc);
c0.copy(c1);
}
const inv = 1 / (acc || 1);
return v.map(x => x * inv);
}
/* --- closed-loop lofting ------------------------------------------------------------ */
/**
* Loft a stack of closed 3D loops (all with equal point counts).
* `capStart` / `capEnd` add contracted rounded terminals instead of hard flat caps.
*/
function loftLoops(loops, { flip = false, capStart = false, capEnd = false, capSegs = 4, capBulge = 0.5, useDistanceV = true } = {}){
let rows = loops;
const cap = (loop, dir, bulgeScale) => {
const c = V3(); for (const p of loop) c.add(p); c.divideScalar(loop.length);
const nrm = V3();
{ // loop plane normal via Newell
for (let i = 0; i < loop.length; i++){
const a = loop[i], b = loop[(i + 1) % loop.length];
nrm.x += (a.y - b.y) * (a.z + b.z);
nrm.y += (a.z - b.z) * (a.x + b.x);
nrm.z += (a.x - b.x) * (a.y + b.y);
}
nrm.normalize().multiplyScalar(dir);
}
let radius = 0; for (const p of loop) radius = Math.max(radius, p.distanceTo(c));
const out = [];
for (let s = 1; s <= capSegs; s++){
const a = (s / capSegs) * PI * 0.5;
const shrink = Math.cos(a), push = Math.sin(a) * radius * bulgeScale;
out.push(loop.map(p => V3().copy(c).addScaledVector(V3().subVectors(p, c), shrink).addScaledVector(nrm, push)));
}
return out;
};
if (capStart) rows = [...cap(loops[0], -1, capBulge).reverse(), ...rows];
if (capEnd) rows = [...rows, ...cap(loops[loops.length - 1], 1, capBulge)];
return gridGeometry(rows, { closeU: true, flip, vRow: useDistanceV ? distanceV(rows) : null });
}
/* --- parallel-transport sweep of an arbitrary section ------------------------------- */
function transportFrames(points){
const n = points.length, tangents = [], normals = [], binormals = [];
for (let i = 0; i < n; i++){
const a = points[Math.max(0, i - 1)], b = points[Math.min(n - 1, i + 1)];
const t = V3().subVectors(b, a);
if (t.lengthSq() < 1e-16) t.copy(tangents[i - 1] || V3(0,0,1));
tangents.push(t.normalize());
}
let nrm = Math.abs(tangents[0].y) < 0.94 ? V3(0,1,0) : V3(1,0,0);
nrm = V3().crossVectors(tangents[0], V3().crossVectors(nrm, tangents[0])).normalize();
for (let i = 0; i < n; i++){
if (i > 0){
const axis = V3().crossVectors(tangents[i-1], tangents[i]);
if (axis.length() > 1e-7){
axis.normalize();
nrm = nrm.clone().applyAxisAngle(axis, Math.acos(clamp(tangents[i-1].dot(tangents[i]), -1, 1)));
}
}
normals.push(nrm.clone());
binormals.push(V3().crossVectors(tangents[i], nrm).normalize());
}
return { tangents, normals, binormals };
}
/**
* Sweep a 2D section (function of t -> array of Vector2 in the frame's normal/binormal
* plane) along a 3D path with parallel-transport frames.
*/
function sweepSection(path, sectionAt, { flip = false, capStart = false, capEnd = false, capSegs = 4, roll = null } = {}){
const F = transportFrames(path);
const loops = path.map((p, i) => {
const t = i / (path.length - 1);
const sec = sectionAt(t, i);
let N = F.normals[i], B = F.binormals[i];
if (roll){
const a = roll(t);
const ca = Math.cos(a), sa = Math.sin(a);
const n2 = V3().addScaledVector(N, ca).addScaledVector(B, sa);
const b2 = V3().addScaledVector(N, -sa).addScaledVector(B, ca);
N = n2; B = b2;
}
return sec.map(s => V3().copy(p).addScaledVector(N, s.x).addScaledVector(B, s.y));
});
return loftLoops(loops, { flip, capStart, capEnd, capSegs });
}
function sweepTube(path, radiusAt, segs = 16, opts = {}){
const rf = typeof radiusAt === 'number' ? () => radiusAt : radiusAt;
const circle = (r) => { const o = []; for (let i = 0; i < segs; i++){ const a = i / segs * TAU; o.push(V2(Math.cos(a) * r, Math.sin(a) * r)); } return o; };
return sweepSection(path, (t) => circle(Math.max(rf(t), 1e-4)), opts);
}
/** Rounded-rect / squircle section helper (width, height, corner exponent). */
function squircle(halfW, halfH, exp = 3.2, segs = 28){
const o = [];
for (let i = 0; i < segs; i++){
const a = i / segs * TAU;
const ca = Math.cos(a), sa = Math.sin(a);
o.push(V2(Math.sign(ca) * Math.pow(Math.abs(ca), 2 / exp) * halfW,
Math.sign(sa) * Math.pow(Math.abs(sa), 2 / exp) * halfH));
}
return o;
}
/* --- airfoils ----------------------------------------------------------------------- */
/**
* NACA-style section with independent thickness, camber, camber position, blunt trailing
* edge and leading-edge droop. Returns a CLOSED loop of Vector2 in (chord, thickness)
* with the leading edge at (0,0) and trailing edge at (1,0). Cosine spacing packs
* resolution into the leading edge where curvature is highest.
*/
function airfoilLoop(n, { thick = 0.11, camber = 0.06, camberPos = 0.4, teThick = 0.004, leRadius = 1 } = {}){
const half = Math.max(6, n >> 1);
const xs = [];
for (let i = 0; i <= half; i++) xs.push(0.5 - 0.5 * Math.cos(PI * i / half));
const yt = (x) => 5 * thick * (0.2969 * Math.sqrt(x) * leRadius - 0.1260 * x - 0.3516 * x*x + 0.2843 * x*x*x - 0.1015 * x*x*x*x)
+ teThick * 0.5 * x;
const yc = (x) => {
const m = camber, p = clamp(camberPos, 0.05, 0.95);
return x < p ? m / (p*p) * (2*p*x - x*x) : m / ((1-p)*(1-p)) * ((1 - 2*p) + 2*p*x - x*x);
};
const dyc = (x) => {
const m = camber, p = clamp(camberPos, 0.05, 0.95);
return x < p ? 2*m / (p*p) * (p - x) : 2*m / ((1-p)*(1-p)) * (p - x);
};
const up = [], lo = [];
for (const x of xs){
const th = Math.atan(dyc(x)), t = yt(x), c = yc(x);
up.push(V2(x - t * Math.sin(th), c + t * Math.cos(th)));
lo.push(V2(x + t * Math.sin(th), c - t * Math.cos(th)));
}
// closed loop: upper LE->TE, blunt TE edge, lower TE->LE (LE pole shared once)
return [...up, ...lo.slice(1).reverse()];
}
/**
* Loft an airfoil across spanwise stations. Every station owns chord, thickness,
* camber, incidence, dihedral and a 3D leading-edge anchor — the real parameters an
* aerodynamicist works in, not a scaled box.
*/
function wingLoft(stations, { sectionPoints = 64, roundTips = false, flip = false } = {}){
const loops = stations.map(st => {
const loop = airfoilLoop(sectionPoints, st.section || {});
const inc = st.incidence || 0;
const ci = Math.cos(inc), si = Math.sin(inc);
const dih = st.dihedral || 0;
const cd = Math.cos(dih), sd = Math.sin(dih);
const chordDir = st.chordDir || V3(0, 0, -1); // trailing direction
const upDir = st.upDir || V3(0, 1, 0);
const spanDir = st.spanDir || V3(1, 0, 0);
return loop.map(p => {
// rotate section about the leading edge by the incidence angle
const cx = p.x * ci + p.y * si;
const cy = -p.x * si + p.y * ci;
const x = cx * st.chord, y = cy * st.chord;
// dihedral tips the section out of the chord plane about the chord axis
const yy = y * cd, sx = y * sd;
return V3().copy(st.le)
.addScaledVector(chordDir, x)
.addScaledVector(upDir, yy)
.addScaledVector(spanDir, sx);
});
});
// roundTips: true | false | [innerEnd, outerEnd]. An element that meets the car's
// centreline must NOT be capped there, or the mirrored half shows a seam.
const want = Array.isArray(roundTips) ? roundTips : [roundTips, roundTips];
const tip = (loop, dir) => {
const c = V3(); for (const p of loop) c.add(p); c.divideScalar(loop.length);
const out = [];
for (let s = 1; s <= 3; s++){
const a = (s / 3) * PI * 0.5;
out.push(loop.map(p => V3().copy(c).addScaledVector(V3().subVectors(p, c), Math.cos(a))
.addScaledVector(dir, Math.sin(a) * 0.006)));
}
return out;
};
if (want[1]){
const d1 = V3().subVectors(loops[loops.length-1][0], loops[loops.length-2][0]).normalize();
loops.push(...tip(loops[loops.length-1], d1));
}
if (want[0]){
const d0 = V3().subVectors(loops[0][0], loops[1][0]).normalize();
loops.unshift(...tip(loops[0], d0).reverse());
}
return loftLoops(loops, { flip, useDistanceV: true });
}
/* --- authored 2D outlines extruded into plates -------------------------------------- */
/**
* A plate is an authored outline (endplate, fence, strake, fin) extruded with a bevel,
* then optionally deformed by a 3D warp. Outline coordinates are real metres in the
* plate's own (u,v) frame; `warp(u,v,w)` returns the final world-space position, which
* is how curled endplates and twisted fences get their shape.
*/
function plate(outline, { thickness = 0.005, bevel = 0.0022, bevelSegs = 2, curveSegs = 1, warp = null, holes = [] } = {}){
const shape = new THREE.Shape(outline.map(p => (p.isVector2 ? p : V2(p[0], p[1]))));
for (const h of holes) shape.holes.push(new THREE.Path(h.map(p => (p.isVector2 ? p : V2(p[0], p[1])))));
const g = new THREE.ExtrudeGeometry(shape, {
depth: Math.max(thickness - bevel * 2, 1e-4),
bevelEnabled: bevel > 0, bevelThickness: bevel, bevelSize: bevel, bevelOffset: 0,
bevelSegments: bevelSegs, curveSegments: curveSegs, steps: 1,
});
g.translate(0, 0, -thickness * 0.5 + bevel);
if (warp){
const pos = g.attributes.position;
const p = V3();
for (let i = 0; i < pos.count; i++){
p.fromBufferAttribute(pos, i);
const q = warp(p.x, p.y, p.z);
pos.setXYZ(i, q.x, q.y, q.z);
}
pos.needsUpdate = true;
}
g.computeVertexNormals();
g.computeBoundingSphere();
return g;
}
/* --- lathes & revolutions ----------------------------------------------------------- */
/** Revolve an (radius, axisPos) profile about +X (wheels, hubs, discs, nuts). */
function latheX(profile, segs = 64, { flip = false, phase = 0 } = {}){
const rows = profile.map(p => {
const loop = [];
for (let i = 0; i < segs; i++){
const a = phase + i / segs * TAU;
loop.push(V3(p.y, Math.cos(a) * Math.max(p.x, 1e-5), Math.sin(a) * Math.max(p.x, 1e-5)));
}
return loop;
});
const vRow = [0]; let acc = 0;
for (let i = 1; i < profile.length; i++){
acc += Math.hypot(profile[i].x - profile[i-1].x, profile[i].y - profile[i-1].y);
vRow.push(acc);
}
for (let i = 0; i < vRow.length; i++) vRow[i] /= acc || 1;
return gridGeometry(rows, { closeU: true, flip, vRow });
}
/** Revolve an (radius, y) profile about +Y. */
function latheY(profile, segs = 48, { flip = false } = {}){
const rows = profile.map(p => {
const loop = [];
for (let i = 0; i < segs; i++){
const a = i / segs * TAU;
loop.push(V3(Math.cos(a) * Math.max(p.x, 1e-5), p.y, Math.sin(a) * Math.max(p.x, 1e-5)));
}
return loop;
});
return gridGeometry(rows, { closeU: true, flip, vRow: null });
}
/* --- transforms & assembly ---------------------------------------------------------- */
function clone(g){ return g.clone(); }
function xform(g, fn){
const pos = g.attributes.position, nor = g.attributes.normal;
const p = V3(), n = V3();
for (let i = 0; i < pos.count; i++){
p.fromBufferAttribute(pos, i);
const q = fn(p.x, p.y, p.z, i);
pos.setXYZ(i, q.x, q.y, q.z);
}
pos.needsUpdate = true;
g.computeVertexNormals();
return g;
}
function mirrorX(g){
const out = g.clone();
out.scale(-1, 1, 1);
const idx = out.index;
if (idx){ for (let i = 0; i < idx.count; i += 3){ const t = idx.getX(i); idx.setX(i, idx.getX(i+2)); idx.setX(i+2, t); } idx.needsUpdate = true; }
const n = out.attributes.normal;
for (let i = 0; i < n.count; i++) n.setX(i, -n.getX(i));
n.needsUpdate = true;
out.computeBoundingSphere();
return out;
}
function merge(list){
const clean = list.filter(Boolean);
if (clean.length === 1) return clean[0];
const out = THREE.BufferGeometryUtils
? THREE.BufferGeometryUtils.mergeGeometries(clean)
: mergeManual(clean);
return out;
}
function mergeManual(geos){
let vTotal = 0, iTotal = 0;
for (const g of geos){ vTotal += g.attributes.position.count; iTotal += g.index ? g.index.count : g.attributes.position.count; }
const pos = new Float32Array(vTotal * 3), nor = new Float32Array(vTotal * 3), uvs = new Float32Array(vTotal * 2);
const idx = vTotal > 65535 ? new Uint32Array(iTotal) : new Uint16Array(iTotal);
let vo = 0, io = 0;
for (const g of geos){
const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv;
pos.set(p.array.subarray(0, p.count * 3), vo * 3);
if (n) nor.set(n.array.subarray(0, n.count * 3), vo * 3);
if (u) uvs.set(u.array.subarray(0, u.count * 2), vo * 2);
if (g.index){ for (let i = 0; i < g.index.count; i++) idx[io++] = g.index.getX(i) + vo; }
else { for (let i = 0; i < p.count; i++) idx[io++] = i + vo; }
vo += p.count;
}
const out = new THREE.BufferGeometry();
out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
out.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
out.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
out.setIndex(new THREE.BufferAttribute(idx, 1));
out.computeBoundingSphere();
return out;
}
/** Rigid placement without an extra Object3D per detail. */
function place(g, { pos = [0,0,0], rot = [0,0,0], scale = [1,1,1] } = {}){
const m = new THREE.Matrix4().compose(
new THREE.Vector3(...pos),
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rot, 'XYZ')),
new THREE.Vector3(...scale));
const out = g.clone();
out.applyMatrix4(m);
return out;
}
export {
TAU, PI, V2, V3, clamp01, lerp, clamp, smooth01, smoother01, deg,
mulberry32, PART_LOG, logPart, spline2, curve1, gridGeometry, distanceV,
loftLoops, transportFrames, sweepSection, sweepTube, squircle, airfoilLoop,
wingLoft, plate, latheX, latheY, clone, xform, mirrorX, merge, mergeManual, place,
};
examples/formula-one-race-car/source/race-car-materials.js
import * as THREE from "three/webgpu";
import {
color, float, fract, mix, mx_noise_float, normalWorld, positionLocal, positionWorld,
pow, saturate, sin, smoothstep, step, texture, uniform, uv, vec2, vec3,
} from "three/tsl";
import { TAU } from "./mesh-kit.js";
import { PAL, PROJ } from "./design-contract.js";
import {
buildCarbonTexture, buildLiverySide, buildLiveryTop, buildTyreWall, buildWheelFace,
} from "./race-car-textures.js";
/*
MATERIALS
One paint mechanism drives every painted panel: a two-plane livery projector whose
plane weight comes from the world normal. Everything else (carbon, rubber, metals,
glass) is an explicit authored identity — no material is a tinted copy of another.
*/
const MAT = {};
const TEX = {};
const U = {
livery: uniform(1.0), // paint coverage — 0 shows the bare structural finish
projDbg: uniform(0.0), // projector-field diagnostic
wear: uniform(0.35),
discGlow: uniform(0.18),
sideSwap: uniform(0.0), // which projector layer paints which flank (glyph handedness)
};
function buildMaterials(){
TEX.side = buildLiverySide();
TEX.top = buildLiveryTop();
TEX.carbon = buildCarbonTexture(512);
TEX.tyre = buildTyreWall();
TEX.face = buildWheelFace();
/* ---- the projector -------------------------------------------------------------- */
const P = positionWorld, N = normalWorld;
const uLong = float(PROJ.Z1).sub(P.z).div(PROJ.zSpan);
const tSide = float(PROJ.Y1).sub(P.y).div(PROJ.hSpan);
// Layer 0 (v in [0.5,1]) and layer 1 (v in [0,0.5]) differ ONLY in glyph handedness.
// Which one belongs to which flank depends on the texture upload's Y orientation, so
// the choice is a switch rather than an assumption.
const sx = step(0.0, P.x);
const sel = mix(sx, float(1.0).sub(sx), U.sideSwap);
const layerBase = sel.mul(0.5).add(0.5);
const vSide = layerBase.sub(tSide);
const uvSide = vec2(uLong, vSide);
const vTop = float(1.0).sub(float(PROJ.X1).sub(P.x).div(PROJ.X1 * 2.0));
const uvTop = vec2(uLong, vTop);
const sideS = texture(TEX.side, uvSide);
const topS = texture(TEX.top, uvTop);
// normalised two-plane blend: a shoulder at 45 degrees must commit to one plane, not
// take half the paint from each and print the graphic twice
const wUp = pow(saturate(N.y), 4.0); // down-facing surfaces never take plan paint
const wSide = pow(N.x.abs(), 4.0).add(pow(N.z.abs(), 4.0).mul(0.40));
const topW = wUp.div(wUp.add(wSide).add(0.0004));
const inBand = step(0.0, uLong).mul(step(uLong, 1.0));
// Nothing under the car is painted: a downward-facing surface low in the frame is
// structural carbon, so the projector must not smear the flank graphics onto it.
const underKill = smoothstep(-0.12, -0.55, N.y).mul(smoothstep(0.36, 0.17, P.y));
const liveryRGB = mix(sideS.xyz, topS.xyz, topW);
const liveryA = mix(sideS.w, topS.w, topW)
.mul(inBand).mul(float(1.0).sub(underKill)).mul(U.livery);
/* ---- carbon substrate ----------------------------------------------------------- */
const weaveUV = uv().mul(vec2(1, 1));
const weave = texture(TEX.carbon, weaveUV);
const grainN = mx_noise_float(P.mul(2.4)).mul(0.5).add(0.5);
const flake = mx_noise_float(P.mul(190.0)).mul(0.5).add(0.5);
const carbonBase = mix(color(PAL.carbon), color(PAL.carbonWarm), weave.w.mul(0.85));
const paintBase = mix(color(PAL.bodyBlack), color(PAL.bodyGloss), grainN.mul(0.55))
.add(color(0x20242c).mul(flake.mul(flake).mul(0.10)));
/* ---- painted bodywork ----------------------------------------------------------- */
function paintedBody({ name, weaveRepeat = [70, 110], base = paintBase, rough = 0.46, cc = 0.28, ccRough = 0.215, side = THREE.FrontSide }){
const m = new THREE.MeshPhysicalNodeMaterial({ side });
m.name = name;
const substrate = mix(base, carbonBase, float(0.30).mul(weave.w.mul(0.6).add(0.4)));
const col = mix(substrate, liveryRGB, liveryA);
const dbg = mix(vec3(0.05, 0.10, 0.55), vec3(0.95, 0.42, 0.05), topW);
m.colorNode = mix(col, dbg, U.projDbg);
// gloss decals over satin paint; the weave adds micro-roughness where paint is thin
m.roughnessNode = mix(
float(rough).add(weave.w.mul(0.055)).sub(grainN.mul(0.03)),
float(0.135),
liveryA.mul(0.85));
m.metalnessNode = float(0.0).add(flake.mul(flake).mul(0.05));
m.clearcoat = cc;
m.clearcoatRoughness = ccRough;
const nm = TEX.carbon.clone();
nm.needsUpdate = true;
nm.repeat.set(weaveRepeat[0], weaveRepeat[1]);
nm.wrapS = nm.wrapT = THREE.RepeatWrapping;
m.normalMap = nm;
m.normalScale = new THREE.Vector2(0.14, 0.14);
return m;
}
MAT.body = paintedBody({ name: 'body', weaveRepeat: [46, 74] });
MAT.bodyFine = paintedBody({ name: 'bodyFine', weaveRepeat: [30, 30] });
MAT.wing = paintedBody({ name: 'wing', weaveRepeat: [26, 96], rough: 0.42, cc: 0.34, ccRough: 0.18, side: THREE.DoubleSide });
MAT.plate = paintedBody({ name: 'plate', weaveRepeat: [18, 18], rough: 0.44, cc: 0.30, ccRough: 0.20, side: THREE.DoubleSide });
/* ---- exposed structural carbon --------------------------------------------------- */
function carbon({ name, repeat = [24, 24], rough = 0.30, cc = 0.85, ccr = 0.12, tint = 1.0, side = THREE.FrontSide }){
const m = new THREE.MeshPhysicalNodeMaterial({ side });
m.name = name;
m.colorNode = carbonBase.mul(tint);
m.roughnessNode = float(rough).add(weave.w.mul(0.08)).sub(grainN.mul(0.02));
m.metalnessNode = float(0.02);
m.clearcoat = cc; m.clearcoatRoughness = ccr;
const nm = TEX.carbon.clone(); nm.needsUpdate = true;
nm.repeat.set(repeat[0], repeat[1]); nm.wrapS = nm.wrapT = THREE.RepeatWrapping;
m.normalMap = nm; m.normalScale = new THREE.Vector2(0.42, 0.42);
return m;
}
MAT.carbonGloss = carbon({ name: 'carbonGloss', repeat: [40, 40], rough: 0.36, cc: 0.50, ccr: 0.18 });
MAT.carbonFloor = carbon({ name: 'carbonFloor', repeat: [60, 130], rough: 0.40, cc: 0.45, ccr: 0.20, side: THREE.DoubleSide });
MAT.carbonMatte = carbon({ name: 'carbonMatte', repeat: [30, 30], rough: 0.52, cc: 0.25, ccr: 0.4, tint: 0.86, side: THREE.DoubleSide });
MAT.carbonDuct = carbon({ name: 'carbonDuct', repeat: [22, 22], rough: 0.62, cc: 0.10, ccr: 0.5, tint: 0.42, side: THREE.DoubleSide });
/* ---- floor + diffuser take the projector too (crimson edge line) ----------------- */
{
const m = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
m.name = 'floor';
const col = mix(carbonBase, liveryRGB, liveryA);
m.colorNode = mix(col, mix(vec3(0.05,0.10,0.55), vec3(0.95,0.42,0.05), topW), U.projDbg);
m.roughnessNode = mix(float(0.42).add(weave.w.mul(0.08)), float(0.20), liveryA);
m.metalnessNode = float(0.02);
m.clearcoat = 0.40; m.clearcoatRoughness = 0.20;
const nm = TEX.carbon.clone(); nm.needsUpdate = true;
nm.repeat.set(70, 150); nm.wrapS = nm.wrapT = THREE.RepeatWrapping;
m.normalMap = nm; m.normalScale = new THREE.Vector2(0.36, 0.36);
MAT.floor = m;
}
/* ---- metals ---------------------------------------------------------------------- */
function metal({ name, col, rough, metalness = 1.0, cc = 0, ccr = 0.2 }){
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = name;
m.colorNode = mix(color(col), color(col).mul(0.74), mx_noise_float(P.mul(34)).mul(0.5).add(0.5).mul(0.5));
m.roughnessNode = float(rough).add(mx_noise_float(P.mul(80)).mul(0.5).add(0.5).mul(0.09));
m.metalness = metalness;
m.clearcoat = cc; m.clearcoatRoughness = ccr;
return m;
}
MAT.titanium = metal({ name: 'titanium', col: 0x5e6167, rough: 0.46 });
MAT.steel = metal({ name: 'steel', col: PAL.steel, rough: 0.18 });
MAT.darkAlloy = metal({ name: 'darkAlloy',col: 0x3a3d43, rough: 0.40 });
MAT.gold = metal({ name: 'gold', col: PAL.goldTint, rough: 0.26 });
MAT.anodRed = metal({ name: 'anodRed', col: 0x8e1024, rough: 0.22, cc: 0.6, ccr: 0.15 });
MAT.anodBlue = metal({ name: 'anodBlue', col: 0x1d2f4a, rough: 0.25 });
/* heat-tinted inconel exhaust: temperature runs along the pipe */
{
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'exhaust';
const heat = saturate(P.z.add(1.60).mul(-1.6));
const c1 = color(0x6a6560), c2 = color(0x7d5a44), c3 = color(0x4a5a72);
m.colorNode = mix(mix(c1, c2, saturate(heat.mul(1.7))), c3, saturate(heat.sub(0.55).mul(2.0)));
m.roughnessNode = float(0.30).add(mx_noise_float(P.mul(120)).mul(0.5).add(0.5).mul(0.18));
m.metalness = 1.0;
MAT.exhaust = m;
}
/* ---- rubber ---------------------------------------------------------------------- */
{
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'rubber';
const wall = texture(TEX.tyre, uv());
const grain = mx_noise_float(positionLocal.mul(320)).mul(0.5).add(0.5);
const base = mix(color(PAL.tyre), color(PAL.tyreShoulder), grain.mul(0.7));
// v ~ 0.5 is the crown: scuffed, slightly glossier than the sidewalls
const crown = smoothstep(0.30, 0.46, uv().y).mul(smoothstep(0.70, 0.54, uv().y));
m.colorNode = mix(base, wall.xyz.add(base.mul(0.35)), wall.xyz.length().mul(1.4).clamp(0, 1))
.mul(float(1.0).add(crown.mul(0.16)));
m.roughnessNode = float(0.86).sub(crown.mul(0.24)).sub(grain.mul(0.10));
m.metalness = 0.0;
m.sheen = 0.35; m.sheenRoughness = 0.75;
m.sheenColor = new THREE.Color(0x3a3a3e);
m.clearcoat = 0.10; m.clearcoatRoughness = 0.65;
MAT.rubber = m;
}
/* Mirroring the wheel geometry mirrors its UVs too, so one flank needs the sidewall
wordmark sampled in reverse or the moulded lettering reads backwards. */
{
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'rubber.mirror';
const flipUV = vec2(float(1.0).sub(uv().x), uv().y);
const wall = texture(TEX.tyre, flipUV);
const grain = mx_noise_float(positionLocal.mul(320)).mul(0.5).add(0.5);
const base = mix(color(PAL.tyre), color(PAL.tyreShoulder), grain.mul(0.7));
const crown = smoothstep(0.30, 0.46, uv().y).mul(smoothstep(0.70, 0.54, uv().y));
m.colorNode = mix(base, wall.xyz.add(base.mul(0.35)), wall.xyz.length().mul(1.4).clamp(0, 1))
.mul(float(1.0).add(crown.mul(0.16)));
m.roughnessNode = float(0.86).sub(crown.mul(0.24)).sub(grain.mul(0.10));
m.metalness = 0.0;
m.sheen = 0.35; m.sheenRoughness = 0.75;
m.sheenColor = new THREE.Color(0x3a3a3e);
m.clearcoat = 0.10; m.clearcoatRoughness = 0.65;
MAT.rubberMirror = m;
}
/* ---- wheel rim & aero cover ------------------------------------------------------ */
{
const m = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
m.name = 'rim';
const ring = mx_noise_float(positionLocal.mul(70)).mul(0.5).add(0.5);
m.colorNode = mix(color(0x121317), color(0x1e2025), ring.mul(0.55));
m.roughnessNode = float(0.56).add(ring.mul(0.10));
m.metalness = 0.55;
m.clearcoat = 0.15; m.clearcoatRoughness = 0.40;
MAT.rim = m;
}
{
// lathe UV: u runs around the rim, v across the profile — no angle reconstruction needed
const m = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
m.name = 'rimCover';
const r = vec2(positionLocal.y, positionLocal.z).length();
const spoke = sin(uv().x.mul(TAU * 10.0)).mul(0.5).add(0.5);
const vane = smoothstep(0.42, 0.46, fract(uv().x.mul(20.0)));
m.colorNode = mix(color(0x0a0b0e), color(0x2b2e35), spoke.mul(smoothstep(0.070, 0.19, r)).mul(0.9).add(vane.mul(0.22)));
m.roughnessNode = float(0.50).add(spoke.mul(0.08));
m.metalness = 0.20;
m.clearcoat = 0.30; m.clearcoatRoughness = 0.30;
MAT.rimCover = m;
}
/* ---- carbon-carbon brake disc, hot ----------------------------------------------- */
{
const m = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
m.name = 'brakeDisc';
const r = vec2(positionLocal.y, positionLocal.z).length();
const rings = sin(r.mul(430.0)).mul(0.5).add(0.5);
const drilled = sin(uv().x.mul(TAU * 36.0)).mul(0.5).add(0.5);
const heat = smoothstep(0.075, 0.155, r).mul(U.discGlow);
m.colorNode = mix(color(0x2a2724), color(0x413a34), rings.mul(0.6).add(drilled.mul(0.2)));
m.emissiveNode = color(PAL.brakeGlow).mul(heat.mul(2.4));
m.roughnessNode = float(0.60).sub(rings.mul(0.10));
m.metalness = 0.15;
MAT.brakeDisc = m;
}
/* ---- glass, lenses, lights -------------------------------------------------------- */
{
const m = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
m.name = 'visor';
m.colorNode = color(0x0b0d10);
m.roughness = 0.045; m.metalness = 0.0;
m.transmission = 0.72; m.ior = 1.52; m.thickness = 0.006;
m.attenuationColor = new THREE.Color(0x1a1208);
m.attenuationDistance = 0.02;
m.clearcoat = 1.0; m.clearcoatRoughness = 0.02;
m.iridescence = 0.55; m.iridescenceIOR = 1.9; m.iridescenceThicknessRange = [180, 520];
MAT.visor = m;
}
{
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'mirror';
m.colorNode = color(0xdfe4ea);
m.roughness = 0.035; m.metalness = 1.0;
MAT.mirror = m;
}
{
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'lens';
m.colorNode = color(0x05070b);
m.roughness = 0.03; m.metalness = 0.1;
m.clearcoat = 1.0; m.clearcoatRoughness = 0.02;
MAT.lens = m;
}
{
const m = new THREE.MeshStandardNodeMaterial();
m.name = 'rainLight';
const grid = step(0.35, fract(uv().x.mul(9.0))).mul(step(0.30, fract(uv().y.mul(4.0))));
m.colorNode = color(0x3a0208);
m.emissiveNode = color(PAL.rainLight).mul(grid.mul(9.0).add(0.35));
m.roughness = 0.35; m.metalness = 0.0;
MAT.rainLight = m;
}
{
const m = new THREE.MeshBasicNodeMaterial();
m.name = 'display';
m.colorNode = texture(TEX.face, uv()).mul(1.6);
MAT.display = m;
}
/* ---- driver ---------------------------------------------------------------------- */
MAT.helmet = (() => {
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'helmet';
const band = smoothstep(0.40, 0.44, uv().y).mul(smoothstep(0.62, 0.58, uv().y));
const crest = smoothstep(0.72, 0.76, uv().y);
m.colorNode = mix(mix(color(PAL.helmetWhite), color(PAL.red), band), color(PAL.carbon), crest.mul(0.85));
m.roughnessNode = float(0.10);
m.metalness = 0.0; m.clearcoat = 1.0; m.clearcoatRoughness = 0.03;
return m;
})();
MAT.suit = (() => {
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'suit';
const n = mx_noise_float(positionLocal.mul(220)).mul(0.5).add(0.5);
m.colorNode = mix(color(0x15161a), color(0x24262b), n);
m.roughnessNode = float(0.80).sub(n.mul(0.10));
m.metalness = 0.0; m.sheen = 0.5; m.sheenRoughness = 0.8;
m.sheenColor = new THREE.Color(0x585c62);
return m;
})();
MAT.padding = (() => {
const m = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
m.name = 'padding';
const n = mx_noise_float(positionLocal.mul(90)).mul(0.5).add(0.5);
m.colorNode = mix(color(0x101115), color(0x1c1e23), n);
m.roughnessNode = float(0.90).sub(n.mul(0.08));
m.metalness = 0.0;
return m;
})();
/* skid block: jabroc/wood composite under the plank */
MAT.plank = (() => {
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = 'plank';
const g = mx_noise_float(P.mul(vec3(6, 6, 90))).mul(0.5).add(0.5);
m.colorNode = mix(color(0x6a5540), color(0x3f3323), g).mul(float(1.0).sub(U.wear.mul(0.35)));
m.roughnessNode = float(0.72).sub(g.mul(0.12));
m.metalness = 0.0;
return m;
})();
MAT.wireframe = new THREE.MeshBasicNodeMaterial({ wireframe: true, transparent: true, opacity: 0.55 });
MAT.wireframe.colorNode = color(0x0e3d2a);
return MAT;
}
export { MAT, TEX, U, buildMaterials };
examples/formula-one-race-car/source/race-car-model.js
import * as THREE from "three/webgpu";
import {
PART_LOG, PI, TAU, V2, V3, airfoilLoop, clamp01, curve1, gridGeometry, latheX, latheY,
lerp, loftLoops, logPart, merge, mirrorX, plate, smooth01, smoother01, spline2, squircle,
sweepSection, sweepTube, wingLoft, xform,
} from "./mesh-kit.js";
import { D } from "./design-contract.js";
import { MAT, TEX, U, buildMaterials } from "./race-car-materials.js";
/* =====================================================================================
SECTION PLANS
Bodywork is authored the way it is drawn: as semantic cross-sections swept along the
car's longitudinal axis. Each section is a fixed set of NAMED control points, so a
parameter curve can move "max width" or "rim height" along the car without changing
topology. Triangle emission happens only at the end.
===================================================================================== */
/**
* Centreline body half-section, 13 semantic control points running from the top
* centreline, out over the deck/rim, down the flank, and back to the bottom centreline.
* `tub` opens the section into a cockpit recess without changing the point count, which
* is what lets the nose, cockpit and engine cover be ONE continuous loft.
*/
function chassisPts({ yRim, wRim, halfW, yShoulder, yBot, wBot, tub = 0, wTub = 0.18,
waist = 0, chine = 0.010, crown = 0.55, belly = 0.5 }){
const open = tub > 0.004;
const yFloor = yRim - tub;
const wFloor = open ? wTub : wRim * 0.30;
const wWall = open ? wTub * 1.10 : wRim * 0.66;
const wallT = open ? 0.62 : 0.42; // where the wall sample sits
const dInner = open ? 0.004 : (yRim - yFloor) * crown * 0.30;
return [
[0, yFloor],
[wFloor, yFloor + dInner],
[wWall, yFloor + (yRim - yFloor) * wallT],
[wRim * 0.94, yRim - (open ? 0.006 : 0.004)],
[wRim, yRim],
[lerp(wRim, halfW, 0.34), lerp(yRim, yShoulder, 0.26)],
[lerp(wRim, halfW, 0.76), lerp(yRim, yShoulder, 0.68)],
[halfW, yShoulder],
[lerp(halfW, wBot, 0.26) - waist, lerp(yShoulder, yBot, 0.30 * belly * 2)],
[lerp(halfW, wBot, 0.68) - waist * 0.55, lerp(yShoulder, yBot, 0.72)],
[wBot, yBot + chine],
[wBot * 0.52, yBot + chine * 0.16],
[0, yBot],
];
}
/**
* Offset closed section for volumes that are not on the centreline (sidepods).
* Four superellipse quadrants with independent exponents plus a lower-outboard
* undercut — the term that actually creates the coke-bottle.
*/
function podPts({ xIn, xOut, yTop, yBot, nTop = 3.6, nBot = 2.4, nIn = 2.6,
undercut = 0, ucY = 0.5, ucW = 0.30, shelf = 0 }, n = 60){
const cx = (xIn + xOut) * 0.5, cy = (yTop + yBot) * 0.5;
const rOut = xOut - cx, rIn = cx - xIn, rTop = yTop - cy, rBot = cy - yBot;
const pts = [];
const q = (count, fx, fy) => { for (let i = 0; i < count; i++){ const a = (i / count) * PI * 0.5; pts.push([fx(a), fy(a)]); } };
const sp = (v, e) => Math.pow(Math.max(v, 0), 2 / e);
const nq = Math.max(6, n >> 2);
q(nq, a => cx + rOut * sp(Math.sin(a), nTop), a => cy + rTop * sp(Math.cos(a), nTop)); // top -> outboard
q(nq, a => cx + rOut * sp(Math.cos(a), nBot), a => cy - rBot * sp(Math.sin(a), nBot)); // outboard -> bottom
q(nq, a => cx - rIn * sp(Math.sin(a), nBot), a => cy - rBot * sp(Math.cos(a), nBot)); // bottom -> inboard
q(nq, a => cx - rIn * sp(Math.cos(a), nIn), a => cy + rTop * sp(Math.sin(a), nIn)); // inboard -> top
if (undercut || shelf){
const yU = lerp(yBot, yTop, ucY), h = (yTop - yBot) * ucW;
for (const p of pts){
const out = clamp01((p[0] - cx) / Math.max(rOut, 1e-4));
if (undercut && out > 0){
const g = Math.exp(-Math.pow((p[1] - yU) / h, 2));
p[0] -= undercut * rOut * g * out;
}
if (shelf && out > 0.25 && p[1] > cy){ // flat aero shelf on the shoulder
const g = clamp01((p[1] - cy) / Math.max(rTop, 1e-4));
p[1] -= shelf * rTop * g * g * out;
}
}
}
return pts;
}
/** Resample an open half-outline and mirror it into a closed ring at station z. */
function ringFromHalf(pts, z, n){
const half = spline2(pts.map(p => V2(p[0], p[1])), n, { closed: false });
const loop = [];
for (let i = 0; i < n; i++) loop.push(V3(half[i].x, half[i].y, z));
for (let i = n - 2; i > 0; i--) loop.push(V3(-half[i].x, half[i].y, z));
return loop;
}
/** Resample a closed outline into a ring at station z. */
function ringFromClosed(pts, z, n){
const loop2 = spline2(pts.map(p => V2(p[0], p[1])), n, { closed: true });
return loop2.map(p => V3(p.x, p.y, z));
}
/** Evaluate a table of named parameter curves at z. */
function paramTrack(table){
const curves = {};
for (const k in table) curves[k] = curve1(table[k]);
return (z) => { const o = {}; for (const k in curves) o[k] = curves[k](z); return o; };
}
/** Longitudinal sampling, optionally eased so ends carry more rings than the middle. */
function stationsFrom(zStart, zEnd, count, ease = 0){
const zs = [];
for (let i = 0; i <= count; i++){
const t = i / count;
const e = ease ? lerp(t, t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t), ease) : t;
zs.push(lerp(zStart, zEnd, e));
}
return zs;
}
/* =====================================================================================
BODYWORK
The nose, survival cell, cockpit tub and engine cover are ONE continuous loft driven
by parameter curves. The cockpit is not a hole cut into a closed shell: the section
itself opens into a recess (`tub`) and closes again, exactly like a lofted surface
model. Sidepods, floor, diffuser, airbox and halo are separate volumes positioned
from the same contract.
===================================================================================== */
const BODY_RING = 96; // ring resolution of the main hull
const BODY_ROWS = 168; // longitudinal rings
/* ---- parameter curves for the main hull -------------------------------------------- */
const HULL = paramTrack({
// deck / rim height along the car
yRim: [[2.755,0.3520],[2.620,0.3960],[2.420,0.4400],[2.180,0.4850],[1.905,0.5460],
[1.700,0.5790],[1.450,0.5980],[1.200,0.6010],[1.075,0.5990],[0.900,0.5900],
[0.640,0.5830],[0.300,0.5790],[0.115,0.5760],[0.020,0.5900],[-0.070,0.6600],
[-0.150,0.7600],[-0.235,0.8400],[-0.340,0.8200],[-0.560,0.7620],[-0.860,0.6820],
[-1.150,0.5900],[-1.400,0.5020],[-1.560,0.4400],[-1.680,0.3900]],
// half-width of the deck / rim
wRim: [[2.755,0.0230],[2.620,0.0470],[2.420,0.0790],[2.180,0.1090],[1.905,0.1420],
[1.700,0.1720],[1.450,0.2180],[1.200,0.2680],[1.075,0.2860],[0.900,0.2960],
[0.640,0.3020],[0.300,0.3040],[0.115,0.3010],[0.020,0.2900],[-0.070,0.2500],
[-0.150,0.2050],[-0.235,0.1780],[-0.340,0.1720],[-0.560,0.1600],[-0.860,0.1380],
[-1.150,0.1100],[-1.400,0.0800],[-1.560,0.0600],[-1.680,0.0440]],
// absolute maximum half-width of the section
halfW: [[2.755,0.0300],[2.620,0.0620],[2.420,0.0990],[2.180,0.1340],[1.905,0.1780],
[1.700,0.2160],[1.450,0.2680],[1.200,0.3150],[1.075,0.3350],[0.900,0.3560],
[0.640,0.3720],[0.300,0.3860],[0.115,0.3840],[0.020,0.3760],[-0.070,0.3520],
[-0.150,0.3220],[-0.235,0.3020],[-0.340,0.2960],[-0.560,0.2760],[-0.860,0.2380],
[-1.150,0.1900],[-1.400,0.1380],[-1.560,0.1020],[-1.680,0.0740]],
yShoulder: [[2.755,0.3260],[2.620,0.3480],[2.420,0.3720],[2.180,0.3900],[1.905,0.3980],
[1.700,0.3900],[1.450,0.3720],[1.200,0.3560],[1.075,0.3480],[0.900,0.3400],
[0.640,0.3340],[0.300,0.3280],[0.115,0.3260],[0.020,0.3320],[-0.070,0.3600],
[-0.235,0.4200],[-0.560,0.4100],[-0.860,0.3760],[-1.150,0.3340],[-1.400,0.2960],
[-1.680,0.2620]],
yBot: [[2.755,0.3020],[2.620,0.2880],[2.420,0.2760],[2.180,0.2660],[1.905,0.2520],
[1.700,0.2100],[1.450,0.1620],[1.200,0.1340],[1.075,0.1260],[0.900,0.1200],
[0.640,0.1160],[0.300,0.1140],[0.115,0.1140],[0.020,0.1160],[-0.070,0.1220],
[-0.235,0.1400],[-0.560,0.1620],[-0.860,0.1800],[-1.150,0.1960],[-1.400,0.2120],
[-1.680,0.2280]],
wBot: [[2.755,0.0200],[2.620,0.0430],[2.420,0.0740],[2.180,0.1000],[1.905,0.1320],
[1.700,0.1680],[1.450,0.2020],[1.200,0.2280],[1.075,0.2380],[0.900,0.2460],
[0.640,0.2520],[0.300,0.2540],[0.115,0.2520],[0.020,0.2460],[-0.070,0.2320],
[-0.235,0.2020],[-0.560,0.1780],[-0.860,0.1500],[-1.150,0.1180],[-1.400,0.0860],
[-1.680,0.0600]],
// cockpit recess: opens just behind the front bulkhead, closes at the headrest
tub: [[1.180,0.0000],[1.120,0.0400],[1.020,0.2500],[0.900,0.2860],[0.500,0.2960],
[0.220,0.2900],[0.115,0.2680],[0.040,0.1200],[-0.010,0.0000]],
wTub: [[1.120,0.0700],[1.000,0.1500],[0.860,0.1880],[0.500,0.2020],[0.220,0.1960],
[0.080,0.1500],[0.000,0.0700]],
// lateral waist between the shoulder and the floor joint
waist:[[2.755,0.0000],[1.905,0.0060],[1.450,0.0180],[1.075,0.0280],[0.640,0.0320],
[0.115,0.0300],[-0.235,0.0220],[-0.860,0.0140],[-1.680,0.0040]],
chine:[[2.755,0.0040],[1.905,0.0090],[1.075,0.0130],[0.115,0.0140],[-0.560,0.0120],[-1.680,0.0070]],
crown:[[2.755,0.7000],[1.905,0.6200],[1.200,0.5400],[-0.235,0.5000],[-1.680,0.5600]],
});
function buildHull(){
const zs = stationsFrom(D.noseTipZ, D.engineCoverEndZ, BODY_ROWS, 0.35);
const loops = zs.map(z => ringFromHalf(chassisPts(HULL(z)), z, BODY_RING / 2 + 1));
const g = loftLoops(loops, { capStart: true, capEnd: true, capSegs: 5, capBulge: 0.42 });
return logPart('hull:nose+monocoque+enginecover', g);
}
/** Padded liner just inside the cockpit recess, so the paint projector never shows
* through the opening. It reuses the hull's own section points, contracted toward the
* recess axis. Emitted as an OPEN sheet — closing the ring here would lid the cockpit. */
function buildCockpitLiner(){
const zs = stationsFrom(1.140, 0.005, 62);
const rows = zs.map(z => {
const p = HULL(z);
const pts = chassisPts(p).slice(0, 5);
const axis = V2(0, p.yRim);
const inset = 0.0045;
const shrunk = pts.map(q => {
const dx = q[0] - axis.x, dy = q[1] - axis.y, L = Math.hypot(dx, dy) || 1;
return V2(q[0] - (dx / L) * inset, q[1] - (dy / L) * inset);
});
shrunk[0].x = 0;
const half = spline2(shrunk, 24, { closed: false });
const row = [];
for (let i = 23; i > 0; i--) row.push(V3(-half[i].x, half[i].y, z)); // left rim -> centre
for (let i = 0; i < 24; i++) row.push(V3( half[i].x, half[i].y, z)); // centre -> right rim
return row;
});
return logPart('cockpit:liner', gridGeometry(rows, { closeU: false, flip: true }));
}
/** Cockpit rim: a swept bead around the opening — the crisp highlight that reads the
* aperture in every three-quarter view. The bead half-width tapers to zero at both
* ends of the aperture so the loop closes on the centreline without a spike. */
function buildCockpitRim(){
const zF = 1.152, zR = 0.002;
const zs = stationsFrom(zF, zR, 66);
const path = zs.map(z => {
const p = HULL(z);
const taper = smooth01(clamp01((zF - z) / 0.115)) * smooth01(clamp01((z - zR) / 0.100));
return V3(p.wRim * taper, p.yRim + 0.0030 * taper, z);
});
const sec = (t) => {
const s = Math.min(smooth01(clamp01(t / 0.10)), smooth01(clamp01((1 - t) / 0.10)));
return squircle(0.0135 * lerp(0.35, 1, s), 0.0090 * lerp(0.35, 1, s), 2.6, 12);
};
const a = sweepSection(path, sec, { capStart: true, capEnd: true, capSegs: 3 });
return logPart('cockpit:rim', merge([a, mirrorX(a)]));
}
/* ---- sidepods ---------------------------------------------------------------------- */
/* A 2026 sidepod is LOW and slab-sided, not a bulbous pontoon: a flat aero shelf on top,
a hard shoulder, and a deep undercut that eats the lower outboard corner. */
const POD = paramTrack({
xIn: [[0.768,0.2660],[0.640,0.2520],[0.400,0.2400],[0.000,0.2320],
[-0.400,0.2340],[-0.900,0.2120],[-1.320,0.1620]],
xOut: [[0.768,0.5560],[0.640,0.6440],[0.400,0.7000],[0.000,0.7160],
[-0.400,0.6780],[-0.900,0.5160],[-1.320,0.2420]],
// straight top and bottom rails: an F1 sidepod is a wedge in profile, not a lozenge
yTop: [[0.768,0.4640],[0.640,0.4740],[0.400,0.4780],[0.000,0.4700],
[-0.400,0.4460],[-0.900,0.4020],[-1.320,0.3480]],
yBot: [[0.768,0.2520],[0.640,0.2280],[0.400,0.2100],[0.000,0.2000],
[-0.400,0.2020],[-0.900,0.2220],[-1.320,0.2640]],
undercut: [[0.768,0.0400],[0.640,0.1800],[0.400,0.4200],[0.000,0.5800],
[-0.400,0.6200],[-0.900,0.5000],[-1.320,0.2800]],
ucY: [[0.768,0.3000],[0.400,0.2200],[0.000,0.1700],[-0.900,0.2200],[-1.320,0.3000]],
ucW: [[0.768,0.4200],[0.000,0.3600],[-1.320,0.4200]],
shelf:[[0.768,0.0800],[0.400,0.2200],[0.000,0.2600],[-0.900,0.1800],[-1.320,0.0600]],
// very high top exponent => flat aero shelf meeting the flank on a hard crease
nTop: [[0.768,5.0000],[0.400,6.2000],[0.000,6.6000],[-0.600,5.4000],[-1.320,3.6000]],
nBot: [[0.768,3.2000],[0.000,3.6000],[-1.320,2.8000]],
nIn: [[0.768,3.2000],[0.000,3.4000],[-1.320,3.0000]],
});
const POD_FRONT_Z = 0.7680; // the plane the inlet mouth lives in
function buildSidepod(){
// front is left OPEN: the inlet lip ring closes it with a real aperture
const zs = stationsFrom(POD_FRONT_Z, -1.320, 100, 0.25);
const loops = zs.map(z => ringFromClosed(podPts(POD(z), 64), z, 72));
const body = loftLoops(loops, { capStart: false, capEnd: true, capSegs: 5, capBulge: 0.55 });
return logPart('sidepod:shell', body);
}
/** Inlet: a real aperture — the lip ring's OUTER outline is the sidepod's own front
* section, so mouth, lip and shell share one parameterisation and cannot drift apart. */
function buildSidepodInlet(){
const zLip = POD_FRONT_Z;
const p = POD(zLip);
const outer = podPts(p, 64);
const cx = (p.xIn + p.xOut) * 0.5, cy = (p.yTop + p.yBot) * 0.5;
const inner = outer.map(q => [cx + (q[0] - cx) * 0.815, cy + (q[1] - cy) * 0.660]);
const lip = plate(spline2(outer.map(q => V2(q[0], q[1])), 110, { closed: true }), {
thickness: 0.026, bevel: 0.0065, bevelSegs: 3,
holes: [spline2(inner.map(q => V2(q[0], q[1])), 96, { closed: true }).reverse()],
});
lip.translate(0, 0, zLip - 0.004);
// throat: contracts and turns inboard as it runs back toward the radiator face
const throat = [];
for (let i = 0; i <= 26; i++){
const t = i / 26, z = lerp(zLip - 0.014, 0.336, t);
const s = lerp(1.0, 0.54, smooth01(t));
const shift = -0.100 * smooth01(t);
throat.push(ringFromClosed(inner.map(q => [cx + (q[0] - cx) * s + shift, cy + (q[1] - cy) * s - 0.012 * t]), z, 60));
}
const duct = loftLoops(throat, { flip: true, capEnd: true, capSegs: 3 });
return { lip: logPart('sidepod:inletLip', lip), duct: logPart('sidepod:inletDuct', duct) };
}
/** Cooling exit louvres: a compact stack of longitudinal slots on the engine-cover
* shoulder, each one sunk into the local surface rather than floating on it. */
function buildLouvres(){
const parts = [];
const ROWS = 5, COLS = 4;
for (let row = 0; row < ROWS; row++){
for (let i = 0; i < COLS; i++){
const z = -0.560 - i * 0.132;
const p = HULL(z);
const t = row / (ROWS - 1);
const yc = lerp(p.yRim - 0.020, p.yShoulder + 0.020, t);
const w = (0.088 - i * 0.011) * (1 - 0.20 * Math.abs(t - 0.5) * 2);
const h = 0.0085;
const outline = [];
const N = 22;
for (let k = 0; k < N; k++){
const a = k / N * TAU;
outline.push(V2(Math.cos(a) * w * 0.5, Math.sin(a) * h * 0.5));
}
const g = plate(outline, { thickness: 0.016, bevel: 0.0022 });
g.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
// ride the flank: interpolate x between the rim crest and the max-width station
const xr = lerp(p.wRim, p.halfW, smooth01(t)) - 0.004;
g.translate(xr, yc, z);
parts.push(g, mirrorX(g));
}
}
return logPart('enginecover:louvres', merge(parts));
}
/* ---- airbox and roll structure ----------------------------------------------------- */
/** Rounded-triangle roll-hoop intake. Shell, lip and throat are ONE folded surface:
* the loft runs back over the outside, turns through the lip, and continues forward
* into the duct — so the aperture can never separate from the bodywork around it. */
function buildAirbox(){
const sec = (w, hT, hB, squash) => {
const pts = [];
const N = 48;
for (let i = 0; i < N; i++){
const a = i / N * TAU, sx = Math.sin(a), cy = Math.cos(a);
const rx = w * Math.sign(sx) * Math.pow(Math.abs(sx), 0.72);
const cyc = (hT + hB) * 0.5, ry = (hT - hB) * 0.5;
const shape = cy > 0 ? Math.pow(Math.abs(cy), 0.60) : -Math.pow(Math.abs(cy), 1.40);
pts.push([rx * (cy > 0 ? 1 - squash * cy : 1), cyc + ry * shape]);
}
return pts;
};
const rows = [];
// 1. outer shell, rear -> front
const back = stationsFrom(-0.585, -0.082, 40);
for (const z of back){
const t = clamp01((-0.082 - z) / 0.503);
rows.push(ringFromClosed(sec(lerp(0.1270, 0.1930, smooth01(Math.min(t * 1.55, 1))),
lerp(0.9430, 0.8760, smooth01(t)),
lerp(0.7580, 0.6960, smooth01(t)), 0.32), z, 60));
}
// 2. lip roll-over at the mouth plane
for (let i = 1; i <= 4; i++){
const t = i / 5;
rows.push(ringFromClosed(sec(lerp(0.1270, 0.1055, t), lerp(0.9430, 0.9200, t),
lerp(0.7580, 0.7760, t), 0.32), -0.082 + 0.010 * Math.sin(PI * t), 60));
}
// 3. throat running back inside, contracting
for (let i = 0; i <= 12; i++){
const t = i / 12, z = lerp(-0.086, -0.330, t), s = lerp(1.0, 0.56, smooth01(t));
rows.push(ringFromClosed(sec(0.1045 * s, 0.8480 + 0.0740 * s, 0.8480 - 0.0740 * s, 0.32), z, 60));
}
const shell = loftLoops(rows, { capStart: true, capEnd: true, capSegs: 4, capBulge: 0.35 });
return { shell: logPart('airbox:shell+throat', shell), throat: null };
}
/** Shark fin + rear engine-cover spine. */
function buildSharkFin(){
const outline = [];
const top = [[-0.360,0.815],[-0.620,0.790],[-0.900,0.742],[-1.180,0.672],[-1.430,0.588],[-1.640,0.500]];
const bot = [[-1.640,0.402],[-1.400,0.508],[-1.140,0.596],[-0.860,0.672],[-0.600,0.730],[-0.360,0.772]];
for (const p of top) outline.push(V2(p[0], p[1]));
for (const p of bot) outline.push(V2(p[0], p[1]));
const smoothed = spline2(outline, 130, { closed: true });
const g = plate(smoothed, { thickness: 0.016, bevel: 0.005, bevelSegs: 3,
warp: (x, y, w) => V3(w * (1 - 0.30 * clamp01((x + 1.64) / 1.28)), y, x) });
return logPart('enginecover:sharkfin', g);
}
/* ---- floor, tunnels and diffuser ---------------------------------------------------- */
const FLOOR = paramTrack({
halfW: [[1.560,0.1900],[1.320,0.3500],[1.060,0.4500],[0.640,0.5060],[0.100,0.5240],
[-0.500,0.5240],[-1.000,0.5100],[-1.400,0.4760],[-1.680,0.4420],[-2.045,0.4120]],
topY: [[1.560,0.1300],[1.320,0.1220],[1.060,0.1180],[0.640,0.1160],[0.100,0.1160],
[-0.500,0.1300],[-1.000,0.1620],[-1.400,0.2300],[-1.680,0.3100],[-2.045,0.4000]],
botY: [[1.560,0.0470],[1.320,0.0400],[1.060,0.0370],[0.640,0.0355],[0.100,0.0380],
[-0.500,0.0480],[-1.000,0.0620],[-1.400,0.0740],[-1.680,0.0820],[-2.045,0.0880]],
tunH: [[1.560,0.0060],[1.320,0.0260],[1.060,0.0480],[0.640,0.0700],[0.100,0.0760],
[-0.500,0.0900],[-1.000,0.1300],[-1.400,0.2000],[-1.680,0.2680],[-2.045,0.3200]],
keelW: [[1.560,0.0900],[1.060,0.1200],[0.100,0.1300],[-0.500,0.1300],[-1.000,0.1250],[-2.045,0.1150]],
edgeH: [[1.560,0.0180],[1.060,0.0300],[0.100,0.0360],[-0.500,0.0360],[-1.000,0.0330],[-1.680,0.0280],[-2.045,0.0240]],
});
function floorPts(z){
const p = FLOOR(z);
const hw = p.halfW, top = p.topY, bot = p.botY, tun = p.tunH, kw = p.keelW, eh = p.edgeH;
return [
[0, top], // top centreline (under the chassis)
[kw * 1.6, top],
[hw * 0.62, top - 0.004],
[hw - 0.030, top - 0.014],
[hw, top - 0.030], // outer edge, top
[hw + 0.004, top - 0.030 - eh * 0.55], // edge fence face
[hw - 0.006, top - 0.030 - eh], // edge, bottom
[hw - 0.055, bot + tun * 0.34], // tunnel outer wall
[hw - 0.140, bot + tun * 0.92], // tunnel roof
[hw - 0.255, bot + tun], // tunnel apex
[kw + 0.055, bot + tun * 0.52], // tunnel inner wall
[kw, bot + 0.004], // keel shoulder
[kw * 0.6, bot],
[0, bot], // plank centreline
];
}
function buildFloor(){
const zs = stationsFrom(1.560, D.diffuserExitZ, 132, 0.3);
const loops = zs.map(z => ringFromHalf(floorPts(z), z, 52));
const g = loftLoops(loops, { capStart: true, capEnd: true, capSegs: 4, capBulge: 0.30 });
return logPart('floor:tunnels+diffuser', g);
}
/** Vertical strakes standing inside the diffuser, each one clipped to the local tunnel
* roof height so no strake ever protrudes through the floor surface. */
function buildDiffuserStrakes(){
const parts = [];
const rows = [
{ x: 0.176, z0: -1.180, z1: -2.030, frac: 0.80 },
{ x: 0.272, z0: -1.280, z1: -2.030, frac: 0.86 },
{ x: 0.358, z0: -1.380, z1: -2.030, frac: 0.84 },
];
for (const r of rows){
const out = [], n = 24;
for (let i = 0; i <= n; i++){
const t = i / n, z = lerp(r.z0, r.z1, t);
const p = FLOOR(z);
out.push(V2(z, p.botY + 0.004 + p.tunH * r.frac * smooth01(clamp01(t * 1.15))));
}
for (let i = n; i >= 0; i--){
const t = i / n, z = lerp(r.z0, r.z1, t);
const p = FLOOR(z);
out.push(V2(z, p.botY + 0.002 + 0.006 * t));
}
const g = plate(out, { thickness: 0.0065, bevel: 0.0018,
warp: (a, b, w) => V3(r.x + w, b, a) });
parts.push(g, mirrorX(g));
}
return logPart('diffuser:strakes', merge(parts));
}
/** Floor-edge fences ahead of the tunnel inlets. Each fence hangs from the floor's own
* upper surface down to just above the reference plane, curving outboard as it runs
* back — so it stands under the floor instead of poking through it. */
function buildFloorFences(){
const parts = [];
const defs = [
{ z0: 1.500, z1: 1.090, xs: 0.250, xe: 0.398, tw: 0.0055 },
{ z0: 1.400, z1: 0.980, xs: 0.336, xe: 0.462, tw: 0.0055 },
{ z0: 1.290, z1: 0.880, xs: 0.412, xe: 0.502, tw: 0.0050 },
];
for (const d of defs){
const out = [], n = 20;
for (let i = 0; i <= n; i++){
const t = i / n, z = lerp(d.z0, d.z1, t);
out.push(V2(z, FLOOR(z).topY - 0.004));
}
for (let i = n; i >= 0; i--){
const t = i / n, z = lerp(d.z0, d.z1, t);
const p = FLOOR(z);
const drop = smooth01(clamp01(t * 1.4)) * 0.70;
out.push(V2(z, lerp(p.topY - 0.006, p.botY + 0.010, drop)));
}
const g = plate(out, { thickness: d.tw, bevel: 0.0016,
warp: (a, b, w) => {
const t = clamp01((d.z0 - a) / (d.z0 - d.z1));
return V3(lerp(d.xs, d.xe, smooth01(t)) + w, b, a);
} });
parts.push(g, mirrorX(g));
}
return logPart('floor:edgeFences', merge(parts));
}
/** Skid plank on the reference plane. */
function buildPlank(){
const zs = stationsFrom(1.400, -1.780, 46);
const loops = zs.map(z => {
const p = FLOOR(z);
const w = Math.min(p.keelW * 0.92, 0.150);
const y = p.botY;
const pts = [[0, y + 0.0005], [w * 0.7, y + 0.0005], [w, y - 0.001], [w, y - 0.010],
[w * 0.7, y - 0.012], [0, y - 0.012]];
return ringFromHalf(pts, z, 10);
});
return logPart('floor:plank', loftLoops(loops, { capStart: true, capEnd: true, capSegs: 2 }));
}
/* ---- halo ------------------------------------------------------------------------- */
function buildHalo(){
const rearMount = V3(0.296, 0.598, 0.050);
const apex = V3(0.000, 0.9110, 1.0600);
const ring = [];
const K = 46;
for (let i = 0; i <= K; i++){
const t = i / K;
// authored 3D path: sweeps out to maximum width mid-way, then closes at the apex
const s = smooth01(t);
const x = lerp(rearMount.x, apex.x, s) + Math.sin(t * PI) * 0.128;
const y = lerp(rearMount.y, apex.y, smoother01(t)) + Math.sin(t * PI) * 0.052;
const z = lerp(rearMount.z, apex.z, Math.pow(t, 0.86));
ring.push(V3(x, y, z));
}
const secAt = (t) => {
const w = lerp(0.0225, 0.0165, smooth01(t));
const h = lerp(0.0330, 0.0245, smooth01(t));
return squircle(w, h, 2.5, 16).map(p => V2(p.x, p.y));
};
const half = sweepSection(ring, secAt, { capStart: true, capEnd: false, capSegs: 3 });
const hoop = merge([half, mirrorX(half)]);
// centre pillar from the apex down to the chassis
const pillar = [];
for (let i = 0; i <= 16; i++){
const t = i / 16;
pillar.push(V3(0, lerp(0.9130, 0.5560, smooth01(t)), lerp(1.0560, 1.1420, t * t)));
}
const post = sweepSection(pillar, (t) => squircle(lerp(0.0175, 0.0245, t), lerp(0.0285, 0.0360, t), 2.4, 14),
{ capStart: true, capEnd: true, capSegs: 3 });
// rear mounting blocks
const blocks = [];
for (const s of [1, -1]){
const g = plate([[-0.040,-0.030],[0.040,-0.030],[0.046,0.028],[-0.034,0.036]], { thickness: 0.030, bevel: 0.004 });
g.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
g.translate(s * 0.296, 0.588, 0.050);
blocks.push(g);
}
return logPart('halo:hoop+pillar', merge([hoop, post, ...blocks]));
}
/** Small aero vane on top of the halo, as run since 2023. */
function buildHaloVane(){
const out = [[1.030,0.905],[0.900,0.928],[0.760,0.936],[0.640,0.930],[0.600,0.916],
[0.720,0.906],[0.870,0.898],[1.010,0.888]];
const g = plate(spline2(out.map(p => V2(p[0], p[1])), 70, { closed: true }), {
thickness: 0.030, bevel: 0.006, bevelSegs: 3,
warp: (z, y, w) => V3(w * 1.5, y + Math.cos(w * 40) * 0.0006, z) });
return logPart('halo:vane', g);
}
/* ---- cockpit surround details ------------------------------------------------------- */
function buildHeadrest(){
const zs = stationsFrom(0.185, -0.070, 26);
const loops = zs.map(z => {
const t = clamp01((0.185 - z) / 0.255);
const w = lerp(0.196, 0.168, smooth01(t));
const yT = lerp(0.596, 0.660, smooth01(t));
const yB = lerp(0.415, 0.470, t);
return ringFromClosed(podPts({ xIn: -w, xOut: w, yTop: yT, yBot: yB, nTop: 2.6, nBot: 2.2, nIn: 2.6 }, 40), z, 44);
});
return logPart('cockpit:headrest', loftLoops(loops, { capStart: true, capEnd: true, capSegs: 4, capBulge: 0.6 }));
}
/** Mirrors: a faired stalk off the cockpit flank carrying a shallow rectangular pod.
* The pod is lofted across its WIDTH so it stays a flat mirror box, not a horn. */
function buildMirrors(){
const parts = [], glass = [];
for (const s of [1, -1]){
const stalk = [];
for (let i = 0; i <= 12; i++){
const t = i / 12;
stalk.push(V3(s * lerp(0.310, 0.436, smooth01(t)), lerp(0.552, 0.578, t), lerp(0.792, 0.824, t)));
}
parts.push(sweepSection(stalk, (t) => squircle(lerp(0.017, 0.012, t), lerp(0.0090, 0.0072, t), 2.6, 12),
{ capStart: true, capEnd: true, capSegs: 3 }));
// pod: rings stacked outboard, each a flat rounded rectangle in (z,y)
const pod = [];
for (let i = 0; i <= 10; i++){
const t = i / 10;
const x = s * lerp(0.402, 0.512, t);
const sc = Math.sin(PI * (0.22 + t * 0.60)) / Math.sin(PI * 0.52);
const loop = [];
for (let k = 0; k < 32; k++){
const a = k / 32 * TAU, sx = Math.sin(a), cy = Math.cos(a);
loop.push(V3(x,
0.5860 + Math.sign(cy) * Math.pow(Math.abs(cy), 0.42) * 0.0345 * sc,
0.8180 + Math.sign(sx) * Math.pow(Math.abs(sx), 0.42) * 0.0290 * sc));
}
pod.push(loop);
}
parts.push(loftLoops(pod, { capStart: true, capEnd: true, capSegs: 3, capBulge: 0.35 }));
const gl = plate([[-0.0250,-0.0300],[0.0250,-0.0300],[0.0250,0.0300],[-0.0250,0.0300]].map(p => V2(p[0], p[1])),
{ thickness: 0.003, bevel: 0.0008 });
gl.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
gl.applyMatrix4(new THREE.Matrix4().makeRotationY(-s * 0.26));
gl.translate(s * 0.4020, 0.5860, 0.8180);
glass.push(gl);
}
return { housing: logPart('cockpit:mirrors', merge(parts)), glass: logPart('cockpit:mirrorGlass', merge(glass)) };
}
/* =====================================================================================
AERODYNAMIC SURFACES
Every wing element is a real spanwise loft of cambered sections: chord, incidence,
thickness, camber and leading-edge anchor are all functions of span. Nothing here is
a scaled slab. Negative camber and negative incidence produce the inverted, TE-up
geometry that makes an F1 wing read correctly from any angle.
===================================================================================== */
/** Build one element from spanwise control curves, mirrored about the centreline. */
function wingElement({ name, x0, x1, steps = 26, leZ, leY, chord, incidence, thick, camber,
camberPos = 0.42, dihedral = null, teThick = 0.0035, sectionPoints = 56,
half = true, tips = null }){
const stations = [];
for (let i = 0; i <= steps; i++){
const t = i / steps;
const x = lerp(x0, x1, t);
stations.push({
le: V3(x, leY(t), leZ(t)),
chord: chord(t),
incidence: incidence(t),
dihedral: dihedral ? dihedral(t) : 0,
section: { thick: thick(t), camber: camber(t), camberPos, teThick },
});
}
// an element that starts on the centreline is left open there, so the mirrored half
// joins it seamlessly instead of showing a capped seam down the middle
const roundTips = tips || [x0 > 0.004, true];
const g = wingLoft(stations, { sectionPoints, roundTips });
return logPart(name, half ? merge([g, mirrorX(g)]) : g);
}
/* ---- FRONT WING -------------------------------------------------------------------- */
function buildFrontWing(){
const parts = [];
const TIP = 0.8950;
// spanwise rise: flat across the regulated neutral centre, lifting only outboard
const rise = (t, amt) => amt * Math.pow(clamp01((t - 0.58) / 0.42), 1.75);
// element 0 — main plane, with the regulation neutral central section
parts.push(wingElement({
name: 'frontwing:mainplane', x0: 0.0, x1: TIP, steps: 44, sectionPoints: 66,
leZ: t => 2.9520 - 0.0260 * Math.pow(clamp01((t - 0.45) / 0.55), 1.6),
leY: t => 0.0740 + 0.0180 * smooth01(clamp01((t - 0.14) / 0.44)) + rise(t, 0.0400),
chord: t => 0.2880 - 0.0140 * smooth01(clamp01((t - 0.60) / 0.40)) - 0.0340 * Math.pow(clamp01((t - 0.88) / 0.12), 2),
incidence: t => -0.050 - 0.070 * smooth01(clamp01((t - 0.14) / 0.60)),
thick: t => 0.058 - 0.009 * t,
camber: t => -0.030 - 0.046 * smooth01(clamp01((t - 0.14) / 0.58)),
dihedral: t => -0.06 - 0.30 * Math.pow(clamp01((t - 0.70) / 0.30), 1.8),
}));
// element 1 — first flap, sitting above and behind the main plane's trailing edge
parts.push(wingElement({
name: 'frontwing:flap1', x0: 0.1300, x1: TIP - 0.005, steps: 38, sectionPoints: 58,
leZ: t => 2.7040 - 0.0260 * Math.pow(clamp01((t - 0.45) / 0.55), 1.6),
leY: t => 0.1330 + 0.0180 * smooth01(t) + rise(t, 0.0420),
chord: t => 0.2100 + 0.0120 * Math.sin(PI * t) - 0.0320 * Math.pow(clamp01((t - 0.86) / 0.14), 2),
incidence: t => -0.205 - 0.100 * smooth01(t),
thick: t => 0.050 - 0.008 * t,
camber: t => -0.072 - 0.026 * smooth01(t),
dihedral: t => -0.10 - 0.34 * Math.pow(clamp01((t - 0.68) / 0.32), 1.8),
}));
// element 2 — outer flap, the movable one under the 2026 rules
parts.push(wingElement({
name: 'frontwing:flap2', x0: 0.2340, x1: TIP - 0.010, steps: 34, sectionPoints: 54,
leZ: t => 2.5260 - 0.0240 * Math.pow(clamp01((t - 0.45) / 0.55), 1.6),
leY: t => 0.2100 + 0.0180 * smooth01(t) + rise(t, 0.0440),
chord: t => 0.1900 + 0.0100 * Math.sin(PI * t) - 0.0300 * Math.pow(clamp01((t - 0.86) / 0.14), 2),
incidence: t => -0.345 - 0.115 * smooth01(t),
thick: t => 0.044 - 0.007 * t,
camber: t => -0.086 - 0.024 * smooth01(t),
dihedral: t => -0.14 - 0.36 * Math.pow(clamp01((t - 0.66) / 0.34), 1.8),
}));
return merge(parts);
}
/** Front-wing endplate: authored outline, curled outboard toward the top rear so the
* plate generates outwash instead of standing flat. */
function buildFrontEndplate(){
const out = [
[2.9620,0.0720],[2.9560,0.1580],[2.9360,0.2260],[2.8880,0.2680],[2.7900,0.2860],
[2.6400,0.2900],[2.5100,0.2800],[2.4180,0.2500],[2.3620,0.1940],[2.3400,0.1220],
[2.3520,0.0620],[2.4400,0.0380],[2.6600,0.0330],[2.8700,0.0450],
];
const outline = spline2(out.map(p => V2(p[0], p[1])), 160, { closed: true });
// two louvre slots cut through the plate — the detail that stops it reading as a slab
const slot = (z0, z1, y0, h) => {
const s = [];
const n = 12;
for (let i = 0; i <= n; i++){ const t = i / n; s.push(V2(lerp(z0, z1, t), y0 + h * Math.sin(PI * (0.10 + t * 0.80)))); }
for (let i = n; i >= 0; i--){ const t = i / n; s.push(V2(lerp(z0, z1, t), y0 - 0.0035)); }
return s.reverse();
};
const curl = (z, y) => {
const up = clamp01((y - 0.100) / 0.190);
const back = clamp01((2.900 - z) / 0.540);
return 0.052 * Math.pow(up, 1.7) * Math.pow(back, 1.20);
};
const g = plate(outline, { thickness: 0.0080, bevel: 0.0024, bevelSegs: 3,
holes: [slot(2.6900, 2.4400, 0.2150, 0.0175), slot(2.7300, 2.4900, 0.1720, 0.0165)],
warp: (z, y, w) => V3(0.8950 + w + curl(z, y), y, z) });
// footplate turning the lower edge outward around the tyre
const foot = [];
const n = 26;
for (let i = 0; i <= n; i++){ const t = i / n; foot.push(V2(lerp(2.920, 2.330, t), 0.034 + 0.009 * Math.sin(PI * t))); }
for (let i = n; i >= 0; i--){ const t = i / n; foot.push(V2(lerp(2.920, 2.330, t), 0.007 + 0.004 * Math.sin(PI * t))); }
const fp = plate(foot, { thickness: 0.046, bevel: 0.004, bevelSegs: 2,
warp: (z, y, w) => {
const t = clamp01((2.920 - z) / 0.590);
return V3(0.8850 + w * (1 + 1.4 * t) + 0.026 * Math.pow(t, 1.4), y, z);
} });
const merged = merge([g, fp]);
return logPart('frontwing:endplate', merge([merged, mirrorX(merged)]));
}
/** Slim faired pylons tying the wing assembly to the underside of the nose. */
function buildNosePylons(){
const parts = [];
for (const s of [1, -1]){
const stations = [];
for (let i = 0; i <= 10; i++){
const t = i / 10;
stations.push({
le: V3(s * lerp(0.0700, 0.0560, t), lerp(0.1180, 0.2680, smoother01(t)), lerp(2.8300, 2.7100, t)),
chord: lerp(0.2400, 0.1900, t),
incidence: 0, dihedral: 0,
section: { thick: 0.115, camber: 0, camberPos: 0.42, teThick: 0.003 },
spanDir: V3(0, 1, 0), upDir: V3(s, 0, 0), chordDir: V3(0, 0, -1),
});
}
parts.push(wingLoft(stations, { sectionPoints: 34, roundTips: [false, false] }));
}
return logPart('frontwing:pylons', merge(parts));
}
/* ---- REAR WING --------------------------------------------------------------------- */
function buildRearWing(){
const parts = [];
const SPAN = 0.4700;
parts.push(wingElement({
name: 'rearwing:mainplane', x0: 0.0, x1: SPAN, steps: 26, sectionPoints: 60,
leZ: t => -1.8980 + 0.0140 * Math.pow(t, 2.2),
leY: t => 0.7280 + 0.0130 * Math.pow(t, 2.0),
chord: t => 0.2480 - 0.0180 * Math.pow(clamp01((t - 0.80) / 0.20), 2),
incidence: t => -0.170 - 0.040 * Math.pow(t, 2),
thick: t => 0.080 - 0.010 * t,
camber: t => -0.062,
}));
parts.push(wingElement({
name: 'rearwing:flap', x0: 0.0, x1: SPAN - 0.004, steps: 24, sectionPoints: 52,
leZ: t => -2.1180 + 0.0100 * Math.pow(t, 2.2),
leY: t => 0.8340 + 0.0140 * Math.pow(t, 2.0),
chord: t => 0.2000 - 0.0160 * Math.pow(clamp01((t - 0.82) / 0.18), 2),
incidence: t => -0.470 - 0.045 * Math.pow(t, 2),
thick: t => 0.058 - 0.008 * t,
camber: t => -0.086,
}));
return merge(parts);
}
function buildRearEndplate(){
const out = [
[-1.8300,0.6180],[-1.8460,0.7420],[-1.8560,0.8420],[-1.8720,0.9080],[-1.9400,0.9260],
[-2.0800,0.9280],[-2.2100,0.9180],[-2.3060,0.8880],[-2.3560,0.8280],[-2.3660,0.7420],
[-2.3600,0.6560],[-2.3200,0.6040],[-2.2200,0.5760],[-2.0600,0.5640],[-1.9200,0.5720],
];
const outline = spline2(out.map(p => V2(p[0], p[1])), 150, { closed: true });
const g = plate(outline, { thickness: 0.0090, bevel: 0.0028, bevelSegs: 3,
warp: (z, y, w) => {
const top = clamp01((y - 0.780) / 0.150);
const back = clamp01((-1.880 - z) / 0.470);
return V3(0.4700 + w + 0.030 * Math.pow(top, 2.0) * Math.pow(back, 1.2), y, z);
} });
// lower louvre strakes on the plate
const strakes = [];
for (let i = 0; i < 3; i++){
const y0 = 0.612 + i * 0.040;
const s = plate([[-1.930, y0], [-2.180, y0 + 0.012], [-2.180, y0 + 0.030], [-1.930, y0 + 0.020]].map(p => V2(p[0], p[1])),
{ thickness: 0.020, bevel: 0.002, warp: (z, y, w) => V3(0.4680 + w * 1.0, y, z) });
strakes.push(s);
}
const one = merge([g, ...strakes]);
return logPart('rearwing:endplate', merge([one, mirrorX(one)]));
}
/** Swan-neck pylons: they meet the UPPER surface of the main plane, as on a modern car. */
function buildRearPylons(){
const parts = [];
for (const s of [1, -1]){
// swan neck: meets the UPPER surface of the main plane, bowed forward
parts.push(strut(V3(s * 0.0560, 0.4180, -1.6120), V3(s * 0.0880, 0.7960, -1.8860),
{ chord: 0.108, thickRatio: 0.24, taper: 0.70, steps: 16, bow: 0.034 }));
}
// DRS actuator pod on the centreline
const pod = [];
for (let i = 0; i <= 12; i++){
const t = i / 12, z = lerp(-1.880, -2.070, t);
const w = Math.sin(PI * (0.12 + t * 0.76)) * 0.044;
const pts = [];
for (let k = 0; k < 24; k++){ const a = k / 24 * TAU; pts.push([Math.sin(a) * w, 0.802 + Math.cos(a) * w * 0.78]); }
pod.push(ringFromClosed(pts, z, 26));
}
parts.push(loftLoops(pod, { capStart: true, capEnd: true, capSegs: 3 }));
return logPart('rearwing:pylons+DRSpod', merge(parts));
}
/* ---- BEAM WING --------------------------------------------------------------------- */
function buildBeamWing(){
const parts = [];
parts.push(wingElement({
name: 'beamwing:upper', x0: 0.0, x1: 0.3820, steps: 18, sectionPoints: 44,
leZ: t => -1.9200 - 0.0180 * Math.pow(t, 2),
leY: t => 0.3560 + 0.0120 * Math.pow(t, 2),
chord: () => 0.1720, incidence: () => -0.300,
thick: () => 0.070, camber: () => -0.070,
}));
parts.push(wingElement({
name: 'beamwing:lower', x0: 0.0, x1: 0.3620, steps: 16, sectionPoints: 40,
leZ: t => -1.9600 - 0.0160 * Math.pow(t, 2),
leY: t => 0.2760 + 0.0100 * Math.pow(t, 2),
chord: () => 0.1420, incidence: () => -0.230,
thick: () => 0.066, camber: () => -0.058,
}));
return merge(parts);
}
/* ---- REAR CRASH STRUCTURE, EXHAUST, RAIN LIGHT ------------------------------------- */
function buildRearStructure(){
const zs = stationsFrom(-1.500, -2.130, 34);
const loops = zs.map(z => {
const t = clamp01((-1.500 - z) / 0.630);
const w = lerp(0.098, 0.052, smoother01(t));
const h = lerp(0.088, 0.046, smoother01(t));
const yc = lerp(0.398, 0.352, t);
const pts = [];
for (let k = 0; k < 36; k++){
const a = k / 36 * TAU, sx = Math.sin(a), cy = Math.cos(a);
pts.push([Math.sign(sx) * Math.pow(Math.abs(sx), 0.62) * w,
yc + Math.sign(cy) * Math.pow(Math.abs(cy), 0.62) * h]);
}
return ringFromClosed(pts, z, 40);
});
return logPart('rear:crashStructure', loftLoops(loops, { capStart: true, capEnd: true, capSegs: 4 }));
}
function buildExhaust(){
const parts = [];
// single central tailpipe
const main = [];
for (let i = 0; i <= 14; i++){
const t = i / 14;
main.push(V3(0, lerp(0.452, 0.436, t), lerp(-1.760, -2.062, t)));
}
parts.push(sweepTube(main, (t) => lerp(0.052, 0.044, t), 26, { capStart: true }));
// inner liner so the pipe reads open
const inner = [];
for (let i = 0; i <= 8; i++){ const t = i / 8; inner.push(V3(0, lerp(0.446, 0.436, t), lerp(-1.960, -2.056, t))); }
parts.push(sweepTube(inner, 0.036, 22, { }));
// wastegate pipes
for (const s of [1, -1]){
const p = [];
for (let i = 0; i <= 10; i++){
const t = i / 10;
p.push(V3(s * lerp(0.052, 0.084, t), lerp(0.470, 0.492, t), lerp(-1.840, -2.010, t)));
}
parts.push(sweepTube(p, 0.0185, 16, { capStart: true }));
}
return logPart('rear:exhaust', merge(parts));
}
function buildRainLight(){
const g = plate([[-0.046,-0.052],[0.046,-0.052],[0.046,0.052],[-0.046,0.052]].map(p => V2(p[0], p[1])),
{ thickness: 0.020, bevel: 0.004, bevelSegs: 2 });
g.translate(0, 0.322, -2.078);
return logPart('rear:rainLight', g);
}
/* ---- BODY-MOUNTED VORTEX GENERATORS ------------------------------------------------- */
function buildVortexGenerators(){
const parts = [];
// sidepod shoulder fins
for (let i = 0; i < 3; i++){
const z0 = 0.520 - i * 0.115;
const out = [];
const n = 12;
for (let k = 0; k <= n; k++){ const t = k / n; out.push(V2(lerp(z0, z0 - 0.135, t), 0.026 * Math.sin(PI * (0.15 + t * 0.7)))); }
for (let k = n; k >= 0; k--){ const t = k / n; out.push(V2(lerp(z0, z0 - 0.135, t), -0.004)); }
const g = plate(out, { thickness: 0.0055, bevel: 0.0015,
warp: (z, y, w) => {
const p = POD(z);
return V3(p.xOut * (0.72 + i * 0.06) + w, p.yTop + y - 0.004, z);
} });
parts.push(g, mirrorX(g));
}
return logPart('aero:vortexGenerators', merge(parts));
}
/* =====================================================================================
WHEELS, BRAKES, SUSPENSION
Tyres are revolved from a measured carcass section (bead -> bulge -> shoulder -> crown)
and carry a baked contact-patch deflection, so a parked car sits on a loaded tyre
rather than a mathematically round one. A second, undeflected carcass is emitted for
the rolling presentation mode — the deflection is geometry, not a shader trick, so it
cannot simply be spun.
===================================================================================== */
/** Measured carcass half-section, mirrored about the wheel centre plane.
* Points are (radius, axial); axial +ve is outboard. */
function tyreProfile({ R, halfW, rimR }){
const bulge = halfW; // widest point of the sidewall
const tread = halfW * 0.795; // crown half-width
const k = [
[rimR, halfW * 0.805],
[rimR + 0.018, halfW * 0.880],
[R * 0.735, bulge * 0.972],
[R * 0.845, bulge],
[R * 0.918, bulge * 0.958],
[R * 0.966, bulge * 0.882],
[R * 0.9915, tread * 1.010],
[R * 0.99935, tread * 0.800],
[R, tread * 0.400],
[R, 0],
];
const half = spline2(k.map(p => V2(p[0], p[1])), 30, { closed: false });
const prof = [];
// outboard bead -> crown -> inboard bead, so texture v maps outboard(0) to inboard(1)
for (let i = 0; i < half.length; i++) prof.push(V2(half[i].x, half[i].y));
for (let i = half.length - 2; i >= 0; i--) prof.push(V2(half[i].x, -half[i].y));
return prof;
}
/** Radial deflection at the contact patch, applied in the wheel's own frame. */
function deflectTyre(geo, R, drop){
return xform(geo, (x, y, z) => {
const r = Math.hypot(y, z);
if (r < 1e-5) return V3(x, y, z);
// the ground is at local y = -R; squash only the lower arc, and bulge the sidewall
const groundY = -(R - drop);
if (y >= groundY) return V3(x, y, z);
const over = (groundY - y) / Math.max(drop, 1e-5);
const s = clamp01(over);
const ny = lerp(y, groundY, smooth01(Math.min(s * 1.35, 1)));
const widen = 1 + 0.085 * smooth01(Math.min(s, 1)) * clamp01(Math.abs(x) / (R * 0.5));
return V3(x * widen, ny, z);
});
}
function buildTyre({ R, halfW, rimR, drop }){
const prof = tyreProfile({ R, halfW, rimR });
// profile runs +X -> -X, so the revolved normals need flipping to face outward
const round = latheX(prof, 108, { flip: true });
const loaded = deflectTyre(latheX(prof, 108, { flip: true }), R, drop);
return { round: logPart('tyre:carcass', round), loaded: logPart('tyre:carcassLoaded', loaded) };
}
/* ---- rim ---------------------------------------------------------------------------- */
function buildRim({ rimR, halfW }){
const parts = [];
const inner = rimR - 0.020;
// barrel: outboard flange, drop centre, inboard flange
const barrel = [
V2(rimR, halfW * 0.815),
V2(rimR - 0.004, halfW * 0.790),
V2(rimR - 0.012, halfW * 0.760),
V2(inner, halfW * 0.700),
V2(inner, halfW * 0.180),
V2(inner - 0.012, 0.0),
V2(inner, -halfW * 0.180),
V2(inner, -halfW * 0.700),
V2(rimR - 0.012, -halfW * 0.760),
V2(rimR - 0.004, -halfW * 0.790),
V2(rimR, -halfW * 0.815),
];
parts.push(latheX(spline2(barrel, 46, { closed: false }), 96, { flip: true }));
// spoke face: an authored annulus with real windows, extruded and dished
const N = 10;
const ring = (r, seg = 72) => { const o = []; for (let i = 0; i < seg; i++){ const a = i / seg * TAU; o.push(V2(Math.cos(a) * r, Math.sin(a) * r)); } return o; };
const windows = [];
for (let i = 0; i < N; i++){
const a0 = (i / N) * TAU + 0.055, a1 = ((i + 1) / N) * TAU - 0.055;
const rIn = 0.074, rOut = rimR - 0.028;
const w = [];
const steps = 7;
for (let k = 0; k <= steps; k++){ const a = lerp(a0, a1, k / steps); w.push(V2(Math.cos(a) * rOut, Math.sin(a) * rOut)); }
for (let k = steps; k >= 0; k--){ const a = lerp(a0 + 0.10, a1 - 0.10, k / steps); w.push(V2(Math.cos(a) * rIn, Math.sin(a) * rIn)); }
windows.push(w.reverse());
}
const face = plate(ring(rimR - 0.006), { thickness: 0.014, bevel: 0.0032, bevelSegs: 2, holes: windows });
xform(face, (x, y, z) => {
const r = Math.hypot(x, y);
const dish = 0.020 * Math.pow(clamp01(r / rimR), 2.1);
return V3(x, y, z - dish);
});
face.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
face.translate(halfW * 0.560, 0, 0);
parts.push(face);
// centre boss + single wheel nut
parts.push(latheX(spline2([V2(0.020, halfW * 0.62), V2(0.058, halfW * 0.64), V2(0.076, halfW * 0.60),
V2(0.080, halfW * 0.50), V2(0.074, halfW * 0.44), V2(0.030, halfW * 0.42)], 22, { closed: false }), 48));
const nut = [];
for (let i = 0; i < 6; i++){
const a = i / 6 * TAU;
nut.push(V2(Math.cos(a) * 0.0345, Math.sin(a) * 0.0345));
}
const nutG = plate(nut, { thickness: 0.030, bevel: 0.0035, bevelSegs: 2 });
nutG.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
nutG.translate(halfW * 0.700, 0, 0);
parts.push(nutG);
// valve stem
const stem = [];
for (let i = 0; i <= 6; i++){ const t = i / 6; stem.push(V3(halfW * 0.60 + 0.012 * t, rimR * 0.80 + 0.026 * t, 0.010 * t)); }
parts.push(sweepTube(stem, 0.0042, 10, { capStart: true, capEnd: true }));
return logPart('wheel:rim', merge(parts));
}
/** Aero rim cover — the closed outer face mandated since the 18" switch. */
function buildRimCover({ rimR, halfW }){
const prof = [
V2(rimR - 0.001, halfW * 0.812),
V2(rimR - 0.014, halfW * 0.832),
V2(rimR - 0.052, halfW * 0.845),
V2(rimR - 0.110, halfW * 0.840),
V2(rimR - 0.160, halfW * 0.822),
V2(0.045, halfW * 0.792),
V2(0.020, halfW * 0.770),
];
const g = latheX(spline2(prof, 34, { closed: false }), 90);
return logPart('wheel:rimCover', g);
}
/* ---- brakes -------------------------------------------------------------------------- */
function buildBrakeDisc({ rOut = 0.1550, rIn = 0.0760, thick = 0.032 } = {}){
const ring = (r, seg = 84) => { const o = []; for (let i = 0; i < seg; i++){ const a = i / seg * TAU; o.push(V2(Math.cos(a) * r, Math.sin(a) * r)); } return o; };
const holes = [ring(rIn, 60).reverse()];
for (let row = 0; row < 2; row++){
const r = rOut - 0.030 - row * 0.030, count = 26 - row * 4;
for (let i = 0; i < count; i++){
const a = i / count * TAU + row * 0.12;
const cx = Math.cos(a) * r, cy = Math.sin(a) * r, hr = 0.0038;
const h = [];
for (let k = 0; k < 7; k++){ const b = k / 7 * TAU; h.push(V2(cx + Math.cos(b) * hr, cy + Math.sin(b) * hr)); }
holes.push(h.reverse());
}
}
const g = plate(ring(rOut, 96), { thickness: thick, bevel: 0.0020, bevelSegs: 1, holes });
g.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
return logPart('brake:disc', g);
}
function buildCaliper({ r = 0.168, span = 1.05, y0 = 0.25 } = {}){
const parts = [];
const path = [];
const steps = 22;
for (let i = 0; i <= steps; i++){
const a = y0 + (i / steps) * span;
path.push(V3(0, Math.sin(a) * r, Math.cos(a) * r));
}
parts.push(sweepSection(path, (t) => {
const w = 0.030 + 0.010 * Math.sin(PI * t);
const h = 0.058 + 0.014 * Math.sin(PI * t);
return squircle(h, w, 3.0, 18).map(p => V2(p.y, p.x));
}, { capStart: true, capEnd: true, capSegs: 3 }));
// piston bosses
for (let i = 0; i < 3; i++){
const a = y0 + span * (0.20 + i * 0.30);
for (const s of [1, -1]){
const c0 = V3(s * 0.030, Math.sin(a) * r, Math.cos(a) * r);
const c1 = V3(s * 0.046, Math.sin(a) * r, Math.cos(a) * r);
parts.push(sweepTube([c0, c1], 0.0165, 14, { capEnd: true }));
}
}
return logPart('brake:caliper', merge(parts));
}
/** Brake duct drum + inlet scoop + outboard fence. */
function buildBrakeDuct({ R, halfW, front }){
const parts = [];
const rD = front ? 0.212 : 0.222;
const xIn = -halfW * 0.30, xOut = halfW * 0.34;
const drum = [];
for (let i = 0; i <= 16; i++){
const t = i / 16, x = lerp(xIn, xOut, t);
const r = rD * (0.94 + 0.06 * Math.sin(PI * t));
const pts = [];
for (let k = 0; k < 40; k++){
const a = k / 40 * TAU;
const squash = 1 - 0.16 * clamp01(-Math.sin(a)); // flattened at the bottom
pts.push([Math.cos(a) * r * squash, Math.sin(a) * r * squash]);
}
drum.push(pts.map(p => V3(x, p[0], p[1])));
}
parts.push(loftLoops(drum, { flip: true }));
// inlet scoop facing forward
const scoop = [];
for (let i = 0; i <= 12; i++){
const t = i / 12;
const z = lerp(0.300, 0.130, t);
const w = lerp(0.052, 0.088, smooth01(t));
const h = lerp(0.062, 0.100, smooth01(t));
const yc = lerp(-0.085, -0.045, t);
const pts = [];
for (let k = 0; k < 28; k++){
const a = k / 28 * TAU;
pts.push(V3(lerp(xIn + 0.02, xOut - 0.03, 0.5) + Math.sin(a) * w * 0.5,
yc + Math.cos(a) * h * 0.5, z));
}
scoop.push(pts);
}
parts.push(loftLoops(scoop, { capStart: true }));
// outboard turning fence
const fence = [];
const n = 20;
for (let i = 0; i <= n; i++){ const a = -0.9 + (i / n) * 2.4; fence.push(V2(Math.cos(a) * (rD + 0.016), Math.sin(a) * (rD + 0.016))); }
for (let i = n; i >= 0; i--){ const a = -0.9 + (i / n) * 2.4; fence.push(V2(Math.cos(a) * (rD - 0.030), Math.sin(a) * (rD - 0.030))); }
const fg = plate(fence, { thickness: 0.007, bevel: 0.002 });
fg.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
fg.translate(xOut + 0.006, 0, 0);
parts.push(fg);
return logPart(front ? 'brake:duct.front' : 'brake:duct.rear', merge(parts));
}
/* ---- upright + hub ------------------------------------------------------------------ */
function buildUpright({ front }){
const zTop = front ? 0.030 : 0.024;
const body = [];
for (let i = 0; i <= 16; i++){
const t = i / 16;
const y = lerp(0.202, -0.192, t);
const w = 0.038 + 0.026 * Math.sin(PI * t) - 0.010 * Math.pow(t, 2);
const d = 0.058 + 0.034 * Math.sin(PI * t);
const loop = [];
for (let k = 0; k < 30; k++){
const a = k / 30 * TAU, sx = Math.sin(a), cy = Math.cos(a);
loop.push(V3(Math.sign(sx) * Math.pow(Math.abs(sx), 0.7) * w,
y,
zTop + Math.sign(cy) * Math.pow(Math.abs(cy), 0.7) * d));
}
body.push(loop);
}
const hub = latheX(spline2([V2(0.030, -0.012), V2(0.064, 0.006), V2(0.072, 0.050),
V2(0.054, 0.074), V2(0.024, 0.074)], 20, { closed: false }), 36);
return logPart(front ? 'suspension:upright.front' : 'suspension:upright.rear',
merge([loftLoops(body, { capStart: true, capEnd: true, capSegs: 3 }), hub]));
}
/* ---- streamlined suspension members -------------------------------------------------- */
/** A faired member: constant streamwise airfoil section lofted along the leg axis. */
function strut(a, b, { chord = 0.062, thickRatio = 0.28, twist = 0, taper = 1, steps = 10, bow = 0 } = {}){
const loops = [];
const axis = V3().subVectors(b, a);
const side = V3().crossVectors(axis, V3(0, 1, 0));
if (side.lengthSq() < 1e-8) side.set(1, 0, 0);
side.normalize();
const up = V3().crossVectors(side, axis).normalize();
for (let i = 0; i <= steps; i++){
const t = i / steps;
const c = V3().lerpVectors(a, b, t).addScaledVector(up, Math.sin(PI * t) * bow);
const ch = chord * lerp(1, taper, t);
const loop = airfoilLoop(30, { thick: thickRatio, camber: 0, teThick: 0.0026 });
const ct = Math.cos(twist), st = Math.sin(twist);
loops.push(loop.map(p => {
const x = -(p.x - 0.34) * ch, y = p.y * ch;
return V3(c.x, c.y + (x * st + y * ct), c.z + (x * ct - y * st));
}));
}
return loftLoops(loops, { capStart: true, capEnd: true, capSegs: 3, capBulge: 0.5 });
}
function buildFrontSuspension(){
const hx = 0.6280, ax = D.frontAxleZ;
const parts = [
// upper wishbone
strut(V3(0.1900, 0.4020, ax + 0.268), V3(hx, 0.4300, ax + 0.014), { chord: 0.070, taper: 0.72, thickRatio: 0.24 }),
strut(V3(0.1860, 0.3960, ax - 0.256), V3(hx, 0.4280, ax - 0.006), { chord: 0.066, taper: 0.74, thickRatio: 0.24 }),
// lower wishbone
strut(V3(0.2260, 0.1560, ax + 0.256), V3(hx + 0.012, 0.1720, ax + 0.016), { chord: 0.082, taper: 0.70, thickRatio: 0.22 }),
strut(V3(0.2300, 0.1520, ax - 0.240), V3(hx + 0.012, 0.1700, ax - 0.008), { chord: 0.078, taper: 0.72, thickRatio: 0.22 }),
// pushrod
strut(V3(hx + 0.006, 0.1880, ax + 0.030), V3(0.2020, 0.4720, ax - 0.214), { chord: 0.040, taper: 0.90, thickRatio: 0.34 }),
// steering track rod
strut(V3(0.2000, 0.2380, ax + 0.150), V3(hx + 0.004, 0.2480, ax + 0.086), { chord: 0.038, taper: 0.92, thickRatio: 0.34 }),
];
const one = merge(parts);
return logPart('suspension:front', merge([one, mirrorX(one)]));
}
function buildRearSuspension(){
const hx = 0.6060, ax = D.rearAxleZ;
const parts = [
strut(V3(0.1500, 0.3960, ax + 0.352), V3(hx, 0.4120, ax + 0.012), { chord: 0.072, taper: 0.72, thickRatio: 0.24 }),
strut(V3(0.1480, 0.3900, ax - 0.226), V3(hx, 0.4100, ax - 0.010), { chord: 0.066, taper: 0.74, thickRatio: 0.24 }),
strut(V3(0.1760, 0.1620, ax + 0.336), V3(hx + 0.012, 0.1740, ax + 0.014), { chord: 0.086, taper: 0.70, thickRatio: 0.22 }),
strut(V3(0.1800, 0.1580, ax - 0.218), V3(hx + 0.012, 0.1720, ax - 0.010), { chord: 0.080, taper: 0.72, thickRatio: 0.22 }),
// pullrod: outboard high, inboard low
strut(V3(hx - 0.004, 0.4020, ax - 0.052), V3(0.1560, 0.2060, ax + 0.318), { chord: 0.040, taper: 0.90, thickRatio: 0.34 }),
// toe link
strut(V3(0.1520, 0.2320, ax - 0.240), V3(hx + 0.004, 0.2420, ax - 0.098), { chord: 0.036, taper: 0.92, thickRatio: 0.34 }),
];
const one = merge(parts);
return logPart('suspension:rear', merge([one, mirrorX(one)]));
}
/* ---- gearbox / rear-end structure ---------------------------------------------------- */
function buildGearbox(){
const zs = stationsFrom(-1.150, -1.760, 30);
const loops = zs.map(z => {
const t = clamp01((-1.150 - z) / 0.610);
const w = lerp(0.176, 0.100, smooth01(t));
const yT = lerp(0.402, 0.372, t), yB = lerp(0.176, 0.212, t);
return ringFromClosed(podPts({ xIn: -w, xOut: w, yTop: yT, yBot: yB, nTop: 3.0, nBot: 2.4, nIn: 3.0 }, 40), z, 44);
});
const box = loftLoops(loops, { capStart: true, capEnd: true, capSegs: 3 });
// driveshaft fairings
const shafts = [];
for (const s of [1, -1]){
shafts.push(strut(V3(s * 0.150, 0.298, D.rearAxleZ), V3(s * 0.600, 0.300, D.rearAxleZ),
{ chord: 0.086, thickRatio: 0.42, taper: 0.86, steps: 6 }));
}
return logPart('rear:gearbox+driveshafts', merge([box, ...shafts]));
}
/* =====================================================================================
COCKPIT, DRIVER AND FINE DETAIL
The small parts are what make a hero model read as modelled rather than approximated:
camera pods, pitot, antennas, jack points, tow eyes, seat belts, wheel display.
Every one is a real emitted surface, sized in millimetres from the same contract.
===================================================================================== */
/* ---- driver ------------------------------------------------------------------------- */
const HEAD = { x: 0, y: 0.6720, z: 0.3400 };
function buildHelmet(){
const parts = [];
const rows = [];
const N = 44;
for (let i = 0; i <= 30; i++){
const t = i / 30; // 0 = crown, 1 = neck
const phi = 0.055 * PI + t * PI * 0.865; // never start on a degenerate pole
const rBase = 0.1345;
const rr = rBase * Math.sin(phi) * (1 + 0.10 * Math.pow(clamp01((t - 0.55) / 0.45), 1.5));
const yy = HEAD.y + rBase * Math.cos(phi) * 1.055;
const loop = [];
for (let k = 0; k < N; k++){
const a = k / N * TAU;
const sx = Math.sin(a), cz = Math.cos(a);
// longer front-to-back than side-to-side, with a slight jaw taper
const fz = cz > 0 ? 1.10 : 1.02;
loop.push(V3(HEAD.x + sx * rr * 0.955, yy, HEAD.z + cz * rr * fz));
}
rows.push(loop);
}
parts.push(loftLoops(rows, { capStart: true, capEnd: true, capSegs: 4, capBulge: 0.7 }));
// aero spoiler on the crown
const sp = [];
for (let i = 0; i <= 10; i++){
const t = i / 10;
sp.push(V3(0, HEAD.y + 0.128 - 0.010 * t, HEAD.z - 0.048 - 0.086 * t));
}
parts.push(sweepSection(sp, (t) => squircle(lerp(0.052, 0.030, t), lerp(0.008, 0.005, t), 2.6, 12),
{ capStart: true, capEnd: true, capSegs: 2 }));
return logPart('driver:helmet', merge(parts));
}
function buildVisor(){
const rows = [];
for (let i = 0; i <= 8; i++){
const t = i / 8;
const yy = HEAD.y + 0.052 - t * 0.072;
const loop = [];
const halfSpan = 1.02 + 0.14 * Math.sin(PI * t);
for (let k = 0; k <= 26; k++){
const a = -halfSpan + (k / 26) * halfSpan * 2;
const rr = 0.1352 * Math.sqrt(Math.max(1 - Math.pow((yy - HEAD.y) / 0.142, 2), 0.05));
loop.push(V3(HEAD.x + Math.sin(a) * rr * 0.965, yy, HEAD.z + Math.cos(a) * rr * 1.10));
}
rows.push(loop);
}
return logPart('driver:visor', gridGeometry(rows, { closeU: false, flip: false }));
}
function buildDriverBody(){
const parts = [];
// shoulders / torso rising out of the tub
const rows = [];
for (let i = 0; i <= 16; i++){
const t = i / 16;
const z = lerp(0.520, 0.180, t);
const w = 0.150 + 0.086 * Math.sin(PI * Math.pow(t, 0.75));
const yT = 0.512 + 0.052 * Math.sin(PI * Math.pow(t, 0.8));
const yB = 0.352;
rows.push(ringFromClosed(podPts({ xIn: -w, xOut: w, yTop: yT, yBot: yB, nTop: 2.6, nBot: 2.2, nIn: 2.6 }, 34), z, 38));
}
parts.push(loftLoops(rows, { capStart: true, capEnd: true, capSegs: 3 }));
// HANS collar
const collar = [];
for (let i = 0; i <= 8; i++){
const t = i / 8;
const w = 0.128 + 0.046 * t;
collar.push(ringFromClosed(podPts({ xIn: -w, xOut: w, yTop: 0.566 - t * 0.018, yBot: 0.512 - t * 0.020, nTop: 2.4, nBot: 2.2, nIn: 2.4 }, 28),
lerp(0.400, 0.250, t), 30));
}
parts.push(loftLoops(collar, { capStart: true, capEnd: true, capSegs: 2 }));
// upper arms reaching the wheel
for (const s of [1, -1]){
parts.push(strut(V3(s * 0.166, 0.480, 0.352), V3(s * 0.118, 0.512, 0.596),
{ chord: 0.104, thickRatio: 0.86, taper: 0.80, steps: 8 }));
// glove
const gl = [];
for (let i = 0; i <= 8; i++){
const t = i / 8, r = 0.050 * Math.sin(PI * (0.22 + t * 0.66));
const loop = [];
for (let k = 0; k < 22; k++){ const a = k / 22 * TAU; loop.push(V3(s * (0.112 + Math.sin(a) * r), 0.522 + Math.cos(a) * r * 1.15, lerp(0.596, 0.660, t))); }
gl.push(loop);
}
parts.push(loftLoops(gl, { capStart: true, capEnd: true, capSegs: 2 }));
}
return logPart('driver:body', merge(parts));
}
function buildSteeringWheel(){
const parts = [];
const cz = 0.6480, cy = 0.5280, tilt = -0.42;
const R = new THREE.Matrix4().makeRotationX(tilt);
// butterfly rim outline in the wheel plane
const out = [
[-0.108,0.052],[-0.052,0.076],[0.052,0.076],[0.108,0.052],[0.112,-0.010],
[0.086,-0.058],[0.042,-0.074],[-0.042,-0.074],[-0.086,-0.058],[-0.112,-0.010],
];
const holes = [
[[-0.086,0.040],[-0.040,0.058],[-0.040,0.010],[-0.086,0.006]],
[[0.086,0.040],[0.040,0.058],[0.040,0.010],[0.086,0.006]],
];
const body = plate(spline2(out.map(p => V2(p[0], p[1])), 110, { closed: true }), {
thickness: 0.030, bevel: 0.006, bevelSegs: 3,
holes: holes.map(h => spline2(h.map(p => V2(p[0], p[1])), 40, { closed: true }).reverse()),
});
body.applyMatrix4(R); body.translate(0, cy, cz);
parts.push(body);
// grips
for (const s of [1, -1]){
const grip = [];
for (let i = 0; i <= 8; i++){
const t = i / 8;
grip.push(V3(s * (0.100 + 0.006 * t), 0.030 - 0.076 * t, 0.014));
}
const g = sweepSection(grip, (t) => squircle(0.019, 0.016, 2.6, 12), { capStart: true, capEnd: true, capSegs: 2 });
g.applyMatrix4(R); g.translate(0, cy, cz);
parts.push(g);
}
// column
const col = [V3(0, cy - 0.020, cz - 0.010), V3(0, cy - 0.062, cz - 0.140)];
parts.push(sweepTube(col, 0.020, 14, { capStart: true, capEnd: true }));
const face = plate([[-0.070,-0.030],[0.070,-0.030],[0.070,0.030],[-0.070,0.030]].map(p => V2(p[0], p[1])),
{ thickness: 0.004, bevel: 0.001 });
face.applyMatrix4(R); face.translate(0, cy + 0.006, cz + 0.017);
return { wheel: logPart('cockpit:steeringWheel', merge(parts)), face: logPart('cockpit:wheelDisplay', face) };
}
function buildBelts(){
const parts = [];
for (const s of [1, -1]){
for (const spread of [0.052, 0.112]){
const p = [];
for (let i = 0; i <= 10; i++){
const t = i / 10;
p.push(V3(s * lerp(spread, spread * 1.9, t), lerp(0.502, 0.372, t), lerp(0.440, 0.238, t)));
}
parts.push(sweepSection(p, () => squircle(0.024, 0.0035, 2.2, 10), { capStart: true, capEnd: true, capSegs: 2 }));
}
}
return logPart('cockpit:harness', merge(parts));
}
/* ---- camera pods, antennas, pitot ---------------------------------------------------- */
function buildCameraPods(){
const parts = [], lenses = [];
// slim streamlined housing: long in z, small in section — a camera fairing, not a ball
const pod = (x, y, z, sc) => {
const rows = [];
const L = 0.108 * sc;
for (let i = 0; i <= 14; i++){
const t = i / 14;
const taper = Math.pow(Math.sin(PI * (0.10 + t * 0.86)), 0.55);
const w = sc * 0.0215 * taper;
const h = sc * 0.0195 * taper;
const zz = z + L * (0.42 - t);
const loop = [];
for (let k = 0; k < 24; k++){
const a = k / 24 * TAU, sx = Math.sin(a), cy = Math.cos(a);
loop.push(V3(x + Math.sign(sx) * Math.pow(Math.abs(sx), 0.78) * w,
y + Math.sign(cy) * Math.pow(Math.abs(cy), 0.78) * h, zz));
}
rows.push(loop);
}
parts.push(loftLoops(rows, { capStart: true, capEnd: true, capSegs: 3, capBulge: 0.45 }));
const lens = latheX(spline2([V2(0.0008, 0), V2(sc * 0.0140, 0), V2(sc * 0.0150, sc * 0.0060)], 10, { closed: false }), 20);
lens.applyMatrix4(new THREE.Matrix4().makeRotationY(-PI * 0.5));
lens.translate(x, y, z + L * 0.44);
lenses.push(lens);
};
pod(0, 0.9700, -0.2020, 1.25); // T-cam above the airbox
pod(0.1580, 0.4620, 2.1400, 0.85); // nose cameras
pod(-0.1580, 0.4620, 2.1400, 0.85);
pod(0.2440, 0.6380, 0.9200, 0.70); // halo-side pods
pod(-0.2440, 0.6380, 0.9200, 0.70);
// pitot boom on the nose tip
const boom = [V3(0, 0.3420, 2.7560), V3(0, 0.3480, 2.8620)];
parts.push(sweepTube(boom, (t) => lerp(0.0075, 0.0042, t), 12, { capStart: true, capEnd: true }));
// GPS / telemetry blisters on the engine cover
for (const [x, y, z, r] of [[0, 0.7180, -0.7400, 0.030], [0.0860, 0.6820, -0.9100, 0.024], [-0.0860, 0.6820, -0.9100, 0.024]]){
const g = latheY(spline2([V2(0.001, y + r * 0.62), V2(r * 0.62, y + r * 0.44), V2(r, y), V2(r * 1.02, y - r * 0.5)], 14, { closed: false }), 24);
g.translate(x, 0, z);
parts.push(g);
}
return { pods: logPart('detail:cameraPods', merge(parts)), lenses: logPart('detail:lenses', merge(lenses)) };
}
/* ---- jack points, tow eyes, tethers --------------------------------------------------- */
function buildServiceFittings(){
const parts = [];
// rear jack bracket
const jack = [];
for (let i = 0; i <= 8; i++){
const t = i / 8;
const loop = [];
for (let k = 0; k < 20; k++){
const a = k / 20 * TAU;
loop.push(V3(Math.sin(a) * lerp(0.030, 0.019, t), 0.360 + Math.cos(a) * lerp(0.022, 0.015, t), lerp(-2.100, -2.230, t)));
}
jack.push(loop);
}
parts.push(loftLoops(jack, { capStart: true, capEnd: true, capSegs: 2 }));
// front tow eye under the nose
const eye = [];
const n = 22;
for (let i = 0; i < n; i++){ const a = i / n * TAU; eye.push(V2(Math.cos(a) * 0.028, Math.sin(a) * 0.028)); }
const eyeHole = [];
for (let i = n - 1; i >= 0; i--){ const a = i / n * TAU; eyeHole.push(V2(Math.cos(a) * 0.014, Math.sin(a) * 0.014)); }
const g = plate(eye, { thickness: 0.014, bevel: 0.003, holes: [eyeHole] });
g.applyMatrix4(new THREE.Matrix4().makeRotationY(PI * 0.5));
g.translate(0, 0.2660, 2.4600);
parts.push(g);
// wheel tethers
for (const s of [1, -1]){
for (const [ax, hx] of [[D.frontAxleZ, 0.5600], [D.rearAxleZ, 0.5400]]){
parts.push(sweepTube([V3(s * 0.200, 0.300, ax + 0.060), V3(s * hx, 0.300, ax + 0.030)], 0.0075, 8, { capStart: true, capEnd: true }));
}
}
return logPart('detail:serviceFittings', merge(parts));
}
/* ---- radiator cores visible through the sidepod inlet ---------------------------------- */
function buildRadiatorCore(){
const parts = [];
for (const s of [1, -1]){
const g = plate([[-0.086,-0.062],[0.086,-0.062],[0.086,0.062],[-0.086,0.062]].map(p => V2(p[0], p[1])),
{ thickness: 0.010, bevel: 0.001 });
g.applyMatrix4(new THREE.Matrix4().makeRotationY(0.16 * s));
g.translate(s * 0.3820, 0.3760, 0.4200);
parts.push(g);
}
return logPart('detail:radiatorCore', merge(parts));
}
/* =====================================================================================
ASSEMBLY
The model is a semantic build order, not a bag of meshes: every part is added with the
material identity it is made of, its own shadow participation, and a name that matches
the part log. Corner hardware is emitted ONCE per axle and mirrored per side, so the
left and right wheels share geometry while keeping opposite winding and UV handedness.
===================================================================================== */
/** Complete car: geometry, materials, corner hardware, and presentation state. */
export function createFormulaOneRaceCar(){
const materials = buildMaterials();
const logStart = PART_LOG.length;
const car = new THREE.Group();
car.name = 'VF-26';
const meshes = [];
function add(geometry, material, name, parent = car, shadow = true){
if (!geometry) return null;
const m = new THREE.Mesh(geometry, material);
m.name = name || material.name;
m.castShadow = shadow; m.receiveShadow = shadow;
parent.add(m);
meshes.push(m);
return m;
}
const pair = (geometry, material, name) => { add(geometry, material, name); add(mirrorX(geometry), material, name + '.L'); };
/* ---- monocoque, nose, cockpit ---- */
add(buildHull(), MAT.body, 'hull');
add(buildCockpitLiner(), MAT.padding, 'cockpitLiner');
add(buildCockpitRim(), MAT.carbonGloss, 'cockpitRim');
add(buildHeadrest(), MAT.padding, 'headrest');
/* ---- sidepods and cooling ---- */
pair(buildSidepod(), MAT.body, 'sidepod');
const inlet = buildSidepodInlet();
pair(inlet.lip, MAT.bodyFine, 'inletLip');
pair(inlet.duct, MAT.carbonDuct, 'inletDuct');
add(buildRadiatorCore(), MAT.darkAlloy, 'radiator');
add(buildLouvres(), MAT.carbonMatte, 'louvres');
/* ---- airbox, roll structure, halo ---- */
const ab = buildAirbox();
add(ab.shell, MAT.body, 'airbox');
add(buildSharkFin(), MAT.body, 'sharkFin');
add(buildHalo(), MAT.carbonGloss, 'halo');
add(buildHaloVane(), MAT.carbonGloss, 'haloVane');
const mir = buildMirrors();
add(mir.housing, MAT.bodyFine, 'mirrors');
add(mir.glass, MAT.mirror, 'mirrorGlass', car, false);
/* ---- floor, tunnels, diffuser ---- */
add(buildFloor(), MAT.floor, 'floor');
add(buildDiffuserStrakes(), MAT.carbonGloss, 'diffuserStrakes');
add(buildFloorFences(), MAT.carbonGloss, 'floorFences');
add(buildPlank(), MAT.plank, 'plank');
add(buildVortexGenerators(), MAT.carbonGloss, 'vortexGenerators');
/* ---- aerodynamic surfaces and rear end ---- */
add(buildFrontWing(), MAT.wing, 'frontWing');
add(buildFrontEndplate(), MAT.plate, 'frontEndplate');
add(buildNosePylons(), MAT.plate, 'nosePylons');
add(buildRearWing(), MAT.wing, 'rearWing');
add(buildRearEndplate(), MAT.plate, 'rearEndplate');
add(buildRearPylons(), MAT.carbonGloss, 'rearPylons');
add(buildBeamWing(), MAT.wing, 'beamWing');
add(buildRearStructure(), MAT.body, 'crashStructure');
add(buildExhaust(), MAT.exhaust, 'exhaust');
add(buildRainLight(), MAT.rainLight, 'rainLight', car, false);
add(buildGearbox(), MAT.carbonMatte, 'gearbox');
/* ---- corners ---- */
const wheels = [];
const cornerCache = {};
function buildCorner(front, side){
const R = front ? D.frontR : D.rearR;
const halfW = (front ? D.frontTyre.width : D.rearTyre.width) * 0.5;
const rimR = D.frontTyre.rimOD * 0.5;
const hubX = front ? D.frontHubX : D.rearHubX;
const axleZ = front ? D.frontAxleZ : D.rearAxleZ;
const key = front ? 'F' : 'R';
if (!cornerCache[key]){
const t = buildTyre({ R, halfW, rimR, drop: front ? 0.0135 : 0.0150 });
cornerCache[key] = {
tyreRound: t.round, tyreLoaded: t.loaded,
rim: buildRim({ rimR, halfW }),
cover: buildRimCover({ rimR, halfW }),
disc: buildBrakeDisc({ rOut: front ? 0.1550 : 0.1500, rIn: 0.0760, thick: front ? 0.032 : 0.030 }),
caliper: buildCaliper({ r: front ? 0.1700 : 0.1650 }),
duct: buildBrakeDuct({ R, halfW, front }),
upright: buildUpright({ front }),
};
}
const C = cornerCache[key];
const M = side > 0 ? (g => g) : mirrorX;
const root = new THREE.Group();
root.position.set(side * hubX, R, axleZ);
car.add(root);
const spin = new THREE.Group();
root.add(spin);
const tyre = add(M(C.tyreLoaded), side > 0 ? MAT.rubberMirror : MAT.rubber, `tyre.${key}${side > 0 ? 'L' : 'R'}`, spin);
add(M(C.rim), MAT.rim, 'rim', spin);
add(M(C.cover), MAT.rimCover, 'rimCover', spin);
add(M(C.disc), MAT.brakeDisc, 'brakeDisc', spin);
add(M(C.caliper), MAT.anodRed, 'caliper', root);
add(M(C.duct), MAT.carbonDuct, 'brakeDuct', root);
add(M(C.upright), MAT.titanium, 'upright', root);
wheels.push({ spin, tyre, round: M(C.tyreRound), loaded: M(C.tyreLoaded), front });
}
for (const front of [true, false]) for (const side of [1, -1]) buildCorner(front, side);
add(buildFrontSuspension(), MAT.carbonGloss, 'frontSuspension');
add(buildRearSuspension(), MAT.carbonGloss, 'rearSuspension');
/* ---- driver, cockpit, fine detail ---- */
add(buildDriverBody(), MAT.suit, 'driver');
add(buildHelmet(), MAT.helmet, 'helmet');
add(buildVisor(), MAT.visor, 'visor', car, false);
add(buildBelts(), MAT.padding, 'harness');
const sw = buildSteeringWheel();
add(sw.wheel, MAT.darkAlloy, 'steeringWheel');
add(sw.face, MAT.display, 'wheelDisplay', car, false);
const cams = buildCameraPods();
add(cams.pods, MAT.bodyFine, 'cameraPods');
add(cams.lenses, MAT.lens, 'lenses', car, false);
add(buildServiceFittings(), MAT.titanium, 'serviceFittings');
const stats = PART_LOG.slice(logStart);
const totalTriangles = stats.reduce((sum, p) => sum + p.tris, 0);
const originalMaterial = new Map();
let rolling = false;
return {
object: car,
meshes,
wheels,
materials,
uniforms: U,
stats,
totalTriangles,
/** Structural read of the emitted surfaces, with materials removed from the answer. */
setWireframe(on){
for (const m of meshes){
if (on){
if (!originalMaterial.has(m)) originalMaterial.set(m, m.material);
m.material = MAT.wireframe;
} else if (originalMaterial.has(m)){
m.material = originalMaterial.get(m);
}
}
},
/** 0 shows the bare structural finish under the paint. */
setLivery(on){ U.livery.value = on ? 1.0 : 0.0; },
/** Which projector plane won each surface, as a two-colour field. */
setProjectorDebug(on){ U.projDbg.value = on ? 1.0 : 0.0; },
/**
* Rolling swaps in the undeflected carcass. The contact patch is GEOMETRY, so a
* parked tyre cannot simply be spun — the flat would orbit with it.
*/
setRolling(on){
rolling = on;
for (const w of wheels) w.tyre.geometry = on ? w.round : w.loaded;
},
update({ delta = 0, elapsed = 0 } = {}){
if (rolling){
for (const w of wheels) w.spin.rotation.x -= delta * (w.front ? 7.4 : 7.3);
}
U.discGlow.value = 0.16 + 0.05 * Math.sin(elapsed * 0.6);
},
dispose(){
const geometries = new Set();
car.traverse((o) => { if (o.isMesh) geometries.add(o.geometry); });
for (const w of wheels){ geometries.add(w.round); geometries.add(w.loaded); }
for (const g of geometries) g.dispose();
for (const m of Object.values(materials)) m.dispose?.();
for (const t of Object.values(TEX)) t.dispose?.();
PART_LOG.length = logStart;
},
};
}
examples/formula-one-race-car/source/race-car-textures.js
import * as THREE from "three/webgpu";
import { PI, V2, clamp01, lerp, smooth01, spline2 } from "./mesh-kit.js";
import { PAL, PROJ } from "./design-contract.js";
/*
GENERATED TEXTURE INPUTS
The livery is a two-plane PROJECTOR, not a per-part unwrap: one canvas painted in the
car's side elevation (z,y) and one painted in its plan view (z,x). Bodywork samples
whichever plane its world normal faces, so the paint scheme stays continuous across
nose / chassis / sidepod / engine-cover boundaries exactly like a real wrap.
*/
function makeCanvas(w, h){
const c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d');
x.clearRect(0, 0, w, h);
x.lineJoin = 'round'; x.lineCap = 'round';
return { c, x };
}
const hex = (n, a = 1) => `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;
/* ---- painting helpers, all in world metres ---------------------------------------- */
function painter(ctx, toPx, ppm){
const P = (z, s) => toPx(z, s); // -> [px, py]
const api = {
ctx, ppm,
m: (metres) => metres * ppm,
poly(points, { fill = null, stroke = null, width = 0.01, close = true, smooth = 0 }){
const pts = smooth ? spline2(points.map(p => V2(p[0], p[1])), smooth, { closed: close }) .map(p => [p.x, p.y]) : points;
ctx.beginPath();
pts.forEach((p, i) => { const q = P(p[0], p[1]); i ? ctx.lineTo(q[0], q[1]) : ctx.moveTo(q[0], q[1]); });
if (close) ctx.closePath();
if (fill){ ctx.fillStyle = fill; ctx.fill(); }
if (stroke){ ctx.strokeStyle = stroke; ctx.lineWidth = width * ppm; ctx.stroke(); }
},
/** Swept band: centreline points [[a,b],...] with per-point half-widths. */
ribbon(points, widths, { fill = null, stroke = null, edge = 0.008, samples = 120 }){
const cl = spline2(points.map(p => V2(p[0], p[1])), samples);
const wl = [];
for (let i = 0; i < samples; i++){
const g = i / (samples - 1) * (widths.length - 1);
const k = Math.min(Math.floor(g), widths.length - 2);
wl.push(lerp(widths[k], widths[k + 1], smooth01(g - k)));
}
const left = [], right = [];
for (let i = 0; i < samples; i++){
const a = cl[Math.max(0, i - 1)], b = cl[Math.min(samples - 1, i + 1)];
const t = V2(b.x - a.x, b.y - a.y); const L = Math.hypot(t.x, t.y) || 1;
const nx = -t.y / L, ny = t.x / L;
left.push([cl[i].x + nx * wl[i], cl[i].y + ny * wl[i]]);
right.push([cl[i].x - nx * wl[i], cl[i].y - ny * wl[i]]);
}
const loop = [...left, ...right.reverse()];
ctx.beginPath();
loop.forEach((p, i) => { const q = P(p[0], p[1]); i ? ctx.lineTo(q[0], q[1]) : ctx.moveTo(q[0], q[1]); });
ctx.closePath();
if (stroke){ ctx.strokeStyle = stroke; ctx.lineWidth = edge * ppm; ctx.stroke(); }
if (fill){ ctx.fillStyle = fill; ctx.fill(); }
},
line(points, { stroke, width = 0.006, smooth = 0 }){
const pts = smooth ? spline2(points.map(p => V2(p[0], p[1])), smooth).map(p => [p.x, p.y]) : points;
ctx.beginPath();
pts.forEach((p, i) => { const q = P(p[0], p[1]); i ? ctx.lineTo(q[0], q[1]) : ctx.moveTo(q[0], q[1]); });
ctx.strokeStyle = stroke; ctx.lineWidth = width * ppm; ctx.stroke();
},
rect(a, b, c, d, fill){
const p0 = P(a, b), p1 = P(c, d);
ctx.fillStyle = fill;
ctx.fillRect(Math.min(p0[0],p1[0]), Math.min(p0[1],p1[1]), Math.abs(p1[0]-p0[0]), Math.abs(p1[1]-p0[1]));
},
/** Glyph set drawn at a world anchor; `mirror` flips handedness only, never position. */
text(str, z, s, { size = 0.14, fill = '#fff', stroke = null, sw = 0.006, slant = 0.20, mirror = false,
rot = 0, align = 'center', weight = 800, spacing = 0, family = '"Arial Narrow",Arial,Helvetica,sans-serif' } = {}){
const q = P(z, s);
ctx.save();
ctx.translate(q[0], q[1]);
if (rot) ctx.rotate(rot);
if (mirror) ctx.scale(-1, 1);
ctx.transform(1, 0, -slant, 1, 0, 0);
ctx.font = `${weight} ${Math.round(size * ppm)}px ${family}`;
ctx.textAlign = align; ctx.textBaseline = 'middle';
let s2 = str;
if (spacing){
const w = ctx.measureText(str).width;
let x = align === 'center' ? -((w + spacing * ppm * (str.length - 1)) / 2) : 0;
ctx.textAlign = 'left';
for (const ch of s2){
if (stroke){ ctx.lineWidth = sw * ppm; ctx.strokeStyle = stroke; ctx.strokeText(ch, x, 0); }
ctx.fillStyle = fill; ctx.fillText(ch, x, 0);
x += ctx.measureText(ch).width + spacing * ppm;
}
} else {
if (stroke){ ctx.lineWidth = sw * ppm; ctx.strokeStyle = stroke; ctx.strokeText(s2, 0, 0); }
ctx.fillStyle = fill; ctx.fillText(s2, 0, 0);
}
ctx.restore();
},
};
return api;
}
/* ---- SIDE ELEVATION -------------------------------------------------------------- */
function buildLiverySide(){
const { SIDE_W, SIDE_LAYER_H, sidePPM, Z1, Y1 } = PROJ;
const { c, x: ctx } = makeCanvas(SIDE_W, SIDE_LAYER_H * 2);
for (let layer = 0; layer < 2; layer++){
// three.js is right-handed with +Z forward and +Y up, so +X is the car's LEFT flank.
// Layer 0 (+X) reads correctly as painted; layer 1 (-X) needs mirrored glyphs.
const mirror = layer === 1;
const off = layer * SIDE_LAYER_H;
const toPx = (z, y) => [(Z1 - z) * sidePPM, off + (Y1 - y) * sidePPM];
const p = painter(ctx, toPx, sidePPM);
const RED = hex(PAL.red), REDB = hex(PAL.redBright), WHT = hex(PAL.white);
const GRY = hex(PAL.bodyGrey, .92), DRK = 'rgba(6,6,8,0.90)';
/* The car is BLACK. Everything below is an accent ribbon on a bare graphite body —
coverage is deliberately sparse so the sculpted surface, not the paint, carries
the form. */
/* --- floor edge pinstripe ---------------------------------------------------- */
p.ribbon([[1.450,0.152],[0.700,0.140],[-0.200,0.141],[-1.000,0.157],[-1.640,0.186]],
[0.0025,0.0075,0.0085,0.0070,0.0025], { fill: RED });
/* --- sidepod spear: slim white blade rising to the rear, crimson under-edge ---- */
const spearC = [[0.756,0.300],[0.520,0.336],[0.140,0.372],[-0.330,0.396],[-0.830,0.404],[-1.290,0.396]];
p.ribbon(spearC, [0.004,0.019,0.026,0.024,0.017,0.005], { fill: WHT });
p.ribbon(spearC.map(q => [q[0], q[1] - 0.032]), [0.003,0.008,0.011,0.010,0.007,0.002], { fill: RED });
/* --- upper shoulder flash rising onto the engine cover ----------------------- */
p.ribbon([[0.600,0.578],[0.120,0.594],[-0.360,0.588],[-0.860,0.550],[-1.280,0.492],[-1.540,0.452]],
[0.004,0.015,0.019,0.016,0.011,0.004], { fill: REDB });
/* --- sidepod inlet surround --------------------------------------------------- */
p.poly([[0.772,0.278],[0.792,0.470],[0.742,0.520],[0.660,0.512],[0.640,0.320],[0.694,0.272]],
{ stroke: RED, width: 0.010, close: true, smooth: 60 });
/* --- nose: white blade with a crimson under-edge ------------------------------ */
p.ribbon([[2.744,0.348],[2.560,0.378],[2.300,0.414],[2.060,0.448],[1.910,0.462]],
[0.005,0.018,0.026,0.024,0.018], { fill: WHT });
p.ribbon([[2.748,0.316],[2.566,0.342],[2.306,0.372],[2.066,0.404],[1.916,0.416]],
[0.003,0.008,0.011,0.010,0.007], { fill: RED });
/* --- race number on the nose flank -------------------------------------------- */
p.text('27', 2.352, 0.418, { size: 0.128, fill: WHT, stroke: hex(PAL.red), sw: 0.008, mirror, slant: 0.22 });
/* --- front wing endplate face -------------------------------------------------- */
p.ribbon([[2.966,0.238],[2.840,0.276],[2.640,0.290],[2.446,0.276]],
[0.010,0.016,0.016,0.010], { fill: WHT });
p.ribbon([[2.972,0.118],[2.930,0.190],[2.884,0.238]], [0.006,0.010,0.008], { fill: RED });
/* --- rear wing endplate --------------------------------------------------------- */
p.ribbon([[-1.848,0.906],[-2.040,0.912],[-2.250,0.904],[-2.368,0.890]],
[0.012,0.016,0.015,0.010], { fill: REDB });
p.ribbon([[-2.318,0.860],[-2.334,0.760],[-2.344,0.672]], [0.010,0.013,0.009], { fill: WHT });
p.text('27', -2.120, 0.796, { size: 0.062, fill: hex(PAL.whiteDim,.9), mirror, slant: 0.2 });
/* --- beam wing / rear crash accents ---------------------------------------------- */
p.ribbon([[-1.910,0.318],[-2.090,0.312],[-2.246,0.304]], [0.005,0.007,0.004], { fill: RED });
/* --- halo pillar accent ----------------------------------------------------------- */
p.ribbon([[1.150,0.636],[1.106,0.766],[1.056,0.866]], [0.009,0.008,0.006], { fill: REDB });
/* --- panel shutlines --------------------------------------------------------- */
const shut = (pts, w = 0.0055) => p.line(pts, { stroke: DRK, width: w, smooth: 90 });
shut([[1.905,0.560],[1.900,0.400],[1.906,0.262]]); // bulkhead joint
shut([[1.075,0.590],[1.078,0.470],[1.086,0.330]]); // cockpit front
shut([[0.120,0.596],[0.126,0.470],[0.140,0.330]]); // cockpit rear
shut([[-1.190,0.520],[-1.196,0.400],[-1.210,0.290]]); // sidepod exit
shut([[0.640,0.556],[0.240,0.590],[-0.300,0.594],[-0.860,0.554],[-1.400,0.470]], 0.0048);
shut([[0.700,0.212],[0.100,0.196],[-0.560,0.196],[-1.220,0.226]], 0.0045); // floor joint
/* --- generated sponsor marks (fictional) --------------------------------------- */
p.text('VF-26', -0.640, 0.520, { size: 0.044, fill: hex(PAL.whiteDim,.95), mirror, slant: 0.16, spacing: 0.004 });
p.text('AERO-DYNAMICA', 0.130, 0.300, { size: 0.030, fill: hex(PAL.whiteDim,.85), mirror, slant: 0.14, spacing: 0.002 });
p.text('TITAN FUELS', -1.010, 0.336, { size: 0.028, fill: hex(PAL.whiteDim,.80), mirror, slant: 0.14, spacing: 0.002 });
p.text('GEN3 HYBRID', 1.500, 0.296, { size: 0.026, fill: hex(PAL.whiteDim,.70), mirror, slant: 0.14, spacing: 0.002 });
p.rect(-1.380, 0.244, -1.208, 0.276, hex(PAL.white, 1));
p.text('E-FUEL 100%', -1.294, 0.260, { size: 0.022, fill: hex(PAL.carbon,1), mirror, slant: 0 });
}
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 16;
t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
t.needsUpdate = true;
return t;
}
/* ---- PLAN VIEW -------------------------------------------------------------------- */
function buildLiveryTop(){
const { TOP_W, TOP_H, topPPM, Z1, X1 } = PROJ;
const { c, x: ctx } = makeCanvas(TOP_W, TOP_H);
const toPx = (z, xw) => [(Z1 - z) * topPPM, (X1 - xw) * topPPM];
const p = painter(ctx, toPx, topPPM);
const RED = hex(PAL.red), REDB = hex(PAL.redBright), WHT = hex(PAL.white), GRY = hex(PAL.bodyGrey, .9);
const DRK = 'rgba(6,6,8,0.88)';
const both = (fn) => { fn(1); fn(-1); };
/* --- nose spear: narrow white blade with crimson edges ------------------------- */
p.ribbon([[2.750,0],[2.520,0],[2.240,0],[1.980,0],[1.800,0]],
[0.005,0.020,0.028,0.032,0.028], { fill: WHT });
p.ribbon([[2.742,0],[2.520,0],[2.240,0],[1.980,0],[1.820,0]],
[0.002,0.008,0.012,0.014,0.012], { fill: hex(PAL.carbonWarm, 1) });
both(s => p.ribbon([[2.750,0.008*s],[2.520,0.026*s],[2.240,0.035*s],[1.980,0.039*s],[1.800,0.035*s]],
[0.0025,0.0060,0.0070,0.0070,0.0055], { fill: RED }));
/* --- number on the nose deck (rotated into the plan frame) --------------------- */
p.text('27', 2.372, 0, { size: 0.098, fill: hex(PAL.white), stroke: hex(PAL.red), sw: 0.006, rot: -Math.PI/2, slant: 0.22, mirror: true });
/* --- chassis blades sweeping out to the sidepod shoulders ---------------------- */
both(s => {
p.ribbon([[1.870,0.078*s],[1.560,0.156*s],[1.220,0.236*s],[0.900,0.318*s],[0.700,0.380*s]],
[0.008,0.014,0.018,0.018,0.010], { fill: WHT });
p.ribbon([[1.866,0.048*s],[1.556,0.122*s],[1.216,0.200*s],[0.898,0.282*s],[0.698,0.344*s]],
[0.004,0.006,0.007,0.007,0.004], { fill: REDB });
});
/* --- sidepod top plane: forward chevrons -------------------------------------- */
both(s => {
p.ribbon([[0.720,0.600*s],[0.300,0.664*s],[-0.240,0.652*s],[-0.760,0.592*s],[-1.190,0.502*s]],
[0.006,0.022,0.026,0.020,0.006], { fill: WHT });
p.ribbon([[0.706,0.548*s],[0.292,0.610*s],[-0.250,0.598*s],[-0.772,0.540*s],[-1.190,0.454*s]],
[0.003,0.008,0.010,0.008,0.003], { fill: RED });
});
/* --- engine cover spine -------------------------------------------------------- */
both(s => p.ribbon([[-0.240,0.058*s],[-0.660,0.052*s],[-1.090,0.041*s],[-1.500,0.026*s],[-1.660,0.016*s]],
[0.006,0.0055,0.0045,0.0032,0.0018], { fill: REDB }));
p.text('27', -1.300, 0, { size: 0.056, fill: hex(PAL.white,.95), rot: -Math.PI/2, slant: 0.2, mirror: true });
/* --- airbox collar ------------------------------------------------------------- */
p.poly([[-0.150,0.092],[-0.330,0.100],[-0.330,-0.100],[-0.150,-0.092]], { stroke: RED, width: 0.008 });
/* --- front wing plan: crimson leading edge, white on the outboard flaps -------- */
both(s => {
p.ribbon([[2.952,0.320*s],[2.918,0.600*s],[2.876,0.800*s],[2.836,0.890*s]],
[0.0060,0.0075,0.0075,0.0050], { fill: RED });
p.ribbon([[2.690,0.480*s],[2.652,0.680*s],[2.612,0.826*s],[2.576,0.892*s]],
[0.0120,0.0160,0.0150,0.0090], { fill: WHT });
p.ribbon([[2.512,0.520*s],[2.478,0.700*s],[2.444,0.832*s],[2.412,0.894*s]],
[0.0100,0.0130,0.0120,0.0070], { fill: hex(PAL.whiteDim,.9) });
});
/* --- rear wing plan ------------------------------------------------------------ */
p.rect(-1.852, -0.472, -1.986, 0.472, REDB);
both(s => p.rect(-2.128, 0.472*s, -2.320, 0.418*s, WHT));
p.rect(-2.150, -0.180, -2.300, 0.180, hex(PAL.white, .95));
/* --- shutlines / cooling exits -------------------------------------------------- */
both(s => p.line([[0.660,0.700*s],[0.120,0.742*s],[-0.420,0.716*s],[-0.960,0.612*s],[-1.230,0.520*s]],
{ stroke: DRK, width: 0.005, smooth: 90 }));
both(s => p.line([[1.900,0.115*s],[1.560,0.208*s],[1.180,0.300*s]], { stroke: DRK, width: 0.0045, smooth: 60 }));
p.line([[1.905,0.100],[1.905,-0.100]], { stroke: DRK, width: 0.0055 });
p.line([[0.115,0.240],[0.115,-0.240]], { stroke: DRK, width: 0.0055 });
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 16;
t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
t.needsUpdate = true;
return t;
}
/* ---- carbon twill: height field -> Sobel normal + AO ------------------------------ */
function buildCarbonTexture(size = 512){
const h = new Float32Array(size * size);
const tow = size / 8; // 4 tows across in each direction (2x2 twill)
for (let y = 0; y < size; y++){
for (let x = 0; x < size; x++){
const gx = Math.floor(x / tow), gy = Math.floor(y / tow);
const over = ((gx + gy) & 3) < 2; // 2x2 twill float pattern
const fx = (x % tow) / tow, fy = (y % tow) / tow;
const along = over ? fx : fy; // filament direction of this tow
const across = over ? fy : fx;
const bulge = Math.sin(PI * clamp01(across)) ** 0.75;
const filament = 0.5 + 0.5 * Math.cos(along * PI * 2 * 9);
let v = (over ? 0.62 : 0.30) + bulge * 0.42 + filament * 0.055 * bulge;
const seam = Math.min(across, 1 - across);
v -= Math.exp(-seam * seam * 340) * 0.20;
h[y * size + x] = v;
}
}
const at = (x, y) => h[((y % size) + size) % size * size + (((x % size) + size) % size)];
const data = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++){
for (let x = 0; x < size; x++){
const dx = (at(x+1,y) - at(x-1,y)) * 3.4;
const dy = (at(x,y+1) - at(x,y-1)) * 3.4;
const inv = 1 / Math.hypot(dx, dy, 1);
const i = (y * size + x) * 4;
data[i] = (-dx * inv * 0.5 + 0.5) * 255;
data[i+1] = ( dy * inv * 0.5 + 0.5) * 255;
data[i+2] = ( inv * 0.5 + 0.5) * 255;
data[i+3] = clamp01(at(x,y)) * 255; // alpha carries the weave height / AO
}
}
const t = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.anisotropy = 16;
t.generateMipmaps = true;
t.minFilter = THREE.LinearMipmapLinearFilter;
t.magFilter = THREE.LinearFilter;
t.needsUpdate = true;
return t;
}
/* ---- tyre sidewall: u = circumference, v = across the section --------------------- */
function buildTyreWall(){
const W = 2048, H = 512;
const { c, x: ctx } = makeCanvas(W, H);
ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, W, H);
// v bands: 0=inner sidewall .. 0.5=tread .. 1=outer sidewall
const rows = 22;
for (let i = 0; i < rows; i++){ // moulding rings on both sidewalls
const t = i / rows;
const yA = H * (0.055 + t * 0.30), yB = H * (0.945 - t * 0.30);
const v = 26 + Math.sin(t * PI * 7) * 12;
ctx.fillStyle = `rgb(${v},${v},${v + 2})`;
ctx.fillRect(0, yA, W, H * 0.006);
ctx.fillRect(0, yB, W, H * 0.006);
}
// compound band (soft = crimson) on the outboard sidewall
const gradient = ctx.createLinearGradient(0, H * 0.80, 0, H * 0.905);
gradient.addColorStop(0, hex(PAL.redBright, 0)); gradient.addColorStop(0.35, hex(PAL.redBright, 1));
gradient.addColorStop(0.75, hex(PAL.red, 1)); gradient.addColorStop(1, hex(PAL.red, 0));
ctx.fillStyle = gradient; ctx.fillRect(0, H * 0.80, W, H * 0.105);
const label = (yc, txt, size, fill, letters) => {
for (let k = 0; k < letters; k++){
const cx = (k + 0.5) / letters * W;
ctx.save(); ctx.translate(cx, yc);
ctx.font = `800 ${size}px "Arial Narrow",Arial,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = fill; ctx.fillText(txt, 0, 0);
ctx.restore();
}
};
label(H * 0.855, 'SOFT', 44, '#f4f6f8', 4);
label(H * 0.755, 'F O R M U L A 1', 26, 'rgba(210,215,220,.72)', 4);
label(H * 0.115, '372 / 720 R18', 22, 'rgba(150,155,160,.6)', 5);
label(H * 0.185, 'CONTROL TYRE', 20, 'rgba(120,125,130,.45)', 5);
// tread crown: micro texture only
ctx.fillStyle = 'rgba(255,255,255,0.018)';
for (let i = 0; i < 2600; i++){
const px = Math.random() * W, py = H * 0.40 + Math.random() * H * 0.20;
ctx.fillRect(px, py, 2 + Math.random() * 5, 1);
}
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.wrapS = THREE.RepeatWrapping; t.wrapT = THREE.ClampToEdgeWrapping;
t.anisotropy = 16; t.needsUpdate = true;
return t;
}
/* ---- steering wheel display + small dials ---------------------------------------- */
function buildWheelFace(){
const { c, x: ctx } = makeCanvas(512, 256);
ctx.fillStyle = '#07080a'; ctx.fillRect(0, 0, 512, 256);
ctx.fillStyle = '#0d1014'; ctx.fillRect(96, 40, 320, 150);
// shift lights
for (let i = 0; i < 14; i++){
const t = i / 13;
ctx.fillStyle = t < 0.45 ? '#12d15a' : t < 0.78 ? '#ff2d20' : '#3a6bff';
ctx.fillRect(104 + i * 22, 12, 15, 16);
}
ctx.fillStyle = '#e8ecef'; ctx.font = '800 74px "Arial Narrow",sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('4', 190, 118);
ctx.font = '700 30px "Arial Narrow",sans-serif'; ctx.fillStyle = '#d2203c';
ctx.fillText('P 7', 320, 88);
ctx.fillStyle = '#7f868d'; ctx.font = '600 24px "Arial Narrow",sans-serif';
ctx.fillText('1:23.4', 320, 140);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace; t.needsUpdate = true;
return t;
}
export { makeCanvas, hex, painter, buildLiverySide, buildLiveryTop, buildCarbonTexture, buildTyreWall, buildWheelFace };
examples/porcelain-brass-submarine/source/design-contract.js
import * as THREE from "three/webgpu";
export const SUBMARINE_DIMENSIONS = {
dome: {
center: new THREE.Vector3(0, 0.02, 0.92),
radius: 1,
tilt: THREE.MathUtils.degToRad(24),
planeOffset: 0.14,
},
hull: { tailZ: -1.3, tailR: 0.3, maxR: 1.015, rings: 56, segs: 128 },
tail: {
collarZ: -1.3,
tipZ: -1.74,
tipR: 0.105,
ringZ: -1.76,
ringR: 0.8,
ringW: 0.145,
ringD: 0.26,
spikeZ0: -1.86,
spikeZ1: -2.3,
prop: { blades: 8, r: 0.62, hubR: 0.115 },
},
finH: {
rootZ0: -0.42,
rootZ1: -1.22,
tipZ0: -1.18,
tipZ1: -1.66,
span: 0.98,
dihedral: THREE.MathUtils.degToRad(3),
},
finL: { angle: THREE.MathUtils.degToRad(146), scale: 0.62 },
pod: { len: 0.52, r: 0.105 },
window: {
uCenter: 0.345,
vCenter: 0.205,
uHalf: 0.052,
vHalf: 0.135,
},
seat: { x: 0, y: -0.34, z: 0.62 },
deck: { y: -0.52 },
};
export const SUBMARINE_PALETTE = {
porcelain: new THREE.Color(0xf8f4ea),
porcelainLow: new THREE.Color(0xe9e2d2),
brass: new THREE.Color(0xc7973f),
brassDeep: new THREE.Color(0x8f6a28),
leather: new THREE.Color(0xefe3cb),
wood: new THREE.Color(0x5c4430),
glassTint: new THREE.Color(0xdcebe6),
lamp: new THREE.Color(0xffd9a4),
interiorDark: new THREE.Color(0x3d3225),
};
examples/porcelain-brass-submarine/source/mesh-kit.js
import * as THREE from "three/webgpu";
export const TAU = Math.PI * 2;
export const V3 = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);
export const lerp = THREE.MathUtils.lerp;
export function mulberry32(seed) {
let a = seed >>> 0;
return function random() {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function smooth01(t) {
return t * t * (3 - 2 * t);
}
export function splinePts(controlPoints, count) {
const curve = new THREE.CatmullRomCurve3(
controlPoints.map((point) => V3(point[0], point[1], 0)),
);
curve.curveType = "centripetal";
return curve
.getSpacedPoints(count)
.map((point) => new THREE.Vector2(point.x, point.y));
}
function transportFrames(points) {
const tangents = [];
const normals = [];
const binormals = [];
const count = points.length;
for (let index = 0; index < count; index += 1) {
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(count - 1, index + 1)];
tangents.push(V3().subVectors(next, previous).normalize());
}
let normal = Math.abs(tangents[0].y) < 0.94 ? V3(0, 1, 0) : V3(1, 0, 0);
normal = V3()
.crossVectors(
tangents[0],
V3().crossVectors(normal, tangents[0]),
)
.normalize();
for (let index = 0; index < count; index += 1) {
if (index > 0) {
const axis = V3().crossVectors(tangents[index - 1], tangents[index]);
if (axis.length() > 1e-6) {
axis.normalize();
const angle = Math.acos(
THREE.MathUtils.clamp(
tangents[index - 1].dot(tangents[index]),
-1,
1,
),
);
normal = normal.clone().applyAxisAngle(axis, angle);
}
}
normals.push(normal.clone());
binormals.push(V3().crossVectors(tangents[index], normal).normalize());
}
return { tangents, normals, binormals };
}
export function gridGeometry(
rows,
{ closeU = true, flip = false, vRow = null, uOffset = 0 } = {},
) {
const rowCount = rows.length;
const columnCount = rows[0].length;
const emittedColumns = closeU ? columnCount + 1 : columnCount;
const positions = new Float32Array(rowCount * emittedColumns * 3);
const normals = new Float32Array(rowCount * emittedColumns * 3);
const uvs = new Float32Array(rowCount * emittedColumns * 2);
const wrap = (column) => ((column % columnCount) + columnCount) % columnCount;
const pointAt = (row, column) => rows[row][
closeU
? wrap(column)
: THREE.MathUtils.clamp(column, 0, columnCount - 1)
];
const dU = V3();
const dV = V3();
const normal = V3();
for (let row = 0; row < rowCount; row += 1) {
for (let column = 0; column < emittedColumns; column += 1) {
const point = pointAt(row, column);
const vertex = row * emittedColumns + column;
positions[vertex * 3] = point.x;
positions[vertex * 3 + 1] = point.y;
positions[vertex * 3 + 2] = point.z;
dU.subVectors(pointAt(row, column + 1), pointAt(row, column - 1));
dV.subVectors(
pointAt(Math.min(rowCount - 1, row + 1), column),
pointAt(Math.max(0, row - 1), column),
);
normal.crossVectors(dU, dV);
if (normal.lengthSq() < 1e-12) {
const previousCenter = V3();
const nextCenter = V3();
const previousRow = Math.max(0, row - 1);
const nextRow = Math.min(rowCount - 1, row + 1);
for (const pointInRow of rows[previousRow]) previousCenter.add(pointInRow);
previousCenter.divideScalar(columnCount);
for (const pointInRow of rows[nextRow]) nextCenter.add(pointInRow);
nextCenter.divideScalar(columnCount);
normal.subVectors(previousCenter, nextCenter);
if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
}
normal.normalize();
if (flip) normal.negate();
normals[vertex * 3] = normal.x;
normals[vertex * 3 + 1] = normal.y;
normals[vertex * 3 + 2] = normal.z;
uvs[vertex * 2] = uOffset + column / (emittedColumns - 1);
uvs[vertex * 2 + 1] = vRow ? vRow[row] : row / (rowCount - 1);
}
}
const indices = [];
for (let row = 0; row < rowCount - 1; row += 1) {
for (let column = 0; column < emittedColumns - 1; column += 1) {
const a = row * emittedColumns + column;
const b = a + 1;
const c = a + emittedColumns;
const d = c + 1;
if (flip) indices.push(a, c, b, b, c, d);
else indices.push(a, b, c, b, d, c);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
}
export function ringPoints(center, axisU, axisV, radius, segments, phase = 0) {
const points = [];
for (let index = 0; index < segments; index += 1) {
const angle = phase + (index / segments) * TAU;
points.push(
V3()
.copy(center)
.addScaledVector(axisU, Math.sin(angle) * radius)
.addScaledVector(axisV, -Math.cos(angle) * radius),
);
}
return points;
}
export function latheZ(profile, segments = 48, { flip = false } = {}) {
const rows = profile.map((point) =>
ringPoints(
V3(0, 0, point.y),
V3(1, 0, 0),
V3(0, 1, 0),
Math.max(point.x, 0.0015),
segments,
)
);
const vRow = [0];
let accumulatedLength = 0;
for (let index = 1; index < profile.length; index += 1) {
accumulatedLength += Math.hypot(
profile[index].x - profile[index - 1].x,
profile[index].y - profile[index - 1].y,
);
vRow.push(accumulatedLength);
}
for (let index = 0; index < vRow.length; index += 1) {
vRow[index] /= accumulatedLength || 1;
}
return gridGeometry(rows, { closeU: true, flip, vRow });
}
export function sweepTube(
path,
radiusFunction,
radialSegments = 14,
{ roundEnds = false, flip = false } = {},
) {
const points = path;
const radiusAt = typeof radiusFunction === "number"
? () => radiusFunction
: radiusFunction;
if (roundEnds) {
const capSegments = 5;
const first = points[0];
const second = points[1];
const last = points[points.length - 1];
const previous = points[points.length - 2];
const startDirection = V3().subVectors(first, second).normalize();
const endDirection = V3().subVectors(last, previous).normalize();
const startRadius = radiusAt(0);
const endRadius = radiusAt(1);
const head = [];
const tail = [];
for (let index = capSegments; index >= 1; index -= 1) {
const angle = (index / capSegments) * Math.PI / 2;
head.push({
p: V3().copy(first).addScaledVector(
startDirection,
Math.sin(angle) * startRadius,
),
s: Math.cos(angle),
});
tail.push({
p: V3().copy(last).addScaledVector(
endDirection,
Math.sin(angle) * endRadius,
),
s: Math.cos(angle),
});
}
const middle = points.map((point) => ({ p: point.clone(), s: 1 }));
const sequence = [...head, ...middle, ...tail.reverse()];
const frames = transportFrames(sequence.map((entry) => entry.p));
const rows = sequence.map((entry, index) => {
const t = THREE.MathUtils.clamp(
(index - capSegments) / (points.length - 1),
0,
1,
);
return ringPoints(
entry.p,
frames.normals[index],
frames.binormals[index],
Math.max(radiusAt(t) * entry.s, 0.0012),
radialSegments,
);
});
return gridGeometry(rows, { closeU: true, flip });
}
const frames = transportFrames(points);
const rows = points.map((point, index) =>
ringPoints(
point,
frames.normals[index],
frames.binormals[index],
Math.max(radiusAt(index / (points.length - 1)), 0.0012),
radialSegments,
)
);
return gridGeometry(rows, { closeU: true, flip });
}
export function arcPath(center, axisU, axisV, radius, start, end, segments = 32) {
const points = [];
for (let index = 0; index <= segments; index += 1) {
const angle = lerp(start, end, index / segments);
points.push(
V3()
.copy(center)
.addScaledVector(axisU, Math.cos(angle) * radius)
.addScaledVector(axisV, Math.sin(angle) * radius),
);
}
return points;
}
export function finLoft(stations, sectionSegments = 40, { flip = false } = {}) {
const rows = stations.map((station) => {
const loop = [];
for (let index = 0; index < sectionSegments; index += 1) {
const angle = (index / sectionSegments) * TAU;
const chordPosition = 0.5 - 0.5 * Math.cos(angle);
const side = Math.sin(angle);
const width = station.thick * Math.pow(
Math.max(Math.sin(Math.PI * Math.pow(chordPosition, 0.85)), 0),
0.62,
);
loop.push(
V3()
.lerpVectors(station.le, station.te, chordPosition)
.addScaledVector(station.up, side * width * 0.5),
);
}
return loop;
});
return gridGeometry(rows, { closeU: true, flip });
}
export function createMesh(geometry, material, parent, name) {
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
if (name) mesh.name = name;
if (parent) parent.add(mesh);
return mesh;
}
examples/porcelain-brass-submarine/source/submarine-materials.js
import * as THREE from "three/webgpu";
import {
color,
float,
frontFacing,
mix,
mx_noise_float,
positionWorld,
select,
texture,
time,
uv,
vec3,
} from "three/tsl";
import {
SUBMARINE_DIMENSIONS as D,
SUBMARINE_PALETTE as PAL,
} from "./design-contract.js";
import { mulberry32, smooth01, TAU } from "./mesh-kit.js";
const ATLAS_WIDTH = 2048;
const ATLAS_HEIGHT = 1024;
function buildDetailAtlas(random) {
const canvas = document.createElement("canvas");
canvas.width = ATLAS_WIDTH;
canvas.height = ATLAS_HEIGHT;
const context = canvas.getContext("2d");
context.fillStyle = "#000000";
context.fillRect(0, 0, ATLAS_WIDTH, ATLAS_HEIGHT);
context.globalCompositeOperation = "lighter";
const xAt = (uValue) => uValue * ATLAS_WIDTH;
const yAt = (vValue) => vValue * ATLAS_HEIGHT;
function curl(x, y, angle, length, curvature, width, growth = 1.1) {
const steps = Math.max(10, (length / 5) | 0);
const stepLength = length / steps;
const spine = [];
let currentAngle = angle;
let currentCurvature = curvature;
for (let index = 0; index <= steps; index += 1) {
spine.push({ x, y, a: currentAngle, t: index / steps });
x += Math.cos(currentAngle) * stepLength;
y += Math.sin(currentAngle) * stepLength;
currentAngle += currentCurvature;
currentCurvature *= growth;
}
const left = [];
const right = [];
for (const sample of spine) {
const sampleWidth = width * Math.pow(1 - sample.t, 1.25) * 0.5 + 0.25;
left.push([
sample.x - Math.sin(sample.a) * sampleWidth,
sample.y + Math.cos(sample.a) * sampleWidth,
]);
right.push([
sample.x + Math.sin(sample.a) * sampleWidth,
sample.y - Math.cos(sample.a) * sampleWidth,
]);
}
context.beginPath();
context.moveTo(left[0][0], left[0][1]);
for (const point of left) context.lineTo(point[0], point[1]);
for (let index = right.length - 1; index >= 0; index -= 1) {
context.lineTo(right[index][0], right[index][1]);
}
context.closePath();
context.fill();
return spine;
}
function fern(x, y, angle, length, width, direction = 1) {
const spine = curl(
x,
y,
angle,
length,
direction * 0.028,
width,
1.065,
);
for (let index = 2; index < spine.length - 2; index += 1) {
const sample = spine[index];
if ((index - 2) % 2) continue;
const taper = 1 - sample.t;
const side = index % 4 < 2 ? 1 : -1;
curl(
sample.x,
sample.y,
sample.a + side * (1.25 + 0.2 * random()),
length * 0.24 * taper + 6,
side * direction * 0.16,
width * 0.55 * taper,
1.22,
);
}
}
function scroll(x, y, angle, length, width, direction = 1) {
curl(x, y, angle, length, direction * 0.075, width, 1.16);
curl(
x,
y,
angle + Math.PI * 0.92,
length * 0.45,
-direction * 0.11,
width * 0.8,
1.2,
);
}
function paintSide() {
const windowU = D.window.uCenter;
const windowV = D.window.vCenter;
const centerX = xAt(windowU);
const centerY = yAt(windowV);
const halfWidth = D.window.uHalf * ATLAS_WIDTH;
const halfHeight = D.window.vHalf * ATLAS_HEIGHT;
function windowPath(scale = 1, target = context) {
target.beginPath();
target.moveTo(centerX, centerY - halfHeight * scale);
target.bezierCurveTo(
centerX + halfWidth * scale,
centerY - halfHeight * scale,
centerX + halfWidth * scale,
centerY - halfHeight * 0.15 * scale,
centerX + halfWidth * 0.92 * scale,
centerY + halfHeight * 0.12 * scale,
);
target.bezierCurveTo(
centerX + halfWidth * 0.72 * scale,
centerY + halfHeight * 0.62 * scale,
centerX + halfWidth * 0.3 * scale,
centerY + halfHeight * 1.02 * scale,
centerX - halfWidth * 0.12 * scale,
centerY + halfHeight * 1.06 * scale,
);
target.bezierCurveTo(
centerX - halfWidth * 0.5 * scale,
centerY + halfHeight * 1.02 * scale,
centerX - halfWidth * 0.86 * scale,
centerY + halfHeight * 0.6 * scale,
centerX - halfWidth * 0.98 * scale,
centerY + halfHeight * 0.1 * scale,
);
target.bezierCurveTo(
centerX - halfWidth * scale,
centerY - halfHeight * 0.2 * scale,
centerX - halfWidth * 0.85 * scale,
centerY - halfHeight * scale,
centerX,
centerY - halfHeight * scale,
);
target.closePath();
}
context.fillStyle = "#ff0000";
windowPath();
context.fill();
context.fillStyle = "#00cc00";
context.strokeStyle = "#00cc00";
context.lineCap = "round";
context.lineWidth = 7;
windowPath(1.1);
context.stroke();
context.lineWidth = 3;
windowPath(1.2);
context.stroke();
context.lineWidth = 5;
context.beginPath();
context.moveTo(xAt(windowU + 0.075), yAt(0.035));
context.bezierCurveTo(
xAt(windowU + 0.095), yAt(0.18),
xAt(windowU + 0.075), yAt(0.42),
xAt(windowU + 0.02), yAt(0.66),
);
context.bezierCurveTo(
xAt(windowU - 0.01), yAt(0.78),
xAt(windowU - 0.03), yAt(0.84),
xAt(windowU - 0.045), yAt(0.9),
);
context.stroke();
context.lineWidth = 2.5;
context.beginPath();
context.moveTo(xAt(windowU + 0.088), yAt(0.035));
context.bezierCurveTo(
xAt(windowU + 0.108), yAt(0.2),
xAt(windowU + 0.086), yAt(0.44),
xAt(windowU + 0.028), yAt(0.68),
);
context.bezierCurveTo(
xAt(windowU - 0.002), yAt(0.8),
xAt(windowU - 0.024), yAt(0.86),
xAt(windowU - 0.04), yAt(0.92),
);
context.stroke();
context.lineWidth = 4;
context.beginPath();
context.moveTo(xAt(windowU - 0.065), yAt(0.06));
context.bezierCurveTo(
xAt(windowU - 0.1), yAt(0.22),
xAt(windowU - 0.088), yAt(0.4),
xAt(windowU - 0.052), yAt(0.52),
);
context.stroke();
fern(centerX + halfWidth * 0.4, centerY + halfHeight * 1.55, Math.PI * 0.44, 200, 10, 1);
fern(centerX - halfWidth * 1.7, centerY + halfHeight * 0.28, Math.PI * 0.52, 150, 8, -1);
fern(xAt(windowU + 0.058), yAt(0.083), Math.PI * 0.62, 120, 7, -1);
scroll(xAt(windowU - 0.052), yAt(0.6), Math.PI * 0.35, 70, 6, 1);
scroll(xAt(windowU + 0.01), yAt(0.76), Math.PI * 0.3, 84, 6, -1);
context.save();
context.filter = "blur(14px)";
context.strokeStyle = "rgba(0,0,90,1)";
context.lineWidth = 16;
windowPath(1.16);
context.stroke();
context.restore();
}
paintSide();
context.save();
context.translate(ATLAS_WIDTH, 0);
context.scale(-1, 1);
paintSide();
context.restore();
context.strokeStyle = "#00cc00";
const ring = (vValue, width) => {
context.lineWidth = width;
context.beginPath();
context.moveTo(0, yAt(vValue));
context.lineTo(ATLAS_WIDTH, yAt(vValue));
context.stroke();
};
ring(0.022, 6);
ring(0.045, 2.5);
ring(0.865, 6);
ring(0.895, 2.5);
ring(0.925, 10);
context.save();
context.filter = "blur(18px)";
context.strokeStyle = "rgba(0,0,70,1)";
ring(0.94, 26);
context.restore();
const detailAtlas = new THREE.CanvasTexture(canvas);
detailAtlas.flipY = false;
detailAtlas.wrapS = THREE.RepeatWrapping;
detailAtlas.wrapT = THREE.ClampToEdgeWrapping;
detailAtlas.colorSpace = THREE.NoColorSpace;
detailAtlas.anisotropy = 8;
return detailAtlas;
}
function buildGaugeFace() {
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
context.fillStyle = "#f7f2e4";
context.beginPath();
context.arc(64, 64, 62, 0, TAU);
context.fill();
context.strokeStyle = "#5a4a30";
context.lineWidth = 2;
for (let index = 0; index <= 12; index += 1) {
const angle = Math.PI * 0.75 + (index / 12) * Math.PI * 1.5;
const innerRadius = index % 3 === 0 ? 44 : 50;
context.beginPath();
context.moveTo(64 + Math.cos(angle) * innerRadius, 64 + Math.sin(angle) * innerRadius);
context.lineTo(64 + Math.cos(angle) * 56, 64 + Math.sin(angle) * 56);
context.stroke();
}
context.strokeStyle = "#8a2f1d";
context.lineWidth = 3;
context.beginPath();
context.moveTo(64, 64);
const needleAngle = Math.PI * 0.75 + 0.62 * Math.PI * 1.5;
context.lineTo(64 + Math.cos(needleAngle) * 46, 64 + Math.sin(needleAngle) * 46);
context.stroke();
context.fillStyle = "#5a4a30";
context.beginPath();
context.arc(64, 64, 4, 0, TAU);
context.fill();
const gaugeFace = new THREE.CanvasTexture(canvas);
gaugeFace.colorSpace = THREE.SRGBColorSpace;
gaugeFace.anisotropy = 4;
return gaugeFace;
}
function buildQuiltNormalTexture(cells = 6) {
const size = 512;
const height = new Float32Array(size * size);
const cell = size / cells;
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const rotatedX = (x + y) / cell;
const rotatedY = (x - y) / cell;
const fractionX = Math.abs(rotatedX - Math.round(rotatedX));
const fractionY = Math.abs(rotatedY - Math.round(rotatedY));
const distance = Math.min(fractionX, fractionY);
let value = smooth01(Math.min(distance * 6, 1));
const gridX = rotatedX - Math.round(rotatedX);
const gridY = rotatedY - Math.round(rotatedY);
const centerDistance = Math.hypot(gridX, gridY);
value *= 1 - 0.85 * Math.exp(-centerDistance * centerDistance * 90);
height[y * size + x] = value;
}
}
const data = new Uint8Array(size * size * 4);
const heightAt = (x, y) => height[((y + size) % size) * size + ((x + size) % size)];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const dx = (heightAt(x + 1, y) - heightAt(x - 1, y)) * 2.2;
const dy = (heightAt(x, y + 1) - heightAt(x, y - 1)) * 2.2;
const inverseLength = 1 / Math.hypot(dx, dy, 1);
const index = (y * size + x) * 4;
data[index] = (-dx * inverseLength * 0.5 + 0.5) * 255;
data[index + 1] = (dy * inverseLength * 0.5 + 0.5) * 255;
data[index + 2] = (inverseLength * 0.5 + 0.5) * 255;
data[index + 3] = 255;
}
}
const quiltTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
quiltTexture.wrapS = THREE.RepeatWrapping;
quiltTexture.wrapT = THREE.RepeatWrapping;
quiltTexture.needsUpdate = true;
quiltTexture.anisotropy = 8;
return quiltTexture;
}
export function createSubmarineMaterials({ seed = 20260714 } = {}) {
const detailAtlas = buildDetailAtlas(mulberry32(seed));
const gaugeFace = buildGaugeFace();
const quiltTexture = buildQuiltNormalTexture();
const materials = {};
const noiseAt = (scale) => mx_noise_float(positionWorld.mul(scale));
const atlas = texture(detailAtlas, uv());
const goldMask = atlas.g.clamp(0, 1);
const grime = atlas.b.clamp(0, 1);
const paintColor = mix(
color(PAL.porcelain),
color(PAL.porcelainLow),
noiseAt(1.6).mul(0.5).add(0.5).mul(0.35),
);
const leafColor = mix(
color(PAL.brass),
color(PAL.brassDeep),
noiseAt(30).mul(0.5).add(0.5).mul(0.4),
);
const frontColor = mix(paintColor, leafColor, goldMask).mul(
grime.mul(0.22).oneMinus(),
);
const hull = new THREE.MeshPhysicalNodeMaterial({ side: THREE.DoubleSide });
hull.colorNode = select(frontFacing, frontColor, color(PAL.interiorDark));
hull.metalnessNode = select(frontFacing, goldMask.mul(0.95), float(0));
hull.roughnessNode = select(
frontFacing,
mix(float(0.34).add(noiseAt(9).mul(0.05)), float(0.22), goldMask),
float(0.85),
);
hull.clearcoat = 0.9;
hull.clearcoatRoughness = 0.14;
hull.opacityNode = atlas.r.oneMinus();
hull.alphaTest = 0.5;
materials.hull = hull;
const porcelain = new THREE.MeshPhysicalNodeMaterial();
porcelain.colorNode = mix(
color(PAL.porcelain),
color(PAL.porcelainLow),
noiseAt(2.2).mul(0.5).add(0.5).mul(0.3),
);
porcelain.roughnessNode = float(0.33).add(noiseAt(8).mul(0.05));
porcelain.metalness = 0;
porcelain.clearcoat = 0.9;
porcelain.clearcoatRoughness = 0.14;
materials.porcelain = porcelain;
function brass(roughness, clearcoat) {
const material = new THREE.MeshPhysicalNodeMaterial();
material.colorNode = mix(
color(PAL.brass),
color(PAL.brassDeep),
noiseAt(26).mul(0.5).add(0.5).mul(0.45),
);
material.metalness = 1;
material.roughnessNode = float(roughness).add(noiseAt(14).mul(0.07));
material.clearcoat = clearcoat;
material.clearcoatRoughness = 0.2;
return material;
}
materials.brass = brass(0.17, 0.35);
materials.brassSatin = brass(0.3, 0.15);
materials.brassDark = brass(0.42, 0);
materials.brassDark.colorNode = color(PAL.brassDeep);
const glass = new THREE.MeshPhysicalNodeMaterial();
glass.color = new THREE.Color(0xffffff);
glass.transmission = 1;
glass.ior = 1.52;
glass.thickness = 0.05;
glass.roughness = 0.035;
glass.metalness = 0;
glass.clearcoat = 1;
glass.clearcoatRoughness = 0.06;
glass.attenuationColor = PAL.glassTint;
glass.attenuationDistance = 2.5;
glass.depthWrite = false;
materials.glass = glass;
const ownedTextures = [detailAtlas, gaugeFace, quiltTexture];
function leather(repeat) {
const material = new THREE.MeshPhysicalNodeMaterial();
material.colorNode = mix(
color(PAL.leather),
color(PAL.leather).mul(0.9),
noiseAt(20).mul(0.5).add(0.5).mul(0.5),
);
material.roughnessNode = float(0.52).add(noiseAt(40).mul(0.06));
material.metalness = 0;
material.sheen = 0.5;
material.sheenRoughness = 0.6;
material.sheenColor = new THREE.Color(0xfff6e0);
material.clearcoat = 0.12;
material.clearcoatRoughness = 0.4;
const normalMap = quiltTexture.clone();
normalMap.repeat.set(repeat, repeat);
normalMap.needsUpdate = true;
ownedTextures.push(normalMap);
material.normalMap = normalMap;
material.normalScale = new THREE.Vector2(0.55, 0.55);
return material;
}
materials.leatherQ = leather(1.4);
materials.leatherPl = leather(1);
materials.leatherPl.normalMap = null;
const wood = new THREE.MeshPhysicalNodeMaterial();
const grain = mx_noise_float(positionWorld.mul(vec3(9, 9, 2.2))).mul(0.5).add(0.5);
wood.colorNode = mix(color(PAL.wood), color(PAL.wood).mul(0.55), grain);
wood.roughnessNode = mix(float(0.32), float(0.5), grain);
wood.metalness = 0;
wood.clearcoat = 0.8;
wood.clearcoatRoughness = 0.18;
materials.wood = wood;
const flicker = time.mul(2.1).sin().mul(time.mul(3.7).sin()).mul(0.05).add(1);
materials.lampCore = new THREE.MeshBasicNodeMaterial();
materials.lampCore.colorNode = color(PAL.lamp).mul(flicker.mul(6));
const lampGlass = new THREE.MeshPhysicalNodeMaterial();
lampGlass.colorNode = color(PAL.lamp);
lampGlass.emissiveNode = color(PAL.lamp).mul(flicker.mul(1.6));
lampGlass.roughness = 0.18;
lampGlass.metalness = 0;
lampGlass.transparent = true;
lampGlass.opacity = 0.85;
materials.lampGlass = lampGlass;
const darkMetal = new THREE.MeshPhysicalNodeMaterial();
darkMetal.colorNode = color(PAL.interiorDark).mul(1.15);
darkMetal.metalness = 0.85;
darkMetal.roughness = 0.5;
materials.darkMetal = darkMetal;
materials.gauge = new THREE.MeshBasicNodeMaterial();
materials.gauge.colorNode = texture(gaugeFace, uv()).mul(1.1);
return { materials, textures: ownedTextures };
}
examples/porcelain-brass-submarine/source/submarine-model.js
import * as THREE from "three/webgpu";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import { SUBMARINE_DIMENSIONS as D } from "./design-contract.js";
import {
arcPath,
createMesh as M,
finLoft,
gridGeometry,
latheZ,
lerp,
ringPoints,
smooth01,
splinePts,
sweepTube,
TAU,
V3,
} from "./mesh-kit.js";
import { createSubmarineMaterials } from "./submarine-materials.js";
export function createSubmarineHullPlan({
rimCenter,
tilt = D.dome.tilt,
} = {}) {
const collarNormal = V3(0, Math.sin(tilt), Math.cos(tilt));
const resolvedRimCenter = rimCenter ?? V3()
.copy(D.dome.center)
.addScaledVector(collarNormal, -D.dome.planeOffset);
const hullPlan = { n: 240, cy: [], z: [], r: [], tiltA: [], v: [] };
const radiusControl = splinePts([
[0.995, 0],
[1.014, 0.07],
[1.006, 0.25],
[0.958, 0.42],
[0.845, 0.6],
[0.645, 0.78],
[0.43, 0.92],
[D.hull.tailR, 1],
], hullPlan.n - 1);
const centerYControl = splinePts([
[resolvedRimCenter.y, 0],
[0.012, 0.18],
[-0.012, 0.5],
[0.02, 0.8],
[0.075, 1],
], hullPlan.n - 1);
let accumulatedLength = 0;
for (let index = 0; index < hullPlan.n; index += 1) {
const t = index / (hullPlan.n - 1);
hullPlan.r[index] = radiusControl[
Math.min(index, radiusControl.length - 1)
].x;
hullPlan.cy[index] = centerYControl[
Math.min(index, centerYControl.length - 1)
].x;
hullPlan.z[index] = lerp(resolvedRimCenter.z, D.hull.tailZ, t);
hullPlan.tiltA[index] = tilt * (
1 - smooth01(Math.min(t / 0.34, 1))
);
if (index > 0) {
accumulatedLength += Math.hypot(
hullPlan.z[index] - hullPlan.z[index - 1],
hullPlan.r[index] - hullPlan.r[index - 1],
) + Math.abs(hullPlan.cy[index] - hullPlan.cy[index - 1]);
}
hullPlan.v[index] = accumulatedLength;
}
for (let index = 0; index < hullPlan.n; index += 1) {
hullPlan.v[index] /= accumulatedLength;
}
return hullPlan;
}
export function sampleSubmarineHullRing(hullPlan, t) {
const scaled = THREE.MathUtils.clamp(t, 0, 1) * (hullPlan.n - 1);
const index = Math.min(hullPlan.n - 2, scaled | 0);
const fraction = scaled - index;
const ringTilt = lerp(
hullPlan.tiltA[index],
hullPlan.tiltA[index + 1],
fraction,
);
return {
c: V3(
0,
lerp(hullPlan.cy[index], hullPlan.cy[index + 1], fraction),
lerp(hullPlan.z[index], hullPlan.z[index + 1], fraction),
),
r: lerp(hullPlan.r[index], hullPlan.r[index + 1], fraction),
axU: V3(1, 0, 0),
axV: V3(0, Math.cos(ringTilt), -Math.sin(ringTilt)),
v: lerp(hullPlan.v[index], hullPlan.v[index + 1], fraction),
};
}
export function createPorcelainBrassSubmarine({ seed = 20260714 } = {}) {
const materialBundle = createSubmarineMaterials({ seed });
const MAT = materialBundle.materials;
const submarine = new THREE.Group();
submarine.name = "porcelain-brass-submarine";
const stats = [];
const stat = (name, geometry) => stats.push({
part: name,
tris: (
geometry.index
? geometry.index.count
: geometry.attributes.position.count
) / 3 | 0,
});
const domeCenter = D.dome.center;
const domeRadius = D.dome.radius;
const tilt = D.dome.tilt;
const collarNormal = V3(0, Math.sin(tilt), Math.cos(tilt));
const rimCenter = V3()
.copy(domeCenter)
.addScaledVector(collarNormal, -D.dome.planeOffset);
const rimRadius = Math.sqrt(
domeRadius * domeRadius - D.dome.planeOffset ** 2,
);
const rimU = V3(1, 0, 0);
const rimV = V3(0, Math.cos(tilt), -Math.sin(tilt));
const hullPlan = createSubmarineHullPlan({ rimCenter, tilt });
function hullRingAt(t) {
return sampleSubmarineHullRing(hullPlan, t);
}
function hullPoint(uValue, t) {
const ring = hullRingAt(t);
const angle = uValue * TAU;
return V3()
.copy(ring.c)
.addScaledVector(ring.axU, Math.sin(angle) * ring.r)
.addScaledVector(ring.axV, -Math.cos(angle) * ring.r);
}
function hullSample(uValue, t) {
const epsilon = 0.004;
const position = hullPoint(uValue, t);
const tangentU = hullPoint(uValue + epsilon, t).sub(
hullPoint(uValue - epsilon, t),
);
const tangentT = hullPoint(uValue, Math.min(t + epsilon, 1)).sub(
hullPoint(uValue, Math.max(t - epsilon, 0)),
);
const normal = V3().crossVectors(tangentU, tangentT).normalize().negate();
return { p: position, n: normal };
}
{
const rows = [];
const vRow = [];
for (let index = 0; index < D.hull.rings; index += 1) {
const t = index / (D.hull.rings - 1);
const ring = hullRingAt(t);
rows.push(ringPoints(ring.c, ring.axU, ring.axV, ring.r, D.hull.segs));
vRow.push(ring.v);
}
const geometry = gridGeometry(rows, { closeU: true, flip: true, vRow });
M(geometry, MAT.hull, submarine, "hull");
stat("hull", geometry);
}
{
const rim = ringPoints(rimCenter, rimU, rimV, rimRadius + 0.004, 140);
rim.push(rim[0].clone());
const collarGeometry = sweepTube(rim, 0.042, 18);
M(collarGeometry, MAT.brass, submarine, "collar");
stat("collar", collarGeometry);
const bead = ringPoints(
V3().copy(rimCenter).addScaledVector(collarNormal, 0.045),
rimU,
rimV,
rimRadius - 0.028,
120,
);
bead.push(bead[0].clone());
const beadGeometry = sweepTube(bead, 0.016, 12);
M(beadGeometry, MAT.brass, submarine, "collarBead");
stat("collarBead", beadGeometry);
}
function windowOutlineUV(scale = 1, count = 64) {
const {
uCenter: windowU,
vCenter: windowV,
uHalf: halfU,
vHalf: halfV,
} = D.window;
const points = [
[windowU, windowV - halfV * scale],
[windowU + halfU * scale, windowV - halfV * scale],
[windowU + halfU * scale, windowV - halfV * 0.15 * scale],
[windowU + halfU * 0.92 * scale, windowV + halfV * 0.12 * scale],
[windowU + halfU * 0.72 * scale, windowV + halfV * 0.62 * scale],
[windowU + halfU * 0.3 * scale, windowV + halfV * 1.02 * scale],
[windowU - halfU * 0.12 * scale, windowV + halfV * 1.06 * scale],
[windowU - halfU * 0.5 * scale, windowV + halfV * 1.02 * scale],
[windowU - halfU * 0.86 * scale, windowV + halfV * 0.6 * scale],
[windowU - halfU * 0.98 * scale, windowV + halfV * 0.1 * scale],
[windowU - halfU * scale, windowV - halfV * 0.2 * scale],
[windowU - halfU * 0.85 * scale, windowV - halfV * scale],
];
const curve = new THREE.CatmullRomCurve3(
points.map((point) => V3(point[0], point[1], 0)),
true,
"centripetal",
);
return curve
.getSpacedPoints(count)
.map((point) => new THREE.Vector2(point.x, point.y));
}
function buildWindow(mirror) {
const outline = windowOutlineUV(1.06, 72);
const lift = (uvPoint) => {
const uValue = mirror ? 1 - uvPoint.x : uvPoint.x;
const sample = hullSample(uValue, uvPoint.y);
return { p: sample.p, n: sample.n };
};
const path = outline.map((point) => {
const { p, n } = lift(point);
return p.addScaledVector(n, 0.01);
});
path.push(path[0].clone());
const frameGeometry = sweepTube(path, 0.017, 12);
M(frameGeometry, MAT.brass, submarine, "windowFrame");
stat("windowFrame", frameGeometry);
const echoOutline = windowOutlineUV(1.22, 72);
const echoPath = echoOutline.map((point) => {
const { p, n } = lift(point);
return p.addScaledVector(n, 0.006);
});
echoPath.push(echoPath[0].clone());
const echoGeometry = sweepTube(echoPath, 0.006, 8);
M(echoGeometry, MAT.brass, submarine, "windowEcho");
stat("windowEcho", echoGeometry);
const rowCount = 9;
const centerUV = new THREE.Vector2(
D.window.uCenter,
D.window.vCenter + D.window.vHalf * 0.05,
);
const rows = [];
for (let index = 0; index < rowCount; index += 1) {
const contraction = 1 - index / (rowCount - 1) * 0.985;
const bulge = Math.sqrt(Math.max(1 - contraction * contraction, 0));
rows.push(outline.map((point) => {
const uvPoint = new THREE.Vector2().copy(centerUV).lerp(
point,
contraction,
);
const { p, n } = lift(uvPoint);
return p.addScaledVector(n, 0.004 + 0.028 * bulge);
}));
}
const glassGeometry = gridGeometry(rows, {
closeU: true,
flip: mirror ? false : true,
});
const glass = M(glassGeometry, MAT.glass, submarine, "windowGlass");
glass.castShadow = false;
stat("windowGlass", glassGeometry);
}
buildWindow(false);
buildWindow(true);
function buildSpear(sideU) {
const group = new THREE.Group();
submarine.add(group);
const startSample = hullSample(sideU, 0.06);
const endSample = hullSample(sideU, 0.52);
const start = startSample.p.clone().addScaledVector(startSample.n, 0.034);
const end = endSample.p.clone().addScaledVector(endSample.n, 0.034);
const contactT = 0.29;
const contact = hullSample(sideU, contactT);
const tangentStep = 0.004;
const direction = hullPoint(sideU, contactT - tangentStep)
.sub(hullPoint(sideU, contactT + tangentStep))
.normalize();
const contactSpan = start.distanceTo(end);
const axisContact = contact.p.clone().addScaledVector(contact.n, 0.034);
const tail = axisContact.clone().addScaledVector(
direction,
-(0.12 + contactSpan * 0.5),
);
const length = contactSpan + 0.46;
const profile = [
[0.0015, 0], [0.016, 0.012], [0.021, 0.03], [0.009, 0.05],
[0.03, 0.075], [0.037, 0.1], [0.0135, 0.135], [0.0125, 0.15],
[0.0125, 0.86], [0.02, 0.885], [0.02, 0.91], [0.013, 0.925],
[0.024, 0.965], [0.0015, 1],
].map((point) => new THREE.Vector2(point[0], (1 - point[1]) * length));
const geometry = latheZ(profile, 26, { flip: false });
const spear = M(geometry, MAT.brass, group, "spear");
stat("spear", geometry);
spear.position.copy(tail);
spear.quaternion.setFromUnitVectors(V3(0, 0, 1), direction);
for (const mountT of [0.16, 0.44]) {
const sample = hullSample(sideU, mountT);
const base = sample.p.clone().addScaledVector(sample.n, -0.005);
const axisDistance = V3().subVectors(base, tail).dot(direction);
const top = tail.clone().addScaledVector(direction, axisDistance);
const mountGeometry = sweepTube(
[base, base.clone().lerp(top, 0.5), top],
(t) => lerp(0.02, 0.011, t),
10,
);
M(mountGeometry, MAT.brassSatin, group, "spearMount");
stat("spearMount", mountGeometry);
}
}
buildSpear(0.25);
buildSpear(0.75);
{
const group = new THREE.Group();
submarine.add(group);
const boardZ = 0.3;
const boardY = -1.115;
const board = new RoundedBoxGeometry(0.46, 0.075, 0.34, 4, 0.035);
M(board, MAT.porcelain, group, "step").position.set(0, boardY, boardZ);
stat("step", board);
const roundedRectangle = (width, depth, radius) => {
const points = [];
const segments = 7;
const corners = [
[width - radius, depth - radius, 0],
[-width + radius, depth - radius, Math.PI / 2],
[-width + radius, -depth + radius, Math.PI],
[width - radius, -depth + radius, Math.PI * 1.5],
];
for (const [centerX, centerZ, startAngle] of corners) {
for (let index = 0; index <= segments; index += 1) {
const angle = startAngle + index / segments * Math.PI / 2;
points.push(V3(
centerX + Math.cos(angle) * radius,
boardY + 0.041,
boardZ + centerZ + Math.sin(angle) * radius,
));
}
}
points.push(points[0].clone());
return points;
};
const trimGeometry = sweepTube(
roundedRectangle(0.185, 0.125, 0.05),
0.0045,
8,
);
M(trimGeometry, MAT.brass, group, "stepTrim");
stat("stepTrim", trimGeometry);
for (const targetZ of [0.17, 0.43]) {
const t = (0.792 - targetZ) / (0.792 - D.hull.tailZ);
const sample = hullSample(0, t);
const point0 = sample.p.clone().addScaledVector(sample.n, -0.01);
const point3 = V3(0, boardY + 0.03, targetZ);
const point1 = point0.clone().addScaledVector(sample.n, 0.06);
const point2 = point3.clone().add(V3(0, 0.06, 0));
const curve = new THREE.CubicBezierCurve3(point0, point1, point2, point3);
const bracketGeometry = sweepTube(
curve.getSpacedPoints(22),
(tValue) => lerp(0.02, 0.014, tValue),
10,
{ roundEnds: false },
);
M(bracketGeometry, MAT.brassSatin, group, "stepBracket");
stat("stepBracket", bracketGeometry);
}
}
{
const sample = hullSample(0, 0.1);
const group = new THREE.Group();
group.position.copy(sample.p);
submarine.add(group);
group.quaternion.setFromUnitVectors(V3(0, 0, 1), sample.n);
const ringGeometry = latheZ([
[0.028, 0.055], [0.055, 0.045], [0.062, 0.02],
[0.058, 0], [0.048, -0.008],
].map((point) => new THREE.Vector2(point[0], point[1])), 26);
M(ringGeometry, MAT.brass, group, "noseLampRing");
stat("noseLampRing", ringGeometry);
const bulb = new THREE.SphereGeometry(0.044, 20, 14);
const bulbMesh = M(bulb, MAT.lampGlass, group, "noseLampBulb");
bulbMesh.position.z = 0.028;
bulbMesh.castShadow = false;
const core = new THREE.SphereGeometry(0.02, 10, 8);
M(core, MAT.lampCore, group, "noseLampCore").position.z = 0.028;
}
{
const group = new THREE.Group();
submarine.add(group);
const startZ = -0.3;
const endZ = -0.78;
const lift = 0.15;
const cornerRadius = 0.06;
const startSample = hullSample(0.5, (0.792 - startZ) / (0.792 - D.hull.tailZ));
const endSample = hullSample(0.5, (0.792 - endZ) / (0.792 - D.hull.tailZ));
const topY = Math.max(startSample.p.y, endSample.p.y) + lift;
const points = [startSample.p.clone().addScaledVector(startSample.n, -0.02)];
points.push(V3(0, topY - cornerRadius, startZ));
const corner = (centerY, centerZ, startAngle, endAngle) => {
for (let index = 1; index <= 6; index += 1) {
const angle = lerp(startAngle, endAngle, index / 6);
points.push(V3(
0,
centerY + Math.sin(angle) * cornerRadius,
centerZ + Math.cos(angle) * cornerRadius,
));
}
};
corner(topY - cornerRadius, startZ - cornerRadius, 0, Math.PI / 2);
points.push(V3(0, topY, endZ + cornerRadius));
corner(topY - cornerRadius, endZ + cornerRadius, Math.PI / 2, Math.PI);
points.push(V3(0, topY - cornerRadius, endZ));
points.push(endSample.p.clone().addScaledVector(endSample.n, -0.02));
const handleGeometry = sweepTube(points, 0.026, 14);
M(handleGeometry, MAT.brass, group, "handle");
stat("handle", handleGeometry);
for (const sample of [startSample, endSample]) {
const flangeGeometry = latheZ([
[0.012, 0.055], [0.05, 0.03], [0.058, 0.012], [0.05, 0],
].map((point) => new THREE.Vector2(point[0], point[1])), 20);
const flange = M(flangeGeometry, MAT.brass, group, "flange");
stat("flange", flangeGeometry);
flange.position.copy(sample.p).addScaledVector(sample.n, -0.004);
flange.quaternion.setFromUnitVectors(V3(0, 0, 1), sample.n);
}
const ventSample = hullSample(0.5, 0.055);
const ventGeometry = latheZ([
[0.001, 0.05], [0.03, 0.046], [0.048, 0.03], [0.055, 0.012], [0.046, 0],
].map((point) => new THREE.Vector2(point[0], point[1])), 22);
const vent = M(ventGeometry, MAT.brass, group, "vent");
stat("vent", ventGeometry);
vent.position.copy(ventSample.p).addScaledVector(ventSample.n, -0.004);
vent.quaternion.setFromUnitVectors(V3(0, 0, 1), ventSample.n);
}
{
const count = 30;
const geometry = new THREE.SphereGeometry(0.0105, 8, 6);
const rivets = new THREE.InstancedMesh(geometry, MAT.brassDark, count);
const matrix = new THREE.Matrix4();
for (let index = 0; index < count; index += 1) {
const angle = index / count * TAU;
const position = V3()
.copy(rimCenter)
.addScaledVector(rimU, Math.sin(angle) * (rimRadius + 0.004))
.addScaledVector(rimV, -Math.cos(angle) * (rimRadius + 0.004))
.addScaledVector(collarNormal, 0)
.addScaledVector(
V3()
.copy(rimU)
.multiplyScalar(Math.sin(angle))
.addScaledVector(rimV, -Math.cos(angle)),
0.04,
);
matrix.setPosition(position);
rivets.setMatrixAt(index, matrix);
}
rivets.castShadow = true;
submarine.add(rivets);
}
{
const maximumAngle = Math.acos(-D.dome.planeOffset / domeRadius);
const profile = [];
for (let index = 0; index <= 46; index += 1) {
const angle = lerp(0.015, maximumAngle, index / 46);
profile.push(new THREE.Vector2(
domeRadius * Math.sin(angle),
domeRadius * Math.cos(angle),
));
}
const geometry = latheZ(profile, 96, { flip: true });
const glass = M(geometry, MAT.glass, submarine, "domeGlass");
glass.castShadow = false;
glass.position.copy(domeCenter);
glass.quaternion.setFromUnitVectors(V3(0, 0, 1), collarNormal);
stat("domeGlass", geometry);
}
{
const group = new THREE.Group();
group.position.copy(domeCenter);
submarine.add(group);
const cageRadius = 0.947;
for (const angle of [0.42, 0.85, 1.28]) {
const ring = ringPoints(
V3(0, 0, cageRadius * Math.cos(angle)),
V3(1, 0, 0),
V3(0, 1, 0),
cageRadius * Math.sin(angle),
84,
);
ring.push(ring[0].clone());
const geometry = sweepTube(ring, 0.0105, 10);
M(geometry, MAT.brass, group, "cageRing").castShadow = false;
stat("cageRing", geometry);
}
for (let ribIndex = 0; ribIndex < 6; ribIndex += 1) {
const azimuth = Math.PI / 6 + ribIndex * Math.PI / 3;
const radial = V3(Math.cos(azimuth), Math.sin(azimuth), 0);
const coefficientA = Math.sin(azimuth) * Math.sin(tilt);
const coefficientB = Math.cos(tilt);
const phase = Math.atan2(coefficientA, coefficientB);
const endAngle = phase + Math.acos(
-D.dome.planeOffset /
(cageRadius * Math.hypot(coefficientA, coefficientB)),
);
const points = [];
const count = 46;
for (let index = 0; index <= count; index += 1) {
const t = index / count;
const angle = lerp(0.1, endAngle, t);
const radius = lerp(
cageRadius,
0.972,
smooth01(Math.max(0, (t - 0.86) / 0.14)),
);
points.push(
V3(0, 0, Math.cos(angle) * radius).addScaledVector(
radial,
Math.sin(angle) * radius,
),
);
}
const geometry = sweepTube(
points,
(t) => lerp(0.013, 0.0095, t),
10,
{ roundEnds: false },
);
M(geometry, MAT.brass, group, "cageRib").castShadow = false;
stat("cageRib", geometry);
}
const boss = new THREE.Group();
boss.position.z = cageRadius - 0.012;
group.add(boss);
const collarGeometry = latheZ([
[0.012, -0.02], [0.075, -0.012], [0.085, 0.012],
[0.07, 0.03], [0.028, 0.038],
].map((point) => new THREE.Vector2(point[0], point[1])), 26);
M(collarGeometry, MAT.brass, boss, "headlightRing");
stat("headlightRing", collarGeometry);
const lensGeometry = new THREE.SphereGeometry(
0.055,
22,
12,
0,
TAU,
0,
Math.PI * 0.46,
);
const lens = M(lensGeometry, MAT.lampGlass, boss, "headlightLens");
lens.rotation.x = Math.PI / 2;
lens.position.z = 0.02;
lens.castShadow = false;
M(new THREE.SphereGeometry(0.026, 10, 8), MAT.lampCore, boss).position.z = 0.02;
}
{
const topPoint = V3().copy(rimCenter).addScaledVector(rimV, rimRadius + 0.01);
const group = new THREE.Group();
group.position.copy(topPoint);
submarine.add(group);
group.quaternion.setFromUnitVectors(V3(0, 0, 1), V3(0, 1, 0.16).normalize());
const baseGeometry = latheZ([
[0.004, 0], [0.065, 0.006], [0.072, 0.022],
[0.05, 0.036], [0.034, 0.05], [0.038, 0.062],
].map((point) => new THREE.Vector2(point[0], point[1])), 26);
M(baseGeometry, MAT.brass, group, "lanternBase");
stat("lanternBase", baseGeometry);
const drum = new THREE.CylinderGeometry(0.034, 0.037, 0.06, 20);
const drumMesh = M(drum, MAT.lampGlass, group);
drumMesh.rotation.x = Math.PI / 2;
drumMesh.position.z = 0.092;
drumMesh.castShadow = false;
M(new THREE.SphereGeometry(0.018, 10, 8), MAT.lampCore, group).position.z = 0.092;
const capGeometry = latheZ([
[0.042, 0.125], [0.046, 0.135], [0.012, 0.152], [0.012, 0.16],
[0.022, 0.175], [0.013, 0.19], [0.0015, 0.198],
].map((point) => new THREE.Vector2(point[0], point[1])), 22);
M(capGeometry, MAT.brass, group, "lanternCap");
stat("lanternCap", capGeometry);
}
const cabin = new THREE.Group();
submarine.add(cabin);
let helmWheel = null;
{
const shape = new THREE.Shape();
const radiusX = 0.6;
const radiusZ = 0.82;
const centerZ = 0.52;
for (let index = 0; index <= 64; index += 1) {
const angle = index / 64 * TAU;
const x = Math.sin(angle) * radiusX;
const z = centerZ + Math.cos(angle) * radiusZ * (
1 - 0.12 * Math.abs(Math.cos(angle))
);
if (index) shape.lineTo(x, z);
else shape.moveTo(x, z);
}
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: 0.045,
bevelEnabled: true,
bevelThickness: 0.012,
bevelSize: 0.012,
bevelSegments: 3,
curveSegments: 8,
});
geometry.rotateX(Math.PI / 2);
const deck = M(geometry, MAT.wood, cabin, "deck");
deck.position.y = D.deck.y + 0.045;
stat("deck", geometry);
const rimPoints = [];
for (let index = 0; index <= 72; index += 1) {
const angle = index / 72 * TAU;
rimPoints.push(V3(
Math.sin(angle) * (radiusX + 0.008),
D.deck.y + 0.03,
centerZ + Math.cos(angle) * (radiusZ + 0.008) * (
1 - 0.12 * Math.abs(Math.cos(angle))
),
));
}
const rimGeometry = sweepTube(rimPoints, 0.012, 10);
M(rimGeometry, MAT.brass, cabin, "deckNosing");
stat("deckNosing", rimGeometry);
}
{
const seat = new THREE.Group();
seat.position.set(D.seat.x, D.seat.y, D.seat.z);
cabin.add(seat);
const cushion = new RoundedBoxGeometry(0.47, 0.17, 0.45, 5, 0.065);
M(cushion, MAT.leatherQ, seat, "seatCushion").position.set(0, 0.085, 0.02);
stat("seatCushion", cushion);
const back = new RoundedBoxGeometry(0.46, 0.64, 0.14, 5, 0.06);
const backMesh = M(back, MAT.leatherQ, seat, "seatBack");
backMesh.position.set(0, 0.42, -0.225);
backMesh.rotation.x = -0.21;
stat("seatBack", back);
const headrest = new RoundedBoxGeometry(0.35, 0.2, 0.11, 4, 0.05);
const headrestMesh = M(headrest, MAT.leatherPl, seat, "headrest");
headrestMesh.position.set(0, 0.8, -0.3);
headrestMesh.rotation.x = -0.26;
stat("headrest", headrest);
for (const side of [-1, 1]) {
const point0 = V3(side * 0.265, 0.16, -0.2);
const point1 = V3(side * 0.285, 0.3, -0.16);
const point2 = V3(side * 0.285, 0.3, 0.14);
const point3 = V3(side * 0.26, 0.22, 0.22);
const curve = new THREE.CubicBezierCurve3(
point0,
point1,
point2,
point3,
);
const armGeometry = sweepTube(
curve.getSpacedPoints(18),
0.052,
14,
{ roundEnds: true },
);
M(armGeometry, MAT.leatherPl, seat, "arm");
stat("arm", armGeometry);
}
const pedestalGeometry = latheZ([
[0.155, 0], [0.16, 0.02], [0.075, 0.05],
[0.06, 0.14], [0.09, 0.19], [0.1, 0.21],
].map((point) => new THREE.Vector2(point[0], point[1])), 26);
const pedestal = M(pedestalGeometry, MAT.brassSatin, seat, "pedestal");
pedestal.rotation.x = -Math.PI / 2;
pedestal.position.y = -0.215;
stat("pedestal", pedestalGeometry);
}
{
const helm = new THREE.Group();
helm.position.set(0, -0.16, 1.15);
cabin.add(helm);
helmWheel = new THREE.Group();
helm.add(helmWheel);
helmWheel.rotation.x = -0.42;
const wheelRadius = 0.185;
const wheelRim = [];
for (let index = 0; index <= 64; index += 1) {
const angle = index / 64 * TAU;
wheelRim.push(V3(
Math.cos(angle) * wheelRadius,
Math.sin(angle) * wheelRadius,
0,
));
}
const wheelRimGeometry = sweepTube(wheelRim, 0.0145, 12);
M(wheelRimGeometry, MAT.brass, helmWheel, "wheelRim");
stat("wheelRim", wheelRimGeometry);
const hubGeometry = latheZ([
[0.0015, 0.05], [0.03, 0.045], [0.045, 0.025],
[0.045, -0.01], [0.028, -0.028], [0.0015, -0.032],
].map((point) => new THREE.Vector2(point[0], point[1])), 20);
M(hubGeometry, MAT.brass, helmWheel, "wheelHub");
stat("wheelHub", hubGeometry);
for (let index = 0; index < 8; index += 1) {
const angle = index / 8 * TAU;
const direction = V3(Math.cos(angle), Math.sin(angle), 0);
const spokeGeometry = sweepTube(
[
direction.clone().multiplyScalar(0.03),
direction.clone().multiplyScalar(wheelRadius),
],
(t) => lerp(0.0095, 0.007, t),
8,
);
M(spokeGeometry, MAT.brass, helmWheel);
const knobGeometry = latheZ([
[0.0015, 0], [0.009, 0.008], [0.0115, 0.03],
[0.0085, 0.052], [0.0125, 0.062], [0.0015, 0.075],
].map((point) => new THREE.Vector2(point[0], point[1])), 12);
const knob = M(knobGeometry, MAT.brass, helmWheel);
knob.position.copy(direction.clone().multiplyScalar(wheelRadius));
knob.quaternion.setFromUnitVectors(
V3(0, 0, 1),
V3(direction.x, direction.y, 0),
);
}
const columnCurve = new THREE.CubicBezierCurve3(
V3(0, 0, 0.02),
V3(0, -0.08, 0.06),
V3(0, -0.2, 0.09),
V3(0, -0.36, 0.05),
);
const columnGeometry = sweepTube(
columnCurve.getSpacedPoints(16),
(t) => lerp(0.024, 0.038, t),
14,
);
M(columnGeometry, MAT.brassSatin, helm, "column");
stat("column", columnGeometry);
const footGeometry = latheZ([
[0.09, 0], [0.085, 0.015], [0.045, 0.03], [0.04, 0.05],
].map((point) => new THREE.Vector2(point[0], point[1])), 20);
const foot = M(footGeometry, MAT.brassSatin, helm);
foot.rotation.x = -Math.PI / 2;
foot.position.set(0, -0.36, 0.05);
for (const side of [-1, 1]) {
const gauge = new THREE.Group();
gauge.position.set(side * 0.075, -0.1, 0.1);
helm.add(gauge);
gauge.rotation.set(-0.5, 0, 0);
const gaugeRimGeometry = latheZ([
[0.012, 0], [0.048, 0.004], [0.052, 0.018], [0.044, 0.03],
].map((point) => new THREE.Vector2(point[0], point[1])), 20);
M(gaugeRimGeometry, MAT.brass, gauge);
const faceGeometry = new THREE.CircleGeometry(0.041, 24);
const face = M(faceGeometry, MAT.gauge, gauge);
face.position.z = 0.022;
face.castShadow = false;
}
const leverGroup = new THREE.Group();
leverGroup.position.set(0.3, -0.36, 0.72);
cabin.add(leverGroup);
const quadrant = arcPath(
V3(0, 0, 0),
V3(0, 1, 0),
V3(0, 0, 1),
0.09,
-0.5,
0.9,
14,
);
const quadrantGeometry = sweepTube(
quadrant,
0.008,
8,
{ roundEnds: true },
);
M(quadrantGeometry, MAT.brass, leverGroup);
stat("lever", quadrantGeometry);
const leverGeometry = sweepTube(
[V3(0, 0, 0), V3(0, 0.16, 0.05)],
(t) => lerp(0.011, 0.007, t),
8,
);
M(leverGeometry, MAT.brassSatin, leverGroup);
M(
new THREE.SphereGeometry(0.018, 12, 10),
MAT.brass,
leverGroup,
).position.set(0, 0.165, 0.052);
const leverBaseGeometry = latheZ([
[0.05, 0], [0.045, 0.012], [0.02, 0.02], [0.018, 0.05],
].map((point) => new THREE.Vector2(point[0], point[1])), 16);
const leverBase = M(leverBaseGeometry, MAT.brassSatin, leverGroup);
leverBase.rotation.x = -Math.PI / 2;
}
{
const glow = new THREE.PointLight(0xffdcae, 0.38, 2.6, 2);
glow.position.set(0, 0.25, 0.75);
submarine.add(glow);
}
let propeller = null;
const tailY = 0.075;
{
const profile = splinePts([
[D.hull.tailR, 0], [0.272, -0.13], [0.205, -0.27],
[0.148, -0.37], [D.tail.tipR, -0.44],
], 30).map((point) =>
new THREE.Vector2(point.x, D.tail.collarZ + point.y - 0)
);
const geometry = latheZ(profile, 64, { flip: true });
const tailCone = M(geometry, MAT.porcelain, submarine, "tailCone");
tailCone.position.y = 0;
stat("tailCone", geometry);
tailCone.position.set(0, tailY, 0);
const firstCollar = ringPoints(
V3(0, tailY, D.tail.collarZ + 0.005),
V3(1, 0, 0),
V3(0, 1, 0),
D.hull.tailR + 0.006,
60,
);
firstCollar.push(firstCollar[0].clone());
const firstCollarGeometry = sweepTube(firstCollar, 0.024, 12);
M(firstCollarGeometry, MAT.brass, submarine, "tailCollarA");
stat("tailCollarA", firstCollarGeometry);
const secondCollar = ringPoints(
V3(0, tailY, D.tail.tipZ + 0.01),
V3(1, 0, 0),
V3(0, 1, 0),
D.tail.tipR + 0.012,
40,
);
secondCollar.push(secondCollar[0].clone());
const secondCollarGeometry = sweepTube(secondCollar, 0.02, 12);
M(secondCollarGeometry, MAT.brass, submarine, "tailCollarB");
stat("tailCollarB", secondCollarGeometry);
}
{
const sectionCount = 36;
const aroundCount = 110;
const rows = [];
for (let index = 0; index <= sectionCount; index += 1) {
const angle = index / sectionCount * TAU + Math.PI * 0.75;
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
const radialOffset = 0.5 * D.tail.ringW * Math.sign(cosine) *
Math.pow(Math.abs(cosine), 0.72);
const zOffset = 0.5 * D.tail.ringD * Math.sign(sine) *
Math.pow(Math.abs(sine), 0.72);
rows.push(ringPoints(
V3(0, tailY, D.tail.ringZ + zOffset),
V3(1, 0, 0),
V3(0, 1, 0),
D.tail.ringR + radialOffset,
aroundCount,
));
}
const geometry = gridGeometry(rows, { closeU: true, flip: false });
M(geometry, MAT.porcelain, submarine, "shroud");
stat("shroud", geometry);
for (const zOffset of [D.tail.ringD * 0.34, -D.tail.ringD * 0.34]) {
for (const radialOffset of [D.tail.ringW * 0.42]) {
const ring = ringPoints(
V3(0, tailY, D.tail.ringZ + zOffset),
V3(1, 0, 0),
V3(0, 1, 0),
D.tail.ringR + radialOffset + 0.028,
100,
);
ring.push(ring[0].clone());
const trimGeometry = sweepTube(ring, 0.009, 8);
M(trimGeometry, MAT.brass, submarine, "shroudTrim");
stat("shroudTrim", trimGeometry);
}
}
const crestGeometry = latheZ([
[0.004, 0], [0.05, 0.008], [0.056, 0.02], [0.024, 0.035],
[0.014, 0.1], [0.028, 0.115], [0.012, 0.15], [0.0015, 0.185],
].map((point) => new THREE.Vector2(point[0], point[1])), 20);
const crest = M(crestGeometry, MAT.brass, submarine, "crest");
stat("crest", crestGeometry);
crest.position.set(
0,
tailY + D.tail.ringR + D.tail.ringW * 0.42,
D.tail.ringZ,
);
crest.quaternion.setFromUnitVectors(V3(0, 0, 1), V3(0, 1, 0));
}
for (let strutIndex = 0; strutIndex < 4; strutIndex += 1) {
const angle = Math.PI / 4 + strutIndex * Math.PI / 2;
const direction = V3(Math.cos(angle), Math.sin(angle), 0);
const stations = [];
for (let index = 0; index <= 4; index += 1) {
const t = index / 4;
const radius = lerp(0.13, D.tail.ringR - 0.04, t);
const centerZ = lerp(-1.6, D.tail.ringZ, t);
const center = V3(
direction.x * radius,
tailY + direction.y * radius,
centerZ,
);
const chord = lerp(0.1, 0.062, t);
const thickness = lerp(0.032, 0.018, t);
stations.push({
le: V3(center.x, center.y, center.z + chord / 2),
te: V3(center.x, center.y, center.z - chord / 2),
up: V3(-direction.y, direction.x, 0),
thick: thickness,
});
}
const geometry = finLoft(stations, 26, { flip: false });
M(geometry, MAT.brassSatin, submarine, "strut");
stat("strut", geometry);
}
{
propeller = new THREE.Group();
propeller.position.set(0, tailY, -1.745);
submarine.add(propeller);
const hubGeometry = latheZ([
[0.0015, -0.215], [0.045, -0.2], [0.08, -0.16],
[0.105, -0.1], [0.115, -0.03], [0.108, 0],
].map((point) => new THREE.Vector2(point[0], point[1])), 30, {
flip: false,
});
M(hubGeometry, MAT.brass, propeller, "propHub");
stat("propHub", hubGeometry);
const stationCount = 8;
const bladeStations = [];
for (let index = 0; index <= stationCount; index += 1) {
const t = index / stationCount;
const radius = lerp(0.1, D.tail.prop.r, t);
const chord = 0.135 * Math.pow(
Math.sin(Math.PI * Math.min(t * 0.94 + 0.05, 1)),
0.6,
) + 0.018;
const pitch = lerp(0.95, 0.5, t);
const radial = V3(1, 0, 0);
const tangent = V3(0, 1, 0);
const chordDirection = V3()
.addScaledVector(tangent, Math.cos(pitch))
.addScaledVector(V3(0, 0, 1), Math.sin(pitch))
.normalize();
const center = V3(radius, 0, -0.075);
bladeStations.push({
le: center.clone().addScaledVector(chordDirection, chord / 2),
te: center.clone().addScaledVector(chordDirection, -chord / 2),
up: V3().crossVectors(radial, chordDirection).normalize(),
thick: lerp(0.02, 0.01, t),
});
}
const bladeGeometry = finLoft(bladeStations, 22, { flip: true });
stat("blade×8", bladeGeometry);
for (let index = 0; index < 8; index += 1) {
const blade = M(bladeGeometry, MAT.brass, propeller, "blade");
blade.rotation.z = index / 8 * TAU;
}
const halo = ringPoints(
V3(0, 0, -0.075),
V3(1, 0, 0),
V3(0, 1, 0),
D.tail.prop.r + 0.012,
80,
);
halo.push(halo[0].clone());
const haloGeometry = sweepTube(halo, 0.011, 10);
M(haloGeometry, MAT.brass, propeller, "propHalo");
stat("propHalo", haloGeometry);
const lampRingGeometry = latheZ([
[0.02, -0.26], [0.052, -0.245], [0.058, -0.225], [0.05, -0.21],
].map((point) => new THREE.Vector2(point[0], point[1])), 20);
M(lampRingGeometry, MAT.brass, propeller);
stat("hubLampRing", lampRingGeometry);
const bulbGeometry = new THREE.SphereGeometry(0.042, 18, 12);
const bulb = M(bulbGeometry, MAT.lampGlass, propeller);
bulb.position.z = -0.235;
bulb.castShadow = false;
M(
new THREE.SphereGeometry(0.02, 10, 8),
MAT.lampCore,
propeller,
).position.z = -0.235;
}
{
const geometry = latheZ([
[0.012, -1.95], [0.052, -1.965], [0.058, -1.99], [0.03, -2.02],
[0.022, -2.1], [0.03, -2.12], [0.022, -2.14], [0.008, -2.24],
[0.016, -2.265], [0.0015, -2.3],
].map((point) => new THREE.Vector2(point[0], point[1])), 22, {
flip: true,
});
const spike = M(geometry, MAT.brass, submarine, "spike");
spike.position.y = tailY;
stat("spike", geometry);
}
function buildPod(scale = 1) {
const pod = new THREE.Group();
const profile = splinePts([
[0.0015, 0.27], [0.052, 0.22], [0.092, 0.12], [0.105, 0],
[0.09, -0.11], [0.068, -0.175], [0.054, -0.205],
], 24).map((point) => new THREE.Vector2(point.x * scale, point.y * scale));
const geometry = latheZ(profile, 30, { flip: true });
M(geometry, MAT.porcelain, pod, "pod");
stat("pod", geometry);
const noseGeometry = latheZ([
[0.0015, 0.275], [0.036, 0.235], [0.055, 0.19],
].map((point) =>
new THREE.Vector2(point[0] * scale, point[1] * scale)
), 20, { flip: true });
M(noseGeometry, MAT.brass, pod, "podNose");
const band = ringPoints(
V3(0, 0, 0.06 * scale),
V3(1, 0, 0),
V3(0, 1, 0),
0.1035 * scale,
26,
);
band.push(band[0].clone());
const bandGeometry = sweepTube(band, 0.006 * scale, 8);
M(bandGeometry, MAT.brass, pod);
const lampRingGeometry = latheZ([
[0.028, -0.24], [0.054, -0.225], [0.058, -0.205], [0.05, -0.195],
].map((point) =>
new THREE.Vector2(point[0] * scale, point[1] * scale)
), 18);
M(lampRingGeometry, MAT.brass, pod);
const bulbGeometry = new THREE.SphereGeometry(0.042 * scale, 16, 12);
const bulb = M(bulbGeometry, MAT.lampGlass, pod);
bulb.position.z = -0.225 * scale;
bulb.castShadow = false;
M(
new THREE.SphereGeometry(0.02 * scale, 10, 8),
MAT.lampCore,
pod,
).position.z = -0.225 * scale;
return pod;
}
function buildFin() {
const fin = new THREE.Group();
const finDimensions = D.finH;
const rootX = 0.72;
const tipX = 1.25;
const stationCount = 8;
const stations = [];
const leadingEdgeZ = (t) => lerp(
finDimensions.rootZ0,
finDimensions.tipZ0,
smooth01(t) * 0.9 + t * 0.1,
);
const trailingEdgeZ = (t) => lerp(
finDimensions.rootZ1,
finDimensions.tipZ1,
t,
);
for (let index = 0; index <= stationCount; index += 1) {
const t = index / stationCount;
const x = lerp(rootX, tipX, t);
const y = 0.01 + Math.sin(finDimensions.dihedral) * (x - rootX);
let leadingZ = leadingEdgeZ(t);
let trailingZ = trailingEdgeZ(t);
if (index === stationCount) {
const middle = (leadingZ + trailingZ) / 2;
const half = (trailingZ - leadingZ) / 2 * 0.42;
leadingZ = middle - half;
trailingZ = middle + half;
}
stations.push({
le: V3(x, y, leadingZ),
te: V3(x, y, trailingZ),
up: V3(0, 1, 0),
thick: lerp(0.075, 0.026, Math.pow(t, 0.8)),
});
}
const geometry = finLoft(stations, 34, { flip: true });
M(geometry, MAT.porcelain, fin, "fin");
stat("fin", geometry);
const edge = [];
for (let index = 0; index <= stationCount; index += 1) {
edge.push(V3(
stations[index].le.x,
stations[index].le.y,
stations[index].le.z,
));
}
for (let index = stationCount; index >= 0; index -= 1) {
edge.push(V3(
stations[index].te.x,
stations[index].te.y,
stations[index].te.z,
));
}
const edgeGeometry = sweepTube(edge, 0.0075, 8, { roundEnds: true });
M(edgeGeometry, MAT.brass, fin, "finEdge");
stat("finEdge", edgeGeometry);
const inset = [];
for (let index = 0; index <= stationCount; index += 1) {
const station = stations[index];
inset.push(V3(
station.le.x,
station.le.y + station.thick * 0.5 + 0.002,
lerp(station.le.z, station.te.z, 0.22),
));
}
const lineGeometry = sweepTube(inset, 0.0045, 6, { roundEnds: true });
M(lineGeometry, MAT.brass, fin, "finLine");
stat("finLine", lineGeometry);
const pod = buildPod(1);
pod.position.set(
tipX + 0.02,
0.01 + Math.sin(finDimensions.dihedral) * (tipX - rootX),
-1.42,
);
fin.add(pod);
return fin;
}
{
const rightFin = buildFin();
submarine.add(rightFin);
const leftFin = buildFin();
leftFin.scale.x = -1;
submarine.add(leftFin);
for (const side of [-1, 1]) {
const lowerFin = buildFin();
lowerFin.scale.set(side * 0.56, 0.56, 0.78);
lowerFin.position.set(0, tailY - 0.02, -0.42);
lowerFin.rotation.z = -side * THREE.MathUtils.degToRad(55);
submarine.add(lowerFin);
}
}
const geometrySet = new Set();
submarine.traverse((object) => {
if (object.geometry) geometrySet.add(object.geometry);
});
const totalTriangles = stats.reduce((total, entry) => total + entry.tris, 0);
return {
object: submarine,
materials: MAT,
stats,
totalTriangles,
hullPlan,
update({ delta = 0, elapsed = 0 } = {}) {
if (propeller) propeller.rotation.z += delta * 1.1;
if (helmWheel) helmWheel.rotation.z = Math.sin(elapsed * 0.22) * 0.35;
submarine.position.y = Math.sin(elapsed * 0.5) * 0.012;
submarine.rotation.z = Math.sin(elapsed * 0.33) * 0.005;
submarine.rotation.x = Math.sin(elapsed * 0.41) * 0.004;
},
dispose() {
for (const geometry of geometrySet) geometry.dispose();
for (const material of Object.values(MAT)) material.dispose();
for (const texture of materialBundle.textures) texture.dispose();
},
};
}
examples/porcelain-brass-submarine/submarine-model.js
export { SUBMARINE_DIMENSIONS } from "./source/design-contract.js";
export { createPorcelainBrassSubmarine } from "./source/submarine-model.js";
examples/procedural-optimus-humanoid/procedural-optimus-humanoid.js
export {
OPTIMUS_COLLECTION_ORDER,
createProceduralOptimusHumanoid,
} from "./source/optimus-humanoid-system.js";
examples/procedural-optimus-humanoid/source/optimus-humanoid-system.js
import * as THREE from "three/webgpu";
import {
Fn, vec3, float, uint, positionLocal, mix, clamp, floor, max, select,
bumpMap, dFdx, dFdy, length, smoothstep as tslSmoothstep,
} from "three/tsl";
const TAU = Math.PI * 2;
const R = d => d * Math.PI / 180;
/* modulo whose result takes the sign of the divisor */
const pmod = (a, b) => ((a % b) + b) % b;
const lerp = (a, b, t) => a + (b - a) * t;
function smoothstep(a, b, x) {
if (b === a) return 0.0;
const t = Math.max(0.0, Math.min(1.0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
/* ---- minimal vector type ------------------------------------------------ */
class V3 {
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
static of(a) { return new V3(a[0], a[1], a[2]); }
clone() { return new V3(this.x, this.y, this.z); }
add(o) { return new V3(this.x + o.x, this.y + o.y, this.z + o.z); }
sub(o) { return new V3(this.x - o.x, this.y - o.y, this.z - o.z); }
mul(s) { return new V3(this.x * s, this.y * s, this.z * s); }
dot(o) { return this.x * o.x + this.y * o.y + this.z * o.z; }
cross(o) {
return new V3(this.y * o.z - this.z * o.y,
this.z * o.x - this.x * o.z,
this.x * o.y - this.y * o.x);
}
get length() { return Math.hypot(this.x, this.y, this.z); }
normalized() {
const l = this.length;
return l > 0 ? new V3(this.x / l, this.y / l, this.z / l) : new V3(0, 0, 0);
}
/* normalize in place while leaving a zero vector unchanged */
normalize() {
const l = this.length;
if (l > 0) { this.x /= l; this.y /= l; this.z /= l; }
return this;
}
toArray() { return [this.x, this.y, this.z]; }
}
const vec = (x, y, z) => new V3(x, y, z);
/* ---- shape-preserving and natural-cubic interpolation ------------------- */
function pchipSlopes(xs, ys) {
const n = xs.length;
if (n === 2) {
const d = (ys[1] - ys[0]) / (xs[1] - xs[0]);
return [d, d];
}
const h = [], d = [];
for (let i = 0; i < n - 1; i++) {
h.push(xs[i + 1] - xs[i]);
d.push((ys[i + 1] - ys[i]) / h[i]);
}
const m = new Array(n).fill(0.0);
for (let i = 1; i < n - 1; i++) {
if (d[i - 1] * d[i] <= 0.0) m[i] = 0.0;
else {
const w1 = 2.0 * h[i] + h[i - 1];
const w2 = h[i] + 2.0 * h[i - 1];
m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]);
}
}
const endslope = (h0, h1, d0, d1) => {
let m0 = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
if (m0 * d0 <= 0) m0 = 0.0;
else if (d0 * d1 <= 0 && Math.abs(m0) > Math.abs(3 * d0)) m0 = 3 * d0;
return m0;
};
const L = h.length, D = d.length;
m[0] = endslope(h[0], h[1], d[0], d[1]);
m[n - 1] = endslope(h[L - 1], h[L - 2], d[D - 1], d[D - 2]);
return m;
}
/* natural cubic spline -> C2 continuous first derivatives */
function splineSlopes(xs, ys) {
const n = xs.length;
if (n < 3) return pchipSlopes(xs, ys);
const h = [];
for (let i = 0; i < n - 1; i++) h.push(xs[i + 1] - xs[i]);
const a = new Array(n).fill(0.0), b = new Array(n).fill(0.0);
const c = new Array(n).fill(0.0), r = new Array(n).fill(0.0);
b[0] = 2.0 / h[0]; c[0] = 1.0 / h[0];
r[0] = 3.0 * (ys[1] - ys[0]) / (h[0] * h[0]);
for (let i = 1; i < n - 1; i++) {
a[i] = 1.0 / h[i - 1];
b[i] = 2.0 * (1.0 / h[i - 1] + 1.0 / h[i]);
c[i] = 1.0 / h[i];
r[i] = 3.0 * ((ys[i] - ys[i - 1]) / (h[i - 1] ** 2)
+ (ys[i + 1] - ys[i]) / (h[i] ** 2));
}
const hl = h[h.length - 1];
a[n - 1] = 1.0 / hl; b[n - 1] = 2.0 / hl;
r[n - 1] = 3.0 * (ys[n - 1] - ys[n - 2]) / (hl * hl);
for (let i = 1; i < n; i++) { /* thomas */
const w = a[i] / b[i - 1];
b[i] -= w * c[i - 1];
r[i] -= w * r[i - 1];
}
const m = new Array(n).fill(0.0);
m[n - 1] = r[n - 1] / b[n - 1];
for (let i = n - 2; i >= 0; i--) m[i] = (r[i] - c[i] * m[i + 1]) / b[i];
return m;
}
/* piecewise cubic hermite through (x, y) samples */
class Curve1D {
constructor(xs, ys, mode = 'pchip') {
/* stable lexicographic ordering on (x, y) */
const pair = xs.map((x, i) => [x, ys[i]]).sort((p, q) => (p[0] - q[0]) || (p[1] - q[1]));
this.xs = pair.map(p => p[0]);
this.ys = pair.map(p => p[1]);
this.m = this.xs.length === 1 ? [0.0]
: (mode === 'spline' ? splineSlopes(this.xs, this.ys)
: pchipSlopes(this.xs, this.ys));
}
at(x) {
const { xs, ys, m } = this;
const n = xs.length;
if (n === 1) return ys[0];
if (x <= xs[0]) return ys[0] + m[0] * (x - xs[0]);
if (x >= xs[n - 1]) return ys[n - 1] + m[n - 1] * (x - xs[n - 1]);
let lo = 0, hi = n - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (xs[mid] <= x) lo = mid; else hi = mid;
}
const h = xs[hi] - xs[lo];
const t = (x - xs[lo]) / h;
const t2 = t * t, t3 = t2 * t;
const h00 = 2 * t3 - 3 * t2 + 1;
const h10 = t3 - 2 * t2 + t;
const h01 = -2 * t3 + 3 * t2;
const h11 = t3 - t2;
return h00 * ys[lo] + h10 * h * m[lo] + h01 * ys[hi] + h11 * h * m[hi];
}
}
/* callable shorthand for curve sampling */
const curveFn = c => (x => c.at(x));
/* ---- polynomial least-squares fitting and evaluation ------------------- */
/* Householder QR least squares: min ||A x - b|| */
function lstsqQR(A, b, rows, cols) {
/* work on copies */
const M = A.map(r => r.slice());
const y = b.slice();
for (let k = 0; k < cols; k++) {
let nrm = 0;
for (let i = k; i < rows; i++) nrm += M[i][k] * M[i][k];
nrm = Math.sqrt(nrm);
if (nrm === 0) continue;
if (M[k][k] > 0) nrm = -nrm;
const v = new Array(rows).fill(0);
for (let i = k; i < rows; i++) v[i] = M[i][k];
v[k] -= nrm;
let vv = 0;
for (let i = k; i < rows; i++) vv += v[i] * v[i];
if (vv < 1e-300) continue;
for (let j = k; j < cols; j++) {
let s = 0;
for (let i = k; i < rows; i++) s += v[i] * M[i][j];
s = 2 * s / vv;
for (let i = k; i < rows; i++) M[i][j] -= s * v[i];
}
let s = 0;
for (let i = k; i < rows; i++) s += v[i] * y[i];
s = 2 * s / vv;
for (let i = k; i < rows; i++) y[i] -= s * v[i];
}
const x = new Array(cols).fill(0);
for (let i = cols - 1; i >= 0; i--) {
let s = y[i];
for (let j = i + 1; j < cols; j++) s -= M[i][j] * x[j];
x[i] = Math.abs(M[i][i]) < 1e-300 ? 0 : s / M[i][i];
}
return x;
}
/* coefficients are ordered from highest power to constant term */
function polyfit(xs, ys, deg, w = null) {
const n = xs.length, order = deg + 1;
const A = [];
for (let i = 0; i < n; i++) {
const row = new Array(order);
let v = 1.0;
for (let j = order - 1; j >= 0; j--) { row[j] = v; v *= xs[i]; }
A.push(row);
}
const b = ys.slice();
if (w) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < order; j++) A[i][j] *= w[i];
b[i] *= w[i];
}
}
/* scale the columns before the least-squares solve for conditioning */
const scale = new Array(order).fill(0);
for (let j = 0; j < order; j++) {
let s = 0;
for (let i = 0; i < n; i++) s += A[i][j] * A[i][j];
scale[j] = Math.sqrt(s) || 1.0;
}
for (let i = 0; i < n; i++) for (let j = 0; j < order; j++) A[i][j] /= scale[j];
const c = lstsqQR(A, b, n, order);
for (let j = 0; j < order; j++) c[j] /= scale[j];
return c;
}
function polyval(c, x) {
let v = 0.0;
for (let i = 0; i < c.length; i++) v = v * x + c[i];
return v;
}
/* ---- track-axis quaternion and Euler matrix construction ---------------- */
/* 4x4, row-major, applied as p' = M p */
function mat4Identity() {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function mat4Mul(a, b) {
const o = new Array(16).fill(0);
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[i * 4 + k] * b[k * 4 + j];
o[i * 4 + j] = s;
}
return o;
}
function mat4Apply(m, p) {
const [x, y, z] = p;
return [m[0] * x + m[1] * y + m[2] * z + m[3],
m[4] * x + m[5] * y + m[6] * z + m[7],
m[8] * x + m[9] * y + m[10] * z + m[11]];
}
function mat4Translation(t) {
const m = mat4Identity();
m[3] = t[0]; m[7] = t[1]; m[11] = t[2];
return m;
}
function mat4Scale(s) {
const m = mat4Identity();
m[0] = s[0]; m[5] = s[1]; m[10] = s[2];
return m;
}
/* XYZ Euler order: M = Rz * Ry * Rx */
function mat4FromEulerXYZ(e) {
const ci = Math.cos(e[0]), cj = Math.cos(e[1]), ch = Math.cos(e[2]);
const si = Math.sin(e[0]), sj = Math.sin(e[1]), sh = Math.sin(e[2]);
const cc = ci * ch, cs = ci * sh, sc = si * ch, ss = si * sh;
return [cj * ch, sj * sc - cs, sj * cc + ss, 0,
cj * sh, sj * ss + cc, sj * cs - sc, 0,
-sj, cj * si, cj * ci, 0,
0, 0, 0, 1];
}
/* 4x4 axis-aligned rotation matrix */
function mat4Rotation(angle, axis) {
const c = Math.cos(angle), s = Math.sin(angle);
if (axis === 'X') return [1, 0, 0, 0, 0, c, -s, 0, 0, s, c, 0, 0, 0, 0, 1];
if (axis === 'Y') return [c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1];
return [c, -s, 0, 0, s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function quatToMat4(q) {
const [w, x, y, z] = q;
const q0 = Math.SQRT2 * w, q1 = Math.SQRT2 * x;
const q2 = Math.SQRT2 * y, q3 = Math.SQRT2 * z;
const qda = q0 * q1, qdb = q0 * q2, qdc = q0 * q3;
const qaa = q1 * q1, qab = q1 * q2, qac = q1 * q3;
const qbb = q2 * q2, qbc = q2 * q3, qcc = q3 * q3;
/* m[0], m[1], and m[2] are the three matrix columns */
const c0 = [1.0 - qbb - qcc, qdc + qab, qac - qdb];
const c1 = [qab - qdc, 1.0 - qaa - qcc, qda + qbc];
const c2 = [qac + qdb, qbc - qda, 1.0 - qaa - qbb];
return [c0[0], c1[0], c2[0], 0,
c0[1], c1[1], c2[1], 0,
c0[2], c1[2], c2[2], 0,
0, 0, 0, 1];
}
function quatMul(q1, q2) {
const t0 = q1[0] * q2[0] - q1[1] * q2[1] - q1[2] * q2[2] - q1[3] * q2[3];
const t1 = q1[0] * q2[1] + q1[1] * q2[0] + q1[2] * q2[3] - q1[3] * q2[2];
const t2 = q1[0] * q2[2] + q1[2] * q2[0] + q1[3] * q2[1] - q1[1] * q2[3];
const t3 = q1[0] * q2[3] + q1[3] * q2[0] + q1[1] * q2[2] - q1[2] * q2[1];
return [t0, t1, t2, t3];
}
/* Track local Z with local Y as the up axis. */
function toTrackQuatZY(v) {
const eps = 1e-4;
let q = [1, 0, 0, 0];
const len = v.length;
if (len === 0) return q;
const tvec = [v.x, v.y, v.z]; /* axis 'Z' is index 5 > 2 */
const nor = [-tvec[1], tvec[0], 0.0];
if (Math.abs(tvec[0]) + Math.abs(tvec[1]) < eps) nor[0] = 1.0;
const co = tvec[2] / len;
const nl = Math.hypot(nor[0], nor[1], nor[2]);
if (nl > 0) { nor[0] /= nl; nor[1] /= nl; nor[2] /= nl; }
const ang = Math.acos(Math.max(-1, Math.min(1, co)));
const si = Math.sin(ang / 2);
q = [Math.cos(ang / 2), nor[0] * si, nor[1] * si, nor[2] * si];
/* axis (2) != upflag (1) -> roll correction */
const m = quatToMat4(q);
const fp = [m[2], m[6], m[10]]; /* third column of the 3x3 */
const angle = -0.5 * Math.atan2(-fp[0], -fp[1]);
const co2 = Math.cos(angle), si2 = Math.sin(angle) / len;
const q2 = [co2, tvec[0] * si2, tvec[1] * si2, tvec[2] * si2];
return quatMul(q2, q);
}
/* ==========================================================================
2. polygon mesh container and modeling operations
weld() -> spatial duplicate merge + shell winding repair
shade() -> smooth polygons + sharp-edge marking
xform() -> direct vertex transformation
Faces stay as n-gons until the very end so polygon normals and the
bevel see exactly the declared polygon topology.
========================================================================== */
const FG_ORIG = 0; /* face came from the generator */
const FG_BEVEL = 1; /* face produced by the bevel modifier (hard normals) */
class Mesh {
constructor(name, verts = [], faces = []) {
this.name = name;
this.v = verts.map(p => [p[0], p[1], p[2]]);
this.f = faces.map(t => Array.from(t));
this.fm = this.f.map(() => 0); /* material slot per face */
this.fg = this.f.map(() => FG_ORIG); /* face group */
this.mats = []; /* material names, indexed by fm */
this.smooth = true; /* polygon smoothing */
this.sharpDeg = null; /* shade(..., sharp_angle) */
this.bevel = null; /* pending BEVEL modifier */
}
clone(name) {
const m = new Mesh(name || this.name, this.v, this.f);
m.fm = this.fm.slice(); m.fg = this.fg.slice(); m.mats = this.mats.slice();
m.smooth = this.smooth; m.sharpDeg = this.sharpDeg;
m.bevel = this.bevel ? { ...this.bevel } : null;
return m;
}
transform(m4) { this.v = this.v.map(p => mat4Apply(m4, p)); return this; }
flipNormals() { this.f = this.f.map(t => t.slice().reverse()); return this; }
/* Newell polygon normal */
faceNormal(fi) {
const t = this.f[fi], n = t.length;
let nx = 0, ny = 0, nz = 0;
for (let i = 0; i < n; i++) {
const a = this.v[t[i]], b = this.v[t[(i + 1) % n]];
nx += (a[1] - b[1]) * (a[2] + b[2]);
ny += (a[2] - b[2]) * (a[0] + b[0]);
nz += (a[0] - b[0]) * (a[1] + b[1]);
}
const l = Math.hypot(nx, ny, nz);
return l > 0 ? [nx / l, ny / l, nz / l] : [0, 0, 1];
}
faceCentre(fi) {
const t = this.f[fi];
let x = 0, y = 0, z = 0;
for (const i of t) { x += this.v[i][0]; y += this.v[i][1]; z += this.v[i][2]; }
return [x / t.length, y / t.length, z / t.length];
}
get faceCount() { return this.f.length; }
}
/* ---- spatial duplicate-vertex removal ---------------------------------- */
/* Merge each duplicate into the first vertex found within
`dist`, keeping that vertex's coordinates; then faces that end up with
fewer than three distinct corners are deleted. This is what collapses the
1e-6 "pole" rings of loft() into a single point and what removes the
zero-width seam ring of pillow(). */
const cellHash = (gx, gy, gz) => (Math.imul(gx, 73856093) ^ Math.imul(gy, 19349663) ^ Math.imul(gz, 83492791)) | 0;
function weldVerts(mesh, dist = 2e-5) {
const n = mesh.v.length;
const cell = Math.max(dist, 1e-9) * 2.0;
const grid = new Map();
const map = new Int32Array(n).fill(-1);
const newV = [];
const d2 = dist * dist;
for (let i = 0; i < n; i++) {
const p = mesh.v[i];
const gx = Math.floor(p[0] / cell), gy = Math.floor(p[1] / cell), gz = Math.floor(p[2] / cell);
let found = -1;
for (let a = -1; a <= 1 && found < 0; a++)
for (let b = -1; b <= 1 && found < 0; b++)
for (let c = -1; c <= 1 && found < 0; c++) {
const bucket = grid.get(cellHash(gx + a, gy + b, gz + c));
if (!bucket) continue;
for (let bi = 0; bi < bucket.length; bi++) {
const q = newV[bucket[bi]];
const dx = q[0] - p[0], dy = q[1] - p[1], dz = q[2] - p[2];
if (dx * dx + dy * dy + dz * dz <= d2) { found = bucket[bi]; break; }
}
}
if (found >= 0) { map[i] = found; continue; }
const idx = newV.length;
newV.push([p[0], p[1], p[2]]);
map[i] = idx;
const k = cellHash(gx, gy, gz);
let bucket = grid.get(k);
if (!bucket) { bucket = []; grid.set(k, bucket); }
bucket.push(idx);
}
const nf = [], nfm = [], nfg = [];
const seen = new Set();
for (let fi = 0; fi < mesh.f.length; fi++) {
const src = mesh.f[fi];
const out = [];
for (const vi of src) {
const m = map[vi];
if (out.length === 0 || out[out.length - 1] !== m) out.push(m);
}
while (out.length > 1 && out[0] === out[out.length - 1]) out.pop();
if (out.length < 3) continue;
/* drop exact duplicate faces that share the same corner set */
const sig = out.slice().sort((a, b) => a - b).join(',');
if (seen.has(sig)) continue;
seen.add(sig);
nf.push(out); nfm.push(mesh.fm[fi]); nfg.push(mesh.fg[fi]);
}
mesh.v = newV; mesh.f = nf; mesh.fm = nfm; mesh.fg = nfg;
return mesh;
}
/* ---- connected-shell winding repair ------------------------------------ */
/* make each connected shell consistently wound, then flip whole shells whose
enclosed signed volume is negative (i.e. point them outward) */
function recalcFaceNormals(mesh) {
const F = mesh.f;
const nf = F.length;
if (!nf) return mesh;
/* edge -> list of (face, dir) */
const EK = mesh.v.length + 1;
const ekey = (a, b) => (a < b ? a * EK + b : b * EK + a);
const edges = new Map();
for (let fi = 0; fi < nf; fi++) {
const t = F[fi], n = t.length;
for (let i = 0; i < n; i++) {
const a = t[i], b = t[(i + 1) % n];
const k = ekey(a, b);
let e = edges.get(k);
if (!e) { e = []; edges.set(k, e); }
e.push([fi, a < b ? 1 : -1]);
}
}
const flip = new Uint8Array(nf);
const comp = new Int32Array(nf).fill(-1);
let nComp = 0;
const adj = new Array(nf); for (let i = 0; i < nf; i++) adj[i] = [];
for (const e of edges.values()) {
if (e.length !== 2) continue;
adj[e[0][0]].push([e[1][0], e[0][1] === e[1][1]]); /* true -> needs flip */
adj[e[1][0]].push([e[0][0], e[0][1] === e[1][1]]);
}
for (let s = 0; s < nf; s++) {
if (comp[s] >= 0) continue;
const stack = [s];
comp[s] = nComp; flip[s] = 0;
const members = [s];
while (stack.length) {
const fi = stack.pop();
for (const [fj, needFlip] of adj[fi]) {
if (comp[fj] >= 0) continue;
comp[fj] = nComp;
flip[fj] = needFlip ? (flip[fi] ^ 1) : flip[fi];
members.push(fj);
stack.push(fj);
}
}
/* signed volume of this shell with the tentative winding */
let vol = 0;
for (const fi of members) {
const t = F[fi];
const ord = flip[fi] ? t.slice().reverse() : t;
const p0 = mesh.v[ord[0]];
for (let i = 1; i < ord.length - 1; i++) {
const p1 = mesh.v[ord[i]], p2 = mesh.v[ord[i + 1]];
vol += (p0[0] * (p1[1] * p2[2] - p1[2] * p2[1])
- p0[1] * (p1[0] * p2[2] - p1[2] * p2[0])
+ p0[2] * (p1[0] * p2[1] - p1[1] * p2[0])) / 6.0;
}
}
if (vol < 0) for (const fi of members) flip[fi] ^= 1;
nComp++;
}
for (let fi = 0; fi < nf; fi++) if (flip[fi]) F[fi] = F[fi].slice().reverse();
return mesh;
}
/* weld and repair winding */
function weld(mesh, dist = 2e-5) {
weldVerts(mesh, dist);
recalcFaceNormals(mesh);
return mesh;
}
/* smooth-angle shading contract */
function shade(mesh, smooth = true, sharpAngle = null) {
mesh.smooth = smooth;
mesh.sharpDeg = sharpAngle;
return mesh;
}
/* material-slot assignment */
function setmat(mesh, name, slot = 0) {
while (mesh.mats.length <= slot) mesh.mats.push(name);
mesh.mats[slot] = name;
return mesh;
}
/* bake T * R * S into the mesh data */
function xform(mesh, { loc = [0, 0, 0], rot = [0, 0, 0], scale = [1, 1, 1] } = {}) {
const m = mat4Mul(mat4Mul(mat4Translation(loc),
mat4FromEulerXYZ(rot.map(R))),
mat4Scale(scale));
return mesh.transform(m);
}
/* mirrored duplicate */
function mirrorDup(mesh, name, axis = 'x') {
const s = { x: [-1, 1, 1], y: [1, -1, 1], z: [1, 1, -1] }[axis];
const m = mesh.clone(name || (mesh.name + '_R'));
m.transform(mat4Scale(s));
m.flipNormals();
return m;
}
/* merge meshes into the first after transforms have been baked */
function join(meshes, name = null) {
const base = meshes[0];
for (let k = 1; k < meshes.length; k++) {
const o = meshes[k];
const off = base.v.length;
for (const p of o.v) base.v.push(p.slice());
for (let i = 0; i < o.f.length; i++) {
base.f.push(o.f[i].map(x => x + off));
base.fm.push(o.fm[i]);
base.fg.push(o.fg[i]);
}
}
if (name) base.name = name;
return base;
}
/* ==========================================================================
split normals -- angle-weighted vertex normals with fans bounded
by sharp edges. Faces produced by the bevel are their own flat island, the
way "Harden Normals" keeps a beveled edge reading as a crisp corner.
========================================================================== */
function computeCornerNormals(mesh) {
const F = mesh.f, V = mesh.v, nf = F.length;
const fn = new Array(nf);
for (let i = 0; i < nf; i++) fn[i] = mesh.faceNormal(i);
const corners = []; /* per face, per corner normal */
for (let i = 0; i < nf; i++) corners.push(new Array(F[i].length));
if (!mesh.smooth) { /* shade flat */
for (let i = 0; i < nf; i++) for (let k = 0; k < F[i].length; k++) corners[i][k] = fn[i];
return { fn, corners };
}
/* bevel faces keep their own geometric normal */
for (let i = 0; i < nf; i++)
if (mesh.fg[i] === FG_BEVEL)
for (let k = 0; k < F[i].length; k++) corners[i][k] = fn[i];
/* sharp-edge marking with a declared smooth-angle limit */
const EK = V.length + 1;
const ekey = (a, b) => (a < b ? a * EK + b : b * EK + a);
let sharpSet = null;
if (mesh.sharpDeg !== null) {
sharpSet = new Set();
const lim = Math.cos(R(mesh.sharpDeg));
const em = new Map();
for (let fi = 0; fi < nf; fi++) {
const t = F[fi], n = t.length;
for (let i = 0; i < n; i++) {
const k = ekey(t[i], t[(i + 1) % n]);
let e = em.get(k); if (!e) { e = []; em.set(k, e); }
e.push(fi);
}
}
for (const [k, fs] of em) {
if (fs.length !== 2) continue;
const d = fn[fs[0]][0] * fn[fs[1]][0] + fn[fs[0]][1] * fn[fs[1]][1] + fn[fs[0]][2] * fn[fs[1]][2];
if (d < lim) sharpSet.add(k);
}
}
/* gather the primary-face corners at each vertex */
const atVert = new Map();
for (let fi = 0; fi < nf; fi++) {
if (mesh.fg[fi] !== FG_ORIG) continue;
const t = F[fi];
for (let k = 0; k < t.length; k++) {
let l = atVert.get(t[k]); if (!l) { l = []; atVert.set(t[k], l); }
l.push([fi, k]);
}
}
const cornerAngle = (fi, k) => {
const t = F[fi], n = t.length;
const p = V[t[k]], a = V[t[(k + n - 1) % n]], b = V[t[(k + 1) % n]];
const u = [a[0] - p[0], a[1] - p[1], a[2] - p[2]];
const w = [b[0] - p[0], b[1] - p[1], b[2] - p[2]];
const lu = Math.hypot(u[0], u[1], u[2]), lw = Math.hypot(w[0], w[1], w[2]);
if (lu < 1e-12 || lw < 1e-12) return 0;
const c = (u[0] * w[0] + u[1] * w[1] + u[2] * w[2]) / (lu * lw);
return Math.acos(Math.max(-1, Math.min(1, c)));
};
for (const [vi, list] of atVert) {
let groups;
if (!sharpSet) {
groups = [list]; /* one smooth fan */
} else {
/* union corners that share a non-sharp edge at this vertex */
const parent = list.map((_, i) => i);
const find = a => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
const uni = (a, b) => { a = find(a); b = find(b); if (a !== b) parent[b] = a; };
/* edge at this vertex -> corners of the two faces sharing it */
const byEdge = new Map();
for (let i = 0; i < list.length; i++) {
const [fi, k] = list[i];
const t = F[fi], n = t.length;
for (const other of [t[(k + 1) % n], t[(k + n - 1) % n]]) {
const kk = ekey(vi, other);
let e = byEdge.get(kk); if (!e) { e = []; byEdge.set(kk, e); }
e.push(i);
}
}
for (const [ek, cs] of byEdge) {
if (sharpSet.has(ek) || cs.length < 2) continue;
for (let i = 1; i < cs.length; i++) uni(cs[0], cs[i]);
}
const buckets = new Map();
for (let i = 0; i < list.length; i++) {
const rt = find(i);
let g = buckets.get(rt); if (!g) { g = []; buckets.set(rt, g); }
g.push(list[i]);
}
groups = [...buckets.values()];
}
for (const g of groups) {
let nx = 0, ny = 0, nz = 0;
for (const [fi, k] of g) {
const w = cornerAngle(fi, k);
nx += fn[fi][0] * w; ny += fn[fi][1] * w; nz += fn[fi][2] * w;
}
const l = Math.hypot(nx, ny, nz);
const nrm = l > 1e-12 ? [nx / l, ny / l, nz / l] : fn[g[0][0]];
for (const [fi, k] of g) corners[fi][k] = nrm;
}
}
/* any corner the loop above missed (isolated face) falls back to flat */
for (let i = 0; i < nf; i++)
for (let k = 0; k < F[i].length; k++)
if (!corners[i][k]) corners[i][k] = fn[i];
return { fn, corners };
}
/* ==========================================================================
3. exact polygon boolean difference
BSP solid subtraction. Only the target faces whose bounds overlap the
cutter take part -- every cutter in this build is a small extruded
rounded-rectangle, so the rest of the shell passes straight through.
========================================================================== */
const CSG_EPS = 1e-9;
class CPlane {
constructor(normal, w) { this.n = normal; this.w = w; }
static fromPoints(a, b, c) {
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
const 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);
if (l < 1e-18) return null;
nx /= l; ny /= l; nz /= l;
return new CPlane([nx, ny, nz], nx * a[0] + ny * a[1] + nz * a[2]);
}
flip() { this.n = [-this.n[0], -this.n[1], -this.n[2]]; this.w = -this.w; }
/* split `poly` into the four buckets (csg.js) */
splitPolygon(poly, cf, cb, f, b) {
const COPLANAR = 0, FRONT = 1, BACK = 2, SPANNING = 3;
let type = 0;
const types = [];
for (let i = 0; i < poly.verts.length; i++) {
const v = poly.verts[i];
const t = this.n[0] * v[0] + this.n[1] * v[1] + this.n[2] * v[2] - this.w;
const ty = t < -CSG_EPS ? BACK : (t > CSG_EPS ? FRONT : COPLANAR);
type |= ty;
types.push(ty);
}
switch (type) {
case COPLANAR: {
const d = this.n[0] * poly.plane.n[0] + this.n[1] * poly.plane.n[1] + this.n[2] * poly.plane.n[2];
(d > 0 ? cf : cb).push(poly);
break;
}
case FRONT: f.push(poly); break;
case BACK: b.push(poly); break;
case SPANNING: {
const fv = [], bv = [];
const n = poly.verts.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const ti = types[i], tj = types[j];
const vi = poly.verts[i], vj = poly.verts[j];
if (ti !== BACK) fv.push(vi);
if (ti !== FRONT) bv.push(vi);
if ((ti | tj) === SPANNING) {
const di = this.n[0] * vi[0] + this.n[1] * vi[1] + this.n[2] * vi[2] - this.w;
const dj = this.n[0] * vj[0] + this.n[1] * vj[1] + this.n[2] * vj[2] - this.w;
const t = di / (di - dj);
const p = [vi[0] + (vj[0] - vi[0]) * t,
vi[1] + (vj[1] - vi[1]) * t,
vi[2] + (vj[2] - vi[2]) * t];
fv.push(p); bv.push(p);
}
}
if (fv.length >= 3) f.push({ verts: fv, plane: poly.plane, mat: poly.mat });
if (bv.length >= 3) b.push({ verts: bv, plane: poly.plane, mat: poly.mat });
break;
}
}
}
}
function cpolyFlip(p) {
const pl = new CPlane(p.plane.n.slice(), p.plane.w);
pl.flip();
return { verts: p.verts.slice().reverse(), plane: pl, mat: p.mat };
}
class CNode {
constructor(polys) {
this.plane = null; this.front = null; this.back = null; this.polygons = [];
if (polys && polys.length) this.build(polys);
}
invert() {
this.polygons = this.polygons.map(cpolyFlip);
if (this.plane) this.plane.flip();
if (this.front) this.front.invert();
if (this.back) this.back.invert();
const t = this.front; this.front = this.back; this.back = t;
}
clipPolygons(polys) {
if (!this.plane) return polys.slice();
let f = [], b = [];
for (const p of polys) this.plane.splitPolygon(p, f, b, f, b);
if (this.front) f = this.front.clipPolygons(f);
b = this.back ? this.back.clipPolygons(b) : [];
return f.concat(b);
}
clipTo(node) {
this.polygons = node.clipPolygons(this.polygons);
if (this.front) this.front.clipTo(node);
if (this.back) this.back.clipTo(node);
}
allPolygons() {
let r = this.polygons.slice();
if (this.front) r = r.concat(this.front.allPolygons());
if (this.back) r = r.concat(this.back.allPolygons());
return r;
}
/* pick a splitting plane that balances instead of always taking polys[0]:
the shells here are curved, and a first-polygon split degenerates into a
linear tree that turns the build quadratic. */
pickPlane(polys) {
const tries = Math.min(polys.length, 12);
let best = null, bestScore = Infinity;
const step = Math.max(1, Math.floor(polys.length / tries));
for (let s = 0; s < polys.length; s += step) {
const pl = polys[s].plane;
let f = 0, b = 0, sp = 0;
const stride = Math.max(1, Math.floor(polys.length / 60));
let cnt = 0;
for (let i = 0; i < polys.length; i += stride) {
cnt++;
let hasF = false, hasB = false;
for (const v of polys[i].verts) {
const t = pl.n[0] * v[0] + pl.n[1] * v[1] + pl.n[2] * v[2] - pl.w;
if (t > CSG_EPS) hasF = true; else if (t < -CSG_EPS) hasB = true;
}
if (hasF && hasB) sp++; else if (hasF) f++; else b++;
}
const score = sp * 3 + Math.abs(f - b);
if (score < bestScore) { bestScore = score; best = s; }
void cnt;
}
return best === null ? 0 : best;
}
build(polys) {
if (!polys.length) return;
if (!this.plane) {
const pi = this.pickPlane(polys);
this.plane = new CPlane(polys[pi].plane.n.slice(), polys[pi].plane.w);
}
const f = [], b = [];
for (const p of polys) this.plane.splitPolygon(p, this.polygons, this.polygons, f, b);
if (f.length) { if (!this.front) this.front = new CNode(null); this.front.build(f); }
if (b.length) { if (!this.back) this.back = new CNode(null); this.back.build(b); }
}
}
function meshToPolys(mesh, indices = null) {
const out = [];
const list = indices || mesh.f.map((_, i) => i);
for (const fi of list) {
const t = mesh.f[fi];
const p0 = mesh.v[t[0]];
for (let i = 1; i < t.length - 1; i++) {
const a = p0, b = mesh.v[t[i]], c = mesh.v[t[i + 1]];
const pl = CPlane.fromPoints(a, b, c);
if (!pl) continue;
out.push({ verts: [a.slice(), b.slice(), c.slice()], plane: pl, mat: mesh.fm[fi] });
}
}
return out;
}
function meshBounds(mesh, indices = null) {
const mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
const list = indices || mesh.f.map((_, i) => i);
for (const fi of list)
for (const vi of mesh.f[fi])
for (let k = 0; k < 3; k++) {
mn[k] = Math.min(mn[k], mesh.v[vi][k]);
mx[k] = Math.max(mx[k], mesh.v[vi][k]);
}
return [mn, mx];
}
/* boolean(a, b, "DIFFERENCE") -- a := a - b, in place */
function booleanDifference(A, B) {
const [bmn, bmx] = meshBounds(B);
const pad = 1e-4;
const near = [], far = [];
for (let fi = 0; fi < A.f.length; fi++) {
let hit = true;
const t = A.f[fi];
const fmn = [Infinity, Infinity, Infinity], fmx = [-Infinity, -Infinity, -Infinity];
for (const vi of t) for (let k = 0; k < 3; k++) {
fmn[k] = Math.min(fmn[k], A.v[vi][k]);
fmx[k] = Math.max(fmx[k], A.v[vi][k]);
}
for (let k = 0; k < 3; k++)
if (fmx[k] < bmn[k] - pad || fmn[k] > bmx[k] + pad) { hit = false; break; }
(hit ? near : far).push(fi);
}
const a = new CNode(meshToPolys(A, near));
const b = new CNode(meshToPolys(B));
a.invert(); a.clipTo(b); b.clipTo(a); b.invert(); b.clipTo(a); b.invert();
a.build(b.allPolygons()); a.invert();
const result = a.allPolygons();
/* rebuild the mesh: untouched faces + the CSG fragments */
const nv = [], nf = [], nfm = [], nfg = [];
const remap = new Map();
const push = p => {
const k = `${Math.round(p[0] * 1e9)},${Math.round(p[1] * 1e9)},${Math.round(p[2] * 1e9)}`;
let i = remap.get(k);
if (i === undefined) { i = nv.length; nv.push(p.slice()); remap.set(k, i); }
return i;
};
for (const fi of far) {
nf.push(A.f[fi].map(vi => push(A.v[vi])));
nfm.push(A.fm[fi]); nfg.push(A.fg[fi]);
}
for (const p of result) {
if (p.verts.length < 3) continue;
const idx = p.verts.map(push);
const uniq = [];
for (const i of idx) if (!uniq.length || uniq[uniq.length - 1] !== i) uniq.push(i);
while (uniq.length > 1 && uniq[0] === uniq[uniq.length - 1]) uniq.pop();
if (uniq.length < 3) continue;
nf.push(uniq); nfm.push(p.mat | 0); nfg.push(FG_ORIG);
}
A.v = nv; A.f = nf; A.fm = nfm; A.fg = nfg;
/* stitch the fragments back into a connected shell and drop slivers */
weldVerts(A, 1e-7);
const keep = [], keepM = [], keepG = [];
for (let fi = 0; fi < A.f.length; fi++) {
const t = A.f[fi];
let area = 0;
const p0 = A.v[t[0]];
for (let i = 1; i < t.length - 1; i++) {
const p1 = A.v[t[i]], p2 = A.v[t[i + 1]];
const ux = p1[0] - p0[0], uy = p1[1] - p0[1], uz = p1[2] - p0[2];
const vx = p2[0] - p0[0], vy = p2[1] - p0[1], vz = p2[2] - p0[2];
area += 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx);
}
if (area < 1e-13) continue;
keep.push(t); keepM.push(A.fm[fi]); keepG.push(A.fg[fi]);
}
A.f = keep; A.fm = keepM; A.fg = keepG;
/* the BSP already emits outward-facing polygons and the untouched faces
keep their input winding, so orientation must NOT be re-derived here:
a CSG result has T-junctions, and a volume test over the fragments they
disconnect would flip patches at random. */
return A;
}
/* ==========================================================================
4. angle-limited bevel construction with overlap clamping,
harden_normals, profile 0.62)
Every mesh in this assembly is shaded fully smooth (finish always
passes sharp=None), so on the lofted shells the angle limit rejects
every edge and this is a no-op. Where it bites is the genuinely hard
geometry: the extruded rounded-rectangle plates, the n-gon end caps and
the rims of the boolean recesses. There "Harden Normals" is what keeps
those edges reading as crisp corners with a thin highlight instead of
smearing into a rounded blob, so the new faces are emitted as their own
flat-shaded island (FG_BEVEL).
========================================================================== */
function applyBevel(mesh, width, segments, angleDeg, profile = 0.62) {
if (!width || width <= 0) return mesh;
const V = mesh.v, F = mesh.f, nf = F.length;
if (!nf) return mesh;
const fn = new Array(nf);
for (let i = 0; i < nf; i++) fn[i] = mesh.faceNormal(i);
const EK = V.length + 1;
const ekey = (a, b) => (a < b ? a * EK + b : b * EK + a);
/* edge -> the faces on it */
const em = new Map();
for (let fi = 0; fi < nf; fi++) {
const t = F[fi], n = t.length;
for (let i = 0; i < n; i++) {
const k = ekey(t[i], t[(i + 1) % n]);
let e = em.get(k);
if (!e) { e = { a: Math.min(t[i], t[(i + 1) % n]), b: Math.max(t[i], t[(i + 1) % n]), faces: [] }; em.set(k, e); }
e.faces.push(fi);
}
}
/* which edges the angle limit selects */
const lim = Math.cos(R(angleDeg));
const bev = new Set();
for (const [k, e] of em) {
if (e.faces.length !== 2) continue;
const n0 = fn[e.faces[0]], n1 = fn[e.faces[1]];
const d = n0[0] * n1[0] + n0[1] * n1[1] + n0[2] * n1[2];
if (d < lim) bev.add(k);
}
if (bev.size === 0) return mesh;
/* corners at each vertex, and the count of beveled edges there */
const atVert = new Map();
for (let fi = 0; fi < nf; fi++) {
const t = F[fi];
for (let k = 0; k < t.length; k++) {
let l = atVert.get(t[k]); if (!l) { l = []; atVert.set(t[k], l); }
l.push([fi, k]);
}
}
const bevAtVert = new Map();
for (const [k, e] of em) {
if (!bev.has(k)) continue;
for (const v of [e.a, e.b]) {
let l = bevAtVert.get(v); if (!l) { l = []; bevAtVert.set(v, l); }
l.push(k);
}
}
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const len3 = a => Math.hypot(a[0], a[1], a[2]);
const nrm3 = a => { const l = len3(a); return l > 0 ? [a[0] / l, a[1] / l, a[2] / l] : [0, 0, 0]; };
/* offset of one corner inside its own face */
function cornerPos(fi, k) {
const t = F[fi], n = t.length;
const vi = t[k], pv = t[(k + n - 1) % n], nx = t[(k + 1) % n];
const p = V[vi];
const ep = sub(V[pv], p), en = sub(V[nx], p);
const lp = len3(ep), ln = len3(en);
if (lp < 1e-12 || ln < 1e-12) return p.slice();
const dP = [ep[0] / lp, ep[1] / lp, ep[2] / lp];
const dN = [en[0] / ln, en[1] / ln, en[2] / ln];
const bp = bev.has(ekey(vi, pv)), bn = bev.has(ekey(vi, nx));
if (!bp && !bn) return p.slice();
const cx = dP[1] * dN[2] - dP[2] * dN[1];
const cy = dP[2] * dN[0] - dP[0] * dN[2];
const cz = dP[0] * dN[1] - dP[1] * dN[0];
const sinT = Math.hypot(cx, cy, cz);
if (sinT < 1e-6) return p.slice();
let a = 0, b = 0;
if (bp && bn) { a = width / sinT; b = width / sinT; }
else if (bn) { a = width / sinT; }
else { b = width / sinT; }
a = Math.min(a, 0.45 * lp); /* clamp_overlap */
b = Math.min(b, 0.45 * ln);
return [p[0] + dP[0] * a + dN[0] * b,
p[1] + dP[1] * a + dN[1] * b,
p[2] + dP[2] * a + dN[2] * b];
}
/* sectors: faces around a vertex joined through NON-beveled edges */
const sectorOf = new Map(); /* `${fi}:${k}` -> sector id (per vertex) */
const sectorPos = new Map(); /* `${vi}:${sid}` -> position */
const sectorList = new Map(); /* vi -> [sid...] */
for (const [vi, list] of atVert) {
const nb = bevAtVert.get(vi);
if (!nb || nb.length < 2) { /* untouched, or a bevel end */
for (const [fi, k] of list) sectorOf.set(fi + ':' + k, 0);
sectorPos.set(vi + ':0', V[vi].slice());
sectorList.set(vi, [0]);
continue;
}
const parent = list.map((_, i) => i);
const find = a => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
const uni = (a, b) => { a = find(a); b = find(b); if (a !== b) parent[b] = a; };
const byEdge = new Map();
for (let i = 0; i < list.length; i++) {
const [fi, k] = list[i];
const t = F[fi], n = t.length;
for (const other of [t[(k + 1) % n], t[(k + n - 1) % n]]) {
const kk = ekey(vi, other);
let e = byEdge.get(kk); if (!e) { e = []; byEdge.set(kk, e); }
e.push(i);
}
}
for (const [ek2, cs] of byEdge) {
if (bev.has(ek2)) continue;
for (let i = 1; i < cs.length; i++) uni(cs[0], cs[i]);
}
const groups = new Map();
for (let i = 0; i < list.length; i++) {
const rt = find(i);
let g = groups.get(rt); if (!g) { g = []; groups.set(rt, g); }
g.push(i);
}
let sid = 0;
const ids = [];
for (const g of groups.values()) {
let px = 0, py = 0, pz = 0;
for (const i of g) {
const c = cornerPos(list[i][0], list[i][1]);
px += c[0]; py += c[1]; pz += c[2];
}
const pos = [px / g.length, py / g.length, pz / g.length];
for (const i of g) sectorOf.set(list[i][0] + ':' + list[i][1], sid);
sectorPos.set(vi + ':' + sid, pos);
ids.push(sid);
sid++;
}
sectorList.set(vi, ids);
}
/* ---- emit ------------------------------------------------------------ */
const nv = [];
const push = p => { nv.push([p[0], p[1], p[2]]); return nv.length - 1; };
const svIdx = new Map();
const sectorVert = (vi, sid) => {
const k = vi + ':' + sid;
let i = svIdx.get(k);
if (i === undefined) { i = push(sectorPos.get(k)); svIdx.set(k, i); }
return i;
};
/* superellipse profile: x^r + y^r = 1, r = 2 is a circular bevel */
const pr = Math.min(24, Math.max(0.2, 2.0 * profile / (1.0 - profile)));
const pexp = 2.0 / pr;
const arcCache = new Map();
function arcAt(vi, sA, sB) {
const flip = sA > sB;
const lo = flip ? sB : sA, hi = flip ? sA : sB;
const key = vi + ':' + lo + ':' + hi;
let arc = arcCache.get(key);
if (!arc) {
const P = V[vi];
const a = sectorPos.get(vi + ':' + lo), b = sectorPos.get(vi + ':' + hi);
const va = sub(a, P), vb = sub(b, P);
arc = [];
for (let i = 0; i <= segments; i++) {
if (i === 0) { arc.push(sectorVert(vi, lo)); continue; }
if (i === segments) { arc.push(sectorVert(vi, hi)); continue; }
const t = i / segments;
const u = Math.pow(Math.cos(t * Math.PI / 2), pexp);
const w = Math.pow(Math.sin(t * Math.PI / 2), pexp);
arc.push(push([P[0] + va[0] * u + vb[0] * w,
P[1] + va[1] * u + vb[1] * w,
P[2] + va[2] * u + vb[2] * w]));
}
arcCache.set(key, arc);
}
return flip ? arc.slice().reverse() : arc;
}
const nfaces = [], nfm = [], nfg = [];
/* primary faces, pulled in to their sector corners */
for (let fi = 0; fi < nf; fi++) {
const t = F[fi];
const out = [];
for (let k = 0; k < t.length; k++) {
const sid = sectorOf.get(fi + ':' + k) ?? 0;
const idx = sectorVert(t[k], sid);
if (!out.length || out[out.length - 1] !== idx) out.push(idx);
}
while (out.length > 1 && out[0] === out[out.length - 1]) out.pop();
if (out.length < 3) continue;
nfaces.push(out); nfm.push(mesh.fm[fi]); nfg.push(mesh.fg[fi]);
}
/* the strip along every beveled edge. The winding is derived from the way
f0 traverses the edge, so the strip is consistent with the shell it
replaces and no global normal recalculation is needed. */
const cornerIdx = (fi, vi) => {
const t = F[fi];
for (let i = 0; i < t.length; i++) if (t[i] === vi) return i;
return -1;
};
for (const [k, e] of em) {
if (!bev.has(k)) continue;
const [f0, f1] = e.faces;
const t0 = F[f0];
const k0 = cornerIdx(f0, e.a);
/* v0 -> v1 is the direction f0 walks this edge */
const forward = k0 >= 0 && t0[(k0 + 1) % t0.length] === e.b;
const v0 = forward ? e.a : e.b, v1 = forward ? e.b : e.a;
const sec = (fi, vi) => sectorOf.get(fi + ':' + cornerIdx(fi, vi)) ?? 0;
const s0f = sec(f0, v0), s0b = sec(f1, v0);
const s1f = sec(f0, v1), s1b = sec(f1, v1);
if (s0f === s0b && s1f === s1b) continue; /* nothing opened up */
const arc0 = arcAt(v0, s0f, s0b);
const arc1 = arcAt(v1, s1f, s1b);
for (let i = 0; i < segments; i++) {
const q = [arc1[i], arc0[i], arc0[i + 1], arc1[i + 1]];
const u = [];
for (const x of q) if (!u.length || u[u.length - 1] !== x) u.push(x);
while (u.length > 1 && u[0] === u[u.length - 1]) u.pop();
if (u.length < 3) continue;
nfaces.push(u); nfm.push(mesh.fm[f0]); nfg.push(FG_BEVEL);
}
}
/* patch where three or more beveled edges meet */
for (const [vi, elist] of bevAtVert) {
if (elist.length < 3) continue;
const arcs = [];
for (const k of elist) {
const e = em.get(k);
if (e.faces.length !== 2) continue;
const corner = (fi) => { const t = F[fi]; for (let i = 0; i < t.length; i++) if (t[i] === vi) return i; return -1; };
const s0 = sectorOf.get(e.faces[0] + ':' + corner(e.faces[0])) ?? 0;
const s1 = sectorOf.get(e.faces[1] + ':' + corner(e.faces[1])) ?? 0;
if (s0 === s1) continue;
arcs.push(arcAt(vi, s0, s1));
}
if (arcs.length < 3) continue;
/* chain the arcs into a closed boundary loop */
const loop = [];
const used = new Array(arcs.length).fill(false);
let cur = arcs[0].slice(); used[0] = true;
loop.push(...cur);
let guard = 0;
while (guard++ < arcs.length * 2) {
const tail = loop[loop.length - 1];
let nextI = -1, rev = false;
for (let i = 0; i < arcs.length; i++) {
if (used[i]) continue;
if (arcs[i][0] === tail) { nextI = i; rev = false; break; }
if (arcs[i][arcs[i].length - 1] === tail) { nextI = i; rev = true; break; }
}
if (nextI < 0) break;
used[nextI] = true;
const seg = rev ? arcs[nextI].slice().reverse() : arcs[nextI];
for (let i = 1; i < seg.length; i++) loop.push(seg[i]);
}
if (loop.length > 2 && loop[0] === loop[loop.length - 1]) loop.pop();
if (loop.length < 3) continue;
let cx = 0, cy = 0, cz = 0;
for (const i of loop) { cx += nv[i][0]; cy += nv[i][1]; cz += nv[i][2]; }
const c = push([cx / loop.length, cy / loop.length, cz / loop.length]);
/* orient the patch against the average of the primary faces here */
let ax = 0, ay = 0, az = 0;
for (const [fi] of (atVert.get(vi) || [])) { ax += fn[fi][0]; ay += fn[fi][1]; az += fn[fi][2]; }
let area = 0;
for (let i = 0; i < loop.length; i++) {
const p = nv[loop[i]], q = nv[loop[(i + 1) % loop.length]];
area += (p[1] - q[1]) * (p[2] + q[2]) * ax
+ (p[2] - q[2]) * (p[0] + q[0]) * ay
+ (p[0] - q[0]) * (p[1] + q[1]) * az;
}
const ord = area >= 0 ? loop : loop.slice().reverse();
for (let i = 0; i < ord.length; i++) {
const j = (i + 1) % ord.length;
if (ord[i] === ord[j]) continue;
nfaces.push([c, ord[i], ord[j]]);
nfm.push(mesh.fm[em.get(elist[0]).faces[0]]);
nfg.push(FG_BEVEL);
}
}
mesh.v = nv; mesh.f = nfaces; mesh.fm = nfm; mesh.fg = nfg;
weldVerts(mesh, 1e-9);
return mesh;
}
/* finish -- weld, shade, then queue the bevel modifier */
function finish(mesh, { bevel = 0.0012, bseg = 2, bangle = 32.0, sharp = null,
weld_d = 2e-5, smooth = true } = {}) {
weld(mesh, weld_d);
shade(mesh, smooth, sharp);
mesh.bevel = bevel ? { width: bevel, segments: bseg, angle: bangle } : null;
return mesh;
}
/* addBevel called directly (after a boolean) */
function addBevel(mesh, width, segments, angle) {
mesh.bevel = { width, segments, angle };
return mesh;
}
/* evaluate the pending modifier stack into the mesh */
function applyMods(mesh) {
if (mesh.bevel) {
applyBevel(mesh, mesh.bevel.width, mesh.bevel.segments, mesh.bevel.angle);
mesh.bevel = null;
}
return mesh;
}
/* boolean */
function boolean(a, b) {
applyMods(a);
booleanDifference(a, b);
return a;
}
/* ==========================================================================
5. lofting and panel-surface primitives
========================================================================== */
const COLL = { TORSO: [], HEAD: [], ARM: [], HAND: [], HIP: [], LEG: [], FOOT: [] };
function collClear(name) { COLL[name].length = 0; }
function collRemove(name, mesh) {
const i = COLL[name].indexOf(mesh);
if (i >= 0) COLL[name].splice(i, 1);
}
function meshObj(name, verts, faces, cname) {
const m = new Mesh(name, verts, faces);
m.coll = cname;
COLL[cname].push(m);
return m;
}
/* ---- cross-section profiles -------------------------------------------- */
const copysignPow = (base, p, s) => {
const v = Math.pow(Math.abs(base), p);
return s < 0 ? -v : v;
};
/* super-ellipse point with independent quadrant extents */
function sePoint(theta, ax, ax2, by, by2, eUp, eDn) {
const c = Math.cos(theta), s = Math.sin(theta);
const w = 0.5 + 0.5 * s;
let e = eDn + (eUp - eDn) * w;
e = Math.max(2.02, e);
const p = 2.0 / e;
const a = c >= 0.0 ? ax : ax2;
const b = s >= 0.0 ? by : by2;
return [a * copysignPow(c, p, c), b * copysignPow(s, p, s)];
}
/* closed 2-D section, `n` points CCW starting near +X */
function profile(n, ax, by, ax2, by2, e, e_dn, even = 0.72, dense = 512, phase = 0.0) {
if (by === undefined || by === null) by = ax;
if (ax2 === undefined || ax2 === null) ax2 = ax;
if (by2 === undefined || by2 === null) by2 = by;
if (e === undefined || e === null) e = 2.0;
if (e_dn === undefined || e_dn === null) e_dn = e;
const pts = new Array(dense);
for (let i = 0; i < dense; i++) pts[i] = sePoint(TAU * i / dense, ax, ax2, by, by2, e, e_dn);
const cum = new Float64Array(dense + 1);
for (let i = 0; i < dense; i++) {
const p0 = pts[i], p1 = pts[(i + 1) % dense];
cum[i + 1] = cum[i] + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
}
const total = cum[dense];
const out = new Array(n);
for (let k = 0; k < n; k++) {
const u = pmod(k / n + phase, 1.0);
const target = u * total;
let lo = 0, hi = dense;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (cum[mid] <= target) lo = mid; else hi = mid;
}
const f = cum[hi] === cum[lo] ? 0.0 : (target - cum[lo]) / (cum[hi] - cum[lo]);
const thArc = TAU * (lo + f) / dense;
const thUni = TAU * u;
const d = pmod(thArc - thUni + Math.PI, TAU) - Math.PI;
out[k] = sePoint(thUni + d * even, ax, ax2, by, by2, e, e_dn);
}
return out;
}
/* ---- lofting ------------------------------------------------------------ */
const STATION_KEYS = ['ax', 'ax2', 'by', 'by2', 'e', 'e_dn', 'ox', 'oy', 'rot', 'scale'];
function normStation(s) {
const d = { ax: 0.05, ax2: null, by: null, by2: null, e: 2.0, e_dn: null,
ox: 0.0, oy: 0.0, rot: 0.0, scale: 1.0, ...s };
if (d.ax2 === null || d.ax2 === undefined) d.ax2 = d.ax;
if (d.by === null || d.by === undefined) d.by = d.ax;
if (d.by2 === null || d.by2 === undefined) d.by2 = d.by;
if (d.e_dn === null || d.e_dn === undefined) d.e_dn = d.e;
return d;
}
function loft(stations, opts = {}) {
const { n = 64, name = 'loft', cname = 'OPTIMUS', cap0 = true, cap1 = true,
mode = 'pchip', even = 0.72, axis = 'z', phase = 0.0,
tip0 = null, tip1 = null, ring_t = null } = opts;
let rings = opts.rings ?? null;
let st = stations.map(normStation);
st = st.map((s, i) => [s, i]).sort((a, b) => (a[0].t - b[0].t) || (a[1] - b[1])).map(p => p[0]);
const ts = st.map(s => s.t);
const curves = {};
for (const k of STATION_KEYS) curves[k] = new Curve1D(ts, st.map(s => s[k]), mode);
if (rings === null) rings = Math.max(24, st.length * 6);
const t0 = ts[0], t1 = ts[ts.length - 1];
let ringTs;
if (ring_t !== null) {
const set = new Set(ring_t.map(t => Math.min(t1, Math.max(t0, t))));
ringTs = [...set].sort((a, b) => a - b);
} else {
ringTs = [];
for (let i = 0; i < rings; i++) ringTs.push(t0 + (t1 - t0) * (i / (rings - 1)));
}
const ringAt = (t, floorV, scaleMul = 1.0) => {
const sc = curves.scale.at(t) * scaleMul;
const pr = profile(n,
Math.max(floorV, curves.ax.at(t) * sc),
Math.max(floorV, curves.by.at(t) * sc),
Math.max(floorV, curves.ax2.at(t) * sc),
Math.max(floorV, curves.by2.at(t) * sc),
Math.max(2.02, curves.e.at(t)),
Math.max(2.02, curves.e_dn.at(t)),
even, 512, phase);
const ox = curves.ox.at(t), oy = curves.oy.at(t), rot = curves.rot.at(t);
const cr = Math.cos(rot), sr = Math.sin(rot);
const ring = new Array(n);
for (let i = 0; i < n; i++) {
const x = pr[i][0], y = pr[i][1];
ring[i] = [x * cr - y * sr + ox, x * sr + y * cr + oy, t];
}
return ring;
};
const ringsets = ringTs.map(t => ringAt(t, 1e-5));
/* rounded caps: shrinking rings then a pole */
function capRings(baseT, direction, depth) {
const out = [];
const steps = 5;
for (let i = 1; i <= steps; i++) {
const f = i / steps;
const rs = Math.pow(Math.cos(f * Math.PI / 2), 0.85);
const dz = Math.sin(f * Math.PI / 2) * depth * direction;
const ring = ringAt(baseT, 1e-6, rs);
for (const p of ring) p[2] = baseT + dz;
out.push(ring);
}
return out;
}
let pre = [], post = [];
if (cap0) {
const d = tip0 !== null ? tip0 : (curves.ax.at(t0) + curves.by.at(t0)) * 0.5;
pre = capRings(t0, -1.0, d).reverse();
}
if (cap1) {
const d = tip1 !== null ? tip1 : (curves.ax.at(t1) + curves.by.at(t1)) * 0.5;
post = capRings(t1, +1.0, d);
}
const allrings = pre.concat(ringsets, post);
const verts = [];
for (const r of allrings) for (const p of r) verts.push(p);
const faces = [];
const nr = allrings.length;
for (let i = 0; i < nr - 1; i++) {
const a = i * n, b = (i + 1) * n;
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([a + j, a + j2, b + j2, b + j]);
}
}
if (!cap0) { const t = []; for (let i = n - 1; i >= 0; i--) t.push(i); faces.push(t); }
if (!cap1) { const base = (nr - 1) * n, t = []; for (let i = 0; i < n; i++) t.push(base + i); faces.push(t); }
const ob = meshObj(name, verts, faces, cname);
if (axis !== 'z') ob.transform(axis === 'x' ? mat4Rotation(R(90), 'Y') : mat4Rotation(R(-90), 'X'));
return ob;
}
/* loft along a polyline spine with parallel-transport frames */
function loftSpine(spinePts, stations, opts = {}) {
const { n = 48, name = 'loft', cname = 'OPTIMUS', up = vec(0, 0, 1),
cap0 = true, cap1 = true, mode = 'pchip', even = 0.72,
tip0 = null, tip1 = null } = opts;
let rings = opts.rings ?? null;
const P = spinePts.map(p => (p instanceof V3 ? p.clone() : V3.of(p)));
if (rings === null) rings = Math.max(24, P.length);
const tsIn = P.map((_, i) => i / (P.length - 1));
const cx = new Curve1D(tsIn, P.map(p => p.x), 'spline');
const cy = new Curve1D(tsIn, P.map(p => p.y), 'spline');
const cz = new Curve1D(tsIn, P.map(p => p.z), 'spline');
const S = [];
for (let i = 0; i < rings; i++) {
const u = i / (rings - 1);
S.push(vec(cx.at(u), cy.at(u), cz.at(u)));
}
const T = [];
for (let i = 0; i < rings; i++) {
let t;
if (i === 0) t = S[1].sub(S[0]);
else if (i === rings - 1) t = S[rings - 1].sub(S[rings - 2]);
else t = S[i + 1].sub(S[i - 1]);
T.push(t.normalized());
}
let ref = up.normalized();
if (Math.abs(ref.dot(T[0])) > 0.98) ref = vec(1, 0, 0);
const N = [ref.sub(T[0].mul(ref.dot(T[0]))).normalized()];
for (let i = 1; i < rings; i++) {
let v = N[N.length - 1].sub(T[i].mul(N[N.length - 1].dot(T[i])));
if (v.length < 1e-9) v = vec(1, 0, 0).sub(T[i].mul(T[i].x));
N.push(v.normalized());
}
const B = [];
for (let i = 0; i < rings; i++) B.push(T[i].cross(N[i]).normalized());
let st = stations.map(normStation);
st = st.map((s, i) => [s, i]).sort((a, b) => (a[0].t - b[0].t) || (a[1] - b[1])).map(p => p[0]);
const ts = st.map(s => s.t);
const curves = {};
for (const k of STATION_KEYS) curves[k] = new Curve1D(ts, st.map(s => s[k]), mode);
let allrings = [];
for (let i = 0; i < rings; i++) {
const t = i / (rings - 1);
const sc = curves.scale.at(t);
const pr = profile(n,
Math.max(1e-6, curves.ax.at(t) * sc),
Math.max(1e-6, curves.by.at(t) * sc),
Math.max(1e-6, curves.ax2.at(t) * sc),
Math.max(1e-6, curves.by2.at(t) * sc),
Math.max(2.02, curves.e.at(t)),
Math.max(2.02, curves.e_dn.at(t)), even);
const ox = curves.ox.at(t), oy = curves.oy.at(t), rot = curves.rot.at(t);
const cr = Math.cos(rot), sr = Math.sin(rot);
const ring = [];
for (const [x, y] of pr) {
const xr = x * cr - y * sr, yr = x * sr + y * cr;
ring.push(S[i].add(N[i].mul(xr + ox)).add(B[i].mul(yr + oy)));
}
allrings.push(ring);
}
function cap(idx, direction, depth) {
const out = [];
const steps = 5;
const base = allrings[idx];
let c = vec(0, 0, 0);
for (const v of base) c = c.add(v);
c = c.mul(1.0 / base.length);
for (let i = 1; i <= steps; i++) {
const f = i / steps;
const rs = Math.pow(Math.cos(f * Math.PI / 2), 0.85);
const dz = Math.sin(f * Math.PI / 2) * depth;
out.push(base.map(v => c.add(v.sub(c).mul(rs)).add(T[idx].mul(dz * direction))));
}
return out;
}
let pre = [], post = [];
if (cap0) {
const d = tip0 !== null ? tip0 : (curves.ax.at(0) + curves.by.at(0)) * 0.5;
pre = cap(0, -1.0, d).reverse();
}
if (cap1) {
const d = tip1 !== null ? tip1 : (curves.ax.at(1) + curves.by.at(1)) * 0.5;
post = cap(rings - 1, +1.0, d);
}
allrings = pre.concat(allrings, post);
const verts = [];
for (const r of allrings) for (const v of r) verts.push(v.toArray());
const faces = [];
const nr = allrings.length;
for (let i = 0; i < nr - 1; i++) {
const a = i * n, b = (i + 1) * n;
for (let j = 0; j < n; j++) {
const j2 = (j + 1) % n;
faces.push([a + j, a + j2, b + j2, b + j]);
}
}
if (!cap0) { const t = []; for (let i = n - 1; i >= 0; i--) t.push(i); faces.push(t); }
if (!cap1) { const base = (nr - 1) * n, t = []; for (let i = 0; i < n; i++) t.push(base + i); faces.push(t); }
return meshObj(name, verts, faces, cname);
}
/* ---- shell / panel construction ---------------------------------------- */
/* 0 at the rim, 1 at the centre, with a soft rolled edge */
function pillowBulge(s, rim) {
if (s >= rim) return 1.0;
return Math.pow(Math.sin(Math.min(1.0, s / Math.max(1e-6, rim)) * Math.PI / 2), 0.85);
}
/* f(u,v) -> the pillow's ring parameter s (0 at the rim, 1 at the pole) */
function pillowS(outline, centre = null) {
const n = outline.length;
let cu, cv;
if (centre === null) {
cu = outline.reduce((s, p) => s + p[0], 0) / n;
cv = outline.reduce((s, p) => s + p[1], 0) / n;
} else { cu = centre[0]; cv = centre[1]; }
return (u, v) => {
const dx = u - cu, dy = v - cv;
const r2 = dx * dx + dy * dy;
if (r2 < 1e-18) return 1.0;
let best = null;
for (let i = 0; i < n; i++) {
const ax = outline[i][0] - cu, ay = outline[i][1] - cv;
const bx = outline[(i + 1) % n][0] - cu, by = outline[(i + 1) % n][1] - cv;
const ex = bx - ax, ey = by - ay;
const den = ex * dy - ey * dx;
if (Math.abs(den) < 1e-15) continue;
const t = (dx * ay - dy * ax) / den;
if (t < -1e-9 || t > 1.0 + 1e-9) continue;
const k = ((ax + t * ex) * dx + (ay + t * ey) * dy) / r2;
if (k > 1e-9 && (best === null || k < best)) best = k;
}
if (best === null || best <= 1.0) return 0.0;
return Math.max(0.0, Math.min(1.0, 1.0 - 1.0 / best));
};
}
/* f(u,v) -> (point, normal) on the OUTER face of the pillow that pillow()
would build from the same arguments */
function pillowEval(outline, surf, { t_front = 0.008, rim = 0.55, centre = null, disp = null } = {}) {
const sOf = pillowS(outline, centre);
return (u, v) => {
const [p, nrm] = surf(u, v);
const b = pillowBulge(sOf(u, v), rim);
const d = disp === null ? 0.0 : disp(u, v) * b;
return [p.add(nrm.mul(t_front * b + d)), nrm];
};
}
/* closed rounded 'pillow' panel: concentric quad rings + one pole per side */
function pillow(outline, surf, opts = {}) {
const { t_front = 0.008, t_back = 0.004, layers = 9, name = 'panel',
cname = 'OPTIMUS', rim = 0.55, centre = null, disp = null,
layer_s = null } = opts;
const n = outline.length;
let cu, cv;
if (centre === null) {
cu = outline.reduce((s, p) => s + p[0], 0) / n;
cv = outline.reduce((s, p) => s + p[1], 0) / n;
} else { cu = centre[0]; cv = centre[1]; }
const shrink = s => outline.map(p => [lerp(p[0], cu, s), lerp(p[1], cv, s)]);
const bulge = s => pillowBulge(s, rim);
let ss;
if (layer_s !== null) {
/* explicit ring parameters, so a caller can crowd layers across a
surface feature instead of raising the density of the whole panel */
ss = [...new Set([0.0, ...layer_s.map(s => Math.min(1.0, Math.max(0.0, s)))])]
.sort((a, b) => a - b);
if (ss[ss.length - 1] < 1.0) ss.push(1.0);
} else {
ss = [];
for (let i = 0; i < layers; i++) ss.push(Math.pow(i / (layers - 1), 0.85));
}
const ringsF = [], ringsB = [];
for (let i = 0; i < ss.length - 1; i++) {
const s = ss[i];
const rf = [], rb = [];
for (const [u, v] of shrink(s)) {
const [p, nrm] = surf(u, v);
const b = bulge(s);
const d = disp === null ? 0.0 : disp(u, v) * b;
rf.push(p.add(nrm.mul(t_front * b + d)));
rb.push(p.sub(nrm.mul(t_back * b)));
}
ringsF.push(rf); ringsB.push(rb);
}
/* The pole must carry the same displacement as the ring beside it. Left
out, it sat t_front below a ring that had been pushed out by disp, and the
fan pinched into a spike -- the "hole" in the middle of the panel. */
const [pc, pn] = surf(cu, cv);
const dpole = disp === null ? 0.0 : disp(cu, cv);
const poleF = pc.add(pn.mul(t_front + dpole));
const poleB = pc.sub(pn.mul(t_back));
const verts = [], faces = [];
for (const r of ringsF) for (const v of r) verts.push(v.toArray());
const idxPoleF = verts.length; verts.push(poleF.toArray());
const offB = verts.length;
for (const r of ringsB) for (const v of r) verts.push(v.toArray());
const idxPoleB = verts.length; verts.push(poleB.toArray());
const L = ringsF.length;
for (let i = 0; i < L - 1; i++) {
const a = i * n, b = (i + 1) * n;
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([a + j, b + j, b + j2, a + j2]); }
}
let a = (L - 1) * n;
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([a + j, idxPoleF, a + j2]); }
for (let i = 0; i < L - 1; i++) {
const aa = offB + i * n, bb = offB + (i + 1) * n;
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([aa + j, aa + j2, bb + j2, bb + j]); }
}
a = offB + (L - 1) * n;
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([a + j2, idxPoleB, a + j]); }
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([j, j2, offB + j2, offB + j]); }
return meshObj(name, verts, faces, cname);
}
function resampleClosed(pts, n, phase = 0.0) {
const m = pts.length;
const cum = [0.0];
for (let i = 0; i < m; i++) {
const a = pts[i], b = pts[(i + 1) % m];
cum.push(cum[cum.length - 1] + Math.hypot(a[0] - b[0], a[1] - b[1]));
}
const total = cum[m];
const out = [];
for (let k = 0; k < n; k++) {
const target = pmod(k / n + phase, 1.0) * total;
let lo = 0, hi = m;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (cum[mid] <= target) lo = mid; else hi = mid; }
const f = cum[hi] === cum[lo] ? 0.0 : (target - cum[lo]) / (cum[hi] - cum[lo]);
const a = pts[lo % m], b = pts[hi % m];
out.push([lerp(a[0], b[0], f), lerp(a[1], b[1], f)]);
}
return out;
}
/* rounded rectangle outline, resampled evenly, CCW */
function rrectOutline(w, h, r, n = 96, cx = 0.0, cy = 0.0, seg = 20) {
const hw = w * 0.5, hh = h * 0.5;
let rr = (typeof r === 'number') ? [r, r, r, r] : r.slice();
rr = rr.map(ri => Math.max(1e-5, Math.min(ri, hw - 1e-5, hh - 1e-5)));
const cs = [[hw - rr[0], hh - rr[0]], [-hw + rr[1], hh - rr[1]],
[-hw + rr[2], -hh + rr[2]], [hw - rr[3], -hh + rr[3]]];
const pts = [];
for (let i = 0; i < 4; i++) {
const [px, py] = cs[i];
const a0 = i * Math.PI / 2;
for (let k = 0; k <= seg; k++) {
const a = a0 + (k / seg) * (Math.PI / 2);
pts.push([px + rr[i] * Math.cos(a), py + rr[i] * Math.sin(a)]);
}
}
return resampleClosed(pts, n).map(p => [p[0] + cx, p[1] + cy]);
}
/* resample an arbitrary closed polygon, optional corner smoothing passes */
function polyOutline(pts, n = 96, smooth = 0) {
let p = pts.map(q => [q[0], q[1]]);
for (let s = 0; s < smooth; s++) {
const q = [];
const m = p.length;
for (let i = 0; i < m; i++) {
const a = p[i], b = p[(i + 1) % m];
q.push([0.75 * a[0] + 0.25 * b[0], 0.75 * a[1] + 0.25 * b[1]]);
q.push([0.25 * a[0] + 0.75 * b[0], 0.25 * a[1] + 0.75 * b[1]]);
}
p = q;
}
return resampleClosed(p, n);
}
/* simple prism from a closed 2-D outline (caps as n-gons) */
function extrudeOutline(outline, z0, z1, opts = {}) {
const { name = 'ext', cname = 'OPTIMUS', taper1 = 1.0, taper0 = 1.0, cx = 0.0, cy = 0.0 } = opts;
const n = outline.length;
const verts = [];
for (const [u, v] of outline) verts.push([(u - cx) * taper0 + cx, (v - cy) * taper0 + cy, z0]);
for (const [u, v] of outline) verts.push([(u - cx) * taper1 + cx, (v - cy) * taper1 + cy, z1]);
const faces = [];
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([j, j2, n + j2, n + j]); }
const b = []; for (let i = n - 1; i >= 0; i--) b.push(i); faces.push(b);
const t = []; for (let i = n; i < 2 * n; i++) t.push(i); faces.push(t);
return meshObj(name, verts, faces, cname);
}
/* round tube along a spine; radii = scalar or per-point list */
function tube(spine, radii, opts = {}) {
const { n = 24, name = 'tube', cname = 'OPTIMUS', cap = true, up = vec(0, 0, 1) } = opts;
const rs = (typeof radii === 'number') ? new Array(spine.length).fill(radii) : radii;
const st = spine.map((_, i) => ({ t: i / (spine.length - 1), ax: rs[i] }));
return loftSpine(spine, st, {
n, name, cname, up, cap0: cap, cap1: cap,
tip0: rs[0] * 0.6, tip1: rs[rs.length - 1] * 0.6,
rings: Math.max(spine.length * 4, 16)
});
}
/* join while keeping the semantic collection in step */
function joinMeshes(meshes, name, cname) {
for (let i = 1; i < meshes.length; i++) collRemove(cname, meshes[i]);
return join(meshes, name);
}
/* ==========================================================================
6. torso -- black torso core (pelvis -> chest -> trapezius -> neck),
white chest plate, back plate, moulded back piece, shoulder straps,
TESLA wordmark.
========================================================================== */
/* (z, half-width, back Y, front depth, e_back, e_front) */
const CORE = [
[0.9520, 0.0620, 0.0730, 0.0290, 2.6, 2.8],
[0.9640, 0.0648, 0.0768, 0.0316, 2.6, 2.8],
[0.9760, 0.0670, 0.0800, 0.0336, 2.6, 2.8],
[0.9880, 0.0688, 0.0828, 0.0350, 2.6, 2.8],
[1.0000, 0.0706, 0.0870, 0.0374, 2.6, 2.8],
[1.0090, 0.0776, 0.0960, 0.0420, 2.7, 2.9],
[1.0135, 0.0930, 0.1090, 0.0500, 2.8, 2.9],
[1.0180, 0.1098, 0.1212, 0.0598, 2.9, 2.9],
[1.0330, 0.1160, 0.1246, 0.0640, 2.8, 3.0],
[1.0480, 0.1210, 0.1276, 0.0656, 2.8, 3.0],
[1.0640, 0.1252, 0.1306, 0.0648, 2.9, 2.85],
[1.0800, 0.1288, 0.1338, 0.0638, 3.0, 2.82],
[1.0960, 0.1312, 0.1372, 0.0628, 3.1, 2.80],
[1.1150, 0.1330, 0.1408, 0.0622, 3.3, 2.74],
[1.1400, 0.1345, 0.1450, 0.0622, 3.5, 2.66],
[1.1700, 0.1362, 0.1500, 0.0630, 3.6, 2.58],
[1.2000, 0.1390, 0.1552, 0.0642, 3.7, 2.52],
[1.2300, 0.1428, 0.1614, 0.0662, 3.8, 2.48],
[1.2600, 0.1472, 0.1672, 0.0682, 3.8, 2.46],
[1.2900, 0.1516, 0.1722, 0.0694, 3.8, 2.45],
[1.3200, 0.1552, 0.1762, 0.0698, 3.8, 2.45],
[1.3500, 0.1580, 0.1776, 0.0640, 3.7, 2.47],
[1.3750, 0.1596, 0.1758, 0.0570, 3.6, 2.52],
[1.3950, 0.1618, 0.1718, 0.0500, 3.5, 2.60],
[1.4100, 0.1672, 0.1672, 0.0420, 3.5, 2.72],
[1.4210, 0.1762, 0.1610, 0.0384, 3.7, 2.86],
[1.4310, 0.1842, 0.1540, 0.0348, 3.3, 2.90],
[1.4400, 0.1876, 0.1452, 0.0316, 3.3, 2.95],
[1.4480, 0.1830, 0.1360, 0.0300, 3.3, 2.92],
[1.4560, 0.1690, 0.1276, 0.0292, 3.2, 2.86],
[1.4640, 0.1432, 0.1190, 0.0286, 3.4, 2.85],
[1.4720, 0.0982, 0.1120, 0.0284, 3.0, 3.0],
[1.4800, 0.0776, 0.1056, 0.0284, 2.7, 2.8],
[1.4900, 0.0580, 0.1075, 0.0316, 2.5, 2.7],
[1.5000, 0.0466, 0.0975, 0.0296, 2.4, 2.6],
[1.5120, 0.0424, 0.0820, 0.0296, 2.4, 2.6],
[1.5280, 0.0410, 0.0780, 0.0326, 2.4, 2.6],
[1.5450, 0.0412, 0.0772, 0.0366, 2.4, 2.6],
[1.5620, 0.0420, 0.0768, 0.0396, 2.4, 2.6],
];
const _ZS = CORE.map(c => c[0]);
let C_AX = null, C_BY = null, C_BY2 = null, C_EB = null, C_EF = null;
function coreCurves() {
C_AX = new Curve1D(_ZS, CORE.map(c => c[1]), 'spline');
C_BY = new Curve1D(_ZS, CORE.map(c => c[2]), 'spline');
C_BY2 = new Curve1D(_ZS, CORE.map(c => c[3]), 'spline');
C_EB = new Curve1D(_ZS, CORE.map(c => c[4]), 'spline');
C_EF = new Curve1D(_ZS, CORE.map(c => c[5]), 'spline');
}
/* A panel edge that reaches the body's own silhouette lands where this
section has infinite slope, and anything past it used to collapse to y=0 --
which is what tore the back panel apart along the flank. Clamping the
normalised radius keeps the surface, and its normal, finite everywhere. */
const _TMAX = 0.960;
/* Y of the core's front surface at (x, z); negative = toward the viewer */
function coreFrontY(x, z) {
const ax = Math.max(1e-6, C_AX.at(z));
const by2 = C_BY2.at(z);
const e = Math.max(2.02, C_EF.at(z));
const t = Math.min(_TMAX, Math.abs(x) / ax);
return -by2 * Math.pow(Math.max(0.0, 1.0 - Math.pow(t, e)), 1.0 / e);
}
function coreBackY(x, z) {
const ax = Math.max(1e-6, C_AX.at(z));
const by = C_BY.at(z);
const e = Math.max(2.02, C_EB.at(z));
const t = Math.min(_TMAX, Math.abs(x) / ax);
return by * Math.pow(Math.max(0.0, 1.0 - Math.pow(t, e)), 1.0 / e);
}
function coreBackSurf(offset = 0.0) {
const d = 3e-4;
const nrm = (u, v) => {
let fx = (coreBackY(u + d, v) - coreBackY(u - d, v)) / (2 * d);
let fz = (coreBackY(u, v + d) - coreBackY(u, v - d)) / (2 * d);
fx = Math.max(-3.2, Math.min(3.2, fx));
fz = Math.max(-3.2, Math.min(3.2, fz));
return vec(-fx, 1.0, -fz).normalize();
};
return (u, v) => {
const p = vec(u, coreBackY(u, v), v);
const n = nrm(u, v);
return [p.add(n.mul(offset)), n];
};
}
function coreFrontSurf(offset = 0.0) {
const d = 3e-4;
const nrm = (u, v) => {
let fx = (coreFrontY(u + d, v) - coreFrontY(u - d, v)) / (2 * d);
let fz = (coreFrontY(u, v + d) - coreFrontY(u, v - d)) / (2 * d);
fx = Math.max(-3.2, Math.min(3.2, fx));
fz = Math.max(-3.2, Math.min(3.2, fz));
return vec(fx, -1.0, fz).normalize();
};
return (u, v) => {
const n = nrm(u, v);
return [vec(u, coreFrontY(u, v), v).add(n.mul(offset)), n];
};
}
const BACK_EDGE = [
[1.4460, 0.0980], [1.4400, 0.1190], [1.4320, 0.1330], [1.4200, 0.1430],
[1.4020, 0.1492], [1.3800, 0.1524], [1.3600, 0.1540], [1.3450, 0.1546],
[1.3200, 0.1512], [1.2900, 0.1476], [1.2600, 0.1432], [1.2300, 0.1388],
[1.2000, 0.1350], [1.1800, 0.1334], [1.1680, 0.1308], [1.1620, 0.1240],
];
const BACK_Z0 = 1.1620, BACK_Z1 = 1.4460;
const BACKPIECE = [
[0.0000, 1.3630], [0.0230, 1.3624], [0.0330, 1.3596], [0.0378, 1.3520],
[0.0424, 1.3318], [0.0474, 1.2993], [0.0532, 1.2633], [0.0618, 1.2272],
[0.0719, 1.2019], [0.0835, 1.1876], [0.1022, 1.1731], [0.1195, 1.1587],
[0.1268, 1.1470], [0.1292, 1.1320], [0.1288, 1.0500], [0.1268, 1.0300],
[0.1180, 1.0225], [0.0880, 1.0200], [0.0400, 1.0192], [0.0000, 1.0190],
];
const BOX_Z1 = 1.1560, BOX_Z0 = 1.0300;
const backEdgeCurve = () => new Curve1D(BACK_EDGE.map(p => p[0]), BACK_EDGE.map(p => p[1]), 'spline');
function backPlateSurf(extra = 0.0) {
const base = coreBackSurf(0.0);
const xe = backEdgeCurve();
return (u, v) => {
const [p, n] = base(u, v);
const r = Math.min(1.0, Math.abs(u) / Math.max(1e-5, xe.at(v)));
const lift = 0.0060 * (0.34 + 0.66 * (1.0 - Math.pow(r, 2.6)));
return [p.add(n.mul(lift + extra)), n];
};
}
function buildBackPanel() {
const xe = backEdgeCurve();
const surf = backPlateSurf();
const z1 = BACK_Z1, z0 = BACK_Z0;
const pts = [];
const N = 58;
for (let i = 0; i <= N; i++) { const v = z1 - (z1 - z0) * (i / N); pts.push([xe.at(v), v]); }
const b = xe.at(z0);
for (let i = 1; i < 13; i++) {
const f = i / 13.0;
pts.push([b * Math.cos(Math.PI * f), z0 - 0.0020 * Math.sin(Math.PI * f)]);
}
for (let i = 0; i <= N; i++) { const v = z0 + (z1 - z0) * (i / N); pts.push([-xe.at(v), v]); }
const t = xe.at(z1);
for (let i = 1; i < 11; i++) {
const f = i / 11.0;
pts.push([-t * Math.cos(Math.PI * f), z1 + 0.0014 * Math.sin(Math.PI * f)]);
}
const outline = polyOutline(pts, 200, 1);
const ob = pillow(outline, surf, {
t_front: 0.0038, t_back: 0.0220, layers: 16,
name: 'back_plate', cname: 'TORSO', rim: 0.16, centre: [0.0, 1.300]
});
finish(ob, { bevel: 0.0008, bseg: 2, bangle: 32 });
setmat(ob, 'M_SHELL');
return ob;
}
/* service recess in the housing face: half-width, half-height, corner radius,
centre z, depth, chamfer width. The rectangle is modelled into the panel's
own displacement rather than cut with a boolean -- the cutter was a flat
prism meeting a curved, swelling face, so the rim depth varied and the
corners came out ragged. */
const RECESS = [0.0775, 0.0375, 0.0140, 1.0940, 0.0086, 0.0155];
/* signed depth of the framed recess: 0 on the frame, -depth on the floor,
chamfered between */
function recessD(u, v) {
const [hw, hh, r, cz, dep, ch] = RECESS;
const dx = Math.max(0.0, Math.abs(u) - (hw - r));
const dz = Math.max(0.0, Math.abs(v - cz) - (hh - r));
const d = Math.hypot(dx, dz) - r;
return -dep * smoothstep(0.0, -ch, d);
}
const housing = v => smoothstep(BOX_Z1 + 0.0180, BOX_Z1 - 0.0140, v)
* (1.0 - smoothstep(BOX_Z0 + 0.0120, BOX_Z0 - 0.0110, v));
function backpieceDisp(u, v) {
const box = housing(v);
const shoulder = smoothstep(1.3630, 1.3250, v);
let d = 0.0034 * shoulder + 0.0112 * box;
/* reference width kept above the panel's own half-width so this never
reaches the clamp -- min() here would crease the flank */
d -= 0.0030 * box * Math.min(1.0, Math.pow(Math.abs(u) / 0.1560, 3.0));
d += box * recessD(u, v);
return d;
}
/* --- the back piece as a grid shell ---------------------------------------
It was built with pillow(), whose rings are scaled copies of the outline
collapsing onto a pole. For a tall shield flaring into a wide housing that
is the wrong topology: the rings pile up unevenly, they carry the outline's
own corners across the middle of the panel, and a rectangular recess cut
into them stair-steps along every ring. A (z, x) grid over the body's back
surface has none of those problems -- uniform cells, and the recess edges
land square on the grid. */
const BP_Z0 = 1.0215, BP_Z1 = 1.3610;
let _BPW = null;
function bpCurves() {
const side = [[1.3596, 0.0330], [1.3520, 0.0378], [1.3318, 0.0424],
[1.2993, 0.0474], [1.2633, 0.0532], [1.2272, 0.0618],
[1.2019, 0.0719], [1.1876, 0.0835], [1.1731, 0.1022],
[1.1587, 0.1195], [1.1470, 0.1268], [1.1320, 0.1292],
[1.0500, 0.1288], [1.0300, 0.1268], [1.0225, 0.1180]];
_BPW = new Curve1D(side.map(p => p[0]), side.map(p => p[1]), 'spline');
}
/* C1 smooth minimum -- a hard min() puts a crease along the curve where the
two arguments swap over, and on this panel that crease was visible running
down the flank */
function smin(a, b, k = 0.008) {
const h = Math.max(0.0, Math.min(1.0, 0.5 + 0.5 * (b - a) / k));
return b * (1.0 - h) + a * h - k * h * (1.0 - h);
}
/* half-width of the back piece at z, never wider than the torso itself */
function bpWidth(z) {
let w = _BPW.at(Math.max(BP_Z0, Math.min(BP_Z1, z)));
const r = 0.0110;
const k = smoothstep(BP_Z1, BP_Z1 - r, z) * smoothstep(BP_Z0, BP_Z0 + r, z);
w *= 0.72 + 0.28 * k;
return Math.max(0.004, smin(w, C_AX.at(z) - 0.0055, 0.010));
}
/* 0 on the panel's rim, 1 once clear of it -- the edge tucks onto the body */
function bpRoll(a, z) {
const w = bpWidth(z);
const d = smin(smin(z - BP_Z0, BP_Z1 - z, 0.005), w * (1.0 - Math.abs(a)), 0.005);
return smoothstep(0.0, 0.0140, d);
}
const BP_SEAT = 0.0011, BP_LIFT = 0.0062, BP_SKIN = 0.0028, BP_BACK = 0.0135;
const bpBase = () => coreBackSurf(0.0);
/* outer face of the back piece. a in [-1,1] across the panel. */
function backPieceSurf(a, z, extra = 0.0) {
const x = a * bpWidth(z);
const [p, n] = bpBase()(x, z);
const b = bpRoll(a, z);
const off = BP_SEAT + (BP_LIFT + BP_SKIN) * b + backpieceDisp(x, z) * b;
return [p.add(n.mul(off + extra)), n];
}
function buildBackPiece() {
bpCurves();
const objs = [];
const base = bpBase();
const NZ = 236, NX = 168;
const zs = [], ax = [];
for (let i = 0; i <= NZ; i++) zs.push(BP_Z0 + (BP_Z1 - BP_Z0) * (i / NZ));
for (let j = 0; j <= NX; j++) ax.push(-1.0 + 2.0 * (j / NX));
const N = NX + 1;
const vf = [], vb = [];
for (const z of zs) {
const w = bpWidth(z);
for (const a of ax) {
const x = a * w;
const [p, n] = base(x, z);
const b = bpRoll(a, z);
const d = backpieceDisp(x, z);
vf.push(p.add(n.mul(BP_SEAT + (BP_LIFT + BP_SKIN) * b + d * b)).toArray());
vb.push(p.add(n.mul(BP_SEAT - BP_BACK * b)).toArray());
}
}
/* The two sheets meet exactly on the rim (the roll-off is 0 there). They
SHARE those vertices by index rather than being welded together after the
fact; relying on a weld leaves stray non-manifold edges at the narrow
top of the panel, where neighbouring columns sit a fraction of a
millimetre apart. */
const verts = vf.slice();
const mapb = new Array(vb.length).fill(0);
for (let i = 0; i <= NZ; i++) {
for (let j = 0; j < N; j++) {
const k = i * N + j;
if (i === 0 || i === NZ || j === 0 || j === NX) mapb[k] = k;
else { mapb[k] = verts.length; verts.push(vb[k]); }
}
}
const faces = [];
for (let i = 0; i < NZ; i++) {
for (let j = 0; j < NX; j++) {
const a0 = i * N + j, b0 = (i + 1) * N + j;
faces.push([a0, a0 + 1, b0 + 1, b0]);
faces.push([mapb[a0], mapb[b0], mapb[b0 + 1], mapb[a0 + 1]]);
}
}
const ob = meshObj('back_piece', verts, faces, 'TORSO');
/* deliberately NOT welded: the rim is already shared by index, and at the
corners the two sheets close to within float32 precision, so even a
zero-distance merge collapsed those quads and holed the shell.
The grid is wound with the outer sheet facing -Y; the shading
flips a back-facing normal for the viewer, three.js culls it instead, so
the winding (not the geometry) is made outward-facing here. */
recalcFaceNormals(ob);
shade(ob, true, 34);
addBevel(ob, 0.0007, 2, 34);
setmat(ob, 'M_DARKMECH');
markRecessFloor(ob);
objs.push(ob);
const psurf = (x, z) => backPieceSurf(x / Math.max(1e-6, bpWidth(z)), z);
const fast = [];
const seats = [[0.0930, 1.1310], [0.0930, 1.0570], /* recess frame */
[0.0330, 1.3430], [0.0424, 1.2960], [0.0500, 1.2560],
[0.0630, 1.2060], [0.0900, 1.1730], [0.1160, 1.1180],
[0.1150, 1.0430]]; /* shield flanks */
seats.forEach(([x, z], k) => {
for (const s of [-1, 1]) {
const [p, n] = psurf(s * x, z);
const st = [{ t: 0.0, ax: 0.0025, by: 0.0025, e: 2.2 },
{ t: 0.0020, ax: 0.0020, by: 0.0020, e: 2.2 }];
const bo = loft(st, { n: 14, rings: 4, name: `bpf${k}${s > 0 ? 'p' : 'm'}`,
cname: 'TORSO', cap0: false, cap1: true, tip1: 0.0008 });
const m = quatToMat4(toTrackQuatZY(n));
const tr = p.sub(n.mul(0.0006)); /* seated into the surface */
m[3] = tr.x; m[7] = tr.y; m[11] = tr.z;
bo.transform(m);
fast.push(bo);
}
});
for (const bo of fast) {
finish(bo, { bevel: 0.0003, bseg: 1, bangle: 40 });
setmat(bo, 'M_ALU');
}
return objs.concat(fast);
}
/* the floor of the recess reads as a lighter inset panel -- done with a
material slot rather than a separate plate, so the back stays one piece */
function markRecessFloor(ob) {
const [hw, hh, r, cz, , ch] = RECESS;
const idx = ob.mats.length;
ob.mats.push('M_DARKGREY');
for (let fi = 0; fi < ob.f.length; fi++) {
const c = ob.faceCentre(fi);
if (c[1] < 0.10 || Math.abs(c[2] - cz) > hh || Math.abs(c[0]) > hw) continue;
const dx = Math.max(0.0, Math.abs(c[0]) - (hw - r));
const dz = Math.max(0.0, Math.abs(c[2] - cz) - (hh - r));
if (Math.hypot(dx, dz) - r < -ch * 0.92) ob.fm[fi] = idx;
}
return ob;
}
function buildTorsoCore() {
coreCurves();
const st = CORE.map(c => ({ t: c[0], ax: c[1], by: c[2], by2: c[3], e: c[4], e_dn: c[5] }));
const ob = loft(st, { n: 96, rings: 220, name: 'torso_core', cname: 'TORSO',
cap0: true, cap1: false, tip0: 0.0135, mode: 'spline', even: 0.62 });
finish(ob, { bevel: 0.0010, bseg: 2, bangle: 40 });
setmat(ob, 'M_BLACK');
return ob;
}
const CHEST_EDGE = [
[1.4415, 0.0975], [1.4370, 0.1120], [1.4310, 0.1240], [1.4230, 0.1292],
[1.4120, 0.1318], [1.3980, 0.1344], [1.3800, 0.1378], [1.3600, 0.1424],
[1.3400, 0.1490], [1.3150, 0.1514], [1.2900, 0.1478], [1.2650, 0.1436],
[1.2400, 0.1404], [1.2150, 0.1374], [1.1900, 0.1348], [1.1650, 0.1326],
[1.1400, 0.1306], [1.1180, 0.1288], [1.1060, 0.1262], [1.1005, 0.1222],
[1.0975, 0.1156],
];
const CHEST_Z0 = 1.0975, CHEST_Z1 = 1.4415, PLATE_LIFT = 0.0068;
const chestEdgeCurve = () => new Curve1D(CHEST_EDGE.map(p => p[0]), CHEST_EDGE.map(p => p[1]), 'spline');
function plateDisp(u, v) {
let d = 0.0;
for (const s of [-1.0, 1.0])
d += 0.0058 * Math.exp(-Math.pow((u - s * 0.0730) / 0.0620, 2)
- Math.pow((v - 1.3320) / 0.0760, 2));
d -= 0.0026 * Math.exp(-Math.pow(u / 0.0330, 2) - Math.pow((v - 1.3480) / 0.0700, 2));
d += 0.0014 * Math.exp(-Math.pow(u / 0.0300, 2)) * smoothstep(1.330, 1.180, v);
d -= 0.0026 * smoothstep(1.235, 1.120, v);
d -= 0.0014 * smoothstep(1.360, 1.430, v);
return d;
}
function plateSurf(extra = 0.0) {
const base = coreFrontSurf(0.0);
const xe = chestEdgeCurve();
return (u, v) => {
const [p, n] = base(u, v);
const r = Math.min(1.0, Math.abs(u) / Math.max(1e-5, xe.at(v)));
const lift = PLATE_LIFT * (0.34 + 0.66 * (1.0 - Math.pow(r, 2.6)));
const d = Math.max(0.0024, lift + plateDisp(u, v));
return [p.add(n.mul(d + extra)), n];
};
}
function buildChestPlate() {
const xe = chestEdgeCurve();
const surf = plateSurf();
const z0 = CHEST_Z0, z1 = CHEST_Z1;
const pts = [];
const N = 66;
for (let i = 0; i <= N; i++) { const v = z1 - (z1 - z0) * (i / N); pts.push([xe.at(v), v]); }
const b = xe.at(z0);
for (let i = 1; i < 15; i++) {
const f = i / 15.0;
pts.push([b * Math.cos(Math.PI * f), z0 - 0.0022 * Math.pow(Math.sin(Math.PI * f), 0.7)]);
}
for (let i = 0; i <= N; i++) { const v = z0 + (z1 - z0) * (i / N); pts.push([-xe.at(v), v]); }
const t = xe.at(z1);
for (let i = 1; i < 13; i++) {
const f = i / 13.0;
pts.push([-t * Math.cos(Math.PI * f), z1 + 0.0016 * Math.pow(Math.sin(Math.PI * f), 0.7)]);
}
const outline = polyOutline(pts, 240, 1);
const ob = pillow(outline, surf, {
t_front: 0.0040, t_back: 0.0250, layers: 20, name: 'chest_plate',
cname: 'TORSO', rim: 0.145, centre: [0.0, 1.278]
});
finish(ob, { bevel: 0.0008, bseg: 2, bangle: 32 });
setmat(ob, 'M_SHELL');
return ob;
}
function buildStrap(side = 1) {
const xe = chestEdgeCurve();
const z1 = 1.4510, z0 = 1.3380;
const d = 3e-4;
const st = [];
const NR = 52;
for (let i = 0; i <= NR; i++) {
const z = z1 - (z1 - z0) * (i / NR);
const t = (z - z0) / (z1 - z0);
let w = 0.0180 * Math.pow(t, 0.38);
w *= 1.0 - 0.52 * smoothstep(0.88, 1.0, t);
const thick = 0.0052 * (0.32 + 0.68 * Math.pow(t, 0.32));
const xin = xe.at(z) + 0.0016;
const xout = Math.min(C_AX.at(z) - 0.0024, xin + 2 * w);
const xc = 0.5 * (xin + xout);
w = Math.max(0.0008, 0.5 * (xout - xin));
const fx = (coreFrontY(xc + d, z) - coreFrontY(xc - d, z)) / (2 * d);
const L = Math.hypot(1.0, fx);
const nx = fx / L, ny = -1.0 / L;
const yc = coreFrontY(xc, z);
const lift = 0.0034;
st.push({ t: z, ax: w * L, by: thick,
ox: side * (xc + nx * lift), oy: yc + ny * lift,
rot: side * Math.atan(fx), e: 2.6 });
}
const ob = loft(st, { n: 30, rings: 110, name: `strap_${side > 0 ? 'L' : 'R'}`,
cname: 'TORSO', cap0: true, cap1: true,
tip0: 0.0028, tip1: 0.0045, mode: 'spline', even: 0.75 });
finish(ob, { bevel: 0.0005, bseg: 2, bangle: 34 });
setmat(ob, 'M_BLACK');
return ob;
}
/* ---- TESLA wordmark ----------------------------------------------------- */
function letterQuads(ch, w, h, b) {
const cx = w * 0.5;
if (ch === 'T') return [
[[0, h], [w, h], [w, h - b], [0, h - b]],
[[cx - b / 2, h - b], [cx + b / 2, h - b], [cx + b / 2, 0], [cx - b / 2, 0]]];
if (ch === 'E') return [
[[0, h], [w, h], [w, h - b], [0, h - b]],
[[0, h / 2 + b / 2], [w * 0.86, h / 2 + b / 2], [w * 0.86, h / 2 - b / 2], [0, h / 2 - b / 2]],
[[0, b], [w, b], [w, 0], [0, 0]]];
if (ch === 'S') return [
[[0, h], [w, h], [w, h - b], [0, h - b]],
[[0, h - b], [b, h - b], [b, h / 2 + b / 2], [0, h / 2 + b / 2]],
[[0, h / 2 + b / 2], [w, h / 2 + b / 2], [w, h / 2 - b / 2], [0, h / 2 - b / 2]],
[[w - b, h / 2 - b / 2], [w, h / 2 - b / 2], [w, b], [w - b, b]],
[[0, b], [w, b], [w, 0], [0, 0]]];
if (ch === 'L') return [
[[0, h], [b, h], [b, 0], [0, 0]],
[[b, b], [w, b], [w, 0], [b, 0]]];
if (ch === 'A') {
const f = w * 0.30;
return [
[[0, 0], [b, 0], [cx - f / 2 + b, h], [cx - f / 2, h]],
[[w - b, 0], [w, 0], [cx + f / 2, h], [cx + f / 2 - b, h]],
[[cx - f / 2, h], [cx + f / 2, h], [cx + f / 2 - b * 0.55, h - b], [cx - f / 2 + b * 0.55, h - b]]];
}
return [];
}
function buildWordmark(z = 1.3555, height = 0.0150, track = 0.0180, depth = 0.0011) {
const surf = plateSurf(0.0040);
const text = 'TESLA';
const lw = { T: 0.0132, E: 0.0113, S: 0.0115, L: 0.0104, A: 0.0136 };
const b = 0.00235;
const total = [...text].reduce((s, c) => s + lw[c], 0) + track * (text.length - 1);
let x0 = -total / 2.0;
const parts = [];
for (let ci = 0; ci < text.length; ci++) {
const ch = text[ci];
const w = lw[ch];
const polys = letterQuads(ch, w, height, b);
for (let pi = 0; pi < polys.length; pi++) {
const poly = polys[pi];
const n = poly.length;
const verts = [];
for (const lift of [0.0, depth]) {
for (const [px, pz] of poly) {
const ux = x0 + px;
const uz = z + pz - height / 2.0;
const [p, nn] = surf(ux, uz);
verts.push(p.add(nn.mul(lift + 0.0003)).toArray());
}
}
const faces = [];
for (let j = 0; j < n; j++) { const j2 = (j + 1) % n; faces.push([j, j2, n + j2, n + j]); }
const top = []; for (let i = n; i < 2 * n; i++) top.push(i); faces.push(top);
const bot = []; for (let i = n - 1; i >= 0; i--) bot.push(i); faces.push(bot);
parts.push(meshObj(`wm_${ci}_${pi}`, verts, faces, 'TORSO'));
}
x0 += w + track;
}
const ob = joinMeshes(parts, 'wordmark', 'TORSO');
weld(ob, 1e-5);
shade(ob, false);
setmat(ob, 'M_LOGO');
return ob;
}
function buildTorso() {
collClear('TORSO');
coreCurves();
buildTorsoCore();
buildChestPlate();
buildBackPanel();
buildBackPiece();
buildStrap(1);
buildStrap(-1);
buildWordmark();
}
/* ==========================================================================
7. head -- head shell: glossy face plate + matte rear hood,
cyan edge LED.
========================================================================== */
/* (z, half-width, back Y, front depth, e_back, e_front) */
const HEAD = [
[1.4880, 0.0560, 0.0920, 0.0300, 2.70, 2.90],
[1.4950, 0.0546, 0.0930, 0.0470, 2.64, 2.84],
[1.5020, 0.0530, 0.0942, 0.0628, 2.58, 2.76],
[1.5090, 0.0520, 0.0946, 0.0748, 2.52, 2.68],
[1.5160, 0.0516, 0.0902, 0.0842, 2.47, 2.60],
[1.5230, 0.0522, 0.0866, 0.0884, 2.43, 2.54],
[1.5300, 0.0536, 0.0842, 0.0908, 2.40, 2.49],
[1.5395, 0.0580, 0.0830, 0.0942, 2.37, 2.45],
[1.5475, 0.0614, 0.0834, 0.0968, 2.36, 2.43],
[1.5555, 0.0638, 0.0846, 0.0990, 2.35, 2.42],
[1.5635, 0.0658, 0.0868, 0.1008, 2.34, 2.41],
[1.5715, 0.0680, 0.0886, 0.1024, 2.34, 2.40],
[1.5795, 0.0702, 0.0908, 0.1038, 2.33, 2.39],
[1.5875, 0.0722, 0.0930, 0.1050, 2.33, 2.38],
[1.5955, 0.0738, 0.0964, 0.1056, 2.33, 2.38],
[1.6030, 0.0750, 0.1002, 0.1058, 2.33, 2.38],
[1.6110, 0.0766, 0.1030, 0.1058, 2.33, 2.38],
[1.6190, 0.0776, 0.1048, 0.1053, 2.34, 2.39],
[1.6270, 0.0784, 0.1064, 0.1042, 2.35, 2.40],
[1.6350, 0.0792, 0.1071, 0.1020, 2.36, 2.41],
[1.6430, 0.0796, 0.1071, 0.1004, 2.37, 2.42],
[1.6510, 0.0796, 0.1068, 0.0982, 2.38, 2.44],
[1.6590, 0.0792, 0.1060, 0.0964, 2.40, 2.46],
[1.6665, 0.0783, 0.1042, 0.0932, 2.42, 2.48],
[1.6745, 0.0772, 0.1016, 0.0896, 2.44, 2.50],
[1.6825, 0.0748, 0.0974, 0.0850, 2.46, 2.52],
[1.6905, 0.0702, 0.0920, 0.0798, 2.48, 2.55],
[1.6985, 0.0643, 0.0870, 0.0720, 2.51, 2.58],
[1.7065, 0.0572, 0.0798, 0.0645, 2.54, 2.62],
[1.7145, 0.0480, 0.0692, 0.0512, 2.58, 2.67],
[1.7215, 0.0374, 0.0578, 0.0384, 2.63, 2.73],
[1.7265, 0.0250, 0.0450, 0.0250, 2.70, 2.82],
[1.7295, 0.0110, 0.0290, 0.0110, 2.80, 2.95],
];
const _HZ = HEAD.map(h => h[0]);
let H_AX = null, H_BY = null, H_BY2 = null, H_EB = null, H_EF = null;
/* least-squares polynomial in normalised z, evaluated as sqrt(P(z)) */
class HPoly {
constructor(xs, ys, deg = 9, w = null) {
this.a = xs[0]; this.b = xs[xs.length - 1];
const t = xs.map(x => this._t(x));
const d = ys.map(y => y * y);
this.c = polyfit(t, d, deg, w);
}
_t(x) { return (2.0 * (x - this.a) / (this.b - this.a)) - 1.0; }
at(x) {
const v = polyval(this.c, this._t(x));
return v > 0.0 ? Math.sqrt(v) : 0.0;
}
}
function fairPoly(xs, ys, deg = 9) {
const w = new Array(xs.length).fill(1.0);
for (let i = 0; i < 3; i++) w[i] = 3.0; /* hold the jaw roll-under */
for (let i = xs.length - 6; i < xs.length; i++) w[i] = 3.0; /* and the crown */
return new HPoly(xs, ys, deg, w);
}
function fairLin(xs, ys, deg = 5) {
const p = polyfit(xs, ys, deg, null);
return { at: x => polyval(p, x) };
}
function headCurves() {
H_AX = fairPoly(_HZ, HEAD.map(h => h[1]));
H_BY = fairPoly(_HZ, HEAD.map(h => h[2]));
H_BY2 = fairPoly(_HZ, HEAD.map(h => h[3]));
H_EB = fairLin(_HZ, HEAD.map(h => h[4]));
H_EF = fairLin(_HZ, HEAD.map(h => h[5]));
}
const CROWN_Z0 = 1.6960, CROWN_Z1 = 1.7160;
function headOy(z) {
const k = smoothstep(CROWN_Z0, CROWN_Z1, z);
return k * 0.5 * (H_BY.at(z) - H_BY2.at(z));
}
function headSection(z) {
const oy = headOy(z);
return [H_AX.at(z), H_BY.at(z) - oy, H_BY2.at(z) + oy, oy];
}
/* u in [-2,2] : 0 = front meridian, +/-1 = widest point, +/-2 = back */
function headPt(u, z) {
const th = -Math.PI / 2 + u * Math.PI / 2;
const [axv, byv, by2v, oy] = headSection(z);
const ax = Math.max(1e-6, axv);
const e = Math.max(2.02, Math.sin(th) < 0 ? H_EF.at(z) : H_EB.at(z));
const p = 2.0 / e;
const c = Math.cos(th), s = Math.sin(th);
const x = ax * copysignPow(c, p, c);
const b = s >= 0 ? byv : by2v;
const y = oy + b * copysignPow(s, p, s);
return vec(x, y, z);
}
function headSurf(offset = 0.0, uscale = 0.085) {
const du = 4e-4, dv = 4e-4;
const raw = (a, v) => headPt(a / uscale, v);
return (a, v) => {
const pu = raw(a + du, v).sub(raw(a - du, v));
const pv = raw(a, v + dv).sub(raw(a, v - dv));
let n = pu.cross(pv);
if (n.length < 1e-12) n = vec(0, -1, 0);
n = n.normalized();
return [raw(a, v).add(n.mul(offset)), n];
};
}
/* exact inverse of headPt for a point lying on the shell */
function uOfPoint(p) {
const z = p[2];
const [axv, byv, by2v, oy] = headSection(z);
const ax = Math.max(1e-6, axv);
const dy = p[1] - oy;
const back = dy >= 0.0;
const b = Math.max(1e-6, back ? byv : by2v);
const e = Math.max(2.02, back ? H_EB.at(z) : H_EF.at(z));
const cx = Math.pow(Math.min(1.0, Math.abs(p[0]) / ax), e / 2.0);
const cy = Math.pow(Math.min(1.0, Math.abs(dy) / b), e / 2.0);
const th = Math.atan2((back ? 1.0 : -1.0) * cy, cx);
return 2.0 * th / Math.PI + 1.0;
}
const SEAM_UZ = [
[0.000, 1.5090], [0.100, 1.5098], [0.200, 1.5120], [0.300, 1.5158],
[0.400, 1.5208], [0.500, 1.5264], [0.600, 1.5324], [0.700, 1.5390],
[0.750, 1.5427], [0.800, 1.5480], [0.830, 1.5536], [0.860, 1.5620],
[0.880, 1.5714], [0.900, 1.5806], [0.920, 1.5920], [0.940, 1.6046],
[0.955, 1.6162], [0.970, 1.6318], [0.985, 1.6486], [1.000, 1.6630],
[1.020, 1.6702], [1.045, 1.6762], [1.075, 1.6822], [1.115, 1.6880],
[1.170, 1.6944], [1.240, 1.7013], [1.330, 1.7072], [1.450, 1.7124],
[1.600, 1.7166], [1.760, 1.7196], [1.900, 1.7212], [2.000, 1.7218],
];
const SEAM_UMAX = 2.000;
const seamCurve = () => new Curve1D(SEAM_UZ.map(p => p[0]), SEAM_UZ.map(p => p[1]), 'pchip');
const Z_APEX = 1.7320;
const CROWN_START = 1.7180;
function buildHeadShell() {
headCurves();
const z0 = HEAD[0][0];
const st = [];
const N = 96;
for (let i = 0; i <= N; i++) {
const z = z0 + (CROWN_START - z0) * (i / N);
const [ax, by, by2, oy] = headSection(z);
st.push({ t: z, ax, by, by2, oy, e: H_EB.at(z), e_dn: H_EF.at(z) });
}
const [axc, byc, by2c, oyc] = headSection(CROWN_START);
const halfc = 0.5 * (byc + by2c);
const h = Z_APEX - CROWN_START;
const eb = H_EB.at(CROWN_START), ef = H_EF.at(CROWN_START);
const M = 26;
for (let j = 1; j <= M; j++) {
const s = 1.0 - j / M;
const z = CROWN_START + h * (1.0 - s * s);
st.push({ t: z, ax: Math.max(2e-5, axc * s), by: Math.max(2e-5, halfc * s),
by2: Math.max(2e-5, halfc * s), oy: oyc, e: eb, e_dn: ef });
}
const NB = 250;
const ringT = [];
for (let i = 0; i <= NB; i++) ringT.push(z0 + (CROWN_START - z0) * (i / NB));
for (let j = 1; j <= M * 2; j++) {
const s = 1.0 - j / (M * 2.0);
ringT.push(CROWN_START + h * (1.0 - s * s));
}
const ob = loft(st, { n: 112, ring_t: ringT, name: 'head_shell', cname: 'HEAD',
cap0: true, cap1: true, tip0: 0.0060, tip1: 0.00012,
mode: 'spline', even: 0.58 });
finish(ob, { bevel: 0.0007, bseg: 2, bangle: 44 });
setmat(ob, 'M_HELMET');
return ob;
}
/* paint the glossy face plate onto the shell -- everything ABOVE the seam */
function assignVisorFaces(ob) {
const cz = seamCurve();
const idx = ob.mats.length;
ob.mats.push('M_VISOR');
for (let fi = 0; fi < ob.f.length; fi++) {
const c = ob.faceCentre(fi);
const u = Math.min(SEAM_UMAX, Math.max(0.0, uOfPoint(c)));
if (c[2] > cz.at(u)) ob.fm[fi] = idx;
}
return ob;
}
function buildVisorLed() {
const cz = seamCurve();
const surf = headSurf(0.0007);
const uscale = 0.085;
const N = 420;
const UM = SEAM_UMAX - 0.004;
const pts = [];
for (let i = 0; i <= N; i++) {
const u = -UM + 2.0 * UM * (i / N);
const z = cz.at(Math.abs(u));
pts.push(surf(u * uscale, z)[0]);
}
const st = [];
for (let i = 0; i <= N; i++) st.push({ t: i / N, ax: 0.00145, by: 0.00105, e: 2.6 });
const ob = loftSpine(pts, st, { n: 14, name: 'visor_led', cname: 'HEAD',
up: vec(0, 0, 1), cap0: true, cap1: true,
tip0: 0.0006, tip1: 0.0006, rings: N + 1 });
finish(ob, { bevel: 0 });
setmat(ob, 'M_LED');
return ob;
}
function buildHead() {
collClear('HEAD');
headCurves();
const sh = buildHeadShell();
assignVisorFaces(sh);
buildVisorLed();
}
/* ==========================================================================
8. arms -- shoulder cap, upper arm, elbow housing + ribbed pad,
open-frame forearm with exposed actuator, wrist block.
========================================================================== */
const ARM_C = [
[1.4400, 0.1755, 0.0470], [1.4000, 0.1900, 0.0510], [1.3600, 0.2055, 0.0555],
[1.3200, 0.2140, 0.0600], [1.2700, 0.2195, 0.0655], [1.2200, 0.2240, 0.0705],
[1.1700, 0.2278, 0.0748], [1.1300, 0.2302, 0.0770], [1.0800, 0.2332, 0.0742],
[1.0300, 0.2372, 0.0662], [0.9800, 0.2420, 0.0546], [0.9300, 0.2470, 0.0404],
[0.8900, 0.2496, 0.0288], [0.8500, 0.2466, 0.0140],
];
let AX_C = null, AY_C = null;
function armCurves() {
const zs = ARM_C.map(a => a[0]);
AX_C = new Curve1D(zs, ARM_C.map(a => a[1]), 'spline');
AY_C = new Curve1D(zs, ARM_C.map(a => a[2]), 'spline');
}
const _st = (z, ax, by, by2 = null, e = 2.6, e_dn = null, sx = 1) => ({
t: z, ax, by, by2: (by2 === null ? by : by2),
e, e_dn: (e_dn === null ? e : e_dn),
ox: sx * AX_C.at(z), oy: AY_C.at(z)
});
function buildShoulderCap(sx = 1) {
const rows = [
[1.4425, 0.0098, 0.0195, 0.0150], [1.4380, 0.0180, 0.0320, 0.0245],
[1.4310, 0.0310, 0.0470, 0.0370], [1.4220, 0.0432, 0.0600, 0.0482],
[1.4110, 0.0510, 0.0668, 0.0552], [1.3980, 0.0572, 0.0712, 0.0604],
[1.3800, 0.0590, 0.0726, 0.0626], [1.3600, 0.0588, 0.0718, 0.0624],
[1.3400, 0.0568, 0.0692, 0.0606], [1.3200, 0.0548, 0.0656, 0.0576],
[1.3020, 0.0524, 0.0616, 0.0544], [1.2870, 0.0500, 0.0578, 0.0512],
[1.2760, 0.0478, 0.0546, 0.0486],
];
const st = rows.map(([z, ax, by, by2]) => {
const t = smoothstep(1.2760, 1.3600, z);
return { t: z, ax, by, by2, e: 2.55, e_dn: 2.75,
ox: sx * (AX_C.at(z) + 0.0026 * t), oy: AY_C.at(z) };
});
const ob = loft(st, { n: 64, rings: 96, name: `shoulder_cap${sx}`, cname: 'ARM',
cap0: false, cap1: true, tip1: 0.0090, mode: 'spline', even: 0.66 });
finish(ob, { bevel: 0.0011, bseg: 3, bangle: 34 });
setmat(ob, 'M_SHELL');
return ob;
}
function buildUpperarm(sx = 1) {
const rows = [
[1.4180, 0.0480, 0.0620, 0.0505], [1.4000, 0.0518, 0.0662, 0.0556],
[1.3700, 0.0538, 0.0682, 0.0588], [1.3400, 0.0532, 0.0664, 0.0578],
[1.3100, 0.0516, 0.0630, 0.0552], [1.2800, 0.0500, 0.0596, 0.0524],
[1.2500, 0.0486, 0.0566, 0.0500], [1.2200, 0.0472, 0.0540, 0.0478],
[1.1990, 0.0464, 0.0524, 0.0464], [1.1930, 0.0458, 0.0516, 0.0456],
];
const st = rows.map(([z, ax, by, by2]) => _st(z, ax, by, by2, 2.55, 2.75, sx));
const ob = loft(st, { n: 64, rings: 92, name: `upperarm${sx}`, cname: 'ARM',
cap0: false, cap1: false, mode: 'spline', even: 0.66 });
finish(ob, { bevel: 0.0010, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
return ob;
}
function buildArmRing(sx = 1, z = 1.1930, h = 0.0042, out = 0.0012) {
const rows = [[z + h, 0.0455, 0.0512, 0.0452], [z, 0.0452, 0.0508, 0.0449],
[z - h, 0.0455, 0.0512, 0.0452]];
const st = rows.map(([zz, ax, by, by2]) => _st(zz, ax - out, by - out, by2 - out, 2.55, 2.75, sx));
const ob = loft(st, { n: 64, rings: 14, name: `armring${sx}`, cname: 'ARM',
cap0: false, cap1: false, mode: 'pchip', even: 0.66 });
finish(ob, { bevel: 0.0004, bseg: 1, bangle: 40 });
setmat(ob, 'M_DARKGREY');
return ob;
}
const ELBOW_ROWS = [
[1.1900, 0.0455, 0.0512, 0.0452], [1.1700, 0.0452, 0.0508, 0.0446],
[1.1400, 0.0446, 0.0500, 0.0434], [1.1100, 0.0438, 0.0486, 0.0418],
[1.0800, 0.0426, 0.0466, 0.0400], [1.0500, 0.0410, 0.0442, 0.0378],
[1.0250, 0.0392, 0.0418, 0.0356], [1.0050, 0.0374, 0.0396, 0.0336],
[0.9930, 0.0360, 0.0380, 0.0322],
];
const EZ = ELBOW_ROWS.map(r => r[0]);
let E_AX = null, E_BY = null, E_BY2 = null;
function elbowCurves() {
E_AX = new Curve1D(EZ, ELBOW_ROWS.map(r => r[1]), 'spline');
E_BY = new Curve1D(EZ, ELBOW_ROWS.map(r => r[2]), 'spline');
E_BY2 = new Curve1D(EZ, ELBOW_ROWS.map(r => r[3]), 'spline');
}
function buildElbowHousing(sx = 1) {
const st = ELBOW_ROWS.map(([z, ax, by, by2]) => _st(z, ax, by, by2, 2.5, 2.7, sx));
const ob = loft(st, { n: 64, rings: 92, name: `elbow${sx}`, cname: 'ARM',
cap0: false, cap1: false, mode: 'spline', even: 0.66 });
finish(ob, { bevel: 0.0009, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
return ob;
}
/* (a,v) on the elbow housing. u=0 -> outboard (+X side), u=+/-1 -> +/-Y */
function elbowSurf(sx = 1, offset = 0.0, uscale = 0.048) {
const du = 4e-4, dv = 4e-4;
const raw = (a, v) => {
const u = a / uscale;
const th = u * Math.PI / 2.0;
const ax = Math.max(1e-6, E_AX.at(v));
const s = Math.sin(th);
const by = s >= 0 ? E_BY.at(v) : E_BY2.at(v);
const e = s >= 0 ? 2.5 : 2.7;
const p = 2.0 / e;
const c = Math.cos(th);
const x = ax * copysignPow(c, p, c);
const y = by * copysignPow(s, p, s);
return vec(sx * (AX_C.at(v) + x), AY_C.at(v) + y, v);
};
return (a, v) => {
const pu = raw(a + du, v).sub(raw(a - du, v));
const pv = raw(a, v + dv).sub(raw(a, v - dv));
let n = sx > 0 ? pu.cross(pv) : pv.cross(pu);
if (n.length < 1e-12) n = vec(sx, 0, 0);
n = n.normalized();
return [raw(a, v).add(n.mul(offset)), n];
};
}
/* (z, half-extent in u) 1.0 = 90 deg of the section */
const PAD = [
[1.1745, 0.00], [1.1715, 0.230], [1.1670, 0.400], [1.1595, 0.545],
[1.1480, 0.650], [1.1320, 0.722], [1.1100, 0.768], [1.0850, 0.784],
[1.0580, 0.778], [1.0340, 0.744], [1.0170, 0.676], [1.0065, 0.568],
[1.0000, 0.412], [0.9975, 0.212], [0.9968, 0.00],
];
function buildElbowPad(sx = 1) {
const zs = PAD.map(p => p[0]);
const cu = new Curve1D(zs, PAD.map(p => p[1]), 'spline');
const uscale = 0.048;
const ctr = -1.00;
const pts = [];
const N = 60;
const z1 = PAD[0][0], z0 = PAD[PAD.length - 1][0];
for (let i = 0; i <= N; i++) { const v = z1 - (z1 - z0) * (i / N); pts.push([(ctr + cu.at(v)) * uscale, v]); }
for (let i = 0; i <= N; i++) { const v = z0 + (z1 - z0) * (i / N); pts.push([(ctr - cu.at(v)) * uscale, v]); }
const outline = polyOutline(pts, 150, 1);
const disp = (u, v) => {
const f = Math.cos(2 * Math.PI * (v - 1.0870) / 0.0208);
const env = smoothstep(0.9990, 1.0140, v) * smoothstep(1.1760, 1.1580, v);
return 0.00135 * f * env;
};
const surf = elbowSurf(sx, 0.0011, uscale);
const ob = pillow(outline, surf, {
t_front: 0.0030, t_back: 0.0120, layers: 42, name: `elbowpad${sx}`,
cname: 'ARM', rim: 0.24, centre: [ctr * uscale, 1.0860], disp
});
finish(ob, { bevel: 0.0005, bseg: 2, bangle: 44 });
setmat(ob, 'M_RUBBER');
return ob;
}
function buildArmSeam(sx = 1, z = 1.3060, depth = 0.0016) {
const rows = [[z + 0.0030, 0.0524, 0.0620, 0.0548],
[z, 0.0512, 0.0606, 0.0536],
[z - 0.0030, 0.0524, 0.0620, 0.0548]];
const st = rows.map(([zz, ax, by, by2]) => ({
t: zz, ax: ax - depth, by: by - depth, by2: by2 - depth,
e: 2.55, e_dn: 2.75, ox: sx * (AX_C.at(zz) + 0.0010), oy: AY_C.at(zz)
}));
const ob = loft(st, { n: 56, rings: 12, name: `armseam${sx}`, cname: 'ARM',
cap0: false, cap1: false, mode: 'pchip', even: 0.66 });
finish(ob, { bevel: 0.0004, bseg: 1, bangle: 44 });
setmat(ob, 'M_DARKGREY');
return ob;
}
const FA_ROWS = [
[0.9960, 0.0362, 0.0382, 0.0324], [0.9800, 0.0356, 0.0372, 0.0314],
[0.9600, 0.0348, 0.0358, 0.0300], [0.9400, 0.0340, 0.0342, 0.0286],
[0.9200, 0.0332, 0.0326, 0.0272], [0.9000, 0.0324, 0.0310, 0.0258],
[0.8850, 0.0318, 0.0298, 0.0248],
];
function buildForearmFrame(sx = 1) {
const zs = FA_ROWS.map(r => r[0]);
const fax = new Curve1D(zs, FA_ROWS.map(r => r[1]), 'spline');
const fby = new Curve1D(zs, FA_ROWS.map(r => r[2]), 'spline');
const fby2 = new Curve1D(zs, FA_ROWS.map(r => r[3]), 'spline');
for (const [rail, sgn] of [['out', 1], ['in', -1]]) {
const st = [];
for (const z of [0.9975, 0.9880, 0.9700, 0.9500, 0.9300, 0.9100, 0.8940, 0.8860]) {
const a = fax.at(z);
st.push({ t: z, ax: 0.0090, by: (fby.at(z) + fby2.at(z)) * 0.50,
by2: (fby.at(z) + fby2.at(z)) * 0.50, e: 2.7, e_dn: 2.7,
ox: sx * (AX_C.at(z) + sgn * (a - 0.0086)),
oy: AY_C.at(z) + (fby.at(z) - fby2.at(z)) * 0.5 });
}
const ob = loft(st, { n: 40, rings: 54, name: `fa_rail_${rail}${sx}`, cname: 'ARM',
cap0: true, cap1: true, tip0: 0.006, tip1: 0.006,
mode: 'spline', even: 0.7 });
finish(ob, { bevel: 0.0008, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
}
const st = [];
for (const z of [0.9975, 0.9850, 0.9650, 0.9450, 0.9250, 0.9060, 0.8930, 0.8865]) {
const a = fax.at(z);
st.push({ t: z, ax: a - 0.0022, by: 0.0058, by2: 0.0058, e: 4.0,
ox: sx * AX_C.at(z), oy: AY_C.at(z) + fby.at(z) - 0.0050 });
}
const ob = loft(st, { n: 44, rings: 48, name: `fa_web${sx}`, cname: 'ARM',
cap0: true, cap1: true, tip0: 0.004, tip1: 0.004, mode: 'spline' });
finish(ob, { bevel: 0.0007, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
}
function buildForearmActuator(sx = 1) {
const st = [];
for (const [z, r] of [[0.9930, 0.0138], [0.9880, 0.0176], [0.9750, 0.0192],
[0.9450, 0.0195], [0.9150, 0.0192], [0.9000, 0.0180],
[0.8940, 0.0150]]) {
st.push({ t: z, ax: r, by: r * 1.02, e: 2.3,
ox: sx * (AX_C.at(z) + 0.0016), oy: AY_C.at(z) + 0.0038 });
}
const ob = loft(st, { n: 44, rings: 44, name: `fa_act${sx}`, cname: 'ARM',
cap0: true, cap1: true, tip0: 0.004, tip1: 0.004, mode: 'spline' });
finish(ob, { bevel: 0.0007, bseg: 2, bangle: 36 });
setmat(ob, 'M_DARKMECH');
return ob;
}
function buildWrist(sx = 1) {
const st = [];
for (const [z, ax, by, by2] of [[0.8900, 0.0300, 0.0288, 0.0244],
[0.8850, 0.0292, 0.0281, 0.0238],
[0.8820, 0.0284, 0.0274, 0.0232],
[0.8790, 0.0268, 0.0260, 0.0222]]) {
st.push(_st(z, ax, by, by2, 3.0, 3.2, sx));
}
const ob = loft(st, { n: 48, rings: 30, name: `wrist${sx}`, cname: 'ARM',
cap0: false, cap1: true, tip1: 0.0062, mode: 'spline' });
finish(ob, { bevel: 0.0008, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
return ob;
}
function buildArm(sx = 1) {
armCurves();
elbowCurves();
buildShoulderCap(sx);
buildUpperarm(sx);
buildArmSeam(sx);
buildArmRing(sx);
buildElbowHousing(sx);
buildElbowPad(sx);
buildForearmFrame(sx);
buildForearmActuator(sx);
buildWrist(sx);
}
function buildArms() {
collClear('ARM');
buildArm(1);
buildArm(-1);
}
/* ==========================================================================
9. hands -- five-finger hand: white structural frame, black back plate,
white phalanges with dark joint bands.
========================================================================== */
const HAND_YAW = 58.0;
const WRIST = [0.2474, 0.0161, 0.8600];
/* the forearm leans forward ~17 deg by the wrist; derived from ARM_C itself */
function wristTilt() {
const z0 = 0.9500, z1 = 0.8600;
if (AX_C === null) armCurves();
const dy = AY_C.at(z1) - AY_C.at(z0);
const dz = z1 - z0;
return Math.atan2(dy, -dz) * 180 / Math.PI;
}
function place(ob, sx) {
xform(ob, { rot: [0, 0, sx * HAND_YAW] });
xform(ob, { rot: [wristTilt(), 0, 0] });
xform(ob, { loc: [sx * WRIST[0], WRIST[1], WRIST[2]] });
return ob;
}
function buildWristCuff(sx = 1, cname = 'HAND') {
const out = [];
let st = [{ t: -0.0010, ax: 0.0296, by: 0.0220, e: 3.2 },
{ t: -0.0075, ax: 0.0300, by: 0.0224, e: 3.2 },
{ t: -0.0155, ax: 0.0296, by: 0.0218, e: 3.2 },
{ t: -0.0205, ax: 0.0286, by: 0.0208, e: 3.2 }];
const ob = loft(st, { n: 48, rings: 20, name: `wrist_cuff${sx}`, cname,
cap0: true, cap1: false, tip0: 0.0050, mode: 'spline' });
finish(ob, { bevel: 0.0006, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
out.push(ob);
st = [{ t: 0.0175, ax: 0.0132, by: 0.0146, e: 2.8 },
{ t: 0.0120, ax: 0.0150, by: 0.0165, e: 2.8 },
{ t: 0.0040, ax: 0.0154, by: 0.0168, e: 2.8 },
{ t: -0.0020, ax: 0.0146, by: 0.0160, e: 2.8 }];
const cv = loft(st, { n: 36, rings: 18, name: `wrist_clevis${sx}`, cname,
cap0: true, cap1: false, tip0: 0.0040, mode: 'spline' });
finish(cv, { bevel: 0.0007, bseg: 2, bangle: 32 });
setmat(cv, 'M_DARKMECH');
out.push(cv);
st = [{ t: -0.0250, ax: 0.0070, by: 0.0070, e: 2.1 },
{ t: 0.0250, ax: 0.0070, by: 0.0070, e: 2.1 }];
const pin = loft(st, { n: 28, rings: 8, name: `wristpin${sx}`, cname,
cap0: true, cap1: true, tip0: 0.0018, tip1: 0.0018, axis: 'x' });
xform(pin, { loc: [0.0, 0.0, 0.0090] });
finish(pin, { bevel: 0.0004, bseg: 2, bangle: 40 });
setmat(pin, 'M_ALU');
out.push(pin);
const bx = extrudeOutline(rrectOutline(0.0090, 0.0086, 0.0022, 44),
-0.0245, -0.0210, { name: `cuff_boss${sx}`, cname });
xform(bx, { rot: [90, 0, 0] });
xform(bx, { loc: [0.0, 0.0, -0.0112] });
finish(bx, { bevel: 0.0006, bseg: 2, bangle: 34 });
setmat(bx, 'M_SHELL');
out.push(bx);
return out.map(o => place(o, sx));
}
function buildPalm(sx = 1, cname = 'HAND') {
const rows = [
[-0.0210, 0.0300, 0.0196, 0.0186], [-0.0300, 0.0336, 0.0206, 0.0196],
[-0.0420, 0.0356, 0.0208, 0.0198], [-0.0560, 0.0368, 0.0202, 0.0192],
[-0.0700, 0.0372, 0.0192, 0.0180], [-0.0810, 0.0368, 0.0180, 0.0168],
[-0.0880, 0.0358, 0.0168, 0.0156],
];
const st = rows.map(([z, ax, by, by2]) => ({ t: z, ax, by, by2, e: 3.0, e_dn: 3.2 }));
const ob = loft(st, { n: 56, rings: 54, name: `palm${sx}`, cname,
cap0: false, cap1: true, tip1: 0.0075, mode: 'spline', even: 0.7 });
finish(ob, { bevel: 0.0009, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL');
return place(ob, sx);
}
function buildHandBack(sx = 1, cname = 'HAND') {
const zs = [-0.0210, -0.0300, -0.0420, -0.0560, -0.0700, -0.0810, -0.0880];
const axs = [0.0292, 0.0316, 0.0334, 0.0340, 0.0338, 0.0332, 0.0322];
const bys = [0.0186, 0.0196, 0.0198, 0.0192, 0.0180, 0.0168, 0.0156];
const cax = new Curve1D(zs, axs, 'spline');
const cby = new Curve1D(zs, bys, 'spline');
const uscale = 0.030;
const raw = (a, v) => {
const u = a / uscale;
const th = -Math.PI / 2 + u * Math.PI / 2;
const ax = Math.max(1e-6, cax.at(v));
const by = cby.at(v);
const p = 2.0 / 3.2;
const c = Math.cos(th), s = Math.sin(th);
return vec(ax * copysignPow(c, p, c), by * copysignPow(s, p, s), v);
};
const surf = (a, v) => {
const pu = raw(a + 3e-4, v).sub(raw(a - 3e-4, v));
const pv = raw(a, v + 3e-4).sub(raw(a, v - 3e-4));
let n = pu.cross(pv);
if (n.length < 1e-12) n = vec(0, -1, 0);
n = n.normalized();
return [raw(a, v).add(n.mul(0.0009)), n];
};
const ol = [[-0.0225, 0.62], [-0.0270, 0.80], [-0.0340, 0.90], [-0.0450, 0.95],
[-0.0580, 0.95], [-0.0700, 0.92], [-0.0800, 0.86], [-0.0862, 0.74],
[-0.0892, 0.52], [-0.0902, 0.00]];
const cu = new Curve1D(ol.map(o => o[0]), ol.map(o => o[1]), 'spline');
const pts = [];
const N = 42;
const z1 = -0.0225, z0 = -0.0902;
for (let i = 0; i <= N; i++) { const v = z1 + (z0 - z1) * (i / N); pts.push([cu.at(v) * uscale, v]); }
for (let i = 0; i <= N; i++) { const v = z0 + (z1 - z0) * (i / N); pts.push([-cu.at(v) * uscale, v]); }
const outline = polyOutline(pts, 124, 1);
const ob = pillow(outline, surf, {
t_front: 0.0022, t_back: 0.0090, layers: 11, name: `handback${sx}`,
cname, rim: 0.30, centre: [0.0, -0.0540]
});
finish(ob, { bevel: 0.0006, bseg: 2, bangle: 30 });
setmat(ob, 'M_BLACK');
return place(ob, sx);
}
/* (x, y, z_knuckle, length, radius, splay, curl) */
const FINGERS = [
[-0.0330, -0.0010, -0.0830, 0.1020, 0.0086, 13.0, 5.0],
[-0.0110, -0.0014, -0.0890, 0.1120, 0.0088, 4.0, 6.0],
[0.0110, -0.0014, -0.0872, 0.1045, 0.0085, -4.5, 6.5],
[0.0330, -0.0010, -0.0798, 0.0880, 0.0078, -14.0, 7.0],
];
function buildFinger(sx, idx, cname = 'HAND') {
const [x, y, z, L, r, splay, curl] = FINGERS[idx];
const segs = [[0.44, 1.00, 0.94], [0.33, 0.94, 0.88], [0.23, 0.88, 0.74]];
const objs = [];
let pos = vec(0, 0, 0);
let ang = 0.0;
const stack = [pos.clone()];
for (const [f] of segs) {
ang += R(curl);
pos = pos.add(vec(0, Math.sin(ang), -Math.cos(ang)).mul(L * f));
stack.push(pos.clone());
}
for (let si = 0; si < segs.length; si++) {
const [, r0, r1] = segs[si];
const a = stack[si], b = stack[si + 1];
const n = b.sub(a).normalized();
const rr0 = r * r0, rr1 = r * r1;
const sp = [a.add(n.mul(0.0006)), a.mul(0.75).add(b.mul(0.25)),
a.mul(0.4).add(b.mul(0.6)), b.sub(n.mul(0.0010))];
const st = [{ t: 0.0, ax: rr0 * 0.94, by: rr0 * 0.78, e: 3.4 },
{ t: 0.34, ax: rr0, by: rr0 * 0.82, e: 3.4 },
{ t: 0.72, ax: (rr0 + rr1) * 0.5, by: (rr0 + rr1) * 0.41, e: 3.4 },
{ t: 1.0, ax: rr1 * 0.96, by: rr1 * 0.80, e: 3.4 }];
const ob = loftSpine(sp, st, { n: 26, name: `fseg${idx}_${si}_${sx}`, cname,
up: vec(1, 0, 0), cap0: true, cap1: true,
tip0: rr0 * 0.55, tip1: rr1 * (si === 2 ? 0.90 : 0.55),
rings: 18 });
finish(ob, { bevel: 0.0005, bseg: 2, bangle: 34 });
setmat(ob, si < 2 ? 'M_SHELL' : 'M_BLACK');
objs.push(ob);
if (si < 2) {
const c = b;
const nn = stack[si + 2].sub(a).normalized();
const jb = loftSpine([c.sub(nn.mul(0.0050)), c.add(nn.mul(0.0050))],
[{ t: 0.0, ax: rr1 * 0.90, by: rr1 * 0.78, e: 3.0 },
{ t: 1.0, ax: rr1 * 0.90, by: rr1 * 0.78, e: 3.0 }],
{ n: 24, name: `fjnt${idx}_${si}_${sx}`, cname,
up: vec(1, 0, 0), cap0: true, cap1: true,
tip0: 0.0012, tip1: 0.0012, rings: 8 });
finish(jb, { bevel: 0.0004, bseg: 1, bangle: 40 });
setmat(jb, 'M_DARKGREY');
objs.push(jb);
}
}
for (const ob of objs) {
xform(ob, { rot: [0, splay, 0] });
xform(ob, { loc: [x, y, z] });
place(ob, sx);
}
return objs;
}
function buildThumb(sx = 1, cname = 'HAND') {
const objs = [];
const L = 0.0700, r = 0.0094;
const segs = [[0.52, 1.00, 0.92], [0.48, 0.92, 0.76]];
let pos = vec(0, 0, 0);
let ang = 0.0;
const stack = [pos.clone()];
for (const [f] of segs) {
ang += R(20.0);
pos = pos.add(vec(0, Math.sin(ang), -Math.cos(ang)).mul(L * f));
stack.push(pos.clone());
}
for (let si = 0; si < segs.length; si++) {
const [, r0, r1] = segs[si];
const a = stack[si], b = stack[si + 1];
const rr0 = r * r0, rr1 = r * r1;
const st = [{ t: 0.0, ax: rr0 * 0.92, by: rr0 * 0.82, e: 3.0 },
{ t: 0.5, ax: (rr0 + rr1) * 0.5, by: (rr0 + rr1) * 0.45, e: 3.0 },
{ t: 1.0, ax: rr1 * 0.95, by: rr1 * 0.84, e: 3.0 }];
const ob = loftSpine([a, a.mul(0.5).add(b.mul(0.5)), b], st,
{ n: 26, name: `thumb${si}_${sx}`, cname,
up: vec(1, 0, 0), cap0: true, cap1: true,
tip0: rr0 * 0.55, tip1: rr1 * (si ? 0.95 : 0.55), rings: 16 });
finish(ob, { bevel: 0.0005, bseg: 2, bangle: 34 });
setmat(ob, si === 0 ? 'M_SHELL' : 'M_BLACK');
objs.push(ob);
}
for (const ob of objs) {
xform(ob, { rot: [0, 34, 0] });
xform(ob, { rot: [-26, 0, 0] });
xform(ob, { loc: [-0.0372, 0.0086, -0.0410] });
place(ob, sx);
}
return objs;
}
function buildHand(sx = 1) {
buildWristCuff(sx);
buildPalm(sx);
buildHandBack(sx);
for (let i = 0; i < 4; i++) buildFinger(sx, i);
buildThumb(sx);
}
function buildHands() {
collClear('HAND');
buildHand(1);
/* left hand = true mirror of the right so the thumb sits correctly */
for (const ob of COLL.HAND.slice()) {
const m = mirrorDup(ob, ob.name + '_M', 'x');
m.coll = 'HAND';
COLL.HAND.push(m);
}
}
/* ==========================================================================
10. hips -- exposed pelvis / hip actuator assembly.
========================================================================== */
const HIPX = 0.1120;
const HIPY = 0.0300;
const POD_Z = 0.8990;
const POD_Y = 0.0470;
function boltRing(cx, cy, cz, r, n, br = 0.0028, h = 0.0024, cname = 'HIP',
mat = 'M_DARKMECH', tag = 'b', phase = 0.0) {
const objs = [];
for (let i = 0; i < n; i++) {
const a = 2 * Math.PI * (i / n) + phase;
const st = [{ t: 0.0, ax: br, by: br, e: 2.2 },
{ t: h, ax: br * 0.84, by: br * 0.84, e: 2.2 }];
const ob = loft(st, { n: 12, rings: 4, name: `${tag}${i}`, cname,
cap0: false, cap1: true, tip1: br * 0.4 });
xform(ob, { loc: [cx + r * Math.cos(a), cy + r * Math.sin(a), cz] });
finish(ob, { bevel: 0.0003, bseg: 1, bangle: 40 });
setmat(ob, mat);
objs.push(ob);
}
return objs;
}
function buildHipCentre() {
const objs = [];
let st = [{ t: 1.0060, ax: 0.0400, by: 0.0470, by2: 0.0300, e: 2.6, oy: HIPY },
{ t: 0.9950, ax: 0.0452, by: 0.0520, by2: 0.0330, e: 2.8, oy: HIPY },
{ t: 0.9820, ax: 0.0478, by: 0.0552, by2: 0.0348, e: 3.0, oy: HIPY },
{ t: 0.9650, ax: 0.0482, by: 0.0558, by2: 0.0352, e: 3.1, oy: HIPY },
{ t: 0.9480, ax: 0.0470, by: 0.0542, by2: 0.0342, e: 3.0, oy: HIPY },
{ t: 0.9360, ax: 0.0432, by: 0.0500, by2: 0.0316, e: 2.9, oy: HIPY }];
const ob = loft(st, { n: 56, rings: 40, name: 'hip_centre', cname: 'HIP',
cap0: true, cap1: true, tip0: 0.0090, tip1: 0.0075, mode: 'spline' });
finish(ob, { bevel: 0.0012, bseg: 2, bangle: 32 });
setmat(ob, 'M_DARKMECH');
objs.push(ob);
for (const [z, r] of [[0.9985, 0.0462], [0.9900, 0.0478], [0.9810, 0.0482]]) {
st = [{ t: z - 0.0026, ax: r, by: r * 1.16, by2: r * 0.72, e: 3.0, oy: HIPY },
{ t: z, ax: r + 0.0026, by: (r + 0.0026) * 1.16, by2: (r + 0.0026) * 0.72, e: 3.0, oy: HIPY },
{ t: z + 0.0026, ax: r, by: r * 1.16, by2: r * 0.72, e: 3.0, oy: HIPY }];
const rg = loft(st, { n: 56, rings: 10, name: `hipring${Math.trunc(z * 1e4)}`,
cname: 'HIP', cap0: false, cap1: false, mode: 'pchip' });
finish(rg, { bevel: 0.0004, bseg: 1, bangle: 40 });
setmat(rg, 'M_DARKMECH');
objs.push(rg);
}
const fp = extrudeOutline(rrectOutline(0.0420, 0.0300, 0.0058, 60),
-0.0075, -0.0018, { name: 'hip_plate', cname: 'HIP' });
xform(fp, { rot: [90, 0, 0] });
xform(fp, { loc: [0.0, -0.0026, 0.9740] });
finish(fp, { bevel: 0.0009, bseg: 2, bangle: 34 });
setmat(fp, 'M_DARKMECH');
objs.push(fp);
objs.push(...boltRing(0.0, -0.0060, 0.9740 + 0.0088, 0.0, 1, 0.0040, 0.0022,
'HIP', 'M_DARKMECH', 'hipctrbolt'));
return objs;
}
function buildHipDrum(sx = 1) {
const objs = [];
const cx = sx * HIPX;
const rows = [[-0.0560, 0.0300, 0.0330], [-0.0470, 0.0400, 0.0442],
[-0.0330, 0.0466, 0.0530], [-0.0140, 0.0492, 0.0578],
[0.0060, 0.0496, 0.0592], [0.0250, 0.0486, 0.0576],
[0.0400, 0.0450, 0.0524], [0.0510, 0.0378, 0.0430],
[0.0560, 0.0300, 0.0330]];
let st = rows.map(([dx, hz, dy]) => ({
t: cx + sx * dx, ax: hz, by: dy, by2: dy * 0.86, e: 3.3, e_dn: 3.5, oy: POD_Y
}));
const ob = loft(st, { n: 64, rings: 56, name: `hipdrum${sx}`, cname: 'HIP',
cap0: true, cap1: true, tip0: 0.0075, tip1: 0.0075,
mode: 'spline', axis: 'x' });
xform(ob, { loc: [0.0, 0.0, POD_Z] });
finish(ob, { bevel: 0.0016, bseg: 3, bangle: 30 });
setmat(ob, 'M_DARKMECH');
const ol = rrectOutline(0.0430, 0.0490, 0.0090, 60);
const cut = extrudeOutline(ol, -0.0090, 0.0110, { name: `poddcut${sx}`, cname: 'HIP', taper0: 0.78 });
xform(cut, { rot: [90, 0, 0] });
xform(cut, { loc: [cx, POD_Y + 0.0600, POD_Z] });
boolean(ob, cut);
collRemove('HIP', cut);
shade(ob, true, 32);
addBevel(ob, 0.0010, 2, 30);
objs.push(ob);
st = [{ t: cx + sx * 0.0500, ax: 0.0250, by: 0.0250, e: 2.05, oy: POD_Y },
{ t: cx + sx * 0.0620, ax: 0.0250, by: 0.0250, e: 2.05, oy: POD_Y },
{ t: cx + sx * 0.0655, ax: 0.0212, by: 0.0212, e: 2.05, oy: POD_Y }];
const bs = loft(st, { n: 48, rings: 14, name: `hipboss${sx}`, cname: 'HIP',
cap0: false, cap1: true, tip1: 0.0045, mode: 'pchip', axis: 'x' });
xform(bs, { loc: [0.0, 0.0, POD_Z] });
finish(bs, { bevel: 0.0008, bseg: 2, bangle: 34 });
setmat(bs, 'M_ALU');
objs.push(bs);
for (let i = 0; i < 8; i++) {
const a = 2 * Math.PI * i / 8 + 0.2;
const stb = [{ t: 0.0, ax: 0.0030, by: 0.0030, e: 2.2 },
{ t: 0.0024, ax: 0.0024, by: 0.0024, e: 2.2 }];
const bo = loft(stb, { n: 12, rings: 4, name: `hipbb${sx}${i}`, cname: 'HIP',
cap0: false, cap1: true, tip1: 0.0010, axis: 'x' });
if (sx < 0) xform(bo, { rot: [0, 0, 180] });
xform(bo, { loc: [cx + sx * 0.0618,
POD_Y + 0.0198 * Math.sin(a),
POD_Z + 0.0198 * Math.cos(a)] });
finish(bo, { bevel: 0.0003, bseg: 1, bangle: 40 });
setmat(bo, 'M_DARKGREY');
objs.push(bo);
}
return objs;
}
function buildHipBackbox() {
const objs = [];
const rows = [[0.9850, 0.0400, 0.0790], [0.9720, 0.0452, 0.1000],
[0.9560, 0.0505, 0.1230], [0.9380, 0.0546, 0.1410],
[0.9160, 0.0570, 0.1510], [0.8940, 0.0574, 0.1540],
[0.8720, 0.0552, 0.1490], [0.8560, 0.0498, 0.1370],
[0.8460, 0.0410, 0.1170]];
const st = rows.map(([z, hw, by]) => ({ t: z, ax: hw, by, by2: 0.0250, e: 3.9, e_dn: 3.2 }));
const ob = loft(st, { n: 56, rings: 64, name: 'hip_sacrum', cname: 'HIP',
cap0: true, cap1: true, tip0: 0.0080, tip1: 0.0080, mode: 'spline' });
finish(ob, { bevel: 0.0018, bseg: 3, bangle: 30 });
setmat(ob, 'M_DARKMECH');
objs.push(ob);
const ol = rrectOutline(0.0330, 0.0760, 0.0100, 64);
const cut = extrudeOutline(ol, -0.0080, 0.0100, { name: 'sacrum_cut', cname: 'HIP', taper0: 0.78 });
xform(cut, { rot: [90, 0, 0] });
xform(cut, { loc: [0.0, 0.1580, 0.9080] });
boolean(ob, cut);
collRemove('HIP', cut);
shade(ob, true, 32);
addBevel(ob, 0.0010, 2, 30);
return objs;
}
function buildHips() {
collClear('HIP');
buildHipCentre();
buildHipBackbox();
for (const sx of [1, -1]) buildHipDrum(sx);
}
/* ==========================================================================
11. legs -- thigh (dark core + white shell), knee cap, shin, ankle
actuators and push-rods.
========================================================================== */
const LEGX = 0.1170;
/* (z, half-width, back Y, front depth, e_back, e_front, x-offset, y-centre) */
const LEG = [
[0.8780, 0.0512, 0.1400, 0.0250, 3.1, 3.2, -0.0060, 0.0000],
[0.8700, 0.0560, 0.1440, 0.0330, 3.1, 3.2, -0.0050, 0.0000],
[0.8500, 0.0596, 0.1490, 0.0480, 3.1, 3.2, -0.0030, 0.0000],
[0.8200, 0.0620, 0.1490, 0.0600, 3.1, 3.2, -0.0010, 0.0000],
[0.7900, 0.0628, 0.1400, 0.0678, 3.1, 3.2, 0.0000, 0.0000],
[0.7600, 0.0630, 0.1300, 0.0728, 3.0, 3.2, 0.0005, 0.0000],
[0.7300, 0.0632, 0.1245, 0.0768, 3.0, 3.2, 0.0010, 0.0000],
[0.7000, 0.0632, 0.1180, 0.0796, 3.0, 3.2, 0.0010, 0.0000],
[0.6700, 0.0602, 0.1060, 0.0802, 3.0, 3.2, 0.0010, 0.0000],
[0.6400, 0.0578, 0.0920, 0.0790, 3.0, 3.2, 0.0010, 0.0000],
[0.6100, 0.0552, 0.0830, 0.0818, 3.0, 3.2, 0.0005, 0.0000],
[0.5800, 0.0540, 0.0712, 0.0866, 3.0, 3.2, 0.0000, 0.0000],
[0.5500, 0.0538, 0.0600, 0.0898, 3.0, 3.2, -0.0005, 0.0000],
[0.5250, 0.0538, 0.0520, 0.0918, 3.0, 3.2, -0.0010, 0.0000],
[0.5050, 0.0532, 0.0478, 0.0932, 3.0, 3.2, -0.0015, 0.0000],
];
const LZ = LEG.map(r => r[0]);
let L_AX = null, L_BY = null, L_BY2 = null, L_OX = null;
function legCurves() {
L_AX = new Curve1D(LZ, LEG.map(r => r[1]), 'spline');
L_BY = new Curve1D(LZ, LEG.map(r => r[2]), 'spline');
L_BY2 = new Curve1D(LZ, LEG.map(r => r[3]), 'spline');
L_OX = new Curve1D(LZ, LEG.map(r => r[6]), 'spline');
}
const _lst = (z, sx, shrink = 0.0) => ({
t: z, ax: L_AX.at(z) - shrink, by: L_BY.at(z) - shrink,
by2: L_BY2.at(z) - shrink, e: 3.0, e_dn: 3.2, ox: sx * (LEGX + L_OX.at(z))
});
function buildThighCore(sx = 1) {
const st = LEG.map(r => _lst(r[0], sx, 0.0032));
const ob = loft(st, { n: 72, rings: 120, name: `thigh_core${sx}`, cname: 'LEG',
cap0: false, cap1: false, mode: 'spline', even: 0.66 });
finish(ob, { bevel: 0.0009, bseg: 2, bangle: 36 });
setmat(ob, 'M_BLACK');
return ob;
}
/* u = 0 -> outboard (+X for sx=+1); u=+1 -> back(+Y); u=-1 -> front(-Y) */
function legSurf(sx = 1, offset = 0.0, uscale = 0.062, shrink = 0.0) {
const du = 4e-4, dv = 4e-4;
const raw = (a, v) => {
const u = a / uscale;
const th = u * Math.PI / 2.0;
const ax = Math.max(1e-6, L_AX.at(v) - shrink);
const s = Math.sin(th);
const by = (s >= 0 ? L_BY.at(v) : L_BY2.at(v)) - shrink;
const e = s >= 0 ? 3.0 : 3.2;
const p = 2.0 / e;
const c = Math.cos(th);
const x = ax * copysignPow(c, p, c);
const y = by * copysignPow(s, p, s);
return vec(sx * (LEGX + L_OX.at(v) + x), y, v);
};
return (a, v) => {
const pu = raw(a + du, v).sub(raw(a - du, v));
const pv = raw(a, v + dv).sub(raw(a, v - dv));
let n = sx > 0 ? pu.cross(pv) : pv.cross(pu);
if (n.length < 1e-12) n = vec(sx, 0, 0);
n = n.normalized();
return [raw(a, v).add(n.mul(offset)), n];
};
}
/* outline of the white thigh shell in (u, z): (z, u_front, u_back) */
const THIGH_SHELL = [
[0.8735, -0.11, 0.05], [0.8690, -0.30, 0.16], [0.8600, -0.58, 0.38],
[0.8470, -0.90, 0.62], [0.8280, -1.18, 0.72], [0.8020, -1.36, 0.82],
[0.7700, -1.50, 0.90], [0.7300, -1.58, 0.96], [0.6900, -1.61, 0.99],
[0.6500, -1.61, 0.99], [0.6100, -1.58, 0.96], [0.5850, -1.53, 0.90],
[0.5650, -1.45, 0.81], [0.5520, -1.33, 0.68], [0.5440, -1.14, 0.48],
[0.5395, -0.88, 0.27], [0.5375, -0.56, 0.00],
];
function buildThighShell(sx = 1) {
const zs = THIGH_SHELL.map(t => t[0]);
const cf = new Curve1D(zs, THIGH_SHELL.map(t => t[1]), 'spline');
const cb = new Curve1D(zs, THIGH_SHELL.map(t => t[2]), 'spline');
const us = 0.062;
const z1 = THIGH_SHELL[0][0], z0 = THIGH_SHELL[THIGH_SHELL.length - 1][0];
const pts = [];
const N = 66;
for (let i = 0; i <= N; i++) { const v = z1 - (z1 - z0) * (i / N); pts.push([cb.at(v) * us, v]); }
for (let i = 0; i <= N; i++) { const v = z0 + (z1 - z0) * (i / N); pts.push([cf.at(v) * us, v]); }
const outline = polyOutline(pts, 200, 1);
const surf = legSurf(sx, 0.0016, us);
const ob = pillow(outline, surf, {
t_front: 0.0032, t_back: 0.0180, layers: 16, name: `thigh_shell${sx}`,
cname: 'LEG', rim: 0.20, centre: [-0.18 * us, 0.7000]
});
finish(ob, { bevel: 0.0010, bseg: 2, bangle: 30 });
setmat(ob, 'M_SHELL_LEG');
return ob;
}
const SHIN = [
[0.5250, 0.0542, 0.0420, 0.0880, 3.0, 3.0, 0.0000],
[0.5050, 0.0545, 0.0405, 0.0905, 3.0, 3.0, 0.0000],
[0.4850, 0.0530, 0.0470, 0.0910, 3.0, 3.0, 0.0000],
[0.4600, 0.0508, 0.0400, 0.0900, 3.0, 3.0, 0.0000],
[0.4300, 0.0358, 0.0262, 0.0850, 3.0, 3.0, 0.0000],
[0.4000, 0.0340, 0.0250, 0.0790, 3.0, 3.0, 0.0000],
[0.3600, 0.0338, 0.0300, 0.0700, 3.0, 3.0, 0.0000],
[0.3200, 0.0348, 0.0348, 0.0620, 3.0, 3.0, 0.0000],
[0.2800, 0.0322, 0.0400, 0.0560, 3.0, 3.0, 0.0000],
[0.2400, 0.0296, 0.0450, 0.0500, 3.0, 3.0, 0.0000],
[0.2000, 0.0272, 0.0480, 0.0430, 3.0, 3.0, 0.0000],
[0.1700, 0.0256, 0.0470, 0.0360, 3.0, 3.0, 0.0000],
[0.1400, 0.0242, 0.0420, 0.0300, 3.0, 3.0, 0.0000],
[0.1150, 0.0234, 0.0340, 0.0250, 3.0, 3.0, 0.0000],
[0.0980, 0.0230, 0.0270, 0.0220, 3.0, 3.0, 0.0000],
];
const SZ = SHIN.map(r => r[0]);
let S_AX = null, S_BY = null, S_BY2 = null;
function shinCurves() {
S_AX = new Curve1D(SZ, SHIN.map(r => r[1]), 'spline');
S_BY = new Curve1D(SZ, SHIN.map(r => r[2]), 'spline');
S_BY2 = new Curve1D(SZ, SHIN.map(r => r[3]), 'spline');
}
function buildShin(sx = 1) {
const st = SHIN.map(r => ({ t: r[0], ax: r[1], by: r[2], by2: r[3],
e: 3.0, e_dn: 3.1, ox: sx * LEGX }));
const ob = loft(st, { n: 64, rings: 130, name: `shin${sx}`, cname: 'LEG',
cap0: false, cap1: false, mode: 'spline', even: 0.66 });
finish(ob, { bevel: 0.0009, bseg: 2, bangle: 34 });
setmat(ob, 'M_SHELL_LEG');
return ob;
}
function buildKneeCap(sx = 1) {
const rows = [[0.5320, 0.0090, 0.0140], [0.5250, 0.0200, 0.0250],
[0.5150, 0.0296, 0.0330], [0.5000, 0.0356, 0.0378],
[0.4820, 0.0372, 0.0392], [0.4640, 0.0356, 0.0378],
[0.4480, 0.0316, 0.0344], [0.4350, 0.0256, 0.0292],
[0.4270, 0.0170, 0.0215], [0.4230, 0.0080, 0.0120]];
const st = rows.map(([z, w, h]) => ({
t: z, ax: w, by: 0.0130, by2: h * 0.30 + 0.0140, e: 2.6, e_dn: 2.6,
ox: sx * LEGX, oy: -(S_BY2 ? S_BY2.at(z) : 0.088) + 0.0140
}));
const ob = loft(st, { n: 48, rings: 54, name: `kneecap${sx}`, cname: 'LEG',
cap0: true, cap1: true, tip0: 0.0035, tip1: 0.0030, mode: 'spline' });
finish(ob, { bevel: 0.0010, bseg: 2, bangle: 32 });
setmat(ob, 'M_SHELL_LEG');
return ob;
}
function buildKneeGap(sx = 1) {
const st = [{ t: 0.5480, ax: 0.0530, by: 0.0420, by2: 0.0850, e: 3.0, ox: sx * LEGX },
{ t: 0.5350, ax: 0.0525, by: 0.0420, by2: 0.0870, e: 3.0, ox: sx * LEGX },
{ t: 0.5200, ax: 0.0510, by: 0.0415, by2: 0.0885, e: 3.0, ox: sx * LEGX },
{ t: 0.5050, ax: 0.0495, by: 0.0408, by2: 0.0890, e: 3.0, ox: sx * LEGX }];
const ob = loft(st, { n: 56, rings: 24, name: `kneejoint${sx}`, cname: 'LEG',
cap0: false, cap1: false, mode: 'spline' });
finish(ob, { bevel: 0.0008, bseg: 2, bangle: 36 });
setmat(ob, 'M_BLACK');
return ob;
}
function buildAnkleActuator(sx = 1, side = 1) {
const objs = [];
const zk = [0.4460, 0.3900, 0.3730, 0.2280, 0.2000, 0.1740, 0.0960];
const xk = [0.0430, 0.0402, 0.0400, 0.0400, 0.0356, 0.0290, 0.0150];
const XO = new Curve1D(zk, xk, 'spline');
const YO = z => 0.5 * (S_BY.at(z) - S_BY2.at(z)) - 0.0050;
const ox = z => sx * (LEGX + side * XO.at(z));
let sp = [[ox(0.4460), YO(0.4460), 0.4460], [ox(0.4220), YO(0.4220), 0.4220],
[ox(0.3980), YO(0.3980), 0.3980], [ox(0.3800), YO(0.3800), 0.3800]];
let rod = tube(sp, [0.0044, 0.0042, 0.0041, 0.0042],
{ n: 16, name: `ankrodU${sx}_${side}`, cname: 'LEG' });
finish(rod, { bevel: 0.0004, bseg: 2, bangle: 36 });
setmat(rod, 'M_ALU');
objs.push(rod);
const cl = extrudeOutline(rrectOutline(0.0130, 0.0175, 0.0038, 44),
0.0, 0.0088, { name: `ankclv${sx}_${side}`, cname: 'LEG' });
xform(cl, { rot: [0, 90, 0] });
xform(cl, { loc: [ox(0.4480) - side * sx * 0.0044, YO(0.4480), 0.4480] });
finish(cl, { bevel: 0.0008, bseg: 2, bangle: 32 });
setmat(cl, 'M_DARKMECH');
objs.push(cl);
const zs = [0.3730, 0.3480, 0.3000, 0.2520, 0.2270];
const spine = zs.map(z => [ox(z), YO(z), z]);
let st = [{ t: 0.00, ax: 0.0080, by: 0.0196, e: 3.2 },
{ t: 0.10, ax: 0.0102, by: 0.0250, e: 3.2 },
{ t: 0.44, ax: 0.0102, by: 0.0252, e: 3.2 },
{ t: 0.82, ax: 0.0098, by: 0.0240, e: 3.2 },
{ t: 1.00, ax: 0.0078, by: 0.0192, e: 3.2 }];
const ob = loftSpine(spine, st, { n: 40, name: `ankact${sx}_${side}`, cname: 'LEG',
up: vec(1, 0, 0), cap0: true, cap1: true,
tip0: 0.0060, tip1: 0.0050, rings: 56 });
finish(ob, { bevel: 0.0009, bseg: 2, bangle: 32 });
setmat(ob, 'M_DARKMECH');
objs.push(ob);
const zc = [0.3790, 0.3700, 0.3610];
st = [{ t: 0.0, ax: 0.0078, by: 0.0196, e: 3.0 },
{ t: 0.5, ax: 0.0112, by: 0.0264, e: 3.0 },
{ t: 1.0, ax: 0.0104, by: 0.0252, e: 3.0 }];
const cap = loftSpine(zc.map(z => [ox(z), YO(z), z]), st,
{ n: 36, name: `ankcap${sx}_${side}`, cname: 'LEG',
up: vec(1, 0, 0), cap0: true, cap1: false,
tip0: 0.0040, rings: 14 });
finish(cap, { bevel: 0.0006, bseg: 2, bangle: 34 });
setmat(cap, 'M_DARKMECH');
objs.push(cap);
sp = [[ox(0.2290), YO(0.2290), 0.2290], [ox(0.2060), YO(0.2060), 0.2060],
[ox(0.1840), YO(0.1840), 0.1840], [ox(0.1680), YO(0.1680), 0.1680]];
rod = tube(sp, [0.0038, 0.0037, 0.0036, 0.0036],
{ n: 16, name: `ankrodL${sx}_${side}`, cname: 'LEG' });
finish(rod, { bevel: 0.0004, bseg: 2, bangle: 36 });
setmat(rod, 'M_ALU');
objs.push(rod);
sp = [[ox(0.1700), YO(0.1700), 0.1700], [ox(0.1420), YO(0.1420), 0.1420],
[ox(0.1160), YO(0.1160), 0.1160], [ox(0.0960), YO(0.0960), 0.0960]];
const lk = tube(sp, [0.0040, 0.0038, 0.0038, 0.0042],
{ n: 16, name: `anklink${sx}_${side}`, cname: 'LEG' });
finish(lk, { bevel: 0.0004, bseg: 2, bangle: 36 });
setmat(lk, 'M_DARKMECH');
objs.push(lk);
return objs;
}
function buildAnkle(sx = 1) {
const objs = [];
let st = [{ t: 0.1080, ax: 0.0238, by: 0.0300, by2: 0.0250, e: 3.0, ox: sx * LEGX },
{ t: 0.0950, ax: 0.0250, by: 0.0320, by2: 0.0268, e: 3.0, ox: sx * LEGX },
{ t: 0.0840, ax: 0.0252, by: 0.0322, by2: 0.0270, e: 3.0, ox: sx * LEGX },
{ t: 0.0740, ax: 0.0238, by: 0.0300, by2: 0.0252, e: 3.0, ox: sx * LEGX }];
const ob = loft(st, { n: 44, rings: 20, name: `ankle${sx}`, cname: 'LEG',
cap0: false, cap1: true, tip1: 0.0060, mode: 'spline' });
finish(ob, { bevel: 0.0008, bseg: 2, bangle: 34 });
setmat(ob, 'M_DARKMECH');
objs.push(ob);
st = [{ t: -0.034, ax: 0.0080, by: 0.0080, e: 2.1 },
{ t: 0.034, ax: 0.0080, by: 0.0080, e: 2.1 }];
const pin = loft(st, { n: 24, rings: 6, name: `anklepin${sx}`, cname: 'LEG',
cap0: true, cap1: true, tip0: 0.0018, tip1: 0.0018, axis: 'x' });
xform(pin, { loc: [sx * LEGX, 0.0030, 0.0870] });
finish(pin, { bevel: 0.0004, bseg: 2, bangle: 40 });
setmat(pin, 'M_ALU');
objs.push(pin);
return objs;
}
function buildLeg(sx = 1) {
legCurves();
shinCurves();
buildThighCore(sx);
buildThighShell(sx);
buildKneeGap(sx);
buildShin(sx);
buildKneeCap(sx);
buildAnkleActuator(sx, 1);
buildAnkleActuator(sx, -1);
buildAnkle(sx);
}
function buildLegs() {
collClear('LEG');
buildLeg(1);
buildLeg(-1);
}
/* ==========================================================================
12. feet -- moulded charcoal boot: upper, sole lip, ankle socket.
========================================================================== */
/* (y, half-width, top z) -- y negative = toward the toe */
const FOOT = [
[0.0665, 0.0270, 0.0330], [0.0600, 0.0348, 0.0500], [0.0470, 0.0410, 0.0660],
[0.0290, 0.0452, 0.0762], [0.0080, 0.0466, 0.0812], [-0.0180, 0.0478, 0.0824],
[-0.0450, 0.0482, 0.0786], [-0.0720, 0.0478, 0.0706], [-0.0980, 0.0466, 0.0602],
[-0.1230, 0.0458, 0.0512], [-0.1440, 0.0442, 0.0430], [-0.1600, 0.0412, 0.0374],
[-0.1710, 0.0364, 0.0338], [-0.1782, 0.0296, 0.0310],
];
function buildFoot(sx = 1) {
let st = FOOT.map(([y, w, h]) => ({ t: -y, ax: w, by: h * 0.72, by2: h * 0.30,
e: 2.15, e_dn: 3.8, oy: h * 0.28 }));
const ob = loft(st, { n: 60, rings: 120, name: `foot${sx}`, cname: 'FOOT',
cap0: true, cap1: true, tip0: 0.0130, tip1: 0.0088,
mode: 'spline', even: 0.66 });
xform(ob, { rot: [90, 0, 0] });
xform(ob, { loc: [sx * LEGX, 0.0, 0.0] });
finish(ob, { bevel: 0.0018, bseg: 3, bangle: 30 });
setmat(ob, 'M_FOOT');
st = FOOT.map(([y, w]) => ({ t: -y, ax: w + 0.0014, by: 0.0050, by2: 0.0050,
e: 3.6, e_dn: 3.2, oy: 0.0050 }));
const lip = loft(st, { n: 60, rings: 120, name: `sole${sx}`, cname: 'FOOT',
cap0: true, cap1: true, tip0: 0.0060, tip1: 0.0042,
mode: 'spline', even: 0.66 });
xform(lip, { rot: [90, 0, 0] });
xform(lip, { loc: [sx * LEGX, 0.0, 0.0] });
finish(lip, { bevel: 0.0012, bseg: 3, bangle: 30 });
setmat(lip, 'M_DARKMECH');
st = [{ t: 0.0930, ax: 0.0262, by: 0.0316, by2: 0.0286, e: 2.6, oy: 0.0060 },
{ t: 0.0860, ax: 0.0258, by: 0.0308, by2: 0.0278, e: 2.6, oy: 0.0060 },
{ t: 0.0750, ax: 0.0246, by: 0.0292, by2: 0.0264, e: 2.6, oy: 0.0060 },
{ t: 0.0650, ax: 0.0224, by: 0.0266, by2: 0.0240, e: 2.6, oy: 0.0060 }];
for (const s of st) s.ox = sx * LEGX;
const sock = loft(st, { n: 44, rings: 20, name: `socket${sx}`, cname: 'FOOT',
cap0: false, cap1: true, tip1: 0.0050, mode: 'spline' });
finish(sock, { bevel: 0.0008, bseg: 2, bangle: 34 });
setmat(sock, 'M_DARKGREY');
}
function buildFeet() {
collClear('FOOT');
buildFoot(1);
buildFoot(-1);
}
/* ==========================================================================
13. procedural PBR materials -- MeshPhysicalNodeMaterial with lookup3
gradient noise, derivative-filtered fBm, mapped roughness, and bump.
The signed noise uses a lookup3 hash, quintic fade, a sixteen-way
gradient selector, and x0.9820 amplitude scaling. The integer lattice is
offset by +8192 before hashing because WGSL leaves a negative i32 -> u32
conversion indeterminate. The offset translates the field without
changing its statistics.
========================================================================== */
const LATTICE_OFF = 8192.0;
const rotl32 = (x, k) => x.shiftLeft(uint(k)).bitOr(x.shiftRight(uint(32 - k)));
/* Jenkins lookup3 final() integer-lattice hash */
const bhash3 = /*#__PURE__*/ Fn(([kx, ky, kz]) => {
const seed = uint(3735928584); /* 0xdeadbeef + (3 << 2) + 13 */
const a = seed.toVar(), b = seed.toVar(), c = seed.toVar();
c.addAssign(kz); b.addAssign(ky); a.addAssign(kx);
c.assign(c.bitXor(b)); c.subAssign(rotl32(b, 14));
a.assign(a.bitXor(c)); a.subAssign(rotl32(c, 11));
b.assign(b.bitXor(a)); b.subAssign(rotl32(a, 25));
c.assign(c.bitXor(b)); c.subAssign(rotl32(b, 16));
a.assign(a.bitXor(c)); a.subAssign(rotl32(c, 4));
b.assign(b.bitXor(a)); b.subAssign(rotl32(a, 14));
c.assign(c.bitXor(b)); c.subAssign(rotl32(b, 24));
return c;
}).setLayout({
name: 'bhash3', type: 'uint',
inputs: [{ name: 'kx', type: 'uint' }, { name: 'ky', type: 'uint' }, { name: 'kz', type: 'uint' }]
});
/* signed gradient selector */
const bgrad = /*#__PURE__*/ Fn(([h32, x, y, z]) => {
const h = h32.bitAnd(uint(15)).toVar();
const u = select(h.lessThan(uint(8)), x, y);
const vt = select(h.equal(uint(12)).or(h.equal(uint(14))), x, z);
const v = select(h.lessThan(uint(4)), y, vt);
const su = select(h.bitAnd(uint(1)).notEqual(uint(0)), u.negate(), u);
const sv = select(h.bitAnd(uint(2)).notEqual(uint(0)), v.negate(), v);
return su.add(sv);
}).setLayout({
name: 'bgrad', type: 'float',
inputs: [{ name: 'h32', type: 'uint' }, { name: 'x', type: 'float' },
{ name: 'y', type: 'float' }, { name: 'z', type: 'float' }]
});
const bfade = t => t.mul(t).mul(t).mul(t.mul(t.mul(6.0).sub(15.0)).add(10.0));
/* signed Perlin field: [-1, 1] */
const bperlin = /*#__PURE__*/ Fn(([p]) => {
const X = floor(p.x).toVar(), Y = floor(p.y).toVar(), Z = floor(p.z).toVar();
const fx = p.x.sub(X).toVar(), fy = p.y.sub(Y).toVar(), fz = p.z.sub(Z).toVar();
const xi = uint(X.add(LATTICE_OFF)).toVar();
const yi = uint(Y.add(LATTICE_OFF)).toVar();
const zi = uint(Z.add(LATTICE_OFF)).toVar();
const x1 = xi.add(uint(1)), y1 = yi.add(uint(1)), z1 = zi.add(uint(1));
const u = bfade(fx), v = bfade(fy), w = bfade(fz);
const fx1 = fx.sub(1.0), fy1 = fy.sub(1.0), fz1 = fz.sub(1.0);
const g000 = bgrad(bhash3(xi, yi, zi), fx, fy, fz);
const g100 = bgrad(bhash3(x1, yi, zi), fx1, fy, fz);
const g010 = bgrad(bhash3(xi, y1, zi), fx, fy1, fz);
const g110 = bgrad(bhash3(x1, y1, zi), fx1, fy1, fz);
const g001 = bgrad(bhash3(xi, yi, z1), fx, fy, fz1);
const g101 = bgrad(bhash3(x1, yi, z1), fx1, fy, fz1);
const g011 = bgrad(bhash3(xi, y1, z1), fx, fy1, fz1);
const g111 = bgrad(bhash3(x1, y1, z1), fx1, fy1, fz1);
const x00 = mix(g000, g100, u), x10 = mix(g010, g110, u);
const x01 = mix(g001, g101, u), x11 = mix(g011, g111, u);
return mix(mix(x00, x10, v), mix(x01, x11, v), w).mul(0.9820);
}).setLayout({
name: 'bperlin', type: 'float', inputs: [{ name: 'p', type: 'vec3' }]
});
/* Normalized fBM maps to [0, 1]. `detail` is a literal in
every material here, so the octave loop is unrolled on the JS side.
Each octave is faded out once its period drops below about two pixels.
A 48-sample temporal resolve averages these frequencies per pixel;
a single-sample real-time pass cannot, and an unfiltered detail-8 noise at
scale 900 aliases into large drifting blotches under the bump node's
screen-space derivatives. Dropping the octaves that are past Nyquist
converges on the intended temporally filtered surface. */
const pixelFootprint = p => max(length(dFdx(p)), length(dFdy(p)));
function bnoise(p, scale, detail, roughness = 0.5, lacunarity = 2.0) {
const q = p.mul(scale);
const fw = pixelFootprint(p).toVar();
let fscale = 1.0, amp = 1.0, maxamp = 0.0;
let sum = null;
const n = Math.floor(detail);
for (let i = 0; i <= n; i++) {
const band = tslSmoothstep(0.25, 0.5, fw.mul(scale * fscale)).oneMinus();
const t = bperlin(q.mul(fscale)).mul(amp).mul(band);
sum = sum === null ? t : sum.add(t);
maxamp += amp;
amp *= roughness;
fscale *= lacunarity;
}
return sum.mul(0.5 / maxamp).add(0.5);
}
/* roughness variation -- MapRange(noise, 0.25..0.75 -> base-amt..base+amt) */
function roughVar(base, amt, scale, detail = 3.0) {
const nz = bnoise(positionLocal, scale, detail, 0.5);
const t = nz.sub(0.25).div(0.5);
const lo = base - amt, hi = base + amt;
return clamp(float(lo).add(t.mul(hi - lo)), Math.min(lo, hi), Math.max(lo, hi));
}
/* procedural bump -- Bump(Strength, Distance=0.0006) over object-space noise */
function noiseBump(scale, detail, strength, roughness = 0.55) {
const h = bnoise(positionLocal, scale, detail, roughness);
return bumpMap(h, float(strength * 0.0006));
}
/* ---- material construction --------------------------------------------- */
const lin = (r, g, b) => new THREE.Color().setRGB(r, g, b, THREE.LinearSRGBColorSpace);
/* The authored specular-IOR level uses 0.5 as neutral; Three.js uses 1.0. */
const specLevel = v => v / 0.5;
const MATS = {};
function principled(name, o) {
const m = new THREE.MeshPhysicalNodeMaterial();
m.name = name;
m.color = lin(...o.base);
m.metalness = o.metallic ?? 0.0;
m.roughness = o.rough ?? 0.5;
if (o.ior !== undefined) m.ior = o.ior;
m.specularIntensity = specLevel(o.spec ?? 0.5);
if (o.coat) { m.clearcoat = o.coat; m.clearcoatRoughness = o.coatRough ?? 0.03; }
if (o.sheen) {
m.sheen = o.sheen;
m.sheenRoughness = o.sheenRough ?? 0.3;
m.sheenColor = o.sheenTint ? lin(...o.sheenTint) : lin(1, 1, 1);
}
if (o.roughNode) m.roughnessNode = o.roughNode;
if (o.normalNode) m.normalNode = o.normalNode;
m.side = THREE.FrontSide;
MATS[name] = m;
return m;
}
function buildMaterials() {
/* M_SHELL -- satin off-white painted composite */
principled('M_SHELL', {
base: [0.828, 0.816, 0.796], metallic: 0.0, rough: 0.34, ior: 1.47, spec: 0.5,
coat: 0.26, coatRough: 0.22, sheen: 0.05, sheenRough: 0.45,
roughNode: roughVar(0.335, 0.045, 9.0),
normalNode: noiseBump(520.0, 6.0, 0.045, 0.6),
});
/* M_SHELL_LEG -- slightly cooler / brighter white used on the legs */
principled('M_SHELL_LEG', {
base: [0.858, 0.854, 0.846], rough: 0.36, coat: 0.22, coatRough: 0.24,
sheen: 0.06, sheenRough: 0.5,
roughNode: roughVar(0.355, 0.04, 8.0),
normalNode: noiseBump(520.0, 6.0, 0.045, 0.55),
});
/* M_BLACK -- matte soft-touch black polymer */
principled('M_BLACK', {
base: [0.0165, 0.0165, 0.0185], metallic: 0.0, rough: 0.46, spec: 0.42,
sheen: 0.25, sheenRough: 0.35, sheenTint: [0.30, 0.31, 0.34],
roughNode: roughVar(0.455, 0.05, 12.0),
normalNode: noiseBump(700.0, 6.0, 0.06, 0.6),
});
/* M_GLOSSBLACK -- piano black identity reserved for optional shell variants */
principled('M_GLOSSBLACK', {
base: [0.0075, 0.0075, 0.0090], metallic: 0.0, rough: 0.055, ior: 1.52,
coat: 1.0, coatRough: 0.025,
});
/* M_VISOR -- glossy smoked glass over the sensor suite */
principled('M_VISOR', {
base: [0.0042, 0.0045, 0.0058], metallic: 0.0, rough: 0.018, ior: 1.58,
spec: 0.62, coat: 1.0, coatRough: 0.008,
});
/* M_HELMET -- matte black composite rear hood */
principled('M_HELMET', {
base: [0.0175, 0.0178, 0.0196], metallic: 0.0, rough: 0.52, ior: 1.46,
spec: 0.36, sheen: 0.28, sheenRough: 0.32, sheenTint: [0.30, 0.31, 0.35],
roughNode: roughVar(0.52, 0.045, 16.0),
normalNode: noiseBump(820.0, 7.0, 0.07, 0.6),
});
/* M_LED */
{
const m = new THREE.MeshStandardNodeMaterial();
m.name = 'M_LED';
m.color = lin(0, 0, 0);
m.roughness = 0.4;
m.emissive = lin(0.03, 0.72, 1.0);
m.emissiveIntensity = 11.0;
MATS.M_LED = m;
}
/* M_DARKMECH -- cast / anodised dark-grey structural mechanism */
principled('M_DARKMECH', {
base: [0.0295, 0.0300, 0.0325], metallic: 0.72, rough: 0.42,
roughNode: roughVar(0.42, 0.07, 55.0),
normalNode: noiseBump(900.0, 8.0, 0.12, 0.7),
});
/* M_ALU -- machined light aluminium */
principled('M_ALU', {
base: [0.560, 0.562, 0.570], metallic: 1.0, rough: 0.26,
roughNode: roughVar(0.26, 0.05, 90.0),
normalNode: noiseBump(1400.0, 6.0, 0.08, 0.55),
});
/* M_STEEL -- polished actuator rod */
principled('M_STEEL', { base: [0.540, 0.548, 0.565], metallic: 1.0, rough: 0.10 });
/* M_RUBBER -- elbow / knee pad */
principled('M_RUBBER', {
base: [0.0130, 0.0132, 0.0150], rough: 0.60, spec: 0.35,
sheen: 0.35, sheenRough: 0.30, sheenTint: [0.26, 0.27, 0.30],
normalNode: noiseBump(340.0, 8.0, 0.18, 0.7),
});
/* M_FOOT -- moulded charcoal sole / boot */
principled('M_FOOT', {
base: [0.0400, 0.0410, 0.0450], rough: 0.52, spec: 0.4, sheen: 0.18, sheenRough: 0.5,
roughNode: roughVar(0.52, 0.06, 30.0),
normalNode: noiseBump(450.0, 8.0, 0.13, 0.55),
});
/* M_LOGO */
principled('M_LOGO', { base: [0.055, 0.054, 0.053], rough: 0.42, metallic: 0.0 });
/* M_DARKGREY -- mid dark grey plastic for small covers */
principled('M_DARKGREY', {
base: [0.075, 0.076, 0.082], rough: 0.40, metallic: 0.25,
normalNode: noiseBump(800.0, 6.0, 0.07, 0.55),
});
return MATS;
}
function toGeometry(mesh) {
applyMods(mesh);
const { corners } = computeCornerNormals(mesh);
const F = mesh.f, V = mesh.v;
const pos = [], nor = [];
const perVert = new Map(); /* vertex -> [normal, outIndex][] */
const groups = new Map(); /* material slot -> index array */
const emit = (vi, n) => {
let list = perVert.get(vi);
if (!list) { list = []; perVert.set(vi, list); }
for (let i = 0; i < list.length; i++) {
const m = list[i][0];
if (Math.abs(m[0] - n[0]) < 1e-6 && Math.abs(m[1] - n[1]) < 1e-6 && Math.abs(m[2] - n[2]) < 1e-6)
return list[i][1];
}
const idx = pos.length / 3;
pos.push(V[vi][0], V[vi][1], V[vi][2]);
nor.push(n[0], n[1], n[2]);
list.push([n, idx]);
return idx;
};
for (let fi = 0; fi < F.length; fi++) {
const t = F[fi], c = corners[fi];
const slot = mesh.fm[fi] | 0;
let g = groups.get(slot);
if (!g) { g = []; groups.set(slot, g); }
const i0 = emit(t[0], c[0]);
for (let i = 1; i < t.length - 1; i++) {
g.push(i0, emit(t[i], c[i]), emit(t[i + 1], c[i + 1]));
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
const index = [];
const order = [...groups.keys()].sort((a, b) => a - b);
const mats = [];
for (const slot of order) {
const g = groups.get(slot);
geo.addGroup(index.length, g.length, mats.length);
for (const i of g) index.push(i);
mats.push(mesh.mats[slot] || mesh.mats[0] || 'M_SHELL');
}
geo.setIndex(index);
geo.computeBoundingSphere();
return { geo, mats };
}
export const OPTIMUS_COLLECTION_ORDER = Object.freeze(["TORSO", "HEAD", "ARM", "HAND", "HIP", "LEG", "FOOT"]);
export function createProceduralOptimusHumanoid() {
for (const collection of Object.values(COLL)) collection.length = 0;
for (const material of Object.values(MATS)) material.dispose?.();
for (const key of Object.keys(MATS)) delete MATS[key];
buildMaterials();
buildTorso();
buildHead();
buildArms();
buildHands();
buildHips();
buildLegs();
buildFeet();
const root = new THREE.Group();
root.name = "PROCEDURAL_OPTIMUS_HUMANOID";
let triangles = 0;
let objects = 0;
for (const collectionName of OPTIMUS_COLLECTION_ORDER) {
const group = new THREE.Group();
group.name = collectionName;
for (const meshData of COLL[collectionName]) {
const { geo, mats } = toGeometry(meshData);
const materials = mats.map((name) => MATS[name] || MATS.M_SHELL);
const mesh = new THREE.Mesh(geo, materials.length === 1 ? materials[0] : materials);
mesh.name = meshData.name;
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
triangles += geo.getIndex().count / 3;
objects += 1;
}
root.add(group);
}
return {
root,
materials: MATS,
collections: COLL,
stats: { triangles, objects, heightMetres: 1.73 },
dispose() {
root.traverse((object) => object.geometry?.dispose?.());
for (const material of Object.values(MATS)) material.dispose?.();
},
};
}
examples/sculpted-gallery-frame/frame-geometry.js
import * as THREE from "three";
export const FRAME_RAIL_Z_OFFSET = 0.055;
export const FRAME_INNER_RIM_T = 0;
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const lerp = (start, end, amount) => start + (end - start) * amount;
const smoothstep = (edge0, edge1, value) => {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
};
export function profileZAt(t, railWidth) {
const scale = railWidth / 0.75;
const crown =
0.355 * scale * Math.pow(Math.max(0, Math.sin(Math.PI * t)), 0.56);
const innerBead =
0.105 * scale * Math.exp(-Math.pow((t - 0.085) / 0.033, 2));
const outerBead =
0.092 * scale * Math.exp(-Math.pow((t - 0.905) / 0.038, 2));
const innerGroove =
-0.115 * scale * Math.exp(-Math.pow((t - 0.205) / 0.043, 2));
const outerGroove =
-0.102 * scale * Math.exp(-Math.pow((t - 0.735) / 0.052, 2));
const shoulder =
0.045 * scale * Math.exp(-Math.pow((t - 0.42) / 0.15, 2));
const cove =
-0.035 * scale * Math.exp(-Math.pow((t - 0.61) / 0.095, 2));
let z =
0.07 * scale +
crown +
innerBead +
outerBead +
innerGroove +
outerGroove +
shoulder +
cove;
z = lerp(0.105 * scale, z, smoothstep(0, 0.052, t));
z = lerp(z, 0.095 * scale, smoothstep(0.952, 1, t));
return z;
}
export function buildFrameProfile(railWidth, samples = 92) {
return Array.from({ length: samples }, (_, index) => {
const t = index / (samples - 1);
return { t, z: profileZAt(t, railWidth) };
});
}
function getRailPoint(orientation, dimensions, t, s, z) {
const outerHalfWidth = dimensions.outerWidth / 2;
const outerHalfHeight = dimensions.outerHeight / 2;
const innerHalfWidth = dimensions.innerWidth / 2;
const innerHalfHeight = dimensions.innerHeight / 2;
const railWidth =
orientation === "top" || orientation === "bottom"
? dimensions.railWidthY
: dimensions.railWidthX;
const a = t * railWidth;
let x = 0;
let y = 0;
if (orientation === "top") {
y = innerHalfHeight + a;
x = lerp(
-outerHalfWidth + dimensions.railWidthX - t * dimensions.railWidthX,
outerHalfWidth - dimensions.railWidthX + t * dimensions.railWidthX,
s,
);
} else if (orientation === "bottom") {
y = -innerHalfHeight - a;
x = lerp(
-outerHalfWidth + dimensions.railWidthX - t * dimensions.railWidthX,
outerHalfWidth - dimensions.railWidthX + t * dimensions.railWidthX,
s,
);
} else if (orientation === "right") {
x = innerHalfWidth + a;
y = lerp(
-outerHalfHeight + dimensions.railWidthY - t * dimensions.railWidthY,
outerHalfHeight - dimensions.railWidthY + t * dimensions.railWidthY,
s,
);
} else {
x = -innerHalfWidth - a;
y = lerp(
-outerHalfHeight + dimensions.railWidthY - t * dimensions.railWidthY,
outerHalfHeight - dimensions.railWidthY + t * dimensions.railWidthY,
s,
);
}
return new THREE.Vector3(x, y, z);
}
function addQuadFace(indices, a, b, c, d) {
indices.push(a, b, c, b, d, c);
}
export function createSculptedRailGeometry(orientation, dimensions) {
const profile = buildFrameProfile(dimensions.profileRailWidth);
const lengthSegments =
orientation === "top" || orientation === "bottom" ? 132 : 156;
const bottomZ = -0.18 * (dimensions.profileRailWidth / 0.75);
const positions = [];
const uvs = [];
const indices = [];
const topIndices = [];
const bottomIndices = [];
const pushVertex = (vertex, u, v) => {
const id = positions.length / 3;
positions.push(vertex.x, vertex.y, vertex.z);
uvs.push(u, v);
return id;
};
profile.forEach(({ t, z }, profileIndex) => {
topIndices[profileIndex] = [];
bottomIndices[profileIndex] = [];
for (let segment = 0; segment <= lengthSegments; segment += 1) {
const s = segment / lengthSegments;
const top = getRailPoint(orientation, dimensions, t, s, z);
const bottom = getRailPoint(orientation, dimensions, t, s, bottomZ);
topIndices[profileIndex][segment] = pushVertex(top, s, t);
bottomIndices[profileIndex][segment] = pushVertex(bottom, s, t + 0.08);
}
});
for (let profileIndex = 0; profileIndex < profile.length - 1; profileIndex += 1) {
for (let segment = 0; segment < lengthSegments; segment += 1) {
addQuadFace(
indices,
topIndices[profileIndex][segment],
topIndices[profileIndex][segment + 1],
topIndices[profileIndex + 1][segment],
topIndices[profileIndex + 1][segment + 1],
);
addQuadFace(
indices,
bottomIndices[profileIndex + 1][segment],
bottomIndices[profileIndex + 1][segment + 1],
bottomIndices[profileIndex][segment],
bottomIndices[profileIndex][segment + 1],
);
}
}
for (let segment = 0; segment < lengthSegments; segment += 1) {
addQuadFace(
indices,
bottomIndices[0][segment],
bottomIndices[0][segment + 1],
topIndices[0][segment],
topIndices[0][segment + 1],
);
addQuadFace(
indices,
topIndices[profile.length - 1][segment],
topIndices[profile.length - 1][segment + 1],
bottomIndices[profile.length - 1][segment],
bottomIndices[profile.length - 1][segment + 1],
);
}
for (let profileIndex = 0; profileIndex < profile.length - 1; profileIndex += 1) {
addQuadFace(
indices,
bottomIndices[profileIndex][0],
topIndices[profileIndex][0],
bottomIndices[profileIndex + 1][0],
topIndices[profileIndex + 1][0],
);
addQuadFace(
indices,
topIndices[profileIndex][lengthSegments],
bottomIndices[profileIndex][lengthSegments],
topIndices[profileIndex + 1][lengthSegments],
bottomIndices[profileIndex + 1][lengthSegments],
);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
geometry.userData.orientation = orientation;
return geometry;
}
export function getFrameMetrics(layout) {
const aspect = 326 / 492;
const innerWidth = layout.postWidth;
const innerHeight = innerWidth / aspect;
const railWidthX = (layout.frameOuterWidth - innerWidth) / 2;
const railWidthY = (layout.frameOuterHeight - innerHeight) / 2;
const profileRailWidth = Math.min(railWidthX, railWidthY);
return {
innerWidth,
innerHeight,
railWidthX,
railWidthY,
profileRailWidth,
cardWidth: innerWidth,
cardHeight: innerHeight,
cardZ:
FRAME_RAIL_Z_OFFSET + profileZAt(FRAME_INNER_RIM_T, profileRailWidth),
};
}
examples/sport-motorcycle/motorcycle-model.js
export {
AX,
D as MOTORCYCLE_DIMENSIONS,
PAL as MOTORCYCLE_PALETTE,
QUALITY,
forkPt,
setQualityScale,
steerPt,
} from "./source/design-contract.js";
export { createSportMotorcycle } from "./source/motorcycle-model.js";
examples/sport-motorcycle/source/design-contract.js
import * as THREE from "three/webgpu";
/* ============================================================================
1. DESIGN CONTRACT
----------------------------------------------------------------------------
Units: metres. Frame: +Z forward (nose), +Y up, +X to the rider's right.
Origin: ground plane, midpoint of the wheelbase. The machine is mirror
symmetric about X = 0; right-hand parts are authored and mirrored.
Every subassembly below is positioned from this table or from a sample taken
on a surface generated from it. Nothing is nudged into place afterwards.
Real 636 cc supersport figures drive the primary numbers:
wheelbase 1400 · seat 830 · length 2025 · width 710 · height 1100
rake 23.5deg · trail 101 · 120/70-17 front · 180/55-17 rear
========================================================================== */
const DEG = Math.PI / 180;
const D = {
/* --- primary chassis geometry --------------------------------------- */
wheelbase: 1.400,
rake: 23.5 * DEG, // steering axis tilt from vertical
tripleOffset: 0.030, // fork axis ahead of steering axis
seatHeight: 0.830,
front: {
axle: [0.700, 0.300], // [z, y]
tyreR: 0.300, // 120/70 ZR17 -> 599.8mm OD
tyreW: 0.120,
rimR: 0.2159, // 17 inch
rimW: 0.0889, // 3.5 inch
discR: 0.155, // 310mm petal disc
discInnerR: 0.079,
discT: 0.0050,
},
rear: {
axle: [-0.700, 0.315], // 180/55 ZR17 -> 629.8mm OD
tyreR: 0.315,
tyreW: 0.180,
rimR: 0.2159,
rimW: 0.1397, // 5.5 inch
discR: 0.110, // 220mm petal disc
discInnerR: 0.052,
discT: 0.0045,
sprocketR: 0.0982, // 43T, 525 pitch
sprocketT: 0.0068,
},
/* --- steering ------------------------------------------------------- */
fork: {
tubeR: 0.0275, // 41mm stanchion + coating
sliderR: 0.0345,
halfTrack: 0.1055, // fork centres either side of the wheel
lowerClampL: 0.545, // distance up the fork axis from the axle
upperClampL: 0.672,
topL: 0.715,
sliderTopL: 0.345, // slider/stanchion transition
axleLugL: 0.048,
},
bar: { halfWidth: 0.268, y: 0.882, z: 0.394, rise: 4 * DEG, sweep: 9 * DEG },
// grip centres ~536mm apart, bar ends just inside the mirrors
/* --- swingarm & drive ----------------------------------------------- */
pivot: [-0.145, 0.365],
countershaft: [-0.156, 0.372],
frontSprocketR: 0.0366, // 15T
chainPitch: 0.015875, // 525 = 5/8"
chainW: 0.0170,
/* --- frame ---------------------------------------------------------- */
headTube: { l0: 0.560, l1: 0.700, r: 0.0345 }, // along the steering axis
spar: {
// twin-spar perimeter rail, right side; sampled head -> pivot
// The rail must pass UNDER the tank cover: run it too high and it becomes
// the machine's top line in profile, hiding the tank hump entirely.
path: [
[0.074, 0.808, 0.432],
[0.112, 0.812, 0.352],
[0.134, 0.804, 0.232],
[0.144, 0.788, 0.092],
[0.145, 0.748, -0.028],
[0.142, 0.646, -0.106],
[0.134, 0.506, -0.138],
[0.130, 0.428, -0.144],
],
},
subframe: {
upper: [[0.072, 0.812, -0.128], [0.088, 0.836, -0.318], [0.086, 0.870, -0.520], [0.070, 0.882, -0.700], [0.052, 0.884, -0.826]],
lower: [[0.062, 0.612, -0.166], [0.082, 0.686, -0.312], [0.086, 0.790, -0.470], [0.078, 0.856, -0.618]],
},
/* --- engine --------------------------------------------------------- */
engine: {
crank: [-0.018, 0.360],
bankAngle: 27 * DEG, // cylinder axis forward from vertical
caseHalfW: 0.140,
headHalfW: 0.128,
sumpY: 0.176,
deckL: 0.148, // crank -> cylinder deck along the bank axis
headL: 0.300, // crank -> top of the cam cover
bore: 0.067,
},
exhaust: {
headerR: 0.0175,
portY: 0.556, portZ: 0.268,
collectorZ: -0.208, collectorY: 0.197,
canA: [0.116, 0.244, -0.296],
canB: [0.163, 0.331, -0.612],
canR0: 0.035, canR1: 0.051,
},
radiator: { z0: 0.288, z1: 0.328, y0: 0.378, y1: 0.602, halfW: 0.146 },
/* --- bodywork ------------------------------------------------------- */
body: {
noseZ: 0.928,
screenTopZ: 0.618, screenTopY: 1.100,
tankTopY: 0.874,
tailTipZ: -0.944,
plateZ: -0.978,
maxHalfW: 0.187, // widest point of the side cowl
mirrorHalfW: 0.352, // 710mm overall width
fenderTipZ: 0.952,
},
/* --- misc ----------------------------------------------------------- */
peg: { z: -0.208, y: 0.372, halfW: 0.186 },
pillionPeg: { z: -0.486, y: 0.616, halfW: 0.152 }, // hanger roots on the subframe
};
/* Derived steering frame: the fork axis carries the front axle; the steering
axis is the fork axis pushed back by the triple-clamp offset. */
const AX = {
up: new THREE.Vector3(0, Math.cos(D.rake), -Math.sin(D.rake)),
fwd: new THREE.Vector3(0, Math.sin(D.rake), Math.cos(D.rake)),
};
/* point on the fork axis, L metres above the front axle, X half-track s */
const forkPt = (L, s = 0) => new THREE.Vector3(
s,
D.front.axle[1] + AX.up.y * L,
D.front.axle[0] + AX.up.z * L,
);
/* point on the steering axis */
const steerPt = (L) => forkPt(L).addScaledVector(AX.fwd, -D.tripleOffset);
/* --------------------------------------------------------------------------
Palette — metallic flat spark black with lime accents.
-------------------------------------------------------------------------- */
const PAL = {
fairing: new THREE.Color(0x14161a), // flat spark black, flake base
fairingDeep: new THREE.Color(0x0a0b0e),
glossBlack: new THREE.Color(0x0d0f13),
charcoal: new THREE.Color(0x24272c),
gunmetal: new THREE.Color(0x3a3f47),
alloyDark: new THREE.Color(0x2b2e33),
castAlloy: new THREE.Color(0x63676d),
brightAlloy: new THREE.Color(0x9aa0a7),
chrome: new THREE.Color(0xc9ced5),
titanium: new THREE.Color(0x8e9299),
steel: new THREE.Color(0x8b9098),
rubber: new THREE.Color(0x0c0c0e),
rubberWorn: new THREE.Color(0x191a1c),
green: new THREE.Color(0x67b62c), // Kawasaki lime
greenDeep: new THREE.Color(0x3f7a17),
lens: new THREE.Color(0xd8e2ec),
lensRed: new THREE.Color(0x7a0d12),
amber: new THREE.Color(0xd8721a),
seat: new THREE.Color(0x121316),
gold: new THREE.Color(0xb9924a),
};
const QUALITY = { low: 0.55, med: 0.78, high: 1.0 };
let QS = QUALITY.high; // segment scale
const seg = (n) => Math.max(6, Math.round(n * QS));
const SEED = 20260724;
export {
AX, D, DEG, PAL, QS, QUALITY, SEED, forkPt, seg, steerPt,
};
/** Segment-count scale. Lower tiers thin every revolve and sweep together. */
export function setQualityScale(scale) { QS = scale; }
export function qualityScale() { return QS; }
examples/sport-motorcycle/source/mesh-kit.js
import * as THREE from "three/webgpu";
import { SEED } from "./design-contract.js";
/* ============================================================================
2. GEOMETRY KERNEL
----------------------------------------------------------------------------
A small set of emitters shared by every part: a semantic mesh writer, a
sampled-grid compiler with explicit seams and winding, revolves, swept
profiles with either parallel-transport or world-up frames, thick panel
shells with real rim thickness, and bevelled extrusions.
Triangle emission is the last step; each part is planned as samples first.
========================================================================== */
const TAU = Math.PI * 2;
const V2 = (x = 0, y = 0) => new THREE.Vector2(x, y);
const V3 = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);
const lerp = THREE.MathUtils.lerp;
const clampf = THREE.MathUtils.clamp;
const smooth01 = (t) => t * t * (3 - 2 * t);
const ease = (t, p) => Math.pow(clampf(t, 0, 1), p);
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = mulberry32(SEED);
/* --- curve resampling ---------------------------------------------------- */
function catmull2(pts, count, closed = false) {
const c = new THREE.CatmullRomCurve3(pts.map((p) => V3(p[0] ?? p.x, p[1] ?? p.y, 0)), closed);
c.curveType = "centripetal";
return c.getSpacedPoints(closed ? count - 1 : count).map((p) => V2(p.x, p.y));
}
function catmull3(pts, count, closed = false) {
const c = new THREE.CatmullRomCurve3(
pts.map((p) => (p.isVector3 ? p.clone() : V3(p[0], p[1], p[2]))), closed);
c.curveType = "centripetal";
return c.getSpacedPoints(closed ? count - 1 : count);
}
/* --- semantic mesh writer ------------------------------------------------ */
class Writer {
constructor() { this.p = []; this.n = []; this.t = []; this.i = []; }
get count() { return this.p.length / 3; }
vert(p, n, u, v) {
this.p.push(p.x, p.y, p.z);
this.n.push(n.x, n.y, n.z);
this.t.push(u, v);
return this.p.length / 3 - 1;
}
tri(a, b, c) { this.i.push(a, b, c); }
quad(a, b, c, d) { this.i.push(a, b, c, b, d, c); }
/* sampled surface: rows[row][col] of Vector3. closeU stitches an explicit
seam column so the UV wrap is not shared with the geometric seam. */
grid(rows, { closeU = false, flip = false, vRow = null, uRange = [0, 1], vRange = [0, 1], uvFn = null } = {}) {
const R = rows.length, C = rows[0].length;
const EC = closeU ? C + 1 : C;
const wrap = (c) => ((c % C) + C) % C;
const at = (r, c) => rows[clampf(r, 0, R - 1)][closeU ? wrap(c) : clampf(c, 0, C - 1)];
const base = this.count;
const dU = V3(), dV = V3(), nn = V3();
for (let r = 0; r < R; r++) {
for (let c = 0; c < EC; c++) {
const P = at(r, c);
dU.subVectors(at(r, c + 1), at(r, c - 1));
dV.subVectors(at(r + 1, c), at(r - 1, c));
nn.crossVectors(dU, dV);
if (nn.lengthSq() < 1e-16) {
const a = V3(), b = V3();
for (const q of rows[clampf(r - 1, 0, R - 1)]) a.add(q);
for (const q of rows[clampf(r + 1, 0, R - 1)]) b.add(q);
nn.subVectors(a.divideScalar(C), b.divideScalar(C));
if (nn.lengthSq() < 1e-16) nn.set(0, 1, 0);
}
nn.normalize(); if (flip) nn.negate();
let u, v;
if (uvFn) { const o = uvFn(r, c, R, EC); u = o[0]; v = o[1]; }
else {
u = lerp(uRange[0], uRange[1], c / (EC - 1));
v = lerp(vRange[0], vRange[1], vRow ? vRow[r] : r / (R - 1));
}
this.vert(P, nn, u, v);
}
}
for (let r = 0; r < R - 1; r++) {
for (let c = 0; c < EC - 1; c++) {
const a = base + r * EC + c, b = a + 1, cc = a + EC, d = cc + 1;
if (flip) this.i.push(a, cc, b, b, cc, d);
else this.i.push(a, b, cc, b, d, cc);
}
}
return this;
}
/* hard-edged flat cap from a closed ring; normal from Newell's method */
fan(ring, flip = false, uvScale = 1) {
const n = V3(); const ctr = V3();
for (let i = 0; i < ring.length; i++) {
const a = ring[i], b = ring[(i + 1) % ring.length];
n.x += (a.y - b.y) * (a.z + b.z);
n.y += (a.z - b.z) * (a.x + b.x);
n.z += (a.x - b.x) * (a.y + b.y);
ctr.add(a);
}
ctr.divideScalar(ring.length); n.normalize(); if (flip) n.negate();
const base = this.count;
this.vert(ctr, n, 0.5, 0.5);
for (let i = 0; i < ring.length; i++) {
const a = ring[i];
this.vert(a, n, 0.5 + (a.x - ctr.x) * uvScale, 0.5 + (a.y - ctr.y) * uvScale);
}
for (let i = 0; i < ring.length; i++) {
const a = base + 1 + i, b = base + 1 + ((i + 1) % ring.length);
if (flip) this.tri(base, b, a); else this.tri(base, a, b);
}
return this;
}
/* quad strip between two equal-length rings (annulus cap, rim band) */
band(ringA, ringB, { closed = true, flip = false, v0 = 0, v1 = 1, hard = true } = {}) {
const N = ringA.length;
const rows = [ringA, ringB];
if (!hard) return this.grid(rows, { closeU: closed, flip, vRange: [v0, v1] });
const base = this.count;
const lim = closed ? N : N - 1;
for (let i = 0; i < lim; i++) {
const j = (i + 1) % N;
const a = ringA[i], b = ringA[j], c = ringB[i], d = ringB[j];
const n = V3().crossVectors(V3().subVectors(b, a), V3().subVectors(c, a)).normalize();
if (flip) n.negate();
const u0 = i / N, u1 = (i + 1) / N;
const i0 = this.vert(a, n, u0, v0), i1 = this.vert(b, n, u1, v0);
const i2 = this.vert(c, n, u0, v1), i3 = this.vert(d, n, u1, v1);
if (flip) this.i.push(i0, i2, i1, i1, i2, i3);
else this.i.push(i0, i1, i2, i1, i3, i2);
}
return this;
}
geometry() {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(this.p, 3));
g.setAttribute("normal", new THREE.Float32BufferAttribute(this.n, 3));
g.setAttribute("uv", new THREE.Float32BufferAttribute(this.t, 2));
g.setIndex(this.i);
g.computeBoundingSphere();
return g;
}
}
const write = (fn) => { const w = new Writer(); fn(w); return w.geometry(); };
/* --- ring / revolve ------------------------------------------------------ */
function ringPoints(center, axisU, axisV, radius, segments, phase = 0) {
const out = [];
for (let i = 0; i < segments; i++) {
const a = phase + (i / segments) * TAU;
out.push(V3().copy(center)
.addScaledVector(axisU, Math.cos(a) * radius)
.addScaledVector(axisV, Math.sin(a) * radius));
}
return out;
}
const AXES = {
x: [V3(0, 1, 0), V3(0, 0, 1), V3(1, 0, 0)],
y: [V3(0, 0, 1), V3(1, 0, 0), V3(0, 1, 0)],
z: [V3(1, 0, 0), V3(0, 1, 0), V3(0, 0, 1)],
};
/* profile: Vector2(radius, distanceAlongAxis) */
function revolveRows(profile, axis, segments, offset = V3()) {
const [u, v, w] = AXES[axis];
return profile.map((pt) => ringPoints(
V3().copy(offset).addScaledVector(w, pt.y), u, v, Math.max(pt.x, 1e-4), segments));
}
function arcLengthV(profile) {
const v = [0]; let s = 0;
for (let i = 1; i < profile.length; i++) {
s += Math.hypot(profile[i].x - profile[i - 1].x, profile[i].y - profile[i - 1].y);
v.push(s);
}
return v.map((x) => x / (s || 1));
}
function revolve(profile, { axis = "z", segments = 48, flip = false, offset = V3(), w = null, capStart = false, capEnd = false, uRange = [0, 1], vRange = [0, 1] } = {}) {
const rows = revolveRows(profile, axis, segments, offset);
const target = w || new Writer();
target.grid(rows, { closeU: true, flip, vRow: arcLengthV(profile), uRange, vRange });
if (capStart) target.fan(rows[0], !flip);
if (capEnd) target.fan(rows[rows.length - 1], flip);
// A lathe is a solid when its profile closes on itself, when both ends are
// capped, OR when both ends land on the axis — that last case is a dome or
// a plug, and missing it left such parts inside-out and invisible from
// outside, since their front faces were the ones being culled.
const last = profile[profile.length - 1];
const closedProfile = Math.hypot(profile[0].x - last.x, profile[0].y - last.y) < 1e-4;
const onAxisEnds = Math.abs(profile[0].x) < 1e-4 && Math.abs(last.x) < 1e-4;
return w ? target : ((capStart && capEnd) || closedProfile || onAxisEnds
? orient(target.geometry()) : target.geometry());
}
/* --- swept profiles ------------------------------------------------------ */
/* Parallel-transport frames: minimises twist along an arbitrary path. */
function transportFrames(points, seedUp) {
const N = points.length, T = [], Nn = [], B = [];
for (let i = 0; i < N; i++) {
const a = points[Math.max(0, i - 1)], b = points[Math.min(N - 1, i + 1)];
const t = V3().subVectors(b, a);
if (t.lengthSq() < 1e-14) t.copy(T[i - 1] || V3(0, 0, 1));
T.push(t.normalize());
}
let n = seedUp ? seedUp.clone() : (Math.abs(T[0].y) < 0.94 ? V3(0, 1, 0) : V3(1, 0, 0));
n = V3().crossVectors(T[0], V3().crossVectors(n, T[0]));
if (n.lengthSq() < 1e-12) n = V3().crossVectors(T[0], V3(1, 0, 0));
n.normalize();
for (let i = 0; i < N; i++) {
if (i > 0) {
const ax = V3().crossVectors(T[i - 1], T[i]);
if (ax.lengthSq() > 1e-12) {
ax.normalize();
n.applyAxisAngle(ax, Math.acos(clampf(T[i - 1].dot(T[i]), -1, 1)));
}
}
Nn.push(n.clone());
B.push(V3().crossVectors(T[i], n).normalize());
}
return { T, N: Nn, B };
}
/* World-up frames: keeps a profile's "up" locked to world Y — used for
chassis rails whose section must not roll along the path. */
function uprightFrames(points, up = V3(0, 1, 0)) {
const N = points.length, T = [], Nn = [], B = [];
for (let i = 0; i < N; i++) {
const a = points[Math.max(0, i - 1)], b = points[Math.min(N - 1, i + 1)];
T.push(V3().subVectors(b, a).normalize());
}
for (let i = 0; i < N; i++) {
let b = V3().crossVectors(T[i], up);
if (b.lengthSq() < 1e-10) b = V3().crossVectors(T[i], V3(0, 0, 1));
b.normalize();
Nn.push(V3().crossVectors(b, T[i]).normalize());
B.push(b);
}
return { T, N: Nn, B };
}
/* sectionFn(t, i) -> [Vector2] in (binormal, normal) frame coordinates */
function sweep(path, sectionFn, {
closed = false, flip = false, upright = false, up = null, seedUp = null,
capStart = true, capEnd = true, w = null, vScale = 1, uRange = [0, 1],
} = {}) {
const F = upright ? uprightFrames(path, up || V3(0, 1, 0)) : transportFrames(path, seedUp);
const rows = path.map((P, i) => {
const t = i / (path.length - 1);
const sec = sectionFn(t, i);
return sec.map((s) => V3().copy(P).addScaledVector(F.B[i], s.x).addScaledVector(F.N[i], s.y));
});
const vRow = [0]; let acc = 0;
for (let i = 1; i < path.length; i++) { acc += path[i].distanceTo(path[i - 1]); vRow.push(acc); }
for (let i = 0; i < vRow.length; i++) vRow[i] = (vRow[i] / (acc || 1)) * vScale;
const target = w || new Writer();
target.grid(rows, { closeU: true, flip, vRow, uRange });
if (capStart && !closed) target.fan(rows[0], !flip);
if (capEnd && !closed) target.fan(rows[rows.length - 1], flip);
return w ? target
: (capStart && capEnd && !closed ? orient(target.geometry()) : target.geometry());
}
const tube = (path, radius, segments = 16, opts = {}) => {
const rf = typeof radius === "number" ? () => radius : radius;
return sweep(path, (t) => {
const r = rf(t), out = [];
for (let i = 0; i < segments; i++) {
const a = (i / segments) * TAU;
out.push(V2(Math.cos(a) * r, Math.sin(a) * r));
}
return out;
}, opts);
};
function arcPath(center, axisU, axisV, radius, a0, a1, segments = 24) {
const out = [];
for (let i = 0; i <= segments; i++) {
const a = lerp(a0, a1, i / segments);
out.push(V3().copy(center).addScaledVector(axisU, Math.cos(a) * radius)
.addScaledVector(axisV, Math.sin(a) * radius));
}
return out;
}
/* --- parametric sections -------------------------------------------------
Four-quadrant superellipse. Separate exponents and extents per quadrant
give one control set that spans soft tank shoulders (n≈2.4) through the
knife-edged creases of a fairing flank (n≈5.5). */
function superSection({
halfW = 1, top = 1, bottom = 1, nTop = 2.6, nBot = 2.6,
nTopIn = null, nBotIn = null, cx = 0, cy = 0, shoulder = 0, segments = 48,
wTop = 1, wBot = 1,
} = {}) {
const out = [];
for (let i = 0; i < segments; i++) {
const a = (i / segments) * TAU;
const ca = Math.cos(a), sa = Math.sin(a);
const upper = sa >= 0;
const nx = upper ? (nTopIn ?? nTop) : (nBotIn ?? nBot); // horizontal falloff
const ny = upper ? nTop : nBot; // vertical falloff
const h = upper ? top : bottom;
const wk = upper ? wTop : wBot; // per-half width scale
const x = Math.sign(ca) * Math.pow(Math.abs(ca), 2 / nx) * halfW * wk;
// shoulder raises or drops the widest point without moving crown or keel
const y = Math.sign(sa) * Math.pow(Math.abs(sa), 2 / ny) * h
+ shoulder * (1 - Math.abs(sa));
out.push(V2(cx + x, cy + y));
}
return out;
}
/* rounded rectangle sampled by true perimeter arc length: four straights and
four quarter-arcs, so corner radius stays exact at any segment count. */
function roundRect(halfW, halfH, r, segments = 40) {
r = Math.min(r, Math.min(halfW, halfH) * 0.999);
const sw = 2 * (halfW - r), sh = 2 * (halfH - r), arc = (Math.PI / 2) * r;
const total = 2 * sw + 2 * sh + 4 * arc;
const corner = [[halfW - r, halfH - r, 0], [-(halfW - r), halfH - r, Math.PI / 2],
[-(halfW - r), -(halfH - r), Math.PI], [halfW - r, -(halfH - r), Math.PI * 1.5]];
const out = [];
for (let i = 0; i < segments; i++) {
let s = (i / segments) * total;
// start mid-way along the right edge so the seam sits on a straight run
s = (s + sh / 2) % total;
if (s < sh) { out.push(V2(halfW, -(halfH - r) + s)); continue; } s -= sh;
if (s < arc) { const a = corner[0][2] + s / r; out.push(V2(corner[0][0] + Math.cos(a) * r, corner[0][1] + Math.sin(a) * r)); continue; } s -= arc;
if (s < sw) { out.push(V2(halfW - r - s, halfH)); continue; } s -= sw;
if (s < arc) { const a = corner[1][2] + s / r; out.push(V2(corner[1][0] + Math.cos(a) * r, corner[1][1] + Math.sin(a) * r)); continue; } s -= arc;
if (s < sh) { out.push(V2(-halfW, halfH - r - s)); continue; } s -= sh;
if (s < arc) { const a = corner[2][2] + s / r; out.push(V2(corner[2][0] + Math.cos(a) * r, corner[2][1] + Math.sin(a) * r)); continue; } s -= arc;
if (s < sw) { out.push(V2(-(halfW - r) + s, -halfH)); continue; } s -= sw;
const a = corner[3][2] + s / r;
out.push(V2(corner[3][0] + Math.cos(a) * r, corner[3][1] + Math.sin(a) * r));
}
return out;
}
/* uniform scale/offset helpers for 2D sections */
const secScale = (pts, sx, sy = sx) => pts.map((p) => V2(p.x * sx, p.y * sy));
const secMove = (pts, dx, dy) => pts.map((p) => V2(p.x + dx, p.y + dy));
/* explicit closed polyline through 2D controls (the sculpted-profile route) */
const section = (controls, n) => catmull2(controls, n, true);
/* --- panel shells --------------------------------------------------------
Bodywork is not a zero-thickness sheet: every cowl carries a visible edge
where it meets the next panel. Offset the sampled outer surface along its
own normals and stitch the four boundaries. */
function gridNormals(rows) {
const R = rows.length, C = rows[0].length;
const at = (r, c) => rows[clampf(r, 0, R - 1)][clampf(c, 0, C - 1)];
const out = [];
for (let r = 0; r < R; r++) {
const line = [];
for (let c = 0; c < C; c++) {
const dU = V3().subVectors(at(r, c + 1), at(r, c - 1));
const dV = V3().subVectors(at(r + 1, c), at(r - 1, c));
const n = V3().crossVectors(dU, dV);
if (n.lengthSq() < 1e-16) n.set(0, 1, 0);
line.push(n.normalize());
}
out.push(line);
}
return out;
}
function panelShell(rows, thickness, { flip = false, rim = [1, 1, 1, 1], w = null, taper = null, uRange = [0, 1], vRange = [0, 1] } = {}) {
const N = gridNormals(rows);
const R = rows.length, C = rows[0].length;
const sgn = flip ? 1 : -1;
const inner = rows.map((row, r) => row.map((P, c) => {
const k = taper ? taper(r / (R - 1), c / (C - 1)) : 1;
return V3().copy(P).addScaledVector(N[r][c], sgn * thickness * k);
}));
const target = w || new Writer();
target.grid(rows, { flip, uRange, vRange });
target.grid(inner, { flip: !flip, uRange, vRange });
const edge = (getOuter, getInner, count, doFlip) => {
const A = [], B = [];
for (let i = 0; i < count; i++) { A.push(getOuter(i)); B.push(getInner(i)); }
target.band(A, B, { closed: false, flip: doFlip });
};
if (rim[0]) edge((i) => rows[0][i], (i) => inner[0][i], C, flip);
if (rim[1]) edge((i) => rows[R - 1][i], (i) => inner[R - 1][i], C, !flip);
if (rim[2]) edge((i) => rows[i][0], (i) => inner[i][0], R, !flip);
if (rim[3]) edge((i) => rows[i][C - 1], (i) => inner[i][C - 1], R, flip);
const all = rim[0] && rim[1] && rim[2] && rim[3];
return w ? target : (all ? orient(target.geometry()) : target.geometry());
}
/* --- bevelled extrusion (rotors, sprockets, brackets, caliper bodies) ---- */
function offsetPoly(pts, d) {
const N = pts.length, out = [];
for (let i = 0; i < N; i++) {
const p = pts[i], a = pts[(i - 1 + N) % N], b = pts[(i + 1) % N];
const e0 = V2().subVectors(p, a).normalize(), e1 = V2().subVectors(b, p).normalize();
const n0 = V2(e0.y, -e0.x), n1 = V2(e1.y, -e1.x);
const n = V2().addVectors(n0, n1).normalize();
const cosHalf = Math.max(0.35, n.dot(n1));
out.push(V2(p.x + n.x * d / cosHalf, p.y + n.y * d / cosHalf));
}
return out;
}
const toRing = (pts2, z, axis = "z") => pts2.map((p) =>
axis === "z" ? V3(p.x, p.y, z) : axis === "x" ? V3(z, p.y, p.x) : V3(p.x, z, p.y));
/* solid plate with chamfered rim */
function extrudePlate(poly, depth, { bevel = 0.0012, axis = "z", z = 0, w = null, flip = false } = {}) {
const h = depth / 2, inn = offsetPoly(poly, -bevel);
const z0 = z - h, z1 = z + h, b = Math.min(bevel, depth * 0.35);
const rows = [
toRing(inn, z0, axis), toRing(poly, z0 + b, axis),
toRing(poly, z1 - b, axis), toRing(inn, z1, axis),
];
const target = w || new Writer();
target.grid(rows, { closeU: true, flip: axis === "x" ? !flip : flip });
target.fan(rows[0], axis === "x" ? flip : !flip);
target.fan(rows[3], axis === "x" ? !flip : flip);
return w ? target : orient(target.geometry());
}
/* resample a closed 2D polyline to n points by arc length — lets an annulus
pair a 258-point toothed profile with a plain circular bore */
function resampleRing(pts, n) {
if (pts.length === n) return pts;
const N = pts.length, cum = [0];
for (let i = 1; i <= N; i++) cum.push(cum[i - 1] + pts[i % N].distanceTo(pts[i - 1]));
const total = cum[N];
const out = [];
let j = 1;
for (let i = 0; i < n; i++) {
const s = (i / n) * total;
while (j < N && cum[j] < s) j++;
const t = (s - cum[j - 1]) / Math.max(1e-9, cum[j] - cum[j - 1]);
out.push(V2().lerpVectors(pts[j - 1], pts[j % N], t));
}
return out;
}
/* annular plate (disc with hub bore, sprocket) */
function extrudeRing(outer, inner, depth, { bevel = 0.0010, axis = "z", z = 0, w = null } = {}) {
inner = resampleRing(inner, outer.length);
const h = depth / 2, b = Math.min(bevel, depth * 0.35);
const oi = offsetPoly(outer, -bevel), ii = offsetPoly(inner, bevel);
const z0 = z - h, z1 = z + h;
const O = [toRing(oi, z0, axis), toRing(outer, z0 + b, axis), toRing(outer, z1 - b, axis), toRing(oi, z1, axis)];
const I = [toRing(ii, z0, axis), toRing(inner, z0 + b, axis), toRing(inner, z1 - b, axis), toRing(ii, z1, axis)];
const target = w || new Writer();
const fx = axis === "x";
target.grid(O, { closeU: true, flip: fx });
target.grid(I, { closeU: true, flip: !fx });
target.band(O[0], I[0], { closed: true, flip: !fx });
target.band(O[3], I[3], { closed: true, flip: fx });
return w ? target : orient(target.geometry());
}
const circlePoly = (r, n, phase = 0, cx = 0, cy = 0) => {
const out = [];
for (let i = 0; i < n; i++) {
const a = phase + (i / n) * TAU;
out.push(V2(cx + Math.cos(a) * r, cy + Math.sin(a) * r));
}
return out;
};
/* --- filleted box (cast covers, calipers, master cylinders) --------------
Loft superellipse sections whose extent follows a rounded end profile, so
corners carry a true 3D fillet instead of a flat chamfer. */
function filletBox(w_, h_, d_, r, {
segments = 32, rings = 11, taperTop = 1, taperBot = 1, shear = 0, w = null, axisCap = true,
} = {}) {
const hw = w_ / 2, hh = h_ / 2, hd = d_ / 2;
r = Math.min(r, Math.min(hw, hh, hd) * 0.98);
const rows = [];
for (let i = 0; i < rings; i++) {
const t = i / (rings - 1);
const z = lerp(-hd, hd, t);
// spherical fillet: how far the section pulls in near each end cap
const over = Math.abs(z) - (hd - r);
const inset = over > 0 ? r - Math.sqrt(Math.max(0, r * r - over * over)) : 0;
const rr = Math.max(1e-4, r - inset);
const base = roundRect(Math.max(1e-4, hw - inset), Math.max(1e-4, hh - inset), rr, segments);
rows.push(base.map((p) => {
const vy = clampf(p.y / hh, -1, 1);
const k = vy > 0 ? lerp(1, taperTop, vy) : lerp(1, taperBot, -vy);
return V3(p.x * k + shear * z, p.y, z);
}));
}
const target = w || new Writer();
target.grid(rows, { closeU: true });
if (axisCap) { target.fan(rows[0], true); target.fan(rows[rows.length - 1], false); }
return w ? target : (axisCap ? orient(target.geometry()) : target.geometry());
}
/* --- orientation guard ---------------------------------------------------
A loft's winding depends on whether its station table runs +Z or -Z, and on
which way its section is wound. Rather than police that by hand in forty
tables, every closed body is checked: if the enclosed signed volume comes
out negative the surface is inside-out, so flip winding and normals.
Silent inversion is the single most common failure in generated hard
surfaces — it survives a wireframe check and only shows up as wrong light. */
function signedVolume(geometry) {
const p = geometry.attributes.position.array;
const idx = geometry.index.array;
let v = 0;
for (let i = 0; i < idx.length; i += 3) {
const a = idx[i] * 3, b = idx[i + 1] * 3, c = idx[i + 2] * 3;
const ax = p[a], ay = p[a + 1], az = p[a + 2];
const bx = p[b], by = p[b + 1], bz = p[b + 2];
const cx = p[c], cy = p[c + 1], cz = p[c + 2];
v += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
}
return v / 6;
}
function flipGeometry(geometry) {
const n = geometry.attributes.normal.array;
for (let i = 0; i < n.length; i++) n[i] = -n[i];
const idx = geometry.index.array;
for (let i = 0; i < idx.length; i += 3) {
const t = idx[i + 1]; idx[i + 1] = idx[i + 2]; idx[i + 2] = t;
}
geometry.attributes.normal.needsUpdate = true;
geometry.index.needsUpdate = true;
return geometry;
}
let INVERTED = 0;
function orient(geometry) {
if (!geometry.index) return geometry;
if (signedVolume(geometry) < 0) { INVERTED++; flipGeometry(geometry); }
return geometry;
}
/* --- geometry utilities -------------------------------------------------- */
function mirrorX(geometry) {
const g = geometry.clone();
const p = g.attributes.position, n = g.attributes.normal;
for (let i = 0; i < p.count; i++) { p.setX(i, -p.getX(i)); n.setX(i, -n.getX(i)); }
const idx = g.index.array;
for (let i = 0; i < idx.length; i += 3) { const t = idx[i + 1]; idx[i + 1] = idx[i + 2]; idx[i + 2] = t; }
g.index.needsUpdate = true; p.needsUpdate = true; n.needsUpdate = true;
g.computeBoundingSphere();
return g;
}
function xform(geometry, m) {
const g = geometry.clone(); g.applyMatrix4(m); return g;
}
const M = {
t: (x, y, z) => new THREE.Matrix4().makeTranslation(x, y, z),
rx: (a) => new THREE.Matrix4().makeRotationX(a),
ry: (a) => new THREE.Matrix4().makeRotationY(a),
rz: (a) => new THREE.Matrix4().makeRotationZ(a),
s: (x, y, z) => new THREE.Matrix4().makeScale(x, y, z),
c: (...ms) => ms.reduce((acc, m) => acc.multiply(m), new THREE.Matrix4()),
};
/* --- part registry & diagnostics ---------------------------------------- */
const PARTS = [];
const SLOT_COLORS = {};
function add(parent, geometry, material, name, { cast = true, receive = true, slot = null } = {}) {
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = cast; mesh.receiveShadow = receive;
mesh.name = name;
mesh.userData.slot = slot || (material.userData && material.userData.slot) || "misc";
parent.add(mesh);
const tris = geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3;
PARTS.push({ name, tris, slot: mesh.userData.slot });
return mesh;
}
/* pair(): author once on +X, mirror to -X — the symmetry contract */
function pair(parent, geometry, material, name, opts = {}) {
const a = add(parent, geometry, material, name + ".R", opts);
const b = add(parent, mirrorX(geometry), material, name + ".L", opts);
return [a, b];
}
function instance(parent, geometry, material, matrices, name, opts = {}) {
const im = new THREE.InstancedMesh(geometry, material, matrices.length);
im.castShadow = opts.cast !== false; im.receiveShadow = opts.receive !== false;
im.name = name;
im.userData.slot = opts.slot || (material.userData && material.userData.slot) || "misc";
matrices.forEach((m, i) => im.setMatrixAt(i, m));
im.instanceMatrix.needsUpdate = true;
parent.add(im);
const tris = (geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3);
PARTS.push({ name, tris: tris * matrices.length, slot: im.userData.slot, instanced: matrices.length });
return im;
}
export {
AXES, INVERTED, M, PARTS, SLOT_COLORS, TAU, V2, V3, Writer, add, arcLengthV, arcPath, catmull2, catmull3, circlePoly, clampf, ease, extrudePlate, extrudeRing, filletBox, flipGeometry, gridNormals, instance, lerp, mirrorX, mulberry32, offsetPoly, orient, pair, panelShell, resampleRing, revolve, revolveRows, ringPoints, rnd, roundRect, secMove, secScale, section, signedVolume, smooth01, superSection, sweep, toRing, transportFrames, tube, uprightFrames, write, xform,
};
/** Closed bodies whose winding was corrected by the orientation guard. */
export function invertedCount() { return INVERTED; }
export function resetInvertedCount() { INVERTED = 0; }
examples/sport-motorcycle/source/motorcycle-materials.js
import * as THREE from "three/webgpu";
import {
color, float, mix, mx_fractal_noise_float, mx_noise_float, positionWorld, texture, uv,
vec2, vec3,
} from "three/tsl";
import { PAL, SEED } from "./design-contract.js";
import { TAU, clampf, ease, lerp, mulberry32, section, smooth01, sweep } from "./mesh-kit.js";
/* ============================================================================
3. GENERATED SURFACE INPUTS + MATERIAL IDENTITIES
----------------------------------------------------------------------------
Every texture below is generated at construction from the fixed seed; no
external assets. Materials are grouped by finish, not by part, because the
whole machine is black-on-black: what separates a fairing from a case from a
rotor here is specular response, not albedo.
========================================================================== */
const TEX = [];
function keep(t) { TEX.push(t); return t; }
function canvas2d(w, h) {
const c = document.createElement("canvas");
c.width = w; c.height = h;
return [c, c.getContext("2d")];
}
function canvasTexture(c, { repeat = [1, 1], aniso = 8, srgb = false, wrap = THREE.RepeatWrapping } = {}) {
const t = new THREE.CanvasTexture(c);
t.wrapS = t.wrapT = wrap;
t.repeat.set(repeat[0], repeat[1]);
t.anisotropy = aniso;
if (srgb) t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
return keep(t);
}
/* height field -> tangent-space normal map (Sobel), wrapped */
function normalFromHeight(height, size, strength = 2.4, sizeY = null) {
const H = sizeY || size;
const data = new Uint8Array(size * H * 4);
const at = (x, y) => height[((y % H) + H) % H * size + (((x % size) + size) % size)];
for (let y = 0; y < H; y++) {
for (let x = 0; x < size; x++) {
const dx = (at(x + 1, y) - at(x - 1, y)) * strength;
const dy = (at(x, y + 1) - at(x, y - 1)) * strength;
const inv = 1 / Math.hypot(dx, dy, 1);
const i = (y * size + x) * 4;
data[i] = (-dx * inv * 0.5 + 0.5) * 255;
data[i + 1] = (dy * inv * 0.5 + 0.5) * 255;
data[i + 2] = (inv * 0.5 + 0.5) * 255;
data[i + 3] = 255;
}
}
const t = new THREE.DataTexture(data, size, H, THREE.RGBAFormat);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.anisotropy = 8; t.needsUpdate = true;
return keep(t);
}
/* value noise sampled on a torus so it tiles */
function tileNoise(size, freq, octaves, seedR) {
const out = new Float32Array(size * size);
const grid = [];
for (let o = 0; o < octaves; o++) {
const f = freq << o, g = new Float32Array(f * f);
for (let i = 0; i < f * f; i++) g[i] = seedR();
grid.push(g);
}
const sm = (t) => t * t * (3 - 2 * t);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let v = 0, amp = 1, norm = 0;
for (let o = 0; o < octaves; o++) {
const f = freq << o, g = grid[o];
const fx = (x / size) * f, fy = (y / size) * f;
const x0 = Math.floor(fx), y0 = Math.floor(fy);
const tx = sm(fx - x0), ty = sm(fy - y0);
const i00 = g[(y0 % f) * f + (x0 % f)], i10 = g[(y0 % f) * f + ((x0 + 1) % f)];
const i01 = g[((y0 + 1) % f) * f + (x0 % f)], i11 = g[((y0 + 1) % f) * f + ((x0 + 1) % f)];
v += amp * lerp(lerp(i00, i10, tx), lerp(i01, i11, tx), ty);
norm += amp; amp *= 0.5;
}
out[y * size + x] = v / norm;
}
}
return out;
}
/* --- 3.1 generated normal / detail maps ---------------------------------- */
function makeFlakeNormal() { // metallic paint flake
const S = 512, R = mulberry32(SEED + 11);
const h = new Float32Array(S * S);
for (let i = 0; i < 5200; i++) {
const x = (R() * S) | 0, y = (R() * S) | 0, r = 1 + R() * 1.6, a = (R() - 0.5) * 1.2;
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
const d = Math.hypot(dx, dy);
if (d > r) continue;
const ix = ((x + dx) % S + S) % S, iy = ((y + dy) % S + S) % S;
h[iy * S + ix] += a * (1 - d / r);
}
}
return normalFromHeight(h, S, 0.9);
}
function makeCastNormal() { // sand-cast alloy grain
const S = 512, R = mulberry32(SEED + 23);
const base = tileNoise(S, 24, 4, R);
const h = new Float32Array(S * S);
for (let i = 0; i < S * S; i++) h[i] = base[i] * 0.55 + (R() - 0.5) * 0.16;
return normalFromHeight(h, S, 1.5);
}
function makeBrushNormal() { // linear brushed metal
const S = 512, R = mulberry32(SEED + 31);
const h = new Float32Array(S * S);
const lanes = new Float32Array(S);
for (let x = 0; x < S; x++) lanes[x] = R();
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
h[y * S + x] = lanes[x] * 0.6 + (R() - 0.5) * 0.08 + Math.sin(x * 0.9 + lanes[x] * 9) * 0.05;
}
return normalFromHeight(h, S, 0.55);
}
function makeCarbonNormal() { // 2x2 twill weave
const S = 512, cell = 32;
const h = new Float32Array(S * S);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const cx = Math.floor(x / cell), cy = Math.floor(y / cell);
const over = ((cx + cy) >> 1 & 1) === 0;
const fx = (x % cell) / cell, fy = (y % cell) / cell;
const along = over ? fy : fx, across = over ? fx : fy;
h[y * S + x] = Math.sin(Math.PI * across) * 0.8 + Math.sin(Math.PI * along * 4) * 0.06 + (over ? 0.16 : 0);
}
return normalFromHeight(h, S, 1.9);
}
function makeGrainNormal() { // fine moulded-plastic grain
const S = 256, R = mulberry32(SEED + 47);
const n = tileNoise(S, 64, 3, R);
return normalFromHeight(n, S, 0.7);
}
function makeSeatNormal() { // seat vinyl pebble + stitch
const S = 512, R = mulberry32(SEED + 53);
const n = tileNoise(S, 96, 2, R);
const h = new Float32Array(S * S);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) h[y * S + x] = n[y * S + x] * 0.85;
return normalFromHeight(h, S, 1.4);
}
function makeKnurlNormal() { // diamond knurl (pegs, bar ends)
const S = 256, cell = 14;
const h = new Float32Array(S * S);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const a = ((x + y) % cell) / cell, b = ((x - y + S * 4) % cell) / cell;
h[y * S + x] = Math.min(Math.sin(a * Math.PI), Math.sin(b * Math.PI));
}
return normalFromHeight(h, S, 2.6);
}
/* --- 3.2 decals ----------------------------------------------------------- */
function decalCanvas(w, h, draw) {
const [c, x] = canvas2d(w, h);
x.clearRect(0, 0, w, h);
draw(x, w, h);
return c;
}
function zx6rDecal() {
return decalCanvas(1024, 320, (x, w, h) => {
x.save();
x.translate(w * 0.5, h * 0.62);
x.transform(1, 0, -0.26, 1, 0, 0); // italic lean
x.font = "800 210px 'Arial Narrow', 'Helvetica Neue', Arial, sans-serif";
x.textAlign = "center"; x.textBaseline = "alphabetic";
const g = x.createLinearGradient(0, -150, 0, 60);
g.addColorStop(0, "#f2f4f6"); g.addColorStop(0.52, "#c3c8cd"); g.addColorStop(1, "#8d949b");
x.fillStyle = g;
x.scale(0.86, 1);
x.fillText("ZX-6R", 0, 0);
x.restore();
});
}
function kawasakiDecal(light = true) {
return decalCanvas(1024, 200, (x, w, h) => {
x.save();
x.translate(w * 0.5, h * 0.74);
x.transform(1, 0, -0.10, 1, 0, 0);
x.font = "700 138px 'Arial Narrow', 'Helvetica Neue', Arial, sans-serif";
x.textAlign = "center"; x.textBaseline = "alphabetic";
x.fillStyle = light ? "#eef1f4" : "#101215";
x.scale(1.02, 1);
x.fillText("Kawasaki", 0, 0);
x.restore();
});
}
function ninjaDecal() {
return decalCanvas(768, 220, (x, w, h) => {
x.save();
x.translate(w * 0.5, h * 0.72);
x.transform(1, 0, -0.22, 1, 0, 0);
x.font = "800 150px 'Arial Narrow', 'Helvetica Neue', Arial, sans-serif";
x.textAlign = "center";
x.fillStyle = "#e7ebee";
x.fillText("Ninja", 0, 0);
x.restore();
});
}
/* tyre: sidewall lettering + shoulder siping, mapped u=circumference v=section */
function tyreMaps() {
// canvas aspect matches the real surface: ~1.88 m of circumference against
// ~0.34 m of section arc, so lettering is not stretched by the mapping
const W = 2048, H = 288;
const [c, x] = canvas2d(W, H);
x.fillStyle = "#0b0b0d"; x.fillRect(0, 0, W, H);
const R = mulberry32(SEED + 71);
// subtle mould-release mottling across the whole carcass
for (let i = 0; i < 2600; i++) {
const v = 8 + R() * 14;
x.fillStyle = `rgba(${v + 12},${v + 12},${v + 14},0.5)`;
x.beginPath(); x.arc(R() * W, R() * H, 2 + R() * 14, 0, TAU); x.fill();
}
// moulded rings bracketing the sidewall lettering band
const ring = (v0, v1, fill) => { x.fillStyle = fill; x.fillRect(0, v0 * H, W, (v1 - v0) * H); };
ring(0.052, 0.060, "rgba(140,140,146,0.16)");
ring(0.160, 0.168, "rgba(140,140,146,0.13)");
ring(0.832, 0.840, "rgba(140,140,146,0.13)");
ring(0.940, 0.948, "rgba(140,140,146,0.16)");
const label = (vc, text, size, alpha, repeats, spacing = 0) => {
x.save();
x.font = `700 ${size}px 'Arial Narrow', Arial, sans-serif`;
x.textAlign = "center"; x.textBaseline = "middle";
x.fillStyle = `rgba(188,190,194,${alpha})`;
if (spacing) x.letterSpacing = spacing + "px";
for (let i = 0; i < repeats; i++) x.fillText(text, (i + 0.5) * (W / repeats), vc * H);
x.restore();
};
label(0.098, "BRIDGESTONE", 16, 0.92, 2, 1.2);
label(0.134, "BATTLAX HYPERSPORT S22", 10, 0.55, 2, 0.5);
label(0.902, "BRIDGESTONE", 16, 0.92, 2, 1.2);
label(0.866, "TUBELESS RADIAL", 9, 0.5, 2, 0.7);
const tex = canvasTexture(c, { srgb: true, aniso: 16 });
// matching micro-relief: fine grain + raised lettering band
const S = 512, hgt = new Float32Array(S * S);
const NR = mulberry32(SEED + 73);
const n = tileNoise(S, 128, 2, NR);
for (let y = 0; y < S; y++) for (let x2 = 0; x2 < S; x2++) hgt[y * S + x2] = n[y * S + x2] * 0.8;
return { map: tex, normal: normalFromHeight(hgt, S, 1.1) };
}
/* wire mesh for radiator core and fairing vents */
function meshScreen(pitch = 26, bar = 9) {
const S = 256;
const [c, x] = canvas2d(S, S);
x.fillStyle = "rgba(0,0,0,0)"; x.clearRect(0, 0, S, S);
x.fillStyle = "#1a1c20";
for (let i = 0; i < S; i += pitch) { x.fillRect(i, 0, bar, S); x.fillRect(0, i, S, bar); }
return canvasTexture(c, { srgb: true });
}
/* radiator fin strip (vertical louvres) */
function radiatorCore() {
const S = 512;
const [c, x] = canvas2d(S, S);
x.fillStyle = "#0a0b0c"; x.fillRect(0, 0, S, S);
for (let i = 0; i < S; i += 7) {
x.fillStyle = i % 14 === 0 ? "#22262b" : "#131518";
x.fillRect(i, 0, 3.4, S);
}
for (let j = 0; j < S; j += 46) { x.fillStyle = "#2c3138"; x.fillRect(0, j, S, 4); }
return canvasTexture(c, { srgb: true, repeat: [1, 1] });
}
/* LED headlight reflector cells */
function ledFace() {
const S = 512;
const [c, x] = canvas2d(S, S);
const g = x.createLinearGradient(0, 0, 0, S);
g.addColorStop(0, "#20242a"); g.addColorStop(1, "#0c0e11");
x.fillStyle = g; x.fillRect(0, 0, S, S);
for (let y = 0; y < S; y += 18) for (let x2 = 0; x2 < S; x2 += 18) {
const d = Math.hypot(x2 - S / 2, y - S / 2) / (S / 2);
const v = Math.max(0, 1 - d) * 210;
x.fillStyle = `rgb(${v * 0.86},${v * 0.92},${v})`;
x.beginPath(); x.arc(x2 + 9, y + 9, 7.4, 0, TAU); x.fill();
}
return canvasTexture(c, { srgb: true, wrap: THREE.ClampToEdgeWrapping });
}
/* --- 3.3 studio environment ---------------------------------------------
A generated equirectangular softbox rig. The whole machine is black
lacquer and dark alloy: essentially all of its shape information arrives as
reflected light, so the environment is a modelling tool, not a backdrop. */
function studioEnvTexture() {
const W = 1024, H = 512;
const data = new Float32Array(W * H * 4);
const boxes = [
// azimuth, elevation, half-extents (deg), intensity, tint, edge softness
{ az: 28, el: 58, aw: 60, ah: 25, i: 6.4, c: [1.00, 0.99, 0.96], s: 0.40 },
{ az: -150, el: 52, aw: 56, ah: 22, i: 3.1, c: [0.96, 0.98, 1.00], s: 0.46 },
{ az: 92, el: 6, aw: 20, ah: 40, i: 2.5, c: [1.00, 1.00, 1.00], s: 0.52 },
{ az: -88, el: 10, aw: 24, ah: 42, i: 1.7, c: [0.95, 0.97, 1.00], s: 0.52 },
{ az: 176, el: 22, aw: 30, ah: 26, i: 2.2, c: [1.00, 0.99, 0.98], s: 0.55 },
{ az: 0, el: -4, aw: 34, ah: 14, i: 0.8, c: [1.00, 1.00, 1.00], s: 0.7 },
];
const dAz = (a, b) => { let d = a - b; while (d > 180) d -= 360; while (d < -180) d += 360; return d; };
for (let y = 0; y < H; y++) {
const el = (0.5 - (y + 0.5) / H) * 180; // +90 zenith .. -90 nadir
// studio shell: bright ceiling, luminous horizon, grey sweep floor.
// Kept near unit average so a white diffuse surface tone-maps to white and
// polished metal reflects a readable box rather than a clipped sheet.
let base = el > 0
? lerp(0.46, 0.92, ease(el / 90, 0.7))
: lerp(0.46, 0.085, ease(-el / 90, 0.55));
for (let x = 0; x < W; x++) {
const az = ((x + 0.5) / W) * 360 - 180;
let r = base, g = base, b = base * 1.015;
for (const S of boxes) {
const u = Math.abs(dAz(az, S.az)) / S.aw;
const v = Math.abs(el - S.el) / S.ah;
const d = Math.hypot(Math.pow(u, 1.7), Math.pow(v, 1.7));
const k = 1 - smooth01(clampf((d - (1 - S.s)) / S.s, 0, 1));
if (k <= 0) continue;
const w = k * k * S.i;
r += w * S.c[0]; g += w * S.c[1]; b += w * S.c[2];
}
const i = (y * W + x) * 4;
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 1;
}
}
const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat, THREE.FloatType);
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.colorSpace = THREE.LinearSRGBColorSpace;
tex.minFilter = tex.magFilter = THREE.LinearFilter;
tex.generateMipmaps = false;
tex.needsUpdate = true;
return keep(tex);
}
/* --- 3.4 material bundles ------------------------------------------------ */
const MAT = {};
function buildMaterials() {
const flakeN = makeFlakeNormal();
const castN = makeCastNormal();
const brushN = makeBrushNormal();
const carbonN = makeCarbonNormal();
const grainN = makeGrainNormal();
const seatN = makeSeatNormal();
const knurlN = makeKnurlNormal();
const tyre = tyreMaps();
const rep = (t, u, v) => { const c = t.clone(); c.wrapS = c.wrapT = THREE.RepeatWrapping; c.repeat.set(u, v); c.needsUpdate = true; return keep(c); };
const wnoise = (s) => mx_noise_float(positionWorld.mul(s));
const tag = (m, slot) => { m.userData.slot = slot; MAT[slot] = MAT[slot] || m; return m; };
/* -- flat "Metallic Spark Black": aluminium flake under a matte clear -- */
const flatBlack = new THREE.MeshPhysicalNodeMaterial();
flatBlack.colorNode = mix(color(PAL.fairing), color(PAL.fairingDeep), wnoise(3.2).mul(0.5).add(0.5).mul(0.55));
// flake is metallic, binder is not: a low base metalness with a sparse
// high-frequency lift reads as suspended aluminium under a matte clear
flatBlack.metalnessNode = float(0.12).add(wnoise(190).mul(0.5).add(0.5).pow(3).mul(0.30));
flatBlack.roughnessNode = float(0.415).add(wnoise(52).mul(0.05));
flatBlack.clearcoat = 0.62;
flatBlack.clearcoatRoughness = 0.30;
flatBlack.normalMap = rep(flakeN, 22, 22);
flatBlack.normalScale = new THREE.Vector2(0.10, 0.10);
tag(flatBlack, "paintFlat");
/* -- satin black on cast alloy: wheels, hubs, chain guard ------------- */
const wheelBlack = new THREE.MeshPhysicalNodeMaterial();
wheelBlack.colorNode = mix(color(0x101216), color(0x07080a), wnoise(6).mul(0.5).add(0.5).mul(0.6));
wheelBlack.metalness = 0.24;
wheelBlack.roughnessNode = float(0.325).add(wnoise(58).mul(0.04));
wheelBlack.clearcoat = 0.55;
wheelBlack.clearcoatRoughness = 0.20;
wheelBlack.normalMap = rep(castN, 7, 7);
wheelBlack.normalScale = new THREE.Vector2(0.12, 0.12);
tag(wheelBlack, "wheelBlack");
/* -- gloss lacquer: tank cover, upper cowl crown, tail spine ---------- */
const glossBlack = new THREE.MeshPhysicalNodeMaterial();
glossBlack.colorNode = mix(color(PAL.glossBlack), color(0x1a1e24), wnoise(2.4).mul(0.5).add(0.5).mul(0.3));
glossBlack.metalness = 0.28;
glossBlack.roughnessNode = float(0.085).add(wnoise(64).mul(0.018));
glossBlack.clearcoat = 1.0;
glossBlack.clearcoatRoughness = 0.035;
glossBlack.normalMap = rep(flakeN, 26, 26);
glossBlack.normalScale = new THREE.Vector2(0.05, 0.05);
tag(glossBlack, "paintGloss");
/* -- charcoal graphic panel: the mid-grey wedge on nose and flank ----- */
const graphite = new THREE.MeshPhysicalNodeMaterial();
graphite.colorNode = mix(color(0x2a2e34), color(0x1b1e23), wnoise(4).mul(0.5).add(0.5));
graphite.metalness = 0.42;
graphite.roughnessNode = float(0.30).add(wnoise(60).mul(0.03));
graphite.clearcoat = 0.85; graphite.clearcoatRoughness = 0.12;
graphite.normalMap = rep(flakeN, 24, 24);
graphite.normalScale = new THREE.Vector2(0.09, 0.09);
tag(graphite, "paintGraphite");
/* -- Kawasaki lime: rim pinstripe and accent flashes ------------------ */
const lime = new THREE.MeshPhysicalNodeMaterial();
lime.colorNode = color(PAL.green);
lime.metalness = 0.1; lime.roughness = 0.16;
lime.clearcoat = 1.0; lime.clearcoatRoughness = 0.05;
tag(lime, "lime");
/* -- unpainted structural alloy: frame, swingarm, triples ------------- */
const anodized = new THREE.MeshPhysicalNodeMaterial();
anodized.colorNode = mix(color(PAL.alloyDark), color(0x181b1f), mx_fractal_noise_float(positionWorld.mul(9), 3, 2.1, 0.5).mul(0.5).add(0.5).mul(0.7));
anodized.metalness = 0.94;
anodized.roughnessNode = float(0.335).add(wnoise(38).mul(0.055));
anodized.normalMap = rep(castN, 5, 5);
anodized.normalScale = new THREE.Vector2(0.34, 0.34);
tag(anodized, "alloyAnodized");
/* -- sand-cast engine alloy ------------------------------------------- */
const castAlloy = new THREE.MeshPhysicalNodeMaterial();
castAlloy.colorNode = mix(color(0x4a4e54), color(0x2c2f34), mx_fractal_noise_float(positionWorld.mul(14), 3, 2.2, 0.55).mul(0.5).add(0.5));
castAlloy.metalness = 0.88;
castAlloy.roughnessNode = float(0.545).add(wnoise(26).mul(0.075));
castAlloy.normalMap = rep(castN, 9, 9);
castAlloy.normalScale = new THREE.Vector2(0.75, 0.75);
tag(castAlloy, "alloyCast");
/* -- machined/brushed alloy: fork sliders, yokes, bar ends ------------ */
const machined = new THREE.MeshPhysicalNodeMaterial();
machined.colorNode = color(PAL.brightAlloy);
machined.metalness = 1.0;
machined.roughnessNode = float(0.215).add(wnoise(70).mul(0.035));
machined.normalMap = rep(brushN, 4, 16);
machined.normalScale = new THREE.Vector2(0.13, 0.035);
tag(machined, "alloyMachined");
/* -- hard chrome fork stanchion --------------------------------------- */
const chrome = new THREE.MeshPhysicalNodeMaterial();
chrome.colorNode = color(PAL.chrome);
chrome.metalness = 1.0; chrome.roughness = 0.045;
chrome.envMapIntensity = 1.25;
tag(chrome, "chrome");
/* -- brake rotor: bright steel, radial brushing, hole field ----------- */
const rotor = new THREE.MeshPhysicalNodeMaterial();
rotor.colorNode = mix(color(0x8f949b), color(0x6c7178), wnoise(40).mul(0.5).add(0.5).mul(0.55));
rotor.metalness = 1.0;
rotor.roughnessNode = float(0.30).add(wnoise(120).mul(0.06));
rotor.normalMap = rep(brushN, 26, 1);
rotor.normalScale = new THREE.Vector2(0.10, 0.22);
tag(rotor, "rotorSteel");
/* -- rotor carrier (hard-anodised inner spider) ----------------------- */
const carrier = new THREE.MeshPhysicalNodeMaterial();
carrier.colorNode = color(0x24272b);
carrier.metalness = 0.95; carrier.roughness = 0.30;
tag(carrier, "rotorCarrier");
/* -- brushed titanium silencer with heat tint ------------------------- */
const titanium = new THREE.MeshPhysicalNodeMaterial();
const heat = positionWorld.z.add(0.34).mul(2.2).clamp(0, 1);
// stock silencer: dark anodised shell, brighter only near the outlet
titanium.colorNode = mix(color(0x33373d), color(0x767b83), heat)
.mul(mix(vec3(1.02, 0.97, 0.93), vec3(1, 1, 1), heat));
titanium.metalness = 1.0;
titanium.roughnessNode = float(0.255).add(wnoise(90).mul(0.05));
titanium.normalMap = rep(brushN, 2, 26);
titanium.normalScale = new THREE.Vector2(0.06, 0.26);
tag(titanium, "titanium");
/* -- exhaust header: heat-stained stainless -------------------------- */
const header = new THREE.MeshPhysicalNodeMaterial();
const stain = mx_fractal_noise_float(positionWorld.mul(11), 3, 2.3, 0.5).mul(0.5).add(0.5);
header.colorNode = mix(color(0x7c7f84), color(0x54484a), stain.mul(0.85));
header.metalness = 1.0;
header.roughnessNode = float(0.255).add(stain.mul(0.12));
header.normalMap = rep(brushN, 2, 18);
header.normalScale = new THREE.Vector2(0.05, 0.16);
tag(header, "header");
/* -- textured black moulding: inner panels, plate hanger, guards ------ */
const plastic = new THREE.MeshPhysicalNodeMaterial();
plastic.colorNode = color(0x131518);
plastic.metalness = 0.0;
plastic.roughnessNode = float(0.62).add(wnoise(48).mul(0.06));
plastic.normalMap = rep(grainN, 14, 14);
plastic.normalScale = new THREE.Vector2(0.5, 0.5);
tag(plastic, "plastic");
/* -- soft rubber: grips, hoses, boots -------------------------------- */
const rubber = new THREE.MeshPhysicalNodeMaterial();
rubber.colorNode = color(0x0e0f11);
rubber.metalness = 0.0; rubber.roughness = 0.78;
rubber.normalMap = rep(grainN, 8, 8);
rubber.normalScale = new THREE.Vector2(0.6, 0.6);
tag(rubber, "rubber");
/* -- tyre carcass ----------------------------------------------------- */
const tyreMat = new THREE.MeshPhysicalNodeMaterial();
tyreMat.map = tyre.map;
tyreMat.colorNode = texture(tyre.map, uv()).rgb.mul(mix(float(0.82), float(1.06), wnoise(24).mul(0.5).add(0.5)));
tyreMat.metalness = 0.0;
tyreMat.roughnessNode = float(0.80).add(wnoise(70).mul(0.06));
tyreMat.normalMap = rep(tyre.normal, 12, 3);
tyreMat.normalScale = new THREE.Vector2(0.55, 0.55);
// a trace of sheen only: more than this reads as waxy or translucent rubber
tyreMat.sheen = 0.06; tyreMat.sheenRoughness = 0.9;
tyreMat.sheenColor = new THREE.Color(0x191b1e);
tag(tyreMat, "tyre");
/* -- seat vinyl ------------------------------------------------------- */
const seat = new THREE.MeshPhysicalNodeMaterial();
seat.colorNode = color(PAL.seat);
seat.metalness = 0.0;
seat.roughnessNode = float(0.545).add(wnoise(80).mul(0.05));
seat.normalMap = rep(seatN, 9, 9);
seat.normalScale = new THREE.Vector2(0.8, 0.8);
seat.sheen = 0.55; seat.sheenRoughness = 0.55;
seat.sheenColor = new THREE.Color(0x3a3f46);
seat.clearcoat = 0.2; seat.clearcoatRoughness = 0.55;
tag(seat, "seat");
/* -- carbon twill ----------------------------------------------------- */
const carbon = new THREE.MeshPhysicalNodeMaterial();
carbon.colorNode = color(0x121418);
carbon.metalness = 0.55;
carbon.roughness = 0.22;
carbon.clearcoat = 1.0; carbon.clearcoatRoughness = 0.06;
carbon.normalMap = rep(carbonN, 10, 10);
carbon.normalScale = new THREE.Vector2(0.7, 0.7);
tag(carbon, "carbon");
/* -- knurled contact surfaces ---------------------------------------- */
const knurled = new THREE.MeshPhysicalNodeMaterial();
knurled.colorNode = color(0x2b2e33);
knurled.metalness = 0.9; knurled.roughness = 0.42;
knurled.normalMap = rep(knurlN, 6, 3);
knurled.normalScale = new THREE.Vector2(1.0, 1.0);
tag(knurled, "knurled");
/* -- fasteners -------------------------------------------------------- */
const bolt = new THREE.MeshPhysicalNodeMaterial();
bolt.colorNode = color(0x555a61);
bolt.metalness = 1.0; bolt.roughness = 0.30;
tag(bolt, "bolt");
const boltDark = new THREE.MeshPhysicalNodeMaterial();
boltDark.colorNode = color(0x1c1f23);
boltDark.metalness = 0.95; boltDark.roughness = 0.36;
tag(boltDark, "boltDark");
/* -- drive chain ------------------------------------------------------ */
const chain = new THREE.MeshPhysicalNodeMaterial();
chain.colorNode = mix(color(0x4e5259), color(0x2a2d31), wnoise(160).mul(0.5).add(0.5));
chain.metalness = 1.0; chain.roughness = 0.36;
tag(chain, "chain");
const chainGold = new THREE.MeshPhysicalNodeMaterial();
chainGold.colorNode = color(0x9a7c3e);
chainGold.metalness = 1.0; chainGold.roughness = 0.34;
tag(chainGold, "chainGold");
/* -- glazing ----------------------------------------------------------
Alpha-blended rather than transmissive on purpose: a single transmissive
material forces an extra full-scene pass per frame, and for thin tinted
covers at this scale the refraction it buys is not visible. */
const headGlass = new THREE.MeshPhysicalNodeMaterial();
headGlass.colorNode = color(0xb9c6d2);
headGlass.opacity = 0.30;
headGlass.transparent = true;
headGlass.depthWrite = false;
headGlass.roughness = 0.035; headGlass.metalness = 0.0;
headGlass.clearcoat = 1.0; headGlass.clearcoatRoughness = 0.02;
headGlass.side = THREE.DoubleSide;
tag(headGlass, "glass");
const screen = new THREE.MeshPhysicalNodeMaterial();
screen.colorNode = color(0x39424d);
screen.opacity = 0.34;
screen.transparent = true;
screen.depthWrite = false;
screen.roughness = 0.035; screen.metalness = 0.0;
screen.clearcoat = 1.0; screen.clearcoatRoughness = 0.02;
screen.envMapIntensity = 1.5;
screen.side = THREE.DoubleSide;
tag(screen, "screen");
const redLens = new THREE.MeshPhysicalNodeMaterial();
redLens.colorNode = color(0x5a0a0f);
redLens.emissiveNode = color(0xff2a18).mul(0.30);
redLens.metalness = 0.0; redLens.roughness = 0.085;
redLens.clearcoat = 1.0; redLens.clearcoatRoughness = 0.03;
redLens.opacity = 0.92; redLens.transparent = true;
tag(redLens, "lensRed");
const amberLens = new THREE.MeshPhysicalNodeMaterial();
amberLens.colorNode = color(0x8a4406);
amberLens.metalness = 0.0; amberLens.roughness = 0.10;
amberLens.clearcoat = 1.0; amberLens.clearcoatRoughness = 0.04;
amberLens.opacity = 0.90; amberLens.transparent = true;
tag(amberLens, "lensAmber");
/* -- LED emitters ----------------------------------------------------- */
const ledCold = new THREE.MeshPhysicalNodeMaterial();
ledCold.colorNode = color(0x0e1116);
ledCold.emissiveNode = color(0xdfeaff).mul(0.18);
ledCold.metalness = 0.2; ledCold.roughness = 0.22;
tag(ledCold, "led");
const reflector = new THREE.MeshPhysicalNodeMaterial();
reflector.colorNode = texture(ledFace(), uv()).rgb;
reflector.metalness = 0.92; reflector.roughness = 0.13;
tag(reflector, "reflector");
/* -- radiator core, screens ------------------------------------------- */
const radCore = new THREE.MeshPhysicalNodeMaterial();
radCore.colorNode = texture(radiatorCore(), uv().mul(vec2(6, 1))).rgb;
radCore.metalness = 0.7; radCore.roughness = 0.62;
tag(radCore, "radiator");
const mesh = new THREE.MeshPhysicalNodeMaterial();
const meshTex = meshScreen();
mesh.colorNode = color(0x0b0d0f);
mesh.opacityNode = texture(meshTex, uv().mul(10)).a.mul(0.92).add(0.08);
mesh.transparent = true;
mesh.metalness = 0.6; mesh.roughness = 0.55;
mesh.side = THREE.DoubleSide;
tag(mesh, "screenMesh");
/* -- instrument face --------------------------------------------------- */
const gauge = new THREE.MeshPhysicalNodeMaterial();
gauge.colorNode = color(0x05060a);
gauge.emissiveNode = color(0x0a1520).mul(0.6);
gauge.roughness = 0.12; gauge.metalness = 0.0;
gauge.clearcoat = 1.0; gauge.clearcoatRoughness = 0.02;
tag(gauge, "gauge");
/* -- decal materials (conformal patches lifted off the parent surface) - */
const decal = (canvas, { rough = 0.20, cc = 1.0 } = {}) => {
const m = new THREE.MeshPhysicalNodeMaterial();
const t = canvasTexture(canvas, { srgb: true, aniso: 16, wrap: THREE.ClampToEdgeWrapping });
m.colorNode = texture(t, uv()).rgb;
m.opacityNode = texture(t, uv()).a;
m.transparent = true;
m.metalness = 0.25; m.roughness = rough;
m.clearcoat = cc; m.clearcoatRoughness = 0.06;
m.polygonOffset = true; m.polygonOffsetFactor = -2; m.polygonOffsetUnits = -2;
m.side = THREE.DoubleSide;
m.userData.slot = "decal";
return m;
};
MAT.decalZX6R = decal(zx6rDecal());
MAT.decalKawasaki = decal(kawasakiDecal(true));
MAT.decalKawasakiDark = decal(kawasakiDecal(false), { rough: 0.28 });
const plateFace = new THREE.MeshPhysicalNodeMaterial();
plateFace.colorNode = color(0xd9dbde);
plateFace.metalness = 0.0;
plateFace.roughnessNode = float(0.38).add(wnoise(180).mul(0.06));
plateFace.clearcoat = 0.5; plateFace.clearcoatRoughness = 0.25;
tag(plateFace, "plate");
MAT.decalNinja = decal(ninjaDecal());
/* -- studio floor ------------------------------------------------------ */
const floor = new THREE.MeshStandardNodeMaterial();
floor.colorNode = color(0xffffff);
floor.roughness = 0.62; floor.metalness = 0.0;
const rad = positionWorld.xz.length();
floor.opacityNode = rad.smoothstep(1.85, 4.60).oneMinus();
floor.transparent = true;
floor.userData.slot = "floor";
MAT.floor = floor;
return MAT;
}
export {
MAT, TEX, buildMaterials, canvas2d, canvasTexture, decalCanvas, kawasakiDecal, keep, ledFace, makeBrushNormal, makeCarbonNormal, makeCastNormal, makeFlakeNormal, makeGrainNormal, makeKnurlNormal, makeSeatNormal, meshScreen, ninjaDecal, normalFromHeight, radiatorCore, studioEnvTexture, tileNoise, tyreMaps, zx6rDecal,
};
examples/sport-motorcycle/source/motorcycle-model.js
import * as THREE from "three/webgpu";
import { color, transformedNormalWorld } from "three/tsl";
import { D, DEG, seg } from "./design-contract.js";
import {
M, PARTS, TAU, V2, Writer, add, filletBox, instance, invertedCount, resetInvertedCount,
revolve,
} from "./mesh-kit.js";
import { MAT, TEX, buildMaterials } from "./motorcycle-materials.js";
import { buildBody, buildControls, buildEngine, buildFrame, buildFrontEnd, buildSwingarm, hexBolt, makeCaliper, makeChain, makeRotor, makeSprocket, makeTyre, makeWheel } from "./motorcycle-parts.js";
/* ============================================================================
7. ASSEMBLY
----------------------------------------------------------------------------
Named subassemblies, each fed only by the design contract.
========================================================================== */
function assembleRunning(root) {
const g = new THREE.Group(); g.name = "running";
root.add(g);
/* -- front wheel ---------------------------------------------------- */
const fw = new THREE.Group();
fw.name = "wheel.front";
fw.position.set(0, D.front.axle[1], D.front.axle[0]);
g.add(fw);
fw.add(makeWheel({
rimW: D.front.rimW, seatR: D.front.rimR, hubHalf: 0.0525, hubR: 0.0455,
boreR: 0.0132, dishOut: 0.004, name: "wheel.front",
}));
fw.add(makeTyre({
sectionW: D.front.tyreW, outerR: D.front.tyreR, seatR: D.front.rimR,
rimW: D.front.rimW, name: "tyre.front",
}));
for (const s of [1, -1]) {
const rot = makeRotor({
outerR: D.front.discR, innerR: 0.1175, thick: D.front.discT,
hubR: 0.0465, boltR: 0.0740, name: "rotor.front" + (s > 0 ? "R" : "L"), side: s,
});
rot.position.x = s * 0.0725;
rot.scale.x = s;
fw.add(rot);
}
// axle + spacers
fw.add(new THREE.Mesh(revolve([
V2(0, -0.108), V2(0.0125, -0.112), V2(0.0125, 0.112), V2(0, 0.108)],
{ axis: "x", segments: seg(24), capStart: true, capEnd: true }), MAT.bolt));
fw.children[fw.children.length - 1].name = "axle.front";
fw.children[fw.children.length - 1].userData.slot = "bolt";
/* -- rear wheel ------------------------------------------------------ */
const rw = new THREE.Group();
rw.name = "wheel.rear";
rw.position.set(0, D.rear.axle[1], D.rear.axle[0]);
g.add(rw);
rw.add(makeWheel({
rimW: D.rear.rimW, seatR: D.rear.rimR, hubHalf: 0.0755, hubR: 0.0505,
boreR: 0.0132, dishOut: -0.010, armDepth: 1.22, name: "wheel.rear",
}));
rw.add(makeTyre({
sectionW: D.rear.tyreW, outerR: D.rear.tyreR, seatR: D.rear.rimR,
rimW: D.rear.rimW, name: "tyre.rear",
}));
const rrot = makeRotor({
outerR: D.rear.discR, innerR: 0.0790, thick: D.rear.discT,
hubR: 0.0400, boltR: 0.0560, name: "rotor.rear", carrierArms: 4,
});
rrot.position.x = 0.0665;
rw.add(rrot);
/* cush-drive carrier + sprocket on the left */
const carrier = new Writer();
revolve([
V2(0.052, -0.0755), V2(0.088, -0.0790), V2(0.092, -0.0880), V2(0.090, -0.0930),
V2(0.062, -0.0940), V2(0.052, -0.0900),
], { axis: "x", segments: seg(48), w: carrier });
add(rw, carrier.geometry(), MAT.alloyCast, "wheel.rear.cush");
const spr = makeSprocket({
teeth: 43, pitchR: D.rear.sprocketR, thick: D.rear.sprocketT, boreR: 0.062,
name: "sprocket.rear", mat: MAT.alloyMachined,
});
spr.position.x = -0.0876;
rw.add(spr);
const sBolts = [];
for (let i = 0; i < 6; i++) {
const a = (i / 6) * TAU + 0.4;
sBolts.push(M.c(M.t(-0.0925, Math.sin(a) * 0.0605, Math.cos(a) * 0.0605), M.rz(Math.PI / 2)));
}
instance(rw, hexBolt(0.0058, 0.0048), MAT.boltDark, sBolts, "sprocket.bolts");
rw.add(new THREE.Mesh(revolve([
V2(0, -0.126), V2(0.0128, -0.130), V2(0.0128, 0.118), V2(0, 0.114)],
{ axis: "x", segments: seg(24), capStart: true, capEnd: true }), MAT.bolt));
rw.children[rw.children.length - 1].name = "axle.rear";
rw.children[rw.children.length - 1].userData.slot = "bolt";
/* -- chain ------------------------------------------------------------ */
g.add(makeChain({
cA: D.countershaft, rA: D.frontSprocketR, cB: D.rear.axle, rB: D.rear.sprocketR,
x: -0.0876, pitch: D.chainPitch, name: "chain",
}));
return { g, fw, rw };
}
function assembleBrakes(root) {
const g = new THREE.Group(); g.name = "brakes"; root.add(g);
/* front: radial-mount monoblocs trailing the fork legs */
for (const s of [1, -1]) {
g.add(makeCaliper({
axleY: D.front.axle[1], axleZ: D.front.axle[0], discX: s * 0.0725,
phi: 135 * DEG, span: 21 * DEG, rIn: 0.116, rOut: 0.153,
halfGap: 0.0082, thick: 0.021, name: "caliper.front" + (s > 0 ? "R" : "L"),
}));
}
/* rear: single-piston sliding caliper on the swingarm torque arm */
g.add(makeCaliper({
axleY: D.rear.axle[1], axleZ: D.rear.axle[0], discX: 0.0665,
phi: 166 * DEG, span: 17 * DEG, rIn: 0.076, rOut: 0.108,
halfGap: 0.0078, thick: 0.019, name: "caliper.rear", mount: "bracket",
}));
add(g, filletBox(0.014, 0.030, 0.062, 0.007, { segments: seg(16), rings: seg(6) })
.translate(0.0865, D.rear.axle[1] + 0.026, D.rear.axle[0] + 0.052),
MAT.alloyAnodized, "caliper.rearBracket");
return g;
}
function buildMotorcycle() {
const root = new THREE.Group();
root.name = "zx6r";
buildFrame(root);
buildEngine(root);
buildSwingarm(root);
buildFrontEnd(root);
assembleRunning(root);
assembleBrakes(root);
buildControls(root);
buildBody(root);
return root;
}
export {
assembleBrakes, assembleRunning, buildMotorcycle,
};
/* ============================================================================
INSTANCE API
----------------------------------------------------------------------------
One call builds materials, emits every subassembly, and returns the machine
with its part registry, measured envelope, and the diagnostic modes the
surfaces are authored against.
========================================================================== */
let wireMaterial = null;
let normalMaterial = null;
const slotMaterials = {};
function buildDebugMaterials() {
if (wireMaterial) return;
wireMaterial = new THREE.MeshBasicNodeMaterial({ wireframe: true });
wireMaterial.colorNode = color(0x2b6cb0);
normalMaterial = new THREE.MeshBasicNodeMaterial();
normalMaterial.colorNode = transformedNormalWorld.mul(0.5).add(0.5);
}
/** Stable hue per material slot, so a slot keeps its colour across rebuilds. */
function slotMaterial(slot) {
if (!slotMaterials[slot]) {
let h = 0;
for (let i = 0; i < slot.length; i++) h = (h * 31 + slot.charCodeAt(i)) >>> 0;
const c = new THREE.Color().setHSL((h % 997) / 997, 0.62, 0.55);
const m = new THREE.MeshBasicNodeMaterial();
m.colorNode = color(c);
slotMaterials[slot] = m;
}
return slotMaterials[slot];
}
export function createSportMotorcycle() {
const materials = buildMaterials();
const partStart = PARTS.length;
resetInvertedCount();
const root = buildMotorcycle();
buildDebugMaterials();
const stats = PARTS.slice(partStart);
const totalTriangles = Math.round(stats.reduce((sum, p) => sum + p.tris, 0));
const trianglesBySlot = {};
for (const p of stats) trianglesBySlot[p.slot] = (trianglesBySlot[p.slot] ?? 0) + p.tris;
const invertedFixups = invertedCount();
const savedMaterials = new Map();
const bounds = new THREE.Box3().setFromObject(root);
return {
object: root,
materials,
stats,
totalTriangles,
trianglesBySlot,
/** Closed bodies the orientation guard had to flip. */
invertedFixups,
/** Measured envelope, for checking the model against the dimension contract. */
measure() {
return {
length: +(bounds.max.z - bounds.min.z).toFixed(3),
width: +(bounds.max.x - bounds.min.x).toFixed(3),
height: +(bounds.max.y - bounds.min.y).toFixed(3),
wheelbase: +(D.front.axle[0] - D.rear.axle[0]).toFixed(3),
seatHeight: D.seatHeight,
};
},
/**
* final | topology | slots | normals | nobody | noglass.
* `slots` is the load-bearing one: it colours every mesh by its material
* slot, which is how a black-on-black machine is checked for finish
* ownership at all.
*/
setDebugMode(mode) {
root.traverse((o) => {
if (!o.isMesh) return;
if (!savedMaterials.has(o)) savedMaterials.set(o, o.material);
const base = savedMaterials.get(o);
const slot = o.userData.slot || "misc";
o.visible = true;
if (mode === "topology") o.material = wireMaterial;
else if (mode === "slots") o.material = slotMaterial(slot);
else if (mode === "normals") o.material = normalMaterial;
else {
o.material = base;
if (mode === "nobody") o.visible = !/^body\./.test(o.name);
if (mode === "noglass") o.visible = !(slot === "glass" || slot === "screen");
}
});
},
dispose() {
root.traverse((o) => { if (o.isMesh) o.geometry.dispose(); });
for (const m of Object.values(materials)) m.dispose?.();
for (const t of TEX) t.dispose?.();
PARTS.length = partStart;
},
};
}
examples/sport-motorcycle/source/motorcycle-parts.js
import * as THREE from "three/webgpu";
import { texture, uv } from "three/tsl";
import { AX, D, DEG, forkPt, seg, steerPt } from "./design-contract.js";
import { M, PARTS, TAU, V2, V3, Writer, add, arcLengthV, catmull2, catmull3, circlePoly, clampf, ease, extrudePlate, extrudeRing, filletBox, gridNormals, instance, lerp, offsetPoly, orient, pair, panelShell, revolve, ringPoints, roundRect, secMove, section, smooth01, superSection, sweep, toRing, tube, write, xform } from "./mesh-kit.js";
import { MAT, canvas2d, canvasTexture, keep } from "./motorcycle-materials.js";
/* ============================================================================
4. WHEELS · TYRES · BRAKES · FINAL DRIVE
----------------------------------------------------------------------------
Wheel-local frame: +X is the axle, the wheel plane is Y/Z. Everything here
is generated from the tyre and rim dimensions in the contract, so changing
180/55 to 190/50 moves the sprocket, chain line and hugger with it.
========================================================================== */
/* --- 4.1 rim shell -------------------------------------------------------
Closed section: the tyre-side barrel out to the flange lips, back along the
inner face. Only the flange edge and the inner face are ever seen, but the
section is closed so the lip silhouette is real geometry. */
function rimSection(rimW, seatR) {
const hw = rimW / 2;
const lip = seatR + 0.0106, wellO = seatR - 0.0185, wellI = wellO - 0.0068;
const p = [];
const push = (u, r) => p.push(V2(r, u)); // (radius, axial) for revolve
// tyre-side barrel, left flange -> right flange
push(-hw - 0.0052, lip);
push(-hw - 0.0022, lip - 0.0052);
push(-hw + 0.0020, seatR + 0.0002);
push(-hw + 0.0125, seatR - 0.0042);
push(-hw + 0.0225, wellO + 0.0034);
push(0, wellO);
push(hw - 0.0225, wellO + 0.0034);
push(hw - 0.0125, seatR - 0.0042);
push(hw - 0.0020, seatR + 0.0002);
push(hw + 0.0022, lip - 0.0052);
push(hw + 0.0052, lip);
// flange tip roll-over
push(hw + 0.0072, lip - 0.0012);
push(hw + 0.0076, lip - 0.0044);
// inner face, right -> left
push(hw + 0.0046, lip - 0.0098);
push(hw - 0.0040, seatR - 0.0092);
push(hw - 0.0170, wellI + 0.0040);
push(hw - 0.0280, wellI);
push(0, wellI - 0.0016);
push(-hw + 0.0280, wellI);
push(-hw + 0.0170, wellI + 0.0040);
push(-hw + 0.0040, seatR - 0.0092);
push(-hw - 0.0046, lip - 0.0098);
push(-hw - 0.0076, lip - 0.0044);
push(-hw - 0.0072, lip - 0.0012);
return p;
}
/* --- 4.2 three-spoke split wheel ----------------------------------------
Three roots leave the hub and fork into six rim arms — the split-spoke
pattern this machine wears. Each arm is a lofted rounded-rectangle section
whose tangential width, axial depth and dish all vary along the radius. */
function spokeArm({ r0, r1, a0, a1, w0, w1, d0, d1, dish0, dish1, rings, segs, bow = 0 }) {
const path = [], secs = [];
for (let i = 0; i < rings; i++) {
const t = i / (rings - 1);
const te = smooth01(t);
const r = lerp(r0, r1, t);
const a = lerp(a0, a1, te) + bow * Math.sin(Math.PI * t) * 0.4;
const x = lerp(dish0, dish1, te);
path.push(V3(x, Math.sin(a) * r, Math.cos(a) * r));
// section is authored in (tangential, axial) and rotated into the wheel
const halfW = lerp(w0, w1, ease(t, 0.85));
const halfD = lerp(d0, d1, ease(t, 0.7));
secs.push(roundRect(halfD, halfW, Math.min(halfD, halfW) * 0.55, segs));
}
// frames: profile x -> axle direction, profile y -> tangential
const rows = path.map((P, i) => {
const radial = V3(0, P.y, P.z).normalize();
const tang = V3().crossVectors(V3(1, 0, 0), radial).normalize();
return secs[i].map((s) => V3().copy(P).addScaledVector(V3(1, 0, 0), s.x).addScaledVector(tang, s.y));
});
return rows;
}
function makeWheel(spec) {
const { rimW, seatR, hubHalf, hubR, boreR, dishOut = 0, armDepth = 1, name = "wheel" } = spec;
const g = new THREE.Group();
g.name = name;
const S = seg(64);
/* rim shell */
add(g, revolve(rimSection(rimW, seatR), { axis: "x", segments: S }), MAT.wheelBlack, name + ".rim");
/* Lime pinstripe: applied to the outward-facing flange wall, the only band
of rim the tyre sidewall does not cover, which is the only place a rim
stripe reads. Landmarks come from the same section constants. */
const stripe = (side) => {
const hw = rimW / 2, lip = seatR + 0.0106;
const a = V2(lip - 0.0102, side * (hw + 0.0043));
const b = V2(lip - 0.0158, side * (hw - 0.0012));
const n = V2(b.y - a.y, -(b.x - a.x)).normalize().multiplyScalar(side * 0.0004);
return revolve([
V2(a.x + n.x, a.y + n.y), V2(b.x + n.x, b.y + n.y),
], { axis: "x", segments: S, flip: side < 0 });
};
add(g, stripe(1), MAT.lime, name + ".pinstripe.R", { cast: false });
add(g, stripe(-1), MAT.lime, name + ".pinstripe.L", { cast: false });
/* hub barrel + bearing faces */
const hubProf = [
V2(boreR, -hubHalf), V2(hubR * 0.72, -hubHalf), V2(hubR * 0.86, -hubHalf + 0.008),
V2(hubR, -hubHalf + 0.020), V2(hubR * 1.02, -hubHalf * 0.35),
V2(hubR * 1.02, hubHalf * 0.35), V2(hubR, hubHalf - 0.020),
V2(hubR * 0.86, hubHalf - 0.008), V2(hubR * 0.72, hubHalf), V2(boreR, hubHalf),
V2(boreR, hubHalf - 0.006), V2(hubR * 0.60, hubHalf - 0.012),
V2(hubR * 0.60, -hubHalf + 0.012), V2(boreR, -hubHalf + 0.006),
];
add(g, revolve(hubProf, { axis: "x", segments: seg(40) }), MAT.wheelBlack, name + ".hub");
// bearing seals
for (const s of [-1, 1]) {
const y = s * (hubHalf - 0.004);
add(g, revolve([V2(boreR + 0.0005, y), V2(hubR * 0.62, y), V2(hubR * 0.62, y + s * 0.004), V2(boreR + 0.0005, y + s * 0.004)],
{ axis: "x", segments: seg(28) }), MAT.rubber, name + ".seal" + s, { cast: false });
}
/* Spokes: three roots, six arms. Sections are deeper across the axle than
they are wide, so the wheel reads as a slim blade from the side and a
stiff web from the front — the way a cast sport wheel actually looks. */
const spokes = new Writer();
const rimInner = seatR - 0.0253;
for (let k = 0; k < 3; k++) {
const base = (k / 3) * TAU + Math.PI * 0.5;
// root: hub -> split point
spokes.grid(spokeArm({
r0: hubR * 0.92, r1: 0.109, a0: base, a1: base, w0: 0.0300, w1: 0.0212,
d0: 0.0250 * armDepth, d1: 0.0225 * armDepth,
dish0: dishOut * 0.15, dish1: dishOut * 0.60, rings: seg(9), segs: seg(22),
}), { closeU: true });
// two arms to the rim
for (const s of [-1, 1]) {
spokes.grid(spokeArm({
r0: 0.1045, r1: rimInner + 0.005, a0: base + s * 0.050, a1: base + s * 0.330,
w0: 0.0158, w1: 0.0124, d0: 0.0232 * armDepth, d1: 0.0176 * armDepth,
dish0: dishOut * 0.62, dish1: dishOut * 0.40, rings: seg(11), segs: seg(20),
bow: s * 0.05,
}), { closeU: true });
}
}
add(g, spokes.geometry(), MAT.wheelBlack, name + ".spokes");
/* valve stem */
const va = 0.9;
const vp = V3(rimW * 0.18, Math.sin(va) * (seatR - 0.024), Math.cos(va) * (seatR - 0.024));
const vdir = V3(0, Math.sin(va), Math.cos(va));
add(g, tube([vp.clone().addScaledVector(vdir, -0.004), vp.clone().addScaledVector(vdir, 0.017),
vp.clone().addScaledVector(vdir, 0.030)], (t) => lerp(0.0042, 0.0034, t), seg(12)),
MAT.boltDark, name + ".valve");
return g;
}
/* --- 4.3 tyre with generated tread --------------------------------------
The tread grooves are geometry, not a normal map: the section radius is
displaced by a sweeping groove field so the shoulder blocks break the
silhouette and catch the rim light the way moulded rubber does. */
function tyreProfile(sectionW, outerR, seatR, rimW) {
const hw = sectionW / 2, hb = rimW / 2;
const c = [
// the bead starts level with the rim flange tip so the tyre wraps the lip
// instead of leaving a bare ring of rim above the seat
[hb + 0.0058, seatR + 0.0098],
[hb + 0.0112, seatR + 0.0232],
[hw * 0.93, seatR + 0.0400],
[hw, (seatR + outerR) * 0.5 + 0.0065],
[hw * 0.985, outerR - 0.0245],
[hw * 0.905, outerR - 0.0105],
[hw * 0.760, outerR - 0.0032],
[hw * 0.520, outerR - 0.0004],
[hw * 0.220, outerR + 0.0002],
[0, outerR + 0.0003],
];
const half = catmull2(c, 26); // bead -> crown
// mirror into one monotonic run: -bead ... crown ... +bead. Emitting the
// crown first folds the loft back on itself and destroys the tread field.
const full = [];
for (let i = 0; i < half.length; i++) full.push(V2(half[i].y, -half[i].x));
for (let i = half.length - 2; i >= 0; i--) full.push(V2(half[i].y, half[i].x));
return full; // (radius, axial)
}
function makeTyre({ sectionW, outerR, seatR, rimW, name }) {
const prof = tyreProfile(sectionW, outerR, seatR, rimW);
// the groove field is the reason for this segment count: an 8 mm slot needs
// ~3 mm sampling around a 1.9 m circumference to survive as geometry
const S = seg(420);
const R = prof.length;
const vRow = arcLengthV(prof);
// lateral position across the crown, 0 at centre -> 1 at shoulder
const crownAt = prof.map((p) => clampf(Math.abs(p.y) / (sectionW * 0.5), 0, 1));
const onTread = prof.map((p) => clampf((p.x - (outerR - 0.030)) / 0.028, 0, 1));
const rows = [];
for (let r = 0; r < R; r++) {
const row = [];
const side = Math.sign(prof[r].y) || 1;
const v = crownAt[r], tread = onTread[r];
for (let c = 0; c < S; c++) {
const a = (c / S) * TAU;
const turn = a / TAU;
let cut = 0;
if (tread > 0.01) {
// seven groove sets per side per revolution, swept back toward the
// shoulder and phase-offset side to side
const sweep = 0.055 + 0.44 * Math.pow(v, 1.25);
const phase = turn * 7 + side * sweep + (side > 0 ? 0 : 0.5);
const d = Math.abs(fractf(phase) - 0.5);
const w = 0.038 + 0.020 * v;
const inBand = smooth01(clampf((0.88 - v) / 0.10, 0, 1)) * smooth01(clampf((v - 0.030) / 0.07, 0, 1));
cut = Math.pow(Math.max(0, 1 - d / w), 0.55) * inBand * tread;
// central circumferential rain groove
if (v < 0.050) cut = Math.max(cut, smooth01(clampf((0.050 - v) / 0.028, 0, 1)) * 0.95);
}
const depth = 0.0052 * smooth01(clampf(cut, 0, 1));
const rr = prof[r].x - depth;
row.push(V3(prof[r].y, Math.sin(a) * rr, Math.cos(a) * rr));
}
rows.push(row);
}
// rows run bead -> bead (+X) and columns run round the carcass, which winds
// the surface inward; flip so the outward face is the one that survives
// culling. Left unflipped you see through to the far wall's interior.
const geo = write((w) => w.grid(rows, {
closeU: true, flip: true,
uvFn: (r, c, R2, C2) => [c / (C2 - 1), vRow[r]],
}));
const m = new THREE.Mesh(geo, MAT.tyre);
m.castShadow = true; m.receiveShadow = true; m.name = name;
m.userData.slot = "tyre";
PARTS.push({ name, tris: geo.index.count / 3, slot: "tyre" });
return m;
}
const fractf = (x) => x - Math.floor(x);
/* --- 4.4 petal brake disc ------------------------------------------------ */
function petalPoly(R, lobes, depth, n) {
const out = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
const t = fractf(a * lobes / TAU);
const notch = Math.pow(0.5 + 0.5 * Math.cos(t * TAU), 2.6);
out.push(V2(Math.cos(a) * (R - depth * notch), Math.sin(a) * (R - depth * notch)));
}
return out;
}
function rotorAlphaFor(outerR, innerR, holeR, rows) {
const W = 1024, H = 160;
const [c, x] = canvas2d(W, H);
x.fillStyle = "#fff"; x.fillRect(0, 0, W, H);
x.fillStyle = "#000";
const span = outerR - innerR;
for (const row of rows) {
const rAt = lerp(outerR, innerR, row.v);
const rx = (holeR / (TAU * rAt)) * W;
const ry = (holeR / span) * H;
for (let i = 0; i < row.n; i++) {
const u = ((i + (row.off || 0)) / row.n) * W;
x.save(); x.translate(u, row.v * H); x.scale(rx, ry);
x.beginPath(); x.arc(0, 0, 1, 0, TAU); x.fill(); x.restore();
if (u < rx * 2) { x.save(); x.translate(u + W, row.v * H); x.scale(rx, ry); x.beginPath(); x.arc(0, 0, 1, 0, TAU); x.fill(); x.restore(); }
}
}
// no mipmaps: a 4 mm hole is only a few texels wide and alphaTest would
// dissolve the whole drilling pattern at the first minification level
const t = canvasTexture(c, { aniso: 16 });
t.generateMipmaps = false;
t.minFilter = THREE.LinearFilter;
t.needsUpdate = true;
return t;
}
function makeRotor({ outerR, innerR, thick, hubR, boltR, name, side = 1, carrierArms = 5 }) {
const g = new THREE.Group(); g.name = name;
const n = seg(200);
const outer = petalPoly(outerR, 12, outerR * 0.062, n);
const inner = circlePoly(innerR, n);
/* friction faces carry the drilled pattern (angle, radial) */
const faces = new Writer();
const oR = toRing(outer, thick / 2, "x"), oL = toRing(outer, -thick / 2, "x");
const iR = toRing(inner, thick / 2, "x"), iL = toRing(inner, -thick / 2, "x");
faces.band(oR, iR, { closed: true, flip: false });
faces.band(oL, iL, { closed: true, flip: true });
const alpha = rotorAlphaFor(outerR, innerR, 0.0022, [
{ v: 0.30, n: 36 }, { v: 0.62, n: 36, off: 0.5 },
]);
const faceMat = MAT.rotorSteel.clone();
faceMat.userData.slot = "rotorSteel";
faceMat.opacityNode = texture(alpha, uv()).r;
faceMat.alphaTest = 0.5;
faceMat.side = THREE.DoubleSide;
add(g, faces.geometry(), faceMat, name + ".face");
/* petal edge + bore edge stay solid so the silhouette is real */
const edges = new Writer();
edges.band(oL, oR, { closed: true, flip: false });
edges.band(iR, iL, { closed: true, flip: false });
add(g, edges.geometry(), MAT.rotorSteel, name + ".edge");
/* floating carrier: arms from the hub flange out to the bobbin ring */
const carrier = new Writer();
const cOuter = innerR + 0.004;
revolve([V2(hubR, -thick * 0.9), V2(hubR, thick * 0.9), V2(hubR + 0.010, thick * 1.5),
V2(hubR + 0.010, -thick * 1.5)], { axis: "x", segments: seg(44), w: carrier });
for (let i = 0; i < carrierArms; i++) {
const a = (i / carrierArms) * TAU + 0.3;
const rows = spokeArm({
r0: hubR + 0.004, r1: cOuter, a0: a, a1: a + 0.16, w0: 0.0155, w1: 0.0092,
d0: thick * 1.45, d1: thick * 1.15, dish0: 0, dish1: 0, rings: seg(7), segs: seg(14),
});
carrier.grid(rows, { closeU: true });
}
// bobbin ring
revolve([V2(cOuter - 0.006, -thick * 1.1), V2(cOuter + 0.003, -thick * 1.1),
V2(cOuter + 0.003, thick * 1.1), V2(cOuter - 0.006, thick * 1.1)],
{ axis: "x", segments: seg(90), w: carrier });
add(g, carrier.geometry(), MAT.rotorCarrier, name + ".carrier");
/* floating buttons */
const bob = revolve([V2(0, -thick * 1.5), V2(0.0042, -thick * 1.5), V2(0.0052, -thick * 0.9),
V2(0.0052, thick * 0.9), V2(0.0042, thick * 1.5), V2(0, thick * 1.5)],
{ axis: "x", segments: seg(12) });
const mats = [];
for (let i = 0; i < 10; i++) {
const a = (i / 10) * TAU + 0.15;
mats.push(M.t(0, Math.sin(a) * (innerR + 0.0015), Math.cos(a) * (innerR + 0.0015)));
}
instance(g, bob, MAT.bolt, mats, name + ".buttons");
/* mounting bolts into the wheel hub */
const boltG = hexBolt(0.0052, 0.0042);
const bm = [];
for (let i = 0; i < 5; i++) {
const a = (i / 5) * TAU + 0.6;
bm.push(M.c(M.t(side * 0.004, Math.sin(a) * boltR, Math.cos(a) * boltR), M.rz(Math.PI / 2)));
}
instance(g, boltG, MAT.bolt, bm, name + ".bolts");
return g;
}
/* --- 4.5 fasteners ------------------------------------------------------- */
/* Fasteners are emitted as solids and then orientation-checked. The hex head
was previously hand-flipped, which left every hex bolt on the machine
inside-out — visible as partially culled slivers wherever one sat proud of
its parent (sprocket carrier, clutch cover, rotor faces). */
function hexBolt(r, h, flange = true) {
const w = new Writer();
const hex = circlePoly(r, 6, Math.PI / 6);
const hexTop = offsetPoly(hex, -r * 0.14);
const rows = [
toRing(hexTop, h, "y"), toRing(hex, h - r * 0.16, "y"), toRing(hex, 0, "y"),
];
w.grid(rows, { closeU: true });
w.fan(rows[0], false);
if (flange) {
revolve([V2(0, 0), V2(r * 1.32, 0), V2(r * 1.30, -r * 0.28), V2(0, -r * 0.30)],
{ axis: "y", segments: 14, w });
} else {
w.fan(rows[2], true);
}
return orient(w.geometry());
}
function socketBolt(r, h) {
const w = new Writer();
revolve([V2(0, h), V2(r * 0.62, h), V2(r * 0.62, h - r * 0.5), V2(r, h - r * 0.55),
V2(r, 0), V2(r * 0.7, -h * 0.6), V2(0, -h * 0.6)], { axis: "y", segments: 18, w });
return orient(w.geometry());
}
/* --- 4.6 final drive ----------------------------------------------------- */
function sprocketPoly(teeth, pitchR, n) {
const tipR = pitchR + 0.0072, rootR = pitchR - 0.0060;
const out = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
const t = fractf(a * teeth / TAU);
// trapezoidal tooth with a rounded tip and generous root radius
const s = Math.abs(t - 0.5) * 2; // 0 at tooth centre
let r;
if (s < 0.42) r = lerp(tipR, tipR - 0.0016, ease(s / 0.42, 2));
else if (s < 0.80) r = lerp(tipR - 0.0016, rootR, smooth01((s - 0.42) / 0.38));
else r = rootR - 0.0004 * Math.sin((s - 0.80) / 0.20 * Math.PI);
out.push(V2(Math.cos(a) * r, Math.sin(a) * r));
}
return out;
}
function makeSprocket({ teeth, pitchR, thick, boreR, name, mat }) {
const n = seg(teeth * 6);
const w = new Writer();
extrudeRing(sprocketPoly(teeth, pitchR, n), circlePoly(pitchR * 0.44, seg(72)), thick,
{ axis: "x", bevel: 0.0009, w });
/* Lightened web. This was a flat ring laid at +/-thick/2 with separate rib
bars floating on it: the ring landed on exactly the plane extrudeRing puts
its faces on, so the two z-fought across the whole hub, and the loose ribs
shaded as detached slivers. It is now one closed revolved profile that
steps down into the recess and back out, sharing only an edge with the
tooth face — no coincident area, no separate parts. */
const inner = pitchR * 0.44, outer = pitchR - 0.017;
const lip = thick * 0.46, floorZ = thick * 0.24;
revolve([
V2(inner - 0.001, -lip), V2(inner + 0.005, -floorZ),
V2(outer - 0.005, -floorZ), V2(outer, -lip),
V2(outer, lip), V2(outer - 0.005, floorZ),
V2(inner + 0.005, floorZ), V2(inner - 0.001, lip),
V2(inner - 0.001, -lip),
], { axis: "x", segments: seg(72), w });
const g = w.geometry();
const mesh = new THREE.Mesh(g, mat || MAT.alloyMachined);
mesh.castShadow = true; mesh.receiveShadow = true; mesh.name = name;
mesh.userData.slot = "alloyMachined";
PARTS.push({ name, tris: g.index.count / 3, slot: "alloyMachined" });
return mesh;
}
/* chain: tangent construction between the two pitch circles, sagged bottom run */
function chainPath(cA, rA, cB, rB, sag) {
const d = Math.hypot(cB[0] - cA[0], cB[1] - cA[1]);
const al = Math.atan2(cB[1] - cA[1], cB[0] - cA[0]);
const be = Math.asin(clampf((rB - rA) / d, -1, 1));
const t1 = al + Math.PI / 2 + be, t2 = al - Math.PI / 2 - be;
const P = (c, r, a) => V2(c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r);
const pts = [];
const run = (p0, p1, n, bow) => {
for (let i = 0; i <= n; i++) {
const t = i / n;
const p = V2().lerpVectors(p0, p1, t);
p.y -= bow * Math.sin(Math.PI * t);
pts.push(p);
}
};
const arc = (c, r, a0, a1, n) => {
for (let i = 1; i < n; i++) pts.push(P(c, r, lerp(a0, a1, i / n)));
};
run(P(cA, rA, t1), P(cB, rB, t1), 26, 0); // top run
arc(cB, rB, t1, t2 - TAU, seg(46)); // around rear
run(P(cB, rB, t2), P(cA, rA, t2), 26, sag); // bottom run, slack
arc(cA, rA, t2, t1 - TAU, seg(20)); // around front
return pts;
}
function makeChain({ cA, rA, cB, rB, x, pitch, name }) {
const g = new THREE.Group(); g.name = name;
const raw = chainPath(cA, rA, cB, rB, 0.013);
// resample to exact pitch spacing so link plates butt end to end
const cum = [0];
for (let i = 1; i < raw.length; i++) cum.push(cum[i - 1] + raw[i].distanceTo(raw[i - 1]));
const total = cum[cum.length - 1] + raw[0].distanceTo(raw[raw.length - 1]);
const links = Math.round(total / pitch);
const step = total / links;
const sampleAt = (s) => {
s = ((s % total) + total) % total;
let i = 1;
while (i < cum.length && cum[i] < s) i++;
if (i >= cum.length) return raw[raw.length - 1].clone().lerp(raw[0], (s - cum[cum.length - 1]) / Math.max(1e-6, total - cum[cum.length - 1]));
const t = (s - cum[i - 1]) / Math.max(1e-6, cum[i] - cum[i - 1]);
return V2().lerpVectors(raw[i - 1], raw[i], t);
};
const hw = D.chainW / 2;
const plate = (() => {
const w = new Writer();
const shape = catmull2([
[-pitch / 2, 0.0062], [-pitch * 0.18, 0.0080], [pitch * 0.18, 0.0080], [pitch / 2, 0.0062],
[pitch / 2 + 0.0018, 0], [pitch / 2, -0.0062], [pitch * 0.18, -0.0080],
[-pitch * 0.18, -0.0080], [-pitch / 2, -0.0062], [-pitch / 2 - 0.0018, 0],
], 30, true);
extrudePlate(shape, 0.0022, { axis: "x", bevel: 0.0005, w });
return w.geometry();
})();
const roller = revolve([V2(0.0035, -0.0038), V2(0.0052, -0.0038), V2(0.0052, 0.0038), V2(0.0035, 0.0038)],
{ axis: "x", segments: seg(14) });
const pin = revolve([V2(0, -hw - 0.0012), V2(0.0022, -hw - 0.0012), V2(0.0022, hw + 0.0012), V2(0, hw + 0.0012)],
{ axis: "x", segments: seg(10), capStart: true, capEnd: true });
const outer = [], innerP = [], rollers = [], pins = [];
for (let i = 0; i < links; i++) {
const s = i * step;
const a = sampleAt(s - step * 0.5), b = sampleAt(s + step * 0.5);
const mid = V2().addVectors(a, b).multiplyScalar(0.5);
const ang = Math.atan2(b.y - a.y, b.x - a.x);
// path is in the Z(fore/aft) - Y(up) plane; +x of the section maps to +Z
const base = M.c(M.t(x, mid.y, mid.x), M.rx(-ang));
const outerPlate = i % 2 === 0;
const off = outerPlate ? hw : hw - 0.0026;
(outerPlate ? outer : innerP).push(M.c(base.clone(), M.t(off, 0, 0)));
(outerPlate ? outer : innerP).push(M.c(base.clone(), M.t(-off, 0, 0)));
if (!outerPlate) rollers.push(base.clone());
pins.push(M.c(M.t(x, b.y, b.x), M.rx(-ang)));
}
instance(g, plate, MAT.chain, outer, name + ".plateOuter");
instance(g, plate, MAT.chainGold, innerP, name + ".plateInner");
instance(g, roller, MAT.chain, rollers, name + ".rollers");
instance(g, pin, MAT.bolt, pins, name + ".pins");
return g;
}
/* ============================================================================
5. CHASSIS · FORKS · SWINGARM · CONTROLS
----------------------------------------------------------------------------
Steering geometry is the spine of this file: every front-end part is placed
by forkPt()/steerPt() at a stated distance along the fork axis, so rake and
triple-clamp offset propagate to the yokes, bars, calipers and fender
without a single hand-placed transform.
========================================================================== */
/* revolve a profile about the axis joining two 3D points */
function revolveAlongPts(w, origin, dir, length, radii, segments) {
const u = Math.abs(dir.y) < 0.94 ? V3(0, 1, 0) : V3(1, 0, 0);
const b = V3().crossVectors(dir, u).normalize();
const n = V3().crossVectors(b, dir).normalize();
const rows = radii.map(([t, r]) => ringPoints(
V3().copy(origin).addScaledVector(dir, t * length), b, n, Math.max(r, 1e-4), segments));
w.grid(rows, { closeU: true });
w.fan(rows[0], true);
w.fan(rows[rows.length - 1], false);
return w;
}
function revolveAlong(w, pts, radii, segments) {
const p0 = pts[0], p1 = pts[pts.length - 1];
return revolveAlongPts(w, p0, V3().subVectors(p1, p0).normalize(), p0.distanceTo(p1), radii, segments);
}
/* helix path for coil springs; pitch flattens at both ends so the coil seats */
function helixPath(p0, p1, coils, radius, segsPerCoil = 16) {
const axis = V3().subVectors(p1, p0);
const len = axis.length();
axis.normalize();
const u = Math.abs(axis.y) < 0.94 ? V3(0, 1, 0) : V3(1, 0, 0);
const b = V3().crossVectors(axis, u).normalize();
const n = V3().crossVectors(b, axis).normalize();
const total = Math.round(coils * segsPerCoil);
const pts = [];
for (let i = 0; i <= total; i++) {
const t = i / total;
const a = t * coils * TAU;
const r = radius * (t < 0.05 ? 0.93 + t * 1.4 : t > 0.95 ? 0.93 + (1 - t) * 1.4 : 1);
pts.push(V3().copy(p0).addScaledVector(axis, t * len)
.addScaledVector(n, Math.cos(a) * r).addScaledVector(b, Math.sin(a) * r));
}
return pts;
}
/* --- 5.1 frame ----------------------------------------------------------
Twin-spar perimeter rail. The box section is shallow and narrow at the
steering head, deepest over the engine where bending peaks, and closes into
a thick plate at the pivot. */
function frameSparSection(t, segs) {
// Slim through the tank run and only deep where it turns down to the pivot.
// A fat rail here becomes a dark diagonal across the flank that swamps the
// tank hump and seat scoop — the spine stops reading at all.
const lat = lerp(0.0150, 0.0205, smooth01(clampf(t * 1.6, 0, 1)))
* (1 - 0.20 * smooth01(clampf((t - 0.64) / 0.36, 0, 1)));
const vert = 0.0215 + 0.0130 * smooth01(clampf(t / 0.55, 0, 1))
+ 0.0235 * smooth01(clampf((t - 0.55) / 0.45, 0, 1));
return secMove(roundRect(lat, vert, Math.min(lat, vert) * 0.46, segs), 0, -0.004 * Math.sin(Math.PI * t));
}
function buildFrame(root) {
const g = new THREE.Group(); g.name = "frame"; root.add(g);
const S = seg(26);
/* steering head */
const hA = steerPt(D.headTube.l0), hB = steerPt(D.headTube.l1);
const hDir = V3().subVectors(hB, hA).normalize();
add(g, tube([
hA.clone().addScaledVector(hDir, -0.012), hA, hB, hB.clone().addScaledVector(hDir, 0.012),
], (t) => D.headTube.r * (t < 0.12 || t > 0.88 ? 1.16 : 1.0), seg(28)),
MAT.alloyAnodized, "frame.headTube");
/* main spars, authored right and mirrored */
const path = catmull3(D.spar.path, seg(34));
pair(g, sweep(path, (t) => frameSparSection(t, S), { upright: true }),
MAT.alloyAnodized, "frame.spar");
/* head gussets tying each spar into the head tube */
const gus = catmull3([
[0.026, 0.856, 0.444], [0.050, 0.844, 0.428], [0.068, 0.830, 0.402], [0.076, 0.818, 0.372],
], seg(12));
pair(g, sweep(gus, (t) => roundRect(lerp(0.011, 0.016, t), lerp(0.022, 0.030, t), 0.008, seg(20)),
{ upright: true }), MAT.alloyAnodized, "frame.gusset");
/* pivot plate: thick cast lug carrying the swingarm and the engine */
const pivotPlate = write((w) => extrudePlate(catmull2([
[-0.070, 0.098], [0.028, 0.088], [0.062, 0.030], [0.058, -0.052],
[0.006, -0.088], [-0.062, -0.070], [-0.086, 0.010],
], seg(48), true), 0.026, { axis: "x", bevel: 0.0035, w }));
pair(g, xform(pivotPlate, M.t(0.134, D.pivot[1], D.pivot[0])), MAT.alloyAnodized, "frame.pivotPlate");
/* pivot boss + swingarm shaft */
add(g, xform(revolve([
V2(0.014, -0.152), V2(0.030, -0.152), V2(0.030, -0.120), V2(0.020, -0.108),
V2(0.020, 0.108), V2(0.030, 0.120), V2(0.030, 0.152), V2(0.014, 0.152),
], { axis: "x", segments: seg(30) }), M.t(0, D.pivot[1], D.pivot[0])),
MAT.alloyCast, "frame.pivotBoss");
add(g, xform(revolve([V2(0, -0.164), V2(0.0135, -0.164), V2(0.0135, 0.164), V2(0, 0.164)],
{ axis: "x", segments: seg(20), capStart: true, capEnd: true }), M.t(0, D.pivot[1], D.pivot[0])),
MAT.bolt, "frame.pivotShaft");
/* front cross member and the upper shock mount */
add(g, tube(catmull3([
[-0.084, 0.845, 0.436], [0, 0.862, 0.440], [0.084, 0.845, 0.436],
], seg(14)), 0.017, seg(14)), MAT.alloyAnodized, "frame.crossFront");
add(g, filletBox(0.128, 0.052, 0.070, 0.014, { segments: seg(26), rings: seg(9) })
.translate(0, 0.618, -0.176), MAT.alloyAnodized, "frame.shockMount");
/* subframe */
pair(g, sweep(catmull3(D.subframe.upper, seg(26)),
(t) => roundRect(lerp(0.0135, 0.0105, t), lerp(0.0205, 0.0150, t), 0.0075, seg(18)),
{ upright: true }), MAT.alloyAnodized, "frame.subUpper");
pair(g, sweep(catmull3(D.subframe.lower, seg(20)),
(t) => roundRect(lerp(0.0115, 0.0092, t), lerp(0.0150, 0.0118, t), 0.0065, seg(16)),
{ upright: true }), MAT.alloyAnodized, "frame.subLower");
add(g, tube(catmull3([[-0.052, 0.884, -0.826], [0, 0.888, -0.838], [0.052, 0.884, -0.826]], seg(12)),
0.0105, seg(12)), MAT.alloyAnodized, "frame.subCross");
return g;
}
/* --- 5.2 front end ------------------------------------------------------- */
function buildFrontEnd(root) {
const g = new THREE.Group(); g.name = "front"; root.add(g);
const F = D.fork, S = seg(30), half = F.halfTrack;
const up = AX.up, fwd = AX.fwd;
const legAt = (L, s = half) => forkPt(L, s);
/* outer tube — the fat upper leg of an inverted fork */
const outerLeg = write((w) => revolveAlong(w,
[legAt(F.topL + 0.004), legAt(F.sliderTopL)],
[[0, F.sliderR * 1.10], [0.03, F.sliderR * 1.05], [0.92, F.sliderR], [1, F.sliderR * 1.06]], S));
pair(g, outerLeg, MAT.paintFlat, "fork.outer");
/* exposed inner tube — hard chrome, the brightest vertical on the machine */
pair(g, write((w) => revolveAlong(w,
[legAt(F.sliderTopL + 0.004), legAt(F.axleLugL + 0.036)],
[[0, F.tubeR], [1, F.tubeR]], S)), MAT.chrome, "fork.stanchion");
/* dust seal collar */
pair(g, write((w) => revolveAlong(w, [legAt(F.sliderTopL - 0.006), legAt(F.sliderTopL + 0.014)],
[[0, F.sliderR * 1.00], [0.5, F.sliderR * 1.07], [1, F.sliderR * 1.00]], seg(24))),
MAT.rubber, "fork.seal");
/* cap and preload adjuster */
pair(g, write((w) => revolveAlong(w, [legAt(F.topL + 0.002), legAt(F.topL + 0.020)],
[[0, F.sliderR * 1.13], [0.7, F.sliderR * 1.13], [1, F.sliderR * 1.03]], seg(24))),
MAT.alloyMachined, "fork.cap");
pair(g, write((w) => revolveAlong(w, [legAt(F.topL + 0.018), legAt(F.topL + 0.030)],
[[0, 0.0125], [1, 0.0108]], 6)), MAT.bolt, "fork.adjuster");
/* axle holder casting */
const ax = legAt(0);
pair(g, write((w) => {
revolveAlong(w, [legAt(F.axleLugL + 0.062), legAt(-0.006)],
[[0, 0.0248], [0.28, 0.0300], [0.70, 0.0332], [1, 0.0296]], seg(26));
revolveAlongPts(w, V3(half - 0.030, ax.y, ax.z), V3(1, 0, 0), 0.062,
[[0, 0.0140], [0.10, 0.0300], [0.34, 0.0336], [0.80, 0.0336], [0.92, 0.0300], [1, 0.0150]], seg(26));
}), MAT.alloyCast, "fork.axleHolder");
/* pinch-bolt ears behind the axle */
pair(g, filletBox(0.030, 0.052, 0.024, 0.007, { segments: seg(20), rings: seg(7) })
.translate(half + 0.014, ax.y + 0.030, ax.z - 0.028), MAT.alloyCast, "fork.pinchEar");
const pinch = [];
for (const dy of [0.012, 0.046]) {
pinch.push(M.c(M.t(half + 0.030, ax.y + dy, ax.z - 0.028), M.rz(-Math.PI / 2)));
}
instance(g, socketBolt(0.0058, 0.010), MAT.boltDark, pinch, "fork.pinchBolt.R");
instance(g, socketBolt(0.0058, 0.010), MAT.boltDark,
pinch.map((m) => M.c(M.s(-1, 1, 1), m)), "fork.pinchBolt.L");
/* -- triple clamps: web plate plus real bosses on the fork/stem axes -- */
const yoke = (L, thick, webHalf, bossR, stemR) => write((w) => {
const c = steerPt(L);
const rows = [];
const R = seg(9);
for (let i = 0; i < R; i++) {
const t = i / (R - 1);
const y = lerp(-thick / 2, thick / 2, t);
const k = 1 - 0.09 * Math.pow(Math.abs(t - 0.5) * 2, 2);
const poly = catmull2([
[-(half + bossR) * k, -webHalf * 0.52 * k], [-half * k, webHalf * 0.90 * k],
[-0.028 * k, webHalf * k], [0.028 * k, webHalf * k], [half * k, webHalf * 0.90 * k],
[(half + bossR) * k, -webHalf * 0.52 * k], [half * 0.60 * k, -webHalf * 0.84 * k],
[-half * 0.60 * k, -webHalf * 0.84 * k],
], seg(56), true);
rows.push(poly.map((p) => V3().copy(c)
.addScaledVector(V3(1, 0, 0), p.x).addScaledVector(fwd, p.y).addScaledVector(up, y)));
}
w.grid(rows, { closeU: true });
w.fan(rows[0], true); w.fan(rows[R - 1], false);
for (const s of [-1, 1]) {
const fc = forkPt(L, s * half);
revolveAlong(w, [fc.clone().addScaledVector(up, -thick * 0.78), fc.clone().addScaledVector(up, thick * 0.78)],
[[0, bossR * 0.97], [0.5, bossR], [1, bossR * 0.97]], seg(24));
}
revolveAlong(w, [c.clone().addScaledVector(up, -thick * 0.7), c.clone().addScaledVector(up, thick * 0.7)],
[[0, stemR], [1, stemR]], seg(22));
});
add(g, yoke(F.lowerClampL, 0.026, 0.045, 0.0388, 0.026), MAT.alloyMachined, "fork.lowerYoke");
add(g, yoke(F.upperClampL, 0.020, 0.040, 0.0368, 0.024), MAT.alloyMachined, "fork.upperYoke");
add(g, tube([steerPt(F.lowerClampL - 0.034), steerPt(F.upperClampL + 0.024)], 0.0155, seg(18)),
MAT.bolt, "fork.stem");
add(g, write((w) => revolveAlong(w,
[steerPt(F.upperClampL + 0.014), steerPt(F.upperClampL + 0.032)], [[0, 0.0228], [1, 0.0215]], 6)),
MAT.bolt, "fork.stemNut");
/* -- clip-on bars ----------------------------------------------------- */
pair(g, write((w) => {
const c = forkPt(F.upperClampL - 0.032, half);
revolveAlong(w, [c.clone().addScaledVector(up, -0.015), c.clone().addScaledVector(up, 0.015)],
[[0, F.sliderR * 1.32], [1, F.sliderR * 1.32]], seg(22));
}), MAT.alloyMachined, "bar.clamp");
const barPath = catmull3([
[half, D.bar.y + 0.010, D.bar.z + 0.030],
[half + 0.034, D.bar.y - 0.001, D.bar.z + 0.012],
[D.bar.halfWidth, D.bar.y - 0.014, D.bar.z - 0.012],
// the tube stops inside the grip: it used to end on the same point as the
// grip and the bar-end, stacking three flat caps in one plane
[D.bar.halfWidth + 0.014, D.bar.y - 0.018, D.bar.z - 0.026],
], seg(18));
pair(g, tube(barPath, 0.0112, seg(16)), MAT.alloyMachined, "bar.tube");
const gripPath = catmull3([
[D.bar.halfWidth - 0.030, D.bar.y - 0.011, D.bar.z - 0.001],
[D.bar.halfWidth, D.bar.y - 0.017, D.bar.z - 0.018],
[D.bar.halfWidth + 0.030, D.bar.y - 0.023, D.bar.z - 0.034],
], seg(12));
pair(g, sweep(gripPath, (t) => circlePoly(lerp(0.0158, 0.0176, Math.sin(t * 2.4)), seg(18)),
{ upright: true }), MAT.rubber, "bar.grip");
const gEnd = gripPath[gripPath.length - 1];
const gDir = V3().subVectors(gEnd, gripPath[gripPath.length - 2]).normalize();
/* Bar end: a short barrel that starts 14 mm inside the rubber at a radius
well under the grip's, so the joint is a collar rather than a ball
perched on the end. Its previous 42 mm sphere read as a separate part. */
pair(g, write((w) => revolveAlongPts(w, gEnd.clone().addScaledVector(gDir, -0.014),
gDir, 0.034, [[0, 0.0120], [0.30, 0.0150], [0.42, 0.0186], [0.80, 0.0184],
[0.92, 0.0158], [1, 0.0088]], seg(24))),
MAT.alloyMachined, "bar.end");
/* lever, master cylinder, reservoir, switch block */
const perch = V3(D.bar.halfWidth - 0.048, D.bar.y - 0.006, D.bar.z + 0.006);
/* The blade should leave the handlebar at an acute angle rather than 90°.
Set the root direction to ~35° relative to the bar, then keep only a
very slight additional sweep near the tip. */
const barDir = V3().subVectors(gripPath[gripPath.length - 1], gripPath[0]).normalize();
const ahead = V3(-barDir.z, 0, barDir.x).normalize();
const leverAngle = 35 * DEG;
const leverDir = barDir.clone().multiplyScalar(Math.cos(leverAngle))
.add(ahead.clone().multiplyScalar(Math.sin(leverAngle))).normalize();
const pivot = perch.clone().addScaledVector(ahead, 0.010).add(V3(0, -0.004, 0));
const leverPath = catmull3([
pivot,
pivot.clone().addScaledVector(leverDir, 0.032).addScaledVector(ahead, 0.001),
pivot.clone().addScaledVector(leverDir, 0.066).addScaledVector(ahead, 0.003),
pivot.clone().addScaledVector(leverDir, 0.098).addScaledVector(barDir, 0.010),
], seg(16));
pair(g, sweep(leverPath, (t) => roundRect(lerp(0.0038, 0.0024, t),
lerp(0.0095, 0.0058, t), 0.0020, seg(14)), { upright: true }),
MAT.alloyMachined, "bar.lever");
pair(g, filletBox(0.034, 0.042, 0.056, 0.010, { segments: seg(22), rings: seg(8) })
.translate(perch.x - 0.004, perch.y + 0.012, perch.z - 0.008), MAT.alloyMachined, "bar.masterCyl");
pair(g, filletBox(0.030, 0.032, 0.030, 0.008, { segments: seg(18), rings: seg(7) })
.translate(perch.x - 0.002, perch.y + 0.040, perch.z - 0.016), MAT.plastic, "bar.reservoir");
pair(g, filletBox(0.030, 0.038, 0.054, 0.010, { segments: seg(20), rings: seg(8) })
.translate(D.bar.halfWidth - 0.086, D.bar.y - 0.012, D.bar.z + 0.012), MAT.plastic, "bar.switchBlock");
/* brake line from the perch down the fork to the caliper */
pair(g, tube(catmull3([
[perch.x - 0.012, perch.y + 0.006, perch.z - 0.022],
[half + 0.074, 0.822, 0.398], [half + 0.044, 0.690, 0.454],
[half + 0.032, 0.548, 0.512], [half + 0.026, 0.424, 0.562],
[half + 0.020, 0.348, 0.598],
], seg(26)), 0.0042, seg(10)), MAT.rubber, "brake.hoseFront");
return g;
}
/* --- 5.3 brake calipers -------------------------------------------------
Two bodies straddling the rotor with bridges over the top: the disc slot is
real geometry, so the caliper reads correctly from every angle. */
function makeCaliper({ axleY, axleZ, discX, phi, span, rIn, rOut, halfGap, thick, name, mount = "radial" }) {
const g = new THREE.Group(); g.name = name;
const rMid = (rIn + rOut) / 2;
const bodyPath = [];
const N = seg(14);
for (let i = 0; i <= N; i++) {
const a = lerp(phi - span, phi + span, i / N);
bodyPath.push(V3(0, axleY + Math.sin(a) * rMid, axleZ + Math.cos(a) * rMid));
}
// transported frames map section.x to the radial direction and section.y to
// the axle direction, so the caliper section is authored radial-first
const sect = (t) => roundRect(
((rOut - rIn) / 2) * (1 - 0.16 * Math.pow(Math.abs(t - 0.5) * 2, 2.4)), thick / 2, thick * 0.32, seg(18));
const outSign = discX >= 0 ? 1 : -1;
for (const s of [1, -1]) {
const off = discX + s * (halfGap + thick / 2);
add(g, sweep(bodyPath.map((p) => V3(off, p.y, p.z)), sect, { seedUp: V3(1, 0, 0) }),
MAT.alloyAnodized, name + ".body" + (s > 0 ? "O" : "I"));
const bores = [];
for (let i = 0; i < 2; i++) {
const a = lerp(phi - span * 0.5, phi + span * 0.5, i);
bores.push(M.t(off + s * thick * 0.44, axleY + Math.sin(a) * rMid, axleZ + Math.cos(a) * rMid));
}
instance(g, revolve([V2(0, 0), V2(0.0126, 0), V2(0.0126, s * 0.0035), V2(0.0098, s * 0.0048)],
{ axis: "x", segments: seg(18) }), MAT.alloyMachined, bores, name + ".bore" + (s > 0 ? "O" : "I"));
}
for (const f of [-0.62, 0, 0.62]) {
const a = phi + span * f;
add(g, filletBox(halfGap * 2 + thick * 2.05, 0.016, 0.020, 0.005, { segments: seg(16), rings: seg(6) })
.translate(discX, axleY + Math.sin(a) * (rOut - 0.005), axleZ + Math.cos(a) * (rOut - 0.005)),
MAT.alloyAnodized, name + ".bridge" + f);
}
for (const s of [1, -1]) {
add(g, sweep(bodyPath.map((p) => V3(discX + s * (halfGap - 0.0024), p.y, p.z)),
() => roundRect((rOut - rIn) * 0.30, 0.0024, 0.0012, seg(10)), { seedUp: V3(1, 0, 0) }),
MAT.plastic, name + ".pad" + (s > 0 ? "O" : "I"));
}
if (mount === "radial") {
const ears = [];
for (const f of [-0.80, 0.80]) {
const a = phi + span * f;
const c = V3(discX + outSign * (halfGap + thick),
axleY + Math.sin(a) * (rIn + 0.008), axleZ + Math.cos(a) * (rIn + 0.008));
add(g, filletBox(0.026, 0.024, 0.026, 0.007, { segments: seg(16), rings: seg(6) })
.translate(c.x, c.y, c.z), MAT.alloyAnodized, name + ".ear" + f);
ears.push(M.c(M.t(c.x + outSign * 0.016, c.y, c.z), M.rz(-outSign * Math.PI / 2)));
}
instance(g, socketBolt(0.0065, 0.010), MAT.boltDark, ears, name + ".bolts");
}
const bn = phi + span * 0.92;
add(g, tube([
V3(discX + halfGap * outSign, axleY + Math.sin(bn) * rOut, axleZ + Math.cos(bn) * rOut),
V3(discX + halfGap * outSign, axleY + Math.sin(bn) * (rOut + 0.017), axleZ + Math.cos(bn) * (rOut + 0.017)),
], (t) => lerp(0.0042, 0.0028, t), seg(10)), MAT.bolt, name + ".bleed");
return g;
}
/* --- 5.4 swingarm, shock, linkage --------------------------------------- */
function buildSwingarm(root) {
const g = new THREE.Group(); g.name = "swingarm"; root.add(g);
const armPath = (s) => catmull3([
[s * 0.118, D.pivot[1] + 0.004, D.pivot[0] + 0.012],
[s * 0.128, D.pivot[1] + 0.020, D.pivot[0] - 0.090],
[s * 0.132, D.pivot[1] + 0.026, D.pivot[0] - 0.230],
[s * 0.126, D.pivot[1] - 0.006, D.pivot[0] - 0.380],
[s * 0.113, D.rear.axle[1] + 0.006, D.rear.axle[0] + 0.052],
[s * 0.108, D.rear.axle[1], D.rear.axle[0] + 0.008],
], seg(28));
const armSect = (t) => {
const vert = lerp(0.062, 0.030, ease(t, 0.85));
const lat = lerp(0.030, 0.020, ease(t, 0.6));
return secMove(roundRect(lat, vert, Math.min(lat, vert) * 0.42, seg(24)),
0, -0.006 * Math.sin(Math.PI * clampf(t * 1.15, 0, 1)));
};
for (const s of [1, -1]) {
add(g, sweep(armPath(s), armSect, { upright: true }), MAT.alloyAnodized,
"swingarm.arm" + (s > 0 ? "R" : "L"));
}
add(g, xform(revolve([
V2(0.024, -0.150), V2(0.044, -0.150), V2(0.048, -0.120),
V2(0.048, 0.120), V2(0.044, 0.150), V2(0.024, 0.150),
], { axis: "x", segments: seg(30) }), M.t(0, D.pivot[1], D.pivot[0])),
MAT.alloyAnodized, "swingarm.pivotTube");
add(g, sweep(catmull3([
[-0.128, D.pivot[1] + 0.030, D.pivot[0] - 0.116],
[0, D.pivot[1] + 0.041, D.pivot[0] - 0.130],
[0.128, D.pivot[1] + 0.030, D.pivot[0] - 0.116],
], seg(16)), () => roundRect(0.030, 0.016, 0.007, seg(18)), { upright: true }),
MAT.alloyAnodized, "swingarm.brace");
for (const s of [1, -1]) {
const c = V3(s * 0.106, D.rear.axle[1], D.rear.axle[0]);
add(g, filletBox(0.026, 0.062, 0.116, 0.012, { segments: seg(22), rings: seg(8) })
.translate(c.x, c.y, c.z + 0.026), MAT.alloyAnodized, "swingarm.carrier" + (s > 0 ? "R" : "L"));
add(g, xform(revolve([V2(0.014, -0.014), V2(0.030, -0.014), V2(0.030, 0.014), V2(0.014, 0.014)],
{ axis: "x", segments: seg(20) }), M.t(c.x, c.y, c.z)), MAT.bolt,
"swingarm.axleNut" + (s > 0 ? "R" : "L"));
add(g, filletBox(0.024, 0.026, 0.030, 0.006, { segments: seg(16), rings: seg(6) })
.translate(c.x, c.y, c.z + 0.086), MAT.alloyMachined, "swingarm.adjuster" + (s > 0 ? "R" : "L"));
}
add(g, sweep(catmull3([
[-0.132, D.pivot[1] + 0.056, D.pivot[0] - 0.020],
[-0.136, D.pivot[1] + 0.062, D.pivot[0] - 0.130],
[-0.132, D.pivot[1] + 0.036, D.pivot[0] - 0.250],
], seg(12)), () => roundRect(0.017, 0.010, 0.005, seg(14)), { upright: true }),
MAT.plastic, "swingarm.chainSlider");
add(g, tube(catmull3([
[0.108, D.pivot[1] + 0.006, D.pivot[0] - 0.070],
[0.104, D.rear.axle[1] + 0.030, D.rear.axle[0] + 0.120],
[0.098, D.rear.axle[1] + 0.020, D.rear.axle[0] + 0.030],
], seg(12)), 0.0075, seg(12)), MAT.alloyMachined, "swingarm.torqueArm");
/* monoshock */
const sTop = V3(0, 0.598, -0.184), sBot = V3(0, 0.318, -0.256);
const dir = V3().subVectors(sBot, sTop).normalize();
add(g, tube([sTop.clone().addScaledVector(dir, 0.030), sTop.clone().addScaledVector(dir, 0.152)],
0.0155, seg(16)), MAT.chrome, "shock.rod");
add(g, tube([sTop.clone().addScaledVector(dir, 0.150), sBot], (t) => lerp(0.0245, 0.0225, t), seg(18)),
MAT.alloyAnodized, "shock.body");
add(g, tube(helixPath(sTop.clone().addScaledVector(dir, 0.028), sBot.clone().addScaledVector(dir, -0.030),
6.2, 0.0375, seg(18)), 0.0060, seg(10)), MAT.paintFlat, "shock.spring");
add(g, filletBox(0.038, 0.052, 0.030, 0.010, { segments: seg(18), rings: seg(7) })
.translate(0.038, 0.470, -0.148), MAT.alloyAnodized, "shock.reservoir");
add(g, filletBox(0.052, 0.046, 0.070, 0.010, { segments: seg(18), rings: seg(7) })
.translate(0, 0.302, -0.262), MAT.alloyAnodized, "shock.rocker");
pair(g, tube([V3(0.032, 0.294, -0.278), V3(0.030, 0.320, -0.174)], 0.0088, seg(12)),
MAT.alloyMachined, "shock.tieRod");
return g;
}
/* --- 5.5 rider controls -------------------------------------------------- */
function buildControls(root) {
const g = new THREE.Group(); g.name = "controls"; root.add(g);
const P = D.peg, pp = D.pillionPeg;
for (const s of [1, -1]) {
add(g, xform(write((w) => extrudePlate(catmull2([
[-0.048, 0.052], [0.020, 0.046], [0.052, 0.004], [0.030, -0.048],
[-0.026, -0.052], [-0.058, -0.006],
], seg(40), true), 0.012, { axis: "x", bevel: 0.0022, w })),
M.t(s * (P.halfW - 0.012), P.y, P.z)), MAT.alloyMachined, "peg.hanger" + (s > 0 ? "R" : "L"));
add(g, write((w) => revolveAlongPts(w, V3(s * P.halfW, P.y, P.z), V3(s, 0.06, 0).normalize(), 0.072,
[[0, 0.0125], [0.12, 0.0152], [0.85, 0.0140], [0.93, 0.0182], [1, 0.0164]], seg(20))),
MAT.knurled, "peg.rider" + (s > 0 ? "R" : "L"));
// the root sits on the lower subframe rail under the tail, not in mid-air
add(g, sweep(catmull3([
[s * 0.076, 0.782, -0.472],
[s * 0.108, 0.716, -0.480],
[s * 0.134, 0.660, -0.484],
[s * (pp.halfW - 0.012), pp.y, pp.z],
], seg(12)), (t) => roundRect(lerp(0.011, 0.013, t), lerp(0.026, 0.016, t), 0.006, seg(14)),
{ upright: true }), MAT.paintFlat, "peg.pillionHanger" + (s > 0 ? "R" : "L"));
add(g, write((w) => revolveAlongPts(w, V3(s * pp.halfW, pp.y, pp.z), V3(s, 0.05, 0).normalize(),
0.052, [[0, 0.0115], [0.15, 0.0140], [0.88, 0.0130], [1, 0.0150]], seg(18))),
MAT.rubber, "peg.pillion" + (s > 0 ? "R" : "L"));
}
add(g, tube(catmull3([
[-0.150, P.y - 0.010, P.z + 0.012], [-0.158, P.y - 0.028, P.z + 0.098],
[-0.150, P.y - 0.030, P.z + 0.168],
], seg(12)), 0.0068, seg(12)), MAT.alloyMachined, "control.shiftLever");
add(g, write((w) => revolveAlongPts(w, V3(-0.150, P.y - 0.030, P.z + 0.168),
V3(-1, 0.02, 0.10).normalize(), 0.040, [[0, 0.0095], [1, 0.0085]], seg(14))),
MAT.rubber, "control.shiftTip");
add(g, tube(catmull3([
[0.150, P.y - 0.008, P.z + 0.008], [0.166, P.y - 0.034, P.z + 0.090],
[0.158, P.y - 0.040, P.z + 0.156],
], seg(12)), 0.0072, seg(12)), MAT.alloyMachined, "control.brakePedal");
add(g, filletBox(0.010, 0.034, 0.026, 0.005, { segments: seg(14), rings: seg(6) })
.translate(0.158, P.y - 0.044, P.z + 0.162), MAT.knurled, "control.pedalPad");
add(g, filletBox(0.030, 0.078, 0.032, 0.011, { segments: seg(18), rings: seg(7) })
.translate(0.166, P.y + 0.026, P.z - 0.026), MAT.alloyMachined, "control.rearMaster");
add(g, tube(catmull3([
[0.166, P.y + 0.062, P.z - 0.030], [0.146, P.y + 0.096, P.z - 0.072],
[0.120, P.y + 0.108, P.z - 0.126],
], seg(10)), 0.0038, seg(10)), MAT.rubber, "control.rearHose");
add(g, tube(catmull3([
[0.166, P.y - 0.006, P.z - 0.020],
[0.140, D.pivot[1] - 0.020, D.pivot[0] - 0.120],
[0.120, D.rear.axle[1] + 0.052, D.rear.axle[0] + 0.180],
[0.106, D.rear.axle[1] + 0.014, D.rear.axle[0] + 0.062],
], seg(20)), 0.0040, seg(10)), MAT.rubber, "control.rearBrakeLine");
add(g, tube(catmull3([
[-0.126, D.pivot[1] - 0.028, D.pivot[0] + 0.030],
[-0.150, D.pivot[1] - 0.072, D.pivot[0] - 0.030],
[-0.156, D.pivot[1] - 0.104, D.pivot[0] - 0.108],
], seg(10)), (t) => lerp(0.0105, 0.0080, t), seg(12)), MAT.paintFlat, "stand.side");
add(g, filletBox(0.010, 0.020, 0.048, 0.005, { segments: seg(14), rings: seg(6) })
.translate(-0.158, D.pivot[1] - 0.112, D.pivot[0] - 0.124), MAT.paintFlat, "stand.foot");
return g;
}
/* ============================================================================
6. ENGINE · EXHAUST · COOLING
----------------------------------------------------------------------------
636 cc inline four. The crankcase is a loft of stations along Z; the barrel
and head are authored upright in a bore-local frame and then rotated by the
contract's bank angle, so ports, cam cover and throttle bodies all move
together if that angle changes.
========================================================================== */
/* loft a run of 2D sections placed along +Z, capped at both ends */
function loftZ(stations, { w = null, flip = false, capStart = true, capEnd = true } = {}) {
const rows = stations.map((s) => s.sec.map((p) => V3(p.x, p.y, s.z)));
const target = w || new Writer();
target.grid(rows, { closeU: true, flip });
if (capStart) target.fan(rows[0], !flip);
if (capEnd) target.fan(rows[rows.length - 1], flip);
return w ? target : orient(target.geometry());
}
/* loft along +Y — used inside the bore-local frame, section is (x, z) */
function loftY(stations, { w = null, flip = false, capStart = true, capEnd = true } = {}) {
const rows = stations.map((s) => s.sec.map((p) => V3(p.x, s.y, p.y)));
const target = w || new Writer();
target.grid(rows, { closeU: true, flip });
if (capStart) target.fan(rows[0], !flip);
if (capEnd) target.fan(rows[rows.length - 1], flip);
return w ? target : orient(target.geometry());
}
function buildEngine(root) {
const g = new THREE.Group(); g.name = "engine"; root.add(g);
const E = D.engine, S = seg(40);
const box = (hw, lo, hi, r) => secMove(roundRect(hw, (hi - lo) / 2, r, S), 0, (hi + lo) / 2);
/* -- 6.1 crankcase ---------------------------------------------------- */
add(g, loftZ([
{ z: 0.168, sec: box(0.074, 0.258, 0.396, 0.026) },
{ z: 0.132, sec: box(0.104, 0.230, 0.424, 0.030) },
{ z: 0.076, sec: box(0.128, 0.202, 0.440, 0.034) },
{ z: 0.018, sec: box(0.139, 0.182, 0.446, 0.036) },
{ z: -0.040, sec: box(0.140, 0.176, 0.444, 0.036) },
{ z: -0.094, sec: box(0.131, 0.192, 0.434, 0.034) },
{ z: -0.140, sec: box(0.108, 0.228, 0.418, 0.030) },
{ z: -0.176, sec: box(0.070, 0.286, 0.400, 0.024) },
]), MAT.alloyCast, "engine.crankcase");
add(g, loftZ([
{ z: 0.124, sec: box(0.086, 0.176, 0.232, 0.024) },
{ z: 0.060, sec: box(0.104, 0.160, 0.216, 0.026) },
{ z: -0.020, sec: box(0.107, 0.154, 0.212, 0.026) },
{ z: -0.092, sec: box(0.094, 0.166, 0.222, 0.024) },
{ z: -0.128, sec: box(0.070, 0.190, 0.240, 0.020) },
]), MAT.alloyCast, "engine.sump");
add(g, write((w) => revolveAlongPts(w, V3(0.050, 0.156, 0.030), V3(0, -1, 0), 0.020,
[[0, 0.0130], [0.5, 0.0130], [1, 0.0110]], 6)), MAT.bolt, "engine.drainPlug");
/* -- 6.2 barrel, head and cam cover in the bore-local frame ---------- */
const bore = new THREE.Group();
bore.name = "engine.top";
bore.position.set(0, E.crank[1], E.crank[0]);
bore.rotation.x = E.bankAngle;
g.add(bore);
const bx = (hw, lo, hi, r) => secMove(roundRect(hw, (hi - lo) / 2, r, S), 0, (hi + lo) / 2);
add(bore, loftY([
{ y: 0.120, sec: bx(0.138, -0.080, 0.072, 0.020) },
{ y: 0.146, sec: bx(0.132, -0.074, 0.066, 0.018) },
{ y: 0.172, sec: bx(0.126, -0.070, 0.062, 0.016) },
{ y: 0.238, sec: bx(0.124, -0.068, 0.060, 0.014) },
{ y: 0.252, sec: bx(0.130, -0.074, 0.070, 0.016) },
]), MAT.alloyCast, "engine.barrel");
add(bore, loftY([
{ y: 0.250, sec: bx(0.132, -0.082, 0.080, 0.016) },
{ y: 0.270, sec: bx(0.136, -0.088, 0.088, 0.018) },
{ y: 0.288, sec: bx(0.134, -0.086, 0.084, 0.018) },
{ y: 0.298, sec: bx(0.128, -0.078, 0.074, 0.018) },
]), MAT.alloyCast, "engine.head");
add(bore, loftY([
{ y: 0.296, sec: bx(0.126, -0.078, 0.072, 0.018) },
{ y: 0.314, sec: bx(0.128, -0.080, 0.074, 0.020) },
{ y: 0.338, sec: bx(0.120, -0.072, 0.066, 0.022) },
{ y: 0.348, sec: bx(0.104, -0.060, 0.054, 0.022) },
]), MAT.alloyCast, "engine.camCover");
const ribs = [], plugs = [], stubs = [];
for (let i = 0; i < 4; i++) {
const x = lerp(-0.084, 0.084, i / 3);
ribs.push(M.t(x, 0.350, 0.002));
plugs.push(M.t(x, 0.352, -0.028));
stubs.push(M.t(x, 0.298, -0.088));
}
instance(bore, filletBox(0.030, 0.014, 0.104, 0.006, { segments: seg(16), rings: seg(6) }),
MAT.alloyCast, ribs, "engine.camRib");
instance(bore, revolve([V2(0.0135, 0), V2(0.0135, 0.013), V2(0.0100, 0.015)],
{ axis: "y", segments: seg(14), capEnd: true }), MAT.boltDark, plugs, "engine.plugTube");
instance(bore, revolve([V2(0.0225, 0), V2(0.0225, 0.030), V2(0.0250, 0.036), V2(0.0250, 0.054)],
{ axis: "y", segments: seg(16) }), MAT.plastic, stubs, "engine.intakeStub");
add(bore, filletBox(0.212, 0.060, 0.088, 0.016, { segments: seg(22), rings: seg(8) })
.translate(0, 0.356, -0.106), MAT.plastic, "engine.throttleBodies");
/* -- 6.3 side covers -------------------------------------------------- */
add(g, xform(revolve([
V2(0.030, 0.140), V2(0.086, 0.140), V2(0.100, 0.134), V2(0.107, 0.120),
V2(0.109, 0.094), V2(0.104, 0.058), V2(0.088, 0.020), V2(0.040, 0.006), V2(0, 0.004),
], { axis: "x", segments: seg(44), capEnd: true }), M.t(0, 0.340, -0.052)),
MAT.alloyCast, "engine.clutchCover");
const ccBolts = [];
for (let i = 0; i < 9; i++) {
const a = (i / 9) * TAU + 0.2;
ccBolts.push(M.c(M.t(0.130, 0.340 + Math.sin(a) * 0.098, -0.052 + Math.cos(a) * 0.098),
M.rz(-Math.PI / 2)));
}
instance(g, hexBolt(0.0050, 0.0038), MAT.bolt, ccBolts, "engine.clutchBolts");
add(g, xform(revolve([
V2(0.028, -0.142), V2(0.078, -0.142), V2(0.092, -0.136), V2(0.099, -0.120),
V2(0.099, -0.086), V2(0.090, -0.036), V2(0.046, -0.008), V2(0, -0.004),
], { axis: "x", segments: seg(44), capEnd: true, flip: true }), M.t(0, 0.352, 0.014)),
MAT.alloyCast, "engine.altCover");
add(g, xform(revolve([
V2(0.026, -0.152), V2(0.062, -0.152), V2(0.070, -0.144), V2(0.072, -0.120),
V2(0.066, -0.104), V2(0.030, -0.096), V2(0, -0.094),
], { axis: "x", segments: seg(32), capEnd: true, flip: true }),
M.t(0, D.countershaft[1], D.countershaft[0])), MAT.alloyCast, "engine.sprocketCover");
const fs = makeSprocket({
teeth: 15, pitchR: D.frontSprocketR, thick: 0.0072, boreR: 0.020,
name: "sprocket.front", mat: MAT.chain,
});
fs.position.set(-0.0876, D.countershaft[1], D.countershaft[0]);
g.add(fs);
add(g, xform(revolve([V2(0.026, -0.106), V2(0.038, -0.106), V2(0.040, -0.070),
V2(0.030, -0.056), V2(0, -0.054)], { axis: "x", segments: seg(22), capEnd: true, flip: true }),
M.t(0, 0.424, -0.126)), MAT.alloyCast, "engine.starter");
add(g, xform(revolve([V2(0.024, 0.104), V2(0.040, 0.104), V2(0.044, 0.078),
V2(0.034, 0.062), V2(0, 0.060)], { axis: "x", segments: seg(22), capEnd: true }),
M.t(0, 0.248, 0.086)), MAT.alloyCast, "engine.waterPump");
/* -- 6.4 exhaust ------------------------------------------------------
Four headers leave the head face, drop down the front of the engine,
converge under the sump and feed the right-side silencer. */
const ex = new THREE.Group(); ex.name = "exhaust"; g.add(ex);
const R = D.exhaust;
for (let i = 0; i < 4; i++) {
const p = V3(lerp(-0.086, 0.086, i / 3), 0.268, 0.086)
.applyAxisAngle(V3(1, 0, 0), E.bankAngle)
.add(V3(0, E.crank[1], E.crank[0]));
const sx = Math.sign(p.x) || 1, ax = Math.abs(p.x);
add(ex, tube(catmull3([
[p.x, p.y, p.z],
[p.x, p.y - 0.050, p.z + 0.048],
[sx * ax * 0.95, p.y - 0.144, p.z + 0.070],
[sx * ax * 0.87, 0.288, p.z + 0.026],
[sx * ax * 0.75, 0.224, 0.156],
[sx * ax * 0.58, 0.199, 0.040],
[sx * ax * 0.42, 0.195, -0.080],
[sx * ax * 0.22, 0.197, -0.166],
], seg(34)), (t) => R.headerR * (1 + 0.10 * t), seg(14)), MAT.header, "exhaust.header" + i);
}
add(ex, filletBox(0.112, 0.084, 0.098, 0.028, { segments: seg(22), rings: seg(8) })
.translate(0.014, R.collectorY + 0.008, R.collectorZ), MAT.header, "exhaust.preChamber");
add(ex, tube(catmull3([
[0.040, R.collectorY + 0.010, R.collectorZ - 0.044],
[0.078, R.collectorY + 0.026, R.collectorZ - 0.074],
[0.108, R.collectorY + 0.048, R.collectorZ - 0.100],
], seg(14)), (t) => lerp(0.036, R.canR0 * 0.92, t), seg(18)), MAT.header, "exhaust.link");
const canA = V3(...R.canA), canB = V3(...R.canB);
const canDir = V3().subVectors(canB, canA).normalize();
add(ex, write((w) => revolveAlongPts(w, canA, canDir, canA.distanceTo(canB), [
[0, R.canR0 * 0.86], [0.06, R.canR0], [0.55, R.canR1 * 0.95],
[0.90, R.canR1], [1, R.canR1 * 0.98],
], seg(32))), MAT.titanium, "exhaust.can");
add(ex, write((w) => revolveAlongPts(w, canB.clone().addScaledVector(canDir, -0.016), canDir, 0.028, [
[0, R.canR1 * 1.04], [0.62, R.canR1 * 1.04], [0.66, R.canR1 * 0.95], [1, R.canR1 * 0.90],
], seg(32))), MAT.alloyAnodized, "exhaust.canCap");
add(ex, write((w) => revolveAlongPts(w, canB.clone().addScaledVector(canDir, 0.001), canDir, 0.018, [
[0, R.canR1 * 0.78], [1, R.canR1 * 0.72],
], seg(28))), MAT.plastic, "exhaust.outlet");
add(ex, sweep(catmull3([
[0.128, 0.310, -0.520], [0.152, 0.344, -0.556], [0.136, 0.396, -0.598],
], seg(10)), () => roundRect(0.006, 0.016, 0.004, seg(12)), { upright: true }),
MAT.alloyAnodized, "exhaust.strap");
/* -- 6.5 cooling ------------------------------------------------------ */
const C = D.radiator;
const radRows = [];
const RN = seg(26);
for (let i = 0; i < 6; i++) {
const t = i / 5;
const y = lerp(C.y0, C.y1, t);
const row = [];
for (let c = 0; c <= RN; c++) {
const x = lerp(-C.halfW, C.halfW, c / RN);
// bowed forward at the centre, following the fairing's plan curve
const bow = 0.026 * (1 - Math.pow(Math.abs(x) / C.halfW, 2));
row.push(V3(x, y, C.z1 + bow - 0.012 * t));
}
radRows.push(row);
}
add(g, panelShell(radRows, 0.028), MAT.radiator, "cooling.radiator");
for (const s of [1, -1]) {
add(g, sweep(catmull3([
[s * (C.halfW + 0.005), C.y0 - 0.008, C.z1 + 0.004],
[s * (C.halfW + 0.007), (C.y0 + C.y1) / 2, C.z1 + 0.008],
[s * (C.halfW + 0.005), C.y1 + 0.008, C.z1 + 0.004],
], seg(10)), () => roundRect(0.010, 0.020, 0.006, seg(14)), { upright: true }),
MAT.alloyCast, "cooling.radTank" + (s > 0 ? "R" : "L"));
}
add(g, xform(revolve([V2(0.086, 0), V2(0.086, 0.026), V2(0.062, 0.030), V2(0, 0.030)],
{ axis: "z", segments: seg(28), capStart: true }), M.t(-0.020, 0.478, C.z0 - 0.056)),
MAT.plastic, "cooling.fan");
add(g, tube(catmull3([
[0.108, C.y1 - 0.014, C.z1 - 0.004], [0.132, 0.578, 0.230], [0.110, 0.536, 0.152],
], seg(12)), 0.0135, seg(12)), MAT.rubber, "cooling.upperHose");
add(g, tube(catmull3([
[-0.100, C.y0 + 0.016, C.z1 - 0.004], [-0.120, 0.318, 0.224], [-0.072, 0.264, 0.118],
[-0.024, 0.252, 0.092],
], seg(14)), 0.0135, seg(12)), MAT.rubber, "cooling.lowerHose");
add(g, filletBox(0.172, 0.048, 0.030, 0.008, { segments: seg(20), rings: seg(7) })
.translate(0, 0.328, C.z1 + 0.010), MAT.radiator, "cooling.oilCooler");
return g;
}
/* ============================================================================
7. BODYWORK
----------------------------------------------------------------------------
Every panel is a sampled surface, not a primitive. Closed volumes (tank,
tail) use four-quadrant superellipse sections whose exponents carry the
design language: soft ~2.6 crowns over knife-edged ~5 lower creases. Open
panels (cowl, flanks, belly, fenders) go through panelShell() so the cut
edges have real thickness — which is what separates a fairing from a decal.
========================================================================== */
/* open arc of the four-quadrant superellipse, a0 -> a1 in section angle */
function arcSuper({ halfW, top, bottom, cx = 0, cy = 0, nTop = 2.6, nBot = 2.6,
nTopIn = null, nBotIn = null, a0, a1, segments = 40 }) {
const out = [];
for (let i = 0; i <= segments; i++) {
const a = lerp(a0, a1, i / segments);
const ca = Math.cos(a), sa = Math.sin(a);
const upper = sa >= 0;
const nx = upper ? (nTopIn ?? nTop) : (nBotIn ?? nBot);
const ny = upper ? nTop : nBot;
const h = upper ? top : bottom;
out.push(V2(
cx + Math.sign(ca) * Math.pow(Math.abs(ca), 2 / nx) * halfW,
cy + Math.sign(sa) * Math.pow(Math.abs(sa), 2 / ny) * h,
));
}
return out;
}
/* Resample a station table so a loft reads as a surface rather than a run of
flats. Ten stations across 700 mm of tank leaves 80 mm facets, which on a
gloss black panel is exactly where the highlight breaks and gives away the
polygon. Z advances linearly; every shape column eases, so the stations
become tangent rather than corners. */
function resampleTable(table, n) {
const m = table.length - 1;
const out = [];
for (let i = 0; i < n; i++) {
const f = (i / (n - 1)) * m;
const j = Math.min(m - 1, Math.floor(f));
const raw = f - j, eased = smooth01(raw);
out.push(table[0].map((_, k) => lerp(table[j][k], table[j + 1][k], k === 0 ? raw : eased)));
}
return out;
}
/* closed loft from a station table, capped at both ends */
function loftClosed(rows) {
return orient(write((w) => {
w.grid(rows, { closeU: true });
w.fan(rows[0], true);
w.fan(rows[rows.length - 1], false);
}));
}
/* conformal decal: lift a rectangle of the parent surface along its own
normals so graphics wrap the panel instead of floating over it */
/* Returns the lifted patch plus whether the parent grid's own winding faces
inward there. A station table that runs -Z produces inward normals, which
would bury the graphic inside the panel and show it mirrored. */
function decalPatch(rows, r0, r1, c0, c1, lift = 0.0007) {
const N = gridNormals(rows);
const centre = V3();
let n = 0;
for (const row of rows) for (const p of row) { centre.add(p); n++; }
centre.divideScalar(n);
let facing = 0;
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
facing += N[r][c].dot(V3().subVectors(rows[r][c], centre));
}
}
const sign = facing < 0 ? -1 : 1;
const out = [];
for (let r = r0; r <= r1; r++) {
const line = [];
for (let c = c0; c <= c1; c++) {
line.push(V3().copy(rows[r][c]).addScaledVector(N[r][c], lift * sign));
}
out.push(line);
}
out.inward = sign < 0;
return out;
}
/* Rows run along the machine's length, so U follows Z and V follows the
column direction across the panel. Seen from +X the nose is to screen-left,
from -X it is to screen-right, so mirrored panels reverse U. V is reversed
per call because a section's columns climb on one flank and fall on the
other. Winding is flipped when the parent grid faces inward there. */
function addDecal(parent, rows, r0, r1, c0, c1, material, name,
{ flipU = false, flipV = false } = {}) {
const patch = decalPatch(rows, r0, r1, c0, c1);
const inward = patch.inward;
const geo = write((w) => w.grid(patch, {
flip: inward,
uvFn: (r, c, R, C) => [
flipU ? 1 - r / (R - 1) : r / (R - 1),
flipV ? c / (C - 1) : 1 - c / (C - 1),
],
}));
return add(parent, geo, material, name, { cast: false, receive: false });
}
function buildBody(root) {
const g = new THREE.Group(); g.name = "body"; root.add(g);
const B = D.body;
const SS = seg(52);
/* ======================================================================
7.1 FUEL TANK COVER — widest at the rider's knees, lower flank scooped
so it tucks in over the frame spar.
==================================================================== */
const tankTable = [
// z, halfW, top, bottom, nTop, nBot, wBot
[-0.300, 0.064, 0.868, 0.784, 2.9, 3.2, 0.92],
[-0.230, 0.090, 0.888, 0.764, 2.9, 3.0, 0.90],
[-0.150, 0.124, 0.896, 0.738, 2.8, 2.7, 0.86],
[-0.060, 0.151, 0.898, 0.714, 2.7, 2.5, 0.82],
[0.030, 0.161, 0.892, 0.702, 2.6, 2.4, 0.80],
[0.120, 0.158, 0.880, 0.704, 2.5, 2.4, 0.82],
[0.210, 0.140, 0.864, 0.718, 2.5, 2.5, 0.86],
[0.300, 0.112, 0.848, 0.740, 2.6, 2.7, 0.90],
[0.372, 0.082, 0.834, 0.766, 2.8, 3.0, 0.94],
[0.418, 0.058, 0.824, 0.790, 3.0, 3.2, 0.96],
];
const tankRows = resampleTable(tankTable, seg(30)).map(([z, hw, top, bot, nT, nB, wB]) => superSection({
halfW: hw, top: (top - bot) / 2, bottom: (top - bot) / 2, cy: (top + bot) / 2,
nTop: nT, nBot: nB, nTopIn: nT * 1.15, nBotIn: nB * 1.6, wBot: wB, segments: SS,
}).map((p) => V3(p.x, p.y, z)));
add(g, loftClosed(tankRows), MAT.paintGloss, "body.tank");
/* Anything sitting on the tank has to be seated on the tank, not at a
remembered height: the crown moved when the hump was raised, which left
the filler half sunk at one edge and floating at the other. Sample the
generated surface and match its local slope. */
const crownOf = (row) => {
let y = -Infinity;
for (const p of row) if (Math.abs(p.x) < 0.030 && p.y > y) y = p.y;
return y;
};
const tankCrown = (z) => {
let lo = null, hi = null;
for (const row of tankRows) {
const rz = row[0].z;
if (rz <= z && (!lo || rz > lo[0].z)) lo = row;
if (rz >= z && (!hi || rz < hi[0].z)) hi = row;
}
if (!lo) return crownOf(hi);
if (!hi || lo === hi) return crownOf(lo);
return lerp(crownOf(lo), crownOf(hi),
(z - lo[0].z) / (hi[0].z - lo[0].z));
};
/* Seat a fitting on the chord through its OWN rim, not on the crown with a
slope guessed from a wider baseline. Sampling ±48 mm for a ±32 mm cap
over-rotated it: the leading edge sank into the tank while the trailing
edge lifted off — half buried, half proud. Taking the chord across the
part's actual radius puts every point of the rim the same height clear. */
const seatOn = (z, reach, lift) => {
const yF = tankCrown(z + reach), yB = tankCrown(z - reach);
return M.c(M.t(0, (yF + yB) / 2 + lift, z),
M.rx(Math.atan2(yB - yF, 2 * reach)));
};
/* domed cap with a rolled rim and a key barrel, sitting in a recessed
collar — a plain disc at this scale disappears into the gloss */
add(g, xform(revolve([
V2(0, 0.0065), V2(0.013, 0.0062), V2(0.023, 0.0042), V2(0.029, 0.0005),
V2(0.0315, -0.0035), V2(0.030, -0.0085), V2(0.024, -0.012), V2(0, -0.013),
], { axis: "y", segments: seg(36) }), seatOn(0.196, 0.032, 0.0030)),
MAT.alloyMachined, "body.fillerCap");
add(g, xform(revolve([
V2(0, 0.0072), V2(0.0068, 0.0070), V2(0.0075, 0.0045), V2(0, 0.0042),
], { axis: "y", segments: seg(20) }), seatOn(0.196, 0.032, 0.0030)),
MAT.boltDark, "body.fillerKey");
// open annular collar: no closed volume to test, so its winding is stated
add(g, xform(revolve([
V2(0.0305, -0.0025), V2(0.0400, -0.0050), V2(0.0425, -0.0125),
], { axis: "y", segments: seg(36), flip: true }), seatOn(0.196, 0.032, 0.0030)),
MAT.paintFlat, "body.fillerRing");
add(g, xform(write((w) => extrudePlate(catmull2([
[0, 0.074], [0.031, 0.040], [0.035, -0.048], [0, -0.072],
[-0.035, -0.048], [-0.031, 0.040],
], seg(40), true), 0.004, { axis: "y", bevel: 0.0012, w })), seatOn(0.058, 0.073, 0.0018)),
MAT.rubber, "body.tankPad");
/* ======================================================================
7.2 RIDER SEAT
==================================================================== */
const seatTable = [
[-0.470, 0.052, 0.856, 0.816], [-0.420, 0.080, 0.845, 0.806],
[-0.372, 0.097, 0.836, 0.798], [-0.326, 0.100, 0.830, 0.794],
[-0.286, 0.093, 0.830, 0.794], [-0.240, 0.076, 0.836, 0.798],
[-0.196, 0.054, 0.846, 0.806],
];
const seatRows = resampleTable(seatTable, seg(18)).map(([z, hw, top, bot]) => superSection({
halfW: hw, top: (top - bot) / 2, bottom: (top - bot) / 2, cy: (top + bot) / 2,
nTop: 3.4, nBot: 4.6, nTopIn: 4.0, nBotIn: 6.0, segments: SS,
}).map((p) => V3(p.x, p.y, z)));
add(g, loftClosed(seatRows), MAT.seat, "body.seatRider");
/* ======================================================================
7.3 TAIL UNIT — rises behind the rider then knifes to a point; high
lower exponents keep the underside folded rather than inflated.
==================================================================== */
// shorter and more upswept than a first pass suggests: a supersport tail
// finishes barely past the rear axle and rides high above the hugger
// The spine is the machine's signature line: hump over the tank, scoop at
// the rider's seat, then a hard kick up through the tail before the tip
// knifes down. A flat run through here reads as a naked bike.
const tailTable = [
[-0.395, 0.090, 0.852, 0.788, 3.0, 3.4],
[-0.462, 0.102, 0.878, 0.780, 3.0, 3.8],
[-0.535, 0.106, 0.898, 0.774, 3.0, 4.2],
[-0.610, 0.103, 0.914, 0.772, 3.1, 4.6],
[-0.686, 0.096, 0.926, 0.776, 3.2, 5.0],
[-0.755, 0.082, 0.934, 0.790, 3.4, 5.2],
// the last three stations are the cut-off face, not a taper: the crown
// holds its height while the underside climbs to meet it
[-0.818, 0.062, 0.939, 0.822, 3.6, 5.0],
[-0.858, 0.038, 0.941, 0.868, 3.8, 4.6],
[-0.878, 0.014, 0.936, 0.916, 4.2, 4.4],
];
const tailRows = resampleTable(tailTable, seg(26)).map(([z, hw, top, bot, nT, nB]) => superSection({
halfW: hw, top: (top - bot) / 2, bottom: (top - bot) / 2, cy: (top + bot) / 2,
nTop: nT, nBot: nB, nTopIn: nT * 1.2, nBotIn: nB * 1.1, segments: SS,
}).map((p) => V3(p.x, p.y, z)));
add(g, loftClosed(tailRows), MAT.paintFlat, "body.tail");
const tR = tailRows.length, tC = tailRows[0].length;
// superSection starts at +X and runs anticlockwise, so the -X flank sits
// either side of the half-way column
addDecal(g, tailRows, Math.round(tR * 0.10), Math.round(tR * 0.42),
0, Math.round(tC * 0.085), MAT.decalKawasaki, "body.decalTail.R", { flipV: true });
addDecal(g, tailRows, Math.round(tR * 0.10), Math.round(tR * 0.42),
Math.round(tC * 0.415), Math.round(tC * 0.50), MAT.decalKawasaki,
"body.decalTail.L", { flipU: true });
// the pillion pad rides proud of the tail crown, as it does on the machine
const pilTable = [
[-0.442, 0.050, 0.884, 0.856], [-0.492, 0.070, 0.900, 0.872],
[-0.545, 0.074, 0.912, 0.886], [-0.600, 0.066, 0.922, 0.898],
[-0.648, 0.046, 0.928, 0.908],
];
const pilRows = resampleTable(pilTable, seg(15)).map(([z, hw, top, bot]) => superSection({
halfW: hw, top: (top - bot) / 2, bottom: (top - bot) / 2, cy: (top + bot) / 2,
nTop: 3.6, nBot: 6, segments: SS,
}).map((p) => V3(p.x, p.y, z)));
add(g, loftClosed(pilRows), MAT.seat, "body.seatPillion");
pair(g, sweep(catmull3([
[0.086, 0.870, -0.470], [0.101, 0.888, -0.560], [0.094, 0.894, -0.646],
], seg(12)), () => roundRect(0.008, 0.011, 0.005, seg(12)), { upright: true }),
MAT.alloyAnodized, "body.grabRail");
/* tail lamp let into the tail end, LED bar behind the lens */
const lampRows = [];
for (let i = 0; i <= 6; i++) {
const t = i / 6, z = lerp(-0.790, -0.862, t);
const hw = lerp(0.066, 0.030, t), h = lerp(0.034, 0.016, t);
lampRows.push(arcSuper({
halfW: hw, top: h, bottom: h, cy: lerp(0.876, 0.904, t),
nTop: 3.4, nBot: 4.6, a0: Math.PI * 0.82, a1: Math.PI * 0.18, segments: seg(26),
}).map((p) => V3(p.x, p.y, z)));
}
add(g, panelShell(lampRows, 0.005, { flip: true }), MAT.lensRed, "body.tailLamp");
add(g, filletBox(0.066, 0.009, 0.020, 0.003, { segments: seg(14), rings: seg(5) })
.translate(0, 0.894, -0.800), MAT.led, "body.tailLED");
/* ======================================================================
7.4 UPPER COWL — one shell from the nose tip back to the screen base,
wrapping down both flanks. Its lower boundary rises at the front: that
gap is the headlight aperture, and the lamp units fill it.
==================================================================== */
const cowlTable = [
// z, halfW, crownY, lowY, nTop, nBot, crownPinch
[0.500, 0.152, 0.982, 0.780, 2.45, 3.2, 1.90],
[0.560, 0.158, 0.990, 0.778, 2.45, 3.2, 1.86],
[0.620, 0.159, 0.986, 0.780, 2.45, 3.3, 1.82],
[0.680, 0.155, 0.972, 0.790, 2.50, 3.4, 1.78],
[0.740, 0.148, 0.952, 0.806, 2.55, 3.5, 1.74],
[0.790, 0.138, 0.932, 0.826, 2.65, 3.6, 1.70],
[0.840, 0.122, 0.912, 0.848, 2.80, 3.8, 1.68],
[0.878, 0.100, 0.896, 0.864, 3.00, 4.0, 1.72],
[0.902, 0.070, 0.884, 0.872, 3.30, 4.2, 1.80],
[0.918, 0.036, 0.876, 0.870, 3.70, 4.4, 1.95],
];
/* Two creases are pressed into the shell either side of the crown and one
along each cheek. They are geometry, not shading: a black fairing has no
albedo variation to read, so its whole form is carried by where the
softbox highlight breaks. */
const CREASES = [[0.255, 0.030, 0.055], [0.745, 0.030, 0.055],
[0.400, 0.020, 0.045], [0.600, 0.020, 0.045]];
const cowlRows = [];
const cowlSegs = seg(58);
for (let i = cowlTable.length - 1; i >= 0; i--) {
const [z, hw, crown, low, nT, nB, pinch] = cowlTable[i];
const cy = (crown + low) / 2, h = (crown - low) / 2;
const sec = arcSuper({
halfW: hw, top: h, bottom: h, cy,
// pinch < 2 narrows the crown into a central ridge instead of a dome
nTop: nT, nBot: nB, nTopIn: pinch, nBotIn: nB * 1.25,
a0: -Math.PI * 0.42, a1: Math.PI * 1.42, segments: cowlSegs,
});
const taper = smooth01(clampf((0.918 - z) / 0.20, 0, 1)); // fade out at the tip
cowlRows.push(sec.map((p, c) => {
const u = c / cowlSegs;
let k = 1;
for (const [uc, depth, wdt] of CREASES) {
k -= depth * taper * Math.exp(-Math.pow((u - uc) / wdt, 2));
}
return V3(p.x * k, cy + (p.y - cy) * k, z);
}));
}
add(g, panelShell(cowlRows, 0.0042), MAT.paintFlat, "body.cowlUpper");
const cC = cowlRows[0].length;
addDecal(g, cowlRows, 4, 7, Math.round(cC * 0.06), Math.round(cC * 0.20),
MAT.decalNinja, "body.decalNinja.R", { flipV: true });
addDecal(g, cowlRows, 4, 7, Math.round(cC * 0.80), Math.round(cC * 0.94),
MAT.decalNinja, "body.decalNinja.L", { flipU: true });
/* lower nose: bridges the headlight aperture down to the side cowls, and
gives the front its folded chin under the lamps */
const chinTable = [
[0.620, 0.150, 0.788, 0.694],
[0.686, 0.148, 0.792, 0.698],
[0.752, 0.143, 0.794, 0.706],
[0.812, 0.133, 0.797, 0.720],
[0.862, 0.118, 0.801, 0.740],
[0.898, 0.094, 0.812, 0.768],
[0.918, 0.056, 0.838, 0.808],
];
const chinRows = [];
for (let i = chinTable.length - 1; i >= 0; i--) {
const [z, hw, yTop, yBot] = chinTable[i];
const row = [];
const N = seg(30);
for (let c = 0; c <= N; c++) {
const u = (c / N) * 2 - 1; // +X edge -> -X edge
const k = Math.pow(Math.max(0, 1 - Math.pow(Math.abs(u), 2.4)), 0.40);
row.push(V3(-u * hw, lerp(yTop, yBot, k), z));
}
chinRows.push(row);
}
add(g, panelShell(chinRows, 0.0042, { flip: true }), MAT.paintFlat, "body.cowlChin");
/* ram-air duct in the nose centre */
const duct = [];
for (let i = 0; i <= 5; i++) {
const t = i / 5, z = lerp(0.924, 0.848, t);
duct.push(roundRect(lerp(0.019, 0.028, t), lerp(0.014, 0.021, t), 0.006, seg(20))
.map((p) => V3(p.x, p.y + lerp(0.838, 0.834, t), z)));
}
add(g, write((w) => w.grid(duct, { closeU: true, flip: true })), MAT.plastic, "body.ramDuct");
add(g, write((w) => w.fan(duct[duct.length - 1], false)), MAT.screenMesh, "body.ramScreen",
{ cast: false });
/* Twin LED headlights filling the aperture between cowl and chin. The
units are wedges that widen and drop as they run back into the nose,
which is what gives the ZX-6R its hooded stare. */
for (const s of [1, -1]) {
const hl = [];
const RN = seg(7);
for (let i = 0; i <= RN; i++) {
const t = i / RN, z = lerp(0.918, 0.822, t);
const cx = s * lerp(0.036, 0.070, ease(t, 0.85));
const hw = lerp(0.014, 0.034, ease(t, 0.8));
const hh = lerp(0.011, 0.026, ease(t, 0.9));
const cy = lerp(0.850, 0.830, t);
hl.push(catmull2([
[-hw, -hh * 0.45], [-hw * 0.62, hh * 0.92], [hw * 0.55, hh],
[hw, hh * 0.10], [hw * 0.78, -hh * 0.72], [-hw * 0.20, -hh],
], seg(30), true).map((p) => V3(cx + p.x * s, cy + p.y, z)));
}
add(g, write((w) => { w.grid(hl, { closeU: true }); w.fan(hl[hl.length - 1], false); }),
MAT.reflector, "body.headlampHousing" + (s > 0 ? "R" : "L"));
add(g, write((w) => w.fan(hl[0].map((p) => V3(p.x, p.y, p.z + 0.004)), false)),
MAT.glass, "body.headlampLens" + (s > 0 ? "R" : "L"), { cast: false });
/* LED projector bar visible through the lens */
add(g, write((w) => w.grid([
hl[1].map((p) => V3(p.x * 0.86 + s * 0.006, p.y * 0.0 + hl[1][0].y * 0 + p.y, p.z - 0.006)),
hl[2].map((p) => V3(p.x * 0.80 + s * 0.008, p.y, p.z - 0.004)),
], { closeU: true })), MAT.led, "body.headlampLED" + (s > 0 ? "R" : "L"), { cast: false });
/* eyebrow position lamp along the top edge of the aperture */
add(g, sweep(catmull3([
[s * 0.030, 0.858, 0.908], [s * 0.052, 0.854, 0.878], [s * 0.072, 0.846, 0.846],
], seg(8)), () => roundRect(0.0035, 0.0050, 0.0018, seg(10)), { upright: true }),
MAT.led, "body.positionLamp" + (s > 0 ? "R" : "L"), { cast: false });
}
/* Windscreen. Its lower edge has to sit ON the cowl crown, which is a domed
section, not a straight line — so sample the same station table the cowl
was lofted from and let each column find its own seating height. The
correction fades out toward the top, where the double-bubble takes over. */
const cowlCrownY = (z, x) => {
const T = cowlTable;
let i = 0;
while (i < T.length - 2 && T[i + 1][0] < z) i++;
const k = clampf((z - T[i][0]) / (T[i + 1][0] - T[i][0]), 0, 1);
const L = (n) => lerp(T[i][n], T[i + 1][n], k);
const hw = L(1), crown = L(2), low = L(3), nT = L(4), pinch = L(6);
const cy = (crown + low) / 2, h = (crown - low) / 2;
const ca = Math.pow(clampf(Math.abs(x) / hw, 0, 1), pinch / 2);
const sa = Math.sqrt(Math.max(0, 1 - ca * ca));
// the pressed creases sit the real surface a little inside the analytic one
return cy + Math.pow(sa, 2 / nT) * h - 0.0025;
};
const scrPath = catmull3([
[0, 0.928, 0.804], [0, 0.968, 0.760], [0, 1.014, 0.708],
[0, 1.056, 0.660], [0, B.screenTopY, B.screenTopZ],
], seg(14));
const scrRows = [];
const scrHW = (t) => lerp(0.126, 0.092, ease(t, 0.9));
const seatCentre = cowlCrownY(scrPath[0].z, 0);
for (let i = 0; i < scrPath.length; i++) {
const t = i / (scrPath.length - 1);
const hw = scrHW(t);
const row = [];
const N = seg(24);
for (let c = 0; c <= N; c++) {
const u = (c / N) * 2 - 1, au = Math.abs(u);
const z = scrPath[i].z + 0.014 * Math.pow(au, 2.0);
// how far the cowl crown falls away at this lateral offset
const seat = cowlCrownY(z, u * scrHW(0)) - seatCentre;
// double bubble: raised centre spine between two shoulder troughs
const bump = 0.010 * Math.exp(-Math.pow(u / 0.30, 2))
- 0.004 * Math.exp(-Math.pow((au - 0.62) / 0.22, 2));
row.push(V3(u * hw,
scrPath[i].y + bump * (1 - t * 0.5)
+ seat * Math.pow(1 - t, 1.6) // seat on the cowl
- 0.016 * Math.pow(au, 2.4) * t, // residual sag up top
z));
}
scrRows.push(row);
}
add(g, panelShell(scrRows, 0.0032), MAT.screen, "body.windscreen", { cast: false });
/* instrument cluster under the screen */
add(g, xform(filletBox(0.148, 0.026, 0.084, 0.010, { segments: seg(20), rings: seg(7) }),
M.c(M.t(0, 0.964, 0.614), M.rx(-0.62))), MAT.plastic, "body.dashShell");
add(g, xform(write((w) => extrudePlate(roundRect(0.060, 0.029, 0.010, seg(36)), 0.003,
{ axis: "z", bevel: 0.001, w })), M.c(M.t(0, 0.974, 0.629), M.rx(-0.62))),
MAT.gauge, "body.dashFace");
/* mirrors on winglet stalks, indicator lens in the leading edge */
for (const s of [1, -1]) {
/* Slim stalk: the housing carries the visual weight, so the arm stays
narrow — a thick arm is what makes a mirror read as a bar. */
add(g, sweep(catmull3([
[s * 0.112, 0.936, 0.690], [s * 0.168, 0.962, 0.680],
[s * 0.224, 0.984, 0.666], [s * 0.268, 0.996, 0.654],
], seg(12)), (t) => roundRect(lerp(0.017, 0.009, t), lerp(0.019, 0.013, t), 0.005, seg(16)),
{ upright: true }), MAT.paintFlat, "body.mirrorStalk" + (s > 0 ? "R" : "L"));
/* Housing: a teardrop shell that grows rearward to the glass face */
// solve the housing origin so its outer edge lands on the overall width
const hx = s * (B.mirrorHalfW - 0.076);
const head = [];
const HN = seg(8);
for (let i = 0; i <= HN; i++) {
const t = i / HN;
const z = lerp(0.700, 0.594, t);
const hw = lerp(0.010, 0.050, ease(t, 0.62));
const hh = lerp(0.009, 0.037, ease(t, 0.55));
head.push(catmull2([
[-hw, -hh * 0.55], [-hw * 0.55, hh], [hw * 0.62, hh * 0.90],
[hw, -hh * 0.15], [hw * 0.30, -hh],
], seg(30), true).map((p) => V3(hx + s * (0.026 * t) + p.x * s, 1.004 + p.y, z)));
}
add(g, orient(write((w) => {
w.grid(head, { closeU: true });
w.fan(head[0], true); w.fan(head[HN], false);
})), MAT.paintFlat, "body.mirrorHead" + (s > 0 ? "R" : "L"));
/* glass inset into the rear face */
add(g, write((w) => w.fan(head[HN].map((p) =>
V3(hx + s * 0.026 + (p.x - hx - s * 0.026) * 0.90, 1.004 + (p.y - 1.004) * 0.90,
p.z + 0.004)), false)),
MAT.chrome, "body.mirrorGlass" + (s > 0 ? "R" : "L"), { cast: false });
/* indicator lens moulded into the leading edge */
add(g, write((w) => w.grid([
head[1].map((p) => V3(p.x, p.y, p.z + 0.002)),
head[2].map((p) => V3(p.x, p.y, p.z + 0.001)),
], { closeU: true })), MAT.lensAmber,
"body.indicatorFront" + (s > 0 ? "R" : "L"), { cast: false });
}
/* ======================================================================
7.5 SIDE COWLS — flank panels from under the mirror to the belly,
carrying the ZX-6R graphic and a hot-air louvre over the radiator.
==================================================================== */
/* Each row is a flow line from the top edge down to the bottom edge. The
bottom of a row sits FORWARD of its top, so the trailing edge rakes
forward as it descends instead of ending in a guillotine cut. */
const flankTable = [
// zTop, yTop, zBot, yBot, halfW, edgeTuck
[0.020, 0.648, 0.132, 0.318, 0.124, 0.60],
[0.090, 0.678, 0.176, 0.330, 0.146, 0.58],
[0.170, 0.710, 0.228, 0.352, 0.164, 0.56],
[0.250, 0.738, 0.284, 0.384, 0.178, 0.55],
[0.330, 0.760, 0.342, 0.428, 0.186, 0.54],
[0.410, 0.778, 0.404, 0.486, 0.187, 0.54],
[0.490, 0.792, 0.468, 0.554, 0.180, 0.56],
[0.560, 0.804, 0.528, 0.630, 0.167, 0.58],
[0.620, 0.814, 0.582, 0.706, 0.148, 0.62],
];
const flankRows = (s) => {
const rows = [];
for (let i = flankTable.length - 1; i >= 0; i--) {
const [zTop, yTop, zBot, yBot, hw, tuck] = flankTable[i];
const row = [];
const N = seg(30);
for (let c = 0; c <= N; c++) {
const v = c / N; // top edge -> bottom edge
let k = Math.pow(Math.sin(Math.PI * (0.10 + 0.80 * v)), 0.42);
k = lerp(tuck, 1, k);
// sharp feature line running the length of the panel
k *= 1 - 0.075 * Math.exp(-Math.pow((v - 0.44) / 0.055, 2));
row.push(V3(s * hw * k, lerp(yTop, yBot, ease(v, 1.12)), lerp(zTop, zBot, ease(v, 1.35))));
}
rows.push(row);
}
return rows;
};
for (const s of [1, -1]) {
const rows = flankRows(s);
add(g, panelShell(rows, 0.0042, { flip: s < 0 }), MAT.paintFlat,
"body.sideCowl" + (s > 0 ? "R" : "L"));
const R = rows.length, C = rows[0].length;
addDecal(g, rows, Math.round(R * 0.32), Math.round(R * 0.54),
Math.round(C * 0.10), Math.round(C * 0.44), MAT.decalZX6R,
"body.decalZX6R" + (s > 0 ? "R" : "L"), { flipU: s < 0 });
addDecal(g, rows, Math.round(R * 0.70), Math.round(R * 0.86),
Math.round(C * 0.50), Math.round(C * 0.86), MAT.decalKawasaki,
"body.decalKawasaki" + (s > 0 ? "R" : "L"), { flipU: s < 0 });
/* hot-air louvre over the radiator, recessed into the panel */
const vent = decalPatch(rows, 1, 4, Math.round(C * 0.14), Math.round(C * 0.40), -0.005);
add(g, write((w) => w.grid(vent, { flip: vent.inward })),
MAT.screenMesh, "body.sideVent" + (s > 0 ? "R" : "L"), { cast: false });
/* inner return lip so the lower cut edge never shows hollow */
const lip = decalPatch(rows, 0, R - 1, C - 3, C - 1, -0.018);
add(g, write((w) => w.grid(lip, { flip: lip.inward })),
MAT.plastic, "body.sideLip" + (s > 0 ? "R" : "L"), { cast: false });
}
/* ======================================================================
7.6 BELLY PAN
==================================================================== */
const bellyTable = [
[-0.250, 0.052, 0.288, 0.242], [-0.160, 0.086, 0.278, 0.214],
[-0.060, 0.113, 0.284, 0.194], [0.040, 0.126, 0.302, 0.182],
[0.140, 0.130, 0.330, 0.182], [0.230, 0.126, 0.364, 0.194],
[0.300, 0.116, 0.402, 0.218], [0.348, 0.100, 0.444, 0.258],
];
const bellyRows = [];
for (let i = bellyTable.length - 1; i >= 0; i--) {
const [z, hw, yTop, yBot] = bellyTable[i];
const row = [];
const N = seg(30);
for (let c = 0; c <= N; c++) {
const u = (c / N) * 2 - 1; // +X edge -> -X edge
const k = Math.pow(Math.max(0, 1 - Math.pow(Math.abs(u), 2.6)), 0.45);
row.push(V3(-u * hw, lerp(yTop, yBot, k), z));
}
bellyRows.push(row);
}
add(g, panelShell(bellyRows, 0.0042, { flip: true }), MAT.paintFlat, "body.bellyPan");
const bR = bellyRows.length, bC = bellyRows[0].length;
addDecal(g, bellyRows, Math.round(bR * 0.44), Math.round(bR * 0.64),
Math.round(bC * 0.58), Math.round(bC * 0.90), MAT.decalKawasaki, "body.decalBelly",
{ flipU: true });
/* ======================================================================
7.7 FENDERS
==================================================================== */
/* Front mudguard. Angles are measured in the wheel frame from straight
ahead (0) through vertical (pi/2) to straight back (pi). The guard has to
reach past the fork axis, which sits at 90deg + rake = 113.5deg, otherwise
it stops over the crown and reads as a floating shell.
Its section is not a bent sheet: a raised centre spine, a crease either
side of it, and skirts that curl down outboard of the tyre shoulders. */
const fenderA0 = 0.55, fenderA1 = 2.22;
const forkA = Math.PI / 2 + D.rake; // where the fork crosses
const fenderGap = (t) => 0.024 + 0.020 * Math.sin(Math.PI * clampf(t, 0, 1));
const fenderHW = (t) => 0.086 - 0.040 * Math.pow(1 - t, 2.2);
const fFender = [];
{
const RN = seg(26), N = seg(38);
for (let i = 0; i <= RN; i++) {
const t = i / RN;
const a = lerp(fenderA0, fenderA1, t);
const r = D.front.tyreR + fenderGap(t);
const hw = fenderHW(t);
// only let the skirt fall once the edge clears the tyre's half width
const skirt = clampf((hw - D.front.tyreW * 0.46) / 0.028, 0, 1);
const row = [];
for (let c = 0; c <= N; c++) {
const u = (c / N) * 2 - 1, au = Math.abs(u);
let rr = r
+ 0.0042 * Math.exp(-Math.pow(u / 0.30, 2)) // centre spine
- 0.0030 * Math.exp(-Math.pow((au - 0.46) / 0.09, 2)) // flanking crease
- 0.044 * skirt * Math.pow(clampf((au - 0.32) / 0.68, 0, 1), 1.7);
// the tip and tail pinch in slightly in plan
const k = 1 - 0.10 * Math.pow(au, 6);
row.push(V3(-u * hw * k,
D.front.axle[1] + Math.sin(a) * rr, D.front.axle[0] + Math.cos(a) * rr));
}
fFender.push(row);
}
}
add(g, panelShell(fFender, 0.0040, { flip: true }), MAT.paintFlat, "body.frontFender");
/* mounting ears bridging the guard's flank to the fork sliders, on the fork
axis so they land wherever rake puts the legs */
{
const t = (forkA - fenderA0) / (fenderA1 - fenderA0);
const r = D.front.tyreR + fenderGap(t);
const my = D.front.axle[1] + Math.sin(forkA) * r;
const mz = D.front.axle[0] + Math.cos(forkA) * r;
const mx = (fenderHW(t) + D.fork.halfTrack) / 2;
pair(g, filletBox(D.fork.halfTrack - fenderHW(t) + 0.016, 0.022, 0.038, 0.006,
{ segments: seg(16), rings: seg(6) }).translate(mx, my, mz),
MAT.paintFlat, "body.fenderStay");
pair(g, xform(socketBolt(0.0050, 0.008), M.c(M.t(D.fork.halfTrack - 0.030, my, mz),
M.rz(Math.PI / 2))), MAT.boltDark, "body.fenderBolt");
}
/* No rear guard. Nothing covers the rear tyre at all: the wheel is open
from swingarm to tail, and the only bodywork behind the seat unit is the
plate bracket below. A shell here is a defect — the part does not exist
on this machine, and adding one fills the negative space that gives the
tail its lift. */
/* ======================================================================
7.8 PLATE HANGER + INDICATORS
==================================================================== */
add(g, sweep(catmull3([
[0, 0.816, -0.788], [0, 0.756, -0.848], [0, 0.682, -0.908], [0, 0.606, -0.952],
], seg(12)), (t) => roundRect(lerp(0.026, 0.052, t), lerp(0.012, 0.006, t), 0.004, seg(16)),
{ upright: true }), MAT.plastic, "body.plateHanger");
add(g, xform(write((w) => extrudePlate(roundRect(0.090, 0.058, 0.006, seg(40)), 0.003,
{ axis: "z", bevel: 0.001, w })), M.c(M.t(0, 0.588, B.plateZ + 0.008), M.rx(0.34))),
MAT.plastic, "body.plate");
add(g, xform(write((w) => extrudePlate(roundRect(0.082, 0.050, 0.005, seg(40)), 0.002,
{ axis: "z", bevel: 0.0008, w })), M.c(M.t(0, 0.588, B.plateZ + 0.012), M.rx(0.34))),
MAT.plate, "body.plateFace");
for (const s of [1, -1]) {
add(g, tube(catmull3([
[s * 0.026, 0.700, -0.906], [s * 0.086, 0.712, -0.918], [s * 0.130, 0.722, -0.926],
], seg(10)), 0.0068, seg(12)), MAT.plastic, "body.indStalk" + (s > 0 ? "R" : "L"));
add(g, xform(revolve([
V2(0, 0), V2(0.019, 0.002), V2(0.021, 0.012), V2(0.016, 0.024), V2(0, 0.028),
], { axis: "x", segments: seg(22), capStart: true }),
M.t(s * 0.130, 0.722, -0.926)), MAT.lensAmber, "body.indRear" + (s > 0 ? "R" : "L"));
}
add(g, xform(write((w) => extrudePlate(roundRect(0.026, 0.014, 0.005, seg(24)), 0.004,
{ axis: "z", bevel: 0.001, w })), M.c(M.t(0, 0.534, -0.960), M.rx(0.34))),
MAT.lensRed, "body.reflector");
/* ======================================================================
7.9 INNER PANELS
==================================================================== */
add(g, loftZ([
{ z: 0.372, sec: secMove(roundRect(0.086, 0.030, 0.014, seg(28)), 0, 0.792) },
{ z: 0.300, sec: secMove(roundRect(0.108, 0.038, 0.016, seg(28)), 0, 0.776) },
{ z: 0.200, sec: secMove(roundRect(0.116, 0.044, 0.018, seg(28)), 0, 0.762) },
{ z: 0.100, sec: secMove(roundRect(0.112, 0.042, 0.018, seg(28)), 0, 0.758) },
{ z: 0.020, sec: secMove(roundRect(0.098, 0.036, 0.016, seg(28)), 0, 0.764) },
]), MAT.plastic, "body.airboxCover");
add(g, sweep(catmull3([
[-0.092, D.pivot[1] + 0.074, D.pivot[0] - 0.150],
[-0.096, D.rear.axle[1] + 0.088, D.rear.axle[0] + 0.220],
[-0.096, D.rear.axle[1] + 0.080, D.rear.axle[0] + 0.090],
], seg(12)), () => roundRect(0.020, 0.008, 0.005, seg(14)), { upright: true }),
MAT.plastic, "body.chainGuard");
return g;
}
export {
addDecal, arcSuper, buildBody, buildControls, buildEngine, buildFrame, buildFrontEnd, buildSwingarm, chainPath, decalPatch, fractf, frameSparSection, helixPath, hexBolt, loftClosed, loftY, loftZ, makeCaliper, makeChain, makeRotor, makeSprocket, makeTyre, makeWheel, petalPoly, resampleTable, revolveAlong, revolveAlongPts, rimSection, rotorAlphaFor, socketBolt, spokeArm, sprocketPoly, tyreProfile,
};
references/complete-submarine-assembly.md
# Complete procedural hard-surface assembly
Use this reference for dimensioned object plans, reusable loft and sweep
primitives, UV-owned apertures, semantic subassemblies, generated fittings,
and complete-model topology diagnostics.
## Contents
- Design contract and coordinate frame
- Geometry kernel
- Tilted-collar hull and apertures
- Semantic assembly
- Material fit
- Observed limitations
- Diagnostics
- Failure diagnosis
## Design contract and coordinate frame
The `porcelain-brass-submarine` example uses metres, with `+Z` forward and
`+Y` upward. The design contract keeps shape decisions separate from triangle
emission:
```text
dome center = (0, 0.02, 0.92) m
dome radius = 1.00 m
collar tilt = 24 degrees
collar plane offset = 0.14 m
hull tail Z = -1.30 m
hull tail radius = 0.30 m
hull maximum radius = 1.015 m
hull output = 56 rings * 128 radial segments
shroud center Z = -1.76 m
shroud radius = 0.80 m
shroud section width = 0.145 m
shroud section depth = 0.26 m
propeller blade count = 8
propeller radius = 0.62 m
```
The collar plane normal and circular intersection are exact:
```text
nColl = (0, sin(24 degrees), cos(24 degrees))
rimCenter = domeCenter - nColl * 0.14 m
rimRadius = sqrt(1.00^2 - 0.14^2) m
rimU = (1, 0, 0)
rimV = (0, cos(24 degrees), -sin(24 degrees))
```
Every subassembly is positioned from this contract or from a hull sample.
There is no late global scale used to repair mismatched parts.
## Geometry kernel
`gridGeometry` compiles sampled `Vector3[row][column]` plans. Closed rows emit
one explicit seam column. The geometric normal is:
```text
dU = P(row, column + 1) - P(row, column - 1)
dV = P(row + 1, column) - P(row - 1, column)
normal = normalize(cross(dU, dV))
```
At a pole, the fallback normal uses the vector between adjacent row centroids.
The `flip` contract reverses both normal direction and index winding.
`latheZ` revolves an `(radius, Z)` profile around `+Z`. Its longitudinal UV
coordinate is normalized accumulated profile distance:
```text
distance[i] = distance[i - 1]
+ hypot(radius[i] - radius[i - 1], Z[i] - Z[i - 1])
v[i] = distance[i] / distance[last]
```
`sweepTube` uses parallel-transport frames. Each tangent is sampled from its
two neighboring path points. The previous normal rotates around the cross
product of consecutive tangents by their clamped dot-product angle. Rounded
ends add exactly `5` tapered rings at each end.
The fin section is a closed lens profile. For section angle `a`:
```text
chord = 0.5 - 0.5 * cos(a)
side = sin(a)
width = thickness
* max(sin(pi * chord^0.85), 0)^0.62
point = lerp(leadingEdge, trailingEdge, chord)
+ up * side * width * 0.5
```
These four emitters are shared by the hull, collar, window frames, rails,
fittings, cage, furniture, shroud, propeller, fins, and pods.
## Tilted-collar hull and apertures
The hull is planned at `240` longitudinal samples before the `56` output rings
are emitted. Radius control points are:
```text
(0.995, 0.00), (1.014, 0.07), (1.006, 0.25), (0.958, 0.42),
(0.845, 0.60), (0.645, 0.78), (0.430, 0.92), (0.300, 1.00)
```
Center-Y control points are:
```text
(rimCenterY, 0.00), (0.012, 0.18), (-0.012, 0.50),
(0.020, 0.80), (0.075, 1.00)
```
The collar tilt decays with:
```text
ringTilt(t) = 24 degrees * (1 - smooth01(min(t / 0.34, 1)))
```
The hull `v` coordinate uses accumulated
`hypot(deltaZ, deltaRadius) + abs(deltaCenterY)`. This keeps ornament placement
stable when the output ring count changes.
Window ownership remains in hull UV space. The center is `(0.345, 0.205)`, the
half extents are `(0.052, 0.135)`, and the other side mirrors `u` with `1 - u`.
The hull material cuts the aperture from the atlas red channel. Two swept
brass outlines use scales `1.06` and `1.22`. Nine glazing rows contract toward
the window center by `0.985`, with outward bulge:
```text
bulge = sqrt(max(1 - contraction^2, 0))
offset = 0.004 m + 0.028 m * bulge
```
The cut-out, frame, and glazing therefore share one parameterization instead
of three independently placed approximations.
## Semantic assembly
The submarine is a hierarchy of role-specific meshes:
- the hull owns its skin, aperture mask, collar, bead, and rivets;
- flank spears sample hull position and normal, then align a revolved profile
to the local hull tangent;
- the step, handle, vent, lamps, and flanges are independent fittings;
- the dome owns glazing, latitude rings, meridian ribs, headlight, and crown
lantern;
- the cabin owns the deck, chair, helm, gauges, and telegraph lever;
- the stern owns the tail cone, collars, superelliptic shroud, struts,
propeller, halo, lamp, and spike;
- each fin owns its lens loft, gilt perimeter, inset line, and lamp pod.
The eight propeller blades share one geometry because their topology and
material are identical. The thirty collar rivets use one instanced sphere.
Parts with different topology retain separate meshes and semantic names.
## Material fit
The model generates its effect-owned texture inputs at construction time:
- a `2048 * 1024` hull atlas stores window cut-out, gold ornament, and soft
grime channels;
- a `512 * 512` Sobel-derived normal map stores the six-cell diamond quilt;
- a `128 * 128` gauge texture stores twelve ticks and the fixed needle pose.
Porcelain, brass, glass, leather, walnut, lamp, and gauge materials remain
separate because their shading and transparency contracts differ. The hull is
double-sided: front faces mix porcelain, gold, and grime; back faces use the
interior dark color. Glass uses transmission `1.0`, IOR `1.52`, thickness
`0.05 m`, roughness `0.035`, and attenuation distance `2.5 m`.
## Observed limitations
- The hull aperture is an alpha-tested material cut-out rather than a boolean
topology opening. The separate glazing and frame make the visible assembly
complete, but the hull boundary has no emitted interior wall.
- The model targets close hero framing. The `128`-segment hull, `96`-segment
dome, repeated sweep sections, and furnished interior are not a distant LOD.
- Triangle evidence counts one copy of shared geometry. Instanced rivets and
repeated propeller-blade draws need separate instance accounting when a
renderer budget is the question.
- Generated canvas textures require a browser canvas implementation.
- Motion animates the propeller, helm, and whole-object float; it is presentation
motion, not a physical vehicle simulation.
## Diagnostics
The example records `part` and `tris` whenever a defining geometry is emitted.
Report that list together with total draw calls and renderer triangles; no one
number represents both unique geometry storage and repeated draws.
Use the gallery modes as follows:
- `final`: complete assembly with generated materials;
- `topology`: wireframe across every material role;
- `hull-loft`: isolated wireframe hull skin showing the `56 * 128` output grid.
- `no-glass`: complete opaque assembly with transmissive glazing hidden.
Also inspect the alpha-tested window boundary, mirrored fin winding, glass
depth order, shared propeller blade, and the transition from tilted collar
rings to the untilted stern frame.
## Failure diagnosis
| Symptom | Check |
| --- | --- |
| hull turns inside out | `gridGeometry` normal flip and index winding must change together |
| sweep twists abruptly | parallel-transport normal rotation or a duplicate interior path point is invalid |
| ornament drifts when tessellation changes | hull UV `v` must use accumulated plan distance, not output-ring index alone |
| window frame misses the cut-out | cut-out, frame, and glazing must share the same hull UV outline |
| mirrored fins shade inward | mirror transform, loft winding, and material side ownership are inconsistent |
| propeller memory multiplies by eight | all eight blade meshes must share one blade geometry |
| cabin disappears through the hull | hull back-face material or alpha aperture ownership has been removed |
| topology mode changes silhouette | the debug path is changing visibility or transforms instead of material wireframe only |
references/geometry-craft-workflow.md
# Geometry craft workflow
Use this reference to replace primitive assembly with a polygon-first modeling
process, explicit part relationships, deliberate edge treatment, and a staged
definition of done. Use metres for the numeric defaults below or scale all
dimensions and audit tolerances together.
## Contents
- Semantic geometry contract
- Polygon-first design model
- Modeling operation selection
- Modifier and emission order
- Join and assembly discipline
- Detail and geometry budgets
- Visual inspection contract
- Observed limitations and defects
- Diagnostics
- Failure diagnosis
## Semantic geometry contract
Write a contract before vertices. Keep it beside the builder so the same
dimensions drive geometry, placement, interaction, and audit measurements.
```js
const contract = {
frame: "+X length, +Y back, +Z up; ground at Z = 0",
unit: "metre",
bounds: { width: 1.8, depth: 0.52, height: 0.98 },
parts: {
frame: { topology: "closed-solid", material: "cast" },
slat: { topology: "closed-solid", material: "metal" },
},
joins: [
{ a: "slat:*", b: "rail:*", relation: "clearance", min: 0.012 },
{ a: "foot:*", b: "ground", relation: "support", gap: 0 },
],
triangleBand: [2500, 10000],
};
```
Record at least:
- authoring frame, world frame, origin, and unit;
- overall bounds and the primary silhouette/profile dimensions;
- whether every part is a closed solid, thick shell, or intentional surface;
- shell thickness, aperture, reveal, and clearance requirements;
- required contacts, supports, intended penetrations, and forbidden overlaps;
- moving-part extrema and swept-volume clearance;
- stable names, material slots, and expected part/triangle bands;
- the views needed to verify silhouette, hidden sides, joins, and interaction.
Do not re-derive a mating dimension in two builders. Compute both parts from
one datum, endpoint, profile, or layout constant.
## Polygon-first design model
Keep quads and n-gons during design. Triangles are a rendering representation,
not the modeling language. Use `MeshData` from
`assets/geometry-quality-kit/procedural-mesh.js` to retain polygon ownership
through transforms and modifiers.
```js
import {
cleanMesh,
loft,
recalcNormals,
smoothShade,
toGeometry,
} from "./geometry-quality-kit/procedural-mesh.js";
const part = loft(sectionRings, {
closeV: true,
capStart: true,
capEnd: true,
});
cleanMesh(part);
recalcNormals(part);
smoothShade(part, 34);
const geometry = toGeometry(part);
```
The module authors Z-up and emits Three.js Y-up once. The conversion
`(x, y, z) -> (x, z, y)` has negative determinant, so emission reverses face
winding while converting positions and normals.
Use smooth-by-angle per-corner normals. A corner averages only adjacent faces
whose face normals remain within the part's crease threshold. Do not replace
this with an unrestricted `computeVertexNormals()` pass.
`cleanMesh()` uses an actual Euclidean weld test across neighboring spatial
cells; a quantized cell key alone is insufficient because two vertices within
the weld distance can straddle a cell boundary. `toGeometry()` preserves
per-vertex color attributes and compiles consecutive `faceMat` values into
Three.js geometry groups.
## Modeling operation selection
Choose the operation that owns the visible form:
| Form | Primary operation |
| --- | --- |
| constant section | profile extrusion / `prism*` |
| changing section | `loft` over authored rings |
| axial body, fastener, vessel | `revolve` with welded poles |
| rail, pipe, cable, frame | `tubeAlong`, `sweepPlanarLoop`, or `runMolding` |
| opening through a solid | `panelWithHoles`, `wallRun`, or `aperturedPrism` |
| hollow rim or casing | `hollowPrism` or `annularPrism` |
| rounded manufactured block | `roundedBoxMesh` or provenance-aware `bevel` |
| thin designed skin | profile pairs or `solidify` with explicit thickness |
Treat primitives as acceptable only when the intended form is genuinely a
primitive or the part is hidden structure. A visible housing assembled from a
box, cylinder, and sphere still owes a unified profile, edge hierarchy, and
plausible junctions.
Prefer direct topology for apertures. A frame built from matched outer/inner
rings owns its opening, jambs, end faces, and bevels in one closed shell. Reach
for general boolean machinery only when a direct ring or segmented-wall
construction cannot express the cut reliably.
## Modifier and emission order
Use this order unless the object's construction demands a stated exception:
```text
semantic profiles and rings
→ extrusion / inset / loft / revolve / sweep
→ solidify
→ subdivision where the design needs it
→ bevel or fillet
→ cleanup inside each part
→ winding repair and smooth-angle assignment
→ topology audit
→ triangle emission
```
Modifier order changes the object. Solidifying after beveling creates a
different rim from beveling a thick shell. Cleaning before a deliberate
modifier can remove topology it expects; cleaning after joining unrelated
parts can weld a butt joint into accidental geometry.
Use the metre-scale bevel bands as starting classes, not one universal radius:
```text
hardware / hinges / casings 0.002 m
panel / shelf / slat edges 0.004 m
carcass / plinth / cast stone 0.007 m
frame / apron / machine body 0.013 m
soft upholstered form 0.045 m
```
Common smooth-angle starting points are `40°` for turned parts, `34°` for
moulded sections, `45°` for shells, `38°` for cast forms, `50°` for tight
rolls, and `32°` for broad tops. Inspect the actual highlight before accepting
the value.
## Join and assembly discipline
Keep one `MeshData` per semantic part. Do not join separate components before
their relationship audit: cleanup can weld intended butt joints and material
merging can erase the names needed to diagnose an overlap.
Use these readable join floors:
```text
applied detail proud of host >= 0.0008 m
intentional reveal 0.0015–0.006 m
gap meant to read at 2 m >= 0.004 m
```
Flush but unrelated faces are forbidden. Choose one of four explicit states:
1. One continuous polygon mesh.
2. A declared structural penetration with a narrow named allowance.
3. A proud part with a readable offset.
4. A reveal or open gap.
Build named audit meshes first with `buildNamedAuditGroup()`. After the named
assembly passes, build one draw mesh per material slot with
`buildMergedAssembly()`, then run the scene audit again because same-slot
merging can contain coplanar triangles inside one mesh. Named audit parts must
have unique stable names; duplicate names fail assembly construction rather
than merging unrelated evidence under one label.
## Detail and geometry budgets
Allocate segments by visible radius and camera distance:
```text
radius <= 0.025 m 10–14 segments
radius <= 0.100 m 16–24 segments
radius >= 0.150 m 28–48 segments for player-adjacent forms
```
Report both part count and triangle count. Triangle count alone cannot expose
an object made from hundreds of independent primitives, and part count alone
cannot expose an over-tessellated hero surface.
Spend topology on silhouette, aperture shape, edge highlights, and contact
points before hidden planar faces. Add the small parts that explain assembly—
fasteners, collars, gussets, pads, seams—only after the primary form and joins
are correct.
## Visual inspection contract
Mechanical checks cannot decide whether a form is plausible or well modeled.
Inspect at least:
- front, rear, both sides, top, underside, and opposing three-quarter views;
- orthographic silhouette views;
- close views of every join, aperture, shell edge, and cap;
- interaction-height views such as seated eye, standing eye, or boarding view;
- both extrema and intermediate poses of moving geometry;
- grazing light, clay material, material-slot colors, wireframe, and normal or
face-orientation diagnostics.
Look specifically for primitive seams, implausible load paths, unsupported
parts, hidden-side defects, paper-thin openings, unrounded manufactured edges,
and trim that floats when viewed from behind.
## Observed limitations and defects
- A closed manifold shell can still be the wrong shape.
- Bounding-box overlap is not proof of triangle collision.
- A generic support ray cannot understand suspended, wall-mounted, or hanging
parts without declared support targets.
- Shell thickness cannot be inferred reliably for every arbitrary mesh; assert
generator-owned thickness samples or construct the shell with `solidify`.
- Cleaning a combined assembly can weld parts that must remain independent.
- A global bevel radius destroys scale hierarchy.
- Smooth shading across caps and side walls rounds edges that should remain
exact.
- A broad collision allowance hides defects; every allowed pair needs stable
names and a construction reason.
## Diagnostics
Keep a build report with:
```text
part count / triangle count / material slots
per-part topology reports
named-assembly z-fight and clash reports
post-merge z-fight report
semantic contract measurements
fixed-view capture list
```
Print actual measured values beside expected ranges. A bare pass/fail hides
whether a clearance is comfortably valid or one floating-point step from the
boundary.
## Failure diagnosis
- Faceted curve: raise profile/ring resolution before adding subdivision.
- Soft hard edge: lower the smooth angle or split cap/side ownership.
- Inside-out solid: repair winding from signed volume before emission.
- Floating component: fix the shared datum or declared support relationship;
do not visually nudge the part downward.
- Z-fight: remove one face, join topology, or create a proud/revealed relation.
- Collision false positive at a butt joint: verify true penetration depth
before adding a narrow named allowance.
- Defect appears only after merging: inspect same-slot parts that lost their
individual object boundary.
references/geometry-quality-gates.md
# Geometry quality gates
Use this reference to run deterministic topology, coplanar, collision,
semantic, and visual gates against procedural Three.js geometry. The scripts in
`assets/geometry-quality-kit/` are plain JavaScript and may be placed directly
into a project, with import paths and scene plumbing adjusted as needed.
## Contents
- Gate order and ownership
- Pre-emission topology audit
- World-space collection contract
- Coplanar overlap audit
- Solid-clash audit
- Semantic geometry contracts
- Support, clearance, and motion
- Visual gate
- Observed limitations and defects
- Diagnostics
- Failure diagnosis
## Gate order and ownership
Run gates in this order:
```text
constructor self-test
→ per-part polygon topology
→ named-part world assembly
→ object-specific geometry contract
→ material-slot merged assembly
→ fixed-view visual inspection
```
Do not skip named-part auditing. A merged mesh can reveal same-slot coplanar
triangles, but its material-slot name cannot identify the two semantic parts
that caused them. Do not skip post-merge auditing either; same-mesh overlaps
must remain comparable.
Use `selftest.js` as the smoke test after placing the kit in a project:
```sh
node path/to/geometry-quality-kit/selftest.js
```
The test plants a `0.5 mm` coplanar offset, a clean `10 mm` separation, a
`60 mm` solid interpenetration, an exact butt joint, duplicate/loose vertices,
and passing/failing semantic measurements.
## Pre-emission topology audit
Run `auditMeshData()` after modifiers and cleanup but before triangle emission:
```js
import { cleanMesh, toTriangles } from "./procedural-mesh.js";
import {
assertMeshAudit,
auditMeshData,
auditTriangleSoup,
} from "./mesh-topology-audit.js";
const part = cleanMesh(buildPart());
assertMeshAudit(auditMeshData(part), "housing");
assertMeshAudit(auditTriangleSoup(toTriangles(part)), "housing emission");
```
The polygon audit reports:
- non-finite vertices and invalid face indices;
- faces with fewer than three usable corners or zero area;
- unused vertices and vertex pairs within `0.00002 m`;
- boundary edges, edges used by more than two faces, and same-direction shared
edges that expose inconsistent local winding;
- edge-connected face components, so shells that only touch at one vertex
remain separate;
- non-positive signed volume for each closed component rather than only the
summed mesh volume;
- mismatched or invalid per-face material indices, per-corner UVs, and
per-vertex color arrays.
`auditTriangleSoup()` separately rejects incomplete triangle arrays,
non-finite positions, non-unit normals, normals opposed to triangle winding,
and zero-area emitted triangles.
The default contract is one outward-wound closed solid. An intentional open
surface must state that exception:
```js
auditMeshData(surface, {
closed: false,
maxComponents: Infinity,
checkOrientation: false,
});
```
Do not use that configuration to silence an accidentally open shell.
## World-space collection contract
`auditGeometry(root, options)` traverses visible meshes by the `isMesh` flag,
not `instanceof`, so it remains valid when two Three.js module instances exist.
It transforms every triangle by `matrixWorld`, validates local position and
normal attributes, and records world bounds per mesh.
```js
const report = auditGeometry(root, {
bounds: null,
includeInstanced: false,
clash: true,
angleRadians: 0.0025,
planeDistance: 0.0015,
overlapArea: 0.0002,
maxTriangles: 600000,
top: 25,
clashAllow: [["bolt:", "housing:"]],
skip: (mesh, name) => name.startsWith("debug:"),
});
```
`mesh.userData.auditSkip = true` skips one mesh. Keep this for non-production
diagnostics, never as a general defect escape. Invisible ancestors suppress
their descendants. Local normal attributes must remain unit length within
`0.001`. Instanced scatter is excluded by default because organic or aggregate
instances often interpenetrate by design. When enabled, every available
instance is audited as `name[index]` with the composed
`matrixWorld * instanceMatrix` transform; the collector never reads beyond the
instance buffer's capacity. Diagnostic names retain the full named-ancestor
path. `clashAllow` prefixes may match that full path or an individual path
segment.
The metre-scale mechanical constants are:
```text
normal angular tolerance 0.0025 rad (~0.143 degrees)
plane separation 0.0015 m
minimum clipped overlap area 0.0002 m² (2 cm²)
normal plane-grid cell 0.02
distance plane-grid cell 0.02 m
maximum clash depth threshold 0.03 m
normal-length tolerance 0.001
```
Scale every one of these coherently if the project's world unit is not a metre.
The neighboring-cell lookup requires `planeCellNormal` to be at least
`2 * sin(angleRadians / 2)` and `planeCellDistance` to be at least
`planeDistance`; invalid combinations throw instead of silently losing
comparisons.
## Coplanar overlap audit
For each triangle, compute a unit normal `n`, plane distance `d = dot(n, p0)`,
world AABB, and area. Canonicalize plane direction so opposed normals share a
plane bucket. Quantize `(nx, ny, nz, d)` and register the triangle in all
`2^4 = 16` neighboring cells.
Inside each cell:
1. Sort by minimum world X and sweep until inflated X bounds no longer meet.
2. Reject Y/Z AABBs separated by more than `0.0015 m`.
3. Require `abs(dot(ni, nj)) >= cos(0.0025)`.
4. Require a vertex-to-plane distance no greater than `0.0015 m`.
5. Project both triangles into one in-plane basis.
6. Clip one convex triangle against the other.
7. Report only clipped overlap at least `0.0002 m²`.
The AABB test must be inflated by the plane tolerance. Without inflation, two
faces separated by `0.5 mm` have disjoint boxes and the exact defect disappears
before the plane test.
Same-facing overlap is a z-fight. Opposed overlap is counted separately as
back-to-back information because a closed solid may legitimately butt against
another closed solid. Back-to-back status is not proof that the construction is
correct; the semantic contract still owns that decision.
## Solid-clash audit
Bounding-box intersection is only a broad phase. For each named mesh pair:
1. Compute the shared AABB and reject empty overlap.
2. Reject a narrowly named intentional pair.
3. Compute each mesh's minimum bound thickness.
4. Set the required shared depth to
`min(0.03, max(0.004, 0.34 * min(thicknessA, thicknessB)))` metres.
5. Spatially hash triangles from one mesh inside the shared region.
Triangles spanning more than `256` cells use a complete fallback candidate
pass rather than disappearing from collision coverage.
6. Test whether edges from either triangle pierce the other using a
segment-restricted Möller–Trumbore test.
7. Report the pair after more than two crossings.
This deliberately ignores touching faces and shallow construction seams while
finding a substantial solid running through another solid. `clashDepth`,
`clashCellSize`, `maxCellsPerTriangle`, and `minimumClashCrossings` are explicit
options; their defaults are `0.03 m`, `0.4 m`, `256`, and `3`. Use stable
semantic prefixes in `clashAllow`; never allow entire material classes.
## Semantic geometry contracts
Generic triangles cannot know that a window must remain open or a wheel must
clear a fender. Keep the measurement beside the builder and use
`geometry-contract.js` for consistent results:
```js
const report = runGeometryContract({
name: "service hatch",
checks: [
rangeCheck("opening-width", (g) => g.opening.width, 0.82, 0.86),
minimumCheck("hinge-clearance", (g) => g.hingeClearance, 0.004),
nearCheck("closed-seat", (g) => g.closedGap, 0, 0.0005),
predicateCheck(
"sight-line",
(g) => g.eyeRayBlocked,
(blocked) => blocked === false,
"false",
),
],
}, measurements);
assertGeometryContract(report);
```
Useful contract measurements include:
- expected bounds, height, width, and placement datum;
- contact depth, reveal, minimum gap, and forbidden overlap;
- aperture width/height and visibility rays through it;
- shell-thickness samples owned by the generator;
- seating, handrail, doorway, step, and control clearances;
- expected material slots, part count, and triangle band;
- curve continuity, loop seam position/twist, curvature, and self-distance.
Always print the measured value and accepted interval. A boolean alone makes a
near-boundary result impossible to judge.
Contract check identifiers must be unique and severity is exactly `"error"` or
`"warning"`; misspellings fail at contract construction time.
## Support, clearance, and motion
A support check needs declared targets. Test the lowest/downward-facing sample
points of the part against the surfaces it is permitted to touch, or compare
analytic datums when both builders share them. A generic downward ray is not
authoritative for wall-mounted, suspended, or hanging geometry.
For moving geometry, audit the whole schedule rather than only its endpoints:
```js
for (let i = 0; i <= 64; i++) {
const t = i / 64;
const pose = mechanismPose(t);
const clearance = measureClearance(pose, environment);
minimum = Math.min(minimum, clearance);
}
```
Raise the sample count where rotation or curvature changes fastest. For a
critical thin clearance, supplement samples with analytic extrema or a swept
silhouette/voxel test. Record the pose of the minimum clearance in diagnostics.
## Visual gate
An agent or human must inspect fixed views after all scripts pass:
- orthographic front/rear/side/top/underside silhouettes;
- opposing three-quarter views;
- grazing-light close views of bevels, caps, seams, and apertures;
- clay, wireframe, material-slot, and normal-orientation views;
- human-height or interaction-height views;
- every extreme pose and at least one intermediate pose.
Reject geometry that looks implausible, primitive-assembled, unsupported,
paper-thin, or poorly finished even when no numeric gate fires.
## Observed limitations and defects
- Triangle truncation makes a report incomplete; `truncated` is a failure for a
release gate, not a performance success.
- Tiny triangles below the overlap-area threshold cannot create a reportable
overlap, but they still require topology and visual checks.
- The clash pass detects triangle crossings, not complete containment without
boundary crossings; add an object-specific containment test where relevant.
- Skinned, morph-targeted, shader-displaced, and procedurally animated vertices
are audited in their stored buffer pose. Audit their required poses through a
posed CPU representation or an object-specific contract.
- The collector audits every stored triangle rather than camera layers,
material groups, or `drawRange`; use a dedicated audit hierarchy when a
geometry buffer intentionally stores inactive regions.
- Same-material merging can obscure names; retain the named audit hierarchy.
- An open surface must opt out of closed-solid checks explicitly.
- A named allowance can become stale after a rename or design change.
- Fixed views do not replace free orbit inspection when a suspicious junction
remains ambiguous.
## Diagnostics
Log both summary and evidence:
```js
const report = logAuditReport(auditGeometry(root));
if (
report.truncated ||
report.zfight.length ||
report.defects.length ||
report.noMaterial.length ||
report.clash.length
) {
throw new Error("geometry quality gate failed");
}
```
Retain pair names, clipped area in `cm²`, representative world position,
triangle crossings, topology issue codes, semantic measurements, and the
camera/pose that exposed a visual defect.
## Failure diagnosis
- Missing known z-fight: confirm bounds include it, triangles were not
truncated, the overlap exceeds `2 cm²`, and all sixteen plane cells remain.
- Many false z-fights: separate same-facing from opposed faces and inspect true
clipped area rather than bounding boxes.
- Missed collision: add containment or swept-volume logic when no triangle
edge crosses the other mesh.
- Many duplicate vertices: run cleanup inside one part, not across the assembly.
- Open boundary after solidify: inspect rim orientation and cap ownership.
- Inside-out report: reverse polygon winding or run winding reconstruction;
never negate normals alone.
- Clean named assembly but dirty merged assembly: isolate the material slot and
compare its contributing part names before merge.
references/procedural-optimus-humanoid-assembly.md
# Procedural Optimus humanoid assembly
Use this reference for a complete human-scale hard-surface robot built from
parameter curves, semantic polygon generators, exact cut operations, authored
bevels, per-corner normals, and material identities that remain readable across
white shells, black polymers, metals, rubber, glass, and emissive details.
## Contents
- Coordinate frame and assembly evidence
- Polygon mesh and modifier contract
- Loft, spine, and pillow generators
- Semantic humanoid construction
- Material identity system
- Geometry emission and ownership
- Observed limits and defects
- Diagnostics
- Failure diagnosis
## Coordinate frame and assembly evidence
The `procedural-optimus-humanoid` example uses metres, keeps `+Z` upward, faces
the figure toward `-Y`, and places the origin on the floor midway between the
ankles. The completed bounds are:
```text
minimum = (-0.2822209001, -0.1870000064, -0.0016486322) m
maximum = ( 0.2822209001, 0.1903314888, 1.7320995331) m
```
The emitted assembly contains `176` named mesh objects and `891,809` triangles
across seven semantic groups:
```text
TORSO 25 HEAD 2 ARM 22 HAND 56
HIP 27 LEG 38 FOOT 6
```
These counts are deterministic evidence. Do not merge groups or delete small fittings
before recording them; the object hierarchy is how the construction stays
inspectable.
## Polygon mesh and modifier contract
The internal mesh holds polygon faces rather than pre-triangulated buffers:
```text
v vertex positions
f polygon index loops
fm material slot per face
fg generated-face class
mats material names by slot
mods pending bevel and boolean operations
```
Welding uses a spatial hash and a default `2e-5 m` distance. It removes
duplicate and degenerate polygons, then reconstructs consistent winding across
each connected shell. Whole shells with negative signed volume are flipped.
Boolean difference uses a BSP polygon split with `CSG_EPS = 1e-9`. Faces of
the minuend are first separated by cutter bounds so distant polygons bypass
the BSP. The rebuilt fragments are welded at `1e-6 m` before winding repair.
Bevel ownership is angle-limited. The modifier identifies shared edges whose
face-normal dot product falls below `cos(angle)`, constructs inset face sectors,
emits the requested profile arcs, and closes bevel-vertex patches. Bevel faces
carry a generated-face class so the corner-normal stage can keep them hard
where required.
## Loft, spine, and pillow generators
`Curve1D` provides two interpolation modes. Shape-preserving cubic Hermite
slopes prevent overshoot in authored dimension tracks; a natural cubic solve
provides C2-continuous tracks where smooth curvature is required.
The section profile is a four-quadrant superellipse with independent positive
and negative half-axes and upper/lower exponents. `profile()` first samples
`512` dense points, accumulates perimeter distance, and blends arc-length and
uniform-angle spacing with the `even` parameter.
`loft()` samples station curves for axes, offsets, exponent, rotation, and
scale. Rounded caps use five rings whose radius follows
`cos(πf/2)^0.85` and whose axial distance follows `sin(πf/2)`.
`loftSpine()` fits a cubic path, computes centered tangents, and propagates its
normal by projection onto each new tangent plane. This parallel-transport frame
prevents the torsional flips produced by a fixed world-up cross product.
`pillow()` emits paired front and back surfaces from one closed outline. The
front bulge falls from a pole to the rim through `pillowBulge`; the back surface
retains explicit thickness. Panels therefore expose a real rim rather than a
single displaced sheet.
## Semantic humanoid construction
The torso owns a continuous black core plus fitted chest, back, strap, box,
recess, and wordmark panels. Front and back panel surfaces query the same core
curves, so their offsets remain tied to the body rather than hand-positioned.
The head owns a closed shell split into a glossy visor and matte hood, a crown
transition, seam curves, and an emissive sensor detail. Arms contain shoulder
caps, upper-arm shells, ring transitions, elbow housings, ribbed pads, forearm
frames, actuators, and wrists.
Each hand is a complete five-finger assembly. A wrist-local frame drives the
palm, back plate, four finger chains, thumb chain, joints, pads, and structural
members. Mirroring is performed in mesh data, after which winding is repaired.
The lower body owns a central pelvis, paired hip drums and covers, thigh cores
and shells, knees, shin shells, ankle actuators, and moulded feet. The semantic
groups remain separate even when they share a material; assembly identity is
not sacrificed to draw-call reduction in this example.
## Material identity system
The example defines fourteen named identities:
```text
M_SHELL M_SHELL_LEG M_BLACK M_GLOSSBLACK
M_VISOR M_HELMET M_LED M_DARKMECH
M_ALU M_STEEL M_RUBBER M_FOOT
M_LOGO M_DARKGREY
```
Every identity is a `MeshPhysicalNodeMaterial` or `MeshStandardNodeMaterial`.
Base colour, metalness, roughness, IOR, specular intensity, clearcoat, sheen,
and emission are authored as one bundle.
The object-space noise is a signed Perlin field built from a Jenkins lookup3
integer hash, quintic fade, a 16-way gradient selector, and `0.9820` amplitude
scale. Fractional Brownian accumulation removes octaves after their period
falls below roughly two pixels:
```text
band = 1 - smoothstep(0.25, 0.5, footprint × scale × octaveFrequency)
```
This derivative filter is part of the material. Without it, scales from `520`
to `1400 m^-1` alias into slowly drifting blotches.
Roughness variation maps normalized noise from `[0.25, 0.75]` into each
material's declared `base ± amount`. Bump distance is exactly
`strength × 0.0006 m`. White shell, helmet composite, cast mechanism,
aluminium, rubber, foot polymer, and dark covers use different scale/detail/
roughness tuples; one generic noise node is not substituted across them.
## Geometry emission and ownership
Before emission, pending booleans and bevels are applied. `computeCornerNormals`
groups face corners into smooth fans bounded by authored sharp edges, then
angle-weights the contributing face normals.
`toGeometry()` triangulates each polygon as a fan only at the final step. A
position is reused only when its corner normal matches within `1e-6`; sharp
fans therefore receive separate vertices. Indices are grouped by material slot
and retain the mesh's ordered material-name table.
The returned root owns the seven semantic groups. Each emitted mesh owns one
`BufferGeometry` and references one or more of the fourteen shared materials.
Disposal releases every geometry and material without touching gallery lights,
camera, or floor.
## Observed limits and defects
- The assembly is a fixed neutral pose. It does not include a skeleton, skin
weights, joint limits, or collision envelopes for animation.
- `891,809` triangles are appropriate for a close inspection model, not an
unrestricted crowd population. Build a deliberate LOD rather than deleting
small parts from the canonical assembly.
- The BSP difference is exact for the declared polygon inputs but is not a
general robust-solid kernel for arbitrary dirty meshes.
- Polygon fan triangulation assumes each emitted face is simple and convex
after modifiers. Concave faces must be decomposed by their generator.
- Shared materials make global wireframe diagnostics inexpensive, but per-part
destructive material mutation would affect every mesh using that identity.
- The geometry system repairs winding and normals; it does not replace a full
inter-part clash, support, or articulation-clearance audit.
## Diagnostics
The gallery exposes four deterministic views:
```text
Complete assembly all seven semantic groups, final materials
Wireframe topology all final triangles with shared material identities
Upper body torso, head, arms, and hands only
Lower body hips, legs, and feet only
```
Always record the object count, triangle count, bounds, group counts, and
material-name set. A count drift localizes lost or duplicated subassemblies
before a screenshot could explain why the silhouette changed.
## Failure diagnosis
If a panel floats, compare its surface query and offset against the owning core
curve. If a mirrored part shades inside-out, run winding repair after the
negative-axis transform. If a bevel pinches, inspect the selected edge angle,
sector inset, and local edge length before reducing segment count.
If procedural materials crawl with camera motion, verify object-space position
and derivative footprint filtering. If edges look melted, inspect corner-normal
fan boundaries rather than lowering global smoothness. If a body region is
missing, inspect the seven collection counts before geometry merging or scene
staging.
references/profile-sweeps-and-mesh-writers.md
# Sculpted profiles and semantic mesh emission
Use this reference for reusable profile sweeps, rail skins and caps, oriented branch rings, semantic mesh writers, material slots, and geometry-level diagnostics.
## Contents
- Sculpted frame profile
- Rail mesh emission
- Semantic dimensions and material fit
- Tree ring emission
- Semantic mesh writer
- Selection and LOD
- Observed limitations
- Diagnostics
## Sculpted frame profile
In the `sculpted-gallery-frame` example, the frame is not a beveled box. A
normalized rail coordinate `t` drives a profile assembled from named lobes:
```text
crown:
0.355 * scale * sin(pi*t)^0.56
inner bead:
0.105 * scale * exp(-((t - 0.085) / 0.033)^2)
outer bead:
0.092 * scale * exp(-((t - 0.905) / 0.038)^2)
inner groove:
-0.115 * scale * exp(-((t - 0.205) / 0.043)^2)
outer groove:
-0.102 * scale * exp(-((t - 0.735) / 0.052)^2)
shoulder:
0.045 * scale * exp(-((t - 0.42) / 0.15)^2)
cove:
-0.035 * scale * exp(-((t - 0.61) / 0.095)^2)
```
`scale = railWidth / 0.75`. The inner and outer ends are blended to controlled
terminal depths, preventing a crown shape from meeting the artwork or wall
with an accidental vertical edge.
The profile uses `92` samples. This is deliberate high curvature
resolution for close hero framing, not a universal default.
## Rail mesh emission
Each of four orientations has its own coordinate mapping. The profile travels
across the rail width while `s` travels along the frame side. Top/bottom rails
use `132` length segments; left/right use `156`.
For every profile sample and length sample, emit:
```text
top vertex at profile depth
bottom vertex at fixed backing depth
UV = (s, t)
```
Then emit:
```text
top skin
bottom skin
inner wall
outer wall
two end caps
```
The index winding helper emits:
```text
a, b, c
b, d, c
```
Normals are computed after all faces are assembled; a bounding sphere is also
computed. Four rail meshes meet at miter-like endpoints because their length
shrinks/expands as `t` moves from inner to outer edge.
## Semantic dimensions and material fit
The frame dimensions are derived:
```text
innerWidth = postWidth
innerHeight = innerWidth / embedAspectRatio
railWidthX = (outerWidth - innerWidth) / 2
railWidthY = (outerHeight - innerHeight) / 2
profileRailWidth = min(railWidthX, railWidthY)
art card Z = rail offset + profileDepth(innerRimT)
```
This keeps artwork, mat, frame profile, and backing coupled. Avoid tuning their
Z positions independently.
The same file pairs the geometry with walnut, antique-gold, and ebony material
bundles. Geometry is judged under grazing spotlights and shadow maps, so profile
depth must create readable highlights without bloom.
## Tree ring emission
The `structured-ash-growth` example under `$threejs-procedural-vegetation`
emits branches as oriented rings. Every branch section owns:
```text
center
orientation
radius
longitudinal fraction
branch level
```
Ring vertices use branch-local radial angle and an explicit seam. Bark UV
length follows branch length and circumference instead of normalizing every
branch to identical density.
Child branches are generated from the growth hierarchy before geometry is
batched. This separation makes it possible to lower radial segments by level
without changing topology ownership.
Leaf cards use canopy-oriented normals rather than only card-plane normals,
showing that generated shading attributes can intentionally differ from
geometric face normals.
## Semantic mesh writer
The `procedural-financial-tower` compiler under
`$threejs-procedural-architecture` accumulates vertices and indices by
material slot. Modules emit semantic geometry into a shared writer rather than
constructing one `Mesh` each.
Preserve this contract:
```text
module plan
-> transformed semantic vertices
-> triangles tagged with material slot
-> grouped BufferGeometry
```
The writer also keeps module transforms and real dimensions outside raw buffer
code. This allows façade, roof, cornice, and ornament generators to share
emission without sharing design logic.
## Selection and LOD
Use:
```text
custom profile mesh
when silhouette and grazing response define identity
oriented rings
when hierarchy and taper define identity
semantic mesh writer
when many varied modules must batch by material
instancing
only when topology is identical and variation is attribute/transform based
```
Derive LOD from the generator:
- reduce profile samples while retaining crown and groove extrema;
- reduce branch radial segments by branch level;
- replace ornaments with relief only after silhouette contribution is small;
- preserve UV density and material slots across levels.
## Observed limitations
- The gallery frame computes smooth normals across all connected frame faces.
If hard backing edges become visible, duplicate vertices by smoothing group.
- Its profile and segment counts are expensive for many frames. Cache geometry
by dimension set and add a lower-detail authored profile.
- The four rails are separate meshes; a larger gallery may need material-based
merging after transforms are fixed.
- Global `computeVertexNormals()` cannot infer semantic hard edges.
- Generic mesh decimation may erase the narrow beads and grooves that create
the frame’s material response.
## Diagnostics
Expose:
```text
profile curve with named lobe contributions
profile sample indices
rail orientation and top/bottom skins
face winding and normal direction
UV checker
frame/art/backing depth relationship
tree branch rings and seams
material groups from the mesh writer
triangle, vertex, group, and draw counts
LOD overlay at the design camera
```
references/vehicle-loft-and-projector-contract.md
# Vehicle lofts, section tracks, and the paint projector
Use this reference for complete vehicle-scale hard-surface objects: one
continuous body loft driven by named parameter curves, sections that open into a
recess without changing topology, offset superellipse volumes, spanwise airfoil
lofts, authored outline plates with holes and warps, a two-plane paint
projector, load-deflected tyres, slot-tagged mesh writers, and the orientation
guard that catches inside-out closed bodies.
## Contents
- Frame and dimension contract
- Parameter-curve section tracks
- Semantic sections and the recess parameter
- Offset superellipse volumes
- Apertures that share one parameterisation
- Spanwise airfoil lofts
- Authored plates: holes, bevels, warps
- Steering-axis placement
- Two-plane paint projector
- Tyres: measured carcass and baked deflection
- Slot-tagged writers and the orientation guard
- Observed limits
- Diagnostics
## Frame and dimension contract
One table owns every dimension; nothing is nudged afterwards. Both vehicle
examples use `+Y` up and `+Z` toward the nose, origin on the ground plane, and
author only the `+X` half.
```text
formula-one-race-car wheelbase 3.400 overall width 1.900 front tyre 0.720 OD
front axle z +1.720 rear axle z −1.680
hub centres derived: width/2 − tyreWidth/2 − clearance
sport-motorcycle wheelbase 1.400 rake 23.5° triple offset 0.030
seat 0.830 front 120/70-17 (0.300 R) rear 180/55-17
```
Derived quantities stay derived. The car's ride plane rakes from the reference
plane, so every floor and diffuser station reads it rather than carrying its own
height:
```js
const floorY = (z) => {
const t = clamp01((D.frontAxleZ - z) / (D.frontAxleZ - D.rearAxleZ));
return D.plankY + D.rakeRise * smooth01(t); // 0.0345 m + 0.0455 m of rake
};
```
## Parameter-curve section tracks
A body is a table of named curves in `z`, each evaluated at every station. The
interpolant must be C1 **and** overshoot-free: a per-segment ease has zero slope
at every knot, which prints a visible ripple onto the lofted surface — the body
reads as a stack of terraces under grazing light. Use shape-preserving monotone
cubic (Fritsch–Carlson) tangents.
```js
function curve1(knots) { // knots: [[x, y], ...]
// secant slopes d[i], then m[i] = 0 where the sign flips,
// else the average; finally rescale when a² + b² > 9 (monotonicity limit).
}
const HULL = paramTrack({ yRim: [...], wRim: [...], halfW: [...], tub: [...] });
const loops = stationsFrom(D.noseTipZ, D.engineCoverEndZ, 168, 0.35)
.map((z) => ringFromHalf(chassisPts(HULL(z)), z, 96 / 2 + 1));
```
`stationsFrom(z0, z1, count, ease)` biases stations toward both ends, which is
where curvature is highest. The car's hull is 168 longitudinal rings at 96 ring
resolution: one loft from nose tip to engine-cover exit.
## Semantic sections and the recess parameter
The half-section is a fixed set of NAMED control points, so a curve can move
"max width" or "rim height" along the body without changing topology. The
cockpit is not a hole cut into a closed shell: the section itself opens into a
recess and closes again.
```text
13 points, top centreline → deck/rim → flank → bottom centreline
tub > 0.004 → open: floor width = wTub, wall at 0.62 of the recess height
tub == 0 → closed: floor width = 0.30·wRim, wall at 0.42
```
That single branch is what lets nose, survival cell, cockpit and engine cover be
ONE continuous surface. A padded liner is emitted separately as an OPEN sheet
from the same section points, contracted 4.5 mm toward the recess axis — closing
that ring would lid the cockpit.
## Offset superellipse volumes
Volumes that are not on the centreline (sidepods, headrest, gearbox, tank, tail)
use four superellipse quadrants with independent exponents, plus an undercut.
```js
podPts({ xIn, xOut, yTop, yBot, nTop: 6.6, nBot: 2.4, nIn: 2.6,
undercut: 0.58, ucY: 0.17, ucW: 0.36, shelf: 0.26 })
```
The exponents carry the design language: a very high `nTop` produces a flat aero
shelf meeting the flank on a hard crease, while ~2.6 gives a soft crown. The
undercut is a Gaussian in height applied only outboard of the section centre —
that term, not the outline, is what creates the coke-bottle.
## Apertures that share one parameterisation
An inlet is a real aperture, and its lip ring's OUTER outline IS the volume's own
front section, so mouth, lip and shell cannot drift apart:
```js
const outer = podPts(POD(POD_FRONT_Z), 64);
const inner = outer.map(([x, y]) => [cx + (x - cx) * 0.815, cy + (y - cy) * 0.660]);
const lip = plate(spline2(outer, 110, { closed: true }), {
thickness: 0.026, bevel: 0.0065,
holes: [spline2(inner, 96, { closed: true }).reverse()], // holes wind opposite
});
```
The throat then lofts from `inner`, contracting to 0.54 and shifting 0.100 m
inboard, so the duct is visibly open rather than a painted black hole.
## Spanwise airfoil lofts
Wing elements are real lofts of cambered sections. Every station owns chord,
incidence, camber, camber position, blunt trailing-edge thickness, and dihedral —
the parameters an aerodynamicist works in, not a scaled slab.
```js
airfoilLoop(n, { thick, camber, camberPos, teThick, leRadius })
// cosine spacing packs resolution into the leading edge;
// closed loop = upper LE→TE, blunt TE, lower TE→LE with the LE pole shared once
```
Negative camber and negative incidence are what make an inverted wing read
correctly from any angle. Tip capping is per-end: an element that meets the
centreline must NOT be capped there, or the mirrored half shows a seam.
```text
front wing main plane: 44 stations, chord 0.288 → 0.240, incidence −0.050 → −0.120
camber −0.030 → −0.076, dihedral −0.06 → −0.36
```
## Authored plates: holes, bevels, warps
Endplates, fences, strakes and louvres are authored 2D outlines in real metres,
extruded with a bevel, then deformed by a 3D warp. The warp is where curl comes
from, so the plate is generated curled rather than bent afterwards:
```js
plate(outline, { thickness: 0.008, bevel: 0.0024, holes: [slotA, slotB],
warp: (z, y, w) => V3(0.8950 + w + curl(z, y), y, z) });
// curl = 0.052 · up^1.7 · back^1.20 → outwash grows toward the top rear
```
## Steering-axis placement
For a two-wheeler the steering geometry is the spine. Derive an axis frame once
and place every front-end part at a stated distance along it; rake and
triple-clamp offset then propagate to yokes, bars, calipers and fender with no
hand-placed transform.
```js
const AX = { up: V3(0, cos(rake), −sin(rake)),
fwd: V3(0, sin(rake), cos(rake)) };
const forkPt = (L, s = 0) => V3(s, axleY + AX.up.y * L, axleZ + AX.up.z * L);
const steerPt = (L) => forkPt(L).addScaledVector(AX.fwd, −tripleOffset);
```
## Two-plane paint projector
A livery is a PROJECTOR, not a per-part unwrap: one canvas painted in the side
elevation `(z, y)` and one in the plan view `(z, x)`. Bodywork samples whichever
plane its world normal faces, so the scheme stays continuous across nose,
chassis, sidepod and engine-cover boundaries exactly like a real wrap.
```js
const wUp = pow(saturate(N.y), 4.0); // plan weight
const wSide = pow(N.x.abs(), 4.0).add(pow(N.z.abs(), 4.0).mul(0.40));
const topW = wUp.div(wUp.add(wSide).add(0.0004)); // NORMALISED
const underKill = smoothstep(-0.12, -0.55, N.y).mul(smoothstep(0.36, 0.17, P.y));
```
Two rules make it work. The blend must be normalised so a 45° shoulder commits
to one plane instead of taking half the paint from each and printing the graphic
twice. And a downward-facing surface low in the frame is structural, so the
under-kill mask stops flank graphics smearing onto the underbody.
The side canvas carries two stacked layers that differ ONLY in glyph handedness;
which layer paints which flank depends on the upload's Y orientation, so expose
it as a switch (`sideSwap`) rather than assuming.
## Tyres: measured carcass and baked deflection
Revolve a measured half-section — bead, sidewall bulge, shoulder, crown — then
bake the contact patch as GEOMETRY:
```js
// squash only the lower arc, and widen the sidewall where it flattens
const groundY = -(R - drop); // drop 0.0135 front / 0.0150 rear
if (y < groundY) {
ny = lerp(y, groundY, smooth01(min(over * 1.35, 1)));
widen = 1 + 0.085 * smooth01(min(over, 1)) * clamp01(abs(x) / (R * 0.5));
}
```
Emit a second, undeflected carcass for the rolling presentation and swap
geometry when it spins — a baked flat cannot be rotated, it would orbit with the
wheel. Mirroring a wheel mirrors its UVs too, so the opposite flank needs the
sidewall wordmark sampled in reverse or the moulded lettering reads backwards.
## Slot-tagged writers and the orientation guard
Every emitted mesh carries a material SLOT, and the writer records its triangle
count. On a black-on-black machine the slot field is the only way to check
finish ownership at all.
```js
add(parent, geometry, material, name, { slot }); // records { name, tris, slot }
pair(parent, geometry, material, name); // author +X, mirror to −X
instance(parent, geometry, material, matrices, name);
```
A loft's winding depends on whether its station table runs `+Z` or `−Z` and on
how its section is wound. Rather than police that by hand across forty tables,
check the enclosed signed volume of every closed body and flip winding and
normals when it is negative:
```js
function orient(geometry) { // 87 fixups on the motorcycle
if (signedVolume(geometry) < 0) flipGeometry(geometry);
return geometry;
}
```
Silent inversion is the most common failure in generated hard surfaces: it
survives a wireframe check and only shows up as wrong light.
## Observed limits
- Emitted budgets at full tessellation: 62 geometries / 375,964 unique triangles
for the car; 267 parts / 303,708 triangles for the motorcycle. Both are
close-inspection budgets, not game budgets.
- Corner hardware is emitted ONCE per axle and mirrored per side. Mirroring
flips winding and UV handedness, so a mirrored wheel needs its own material
variant for any lettered texture.
- `mergeGeometries` is unavailable on some builds; keep a manual merge that
copies position, normal and uv and offsets indices, and promote to `Uint32`
above 65,535 vertices.
- Plate holes must wind opposite to their outline or the extruder fills them.
- A parameter track is only as smooth as its knot spacing: two knots closer than
one station spacing reintroduce the terracing the monotone interpolant exists
to avoid.
- The projector is a world-space field. Two bodies at different world positions
cannot share one graphic unless both are inside the paint plane's `z` band —
the `inBand` step is what keeps everything outside it unpainted.
## Diagnostics
Expose, and read in this order:
```text
per-part triangle/vertex table, sorted by triangle count
assembly topology (wireframe over the whole model, ground hidden)
material slots as stable per-slot hues
world normals as colour
bare structure (paint coverage forced to zero)
projector plane weight as a two-colour field
rolling carcass (undeflected geometry swapped in)
bodywork removed / glass removed
measured envelope vs the dimension contract
orientation-guard fixup count
```
The measured envelope is the cheap regression: build the bounding box and
compare length, width, height and wheelbase against the contract. The motorcycle
returns 2.016 × 0.704 × 1.105 m against published 2.025 × 0.710 × 1.100, which
is the tolerance an authored body should hold. A projector-plane view that shows
one graphic twice on a shoulder means the plane blend lost its normalisation; a
slot view with a surprise hue means a part was added without its slot.
SKILL.md
---
name: threejs-procedural-geometry
description: Build well-crafted production procedural meshes in Three.js. Use for complete hard-surface assemblies and humanoid robots, profile extrusion, parameter-curve and spine lofts, pillow panels, exact polygon cuts, inset, revolve, sweep, solidify, bevels and fillets, shell thickness, direct-topology apertures, semantic mesh writers, or diagnosing primitive-built forms, coplanar flicker, loose/non-manifold geometry, detached parts, interpenetration, support, clearance, and swept-envelope defects.
---
# Procedural Geometry
Generate geometry from a semantic contract and explicit coordinate frame.
Treat polygon operations as the design model and triangle emission as the final
compilation step. Do not declare an object complete until its topology,
assembly relationships, semantic measurements, and fixed views all pass.
This skill contains exemplary examples and assets beyond descriptive guidance,
they're worth studying, referencing, or even copying. Use them sufficiently
when relevant and do NOT blindly skip them.
## Craft loop
1. Define units, local/world frames, dimensions, bounds, primary profiles,
topology intent, shell thickness, apertures, material slots, and triangle
band.
2. Declare every required contact, support, clearance, penetration, reveal,
moving envelope, and interaction datum.
3. Choose a modeling operation for each visible form: profile extrusion, inset,
loft, revolve, sweep, direct-topology aperture, solidify, subdivision,
bevel, or fillet.
4. Keep quads and n-gons through design. Preserve one named mesh per semantic
part; do not weld unrelated parts or merge material slots yet.
5. Apply modifiers in a deliberate order, then clean inside each part, repair
winding, assign a part-specific smooth angle, and emit triangles.
6. Run the polygon topology gate, then audit the named world assembly for
coplanar overlap, defects, and substantial solid clashes.
7. Run builder-owned semantic checks, including support, openings, shell
thickness samples, ergonomic clearances, and moving extrema.
8. Merge by material slot, audit again, and inspect fixed silhouettes, hidden
sides, joints, interaction views, wireframe, normals, and grazing light.
## Modeling bar
- Use primitives only for genuinely primitive or hidden structural parts.
- Give every visible manufactured edge a scale-appropriate bevel or fillet.
- Build openings into one closed shell; do not stack a dark plane over a solid.
- Construct thick shells with paired profiles or `solidify`; never rely on a
single surface where an exposed rim reveals thickness.
- Drive mating parts from the same datum. A late visual nudge is a failed
dimension contract.
- Choose a continuous mesh for a continuous manufactured form. An arrangement
of intersecting primitives is not a substitute for modeling.
- Keep narrow, named allowances only for intersections that are structurally
intentional.
Read [references/geometry-craft-workflow.md](references/geometry-craft-workflow.md)
for the polygon-first modeling grammar, modifier order, join floors, detail
budgets, fixed visual-review contract, and geometry-craft failure diagnosis.
Read [references/geometry-quality-gates.md](references/geometry-quality-gates.md)
for exact topology, coplanar, solid-clash, semantic, support, clearance,
motion-envelope, and visual gates with metre-scale tolerances.
## Portable JavaScript kit
Place the complete `assets/geometry-quality-kit/` directory into a Three.js
project when the project lacks an equivalent modeling and quality layer.
Adjust import paths and scene plumbing as needed; preserve the geometry and
audit contracts.
- [procedural-mesh.js](assets/geometry-quality-kit/procedural-mesh.js) provides
`MeshData`, polygon cleanup, winding reconstruction, profiles, offsets,
extrusion, loft, revolve, sweeps, solidify, subdivision, bevels, apertures,
smooth-angle normals, material-slot builds, and Three.js emission.
- [mesh-topology-audit.js](assets/geometry-quality-kit/mesh-topology-audit.js)
checks loose/duplicate vertices, degenerates, open/non-manifold edges,
detached components, signed volume, and emitted positions/normals.
- [geometry-audit.js](assets/geometry-quality-kit/geometry-audit.js) audits a
built `Object3D` hierarchy for true clipped coplanar overlap, invalid mesh
data, missing materials, and substantial triangle-crossing clashes.
- [geometry-contract.js](assets/geometry-quality-kit/geometry-contract.js)
runs object-specific measurements without imposing project-specific shape
semantics.
- [assembly.js](assets/geometry-quality-kit/assembly.js) preserves named parts
for auditing, then builds one draw mesh per material slot after gates pass.
- [selftest.js](assets/geometry-quality-kit/selftest.js) plants known defects
and verifies the modeling, topology, contract, assembly, z-fight, and clash
paths; run it after placing or adapting the kit.
## Mechanism references and implementations
Read [references/profile-sweeps-and-mesh-writers.md](references/profile-sweeps-and-mesh-writers.md)
for the exact sculpted-frame profile, rail emission, tree rings, semantic mesh
writer, and their observed scaling limits.
Read the
[sculpted gallery frame geometry](examples/sculpted-gallery-frame/frame-geometry.js)
for profile sweeps, miter-like rail mapping, deliberate cap ownership, PBR
surface bundles, grazing highlights, selective bloom, and geometry diagnostics.
Read
[references/complete-submarine-assembly.md](references/complete-submarine-assembly.md)
for the exact dimensioned object contract, shared loft/sweep kernel, UV-owned
apertures, semantic subassemblies, generated fittings, and model diagnostics.
Read the
[porcelain-and-brass submarine model](examples/porcelain-brass-submarine/submarine-model.js)
for a complete assembly with a tilted-collar hull loft, parallel-transport trim,
furnished glass cabin, shrouded propeller, lens-section fins, and per-part
triangle evidence.
Read
[references/vehicle-loft-and-projector-contract.md](references/vehicle-loft-and-projector-contract.md)
for parameter-curve section tracks, recess-opening sections, superellipse
volumes, spanwise airfoil lofts, warped plates, projector ownership,
load-deflected tyres, and measured limits.
Read the
[Formula One race car model](examples/formula-one-race-car/race-car-model.js)
for one continuous body loft, section-owned cockpit recess, real inlet
aperture, spanwise wing lofts, livery projection, and contact-deflected tyres.
Read the
[sport motorcycle model](examples/sport-motorcycle/motorcycle-model.js)
for slot-tagged emission, revolve and upright-frame sweeps, offset panel shells,
spoked wheels, a hanging chain path, and a volume-audited assembly.
Read
[references/procedural-optimus-humanoid-assembly.md](references/procedural-optimus-humanoid-assembly.md)
for the exact coordinate, polygon/modifier, curve, loft, spine, pillow, CSG,
bevel, semantic assembly, filtered material, emission, limitation, and
diagnostic contracts of a complete human-scale robot.
Read the
[procedural Optimus humanoid entry](examples/procedural-optimus-humanoid/procedural-optimus-humanoid.js)
and its complete
[geometry and material system](examples/procedural-optimus-humanoid/source/optimus-humanoid-system.js)
for a 176-object torso/head/arm/hand/hip/leg/foot assembly with five-finger
hands, 891,809 emitted triangles, fourteen PBR identities, exact polygon cuts,
angle-limited bevels, split corner normals, and derivative-filtered object-space
roughness and bump.
Read the
[procedural financial tower compiler](../threejs-procedural-architecture/examples/procedural-financial-tower/building-system.js)
for semantic placement compilation and material-slot instancing at building
scale.
## Failure conditions
- same-facing coplanar triangles survive at a visible scale;
- a loose vertex, degenerate face, open solid, non-manifold edge, detached
component, invalid normal, or inward closed volume reaches emission;
- substantial unrelated solids intersect, or a placed part lacks a declared
support/contact relationship;
- an exposed shell is paper-thin or an aperture is a visual overlay;
- a visible primitive keeps razor edges or joins another primitive without a
designed transition;
- profile frames flip, caps share smooth side normals, or UV density changes
with segment count;
- material merging happens before named-part auditing;
- triangle count is the only complexity evidence;
- a script passes but fixed-view inspection still finds implausible modeling.
- a complete humanoid is reduced to intersecting capsules and boxes;
- mirrored hands or limbs keep inward winding;
- high-frequency object-space material noise is emitted without footprint filtering.
## Routing boundary
This skill owns reusable mesh construction and geometry quality. Use
`$threejs-procedural-materials` when surface identity is primary,
`$threejs-procedural-architecture` for a building grammar, and
`$threejs-procedural-vegetation` for a growth hierarchy; those subject skills
may then apply these geometry mechanisms.