aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/props.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/props.js')
-rw-r--r--public/bluebey-studio/src/props.js459
1 files changed, 459 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
new file mode 100644
index 0000000..5ad958a
--- /dev/null
+++ b/public/bluebey-studio/src/props.js
@@ -0,0 +1,459 @@
+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 separate, named child
+ * tagged `userData.canvasTexture`, so a later feature can paint a CanvasTexture
+ * onto it without touching the frame - and `applyPropFaceScale` can grow just
+ * that surface, about its own centre, leaving the frame and posts alone. It is a
+ * thin box rather than a plane: 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]);
+ addBox(group, material('accent'), [1.42, 0.98, 0.12], [0, 2.21, 0]);
+
+ const face = addBox(group, material('paper'), [1.18, 0.76, 0.08], [0, 2.21, 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. It is twisted slightly sideways so it does not read as a
+ // knob sitting straight up on the can.
+ const hinge = new THREE.Group();
+ hinge.position.set(0, 0.42, -0.135);
+ hinge.rotation.x = -1.05;
+ hinge.rotation.z = 0.4;
+ group.add(hinge);
+ addCylinder(hinge, material('metal', 0.9), 0.132, 0.132, 0.018, [0, 0.12, 0], 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 surface
+ // alone, so the frame and posts 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 surface about its own centre, so only the 看板's face grows
+ * and its frame and posts stay where they are. `addBox` puts the mesh's origin at
+ * the box 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) {
+ const face = propFace(group);
+ if (!face) return;
+ const value = Number(faceScale);
+ const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1;
+ face.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();
+}