aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/props.js
blob: a5fcbcc057d6cd465e0cedb41d5560d320626f4f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
import * as THREE from 'three';

/**
 * Procedural 小物 (props) that can be placed on the stage next to the character:
 * a lectern, a desk, a microphone stand, a signboard, a potted plant and a
 * cardboard box.
 *
 * Everything is built from three's primitives instead of loaded from a file, for
 * three reasons. The character is about 4.15 units tall with its feet on y = 0
 * and its body a sphere of radius ~1.6 centred near y = 2.4, so every generator
 * below works from that scale, and every group's origin is its *base centre* -
 * dropping one at a ground position and rotating it about y is all the app has
 * to do. The studio also re-skins props with the same toon/flat materials and
 * the same inverted-hull outline as the body, so the geometry stays chunky: no
 * plate thinner than ~0.05 units (a thin plate's offset hull turns into a smear)
 * and no textures anywhere. And props must be identical on every run, so every
 * size, position and angle here is a literal - no Math.random().
 *
 * Colours are never hard-coded on a mesh. Each material is tagged with
 * `userData.part`, and `applyPropColors` fills the colour in from the app's
 * render.colors palette, which is what lets the colour-theme feature recolour a
 * prop that was built minutes ago. The mapping keeps lightness apart from hue,
 * so a prop still reads in the near-monochrome すみ theme:
 *
 *   wood    the body colour, darkened          - painted wood, cardboard, soil
 *   paper   the body colour, nearly white      - the blank sign face
 *   accent  the accent colour                  - legs, frames, pots, mic bodies
 *   leaf    the leaf colour                    - foliage
 *   metal   a fixed neutral grey               - stands, booms, microphone heads
 *
 * `userData.shade` multiplies a part's colour, so one prop can use two tones of
 * the same part - a cardboard box and its darker inner flaps - without adding a
 * sixth palette entry.
 */

/** Reused when something has to be aimed along a direction vector. */
const UP = new THREE.Vector3(0, 1, 0);
const WHITE = new THREE.Color(0xffffff);
/** A fixed grey: a metal stand has to read in every theme, warm or grey. */
const METAL_GREY = '#b9bec7';
/** Enough of the palette to build with when the app has not sent one yet. */
const FALLBACK_COLORS = { body: '#c8b0f0', accent: '#8a4fe0', leaf: '#a6dd6a' };

const PART_NAMES = ['wood', 'metal', 'paper', 'accent', 'leaf'];

const clamp01 = (value) => Math.min(1, Math.max(0, value));

/** The lectern's reading surface tilts up towards the audience, i.e. towards +z. */
const SLAB_TILT = -0.32;

// ---------------------------------------------------------------------------
// Building blocks
// ---------------------------------------------------------------------------

/**
 * A material per (part, shade) inside one prop. Sharing them keeps the draw
 * calls down and, more importantly, lets `disposeProp` release each one once.
 */
function materialCache() {
  const cache = new Map();
  return (part, shade = 1) => {
    if (!PART_NAMES.includes(part)) throw new Error(`unknown prop material part: ${part}`);
    const key = `${part}:${shade}`;
    let material = cache.get(key);
    if (!material) {
      const metal = part === 'metal';
      material = new THREE.MeshStandardMaterial({
        color: 0xffffff,          // filled in by applyPropColors
        roughness: 0.75,
        metalness: metal ? 0.6 : 0,
      });
      material.userData.part = part;
      material.userData.shade = shade;
      cache.set(key, material);
    }
    return material;
  };
}

/**
 * Wraps a generator so each prop gets its own material cache. The generator
 * receives `(group, material, options)`, fills the group and returns nothing.
 */
function prop(generator) {
  return (options = {}) => {
    const group = new THREE.Group();
    generator(group, materialCache(), options);
    return group;
  };
}

/** Adds a mesh, matching the body's shadow flags, and returns it. */
function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) {
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(x, y, z);
  mesh.castShadow = true;
  mesh.receiveShadow = false;
  parent.add(mesh);
  return mesh;
}

