From d5b8d58a82a040a2facb5d226b64648aa66b2629 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:47:24 +0900 Subject: bluebey-studio: /bluebey-studio/ をテスト公開用にBasic認証つきで復帰 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 準拠版アプリを public/bluebey-studio/ に戻し、.htaccess でBasic認証(ユーザー bluebey)を設定。/bluebey-studio/ のみ保護し他は素通し。本公開は市の確認後に認証を外す想定。 --- public/bluebey-studio/src/presets.js | 668 +++++++++++++++++++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 public/bluebey-studio/src/presets.js (limited to 'public/bluebey-studio/src/presets.js') diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js new file mode 100644 index 0000000..a31c952 --- /dev/null +++ b/public/bluebey-studio/src/presets.js @@ -0,0 +1,668 @@ +/** + * The complete, serialisable studio state plus the preset libraries. + * + * Everything here is plain JSON: "設定を保存" writes exactly this object, and + * "設定を読み込む" merges it back in, so a look can be archived and shared. + * + * Eye parameters are named after the character's own left/right, NOT the + * viewer's: `left` is the eye on the model's +x side, which is the half of the + * eye texture with u > 0.5. In a front view that eye appears on the right of + * the screen. + */ + +/** + * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or + * two opposite cuts) can be made. A wall's position is kept *relative to the + * character*, so moving ぶるべー carries its walls along (see src/clip.js). + */ +function wallDefaults() { + return { + on: false, + x: 0, y: 0, z: 0, // a point the plane passes through, relative to the character + yaw: 0, // turn about Y, degrees + tilt: 0, // lean about X, degrees + // The guide is the only part of the wall that is ever *drawn* (the wall + // itself writes depth but no colour, so it hides what is behind it + // without being visible). `guide` shows that rectangle while placing, + // which is also when it can be dragged in the viewport. + guide: false, + size: 1, // the wall's size (a real width and height, not a clip) + }; +} + +export function defaultState() { + return { + version: 1, + view: { + projection: 'persp', // persp | ortho + fov: 30, + azimuth: 0, // degrees, 0 = looking at the face + polar: 76, // degrees from +y, 90 = level with the target + distance: 0, // 0 = fit to the model automatically + orthoHeight: 0, // 0 = fit to the model automatically + targetY: 0, // 0 = centre of the model + // The mouse orbit and pan write their own target back here (see + // ViewRig.captureInto), so refreshing the view - or sharing a link - keeps + // the camera the user actually framed. + targetX: 0, + targetZ: 0, + autoRotate: false, + autoRotateSpeed: 0.8, + // solid | transparent | preset | image | effect | camera + // preset = one of the CC0 backdrops in assets/backgrounds + // image = something the user loaded (data URL) + // effect = a procedurally drawn manga effect line (no file at all) + // camera = the phone's camera, for the AR mode + background: 'solid', + backgroundColor: '#ffffff', + backgroundPreset: 'autumn_park', + backgroundEffect: 'focus', // focus | fall | speed | ellipse + backgroundImage: null, // data URL, set by 「画像を読み込む」 + backgroundFit: 'cover', // cover | contain | stretch | tile + backgroundBlur: 0, // px + backgroundDarken: 0, // 0..1 dark veil, helps the character read + backgroundScale: 1, + backgroundOffset: { x: 0, y: 0 }, + // AR: which camera, and whether to mirror (front camera feels mirrored) + cameraFacing: 'environment', // environment | user + cameraMirror: false, + gyro: false, // look around by turning the phone + }, + render: { + style: 'real', // real | flat | lineart | outline + outline: true, + outlineWidth: 0.022, + outlineColor: '#2a1e33', + paper: '#ffffff', + shadow: true, + // The soft round blob at the character's feet. Off by default: it sat at the + // origin and stayed behind when the character was moved. See look.js. + contactBlob: false, + pngScale: 2, + pngTransparent: false, + svgWidth: 2048, + lightAzimuth: -38, + lightElevation: 40, + lightIntensity: 2.1, + ambient: 0.9, + // The colour of the main light and of the ambient (sky) light. + lightColor: '#ffffff', + ambientColor: '#ffffff', + exposure: 1, + // The fixed body palette. ぶるべー keeps its colours, so there is no theme + // picker and no per-part editor; the parts live here so the styles and the + // model have one place to read them from. + colors: { + body: '#c8b0f0', + accent: '#8a4fe0', + nose: '#7a4fb0', + leaf: '#a6dd6a', + vein: '#7fbf3f', + feet: '#7a4fb0', + }, + // Mirror the whole character, for laying out a panel that faces its text. + mirror: false, + // Shadows: the soft blob under the feet, plus the cast shadow. + shadowOpacity: 0.22, + shadowSoftness: 1.6, + shadowOffset: 0, + contactShadow: false, // blob only, no cast shadow + // Lighting environment. 'room' is three's RoomEnvironment; 'gradient' is + // the tiny procedural sky the studio always had. + environment: 'gradient', // gradient | room | none + envIntensity: 1, + // Hand-drawn line work (SVG export and the outline pass). + handDrawn: 0, // 0 = off, 1 = a lot + handDrawnSeed: 7, + handDrawnScale: 40, + handDrawnPasses: 1, + // How the outline is drawn - the two methods take the parts they are good + // at. See MODEL-GUIDE.md §5 and src/outline.js. + // + // 'screen' (the default) hands the leaves to the screen-space edge pass, + // because a hull cannot outline a shell that thin: the expanded + // front and back cross inside it and the line breaks up. Every + // other part keeps its hull, whose line is computed from the + // geometry and so comes out smooth, where the pixel grid would + // make it stepped. + // 'hull' the inverted-hull copy everywhere - cheap, and the way it was + // done before, but the leaves then come out as a tangle. + outlineMethod: 'screen', + outlinePixels: 2.4, // 'screen' only: line width in screen pixels + // Whether a leaf gets a line where it meets the *body*. There is no right + // answer here, so it is a setting: + // on - the body counts as paper, so the leaves are outlined where they + // emerge from it. Without this a line drawing cannot tell the + // leaves and the body apart at all (both are paper). + // off - the body blocks the line, so the leaf simply passes behind it and + // nothing is drawn along the meeting. Cleaner, but the leaves and + // the body merge into one white shape. + leafBodyLine: true, + // 見えない壁 (the invisible wall). Up to two placeable rectangles that hide + // whatever is behind them, so the character can be buried in a wall and + // only the rest of the body shows. See src/clip.js. + wall: wallDefaults(), + wall2: wallDefaults(), + }, + pose: { + bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0 + root: [0, 0, 0], // whole-body offset in model units + }, + anim: { + mode: 'off', // off | idle | walk + blink: true, + blinkInterval: 3.4, // seconds between blinks + lookAround: false, + speed: 1, + }, + face: { + // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js. + // `x`/`z` slide it sideways / forwards, `height` lifts it off the head; + // `tiltX` leans it forward/back and `tiltZ` leans it sideways. + hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 }, + eyes: { + source: 'parametric', // parametric | opened | closed | ... (hand-drawn) + linked: true, // move both eyes together + left: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null }, + right: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null }, + irisScale: 1, + lookMax: 1.2, + highlight: true, + lidWidth: 14, + lowerLid: 0, + lashes: false, + lashAngle: 0, + lashPos: 0, + // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat` + // is a straight lid edge (the hard, narrowed look). `lidTilt` rotates the + // narrowed eye. See faceArt.js. + lidShape: 'curve', + lidTilt: 0, + heartScale: 2, + heartColor: '#e0344f', + // The tears live on each eye (see `eyes.left.tear`), so one eye can cry + // on its own; only the colour is shared. + tearColor: '#8fd8ff', + // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same + // plate as the eyes, out to the side of the nose. Starts off. + snot: { + enabled: false, + size: 1.23, + offsetX: -23, + offsetY: 0, + offsetZ: 16, + color: '#e3ecff', + }, + // 眼鏡 / サングラス are drawn into the same texture as the eyes, so they + // travel with the head instead of being a separate 3D mesh. Off by + // default. `kind` only picks which lens defaults the panel offers; the + // drawing itself reads the colours and opacities below. + glasses: { + enabled: false, + kind: 'glasses', // glasses | sunglasses + frameColor: '#2a1e33', + frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke) + lensColor: '#2b2433', + lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark + lensGap: 0, // px, + moves the two lenses further apart + scale: 1, // lens size multiplier + offsetY: 0, // px, artwork units (positive = down) + tilt: 0, // degrees, both lenses turn together + }, + white: '#ffffff', + iris: '#150e1b', + line: '#55386e', + }, + mouth: { + source: 'parametric', // parametric | original + visible: true, + smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown + width: 1, // the smile line's length (the chord); also the O's width + thickness: 1, + open: 0, // 0 = closed lips, 1 = wide open + tilt: 0, // degrees + offsetY: 0, // texture pixels + tongue: 0.96, // 0 hides it; scales the whole tongue + tonguePos: 0.84, // 0..1 along the smile line + corners: 0.71, // length of the corner marks, 0 hides them + cornerAngle: -8, // degrees, tilts the corner marks + round: 0, // >0 turns the mouth into a round "O" + color: '#ff1a44', + innerColor: '#4a0f1e', + tongueColor: '#ff2d2d', + cornerColor: '#725497', + line: '#55386e', + }, + }, + // ----------------------------------------------------------------- look at + // Where the character should look (world units). + lookAt: { + enabled: false, + x: 0, + y: 2.6, + z: 3, + turnBody: false, // also rotate the whole body a little + amount: 1, + // The turn and the gaze are *remembered* rather than derived, so switching + // the mode (or just 体も向ける) off leaves the character where it is instead + // of snapping it back to the front. See `applyLookAt` in main.js. + bodyYawDeg: 0, + freeze: null, + }, + // ------------------------------------------------------------------- props + // 小物. Each entry is a procedural prop from src/props.js. + props: { + items: [], + }, + // -------------------------------------------------------------------- 擬音 + // マンガのオノマトペ (擬音) as stickers over the picture. Each item is a crop + // of a sheet - `{ id, sheet, sx, sy, sw, sh, x, y, w, rot, flip }` - and only + // the sheet *name* is stored, never the bitmap, so a shared link stays small + // (see src/gion.js for the geometry). + gion: { + items: [], + }, + // ------------------------------------------------------------------- story + // 4コマ / 紙芝居. Each panel is a small state patch (pose, face). + story: { + columns: 1, + gap: 12, + padding: 20, + sheetBackground: '#ffffff', + // How big each captured panel is, as a multiple of the viewport. 2x doubles + // each side, so the sheet PNG gets four times the pixels per panel. + scale: 1.5, + panels: [], + }, + }; +} + +/** Expression presets. Each patches a copy of the default state. */ +export const FACE_PRESETS = [ + { + id: 'normal', + label: 'ふつう', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + // Depth 1 = the hand-drawn original; the corners and tongue below are also + // the settings that reproduce it (measured against the artwork). + mouth: { smile: 1, width: 1, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0.96, tonguePos: 0.84, corners: 0.71, cornerAngle: -8 }, + }, + }, + { + id: 'laugh', + label: 'にっこり', + face: { + eyes: { + left: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' }, + right: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' }, + highlight: false, + }, + // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a + // round mascot reads as a shout, so only びっくり keeps the round O. + mouth: { smile: 1.15, width: 1.14, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, round: 0, tongue: 0.96, corners: 1.25 }, + }, + }, + { + id: 'eating', + label: 'たべている', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + // A small, cute round mouth (the other round mouth, with びっくり). + mouth: { smile: 0.2, width: 0.8, thickness: 1.1, open: 0, tilt: 0, offsetY: 6, round: 0.58, tongue: 0.7, corners: 0 }, + }, + }, + { + id: 'wink', + label: 'ウインク', + face: { + eyes: { + // The shut eye is the original `eyes-close-tight` artwork: three strokes + // sharing one vertex, pointing at the nose. + left: { shape: 'line3', open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 }, + irisScale: 1, + highlight: true, + }, + mouth: { smile: 1.0, width: 1.02, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 1 }, + }, + }, + { + id: 'surprised', + label: 'びっくり', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + irisScale: 0.74, + lookMax: 0.35, + highlight: true, + }, + mouth: { smile: 0.2, width: 0.85, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, round: 0.62, tongue: 0.25, corners: 0 }, + }, + }, + { + id: 'sleepy', + label: 'ねぼけ', + face: { + eyes: { + left: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' }, + right: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' }, + irisScale: 1, + highlight: true, + // A sleeper's snot bubble, with the defaults from the panel. + snot: { enabled: true }, + }, + mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 }, + }, + }, + { + id: 'shy', + label: 'てれ', + face: { + eyes: { + // Blushing, eyes half shut and looking away. + left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' }, + right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + mouth: { smile: 0.5, width: 0.82, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0.5 }, + }, + }, + { + id: 'sidelong', + label: 'じと目', + face: { + eyes: { + // Flat, narrowed lids tilted inwards - a sideways, unimpressed look. + // 上まぶたの高さ is now independent of the lower lid, so `open` carries the + // whole slit (it used to be measured from the raised lower lid). + left: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' }, + right: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' }, + lowerLid: 0.42, + lidShape: 'flat', + lidTilt: 12, + irisScale: 0.92, + highlight: true, + }, + mouth: { smile: 0.12, width: 0.78, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, + { + id: 'love', + label: 'すき', + face: { + eyes: { + left: { shape: 'heart', open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + right: { shape: 'heart', open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + irisScale: 1, + // The white behind a heart would poke out around its lobes, so hide it. + heartColor: '#e0344f', + highlight: false, + }, + mouth: { smile: 1.15, width: 1, thickness: 1.05, open: 0.16, tilt: 4, offsetY: 0, tongue: 0.9, corners: 1.3 }, + }, + }, + { + id: 'grumpy', + label: 'むっと', + face: { + eyes: { + left: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 }, + right: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 }, + irisScale: 0.86, + highlight: true, + }, + mouth: { smile: -0.62, width: 0.84, thickness: 1.15, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, + { + id: 'cry', + label: 'ないてる', + face: { + eyes: { + // Big tears that hang low and lean outwards - the classic "crying hard" + // look. The sideways position and the tilt are mirrored between the eyes, + // so the pair stays symmetric. + left: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: -20 }, + right: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: 20 }, + irisScale: 1.02, + lowerLid: 0.42, + highlight: true, + }, + mouth: { smile: -0.5, width: 0.72, thickness: 1.25, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, +]; + +/** + * Poses. `bones` holds per-bone XYZ degrees relative to the rest pose. + * + * The axes were measured (turn one bone 40° and watch the hand's centre move), + * and the two sides are NOT the same. That is what used to send the left hand + * behind while the right one came forward: + * + * arm / hand + * X lift. Up on both sides, so the SAME value is symmetric. + * Y roll about the flipper - which way the palm faces. + * Z forward/back. +Z is BACK on the left and FORWARD on the right, so a + * symmetric pose writes the right-hand Z with the opposite sign, while + * a walk (opposite arms) writes the SAME sign on both. + * legsupport + * X out to the side and up (same value on both sides is symmetric) + * Y twist (which way the toe points) + * Z forward/back, mirrored like `arm` + * It pivots at the middle of the body, so the feet travel a long way for + * a small angle - keep it under about 15 degrees. + * master + * X lean forward Y spin around Z roll sideways + * + * `armsupport` still exists in the rig but it is a control bone that pivots at + * the body's centre, so it swings a hand in a wide arc across the face. Every + * preset below drives `arm`/`hand` instead. + */ +export const POSE_PRESETS = [ + { id: 'stand', label: '立ち', pose: { bones: {} } }, + { + id: 'attention', + label: 'きおつけ', + pose: { + bones: { + // Both flippers dropped to the sides. A negative X lowers the arm, and + // the value is the same on both sides (only Z mirrors between them). + 'arm.l': [-62, 0, 0], + 'arm.r': [-62, 0, 0], + }, + }, + }, + { + id: 'bow', + label: 'おじぎ', + pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } }, + }, + { + id: 'deep-bow', + label: 'ふかくおじぎ', + pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } }, + }, + { + id: 'tilt', + label: 'くびかしげ', + pose: { + bones: { + master: [0, 0, 16], + // The roll turns the whole body about its base, which swings the feet up + // and down, so the legs take the difference. The right-hand leg (on the + // left of the screen) is the one that is planted: it opens outwards a + // little and keeps the sole down. The left-hand leg bends back behind it. + 'legsupport.r': [8, 0, 0], + 'legsupport.l': [-6, 0, 14], + }, + }, + }, + { + id: 'wave', + label: 'てをふる', + pose: { + bones: { + master: [0, 0, -4], + // The left flipper goes up and slightly forward. Y is the roll about the + // flipper's own length, which is what turns the palm to face outwards. + 'arm.l': [84, 0, -6], + 'hand.l': [0, -30, 0], + }, + }, + }, + { + id: 'banzai', + label: 'ばんざい', + pose: { + bones: { + master: [-8, 0, 0], + // Both flippers as far up as the shoulder allows (the pivot sits at the + // middle of the body, so they cannot reach above the head). + 'arm.l': [106, 0, -12], + 'arm.r': [106, 0, 12], + }, + root: [0, 0.2, 0], + }, + }, + { + id: 'stretch', + label: 'のびをする', + pose: { + bones: { + master: [-16, 0, 0], + 'arm.l': [112, 0, -8], + 'arm.r': [112, 0, 8], + }, + root: [0, 0.08, 0], + }, + }, + { + id: 'jump', + label: 'ジャンプ', + pose: { + bones: { + master: [-8, 0, 0], + 'arm.l': [68, 0, -10], + 'arm.r': [68, 0, 10], + 'legsupport.l': [10, 0, 0], + 'legsupport.r': [10, 0, 0], + }, + root: [0, 0.62, 0], + }, + }, + { + id: 'leap', + label: '大ジャンプ', + pose: { + bones: { + master: [-18, 0, 0], + 'arm.l': [92, 0, -12], + 'arm.r': [92, 0, 12], + 'legsupport.l': [14, 0, 0], + 'legsupport.r': [14, 0, 0], + }, + root: [0, 1.0, 0], + }, + }, + { + id: 'dance', + label: 'おどる', + pose: { + bones: { + master: [-4, 0, -12], + // Deliberately uneven: the left flipper is up, the right one is out in + // front, and the feet step the other way. + 'arm.l': [78, 0, -14], + 'arm.r': [14, 0, 34], + 'legsupport.l': [0, 0, 8], + 'legsupport.r': [0, 0, 8], + }, + root: [0, 0.14, 0], + }, + }, + { + id: 'tip-over', + label: 'こてん', + pose: { + bones: { + master: [0, 0, 68], + 'arm.l': [26, 0, -18], + 'arm.r': [10, 0, 18], + }, + root: [0, 0.25, 0], + }, + }, + { + id: 'lie', + label: 'ねている', + pose: { + bones: { + // Flat on its back: X tips the body backwards all the way over, so the + // face looks up. The root lift keeps the back resting on the ground + // rather than sinking through it (the body's depth becomes its height). + master: [-88, 0, 0], + // Both flippers rest against the body, loose. + 'arm.l': [-18, 0, 0], + 'arm.r': [-18, 0, 0], + }, + root: [0, 1.5, 0], + }, + }, + { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } }, + { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } }, + { + id: 'present', + label: '右を紹介', + pose: { + bones: { + master: [6, -26, 0], + // The right flipper sweeps forward to point at whatever is on its right + // (+Z is forwards on the right); the left one just steps aside. + 'arm.r': [16, 0, 52], + 'arm.l': [10, 0, -6], + }, + }, + }, + { + id: 'present-left', + label: '左を紹介', + pose: { + bones: { + master: [6, 26, 0], + 'arm.l': [16, 0, -52], + 'arm.r': [10, 0, 6], + }, + }, + }, +]; + +/** Deep-merge `patch` into `target` (plain objects only). */ +export function applyPatch(target, patch) { + for (const [key, value] of Object.entries(patch ?? {})) { + if (value && typeof value === 'object' && !Array.isArray(value) + && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { + applyPatch(target[key], value); + } else { + target[key] = Array.isArray(value) ? value.slice() : value; + } + } + return target; +} + +export function cloneState(state) { + return JSON.parse(JSON.stringify(state)); +} -- cgit v1.3.1