From 7ad18432f95130f07b7c3490b90e80c42f8a43d8 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Tue, 18 Aug 2026 13:36:06 +0200 Subject: [PATCH] feat(studio): refine body editing and export previews --- src/app/styles.css | 160 ++++-- .../__tests__/canvas-preview-test.ts | 40 +- src/features/rendering/canvasPreview.ts | 10 + .../rendering/components/AvatarCanvas.tsx | 27 +- .../studio/__tests__/runtime-code-test.tsx | 19 + .../components/BodyConstructionAccordion.tsx | 468 ++++++++++++++++++ .../components/HighlightedRuntimeCode.tsx | 24 + .../studio/components/RuntimeGuideDialog.tsx | 29 +- .../studio/components/StudioInspector.tsx | 391 +-------------- src/features/studio/useStudioController.ts | 46 +- src/i18n/index.ts | 2 + src/i18n/zh.ts | 2 + 12 files changed, 760 insertions(+), 458 deletions(-) create mode 100644 src/features/studio/__tests__/runtime-code-test.tsx create mode 100644 src/features/studio/components/BodyConstructionAccordion.tsx create mode 100644 src/features/studio/components/HighlightedRuntimeCode.tsx diff --git a/src/app/styles.css b/src/app/styles.css index 3244490..46eca10 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -591,10 +591,10 @@ input { display: block; overflow-x: auto; padding: 10px 12px; - border: 1px solid #dfe3eb; - border-radius: 9px; - background: #f7f8fb; - color: #3159cb; + border: 1px solid #2d323b; + border-radius: 10px; + background: #17191d; + color: #d8dee9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.5; @@ -1029,6 +1029,7 @@ input { .gizmo-cluster { position: absolute; + z-index: 4; top: 2%; right: 2%; display: flex; @@ -2384,68 +2385,155 @@ p { } .body-tree { display: grid; - gap: 6px; + gap: 8px; } -.body-tree > button { +.body-node-accordion-item { + overflow: hidden; + border: 1px solid #dde1e9; + border-radius: 13px; + background: #fff; + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} +.body-node-accordion-item[data-open] { + border-color: #7796ec; + box-shadow: 0 0 0 2px rgb(91 127 229 / 10%); +} +.body-node-trigger { display: flex; align-items: center; gap: 10px; width: 100%; - height: auto; - min-height: 46px; - padding: 10px 12px; + min-height: 66px; + padding: 9px 12px; text-align: left; white-space: normal; + transition: background-color 160ms ease; } -.body-tree > button[aria-pressed='true'] { - border-color: #7796ec; +.body-node-trigger:hover, +.body-node-trigger[aria-expanded='true'] { background: #edf1ff; +} +.body-node-trigger[aria-expanded='true'] { color: #3f63c6; } -.body-tree > button span:last-child { +.body-node-trigger > svg { + width: 18px; + height: 18px; + margin-left: auto; + color: #7c8492; + transition: transform 180ms cubic-bezier(0.2, 0, 0, 1); +} +.body-node-trigger[aria-expanded='true'] > svg { + transform: rotate(180deg); +} +.body-node-summary { display: grid; min-width: 0; + flex: 1; gap: 2px; } -.body-tree strong, -.body-tree small { +.body-node-summary strong, +.body-node-summary small { line-height: 1.25; } -.body-tree small { +.body-node-summary small { color: #858a95; font-size: 9px; } -.body-node-icon { +.body-node-title-line { + display: flex; + align-items: center; + gap: 7px; +} +.body-node-title-line [data-slot='badge'] { + padding: 2px 6px; + font-size: 7px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.body-node-accordion-content > div { + padding: 0 12px 12px; +} +.body-node-accordion-content > div::before { + display: block; + height: 1px; + margin-bottom: 12px; + background: #e6e9f0; + content: ''; +} +.body-structure-thumbnail { display: grid; - width: 34px; - height: 34px; - flex: 0 0 34px; + width: 48px; + height: 48px; + overflow: hidden; + flex: 0 0 48px; place-items: center; - color: #7c8492; + border-radius: 9px; + background: #f7f8fb; + outline: 1px solid rgb(0 0 0 / 10%); } -.body-node-icon-primary { - width: 40px; - height: 40px; - flex-basis: 40px; -} -.body-node-icon svg { +.body-structure-thumbnail svg { width: 100%; height: 100%; + padding: 2px; } -.body-node-icon svg path { - fill: currentColor; +.body-structure-thumbnail path { + vector-effect: non-scaling-stroke; + stroke-linejoin: round; + transition: + fill 160ms ease, + stroke 160ms ease, + opacity 160ms ease; +} +.body-structure-thumbnail path.is-active { + fill: #7c8492; + stroke: #646d7a; + stroke-width: 1.5; +} +.body-structure-thumbnail path.is-context { + fill: transparent; + stroke: #a8afbb; + stroke-width: 1.5; + opacity: 0.9; } .body-add { display: grid; - gap: 7px; + gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #eceef2; - color: #858a95; - font-size: 10px; - font-weight: 750; } -.body-add > div { +.body-add-trigger { + width: 100%; + min-height: 42px; + border-style: dashed; + color: #5875cf; + transition: + border-color 160ms ease, + background-color 160ms ease, + transform 120ms ease; +} +.body-add-trigger:active { + transform: scale(0.96); +} +.body-add-trigger span { + flex: 1; + text-align: left; +} +.body-add-trigger small { + color: #858a95; + font-size: 9px; + font-variant-numeric: tabular-nums; +} +.body-add-trigger > svg:last-child { + transition: transform 180ms cubic-bezier(0.2, 0, 0, 1); +} +.body-add-trigger[aria-expanded='true'] > svg:last-child { + transform: rotate(180deg); +} +.body-add-options { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; @@ -2465,11 +2553,7 @@ p { height: 30px; } .body-node-editor { - margin-top: 14px; - padding: 13px; - border: 1px solid #dde3f2; - border-radius: 13px; - background: #fafbff; + padding: 0; } .body-node-actions { display: flex; diff --git a/src/features/rendering/__tests__/canvas-preview-test.ts b/src/features/rendering/__tests__/canvas-preview-test.ts index c9dabec..0963a05 100644 --- a/src/features/rendering/__tests__/canvas-preview-test.ts +++ b/src/features/rendering/__tests__/canvas-preview-test.ts @@ -1,8 +1,46 @@ import { applyAvatarEyeDefaults, defaultAvatarEyes } from '@/features/avatar/avatars' -import { resolveCanvasPreviewExpression } from '@/features/rendering/canvasPreview' +import { + resetBodyEditorView, + resolveCanvasPreviewExpression, + shouldSyncCanvasPreviewToReact, +} from '@/features/rendering/canvasPreview' import { defaultExpression } from '@/features/avatar/presets' describe('canvas preview expression', () => { + it('resets the complete temporary pose when body editing opens', () => { + const expression = { + ...defaultExpression, + id: 'expression-active', + semanticKey: 'active', + headX: 24, + headY: -31, + headZ: 17, + spacing: 62, + leftAngle: 12, + widthLeft: 44, + heightRight: 18, + positionXLeft: 9, + positionYRight: 14, + perspective: 3, + eyeMotion: 'shake' as const, + bodyMotion: 'slowDrift' as const, + bodyColor: '#123456', + eyeColor: '#abcdef', + } + + expect(resetBodyEditorView(expression)).toEqual({ + ...defaultExpression, + id: 'expression-active', + semanticKey: 'active', + }) + }) + + it('keeps body rotation previews in Motion values until the gesture commits', () => { + expect(shouldSyncCanvasPreviewToReact(true, 'head')).toBe(false) + expect(shouldSyncCanvasPreviewToReact(true, 'eyes')).toBe(true) + expect(shouldSyncCanvasPreviewToReact(false, 'head')).toBe(true) + }) + it('keeps customized neutral eyes applied while rotating the body preview', () => { const eyes = { ...defaultAvatarEyes, diff --git a/src/features/rendering/canvasPreview.ts b/src/features/rendering/canvasPreview.ts index 43c7ec0..9cd2c3a 100644 --- a/src/features/rendering/canvasPreview.ts +++ b/src/features/rendering/canvasPreview.ts @@ -1,8 +1,18 @@ import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars' import { type Expression } from '../avatar/geometry' +import { defaultExpression } from '../avatar/presets' export type CanvasPreviewTarget = 'head' | 'eyes' +export const resetBodyEditorView = (expression: Expression): Expression => ({ + ...defaultExpression, + id: expression.id, + ...(expression.semanticKey ? { semanticKey: expression.semanticKey } : {}), +}) + +export const shouldSyncCanvasPreviewToReact = (bodyEditing: boolean, target: CanvasPreviewTarget) => + !bodyEditing || target !== 'head' + export const resolveCanvasPreviewExpression = ( expression: Expression, avatarEyes: AvatarEyeDefaults, diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx index 2ddbd8f..6ab25f4 100644 --- a/src/features/rendering/components/AvatarCanvas.tsx +++ b/src/features/rendering/components/AvatarCanvas.tsx @@ -55,12 +55,14 @@ import { export function RotationGizmo({ expression, rendered, + onPreview, onChange, onActiveChange, onReset, }: { expression: Expression rendered: RenderedRotationGizmo + onPreview: (next: Expression) => void onChange: (next: Expression) => void onActiveChange: (active: boolean) => void onReset: () => void @@ -77,6 +79,7 @@ export function RotationGizmo({ | { type: 'view'; startAngle: number; expression: Expression } | null >(null) + const latestExpression = useRef(expression) const pose = poseFromExpression(expression) const rings = { x: rotationRing(pose, 'x'), @@ -119,6 +122,7 @@ export function RotationGizmo({ tangent: unitVector(previous, next), expression, } + latestExpression.current = expression event.currentTarget.setPointerCapture(event.pointerId) } const startView = (event: React.PointerEvent) => { @@ -126,6 +130,7 @@ export function RotationGizmo({ onActiveChange(true) const point = toLocal(event) drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression } + latestExpression.current = expression event.currentTarget.setPointerCapture(event.pointerId) } const move = (event: React.PointerEvent) => { @@ -137,23 +142,34 @@ export function RotationGizmo({ Math.sin(currentAngle - drag.current.startAngle), Math.cos(currentAngle - drag.current.startAngle) ) - onChange(rotateExpressionAroundCamera(drag.current.expression, delta)) + const next = rotateExpressionAroundCamera(drag.current.expression, delta) + latestExpression.current = next + onPreview(next) return } const signedDistance = (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] + (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1] - onChange( - rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5) + const next = rotateExpressionAroundAxis( + drag.current.expression, + drag.current.axis, + signedDistance * 1.5 ) + latestExpression.current = next + onPreview(next) } const stop = () => { + if (drag.current) onChange(latestExpression.current) drag.current = null onActiveChange(false) } const cancel = () => { - if (drag.current) onChange(drag.current.expression) - stop() + if (drag.current) { + onPreview(drag.current.expression) + onChange(drag.current.expression) + } + drag.current = null + onActiveChange(false) } useEscapeToCancel(cancel) return ( @@ -883,6 +899,7 @@ export function AvatarCanvas({ onPreview(next, 'head')} onChange={onChange} onActiveChange={active => onHighlightChange(active ? 'head' : null)} onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })} diff --git a/src/features/studio/__tests__/runtime-code-test.tsx b/src/features/studio/__tests__/runtime-code-test.tsx new file mode 100644 index 0000000..c5ba790 --- /dev/null +++ b/src/features/studio/__tests__/runtime-code-test.tsx @@ -0,0 +1,19 @@ +import { renderToStaticMarkup } from 'react-dom/server' + +import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode' + +describe('runtime code highlighting', () => { + it('uses the guide token colors for quick-start TypeScript code', () => { + const markup = renderToStaticMarkup( + + {`import { createAvatar } from '@bible-strong/avatar-react' +const Avatar = createAvatar(definition) +return `} + + ) + + expect(markup).toContain('runtime-token-keyword') + expect(markup).toContain('runtime-token-string') + expect(markup).toContain('runtime-token-tag') + }) +}) diff --git a/src/features/studio/components/BodyConstructionAccordion.tsx b/src/features/studio/components/BodyConstructionAccordion.tsx new file mode 100644 index 0000000..7c34421 --- /dev/null +++ b/src/features/studio/components/BodyConstructionAccordion.tsx @@ -0,0 +1,468 @@ +import { ChevronDown, Plus } from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import { useState } from 'react' + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' + +import { InspectorCard, PanelTitle } from '@/app/components/common' +import { NumericField } from '@/app/components/controls' +import { getPreviewGeometry, scaleSurface } from '@/app/studio-utils' +import { bodyPrimitiveTypes, MAX_BODY_NODES, type BodyNode } from '@/features/avatar/body' +import { SurfaceThumbnail } from '@/features/avatar/components/ExpressionWorkspace' +import { surfaceLabels, surfacePresets, type SurfaceConfig } from '@/features/avatar/surfaces' +import type { StudioController } from '@/features/studio/useStudioController' + +function BodyStructureThumbnail({ + surface, + bodyNodes, + activeNodeId, + expression, +}: { + surface: SurfaceConfig + bodyNodes: BodyNode[] + activeNodeId: string + expression: StudioController['expression'] +}) { + const geometry = getPreviewGeometry(expression, surface, bodyNodes) + const pathClassName = (nodeId: string | null) => + activeNodeId === 'primary' + ? nodeId === null + ? 'is-active' + : 'is-context' + : nodeId === activeNodeId + ? 'is-active' + : 'is-context' + + return ( + + ) +} + +export function BodyConstructionAccordion({ + controller, + reduceMotion, +}: { + controller: StudioController + reduceMotion: boolean +}) { + const [addOpen, setAddOpen] = useState(false) + const { + addBodyNode, + bodyNodes, + deleteSelectedBodyNode, + duplicateSelectedBodyNode, + expression, + selectBodyNode, + selectedBodyNode, + selectedBodyNodeId, + surface, + t, + updateNodeVector, + updateSelectedBodyNode, + updateSurface, + } = controller + + return ( + + + { + const nextNodeId = value.at(-1) + if (nextNodeId) selectBodyNode(nextNodeId) + }} + > + + + + + + {t(surfaceLabels[surface.type])} + {t('Principale')} + + {t('porte les yeux')} + + + +
+ {bodyPrimitiveTypes.map(type => { + const previewSurface = type === surface.type ? surface : surfacePresets[type] + return ( + + ) + })} +
+
+ + updateSurface( + scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) + ) + } + /> + updateSurface({ ...surface, width })} + /> + updateSurface({ ...surface, height })} + /> + updateSurface({ ...surface, depth })} + /> + {(surface.type === 'cube' || surface.type === 'diamond') && ( + controller.updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {surface.type === 'cylinder' && ( + controller.updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {(surface.type === 'cylinder' || surface.type === 'cone') && ( + controller.updateHighlight(active ? 'head' : null)} + onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })} + /> + )} + {surface.type === 'cone' && ( + <> + controller.updateHighlight(active ? 'head' : null)} + onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })} + /> + controller.updateHighlight(active ? 'head' : null)} + onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })} + /> + + )} +
+
+
+ + {bodyNodes.map(node => ( + + + + + {t(node.name)} + {t(surfaceLabels[node.surface.type])} + + + + {selectedBodyNodeId === node.id && selectedBodyNode && ( +
+
+ {t('Réglages de la forme')} +
+ + +
+
+

+ {t('Gizmo local')} + {t('Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.')} +

+
+ + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: scaleSurface(currentNode.surface, size, { + width: 10, + height: 10, + depth: 10, + }), + })) + } + /> + {(['width', 'height', 'depth'] as const).map(dimension => ( + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, [dimension]: value }, + })) + } + /> + ))} + {(selectedBodyNode.surface.type === 'cube' || + selectedBodyNode.surface.type === 'diamond' || + selectedBodyNode.surface.type === 'cylinder') && ( + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, roundness }, + })) + } + /> + )} + {(selectedBodyNode.surface.type === 'cylinder' || + selectedBodyNode.surface.type === 'cone') && ( + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, morphRoundness }, + })) + } + /> + )} + {selectedBodyNode.surface.type === 'cone' && ( + <> + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, tipRoundness }, + })) + } + /> + + updateSelectedBodyNode(currentNode => ({ + ...currentNode, + surface: { ...currentNode.surface, baseRoundness }, + })) + } + /> + + )} +
+
+
+