/** A cuboid, sized as [width, height, depth] and centred on `at`. */
function addBox(parent, material, size, at = [0, 0, 0]) {
  const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
  return addMesh(parent, geometry, material, at[0], at[1], at[2]);
}

/** A cylinder (or cone, if the two radii differ) centred on `at`. */
function addCylinder(parent, material, radiusTop, radiusBottom, height, at, segments = 12) {
  const geometry = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments);
  return addMesh(parent, geometry, material, at[0], at[1], at[2]);
}

/** A rounded blob. The caller usually scales it into a leaf or an ellipsoid. */
function addSphere(parent, material, radius, at, segments = [10, 8]) {
  const geometry = new THREE.SphereGeometry(radius, segments[0], segments[1]);
  return addMesh(parent, geometry, material, at[0], at[1], at[2]);
}

/** A rod from `from` to `to`: stems, booms and struts are all just this. */
function addStrut(parent, material, from, to, radius, segments = 8) {
  const start = new THREE.Vector3(from[0], from[1], from[2]);
  const end = new THREE.Vector3(to[0], to[1], to[2]);
  const axis = end.clone().sub(start);
  const length = axis.length();
  const mesh = addMesh(
    parent,
    new THREE.CylinderGeometry(radius, radius, length, segments),
    material,
    (start.x + end.x) / 2,
    (start.y + end.y) / 2,
    (start.z + end.z) / 2,
  );
  mesh.quaternion.setFromUnitVectors(UP, axis.normalize());
  return mesh;
}

// ---------------------------------------------------------------------------
// The props
// ---------------------------------------------------------------------------

/**
 * 演台. A foot plate, a pedestal, a slab that tilts up towards the audience and
 * a gooseneck microphone on the corner nearest the camera. The mic is on the
 * audience edge on purpose: on the far edge the slab would hide it from a
 * slightly-above camera.
 */
const buildPodium = prop((group, material) => {
  addBox(group, material('accent'), [1.30, 0.10, 0.98], [0, 0.05, 0]);
  addBox(group, material('wood'), [1.10, 1.30, 0.78], [0, 0.75, 0]);

  const slab = addBox(group, material('wood'), [1.42, 0.12, 0.86], [0, 1.42, 0.10]);
  slab.rotation.x = SLAB_TILT;
  // A lip along the slab's low edge, so the silhouette reads as a lectern top.
  const lip = addBox(group, material('accent'), [1.02, 0.10, 0.10], [0, 1.498, 0.527]);
  lip.rotation.x = SLAB_TILT;

  const stem = addCylinder(group, material('metal'), 0.032, 0.032, 0.60, [-0.48, 1.80, 0.26], 8);
  stem.rotation.x = SLAB_TILT;
  const head = addSphere(group, material('metal'), 0.09, [-0.48, 2.11, 0.15], [10, 8]);
  head.scale.set(1, 1.25, 1);
});

/**
 * 机. A top, four legs and a pair of side rails. The legs and rails take the
 * darker `accent` tone so the table keeps its shape in a flat or greyscale
 * render, where a single flat colour would turn it into a silhouette.
 */
const buildDesk = prop((group, material) => {
  addBox(group, material('wood'), [2.30, 0.12, 1.20], [0, 1.44, 0]);
  for (const x of [-1.00, 1.00]) {
    for (const z of [-0.46, 0.46]) {
      addBox(group, material('accent'), [0.14, 1.38, 0.14], [x, 0.69, z]);
    }
    addBox(group, material('accent'), [0.12, 0.10, 1.00], [x, 0.42, 0]);
  }
});

/**
 * マイク. A round weighted base, a pole, a short boom and a body with a grille
 * head, standing about as high as the character's hands.
 */
