@volter/editor-blender 0.1.141 → 0.1.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/README.md +1 -1
- package/contributions/blender-animation-editors.tsx +387 -0
- package/contributions/blender-game-panel.tsx +25 -24
- package/contributions/blender-header-menus.tsx +2 -2
- package/contributions/blender-icons.source.mjs +1 -1
- package/contributions/blender-model-opening.tsx +3 -3
- package/contributions/blender-node-editor.document.tsx +1 -1
- package/contributions/blender-outliner-authoring.ts +4 -4
- package/contributions/blender-outliner-model.ts +1 -1
- package/contributions/blender-play-skin.ts +48 -6
- package/contributions/blender-pose.ts +7 -0
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +2 -2
- package/contributions/blender-properties-tab.tsx +2 -2
- package/contributions/blender-properties-view.tsx +1 -1
- package/contributions/blender-runtime-skin.ts +35 -1
- package/contributions/blender-runtime.document.tsx +17 -13
- package/contributions/blender-screenshot.menu.ts +3 -3
- package/contributions/blender-timeline-geometry.ts +2 -2
- package/contributions/blender-timeline.document.tsx +41 -10
- package/contributions/blender-uv-editor.document.tsx +1 -1
- package/contributions/blender-version.status.tsx +2 -2
- package/contributions/blender.command.ts +3 -3
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.keymap.ts +2 -2
- package/contributions/blender.status.tsx +2 -2
- package/contributions/blender.style.ts +3 -3
- package/contributions/model.layout.ts +2 -2
- package/contributions/models.finder.ts +1 -1
- package/contributions/properties-data.inspector.tsx +1 -1
- package/contributions/properties-scene.inspector.tsx +1 -1
- package/contributions/sculpt.layout.ts +1 -1
- package/contributions/shading.layout.ts +1 -1
- package/contributions/texture.layout.ts +1 -1
- package/contributions/uv-editing.layout.ts +1 -1
- package/dist-node/serving.mjs +2 -2
- package/host/blender-runtime-host.ts +4 -4
- package/node/blender-mcp.ts +3 -3
- package/package.json +6 -6
- package/serving/blender-routes.ts +4 -4
- package/serving/index.ts +2 -2
- package/src/layouts.tsx +3 -3
- package/src/looks.ts +1 -1
- package/src/play-live.ts +27 -0
- package/src/play-mode.ts +23 -15
- package/src/presentation.ts +2 -2
- package/src/regions.ts +1 -1
- package/src/timeline-view-state.ts +7 -1
- package/src/uv-view-state.ts +1 -1
- package/view/blender-view-routes.ts +2 -2
package/LICENSE
CHANGED
|
@@ -34,6 +34,17 @@ ARTWORK is not.
|
|
|
34
34
|
be copied into an Apache-2.0 or MIT part of this repository —
|
|
35
35
|
`packages/editor`'s own icon set included.
|
|
36
36
|
|
|
37
|
+
ONE SOURCE FILE FOLLOWS BLENDER'S OWN CODE.
|
|
38
|
+
|
|
39
|
+
`contributions/blender-pose.ts` evaluates a pose as Blender does, and is
|
|
40
|
+
written from Blender's source: its damped-track constraint follows
|
|
41
|
+
`damptrack_do_transform` statement by statement, and its NLA stack, channel
|
|
42
|
+
blending, bone matrices and F-Curve evaluation follow the Blender functions
|
|
43
|
+
its header names. It is a derivative work of GPL-2.0-or-later code and,
|
|
44
|
+
exercising Blender's "or later", is conveyed under the GNU General Public
|
|
45
|
+
License, version 3 or later, not the AGPL. The file says so in its header.
|
|
46
|
+
It may not be copied into an Apache-2.0 or MIT part of this repository.
|
|
47
|
+
|
|
37
48
|
THE COMBINATION, AND WHY THIS PACKAGE CARRIES TWO LICENCES. Our own code in
|
|
38
49
|
this package is licensed under the GNU Affero General Public License,
|
|
39
50
|
version 3 only.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Blender modeling documents, inspectors, commands, layouts and presentation for
|
|
4
4
|
Volter Editor. The integration talks to the independent Blender engine over its
|
|
5
|
-
worker wire and contributes its editing surfaces through the
|
|
5
|
+
worker wire and contributes its editing surfaces through the SDK.
|
|
6
6
|
|
|
7
7
|
The package uses the project contracts and shared Three rendering/capture
|
|
8
8
|
utilities. It imports no editor-core internals and no game runtime. Its
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLENDER'S ANIMATION EDITORS beside the Timeline, in the bottom area's Animation view: the editor-
|
|
3
|
+
* type menu Blender's area header has, the Dope Sheet's ACTION EDITOR and the NLA editor.
|
|
4
|
+
*
|
|
5
|
+
* - **Action Editor** (`SpaceAction.mode == 'ACTION'`): one armature's active action. Its
|
|
6
|
+
* selector is Blender's: picking an action writes `animation_data.action` (and its slot), so the
|
|
7
|
+
* character plays it; the channel list is one row per bone with the frames it keys.
|
|
8
|
+
* - **NLA** (`SPACE_NLA`): each armature's tracks, bottom track last as Blender lists them, their
|
|
9
|
+
* strips over scene frames, and the active action's own line on top.
|
|
10
|
+
*
|
|
11
|
+
* WHILE A GAME RUNS both show what the game is animating, read-only, the way a game engine's
|
|
12
|
+
* animator view highlights the live state: each character's clips as its last update posed them
|
|
13
|
+
* (`blender-play-skin.ts`'s `live`), with the frame and influence of each. Nothing is written
|
|
14
|
+
* then: the game owns its copy.
|
|
15
|
+
*/
|
|
16
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
17
|
+
import { blenderRnaSet, blenderRnaVersion, subscribeBlenderRna } from '../host/blender-runtime-host';
|
|
18
|
+
import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
|
|
19
|
+
import {
|
|
20
|
+
type AnimationEditor,
|
|
21
|
+
setTimelineViewState,
|
|
22
|
+
subscribeTimelineView,
|
|
23
|
+
timelineViewState,
|
|
24
|
+
timelineViewVersion,
|
|
25
|
+
} from '../src/timeline-view-state';
|
|
26
|
+
import { PlayModeSwitch } from './blender-game-panel';
|
|
27
|
+
import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
|
|
28
|
+
import {
|
|
29
|
+
CHANNEL_HEIGHT,
|
|
30
|
+
DIAMOND_RADIUS,
|
|
31
|
+
DIAMOND_STROKE,
|
|
32
|
+
HEADER_HEIGHT,
|
|
33
|
+
KEY_COLORS,
|
|
34
|
+
PLAYHEAD,
|
|
35
|
+
SCRUB_HEIGHT,
|
|
36
|
+
TIMELINE_CHROME,
|
|
37
|
+
TIMELINE_THEME,
|
|
38
|
+
} from './blender-timeline-geometry';
|
|
39
|
+
|
|
40
|
+
const EDITORS: readonly { readonly id: AnimationEditor; readonly label: string }[] = [
|
|
41
|
+
{ id: 'timeline', label: 'Timeline' },
|
|
42
|
+
{ id: 'action', label: 'Action Editor' },
|
|
43
|
+
{ id: 'nla', label: 'Nonlinear Animation' },
|
|
44
|
+
];
|
|
45
|
+
/** The channel region's width: Blender's default `ACHANNEL_NAMEWIDTH` area for a Dope Sheet. */
|
|
46
|
+
const CHANNELS_WIDTH = 200;
|
|
47
|
+
|
|
48
|
+
/** BLENDER'S EDITOR-TYPE MENU, limited to its animation editors: which one this area shows. */
|
|
49
|
+
export function AnimationEditorMenu() {
|
|
50
|
+
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
51
|
+
const { editor } = timelineViewState();
|
|
52
|
+
return (
|
|
53
|
+
<select
|
|
54
|
+
aria-label="Animation editor"
|
|
55
|
+
data-testid="animation-editor-type"
|
|
56
|
+
value={editor}
|
|
57
|
+
onChange={(event) => setTimelineViewState({ editor: event.target.value as AnimationEditor })}
|
|
58
|
+
style={{
|
|
59
|
+
height: TIMELINE_CHROME.unit,
|
|
60
|
+
border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
|
|
61
|
+
borderRadius: TIMELINE_CHROME.widgetRadius,
|
|
62
|
+
background: TIMELINE_CHROME.widget,
|
|
63
|
+
color: TIMELINE_CHROME.widgetText,
|
|
64
|
+
font: 'inherit',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{EDITORS.map((one) => <option key={one.id} value={one.id}>{one.label}</option>)}
|
|
68
|
+
</select>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Re-render on every frame while a game runs (its animation changes each update), else never. */
|
|
73
|
+
function useLive() {
|
|
74
|
+
const live = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null);
|
|
75
|
+
const [, setTick] = useState(0);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!live) return;
|
|
78
|
+
let frame = requestAnimationFrame(function tick() {
|
|
79
|
+
setTick((n) => (n + 1) % 1_000_000);
|
|
80
|
+
frame = requestAnimationFrame(tick);
|
|
81
|
+
});
|
|
82
|
+
return () => cancelAnimationFrame(frame);
|
|
83
|
+
}, [live]);
|
|
84
|
+
return live;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function useMeasured() {
|
|
88
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
89
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const element = ref.current;
|
|
92
|
+
if (!element) return;
|
|
93
|
+
const observer = new ResizeObserver(() => setSize({ w: element.clientWidth, h: element.clientHeight }));
|
|
94
|
+
observer.observe(element);
|
|
95
|
+
setSize({ w: element.clientWidth, h: element.clientHeight });
|
|
96
|
+
return () => observer.disconnect();
|
|
97
|
+
}, []);
|
|
98
|
+
return { ref, size };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Header({ children }: { readonly children?: ReactNode }) {
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
height: HEADER_HEIGHT,
|
|
106
|
+
flex: `0 0 ${HEADER_HEIGHT}px`,
|
|
107
|
+
display: 'flex',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
gap: TIMELINE_CHROME.headerGap,
|
|
110
|
+
padding: TIMELINE_CHROME.headerPadding,
|
|
111
|
+
background: TIMELINE_THEME.header,
|
|
112
|
+
borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<PlayModeSwitch title="Animation" />
|
|
116
|
+
<AnimationEditorMenu />
|
|
117
|
+
{children}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function Frame({ children, header }: { readonly children: ReactNode; readonly header: ReactNode }) {
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
style={{
|
|
126
|
+
position: 'absolute',
|
|
127
|
+
inset: 0,
|
|
128
|
+
display: 'flex',
|
|
129
|
+
flexDirection: 'column',
|
|
130
|
+
overflow: 'hidden',
|
|
131
|
+
background: TIMELINE_THEME.back,
|
|
132
|
+
color: TIMELINE_CHROME.text,
|
|
133
|
+
font: '11px Inter, system-ui, sans-serif',
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{header}
|
|
137
|
+
{children}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A frame ruler over [start, end] in `width` pixels: majors every step that keeps labels apart. */
|
|
143
|
+
function ruler(start: number, end: number, width: number) {
|
|
144
|
+
const span = Math.max(1, end - start);
|
|
145
|
+
const pixelsPerFrame = Math.max(1e-6, width / (span * 1.1));
|
|
146
|
+
const first = start - span * 0.05;
|
|
147
|
+
const toX = (frame: number) => (frame - first) * pixelsPerFrame;
|
|
148
|
+
const steps = [1, 2, 5, 10, 20, 25, 50, 100, 250, 500, 1000];
|
|
149
|
+
const step = steps.find((one) => one * pixelsPerFrame >= 40) ?? 1000;
|
|
150
|
+
const majors: number[] = [];
|
|
151
|
+
// Unmeasured (zero width) draws no ruler; a measured one has at most width / 40 labels.
|
|
152
|
+
if (width > 0)
|
|
153
|
+
for (let frame = Math.ceil(first / step) * step; toX(frame) <= width && majors.length < 500; frame += step) majors.push(frame);
|
|
154
|
+
return { toX, majors, toFrame: (x: number) => first + x / pixelsPerFrame };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Playhead({ x, frame, height }: { readonly x: number; readonly frame: number; readonly height: number }) {
|
|
158
|
+
const text = `${Math.round(frame)}`;
|
|
159
|
+
const width = Math.max(PLAYHEAD.minPillWidth, text.length * 6 + 2 * PLAYHEAD.textPadding);
|
|
160
|
+
return (
|
|
161
|
+
<g>
|
|
162
|
+
<rect x={x - PLAYHEAD.stalkWidth / 2} y={SCRUB_HEIGHT} width={PLAYHEAD.stalkWidth} height={Math.max(0, height - SCRUB_HEIGHT)} fill={TIMELINE_THEME.playhead} />
|
|
163
|
+
<rect x={x - width / 2} y={PLAYHEAD.margin} width={width} height={SCRUB_HEIGHT - 2 * PLAYHEAD.margin} rx={PLAYHEAD.radius} fill={TIMELINE_THEME.playhead} />
|
|
164
|
+
<text x={x} y={15.5} textAnchor="middle" fill={TIMELINE_THEME.playheadText} fontSize={11}>{text}</text>
|
|
165
|
+
</g>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function Grid({ majors, toX, width, height }: { readonly majors: number[]; readonly toX: (f: number) => number; readonly width: number; readonly height: number }) {
|
|
170
|
+
return (
|
|
171
|
+
<g>
|
|
172
|
+
{majors.map((frame) => <line key={`g${frame}`} x1={toX(frame)} y1={SCRUB_HEIGHT} x2={toX(frame)} y2={height} stroke={TIMELINE_THEME.grid} />)}
|
|
173
|
+
<rect x={0} y={0} width={width} height={SCRUB_HEIGHT} fill={TIMELINE_THEME.scrubBack} />
|
|
174
|
+
{majors.map((frame) => <text key={`t${frame}`} x={toX(frame)} y={15.5} textAnchor="middle" fill={TIMELINE_THEME.scrubText} fontSize={11}>{frame}</text>)}
|
|
175
|
+
</g>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function diamond(x: number, y: number, key: string) {
|
|
180
|
+
const r = DIAMOND_RADIUS;
|
|
181
|
+
return <polygon key={key} points={`${x},${y - r} ${x + r},${y} ${x},${y + r} ${x - r},${y}`} fill={KEY_COLORS['KEYFRAME']!.fill} stroke={TIMELINE_THEME.keyBorder} strokeWidth={DIAMOND_STROKE} />;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const address = (armature: string) => `bpy.data.objects[${JSON.stringify(armature)}].animation_data`;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* THE DOPE SHEET'S ACTION EDITOR for the armature the Timeline describes. Its selector is
|
|
188
|
+
* Blender's: an action picked here becomes the armature's `animation_data.action`.
|
|
189
|
+
*/
|
|
190
|
+
export function BlenderActionEditor() {
|
|
191
|
+
useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
192
|
+
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
193
|
+
const live = useLive();
|
|
194
|
+
const { ref, size } = useMeasured();
|
|
195
|
+
const [refusal, setRefusal] = useState<string | null>(null);
|
|
196
|
+
const play = blenderSkin.state();
|
|
197
|
+
const armature = blenderSkin.subject ?? play.armature;
|
|
198
|
+
const running = live?.live().find((one) => one.armature === armature) ?? null;
|
|
199
|
+
// READ-ONLY WHILE ANY GAME RUNS: the game owns its copy, and the document is not edited under it.
|
|
200
|
+
const gameRunning = live !== null;
|
|
201
|
+
const top = running?.layers.at(-1) ?? null;
|
|
202
|
+
const action = running ? top?.action ?? null : play.action;
|
|
203
|
+
const channels = useMemo(
|
|
204
|
+
() => (running && action ? live!.channels(running.armature, action) : blenderSkin.channels()),
|
|
205
|
+
// The live clip's keys do not change while it plays; the edited clip's change with its revision.
|
|
206
|
+
[running ? action : play.action, play.engineCalls, live],
|
|
207
|
+
);
|
|
208
|
+
const frames = channels.flatMap((channel) => channel.keys);
|
|
209
|
+
const start = frames.length ? Math.min(...frames) : play.start;
|
|
210
|
+
const end = frames.length ? Math.max(...frames) : play.end;
|
|
211
|
+
const width = Math.max(0, size.w - CHANNELS_WIDTH);
|
|
212
|
+
const { toX, majors, toFrame } = ruler(start, end, width);
|
|
213
|
+
const frame = running ? top?.frame ?? start : play.frame;
|
|
214
|
+
const rowTop = (i: number) => SCRUB_HEIGHT + CHANNEL_HEIGHT * (i + 1);
|
|
215
|
+
const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (channels.length + 2);
|
|
216
|
+
const summary = [...new Set(frames)].sort((a, b) => a - b);
|
|
217
|
+
|
|
218
|
+
const pick = async (name: string) => {
|
|
219
|
+
if (!armature || gameRunning) return;
|
|
220
|
+
setRefusal(null);
|
|
221
|
+
// ONE WRITE, as Blender's own selector makes it: assigning the action lets Blender choose the
|
|
222
|
+
// slot that fits this armature (measured: `OBArmature` for a UAL1 clip on a renamed rig).
|
|
223
|
+
try {
|
|
224
|
+
await blenderRnaSet(address(armature), 'action', `bpy.data.actions[${JSON.stringify(name)}]`);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
setRefusal(`${name} could not be assigned to ${armature}: ${error instanceof Error ? error.message : String(error)}`);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const header = (
|
|
231
|
+
<Header>
|
|
232
|
+
<span style={{ color: TIMELINE_CHROME.textHi }}>{armature ?? 'No armature'}</span>
|
|
233
|
+
<select
|
|
234
|
+
aria-label="Action"
|
|
235
|
+
data-testid="action-editor-action"
|
|
236
|
+
value={action ?? ''}
|
|
237
|
+
disabled={!armature || gameRunning}
|
|
238
|
+
title={gameRunning ? 'A game is running; it chooses the clips while it runs' : 'The action this armature plays (animation_data.action)'}
|
|
239
|
+
onChange={(event) => void pick(event.target.value)}
|
|
240
|
+
style={{
|
|
241
|
+
height: TIMELINE_CHROME.unit,
|
|
242
|
+
minWidth: 180,
|
|
243
|
+
border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
|
|
244
|
+
borderRadius: TIMELINE_CHROME.widgetRadius,
|
|
245
|
+
background: TIMELINE_CHROME.widget,
|
|
246
|
+
color: TIMELINE_CHROME.widgetText,
|
|
247
|
+
font: 'inherit',
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
{action === null ? <option value="">No action</option> : null}
|
|
251
|
+
{(running && action ? [action] : blenderSkin.actionNames()).map((name) => <option key={name} value={name}>{name}</option>)}
|
|
252
|
+
</select>
|
|
253
|
+
{running ? (
|
|
254
|
+
<span data-testid="action-editor-live" style={{ color: TIMELINE_THEME.playhead }}>
|
|
255
|
+
● game running: {running.layers.map((layer) => `${layer.action} @${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}`).join(' + ')}
|
|
256
|
+
</span>
|
|
257
|
+
) : null}
|
|
258
|
+
<div style={{ flex: 1 }} />
|
|
259
|
+
{refusal ? <span style={{ color: TIMELINE_CHROME.refusal }}>{refusal}</span> : null}
|
|
260
|
+
</Header>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<Frame header={header}>
|
|
265
|
+
<div ref={ref} style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
|
266
|
+
<div style={{ display: 'flex', minHeight: height }}>
|
|
267
|
+
<div style={{ width: CHANNELS_WIDTH, flex: `0 0 ${CHANNELS_WIDTH}px`, borderRight: `1px solid ${TIMELINE_CHROME.rule}` }}>
|
|
268
|
+
<div style={{ height: SCRUB_HEIGHT, background: TIMELINE_THEME.scrubBack }} />
|
|
269
|
+
<div style={{ height: CHANNEL_HEIGHT, paddingLeft: 6, color: TIMELINE_CHROME.textHi }}>Summary</div>
|
|
270
|
+
{channels.map((channel) => (
|
|
271
|
+
<div key={channel.bone} style={{ height: CHANNEL_HEIGHT, paddingLeft: 18, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.bone}</div>
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
<svg
|
|
275
|
+
width={width}
|
|
276
|
+
height={height}
|
|
277
|
+
style={{ display: 'block', cursor: gameRunning ? 'default' : 'ew-resize' }}
|
|
278
|
+
aria-label="Action Editor"
|
|
279
|
+
onPointerDown={(event) => {
|
|
280
|
+
if (gameRunning) return;
|
|
281
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
282
|
+
blenderSkin.transport?.seekFrame(Math.round(toFrame(event.clientX - box.left)));
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
<rect x={0} y={0} width={width} height={height} fill={TIMELINE_THEME.back} />
|
|
286
|
+
<Grid majors={majors} toX={toX} width={width} height={height} />
|
|
287
|
+
{summary.map((key) => diamond(toX(key), rowTop(0) - CHANNEL_HEIGHT / 2, `s${key}`))}
|
|
288
|
+
{channels.map((channel, i) => channel.keys.map((key) => diamond(toX(key), rowTop(i + 1) - CHANNEL_HEIGHT / 2, `${channel.bone}${key}`)))}
|
|
289
|
+
<Playhead x={toX(frame)} frame={frame} height={height} />
|
|
290
|
+
</svg>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</Frame>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** THE NLA EDITOR: every armature's tracks and strips over scene frames, and its active action. */
|
|
298
|
+
export function BlenderNlaEditor() {
|
|
299
|
+
useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
300
|
+
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
301
|
+
const live = useLive();
|
|
302
|
+
const { ref, size } = useMeasured();
|
|
303
|
+
const play = blenderSkin.state();
|
|
304
|
+
const stacks = blenderSkin.stacks();
|
|
305
|
+
const running = live?.live() ?? null;
|
|
306
|
+
const width = Math.max(0, size.w - CHANNELS_WIDTH);
|
|
307
|
+
const { toX, majors } = ruler(play.start, play.end, width);
|
|
308
|
+
type Row = { label: string; kind: 'armature' | 'action' | 'track'; strips: { name: string; start: number; end: number; mute: boolean }[]; live?: string; mute?: boolean };
|
|
309
|
+
const rows: Row[] = [];
|
|
310
|
+
for (const stack of stacks) {
|
|
311
|
+
const layers = running?.find((one) => one.armature === stack.armature)?.layers ?? null;
|
|
312
|
+
const influence = (track: string | null) => {
|
|
313
|
+
const layer = layers?.find((one) => one.track === track);
|
|
314
|
+
return layer ? `@${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}` : undefined;
|
|
315
|
+
};
|
|
316
|
+
rows.push({ label: stack.armature, kind: 'armature', strips: [] });
|
|
317
|
+
const activeAction = layers ? layers.find((one) => one.track === null)?.action ?? null : stack.action;
|
|
318
|
+
const activeLive = influence(null);
|
|
319
|
+
rows.push({ label: activeAction ? `[Action] ${activeAction}` : '[Action] (none)', kind: 'action', strips: [], ...(activeLive ? { live: activeLive } : {}) });
|
|
320
|
+
for (const track of [...(stack.animation?.tracks ?? [])].reverse()) {
|
|
321
|
+
const playingHere = influence(track.name);
|
|
322
|
+
rows.push({
|
|
323
|
+
label: track.name,
|
|
324
|
+
kind: 'track',
|
|
325
|
+
mute: track.mute,
|
|
326
|
+
strips: track.strips.map((strip) => ({ name: strip.name, start: strip.start, end: strip.end, mute: strip.mute })),
|
|
327
|
+
...(playingHere ? { live: playingHere } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (rows.length + 1);
|
|
332
|
+
const header = (
|
|
333
|
+
<Header>
|
|
334
|
+
{running ? <span data-testid="nla-live" style={{ color: TIMELINE_THEME.playhead }}>● game running: each playing track shows the game's frame and influence; the strips are the file's layout</span> : null}
|
|
335
|
+
</Header>
|
|
336
|
+
);
|
|
337
|
+
return (
|
|
338
|
+
<Frame header={header}>
|
|
339
|
+
<div ref={ref} style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
|
340
|
+
<div style={{ display: 'flex', minHeight: height }}>
|
|
341
|
+
<div style={{ width: CHANNELS_WIDTH, flex: `0 0 ${CHANNELS_WIDTH}px`, borderRight: `1px solid ${TIMELINE_CHROME.rule}` }}>
|
|
342
|
+
<div style={{ height: SCRUB_HEIGHT, background: TIMELINE_THEME.scrubBack }} />
|
|
343
|
+
{rows.map((row, i) => (
|
|
344
|
+
<div
|
|
345
|
+
key={`${row.label}${i}`}
|
|
346
|
+
data-testid={`nla-row-${row.kind}`}
|
|
347
|
+
style={{
|
|
348
|
+
height: CHANNEL_HEIGHT,
|
|
349
|
+
paddingLeft: row.kind === 'armature' ? 6 : 18,
|
|
350
|
+
whiteSpace: 'nowrap',
|
|
351
|
+
overflow: 'hidden',
|
|
352
|
+
textOverflow: 'ellipsis',
|
|
353
|
+
color: row.kind === 'armature' ? TIMELINE_CHROME.textHi : row.mute ? '#6a6a6a' : TIMELINE_CHROME.text,
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
{row.label}{row.live ? <span style={{ color: TIMELINE_THEME.playhead }}> {row.live}</span> : null}
|
|
357
|
+
</div>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
<svg width={width} height={height} style={{ display: 'block' }} aria-label="NLA">
|
|
361
|
+
<rect x={0} y={0} width={width} height={height} fill={TIMELINE_THEME.back} />
|
|
362
|
+
<Grid majors={majors} toX={toX} width={width} height={height} />
|
|
363
|
+
{rows.map((row, i) => {
|
|
364
|
+
const y = SCRUB_HEIGHT + CHANNEL_HEIGHT * i + 2;
|
|
365
|
+
return row.strips.map((strip) => (
|
|
366
|
+
<g key={`${i}${strip.name}${strip.start}`}>
|
|
367
|
+
<rect
|
|
368
|
+
x={toX(strip.start)}
|
|
369
|
+
y={y}
|
|
370
|
+
width={Math.max(2, toX(strip.end) - toX(strip.start))}
|
|
371
|
+
height={CHANNEL_HEIGHT - 4}
|
|
372
|
+
rx={2}
|
|
373
|
+
fill={strip.mute || row.mute ? '#4a4a4a' : running ? (row.live ? '#5a7fb8' : '#555555') : '#7b7b7b'}
|
|
374
|
+
opacity={running ? 0.6 : 1}
|
|
375
|
+
stroke="#1a1a1a"
|
|
376
|
+
/>
|
|
377
|
+
<text x={toX(strip.start) + 4} y={y + CHANNEL_HEIGHT - 7} fill="#ffffff" fontSize={10}>{strip.name}</text>
|
|
378
|
+
</g>
|
|
379
|
+
));
|
|
380
|
+
})}
|
|
381
|
+
{running ? null : <Playhead x={toX(play.frame)} frame={play.frame} height={height} />}
|
|
382
|
+
</svg>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
</Frame>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* ## One Play, here
|
|
8
8
|
*
|
|
9
9
|
* The confusion it ends was two Plays on one screen: the header's (the play script) and the
|
|
10
|
-
* Timeline's (the file's animation). A mode now chooses which one
|
|
11
|
-
* Timeline's transport is the
|
|
12
|
-
*
|
|
10
|
+
* Timeline's (the file's animation). A mode now chooses which one is shown. In Animation mode the
|
|
11
|
+
* Timeline's transport is the one shown; in Game mode this panel's Play is, and the Timeline is
|
|
12
|
+
* not mounted. A game started in Game mode keeps running in Animation mode. The 3D viewport's header carries neither (`blender-header-menus.tsx`
|
|
13
13
|
* is its menus alone). Escape still stops a game, from the stage, as before.
|
|
14
14
|
*
|
|
15
|
-
* ## The Game |
|
|
15
|
+
* ## The Game | Animation switch is the bottom area's own
|
|
16
16
|
*
|
|
17
17
|
* Switching modes swaps this area and nothing else, so the switch stands at the leading edge of
|
|
18
18
|
* the area's header row, in place of its title — in this panel and in the Timeline alike
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Blender's Timeline strip (`model.layout.ts`'s measured 0.0719 — 63 px of an 880-px column)
|
|
26
26
|
* showed this panel's play log as one clipped line. So Game mode asks the area for
|
|
27
|
-
* {@link GAME_PANEL_RATIO} (`setWorkspaceAreaRatio`, by the bottom area's document) and
|
|
27
|
+
* {@link GAME_PANEL_RATIO} (`setWorkspaceAreaRatio`, by the bottom area's document) and Animation
|
|
28
28
|
* mode gives Blender's back: each switch stands the area up at its mode's size, as a workspace
|
|
29
29
|
* switch does, and the sash stays the person's in between.
|
|
30
30
|
*
|
|
31
31
|
* ## What it drives is the Play tool's own run
|
|
32
32
|
*
|
|
33
33
|
* Every control here is a call on the Play tool's door (`DocumentPlayExtension` and its
|
|
34
|
-
* `transport`, `@volter/
|
|
35
|
-
* reads every frame (`@volter/
|
|
34
|
+
* `transport`, `@volter/sdk/kit/document-play-extension`), the same calls the runner
|
|
35
|
+
* reads every frame (`@volter/play`'s `model-play.ts`), so Pause freezes the
|
|
36
36
|
* simulation itself — the `dt` the script is handed — and the clock drawn here is that
|
|
37
37
|
* simulation's: the seconds the script was given and the updates it ran. The verbs below are
|
|
38
38
|
* the same calls behind command ids (`volter.model-play.<verb>`), which is how an agent drives
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
* playhead's blue, which is Blender's own selected-widget colour in the same theme.
|
|
65
65
|
*/
|
|
66
66
|
import { blenderSkin } from './blender-runtime-skin';
|
|
67
|
-
import { registerViewVerbs } from '@volter/
|
|
67
|
+
import { registerViewVerbs } from '@volter/sdk/views';
|
|
68
68
|
import {
|
|
69
69
|
type DocumentPlayAutoplay,
|
|
70
70
|
type DocumentPlayClock,
|
|
@@ -73,7 +73,7 @@ import {
|
|
|
73
73
|
type DocumentPlayTransport,
|
|
74
74
|
documentPlayExtension,
|
|
75
75
|
subscribeDocumentPlayExtensions,
|
|
76
|
-
} from '@volter/
|
|
76
|
+
} from '@volter/sdk/kit/document-play-extension';
|
|
77
77
|
import {
|
|
78
78
|
type MouseEvent,
|
|
79
79
|
type ReactNode,
|
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
type ModelPlayMode,
|
|
90
90
|
modelDocumentSource,
|
|
91
91
|
modelPlayMode,
|
|
92
|
+
playModeOf,
|
|
92
93
|
playModeVersion,
|
|
93
94
|
servedModelDocument,
|
|
94
95
|
setChosenPlayMode,
|
|
@@ -112,12 +113,11 @@ function extension(): DocumentPlayExtension | null {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/**
|
|
115
|
-
* FLIP A DOCUMENT BETWEEN GAME AND
|
|
116
|
-
*
|
|
117
|
-
*
|
|
116
|
+
* FLIP A DOCUMENT BETWEEN GAME AND ANIMATION. A running game keeps running when Animation is
|
|
117
|
+
* chosen: the animation editors then show what it animates. The file's animation playing in the
|
|
118
|
+
* Timeline when Game is chosen is paused (Game has no Timeline transport).
|
|
118
119
|
*/
|
|
119
120
|
export function switchPlayMode(documentId: string, mode: ModelPlayMode): void {
|
|
120
|
-
if (mode === 'movie' && extension()?.playing(documentId)) extension()?.setPlaying(documentId, false);
|
|
121
121
|
if (mode === 'game' && blenderSkin.transport?.snapshot().playbackState === 'playing') blenderSkin.transport.pause();
|
|
122
122
|
setChosenPlayMode(documentId, mode);
|
|
123
123
|
}
|
|
@@ -138,9 +138,9 @@ function verbDocument(args: Record<string, unknown> | undefined): string {
|
|
|
138
138
|
function verbExtension(): DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> } {
|
|
139
139
|
const found = extension();
|
|
140
140
|
if (found === null)
|
|
141
|
-
throw new Error('This project has no Play tool: `cyclotron add-play` adds @volter/
|
|
141
|
+
throw new Error('This project has no Play tool: `cyclotron add-play` adds @volter/play.');
|
|
142
142
|
if (found.transport === undefined)
|
|
143
|
-
throw new Error('The installed Play tool has no pause, step, speed or restart; update @volter/
|
|
143
|
+
throw new Error('The installed Play tool has no pause, step, speed or restart; update @volter/play.');
|
|
144
144
|
return found as DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> };
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -220,8 +220,9 @@ registerViewVerbs({
|
|
|
220
220
|
const documentId = verbDocument(args);
|
|
221
221
|
const mode = args?.['mode'] ?? args?.['to'];
|
|
222
222
|
if (mode !== undefined) {
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
const asked = playModeOf(mode);
|
|
224
|
+
if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
|
|
225
|
+
switchPlayMode(documentId, asked);
|
|
225
226
|
}
|
|
226
227
|
return gameState(documentId);
|
|
227
228
|
},
|
|
@@ -232,9 +233,9 @@ registerViewVerbs({
|
|
|
232
233
|
run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'game'); return gameState(documentId); },
|
|
233
234
|
},
|
|
234
235
|
{
|
|
235
|
-
id: '
|
|
236
|
-
title: 'Game: Switch to
|
|
237
|
-
run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, '
|
|
236
|
+
id: 'animation-mode',
|
|
237
|
+
title: 'Game: Switch to Animation Mode',
|
|
238
|
+
run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'animation'); return gameState(documentId); },
|
|
238
239
|
},
|
|
239
240
|
{
|
|
240
241
|
// PLAY IS GAME MODE'S CONTROL, so asking for it puts the document in Game mode: the person
|
|
@@ -326,7 +327,7 @@ registerViewVerbs({
|
|
|
326
327
|
const found = verbExtension();
|
|
327
328
|
const { transport } = found;
|
|
328
329
|
if (transport.setAutoplay === undefined)
|
|
329
|
-
throw new Error('The installed Play tool has no autoplay; update @volter/
|
|
330
|
+
throw new Error('The installed Play tool has no autoplay; update @volter/play.');
|
|
330
331
|
// Refused with the reason, stopped or no bot, as `play state`'s `autoplay.why` gives it.
|
|
331
332
|
const why = on ? autoplayWhy(found.playing(documentId), transport.clock(documentId), transport.autoplay?.(documentId) ?? NO_BOT) : null;
|
|
332
333
|
if (why !== null) throw new Error(why);
|
|
@@ -439,9 +440,9 @@ export function PlayModeSwitch({ title }: { readonly title: string }) {
|
|
|
439
440
|
</GameButton>
|
|
440
441
|
);
|
|
441
442
|
return (
|
|
442
|
-
<div role="group" aria-label="Game or
|
|
443
|
-
{cell('game', 'Game', 'Game
|
|
444
|
-
{cell('
|
|
443
|
+
<div role="group" aria-label="Game or Animation" data-testid="model-play-mode" style={{ display: 'flex' }}>
|
|
444
|
+
{cell('game', 'Game', 'Game: this area is the Game panel, with Play, pause, step, speed, restart and autoplay')}
|
|
445
|
+
{cell('animation', 'Animation', 'Animation: this area is Blender’s animation editors (Timeline, Action Editor, NLA); a running game keeps running and they show what it animates')}
|
|
445
446
|
</div>
|
|
446
447
|
);
|
|
447
448
|
}
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* because switching modes swaps that area and nothing here.
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
import type { ToolNotice } from '@volter/
|
|
49
|
+
import type { ToolNotice } from '@volter/sdk/contributions';
|
|
50
50
|
import {
|
|
51
51
|
AnchoredMenu,
|
|
52
52
|
EditorToolbar,
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
MenuSeparator,
|
|
55
55
|
MenuSubmenu,
|
|
56
56
|
MenuTrigger,
|
|
57
|
-
} from '@volter/
|
|
57
|
+
} from '@volter/sdk/widgets';
|
|
58
58
|
import { type ReactNode, type RefObject, useRef, useState, useSyncExternalStore } from 'react';
|
|
59
59
|
import {
|
|
60
60
|
type BlenderCreatableKind,
|
|
@@ -2507,7 +2507,7 @@ g(
|
|
|
2507
2507
|
* glyph — and an explicit site tone wins by contract. A tone there would
|
|
2508
2508
|
* never paint.
|
|
2509
2509
|
*/
|
|
2510
|
-
/** The union `IconCategoryTone` declares (`@volter/
|
|
2510
|
+
/** The union `IconCategoryTone` declares (`@volter/sdk/looks`), and the
|
|
2511
2511
|
* reason `blender.style.ts` may narrow this JSON with an assertion: a tone
|
|
2512
2512
|
* outside it never reaches the artifact. */
|
|
2513
2513
|
const TONES = ['object', 'modifier', 'material', 'tool', 'operator', 'data', 'scene', 'collection', 'select'];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ModelDocumentPreview } from '../src/model-document-preview';
|
|
2
2
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
3
|
-
import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/
|
|
4
|
-
import type { AuthoringAdapter, EditorNode } from '@volter/
|
|
5
|
-
import { AssetEditorSubject } from '@volter/
|
|
3
|
+
import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
|
|
4
|
+
import type { AuthoringAdapter, EditorNode } from '@volter/project/adapter';
|
|
5
|
+
import { AssetEditorSubject } from '@volter/sdk/kit/components/AssetEditorShell';
|
|
6
6
|
import { useMemo } from 'react';
|
|
7
7
|
|
|
8
8
|
/** A loading/error surface has no authoring stage and publishes no model. */
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import type { BlenderNode, BlenderNodeTree } from '@volter/blender-engine/browser/rna';
|
|
41
|
-
import { registerViewVerbs } from '@volter/
|
|
41
|
+
import { registerViewVerbs } from '@volter/sdk/views';
|
|
42
42
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
43
43
|
import {
|
|
44
44
|
blenderNodeTree,
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
|
|
59
59
|
import type { BlenderOutlinerRow } from '@volter/blender-engine/browser/rna';
|
|
60
60
|
import type { ToolObject3DDocumentAuthoringFactory } from '@volter/editor-threejs/object3d-contributions';
|
|
61
|
-
import { editorHost } from '@volter/
|
|
61
|
+
import { editorHost } from '@volter/sdk/host';
|
|
62
62
|
import type {
|
|
63
63
|
AuthoringAdapter,
|
|
64
64
|
AuthoringCapabilities,
|
|
@@ -73,7 +73,7 @@ import type {
|
|
|
73
73
|
Transform,
|
|
74
74
|
TransformProvider,
|
|
75
75
|
WriteAck,
|
|
76
|
-
} from '@volter/
|
|
76
|
+
} from '@volter/project/adapter';
|
|
77
77
|
import type * as THREE from 'three';
|
|
78
78
|
import { threeObject } from '@volter/editor-threejs/adapter/three-contract';
|
|
79
79
|
import { viewportStages } from '@volter/editor-threejs/viewport-door';
|
|
@@ -340,7 +340,7 @@ function blenderWorldMatrixRows(object: THREE.Object3D, root: THREE.Object3D): n
|
|
|
340
340
|
* gesture that cannot complete (the shape B6 set for Set Origin ▸ Origin to 3D
|
|
341
341
|
* Cursor). Blender's Image ▸ Reference/Background/Mesh Plane and Volume ▸
|
|
342
342
|
* Import OpenVDB open a FILE BROWSER; this editor has no door to open one
|
|
343
|
-
* through — `EditorHost.files` (`@volter/
|
|
343
|
+
* through — `EditorHost.files` (`@volter/sdk/host`) reads and writes
|
|
344
344
|
* project-relative paths and has no "pick a file" member at all — so those
|
|
345
345
|
* rows carry {@link BlenderCreatableKind.refusal} instead. Measured at the
|
|
346
346
|
* engine on 2026-09-21: `object.empty_image_add` answers "poll() failed,
|
|
@@ -383,7 +383,7 @@ export interface BlenderCreatableKind {
|
|
|
383
383
|
function needsAFilePicker(what: string, blenderSaid: string): string {
|
|
384
384
|
return (
|
|
385
385
|
`${what} needs a file, and this editor has no door to ask for one: the host's file ` +
|
|
386
|
-
`door (\`EditorHost.files\`, \`@volter/
|
|
386
|
+
`door (\`EditorHost.files\`, \`@volter/sdk/host\`) reads and writes ` +
|
|
387
387
|
`project-relative paths and has no file-picker member, so there is nothing to open ` +
|
|
388
388
|
`the workbench's own dialog through. Blender's row opens a file browser; run without ` +
|
|
389
389
|
`one, the engine answers "${blenderSaid}".`
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { BlenderOutlinerRow, BlenderOutlinerTree } from '@volter/blender-engine/browser/rna';
|
|
18
|
-
import { editorHost } from '@volter/
|
|
18
|
+
import { editorHost } from '@volter/sdk/host';
|
|
19
19
|
import type * as THREE from 'three';
|
|
20
20
|
import {
|
|
21
21
|
blenderOutliner,
|