@volter/project 0.0.0-stage → 0.5.204

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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +18 -3
  4. package/package.json +64 -4
  5. package/schemas/engine-api.json +57 -0
  6. package/schemas/engine-api.md +22 -0
  7. package/schemas/volter-project.schema.json +468 -0
  8. package/schemas/volter-settings.schema.json +149 -0
  9. package/src/adapter/adapter-module.ts +997 -0
  10. package/src/adapter/adapter-surface.ts +34 -0
  11. package/src/adapter/authoring-seam-contract.ts +224 -0
  12. package/src/adapter/authoring.ts +1336 -0
  13. package/src/adapter/binding.ts +445 -0
  14. package/src/adapter/debug-registry-ref.ts +19 -0
  15. package/src/adapter/editor-looks.ts +519 -0
  16. package/src/adapter/finders/finder-result.ts +29 -0
  17. package/src/adapter/finders/registry.ts +116 -0
  18. package/src/adapter/frame-capture.ts +80 -0
  19. package/src/adapter/host-context.ts +114 -0
  20. package/src/adapter/index.ts +209 -0
  21. package/src/adapter/ingest/contract-system-slots.ts +28 -0
  22. package/src/adapter/ingest/game-contract-seams.ts +86 -0
  23. package/src/adapter/ingest/game-contract.ts +506 -0
  24. package/src/adapter/ingest/mount-readiness.ts +140 -0
  25. package/src/adapter/loop-gate-report.ts +53 -0
  26. package/src/adapter/manifest-interpreter.ts +64 -0
  27. package/src/adapter/native-entry-surface.ts +100 -0
  28. package/src/adapter/physics-adapter-2d.ts +30 -0
  29. package/src/adapter/render-audio.ts +20 -0
  30. package/src/adapter/render-memory.ts +34 -0
  31. package/src/adapter/renderer-config.ts +84 -0
  32. package/src/adapter/root-adapter.ts +243 -0
  33. package/src/adapter/root-seam-contract.ts +61 -0
  34. package/src/adapter/root-stacking.ts +67 -0
  35. package/src/adapter/seam-evidence.ts +336 -0
  36. package/src/adapter/system-adapter.ts +865 -0
  37. package/src/adapter/system-seam-contract.ts +138 -0
  38. package/src/adapter/system-slot.ts +301 -0
  39. package/src/adapter/transform.ts +35 -0
  40. package/src/adapter/workspace-arrangement.ts +92 -0
  41. package/src/core/system-phase.ts +52 -0
  42. package/src/inspection.ts +281 -0
  43. package/src/manifest/configuration-kinds.ts +241 -0
  44. package/src/manifest/editor-port.ts +69 -0
  45. package/src/manifest/filename.ts +45 -0
  46. package/src/manifest/kind-modules.ts +58 -0
  47. package/src/manifest/load-file.ts +33 -0
  48. package/src/manifest/load.ts +380 -0
  49. package/src/manifest/locate.ts +55 -0
  50. package/src/manifest/runtime-environment.ts +48 -0
  51. package/src/manifest/schema.ts +690 -0
  52. package/src/settings/keys.ts +152 -0
  53. package/src/settings/schema.ts +151 -0
