feat(studio): refine body editing and export previews

This commit is contained in:
smontlouis 2026-08-18 13:36:06 +02:00
parent 973d5e98c0
commit 7ad18432f9
12 changed files with 760 additions and 458 deletions

View File

@ -591,10 +591,10 @@ input {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #dfe3eb; border: 1px solid #2d323b;
border-radius: 9px; border-radius: 10px;
background: #f7f8fb; background: #17191d;
color: #3159cb; color: #d8dee9;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; font-size: 11px;
line-height: 1.5; line-height: 1.5;
@ -1029,6 +1029,7 @@ input {
.gizmo-cluster { .gizmo-cluster {
position: absolute; position: absolute;
z-index: 4;
top: 2%; top: 2%;
right: 2%; right: 2%;
display: flex; display: flex;
@ -2384,68 +2385,155 @@ p {
} }
.body-tree { .body-tree {
display: grid; 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; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
height: auto; min-height: 66px;
min-height: 46px; padding: 9px 12px;
padding: 10px 12px;
text-align: left; text-align: left;
white-space: normal; white-space: normal;
transition: background-color 160ms ease;
} }
.body-tree > button[aria-pressed='true'] { .body-node-trigger:hover,
border-color: #7796ec; .body-node-trigger[aria-expanded='true'] {
background: #edf1ff; background: #edf1ff;
}
.body-node-trigger[aria-expanded='true'] {
color: #3f63c6; 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; display: grid;
min-width: 0; min-width: 0;
flex: 1;
gap: 2px; gap: 2px;
} }
.body-tree strong, .body-node-summary strong,
.body-tree small { .body-node-summary small {
line-height: 1.25; line-height: 1.25;
} }
.body-tree small { .body-node-summary small {
color: #858a95; color: #858a95;
font-size: 9px; 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; display: grid;
width: 34px; width: 48px;
height: 34px; height: 48px;
flex: 0 0 34px; overflow: hidden;
flex: 0 0 48px;
place-items: center; place-items: center;
color: #7c8492; border-radius: 9px;
background: #f7f8fb;
outline: 1px solid rgb(0 0 0 / 10%);
} }
.body-node-icon-primary { .body-structure-thumbnail svg {
width: 40px;
height: 40px;
flex-basis: 40px;
}
.body-node-icon svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 2px;
} }
.body-node-icon svg path { .body-structure-thumbnail path {
fill: currentColor; 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 { .body-add {
display: grid; display: grid;
gap: 7px; gap: 8px;
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid #eceef2; 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; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px; gap: 6px;
@ -2465,11 +2553,7 @@ p {
height: 30px; height: 30px;
} }
.body-node-editor { .body-node-editor {
margin-top: 14px; padding: 0;
padding: 13px;
border: 1px solid #dde3f2;
border-radius: 13px;
background: #fafbff;
} }
.body-node-actions { .body-node-actions {
display: flex; display: flex;

View File

@ -1,8 +1,46 @@
import { applyAvatarEyeDefaults, defaultAvatarEyes } from '@/features/avatar/avatars' 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' import { defaultExpression } from '@/features/avatar/presets'
describe('canvas preview expression', () => { 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', () => { it('keeps customized neutral eyes applied while rotating the body preview', () => {
const eyes = { const eyes = {
...defaultAvatarEyes, ...defaultAvatarEyes,

View File

@ -1,8 +1,18 @@
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars' import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars'
import { type Expression } from '../avatar/geometry' import { type Expression } from '../avatar/geometry'
import { defaultExpression } from '../avatar/presets'
export type CanvasPreviewTarget = 'head' | 'eyes' 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 = ( export const resolveCanvasPreviewExpression = (
expression: Expression, expression: Expression,
avatarEyes: AvatarEyeDefaults, avatarEyes: AvatarEyeDefaults,

View File

@ -55,12 +55,14 @@ import {
export function RotationGizmo({ export function RotationGizmo({
expression, expression,
rendered, rendered,
onPreview,
onChange, onChange,
onActiveChange, onActiveChange,
onReset, onReset,
}: { }: {
expression: Expression expression: Expression
rendered: RenderedRotationGizmo rendered: RenderedRotationGizmo
onPreview: (next: Expression) => void
onChange: (next: Expression) => void onChange: (next: Expression) => void
onActiveChange: (active: boolean) => void onActiveChange: (active: boolean) => void
onReset: () => void onReset: () => void
@ -77,6 +79,7 @@ export function RotationGizmo({
| { type: 'view'; startAngle: number; expression: Expression } | { type: 'view'; startAngle: number; expression: Expression }
| null | null
>(null) >(null)
const latestExpression = useRef(expression)
const pose = poseFromExpression(expression) const pose = poseFromExpression(expression)
const rings = { const rings = {
x: rotationRing(pose, 'x'), x: rotationRing(pose, 'x'),
@ -119,6 +122,7 @@ export function RotationGizmo({
tangent: unitVector(previous, next), tangent: unitVector(previous, next),
expression, expression,
} }
latestExpression.current = expression
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
} }
const startView = (event: React.PointerEvent<SVGElement>) => { const startView = (event: React.PointerEvent<SVGElement>) => {
@ -126,6 +130,7 @@ export function RotationGizmo({
onActiveChange(true) onActiveChange(true)
const point = toLocal(event) const point = toLocal(event)
drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression } drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression }
latestExpression.current = expression
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
} }
const move = (event: React.PointerEvent<SVGElement>) => { const move = (event: React.PointerEvent<SVGElement>) => {
@ -137,23 +142,34 @@ export function RotationGizmo({
Math.sin(currentAngle - drag.current.startAngle), Math.sin(currentAngle - drag.current.startAngle),
Math.cos(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 return
} }
const signedDistance = const signedDistance =
(point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] + (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] +
(point[1] - drag.current.startPoint[1]) * drag.current.tangent[1] (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1]
onChange( const next = rotateExpressionAroundAxis(
rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5) drag.current.expression,
drag.current.axis,
signedDistance * 1.5
) )
latestExpression.current = next
onPreview(next)
} }
const stop = () => { const stop = () => {
if (drag.current) onChange(latestExpression.current)
drag.current = null drag.current = null
onActiveChange(false) onActiveChange(false)
} }
const cancel = () => { const cancel = () => {
if (drag.current) onChange(drag.current.expression) if (drag.current) {
stop() onPreview(drag.current.expression)
onChange(drag.current.expression)
}
drag.current = null
onActiveChange(false)
} }
useEscapeToCancel(cancel) useEscapeToCancel(cancel)
return ( return (
@ -883,6 +899,7 @@ export function AvatarCanvas({
<RotationGizmo <RotationGizmo
expression={expression} expression={expression}
rendered={rotationGizmo} rendered={rotationGizmo}
onPreview={next => onPreview(next, 'head')}
onChange={onChange} onChange={onChange}
onActiveChange={active => onHighlightChange(active ? 'head' : null)} onActiveChange={active => onHighlightChange(active ? 'head' : null)}
onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })} onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })}

View File

@ -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(
<code>
<HighlightedRuntimeCode>{`import { createAvatar } from '@bible-strong/avatar-react'
const Avatar = createAvatar(definition)
return <Avatar defaultExpression="neutral" />`}</HighlightedRuntimeCode>
</code>
)
expect(markup).toContain('runtime-token-keyword')
expect(markup).toContain('runtime-token-string')
expect(markup).toContain('runtime-token-tag')
})
})

View File

@ -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 (
<span className="body-structure-thumbnail" aria-hidden="true">
<svg viewBox="-150 -150 300 300">
{geometry.backPaths.map((pathValue, index) => (
<path
className={pathClassName(geometry.backNodeIds[index] ?? null)}
d={pathValue}
key={`back-${geometry.backNodeIds[index] ?? index}`}
/>
))}
<path className={pathClassName(null)} d={geometry.headPath} />
{geometry.frontPaths.map((pathValue, index) => (
<path
className={pathClassName(geometry.frontNodeIds[index] ?? null)}
d={pathValue}
key={`front-${geometry.frontNodeIds[index] ?? index}`}
/>
))}
</svg>
</span>
)
}
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 (
<InspectorCard className="body-panel">
<PanelTitle
level={3}
title="Construction du corps"
subtitle="Une forme principale porte les yeux. Les autres primitives se placent autour d’elle."
/>
<Accordion
className="body-tree"
value={[selectedBodyNodeId]}
onValueChange={value => {
const nextNodeId = value.at(-1)
if (nextNodeId) selectBodyNode(nextNodeId)
}}
>
<AccordionItem value="primary" className="body-node-accordion-item">
<AccordionTrigger className="body-node-trigger">
<BodyStructureThumbnail
surface={surface}
bodyNodes={bodyNodes}
activeNodeId="primary"
expression={expression}
/>
<span className="body-node-summary">
<span className="body-node-title-line">
<strong>{t(surfaceLabels[surface.type])}</strong>
<Badge>{t('Principale')}</Badge>
</span>
<small>{t('porte les yeux')}</small>
</span>
</AccordionTrigger>
<AccordionContent className="body-node-accordion-content">
<div className="surface-grid body-surface-grid">
{bodyPrimitiveTypes.map(type => {
const previewSurface = type === surface.type ? surface : surfacePresets[type]
return (
<Button
className="surface-card"
variant="outline"
type="button"
key={type}
aria-pressed={surface.type === type}
onClick={() => {
if (type !== surface.type) updateSurface({ ...surfacePresets[type] })
}}
>
<SurfaceThumbnail surface={previewSurface} />
<span>{t(surfaceLabels[type])}</span>
</Button>
)
})}
</div>
<div className="surface-fields">
<NumericField
label="Échelle"
value={Math.max(surface.width, surface.height, surface.depth)}
min={120}
max={300}
unit="u"
onChange={size =>
updateSurface(
scaleSurface(surface, size, { width: 120, height: 120, depth: 100 })
)
}
/>
<NumericField
label="Largeur"
value={surface.width}
min={120}
max={300}
unit="u"
onChange={width => updateSurface({ ...surface, width })}
/>
<NumericField
label="Hauteur"
value={surface.height}
min={120}
max={300}
unit="u"
onChange={height => updateSurface({ ...surface, height })}
/>
<NumericField
label="Profondeur"
value={surface.depth}
min={100}
max={300}
unit="u"
onChange={depth => updateSurface({ ...surface, depth })}
/>
{(surface.type === 'cube' || surface.type === 'diamond') && (
<NumericField
label="Rondeur"
value={surface.roundness}
min={0}
max={2}
step={0.01}
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={roundness => updateSurface({ ...surface, roundness })}
/>
)}
{surface.type === 'cylinder' && (
<NumericField
label="Rondeur des arêtes"
value={surface.roundness}
min={0}
max={2}
step={0.01}
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={roundness => updateSurface({ ...surface, roundness })}
/>
)}
{(surface.type === 'cylinder' || surface.type === 'cone') && (
<NumericField
label="Rondeur globale"
value={surface.morphRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })}
/>
)}
{surface.type === 'cone' && (
<>
<NumericField
label="Rondeur pointe"
value={surface.tipRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })}
/>
<NumericField
label="Rondeur base"
value={surface.baseRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })}
/>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
{bodyNodes.map(node => (
<AccordionItem value={node.id} key={node.id} className="body-node-accordion-item">
<AccordionTrigger className="body-node-trigger">
<BodyStructureThumbnail
surface={surface}
bodyNodes={bodyNodes}
activeNodeId={node.id}
expression={expression}
/>
<span className="body-node-summary">
<strong>{t(node.name)}</strong>
<small>{t(surfaceLabels[node.surface.type])}</small>
</span>
</AccordionTrigger>
<AccordionContent className="body-node-accordion-content">
{selectedBodyNodeId === node.id && selectedBodyNode && (
<div className="body-node-editor">
<div className="body-node-actions">
<strong>{t('Réglages de la forme')}</strong>
<div>
<Button
variant="outline"
size="sm"
type="button"
disabled={bodyNodes.length >= MAX_BODY_NODES}
onClick={duplicateSelectedBodyNode}
>
{t('Dupliquer')}
</Button>
<Button
variant="destructive"
size="sm"
type="button"
onClick={deleteSelectedBodyNode}
>
{t('Supprimer')}
</Button>
</div>
</div>
<p className="body-gizmo-help">
<Badge variant="outline">{t('Gizmo local')}</Badge>
{t('Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.')}
</p>
<div className="surface-fields">
<NumericField
label="Échelle"
value={Math.max(
selectedBodyNode.surface.width,
selectedBodyNode.surface.height,
selectedBodyNode.surface.depth
)}
min={10}
max={300}
unit="u"
onChange={size =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: scaleSurface(currentNode.surface, size, {
width: 10,
height: 10,
depth: 10,
}),
}))
}
/>
{(['width', 'height', 'depth'] as const).map(dimension => (
<NumericField
key={dimension}
label={
{ width: 'Largeur', height: 'Hauteur', depth: 'Profondeur' }[dimension]
}
value={selectedBodyNode.surface[dimension]}
min={10}
max={300}
unit="u"
onChange={value =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, [dimension]: value },
}))
}
/>
))}
{(selectedBodyNode.surface.type === 'cube' ||
selectedBodyNode.surface.type === 'diamond' ||
selectedBodyNode.surface.type === 'cylinder') && (
<NumericField
label="Rondeur"
value={selectedBodyNode.surface.roundness}
min={0}
max={2}
step={0.01}
onChange={roundness =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, roundness },
}))
}
/>
)}
{(selectedBodyNode.surface.type === 'cylinder' ||
selectedBodyNode.surface.type === 'cone') && (
<NumericField
label="Rondeur globale"
value={selectedBodyNode.surface.morphRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={morphRoundness =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, morphRoundness },
}))
}
/>
)}
{selectedBodyNode.surface.type === 'cone' && (
<>
<NumericField
label="Rondeur pointe"
value={selectedBodyNode.surface.tipRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={tipRoundness =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, tipRoundness },
}))
}
/>
<NumericField
label="Rondeur base"
value={selectedBodyNode.surface.baseRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={baseRoundness =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, baseRoundness },
}))
}
/>
</>
)}
</div>
<div className="body-transform-grid">
<div>
<h3>{t('Position locale')}</h3>
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<NumericField
key={axis}
label={axis}
value={selectedBodyNode.position[index]}
unit="u"
onChange={value =>
updateNodeVector('position', index as 0 | 1 | 2, value)
}
/>
))}
</div>
<div>
<h3>{t('Rotation locale')}</h3>
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<NumericField
key={axis}
label={axis}
value={selectedBodyNode.rotation[index]}
unit="°"
onChange={value =>
updateNodeVector('rotation', index as 0 | 1 | 2, value)
}
/>
))}
</div>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<div className="body-add">
<Button
className="body-add-trigger"
variant="outline"
type="button"
aria-expanded={addOpen}
disabled={bodyNodes.length >= MAX_BODY_NODES}
onClick={() => setAddOpen(open => !open)}
>
<Plus />
<span>{t('Ajouter une forme')}</span>
<small>
{bodyNodes.length}/{MAX_BODY_NODES}
</small>
<ChevronDown />
</Button>
<AnimatePresence initial={false}>
{addOpen && (
<motion.div
className="body-add-options"
initial={reduceMotion ? false : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 1 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.16, ease: [0.2, 0, 0, 1] }}
>
{bodyPrimitiveTypes.map(type => (
<Button
className="surface-card body-add-card"
variant="outline"
type="button"
key={type}
onClick={() => {
addBodyNode(type)
setAddOpen(false)
}}
>
<SurfaceThumbnail surface={surfacePresets[type]} />
<span>{t(surfaceLabels[type])}</span>
</Button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</InspectorCard>
)
}

View File

@ -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 (
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
{token}
</span>
)
})
}

