diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/props.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/props.js')
| -rw-r--r-- | bluebey-studio/src/props.js | 646 |
1 files changed, 646 insertions, 0 deletions
diff --git a/bluebey-studio/src/props.js b/bluebey-studio/src/props.js new file mode 100644 index 0000000..78525bd --- /dev/null +++ b/bluebey-studio/src/props.js @@ -0,0 +1,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(); +} |
