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.
- package/LICENSE +42 -0
- package/README.md +137 -0
- package/dist/BrandBot.d.ts +40 -0
- package/dist/BrandBot.js +53 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/nexbot-model.d.ts +3 -0
- package/dist/nexbot-model.js +8692 -0
- package/dist/robot-core.d.ts +2 -0
- package/dist/robot-core.js +681 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/package.json +39 -0
- package/preview.png +0 -0
|
@@ -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
|
+
}
|