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 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); 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. 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; addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6); addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6); 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. addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6); }); /** コップ: 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]); addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]); }); 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: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil }, { id: 'cup', label: 'コップ', height: 0.66, build: buildCup }, { 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(); }