diff options
Diffstat (limited to 'public/bluebey-studio/src/props.js')
| -rw-r--r-- | public/bluebey-studio/src/props.js | 646 |
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(); -} |
