feat(studio): refine body editing and export previews
This commit is contained in:
parent
973d5e98c0
commit
7ad18432f9
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<SVGElement>) => {
|
||||
@ -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<SVGElement>) => {
|
||||
@ -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({
|
||||
<RotationGizmo
|
||||
expression={expression}
|
||||
rendered={rotationGizmo}
|
||||
onPreview={next => onPreview(next, 'head')}
|
||||
onChange={onChange}
|
||||
onActiveChange={active => onHighlightChange(active ? 'head' : null)}
|
||||
onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })}
|
||||
|
||||
19
src/features/studio/__tests__/runtime-code-test.tsx
Normal file
19
src/features/studio/__tests__/runtime-code-test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
468
src/features/studio/components/BodyConstructionAccordion.tsx
Normal file
468
src/features/studio/components/BodyConstructionAccordion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/features/studio/components/HighlightedRuntimeCode.tsx
Normal file
24
src/features/studio/components/HighlightedRuntimeCode.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -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 (
|
||||
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
|
||||
{token}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
function GuideCode({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className="avatar-guide-code" tabIndex={0}>
|
||||
<code>{highlightedCode(children)}</code>
|
||||
<code>
|
||||
<HighlightedRuntimeCode>{children}</HighlightedRuntimeCode>
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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."
|
||||
>
|
||||
<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."
|
||||
/>
|
||||
<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>
|
||||
<BodyConstructionAccordion
|
||||
controller={controller}
|
||||
reduceMotion={Boolean(reduceMotion)}
|
||||
/>
|
||||
<InspectorCard className="color-panel">
|
||||
<PanelTitle
|
||||
level={3}
|
||||
@ -1818,7 +1453,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
<div className="runtime-quick-start-step">
|
||||
<span>{t('Installation')}</span>
|
||||
<code>
|
||||
{exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
|
||||
<HighlightedRuntimeCode>
|
||||
{exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
|
||||
</HighlightedRuntimeCode>
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@ -1826,9 +1463,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
<span>{t('Utilisation minimale')}</span>
|
||||
<pre tabIndex={0}>
|
||||
<code>
|
||||
{exportFormat === 'react'
|
||||
? reactQuickStartExample(runtimePreviewAnimation)
|
||||
: webQuickStartExample(runtimePreviewAnimation)}
|
||||
<HighlightedRuntimeCode>
|
||||
{exportFormat === 'react'
|
||||
? reactQuickStartExample(runtimePreviewAnimation)
|
||||
: webQuickStartExample(runtimePreviewAnimation)}
|
||||
</HighlightedRuntimeCode>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -369,8 +369,10 @@ const english: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@ -327,8 +327,10 @@ export const chinese: Record<string, string> = {
|
||||
'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': '在相机平面中移动',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user