View File

@ -9,6 +9,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode'
import { useStudioLanguage } from '@/i18n' import { useStudioLanguage } from '@/i18n'
const runtimeInstallExample = 'npm install @bible-strong/avatar-react react react-dom' 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 (
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
{token}
</span>
)
})
function GuideCode({ children }: { children: string }) { function GuideCode({ children }: { children: string }) {
return ( return (
<pre className="avatar-guide-code" tabIndex={0}> <pre className="avatar-guide-code" tabIndex={0}>
<code>{highlightedCode(children)}</code> <code>
<HighlightedRuntimeCode>{children}</HighlightedRuntimeCode>
</code>
</pre> </pre>
) )
} }

View File

@ -51,21 +51,20 @@ import {
StatePlayer, StatePlayer,
} from '@/app/components/common' } from '@/app/components/common'
import { ColorField, LinkButton, NumericField } from '@/app/components/controls' 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 { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
import { import {
ExpressionCard, ExpressionCard,
ExpressionPreview, ExpressionPreview,
ExpressionWorkspace, ExpressionWorkspace,
SurfaceThumbnail,
} from '@/features/avatar/components/ExpressionWorkspace' } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter' import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { AvatarPage } from '@/features/studio/components/AvatarDrawer' 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 { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog'
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog' import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
@ -107,7 +106,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
activeSequence, activeSequence,
activeSequenceLabel, activeSequenceLabel,
activeState, activeState,
addBodyNode,
avatarDragOrigin, avatarDragOrigin,
avatarDragPreview, avatarDragPreview,
avatars, avatars,
@ -126,7 +124,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
commitStateMove, commitStateMove,
copyAvatarRuntimeDefinition, copyAvatarRuntimeDefinition,
createNewAvatar, createNewAvatar,
deleteSelectedBodyNode,
downloadAvatarExport, downloadAvatarExport,
downloadAvatarRuntimeDefinition, downloadAvatarRuntimeDefinition,
downloadStudioProject, downloadStudioProject,
@ -138,7 +135,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
draggingStateId, draggingStateId,
duplicateAvatar, duplicateAvatar,
duplicateExpression, duplicateExpression,
duplicateSelectedBodyNode,
duplicateSequenceEditing, duplicateSequenceEditing,
duplicateState, duplicateState,
editing, editing,
@ -178,9 +174,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
saveAvatarEditing, saveAvatarEditing,
saveEditing, saveEditing,
saveSequenceEditing, saveSequenceEditing,
selectBodyNode,
selectedBodyNode,
selectedBodyNodeId,
selectedExportAnimations, selectedExportAnimations,
selectedSequenceStepId, selectedSequenceStepId,
selectedState, selectedState,
@ -234,8 +227,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateDimension, updateDimension,
updateHighlight, updateHighlight,
updateImmediate, updateImmediate,
updateNodeVector,
updateSelectedBodyNode,
updateSize, updateSize,
updateSpacing, updateSpacing,
updateSurface, updateSurface,
@ -463,366 +454,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
title="Corps" title="Corps"
subtitle="Construction, forme et couleur de la tête de l’avatar." subtitle="Construction, forme et couleur de la tête de l’avatar."
> >
<InspectorCard className="body-panel"> <BodyConstructionAccordion
<PanelTitle controller={controller}
level={3} reduceMotion={Boolean(reduceMotion)}
title="Construction du corps" />
subtitle="Une forme principale porte les yeux. Les autres primitives se placent autour d’elle."
/>
<div className="body-tree">
<Button
variant="outline"
type="button"
aria-pressed={selectedBodyNodeId === 'primary'}
onClick={() => selectBodyNode('primary')}
>
<span className="body-node-icon body-node-icon-primary">
<SurfaceThumbnail surface={surface} />
</span>
<span>
<strong>{t('Forme principale')}</strong>
<small>
{t(surfaceLabels[surface.type])} · {t('porte les yeux')}
</small>
</span>
</Button>
{bodyNodes.map(node => (
<Button
variant="outline"
type="button"
key={node.id}
aria-pressed={selectedBodyNodeId === node.id}
onClick={() => selectBodyNode(node.id)}
>
<span className="body-node-icon">
<SurfaceThumbnail surface={node.surface} />
</span>
<span>
<strong>{t(node.name)}</strong>
<small>{t(surfaceLabels[node.surface.type])}</small>
</span>
</Button>
))}
</div>
<div className="body-add">
<span>
{t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES}
</span>
<div>
{bodyPrimitiveTypes.map(type => (
<Button
className="surface-card body-add-card"
variant="outline"
type="button"
key={type}
disabled={bodyNodes.length >= MAX_BODY_NODES}
onClick={() => addBodyNode(type)}
>
<SurfaceThumbnail surface={surfacePresets[type]} />
<span>{t(surfaceLabels[type])}</span>
</Button>
))}
</div>
</div>
{selectedBodyNode && (
<div className="body-node-editor">
<div className="body-node-actions">
<strong>{selectedBodyNode.name}</strong>
<div>
<Button
variant="outline"
size="sm"
type="button"
disabled={bodyNodes.length >= MAX_BODY_NODES}
onClick={duplicateSelectedBodyNode}
>
{t('Dupliquer')}
</Button>
<Button
variant="destructive"
size="sm"
type="button"
onClick={deleteSelectedBodyNode}
>
{t('Supprimer')}
</Button>
</div>
</div>
<p className="body-gizmo-help">
<Badge variant="outline">{t('Gizmo local')}</Badge>
{t(
'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.'
)}
</p>
<div className="surface-fields">
<NumericField
label="Échelle"
value={Math.max(
selectedBodyNode.surface.width,
selectedBodyNode.surface.height,
selectedBodyNode.surface.depth
)}
min={10}
max={300}
unit="u"
onChange={size =>
updateSelectedBodyNode(node => ({
...node,
surface: scaleSurface(node.surface, size, {
width: 10,
height: 10,
depth: 10,
}),
}))
}
/>
{(['width', 'height', 'depth'] as const).map(dimension => (
<NumericField
key={dimension}
label={
{ width: 'Largeur', height: 'Hauteur', depth: 'Profondeur' }[
dimension
]
}
value={selectedBodyNode.surface[dimension]}
min={10}
max={300}
unit="u"
onChange={value =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, [dimension]: value },
}))
}
/>
))}
{(selectedBodyNode.surface.type === 'cube' ||
selectedBodyNode.surface.type === 'diamond' ||
selectedBodyNode.surface.type === 'cylinder') && (
<NumericField
label="Rondeur"
value={selectedBodyNode.surface.roundness}
min={0}
max={2}
step={0.01}
onChange={roundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, roundness },
}))
}
/>
)}
{(selectedBodyNode.surface.type === 'cylinder' ||
selectedBodyNode.surface.type === 'cone') && (
<NumericField
label="Rondeur globale"
value={selectedBodyNode.surface.morphRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={morphRoundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, morphRoundness },
}))
}
/>
)}
{selectedBodyNode.surface.type === 'cone' && (
<>
<NumericField
label="Rondeur pointe"
value={selectedBodyNode.surface.tipRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={tipRoundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, tipRoundness },
}))
}
/>
<NumericField
label="Rondeur base"
value={selectedBodyNode.surface.baseRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={baseRoundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, baseRoundness },
}))
}
/>
</>
)}
</div>
<div className="body-transform-grid">
<div>
<h3>{t('Position locale')}</h3>
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<NumericField
key={axis}
label={axis}
value={selectedBodyNode.position[index]}
unit="u"
onChange={value =>
updateNodeVector('position', index as 0 | 1 | 2, value)
}
/>
))}
</div>
<div>
<h3>{t('Rotation locale')}</h3>
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<NumericField
key={axis}
label={axis}
value={selectedBodyNode.rotation[index]}
unit="°"
onChange={value =>
updateNodeVector('rotation', index as 0 | 1 | 2, value)
}
/>
))}
</div>
</div>
</div>
)}
</InspectorCard>
<InspectorCard className="surface-panel">
<PanelTitle
level={3}
title="Forme principale"
subtitle="Cette surface est la référence du visage et porte les yeux."
/>
<div className="surface-grid">
{bodyPrimitiveTypes.map(type => {
const previewSurface =
type === surface.type ? surface : surfacePresets[type]
return (
<Button
className="surface-card"
variant="outline"
type="button"
key={type}
aria-pressed={surface.type === type}
onClick={() => {
selectBodyNode('primary')
if (type !== surface.type) {
updateSurface({ ...surfacePresets[type] })
}
}}
>
<SurfaceThumbnail surface={previewSurface} />
<span>{t(surfaceLabels[type])}</span>
</Button>
)
})}
</div>
<div className="surface-fields">
<NumericField
label="Échelle"
value={Math.max(surface.width, surface.height, surface.depth)}
min={120}
max={300}
unit="u"
onChange={size =>
updateSurface(
scaleSurface(surface, size, { width: 120, height: 120, depth: 100 })
)
}
/>
<NumericField
label="Largeur"
value={surface.width}
min={120}
max={300}
unit="u"
onChange={width => updateSurface({ ...surface, width })}
/>
<NumericField
label="Hauteur"
value={surface.height}
min={120}
max={300}
unit="u"
onChange={height => updateSurface({ ...surface, height })}
/>
<NumericField
label="Profondeur"
value={surface.depth}
min={100}
max={300}
unit="u"
onChange={depth => updateSurface({ ...surface, depth })}
/>
{(surface.type === 'cube' || surface.type === 'diamond') && (
<NumericField
label="Rondeur"
value={surface.roundness}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={roundness => updateSurface({ ...surface, roundness })}
/>
)}
{surface.type === 'cylinder' && (
<NumericField
label="Rondeur des arêtes"
value={surface.roundness}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={roundness => updateSurface({ ...surface, roundness })}
/>
)}
{(surface.type === 'cylinder' || surface.type === 'cone') && (
<NumericField
label="Rondeur globale"
value={surface.morphRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={morphRoundness =>
updateSurface({ ...surface, morphRoundness })
}
/>
)}
{surface.type === 'cone' && (
<>
<NumericField
label="Rondeur pointe"
value={surface.tipRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={tipRoundness =>
updateSurface({ ...surface, tipRoundness })
}
/>
<NumericField
label="Rondeur base"
value={surface.baseRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={baseRoundness =>
updateSurface({ ...surface, baseRoundness })
}
/>
</>
)}
</div>
</InspectorCard>
<InspectorCard className="color-panel"> <InspectorCard className="color-panel">
<PanelTitle <PanelTitle
level={3} level={3}
@ -1818,7 +1453,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
<div className="runtime-quick-start-step"> <div className="runtime-quick-start-step">
<span>{t('Installation')}</span> <span>{t('Installation')}</span>
<code> <code>
{exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall} <HighlightedRuntimeCode>
{exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
</HighlightedRuntimeCode>
</code> </code>
</div> </div>
@ -1826,9 +1463,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
<span>{t('Utilisation minimale')}</span> <span>{t('Utilisation minimale')}</span>
<pre tabIndex={0}> <pre tabIndex={0}>
<code> <code>
{exportFormat === 'react' <HighlightedRuntimeCode>
? reactQuickStartExample(runtimePreviewAnimation) {exportFormat === 'react'
: webQuickStartExample(runtimePreviewAnimation)} ? reactQuickStartExample(runtimePreviewAnimation)
: webQuickStartExample(runtimePreviewAnimation)}
</HighlightedRuntimeCode>
</code> </code>
</pre> </pre>
</div> </div>

View File

@ -95,7 +95,9 @@ import {
type SnapshotBackground, type SnapshotBackground,
} from '@/features/export/snapshotExporter' } from '@/features/export/snapshotExporter'
import { import {
resetBodyEditorView,
resolveCanvasPreviewExpression, resolveCanvasPreviewExpression,
shouldSyncCanvasPreviewToReact,
type CanvasPreviewTarget, type CanvasPreviewTarget,
} from '@/features/rendering/canvasPreview' } from '@/features/rendering/canvasPreview'
import { import {
@ -768,9 +770,13 @@ export function useStudioController() {
const activateAvatar = (id: string, editBody = false, preserveMode = false) => { const activateAvatar = (id: string, editBody = false, preserveMode = false) => {
const avatar = avatarsRef.current.find(item => item.id === id) const avatar = avatarsRef.current.find(item => item.id === id)
if (!avatar) return if (!avatar) return
const resumeActiveSequence = statePlaying const resumeActiveSequence = !editBody && statePlaying
if (resumeActiveSequence) pauseState(false) if (editBody) {
if (editBody) suspendStateForEditor() suspendStateForEditor()
stopState(false)
} else if (resumeActiveSequence) {
pauseState(false)
}
if (editBody && !avatarEditSnapshot.current) { if (editBody && !avatarEditSnapshot.current) {
avatarEditSnapshot.current = { avatarEditSnapshot.current = {
avatars: avatarsRef.current, avatars: avatarsRef.current,
@ -795,9 +801,10 @@ export function useStudioController() {
setExpressions(nextExpressions) setExpressions(nextExpressions)
setSequences(nextSequences) setSequences(nextSequences)
setExportAnimationIds(nextSequences.map(animation => animation.id)) setExportAnimationIds(nextSequences.map(animation => animation.id))
const nextActiveSequence = activeState const nextActiveSequence =
? (nextSequences.find(sequence => sequence.id === activeState) ?? null) !editBody && activeState
: null ? (nextSequences.find(sequence => sequence.id === activeState) ?? null)
: null
const nextSelectedState = const nextSelectedState =
nextActiveSequence?.id ?? nextActiveSequence?.id ??
(nextSequences.some(sequence => sequence.id === 'idle') (nextSequences.some(sequence => sequence.id === 'idle')
@ -811,15 +818,26 @@ export function useStudioController() {
setActiveExpression(null) setActiveExpression(null)
setEditing(null) setEditing(null)
setBodyEditing(editBody) setBodyEditing(editBody)
if (!preserveMode || editBody) setMode('manual') if (!preserveMode || editBody) {
const nextExpression = nextActiveSequence modeRef.current = 'manual'
setMode('manual')
}
const selectedExpression = nextActiveSequence
? currentStateExpression ? currentStateExpression
: { ...(nextExpressions[0] ?? defaultExpression) } : { ...(nextExpressions[0] ?? defaultExpression) }
const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression
setExpression(nextExpression) setExpression(nextExpression)
setDisplayColors(resolveColors(nextExpression, avatar.colors)) if (editBody) {
canonicalTarget.current = nextExpression transitionToExpression(nextExpression, null, {
transitionTarget.current = nextExpression transitionMs: 450,
paintPose(poseFromExpression(nextExpression)) transition: 'smooth',
})
} else {
setDisplayColors(resolveColors(nextExpression, avatar.colors))
canonicalTarget.current = nextExpression
transitionTarget.current = nextExpression
paintPose(poseFromExpression(nextExpression))
}
if (nextActiveSequence && resumeActiveSequence) { if (nextActiveSequence && resumeActiveSequence) {
pausedSequenceTransition.current = null pausedSequenceTransition.current = null
launchSequence(nextActiveSequence, true, false) launchSequence(nextActiveSequence, true, false)
@ -1248,7 +1266,9 @@ export function useStudioController() {
const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => {
const now = performance.now() 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) { if (updateInspector) {
lastInspectorFrame.current = now lastInspectorFrame.current = now
if (editing) { if (editing) {

View File

@ -369,8 +369,10 @@ const english: Record<string, string> = {
'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': '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.', 'One primary shape carries the eyes. Other primitives are placed around it.',
'Forme principale': 'Primary shape', 'Forme principale': 'Primary shape',
Principale: 'Primary',
'porte les yeux': 'carries the eyes', 'porte les yeux': 'carries the eyes',
'Ajouter une forme': 'Add a shape', 'Ajouter une forme': 'Add a shape',
'Réglages de la forme': 'Shape settings',
Dupliquer: 'Duplicate', Dupliquer: 'Duplicate',
'Gizmo local': 'Local gizmo', 'Gizmo local': 'Local gizmo',
'Déplacer dans le plan de la caméra': 'Move in camera plane', 'Déplacer dans le plan de la caméra': 'Move in camera plane',

View File

@ -327,8 +327,10 @@ export const chinese: Record<string, string> = {
'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.':
'主形状承载眼睛,其他基础形状围绕它放置。', '主形状承载眼睛,其他基础形状围绕它放置。',
'Forme principale': '主形状', 'Forme principale': '主形状',
Principale: '主要',
'porte les yeux': '承载眼睛', 'porte les yeux': '承载眼睛',
'Ajouter une forme': '添加形状', 'Ajouter une forme': '添加形状',
'Réglages de la forme': '形状设置',
Dupliquer: '复制', Dupliquer: '复制',
'Gizmo local': '局部控制器', 'Gizmo local': '局部控制器',
'Déplacer dans le plan de la caméra': '在相机平面中移动', 'Déplacer dans le plan de la caméra': '在相机平面中移动',