const buildMic = prop((group, material) => {
  addCylinder(group, material('metal'), 0.40, 0.42, 0.10, [0, 0.05, 0], 16);
  addCylinder(group, material('metal'), 0.048, 0.058, 1.35, [0, 0.775, 0], 10);
  addStrut(group, material('metal'), [0, 1.42, 0], [0, 1.62, 0.28], 0.038);

  const body = addCylinder(group, material('accent'), 0.13, 0.13, 0.44, [0, 1.80, 0.36], 12);
  body.rotation.x = 0.30;
  const head = addSphere(group, material('metal'), 0.145, [0, 2.05, 0.42], [12, 8]);
  head.scale.set(1, 0.9, 1);
});

/**
 * 看板. A post, a frame and a blank face. The face is a named child tagged
 * `userData.canvasTexture`, so a later feature can paint a CanvasTexture onto it
 * without hunting for the writable mesh. The face *and* its frame are grouped,
 * so `applyPropFaceScale` can grow the whole board about the face's centre while
 * the post and base stay put. Both are thin boxes rather than planes: the outline
 * pass offsets geometry along its normals, and a zero-thickness plane gets a
 * visibly doubled rim.
 */
const buildSign = prop((group, material) => {
  addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]);
  addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]);

  // The board is one group so the face and its frame scale together, about the
  // board's own centre - which is also the face's centre.
  const board = new THREE.Group();
  board.name = 'sign-board';
  board.userData.faceScaleGroup = true;
  board.position.set(0, 2.21, 0);
  group.add(board);

  addBox(board, material('accent'), [1.42, 0.98, 0.12], [0, 0, 0]);
  const face = addBox(board, material('paper'), [1.18, 0.76, 0.08], [0, 0, 0.06]);
  face.name = 'sign-face';
  face.userData.canvasTexture = true;
});

/** The branches of 観葉植物: where a stem ends, which way its leaf points. */
const PLANT_BRANCHES = [
  { end: [0.05, 1.12, 0.04], dir: [0.10, 0.96, 0.14] },
  { end: [-0.30, 0.98, -0.06], dir: [-0.52, 0.76, -0.30] },
  { end: [0.28, 1.00, 0.14], dir: [0.48, 0.74, 0.40] },
  { end: [-0.40, 0.80, -0.16], dir: [-0.86, 0.36, -0.28] },
  { end: [0.38, 0.82, 0.16], dir: [0.88, 0.32, 0.26] },
  { end: [-0.04, 1.06, -0.20], dir: [-0.14, 0.95, -0.28] },
];

/**
 * 観葉植物. A tapered pot with a rim, dark soil, and leaves that are just
 * squashed spheres aimed along each branch's direction - the cheapest way to get
 * a soft, rounded leaf that still takes a clean outline.
 */
const buildPlant = prop((group, material) => {
  addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14);
  addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14);
  addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14);

  for (const branch of PLANT_BRANCHES) {
    addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035);

    const direction = new THREE.Vector3(branch.dir[0], branch.dir[1], branch.dir[2]).normalize();
    const leaf = addSphere(group, material('leaf'), 0.30, [0, 0, 0], [8, 6]);
    leaf.scale.set(0.46, 1, 0.22);
    leaf.position.set(branch.end[0], branch.end[1], branch.end[2]).addScaledVector(direction, 0.26);
    leaf.quaternion.setFromUnitVectors(UP, direction);
  }
});

/**
 * 段ボール箱. The body and two lid flaps, plus a tape strip when the lid is
 * shut. The flaps are children of pivot groups sitting on the box's top edges,
 * so `open: true` is a single rotation each. The flaps are a darker shade of the
 * same wood part, which is what makes the box read as cardboard rather than a
 * solid block.
 */