{t('Position locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('position', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+

{t('Rotation locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('rotation', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+
+ )} +
+
+ ))} +
+ +
+ + + {addOpen && ( + + {bodyPrimitiveTypes.map(type => ( + + ))} + + )} + +
+
+ ) +} diff --git a/src/features/studio/components/HighlightedRuntimeCode.tsx b/src/features/studio/components/HighlightedRuntimeCode.tsx new file mode 100644 index 0000000..c6a934c --- /dev/null +++ b/src/features/studio/components/HighlightedRuntimeCode.tsx @@ -0,0 +1,24 @@ +const codeTokenPattern = + /(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)\b)/g + +export function HighlightedRuntimeCode({ children }: { children: string }) { + return children.split(codeTokenPattern).map((token, index) => { + if (!token) return null + const kind = token.startsWith('//') + ? 'comment' + : token.startsWith("'") || token.startsWith('"') || token.startsWith('@') + ? 'string' + : token.startsWith('<') + ? 'tag' + : /^(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)$/.test( + token + ) + ? 'keyword' + : 'plain' + return ( + + {token} + + ) + }) +} diff --git a/src/features/studio/components/RuntimeGuideDialog.tsx b/src/features/studio/components/RuntimeGuideDialog.tsx index cb9e103..2e8a036 100644 --- a/src/features/studio/components/RuntimeGuideDialog.tsx +++ b/src/features/studio/components/RuntimeGuideDialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode' import { useStudioLanguage } from '@/i18n' const runtimeInstallExample = 'npm install @bible-strong/avatar-react react react-dom' @@ -99,34 +100,12 @@ export function Controls() { }` -const codeTokenPattern = - /(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)\b)/g - -const highlightedCode = (source: string) => - source.split(codeTokenPattern).map((token, index) => { - if (!token) return null - const kind = token.startsWith('//') - ? 'comment' - : token.startsWith("'") || token.startsWith('"') || token.startsWith('@') - ? 'string' - : token.startsWith('<') - ? 'tag' - : /^(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)$/.test( - token - ) - ? 'keyword' - : 'plain' - return ( - - {token} - - ) - }) - function GuideCode({ children }: { children: string }) { return (
-      {highlightedCode(children)}
+      
+        {children}
+      
     
) } diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 01429ff..1996a1b 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -51,21 +51,20 @@ import { StatePlayer, } from '@/app/components/common' import { ColorField, LinkButton, NumericField } from '@/app/components/controls' -import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils' +import { formatSeconds, type Side, type SnapshotFormat } from '@/app/studio-utils' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { defaultAvatarEyes } from '@/features/avatar/avatars' -import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body' import { ExpressionCard, ExpressionPreview, ExpressionWorkspace, - SurfaceThumbnail, } from '@/features/avatar/components/ExpressionWorkspace' import { defaultExpression } from '@/features/avatar/presets' -import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces' import { type SnapshotBackground } from '@/features/export/snapshotExporter' import { AvatarPage } from '@/features/studio/components/AvatarDrawer' +import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion' +import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode' import { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog' import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog' import { StudioIdentity } from '@/features/studio/components/StudioIdentity' @@ -107,7 +106,6 @@ export function StudioInspector({ controller }: { controller: StudioController } activeSequence, activeSequenceLabel, activeState, - addBodyNode, avatarDragOrigin, avatarDragPreview, avatars, @@ -126,7 +124,6 @@ export function StudioInspector({ controller }: { controller: StudioController } commitStateMove, copyAvatarRuntimeDefinition, createNewAvatar, - deleteSelectedBodyNode, downloadAvatarExport, downloadAvatarRuntimeDefinition, downloadStudioProject, @@ -138,7 +135,6 @@ export function StudioInspector({ controller }: { controller: StudioController } draggingStateId, duplicateAvatar, duplicateExpression, - duplicateSelectedBodyNode, duplicateSequenceEditing, duplicateState, editing, @@ -178,9 +174,6 @@ export function StudioInspector({ controller }: { controller: StudioController } saveAvatarEditing, saveEditing, saveSequenceEditing, - selectBodyNode, - selectedBodyNode, - selectedBodyNodeId, selectedExportAnimations, selectedSequenceStepId, selectedState, @@ -234,8 +227,6 @@ export function StudioInspector({ controller }: { controller: StudioController } updateDimension, updateHighlight, updateImmediate, - updateNodeVector, - updateSelectedBodyNode, updateSize, updateSpacing, updateSurface, @@ -463,366 +454,10 @@ export function StudioInspector({ controller }: { controller: StudioController } title="Corps" subtitle="Construction, forme et couleur de la tête de l’avatar." > - - -
- - {bodyNodes.map(node => ( - - ))} -
-
- - {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES} - -
- {bodyPrimitiveTypes.map(type => ( - - ))} -
-
- {selectedBodyNode && ( -
-
- {selectedBodyNode.name} -
- - -
-
-

- {t('Gizmo local')} - {t( - 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' - )} -

-
- - updateSelectedBodyNode(node => ({ - ...node, - surface: scaleSurface(node.surface, size, { - width: 10, - height: 10, - depth: 10, - }), - })) - } - /> - {(['width', 'height', 'depth'] as const).map(dimension => ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, [dimension]: value }, - })) - } - /> - ))} - {(selectedBodyNode.surface.type === 'cube' || - selectedBodyNode.surface.type === 'diamond' || - selectedBodyNode.surface.type === 'cylinder') && ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, roundness }, - })) - } - /> - )} - {(selectedBodyNode.surface.type === 'cylinder' || - selectedBodyNode.surface.type === 'cone') && ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, morphRoundness }, - })) - } - /> - )} - {selectedBodyNode.surface.type === 'cone' && ( - <> - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, tipRoundness }, - })) - } - /> - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, baseRoundness }, - })) - } - /> - - )} -
-
-
-

