brandbot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import type { BrandbotOptions, BrandbotInstance } from './types.js';
2
+ export declare function createBrandbot(container: HTMLElement, options?: BrandbotOptions): BrandbotInstance;
@@ -0,0 +1,681 @@
1
+ /**
2
+ * BrandBot — framework-agnostic core for the brandable 3D robot mascot.
3
+ *
4
+ * const robot = createBrandbot(container, { gltf: modelJson });
5
+ * robot.set({ primary: '#13294b', eyes: '#7fd4ff', logoText: 'ACME' });
6
+ * robot.spin();
7
+ * robot.dispose();
8
+ */
9
+ import * as THREE from 'three';
10
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
11
+ import { DecalGeometry } from 'three/addons/geometries/DecalGeometry.js';
12
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
13
+ const DEFAULTS = {
14
+ gltf: null,
15
+ modelUrl: null,
16
+ trackPointer: 'window',
17
+ shadow: true,
18
+ legs: true,
19
+ orbit: false,
20
+ intro: true,
21
+ camera: { position: [0, 2.95, 5.6], target: [0, 2.95, 0], fov: 34 },
22
+ primary: '#17171c', accent: '#08080a', visor: '#b0b0b8', hands: '#77777e',
23
+ eyes: '#e9f4ff', logoText: '', logoColor: '#ffffff', logoImage: null,
24
+ };
25
+ export function createBrandbot(container, options = {}) {
26
+ const opts = { ...DEFAULTS, ...options };
27
+ /* ------------------------------------------------------------ renderer */
28
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
29
+ renderer.setPixelRatio(Math.min(Math.max(devicePixelRatio, 1.5), 2)); // supersample 1x displays
30
+ renderer.shadowMap.enabled = opts.shadow;
31
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
32
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
33
+ renderer.toneMappingExposure = 1.05;
34
+ renderer.domElement.style.display = 'block';
35
+ renderer.domElement.style.width = '100%';
36
+ renderer.domElement.style.height = '100%';
37
+ container.appendChild(renderer.domElement);
38
+ const scene = new THREE.Scene();
39
+ // studio reflection environment: softbox strips on black, for the sharp
40
+ // highlight streaks in the chrome face and glossy shell
41
+ const pmrem = new THREE.PMREMGenerator(renderer);
42
+ {
43
+ const env = new THREE.Scene();
44
+ env.background = new THREE.Color(0x000000);
45
+ const strip = (w, h, intensity, x, y, z) => {
46
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color().setScalar(intensity), side: THREE.DoubleSide }));
47
+ m.position.set(x, y, z);
48
+ m.lookAt(0, 1, 0);
49
+ env.add(m);
50
+ };
51
+ strip(7, 7, 12, 0, 9, 2); // overhead softbox — glossy crown highlight
52
+ strip(3, 9, 5, -8, 3, 0.5);
53
+ strip(3, 9, 4, 8, 3, 0);
54
+ strip(1.6, 7, 3.5, -5, 3, -6);
55
+ strip(1.6, 7, 3.5, 6, 3, -6);
56
+ // high blur sigma = soft, natural studio reflections instead of hard streaks;
57
+ // panels sit at the true sides so the face center reflects dark, edges bright
58
+ scene.environment = pmrem.fromScene(env, 0.3).texture;
59
+ }
60
+ const camera = new THREE.PerspectiveCamera(opts.camera.fov, 1, 0.1, 100);
61
+ const camTarget = new THREE.Vector3(opts.camera.target[0], opts.camera.target[1], opts.camera.target[2]);
62
+ const camEnd = new THREE.Vector3(opts.camera.position[0], opts.camera.position[1], opts.camera.position[2]);
63
+ // intro: start zoomed on the FACE, then pull back and down to the full-body
64
+ // framing. The face shrinks and the head rises toward the top of frame as
65
+ // the body is revealed below.
66
+ const introFromPos = new THREE.Vector3(camTarget.x, 3.95, camTarget.z + 1.75);
67
+ const introFromLook = new THREE.Vector3(camTarget.x, 4.05, camTarget.z);
68
+ const _introLook = new THREE.Vector3();
69
+ camera.position.copy(opts.intro ? introFromPos : camEnd);
70
+ camera.lookAt(opts.intro ? introFromLook : camTarget);
71
+ // drag-to-rotate, only when asked (demos). Off by default so the component
72
+ // sits locked in its box and faces forward in real apps.
73
+ let controls = null;
74
+ if (opts.orbit) {
75
+ controls = new OrbitControls(camera, renderer.domElement);
76
+ controls.target.copy(camTarget);
77
+ controls.enableDamping = true;
78
+ controls.enablePan = false;
79
+ controls.minDistance = 3.5;
80
+ controls.maxDistance = 11;
81
+ controls.minPolarAngle = 0.7;
82
+ controls.maxPolarAngle = 1.75;
83
+ controls.enabled = !opts.intro; // handed control after the intro dolly
84
+ }
85
+ // the intro is armed but only *starts* once the model is in (see setupModel),
86
+ // so the face-zoom plays when the robot is actually visible — not during the
87
+ // async load when the canvas is still empty
88
+ const introState = { active: false, t: 0, dur: 2.3 };
89
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x3a3f4a, 0.55));
90
+ const key = new THREE.DirectionalLight(0xffffff, 1.6);
91
+ key.position.set(3.5, 7, 5);
92
+ key.castShadow = opts.shadow;
93
+ key.shadow.mapSize.set(2048, 2048);
94
+ key.shadow.camera.left = -4;
95
+ key.shadow.camera.right = 4;
96
+ key.shadow.camera.top = 6;
97
+ key.shadow.camera.bottom = -1;
98
+ key.shadow.radius = 6;
99
+ scene.add(key);
100
+ const rimL = new THREE.DirectionalLight(0xffffff, 2.2);
101
+ rimL.position.set(-6, 4, -2);
102
+ scene.add(rimL);
103
+ const rimR = new THREE.DirectionalLight(0xffffff, 1.8);
104
+ rimR.position.set(6, 4, -2.5);
105
+ scene.add(rimR);
106
+ if (opts.shadow) {
107
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(40, 40), new THREE.ShadowMaterial({ opacity: 0.16 }));
108
+ ground.rotation.x = -Math.PI / 2;
109
+ ground.receiveShadow = true;
110
+ scene.add(ground);
111
+ }
112
+ /* ------------------------------------------------------------ materials */
113
+ // Weave maps are painted in NORMALIZED tones (base→white) and darkened via
114
+ // material.color — so user-chosen colors actually show instead of being
115
+ // multiplied into black by a dark texture.
116
+ function makeCarbonTexture(size = 256, cell = 16, base = '#2c2c2c') {
117
+ const c = document.createElement('canvas');
118
+ c.width = c.height = size;
119
+ const g = c.getContext('2d');
120
+ g.fillStyle = base;
121
+ g.fillRect(0, 0, size, size);
122
+ for (let y = 0; y < size / cell; y++) {
123
+ for (let x = 0; x < size / cell; x++) {
124
+ const horiz = (x + y) % 2 === 0;
125
+ const grad = horiz
126
+ ? g.createLinearGradient(x * cell, 0, (x + 1) * cell, 0)
127
+ : g.createLinearGradient(0, y * cell, 0, (y + 1) * cell);
128
+ grad.addColorStop(0, base);
129
+ grad.addColorStop(0.5, '#ffffff');
130
+ grad.addColorStop(1, base);
131
+ g.fillStyle = grad;
132
+ g.fillRect(x * cell + 0.5, y * cell + 0.5, cell - 1, cell - 1);
133
+ }
134
+ }
135
+ const tex = new THREE.CanvasTexture(c);
136
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
137
+ tex.anisotropy = 8;
138
+ return tex;
139
+ }
140
+ const carbonTex = makeCarbonTexture(256, 16, '#2c2c2c'); // strong weave (arms)
141
+ const carbonTexSubtle = makeCarbonTexture(256, 16, '#6e6e72'); // barely-there weave (torso)
142
+ function boxUV(geometry, scale) {
143
+ if (geometry.userData.boxUV)
144
+ return;
145
+ geometry.userData.boxUV = true;
146
+ const pos = geometry.attributes.position, nor = geometry.attributes.normal;
147
+ const uv = new Float32Array(pos.count * 2);
148
+ for (let i = 0; i < pos.count; i++) {
149
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
150
+ const nx = Math.abs(nor.getX(i)), ny = Math.abs(nor.getY(i)), nz = Math.abs(nor.getZ(i));
151
+ let u, v;
152
+ if (nx >= ny && nx >= nz) {
153
+ u = z;
154
+ v = y;
155
+ }
156
+ else if (ny >= nx && ny >= nz) {
157
+ u = x;
158
+ v = z;
159
+ }
160
+ else {
161
+ u = x;
162
+ v = y;
163
+ }
164
+ uv[i * 2] = u * scale;
165
+ uv[i * 2 + 1] = v * scale;
166
+ }
167
+ geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
168
+ }
169
+ const materials = {
170
+ primary: new THREE.MeshPhysicalMaterial({ color: 0x17171c, roughness: 0.52, metalness: 0.25, clearcoat: 0.45, clearcoatRoughness: 0.4, envMapIntensity: 1.0, map: carbonTexSubtle, bumpMap: carbonTexSubtle, bumpScale: 0.25 }),
171
+ primaryPlain: new THREE.MeshPhysicalMaterial({ color: 0x0e0e12, roughness: 0.38, metalness: 0.35, clearcoat: 0.8, clearcoatRoughness: 0.25, envMapIntensity: 1.1 }),
172
+ carbon: new THREE.MeshPhysicalMaterial({ color: 0x2e2e36, roughness: 0.5, metalness: 0.25, clearcoat: 0.5, clearcoatRoughness: 0.3, envMapIntensity: 1.0, map: carbonTex, bumpMap: carbonTex, bumpScale: 0.8 }),
173
+ joint: new THREE.MeshStandardMaterial({ color: 0x08080a, roughness: 0.35, metalness: 0.9, envMapIntensity: 1.1 }),
174
+ visor: new THREE.MeshPhysicalMaterial({ color: 0xb0b0b8, roughness: 0.06, metalness: 1.0, envMapIntensity: 1.6 }),
175
+ chrome: new THREE.MeshPhysicalMaterial({ color: 0x77777e, roughness: 0.18, metalness: 1.0, envMapIntensity: 1.1 }),
176
+ };
177
+ /* ------------------------------------------------------ canvas textures */
178
+ const eyeCanvas = document.createElement('canvas');
179
+ eyeCanvas.width = 512;
180
+ eyeCanvas.height = 256;
181
+ const eyeTexture = new THREE.CanvasTexture(eyeCanvas);
182
+ eyeTexture.colorSpace = THREE.SRGBColorSpace;
183
+ let eyeColor = opts.eyes;
184
+ function drawEyes(color, squash = 0) {
185
+ eyeColor = color;
186
+ const g = eyeCanvas.getContext('2d');
187
+ g.clearRect(0, 0, 512, 256);
188
+ g.fillStyle = color;
189
+ // squash 0..1 collapses the dot rows (blink)
190
+ for (const cx of [102, 410]) {
191
+ for (let row = 0; row < 5; row++) {
192
+ for (let col = 0; col < 7; col++) {
193
+ const x = cx + (col - 3) * 16, y = 126 + (row - 2) * 16 * (1 - squash);
194
+ if (((col - 3) / 3.4) ** 2 + ((row - 2) / 2.6) ** 2 > 1)
195
+ continue;
196
+ g.beginPath();
197
+ g.arc(x, y, 6.5 * (1 - squash * 0.35), 0, Math.PI * 2);
198
+ g.fill();
199
+ }
200
+ }
201
+ }
202
+ eyeTexture.needsUpdate = true;
203
+ }
204
+ const logoCanvas = document.createElement('canvas');
205
+ logoCanvas.width = 512;
206
+ logoCanvas.height = 512;
207
+ const logoTexture = new THREE.CanvasTexture(logoCanvas);
208
+ logoTexture.colorSpace = THREE.SRGBColorSpace;
209
+ logoTexture.anisotropy = 8;
210
+ function drawLogoText(text, color) {
211
+ const g = logoCanvas.getContext('2d');
212
+ g.clearRect(0, 0, 512, 512);
213
+ if (text) {
214
+ g.fillStyle = color;
215
+ g.textAlign = 'center';
216
+ g.textBaseline = 'middle';
217
+ const words = String(text).toUpperCase().split(/\s+/).slice(0, 3);
218
+ let size = 110;
219
+ do {
220
+ g.font = `800 ${size}px -apple-system, Inter, sans-serif`;
221
+ size -= 6;
222
+ } while (words.some(w => g.measureText(w).width > 440) && size > 30);
223
+ const lh = size * 1.25, y0 = 256 - (words.length - 1) * lh / 2;
224
+ words.forEach((w, i) => g.fillText(w, 256, y0 + i * lh));
225
+ }
226
+ logoTexture.needsUpdate = true;
227
+ }
228
+ function drawLogoImage(img) {
229
+ const g = logoCanvas.getContext('2d');
230
+ g.clearRect(0, 0, 512, 512);
231
+ const s = Math.min(440 / img.width, 440 / img.height);
232
+ const w = img.width * s, h = img.height * s;
233
+ g.drawImage(img, 256 - w / 2, 256 - h / 2, w, h);
234
+ logoTexture.needsUpdate = true;
235
+ }
236
+ /* ----------------------------------------------------------- model rig */
237
+ const robot = new THREE.Group();
238
+ scene.add(robot);
239
+ let headPivot = null;
240
+ let waistPivot = null;
241
+ let bottomNode = null;
242
+ const armRigs = [];
243
+ function addDecal(target, position, size, material) {
244
+ target.updateWorldMatrix(true, false);
245
+ const geo = new DecalGeometry(target, position, new THREE.Euler(), size);
246
+ geo.applyMatrix4(new THREE.Matrix4().copy(target.matrixWorld).invert());
247
+ const m = new THREE.Mesh(geo, material);
248
+ m.renderOrder = 1;
249
+ target.add(m);
250
+ return m;
251
+ }
252
+ // `getPoint` is evaluated only after world matrices are refreshed —
253
+ // a precomputed point can be measured against stale matrices.
254
+ function pivotAt(node, getPoint) {
255
+ const parent = node.parent;
256
+ parent.updateWorldMatrix(true, true);
257
+ const point = getPoint();
258
+ const pivot = new THREE.Group();
259
+ parent.add(pivot);
260
+ pivot.position.copy(parent.worldToLocal(point.clone()));
261
+ pivot.updateWorldMatrix(true, false);
262
+ pivot.attach(node);
263
+ return pivot;
264
+ }
265
+ // Pivot whose local Y axis is aligned to a world-space direction, so
266
+ // rotation.y twists `node` axially (e.g. forearm pronation). Outer group
267
+ // carries the alignment; the returned inner group is safe to animate.
268
+ function axialPivotAt(node, getPoint, getAxis) {
269
+ const parent = node.parent;
270
+ parent.updateWorldMatrix(true, true);
271
+ const point = getPoint();
272
+ const axis = getAxis();
273
+ const align = new THREE.Group();
274
+ parent.add(align);
275
+ align.position.copy(parent.worldToLocal(point.clone()));
276
+ const inv = new THREE.Matrix4().copy(parent.matrixWorld).invert();
277
+ const axisLocal = axis.clone().transformDirection(inv);
278
+ align.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axisLocal);
279
+ align.updateWorldMatrix(true, false);
280
+ const pivot = new THREE.Group();
281
+ align.add(pivot);
282
+ pivot.updateWorldMatrix(true, false);
283
+ pivot.attach(node);
284
+ return pivot;
285
+ }
286
+ const bboxOf = (n) => new THREE.Box3().setFromObject(n);
287
+ const centerOf = (n) => bboxOf(n).getCenter(new THREE.Vector3());
288
+ const topOf = (n) => { const b = bboxOf(n); return new THREE.Vector3((b.min.x + b.max.x) / 2, b.max.y, (b.min.z + b.max.z) / 2); };
289
+ const bottomOf = (n) => { const b = bboxOf(n); return new THREE.Vector3((b.min.x + b.max.x) / 2, b.min.y, (b.min.z + b.max.z) / 2); };
290
+ // One arm is a mirrored instance, which flips the sense of some rotations;
291
+ // probe each axis once and store the sign that moves the hand as intended.
292
+ function calibrateRig(rig) {
293
+ const center = (setup) => {
294
+ rig.shoulder.rotation.set(0, 0, 0);
295
+ if (rig.elbow)
296
+ rig.elbow.rotation.set(0, 0, 0);
297
+ if (setup)
298
+ setup();
299
+ rig.shoulder.updateWorldMatrix(true, true);
300
+ const c = bboxOf(rig.handRef).getCenter(new THREE.Vector3());
301
+ rig.shoulder.rotation.set(0, 0, 0);
302
+ if (rig.elbow)
303
+ rig.elbow.rotation.set(0, 0, 0);
304
+ rig.shoulder.updateWorldMatrix(true, true);
305
+ return c;
306
+ };
307
+ const base = center();
308
+ const out = center(() => { rig.shoulder.rotation.z = 0.4; });
309
+ rig.outSign = (out.x - base.x) * rig.side > 0 ? 1 : -1;
310
+ const raise = center(() => { rig.shoulder.rotation.x = -0.4; });
311
+ rig.raiseSign = (raise.z - base.z) + (raise.y - base.y) > 0 ? -1 : 1;
312
+ if (rig.elbow) {
313
+ const bend = center(() => { rig.elbow.rotation.x = -0.6; });
314
+ rig.bendSign = (bend.z - base.z) + (bend.y - base.y) > 0 ? -1 : 1;
315
+ const bent = center(() => { rig.elbow.rotation.x = rig.bendSign * 0.9; });
316
+ const splay = center(() => { rig.elbow.rotation.x = rig.bendSign * 0.9; rig.elbow.rotation.y = 0.4; });
317
+ rig.splaySign = (splay.x - bent.x) * rig.side > 0 ? 1 : -1;
318
+ }
319
+ }
320
+ function setupModel(gltf) {
321
+ const bot = gltf.scene.getObjectByName('Bot') || gltf.scene;
322
+ const junk = [];
323
+ gltf.scene.traverse(o => { if (o.isLight || o.isCamera)
324
+ junk.push(o); });
325
+ junk.forEach(o => o.removeFromParent());
326
+ let box = new THREE.Box3().setFromObject(bot);
327
+ bot.scale.multiplyScalar(4.6 / box.getSize(new THREE.Vector3()).y);
328
+ robot.add(bot);
329
+ box = new THREE.Box3().setFromObject(bot);
330
+ const c = box.getCenter(new THREE.Vector3());
331
+ bot.position.x -= c.x;
332
+ bot.position.z -= c.z;
333
+ bot.position.y -= box.min.y;
334
+ bot.traverse(o => {
335
+ const m = o;
336
+ if (!m.isMesh)
337
+ return;
338
+ m.castShadow = true;
339
+ m.material = /Cylinder|Cube/.test(m.name) ? materials.joint : materials.primaryPlain;
340
+ });
341
+ const headMesh = bot.getObjectByName('Head_2'); // loader swaps spaces for underscores
342
+ if (headMesh)
343
+ headMesh.material = materials.visor;
344
+ const bodyMesh = bot.getObjectByName('Body');
345
+ if (bodyMesh) {
346
+ boxUV(bodyMesh.geometry, 0.035);
347
+ bodyMesh.material = materials.primary;
348
+ }
349
+ const headNode = bot.getObjectByName('Head') || headMesh;
350
+ if (headNode)
351
+ headPivot = pivotAt(headNode, () => centerOf(headNode));
352
+ const topPart = bot.getObjectByName('Top_part');
353
+ if (topPart)
354
+ waistPivot = pivotAt(topPart, () => bottomOf(topPart));
355
+ bottomNode = bot.getObjectByName('Bottom') || null; // legs + pelvis
356
+ if (bottomNode)
357
+ bottomNode.visible = state.legs;
358
+ // two arm assemblies; the loader dedupes the second's names with suffixes
359
+ const armRoots = [];
360
+ bot.traverse(o => { if (/^Hand_LEFT(_\d+)?$/.test(o.name))
361
+ armRoots.push(o); });
362
+ armRoots.forEach(root => {
363
+ let arm, forearm, elbowNode;
364
+ root.traverse(o => {
365
+ if (!arm && /^arm(_\d+)?$/.test(o.name))
366
+ arm = o;
367
+ if (!forearm && /^forearm(_\d+)?$/.test(o.name))
368
+ forearm = o;
369
+ if (!elbowNode && /^elbow(_\d+)?$/.test(o.name))
370
+ elbowNode = o;
371
+ });
372
+ if (!arm)
373
+ return;
374
+ let shoulderJoint = null;
375
+ arm.traverse(o => { if (!shoulderJoint && /^Cube_2(_\d+)?$/.test(o.name))
376
+ shoulderJoint = o; });
377
+ const elbowRing = elbowNode && elbowNode.children.find(ch => /^Group(_\d+)?$/.test(ch.name));
378
+ const armNode = arm;
379
+ const shoulder = pivotAt(armNode, () => shoulderJoint ? centerOf(shoulderJoint) : topOf(armNode));
380
+ const forearmNode = forearm;
381
+ const elbow = forearmNode ? pivotAt(forearmNode, () => elbowRing ? centerOf(elbowRing) : topOf(forearmNode)) : null;
382
+ const side = Math.sign(shoulder.getWorldPosition(new THREE.Vector3()).x) || 1;
383
+ // pronation pivot: twist the whole forearm (hand included) around its
384
+ // own long axis — palm rotation with nothing to detach
385
+ let handMesh = null;
386
+ (forearmNode || root).traverse(o => { if (!handMesh && o.isMesh && /^Hand(_\d+)?$/.test(o.name))
387
+ handMesh = o; });
388
+ let twist = null;
389
+ if (forearmNode && handMesh) {
390
+ const hand = handMesh;
391
+ const elbowPoint = () => elbowRing ? centerOf(elbowRing) : topOf(forearmNode);
392
+ twist = axialPivotAt(forearmNode, elbowPoint, () => centerOf(hand).sub(elbowPoint()).normalize());
393
+ }
394
+ const rig = { shoulder, elbow, twist, handRef: forearmNode || armNode, side, raiseSign: -1, outSign: 1, bendSign: -1, splaySign: 1 };
395
+ calibrateRig(rig);
396
+ armRigs.push(rig);
397
+ root.traverse(o => {
398
+ const m = o;
399
+ if (!m.isMesh)
400
+ return;
401
+ if (/^Hand(_\d+)?$/.test(m.name)) {
402
+ m.material = materials.chrome;
403
+ return;
404
+ }
405
+ if (/^(Rectangle_3|Ellipse_3)(_\d+)?$/.test(m.name)) {
406
+ m.material = materials.joint;
407
+ return;
408
+ }
409
+ boxUV(m.geometry, 0.045);
410
+ m.material = materials.carbon;
411
+ });
412
+ });
413
+ if (headMesh) {
414
+ const hb = bboxOf(headMesh), hc = hb.getCenter(new THREE.Vector3()), hs = hb.getSize(new THREE.Vector3());
415
+ addDecal(headMesh, new THREE.Vector3(hc.x, hc.y + hs.y * 0.06, hb.max.z), new THREE.Vector3(hs.x * 0.80, hs.x * 0.40, hs.z * 0.45), new THREE.MeshBasicMaterial({ map: eyeTexture, transparent: true, toneMapped: false,
416
+ polygonOffset: true, polygonOffsetFactor: -4, depthWrite: false }));
417
+ }
418
+ if (bodyMesh) {
419
+ const bb = bboxOf(bodyMesh), bc = bb.getCenter(new THREE.Vector3()), bs = bb.getSize(new THREE.Vector3());
420
+ addDecal(bodyMesh, new THREE.Vector3(bc.x, bc.y + bs.y * 0.12, bb.max.z), new THREE.Vector3(bs.x * 0.55, bs.x * 0.55, bs.z * 0.5), new THREE.MeshStandardMaterial({ map: logoTexture, transparent: true, roughness: 0.5, metalness: 0.1,
421
+ polygonOffset: true, polygonOffsetFactor: -4, depthWrite: false }));
422
+ }
423
+ // model is in — now play the zoom-in entrance
424
+ if (opts.intro) {
425
+ introState.active = true;
426
+ introState.t = 0;
427
+ }
428
+ }
429
+ const loader = new GLTFLoader();
430
+ if (opts.gltf)
431
+ loader.parse(JSON.stringify(opts.gltf), '', setupModel, (err) => console.error('BrandBot: parse failed', err));
432
+ else if (opts.modelUrl)
433
+ loader.load(opts.modelUrl, setupModel, undefined, (err) => console.error('BrandBot: load failed', err));
434
+ else
435
+ console.error('BrandBot: pass `gltf` (JSON) or `modelUrl` in options');
436
+ /* -------------------------------------------------------------- pointer */
437
+ // store raw client coords; the head-aim maps them into the canvas's own
438
+ // normalized space each frame, so "cursor on the face" means looking
439
+ // straight ahead regardless of where the canvas sits on the page
440
+ const pointer = { clientX: 0, clientY: 0, active: false, last: 0 };
441
+ function onPointerMove(e) {
442
+ pointer.clientX = e.clientX;
443
+ pointer.clientY = e.clientY;
444
+ pointer.active = true;
445
+ pointer.last = Date.now();
446
+ }
447
+ function onLeave() { pointer.active = false; }
448
+ const pointerTarget = opts.trackPointer === 'element' ? container : window;
449
+ if (opts.trackPointer) {
450
+ pointerTarget.addEventListener('pointermove', onPointerMove);
451
+ document.addEventListener('mouseleave', onLeave);
452
+ }
453
+ /* ------------------------------------------------------------ animation */
454
+ const GESTURES = [
455
+ { raise: 0.03, out: 0.20, bend: 0.65, splay: 0.15, twist: 0 }, // shrug (home)
456
+ { raise: 0.04, out: 0.22, bend: 0.78, splay: 0.12, twist: 0.15 }, // shrug, hands high
457
+ { raise: 0.02, out: 0.26, bend: 0.50, splay: 0.22, twist: 0.35 }, // open wide
458
+ { raise: -0.08, out: 0.02, bend: -0.60, splay: 0.02, twist: 0.25 }, // arms fully down at the sides
459
+ { raise: 0, out: 0.30, bend: 0.05, splay: 0.28, twist: 0.45 }, // down + open
460
+ { raise: 0, out: 0.12, bend: 0.25, splay: 0.10, twist: 0.2 }, // hands forward, low
461
+ { raise: -0.04, out: 0.22, bend: -0.30, splay: 0.06, twist: 1.15 }, // arms out, palms to the sides
462
+ ];
463
+ const LOW_POSES = [2, 3, 4, 5, 6];
464
+ const gesture = { cur: GESTURES[0], t: 5 };
465
+ function nextGesture() {
466
+ const ids = pointer.active ? LOW_POSES : GESTURES.map((_, i) => i);
467
+ const pool = ids.map(i => GESTURES[i]).filter(g => g !== gesture.cur);
468
+ return pool[Math.floor(Math.random() * pool.length)];
469
+ }
470
+ const clock = new THREE.Clock();
471
+ const _headNdc = new THREE.Vector3();
472
+ let waistFollow = 0;
473
+ const blink = { active: false, t0: 0, next: 3.5 };
474
+ const spinState = { active: false, t: 0, dur: 1.1 };
475
+ const easeInOut = (x) => x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
476
+ // the head snaps toward the cursor fast, but a per-frame angular cap keeps a
477
+ // big jump (cursor leaving and re-entering on the far side) from teleporting
478
+ const MAX_HEAD_SPEED = 11; // rad/s
479
+ const stepAngle = (cur, target, dt) => {
480
+ const next = THREE.MathUtils.damp(cur, target, 24, dt);
481
+ const cap = MAX_HEAD_SPEED * dt;
482
+ return cur + THREE.MathUtils.clamp(next - cur, -cap, cap);
483
+ };
484
+ let raf = 0, disposed = false;
485
+ function tick() {
486
+ if (disposed)
487
+ return;
488
+ raf = requestAnimationFrame(tick);
489
+ // clamp dt so a long frame (e.g. the model parse blocking the thread, or a
490
+ // backgrounded tab) can't jump the intro past its face-zoom in one step
491
+ const dt = Math.min(clock.getDelta(), 0.05);
492
+ const t = clock.elapsedTime;
493
+ robot.position.y = 0; // locked vertically — no float/bounce
494
+ // occasional blink: rows collapse and reopen over ~0.22s
495
+ if (!blink.active && t > blink.next) {
496
+ blink.active = true;
497
+ blink.t0 = t;
498
+ }
499
+ if (blink.active) {
500
+ const p = (t - blink.t0) / 0.22;
501
+ if (p >= 1) {
502
+ blink.active = false;
503
+ blink.next = t + 2.5 + Math.random() * 4.5;
504
+ drawEyes(eyeColor, 0);
505
+ }
506
+ else {
507
+ drawEyes(eyeColor, Math.sin(Math.PI * p));
508
+ }
509
+ }
510
+ // map the cursor into the canvas's own NDC (origin = the rendered robot),
511
+ // so the aim is measured against where the robot actually is on screen
512
+ let px = 0, py = 0;
513
+ if (pointer.active) {
514
+ const r = renderer.domElement.getBoundingClientRect();
515
+ px = ((pointer.clientX - r.left) / (r.width || 1)) * 2 - 1;
516
+ py = ((pointer.clientY - r.top) / (r.height || 1)) * 2 - 1;
517
+ }
518
+ if (headPivot) {
519
+ // aim relative to the head's own screen position: cursor on the face
520
+ // means looking straight ahead. Near-instant, jitter-smoothed only.
521
+ let lookY, lookX;
522
+ if (pointer.active) {
523
+ headPivot.getWorldPosition(_headNdc).project(camera);
524
+ const hx = _headNdc.x, hy = -_headNdc.y;
525
+ // tanh: steep near the head, saturating toward the screen edges
526
+ lookY = Math.tanh((px - hx) * 3.2) * 0.75;
527
+ lookX = THREE.MathUtils.clamp(Math.tanh((py - hy) * 3.0) * 0.45 + 0.06, -0.32, 0.5);
528
+ }
529
+ else {
530
+ lookY = Math.sin(t * 0.5) * 0.14;
531
+ lookX = Math.sin(t * 0.8) * 0.05 + 0.04;
532
+ }
533
+ headPivot.rotation.y = stepAngle(headPivot.rotation.y, lookY, dt);
534
+ headPivot.rotation.x = stepAngle(headPivot.rotation.x, lookX, dt);
535
+ }
536
+ // the base stays planted: tiny gaze pitch, a hint of turn, nothing more
537
+ const headPitchNow = headPivot ? headPivot.rotation.x : 0;
538
+ if (waistPivot) {
539
+ waistFollow = THREE.MathUtils.damp(waistFollow, pointer.active ? px * 0.025 : 0, 14, dt);
540
+ waistPivot.rotation.y = waistFollow + Math.sin(t * 0.45) * 0.018 + Math.sin(t * 0.21 + 1.3) * 0.01;
541
+ waistPivot.rotation.z = Math.sin(t * 0.6 + 0.7) * 0.008;
542
+ // no breathing pitch (that read as a vertical bob); only follow the gaze
543
+ waistPivot.rotation.x = headPitchNow * 0.12;
544
+ }
545
+ gesture.t -= dt;
546
+ if (gesture.t <= 0) {
547
+ gesture.t = 5 + Math.random() * 6;
548
+ gesture.cur = nextGesture();
549
+ }
550
+ // both arms always mirror exactly; low damping = chill drifting moves
551
+ const swayRaise = Math.sin(t * 0.5) * 0.03;
552
+ const swayOut = Math.sin(t * 0.4 + 1) * 0.02;
553
+ const swayBend = Math.sin(t * 0.7 + 0.6) * 0.05;
554
+ // shoulders also swing slightly with the head's pitch
555
+ const headPitch = headPitchNow;
556
+ for (const rig of armRigs) {
557
+ const g = gesture.cur;
558
+ const d = (cur, target) => THREE.MathUtils.damp(cur, target, 1.2, dt);
559
+ rig.shoulder.rotation.x = d(rig.shoulder.rotation.x, rig.raiseSign * (g.raise + swayRaise + headPitch * 0.22));
560
+ rig.shoulder.rotation.y = d(rig.shoulder.rotation.y, 0);
561
+ rig.shoulder.rotation.z = d(rig.shoulder.rotation.z, rig.outSign * (g.out + swayOut));
562
+ if (rig.elbow) {
563
+ rig.elbow.rotation.x = d(rig.elbow.rotation.x, rig.bendSign * (g.bend + swayBend));
564
+ rig.elbow.rotation.y = d(rig.elbow.rotation.y, rig.splaySign * g.splay);
565
+ }
566
+ // forearm pronation: per-pose palm twist plus a gentle ever-turning
567
+ // sway. Same value for both arms — the mirrored instance flips it
568
+ // visually, which is exactly what keeps the palms symmetric.
569
+ if (rig.twist) {
570
+ const tw = (g.twist || 0) + Math.sin(t * 0.45 + 1.7) * 0.10 + Math.sin(t * 1.1 + 0.4) * 0.05;
571
+ rig.twist.rotation.y = THREE.MathUtils.damp(rig.twist.rotation.y, tw, 1.2, dt);
572
+ }
573
+ }
574
+ if (spinState.active) {
575
+ spinState.t += dt;
576
+ const k = Math.min(spinState.t / spinState.dur, 1);
577
+ robot.rotation.y = easeInOut(k) * Math.PI * 2;
578
+ if (k >= 1) {
579
+ spinState.active = false;
580
+ robot.rotation.y = 0;
581
+ }
582
+ }
583
+ else {
584
+ // base + legs fully static — no whole-body lean or sway. The upper body
585
+ // follows the cursor via the waist pivot instead, so the legs never move.
586
+ robot.rotation.y = 0;
587
+ }
588
+ // one-time zoom-in entrance; hands control to OrbitControls when finished
589
+ if (introState.active) {
590
+ introState.t += dt;
591
+ const k = Math.min(introState.t / introState.dur, 1);
592
+ // easeInOutQuint: a long, slow hold on the face, then accelerates and settles
593
+ const e = k < 0.5 ? 16 * k ** 5 : 1 - Math.pow(-2 * k + 2, 5) / 2;
594
+ camera.position.lerpVectors(introFromPos, camEnd, e);
595
+ _introLook.lerpVectors(introFromLook, camTarget, e);
596
+ camera.lookAt(_introLook);
597
+ if (k >= 1) {
598
+ introState.active = false;
599
+ if (controls)
600
+ controls.enabled = true;
601
+ }
602
+ }
603
+ else if (controls) {
604
+ controls.update();
605
+ }
606
+ renderer.render(scene, camera);
607
+ }
608
+ /* --------------------------------------------------------------- sizing */
609
+ function resize() {
610
+ const w = container.clientWidth || 1, h = container.clientHeight || 1;
611
+ camera.aspect = w / h;
612
+ camera.updateProjectionMatrix();
613
+ renderer.setSize(w, h, false);
614
+ }
615
+ resize();
616
+ const observer = new ResizeObserver(resize);
617
+ observer.observe(container);
618
+ /* ------------------------------------------------------------------ API */
619
+ const state = { logoText: opts.logoText, logoColor: opts.logoColor, legs: opts.legs !== false };
620
+ function set(o = {}) {
621
+ if ('legs' in o && o.legs !== undefined) {
622
+ state.legs = !!o.legs;
623
+ if (bottomNode)
624
+ bottomNode.visible = state.legs;
625
+ }
626
+ if (o.primary) {
627
+ materials.primary.color.set(o.primary);
628
+ materials.primaryPlain.color.set(o.primary).multiplyScalar(0.65);
629
+ materials.carbon.color.set(o.primary).multiplyScalar(2.0);
630
+ }
631
+ if (o.accent)
632
+ materials.joint.color.set(o.accent);
633
+ if (o.visor)
634
+ materials.visor.color.set(o.visor);
635
+ if (o.hands)
636
+ materials.chrome.color.set(o.hands);
637
+ if (o.eyes)
638
+ drawEyes(o.eyes);
639
+ if (o.logoColor) {
640
+ state.logoColor = o.logoColor;
641
+ drawLogoText(state.logoText, state.logoColor);
642
+ }
643
+ if ('logoText' in o && o.logoText !== undefined) {
644
+ state.logoText = o.logoText;
645
+ drawLogoText(o.logoText, state.logoColor);
646
+ }
647
+ if (o.logoImage) {
648
+ const img = new Image();
649
+ img.crossOrigin = 'anonymous';
650
+ img.onload = () => drawLogoImage(img);
651
+ img.src = o.logoImage;
652
+ }
653
+ return api;
654
+ }
655
+ function dispose() {
656
+ disposed = true;
657
+ cancelAnimationFrame(raf);
658
+ observer.disconnect();
659
+ if (controls)
660
+ controls.dispose();
661
+ if (opts.trackPointer) {
662
+ pointerTarget.removeEventListener('pointermove', onPointerMove);
663
+ document.removeEventListener('mouseleave', onLeave);
664
+ }
665
+ renderer.dispose();
666
+ pmrem.dispose();
667
+ renderer.domElement.remove();
668
+ }
669
+ const api = {
670
+ set,
671
+ spin: () => { spinState.active = true; spinState.t = 0; },
672
+ replay: () => { introState.active = true; introState.t = 0; if (controls)
673
+ controls.enabled = false; },
674
+ dispose, scene, camera, renderer,
675
+ };
676
+ drawEyes(opts.eyes);
677
+ drawLogoText(opts.logoText, opts.logoColor);
678
+ set(opts);
679
+ tick();
680
+ return api;
681
+ }