const buildBox = prop((group, material, options) => {
  const flapsDown = options.open !== true;
  addBox(group, material('wood'), [1.24, 1.00, 1.24], [0, 0.50, 0]);

  const back = new THREE.Group();
  back.position.set(0, 1.00, -0.62);
  back.rotation.x = flapsDown ? 0 : -1.05;
  group.add(back);
  addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]);

  const front = new THREE.Group();
  front.position.set(0, 1.00, 0.62);
  front.rotation.x = flapsDown ? 0 : 1.05;
  group.add(front);
  addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]);

  if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]);
});

/**
 * 空いた缶詰. A short metal can, opened: a foot bead, the body, a label band and
 * a mouth rim, plus the single lid peeled back on a hinge at the back of the
 * mouth so it stands up behind the opening. It is deliberately
 * small next to the 4-unit-tall character - something to be pointed at rather
 * than stood behind - so every size here stays under a unit, and the whole can
 * leans a little so it does not read as a diagram. The label is the one coloured
 * part; everything else is the neutral `metal` grey, so it still reads as a tin.
 */
const buildCan = prop((group, material) => {
  addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16);
  addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16);
  addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16);
  addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16);
  // A darker disc just inside the rim so the mouth reads as empty, not capped.
  addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16);

  // The one lid, peeled back on a hinge at the back of the mouth so its
  // underside shows. The hinge sits *on* the can's back rim and the disc is
  // placed so its own rim passes through that same point (its centre is one
  // radius in front of the hinge), so opening the hinge rotates the lid about
  // its edge - the two rims stay joined instead of the lid floating in mid-air.
  // It is twisted slightly sideways so it does not read as a knob sitting
  // straight up on the can.
  const lidRadius = 0.132;
  const hinge = new THREE.Group();
  hinge.position.set(0, 0.42, -lidRadius);
  hinge.rotation.x = -1.05;
  hinge.rotation.z = 0.4;
  group.add(hinge);
  addCylinder(hinge, material('metal', 0.9), lidRadius, lidRadius, 0.018, [0, 0, lidRadius], 18);

  group.rotation.z = 0.05;
});

// ---------------------------------------------------------------------------
// The library
// ---------------------------------------------------------------------------

/**
 * `height` is the approximate height in units, measured from the base, so the UI
 * can offer a sensible scale. Where a prop has options, it is the height of the
 * default variant: an open cardboard box stands about a third taller again, so
 * the offered scale stays sensible either way.
 */
export const PROP_LIBRARY = [
  { id: 'podium', label: '演台', height: 2.2, build: buildPodium },
  { id: 'desk', label: '机', height: 1.5, build: buildDesk },
  { id: 'mic', label: 'マイク', height: 2.2, build: buildMic },
  { id: 'sign', label: '看板', height: 2.7, build: buildSign },
  { id: 'plant', label: '観葉植物', height: 1.7, build: buildPlant },
  { id: 'box', label: '段ボール箱', height: 1.15, build: buildBox },
  { id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan },
];

/**
 * Where a freshly added prop goes. The character's body is a sphere of radius
 * ~1.6 around x = 0, z = 0, so anything closer than about 2.2 units would push
 * into it; each prop therefore sits 2.6-3.0 units out, in the quadrant that
 * suits its height, turned only slightly - a prop square to the camera reads as
 * a diagram, and one turned all the way shows its side.
 */
export const PROP_DEFAULTS = {
  podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 },
  desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 },
  mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 },
  // `faceScale` only means something for a 看板: it grows the writing area (the
  // face and its frame) together, so the post and base stay put. The panel owns
  // that control.
  sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1 },
  plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 },
  box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 },
  can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 },
};

/** The palette entry each part reads, derived from the app's colours. */
function derivePalette(colors = {}) {
  const body = new THREE.Color(colors.body ?? FALLBACK_COLORS.body);
  const accent = new THREE.Color(colors.accent ?? FALLBACK_COLORS.accent);
  const leaf = new THREE.Color(colors.leaf ?? FALLBACK_COLORS.leaf);
  return {
    // Darkened rather than mixed towards black, so the surface keeps a hint of
    // the theme's hue instead of turning into a grey.
    wood: body.clone().multiplyScalar(0.72),
    paper: body.clone().lerp(WHITE, 0.85),
    accent,
    leaf,
    metal: new THREE.Color(METAL_GREY),
  };
}

