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
|
import * as THREE from 'three';
/**
* Hats for ぶるべー. Each hat is a small group of primitives whose base sits at
* y = 0, so the app can drop it straight onto the top of the head (see
* `hatMount` in src/main.js). The character's front is +z, so a brim or a badge
* faces +z.
*
* These are deliberately not textured: like the props, they are built from a few
* rounded primitives so they take a clean outline and read in every render style.
*/
const material = (color, opts = {}) => new THREE.MeshStandardMaterial({
color,
roughness: 0.72,
metalness: 0,
...opts,
});
function add(parent, geometry, mat, x = 0, y = 0, z = 0) {
const mesh = new THREE.Mesh(geometry, mat);
mesh.position.set(x, y, z);
mesh.castShadow = true;
mesh.receiveShadow = false;
parent.add(mesh);
return mesh;
}
const cylinder = (rt, rb, h, seg = 24) => new THREE.CylinderGeometry(rt, rb, h, seg);
const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI) => (
new THREE.SphereGeometry(r, w, h, phiStart, phiLength, thetaStart, thetaLength)
);
const box = (w, h, d) => new THREE.BoxGeometry(w, h, d);
/**
* The 線画 fill a part takes, as how far it steps from the paper towards the ink
* (see `addMesh` in src/styles.js). Every hat part already gets the light default
* tone, but two parts of the *same* hat then come out the same shade - so a part
* that sits flush on another (a band on a crown) loses its seam. Tagging it with
* a darker tone puts that seam back, without touching リアル / フラット, where the
* part's own colour already draws it.
*/
function tone(mesh, strength) {
mesh.userData.tone = strength;
return mesh;
}
/** シルクハット: a tall felt crown that widens a little at the top, a rolled brim
* and a ribbon with a flat bow. */
function buildSilk() {
const group = new THREE.Group();
const felt = material('#17131e', { roughness: 0.55 });
const ribbon = material('#352a45');
// Brim: a shallow disc with a rolled edge, so it has a thickness in line art.
add(group, cylinder(1.80, 1.66, 0.10, 40), felt, 0, 0.05, 0);
const rim = add(group, new THREE.TorusGeometry(1.785, 0.075, 10, 48), felt, 0, 0.095, 0);
rim.rotation.x = Math.PI / 2;
tone(rim, 0.22);
// Crown: a lathe, very slightly wider at the top - the classic top-hat flare.
const profile = [
[1.00, 0.00], [0.985, 0.10], [0.975, 0.88], [0.99, 1.32],
[1.02, 1.49], [1.03, 1.57], [0.95, 1.64], [0.62, 1.67], [0.0, 1.68],
].map(([x, y]) => new THREE.Vector2(x, y));
add(group, new THREE.LatheGeometry(profile, 40), felt, 0, 0.09, 0);
// Ribbon: a band around the base, with a flat bow on the left side.
tone(add(group, cylinder(1.015, 1.02, 0.20, 40), ribbon, 0, 0.20, 0), 0.30);
const bow = tone(add(group, box(0.07, 0.20, 0.30), ribbon, -0.99, 0.20, 0), 0.14);
bow.rotation.y = 0.16;
return group;
}
/** キャップ: a low six-panel crown with a button, a band and a long curved bill. */
function buildCap() {
const group = new THREE.Group();
const cloth = material('#3f6fd0', { roughness: 0.82 });
const dark = material('#2c4d96', { roughness: 0.86 });
const button = material('#243c74', { roughness: 0.7 });
// The crown: a hemisphere whose flat face sits on y = 0, squashed and leaning
// a touch forward so it reads as fabric, not a bowl.
const crown = add(group, sphere(1.05, 32, 20, 0, Math.PI * 2, 0, Math.PI / 2), cloth, 0, 0.02, 0.02);
crown.scale.set(1, 0.88, 1.03);
// The band around the rim, and the button on top.
tone(add(group, cylinder(1.075, 1.075, 0.14, 32), dark, 0, 0.07, 0.02), 0.22);
tone(add(group, sphere(0.10, 14, 10), button, 0, 0.925, 0.02), 0.18);
// The bill: an oval that is long at the front and tucked under the crown at
// the back, tipped down a little.
const bill = tone(add(group, cylinder(1.12, 1.12, 0.075, 32), material('#5b86e0', { roughness: 0.82 }), 0, 0.075, 0.75), 0.26);
bill.scale.set(1, 1, 1.9);
bill.rotation.x = -0.28;
return group;
}
/** コック帽: a low band under a mushroom-shaped puff with vertical pleats. */
function buildChef() {
const group = new THREE.Group();
const cloth = material('#f8f7f3', { roughness: 0.9 });
const shade = material('#e4e1d7', { roughness: 0.9 });
// The band that sits on the head.
add(group, cylinder(0.86, 0.90, 0.62, 32), cloth, 0, 0.31, 0);
tone(add(group, cylinder(0.885, 0.885, 0.06, 32), shade, 0, 0.59, 0), 0.24);
// The puff: a lathe profile that swells out past the band and closes over the
// top, which gives the silhouette a toque has (a ball just looks like a head).
const profile = [
[0.86, 0.60], [1.10, 0.68], [1.28, 0.85], [1.35, 1.10],
[1.32, 1.35], [1.18, 1.56], [0.86, 1.68], [0.44, 1.73], [0.0, 1.74],
].map(([x, y]) => new THREE.Vector2(x, y));
add(group, new THREE.LatheGeometry(profile, 36), cloth, 0, 0, 0);
// Pleats: soft vertical lobes around the swell, thin across and deep along the
// radius, ten of them as a real toque has.
for (let i = 0; i < 10; i += 1) {
const a = (i / 10) * Math.PI * 2;
const lobe = add(group, sphere(0.5, 12, 10), cloth,
Math.cos(a) * 1.16, 1.18, Math.sin(a) * 1.16);
lobe.scale.set(0.40, 0.85, 0.55);
lobe.rotation.y = -(a + Math.PI / 2);
}
// A rolled seam where the band meets the puff.
const roll = add(group, new THREE.TorusGeometry(1.00, 0.07, 10, 36), shade, 0, 0.64, 0);
roll.rotation.x = Math.PI / 2;
tone(roll, 0.16);
return group;
}
/** 消防士の帽子: a red helmet with a brim, a top ridge and a small front badge. */
function buildFire() {
const group = new THREE.Group();
const red = material('#c5342f');
const dark = material('#8f241f');
const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 });
const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0);
dome.scale.set(1, 0.95, 1);
const brim = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28);
brim.scale.set(1, 1, 1.1);
// The ridge along the top, front to back.
const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28);
crest.rotation.x = -0.05;
tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22);
return group;
}
const BUILDERS = {
silk: buildSilk,
cap: buildCap,
chef: buildChef,
fire: buildFire,
};
/** The hats offered in the panel: `none` clears it. */
export const HAT_LIBRARY = [
{ id: 'none', label: 'なし' },
{ id: 'silk', label: 'シルクハット' },
{ id: 'cap', label: 'キャップ' },
{ id: 'chef', label: 'コック帽' },
{ id: 'fire', label: '消防士' },
];
/** Builds the named hat, or `null` for `none` / an unknown name. */
export function buildHat(id) {
const builder = BUILDERS[id];
return builder ? builder() : null;
}
|