@@ -0,0 +1,519 @@
1
+ /**
2
+ * THE LOOK POINTS — a workspace layout, a keymap and a style bundle as DATA a
3
+ * package contributes (WORKBENCH.md §Contribution points: "Layouts, keymaps
4
+ * and styles are DATA points: they need no code to load, which is what makes
5
+ * a 'styled build' a manifest choice rather than a code change").
6
+ *
7
+ * A contribution module declares its point and exports ONE object:
8
+ *
9
+ * `model.layout.ts` export const point = 'workspace.layout'; export const layout: WorkspaceLayoutContribution
10
+ * `blender.keymap.ts` export const point = 'workspace.keymap'; export const keymap: KeymapContribution
11
+ * `blender.style.ts` export const point = 'workspace.style'; export const style: StyleContribution
12
+ *
13
+ * The editor registers what a project's declared packages contribute beside
14
+ * its own built-ins — Look, Animate and Design as workspaces, the volter keymap,
15
+ * the Classic/Glass/Maya/Substance bundles — and never asks which package is
16
+ * open: a build without the Blender look has no Model workspace.
17
+ */
18
+ import type { WorkspaceArrangement } from './workspace-arrangement';
19
+
20
+ /**
21
+ * Every keyboard action the editor's chrome binds. A keymap binds these and
22
+ * nothing else; a game never reads this table (play-mode input isolation is a
23
+ * different mechanism).
24
+ */
25
+ export type EditorKeyActionId =
26
+ | 'edit.undo'
27
+ | 'edit.redo'
28
+ | 'edit.copy'
29
+ | 'edit.cut'
30
+ | 'edit.paste'
31
+ | 'edit.duplicate'
32
+ | 'edit.group'
33
+ | 'edit.wrap'
34
+ | 'edit.unwrap'
35
+ | 'edit.selectAll'
36
+ | 'edit.deselectAll'
37
+ | 'edit.save'
38
+ | 'edit.delete'
39
+ | 'edit.enterScope'
40
+ | 'edit.exitScopeOrDeselect'
41
+ | 'edit.nudgeUp'
42
+ | 'edit.nudgeUpCoarse'
43
+ | 'edit.nudgeDown'
44
+ | 'edit.nudgeDownCoarse'
45
+ | 'edit.nudgeLeft'
46
+ | 'edit.nudgeLeftCoarse'
47
+ | 'edit.nudgeRight'
48
+ | 'edit.nudgeRightCoarse'
49
+ | 'view.resetPan'
50
+ | 'view.commandPalette'
51
+ | 'view.toggleConsole'
52
+ | 'view.focusMode'
53
+ | 'workspace.cycleNext'
54
+ | 'workspace.cyclePrevious'
55
+ /** Arm the tool that draws NO transform gizmo — Blender's Select Box, whose
56
+ * 3D View keymap puts it on `W` (`blender_default.py:1950-1955`,
57
+ * `op_tool_cycle("builtin.select_box", …)`). This editor's own keymap binds
58
+ * it to nothing: W/E/R/T are its four transform tools. */
59
+ | 'transform.select'
60
+ | 'transform.combined'
61
+ | 'transform.translate'
62
+ | 'transform.rotate'
63
+ | 'transform.scale'
64
+ | 'viewport.toggleSnap'
65
+ | 'viewport.frameSelection'
66
+ | 'viewport.cyclePivot'
67
+ | 'canvas.panMode'
68
+ | 'canvas.rulerMode'
69
+ | 'canvas.toggleGrid'
70
+ | 'viewport.vertexSnapHold'
71
+ | 'viewport.snapToFloor'
72
+ | 'view.top'
73
+ | 'view.front'
74
+ | 'view.right'
75
+ | 'view.bottom'
76
+ | 'view.back'
77
+ | 'view.left'
78
+ /** Step the view: orbit 15° about the world's up or the view's horizon, turn to the opposite
79
+ * side, or roll 15° (Blender's `view3d.view_orbit` and `view3d.view_roll` on the numpad). */
80
+ | 'view.orbitLeft'
81
+ | 'view.orbitRight'
82
+ | 'view.orbitUp'
83
+ | 'view.orbitDown'
84
+ | 'view.opposite'
85
+ | 'view.rollLeft'
86
+ | 'view.rollRight'
87
+ /** Frame everything in the view, selected or not (Blender's `view3d.view_all`, Home). */
88
+ | 'view.all'
89
+ /** Step the view nearer or farther by the keymap's zoom step (`view3d.zoom`, numpad +/-). */
90
+ | 'view.zoomIn'
91
+ | 'view.zoomOut'
92
+ | 'view.perspective'
93
+ /** Switch the view between perspective and orthographic, keeping where it looks from
94
+ * (Blender's `view3d.view_persportho`, numpad 5). */
95
+ | 'view.projection'
96
+ | 'view.camera';
97
+
98
+ /**
99
+ * One chord. `key` is matched case-insensitively against `KeyboardEvent.key`;
100
+ * `code` (when set) is matched against `KeyboardEvent.code` instead, for
101
+ * chords whose typed character varies by layout or modifier (Option+letter on
102
+ * macOS types a symbol). `mod` is ⌘ on macOS and Ctrl elsewhere.
103
+ */
104
+ export interface KeyChord {
105
+ readonly key: string;
106
+ readonly code?: string;
107
+ readonly mod?: boolean;
108
+ /** The Control key itself on every platform, macOS included (Blender's Ctrl), where `mod`
109
+ * would be ⌘. */
110
+ readonly ctrl?: boolean;
111
+ readonly shift?: boolean;
112
+ readonly alt?: boolean;
113
+ }
114
+
115
+ /** The chords a keymap binds for the actions it names. An action a keymap
116
+ * omits keeps the editor's own binding; an EMPTY list unbinds it. */
117
+ export type KeymapBindings = Partial<Record<EditorKeyActionId, readonly KeyChord[]>>;
118
+
119
+ export interface KeymapContribution {
120
+ /** The `keymap` settings value that selects it. */
121
+ readonly id: string;
122
+ readonly title: string;
123
+ readonly description: string;
124
+ readonly bindings: KeymapBindings;
125
+ /** Which mouse button orbits the viewport. Absent: the editor's own. */
126
+ readonly navigation?: KeymapNavigation;
127
+ }
128
+
129
+ /**
130
+ * THE VIEWPORT'S MOUSE, as a keymap states it: the button that orbits. The editor's own orbits
131
+ * with the right button and pans with the middle; a keymap that orbits with the middle (Blender's
132
+ * `view3d.rotate` on MIDDLEMOUSE) pans with Shift and the same button, as that keymap does.
133
+ */
134
+ export interface KeymapNavigation {
135
+ readonly orbit: 'middle' | 'right';
136
+ /**
137
+ * The orbit as a TURNTABLE that keeps the view's roll and may pass over the top (Blender's
138
+ * `view3d.rotate` in Turntable mode): a sideways drag spins the view about the world's up, a
139
+ * vertical one pitches it about the horizon, each at this angle per CSS pixel. Absent: the
140
+ * editor's own orbit, which holds the view level and stops at the poles.
141
+ */
142
+ readonly turntable?: { readonly degreesPerPixel: number };
143
+ /**
144
+ * An orthographic view still down an axis turns perspective when a rotate starts, as Blender's
145
+ * Auto Perspective does (`ED_view3d_persp_ensure`); the axis views themselves are orthographic.
146
+ */
147
+ readonly autoPerspective?: boolean;
148
+ /**
149
+ * ZOOM: `step` is the distance factor one zoom key moves by (Blender's `view_zoom_apply_step`,
150
+ * 1.2); `drag: 'dolly'` makes a zoom drag Blender's Dolly style (`viewzoom_scale_value`: the
151
+ * distance scales by `2 · (len / len₀ − 1) + 1`, `len` the pointer's height below the region's
152
+ * top plus 5). Absent: the editor's own.
153
+ */
154
+ readonly zoom?: { readonly step: number; readonly drag?: 'dolly' };
155
+ }
156
+
157
+ export type WorkspaceLayoutRegions = NonNullable<WorkspaceArrangement['regions']>;
158
+
159
+ export interface WorkspaceLayoutContribution {
160
+ readonly id: string;
161
+ readonly title: string;
162
+ readonly description: string;
163
+ /** What the workspace needs the project to DECLARE before it applies:
164
+ * `'mounts'` (at least one root that plays) or a document kind the
165
+ * project's table lists. Omitted: every project. */
166
+ readonly requires?: 'mounts' | { readonly documentKind: string };
167
+ /** Which chrome regions the workspace shows; absent means every region. */
168
+ readonly regions?: WorkspaceLayoutRegions;
169
+ /** The workspace's EDITOR AREAS beside the centre document — a second
170
+ * editor group holding a `workspace.document` contribution, the shape
171
+ * Blender's own screens have (`WorkspaceAreaContribution`). Absent: the
172
+ * centre document alone. */
173
+ readonly areas?: WorkspaceArrangement['areas'];
174
+ }
175
+
176
+ /**
177
+ * An OPAQUE material a style bundle carries: Classic's physics (opaque
178
+ * surfaces, conventional bars and panels) under its own corner radii and
179
+ * shadows. Chroma stays the palette's; Glass is the editor's own and is not
180
+ * contributable.
181
+ */
182
+ export interface MaterialContribution {
183
+ readonly id: string;
184
+ readonly title: string;
185
+ readonly description: string;
186
+ /** Corner radii, smallest to largest, and the pill. */
187
+ readonly shape: {
188
+ readonly small: string;
189
+ readonly medium: string;
190
+ readonly large: string;
191
+ readonly full: string;
192
+ };
193
+ /** `box-shadow` values (`none` allowed) for raised widgets, menus, dialogs. */
194
+ readonly elevation: {
195
+ readonly small: string;
196
+ readonly medium: string;
197
+ readonly large: string;
198
+ };
199
+ /** How tight the chrome sits: control heights, the type scale and the
200
+ * chrome rows, in px. Every field is optional; absent ones keep the
201
+ * editor's own. */
202
+ readonly density?: DensityContribution;
203
+ /** What the look states about the 3D stage ({@link StageContribution}). */
204
+ readonly stage?: StageContribution;
205
+ }
206
+
207
+ export interface DensityContribution {
208
+ readonly control?: {
209
+ readonly compact?: number;
210
+ readonly default?: number;
211
+ readonly comfortable?: number;
212
+ };
213
+ readonly font?: {
214
+ readonly xs?: number;
215
+ readonly sm?: number;
216
+ readonly base?: number;
217
+ readonly md?: number;
218
+ readonly lg?: number;
219
+ readonly xl?: number;
220
+ readonly '2xl'?: number;
221
+ readonly heading?: number;
222
+ };
223
+ /** Glyph sizes, independent of the type scale: Blender's chrome glyphs are
224
+ * 14 px whatever the text around them measures. */
225
+ readonly icon?: {
226
+ readonly xs?: number;
227
+ readonly sm?: number;
228
+ readonly md?: number;
229
+ readonly lg?: number;
230
+ readonly xl?: number;
231
+ readonly '2xl'?: number;
232
+ };
233
+ readonly chrome?: {
234
+ readonly commandBar?: number;
235
+ readonly panelHeader?: number;
236
+ readonly localToolbar?: number;
237
+ readonly treeRow?: number;
238
+ /** How far one level of a tree steps right. Absent keeps the editor's own
239
+ * 14; Blender's Outliner steps by its ROW HEIGHT — a square grid, 20 —
240
+ * with the chevron owning one cell and the type glyph the next. */
241
+ readonly treeIndent?: number;
242
+ readonly statusBar?: number;
243
+ /** The tool rail: a tool's box, its width, the gap between groups. */
244
+ readonly toolSize?: number;
245
+ readonly toolWidth?: number;
246
+ readonly toolGap?: number;
247
+ /** How wide the seam between two docked AREAS is cut, in px. Absent
248
+ * keeps the editor's own hairline; Blender's groove measures 3.5. The
249
+ * colour is the palette's `color.boundary.area`. */
250
+ readonly areaSeam?: number;
251
+ /** THE EMBOSS beside that groove, as a percentage of WHITE mixed into the
252
+ * area's OWN fill — so it is one number and every area computes its own
253
+ * colour from it. Absent (or 0) paints nothing, which is what every skin
254
+ * but Blender does; Blender's measures 8.2. See `theme.ts`'s
255
+ * `areaEmbossValue` for the five fills it was solved against. */
256
+ readonly areaEmboss?: number;
257
+ };
258
+ }
259
+
260
+ /**
261
+ * WHAT A LOOK STATES ABOUT THE 3D STAGE, as opposed to about the chrome around it: how the
262
+ * stage's furniture is drawn (sizes, widths, the selection box's form). Absent members keep
263
+ * the editor's own. What the stage DOES — its boot tool, its box-select rule, its world's up
264
+ * axis — is its presentation's (`@volter/sdk/kit/viewport-presentation`), never the
265
+ * look's (ARCHITECTURE.md rule 7).
266
+ */
267
+ export interface StageContribution {
268
+ /**
269
+ * THE TRANSFORM GIZMO'S SCREEN SIZE, in px per gizmo unit — Blender's
270
+ * `U.gizmo_size` (`DNA_userdef_types.h:1095`, default 75; Preferences ▸
271
+ * Viewport ▸ Gizmos ▸ Size, `rna_userdef.cc:5415-5419`).
272
+ *
273
+ * A gizmo is a CONSTANT SCREEN SIZE, never a fraction of the object or of
274
+ * the viewport: `wm_gizmo.cc:450-474` computes
275
+ * `scale_final = scale_basis · UI_SCALE_FAC · U.gizmo_size ·
276
+ * ED_view3d_pixel_size_no_ui_scale(rv3d, origin)`, and that last term is
277
+ * world units per device pixel while `UI_SCALE_FAC` is device pixels per
278
+ * UI pixel — so one gizmo unit is exactly `U.gizmo_size` CSS px at any
279
+ * display scale.
280
+ *
281
+ * Absent keeps the editor's own gizmo, which is a fraction of the
282
+ * VIEWPORT (three's `TransformControls` at `size` 1). What the number
283
+ * means on our side — three's handles are authored on a unit half
284
+ * Blender's — is `editor-viewport.ts`'s to know, and it says so there.
285
+ */
286
+ readonly gizmoSize?: number;
287
+ /**
288
+ * THE GIZMO'S HANDLES AT REST AND HIGHLIGHTED. `gizmoOpacity` is a resting handle's opacity
289
+ * (a highlighted one is opaque): Blender's 0.6 (`transform_gizmo_3d.cc`,
290
+ * `gizmo_get_axis_color`), Godot's 0.9 (`editors/3d/manipulator_gizmo_opacity`), Unity's
291
+ * 0.93 (`Handles.cs`, the axis colours' alpha). A handle with no fixed highlight colour
292
+ * (`color.gizmo.hover`) highlights in its own axis colour with its saturation multiplied by
293
+ * `gizmoHighlightSaturation` and its value set to `gizmoHighlightValue`, each kept when
294
+ * absent: Blender keeps both, Godot's are 0.25 and 1 (`node_3d_editor_plugin.cpp`,
295
+ * `col.from_hsv(col.get_h(), col.get_s() * 0.25, 1.0, 1)`).
296
+ */
297
+ readonly gizmoOpacity?: number;
298
+ /**
299
+ * THE MOVE ARROWS' SHAPE: `gizmoArrowLength` is how far an arrow's tip stands from the
300
+ * centre, in rotation-ring radii (three's own 1.2; Godot's 1.6, measured on
301
+ * `tuto_3d5.png` as a 142 px tip against a 90 px ring), and `gizmoArrowHead` scales its
302
+ * head (three's own 1; Godot's about 1.5). Which handles the tool offers is the view's
303
+ * (`interaction.transformHandles`), never the look's.
304
+ */
305
+ readonly gizmoArrowLength?: number;
306
+ readonly gizmoArrowHead?: number;
307
+ /** THE ROTATION RINGS' THICKNESS, as a multiple of three's own (Godot's rings are about
308
+ * twice as thick, `tuto_3d5.png`). */
309
+ readonly gizmoRingWidth?: number;
310
+ /**
311
+ * THE NAVIGATION GIZMO'S FORM AND CORNER: lettered `balls` (the editor's own, Blender's and
312
+ * Godot's), `cones` round a centre cube (Unity's scene gizmo) or a lettered axis `triad`
313
+ * (Unreal's); `top-right` (the editor's own) or `bottom-left` (Unreal's). Whether a click on
314
+ * it turns the view is the view's (`overlays.navigation`).
315
+ */
316
+ readonly navigationGizmo?: 'balls' | 'godot' | 'cones' | 'triad';
317
+ readonly navigationCorner?: 'top-right' | 'bottom-left';
318
+ /** The triad's size, a multiple of its own 24 px; line and letter stay inside the gizmo's
319
+ * 90 px box up to about 1.3 (Unreal's triad, letters included, is about 40 px). */
320
+ readonly navigationSize?: number;
321
+ readonly gizmoHighlightSaturation?: number;
322
+ readonly gizmoHighlightValue?: number;
323
+ /**
324
+ * THE FLOOR GRID'S LINES, in device pixels: the minor lines' width, the major lines' width,
325
+ * and how far the major lines' colour is carried past the minor's from the backdrop (1 draws
326
+ * both levels alike). A look that states none keeps the editor's own floor, one hairline
327
+ * level — the grid the kit drew before its floor was fitted to Blender. Blender's are 1.5,
328
+ * 2.25 and 39/21, measured across one scanline of `modeling-object-none.png`: the 1 m line
329
+ * 4 px at half rise plateauing at 83, the 10 m line 6 px plateauing at 101
330
+ * (`docs/VIEWPORT-STAGE.md`: widths are the look's, like colours).
331
+ */
332
+ readonly gridLineWidth?: number;
333
+ readonly gridMajorWidth?: number;
334
+ readonly gridMajorContrast?: number;
335
+ /** THE AXIS LINES' WIDTH in device pixels (the editor's own and Blender's are 2, measured
336
+ * on `modeling-object-none.png`). Which axis lines show is the view's
337
+ * (`overlays.axes`), never the look's. */
338
+ readonly axisLineWidth?: number;
339
+ /**
340
+ * HOW THE SELECTION BOX MARK IS DRAWN (the view's `overlays.selection.box`): `corners`, the
341
+ * editor's own brackets, or `edges`, the full box (Godot's selection box); and its stroke in
342
+ * CSS px (the editor's own is 3; Godot's box is a hairline).
343
+ */
344
+ readonly selectionBox?: 'corners' | 'edges';
345
+ /** Which axes the box is measured along: the world's (the editor's own) or the object's,
346
+ * so it turns with a rotated object (Godot's). */
347
+ readonly selectionBoxFrame?: 'world' | 'object';
348
+ /**
349
+ * THE SELECTION OUTLINE'S FORM: `soft`, the editor's own blurred halo, or `crisp`, a hard
350
+ * line `outlineWidth` device pixels wide (Blender's, Unity's and Unreal's are crisp); and
351
+ * whether the parts of the selection other objects hide are drawn too (`outlineHidden`,
352
+ * the editor's own; Unity's and Blender's are not).
353
+ */
354
+ readonly outlineStyle?: 'soft' | 'crisp';
355
+ readonly outlineWidth?: number;
356
+ readonly outlineHidden?: boolean;
357
+ /** THE SELECTION WIRE'S OPACITY (the editor's own 0.5; Unity's blue wire is about 0.25,
358
+ * `SceneVisExVisible.png`). Its colour is the palette's `color.viewport.wire`. */
359
+ readonly wireOpacity?: number;
360
+ readonly selectionBoxWidth?: number;
361
+ /**
362
+ * THE BOX-SELECT OUTLINE'S DASH, in device pixels: each dash this long in the palette's
363
+ * `color.viewport.marqueeLine`, each gap as long in its `marqueeGap`. Absent draws the
364
+ * editor's own solid line. Blender's is 4: `wm_gesture_draw_rect` (`wm_gesture.cc`) draws the
365
+ * box with a `dash_width` of 8 framebuffer pixels at a `udash_factor` of 0.5, the first half
366
+ * of each period in its `color` and the second in its `color2`. What a box drag selects is
367
+ * the view's (`interaction.boxSelect`), never the look's.
368
+ */
369
+ readonly marqueeDash?: number;
370
+ /** THE STAGE'S OWN CHROME: which overlay controls the viewport carries and where. See
371
+ * {@link StageChromeContribution}; absent keeps the editor's own set. */
372
+ readonly chrome?: StageChromeContribution;
373
+ /** The stage's controls in the target's words. See {@link StageWordsContribution}. */
374
+ readonly words?: StageWordsContribution;
375
+ }
376
+
377
+ /**
378
+ * THE VIEWPORT'S OWN CHROME, as each target's viewport arranges it (read from the reference
379
+ * frames, `docs/VIEWPORT-STAGE.md`): a look places the stage's controls, it never adds one.
380
+ * Each member is independently optional; absent keeps the editor's own arrangement (Blender's:
381
+ * the view text top-left, the tool shelf, the display controls in the top-right corner).
382
+ *
383
+ * - `bar`: a row across the stage's top. `strip` is a flush panel band (Godot's 3D toolbar,
384
+ * Unity's Scene view toolbar); `pills` is a row of rounded pills floating over the view
385
+ * (Unreal's level viewport toolbar). Absent, there is no bar.
386
+ * - `viewName`: where the view's name is. `text` is Blender's lines at the top-left; `menu`
387
+ * a pill at the top-left that opens the view menu (Godot's "⋮ Perspective"); `gizmo` a label
388
+ * under the navigation gizmo that toggles the projection (Unity's "Persp"); `bar` the first
389
+ * pill of the bar, opening the view menu (Unreal's "Perspective").
390
+ * - `tools`: the transform tools on the `shelf` (Blender's, Unity's Tools overlay) or at the
391
+ * bar's `bar-start` (Godot) or `bar-end` (Unreal).
392
+ * - `display`: the display controls (shading, grid, helpers, lights) in the top-right
393
+ * `corner` or at the bar's `bar-start` (Unity, Unreal) or `bar-end` (Godot).
394
+ * - `transformControls`: the stage's transform controls (orientation, pivot, snap and its
395
+ * values) in the document's `header` (the editor's own, Blender's) or on the `bar` after the
396
+ * tools (Unreal's row: world/local, the snap values; Godot's: local space, snap).
397
+ *
398
+ * Whether the zoom and pan cluster and the camera readout are drawn at all is the view's
399
+ * (`overlays.navigationControls`, `overlays.cameraReadout`), not the look's.
400
+ */
401
+ export interface StageChromeContribution {
402
+ readonly bar?: 'strip' | 'pills';
403
+ readonly viewName?: 'text' | 'menu' | 'gizmo' | 'bar';
404
+ readonly tools?: 'shelf' | 'bar-start' | 'bar-end';
405
+ readonly display?: 'corner' | 'bar-start' | 'bar-end';
406
+ readonly transformControls?: 'header' | 'bar';
407
+ }
408
+
409
+ /**
410
+ * THE STAGE'S CONTROLS IN THE TARGET'S OWN WORDS: what the shading menu calls each mode
411
+ * (Unreal's `Lit`, Unity's `Shaded`, Godot's `Display Normal` for the editor's `solid`) and
412
+ * what the helpers menu is called, drawn as a word where named (Unreal's `Show`, Unity's
413
+ * `Gizmos`). A mode or menu the look does not name keeps the editor's own word and mark.
414
+ */
415
+ export interface StageWordsContribution {
416
+ readonly shading?: Readonly<Partial<Record<StageShadingModeId, string>>>;
417
+ readonly helpers?: string;
418
+ }
419
+ /** The stage's shading modes, by id (`@volter/editor-threejs` `viewport-shading`). */
420
+ export type StageShadingModeId =
421
+ | 'solid'
422
+ | 'clay'
423
+ | 'unlit'
424
+ | 'wireframe'
425
+ | 'matcap'
426
+ | 'normals'
427
+ | 'overdraw'
428
+ | 'preview'
429
+ | 'rendered';
430
+
431
+ /**
432
+ * A glyph's CATEGORY — the colour channel a set may carry per glyph, painted
433
+ * as `var(--volter-category-<tone>, currentColor)` so a palette that names no
434
+ * `color.category` group paints the glyph exactly as a monochrome one.
435
+ *
436
+ * The vocabulary is Blender's Properties-tab rail read as data: that rail
437
+ * groups by HUE, and at 14 px the hue is what tells one tab from another
438
+ * (`properties-object.png` — the shapes are near-indistinguishable). Each
439
+ * member is a MEASURED ink; see `EditorTheme.color.category` for the values
440
+ * and their source coordinates.
441
+ */
442
+ export type IconCategoryTone =
443
+ | 'object'
444
+ | 'modifier'
445
+ | 'material'
446
+ | 'tool'
447
+ | 'operator'
448
+ | 'data'
449
+ // Blender's SCENE group and its COLLECTION, added 2026-09-19 (WORK.md
450
+ // §Blender in the tab is Blender, "Inspection parity", I2 decision 3). They
451
+ // are not a reading of the rail: every Blender icon declares its group in
452
+ // `UI_icons.hh` through a `DEF_ICON_<GROUP>` macro, and
453
+ // `interface_icons.cc:126-132` maps those onto the theme members
454
+ // `interface/resources.cc:1059-1078` reads — `DEF_ICON_SCENE` →
455
+ // `TH_ICON_SCENE` → `.tui.icon_scene` #cccccc
456
+ // (`userdef_default_theme.c:272`), `DEF_ICON_COLLECTION` →
457
+ // `.tui.icon_collection` #ffffff (`:273`). The Render, Output, View Layer
458
+ // and Scene tabs are `DEF_ICON_SCENE`; the Collection tab is
459
+ // `DEF_ICON_COLLECTION(GROUP)` (`UI_icons.hh:248`).
460
+ | 'scene'
461
+ | 'collection'
462
+ // The SELECTION TOOLS' marquee, which Blender bakes into their tool icons' geometry
463
+ // (`ops.generic.select_box`) rather than reading from a theme member.
464
+ | 'select';
465
+
466
+ /**
467
+ * An ICON SET a style bundle carries: glyphs keyed by the Font Awesome icon
468
+ * name each editor site names (`plus`, `xmark`, `trash`, `gear`, …), each one
469
+ * `path` painted with `currentColor` in its `viewBox` (default `0 0 16 16`).
470
+ * A set may be partial — a glyph it lacks paints the editor's own.
471
+ *
472
+ * `tone` and `tonedPath` are the COLOUR channel, and they are two shapes of
473
+ * one idea because Blender draws two shapes of it:
474
+ *
475
+ * - `tone` alone tints the WHOLE glyph — the Properties rail's category
476
+ * tabs and the outliner's type glyphs, where the mark IS the category.
477
+ * - `tone` with `tonedPath` splits the glyph in two: `path` stays neutral
478
+ * (`currentColor`) and `tonedPath` takes the category ink. That is
479
+ * exactly how an operator mark reads in `modeling-edit-none.png` — the
480
+ * cube stays white and only the OPERATED element is tinted — and a
481
+ * single per-glyph tone cannot express it.
482
+ *
483
+ * Either way an explicit `tone` prop at the SITE wins, so a de-emphasized
484
+ * (`dim`/`muted`) glyph stays de-emphasized.
485
+ */
486
+ export interface IconSetContribution {
487
+ readonly id: string;
488
+ readonly title: string;
489
+ readonly glyphs: Readonly<
490
+ Record<
491
+ string,
492
+ {
493
+ readonly viewBox?: string;
494
+ readonly path: string;
495
+ readonly tone?: IconCategoryTone;
496
+ readonly tonedPath?: string;
497
+ }
498
+ >
499
+ >;
500
+ }
501
+
502
+ export interface StyleContribution {
503
+ readonly id: string;
504
+ readonly title: string;
505
+ /** A palette id the editor knows — a built-in, or the `palette` document
506
+ * this contribution carries. */
507
+ readonly paletteId: string;
508
+ /** A material id the editor knows — `classic`, `glass`, or the `material`
509
+ * this contribution carries. */
510
+ readonly materialId: string;
511
+ readonly material?: MaterialContribution;
512
+ /** The icon set this bundle applies — the `icons` it carries, or one the
513
+ * editor knows. Absent: the editor's own glyphs. */
514
+ readonly iconSetId?: string;
515
+ readonly icons?: IconSetContribution;
516
+ /** A v3 palette DOCUMENT (the same shape a person imports), registered into
517
+ * the editor's theme library with this bundle. */
518
+ readonly palette?: unknown;
519
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What every finder answers with.
3
+ *
4
+ * A finder is a PURE function over project source/files with explicit inputs
5
+ * and outputs — it never reads a filesystem, never fetches, and never consults
6
+ * ambient state. What it cannot answer it says so in {@link FinderResult.notes}
7
+ * rather than degrading silently: "has none" and "nobody could look" are
8
+ * different facts, and only the second is a defect.
9
+ */
10
+
11
+ import type { DocumentEntry } from '../adapter-module';
12
+
13
+ export interface FinderResult {
14
+ /** Scene-table entries this finder produced, in discovery order. */
15
+ readonly entries: readonly DocumentEntry[];
16
+ /**
17
+ * The entry this finder's own answer makes the default, when its answer
18
+ * settles it UNAMBIGUOUSLY (one candidate). A finder never picks between
19
+ * candidates — that is the adapter author's declaration to make, and an
20
+ * absent default stays the honest fact that none was declared.
21
+ */
22
+ readonly default?: string;
23
+ /**
24
+ * Loud, non-fatal diagnostics: a selection identifier that is not in the
25
+ * source, an entrypoint whose bytes could not be read. Every note names the
26
+ * file it is about. The host surfaces these; it never swallows them.
27
+ */
28
+ readonly notes: readonly string[];
29
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * THE FINDER REGISTRY — the open set of finders an adapter may SELECT by
3
+ * name (ARCHITECTURE-CORE §The editor protocol, "Documents, not scenes").
4
+ *
5
+ * A finder is a pure function from the project's gathered source to document
6
+ * entries, plus the Zod schema of its own parameters. The engine registers
7
+ * its two here (`./index.ts`); a contribution registers more through the
8
+ * same door. `adapter-module.ts` validates only the selection ENVELOPE; the
9
+ * parameters are checked against the finder's schema when the loader runs
10
+ * it — an unregistered name or a bad parameter refuses loudly, naming both.
11
+ */
12
+
13
+ import type { ZodType } from 'zod';
14
+ import type { FinderSelection } from '../adapter-module';
15
+ import type { FinderResult } from './finder-result';
16
+
17
+ /** What a registration's selection type must carry: the name. A finder's own
18
+ * params interface satisfies this without an index signature. */
19
+ export type FinderSelectionEnvelope = { readonly finder: string };
20
+
21
+ export interface FinderRegistration<
22
+ S extends FinderSelectionEnvelope = FinderSelection,
23
+ I = unknown,
24
+ > {
25
+ readonly name: S['finder'];
26
+ /** The selection's full shape, `finder` included; `.strict()` by the
27
+ * same rule as every other adapter table. */
28
+ readonly schema: ZodType<S>;
29
+ readonly run: (selection: S, input: I) => FinderResult;
30
+ /**
31
+ * WHAT OF THE MATCHED PROJECT IT READS. `sources` (the default) is the text of every file
32
+ * under the selection's `include` globs (`input.sources`) and their paths; `files` is the
33
+ * paths alone (`input.files`). A finder over BINARY documents says `files`, so the host never
34
+ * fetches a file it could not hand over as text: `@volter/editor-blender`'s
35
+ * `modelsFromBlendFiles` lists `.blend` files, and reading each one through the text-source
36
+ * route asked a multi-megabyte binary of a route capped at 2 MiB of text — a
37
+ * `413 Payload Too Large` per model per resolve, for bytes nothing would have read.
38
+ */
39
+ readonly reads?: 'sources' | 'files';
40
+ }
41
+
42
+ const registry = new Map<string, FinderRegistration>();
43
+
44
+ /** Register a finder. A second registration under one name replaces the
45
+ * first (a module re-evaluated on save), never throws. */
46
+ export function registerFinder<S extends FinderSelectionEnvelope, I>(
47
+ registration: FinderRegistration<S, I>,
48
+ ): () => void {
49
+ const entry = registration as unknown as FinderRegistration;
50
+ registry.set(registration.name, entry);
51
+ return () => {
52
+ if (registry.get(registration.name) === entry) registry.delete(registration.name);
53
+ };
54
+ }
55
+
56
+ /**
57
+ * What a CONTRIBUTION exports to register a finder (ARCHITECTURE-CORE §The
58
+ * project model): a plain object a project module `editor/contributions/<name>.finder.ts`
59
+ * exports as `finder`, handed by the HOST to {@link registerContributedFinder}.
60
+ * A selection may carry `include` globs; the host then supplies the matching
61
+ * project sources on `input.sources`, which is how a finder reads the
62
+ * project without a file system.
63
+ */
64
+ export type FinderContribution = FinderRegistration<FinderSelection, unknown>;
65
+
66
+ /** Register a contribution's finder after checking its shape by name. */
67
+ export function registerContributedFinder(value: unknown, source: string): () => void {
68
+ const record = (value ?? {}) as Partial<FinderContribution>;
69
+ const problems: string[] = [];
70
+ if (typeof record.name !== 'string' || record.name.length === 0)
71
+ problems.push('`name` must be a non-empty string');
72
+ const schema = record.schema as { safeParse?: unknown } | undefined;
73
+ if (!schema || typeof schema.safeParse !== 'function')
74
+ problems.push('`schema` must be a Zod schema');
75
+ if (typeof record.run !== 'function') problems.push('`run(selection, input)` must be a function');
76
+ if (record.reads !== undefined && record.reads !== 'sources' && record.reads !== 'files')
77
+ problems.push('`reads`, when given, must be `sources` or `files`');
78
+ if (problems.length > 0) throw new Error(`${source}: not a finder — ${problems.join('; ')}`);
79
+ return registerFinder(record as FinderContribution);
80
+ }
81
+
82
+ /** Whether the named finder reads its files' TEXT ({@link FinderRegistration.reads}). An
83
+ * unregistered name answers false: its selection refuses by name when it runs
84
+ * ({@link runRegisteredFinder}), so text fetched for it would be read by nothing — and a
85
+ * contribution still loading is exactly when a paths-only finder is not registered yet. */
86
+ export function finderReadsSources(name: string): boolean {
87
+ const registration = registry.get(name);
88
+ return registration !== undefined && registration.reads !== 'files';
89
+ }
90
+
91
+ /** Every registered finder name, in registration order. */
92
+ export function registeredFinderNames(): readonly string[] {
93
+ return [...registry.keys()];
94
+ }
95
+
96
+ /** Validate a selection against its finder's schema and run it. */
97
+ export function runRegisteredFinder<I>(selection: FinderSelection, input: I): FinderResult {
98
+ const registration = registry.get(selection.finder);
99
+ if (!registration) {
100
+ const known = registeredFinderNames();
101
+ throw new Error(
102
+ `Unknown finder ${JSON.stringify(selection.finder)} — registered: ${
103
+ known.length > 0 ? known.map((n) => `"${n}"`).join(', ') : 'none'
104
+ }`,
105
+ );
106
+ }
107
+ const parsed = registration.schema.safeParse(selection);
108
+ if (!parsed.success) {
109
+ throw new Error(
110
+ `Finder ${JSON.stringify(selection.finder)}: ${parsed.error.issues
111
+ .map((issue) => `${issue.path.join('.') || '(selection)'}: ${issue.message}`)
112
+ .join('; ')}`,
113
+ );
114
+ }
115
+ return registration.run(parsed.data, input);
116
+ }