@volter/editor-blender 0.1.0
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 +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- package/src/uv-view-state.ts +125 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MODEL DOCUMENT — Blender's, and Blender's alone (ARCHITECTURE-CORE §The
|
|
3
|
+
* project model, "A model is Blender data, and a prefab is not a model";
|
|
4
|
+
* WORK.md §Blender in the tab is Blender, "The mesh kit retires", M1).
|
|
5
|
+
*
|
|
6
|
+
* This module declares `documentKind = 'model'`, so the host opens every
|
|
7
|
+
* `model` entry the project's table lists as its OWN document — titled by the
|
|
8
|
+
* entry's label, one per entry — and mounts this with the entry as
|
|
9
|
+
* `props.document`. A `model` entry names a `.blend`
|
|
10
|
+
* (`models.finder.ts`), and opening the document OPENS THAT FILE IN THE
|
|
11
|
+
* ENGINE: `session.py` opens the named file at start and saves back to it, so
|
|
12
|
+
* this is the WS-F save path run backwards.
|
|
13
|
+
*
|
|
14
|
+
* bpy owns every mutation; this document displays them. A gesture is a bpy
|
|
15
|
+
* call the engine records — there is no cage, no append-per-gesture and no
|
|
16
|
+
* TypeScript geometry module. The bpy scripts that authored a `.blend` are
|
|
17
|
+
* ordinary project files beside it (`src/models/<name>.py`); this document
|
|
18
|
+
* does not run them, a person or an agent does, through the session's Blender
|
|
19
|
+
* (`vgai blender-mcp`).
|
|
20
|
+
*
|
|
21
|
+
* WITHOUT AN ENTRY it is still the document `blender-start` presents into: a
|
|
22
|
+
* project that lists no `.blend` of its own gets one Model document at the
|
|
23
|
+
* standing `blender:runtime` address (`project-adapter.ts` injects it), and
|
|
24
|
+
* the session opens its default `models/model.blend`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
28
|
+
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
|
|
29
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
30
|
+
import { useCallback, useEffect } from 'react';
|
|
31
|
+
import * as THREE from 'three';
|
|
32
|
+
import { bindModelDocument, openModelDocumentBlend } from '../host/blender-runtime-host';
|
|
33
|
+
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
34
|
+
import { createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
35
|
+
import { blenderSkin } from './blender-runtime-skin';
|
|
36
|
+
|
|
37
|
+
export const point = 'workspace.document';
|
|
38
|
+
export const title = 'Blender Model';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* THE DOCUMENT'S HEADER IS BLENDER'S 3D VIEWPORT HEADER — Select, Add and
|
|
42
|
+
* Object, the menu words `VIEW3D_MT_editor_menus` draws in Object Mode. The
|
|
43
|
+
* rows, their order and what View's absence means are
|
|
44
|
+
* `blender-header-menus.tsx`'s own header; this is the one line the host's
|
|
45
|
+
* strip mounts (`ToolDocumentToolbar`, `DocumentHeaderStrip`).
|
|
46
|
+
*/
|
|
47
|
+
export const Toolbar: ToolDocumentToolbar = ({ documentId, notify }) => (
|
|
48
|
+
<BlenderObjectModeHeader documentId={documentId} notify={notify} />
|
|
49
|
+
);
|
|
50
|
+
/** The kind this document EDITS: every `model` table entry opens here. */
|
|
51
|
+
export const documentKind = 'model';
|
|
52
|
+
/**
|
|
53
|
+
* ON A MODEL DOCUMENT THE PROPERTIES RAIL IS BLENDER'S, AND BLENDER'S ALONE
|
|
54
|
+
* (owner ruling; WORK.md §Blender in the tab is Blender, "Inspection parity",
|
|
55
|
+
* I2 decision 1).
|
|
56
|
+
*
|
|
57
|
+
* Blender's Properties editor draws the tabs `ED_buttons_tabs_list` returns
|
|
58
|
+
* (`space_buttons/space_buttons.cc:201-255`) and nothing else. A rail that
|
|
59
|
+
* carried the host's own Preview, Transform, generic Object, Geometry and
|
|
60
|
+
* Materials blocks beside Blender's sixteen would not be Blender's rail — it
|
|
61
|
+
* would be Blender's rail plus another editor's, and the second editor would
|
|
62
|
+
* be showing three.js's reading of data Blender owns.
|
|
63
|
+
*
|
|
64
|
+
* Declaring this stands the host's sections down for THIS document only: the
|
|
65
|
+
* composer keeps the sections contributed by this contribution's own package
|
|
66
|
+
* (`@volter/editor-blender`) and nothing else (`inspection/compose.ts`,
|
|
67
|
+
* `OwnedInspectorRail`). `inspectorBuiltins` is empty — there is no host block
|
|
68
|
+
* a Blender tab does not already answer for, and the Preview in particular is
|
|
69
|
+
* the document's own viewport, which is the whole centre of the screen.
|
|
70
|
+
*/
|
|
71
|
+
export const inspectorRail = 'owned';
|
|
72
|
+
export const inspectorBuiltins: readonly string[] = [];
|
|
73
|
+
|
|
74
|
+
// The Python session outlives workspace switches and document remounts. Keep
|
|
75
|
+
// its disposable presentation for this project module's lifetime too. A new
|
|
76
|
+
// Python session explicitly replaces it through applyFrame's session address.
|
|
77
|
+
// It is CONSTRUCTED in `blender-runtime-view.ts` rather than here, because the
|
|
78
|
+
// Timeline binds a skeleton into the same presented graph and there is exactly
|
|
79
|
+
// one set of presented objects (`blender-runtime-skin.ts`).
|
|
80
|
+
const view = blenderModelView;
|
|
81
|
+
|
|
82
|
+
export default function BlenderModelDocument({
|
|
83
|
+
active,
|
|
84
|
+
document,
|
|
85
|
+
documentId,
|
|
86
|
+
surfaces,
|
|
87
|
+
notify,
|
|
88
|
+
publishContext,
|
|
89
|
+
}: ToolContributionProps) {
|
|
90
|
+
const build = useCallback(() => ({ root: view.root, dispose() {} }), []);
|
|
91
|
+
const blend = document?.source?.path;
|
|
92
|
+
useEffect(() => publishContext?.(view), [publishContext]);
|
|
93
|
+
/**
|
|
94
|
+
* THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
|
|
95
|
+
* (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
|
|
96
|
+
*
|
|
97
|
+
* Blender's viewport overlay has a Bones checkbox (`View3DOverlay.
|
|
98
|
+
* show_bones`, `rna_space.cc:5125-5129`, drawn by `space_view3d.py:7161`)
|
|
99
|
+
* and this editor's Helpers menu already has a Skeletons row; a weight
|
|
100
|
+
* display had no member and gained one (`weights`). Handing each group to
|
|
101
|
+
* the stage through `setHelper` is the whole wiring: the host marks it
|
|
102
|
+
* editor-owned — out of the hierarchy, out of the raycast — sets its
|
|
103
|
+
* visibility from that kind's checkbox on the spot, keeps it across the
|
|
104
|
+
* stage's remounts, and toggles it afterwards. Nothing here reads or
|
|
105
|
+
* mirrors the toggle's state, which is why there is no second copy of it.
|
|
106
|
+
*/
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!documentId) return;
|
|
109
|
+
const { viewport } = editorHost();
|
|
110
|
+
const groups = view.overlayGroups();
|
|
111
|
+
const apply = (): void => {
|
|
112
|
+
const stage = viewport.stages().find((one) => one.documentId === documentId);
|
|
113
|
+
if (!stage) return;
|
|
114
|
+
for (const { kind, object } of groups) stage.setHelper(kind, object);
|
|
115
|
+
};
|
|
116
|
+
apply();
|
|
117
|
+
const stop = viewport.onStages(apply);
|
|
118
|
+
return () => {
|
|
119
|
+
stop();
|
|
120
|
+
const stage = viewport.stages().find((one) => one.documentId === documentId);
|
|
121
|
+
for (const { kind } of groups) stage?.setHelper(kind, null);
|
|
122
|
+
};
|
|
123
|
+
}, [documentId]);
|
|
124
|
+
/**
|
|
125
|
+
* ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
|
|
126
|
+
*
|
|
127
|
+
* This document is the one that HAS the id — the Timeline binds to the
|
|
128
|
+
* `blenderModelView` singleton and cannot name a document — so the one door
|
|
129
|
+
* use lives here, and the Timeline drives `blenderSkin.transport`.
|
|
130
|
+
*
|
|
131
|
+
* The transport is registered by the stage host when the stage mounts, which
|
|
132
|
+
* may be after this effect first runs, so it retries on the registry's own
|
|
133
|
+
* notification rather than assuming an order.
|
|
134
|
+
*/
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!documentId) return;
|
|
137
|
+
const { transport } = editorHost();
|
|
138
|
+
let detach: (() => void) | null = null;
|
|
139
|
+
const attach = (): void => {
|
|
140
|
+
if (detach) return;
|
|
141
|
+
const handle = transport.for(documentId);
|
|
142
|
+
if (!handle) return;
|
|
143
|
+
detach = blenderSkin.attachTo(handle);
|
|
144
|
+
};
|
|
145
|
+
attach();
|
|
146
|
+
const stop = transport.subscribe(attach);
|
|
147
|
+
return () => {
|
|
148
|
+
stop();
|
|
149
|
+
detach?.();
|
|
150
|
+
};
|
|
151
|
+
}, [documentId]);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!documentId) return;
|
|
154
|
+
if (blend === undefined) {
|
|
155
|
+
// Opened by address: the session names its own document.
|
|
156
|
+
bindModelDocument(null);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// A FRESHLY OPENED MODEL DOCUMENT PRESENTS WITHOUT AN EXECUTE. Every
|
|
160
|
+
// mutation presents (`session.py::dispatch`), so the document only ever
|
|
161
|
+
// received a frame once something had RUN — open a `.blend` and the
|
|
162
|
+
// viewport stayed empty until a `vgai blender-exec`. The first present is
|
|
163
|
+
// the document's to ask for, because opening a document IS the request to
|
|
164
|
+
// see what is in it (I1 found this; WORK.md §Blender in the tab is
|
|
165
|
+
// Blender, "Inspection parity", I2 decision 6).
|
|
166
|
+
void openModelDocumentBlend(documentId, blend, document!.id).catch((error: unknown) => {
|
|
167
|
+
notify?.({
|
|
168
|
+
tone: 'error',
|
|
169
|
+
title: `Blender could not open ${blend}`,
|
|
170
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
return () => bindModelDocument(null);
|
|
174
|
+
}, [blend, document?.id, documentId, notify]);
|
|
175
|
+
if (!documentId) return null;
|
|
176
|
+
const Surface = surfaces.Object3DAuthoring;
|
|
177
|
+
return (
|
|
178
|
+
<Surface
|
|
179
|
+
active={active ?? true}
|
|
180
|
+
documentId={documentId}
|
|
181
|
+
sourcePath={blend ?? 'blender:runtime'}
|
|
182
|
+
displayName={document?.label ?? 'Model'}
|
|
183
|
+
build={build}
|
|
184
|
+
audit={false}
|
|
185
|
+
// ON A MODEL DOCUMENT THE HIERARCHY IS BLENDER'S OUTLINER, for the same
|
|
186
|
+
// reason the Properties rail is Blender's rail: the rows this panel drew
|
|
187
|
+
// were the PRESENTER's three.js graph — a reading of Blender's data by
|
|
188
|
+
// the thing that photographs it — where Blender's own Outliner shows the
|
|
189
|
+
// VIEW LAYER's datablock tree. `blender-outliner-authoring.ts` is the
|
|
190
|
+
// provider; the default adapter it delegates to still answers everything
|
|
191
|
+
// about the presentation (WORK.md §Blender in the tab is Blender,
|
|
192
|
+
// "Inspection parity", I3).
|
|
193
|
+
authoring={createBlenderOutlinerAuthoring}
|
|
194
|
+
cameraDirection={[0.8187, 0.4458, 0.3617]}
|
|
195
|
+
// AND AS FAR BACK AS BLENDER'S STARTUP VIEW STANDS. The direction alone
|
|
196
|
+
// put the cube where Blender's is but FILLING the frame: the factory
|
|
197
|
+
// view is 18.39 units from a 2 m cube (`region_3d.view_distance` at
|
|
198
|
+
// `--factory-startup`), about a third of the size a bare fit gives, which
|
|
199
|
+
// is the number `openingFit` was written for and nothing was passing.
|
|
200
|
+
openingFit={3}
|
|
201
|
+
// SOLID SHADING IS BLENDER'S, and Blender's Solid mode has NO world
|
|
202
|
+
// light: `light_ambient` is (0,0,0), there is no IBL, and the model is
|
|
203
|
+
// lit by four studio lights stated in VIEW space. So this document
|
|
204
|
+
// turns the editor's studio dressing off — its RoomEnvironment IBL and
|
|
205
|
+
// its one warm key, which between them were the blow-out row 1
|
|
206
|
+
// measured (the factory cube read (240,240,239)/(231,231,231)/
|
|
207
|
+
// (227,226,225) against Blender's (141,143,145)/(129,131,131)/
|
|
208
|
+
// (111,112,113), a spread of 13 against 30) — and hands the stage
|
|
209
|
+
// Blender's own four instead. `keyLight: false` also stands down the
|
|
210
|
+
// editor's design-time light rig, which answers the same question.
|
|
211
|
+
// The BACKGROUND stays the dressing's, because the Blender palette
|
|
212
|
+
// already paints the viewport its own flat grey.
|
|
213
|
+
//
|
|
214
|
+
// AND THE VIEW TRANSFORM IS BLENDER'S. Blender's factory scene is AgX
|
|
215
|
+
// (`view_settings.view_transform`), and this document's own RENDER path
|
|
216
|
+
// already photographs through `THREE.AgXToneMapping` (`presenter.ts`),
|
|
217
|
+
// so the viewport was the one surface in the chain running a different
|
|
218
|
+
// curve from the thing it frames. MEASURED on the factory cube, our own
|
|
219
|
+
// radiance through each operator against Blender's (141,143,145)/
|
|
220
|
+
// (129,131,131)/(111,112,113): ACES lands the three faces within 6
|
|
221
|
+
// levels with a spread of 40 where Blender's is 30 — the curve, not the
|
|
222
|
+
// lights, is what was left of row 1's spread — and AgX within 3 at a
|
|
223
|
+
// spread of 26.
|
|
224
|
+
dressing={{
|
|
225
|
+
environment: false,
|
|
226
|
+
keyLight: false,
|
|
227
|
+
toneMapping: THREE.AgXToneMapping,
|
|
228
|
+
viewLocked: view.studioLights(),
|
|
229
|
+
}}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TIMELINE'S CONSTANTS — every one read from Blender's source at the
|
|
3
|
+
* engine's pin (5.2.0, `fbe6228777e7`) AND checked against a PIXEL in Blender's
|
|
4
|
+
* own frame (5.2.1 on this box, `scripts/blender-reference-frames.py`).
|
|
5
|
+
*
|
|
6
|
+
* This is the first unit of the inspection arc with a sighted read behind it.
|
|
7
|
+
* I1-I5 each recorded "`/Volumes/PeakSSD` was not mounted, so this is graded
|
|
8
|
+
* against Blender's SOURCE alone"; Blender is installed here, the script above
|
|
9
|
+
* drives its real GUI, and the numbers below carry three things each: the
|
|
10
|
+
* source that defines them, the pixel that confirms them, and what we draw.
|
|
11
|
+
*
|
|
12
|
+
* ## The screenshot reads ONE 8-BIT LEVEL BELOW the theme byte
|
|
13
|
+
*
|
|
14
|
+
* Measured on every flat fill in the frame: the theme's `#303030` reads
|
|
15
|
+
* `#2f2f2f`, `#1d1d1d` reads `#1c1c1c`, `#161616` reads `#151515`,
|
|
16
|
+
* `#4772b3` reads `#4671b2`, `#ffbe33` reads `#ffbd32`. It is uniform and it
|
|
17
|
+
* is the screenshot path's, not the theme's — so the values below are the
|
|
18
|
+
* THEME BYTES (what Blender means) and a reader comparing them against a
|
|
19
|
+
* reference PNG should expect that −1.
|
|
20
|
+
*
|
|
21
|
+
* ## The theme's own table disagrees with the source's default in one place,
|
|
22
|
+
* and the running theme wins
|
|
23
|
+
*
|
|
24
|
+
* `userdef_default_theme.c:490` declares `.space_action.back` as
|
|
25
|
+
* `RGBA(0x30303000)` — alpha ZERO. Read off the RUNNING factory theme
|
|
26
|
+
* (`bpy.context.preferences.themes[0].dopesheet_editor.space.back`) it is
|
|
27
|
+
* `#303030ff`. The difference is load-bearing: `ANIM_draw_framerange` paints
|
|
28
|
+
* the out-of-range area with `TH_BACK` shaded −25 at alpha −100, which at
|
|
29
|
+
* alpha 0 would draw NOTHING and at alpha 255 draws a visible darkening. The
|
|
30
|
+
* frame settles it — outside the range is `#202020` against `#2f2f2f` inside,
|
|
31
|
+
* which is exactly `48 + (23 − 48) × 155/255 = 32.8`. The screenshot wins.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** `U.widget_unit` — `wm_window.cc:779`, `int(roundf(18 * scale_factor)) + 2 *
|
|
35
|
+
* pixelsize` = 20 at scale 1. Confirmed in the frame: a transport button
|
|
36
|
+
* block of six is 120 px wide (x 697…816), so each is 20. */
|
|
37
|
+
export const WIDGET_UNIT = 20;
|
|
38
|
+
|
|
39
|
+
/** `UI_TIME_SCRUB_MARGIN_Y` — `UI_view2d.hh:491`, `23 * UI_SCALE_FAC`. The
|
|
40
|
+
* strip the ruler and the playhead's number pill live in. CONFIRMED: the
|
|
41
|
+
* Timeline area's `.regions.scrubbing.back` band is exactly 23 px tall
|
|
42
|
+
* (image rows 26…48 under a 26-px header). */
|
|
43
|
+
export const SCRUB_HEIGHT = 23;
|
|
44
|
+
|
|
45
|
+
/** `HEADERY` — `DNA_screen_types.h:602`, `20 + HEADER_PADDING_Y`. CONFIRMED
|
|
46
|
+
* at 26 px, with its 20-px widgets centred (3 px above, 3 below). */
|
|
47
|
+
export const HEADER_HEIGHT = 26;
|
|
48
|
+
|
|
49
|
+
/** `ANIM_UI_get_channel_height` — `anim_channels_defines.cc:5147-5150`,
|
|
50
|
+
* `0.8 * keyframe_scale_fac * widget_unit`; `keyframe_scale_fac` is 1.0
|
|
51
|
+
* (`userdef_default_theme.c:508`, and read back 1.0 off the running theme). */
|
|
52
|
+
export const CHANNEL_HEIGHT = 16;
|
|
53
|
+
/** `ANIM_UI_get_channel_skip` — `:5152-5155`, `0.1 * widget_unit`. */
|
|
54
|
+
export const CHANNEL_SKIP = 2;
|
|
55
|
+
/** `ANIM_UI_get_first_channel_top` — `:5157-5160`: the first row's TOP is
|
|
56
|
+
* `-UI_TIME_SCRUB_MARGIN_Y - skip` below the region's top edge. */
|
|
57
|
+
export const FIRST_CHANNEL_TOP = SCRUB_HEIGHT + CHANNEL_SKIP;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE TIMELINE DRAWS EXACTLY ONE CHANNEL ROW, and that is a source fact rather
|
|
61
|
+
* than a layout choice: `action_create` sets `ADS_FLAG_SUMMARY_COLLAPSED` when
|
|
62
|
+
* the area's subtype is `SACTCONT_TIMELINE` (`space_action.cc:80-83`), and
|
|
63
|
+
* `animdata_filter_dopesheet` returns 0 right after adding the summary channel
|
|
64
|
+
* when that flag is set (`anim_filter.cc:3845-3849`). The channel-NAMES region
|
|
65
|
+
* is not drawn at all in Timeline mode
|
|
66
|
+
* (`action_region_poll_hide_in_timeline`, `space_action.cc:785-790`).
|
|
67
|
+
*
|
|
68
|
+
* THE ROW HAS NO BACKDROP EITHER. `draw_backdrops` branches on
|
|
69
|
+
* `ELEM(ac->datatype, ANIMCONT_ACTION, ANIMCONT_DOPESHEET, ANIMCONT_SHAPEKEY)`
|
|
70
|
+
* and then on GPENCIL and MASK (`action_draw.cc:218-300`); `ANIMCONT_TIMELINE`
|
|
71
|
+
* matches none of them, so the summary row is drawn straight onto the region's
|
|
72
|
+
* own clear. CONFIRMED: the row is `#2f2f2f` inside the frame range, the same
|
|
73
|
+
* value as the rest of the region.
|
|
74
|
+
*/
|
|
75
|
+
export const TIMELINE_ROWS = 1;
|
|
76
|
+
|
|
77
|
+
/** `MIN_MAJOR_LINE_DISTANCE` — `view2d_draw.cc:41`, `U.v2d_min_gridsize *
|
|
78
|
+
* UI_SCALE_FAC`, default 35 (`versioning_userdef.cc:1028-1029`). The real
|
|
79
|
+
* minimum is `max(35, label width + 6)` (`get_min_line_distance_x`, `:485`,
|
|
80
|
+
* `text_padding` at `:478`). */
|
|
81
|
+
export const MIN_MAJOR_LINE_DISTANCE = 35;
|
|
82
|
+
/** `get_label_width`'s `text_padding` — `view2d_draw.cc:478`. */
|
|
83
|
+
export const LABEL_PADDING = 6;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `calculate_grid_step` — `view2d_draw.cc:88-118`, and it is NOT a table of
|
|
87
|
+
* powers: it starts at the BASE (the scene fps, because the Timeline's ruler
|
|
88
|
+
* is `view2d_draw_scale_x(…, base = fps)` through `ED_time_scrub_draw`,
|
|
89
|
+
* `space_action.cc:298`) and then either SHRINKS it by a special prime
|
|
90
|
+
* factorisation that prefers to land on 2, or DOUBLES it until a step is at
|
|
91
|
+
* least `minDistance` pixels apart.
|
|
92
|
+
*
|
|
93
|
+
* `get_divisor` (`:52-79`) is the shrinking half: of 2, 3 and 5, prefer the
|
|
94
|
+
* divisor whose quotient is exactly 2 ("animating on 2s is a very useful thing
|
|
95
|
+
* for animators"), else the first that divides cleanly, else the distance
|
|
96
|
+
* itself (so the next step down is 1).
|
|
97
|
+
*/
|
|
98
|
+
export function gridDivisor(distance: number): number {
|
|
99
|
+
const divisors = [2, 3, 5];
|
|
100
|
+
const clean: boolean[] = [];
|
|
101
|
+
for (let i = 0; i < divisors.length; i++) {
|
|
102
|
+
const divisor = divisors[i]!;
|
|
103
|
+
const result = Math.trunc(distance / divisor);
|
|
104
|
+
const exact = result * divisor === distance;
|
|
105
|
+
if (exact && result === 2) return divisor;
|
|
106
|
+
clean[i] = exact;
|
|
107
|
+
}
|
|
108
|
+
for (let i = 0; i < divisors.length; i++) if (clean[i]) return divisors[i]!;
|
|
109
|
+
return distance;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `calculate_grid_step` (`view2d_draw.cc:88-118`), transcribed. `base` is the
|
|
113
|
+
* scene fps for a frame ruler; `pixelWidth` is the region's width + 1 and
|
|
114
|
+
* `viewWidth` the frames it spans. */
|
|
115
|
+
export function gridStep(
|
|
116
|
+
base: number,
|
|
117
|
+
pixelWidth: number,
|
|
118
|
+
viewWidth: number,
|
|
119
|
+
minDistance: number,
|
|
120
|
+
): number {
|
|
121
|
+
if (viewWidth === 0) return 1;
|
|
122
|
+
const perUnit = pixelWidth / viewWidth;
|
|
123
|
+
let distance = Math.max(base, 1);
|
|
124
|
+
if (perUnit * distance > minDistance) {
|
|
125
|
+
while (distance > 1) {
|
|
126
|
+
const divisor = gridDivisor(distance);
|
|
127
|
+
const result = Math.trunc(distance / divisor);
|
|
128
|
+
if (perUnit * result < minDistance) break;
|
|
129
|
+
distance = result;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
while (perUnit * distance < minDistance && distance < 1 << 30) distance *= 2;
|
|
133
|
+
}
|
|
134
|
+
return distance;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `view2d_draw_lines`' minor half — `view2d_draw.cc:260-285`: one more
|
|
138
|
+
* `get_divisor` step below the major distance, drawn only while it stays at
|
|
139
|
+
* least `MIN_MAJOR_LINE_DISTANCE / 5` pixels apart, and (for a frame ruler,
|
|
140
|
+
* where `show_fractions` is false through `ED_time_scrub_draw`'s
|
|
141
|
+
* `discrete_frames = true`) only while the major distance is above 1
|
|
142
|
+
* (`view2d_draw_lines_x`, `:545`). */
|
|
143
|
+
export function minorStep(major: number): number | null {
|
|
144
|
+
if (major <= 1) return null;
|
|
145
|
+
return major / gridDivisor(Math.round(major));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* THE THEME, as the RUNNING factory theme answers it (read back through
|
|
150
|
+
* `bpy.context.preferences.themes[0]`), with each key's source line beside it.
|
|
151
|
+
* Where the source's default literal and the running theme disagree the
|
|
152
|
+
* running theme is what draws, and the disagreement is named.
|
|
153
|
+
*/
|
|
154
|
+
export const TIMELINE_THEME = {
|
|
155
|
+
/** `.space_action.back` — `userdef_default_theme.c:490` declares
|
|
156
|
+
* `0x30303000`; the RUNNING theme is `#303030ff` (see the module header).
|
|
157
|
+
* The region's clear, `frame_buffer_clear(TH_BACK)`. */
|
|
158
|
+
back: '#303030',
|
|
159
|
+
/** `.space_action.header` `0x303030b3` (`:494`) over the window. Reads
|
|
160
|
+
* `#2f2f2f` in the frame, which at alpha 0xb3 over `#2d2d2d`-ish window
|
|
161
|
+
* chrome is what this value resolves to; drawn flat here. */
|
|
162
|
+
header: '#303030',
|
|
163
|
+
/** `.space_action.grid` — `:498`. The MAJOR frame lines. */
|
|
164
|
+
grid: '#161616',
|
|
165
|
+
/** `get_color_shade_3ubv(TH_GRID, 16)` — `view2d_draw.cc:266`. The MINOR
|
|
166
|
+
* lines, exactly 16 levels lighter. CONFIRMED `#252525` in the frame
|
|
167
|
+
* against the major's `#151515`. */
|
|
168
|
+
gridMinor: '#262626',
|
|
169
|
+
/** `.regions.scrubbing.back` — `userdef_default_theme.c:300`. */
|
|
170
|
+
scrubBack: '#1d1d1d',
|
|
171
|
+
/** `.regions.scrubbing.text` — `:301`, the ruler's numbers
|
|
172
|
+
* (`TH_TIME_SCRUB_TEXT`, passed by `ED_time_scrub_draw`). */
|
|
173
|
+
scrubText: '#808080',
|
|
174
|
+
/** `.common.anim.playhead` (`TH_CFRAME`) — `:312`. The stalk, the pill and
|
|
175
|
+
* its tip. CONFIRMED `#4671b2` in the frame. */
|
|
176
|
+
playhead: '#4772b3',
|
|
177
|
+
/** `TH_HEADER_TEXT_HI` — the pill's number (`draw_playhead_box`,
|
|
178
|
+
* `time_scrub_ui.cc:182`); `.space_action.header_text_hi` `:496`. */
|
|
179
|
+
playheadText: '#ffffff',
|
|
180
|
+
/** `.common.anim.preview_range` — `:313`, the preview-range curtains
|
|
181
|
+
* (`ANIM_draw_previewrange`, `anim_draw.cc:85-115`, shaded −25 / alpha
|
|
182
|
+
* −30). Drawn only when `use_preview_range`. */
|
|
183
|
+
previewRange: '#a14d00',
|
|
184
|
+
/** `.common.anim.long_key` / `long_key_selected` — `:332-333`, the HELD-key
|
|
185
|
+
* bar between two columns whose value does not change. */
|
|
186
|
+
longKey: '#ffffff',
|
|
187
|
+
longKeyAlpha: 0x1f / 255,
|
|
188
|
+
longKeySelected: '#ff8c00',
|
|
189
|
+
longKeySelectedAlpha: 0x99 / 255,
|
|
190
|
+
/** `.space_action.keyborder` / `keyborder_select` — `:502-503`, BOTH black
|
|
191
|
+
* (confirmed off the running theme: `#000000ff` each). */
|
|
192
|
+
keyBorder: '#000000',
|
|
193
|
+
} as const;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* THE SIX KEYFRAME TYPES, unselected / selected — `.common.anim`
|
|
197
|
+
* (`userdef_default_theme.c:320-331`), keyed by `Keyframe.type`'s own
|
|
198
|
+
* identifier so the door's answer indexes it directly.
|
|
199
|
+
*/
|
|
200
|
+
export const KEY_COLORS: Record<string, { readonly fill: string; readonly selected: string }> = {
|
|
201
|
+
KEYFRAME: { fill: '#bfbfbf', selected: '#ffbe33' },
|
|
202
|
+
EXTREME: { fill: '#e8b3cc', selected: '#f28080' },
|
|
203
|
+
BREAKDOWN: { fill: '#b3dbe8', selected: '#54bfed' },
|
|
204
|
+
JITTER: { fill: '#94e575', selected: '#61c042' },
|
|
205
|
+
MOVING_HOLD: { fill: '#808080', selected: '#ffaf23' },
|
|
206
|
+
GENERATED: { fill: '#585858', selected: '#a28962' },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Per-type SIZE multipliers — `keyframes_draw.cc:62-85`. */
|
|
210
|
+
export const KEY_SIZE_FACTOR: Record<string, number> = {
|
|
211
|
+
KEYFRAME: 1,
|
|
212
|
+
EXTREME: 1.2,
|
|
213
|
+
MOVING_HOLD: 0.925,
|
|
214
|
+
BREAKDOWN: 0.85,
|
|
215
|
+
JITTER: 0.8,
|
|
216
|
+
GENERATED: 0.75,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* THE DIAMOND, and this is the one number a lane must not re-derive by eye.
|
|
221
|
+
*
|
|
222
|
+
* `icon_size = widget_unit * 0.5 * yscale_fac` = 10 (`keyframes_draw.cc:218`),
|
|
223
|
+
* and the SHAPE is the shader's arithmetic
|
|
224
|
+
* (`gpu_shader_keyframe_shape_vert.glsl:46-66`, `outline_scale` 1 at its one
|
|
225
|
+
* caller `keyframes_draw.cc:660`):
|
|
226
|
+
*
|
|
227
|
+
* half_width = 0.06 + (size − 10) × 0.04 → 0.06
|
|
228
|
+
* line_width = half_width + line_falloff(1.0) → 1.06
|
|
229
|
+
* thresholds = (max(0, line_width − 1), line_width) → (0.06, 1.06)
|
|
230
|
+
* ext_radius = round(0.5 × size) + thresholds.x → 5.06
|
|
231
|
+
* pointSize = ceil(ext_radius + thresholds.y) × 2 + 1 → 15
|
|
232
|
+
*
|
|
233
|
+
* and the fragment's diamond test is an L1 ball: `radius = (|x| + |y|) ×
|
|
234
|
+
* √0.5` against `radii[0] = ext_radius × √0.5`, with
|
|
235
|
+
* `alpha = 1 − smoothstep(thresholds.x, thresholds.y, |outline_dist|)`. So the
|
|
236
|
+
* OUTLINE is a band around L1 = 5.06 whose PERPENDICULAR half-extent is
|
|
237
|
+
* `line_width` = 1.06 px, and the fill shows inside L1 ≈ 3.56.
|
|
238
|
+
*
|
|
239
|
+
* CONFIRMED PIXEL FOR PIXEL in Blender's frame: the sprite is 15 rows tall
|
|
240
|
+
* (image rows 51…65 for a key centred at 58), the filled core is 7 rows
|
|
241
|
+
* (55…61 — L1 ≤ 3.5), and the black band reaches L1 ≈ 6.5.
|
|
242
|
+
*
|
|
243
|
+
* WE DRAW IT as an SVG polygon of half-diagonal `DIAMOND_RADIUS` with a
|
|
244
|
+
* `DIAMOND_STROKE`-wide stroke straddling it, which reproduces both extents
|
|
245
|
+
* exactly — 3.56 for the fill's edge and 6.56 for the outline's. The stated
|
|
246
|
+
* difference: Blender's band is a 0.06 → 1.06 smoothstep in each direction and
|
|
247
|
+
* SVG has no such ramp, so the browser antialiases its own hard edges instead.
|
|
248
|
+
* The INK-weighted equivalent would be 1.12 px (the integral of
|
|
249
|
+
* `1 − smoothstep(0,1,u)` is exactly ½, so each side deposits 0.06 + 0.5), and
|
|
250
|
+
* it would put the fill 0.7 px further out than Blender's — the extents are
|
|
251
|
+
* what a parity read measures, so the extent is what is matched.
|
|
252
|
+
*/
|
|
253
|
+
export const KEY_ICON_SIZE = 10;
|
|
254
|
+
export const DIAMOND_RADIUS = 5.06;
|
|
255
|
+
export const DIAMOND_STROKE = 2.12;
|
|
256
|
+
/** `gl_PointSize` — the sprite Blender rasterises the shape into. Reported in
|
|
257
|
+
* the view's `state` so a parity reading can check it. */
|
|
258
|
+
export const DIAMOND_SPRITE = 15;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* THE PLAYHEAD — `get_playhead_dimensions` (`time_scrub_ui.cc:96-116`) and
|
|
262
|
+
* `draw_playhead_box` / `draw_playhead_tip` (`:161-206`).
|
|
263
|
+
*
|
|
264
|
+
* CONFIRMED: a 24-px pill (22 px of flat `#4671b2` plus a pixel of
|
|
265
|
+
* antialiasing each side) starting 2 px below the scrub strip's top, and a
|
|
266
|
+
* 3-px stalk (`draw_playhead_stalk`'s `rect` at `UI_SCALE_FAC ≥ 0.91`:
|
|
267
|
+
* `floor(x − 1) − 1` to `floor(x + 2) + 1`, of which the shadow is the outer
|
|
268
|
+
* pixel on each side).
|
|
269
|
+
*/
|
|
270
|
+
export const PLAYHEAD = {
|
|
271
|
+
/** `box_min_width = 24 * UI_SCALE_FAC` (`:109`); a wider number grows it to
|
|
272
|
+
* `text_width + 2 * text_padding`. */
|
|
273
|
+
minPillWidth: 24,
|
|
274
|
+
/** `text_padding = 4 * UI_SCALE_FAC` (`:107`). */
|
|
275
|
+
textPadding: 4,
|
|
276
|
+
/** `box_margin = 2 * UI_SCALE_FAC` (`:110`). */
|
|
277
|
+
margin: 2,
|
|
278
|
+
/** `box_corner_radius = 4 * UI_SCALE_FAC` (`draw_playhead_box:165`). */
|
|
279
|
+
radius: 4,
|
|
280
|
+
/** `tri_half_width` / `tri_height` = `6 * UI_SCALE_FAC` (`:113-114`). */
|
|
281
|
+
tipHalfWidth: 6,
|
|
282
|
+
tipHeight: 6,
|
|
283
|
+
/** The stalk's drawn core, measured: 3 px. */
|
|
284
|
+
stalkWidth: 3,
|
|
285
|
+
} as const;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `ANIM_draw_framerange` — `anim_draw.cc:172-190`: the area OUTSIDE
|
|
289
|
+
* `scene.frame_start … frame_end` takes `TH_BACK` shaded −25 with alpha −100.
|
|
290
|
+
* With the running theme's `#303030ff` that resolves to
|
|
291
|
+
* `48 + (23 − 48) × 155/255 = 32.8`, and the frame reads `#202020` (32).
|
|
292
|
+
* Drawn here as one flat colour rather than a blend, because the blend has one
|
|
293
|
+
* possible answer over the region's own clear.
|
|
294
|
+
*/
|
|
295
|
+
export const OUT_OF_RANGE = '#212121';
|
|
296
|
+
|
|
297
|
+
/** `.space_action.text` — `userdef_default_theme.c:492`, the status line's. */
|
|
298
|
+
export const TIMELINE_CHROME = {
|
|
299
|
+
text: '#a6a6a6',
|
|
300
|
+
textHi: '#ffffff',
|
|
301
|
+
/** `ui.editor_border` — `#161616`, read back off the running theme. */
|
|
302
|
+
rule: '#161616',
|
|
303
|
+
/** `wcol_tool.inner` / `.text` / `.outline`, read back off the running
|
|
304
|
+
* theme: `#545454` / `#e6e6e6` / `#3d3d3d`, roundness 0.4. A 20-px widget
|
|
305
|
+
* therefore has a 4-px corner radius. */
|
|
306
|
+
widget: '#545454',
|
|
307
|
+
widgetText: '#e6e6e6',
|
|
308
|
+
widgetOutline: '#3d3d3d',
|
|
309
|
+
widgetRadius: 4,
|
|
310
|
+
/** `UI_UNIT_X` / `UI_UNIT_Y` — one `widget_unit`. Confirmed: the six
|
|
311
|
+
* transport buttons span exactly 120 px. */
|
|
312
|
+
unit: WIDGET_UNIT,
|
|
313
|
+
refusal: '#ffaf23',
|
|
314
|
+
/** The header's own padding and the gap between its widget groups —
|
|
315
|
+
* `space_time.py` builds it as a `layout.row(align=True)` per group with
|
|
316
|
+
* `separator_spacer()` between, and Blender's own header measures 8 px of
|
|
317
|
+
* lead-in. Held here rather than inline for the reason the UV view's
|
|
318
|
+
* `statusPadding` is: a drawing's numbers belong with its other numbers. */
|
|
319
|
+
headerPadding: '0 8px',
|
|
320
|
+
headerGap: 8,
|
|
321
|
+
/** The refusal line under the ruler. */
|
|
322
|
+
statusPadding: '2px 8px',
|
|
323
|
+
} as const;
|