/**
 * Builds one prop.
 *
 * @param {string} id      one of PROP_LIBRARY's ids
 * @param {object} [options]
 * @param {object} [options.colors]        the app's render.colors palette
 * @param {string} [options.outlineColor]  kept on the group for the outline pass
 * @param {number} [options.scale]         uniform scale for the whole prop
 * @returns {THREE.Group} the prop, origin at its base centre, facing +z
 */
export function buildProp(id, options = {}) {
  const spec = PROP_LIBRARY.find((entry) => entry.id === id);
  if (!spec) {
    const known = PROP_LIBRARY.map((entry) => entry.id).join(', ');
    throw new Error(`unknown prop id ${JSON.stringify(id)}; known props: ${known}`);
  }

  const group = spec.build(options);
  group.name = `prop:${id}`;
  group.userData.propId = id;
  group.userData.outlineColor = options.outlineColor ?? null;
  if (Number.isFinite(options.scale) && options.scale !== 1) {
    group.scale.setScalar(options.scale);
  }
  return applyPropColors(group, options.colors);
}

/**
 * Recolours a built prop in place, for the colour-theme feature. Every mesh
 * material this module creates carries `userData.part`, so the walk only has to
 * look at those; anything else (a future prop built by hand) is left alone.
 */
export function applyPropColors(group, colors = {}) {
  const palette = derivePalette(colors);
  const seen = new Set();

  group.traverse((object) => {
    const list = Array.isArray(object.material) ? object.material : [object.material];
    for (const material of list) {
      if (!material || seen.has(material)) continue;
      seen.add(material);
      const part = material.userData?.part;
      if (!part) continue;
      const shade = Number.isFinite(material.userData.shade) ? material.userData.shade : 1;
      material.color.copy(palette[part] ?? palette.wood).multiplyScalar(shade);
      material.color.r = clamp01(material.color.r);
      material.color.g = clamp01(material.color.g);
      material.color.b = clamp01(material.color.b);
    }
  });

  return group;
}

/**
 * The writing surface of a prop: the mesh tagged `userData.canvasTexture`, i.e.
 * the 看板's face. A prop without one returns `null`, so callers can treat every
 * prop the same.
 */
export function propFace(group) {
  let face = null;
  group?.traverse?.((object) => {
    if (!face && object.userData?.canvasTexture) face = object;
  });
  return face;
}

/**
 * Scale the writing area - the 看板's face *and* the frame around it - about the
 * board's own centre, so the post and base stay where they are. A prop that tags
 * a group `faceScaleGroup` is scaled as a whole; anything else falls back to the
 * writable mesh alone. `addBox` puts a mesh's origin at the box centre and the
 * 看板's board group sits at the face centre, which is what makes this a scale
 * about the centre rather than about the prop's base.
 *
 * WHY this is applied from the panel: the app rebuilds every prop from the state
 * (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale,
 * so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards.
 */
export function applyPropFaceScale(group, faceScale) {
  let target = null;
  group?.traverse?.((object) => {
    if (!target && object.userData?.faceScaleGroup) target = object;
  });
  target ??= propFace(group);
  if (!target) return;
  const value = Number(faceScale);
  const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1;
  target.scale.setScalar(scale);
}

/** The world-space box the prop occupies; the app drops its contact shadow from it. */
export function propBounds(group) {
  group.updateMatrixWorld(true);
  return new THREE.Box3().setFromObject(group);
}

/** Releases every geometry and material in the prop and empties the group. */
export function disposeProp(group) {
  const geometries = new Set();
  const materials = new Set();

  group.traverse((object) => {
    if (object.geometry) geometries.add(object.geometry);
    const list = Array.isArray(object.material) ? object.material : [object.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();
  group.clear();
}