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.js646
1 files changed, 0 insertions, 646 deletions
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
deleted file mode 100644
index 78525bd..0000000
--- a/public/bluebey-studio/src/props.js
+++ /dev/null
@@ -1,646 +0,0 @@
-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();
-}