import * as THREE from 'three'; import { mergeGeoms } from '../core/math.js'; import { PART } from './body.js'; import { computeSkin } from './skinning.js'; import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js'; /** * Skater equipment, in layers. * * A hockey player is dressed, not painted, and the order is the order it goes * on in a dressing room: * * 1. shoulder pads and elbow caps — the under layer that gives the torso its * shape. Mostly hidden, which is the point: the jersey drapes over it. * 2. jersey — long sleeves, hem past the waist, cut wide enough to clear the * pads underneath. * 3. pants — waist-high padded shorts down to just above the knee. * 4. socks over shin guards, taped at the top and bottom of the wrap. * 5. skates, gloves, helmet. * * ### Skinned vs socketed * * Anything that crosses a joint is skinned to the same skeleton the body uses * (`computeSkin`, then bound as a second SkinnedMesh sharing `skelData`). A * jersey bolted to the chest bone tears open at the shoulder the first time an * arm swings; a pant leg bolted to the pelvis passes through the thigh on a * knee bend. Cloth is authored in rest space, exactly like the body geometry. * * Boots, gloves and the helmet are rigid shells that genuinely do not bend, so * they are socketed to the foot, hand and head bones and cost nothing to skin. * * ### Fit * * Every radius scales off the physique factors the body loft was built from * (`bodyGeo.userData.physique`), so a heavy build gets a bigger jersey instead * of wearing its chest through the front of it. */ /** Rest direction the upper arm points, in its own bone space (A-pose). */ const ARM_DIR = { L: new THREE.Vector3(0.15, -0.252, 0.01).normalize(), R: new THREE.Vector3(-0.15, -0.252, 0.01).normalize(), }; /** Rest direction the fingers point, from the hand bone. */ const HAND_DIR = { L: new THREE.Vector3(0.045, -0.095, 0.008).normalize(), R: new THREE.Vector3(-0.045, -0.095, 0.008).normalize(), }; const DOWN = new THREE.Vector3(0, -1, 0); export const KIT = { helmet: { /** Skull centre in head-bone-local space. */ riseY: 0.094, pushZ: -0.004, rx: 0.114, ry: 0.148, rz: 0.125, /** * Polar angle the shell starts at. This is the number that decides whether * you get a helmet or a beanie: the bottom ring sits at * riseY − ry·cos(phi0), so it has to come out *below* the ear line. */ phi0: 0.36, wall: 0.009, /** Brow line: everything in front of and below this is open face. */ browY: 0.03, earY: -0.022, }, /** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */ bladeY: -0.09, }; /** * What the kit covers, as `aT` ranges per body part. * * The body underneath a dressed skater is wasted work and a source of * poke-through: a shoulder rolls, a hip flexes, and a sliver of the layer below * pushes through a seam. Ludus solved it by dropping the covered body faces * once the clothing went on, and the same applies here. * * Ranges are deliberately short of the seams. A triangle is only dropped when * *all three* of its vertices are covered, which leaves a one-triangle fringe * under every edge of the gear — cheap insurance against a gap opening up at * the collar or the cuff when the pose moves. */ export const COVERAGE = { // Jersey and pants, up to the collar. The neck and above stay. [PART.TORSO]: [0.0, 0.9], // Sleeve and glove, deltoid to fingertips. The shoulder ball has to be in // here: it is the widest thing on the arm and it sits exactly where the // sleeve meets the yoke, so leaving it visible shows it through the seam. [PART.ARM_L]: [0.0, 1.0], [PART.ARM_R]: [0.0, 1.0], // Pants, socks and boots enclose the leg end to end. [PART.LEG_L]: [0.0, 1.0], [PART.LEG_R]: [0.0, 1.0], }; /** * Drop the body faces the kit covers. Call after `computeSkin` and after the * body has been painted — it only rewrites the index. */ export function hideCoveredBody(geo, coverage = COVERAGE) { const partAttr = geo.attributes.aPart; const tAttr = geo.attributes.aT; if (!partAttr || !tAttr || !geo.index) return geo; const covered = (v) => { const range = coverage[partAttr.getX(v)]; if (!range) return false; const t = tAttr.getX(v); return t >= range[0] && t <= range[1]; }; const idx = geo.index.array; const keep = []; for (let f = 0; f < idx.length; f += 3) { const a = idx[f]; const b = idx[f + 1]; const c = idx[f + 2]; if (covered(a) && covered(b) && covered(c)) continue; keep.push(a, b, c); } geo.setIndex(keep); return geo; } /** * @param {*} mats from `buildSkaterGearMaterials` * @param {*} skelData the skeleton the cloth binds to * @param {{bulk:number,waistF:number,shoulderF:number,armF:number,legF:number,headF:number}} phys */ export function buildSkaterGear(mats, skelData, phys) { const pieces = []; const skinned = []; const disposables = []; const bulk = phys?.bulk ?? 1; const shoulder = (phys?.shoulderF ?? 1) * bulk; const waist = (phys?.waistF ?? 1) * bulk; const armF = phys?.armF ?? 1; const legF = phys?.legF ?? 1; const headF = phys?.headF ?? 1; const PAL = { jersey: tint(mats.jersey.color), accent: tint(mats.accent.color), trim: tint(mats.trim.color), pad: tint(mats.pad.color), tape: tint(mats.tape.color), }; const V = (x, y, z = 0) => new THREE.Vector3(x, y, z); const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col }); function mesh(geo, mat, name) { const m = new THREE.Mesh(geo, mat); m.name = name; m.castShadow = true; m.receiveShadow = true; disposables.push(geo); return m; } /** Point a group's −Y down a bone's real limb direction. */ function alignTo(group, dir) { group.quaternion.setFromUnitVectors(DOWN, dir); return group; } /** * Merge rest-space pieces, solve skin weights, and bind to the body's * skeleton. `computeSkin` overwrites the colour attribute with its debug * heatmap, so the kit colours are stashed and put back afterwards — same * dance `paintKit` does for the body. */ function skin(parts, mat, name) { const geo = mergeGeoms(parts); for (const p of parts) p.dispose(); const colors = geo.attributes.color.array.slice(); computeSkin(geo, skelData); geo.userData.heatColors = geo.attributes.color.array.slice(); geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geo.computeVertexNormals(); const m = new THREE.SkinnedMesh(geo, mat); m.name = name; m.castShadow = true; m.receiveShadow = true; m.frustumCulled = false; // Bound before parenting, so the bind matrix is identity — matching the // body mesh. The root bone stays parented to the body; a second mesh only // borrows the skeleton. m.updateMatrixWorld(true); m.bind(skelData.skeleton, m.matrixWorld.clone()); disposables.push(geo); skinned.push(m); pieces.push(m); return m; } // ---- 1. under layer: shoulder pads ------------------------------------- // Sits between skin and jersey. Barely seen, but it is what makes the jersey // sit square across the shoulders instead of shrink-wrapping the deltoids. // Kept a clear centimetre inside the jersey at every ring. Two skinned // meshes never deform identically — their vertices sit in different places, // so the distance-field solve hands them different weights — and a pad that // merely *touches* the inside of a sweater will tear through it on a shoulder // roll. What actually shows is the collar, standing above the neckline. const padChest = loft([ S(V(0, 1.18, 0.006), 0.156 * bulk, 0.108 * bulk, 4, PAL.pad), S(V(0, 1.26, 0.008), 0.17 * shoulder, 0.116 * bulk, 4), S(V(0, 1.335, 0.008), 0.186 * shoulder, 0.12 * bulk, 4), S(V(0, 1.392, 0.01), 0.16 * shoulder, 0.106 * bulk, 4), S(V(0, 1.428, 0.012), 0.1 * bulk, 0.09 * bulk, 3), S(V(0, 1.452, 0.013), 0.094 * bulk, 0.085 * bulk, 3), ], { radial: 16, sub: 3, part: PART.TORSO, t0: 0.5, t1: 0.96 }); skin([padChest], mats.padded, 'shoulderPads'); // Deltoid caps ride the upper arms so they follow the shoulder, not the ribs. function makeCap(side) { const g = new THREE.Group(); g.name = `shoulderCap${side}`; // Kept under the sleeve radius at every ring: the cap is rigid on the bone // and the sleeve is skinned, so anything close to the same size pushes // through the cloth the moment the arm swings. const cap = loft([ S(V(0, 0.04, 0.008), 0.062 * armF, 0.058 * armF, 3, PAL.pad), S(V(0, -0.025, 0.01), 0.074 * armF, 0.07 * armF, 4), S(V(0, -0.09, 0.008), 0.068 * armF, 0.064 * armF, 4), S(V(0, -0.14, 0.006), 0.054 * armF, 0.05 * armF, 3), ], { radial: 14, sub: 3 }); g.add(mesh(cap, mats.padded, `shoulderCap${side}Shell`)); alignTo(g, ARM_DIR[side]); pieces.push(g); return g; } const capL = makeCap('L'); const capR = makeCap('R'); // ---- 2. jersey ---------------------------------------------------------- // Torso plus two long sleeves, merged into one skinned mesh. Waist stripes // and cuff bands are cut the same way the goalie's pad bands are: two // sections a centimetre apart. const jerseyParts = []; jerseyParts.push(loft([ // Hem hangs over the pants, so it has to clear the widest part of them. S(V(0, 0.878, 0.004), 0.226 * bulk, 0.17 * bulk, 4, PAL.jersey), S(V(0, 0.905, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.accent), S(V(0, 0.94, 0.004), 0.233 * bulk, 0.175 * bulk, 4), S(V(0, 0.95, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.trim), S(V(0, 0.98, 0.005), 0.229 * bulk, 0.171 * bulk, 4), S(V(0, 0.99, 0.005), 0.228 * bulk, 0.17 * bulk, 4, PAL.jersey), S(V(0, 1.075, 0.005), 0.207 * waist, 0.152 * waist, 4), S(V(0, 1.165, 0.007), 0.202 * bulk, 0.148 * bulk, 4), S(V(0, 1.255, 0.009), 0.212 * bulk, 0.155 * bulk, 4), // Over the shoulder pads — the widest point of a dressed player. S(V(0, 1.335, 0.01), 0.242 * shoulder, 0.16 * bulk, 5), S(V(0, 1.395, 0.012), 0.222 * shoulder, 0.142 * bulk, 4), S(V(0, 1.418, 0.013), 0.17 * shoulder, 0.12 * bulk, 4), S(V(0, 1.432, 0.013), 0.108 * bulk, 0.098 * bulk, 3, PAL.trim), S(V(0, 1.462, 0.014), 0.098 * bulk, 0.09 * bulk, 3), ], { radial: 20, sub: 3, part: PART.TORSO, t0: 0.0, t1: 0.98 })); for (const side of ['L', 'R']) { const s = side === 'L' ? 1 : -1; const P = (x, y, z = 0) => V(s * x, y, z); jerseyParts.push(loft([ // Wide enough at the top to swallow the deltoid ball, and buried in the // torso shell so the shoulder seam never opens. S(P(0.10, 1.415, 0.008), 0.108 * armF, 0.10 * armF, 3, PAL.jersey), S(P(0.175, 1.385, 0.01), 0.118 * armF, 0.112 * armF, 3), S(P(0.245, 1.325, 0.01), 0.105 * armF, 0.10 * armF, 3), S(P(0.30, 1.27, 0.01), 0.09 * armF, 0.086 * armF, 3), S(P(0.355, 1.16, 0.012), 0.072 * armF, 0.068 * armF, 3), // Elbow cap under the sleeve. S(P(0.397, 1.095, 0.013), 0.076 * armF, 0.072 * armF, 3), S(P(0.447, 0.985, 0.016), 0.064 * armF, 0.06 * armF, 3), S(P(0.472, 0.93, 0.018), 0.058 * armF, 0.055 * armF, 3, PAL.accent), S(P(0.487, 0.898, 0.02), 0.057 * armF, 0.054 * armF, 3), S(P(0.497, 0.876, 0.022), 0.056 * armF, 0.053 * armF, 3, PAL.trim), S(P(0.512, 0.844, 0.024), 0.053 * armF, 0.05 * armF, 3), ], { radial: 14, sub: 3, part: side === 'L' ? PART.ARM_L : PART.ARM_R, t0: 0.1, t1: 0.94, })); } skin(jerseyParts, mats.cloth, 'jersey'); // ---- 3. pants ----------------------------------------------------------- // Waist-high padded shorts: a hip shell plus two thigh tubes that stop above // the knee. Stiff, so they are wide and barely taper. const pantParts = []; pantParts.push(loft([ S(V(0, 1.115, 0.004), 0.178 * waist, 0.132 * waist, 4, PAL.trim), S(V(0, 1.09, 0.004), 0.186 * waist, 0.138 * waist, 4), S(V(0, 1.08, 0.004), 0.19 * waist, 0.142 * waist, 4, PAL.accent), S(V(0, 1.055, 0.005), 0.196 * waist, 0.146 * waist, 4), S(V(0, 1.045, 0.005), 0.198 * waist, 0.148 * waist, 4, PAL.trim), S(V(0, 0.99, 0.005), 0.205 * bulk, 0.152 * bulk, 5), S(V(0, 0.94, 0.005), 0.207 * bulk, 0.154 * bulk, 5), S(V(0, 0.90, 0.004), 0.198 * bulk, 0.146 * bulk, 5), ], { radial: 18, sub: 3, part: PART.TORSO, t0: 0.02, t1: 0.34 })); for (const side of ['L', 'R']) { const s = side === 'L' ? 1 : -1; const P = (x, y, z = 0) => V(s * x, y, z); pantParts.push(loft([ S(P(0.098, 0.97, 0.004), 0.142 * legF, 0.132 * legF, 4, PAL.trim), S(P(0.112, 0.90, 0.006), 0.138 * legF, 0.13 * legF, 4), S(P(0.12, 0.80, 0.008), 0.13 * legF, 0.122 * legF, 4), S(P(0.126, 0.71, 0.008), 0.122 * legF, 0.114 * legF, 4), S(P(0.127, 0.688, 0.008), 0.119 * legF, 0.111 * legF, 4, PAL.accent), S(P(0.128, 0.668, 0.008), 0.116 * legF, 0.108 * legF, 4), S(P(0.1285, 0.658, 0.008), 0.114 * legF, 0.106 * legF, 4, PAL.trim), S(P(0.129, 0.645, 0.008), 0.112 * legF, 0.104 * legF, 4), ], { radial: 14, sub: 3, part: side === 'L' ? PART.LEG_L : PART.LEG_R, t0: 0.02, t1: 0.34, })); } skin(pantParts, mats.padded, 'pants'); // ---- 4. socks over shin guards ----------------------------------------- // The sock is the visible layer; the guard underneath is read as the bulge at // the knee and the flat down the front of the shin. Tape bands at the top and // bottom of the wrap, where a player actually tapes. const sockParts = []; for (const side of ['L', 'R']) { const s = side === 'L' ? 1 : -1; const part = side === 'L' ? PART.LEG_L : PART.LEG_R; const P = (x, y, z = 0) => V(s * x, y, z); sockParts.push(loft([ S(P(0.124, 0.735, 0.008), 0.098 * legF, 0.094 * legF, 3, PAL.jersey), S(P(0.128, 0.66, 0.01), 0.094 * legF, 0.09 * legF, 3), // Tape at the top of the wrap. S(P(0.129, 0.638, 0.01), 0.093 * legF, 0.089 * legF, 3, PAL.tape), S(P(0.13, 0.60, 0.012), 0.092 * legF, 0.088 * legF, 3), S(P(0.13, 0.578, 0.012), 0.092 * legF, 0.088 * legF, 3, PAL.jersey), // Knee. S(P(0.131, 0.53, 0.016), 0.096 * legF, 0.094 * legF, 3), S(P(0.132, 0.45, 0.014), 0.086 * legF, 0.082 * legF, 3), S(P(0.133, 0.35, 0.01), 0.079 * legF, 0.074 * legF, 3), S(P(0.133, 0.26, 0.006), 0.072 * legF, 0.066 * legF, 3), // Tape at the bottom of the wrap. S(P(0.133, 0.232, 0.005), 0.07 * legF, 0.064 * legF, 3, PAL.tape), S(P(0.132, 0.20, 0.004), 0.068 * legF, 0.062 * legF, 3), S(P(0.132, 0.18, 0.003), 0.066 * legF, 0.06 * legF, 3, PAL.jersey), S(P(0.131, 0.135, 0.002), 0.06 * legF, 0.056 * legF, 3), S(P(0.131, 0.105, 0.004), 0.056 * legF, 0.052 * legF, 3, PAL.trim), ], { radial: 14, sub: 3, part, t0: 0.30, t1: 0.87 })); // Knee cap: a dome off the front of the wrap. sockParts.push(loft([ S(P(0.131, 0.545, 0.02), 0.062 * legF, 0.058 * legF, 3, PAL.jersey), S(P(0.131, 0.542, 0.058), 0.07 * legF, 0.066 * legF, 3), S(P(0.131, 0.538, 0.088), 0.058 * legF, 0.054 * legF, 3), S(P(0.131, 0.534, 0.104), 0.03 * legF, 0.028 * legF, 3), ], { radial: 14, sub: 3, part, t0: 0.48, t1: 0.54 })); } skin(sockParts, mats.cloth, 'socks'); // ---- 5. skates ---------------------------------------------------------- // Foot-bone local: +Z is forward past the toe, the sole sits a little under // the bone, the blade hangs where the ice is. function makeSkate(side) { const g = new THREE.Group(); g.name = `skate${side}`; const boot = loft([ S(V(0, -0.014, -0.088), 0.036, 0.042, 4, PAL.trim), S(V(0, -0.02, -0.05), 0.046, 0.05, 4), S(V(0, -0.026, 0.01), 0.05, 0.048, 4), S(V(0, -0.03, 0.07), 0.048, 0.042, 4), S(V(0, -0.034, 0.125), 0.04, 0.032, 4), S(V(0, -0.038, 0.162), 0.022, 0.018, 3), ], { radial: 16, sub: 4 }); g.add(mesh(boot, mats.hard, `skate${side}Boot`)); // Ankle cuff — the kit stops at the ankle, as asked. const cuff = loft([ S(V(0, -0.012, -0.05), 0.048, 0.05, 4, PAL.trim), S(V(0, 0.03, -0.045), 0.05, 0.048, 4), S(V(0, 0.062, -0.038), 0.047, 0.044, 4, PAL.pad), S(V(0, 0.078, -0.032), 0.041, 0.038, 3), ], { radial: 14, sub: 3 }); g.add(mesh(cuff, mats.hard, `skate${side}Cuff`)); // Tongue up the front of the ankle. const tongue = loft([ S(V(0, -0.01, 0.03), 0.03, 0.014, 3, PAL.trim), S(V(0, 0.03, 0.012), 0.033, 0.015, 3), S(V(0, 0.07, 0.0), 0.031, 0.014, 3, PAL.accent), ], { radial: 10, sub: 3 }); g.add(mesh(tongue, mats.hard, `skate${side}Tongue`)); // Holder: two posts off the sole down to the runner. const holder = []; for (const z of [-0.045, 0.085]) { holder.push(tube([ V(0, -0.05, z), V(0, -0.062, z + (z < 0 ? 0.008 : -0.008)), V(0, -0.072, z + (z < 0 ? 0.012 : -0.012)), ], 0.011, { radial: 6 })); } holder.push(tube([ V(0, -0.073, -0.075), V(0, -0.076, 0), V(0, -0.073, 0.13), ], 0.008, { radial: 6 })); g.add(mesh(mergeBars(holder), mats.holder, `skate${side}Holder`)); // Runner: a thin steel blade with the toe and heel curling up off the ice. const blade = loft([ S(V(0, KIT.bladeY + 0.028, -0.108), 0.0035, 0.012, 3, PAL.trim), S(V(0, KIT.bladeY + 0.012, -0.088), 0.0035, 0.013, 3), S(V(0, KIT.bladeY + 0.012, 0.12), 0.0035, 0.013, 3), S(V(0, KIT.bladeY + 0.03, 0.145), 0.0035, 0.012, 3), ], { radial: 6, sub: 4 }); g.add(mesh(blade, mats.steel, `skate${side}Blade`)); // Laces. const laces = []; for (const y of [0.0, 0.022, 0.044]) { laces.push(tube([ V(-0.03, y - 0.005, 0.03 - y * 0.4), V(0, y + 0.004, 0.022 - y * 0.4), V(0.03, y - 0.005, 0.03 - y * 0.4), ], 0.004, { radial: 5 })); } g.add(mesh(mergeBars(laces), mats.lace, `skate${side}Laces`)); pieces.push(g); return g; } const skateL = makeSkate('L'); const skateR = makeSkate('R'); // ---- 6. gloves ---------------------------------------------------------- // Glove space: fingers down −Y, back of the hand +Z, then rotated onto the // hand bone's real axis. The stick is aimed from the same bone, so the glove // has to stay a shell around the hand and not swallow the shaft. function makeGlove(side) { const s = side === 'L' ? 1 : -1; const g = new THREE.Group(); g.name = `glove${side}`; const body = loft([ // Flared cuff roll at the wrist. S(V(0, 0.085, -0.004), 0.056, 0.054, 3, PAL.trim), S(V(0, 0.062, -0.002), 0.068, 0.064, 3, PAL.accent), S(V(0, 0.03, 0.002), 0.074, 0.068, 3), S(V(0, 0.012, 0.004), 0.076, 0.07, 3, PAL.jersey), S(V(0, -0.04, 0.01), 0.08, 0.068, 4), S(V(0, -0.105, 0.014), 0.082, 0.066, 4), S(V(0, -0.16, 0.014), 0.076, 0.06, 4), S(V(0, -0.19, 0.012), 0.062, 0.05, 4, PAL.trim), S(V(0, -0.215, 0.008), 0.042, 0.034, 3), ], { radial: 16, sub: 4 }); g.add(mesh(body, mats.hard, `glove${side}Body`)); // Backhand rolls — the padded ridges across the knuckles. for (const [y, r] of [[-0.06, 0.026], [-0.115, 0.024]]) { const roll = loft([ S(V(-s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3, PAL.accent), S(V(0, y, 0.062), r, r * 0.9, 3), S(V(s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3), ], { radial: 10, sub: 4 }); g.add(mesh(roll, mats.hard, `glove${side}Roll`)); } // Thumb, curling toward the shaft. const thumb = loft([ S(V(s * 0.058, -0.005, 0.03), 0.03, 0.028, 3, PAL.jersey), S(V(s * 0.09, -0.065, 0.052), 0.028, 0.026, 3), S(V(s * 0.092, -0.12, 0.066), 0.023, 0.022, 3, PAL.trim), ], { radial: 10, sub: 4 }); g.add(mesh(thumb, mats.hard, `glove${side}Thumb`)); alignTo(g, HAND_DIR[side]); g.rotateY(s * 0.25); pieces.push(g); return g; } const gloveL = makeGlove('L'); const gloveR = makeGlove('R'); // ---- 7. helmet ---------------------------------------------------------- // Same carved-shell builder as the goalie mask, cut differently: the whole // lower front is open face, with ear ports at the sides. const H = KIT.helmet; const skull = new THREE.Vector3(0, H.riseY, H.pushZ); function helmetSurface(theta, v, out) { const phi = H.phi0 + (Math.PI - H.phi0) * v; const sp = Math.sin(phi); const cp = Math.cos(phi); const f = Math.cos(theta); const sx = Math.sin(theta); const front = Math.max(0, f); const back = Math.max(0, -f); let rx = H.rx * headF; let rz = H.rz * headF; // Occipital shell carries out over the back of the skull. rz *= 1 + 0.10 * back * v; // Slight flat across the forehead. rz *= 1 - 0.06 * front * front * v; const x = rx * sp * sx; const y = -H.ry * headF * cp; let z = rz * sp * f; // Brow lip juts forward over the eyes. const lip = Math.exp(-(((v - 0.08) / 0.12) ** 2)) * front ** 2; z += 0.008 * lip; return out.set(skull.x + x, skull.y + y, skull.z + z); } /** Open face below the brow, plus a port over each ear. */ const helmetPort = (p) => { const dy = p.y - skull.y; const dz = p.z - skull.z; const ax = Math.abs(p.x); // The face: front-centre below the brow. Narrow, so the shell keeps its // cheek coverage instead of turning into a cap. if (dz > 0.028 && dy < H.browY && ax < 0.072) return true; // Ear ports, covered by the cups. if (ax > 0.088 && dy < H.earY + 0.026 && dy > H.earY - 0.042 && Math.abs(dz + 0.014) < 0.038) { return true; } return false; }; const helmetColor = (p, kind) => { if (kind === 'inner') return PAL.pad; const dy = p.y - skull.y; // Dark brim around the bottom edge of the shell. if (dy < -0.028) return PAL.trim; // Centre stripe over the crown. if (Math.abs(p.x) < 0.019 && dy > 0.03) return PAL.accent; return PAL.jersey; }; const helmet = new THREE.Group(); helmet.name = 'helmet'; helmet.add(mesh( carvedShell({ rows: 26, cols: 36, thickness: H.wall, center: skull, surface: helmetSurface, port: helmetPort, color: helmetColor, }), mats.hard, 'helmetShell', )); // Ear cups over the ports, on their own straps. for (const s of [1, -1]) { const cup = loft([ S(V(s * 0.09, skull.y + H.earY, skull.z - 0.014), 0.028, 0.026, 3, PAL.trim), S(V(s * 0.104, skull.y + H.earY, skull.z - 0.014), 0.03, 0.028, 3), S(V(s * 0.111, skull.y + H.earY, skull.z - 0.014), 0.023, 0.021, 3), ], { radial: 12, sub: 3, ref: new THREE.Vector3(0, 1, 0) }); helmet.add(mesh(cup, mats.hard, 'helmetEar')); } // Chin strap under the jaw. helmet.add(mesh( tube([ V(-0.105, skull.y + H.earY - 0.012, skull.z - 0.01), V(-0.07, skull.y - 0.12, skull.z + 0.03), V(0, skull.y - 0.145, skull.z + 0.05), V(0.07, skull.y - 0.12, skull.z + 0.03), V(0.105, skull.y + H.earY - 0.012, skull.z - 0.01), ], 0.006, { radial: 6 }), mats.strap, 'helmetStrap', )); // Half visor: eye level only. Run it down over the whole face and the player // reads as a welder. { const arc = []; for (let i = 0; i <= 10; i++) { const a = -0.82 + (1.64 * i) / 10; arc.push(V( Math.sin(a) * 0.106 * headF, skull.y + 0.004, skull.z + Math.cos(a) * 0.116 * headF, )); } // The ring axes here are u = up, w = front-to-back, so `rx` is the shield's // height and `rz` is its thickness. Swap those two and you get a shelf // sticking out of the face instead of a shield hanging over the eyes. const visor = loft( arc.map((c, i) => S(c, i === 0 || i === arc.length - 1 ? 0.026 : 0.038, 0.003, 3)), { radial: 8, sub: 2, ref: new THREE.Vector3(0, 1, 0) }, ); helmet.add(mesh(visor, mats.visor, 'helmetVisor')); } pieces.push(helmet); return { padChest, capL, capR, skateL, skateR, gloveL, gloveR, helmet, /** Skinned cloth meshes — these go on the mover, not on a bone. */ skinned, pieces, attachTo(bones, mover) { for (const m of skinned) mover.add(m); bones.upperArmL.add(capL); bones.upperArmR.add(capR); bones.footL.add(skateL); bones.footR.add(skateR); bones.handL.add(gloveL); bones.handR.add(gloveR); bones.head.add(helmet); }, destroy() { for (const p of pieces) p.removeFromParent(); for (const g of disposables) g.dispose(); }, }; } export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) { return { /** Cloth: jersey, socks. Vertex-coloured, matte. */ cloth: new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.88, metalness: 0.0, }), /** Padded shells: pants, shoulder pads. */ padded: new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.72, metalness: 0.02, }), /** Hard shells: helmet, skate boots, gloves. */ hard: new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.38, metalness: 0.06, }), steel: new THREE.MeshStandardMaterial({ color: 0xc8ccd4, roughness: 0.22, metalness: 0.85, }), holder: new THREE.MeshStandardMaterial({ color: 0x16181d, roughness: 0.45, metalness: 0.1, }), lace: new THREE.MeshStandardMaterial({ color: 0xdad6cc, roughness: 0.9 }), strap: new THREE.MeshStandardMaterial({ color: 0x14141a, roughness: 0.85 }), visor: new THREE.MeshPhysicalMaterial({ color: 0x9fb8c8, roughness: 0.08, metalness: 0.0, transparent: true, opacity: 0.32, side: THREE.DoubleSide, }), // Colour sources for the vertex-painted pieces. jersey: new THREE.MeshStandardMaterial({ color: teamJersey }), accent: new THREE.MeshStandardMaterial({ color: teamAccent }), trim: new THREE.MeshStandardMaterial({ color: 0x16181d }), pad: new THREE.MeshStandardMaterial({ color: 0x3a3f4a }), tape: new THREE.MeshStandardMaterial({ color: 0xe8e4d8 }), }; }