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
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
|
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;
}
/**
* The 線画 fill a part takes, as how far it steps from the paper towards the ink
* (see `addMesh` in src/styles.js). A prop is paper in 線画, so two parts of a prop
* that meet flush - the pencil's lead in its wood, a can's label on its body - come
* out as one white shape. Tagging one of them with a tone puts the seam back; it
* has no effect on リアル / フラット, where the part's own colour draws it.
*/
function tone(mesh, strength) {
mesh.userData.tone = strength;
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);
tone(addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14), 0.18);
tone(addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14), 0.28);
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);
tone(addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]), 0.20);
const front = new THREE.Group();
front.position.set(0, 1.00, 0.62);
front.rotation.x = flapsDown ? 0 : 1.05;
group.add(front);
tone(addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]), 0.20);
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);
tone(addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16), 0.20);
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.
tone(addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16), 0.30);
// 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;
});
/**
* ベッド. A wooden frame on four legs with a mattress, a headboard at the far end
* and a pillow, sized so the character can be posed lying on it.
*/
const buildBed = prop((group, material) => {
// Sized to take the character lying down: the body is a sphere of radius ~1.6,
// so the frame is a little over 3 units across and 4 units long.
for (const x of [-1.55, 1.55]) {
for (const z of [-1.85, 1.85]) {
addBox(group, material('wood'), [0.22, 0.5, 0.22], [x, 0.25, z]);
}
}
addBox(group, material('wood'), [3.5, 0.26, 4.3], [0, 0.63, 0]);
// The headboard sits at the far (-z) end, away from the camera.
addBox(group, material('wood'), [3.5, 1.5, 0.2], [0, 1.25, -2.15]);
addBox(group, material('paper'), [3.3, 0.46, 4.1], [0, 0.99, 0]);
// The pillow, slightly tipped up against the headboard.
const pillow = addBox(group, material('paper', 0.9), [2.2, 0.34, 0.9], [0, 1.39, -1.5]);
pillow.rotation.x = -0.08;
});
/**
* 布団. A mattress laid on the floor, a pillow and a quilt folded over the lower
* end.
*/
const buildFuton = prop((group, material) => {
addBox(group, material('paper'), [3.6, 0.3, 4.4], [0, 0.15, 0]);
const pillow = addBox(group, material('paper', 0.9), [2.2, 0.3, 0.9], [0, 0.45, -1.6]);
pillow.rotation.x = -0.06;
addBox(group, material('accent', 0.8), [3.6, 0.24, 2.7], [0, 0.42, 0.85]);
});
// ---------------------------------------------------------------------------
// 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.
*/
/** 鉛筆: an oversized pencil standing on its eraser, point up. */
const buildPencil = prop((group, material) => {
const r = 0.13;
// Eraser, ferrule and lead are each a band of the same width against the body,
// so without a tone of their own they vanish into it in 線画.
tone(addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6), 0.20);
tone(addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6), 0.24);
addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6);
addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6);
// The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the
// two look joined, while its point still pokes out past the wood.
tone(addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6), 0.45);
});
/** コップ: a mug with a rim, a handle and a little drink inside. */
const buildCup = prop((group, material) => {
addCylinder(group, material('paper'), 0.34, 0.30, 0.66, [0, 0.33, 0], 18);
addCylinder(group, material('paper', 0.86), 0.30, 0.30, 0.05, [0, 0.645, 0], 18);
addCylinder(group, material('wood', 0.5), 0.28, 0.28, 0.02, [0, 0.61, 0], 18);
addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0);
});
/** ノート: a fat pad of pages under a thin cover, bound along one long edge. */
const buildNotebook = prop((group, material) => {
const width = 1.5;
const depth = 2.0;
const pages = 0.18;
const cover = 0.04;
// The cover pokes out past the pages, so the notebook reads from above.
addBox(group, material('wood', 0.6), [width + 0.08, cover, depth + 0.08], [0, cover / 2, 0]);
addBox(group, material('paper'), [width, pages, depth], [0, cover + pages / 2, 0]);
addBox(
group,
material('accent'),
[0.16, pages + 0.04, depth + 0.02],
[-width / 2 + 0.08, cover + pages / 2, 0],
);
});
/** 消しゴム: a chunky two-tone block, a hard accent top on a soft white base. */
const buildEraser = prop((group, material) => {
const width = 0.9;
const depth = 0.5;
const half = 0.15;
addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]);
tone(addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]), 0.22);
});
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 },
{ id: 'bed', label: 'ベッド', height: 2.0, build: buildBed },
{ id: 'futon', label: '布団', height: 0.65, build: buildFuton },
{ id: 'cup', label: 'コップ', height: 0.66, build: buildCup },
{ id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil },
{ id: 'notebook', label: 'ノート', height: 0.22, build: buildNotebook },
{ id: 'eraser', label: '消しゴム', height: 0.3, build: buildEraser },
];
/**
* 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, text: '' },
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 },
bed: { x: 0, y: 0, z: -4.7, rotY: 0, scale: 1 },
futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 },
pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 },
cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 },
notebook: { x: -3.2, y: 0, z: -2.7, rotY: 0.35, scale: 1 },
eraser: { x: 3.6, y: 0, z: 2.6, rotY: -0.45, 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);
}
/**
* Paint text onto a prop's writing surface (the 看板's face), or clear it.
*
* The face's material gets a `CanvasTexture`, drawn here with a system font stack
* (no font file is needed, and Japanese falls back to whatever the device has).
* Long lines and long blocks are shrunk to fit the board, and explicit newlines
* are honoured. The material is per-prop (see `materialCache`), so the map never
* leaks to another sign.
*/
export function applyPropText(group, text) {
if (typeof document === 'undefined') return; // Node (tests): no canvas
const face = propFace(group);
const material = face?.material;
if (!material) return;
const value = typeof text === 'string' ? text : '';
if (!value.trim()) {
if (material.map) {
material.map.dispose();
material.map = null;
material.needsUpdate = true;
}
material.userData.signText = '';
return;
}
if (material.userData.signText === value) return; // nothing changed
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 330;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f4efe2';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#241a30';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const family = '"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif';
const lines = value.split(/\r\n|\r|\n/);
const maxWidth = canvas.width * 0.86;
let size = 150;
const fits = () => {
ctx.font = `700 ${size}px ${family}`;
return lines.every((line) => ctx.measureText(line).width <= maxWidth)
&& lines.length * size * 1.15 <= canvas.height * 0.86;
};
while (size > 18 && !fits()) size -= 2;
ctx.font = `700 ${size}px ${family}`;
const step = size * 1.15;
const startY = canvas.height / 2 - ((lines.length - 1) * step) / 2;
lines.forEach((line, index) => ctx.fillText(line, canvas.width / 2, startY + index * step));
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 4;
if (material.map) material.map.dispose();
material.map = texture;
material.userData.signText = value;
material.needsUpdate = true;
}
/** 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();
}
|