{t('Position locale')}

- {(['X', 'Y', 'Z'] as const).map((axis, index) => ( - - updateNodeVector('position', index as 0 | 1 | 2, value) - } - /> - ))} -
-
-

{t('Rotation locale')}

- {(['X', 'Y', 'Z'] as const).map((axis, index) => ( - - updateNodeVector('rotation', index as 0 | 1 | 2, value) - } - /> - ))} -
-
-
- )} -
- - -
- {bodyPrimitiveTypes.map(type => { - const previewSurface = - type === surface.type ? surface : surfacePresets[type] - return ( - - ) - })} -
-
- - updateSurface( - scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) - ) - } - /> - updateSurface({ ...surface, width })} - /> - updateSurface({ ...surface, height })} - /> - updateSurface({ ...surface, depth })} - /> - {(surface.type === 'cube' || surface.type === 'diamond') && ( - updateHighlight(active ? 'head' : null)} - onChange={roundness => updateSurface({ ...surface, roundness })} - /> - )} - {surface.type === 'cylinder' && ( - updateHighlight(active ? 'head' : null)} - onChange={roundness => updateSurface({ ...surface, roundness })} - /> - )} - {(surface.type === 'cylinder' || surface.type === 'cone') && ( - updateHighlight(active ? 'head' : null)} - onChange={morphRoundness => - updateSurface({ ...surface, morphRoundness }) - } - /> - )} - {surface.type === 'cone' && ( - <> - updateHighlight(active ? 'head' : null)} - onChange={tipRoundness => - updateSurface({ ...surface, tipRoundness }) - } - /> - updateHighlight(active ? 'head' : null)} - onChange={baseRoundness => - updateSurface({ ...surface, baseRoundness }) - } - /> - - )} -
-
+ {t('Installation')} - {exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall} + + {exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall} + @@ -1826,9 +1463,11 @@ export function StudioInspector({ controller }: { controller: StudioController } {t('Utilisation minimale')}
                         
-                          {exportFormat === 'react'
-                            ? reactQuickStartExample(runtimePreviewAnimation)
-                            : webQuickStartExample(runtimePreviewAnimation)}
+                          
+                            {exportFormat === 'react'
+                              ? reactQuickStartExample(runtimePreviewAnimation)
+                              : webQuickStartExample(runtimePreviewAnimation)}
+                          
                         
                       
diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 43fd24f..383d6a4 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -95,7 +95,9 @@ import { type SnapshotBackground, } from '@/features/export/snapshotExporter' import { + resetBodyEditorView, resolveCanvasPreviewExpression, + shouldSyncCanvasPreviewToReact, type CanvasPreviewTarget, } from '@/features/rendering/canvasPreview' import { @@ -768,9 +770,13 @@ export function useStudioController() { const activateAvatar = (id: string, editBody = false, preserveMode = false) => { const avatar = avatarsRef.current.find(item => item.id === id) if (!avatar) return - const resumeActiveSequence = statePlaying - if (resumeActiveSequence) pauseState(false) - if (editBody) suspendStateForEditor() + const resumeActiveSequence = !editBody && statePlaying + if (editBody) { + suspendStateForEditor() + stopState(false) + } else if (resumeActiveSequence) { + pauseState(false) + } if (editBody && !avatarEditSnapshot.current) { avatarEditSnapshot.current = { avatars: avatarsRef.current, @@ -795,9 +801,10 @@ export function useStudioController() { setExpressions(nextExpressions) setSequences(nextSequences) setExportAnimationIds(nextSequences.map(animation => animation.id)) - const nextActiveSequence = activeState - ? (nextSequences.find(sequence => sequence.id === activeState) ?? null) - : null + const nextActiveSequence = + !editBody && activeState + ? (nextSequences.find(sequence => sequence.id === activeState) ?? null) + : null const nextSelectedState = nextActiveSequence?.id ?? (nextSequences.some(sequence => sequence.id === 'idle') @@ -811,15 +818,26 @@ export function useStudioController() { setActiveExpression(null) setEditing(null) setBodyEditing(editBody) - if (!preserveMode || editBody) setMode('manual') - const nextExpression = nextActiveSequence + if (!preserveMode || editBody) { + modeRef.current = 'manual' + setMode('manual') + } + const selectedExpression = nextActiveSequence ? currentStateExpression : { ...(nextExpressions[0] ?? defaultExpression) } + const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression setExpression(nextExpression) - setDisplayColors(resolveColors(nextExpression, avatar.colors)) - canonicalTarget.current = nextExpression - transitionTarget.current = nextExpression - paintPose(poseFromExpression(nextExpression)) + if (editBody) { + transitionToExpression(nextExpression, null, { + transitionMs: 450, + transition: 'smooth', + }) + } else { + setDisplayColors(resolveColors(nextExpression, avatar.colors)) + canonicalTarget.current = nextExpression + transitionTarget.current = nextExpression + paintPose(poseFromExpression(nextExpression)) + } if (nextActiveSequence && resumeActiveSequence) { pausedSequenceTransition.current = null launchSequence(nextActiveSequence, true, false) @@ -1248,7 +1266,9 @@ export function useStudioController() { const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { const now = performance.now() - const updateInspector = now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS + const updateInspector = + shouldSyncCanvasPreviewToReact(bodyEditing, target) && + now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS if (updateInspector) { lastInspectorFrame.current = now if (editing) { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0d4c936..b9da123 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -369,8 +369,10 @@ const english: Record = { 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': 'One primary shape carries the eyes. Other primitives are placed around it.', 'Forme principale': 'Primary shape', + Principale: 'Primary', 'porte les yeux': 'carries the eyes', 'Ajouter une forme': 'Add a shape', + 'Réglages de la forme': 'Shape settings', Dupliquer: 'Duplicate', 'Gizmo local': 'Local gizmo', 'Déplacer dans le plan de la caméra': 'Move in camera plane', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9997d02..eef7535 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -327,8 +327,10 @@ export const chinese: Record = { 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': '主形状承载眼睛,其他基础形状围绕它放置。', 'Forme principale': '主形状', + Principale: '主要', 'porte les yeux': '承载眼睛', 'Ajouter une forme': '添加形状', + 'Réglages de la forme': '形状设置', Dupliquer: '复制', 'Gizmo local': '局部控制器', 'Déplacer dans le plan de la caméra': '在相机平面中移动',