@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.
Files changed (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. 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;