diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..24446a1 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,28 @@ +# Third-party notices + +## OneWorks Avatar + +The `oneworks-*` body shapes and bundled character silhouettes are adapted from +[OneWorks Avatar](https://github.com/oneworks-ai/avatar). + +Copyright (c) 2026-present One Works contributors + +Licensed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/react-vite-consumer/src/strobi.avatar.json b/examples/react-vite-consumer/src/strobi.avatar.json index cb86f62..f9818ca 100644 --- a/examples/react-vite-consumer/src/strobi.avatar.json +++ b/examples/react-vite-consumer/src/strobi.avatar.json @@ -13,8 +13,8 @@ "nodes": [] }, "colors": { - "body": "#5b7fe5", - "eyes": "#111316" + "body": "#7c89ff", + "eyes": "#50d7fe" }, "expressions": { "neutral": { @@ -27,6 +27,7 @@ "left": { "width": 20, "height": 50, + "curvature": 0, "x": 0, "y": -7, "angle": 0 @@ -34,6 +35,7 @@ "right": { "width": 20, "height": 50, + "curvature": 0, "x": 0, "y": -7, "angle": 0 @@ -835,6 +837,37 @@ "colors": { "body": "#adc3ff" } + }, + "joyful-arc": { + "head": { + "x": -8.75234375, + "y": -8.743359375, + "z": -10.773828125 + }, + "eyes": { + "left": { + "width": 54, + "height": 15, + "curvature": 1, + "x": 0, + "y": -18, + "angle": 0 + }, + "right": { + "width": 54, + "height": 15, + "curvature": 1, + "x": 0, + "y": -18, + "angle": 0 + }, + "spacing": 69.276171875 + }, + "perspective": 1, + "motion": { + "eyes": "none", + "body": "none" + } } }, "expressionOrder": [ @@ -865,7 +898,8 @@ "suspicious-right", "shy-downward", "angry-brows", - "uneasy-left" + "uneasy-left", + "joyful-arc" ], "animations": { "sleeping": { @@ -1258,7 +1292,7 @@ "transition": "smooth" }, { - "expression": "angry-left", + "expression": "angry-brows", "holdMs": 2300, "transitionMs": 500, "transition": "smooth" @@ -1353,6 +1387,35 @@ "group": "Réactions" } }, + "joyful": { + "playbackMode": "loop", + "steps": [ + { + "expression": "joyful-wide", + "holdMs": 2300, + "transitionMs": 700, + "transition": "gentleSpring" + }, + { + "expression": "joyful-arc", + "holdMs": 2300, + "transitionMs": 700, + "transition": "gentleSpring" + } + ], + "blink": { + "enabled": false, + "initialDelayMs": 2100, + "minIntervalMs": 2800, + "maxIntervalMs": 5000, + "durationMs": 260 + }, + "metadata": { + "label": "joyful", + "description": "Morphing fluide entre les expressions 11 et 27.", + "group": "Réactions" + } + }, "curious": { "playbackMode": "loop", "steps": [ @@ -1608,7 +1671,7 @@ "transition": "smooth" }, { - "expression": "surprised-wide-left", + "expression": "uneasy-left", "holdMs": 2300, "transitionMs": 500, "transition": "smooth" @@ -1718,6 +1781,7 @@ "angry", "drowsy", "happy", + "joyful", "curious", "confused", "surprised", diff --git a/packages/avatar-core/src/__tests__/core-test.ts b/packages/avatar-core/src/__tests__/core-test.ts index 6e8caa4..0e143c2 100644 --- a/packages/avatar-core/src/__tests__/core-test.ts +++ b/packages/avatar-core/src/__tests__/core-test.ts @@ -3,6 +3,7 @@ import { bodyFromDefinition, createAvatarPlaybackState, expressionFromDefinition, + interpolatePose, parseAvatarDefinition, playAvatarAnimation, pauseAvatarPlayback, @@ -13,7 +14,11 @@ import { resumeAvatarPlayback, resolveAnimation, sampleAvatarFrame, + surfaceFrontSampleAt, + surfacePresets, + surfaceSampleAt, type AvatarDefinition, + type SurfaceType, } from '../index' const expression = { @@ -66,7 +71,64 @@ const definition: AvatarDefinition = { animationOrder: ['idle'], } +const pathPoints = (value: string): [number, number][] => + Array.from(value.matchAll(/[ML](-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g), match => [ + Number(match[1]), + Number(match[2]), + ]) + +const pathSelfIntersects = (value: string) => { + const points = pathPoints(value) + const orientation = ( + [ax, ay]: [number, number], + [bx, by]: [number, number], + [cx, cy]: [number, number] + ) => (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + + for (let first = 0; first < points.length; first += 1) { + const firstNext = (first + 1) % points.length + for (let second = first + 2; second < points.length; second += 1) { + const secondNext = (second + 1) % points.length + if (secondNext === first) continue + const abC = orientation(points[first], points[firstNext], points[second]) + const abD = orientation(points[first], points[firstNext], points[secondNext]) + const cdA = orientation(points[second], points[secondNext], points[first]) + const cdB = orientation(points[second], points[secondNext], points[firstNext]) + if (abC * abD < -0.000001 && cdA * cdB < -0.000001) return true + } + } + return false +} + describe('@bible-strong/avatar-core', () => { + it('renders every adapted OneWorks surface with finite body and eye geometry', () => { + const types: SurfaceType[] = [ + 'ellipse', + 'square', + 'rounded', + 'teardrop', + 'trapezoid', + 'frustum', + 'half-cone', + ] + const neutral = poseFromExpression(expressionFromDefinition('neutral', expression)) + + types.forEach(type => { + const surface = surfacePresets[type] + const sample = surfaceSampleAt(surface, 0.4, -0.25) + const face = surfaceFrontSampleAt(surface, 18, -12) + const geometry = renderAvatar(neutral, surface) + + expect( + [...sample.point, ...sample.normal, ...face.point, ...face.normal].every(Number.isFinite) + ).toBe(true) + expect(geometry.headPath).toContain('M') + expect(geometry.leftPath).toContain('M') + expect(geometry.rightPath).toContain('M') + expect(JSON.stringify(geometry)).not.toContain('NaN') + }) + }) + it('loads a JSON definition and resolves an explicit semantic animation', () => { const parsed = parseAvatarDefinition(JSON.stringify(definition)) expect(parsed.ok).toBe(true) @@ -241,6 +303,74 @@ describe('@bible-strong/avatar-core', () => { expect(scene.colors).toEqual(definition.colors) }) + it('interpolates joyful eye curvature with the standard expression fields', () => { + const regular = poseFromExpression(expressionFromDefinition('neutral', expression)) + const joyful = poseFromExpression( + expressionFromDefinition('joyful', { + ...expression, + eyes: { + ...expression.eyes, + left: { ...expression.eyes.left, curvature: 1 }, + right: { ...expression.eyes.right, curvature: 1 }, + }, + }) + ) + + const halfway = interpolatePose(regular, joyful, 0.5) + + expect(halfway.expression.curvatureLeft).toBe(0.5) + expect(halfway.expression.curvatureRight).toBe(0.5) + }) + + it('keeps curved eyes from folding over themselves while morphing into tall eyes', () => { + const joyful = poseFromExpression( + expressionFromDefinition('joyful', { + ...expression, + eyes: { + left: { width: 54, height: 15, x: 0, y: -18, angle: 0, curvature: 1 }, + right: { width: 54, height: 15, x: 0, y: -18, angle: 0, curvature: 1 }, + spacing: 69.276, + }, + }) + ) + const tallTargets = [ + { left: [51.4, 50.1, 0], right: [50.5, 49.4, 0], spacing: 69 }, + { left: [28.8, 51.4, 0], right: [17.3, 42.7, 90], spacing: 56.6 }, + ] as const + const surface = bodyFromDefinition(definition.body).primary + + tallTargets.forEach(target => { + const targetPose = poseFromExpression( + expressionFromDefinition('target', { + ...expression, + eyes: { + left: { + width: target.left[0], + height: target.left[1], + x: 0, + y: 0, + angle: target.left[2], + }, + right: { + width: target.right[0], + height: target.right[1], + x: 0, + y: 0, + angle: target.right[2], + }, + spacing: target.spacing, + }, + }) + ) + + for (let step = 0; step <= 100; step += 1) { + const geometry = renderAvatar(interpolatePose(joyful, targetPose, step / 100), surface, 1) + expect(pathSelfIntersects(geometry.leftPath)).toBe(false) + expect(pathSelfIntersects(geometry.rightPath)).toBe(false) + } + }) + }) + it('does not alias cached geometry for surfaces that differ beyond four decimals', () => { const narrow: AvatarDefinition = { ...definition, diff --git a/packages/avatar-core/src/avatarDefinition.schema.json b/packages/avatar-core/src/avatarDefinition.schema.json index a02b1f4..248dd50 100644 --- a/packages/avatar-core/src/avatarDefinition.schema.json +++ b/packages/avatar-core/src/avatarDefinition.schema.json @@ -71,11 +71,19 @@ "mickey", "cursor", "cube", + "ellipse", + "square", + "rounded", "capsule", "cylinder", "cone", + "teardrop", + "trapezoid", + "frustum", + "half-cone", "diamond", - "ghost" + "ghost", + "apple-bite" ] }, "width": { "$ref": "#/$defs/dimension" }, @@ -84,7 +92,9 @@ "roundness": { "$ref": "#/$defs/roundness" }, "morphRoundness": { "$ref": "#/$defs/roundness" }, "tipRoundness": { "$ref": "#/$defs/roundness" }, - "baseRoundness": { "$ref": "#/$defs/roundness" } + "baseRoundness": { "$ref": "#/$defs/roundness" }, + "topScale": { "type": "number", "minimum": 0.05, "maximum": 2 }, + "cutAngle": { "type": "number", "minimum": -360, "maximum": 360 } } }, "nodeSurface": { @@ -93,7 +103,23 @@ { "type": "object", "properties": { - "type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] } + "type": { + "enum": [ + "sphere", + "cube", + "ellipse", + "square", + "rounded", + "capsule", + "cylinder", + "cone", + "teardrop", + "trapezoid", + "frustum", + "half-cone", + "diamond" + ] + } } } ] @@ -133,6 +159,7 @@ "required": ["surface", "position", "rotation"], "properties": { "layer": { "enum": ["auto", "front", "back"] }, + "color": { "$ref": "#/$defs/hexColor" }, "surface": { "$ref": "#/$defs/nodeSurface" }, "position": { "$ref": "#/$defs/position" }, "rotation": { "$ref": "#/$defs/rotation" } @@ -159,7 +186,8 @@ "height": { "$ref": "#/$defs/boundedNumber" }, "x": { "$ref": "#/$defs/boundedNumber" }, "y": { "$ref": "#/$defs/boundedNumber" }, - "angle": { "$ref": "#/$defs/boundedNumber" } + "angle": { "$ref": "#/$defs/boundedNumber" }, + "curvature": { "type": "number", "minimum": 0, "maximum": 1 } } }, "expression": { @@ -238,7 +266,7 @@ "expression": { "$ref": "#/$defs/semanticKey" }, "holdMs": { "type": "number", "minimum": 100, "maximum": 60000 }, "transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 }, - "transition": { "enum": ["spring", "smooth", "snappy"] } + "transition": { "enum": ["spring", "gentleSpring", "smooth", "snappy"] } } } }, diff --git a/packages/avatar-core/src/avatarDefinition.ts b/packages/avatar-core/src/avatarDefinition.ts index 7c9e83a..4b1bfbb 100644 --- a/packages/avatar-core/src/avatarDefinition.ts +++ b/packages/avatar-core/src/avatarDefinition.ts @@ -41,14 +41,17 @@ export type SurfaceDefinition = { morphRoundness?: number tipRoundness?: number baseRoundness?: number + topScale?: number + cutAngle?: number } -export type BodyNodeSurfaceType = Exclude +export type BodyNodeSurfaceType = Exclude export type PrimarySurfaceDefinition = SurfaceDefinition export type BodyNodeSurfaceDefinition = SurfaceDefinition export type AvatarBodyNodeDefinition = { layer?: 'auto' | 'front' | 'back' + color?: HexColor surface: BodyNodeSurfaceDefinition position: [number, number, number] rotation: [number, number, number] @@ -62,8 +65,15 @@ export type AvatarBodyDefinition = { export type AvatarExpressionDefinition = { head: { x: number; y: number; z: number } eyes: { - left: { width: number; height: number; x: number; y: number; angle: number } - right: { width: number; height: number; x: number; y: number; angle: number } + left: { width: number; height: number; x: number; y: number; angle: number; curvature?: number } + right: { + width: number + height: number + x: number + y: number + angle: number + curvature?: number + } spacing: number } perspective: number @@ -78,7 +88,7 @@ export type AvatarAnimationStepDefinition = { expression: ExpressionKey holdMs: number transitionMs: number - transition: 'spring' | 'smooth' | 'snappy' + transition: 'spring' | 'gentleSpring' | 'smooth' | 'snappy' } export type AvatarAnimationDefinition = { diff --git a/packages/avatar-core/src/body.ts b/packages/avatar-core/src/body.ts index 99db4c7..8fc69db 100644 --- a/packages/avatar-core/src/body.ts +++ b/packages/avatar-core/src/body.ts @@ -5,6 +5,8 @@ export type BodyVector = readonly [number, number, number] export type BodyNode = { id: string name: string + layer?: 'auto' | 'front' | 'back' + color?: string surface: SurfaceConfig position: BodyVector rotation: BodyVector @@ -18,9 +20,16 @@ export type AvatarBody = { export const bodyPrimitiveTypes = [ 'sphere', 'cube', + 'ellipse', + 'square', + 'rounded', 'capsule', 'cylinder', 'cone', + 'teardrop', + 'trapezoid', + 'frustum', + 'half-cone', 'diamond', ] as const @@ -31,6 +40,8 @@ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) const vector = (value: unknown): value is BodyVector => Array.isArray(value) && value.length === 3 && value.every(finite) +const hexColor = (value: unknown): value is string => + typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => { if (!value || typeof value !== 'object') return { ...fallback } @@ -46,6 +57,8 @@ export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): Sur return { ...fallback } if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness)) return { ...fallback } + if (candidate.topScale !== undefined && !finite(candidate.topScale)) return { ...fallback } + if (candidate.cutAngle !== undefined && !finite(candidate.cutAngle)) return { ...fallback } return { ...preset, ...candidate, type } } @@ -80,6 +93,11 @@ export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): .slice(0, MAX_BODY_NODES) .map(node => ({ ...node, + layer: + node.layer === 'front' || node.layer === 'back' || node.layer === 'auto' + ? node.layer + : undefined, + color: hexColor(node.color) ? node.color : undefined, surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]), })) : [] diff --git a/packages/avatar-core/src/geometry.ts b/packages/avatar-core/src/geometry.ts index db5ff1c..84a8ac4 100644 --- a/packages/avatar-core/src/geometry.ts +++ b/packages/avatar-core/src/geometry.ts @@ -22,6 +22,8 @@ export type Expression = { widthRight: number heightLeft: number heightRight: number + curvatureLeft: number + curvatureRight: number spacing: number positionXLeft: number positionXRight: number @@ -98,6 +100,8 @@ export const expressionFields: ExpressionNumericField[] = [ 'widthRight', 'heightLeft', 'heightRight', + 'curvatureLeft', + 'curvatureRight', 'spacing', 'positionXLeft', 'positionXRight', @@ -519,6 +523,8 @@ const surfaceCacheKey = (surface: SurfaceConfig) => surface.morphRoundness, surface.tipRoundness, surface.baseRoundness, + surface.topScale, + surface.cutAngle, ]) const cacheSurfaceValue = (cache: Map, key: string, value: Value) => { @@ -569,12 +575,33 @@ const eyePoints = ( const width = expression[`width${suffix}`] const restingHeight = expression[`height${suffix}`] const height = 5 + (restingHeight - 5) * blink + const curvature = expression[`curvature${suffix}`] + const cornerRadius = Math.min(width / 2, height / 2) + const segmentHalf = Math.max(width / 2 - cornerRadius, 0) + const desiredCurveDepth = Math.max(9, width * 0.38) * curvature * blink + // Keep the offset capsule below the cusp limit of the curved centerline. + // Without this bound, morphing a curved horizontal eye into a tall eye makes + // segmentHalf approach zero while its slope tends to infinity, folding the + // outline over itself for a few frames. + const maximumSafeCurveDepth = + segmentHalf > 0 ? (0.5 * segmentHalf ** 2) / (2 * Math.max(cornerRadius, Number.EPSILON)) : 0 + const curveDepth = Math.min(desiredCurveDepth, maximumSafeCurveDepth) const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x const centerY = expression[`positionY${suffix}`] + offset.y const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle) return roundedRectangle(width, height).map(([localX, localY]) => { - const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle) - const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle) + const curveX = segmentHalf ? clamp(localX, -segmentHalf, segmentHalf) : 0 + const normalizedX = segmentHalf ? curveX / segmentHalf : 0 + const curveY = -curveDepth * (1 - normalizedX ** 2) + const slope = segmentHalf ? (2 * curveDepth * curveX) / segmentHalf ** 2 : 0 + const tangentLength = Math.hypot(1, slope) + const tangentX = 1 / tangentLength + const tangentY = slope / tangentLength + const offsetAlongCurve = localX - curveX + const curvedX = curveX + tangentX * offsetAlongCurve - tangentY * localY + const curvedY = curveY + tangentY * offsetAlongCurve + tangentX * localY + const rotatedX = curvedX * Math.cos(angle) - curvedY * Math.sin(angle) + const rotatedY = curvedX * Math.sin(angle) + curvedY * Math.cos(angle) return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY) }) } @@ -1198,6 +1225,126 @@ const projectedGhostPath = (pose: AvatarPose, surface: SurfaceConfig) => { ].join('') } +const projectedAppleBitePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const halfWidth = surface.width / 2 + const halfHeight = surface.height / 2 + const point = (x: number, y: number) => projectLocalPoint(pose, [x, y, 0]) + const format = ([x, y]: Point3) => `${x.toFixed(2)} ${y.toFixed(2)}` + const move = (x: number, y: number) => `M${format(point(x, y))}` + const line = (x: number, y: number) => `L${format(point(x, y))}` + const cubicTo = ( + firstX: number, + firstY: number, + secondX: number, + secondY: number, + endX: number, + endY: number + ) => + `C${format(point(firstX, firstY))} ${format(point(secondX, secondY))} ${format(point(endX, endY))}` + + return [ + move(-halfWidth * 0.46, -halfHeight), + cubicTo( + -halfWidth * 0.08, + -halfHeight, + halfWidth * 0.15, + -halfHeight * 0.78, + halfWidth * 0.12, + -halfHeight * 0.54 + ), + cubicTo( + halfWidth * 0.4, + -halfHeight * 0.72, + halfWidth * 0.74, + -halfHeight * 0.75, + halfWidth, + -halfHeight * 0.56 + ), + cubicTo( + halfWidth * 0.8, + -halfHeight * 0.43, + halfWidth * 0.68, + -halfHeight * 0.24, + halfWidth * 0.68, + -halfHeight * 0.03 + ), + cubicTo( + halfWidth * 0.68, + halfHeight * 0.2, + halfWidth * 0.8, + halfHeight * 0.4, + halfWidth, + halfHeight * 0.5 + ), + cubicTo( + halfWidth * 0.82, + halfHeight * 0.83, + halfWidth * 0.54, + halfHeight, + halfWidth * 0.26, + halfHeight + ), + cubicTo( + halfWidth * 0.08, + halfHeight, + -halfWidth * 0.02, + halfHeight * 0.91, + -halfWidth * 0.16, + halfHeight * 0.91 + ), + cubicTo( + -halfWidth * 0.31, + halfHeight * 0.91, + -halfWidth * 0.4, + halfHeight, + -halfWidth * 0.57, + halfHeight + ), + cubicTo( + -halfWidth * 0.89, + halfHeight, + -halfWidth, + halfHeight * 0.67, + -halfWidth, + halfHeight * 0.3 + ), + cubicTo( + -halfWidth, + -halfHeight * 0.16, + -halfWidth * 0.8, + -halfHeight * 0.58, + -halfWidth * 0.49, + -halfHeight * 0.7 + ), + cubicTo( + -halfWidth * 0.28, + -halfHeight * 0.78, + -halfWidth * 0.03, + -halfHeight * 0.69, + halfWidth * 0.08, + -halfHeight * 0.55 + ), + line(-halfWidth * 0.1, -halfHeight * 0.66), + cubicTo( + -halfWidth * 0.12, + -halfHeight * 0.82, + -halfWidth * 0.22, + -halfHeight * 0.88, + -halfWidth * 0.35, + -halfHeight * 0.84 + ), + cubicTo( + -halfWidth * 0.41, + -halfHeight * 0.84, + -halfWidth * 0.45, + -halfHeight * 0.93, + -halfWidth * 0.46, + -halfHeight + ), + 'Z', + ].join('') +} + const headPath = (pose: AvatarPose, surface: SurfaceConfig) => { if (surface.type === 'sphere' || surface.type === 'mickey') { const exactPath = projectedEllipsoidPath(pose, surface) @@ -1215,6 +1362,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => { if (surface.type === 'cube') return projectedCubePath(pose, surface) if (surface.type === 'diamond') return projectedDiamondPath(pose, surface) if (surface.type === 'ghost') return projectedGhostPath(pose, surface) + if (surface.type === 'apple-bite') return projectedAppleBitePath(pose, surface) const key = surfaceCacheKey(surface) let localSamples = headSamplesCache.get(key) @@ -1306,8 +1454,8 @@ const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => { depth, front: node.layer === 'front' || - (node.layer !== 'back' && - depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO), + (node.layer !== 'back' && + depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO), } }) .sort((left, right) => left.depth - right.depth) diff --git a/packages/avatar-core/src/runtime.ts b/packages/avatar-core/src/runtime.ts index 65522c1..92aff19 100644 --- a/packages/avatar-core/src/runtime.ts +++ b/packages/avatar-core/src/runtime.ts @@ -224,6 +224,10 @@ const easing = ( const progress = Math.max(0, Math.min(1, value)) if (transition === 'smooth') return progress * progress * (3 - 2 * progress) if (transition === 'snappy') return 1 - (1 - progress) ** 3 + if (transition === 'gentleSpring') { + const end = 1 - Math.exp(-9) * Math.cos(5) + return (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) / end + } const end = 1 - Math.exp(-6) * Math.cos(8) return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end)) } diff --git a/packages/avatar-core/src/scene.ts b/packages/avatar-core/src/scene.ts index f1746c1..c27b221 100644 --- a/packages/avatar-core/src/scene.ts +++ b/packages/avatar-core/src/scene.ts @@ -20,6 +20,8 @@ export const expressionFromDefinition = ( widthRight: expression.eyes.right.width, heightLeft: expression.eyes.left.height, heightRight: expression.eyes.right.height, + curvatureLeft: expression.eyes.left.curvature ?? 0, + curvatureRight: expression.eyes.right.curvature ?? 0, spacing: expression.eyes.spacing, positionXLeft: expression.eyes.left.x, positionXRight: expression.eyes.right.x, @@ -39,6 +41,8 @@ export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({ nodes: body.nodes.map((node, index) => ({ id: `runtime-node-${index}`, name: `Runtime node ${index + 1}`, + ...(node.layer ? { layer: node.layer } : {}), + ...(node.color ? { color: node.color } : {}), surface: { ...node.surface }, position: [...node.position], rotation: [...node.rotation], diff --git a/packages/avatar-core/src/surfaces.ts b/packages/avatar-core/src/surfaces.ts index 62a7360..6a203f7 100644 --- a/packages/avatar-core/src/surfaces.ts +++ b/packages/avatar-core/src/surfaces.ts @@ -1,7 +1,23 @@ import type { Point3 } from './geometry' export type SurfaceType = - 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' | 'ghost' + | 'sphere' + | 'mickey' + | 'cursor' + | 'cube' + | 'ellipse' + | 'square' + | 'rounded' + | 'capsule' + | 'cylinder' + | 'cone' + | 'teardrop' + | 'trapezoid' + | 'frustum' + | 'half-cone' + | 'diamond' + | 'ghost' + | 'apple-bite' export type SurfaceConfig = { type: SurfaceType @@ -12,6 +28,8 @@ export type SurfaceConfig = { morphRoundness?: number tipRoundness?: number baseRoundness?: number + topScale?: number + cutAngle?: number } export type SurfaceSample = { @@ -24,6 +42,9 @@ export const surfacePresets: Record = { mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 }, cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 }, cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 }, + ellipse: { type: 'ellipse', width: 245, height: 189, depth: 195, roundness: 1 }, + square: { type: 'square', width: 240, height: 240, depth: 196, roundness: 0.6 }, + rounded: { type: 'rounded', width: 240, height: 240, depth: 211, roundness: 1 }, capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 }, cylinder: { type: 'cylinder', @@ -43,8 +64,33 @@ export const surfacePresets: Record = { tipRoundness: 0.55, baseRoundness: 0.45, }, + teardrop: { type: 'teardrop', width: 230, height: 258, depth: 195, roundness: 0.78 }, + trapezoid: { + type: 'trapezoid', + width: 250, + height: 232, + depth: 204, + roundness: 1.44, + topScale: 0.82, + }, + frustum: { + type: 'frustum', + width: 250, + height: 250, + depth: 223, + roundness: 0.48, + }, + 'half-cone': { + type: 'half-cone', + width: 250, + height: 250, + depth: 223, + roundness: 0.48, + cutAngle: 0, + }, diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 }, ghost: { type: 'ghost', width: 250, height: 245, depth: 190, roundness: 1 }, + 'apple-bite': { type: 'apple-bite', width: 240, height: 250, depth: 190, roundness: 1 }, } export const surfaceLabels: Record = { @@ -52,16 +98,80 @@ export const surfaceLabels: Record = { mickey: 'Mickey', cursor: 'Curseur', cube: 'Cube', + ellipse: 'Ellipse', + square: 'Carré', + rounded: 'Arrondi', capsule: 'Capsule', cylinder: 'Cylindre', cone: 'Cône', + teardrop: 'Goutte', + trapezoid: 'Trapèze', + frustum: 'Tronc de cône', + 'half-cone': 'Demi-cône', diamond: 'Diamant', ghost: 'Ghost', + 'apple-bite': 'Apple Bite', } const signedPower = (value: number, exponent: number) => Math.sign(value) * Math.abs(value) ** exponent +const interpolate = (from: number, to: number, progress: number) => from + (to - from) * progress + +const ONEWORKS_SHAPE_EXPONENTS = { + rounded: 0.5, + square: 0.3, + teardrop: 0.78, +} as const + +const oneworksSuperellipsoid = ( + config: SurfaceConfig, + longitude: number, + latitude: number, + exponent: number +): Point3 => { + const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent + return [ + (config.width / 2) * latitudeFactor * signedPower(Math.sin(longitude), exponent), + (config.height / 2) * signedPower(Math.sin(latitude), exponent), + (config.depth / 2) * latitudeFactor * signedPower(Math.cos(longitude), exponent), + ] +} + +const oneworksTeardrop = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => { + const exponent = ONEWORKS_SHAPE_EXPONENTS.teardrop + const vertical = signedPower(Math.sin(latitude), exponent) + const progress = Math.max(0, Math.min(1, (vertical + 1) / 2)) + const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent + const widthTaper = interpolate(0.46, 1.24, progress) + const depthTaper = interpolate(0.7, 1.12, progress) + return [ + (config.width / 2) * widthTaper * latitudeFactor * signedPower(Math.sin(longitude), exponent), + (config.height / 2) * vertical, + (config.depth / 2) * depthTaper * latitudeFactor * signedPower(Math.cos(longitude), exponent), + ] +} + +const trapezoidExponent = (config: SurfaceConfig) => + interpolate(0.34, 0.76, clampRoundness(config.roundness) / 2) + +const oneworksTrapezoid = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => { + const exponent = trapezoidExponent(config) + const vertical = signedPower(Math.sin(latitude), exponent) + const progress = Math.max(0, Math.min(1, (vertical + 1) / 2)) + const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent + const horizontalTaper = interpolate(config.topScale ?? 0.82, 1.08, progress) + const depthTaper = interpolate(0.92, 1.04, progress) + return [ + (config.width / 2) * + horizontalTaper * + latitudeFactor * + signedPower(Math.sin(longitude), exponent), + (config.height / 2) * vertical, + (config.depth / 2) * depthTaper * latitudeFactor * signedPower(Math.cos(longitude), exponent), + ] +} + const superellipsoid = ( longitude: number, latitude: number, @@ -285,6 +395,28 @@ const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile = const morphedConeProfileAt = (config: SurfaceConfig, progress: number) => morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress)) +const oneworksFrustumProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => { + const clampedProgress = Math.max(0, Math.min(1, progress)) + const easedProgress = clampedProgress * clampedProgress * (3 - 2 * clampedProgress) + const roundedProgress = interpolate( + clampedProgress, + easedProgress, + (clampRoundness(config.roundness) / 2) * 0.55 + ) + return { + radiusScale: interpolate(0.46, 1, roundedProgress), + verticalProgress: clampedProgress, + } +} + +const oneworksHalfConeProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => { + const clampedProgress = Math.max(0, Math.min(1, progress)) + return { + radiusScale: clampedProgress ** interpolate(1, 0.56, clampRoundness(config.roundness) / 2), + verticalProgress: clampedProgress, + } +} + export const cursorLayout = (config: SurfaceConfig) => { const coneHeight = config.height * 0.36 const bodyHeight = config.height - coneHeight @@ -308,6 +440,12 @@ export const surfacePointAt = ( case 'sphere': case 'mickey': return superellipsoid(longitude, latitude, width, height, depth, 1, 1) + case 'ellipse': + return oneworksSuperellipsoid(config, longitude, latitude, 1) + case 'square': + return oneworksSuperellipsoid(config, longitude, latitude, ONEWORKS_SHAPE_EXPONENTS.square) + case 'rounded': + return oneworksSuperellipsoid(config, longitude, latitude, ONEWORKS_SHAPE_EXPONENTS.rounded) case 'cube': return cube(config, longitude, latitude) case 'cylinder': { @@ -337,8 +475,13 @@ export const surfacePointAt = ( } case 'diamond': return diamond(config, longitude, latitude) + case 'teardrop': + return oneworksTeardrop(config, longitude, latitude) + case 'trapezoid': + return oneworksTrapezoid(config, longitude, latitude) case 'capsule': case 'ghost': + case 'apple-bite': return capsule(config, longitude, latitude) case 'cone': { const progress = (latitude + Math.PI / 2) / Math.PI @@ -349,6 +492,25 @@ export const surfacePointAt = ( (depth / 2) * profile.radiusScale * Math.cos(longitude), ] } + case 'frustum': { + const progress = (latitude + Math.PI / 2) / Math.PI + const profile = oneworksFrustumProfileAt(config, progress) + return [ + (width / 2) * profile.radiusScale * Math.sin(longitude), + -height / 2 + height * profile.verticalProgress, + (depth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + case 'half-cone': { + const progress = (latitude + Math.PI / 2) / Math.PI + const profile = oneworksHalfConeProfileAt(config, progress) + const sampledLongitude = ((config.cutAngle ?? 0) * Math.PI) / 180 + longitude / 2 + return [ + (width / 2) * profile.radiusScale * Math.sin(sampledLongitude), + -height / 2 + height * profile.verticalProgress, + (depth / 2) * profile.radiusScale * Math.cos(sampledLongitude), + ] + } } } @@ -435,6 +597,12 @@ const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => { return normal } +const oneworksSuperellipsoidNormal = ( + config: SurfaceConfig, + point: Point3, + parameterExponent: number +) => lpNormal(config, point, 2 / parameterExponent) + const lpFrontSample = ( config: SurfaceConfig, x: number, @@ -486,6 +654,39 @@ const ellipsoidFrontSample = ( } } +const oneworksProfileFrontSample = ( + config: SurfaceConfig, + x: number, + y: number, + profile: 'teardrop' | 'trapezoid' +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + const exponent = + profile === 'teardrop' ? ONEWORKS_SHAPE_EXPONENTS.teardrop : trapezoidExponent(config) + const vertical = Math.max(-1, Math.min(1, y / radiusY)) + const sineLatitude = signedPower(vertical, 1 / exponent) + const latitude = Math.asin(Math.max(-1, Math.min(1, sineLatitude))) + const progress = Math.max(0, Math.min(1, (vertical + 1) / 2)) + const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent + const widthTaper = + profile === 'teardrop' + ? interpolate(0.46, 1.24, progress) + : interpolate(config.topScale ?? 0.82, 1.08, progress) + const depthTaper = + profile === 'teardrop' ? interpolate(0.7, 1.12, progress) : interpolate(0.92, 1.04, progress) + const sectionRadiusX = radiusX * widthTaper * latitudeFactor + const sectionRadiusZ = radiusZ * depthTaper * latitudeFactor + const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x)) + const normalizedX = sectionRadiusX > 0 ? surfaceX / sectionRadiusX : 0 + const sineLongitude = signedPower(Math.max(-1, Math.min(1, normalizedX)), 1 / exponent) + const longitude = Math.asin(Math.max(-1, Math.min(1, sineLongitude))) + const z = sectionRadiusZ * signedPower(Math.max(Math.cos(longitude), 0), exponent) + const point: Point3 = [surfaceX, vertical * radiusY, z] + return { point, normal: tangentNormalAt(config, longitude, latitude) } +} + const radialProfileFrontSample = ( config: SurfaceConfig, x: number, @@ -532,13 +733,25 @@ export const surfaceFrontSampleAt = ( switch (config.type) { case 'sphere': case 'mickey': + case 'ellipse': return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ) + case 'square': + return lpFrontSample(config, x, y, 2 / ONEWORKS_SHAPE_EXPONENTS.square, (surface, point) => + oneworksSuperellipsoidNormal(surface, point, ONEWORKS_SHAPE_EXPONENTS.square) + ) + + case 'rounded': + return lpFrontSample(config, x, y, 2 / ONEWORKS_SHAPE_EXPONENTS.rounded, (surface, point) => + oneworksSuperellipsoidNormal(surface, point, ONEWORKS_SHAPE_EXPONENTS.rounded) + ) + case 'cube': return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal) case 'capsule': - case 'ghost': { + case 'ghost': + case 'apple-bite': { const capRadiusY = Math.min(radiusX, radiusY) const straightHalf = Math.max(0, radiusY - capRadiusY) const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y @@ -572,6 +785,18 @@ export const surfaceFrontSampleAt = ( case 'cone': return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1) + case 'frustum': + return radialProfileFrontSample(config, x, y, oneworksFrustumProfileAt, 1) + + case 'half-cone': + return radialProfileFrontSample(config, x, y, oneworksHalfConeProfileAt, 1) + + case 'teardrop': + return oneworksProfileFrontSample(config, x, y, 'teardrop') + + case 'trapezoid': + return oneworksProfileFrontSample(config, x, y, 'trapezoid') + case 'diamond': return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal) } @@ -586,7 +811,7 @@ export const surfaceNormalAt = ( // An ellipsoid has a cheap exact normal. This is also the overwhelmingly // common path for the default spherical head. - if (config.type === 'sphere' || config.type === 'mickey') { + if (config.type === 'sphere' || config.type === 'mickey' || config.type === 'ellipse') { const halfWidth = config.width / 2 || 1 const halfHeight = config.height / 2 || 1 const halfDepth = config.depth / 2 || 1 @@ -613,6 +838,14 @@ export const surfaceNormalAt = ( return cubeNormal(config, point) } + if (config.type === 'square') { + return oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.square) + } + + if (config.type === 'rounded') { + return oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.rounded) + } + return tangentNormalAt(config, longitude, latitude) } @@ -623,7 +856,7 @@ export const surfaceSampleAt = ( ): SurfaceSample => { const point = surfacePointAt(config, longitude, latitude) - if (config.type === 'sphere' || config.type === 'mickey') { + if (config.type === 'sphere' || config.type === 'mickey' || config.type === 'ellipse') { const halfWidth = config.width / 2 || 1 const halfHeight = config.height / 2 || 1 const halfDepth = config.depth / 2 || 1 @@ -662,6 +895,20 @@ export const surfaceSampleAt = ( } } + if (config.type === 'square') { + return { + point, + normal: oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.square), + } + } + + if (config.type === 'rounded') { + return { + point, + normal: oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.rounded), + } + } + return { point, normal: tangentNormalAt(config, longitude, latitude), diff --git a/packages/avatar-web/src/__tests__/avatar-web-test.ts b/packages/avatar-web/src/__tests__/avatar-web-test.ts index 745fbde..75433f5 100644 --- a/packages/avatar-web/src/__tests__/avatar-web-test.ts +++ b/packages/avatar-web/src/__tests__/avatar-web-test.ts @@ -34,6 +34,46 @@ describe('@bible-strong/avatar-web', () => { expect(document.querySelector('#avatar svg')).toBeNull() }) + it('tints fixed-color body parts when an expression overrides the body color', () => { + const tintedDefinition = { + ...definitionJson, + body: { + ...definitionJson.body, + nodes: [ + { + layer: 'back', + color: '#a95f47', + surface: { + type: 'ellipse', + width: 40, + height: 68, + depth: 36, + roundness: 1, + }, + position: [-64, -80, -18], + rotation: [-4, -7, 8], + }, + ], + }, + expressions: { + ...definitionJson.expressions, + neutral: { + ...definitionJson.expressions.neutral, + colors: { body: '#ba3636' }, + }, + }, + } + + const avatar = createAvatar('#avatar', { + definition: tintedDefinition, + defaultExpression: 'neutral', + }) + const firstBodyPart = document.querySelector('#avatar svg > path') + + expect(firstBodyPart?.getAttribute('fill')).toBe('#ba3636') + avatar.destroy() + }) + it('returns typed errors for unknown targets', () => { const avatar = createAvatar('#avatar', { definition: definitionJson }) diff --git a/packages/avatar-web/src/index.ts b/packages/avatar-web/src/index.ts index d14ceed..80080f6 100644 --- a/packages/avatar-web/src/index.ts +++ b/packages/avatar-web/src/index.ts @@ -145,7 +145,19 @@ export function createAvatar( if (onError) onError(error) else console.error(`[Avatar] ${error.message}`) } - const paint = (scene: ReturnType) => { + const colorForNodeId = ( + id: string | null | undefined, + fallback: string, + bodyColorOverride: boolean + ) => { + if (bodyColorOverride) return fallback + if (!id?.startsWith('runtime-node-')) return fallback + const index = Number(id.slice('runtime-node-'.length)) + return definition.body.nodes[index]?.color ?? fallback + } + const paint = (scene: ReturnType, bodyColorOverride = false) => { + host.style.setProperty('--bs-avatar-body-color', scene.colors.body) + host.style.setProperty('--bs-avatar-eye-color', scene.colors.eyes) clipHeadPath.setAttribute('d', scene.geometry.headPath) headPath.setAttribute('d', scene.geometry.headPath) headPath.setAttribute('fill', scene.colors.body) @@ -157,11 +169,23 @@ export function createAvatar( rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0') backPaths.forEach((element, index) => { element.setAttribute('d', scene.geometry.backPaths[index] ?? '') - element.setAttribute('fill', scene.colors.body) + const color = colorForNodeId( + scene.geometry.backNodeIds[index], + scene.colors.body, + bodyColorOverride + ) + element.setAttribute('fill', color) + element.style.setProperty('--bs-avatar-node-color', color) }) frontPaths.forEach((element, index) => { element.setAttribute('d', scene.geometry.frontPaths[index] ?? '') - element.setAttribute('fill', scene.colors.body) + const color = colorForNodeId( + scene.geometry.frontNodeIds[index], + scene.colors.body, + bodyColorOverride + ) + element.setAttribute('fill', color) + element.style.setProperty('--bs-avatar-node-color', color) }) } @@ -180,7 +204,10 @@ export function createAvatar( const renderCurrent = (now: number) => { const environment = runtimeEnvironment() paintedFrame = sampleAvatarFrame(definition, playback, now, environment) - paint(renderAvatarFrame(definition, playback, now, environment)) + paint( + renderAvatarFrame(definition, playback, now, environment), + Boolean(definition.expressions[playback.activeExpression]?.colors?.body) + ) notifyExpression() } const tick = (now: number) => { @@ -260,7 +287,10 @@ export function createAvatar( playback = createAvatarPlaybackState() if (frameRequest !== null) cancelAnimationFrame(frameRequest) frameRequest = null - paint(renderAvatarDefinition(definition)) + paint( + renderAvatarDefinition(definition), + Boolean(definition.expressions.neutral.colors?.body) + ) notifyExpression() }, getState() { @@ -293,7 +323,7 @@ export function createAvatar( renderCurrent(performance.now()) } } else { - paint(initialScene) + paint(initialScene, Boolean(definition.expressions.neutral.colors?.body)) paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment()) notifyExpression() } diff --git a/src/app/styles.css b/src/app/styles.css index 808fe80..ab5fe5c 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -70,8 +70,43 @@ input { min-height: 100vh; overflow-x: clip; } +.studio-catalog-only { + display: block; + min-height: 100vh; +} +.studio-catalog-only .inspector { + width: 100%; + min-height: 100vh; + border-left: 0; +} +.studio-catalog-only .inspector-identity { + position: sticky; + z-index: 20; + top: 0; + display: flex; + min-height: 64px; + margin: -28px -26px 24px; + padding: 0 28px; + border-bottom: 1px solid #d9dce3; + background: rgb(245 246 248 / 94%); + backdrop-filter: blur(14px); +} +.studio-catalog-only .inspector-identity .brand { + top: 0; + margin-left: 0; + color: #17191d; +} +.studio-catalog-only .inspector-identity .brand em { + color: #767d88; +} +.studio-catalog-only .inspector-identity .language-picker { + top: 0; + margin-right: 0; +} .stage-column { + --avatar-interior-color: #000000; + position: sticky; top: 0; display: flex; @@ -81,7 +116,7 @@ input { justify-content: center; overflow: hidden; padding: 28px; - background: #101316; + background: #000000; color: white; } @@ -90,14 +125,14 @@ input { width: 70vw; height: 70vw; border-radius: 50%; - background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%); + background: none; content: ''; pointer-events: none; } .photo-mode-active::before { width: 82vw; height: 82vw; - background: radial-gradient(circle, rgb(91 127 229 / 24%), transparent 66%); + background: none; } .photo-live-frame { position: relative; @@ -448,6 +483,7 @@ input { .is-pixel-rendered .avatar-head, .is-pixel-rendered .avatar-eye { fill: transparent; + stroke: transparent; transition: none; } .is-pixel-rendered .avatar-head.cyan-outline, @@ -1177,19 +1213,35 @@ input { } .avatar-head, .preview-head { - fill: var(--avatar-body-color, #5b7fe5); + fill: var(--avatar-interior-color, #101316); + stroke: var(--avatar-node-color, var(--avatar-body-color, #5b7fe5)); + stroke-width: var(--avatar-outline-width, 10); + stroke-linecap: round; + stroke-linejoin: round; } .avatar-eye, .preview-eye { - fill: var(--avatar-eye-color, #111316); + fill: var(--avatar-eye-color, #ffffff); } .avatar-head, .avatar-eye { - transition: fill 420ms cubic-bezier(0.22, 1, 0.36, 1); + transition: + fill 420ms cubic-bezier(0.22, 1, 0.36, 1), + stroke 420ms cubic-bezier(0.22, 1, 0.36, 1), + stroke-width 420ms cubic-bezier(0.22, 1, 0.36, 1); } .avatar-eye { cursor: pointer; } +.is-filled-vector .avatar-head { + fill: var(--avatar-node-color, var(--avatar-body-color, #5b7fe5)); +} +.is-filled-vector .avatar-eye { + fill: var(--avatar-eye-color, #101316); +} +.avatar-preview { + --avatar-interior-color: #ffffff; +} .cyan-outline { stroke: #51ddff; stroke-width: 2.6; @@ -1935,9 +1987,18 @@ p { font-size: 8px; } .avatar-shelf .avatar-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.studio-catalog-only .mode-page-transition, +.studio-catalog-only .avatar-page, +.studio-catalog-only .avatar-shelf { + width: 100%; + max-width: none; +} +.studio-catalog-only .avatar-shelf .avatar-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} .avatar-shelf .avatar-card, .avatar-shelf .avatar-add { height: auto; @@ -1969,6 +2030,9 @@ p { min-height: 0; flex: 1 1 auto; } +.avatar-preview-live { + pointer-events: none; +} .avatar-shelf .avatar-card > span { min-height: 12px; padding: 2px 4px; diff --git a/src/features/animation/__tests__/sequences-test.ts b/src/features/animation/__tests__/sequences-test.ts index 863ba33..e1e5574 100644 --- a/src/features/animation/__tests__/sequences-test.ts +++ b/src/features/animation/__tests__/sequences-test.ts @@ -3,6 +3,7 @@ import { createInitialSequences, duplicateSequence, getSequenceSpring, + scaleSpringDynamics, normalizeSequencesForExpressions, parseSequences, remapSequencesAfterExpressionDelete, @@ -11,7 +12,8 @@ import { initialExpressions } from '@/features/avatar/presets' describe('editable avatar sequences', () => { it('creates the idle animation with editable steps and blink settings', () => { - const idle = createInitialSequences().find(sequence => sequence.id === 'idle') + const sequences = createInitialSequences() + const idle = sequences.find(sequence => sequence.id === 'idle') expect(idle?.steps.map(step => step.expressionId)).toEqual([ initialExpressions[0].id, @@ -19,6 +21,41 @@ describe('editable avatar sequences', () => { ]) expect(idle?.steps[0].holdMs).toBe(5200) expect(idle?.blink.durationMs).toBe(280) + expect(sequences).toHaveLength(24) + }) + + it('creates a saved joyful animation that morphs between expressions 11 and 27', () => { + const joyful = createInitialSequences().find(sequence => sequence.id === 'joyful') + + expect(joyful?.steps).toEqual([ + { + id: 'joyful-step-0', + expressionId: 'expression-11', + holdMs: 2300, + transitionMs: 700, + transition: 'gentleSpring', + }, + { + id: 'joyful-step-1', + expressionId: 'expression-joyful-arc', + holdMs: 2300, + transitionMs: 700, + transition: 'gentleSpring', + }, + ]) + expect(joyful?.playbackMode).toBe('loop') + expect(joyful?.blink.enabled).toBe(false) + }) + + it('uses the motion expressions as the second Angry and Scared steps', () => { + const sequences = createInitialSequences() + + expect( + sequences.find(sequence => sequence.id === 'angry')?.steps.map(step => step.expressionId) + ).toEqual(['expression-07', 'expression-3d2bed26-f97c-477d-922f-77600cb10e92']) + expect( + sequences.find(sequence => sequence.id === 'scared')?.steps.map(step => step.expressionId) + ).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8']) }) it('supports loop, once and ping-pong playback cursors', () => { @@ -96,6 +133,16 @@ describe('editable avatar sequences', () => { expect(smooth.damping).toBeGreaterThan(0) }) + it('time-scales spring dynamics without changing their motion character', () => { + const spring = getSequenceSpring('smooth', 500, 7) + + expect(scaleSpringDynamics(spring, 1)).toEqual(spring) + expect(scaleSpringDynamics(spring, 2)).toEqual({ + stiffness: spring.stiffness * 4, + damping: spring.damping * 2, + }) + }) + it('clears the public semantic key when an animation is duplicated', () => { const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' } diff --git a/src/features/animation/components/SequenceWorkspace.tsx b/src/features/animation/components/SequenceWorkspace.tsx index 32c8cc1..3923504 100644 --- a/src/features/animation/components/SequenceWorkspace.tsx +++ b/src/features/animation/components/SequenceWorkspace.tsx @@ -380,6 +380,7 @@ export function SequenceWorkspace({ value={selectedStep.transition} items={[ { value: 'spring', label: t('Ressort') }, + { value: 'gentleSpring', label: t('Ressort léger') }, { value: 'smooth', label: t('Douce') }, { value: 'snappy', label: t('Rapide') }, ]} @@ -392,6 +393,7 @@ export function SequenceWorkspace({ {t('Ressort')} + {t('Ressort léger')} {t('Douce')} {t('Rapide')} diff --git a/src/features/animation/sequences.ts b/src/features/animation/sequences.ts index 5c05261..dd28989 100644 --- a/src/features/animation/sequences.ts +++ b/src/features/animation/sequences.ts @@ -8,7 +8,7 @@ import { import type { Expression } from '../avatar/geometry' export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong' -export type SequenceTransition = 'spring' | 'smooth' | 'snappy' +export type SequenceTransition = 'spring' | 'gentleSpring' | 'smooth' | 'snappy' export type SequenceStep = { id: string @@ -45,7 +45,7 @@ export type SequenceCursor = { } const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong'] -const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy'] +const transitions: SequenceTransition[] = ['spring', 'gentleSpring', 'smooth', 'snappy'] const builtInSequenceIds = new Set(Object.values(stateGroups).flat()) const finite = (value: unknown, fallback: number, min: number, max: number) => @@ -79,10 +79,10 @@ export const createInitialSequences = (): AvatarSequence[] => id: `${id}-step-${index}`, expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id, holdMs: playback.expressionIntervalMs, - transitionMs: 500, - transition: 'smooth', + transitionMs: id === 'joyful' ? 700 : 500, + transition: id === 'joyful' ? 'gentleSpring' : 'smooth', })), - blink: { enabled: true, ...playback.blink }, + blink: { enabled: id !== 'joyful', ...playback.blink }, } }) ) @@ -283,10 +283,30 @@ export const getSequenceSpring = ( baseSpeed: number ) => { const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3) - const styleFactor = transition === 'smooth' ? 0.72 : transition === 'snappy' ? 1.45 : 1 + const styleFactor = + transition === 'smooth' + ? 0.72 + : transition === 'snappy' + ? 1.45 + : transition === 'gentleSpring' + ? 0.9 + : 1 const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5) return { stiffness: 70 + speed * 24, - damping: (17 + speed * 1.7) * (transition === 'smooth' ? 1.18 : 1), + damping: + (17 + speed * 1.7) * + (transition === 'smooth' ? 1.18 : transition === 'gentleSpring' ? 1.2 : 1), + } +} + +export const scaleSpringDynamics = ( + spring: Readonly<{ stiffness: number; damping: number }>, + timeScale: number +) => { + const boundedTimeScale = Math.max(timeScale, 0.01) + return { + stiffness: spring.stiffness * boundedTimeScale ** 2, + damping: spring.damping * boundedTimeScale, } } diff --git a/src/features/avatar/__tests__/avatar-definition-test.ts b/src/features/avatar/__tests__/avatar-definition-test.ts index 4e17616..1c5d009 100644 --- a/src/features/avatar/__tests__/avatar-definition-test.ts +++ b/src/features/avatar/__tests__/avatar-definition-test.ts @@ -486,9 +486,9 @@ describe('Studio to avatar definition conversion', () => { expect(result.ok).toBe(true) if (!result.ok) return - expect(result.value.expressionOrder).toHaveLength(28) + expect(result.value.expressionOrder).toHaveLength(31) expect(result.value.expressionOrder[0]).toBe('neutral') - expect(result.value.animationOrder).toHaveLength(23) + expect(result.value.animationOrder).toHaveLength(25) expect(result.value.animationOrder).toContain('idle') expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([ 'upward-side-glance', @@ -624,7 +624,7 @@ describe('Studio to avatar definition conversion', () => { createAvatarDefinition({ avatar, behavior }) expect(JSON.stringify({ avatar, behavior })).toBe(before) - expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' }) + expect(defaultAvatarColors).toEqual({ body: '#7c89ff', eyes: '#7c89ff' }) }) }) diff --git a/src/features/avatar/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts index 3ca19b3..ce09a39 100644 --- a/src/features/avatar/__tests__/avatars-test.ts +++ b/src/features/avatar/__tests__/avatars-test.ts @@ -6,11 +6,13 @@ import { createAvatar, createUnkeyedExpressionCopy, defaultAvatarEyes, + ensurePrimitiveBundledAvatars, parseAvatarEyeDefaults, parseAvatarRenderStyle, resolveAvatarBehavior, } from '@/features/avatar/avatars' import { initialExpressions } from '@/features/avatar/presets' +import { surfacePresets } from '@/features/avatar/surfaces' describe('avatar eye defaults', () => { it('clears the public semantic key when creating custom content from a preset', () => { @@ -61,6 +63,135 @@ describe('avatar render style', () => { ).toEqual({ type: 'vector' }) expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' }) }) + + it('preserves the filled treatment for imported vector skins', () => { + expect(parseAvatarRenderStyle({ type: 'vector', filled: true })).toEqual({ + type: 'vector', + filled: true, + }) + }) +}) + +describe('OneWorks animal construction', () => { + const createBundledLibrary = () => { + const seed = createAvatar('Seed') + seed.id = 'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e' + return ensurePrimitiveBundledAvatars({ activeAvatarId: seed.id, avatars: [seed] }) + } + + it('keeps the source occlusion order for every imported animal', () => { + const library = createBundledLibrary() + const layers = (id: string) => + library.avatars.find(avatar => avatar.id === id)?.body.nodes.map(node => node.layer) + + expect(layers('oneworks-cat')).toBeUndefined() + expect(layers('oneworks-dog')).toEqual(['front', 'front']) + expect(layers('oneworks-bear')).toEqual(['back', 'back']) + expect(layers('oneworks-rabbit')).toEqual(['back', 'back']) + expect(layers('oneworks-bun')).toEqual(['front']) + }) + + it('keeps the unfinished piglet hidden and removes a persisted copy', () => { + const library = createBundledLibrary() + const stored = { + ...library, + activeAvatarId: 'memento-piglet', + avatars: [ + ...library.avatars, + { + ...createAvatar('Порося'), + id: 'memento-piglet', + body: { + primary: { ...surfacePresets.sphere }, + nodes: [], + }, + }, + ], + } + + const restored = ensurePrimitiveBundledAvatars(stored) + + expect(restored.avatars.some(avatar => avatar.id === 'memento-piglet')).toBe(false) + expect(restored.activeAvatarId).not.toBe('memento-piglet') + }) + + it('removes Cow Cat from a previously persisted library', () => { + const library = createBundledLibrary() + const cowCat = { ...createAvatar('Cow Cat'), id: 'oneworks-cat-cow' } + const restored = ensurePrimitiveBundledAvatars({ + ...library, + activeAvatarId: cowCat.id, + avatars: [...library.avatars, cowCat], + }) + + expect(restored.avatars.some(avatar => avatar.id === cowCat.id)).toBe(false) + expect(restored.activeAvatarId).not.toBe(cowCat.id) + }) + + it('repairs the previous persisted dog-ear layer and placement', () => { + const library = createBundledLibrary() + const stored = { + ...library, + avatars: library.avatars.map(avatar => + avatar.id !== 'oneworks-dog' + ? avatar + : { + ...avatar, + body: { + ...avatar.body, + nodes: avatar.body.nodes.map(node => ({ + ...node, + layer: 'back' as const, + position: [node.position[0] < 0 ? -72 : 72, -52, 0] as const, + })), + }, + } + ), + } + + const dog = ensurePrimitiveBundledAvatars(stored).avatars.find( + avatar => avatar.id === 'oneworks-dog' + )! + + expect(dog.body.nodes.map(node => node.layer)).toEqual(['front', 'front']) + expect(dog.body.nodes.map(node => node.position)).toEqual([ + [-82, -65, 0], + [82, -65, 0], + ]) + }) + + it('moves previously persisted bear ears farther outside the head', () => { + const library = createBundledLibrary() + const stored = { + ...library, + avatars: library.avatars.map(avatar => + avatar.id !== 'oneworks-bear' + ? avatar + : { + ...avatar, + body: { + ...avatar.body, + nodes: avatar.body.nodes.map(node => ({ + ...node, + position: + node.id === 'bear-ear-left' + ? ([-58, -72, -18] as const) + : ([58, -70, -20] as const), + })), + }, + } + ), + } + + const bear = ensurePrimitiveBundledAvatars(stored).avatars.find( + avatar => avatar.id === 'oneworks-bear' + )! + + expect(bear.body.nodes.map(node => node.position)).toEqual([ + [-64, -80, -18], + [64, -78, -20], + ]) + }) }) describe('avatar behavior library', () => { diff --git a/src/features/avatar/__tests__/generate-strobi-fixture-test.ts b/src/features/avatar/__tests__/generate-strobi-fixture-test.ts index 7d29639..c50561d 100644 --- a/src/features/avatar/__tests__/generate-strobi-fixture-test.ts +++ b/src/features/avatar/__tests__/generate-strobi-fixture-test.ts @@ -3,10 +3,11 @@ import { resolve } from 'node:path' import { createAvatarDefinition } from '../avatarDefinition' import { resolveAvatarBehavior } from '../avatars' -import { loadStudioDocument } from '../../studio/studioDocument' +import defaultStudioDocument from '../../studio/defaultStudioDocument.json' +import type { StudioDocument } from '../../studio/studioDocument' it('keeps the consumer fixture synchronized with the bundled Strobi Studio document', async () => { - const document = loadStudioDocument({ getItem: () => null }) + const document = defaultStudioDocument as unknown as StudioDocument const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi') if (!avatar) throw new Error('Bundled Strobi avatar not found') const behavior = resolveAvatarBehavior(avatar, { diff --git a/src/features/avatar/avatarDefinition.ts b/src/features/avatar/avatarDefinition.ts index ecea833..e45d66e 100644 --- a/src/features/avatar/avatarDefinition.ts +++ b/src/features/avatar/avatarDefinition.ts @@ -30,6 +30,8 @@ const mapSurface = ( ...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }), ...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }), ...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }), + ...(surface.topScale === undefined ? {} : { topScale: surface.topScale }), + ...(surface.cutAngle === undefined ? {} : { cutAngle: surface.cutAngle }), }) const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({ @@ -38,6 +40,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({ left: { width: expression.widthLeft, height: expression.heightLeft, + curvature: expression.curvatureLeft, x: expression.positionXLeft, y: expression.positionYLeft, angle: expression.leftAngle, @@ -45,6 +48,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({ right: { width: expression.widthRight, height: expression.heightRight, + curvature: expression.curvatureRight, x: expression.positionXRight, y: expression.positionYRight, angle: expression.rightAngle, @@ -170,6 +174,8 @@ export const createAvatarDefinition = ({ body: { primary: mapSurface(avatar.body.primary), nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({ + ...(node.layer ? { layer: node.layer } : {}), + ...(node.color ? { color: node.color as HexColor } : {}), surface: mapSurface(node.surface as SurfaceDefinition), position: [...node.position], rotation: [...node.rotation], diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts index f36e6cb..ad23ffa 100644 --- a/src/features/avatar/avatars.ts +++ b/src/features/avatar/avatars.ts @@ -31,7 +31,8 @@ export type PixelRenderStyle = { type: 'pixel' resolution: number } -export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle +export type VectorRenderStyle = { type: 'vector'; filled?: boolean } +export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle export type AvatarEyeDefaults = Pick< Expression, | 'widthLeft' @@ -66,6 +67,8 @@ export const defaultAvatarColors: AvatarColors = { eyes: deslopAccentColors.indigo, } export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' } +export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true } +const filledAvatarEyeColor = '#111316' export const defaultPixelRenderStyle: PixelRenderStyle = { type: 'pixel', resolution: 64, @@ -150,6 +153,19 @@ const bundledAvatarColorMigrations: Record [id, index])) +// Remove hidden bundled characters from both fresh and previously persisted local libraries. +const hiddenBundledAvatarIds = new Set([ + 'memento-piglet', + 'oneworks-cat', + 'oneworks-cat-cow', +]) + const retiredBundledAvatarIds = new Set([ + 'memento-arctic-fox', 'strobi', 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed', 'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd', @@ -175,6 +211,14 @@ const retiredBundledAvatarIds = new Set([ 'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0', ]) +const legacyBundledAvatarNames: Record = { + 'oneworks-cat': 'OneWorks Cat', + 'oneworks-dog': 'OneWorks Dog', + 'oneworks-bear': 'OneWorks Bear', + 'oneworks-rabbit': 'OneWorks Rabbit', + 'oneworks-bun': 'OneWorks Bun', +} + const primitiveBundledAvatarSpecs = [ ['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1], ['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78], @@ -182,23 +226,584 @@ const primitiveBundledAvatarSpecs = [ ['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82], ['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82], ['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1], - ['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 1], + ['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9], + ['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9], ] as const -const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({ - expressions: (behavior?.expressions ?? initialExpressions).map(expression => { +const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => { + const storedExpressions = behavior?.expressions ?? initialExpressions + const baseExpressions = storedExpressions.filter( + expression => !expression.semanticKey?.startsWith('scared-state-') + ) + const expressions = baseExpressions.map((expression, index) => { const fixedColorExpression = { ...expression } - delete fixedColorExpression.bodyColor - delete fixedColorExpression.eyeColor + const bundledExpression = + initialExpressions.find(candidate => candidate.semanticKey === expression.semanticKey) ?? + initialExpressions[index] + if (expression.semanticKey === 'angry-brows' || expression.semanticKey === 'uneasy-left') { + if (bundledExpression?.bodyColor) { + fixedColorExpression.bodyColor = bundledExpression.bodyColor + } else { + delete fixedColorExpression.bodyColor + } + if (bundledExpression?.eyeColor) { + fixedColorExpression.eyeColor = bundledExpression.eyeColor + } else { + delete fixedColorExpression.eyeColor + } + } else { + delete fixedColorExpression.bodyColor + delete fixedColorExpression.eyeColor + } return fixedColorExpression - }), - sequences: (behavior?.sequences ?? createInitialSequences()).map(sequence => ({ + }) + const expressionById = new Map(expressions.map(expression => [expression.id, expression])) + const sourceSequences = behavior?.sequences ?? createInitialSequences() + const scaredSequence = sourceSequences.find(sequence => sequence.semanticKey === 'scared') + const scaredBodyColor = + initialExpressions.find(expression => expression.semanticKey === 'uneasy-left')?.bodyColor ?? + '#adc3ff' + const scaredExpressions = (scaredSequence?.steps ?? []).flatMap(step => { + const baseId = step.expressionId.replace(/-scared-state$/, '') + const source = expressionById.get(baseId) + if (!source) return [] + return [ + { + ...source, + id: `${baseId}-scared-state`, + semanticKey: `scared-state-${(source.semanticKey ?? source.id).replace(/^scared-state-/, '')}`, + bodyColor: scaredBodyColor, + }, + ] + }) + const sequences = sourceSequences.map(sequence => ({ + ...sequence, + steps: sequence.steps.map(step => ({ + ...step, + expressionId: + sequence.semanticKey === 'scared' + ? `${step.expressionId.replace(/-scared-state$/, '')}-scared-state` + : step.expressionId, + })), + blink: { ...sequence.blink }, + })) + + return { expressions: [...expressions, ...scaredExpressions], sequences } +} + +const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => { + const sourceExpressions = (behavior?.expressions ?? initialExpressions).filter( + expression => + expression.semanticKey !== 'onboarding-listening' && + expression.semanticKey !== 'onboarding-curious' + ) + const expressions = sourceExpressions.map(expression => { + const fixedColorExpression = { ...expression } + const bundledExpression = initialExpressions.find( + candidate => candidate.semanticKey === expression.semanticKey + ) + if (expression.semanticKey === 'angry-brows') { + fixedColorExpression.bodyColor = bundledExpression?.bodyColor + fixedColorExpression.eyeColor = bundledExpression?.eyeColor + } else { + delete fixedColorExpression.bodyColor + delete fixedColorExpression.eyeColor + } + return fixedColorExpression + }) + const listeningSource = expressions.find(expression => expression.semanticKey === 'downward-gaze') + const curiousSource = expressions.find(expression => expression.semanticKey === 'small-attentive') + const onboardingExpressions: Expression[] = + listeningSource && curiousSource + ? [ + { + ...listeningSource, + id: 'memento-onboarding-listening', + semanticKey: 'onboarding-listening', + headX: -22, + positionYLeft: -50, + positionYRight: -50, + }, + { + ...curiousSource, + id: 'memento-onboarding-curious', + semanticKey: 'onboarding-curious', + headX: -12, + positionYLeft: -22, + positionYRight: -22, + }, + ] + : [] + const sequences = (behavior?.sequences ?? createInitialSequences()).map(sequence => ({ ...sequence, steps: sequence.steps.map(step => ({ ...step })), blink: { ...sequence.blink }, - })), + })) + return { + expressions: [...expressions, ...onboardingExpressions], + sequences: [ + ...sequences.filter(sequence => sequence.semanticKey !== 'onboarding'), + ...(onboardingExpressions.length === 2 + ? [ + { + id: 'memento-onboarding', + semanticKey: 'onboarding', + name: 'onboarding', + group: 'Memento', + description: 'Alternates attentive listening with curiosity.', + builtIn: true, + playbackMode: 'loop' as const, + steps: [ + { + id: 'memento-onboarding-listening-step', + expressionId: 'memento-onboarding-listening', + holdMs: 1200, + transitionMs: 350, + transition: 'smooth' as const, + }, + { + id: 'memento-onboarding-curious-step', + expressionId: 'memento-onboarding-curious', + holdMs: 1200, + transitionMs: 350, + transition: 'smooth' as const, + }, + ], + blink: { + enabled: true, + initialDelayMs: 2600, + minIntervalMs: 3400, + maxIntervalMs: 6200, + durationMs: 280, + }, + }, + ] + : []), + ], + } +} + +type OneWorksShape = + | 'sphere' + | 'ellipse' + | 'square' + | 'rounded' + | 'capsule' + | 'teardrop' + | 'diamond' + | 'trapezoid' + | 'cone' + | 'frustum' + | 'half-cone' + +// Adapted from OneWorks Avatar's MIT-licensed parametric shape catalog. +// See avatar-lab/THIRD_PARTY_NOTICES.md for attribution and source details. +const oneWorksShapeDimensions: Record< + OneWorksShape, + { width: number; height: number; depth: number } +> = { + sphere: { width: 278, height: 278, depth: 278 }, + ellipse: { width: 306, height: 236, depth: 244 }, + square: { width: 264, height: 264, depth: 216 }, + rounded: { width: 264, height: 264, depth: 232 }, + capsule: { width: 300, height: 218, depth: 218 }, + teardrop: { width: 264, height: 296, depth: 224 }, + diamond: { width: 278, height: 278, depth: 212 }, + trapezoid: { width: 284, height: 264, depth: 232 }, + cone: { width: 278, height: 278, depth: 248 }, + frustum: { width: 278, height: 278, depth: 248 }, + 'half-cone': { width: 278, height: 278, depth: 248 }, +} + +const oneWorksSurfaceType: Record = { + sphere: 'sphere', + ellipse: 'ellipse', + square: 'square', + rounded: 'rounded', + // OneWorks' capsule is a wide superellipsoid, not a vertical stadium. + capsule: 'rounded', + teardrop: 'teardrop', + diamond: 'diamond', + trapezoid: 'trapezoid', + cone: 'cone', + frustum: 'frustum', + 'half-cone': 'half-cone', +} + +const oneWorksSurface = ( + shape: OneWorksShape, + scaleX = 1, + scaleY = scaleX, + scaleZ = Math.min(scaleX, scaleY), + options: Partial<(typeof surfacePresets)[keyof typeof surfacePresets]> = {} +) => { + const type = oneWorksSurfaceType[shape] + const dimensions = oneWorksShapeDimensions[shape] + return { + ...surfacePresets[type], + width: dimensions.width * scaleX, + height: dimensions.height * scaleY, + depth: dimensions.depth * scaleZ, + ...(shape === 'diamond' ? { roundness: 1.3 } : {}), + ...(shape === 'trapezoid' ? { roundness: 1.44, topScale: 0.82 } : {}), + ...(shape === 'cone' + ? { roundness: 0.48, morphRoundness: 0, tipRoundness: 1, baseRoundness: 0.35 } + : {}), + ...options, + type, + } +} + +const oneWorksNode = ( + id: string, + name: string, + shape: OneWorksShape, + position: readonly [number, number, number], + scaleX: number, + scaleY: number, + options: { + scaleZ?: number + rotation?: readonly [number, number, number] + surface?: Partial<(typeof surfacePresets)[keyof typeof surfacePresets]> + layer?: 'auto' | 'front' | 'back' + color?: string + } = {} +) => ({ + id, + name, + layer: options.layer ?? ('back' as const), + ...(options.color ? { color: options.color } : {}), + surface: oneWorksSurface(shape, scaleX, scaleY, options.scaleZ, options.surface), + position: [...position] as [number, number, number], + rotation: [...(options.rotation ?? [0, 0, 0])] as [number, number, number], }) +const oneWorksAvatar = ( + id: string, + name: string, + shape: OneWorksShape, + color: string +): StudioAvatar => ({ + id, + name, + body: { primary: oneWorksSurface(shape, 0.84), nodes: [] }, + colors: { body: color, eyes: color }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...defaultAvatarRenderStyle }, +}) + +const oneWorksOriginalAnimalColors: Record = { + 'oneworks-cat': { body: '#f7f7f4', eyes: '#050608' }, + 'oneworks-cat-siamese': { body: '#ead7b8', eyes: '#281913' }, + 'oneworks-cat-british-shorthair': { body: '#b89a6b', eyes: '#35291f' }, + 'oneworks-cat-russian-blue': { body: '#718493', eyes: '#17232c' }, + 'oneworks-cat-orange-tabby': { body: '#d98a35', eyes: '#3b2416' }, + 'oneworks-cat-black': { body: '#111419', eyes: '#eef2f5' }, + 'oneworks-dog': { body: '#d4d0c8', eyes: '#211f1d' }, + 'oneworks-bear': { body: '#a95f47', eyes: '#2b1d18' }, + 'oneworks-rabbit': { body: '#eee9df', eyes: '#292724' }, + 'oneworks-bun': { body: '#fff3d9', eyes: '#241915' }, +} + +const oneWorksBrownDogColors: AvatarColors = { + body: '#e3b17f', + eyes: '#2b1d18', +} + +const oneWorksBundledAnimalColors: Record = { + ...oneWorksOriginalAnimalColors, + 'oneworks-dog-brown': oneWorksBrownDogColors, +} + +const mementoAnimalColors: Record = { + 'memento-piglet': { body: deslopAccentColors.pink, eyes: filledAvatarEyeColor }, +} + +const oneWorksCatAvatar = ( + id: string, + name: string, + colors: AvatarColors, + earWidth: number, + earHeight: number, + earColor = colors.body +): StudioAvatar => { + const earScaleX = 0.24 * (earWidth / 104) + const earScaleY = 0.29 * (earHeight / 114) + return { + id, + name, + body: { + primary: oneWorksSurface('ellipse', 0.73, 0.68), + nodes: [ + oneWorksNode('cat-ear-left', 'Left ear', 'cone', [-56, -78, -8], earScaleX, earScaleY, { + rotation: [-7, -13, -9], + surface: { roundness: 0.96 }, + color: earColor, + }), + oneWorksNode( + 'cat-ear-right', + 'Right ear', + 'cone', + [56, -78, -10], + earScaleX * 0.96, + earScaleY * 0.97, + { + rotation: [-6, 13, 9], + surface: { roundness: 1.04 }, + color: earColor, + } + ), + ], + }, + colors: { ...colors }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...filledAvatarRenderStyle }, + behavior: fixedSkinColorBehavior(), + } +} + +const oneWorksDogAvatar = ( + id: string, + name: string, + colors: AvatarColors, + earColor: string +): StudioAvatar => ({ + id, + name, + body: { + primary: oneWorksSurface('trapezoid', 0.72, 0.8, 0.72, { + roundness: 1.68, + topScale: 0.68, + }), + nodes: [ + oneWorksNode('dog-ear-left', 'Left ear', 'teardrop', [-82, -65, 0], 0.18, 0.34, { + scaleZ: 0.18, + rotation: [-4, -10, 22], + layer: 'front', + color: earColor, + }), + oneWorksNode('dog-ear-right', 'Right ear', 'teardrop', [82, -65, 0], 0.18, 0.34, { + scaleZ: 0.18, + rotation: [-4, 10, -22], + layer: 'front', + color: earColor, + }), + ], + }, + colors: { ...colors }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...filledAvatarRenderStyle }, + behavior: fixedSkinColorBehavior(), +}) + +const oneWorksBundledAvatars: StudioAvatar[] = [ + oneWorksAvatar('oneworks-sphere', 'OneWorks Sphere', 'sphere', deslopAccentColors.cyan), + oneWorksAvatar('oneworks-ellipse', 'OneWorks Ellipse', 'ellipse', deslopAccentColors.teal), + oneWorksAvatar('oneworks-square', 'OneWorks Square', 'square', deslopAccentColors.brown), + oneWorksAvatar('oneworks-rounded', 'OneWorks Rounded', 'rounded', deslopAccentColors.blue), + oneWorksAvatar('oneworks-capsule', 'OneWorks Capsule', 'capsule', deslopAccentColors.green), + oneWorksAvatar('oneworks-teardrop', 'OneWorks Teardrop', 'teardrop', deslopAccentColors.pink), + oneWorksAvatar('oneworks-diamond', 'OneWorks Diamond', 'diamond', deslopAccentColors.purple), + oneWorksAvatar('oneworks-trapezoid', 'OneWorks Trapezoid', 'trapezoid', deslopAccentColors.mint), + oneWorksAvatar('oneworks-cone', 'OneWorks Cone', 'cone', deslopAccentColors.red), + oneWorksAvatar('oneworks-frustum', 'OneWorks Frustum', 'frustum', deslopAccentColors.yellow), + oneWorksAvatar( + 'oneworks-half-cone', + 'OneWorks Half Cone', + 'half-cone', + deslopAccentColors.indigo + ), + { + id: 'oneworks-cloud', + name: 'OneWorks Cloud', + body: { + primary: oneWorksSurface('ellipse', 0.83, 0.66), + nodes: [ + oneWorksNode('cloud-crown-left', 'Crown left', 'sphere', [-91, -48, -42], 0.38, 0.38), + oneWorksNode('cloud-crown-center', 'Crown center', 'rounded', [-20, -79, -34], 0.48, 0.46), + oneWorksNode('cloud-crown-right', 'Crown right', 'sphere', [65, -55, -47], 0.34, 0.37), + oneWorksNode('cloud-edge-left', 'Left edge', 'rounded', [-122, 13, -26], 0.41, 0.36), + oneWorksNode('cloud-edge-right', 'Right edge', 'sphere', [119, 20, -29], 0.37, 0.34), + oneWorksNode('cloud-base-left', 'Base left', 'capsule', [-65, 72, -38], 0.47, 0.27), + oneWorksNode('cloud-base-right', 'Base right', 'capsule', [52, 70, -36], 0.52, 0.29), + ], + }, + colors: { body: deslopAccentColors.cyan, eyes: deslopAccentColors.cyan }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...defaultAvatarRenderStyle }, + }, + { + id: 'oneworks-sun', + name: 'OneWorks Sun', + body: { + primary: oneWorksSurface('sphere', 0.72), + nodes: Array.from({ length: 8 }, (_, index) => { + const angle = (index * Math.PI) / 4 + return oneWorksNode( + `sun-ray-${index + 1}`, + `Ray ${index + 1}`, + index % 2 === 0 ? 'diamond' : 'sphere', + [Math.cos(angle) * 125, Math.sin(angle) * 125, -18], + 0.14, + 0.14 + ) + }), + }, + colors: { body: deslopAccentColors.yellow, eyes: deslopAccentColors.yellow }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...defaultAvatarRenderStyle }, + }, + oneWorksCatAvatar('oneworks-cat', 'Cat', oneWorksOriginalAnimalColors['oneworks-cat'], 104, 114), + oneWorksCatAvatar( + 'oneworks-cat-siamese', + 'Siamese', + oneWorksOriginalAnimalColors['oneworks-cat-siamese'], + 104, + 114, + '#3c2118' + ), + oneWorksCatAvatar( + 'oneworks-cat-british-shorthair', + 'British Shorthair', + oneWorksOriginalAnimalColors['oneworks-cat-british-shorthair'], + 90, + 86 + ), + oneWorksCatAvatar( + 'oneworks-cat-russian-blue', + 'Russian Blue', + oneWorksOriginalAnimalColors['oneworks-cat-russian-blue'], + 104, + 116 + ), + oneWorksCatAvatar( + 'oneworks-cat-orange-tabby', + 'Orange Tabby', + oneWorksOriginalAnimalColors['oneworks-cat-orange-tabby'], + 100, + 100 + ), + oneWorksCatAvatar( + 'oneworks-cat-black', + 'Black Cat', + oneWorksOriginalAnimalColors['oneworks-cat-black'], + 102, + 106 + ), + oneWorksDogAvatar( + 'oneworks-dog', + 'Dog', + oneWorksOriginalAnimalColors['oneworks-dog'], + deslopAccentColors.brown + ), + oneWorksDogAvatar('oneworks-dog-brown', 'Brown Dog', oneWorksBrownDogColors, '#a95f47'), + { + id: 'oneworks-bear', + name: 'Bear', + body: { + primary: oneWorksSurface('trapezoid', 0.74, 0.72, 0.72, { roundness: 1.56 }), + nodes: [ + oneWorksNode('bear-ear-left', 'Left ear', 'ellipse', [-64, -80, -18], 0.2, 0.34, { + rotation: [-4, -7, 8], + color: '#a95f47', + }), + oneWorksNode('bear-ear-right', 'Right ear', 'ellipse', [64, -78, -20], 0.19, 0.33, { + rotation: [-3, 8, -7], + color: '#a95f47', + }), + ], + }, + colors: { ...oneWorksOriginalAnimalColors['oneworks-bear'] }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...filledAvatarRenderStyle }, + behavior: fixedSkinColorBehavior(), + }, + { + id: 'oneworks-rabbit', + name: 'Rabbit', + body: { + primary: oneWorksSurface('trapezoid', 0.72, 0.74, 0.72, { + roundness: 2, + topScale: 0.94, + }), + nodes: [ + oneWorksNode('rabbit-ear-left', 'Left ear', 'trapezoid', [-50, -76, -22], 0.18, 0.6, { + rotation: [-3, -5, -8], + surface: { roundness: 2, topScale: 0.9 }, + }), + oneWorksNode('rabbit-ear-right', 'Right ear', 'trapezoid', [48, -79, -24], 0.17, 0.62, { + rotation: [-4, 7, 11], + surface: { roundness: 2, topScale: 0.9 }, + }), + ], + }, + colors: { ...oneWorksOriginalAnimalColors['oneworks-rabbit'] }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...filledAvatarRenderStyle }, + behavior: fixedSkinColorBehavior(), + }, + { + id: 'oneworks-bun', + name: 'Bun', + body: { + primary: oneWorksSurface('sphere', 0.7, 0.5, 0.7), + nodes: [ + oneWorksNode('bun-crown', 'Rounded bun crown', 'cone', [0, -46, -14], 0.5, 0.23, { + scaleZ: 0.5, + layer: 'front', + surface: { roundness: 0.92, topScale: 0.82 }, + }), + ], + }, + colors: { ...oneWorksOriginalAnimalColors['oneworks-bun'] }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...filledAvatarRenderStyle }, + behavior: fixedSkinColorBehavior(), + }, +] + +const mementoPigletAvatar: StudioAvatar = { + id: 'memento-piglet', + name: 'Порося', + body: { + primary: oneWorksSurface('trapezoid', 0.72, 0.8, 0.72, { + roundness: 1.68, + topScale: 0.68, + }), + nodes: [ + oneWorksNode('piglet-ear-left', 'Left ear', 'ellipse', [-57, -76, -18], 0.18, 0.29, { + rotation: [-4, -7, 8], + color: deslopAccentColors.red, + }), + oneWorksNode('piglet-ear-right', 'Right ear', 'ellipse', [57, -75, -20], 0.18, 0.29, { + rotation: [-3, 8, -7], + color: deslopAccentColors.red, + }), + oneWorksNode('piglet-snout', 'Snout', 'ellipse', [0, 44, 76], 0.25, 0.16, { + scaleZ: 0.1, + layer: 'front', + color: deslopAccentColors.red, + }), + oneWorksNode('piglet-nostril-left', 'Left nostril', 'ellipse', [-14, 44, 90], 0.035, 0.048, { + scaleZ: 0.025, + rotation: [0, 0, -5], + layer: 'front', + color: filledAvatarEyeColor, + }), + oneWorksNode('piglet-nostril-right', 'Right nostril', 'ellipse', [14, 44, 90], 0.035, 0.048, { + scaleZ: 0.025, + rotation: [0, 0, 5], + layer: 'front', + color: filledAvatarEyeColor, + }), + ], + }, + colors: { ...mementoAnimalColors['memento-piglet'] }, + eyes: { ...defaultAvatarEyes }, + renderStyle: { ...filledAvatarRenderStyle }, + behavior: fixedSkinColorBehavior(), +} + const primitiveBundledAvatars: StudioAvatar[] = [ ...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({ id, @@ -212,7 +817,10 @@ const primitiveBundledAvatars: StudioAvatar[] = [ }, nodes: [], }, - colors: { body: color, eyes: color }, + colors: { + body: color, + eyes: surface === 'ghost' || surface === 'apple-bite' ? filledAvatarEyeColor : color, + }, eyes: surface === 'ghost' ? { @@ -220,10 +828,24 @@ const primitiveBundledAvatars: StudioAvatar[] = [ positionYLeft: defaultAvatarEyes.positionYLeft - 20, positionYRight: defaultAvatarEyes.positionYRight - 20, } - : { ...defaultAvatarEyes }, - renderStyle: { ...defaultAvatarRenderStyle }, - ...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}), + : surface === 'apple-bite' + ? { + ...defaultAvatarEyes, + positionXLeft: defaultAvatarEyes.positionXLeft - 18, + positionXRight: defaultAvatarEyes.positionXRight - 18, + } + : { ...defaultAvatarEyes }, + renderStyle: + surface === 'ghost' || surface === 'apple-bite' + ? { ...filledAvatarRenderStyle } + : { ...defaultAvatarRenderStyle }, + ...(surface === 'ghost' + ? { behavior: mementoBehavior() } + : surface === 'apple-bite' + ? { behavior: fixedSkinColorBehavior() } + : {}), })), + mementoPigletAvatar, { id: 'primitive-willy', name: 'Willy', @@ -287,9 +909,26 @@ const primitiveBundledAvatars: StudioAvatar[] = [ eyes: { ...defaultAvatarEyes }, renderStyle: { ...defaultAvatarRenderStyle }, }, + ...oneWorksBundledAvatars, ] -const originalSimpleBundledAvatarIds = new Set(simpleBundledAvatarIds.slice(0, 4)) +const originalSimpleBundledAvatarIds = new Set([ + 'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e', + 'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b', + 'avatar-1786600724626', + 'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8', +]) +const filledBundledAvatarIds = new Set([ + ...Object.keys(oneWorksBundledAnimalColors), + ...Object.keys(mementoAnimalColors), + 'primitive-ghost', + 'primitive-apple-bite', +]) +const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors)) +const filledPrimitiveAvatarColors: Record = { + 'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor }, + 'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor }, +} const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({ ...avatar, @@ -305,17 +944,107 @@ const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({ colors: { ...avatar.colors }, eyes: { ...avatar.eyes }, renderStyle: { ...avatar.renderStyle }, - ...(avatar.behavior ? { behavior: mementoBehavior(avatar.behavior) } : {}), + ...(avatar.behavior + ? { + behavior: { + expressions: avatar.behavior.expressions.map(expression => ({ ...expression })), + sequences: avatar.behavior.sequences.map(sequence => ({ + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, + })), + }, + } + : {}), }) export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLibrary => { - if (!library.avatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) return library - const resizedAvatars = library.avatars.map(avatar => { + const visibleLibraryAvatars = library.avatars.filter( + avatar => !hiddenBundledAvatarIds.has(avatar.id) + ) + if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) { + const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => { + if (avatar.id === 'primitive-ghost') { + return { ...avatar, behavior: mementoBehavior(avatar.behavior) } + } + if (filledBundledAvatarIds.has(avatar.id)) { + return { ...avatar, behavior: fixedSkinColorBehavior(avatar.behavior) } + } + return avatar + }) + return { + ...library, + activeAvatarId: avatarsWithCurrentColorBehavior.some( + avatar => avatar.id === library.activeAvatarId + ) + ? library.activeAvatarId + : (avatarsWithCurrentColorBehavior[0]?.id ?? library.activeAvatarId), + avatars: avatarsWithCurrentColorBehavior, + } + } + const resizedAvatars = visibleLibraryAvatars.map(avatar => { const bundled = primitiveBundledAvatars.find(candidate => candidate.id === avatar.id) if (!bundled) return avatar const migratedNodes = avatar.body.nodes.map(node => { const bundledNode = bundled.body.nodes.find(candidate => candidate.id === node.id) - if (!bundledNode || avatar.id !== 'primitive-willy') return node + if (!bundledNode) return node + const nodeWithOriginalColor = bundledNode.color ? { ...node, color: bundledNode.color } : node + const nodeWithOriginalLayer = oneWorksOriginalAnimalIds.has(avatar.id) + ? { ...nodeWithOriginalColor, layer: bundledNode.layer } + : nodeWithOriginalColor + const isDogEar = + ['oneworks-dog', 'oneworks-dog-brown'].includes(avatar.id) && + (node.id === 'dog-ear-left' || node.id === 'dog-ear-right') + const hasLegacyDogEarPosition = + node.position[1] === -52 || + (node.position[1] === -65 && + ((node.id === 'dog-ear-left' && node.position[0] === -92) || + (node.id === 'dog-ear-right' && node.position[0] === 92))) || + (node.position[1] === -78 && [72, 88].includes(Math.abs(node.position[0]))) || + (node.position[1] === -62 && + ((node.id === 'dog-ear-left' && node.position[0] === -88) || + (node.id === 'dog-ear-right' && [88, 132, 142, 146].includes(node.position[0])))) || + (node.position[1] === -42 && + ((node.id === 'dog-ear-left' && node.position[0] === -100) || + (node.id === 'dog-ear-right' && node.position[0] === 146))) || + (node.position[1] === -30 && + ((node.id === 'dog-ear-left' && node.position[0] === -100) || + (node.id === 'dog-ear-right' && [140, 152].includes(node.position[0])))) + const hasLegacyDogEarGeometry = isDogEar && hasLegacyDogEarPosition + const isBearEar = + avatar.id === 'oneworks-bear' && + (node.id === 'bear-ear-left' || node.id === 'bear-ear-right') + const hasLegacyBearEarPosition = + (node.id === 'bear-ear-left' && + node.position[0] === -58 && + node.position[1] === -72 && + node.position[2] === -18) || + (node.id === 'bear-ear-right' && + node.position[0] === 58 && + node.position[1] === -70 && + node.position[2] === -20) + const hasLegacyBearEarGeometry = isBearEar && hasLegacyBearEarPosition + const isPigletFaceFeature = + avatar.id === 'memento-piglet' && + ['piglet-snout', 'piglet-nostril-left', 'piglet-nostril-right'].includes(node.id) + const hasLegacyPigletFaceGeometry = + isPigletFaceFeature && + ((node.id === 'piglet-snout' && + ([8, 154].includes(node.position[2]) || + (node.position[1] === 57 && node.position[2] === 76))) || + ((node.id === 'piglet-nostril-left' || node.id === 'piglet-nostril-right') && + ([20, 176].includes(node.position[2]) || + (node.position[1] === 57 && node.position[2] === 90)))) + const nodeWithCurrentAnimalGeometry = + hasLegacyDogEarGeometry || hasLegacyBearEarGeometry || hasLegacyPigletFaceGeometry + ? { + ...nodeWithOriginalLayer, + surface: { ...bundledNode.surface }, + position: [...bundledNode.position] as typeof bundledNode.position, + rotation: [...bundledNode.rotation] as typeof bundledNode.rotation, + } + : nodeWithOriginalLayer + if (avatar.id !== 'primitive-willy') return nodeWithCurrentAnimalGeometry const isLegacyLowerSphere = (node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') && node.surface.type === 'sphere' && @@ -329,7 +1058,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib node.surface.depth === 135 && node.position[2] === 12) || (node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148)) - if (!isLegacyLowerSphere && !isLegacyTopCap) return node + if (!isLegacyLowerSphere && !isLegacyTopCap) return nodeWithCurrentAnimalGeometry return { ...bundledNode, surface: { ...bundledNode.surface }, @@ -358,8 +1087,11 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib : migratedNodes.some((node, index) => node !== avatar.body.nodes[index]) ? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } } : avatar + const legacyBundledName = legacyBundledAvatarNames[avatarWithCurrentNodes.id] const avatarWithCurrentName = - avatarWithCurrentNodes.id === 'primitive-ghost' && avatarWithCurrentNodes.name === 'Ghost' + (avatarWithCurrentNodes.id === 'primitive-ghost' && + avatarWithCurrentNodes.name === 'Ghost') || + avatarWithCurrentNodes.name === legacyBundledName ? { ...avatarWithCurrentNodes, name: bundled.name } : avatarWithCurrentNodes const avatarWithCurrentEyes = @@ -372,12 +1104,36 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib ) ? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } } : avatarWithCurrentName - const avatarWithFixedColorBehavior = - avatarWithCurrentEyes.id === 'primitive-ghost' - ? { ...avatarWithCurrentEyes, behavior: mementoBehavior(avatarWithCurrentEyes.behavior) } + const avatarWithAppleBiteEyes = + avatarWithCurrentEyes.id === 'primitive-apple-bite' && + avatarWithCurrentEyes.eyes.positionXLeft === defaultAvatarEyes.positionXLeft && + avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight + ? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } } : avatarWithCurrentEyes + const avatarWithFixedColorBehavior = + avatarWithAppleBiteEyes.id === 'primitive-ghost' + ? { + ...avatarWithAppleBiteEyes, + behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior), + } + : filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id) + ? { + ...avatarWithAppleBiteEyes, + behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior), + } + : avatarWithAppleBiteEyes + const avatarWithCurrentRenderStyle = filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id) + ? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } } + : avatarWithFixedColorBehavior + const bundledFilledColors = + mementoAnimalColors[avatarWithCurrentRenderStyle.id] ?? + oneWorksBundledAnimalColors[avatarWithCurrentRenderStyle.id] ?? + filledPrimitiveAvatarColors[avatarWithCurrentRenderStyle.id] + const avatarWithOriginalColors = bundledFilledColors + ? { ...avatarWithCurrentRenderStyle, colors: { ...bundledFilledColors } } + : avatarWithCurrentRenderStyle const original = surfacePresets[bundled.body.primary.type] - const primary = avatarWithFixedColorBehavior.body.primary + const primary = avatarWithOriginalColors.body.primary const stillUsesOriginalSize = primary.type === original.type && primary.width === original.width && @@ -389,17 +1145,25 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib primary.width === 122 && primary.height === 210 && primary.depth === 115 - if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody) return avatarWithFixedColorBehavior + const stillUsesPreviousPigletBody = + avatar.id === 'memento-piglet' && + primary.type === 'sphere' && + primary.width === 194.6 && + primary.height === 194.6 && + primary.depth === 200.16 + if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody && !stillUsesPreviousPigletBody) { + return avatarWithOriginalColors + } return { - ...avatarWithFixedColorBehavior, - body: { ...avatarWithFixedColorBehavior.body, primary: { ...bundled.body.primary } }, + ...avatarWithOriginalColors, + body: { ...avatarWithOriginalColors.body, primary: { ...bundled.body.primary } }, } }) const existingIds = new Set(resizedAvatars.map(avatar => avatar.id)) const avatars = [ ...resizedAvatars, ...primitiveBundledAvatars - .filter(avatar => !existingIds.has(avatar.id)) + .filter(avatar => !hiddenBundledAvatarIds.has(avatar.id) && !existingIds.has(avatar.id)) .map(clonePrimitiveBundledAvatar), ].sort((left, right) => { const leftOrder = simpleBundledAvatarOrder.get( @@ -413,7 +1177,13 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib if (rightOrder === undefined) return -1 return leftOrder - rightOrder }) - return { ...library, avatars } + return { + ...library, + activeAvatarId: avatars.some(avatar => avatar.id === library.activeAvatarId) + ? library.activeAvatarId + : (avatars[0]?.id ?? library.activeAvatarId), + avatars, + } } export const migrateBundledAvatarColors = (id: string, colors: AvatarColors): AvatarColors => { @@ -434,7 +1204,10 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe : fallback export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => { - const candidate = value as Partial | null + const candidate = value as { type?: unknown; filled?: unknown; resolution?: unknown } | null + if (candidate?.type === 'vector') { + return candidate.filled === true ? { type: 'vector', filled: true } : { type: 'vector' } + } if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') { return { ...defaultAvatarRenderStyle } } @@ -616,7 +1389,9 @@ export const parseAvatarLibrary = ( .filter(avatar => { if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string') return false - if (retiredBundledAvatarIds.has(avatar.id)) return false + if (retiredBundledAvatarIds.has(avatar.id) || hiddenBundledAvatarIds.has(avatar.id)) { + return false + } if (seenIds.has(avatar.id)) return false seenIds.add(avatar.id) return true diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx index 61ce31e..45c5700 100644 --- a/src/features/avatar/components/ExpressionWorkspace.tsx +++ b/src/features/avatar/components/ExpressionWorkspace.tsx @@ -1,4 +1,5 @@ import { ArrowLeft, Copy, Pencil, RotateCcw, Trash2 } from 'lucide-react' +import { motion } from 'motion/react' import { useState, type RefObject } from 'react' import { Button } from '@/components/ui/button' @@ -27,15 +28,38 @@ import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditin import { type Expression } from '@/features/avatar/geometry' import { defaultExpression } from '@/features/avatar/presets' import { type SurfaceConfig } from '@/features/avatar/surfaces' -import { StaticPixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' +import { + avatarBodyOutlineWidth, + resolveAvatarNodeColor, + useAvatarBodyColorOverride, +} from '@/features/rendering/avatarAppearance' +import { + LivePixelAvatarCanvas, + StaticPixelAvatarCanvas, +} from '@/features/rendering/components/PixelAvatarCanvas' +import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene' export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) { const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes) + const outlineWidth = avatarBodyOutlineWidth() return ( ) } @@ -59,6 +83,16 @@ export function ExpressionPreview({ }) { const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes) const resolvedColors = resolveColors(expression, colors) + const outlineWidth = avatarBodyOutlineWidth() + const filled = renderStyle.type === 'vector' && renderStyle.filled === true + const bodyFill = filled ? resolvedColors.body : 'var(--avatar-interior-color, #ffffff)' + const eyeFill = resolvedColors.eyes + const nodeColor = (id: string | null | undefined) => + resolveAvatarNodeColor({ + nodeColor: bodyNodes.find(node => node.id === id)?.color, + bodyColor: resolvedColors.body, + expressionBodyColor: expression.bodyColor, + }) if (renderStyle.type === 'pixel') { return ( - {geometry.backPaths.map((pathValue, index) => ( - - ))} - + {geometry.backPaths.map((pathValue, index) => { + const color = nodeColor(geometry.backNodeIds[index]) + return ( + + ) + })} + - {geometry.frontPaths.map((pathValue, index) => ( - { + const color = nodeColor(geometry.frontNodeIds[index]) + return ( + + ) + })} + + ) +} + +export function LiveExpressionPreview({ + scene, + colors, + baseBodyColor, + bodyNodes, + renderStyle, + expressionBodyColor, + id, +}: { + scene: RenderedScene + colors: RenderedColors + baseBodyColor: string + bodyNodes: BodyNode[] + renderStyle: AvatarRenderStyle + expressionBodyColor?: string + id: string +}) { + const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor) + + if (renderStyle.type === 'pixel') { + return ( + + ) + } + + const clipId = `live-preview-${id}` + const filled = renderStyle.filled === true + const outlineWidth = avatarBodyOutlineWidth() + const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden + const nodeColor = (nodeId: string | null | undefined) => + inheritBodyColor + ? colors.body + : (bodyNodes.find(node => node.id === nodeId)?.color ?? colors.body) + + return ( + ) } diff --git a/src/features/avatar/presets.ts b/src/features/avatar/presets.ts index b254081..819e166 100644 --- a/src/features/avatar/presets.ts +++ b/src/features/avatar/presets.ts @@ -56,9 +56,26 @@ const calibrated: number[][] = [ [-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0], ] -export const initialExpressions: Expression[] = calibrated.map( - ( - [ +export const initialExpressions: Expression[] = [ + ...calibrated.map( + ( + [ + headX, + headY, + headZ, + widthLeft, + widthRight, + heightLeft, + heightRight, + spacing, + latitude, + leftAngle, + rightAngle, + ], + index + ) => ({ + id: `expression-${String(index).padStart(2, '0')}`, + semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`], headX, headY, headZ, @@ -66,34 +83,93 @@ export const initialExpressions: Expression[] = calibrated.map( widthRight, heightLeft, heightRight, + curvatureLeft: 0, + curvatureRight: 0, spacing, - latitude, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: latitude, + positionYRight: latitude, leftAngle, rightAngle, - ], - index - ) => ({ - id: `expression-${String(index).padStart(2, '0')}`, - semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`], - headX, - headY, - headZ, - widthLeft, - widthRight, - heightLeft, - heightRight, - spacing, + perspective: 1, + eyeMotion: 'none' as const, + bodyMotion: 'none' as const, + }) + ), + { + id: 'expression-3d2bed26-f97c-477d-922f-77600cb10e92', + semanticKey: 'angry-brows', + headX: 10.473974503042374, + headY: 5.087293619785961, + headZ: 4.698252132317348, + widthLeft: 27.126562499999995, + widthRight: 27.126562499999995, + heightLeft: 63.028125, + heightRight: 63.028125, + curvatureLeft: 0, + curvatureRight: 0, + spacing: 68.7, positionXLeft: 0, positionXRight: 0, - positionYLeft: latitude, - positionYRight: latitude, - leftAngle, - rightAngle, + positionYLeft: 0, + positionYRight: 0, + leftAngle: -36.244531249999994, + rightAngle: 27.730078125000002, perspective: 1, - eyeMotion: 'none', - bodyMotion: 'none', - }) -) + eyeMotion: 'none' as const, + bodyMotion: 'shake' as const, + bodyColor: '#ba3636', + eyeColor: '#610000', + }, + { + id: 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8', + semanticKey: 'uneasy-left', + headX: -12.303515625, + headY: -17.601171875, + headZ: 5.9109375, + widthLeft: 20.605859374999994, + widthRight: 20.605859374999994, + heightLeft: 47.769921874999994, + heightRight: 47.769921874999994, + curvatureLeft: 0, + curvatureRight: 0, + spacing: 54.9, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: 0, + positionYRight: 0, + leftAngle: 23.523046875000002, + rightAngle: -24.042578125000002, + perspective: 1, + eyeMotion: 'shake' as const, + bodyMotion: 'slowDrift' as const, + bodyColor: '#adc3ff', + }, + { + id: 'expression-joyful-arc', + semanticKey: 'joyful-arc', + headX: -8.75234375, + headY: -8.743359375, + headZ: -10.773828125, + widthLeft: 54, + widthRight: 54, + heightLeft: 15, + heightRight: 15, + curvatureLeft: 1, + curvatureRight: 1, + spacing: 69.276171875, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: -18, + positionYRight: -18, + leftAngle: 0, + rightAngle: 0, + perspective: 1, + eyeMotion: 'none' as const, + bodyMotion: 'none' as const, + }, +] export const defaultExpression: Expression = { id: 'expression-neutral', @@ -104,6 +180,8 @@ export const defaultExpression: Expression = { widthRight: 20, heightLeft: 50, heightRight: 50, + curvatureLeft: 0, + curvatureRight: 0, spacing: 35, positionXLeft: 0, positionXRight: 0, @@ -125,6 +203,7 @@ export const stateGroups = { 'angry', 'drowsy', 'happy', + 'joyful', 'curious', 'confused', 'bored', @@ -149,9 +228,10 @@ export const statePools: Record = { excited: [2, 17, 21, 3, 11], surprised: [3, 21], suspicious: [14, 5, 23], - angry: [7, 16], + angry: [7, 25], drowsy: [4, 22, 13], happy: [2, 11, 17, 19], + joyful: [11, 27], curious: [3, 21, 0, 15], confused: [14, 5, 8], bored: [4, 22, 0], @@ -159,7 +239,7 @@ export const statePools: Record = { shy: [0, 24, 13], sad: [4, 13, 22], laughing: [2, 11, 17], - scared: [3, 21], + scared: [3, 26], playful: [2, 17, 11, 8], celebrate: [2, 8, 17], } @@ -244,5 +324,6 @@ export const stateNotes: Record = { searching: 'Balayage rapide et changements très fréquents.', working: 'Rythme régulier et expressions concentrées.', excited: 'Grandes expressions et transitions rapides.', + joyful: 'Morphing fluide entre les expressions 11 et 27.', curious: 'Inclinaisons et forte asymétrie.', } diff --git a/src/features/export/proceduralBrowserRuntime.ts b/src/features/export/proceduralBrowserRuntime.ts index 6b5c4e9..55df635 100644 --- a/src/features/export/proceduralBrowserRuntime.ts +++ b/src/features/export/proceduralBrowserRuntime.ts @@ -8,7 +8,10 @@ const easeProgress = (progress, transition) => transition === 'smooth' ? progress * progress * (3 - 2 * progress) : transition === 'snappy' ? 1 - (1 - progress) ** 3 - : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress); + : transition === 'gentleSpring' + ? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) + / (1 - Math.exp(-9) * Math.cos(5)) + : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress); const nearestAngle = (target, current) => { let resolved = target; while (resolved - current > 180) resolved -= 360; @@ -134,12 +137,13 @@ function mountAvatar(target, options = {}) { const renderElement = pixelStyle ? canvas : svg; host.replaceChildren(renderElement); - const ensurePaths = (group, paths, fill) => { + const ensurePaths = (group, paths, fill, nodeIds, bodyColorOverride) => { while (group.children.length < paths.length) group.append(svgElement('path')); while (group.children.length > paths.length) group.lastElementChild.remove(); paths.forEach((path, index) => { group.children[index].setAttribute('d', path); - group.children[index].setAttribute('fill', fill); + const node = DATA.avatar.bodyNodes.find(candidate => candidate.id === nodeIds[index]); + group.children[index].setAttribute('fill', bodyColorOverride ? fill : node?.color || fill); }); }; let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0]; @@ -200,8 +204,23 @@ function mountAvatar(target, options = {}) { return; } motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')'); - ensurePaths(backLayer, geometry.backPaths, currentColors.body); - ensurePaths(frontLayer, geometry.frontPaths, currentColors.body); + const bodyColorOverride = Boolean( + currentPose.expression.bodyColor || transitionState?.toPose.expression.bodyColor + ); + ensurePaths( + backLayer, + geometry.backPaths, + currentColors.body, + geometry.backNodeIds, + bodyColorOverride + ); + ensurePaths( + frontLayer, + geometry.frontPaths, + currentColors.body, + geometry.frontNodeIds, + bodyColorOverride + ); head.setAttribute('d', geometry.headPath); head.setAttribute('fill', currentColors.body); clipHead.setAttribute('d', geometry.headPath); diff --git a/src/features/export/standaloneEngine.generated.ts b/src/features/export/standaloneEngine.generated.ts index 217f5c6..590a779 100644 --- a/src/features/export/standaloneEngine.generated.ts +++ b/src/features/export/standaloneEngine.generated.ts @@ -1,2 +1,2 @@ // Generated by scripts/generate-standalone-engine.mjs. Do not edit. -export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=e=>e*e*(3-2*e),n=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},r=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,i=17.29,a=(e,r,i,a)=>{let o=e/a,s=Math.floor(o),c=t(o-s),l=n(s*3+r+i);return l+(n((s+1)*3+r+i)-l)*c},o=(e,r,i)=>{let a=1100;if(e<=0)return 0;let o=Math.floor(e/a),s=(e-o*a)/140,c=t(Math.min(s,1)),l=o===0?0:n((o-1)*2+r+i);return l+(n(o*2+r+i)-l)*c},s=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,c=(e,t,n=1)=>{let i=r(e);if(e.bodyMotion===`slowDrift`)return{x:a(t,3,i,2900)*1.45*n,y:a(t,4,i,3700)*1.1*n};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35*n,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1*n}}return{x:0,y:0}},l=(e,t,n=1)=>{if(e.eyeMotion===`microSaccades`)return{x:o(t,0,i)*1.5*n,y:o(t,1,i)*.9*n};if(e.eyeMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2*n,y:(Math.sin(e*59)+Math.sin(e*83)*.4)*.8*n}}return{x:0,y:0}},u=(e,t,n=1)=>{let i={...e},o=r(e);if(e.bodyMotion===`slowDrift`)i.headX+=a(t,0,o,2600)*.8*n,i.headY+=a(t,1,o,3300)*1.15*n,i.headZ+=a(t,2,o,4100)*.45*n;else if(e.bodyMotion===`shake`){let e=t/1e3;i.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15*n,i.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35*n,i.headZ+=Math.sin(e*43)*.7*n}return i},d=(e,t,n=1)=>{let r=u(e,t,n),i=l(e,t,n);return r.positionXLeft+=i.x,r.positionXRight+=i.x,r.positionYLeft+=i.y,r.positionYRight+=i.y,r},f=(e,t)=>Math.sign(e)*Math.abs(e)**t,p=(e,t,n,r,i,a,o)=>{let s=f(Math.cos(t),a);return[n/2*s*f(Math.sin(e),o),r/2*f(Math.sin(t),a),i/2*s*f(Math.cos(e),o)]},m=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),g=e=>1+h(e.roundness)/2,_=.04,v=e=>e.roundness<=0?1/0:2/(_+h(e.roundness)/2*.96),y=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},b=(e,t,n)=>y(e,t,n,g(e)),x=(e,t,n)=>y(e,t,n,v(e)),ee=.24,te=.2,ne=.22,re=e=>h(e.morphRoundness)/2,ie=(e,t,n)=>{let r=re(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},S=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},ae=e=>({tipFraction:(e.tipRoundness??0)*ee,baseFraction:(e.baseRoundness??0)*te}),C=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*ne;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},oe=(e,t)=>ie(e,t,C(e,t)),w=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=ae(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:S(r,r/2,r/4,0,e),verticalProgress:S(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},T=(e,t)=>ie(e,t,se(e,t)),E=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},D=(e,t,n)=>{let{width:r,height:i,depth:a}=e;switch(e.type){case`sphere`:case`mickey`:return p(t,n,r,i,a,1,1);case`cube`:return x(e,t,n);case`cylinder`:{let o=oe(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),-i/2+i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}case`cursor`:{let r=E(e),i=(n+Math.PI/2)/Math.PI,a=C({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},i);return[r.bodyWidth/2*a.radiusScale*Math.sin(t),r.bodyCenterY-r.bodyHeight/2+r.bodyHeight*a.verticalProgress,r.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return b(e,t,n);case`capsule`:case`ghost`:return m(e,t,n);case`cone`:{let o=T(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),i/2-i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}}},O=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],k=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},ce=(e,t,n)=>{let r=e.type===`cone`?-1:1;return k([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},le=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=D(e,t-r,n),a=D(e,t+r,n),o=D(e,t,Math.max(-Math.PI/2,n-r)),s=D(e,t,Math.min(Math.PI/2,n+r));return ce(e,O(a,i),O(s,o))},A=(e,t)=>Math.sign(e)*Math.abs(e)**t,ue=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return k([A(t[0]/r,n-1)/r,A(t[1]/i,n-1)/i,A(t[2]/a,n-1)/a])},de=(e,t)=>ue(e,t,g(e)),fe=(e,t)=>{let n=v(e);if(Number.isFinite(n))return ue(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},pe=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},me=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:k([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},j=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=w(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=w(e,h,r),v=(w(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),b=-(o/a)*d/(l*y||1),x=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:k([-b,-x,1])}},he=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return me(t,n,r,i,a);case`cube`:return pe(e,t,n,v(e),fe);case`capsule`:case`ghost`:{let e=Math.min(r,i),o=Math.max(0,i-e);return me(t,n,r,e,a,n<-o?-o:n>o?o:n)}case`cylinder`:return j(e,t,n,oe,1);case`cursor`:{let r=E(e),i=j({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,C,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return j(e,t,n,T,-1);case`diamond`:return pe(e,t,n,g(e),de)}},ge=(e,t,n)=>{let r=D(e,t,n);if(e.type===`sphere`||e.type===`mickey`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:k([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:k([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:de(e,r)}:e.type===`cube`?{point:r,normal:fe(e,r)}:{point:r,normal:le(e,t,n)}},M=620,_e=14,ve=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],N=e=>e*Math.PI/180,P=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},F=([e,t,n,r],[i,a,o,s])=>P([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),I=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return P([Math.cos(i),e*a,t*a,n*a])},L=(e,t,n)=>{let r=I([1,0,0],e),i=I([0,1,0],t);return F(F(I([0,0,1],n),r),i)},R=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},ye=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r<_e;r+=1){let o=n+r/_e*(Math.PI/2);a.push([e+Math.cos(o)*i,t+Math.sin(o)*i])}};return o([-n+i,-r],[n-i,-r]),s(n-i,-r+i,-Math.PI/2),o([n,-r+i],[n,r-i]),s(n-i,r-i,0),o([n-i,r],[-n+i,r]),s(-n+i,r-i,Math.PI/2),o([-n,r-i],[-n,-r+i]),s(-n+i,-r+i,Math.PI),a},z=(e,t)=>{let n=M-e[2]*t,r=Math.abs(n)<1e-4?M/1e-4:M/n;return[e[0]*r,e[1]*r,e[2]]},B=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,be=e=>({expression:e,orientation:L(N(e.headX),N(e.headY),N(e.headZ))}),xe=24,Se=25,Ce=73,V=144,we=33,Te=73,H=new Map,Ee=new Map,De=new Map,U=e=>JSON.stringify([e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness]),W=(e,t,n)=>(e.size>=xe&&e.delete(e.keys().next().value),e.set(t,n),n),Oe=(e,t,n)=>ge(e,t,n),ke=(e,t)=>({point:z(R(e.orientation,t.point),e.expression.perspective),normal:R(e.orientation,t.normal)}),Ae=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},je=(e,t,n,r)=>{let[i,a]=Ae(n,r);return ke(e,he(t,i,a))},Me=(e,t,n,r,i={x:0,y:0})=>{let a=e.expression,o=n<0?`Left`:`Right`,s=a[`width${o}`],c=5+(a[`height${o}`]-5)*r,l=n*a.spacing/2+a[`positionX${o}`]+i.x,u=a[`positionY${o}`]+i.y,d=N(n<0?a.leftAngle:a.rightAngle);return ye(s,c).map(([n,r])=>{let i=n*Math.cos(d)-r*Math.sin(d),a=n*Math.sin(d)+r*Math.cos(d);return je(e,t,l+i,u+a)})},Ne=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>B(e,!1)).join(``)},Pe=(e,t)=>{let n=U(t),r=De.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>Oe(t,N(-180+r*5),N(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>Oe(t,N(e),N(-90+r*5))));r=W(De,n,[...e,...i])}return r.map(t=>Ne(t.map(t=>ke(e,t))))},G=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},K=e=>{if(e.length<3)return B(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},q=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),Fe=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,J=(e,t)=>z(R(e.orientation,t),e.expression.perspective),Y=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/V*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),X=(e,t)=>{let n=U(t),r=H.get(n);return r||(r=Array.from({length:we},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:Te},(e,n)=>D(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),W(H,n,r)),K(q(G(r.map(t=>J(e,t)))))},Ie=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return X(e,t);let n=t.height/2;return K(q(G([...Y(t.width,t.depth,-n),...Y(t.width,t.depth,n)].map(t=>J(e,t)))))},Le=(e,t)=>{let n=E(t),r=n.bodyHeight/2;return K(q(G([...Y(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...Y(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>J(e,t)))))},Re=(e,t)=>{let n=E(t),r=J(e,[0,n.coneApexY,0]);return K(q(G([...Y(t.width,t.depth,n.coneBaseY).map(t=>J(e,t)),r])))},ze=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return X(e,t);let n=J(e,[0,-t.height/2,0]),r=G([...Y(t.width,t.depth,t.height/2).map(t=>J(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return K(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?B(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${Fe(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Be=(e,t)=>{if(t.roundness>0)return X(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return B(G([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>J(e,t))))},Ve=(e,t)=>{if(t.roundness>0)return X(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return B(G([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>J(e,t))))},He=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},Z=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},Q=(e,t,n=[0,0,0])=>{let r=[R(e.orientation,[1,0,0]),R(e.orientation,[0,1,0]),R(e.orientation,[0,0,1])],i=R(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return He(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=M/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:He(h,g,b/x,-y/x,v/x)},Ue=(e,t)=>{let n=Q(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return Z({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?Z(n):null},We=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>Q(e,s,[t*i,a,o])).filter(e=>e!==null).map(Z)},Ge=(e,t)=>t.type===`mickey`?We(e,t):t.type===`cursor`?[Re(e,t)]:[],$=e=>Array.from({length:V},(t,n)=>{let r=n/V*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),Ke=e=>{if(e.length<3)return B(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},qe=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=Q(e,o,[0,a,0]),c=Q(e,o,[0,-a,0]);return!s||!c?null:Ke(G([...$(s),...$(c)]))},Je=(e,t)=>{let n=t.width/2,r=t.height/2,i=t.width/6,a=t.width*.018,o=-r*.08,s=r*.5,c=r*.18,l=r,u=(t,n)=>J(e,[t,n,0]),d=([e,t])=>`${e.toFixed(2)} ${t.toFixed(2)}`,f=(e,t)=>`M${d(u(e,t))}`,p=(e,t)=>`L${d(u(e,t))}`,m=(e,t,n,r,i,a)=>`C${d(u(e,t))} ${d(u(n,r))} ${d(u(i,a))}`;return[f(-n,o),m(-n,-r*.62,-n*.56,-r,0,-r),m(n*.56,-r,n,-r*.62,n,o),p(n,s),m(n,l*.82,n*.82,l,n*.66,l),m(n*.49,l,i+a,l*.82,i+a,s),p(i+a,c),m(i+a,c-6,i-a,c-6,i-a,c),p(i-a,s),m(i-a,l*.82,n*.17,l,0,l),m(-n*.17,l,-i+a,l*.82,-i+a,s),p(-i+a,c),m(-i+a,c-6,-i-a,c-6,-i-a,c),p(-i-a,s),m(-i-a,l*.82,-n*.49,l,-n*.66,l),m(-n*.82,l,-n,l*.82,-n,s),p(-n,o),`Z`].join(``)},Ye=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Ue(e,t);if(n)return n}if(t.type===`capsule`){let n=qe(e,t);if(n)return n}if(t.type===`cylinder`)return Ie(e,t);if(t.type===`cursor`)return Le(e,t);if(t.type===`cone`)return ze(e,t);if(t.type===`cube`)return Be(e,t);if(t.type===`diamond`)return Ve(e,t);if(t.type===`ghost`)return Je(e,t);let n=U(t),r=H.get(n);return r||(r=Array.from({length:Se},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:Ce},(e,n)=>D(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),W(H,n,r)),B(G(r.map(t=>z(R(e.orientation,t),e.expression.perspective))))},Xe=(e,t)=>{let n=U(t.surface),r=Ee.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return D(t.surface,i,r)})}).flat(),W(Ee,n,r));let i=L(N(t.rotation[0]),N(t.rotation[1]),N(t.rotation[2])),a=G(r.map(n=>{let r=R(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return z(R(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?B(a):K(q(a))},Ze=.1,Qe=(e,t)=>{let n=L(N(t.rotation[0]),N(t.rotation[1]),N(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>R(e.orientation,R(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},$e=(e,t)=>{let n=t.map(t=>{let n=R(e.orientation,t.position)[2];return{id:t.id,path:Xe(e,t),depth:n,front:t.layer===`front`||t.layer!==`back`&&n>Qe(e,t)*Ze}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}};return e.ambientBodyOffset=c,e.ambientEyeOffset=l,e.applyAmbientBodyMotion=u,e.applyAmbientMotion=d,e.expressionFields=ve,e.hasAmbientMotion=s,e.poseFromExpression=be,e.renderAvatar=(e,t,n=1,r={})=>{let i=Me(e,t,-1,n,r.eyeOffset),a=Me(e,t,1,n,r.eyeOffset),o=i.map(e=>e.point),s=a.map(e=>e.point),c=$e(e,r.bodyNodes??[]),l=Ge(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:Ye(e,t),leftPath:B(o),rightPath:B(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:Pe(e,t)}},e})({});" +export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=e=>e*e*(3-2*e),n=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},r=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,i=17.29,a=(e,r,i,a)=>{let o=e/a,s=Math.floor(o),c=t(o-s),l=n(s*3+r+i);return l+(n((s+1)*3+r+i)-l)*c},o=(e,r,i)=>{let a=1100;if(e<=0)return 0;let o=Math.floor(e/a),s=(e-o*a)/140,c=t(Math.min(s,1)),l=o===0?0:n((o-1)*2+r+i);return l+(n(o*2+r+i)-l)*c},s=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,c=(e,t,n=1)=>{let i=r(e);if(e.bodyMotion===`slowDrift`)return{x:a(t,3,i,2900)*1.45*n,y:a(t,4,i,3700)*1.1*n};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35*n,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1*n}}return{x:0,y:0}},l=(e,t,n=1)=>{if(e.eyeMotion===`microSaccades`)return{x:o(t,0,i)*1.5*n,y:o(t,1,i)*.9*n};if(e.eyeMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2*n,y:(Math.sin(e*59)+Math.sin(e*83)*.4)*.8*n}}return{x:0,y:0}},u=(e,t,n=1)=>{let i={...e},o=r(e);if(e.bodyMotion===`slowDrift`)i.headX+=a(t,0,o,2600)*.8*n,i.headY+=a(t,1,o,3300)*1.15*n,i.headZ+=a(t,2,o,4100)*.45*n;else if(e.bodyMotion===`shake`){let e=t/1e3;i.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15*n,i.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35*n,i.headZ+=Math.sin(e*43)*.7*n}return i},d=(e,t,n=1)=>{let r=u(e,t,n),i=l(e,t,n);return r.positionXLeft+=i.x,r.positionXRight+=i.x,r.positionYLeft+=i.y,r.positionYRight+=i.y,r},f=(e,t)=>Math.sign(e)*Math.abs(e)**t,p=(e,t,n)=>e+(t-e)*n,m={rounded:.5,square:.3,teardrop:.78},h=(e,t,n,r)=>{let i=Math.max(Math.cos(n),0)**r;return[e.width/2*i*f(Math.sin(t),r),e.height/2*f(Math.sin(n),r),e.depth/2*i*f(Math.cos(t),r)]},g=(e,t,n)=>{let r=m.teardrop,i=f(Math.sin(n),r),a=Math.max(0,Math.min(1,(i+1)/2)),o=Math.max(Math.cos(n),0)**r,s=p(.46,1.24,a),c=p(.7,1.12,a);return[e.width/2*s*o*f(Math.sin(t),r),e.height/2*i,e.depth/2*c*o*f(Math.cos(t),r)]},_=e=>p(.34,.76,x(e.roundness)/2),v=(e,t,n)=>{let r=_(e),i=f(Math.sin(n),r),a=Math.max(0,Math.min(1,(i+1)/2)),o=Math.max(Math.cos(n),0)**r,s=p(e.topScale??.82,1.08,a),c=p(.92,1.04,a);return[e.width/2*s*o*f(Math.sin(t),r),e.height/2*i,e.depth/2*c*o*f(Math.cos(t),r)]},y=(e,t,n,r,i,a,o)=>{let s=f(Math.cos(t),a);return[n/2*s*f(Math.sin(e),o),r/2*f(Math.sin(t),a),i/2*s*f(Math.cos(e),o)]},b=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),S=e=>1+x(e.roundness)/2,ee=.04,C=e=>e.roundness<=0?1/0:2/(ee+x(e.roundness)/2*.96),w=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},te=(e,t,n)=>w(e,t,n,S(e)),ne=(e,t,n)=>w(e,t,n,C(e)),re=.24,ie=.2,ae=.22,oe=e=>x(e.morphRoundness)/2,se=(e,t,n)=>{let r=oe(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},T=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},ce=e=>({tipFraction:(e.tipRoundness??0)*re,baseFraction:(e.baseRoundness??0)*ie}),E=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*ae;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},le=(e,t)=>se(e,t,E(e,t)),D=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=ce(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:T(r,r/2,r/4,0,e),verticalProgress:T(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},de=(e,t)=>se(e,t,ue(e,t)),fe=(e,t)=>{let n=Math.max(0,Math.min(1,t));return{radiusScale:p(.46,1,p(n,n*n*(3-2*n),x(e.roundness)/2*.55)),verticalProgress:n}},pe=(e,t)=>{let n=Math.max(0,Math.min(1,t));return{radiusScale:n**p(1,.56,x(e.roundness)/2),verticalProgress:n}},O=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},k=(e,t,n)=>{let{width:r,height:i,depth:a}=e;switch(e.type){case`sphere`:case`mickey`:return y(t,n,r,i,a,1,1);case`ellipse`:return h(e,t,n,1);case`square`:return h(e,t,n,m.square);case`rounded`:return h(e,t,n,m.rounded);case`cube`:return ne(e,t,n);case`cylinder`:{let o=le(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),-i/2+i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}case`cursor`:{let r=O(e),i=(n+Math.PI/2)/Math.PI,a=E({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},i);return[r.bodyWidth/2*a.radiusScale*Math.sin(t),r.bodyCenterY-r.bodyHeight/2+r.bodyHeight*a.verticalProgress,r.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return te(e,t,n);case`teardrop`:return g(e,t,n);case`trapezoid`:return v(e,t,n);case`capsule`:case`ghost`:case`apple-bite`:return b(e,t,n);case`cone`:{let o=de(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),i/2-i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}case`frustum`:{let o=fe(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),-i/2+i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}case`half-cone`:{let o=pe(e,(n+Math.PI/2)/Math.PI),s=(e.cutAngle??0)*Math.PI/180+t/2;return[r/2*o.radiusScale*Math.sin(s),-i/2+i*o.verticalProgress,a/2*o.radiusScale*Math.cos(s)]}}},me=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],A=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},he=(e,t,n)=>{let r=e.type===`cone`?-1:1;return A([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},ge=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=k(e,t-r,n),a=k(e,t+r,n),o=k(e,t,Math.max(-Math.PI/2,n-r)),s=k(e,t,Math.min(Math.PI/2,n+r));return he(e,me(a,i),me(s,o))},j=(e,t)=>Math.sign(e)*Math.abs(e)**t,M=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return A([j(t[0]/r,n-1)/r,j(t[1]/i,n-1)/i,j(t[2]/a,n-1)/a])},_e=(e,t)=>M(e,t,S(e)),ve=(e,t)=>{let n=C(e);if(Number.isFinite(n))return M(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},N=(e,t,n)=>M(e,t,2/n),P=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},ye=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:A([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},be=(e,t,n,r)=>{let i=e.width/2||1,a=e.height/2||1,o=e.depth/2||1,s=r===`teardrop`?m.teardrop:_(e),c=Math.max(-1,Math.min(1,n/a)),l=f(c,1/s),u=Math.asin(Math.max(-1,Math.min(1,l))),d=Math.max(0,Math.min(1,(c+1)/2)),h=Math.max(Math.cos(u),0)**s,g=r===`teardrop`?p(.46,1.24,d):p(e.topScale??.82,1.08,d),v=r===`teardrop`?p(.7,1.12,d):p(.92,1.04,d),y=i*g*h,b=o*v*h,x=Math.max(-y,Math.min(y,t)),S=y>0?x/y:0,ee=f(Math.max(-1,Math.min(1,S)),1/s),C=Math.asin(Math.max(-1,Math.min(1,ee))),w=b*f(Math.max(Math.cos(C),0),s);return{point:[x,c*a,w],normal:ge(e,C,u)}},F=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=D(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=D(e,h,r),v=(D(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),b=-(o/a)*d/(l*y||1),x=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:A([-b,-x,1])}},xe=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:case`ellipse`:return ye(t,n,r,i,a);case`square`:return P(e,t,n,2/m.square,(e,t)=>N(e,t,m.square));case`rounded`:return P(e,t,n,2/m.rounded,(e,t)=>N(e,t,m.rounded));case`cube`:return P(e,t,n,C(e),ve);case`capsule`:case`ghost`:case`apple-bite`:{let e=Math.min(r,i),o=Math.max(0,i-e);return ye(t,n,r,e,a,n<-o?-o:n>o?o:n)}case`cylinder`:return F(e,t,n,le,1);case`cursor`:{let r=O(e),i=F({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,E,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return F(e,t,n,de,-1);case`frustum`:return F(e,t,n,fe,1);case`half-cone`:return F(e,t,n,pe,1);case`teardrop`:return be(e,t,n,`teardrop`);case`trapezoid`:return be(e,t,n,`trapezoid`);case`diamond`:return P(e,t,n,S(e),_e)}},Se=(e,t,n)=>{let r=k(e,t,n);if(e.type===`sphere`||e.type===`mickey`||e.type===`ellipse`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:A([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:A([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:_e(e,r)}:e.type===`cube`?{point:r,normal:ve(e,r)}:e.type===`square`?{point:r,normal:N(e,r,m.square)}:e.type===`rounded`?{point:r,normal:N(e,r,m.rounded)}:{point:r,normal:ge(e,t,n)}},I=620,Ce=14,we=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`curvatureLeft`,`curvatureRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],Te=(e,t,n)=>Math.max(t,Math.min(n,e)),L=e=>e*Math.PI/180,Ee=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},De=([e,t,n,r],[i,a,o,s])=>Ee([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),R=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return Ee([Math.cos(i),e*a,t*a,n*a])},z=(e,t,n)=>{let r=R([1,0,0],e),i=R([0,1,0],t);return De(De(R([0,0,1],n),r),i)},B=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},Oe=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r{let n=I-e[2]*t,r=Math.abs(n)<1e-4?I/1e-4:I/n;return[e[0]*r,e[1]*r,e[2]]},H=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,ke=e=>({expression:e,orientation:z(L(e.headX),L(e.headY),L(e.headZ))}),Ae=24,je=25,Me=73,U=144,Ne=33,Pe=73,W=new Map,Fe=new Map,Ie=new Map,G=e=>JSON.stringify([e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness,e.topScale,e.cutAngle]),K=(e,t,n)=>(e.size>=Ae&&e.delete(e.keys().next().value),e.set(t,n),n),Le=(e,t,n)=>Se(e,t,n),Re=(e,t)=>({point:V(B(e.orientation,t.point),e.expression.perspective),normal:B(e.orientation,t.normal)}),ze=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},Be=(e,t,n,r)=>{let[i,a]=ze(n,r);return Re(e,xe(t,i,a))},Ve=(e,t,n,r,i={x:0,y:0})=>{let a=e.expression,o=n<0?`Left`:`Right`,s=a[`width${o}`],c=5+(a[`height${o}`]-5)*r,l=a[`curvature${o}`],u=Math.min(s/2,c/2),d=Math.max(s/2-u,0),f=Math.max(9,s*.38)*l*r,p=d>0?.5*d**2/(2*Math.max(u,2**-52)):0,m=Math.min(f,p),h=n*a.spacing/2+a[`positionX${o}`]+i.x,g=a[`positionY${o}`]+i.y,_=L(n<0?a.leftAngle:a.rightAngle);return Oe(s,c).map(([n,r])=>{let i=d?Te(n,-d,d):0,a=d?i/d:0,o=-m*(1-a**2),s=d?2*m*i/d**2:0,c=Math.hypot(1,s),l=1/c,u=s/c,f=n-i,p=i+l*f-u*r,v=o+u*f+l*r,y=p*Math.cos(_)-v*Math.sin(_),b=p*Math.sin(_)+v*Math.cos(_);return Be(e,t,h+y,g+b)})},He=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>H(e,!1)).join(``)},Ue=(e,t)=>{let n=G(t),r=Ie.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>Le(t,L(-180+r*5),L(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>Le(t,L(e),L(-90+r*5))));r=K(Ie,n,[...e,...i])}return r.map(t=>He(t.map(t=>Re(e,t))))},q=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},J=e=>{if(e.length<3)return H(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},Y=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),We=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,X=(e,t)=>V(B(e.orientation,t),e.expression.perspective),Z=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/U*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),Q=(e,t)=>{let n=G(t),r=W.get(n);return r||(r=Array.from({length:Ne},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:Pe},(e,n)=>k(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),K(W,n,r)),J(Y(q(r.map(t=>X(e,t)))))},Ge=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return Q(e,t);let n=t.height/2;return J(Y(q([...Z(t.width,t.depth,-n),...Z(t.width,t.depth,n)].map(t=>X(e,t)))))},Ke=(e,t)=>{let n=O(t),r=n.bodyHeight/2;return J(Y(q([...Z(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...Z(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>X(e,t)))))},qe=(e,t)=>{let n=O(t),r=X(e,[0,n.coneApexY,0]);return J(Y(q([...Z(t.width,t.depth,n.coneBaseY).map(t=>X(e,t)),r])))},Je=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return Q(e,t);let n=X(e,[0,-t.height/2,0]),r=q([...Z(t.width,t.depth,t.height/2).map(t=>X(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return J(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?H(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${We(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Ye=(e,t)=>{if(t.roundness>0)return Q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return H(q([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>X(e,t))))},Xe=(e,t)=>{if(t.roundness>0)return Q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return H(q([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>X(e,t))))},Ze=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},Qe=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},$=(e,t,n=[0,0,0])=>{let r=[B(e.orientation,[1,0,0]),B(e.orientation,[0,1,0]),B(e.orientation,[0,0,1])],i=B(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return Ze(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=I/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:Ze(h,g,b/x,-y/x,v/x)},$e=(e,t)=>{let n=$(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return Qe({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?Qe(n):null},et=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>$(e,s,[t*i,a,o])).filter(e=>e!==null).map(Qe)},tt=(e,t)=>t.type===`mickey`?et(e,t):t.type===`cursor`?[qe(e,t)]:[],nt=e=>Array.from({length:U},(t,n)=>{let r=n/U*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),rt=e=>{if(e.length<3)return H(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},it=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=$(e,o,[0,a,0]),c=$(e,o,[0,-a,0]);return!s||!c?null:rt(q([...nt(s),...nt(c)]))},at=(e,t)=>{let n=t.width/2,r=t.height/2,i=t.width/6,a=t.width*.018,o=-r*.08,s=r*.5,c=r*.4,l=r,u=(t,n)=>X(e,[t,n,0]),d=([e,t])=>`${e.toFixed(2)} ${t.toFixed(2)}`,f=(e,t)=>`M${d(u(e,t))}`,p=(e,t)=>`L${d(u(e,t))}`,m=(e,t,n,r,i,a)=>`C${d(u(e,t))} ${d(u(n,r))} ${d(u(i,a))}`;return[f(-n,o),m(-n,-r*.62,-n*.56,-r,0,-r),m(n*.56,-r,n,-r*.62,n,o),p(n,s),m(n,l*.82,n*.82,l,n*.66,l),m(n*.49,l,i+a,l*.82,i+a,s),p(i+a,c),m(i+a,c-6,i-a,c-6,i-a,c),p(i-a,s),m(i-a,l*.82,n*.17,l,0,l),m(-n*.17,l,-i+a,l*.82,-i+a,s),p(-i+a,c),m(-i+a,c-6,-i-a,c-6,-i-a,c),p(-i-a,s),m(-i-a,l*.82,-n*.49,l,-n*.66,l),m(-n*.82,l,-n,l*.82,-n,s),p(-n,o),`Z`].join(``)},ot=(e,t)=>{let n=t.width/2,r=t.height/2,i=(t,n)=>X(e,[t,n,0]),a=([e,t])=>`${e.toFixed(2)} ${t.toFixed(2)}`,o=(e,t)=>`M${a(i(e,t))}`,s=(e,t)=>`L${a(i(e,t))}`,c=(e,t,n,r,o,s)=>`C${a(i(e,t))} ${a(i(n,r))} ${a(i(o,s))}`;return[o(-n*.46,-r),c(-n*.08,-r,n*.15,-r*.78,n*.12,-r*.54),c(n*.4,-r*.72,n*.74,-r*.75,n,-r*.56),c(n*.8,-r*.43,n*.68,-r*.24,n*.68,-r*.03),c(n*.68,r*.2,n*.8,r*.4,n,r*.5),c(n*.82,r*.83,n*.54,r,n*.26,r),c(n*.08,r,-n*.02,r*.91,-n*.16,r*.91),c(-n*.31,r*.91,-n*.4,r,-n*.57,r),c(-n*.89,r,-n,r*.67,-n,r*.3),c(-n,-r*.16,-n*.8,-r*.58,-n*.49,-r*.7),c(-n*.28,-r*.78,-n*.03,-r*.69,n*.08,-r*.55),s(-n*.1,-r*.66),c(-n*.12,-r*.82,-n*.22,-r*.88,-n*.35,-r*.84),c(-n*.41,-r*.84,-n*.45,-r*.93,-n*.46,-r),`Z`].join(``)},st=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=$e(e,t);if(n)return n}if(t.type===`capsule`){let n=it(e,t);if(n)return n}if(t.type===`cylinder`)return Ge(e,t);if(t.type===`cursor`)return Ke(e,t);if(t.type===`cone`)return Je(e,t);if(t.type===`cube`)return Ye(e,t);if(t.type===`diamond`)return Xe(e,t);if(t.type===`ghost`)return at(e,t);if(t.type===`apple-bite`)return ot(e,t);let n=G(t),r=W.get(n);return r||(r=Array.from({length:je},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:Me},(e,n)=>k(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),K(W,n,r)),H(q(r.map(t=>V(B(e.orientation,t),e.expression.perspective))))},ct=(e,t)=>{let n=G(t.surface),r=Fe.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return k(t.surface,i,r)})}).flat(),K(Fe,n,r));let i=z(L(t.rotation[0]),L(t.rotation[1]),L(t.rotation[2])),a=q(r.map(n=>{let r=B(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return V(B(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?H(a):J(Y(a))},lt=.1,ut=(e,t)=>{let n=z(L(t.rotation[0]),L(t.rotation[1]),L(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>B(e.orientation,B(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},dt=(e,t)=>{let n=t.map(t=>{let n=B(e.orientation,t.position)[2];return{id:t.id,path:ct(e,t),depth:n,front:t.layer===`front`||t.layer!==`back`&&n>ut(e,t)*lt}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}};return e.ambientBodyOffset=c,e.ambientEyeOffset=l,e.applyAmbientBodyMotion=u,e.applyAmbientMotion=d,e.expressionFields=we,e.hasAmbientMotion=s,e.poseFromExpression=ke,e.renderAvatar=(e,t,n=1,r={})=>{let i=Ve(e,t,-1,n,r.eyeOffset),a=Ve(e,t,1,n,r.eyeOffset),o=i.map(e=>e.point),s=a.map(e=>e.point),c=dt(e,r.bodyNodes??[]),l=tt(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:st(e,t),leftPath:H(o),rightPath:H(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:Ue(e,t)}},e})({});" diff --git a/src/features/rendering/__tests__/avatar-appearance-test.ts b/src/features/rendering/__tests__/avatar-appearance-test.ts new file mode 100644 index 0000000..3d79923 --- /dev/null +++ b/src/features/rendering/__tests__/avatar-appearance-test.ts @@ -0,0 +1,36 @@ +import { migrateBundledAvatarColors } from '@/features/avatar/avatars' +import { + avatarBodyOutlineWidth, + resolveAvatarNodeColor, +} from '@/features/rendering/avatarAppearance' + +describe('outlined avatar appearance', () => { + it('uses the same fixed body outline width for every avatar', () => { + expect(avatarBodyOutlineWidth()).toBe(12) + }) + + it('migrates bundled avatar colors to Deslop accent tokens', () => { + expect(migrateBundledAvatarColors('strobi', { body: '#5b7fe5', eyes: '#111316' })).toEqual({ + body: '#7c89ff', + eyes: '#50d7fe', + }) + }) + + it('preserves user-customized colors', () => { + expect(migrateBundledAvatarColors('strobi', { body: '#123456', eyes: '#654321' })).toEqual({ + body: '#123456', + eyes: '#654321', + }) + }) + + it('lets an explicit expression color tint fixed-color body parts', () => { + expect( + resolveAvatarNodeColor({ + nodeColor: '#a95f47', + bodyColor: '#ba3636', + expressionBodyColor: '#ba3636', + }) + ).toBe('#ba3636') + expect(resolveAvatarNodeColor({ nodeColor: '#a95f47', bodyColor: '#e3b17f' })).toBe('#a95f47') + }) +}) diff --git a/src/features/rendering/__tests__/rendered-scene-test.ts b/src/features/rendering/__tests__/rendered-scene-test.ts index f4dcdb7..d102ea8 100644 --- a/src/features/rendering/__tests__/rendered-scene-test.ts +++ b/src/features/rendering/__tests__/rendered-scene-test.ts @@ -9,11 +9,29 @@ import { paintRenderedScene, } from '@/features/rendering/renderedScene' import { surfacePresets } from '@/features/avatar/surfaces' +import { loadStudioDocument } from '@/features/studio/studioDocument' import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json' import type { BodyNode } from '@/features/avatar/body' import type { Expression } from '@/features/avatar/geometry' import type { SurfaceConfig } from '@/features/avatar/surfaces' +const pathBounds = (value: string) => { + const numbers = (value.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number) + const x = numbers.filter((_, index) => index % 2 === 0) + const y = numbers.filter((_, index) => index % 2 === 1) + return { + left: Math.min(...x), + top: Math.min(...y), + right: Math.max(...x), + bottom: Math.max(...y), + } +} + +const overlap = (first: ReturnType, second: ReturnType) => ({ + x: Math.min(first.right, second.right) - Math.max(first.left, second.left), + y: Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top), +}) + describe('rendered avatar scene', () => { it('keeps layer identity and hit mapping behind the scene seam', () => { const node = createBodyNode('sphere', 0) @@ -68,5 +86,85 @@ describe('rendered avatar scene', () => { { bodyNodes: avatar.body.nodes as BodyNode[] } ) expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f') + + const forcedFront = renderAvatar( + poseFromExpression({ ...expression, headY: 35 }), + avatar.body.primary as SurfaceConfig, + 1, + { + bodyNodes: [{ ...(avatar.body.nodes[0] as unknown as BodyNode), layer: 'front' }], + } + ) + expect(forcedFront.frontNodeIds).toContain(avatar.body.nodes[0].id) + }) + + it('anchors dog ears in front of the head across expressions', () => { + const studio = loadStudioDocument({ getItem: () => null }) + const avatar = studio.library.avatars.find(item => item.id === 'oneworks-dog')! + const detachedEars: string[] = [] + + const neutral = renderAvatar( + poseFromExpression(defaultExpression), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + expect(neutral.frontNodeIds).toEqual(['dog-ear-left', 'dog-ear-right']) + + studio.expressions.forEach(expression => { + const geometry = renderAvatar( + poseFromExpression(expression as Expression), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + const head = pathBounds(geometry.headPath) + + const earIds = ['dog-ear-left', 'dog-ear-right'] as const + earIds.forEach(nodeId => { + const frontIndex = geometry.frontNodeIds.indexOf(nodeId) + expect(geometry.backNodeIds).not.toContain(nodeId) + expect(frontIndex).toBeGreaterThanOrEqual(0) + const earPath = geometry.frontPaths[frontIndex] + const ear = pathBounds(earPath) + const headOverlap = overlap(ear, head) + if (headOverlap.x <= 0 || headOverlap.y <= 0) { + detachedEars.push( + `${expression.id}/${nodeId} (${headOverlap.x.toFixed(2)} × ${headOverlap.y.toFixed(2)})` + ) + } + }) + + }) + + expect(detachedEars, 'detached dog ears').toEqual([]) + }) + + it('keeps bear ears attached behind the head across expressions', () => { + const studio = loadStudioDocument({ getItem: () => null }) + const avatar = studio.library.avatars.find(item => item.id === 'oneworks-bear')! + const detachedEars: string[] = [] + + studio.expressions.forEach(expression => { + const geometry = renderAvatar( + poseFromExpression(expression as Expression), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + const head = pathBounds(geometry.headPath) + + ;(['bear-ear-left', 'bear-ear-right'] as const).forEach(nodeId => { + const earIndex = geometry.backNodeIds.indexOf(nodeId) + expect(earIndex).toBeGreaterThanOrEqual(0) + const ear = pathBounds(geometry.backPaths[earIndex]) + const headOverlap = overlap(ear, head) + if (headOverlap.x <= 0 || headOverlap.y <= 0) { + detachedEars.push(expression.id + '/' + nodeId) + } + }) + }) + + expect(detachedEars, 'detached bear ears').toEqual([]) }) }) diff --git a/src/features/rendering/avatarAppearance.ts b/src/features/rendering/avatarAppearance.ts new file mode 100644 index 0000000..53ff537 --- /dev/null +++ b/src/features/rendering/avatarAppearance.ts @@ -0,0 +1,37 @@ +import { useMotionValueEvent, type MotionValue } from 'motion/react' +import { useEffect, useState } from 'react' + +export const avatarBodyOutlineWidth = () => 12 + +const colorsMatch = (left: string, right: string) => + left.trim().toLowerCase() === right.trim().toLowerCase() + +export const useAvatarBodyColorOverride = ( + renderedBodyColor: MotionValue, + baseBodyColor: string +) => { + const [overridden, setOverridden] = useState( + () => !colorsMatch(renderedBodyColor.get(), baseBodyColor) + ) + + useEffect(() => { + setOverridden(!colorsMatch(renderedBodyColor.get(), baseBodyColor)) + }, [baseBodyColor, renderedBodyColor]) + + useMotionValueEvent(renderedBodyColor, 'change', latest => { + const next = !colorsMatch(latest, baseBodyColor) + setOverridden(current => (current === next ? current : next)) + }) + + return overridden +} + +export const resolveAvatarNodeColor = ({ + nodeColor, + bodyColor, + expressionBodyColor, +}: { + nodeColor?: string + bodyColor: string + expressionBodyColor?: string +}) => expressionBodyColor ?? nodeColor ?? bodyColor diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx index 6ab25f4..a2872b7 100644 --- a/src/features/rendering/components/AvatarCanvas.tsx +++ b/src/features/rendering/components/AvatarCanvas.tsx @@ -1,6 +1,6 @@ import { RotateCcw } from 'lucide-react' import { motion } from 'motion/react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import { Button } from '@/components/ui/button' import { useStudioLanguage } from '@/i18n' @@ -496,8 +496,10 @@ export function AvatarCanvas({ expression, avatarEyes, surface, + bodyNodes, scene, colors, + expressionBodyColor, renderStyle, rotationGizmo, showWire, @@ -522,8 +524,10 @@ export function AvatarCanvas({ expression: Expression avatarEyes: AvatarEyeDefaults surface: SurfaceConfig + bodyNodes: BodyNode[] scene: RenderedScene colors: RenderedColors + expressionBodyColor?: string renderStyle: AvatarRenderStyle rotationGizmo: RenderedRotationGizmo showWire: boolean @@ -593,6 +597,11 @@ export function AvatarCanvas({ if (!bodyEditing || !selectedBodyNodeId) return null return findBodyNodePath(scene, selectedBodyNodeId) })() + const nodeColorStyle = (id: string | null | undefined) => { + if (expressionBodyColor ?? expression.bodyColor) return undefined + const color = bodyNodes.find(node => node.id === id)?.color + return color ? ({ '--avatar-node-color': color } as CSSProperties) : undefined + } const toSvg = (event: React.PointerEvent): readonly [number, number] => { const rectangle = svgRef.current!.getBoundingClientRect() @@ -774,7 +783,9 @@ export function AvatarCanvas({ } useEscapeToCancel(cancelDrag) return ( -
+
{playback && ( selectBodyPath(event, backNodeIds.current[index])} /> ))} @@ -848,6 +860,7 @@ export function AvatarCanvas({ className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`} d={pathValue} key={index} + style={nodeColorStyle(frontNodeIds.current[index])} onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])} /> ))} diff --git a/src/features/studio/__tests__/studio-document-test.ts b/src/features/studio/__tests__/studio-document-test.ts index 74ad750..c7a19e2 100644 --- a/src/features/studio/__tests__/studio-document-test.ts +++ b/src/features/studio/__tests__/studio-document-test.ts @@ -28,42 +28,188 @@ const documentFixture = (): StudioDocument => { describe('Studio document', () => { const storage = (value: string | null = null) => ({ getItem: () => value }) + const bundledAvatarNames = [ + 'Memento', + 'Apple Bite', + 'Siamese', + 'British Shorthair', + 'Russian Blue', + 'Orange Tabby', + 'Black Cat', + 'Dog', + 'Brown Dog', + 'Bear', + 'Rabbit', + 'Bun', + 'Onee', + 'Cubee', + 'Nova', + 'Citrus', + 'Sphere', + 'Cube', + 'Capsule', + 'Cylinder', + 'Cone', + 'Diamond', + 'Willy', + 'OneWorks Sphere', + 'OneWorks Ellipse', + 'OneWorks Square', + 'OneWorks Rounded', + 'OneWorks Capsule', + 'OneWorks Teardrop', + 'OneWorks Diamond', + 'OneWorks Trapezoid', + 'OneWorks Cone', + 'OneWorks Frustum', + 'OneWorks Half Cone', + 'OneWorks Cloud', + 'OneWorks Sun', + ] it('loads the bundled Studio snapshot when no local project exists', () => { const document = loadStudioDocument(storage()) - expect(document.library.avatars).toHaveLength(12) - expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id) - expect(document.library.avatars.map(avatar => avatar.name)).toEqual([ - 'Onee', - 'Cubee', - 'Nova', - 'Citrus', - 'Sphere', - 'Cube', - 'Capsule', - 'Cylinder', - 'Cone', - 'Diamond', - 'Memento', - 'Willy', + expect(document.library.avatars).toHaveLength(bundledAvatarNames.length) + expect(document.library.activeAvatarId).toBe('avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e') + expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames) + expect( + document.library.avatars + .filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled) + .every(avatar => avatar.colors.body === avatar.colors.eyes) + ).toBe(true) + expect(document.library.avatars.slice(0, 12).map(avatar => avatar.body.primary.type)).toEqual([ + 'ghost', + 'apple-bite', + 'ellipse', + 'ellipse', + 'ellipse', + 'ellipse', + 'ellipse', + 'trapezoid', + 'trapezoid', + 'trapezoid', + 'trapezoid', + 'sphere', + ]) + expect(document.library.avatars.some(avatar => avatar.id === 'memento-piglet')).toBe(false) + expect(document.library.avatars.some(avatar => avatar.id === 'oneworks-cat')).toBe(false) + expect(document.library.avatars.some(avatar => avatar.id === 'oneworks-cat-cow')).toBe(false) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-willy')?.body.nodes + ).toHaveLength(3) + expect( + document.library.avatars.find(avatar => avatar.id === 'oneworks-cloud')?.body.nodes + ).toHaveLength(7) + expect( + document.library.avatars.find(avatar => avatar.id === 'oneworks-sun')?.body.nodes + ).toHaveLength(8) + expect( + document.library.avatars + .filter(avatar => + ['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some( + id => avatar.id === `oneworks-${id}` + ) + ) + .every(avatar => avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true) + ).toBe(true) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.renderStyle + ).toEqual({ + type: 'vector', + filled: true, + }) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle + ).toEqual({ type: 'vector', filled: true }) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.body.primary + ).toMatchObject({ width: 225, height: 220.5, depth: 171 }) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.body.primary + ).toMatchObject({ width: 216, height: 225, depth: 171 }) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes + .positionXLeft + ).toBe(defaultAvatarEyes.positionXLeft - 18) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes + .positionXRight + ).toBe(defaultAvatarEyes.positionXRight - 18) + expect( + document.library.avatars.find(avatar => avatar.id === 'oneworks-cat-siamese')?.colors + ).toEqual({ body: '#ead7b8', eyes: '#281913' }) + expect( + document.library.avatars.find(avatar => avatar.id === 'oneworks-cat-black')?.colors + ).toEqual({ body: '#111419', eyes: '#eef2f5' }) + expect( + document.library.avatars + .find(avatar => avatar.id === 'oneworks-cat-siamese') + ?.body.nodes.map(node => node.color) + ).toEqual(['#3c2118', '#3c2118']) + expect(document.library.avatars.find(avatar => avatar.id === 'oneworks-dog')?.colors).toEqual({ + body: '#d4d0c8', + eyes: '#211f1d', + }) + expect( + document.library.avatars.find(avatar => avatar.id === 'oneworks-dog-brown')?.colors + ).toEqual({ + body: '#e3b17f', + eyes: '#2b1d18', + }) + expect(document.library.avatars.find(avatar => avatar.id === 'oneworks-bear')?.colors).toEqual({ + body: '#a95f47', + eyes: '#2b1d18', + }) + expect( + document.library.avatars + .find(avatar => avatar.id === 'oneworks-dog') + ?.body.nodes.map(node => node.color) + ).toEqual(['#be9675', '#be9675']) + expect( + document.library.avatars + .find(avatar => avatar.id === 'oneworks-dog-brown') + ?.body.nodes.map(node => node.color) + ).toEqual(['#a95f47', '#a95f47']) + expect( + document.library.avatars + .filter(avatar => + ['oneworks-dog', 'oneworks-dog-brown', 'oneworks-bear'].includes(avatar.id) + ) + .map(avatar => ({ + id: avatar.id, + hasBehavior: Boolean(avatar.behavior), + coloredExpressions: + avatar.behavior?.expressions.filter( + expression => expression.bodyColor || expression.eyeColor + ).length ?? -1, + })) + ).toEqual([ + { id: 'oneworks-dog', hasBehavior: true, coloredExpressions: 4 }, + { id: 'oneworks-dog-brown', hasBehavior: true, coloredExpressions: 4 }, + { id: 'oneworks-bear', hasBehavior: true, coloredExpressions: 4 }, ]) expect( - document.library.avatars.every(avatar => avatar.colors.body === avatar.colors.eyes) - ).toBe(true) - expect(document.library.avatars.slice(4).map(avatar => avatar.body.primary.type)).toEqual([ - 'sphere', - 'cube', - 'capsule', - 'cylinder', - 'cone', - 'diamond', - 'ghost', - 'capsule', - ]) - expect(document.library.avatars.at(-1)?.body.nodes).toHaveLength(3) + document.library.avatars + .find(avatar => avatar.id === 'oneworks-bear') + ?.body.nodes.map(node => node.color) + ).toEqual(['#a95f47', '#a95f47']) const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost') - expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' }) + expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#111316' }) + expect( + document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors + ).toEqual({ body: '#ff5558', eyes: '#111316' }) + expect( + document.library.avatars + .find(avatar => avatar.id === 'primitive-apple-bite') + ?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor) + .map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor]) + ).toEqual([ + ['angry-brows', '#ba3636', '#610000'], + ['uneasy-left', '#adc3ff', undefined], + ['scared-state-surprised-left', '#adc3ff', undefined], + ['scared-state-uneasy-left', '#adc3ff', undefined], + ]) expect( document.library.avatars .filter(avatar => avatar.id !== 'primitive-ghost') @@ -72,12 +218,20 @@ describe('Studio document', () => { expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20) expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20) expect( - memento?.behavior?.expressions.every( - expression => !expression.bodyColor && !expression.eyeColor - ) + memento?.behavior?.expressions.find( + expression => expression.semanticKey === 'onboarding-curious' + )?.positionYLeft + ).toBe(-22) + expect( + memento?.behavior?.sequences.some(sequence => sequence.semanticKey === 'onboarding') ).toBe(true) - expect(document.expressions).toHaveLength(27) - expect(document.sequences).toHaveLength(23) + expect( + memento?.behavior?.expressions + .filter(expression => expression.bodyColor || expression.eyeColor) + .map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor]) + ).toEqual([['angry-brows', '#ba3636', '#610000']]) + expect(document.expressions).toHaveLength(28) + expect(document.sequences).toHaveLength(24) expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2) expect( document.sequences @@ -90,11 +244,40 @@ describe('Studio document', () => { .find(sequence => sequence.id === 'scared') ?.steps.map(step => step.expressionId) ).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8']) + expect( + document.sequences + .find(sequence => sequence.id === 'joyful') + ?.steps.map(step => [step.expressionId, step.transitionMs, step.transition]) + ).toEqual([ + ['expression-11', 700, 'gentleSpring'], + ['expression-joyful-arc', 700, 'gentleSpring'], + ]) expect(document.expressions.every(expression => expression.semanticKey)).toBe(true) expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true) expect(document.playback).toEqual({ stateId: 'proud', playing: true }) }) + it('exports every imported OneWorks skin as a valid avatar definition', () => { + const document = loadStudioDocument(storage()) + const importedAvatars = document.library.avatars.filter(avatar => + avatar.id.startsWith('oneworks-') + ) + + expect(importedAvatars).toHaveLength(23) + importedAvatars.forEach(avatar => { + const result = createAvatarDefinition({ + avatar, + behavior: avatar.behavior ?? { + expressions: document.expressions, + sequences: document.sequences, + }, + }) + + expect(result.ok, avatar.name).toBe(true) + if (result.ok) expect(JSON.stringify(result.value)).not.toContain('NaN') + }) + }) + it('clears only the persisted Studio project', () => { const removeItem = vi.fn() @@ -108,6 +291,122 @@ describe('Studio document', () => { expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument) }) + it('migrates previously saved bundled filled avatars to the filled treatment', () => { + const fallback = loadStudioDocument(storage()) + const filledIds = new Set([ + 'primitive-ghost', + 'primitive-apple-bite', + ...fallback.library.avatars + .filter(avatar => avatar.id.startsWith('oneworks-')) + .map(avatar => avatar.id), + ]) + const legacy = { + ...fallback, + library: { + ...fallback.library, + avatars: fallback.library.avatars.map(avatar => + filledIds.has(avatar.id) + ? { + ...avatar, + renderStyle: { type: 'vector' }, + eyes: + avatar.id === 'primitive-apple-bite' + ? { ...avatar.eyes, positionXLeft: 0, positionXRight: 0 } + : avatar.eyes, + body: { + ...avatar.body, + nodes: avatar.body.nodes.map(node => ({ ...node, color: undefined })), + }, + } + : avatar + ), + }, + } satisfies StudioDocument + + const restored = loadStudioDocument(storage(JSON.stringify(legacy))) + const restoredById = new Map(restored.library.avatars.map(avatar => [avatar.id, avatar])) + + ;['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].forEach(id => { + expect(restoredById.get(`oneworks-${id}`)?.renderStyle).toEqual({ + type: 'vector', + filled: true, + }) + }) + expect(restoredById.get('primitive-ghost')?.renderStyle).toEqual({ + type: 'vector', + filled: true, + }) + expect(restoredById.get('primitive-apple-bite')?.renderStyle).toEqual({ + type: 'vector', + filled: true, + }) + expect(restoredById.get('primitive-ghost')?.colors).toEqual({ + body: '#ff9d45', + eyes: '#111316', + }) + expect(restoredById.get('primitive-apple-bite')?.colors).toEqual({ + body: '#ff5558', + eyes: '#111316', + }) + expect(restoredById.get('primitive-apple-bite')?.eyes.positionXLeft).toBe(-18) + expect(restoredById.get('primitive-apple-bite')?.eyes.positionXRight).toBe(-18) + expect( + restoredById + .get('primitive-apple-bite') + ?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor) + .map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor]) + ).toEqual([ + ['angry-brows', '#ba3636', '#610000'], + ['uneasy-left', '#adc3ff', undefined], + ['scared-state-surprised-left', '#adc3ff', undefined], + ['scared-state-uneasy-left', '#adc3ff', undefined], + ]) + expect(restoredById.get('oneworks-rabbit')?.colors).toEqual({ + body: '#eee9df', + eyes: '#292724', + }) + expect(restoredById.get('oneworks-bun')?.colors).toEqual({ + body: '#fff3d9', + eyes: '#241915', + }) + expect(restoredById.get('oneworks-dog')?.colors).toEqual({ + body: '#d4d0c8', + eyes: '#211f1d', + }) + expect(restoredById.get('oneworks-dog')?.body.nodes.map(node => node.color)).toEqual([ + '#be9675', + '#be9675', + ]) + expect(restoredById.get('oneworks-dog-brown')?.colors).toEqual({ + body: '#e3b17f', + eyes: '#2b1d18', + }) + expect(restoredById.get('oneworks-dog-brown')?.body.nodes.map(node => node.color)).toEqual([ + '#a95f47', + '#a95f47', + ]) + expect(restoredById.get('oneworks-bear')?.colors).toEqual({ + body: '#a95f47', + eyes: '#2b1d18', + }) + expect( + restoredById + .get('oneworks-dog-brown') + ?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor) + .map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor]) + ).toEqual([ + ['angry-brows', '#ba3636', '#610000'], + ['uneasy-left', '#adc3ff', undefined], + ['scared-state-surprised-left', '#adc3ff', undefined], + ['scared-state-uneasy-left', '#adc3ff', undefined], + ]) + expect(restoredById.get('oneworks-bear')?.body.nodes.map(node => node.color)).toEqual([ + '#a95f47', + '#a95f47', + ]) + expect(restoredById.get('oneworks-sun')?.renderStyle).toEqual({ type: 'vector' }) + }) + it('restores standalone built-in animations from the temporary idle pairs', () => { const fallback = loadStudioDocument(storage()) const pairedDocument: StudioDocument = { @@ -133,34 +432,94 @@ describe('Studio document', () => { expect(restored.playback).toEqual({ stateId: 'proud', playing: true }) }) + it('adds the joyful arc expression to an existing local project', () => { + const fallback = loadStudioDocument(storage()) + const localDocument = { + ...fallback, + expressions: fallback.expressions.filter( + expression => expression.semanticKey !== 'joyful-arc' + ), + } + + const restored = loadStudioDocument(storage(JSON.stringify(localDocument))) + + expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc') + }) + + it('adds the joyful animation to an existing local project', () => { + const fallback = loadStudioDocument(storage()) + const localDocument = { + ...fallback, + sequences: fallback.sequences.filter(sequence => sequence.id !== 'joyful'), + } + + const restored = loadStudioDocument(storage(JSON.stringify(localDocument))) + const joyful = restored.sequences.find(sequence => sequence.id === 'joyful') + + expect(joyful?.steps.map(step => step.expressionId)).toEqual([ + 'expression-11', + 'expression-joyful-arc', + ]) + expect(joyful?.blink.enabled).toBe(false) + }) + + it('updates the bundled joyful arc thickness in an existing local project', () => { + const fallback = loadStudioDocument(storage()) + const legacyExpressions = fallback.expressions.map(expression => + expression.semanticKey === 'joyful-arc' + ? { ...expression, heightLeft: 11, heightRight: 11 } + : expression + ) + const localDocument = { + ...fallback, + expressions: legacyExpressions, + library: { + ...fallback.library, + avatars: fallback.library.avatars.map((avatar, index) => + index === 0 + ? { + ...avatar, + behavior: { expressions: legacyExpressions, sequences: fallback.sequences }, + } + : avatar + ), + }, + } + + const restored = loadStudioDocument(storage(JSON.stringify(localDocument))) + const joyfulArc = restored.expressions.find( + expression => expression.semanticKey === 'joyful-arc' + ) + + expect(joyfulArc?.heightLeft).toBe(15) + expect(joyfulArc?.heightRight).toBe(15) + const avatarJoyfulArc = restored.library.avatars[0].behavior?.expressions.find( + expression => expression.semanticKey === 'joyful-arc' + ) + expect(avatarJoyfulArc?.heightLeft).toBe(15) + expect(avatarJoyfulArc?.heightRight).toBe(15) + expect( + restored.library.avatars[0].behavior?.sequences.some(sequence => sequence.id === 'joyful') + ).toBe(true) + }) + it('removes retired bundled avatars from a locally saved project', () => { const fallback = loadStudioDocument(storage()) const retired = createAvatar('Freddy') retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed' + const retiredFox = createAvatar('Продовый песец') + retiredFox.id = 'memento-arctic-fox' const localDocument: StudioDocument = { ...fallback, library: { - activeAvatarId: retired.id, - avatars: [retired, ...fallback.library.avatars], + activeAvatarId: retiredFox.id, + avatars: [retired, retiredFox, ...fallback.library.avatars], }, } const document = loadStudioDocument(storage(JSON.stringify(localDocument))) - expect(document.library.avatars.map(avatar => avatar.name)).toEqual([ - 'Onee', - 'Cubee', - 'Nova', - 'Citrus', - 'Sphere', - 'Cube', - 'Capsule', - 'Cylinder', - 'Cone', - 'Diamond', - 'Memento', - 'Willy', - ]) + expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames) expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id) }) @@ -183,6 +542,35 @@ describe('Studio document', () => { ).toBe(defaultAvatarEyes.positionYLeft - 20) }) + it('simplifies bundled animal names in an existing local project', () => { + const fallback = loadStudioDocument(storage()) + const legacyNames: Record = { + 'oneworks-dog': 'OneWorks Dog', + 'oneworks-bear': 'OneWorks Bear', + 'oneworks-rabbit': 'OneWorks Rabbit', + 'oneworks-bun': 'OneWorks Bun', + } + const legacy = { + ...fallback, + library: { + ...fallback.library, + avatars: fallback.library.avatars.map(avatar => ({ + ...avatar, + name: legacyNames[avatar.id] ?? avatar.name, + })), + }, + } + + const restored = loadStudioDocument(storage(JSON.stringify(legacy))) + const restoredById = new Map(restored.library.avatars.map(avatar => [avatar.id, avatar])) + + expect(restoredById.has('oneworks-cat')).toBe(false) + expect(restoredById.get('oneworks-dog')?.name).toBe('Dog') + expect(restoredById.get('oneworks-bear')?.name).toBe('Bear') + expect(restoredById.get('oneworks-rabbit')?.name).toBe('Rabbit') + expect(restoredById.get('oneworks-bun')?.name).toBe('Bun') + }) + it('restores bundled semantic keys in a legacy local project', () => { const fallback = loadStudioDocument(storage()) const legacy = structuredClone(fallback) diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx index f244ca9..9ab34e4 100644 --- a/src/features/studio/components/AvatarDrawer.tsx +++ b/src/features/studio/components/AvatarDrawer.tsx @@ -12,8 +12,115 @@ import { import { defaultAvatarEyes } from '@/features/avatar/avatars' import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { defaultExpression } from '@/features/avatar/presets' +import { + avatarBodyOutlineWidth, + useAvatarBodyColorOverride, +} from '@/features/rendering/avatarAppearance' +import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' +import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene' import type { StudioController } from '@/features/studio/useStudioController' +function LiveAvatarPreview({ + avatar, + colors, + scene, + expressionBodyColor, +}: { + avatar: StudioController['activeAvatar'] + colors: RenderedColors + scene: RenderedScene + expressionBodyColor?: string +}) { + const outlineWidth = avatarBodyOutlineWidth() + const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true + const fixedSkinColors = + avatar.behavior?.expressions.every( + expression => !expression.bodyColor && !expression.eyeColor + ) ?? false + const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, avatar.colors.body) + const bodyColor = fixedSkinColors && !bodyColorIsOverridden ? avatar.colors.body : colors.body + const eyeColor = fixedSkinColors ? avatar.colors.eyes : colors.eyes + const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden + const nodeColor = (id: string | null | undefined) => + inheritBodyColor + ? bodyColor + : (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor) + const bodyFill = filled ? bodyColor : '#ffffff' + const clipId = `live-avatar-${avatar.id}` + + if (avatar.renderStyle.type === 'pixel') { + return ( + + ) + } + + return ( + + ) +} + export function AvatarPage({ controller }: { controller: StudioController }) { const { activateAvatar, @@ -29,8 +136,12 @@ export function AvatarPage({ controller }: { controller: StudioController }) { draggingAvatarId, duplicateAvatar, expressions, + playbackStatus, previewAvatarMove, reduceMotion, + renderedColors, + renderedExpressionBodyColor, + renderedScene, setDeleteAvatarOpen, setDraggingAvatarId, setFocusAvatarName, @@ -93,15 +204,24 @@ export function AvatarPage({ controller }: { controller: StudioController }) { activateAvatar(avatar.id, true) }} > - + {activeAvatarId === avatar.id && playbackStatus !== 'stopped' ? ( + + ) : ( + + )} {avatar.name} } diff --git a/src/features/studio/components/BodyConstructionAccordion.tsx b/src/features/studio/components/BodyConstructionAccordion.tsx index 7c34421..ec4ac5d 100644 --- a/src/features/studio/components/BodyConstructionAccordion.tsx +++ b/src/features/studio/components/BodyConstructionAccordion.tsx @@ -176,7 +176,11 @@ export function BodyConstructionAccordion({ unit="u" onChange={depth => updateSurface({ ...surface, depth })} /> - {(surface.type === 'cube' || surface.type === 'diamond') && ( + {(surface.type === 'cube' || + surface.type === 'diamond' || + surface.type === 'trapezoid' || + surface.type === 'frustum' || + surface.type === 'half-cone') && ( )} + {surface.type === 'trapezoid' && ( + controller.updateHighlight(active ? 'head' : null)} + onChange={topScale => updateSurface({ ...surface, topScale })} + /> + )} + {surface.type === 'half-cone' && ( + controller.updateHighlight(active ? 'head' : null)} + onChange={cutAngle => updateSurface({ ...surface, cutAngle })} + /> + )}
@@ -320,7 +347,10 @@ export function BodyConstructionAccordion({ ))} {(selectedBodyNode.surface.type === 'cube' || selectedBodyNode.surface.type === 'diamond' || - selectedBodyNode.surface.type === 'cylinder') && ( + selectedBodyNode.surface.type === 'cylinder' || + selectedBodyNode.surface.type === 'trapezoid' || + selectedBodyNode.surface.type === 'frustum' || + selectedBodyNode.surface.type === 'half-cone') && ( )} + {selectedBodyNode.surface.type === 'trapezoid' && ( + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, topScale }, + })) + } + /> + )} + {selectedBodyNode.surface.type === 'half-cone' && ( + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, cutAngle }, + })) + } + /> + )}
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 7a8cee3..251dd67 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -72,6 +72,7 @@ import { ExpressionCard, ExpressionPreview, ExpressionWorkspace, + LiveExpressionPreview, } from '@/features/avatar/components/ExpressionWorkspace' import { defaultExpression } from '@/features/avatar/presets' import { randomSnapshotPalette } from '@/features/export/snapshotPalette' @@ -488,6 +489,9 @@ export function StudioInspector({ controller }: { controller: StudioController } projectImportError, projectImportRef, reduceMotion, + renderedColors, + renderedExpressionBodyColor, + renderedScene, renameActiveAvatar, runtimeDefinitionResult, runtimeCopyStatus, @@ -1245,17 +1249,30 @@ export function StudioInspector({ controller }: { controller: StudioController } onClick={() => launchSequence(sequence)} onDoubleClick={() => openSequenceEditor(sequence)} > - + {activeSequence?.id === sequence.id && + playbackStatus !== 'stopped' ? ( + + ) : ( + + )} {animationSemanticKeyError(sequence) && ( diff --git a/src/features/studio/components/StudioView.tsx b/src/features/studio/components/StudioView.tsx index 0c48d2a..8aeaf62 100644 --- a/src/features/studio/components/StudioView.tsx +++ b/src/features/studio/components/StudioView.tsx @@ -1,56 +1,13 @@ -import { useEffect, useState } from 'react' - -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' import { StudioDialogs } from '@/features/studio/components/StudioDialogs' import { StudioInspector } from '@/features/studio/components/StudioInspector' -import { StudioStage } from '@/features/studio/components/StudioStage' import type { StudioController } from '@/features/studio/useStudioController' -const STACKED_LAYOUT_QUERY = '(max-width: 980px)' - export function StudioView(controller: StudioController) { - const [stackedLayout, setStackedLayout] = useState( - () => window.matchMedia(STACKED_LAYOUT_QUERY).matches - ) - - useEffect(() => { - const mediaQuery = window.matchMedia(STACKED_LAYOUT_QUERY) - const updateStackedLayout = () => setStackedLayout(mediaQuery.matches) - mediaQuery.addEventListener('change', updateStackedLayout) - return () => mediaQuery.removeEventListener('change', updateStackedLayout) - }, []) - return (
- {stackedLayout ? ( - - - - - - - - - - ) : ( -
- - -
- )} +
+ +
) diff --git a/src/features/studio/defaultStudioDocument.json b/src/features/studio/defaultStudioDocument.json index 141295f..1a091d8 100644 --- a/src/features/studio/defaultStudioDocument.json +++ b/src/features/studio/defaultStudioDocument.json @@ -17,8 +17,8 @@ "nodes": [] }, "colors": { - "body": "#5b7fe5", - "eyes": "#111316" + "body": "#7c89ff", + "eyes": "#50d7fe" }, "eyes": { "widthLeft": 20, @@ -113,8 +113,8 @@ ] }, "colors": { - "body": "#e6855c", - "eyes": "#ffffff" + "body": "#ff9d45", + "eyes": "#ffda1a" }, "eyes": { "widthLeft": 28.934879954481378, @@ -147,8 +147,8 @@ "nodes": [] }, "colors": { - "body": "#ffcf24", - "eyes": "#000000" + "body": "#ffda1a", + "eyes": "#ffda1a" }, "eyes": { "widthLeft": 20, @@ -178,8 +178,8 @@ "nodes": [] }, "colors": { - "body": "#55b6c3", - "eyes": "#111316" + "body": "#1ad7e3", + "eyes": "#1ad7e3" }, "eyes": { "widthLeft": 13.26171875, @@ -209,8 +209,8 @@ "nodes": [] }, "colors": { - "body": "#000000", - "eyes": "#ffffff" + "body": "#df48f3", + "eyes": "#ff4b6f" }, "eyes": { "widthLeft": 20, @@ -409,8 +409,8 @@ ] }, "colors": { - "body": "#e69a5c", - "eyes": "#111316" + "body": "#be9675", + "eyes": "#ff9d45" }, "eyes": { "widthLeft": 20, @@ -483,8 +483,8 @@ ] }, "colors": { - "body": "#ffc2e9", - "eyes": "#3e4e65" + "body": "#ff4b6f", + "eyes": "#df48f3" }, "eyes": { "widthLeft": 20, @@ -599,8 +599,8 @@ ] }, "colors": { - "body": "#c9cbcf", - "eyes": "#111316" + "body": "#50d7fe", + "eyes": "#1a9cff" }, "eyes": { "widthLeft": 13.942812499999993, @@ -630,8 +630,8 @@ "nodes": [] }, "colors": { - "body": "#e65c5c", - "eyes": "#111316" + "body": "#ff5558", + "eyes": "#ff5558" }, "eyes": { "widthLeft": 19.975390625, @@ -664,8 +664,8 @@ "nodes": [] }, "colors": { - "body": "#dbe2f5", - "eyes": "#111316" + "body": "#1a9cff", + "eyes": "#1a9cff" }, "eyes": { "widthLeft": 13.389062500000001, @@ -1253,6 +1253,29 @@ "eyeMotion": "shake", "bodyMotion": "slowDrift", "bodyColor": "#adc3ff" + }, + { + "id": "expression-joyful-arc", + "semanticKey": "joyful-arc", + "headX": -8.75234375, + "headY": -8.743359375, + "headZ": -10.773828125, + "widthLeft": 54, + "widthRight": 54, + "heightLeft": 15, + "heightRight": 15, + "curvatureLeft": 1, + "curvatureRight": 1, + "spacing": 69.276171875, + "positionXLeft": 0, + "positionXRight": 0, + "positionYLeft": -18, + "positionYRight": -18, + "leftAngle": 0, + "rightAngle": 0, + "perspective": 1, + "eyeMotion": "none", + "bodyMotion": "none" } ], "sequences": [ @@ -1699,7 +1722,7 @@ }, { "id": "angry-step-1", - "expressionId": "expression-16", + "expressionId": "expression-3d2bed26-f97c-477d-922f-77600cb10e92", "holdMs": 2300, "transitionMs": 500, "transition": "smooth" @@ -1798,6 +1821,38 @@ "durationMs": 260 } }, + { + "id": "joyful", + "semanticKey": "joyful", + "name": "joyful", + "group": "Réactions", + "description": "Morphing fluide entre les expressions 11 et 27.", + "builtIn": true, + "playbackMode": "loop", + "steps": [ + { + "id": "joyful-step-0", + "expressionId": "expression-11", + "holdMs": 2300, + "transitionMs": 700, + "transition": "gentleSpring" + }, + { + "id": "joyful-step-1", + "expressionId": "expression-joyful-arc", + "holdMs": 2300, + "transitionMs": 700, + "transition": "gentleSpring" + } + ], + "blink": { + "enabled": false, + "initialDelayMs": 2100, + "minIntervalMs": 2800, + "maxIntervalMs": 5000, + "durationMs": 260 + } + }, { "id": "curious", "semanticKey": "curious", @@ -2089,7 +2144,7 @@ }, { "id": "scared-step-1", - "expressionId": "expression-21", + "expressionId": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8", "holdMs": 2300, "transitionMs": 500, "transition": "smooth" diff --git a/src/features/studio/studioDocument.ts b/src/features/studio/studioDocument.ts index 84b374d..45d7ef4 100644 --- a/src/features/studio/studioDocument.ts +++ b/src/features/studio/studioDocument.ts @@ -1,4 +1,5 @@ import { + ensurePrimitiveBundledAvatars, parseAvatarLibrary, parseExpressions, restoreLegacyBehaviorSemanticKeys, @@ -99,17 +100,181 @@ const createBundledStudioDocument = () => { return parseStudioDocument(snapshot, snapshot) } +const restoreStandaloneBundledAnimations = ( + document: StudioDocument, + fallback: StudioDocument +): Pick => { + const bundledIds = new Set(fallback.sequences.map(sequence => sequence.id)) + const targetByPairId = new Map( + fallback.sequences + .filter(sequence => sequence.id !== 'idle') + .map(sequence => [`idle-${sequence.id}`, sequence.id]) + ) + if (!document.sequences.some(sequence => targetByPairId.has(sequence.id))) { + return { sequences: document.sequences, playback: document.playback } + } + const custom = document.sequences.filter( + sequence => !bundledIds.has(sequence.id) && !targetByPairId.has(sequence.id) + ) + const stateId = + document.playback.stateId === null + ? null + : (targetByPairId.get(document.playback.stateId) ?? document.playback.stateId) + return { + sequences: [...fallback.sequences, ...custom], + playback: { ...document.playback, stateId }, + } +} + +const restoreSignatureAnimationSteps = ( + sequences: AvatarSequence[], + fallback: AvatarSequence[], + signatureIds = new Set(['angry', 'scared', 'joyful']) +) => { + const fallbackById = new Map(fallback.map(sequence => [sequence.id, sequence])) + const restored = sequences.map(sequence => { + if (!signatureIds.has(sequence.id)) return sequence + const canonical = fallbackById.get(sequence.id) + if (!canonical) return sequence + if (sequence.id === 'joyful') { + return { + ...canonical, + steps: canonical.steps.map(step => ({ ...step })), + blink: { ...canonical.blink }, + } + } + const steps = canonical.steps.map((canonicalStep, index) => ({ + ...(sequence.steps[index] ?? canonicalStep), + id: canonicalStep.id, + expressionId: canonicalStep.expressionId, + })) + const unchanged = + steps.length === sequence.steps.length && + steps.every( + (step, index) => + step.id === sequence.steps[index].id && + step.expressionId === sequence.steps[index].expressionId + ) + return unchanged ? sequence : { ...sequence, steps } + }) + const joyful = fallbackById.get('joyful') + if (!joyful || restored.some(sequence => sequence.id === joyful.id)) return restored + return [ + ...restored, + { + ...joyful, + steps: joyful.steps.map(step => ({ ...step })), + blink: { ...joyful.blink }, + }, + ] +} + +const restoreSignatureExpressions = ( + expressions: Expression[], + fallback: Expression[], + addMissing = true +) => { + const signatureKeys = new Set(['joyful-arc']) + const fallbackByKey = new Map( + fallback + .filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey)) + .map(expression => [expression.semanticKey!, expression]) + ) + let changed = false + const restored = expressions.map(expression => { + const canonical = expression.semanticKey ? fallbackByKey.get(expression.semanticKey) : undefined + if ( + !canonical || + (expression.curvatureLeft === canonical.curvatureLeft && + expression.curvatureRight === canonical.curvatureRight && + expression.heightLeft === canonical.heightLeft && + expression.heightRight === canonical.heightRight) + ) { + return expression + } + changed = true + return { + ...expression, + curvatureLeft: canonical.curvatureLeft, + curvatureRight: canonical.curvatureRight, + heightLeft: canonical.heightLeft, + heightRight: canonical.heightRight, + } + }) + const existingKeys = new Set(restored.map(expression => expression.semanticKey)) + const missing = addMissing + ? fallback.filter( + expression => + expression.semanticKey && + signatureKeys.has(expression.semanticKey) && + !existingKeys.has(expression.semanticKey) + ) + : [] + if (missing.length) return [...restored, ...missing.map(expression => ({ ...expression }))] + return changed ? restored : expressions +} + +const restoreAvatarSignatureBehavior = ( + library: AvatarLibrary, + fallbackExpressions: Expression[], + fallbackSequences: AvatarSequence[] +) => { + let changed = false + const avatars = library.avatars.map(avatar => { + if (!avatar.behavior) return avatar + const expressions = restoreSignatureExpressions( + avatar.behavior.expressions, + fallbackExpressions, + false + ) + const availableExpressionIds = new Set(expressions.map(expression => expression.id)) + const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful') + const sequences = + joyful && joyful.steps.every(step => availableExpressionIds.has(step.expressionId)) + ? restoreSignatureAnimationSteps( + avatar.behavior.sequences, + fallbackSequences, + new Set(['angry', 'joyful']) + ) + : avatar.behavior.sequences + if (expressions === avatar.behavior.expressions && sequences === avatar.behavior.sequences) { + return avatar + } + changed = true + return { ...avatar, behavior: { expressions, sequences } } + }) + return changed ? { ...library, avatars } : library +} + export const loadStudioDocument = ( storage: Pick = window.localStorage ): StudioDocument => { const fallback = createBundledStudioDocument() try { - return parseStudioDocument( + const document = parseStudioDocument( JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'), fallback ) + const expressions = restoreSignatureExpressions(document.expressions, fallback.expressions) + const hydrated = expressions === document.expressions ? document : { ...document, expressions } + const restored = restoreStandaloneBundledAnimations(hydrated, fallback) + const sequences = restoreSignatureAnimationSteps(restored.sequences, fallback.sequences) + const library = restoreAvatarSignatureBehavior( + ensurePrimitiveBundledAvatars(hydrated.library), + fallback.expressions, + fallback.sequences + ) + return { + ...hydrated, + library, + ...restored, + sequences, + } } catch { - return fallback + return { + ...fallback, + library: ensurePrimitiveBundledAvatars(fallback.library), + } } } diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 044af14..27e92e3 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -38,6 +38,7 @@ import { getSequenceSpring, readSequenceClock, remapSequencesAfterExpressionDelete, + scaleSpringDynamics, type AvatarSequence, type SequenceStep, } from '@/features/animation/sequences' @@ -128,6 +129,9 @@ import { type StudioDocument, } from '@/features/studio/studioDocument' +const JOYFUL_ARC_SEMANTIC_KEY = 'joyful-arc' +const JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER = 1.5 + export function useStudioController() { const { language, setLanguage, t } = useStudioLanguage() const [mode, setMode] = useState('avatars') @@ -340,6 +344,7 @@ export function useStudioController() { const lastTransitionTime = useRef(null) const lastInspectorFrame = useRef(0) const springSpeedRef = useRef(springSpeed) + const expressionEntrySpeedMultiplier = useRef(1) const sequenceTransitionRef = useRef>({ transitionMs: 500, transition: 'smooth', @@ -525,6 +530,8 @@ export function useStudioController() { transitionMs: 500, transition: 'smooth', } + expressionEntrySpeedMultiplier.current = + next.semanticKey === JOYFUL_ARC_SEMANTIC_KEY ? JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER : 1 setActiveExpression(index) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (reduceMotion || transitionSettings?.transitionMs === 0) { @@ -576,7 +583,10 @@ export function useStudioController() { ? progress * progress * (3 - 2 * progress) : transitionSettings.transition === 'snappy' ? 1 - (1 - progress) ** 3 - : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) + : transitionSettings.transition === 'gentleSpring' + ? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) / + (1 - Math.exp(-9) * Math.cos(5)) + : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion } expressionFields.forEach(field => { animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased @@ -618,11 +628,11 @@ export function useStudioController() { if (avatar) { const targetColors = resolveColors(next, avatar.colors) bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, { - duration: 0.35, + duration: 0.35 / expressionEntrySpeedMultiplier.current, ease: 'easeInOut', }) eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, { - duration: 0.35, + duration: 0.35 / expressionEntrySpeedMultiplier.current, ease: 'easeInOut', }) } @@ -644,16 +654,23 @@ export function useStudioController() { const previousTime = lastTransitionTime.current ?? time const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30) lastTransitionTime.current = time - const { stiffness, damping } = getSequenceSpring( - sequenceTransitionRef.current.transition, - sequenceTransitionRef.current.transitionMs, - springSpeedRef.current + const { stiffness, damping } = scaleSpringDynamics( + getSequenceSpring( + sequenceTransitionRef.current.transition, + sequenceTransitionRef.current.transitionMs, + springSpeedRef.current + ), + expressionEntrySpeedMultiplier.current ) const mass = 0.85 const currentExpression = displayedPose.current.expression if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) { if (retargetStartedAt.current < 0) retargetStartedAt.current = time - const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1) + const linearProgress = Math.min( + (time - retargetStartedAt.current) / + (RETARGET_BLEND_MS / expressionEntrySpeedMultiplier.current), + 1 + ) const smoothProgress = linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10) const blendedTarget = { ...retargetTo.current } @@ -815,6 +832,7 @@ export function useStudioController() { const nextExpressions = nextBehavior.expressions const nextSequences = nextBehavior.sequences stopTransition(true) + stopColorTransitions() activeAvatarIdRef.current = id surfaceRef.current = avatar.body.primary bodyNodesRef.current = avatar.body.nodes @@ -849,9 +867,23 @@ export function useStudioController() { modeRef.current = 'manual' setMode('manual') } + const avatarStateExpression = + nextExpressions.find(expression => expression.id === currentStateExpression.id) ?? + nextExpressions.find( + expression => + currentStateExpression.semanticKey && + expression.semanticKey === currentStateExpression.semanticKey + ) const selectedExpression = nextActiveSequence - ? currentStateExpression + ? { ...(avatarStateExpression ?? currentStateExpression) } : { ...(nextExpressions[0] ?? defaultExpression) } + const usesFixedSkinColors = nextExpressions.every( + expression => !expression.bodyColor && !expression.eyeColor + ) + if (usesFixedSkinColors) { + delete selectedExpression.bodyColor + delete selectedExpression.eyeColor + } const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression setExpression(nextExpression) if (editBody) { @@ -1537,6 +1569,16 @@ export function useStudioController() { : activeSequence.name : null const expressionById = new Map(expressions.map(item => [item.id, item])) + const playbackExpression = + activeSequence && playbackVisual.position !== null + ? expressionById.get(activeSequence.steps[playbackVisual.position]?.expressionId ?? '') + : null + const activeSequenceUsesBodyColor = + activeSequence?.steps.some(step => expressionById.get(step.expressionId)?.bodyColor) ?? false + const renderedExpressionBodyColor = + playbackExpression?.bodyColor ?? + expression.bodyColor ?? + (activeSequenceUsesBodyColor ? renderedColors.body.get() : undefined) const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') => t( issue === 'missing_semantic_key' @@ -1999,6 +2041,7 @@ export function useStudioController() { reduceMotion, renameActiveAvatar, renderedColors, + renderedExpressionBodyColor, renderedRotationGizmo, renderedScene, runtimeDefinitionResult, diff --git a/src/i18n/index.ts b/src/i18n/index.ts index c3cac44..eae2587 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -100,6 +100,15 @@ const english: Record = { 'Couleur des yeux': 'Eye color', Largeur: 'Width', Hauteur: 'Height', + Ellipse: 'Ellipse', + Carré: 'Square', + Arrondi: 'Rounded', + Goutte: 'Teardrop', + Trapèze: 'Trapezoid', + 'Tronc de cône': 'Frustum', + 'Demi-cône': 'Half cone', + 'Échelle du sommet': 'Top scale', + 'Angle de coupe': 'Cut angle', Échelle: 'Scale', 'Taille proportionnelle': 'Proportional size', 'Œil gauche': 'Left eye', @@ -504,6 +513,7 @@ const english: Record = { 'Durée de transition': 'Transition duration', Transition: 'Transition', Ressort: 'Spring', + 'Ressort léger': 'Gentle spring', Douce: 'Smooth', Rapide: 'Snappy', 'Ajouter une expression': 'Add expression', @@ -588,6 +598,7 @@ const english: Record = { 'Fast tilts and rotations to simulate a moving agent.', 'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.', 'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.', + 'Morphing fluide entre les expressions 11 et 27.': 'Smooth morph between expressions 11 and 27.', 'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.', sleeping: 'sleeping', waking: 'waking', @@ -602,6 +613,7 @@ const english: Record = { angry: 'angry', drowsy: 'drowsy', happy: 'happy', + joyful: 'joyful', curious: 'curious', confused: 'confused', bored: 'bored', @@ -655,6 +667,7 @@ const frenchStates: Record = { angry: 'en colère', drowsy: 'somnolent', happy: 'heureux', + joyful: 'joyeux', curious: 'curieux', confused: 'confus', bored: 'ennuyé',