@volter/blender-engine 0.1.8 → 0.1.10

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.
@@ -214,33 +214,37 @@ export function aimLight(light: THREE.Light, node: THREE.Object3D): void {
214
214
  * parented to the camera. `smooth` is the wrap factor; see
215
215
  * {@link ViewportLighting} for how it becomes three's lambert.
216
216
  *
217
- * `specular_color` is deliberately NOT carried: three's own BRDF produces the
218
- * material's highlight from the same light colour, and a second colour for it
219
- * would be a second specular model beside the one already shading the model.
217
+ * `specular` is each light's `specular_color`, which Blender's own shading function uses
218
+ * (`blender-workbench-material.ts`); the three.js light rig below does not.
220
219
  */
221
- const SOLID_LIGHTS: readonly {
220
+ export const SOLID_LIGHTS: readonly {
222
221
  readonly direction: readonly [number, number, number];
223
222
  readonly diffuse: readonly [number, number, number];
223
+ readonly specular: readonly [number, number, number];
224
224
  readonly smooth: number;
225
225
  }[] = [
226
226
  {
227
227
  direction: [-0.352546, 0.170931, -0.920051],
228
228
  diffuse: [0.033103, 0.033103, 0.033103],
229
+ specular: [0.266761, 0.266761, 0.266761],
229
230
  smooth: 0.52662,
230
231
  },
231
232
  {
232
233
  direction: [-0.408163, 0.346939, 0.844415],
233
234
  diffuse: [0.521083, 0.538226, 0.538226],
235
+ specular: [0.59903, 0.59903, 0.59903],
234
236
  smooth: 0.0,
235
237
  },
236
238
  {
237
239
  direction: [0.521739, 0.826087, 0.212999],
238
240
  diffuse: [0.038403, 0.034357, 0.04953],
241
+ specular: [0.106102, 0.125981, 0.158523],
239
242
  smooth: 0.478261,
240
243
  },
241
244
  {
242
245
  direction: [0.624519, -0.562067, -0.542269],
243
246
  diffuse: [0.090838, 0.08208, 0.072255],
247
+ specular: [0.106535, 0.084771, 0.06608],
244
248
  smooth: 0.2,
245
249
  },
246
250
  ];
@@ -260,7 +264,7 @@ const SOLID_LIGHTS: readonly {
260
264
  * and AgX within 3, so the curve is NOT what row 1's 13-vs-30 spread was made
261
265
  * of and the stage keeps its own operator).
262
266
  *
263
- * THE ROUNDS, on a `model-editor create` scaffold driven through `vgai
267
+ * THE ROUNDS, on a `model-editor create` scaffold driven through `volter-model-editor
264
268
  * screenshot editor`, 30x30 means (std 0 — every patch inside one flat face)
265
269
  * of the factory cube's three visible faces against Blender 5.2's own frame,
266
270
  * (141,143,145) top / (129,131,131) left / (111,112,113) front:
@@ -282,6 +286,17 @@ const SOLID_LIGHTS: readonly {
282
286
  * no such coupling, nor a second colour per light to carry
283
287
  * `specular_color`. Closing that is a shader of our own, not a light rig.
284
288
  */
289
+ /*
290
+ * SURFACES NO LONGER TAKE THIS RIG IN SOLID: they are drawn by Blender's own function
291
+ * (`blender-workbench-material.ts`), which reproduces Blender on the faces a single gain could
292
+ * not (a face square to the view: 158-160 against Blender's 161, where this rig gave 143). The rig
293
+ * still lights what that function does not draw (volume boxes). WHAT THE MEASUREMENT SHOWED about
294
+ * the rounds above: the three-face reference frames match Blender's function under the STANDARD
295
+ * view transform (function: 147/136/116 against 144/132/113; under AgX, 151/142/126), so they were
296
+ * made under Standard, and the gain was absorbing a view-transform difference, not a shading one.
297
+ * Blender's factory transform is AgX (`--factory-startup`: `view_transform` AgX), which the stage
298
+ * uses.
299
+ */
285
300
  const SOLID_STUDIO_GAIN = 0.69;
286
301
 
287
302
  /**
@@ -32,6 +32,16 @@ import { type CompiledGraph, compileMaterialGraph, graphSeeThrough, materialGrap
32
32
  import {worldMedium, WorldVolumePass} from './blender-world-volume';
33
33
  import { BlenderTextureSamplers } from './blender-texture-samplers';
34
34
  import { WeightOverlay, weightsSchema } from './blender-runtime-weights';
35
+ import { CursorOverlay, type CursorPlacement, cursorSchema } from './blender-runtime-cursor';
36
+ import { emptySchema, ExtrasOverlay } from './blender-runtime-extras';
37
+ import {
38
+ type BlenderCameraView,
39
+ blenderCameraView,
40
+ CAMERA_ZOOM,
41
+ cameraDataSchema,
42
+ panCameraView,
43
+ } from './blender-runtime-camera-view';
44
+ import { DEFAULT_VIEWPORT_DISPLAY, workbenchMaterial } from './blender-workbench-material';
35
45
 
36
46
  const scalar = z.number().finite();
37
47
  const point = z.tuple([scalar, scalar, scalar]);
@@ -255,7 +265,11 @@ function loadPngTexture(png: Uint8Array): { texture: THREE.Texture; ready: Promi
255
265
  // `three/src/textures/Texture.js:274` (0.180.0), "this property has no
256
266
  // effect when using `ImageBitmap`. You need to configure the flip on bitmap
257
267
  // creation instead."
258
- const decoding = createImageBitmap(blob, { imageOrientation: 'flipY' })
268
+ //
269
+ // Its alpha is asked for here too: the browser's `default` premultiplies (Chrome's does), and
270
+ // every reader of this texture takes the file's own texels, straight unless the image says it
271
+ // stores them premultiplied, which the reader then undoes.
272
+ const decoding = createImageBitmap(blob, { imageOrientation: 'flipY', premultiplyAlpha: 'none' })
259
273
  .then((bitmap) => {
260
274
  if (disposed) {
261
275
  bitmap.close();
@@ -422,6 +436,12 @@ const materialSchema = z
422
436
  * `blender-node-graph.ts`. The constants above still apply to every
423
437
  * input the graph does not carry. */
424
438
  graph: materialGraphSchema.optional(),
439
+ /** The material's VIEWPORT DISPLAY (`Material.diffuse_color`, `roughness`, `metallic`), what
440
+ * Blender's Solid shading colours it by (`blender-workbench-material.ts`). */
441
+ viewport: z
442
+ .object({ color: z.tuple([scalar, scalar, scalar, scalar]), roughness: scalar, metallic: scalar })
443
+ .strict()
444
+ .optional(),
425
445
  })
426
446
  .strict();
427
447
  /** THE FRAME CONTRACT, and the reason it is exported: the C++ export door
@@ -503,9 +523,84 @@ export const frameSchema = z
503
523
  /** The active object's active vertex group, per vertex
504
524
  * (`blender-runtime-weights.ts`); null when nothing is painted. */
505
525
  weights: weightsSchema.nullable().default(null),
526
+ /** `Scene.cursor.matrix` (`blender-runtime-cursor.ts`); absent from an
527
+ * engine that does not report it. */
528
+ cursor: cursorSchema.optional(),
529
+ /** What a camera view looks through (`session.py`): the scene's camera, the view layer's
530
+ * cameras in order, and the render's shape with its pixel aspect. */
531
+ camera_view: z
532
+ .object({
533
+ scene_camera: z.string().nullable(),
534
+ view_layer_cameras: z.array(z.string()),
535
+ aspect: z.number().finite().positive(),
536
+ })
537
+ .optional(),
538
+ /** Each light datablock's clip start and cut-off distance (`blender-runtime-extras.ts`). */
539
+ light_distances: z.record(z.string(), z.tuple([z.number().finite(), z.number().finite()])).default({}),
540
+ /** Each empty's display type and size (`blender-runtime-extras.ts`). */
541
+ empties: z.record(z.string(), emptySchema).default({}),
542
+ /** The 3D View the file saved (`session.py` `_saved_view`), null when it holds none. */
543
+ view: z
544
+ .object({
545
+ location: z.tuple([z.number(), z.number(), z.number()]),
546
+ rotation: z.tuple([z.number(), z.number(), z.number(), z.number()]),
547
+ distance: z.number().finite(),
548
+ perspective: z.enum(['PERSP', 'ORTHO', 'CAMERA']),
549
+ lens: z.number().finite().positive().default(50),
550
+ })
551
+ .nullable()
552
+ .optional(),
553
+ /** The viewport's subject line as Blender composes it (`draw_selected_name`), less the
554
+ * frame and its marker, with the scene's markers in list order. */
555
+ subject: z
556
+ .object({
557
+ frame: z.number().int(),
558
+ body: z.string(),
559
+ markers: z.array(z.tuple([z.number().int(), z.string()])),
560
+ })
561
+ .optional(),
562
+ /** `Scene.unit_settings`: the length system and scale the grid's step is named in. */
563
+ units: z
564
+ .object({ system: z.enum(['NONE', 'METRIC', 'IMPERIAL']), scale_length: z.number().finite() })
565
+ .optional(),
506
566
  })
507
567
  .strict();
508
568
  type Frame = z.infer<typeof frameSchema>;
569
+
570
+ /** `BKE_scene_find_marker_name`: the list walked from both ends at once, the front one asked
571
+ * first, so of several markers on one frame the answer is not simply the first. */
572
+ function markerAt(markers: readonly (readonly [number, string])[], frame: number): string | null {
573
+ for (let front = 0, back = markers.length - 1; front < markers.length && back >= 0; front++, back--) {
574
+ if (markers[front]![0] === frame) return markers[front]![1];
575
+ if (front === back) break;
576
+ if (markers[back]![0] === frame) return markers[back]![1];
577
+ }
578
+ return null;
579
+ }
580
+
581
+ /** Blender's length units, smallest first, with their display names (`unit.cc`
582
+ * `buMetricLenDef`, `buImperialLenDef`; nano- and picometres are compiled out there). */
583
+ const LENGTH_UNITS: Readonly<Record<'METRIC' | 'IMPERIAL', readonly (readonly [number, string])[]>> = {
584
+ METRIC: [
585
+ [0.000001, 'Micrometers'],
586
+ [0.001, 'Millimeters'],
587
+ [0.01, 'Centimeters'],
588
+ [0.1, '10 Centimeters'],
589
+ [1, 'Meters'],
590
+ [10, '10 Meters'],
591
+ [100, '100 Meters'],
592
+ [1000, 'Kilometers'],
593
+ ],
594
+ IMPERIAL: [
595
+ [0.0000254, 'Thou'],
596
+ [0.0254, 'Inches'],
597
+ [0.3048, 'Feet'],
598
+ [0.9144, 'Yards'],
599
+ [20.1168, 'Chains'],
600
+ [201.168, 'Furlongs'],
601
+ [1609.344, 'Miles'],
602
+ ],
603
+ };
509
604
  /** One render's two poses, in Blender's own (Z-up) frame. */
510
605
  export interface PhotographRecord {
511
606
  /** `scene.camera.matrix_world`, as `session.py::_photograph` sent it. */
@@ -539,7 +634,7 @@ export class BlenderRuntimeView {
539
634
  * visibility belongs to the viewport's Helpers menu rather than to this
540
635
  * presenter, and the door for that is the host's own — a document hands
541
636
  * each group to its stage's `setHelper(kind, object)`
542
- * (`@vgai/editor-sdk/host`, `EditorHostStage`), which marks it
637
+ * (`@volter/editor-sdk/host`, `EditorHostStage`), which marks it
543
638
  * `editorHelper`, keeps it out of the hierarchy and the raycast, and turns
544
639
  * it on and off with the kind's checkbox. `blender-runtime.document.tsx`
545
640
  * is where they are handed over.
@@ -550,13 +645,34 @@ export class BlenderRuntimeView {
550
645
  */
551
646
  private readonly armatureOverlay = new ArmatureOverlay();
552
647
  private readonly weightOverlay = new WeightOverlay();
648
+ private readonly cursorOverlay = new CursorOverlay();
649
+ /** Blender's overlay extras: cameras, lights and empties (`blender-runtime-extras.ts`). */
650
+ private readonly extrasOverlay = new ExtrasOverlay();
553
651
  /** The two groups the stage is handed: the Helpers menu owns THEIR
554
652
  * `visible`, and the inner group is what a RENDER stands down (an overlay
555
653
  * is modeling chrome and never appears in a photograph — the same
556
654
  * distinction `applyVisibility` draws for the scene's own objects). */
557
655
  private readonly armatureRoot = new THREE.Group();
558
656
  private readonly weightRoot = new THREE.Group();
657
+ private readonly cursorRoot = new THREE.Group();
658
+ private readonly cameraExtrasRoot = new THREE.Group();
659
+ private readonly lightExtrasRoot = new THREE.Group();
660
+ private readonly emptyExtrasRoot = new THREE.Group();
559
661
  private rendered = false;
662
+ /** The camera the VIEWPORT is held in render lighting through (Blender's Rendered shading),
663
+ * or null when the viewport shows modeling lighting. See {@link holdRendered}. */
664
+ private heldRendered: (() => THREE.Camera) | null = null;
665
+ /** What the World was last composed for ({@link worldKeyFor}), or null when it is cleared:
666
+ * composing rebuilds its textures, so it happens only when the key changes. */
667
+ private worldApplied: string | null = null;
668
+ /** The frame's World, as a key, taken once per frame. */
669
+ private worldKey = 'null';
670
+ /** A render photograph is being taken: the one state in which overlays stand down. */
671
+ private capturing = false;
672
+ /** The stage draws Blender's Solid: surfaces wear Blender's own shading function
673
+ * (`blender-workbench-material.ts`) over their viewport display. */
674
+ private workbench = false;
675
+ private readonly workbenchMaterials = new Map<string, THREE.Material>();
560
676
  private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72 });
561
677
  /** Base Color images, by image name -- ONE texture per image however many
562
678
  * materials read it, and the cache OWNS it: a material points at one and
@@ -676,6 +792,114 @@ export class BlenderRuntimeView {
676
792
  * allows an active object that is not selected, and the Properties editor
677
793
  * follows the ACTIVE one while the outline follows the selected set.
678
794
  */
795
+ /**
796
+ * WHERE BLENDER OPENS THIS FILE: its saved 3D View in the stage's frame — the pivot, the
797
+ * direction from the pivot to the eye (the view's +Z, `view_rotation` turning view space into
798
+ * the world: RNA's inverse of `viewquat`), the screen's up (the view's +Y), the distance and
799
+ * the projection. A view saved through the scene camera opens in perspective along the same
800
+ * axis: looking through a camera is not a view this stage has. Null before a frame or when
801
+ * the file saved no 3D View.
802
+ */
803
+ savedView(): {
804
+ readonly target: readonly [number, number, number];
805
+ readonly direction: readonly [number, number, number];
806
+ readonly up: readonly [number, number, number];
807
+ readonly distance: number;
808
+ readonly projection: 'perspective' | 'orthographic';
809
+ readonly lens: number;
810
+ } | null {
811
+ const saved = this.frame?.view;
812
+ if (!saved) return null;
813
+ const [w, x, y, z] = saved.rotation;
814
+ const rotation = new THREE.Quaternion(x, y, z, w);
815
+ const target = new THREE.Vector3(...saved.location).applyMatrix4(this.root.matrix);
816
+ const axis = (v: THREE.Vector3) => v.applyQuaternion(rotation).applyMatrix4(this.root.matrix).normalize();
817
+ return {
818
+ target: target.toArray(),
819
+ direction: axis(new THREE.Vector3(0, 0, 1)).toArray(),
820
+ up: axis(new THREE.Vector3(0, 1, 0)).toArray(),
821
+ distance: saved.distance,
822
+ projection: saved.perspective === 'ORTHO' ? 'orthographic' : 'perspective',
823
+ lens: saved.lens,
824
+ };
825
+ }
826
+
827
+ /**
828
+ * THE CAMERA A CAMERA VIEW ENTERED NOW WOULD LOOK THROUGH, as `view_camera_exec` picks it:
829
+ * the scene's camera, else the active object when it is a camera, else the view layer's first
830
+ * camera; null when there is none (Blender's operator then does nothing).
831
+ */
832
+ cameraViewCamera(): string | null {
833
+ const frame = this.frame;
834
+ const view = frame?.camera_view;
835
+ if (!frame || !view) return null;
836
+ if (view.scene_camera !== null && frame.cameras[view.scene_camera]) return view.scene_camera;
837
+ if (frame.active !== null && frame.cameras[frame.active]) return frame.active;
838
+ return view.view_layer_cameras.find((name) => frame.cameras[name]) ?? null;
839
+ }
840
+
841
+ /** A pose in the stage's frame back in Blender's: the location and the rotation `(w, x, y, z)`. */
842
+ blenderPose(
843
+ position: readonly [number, number, number],
844
+ quaternion: readonly [number, number, number, number],
845
+ ): { readonly location: [number, number, number]; readonly rotation: [number, number, number, number] } {
846
+ const toBlender = new THREE.Matrix4().copy(this.root.matrix).invert();
847
+ const pose = new THREE.Matrix4()
848
+ .compose(new THREE.Vector3(...position), new THREE.Quaternion(...quaternion), new THREE.Vector3(1, 1, 1))
849
+ .premultiply(toBlender);
850
+ const location = new THREE.Vector3();
851
+ const rotation = new THREE.Quaternion();
852
+ pose.decompose(location, rotation, new THREE.Vector3());
853
+ return { location: location.toArray(), rotation: [rotation.w, rotation.x, rotation.y, rotation.z] };
854
+ }
855
+
856
+ /** The zoom a camera view opens at and keeps within (`blender-runtime-camera-view.ts`). */
857
+ readonly cameraViewZoom = CAMERA_ZOOM;
858
+ /** `view_move` in a camera view (`panCameraView`). */
859
+ readonly cameraViewPan = panCameraView;
860
+
861
+ /** `camera`'s view on a region (`blenderCameraView`), or null when it is not in the frame. */
862
+ cameraView(
863
+ camera: string,
864
+ region: { readonly width: number; readonly height: number },
865
+ zoom: number,
866
+ offset: readonly [number, number],
867
+ ): BlenderCameraView | null {
868
+ const frame = this.frame;
869
+ const data = cameraDataSchema.safeParse(frame?.cameras[camera]);
870
+ if (!frame?.camera_view || !data.success) return null;
871
+ return blenderCameraView(data.data, this.root.matrix, region, zoom, offset, frame.camera_view.aspect);
872
+ }
873
+
874
+ /** The viewport's subject line at `frame`, `(1) Collection | Cube`: the engine's body with
875
+ * the frame before it and that frame's marker after it; `frame` null is the scene's own
876
+ * `frame_current`. Null before a frame or from an engine that does not report it. */
877
+ subjectLine(playhead: number | null): string | null {
878
+ const subject = this.frame?.subject;
879
+ if (subject === undefined) return null;
880
+ const frame = playhead ?? subject.frame;
881
+ const marker = markerAt(subject.markers, frame);
882
+ return `(${frame})${subject.body}${marker === null ? '' : ` <${marker}>`}`;
883
+ }
884
+
885
+ /**
886
+ * THE GRID STEP'S NAME for an axis-aligned orthographic view drawn at `worldPerDevicePixel`,
887
+ * as `ED_view3d_grid_view_scale` picks it (`view3d_draw.cc`): the scene's length units from
888
+ * the smallest up, each scaled by `1 / scale_length`, and the first longer than six device
889
+ * pixels (`12 / (sizex * winmat[0][0])`) is named by its display name. A scene with no unit
890
+ * system names none. The overlay's grid Scale (`View3DOverlay.grid_scale`, `× 0.5`) has no
891
+ * home here and is taken as 1.
892
+ */
893
+ gridUnitName(worldPerDevicePixel: number): string | null {
894
+ const units = this.frame?.units;
895
+ if (units === undefined || units.system === 'NONE') return null;
896
+ const steps = LENGTH_UNITS[units.system];
897
+ const reach = 6 * worldPerDevicePixel;
898
+ const scale = 1 / (units.scale_length || 1);
899
+ const step = steps.find(([size]) => size * scale > reach) ?? steps[steps.length - 1]!;
900
+ return step[1];
901
+ }
902
+
679
903
  blenderSelection(): { readonly selected: readonly string[]; readonly active: string | null } {
680
904
  const frame = this.frame;
681
905
  if (frame === null) return { selected: [], active: null };
@@ -688,7 +912,7 @@ export class BlenderRuntimeView {
688
912
  /**
689
913
  * BLENDER'S MODE, PUBLISHED ON THE DOCUMENT'S OWN CONTEXT.
690
914
  *
691
- * `vgai.stage.mode` (U6's context key, `editor-host-door.ts`'s `stage()`) had
915
+ * `volter.stage.mode` (U6's context key, `editor-host-door.ts`'s `stage()`) had
692
916
  * no reporter at all: Edit Mesh went with the mesh kit and the Model document
693
917
  * had no mode strip, so every `when` clause that reads it was answering
694
918
  * `null`. The engine HAS a mode — `bpy.context.mode`, and `Object.mode` on the
@@ -767,6 +991,10 @@ export class BlenderRuntimeView {
767
991
  for (const [group, overlay] of [
768
992
  [this.armatureRoot, this.armatureOverlay.group],
769
993
  [this.weightRoot, this.weightOverlay.group],
994
+ [this.cursorRoot, this.cursorOverlay.group],
995
+ [this.cameraExtrasRoot, this.extrasOverlay.cameras],
996
+ [this.lightExtrasRoot, this.extrasOverlay.lights],
997
+ [this.emptyExtrasRoot, this.extrasOverlay.empties],
770
998
  ] as const) {
771
999
  group.matrixAutoUpdate = false;
772
1000
  group.matrix.copy(this.root.matrix);
@@ -775,6 +1003,13 @@ export class BlenderRuntimeView {
775
1003
  }
776
1004
  this.armatureRoot.name = 'BlenderBones';
777
1005
  this.weightRoot.name = 'BlenderWeights';
1006
+ this.cursorRoot.name = 'Blender3DCursor';
1007
+ this.cameraExtrasRoot.name = 'BlenderCameraExtras';
1008
+ // The extras are picked as the objects they draw (`userData.volterPicksAs` on each part).
1009
+ for (const root of [this.cameraExtrasRoot, this.lightExtrasRoot, this.emptyExtrasRoot])
1010
+ root.userData['volterPickable'] = true;
1011
+ this.lightExtrasRoot.name = 'BlenderLightExtras';
1012
+ this.emptyExtrasRoot.name = 'BlenderEmptyExtras';
778
1013
  }
779
1014
 
780
1015
  /**
@@ -806,9 +1041,130 @@ export class BlenderRuntimeView {
806
1041
  return [
807
1042
  { kind: 'skeletons', object: this.armatureRoot },
808
1043
  { kind: 'weights', object: this.weightRoot },
1044
+ // Blender's overlay popover's "3D Cursor" (`View3DOverlay.show_cursor`).
1045
+ { kind: 'cursor', object: this.cursorRoot },
1046
+ // The overlay's extras, under the Helpers menu's own kinds (Blender's "Extras").
1047
+ { kind: 'cameras', object: this.cameraExtrasRoot },
1048
+ { kind: 'lights', object: this.lightExtrasRoot },
1049
+ { kind: 'empties', object: this.emptyExtrasRoot },
809
1050
  ];
810
1051
  }
811
1052
 
1053
+ /**
1054
+ * WHERE SHIFT+RIGHT-CLICK PUTS THE 3D CURSOR, in Blender's frame, or null
1055
+ * when the click is not on the view the cursor was drawn in. The drawn
1056
+ * cursor moves at once; the engine's next frame confirms it.
1057
+ */
1058
+ placeCursor(clientX: number, clientY: number, target: EventTarget | null): CursorPlacement | null {
1059
+ const presented = new Set(this.objects.values());
1060
+ const placement = this.cursorOverlay.placementAt(clientX, clientY, target, this.root, (object) =>
1061
+ presented.has(object),
1062
+ );
1063
+ if (placement !== null) {
1064
+ this.cursorOverlay.show(placement);
1065
+ presenterChanged();
1066
+ }
1067
+ return placement;
1068
+ }
1069
+
1070
+ /**
1071
+ * BLENDER'S RENDERED SHADING: the viewport held in the lighting a render photographs with
1072
+ * ({@link setRendered}) — the scene's own lights, its World behind and around the model,
1073
+ * `hide_render` visibility and shadows — seen through `drawCamera()`, the camera the stage
1074
+ * draws with (orthographic in an orthographic view). `null` returns the viewport to modeling.
1075
+ * A render taken meanwhile ends back in this state rather than in modeling.
1076
+ */
1077
+ holdRendered(drawCamera: (() => THREE.Camera) | null): void {
1078
+ if (drawCamera === this.heldRendered) return;
1079
+ this.heldRendered = drawCamera;
1080
+ if (this.capturing) return;
1081
+ this.report(drawCamera === null ? this.applyRendered(false) : this.applyRendered(true, drawCamera()));
1082
+ }
1083
+
1084
+ /**
1085
+ * Re-apply the held render lighting if the draw camera or the World changed since it was
1086
+ * last applied. Cheap when nothing did; the stage calls it every frame it draws.
1087
+ */
1088
+ refreshRendered(): void {
1089
+ // A photograph in progress keeps its own state; it returns to this one when it ends.
1090
+ if (this.heldRendered === null || this.capturing) return;
1091
+ const camera = this.heldRendered();
1092
+ if (this.worldKeyFor(camera) === this.worldApplied) return;
1093
+ this.report(this.applyRendered(true, camera));
1094
+ }
1095
+
1096
+ /**
1097
+ * What the World is composed for: the camera, the frame's World, and for an orthographic
1098
+ * camera its direction too, which an orthographic backdrop is sampled along
1099
+ * (`WorldBackground.compose`).
1100
+ */
1101
+ private worldKeyFor(camera: THREE.Camera): string {
1102
+ const ortho = (camera as THREE.OrthographicCamera).isOrthographicCamera;
1103
+ // Coarse, so an orbit recomposes a handful of times rather than every frame.
1104
+ const turn = ortho ? camera.getWorldDirection(new THREE.Vector3()).toArray().map((v) => v.toFixed(1)).join(',') : '';
1105
+ return `${camera.uuid}:${turn}:${this.worldKey}`;
1106
+ }
1107
+
1108
+ private report(work: Promise<void>): void {
1109
+ void work.then(presenterChanged, (error: unknown) =>
1110
+ // biome-ignore lint/suspicious/noConsole: the editor console captures console.error session-wide; this is the report channel.
1111
+ console.error(`Rendered shading could not be applied: ${error instanceof Error ? error.message : String(error)}`),
1112
+ );
1113
+ }
1114
+
1115
+ /**
1116
+ * BLENDER'S SOLID SHADING ON OR OFF: while on, every presented surface is drawn by Blender's
1117
+ * Solid function over its material's viewport display; a render photograph, Material Preview
1118
+ * and Rendered draw the authored materials.
1119
+ */
1120
+ setWorkbench(on: boolean): void {
1121
+ if (on === this.workbench) return;
1122
+ this.workbench = on;
1123
+ this.applyWorkbench();
1124
+ presenterChanged();
1125
+ }
1126
+
1127
+ /** Each surface's material for the state: authored, or Solid's, derived from the frame every
1128
+ * time so a replaced node (the skin's) is never left wearing the other. */
1129
+ private applyWorkbench(): void {
1130
+ const solid = this.workbench && !this.rendered;
1131
+ const used = new Set<string>();
1132
+ for (const obj of this.frame?.objects ?? []) {
1133
+ if (obj.mesh === null || obj.volume) continue;
1134
+ const mesh = this.objects.get(obj.id) as THREE.Mesh | undefined;
1135
+ if (!mesh?.isMesh) continue;
1136
+ const slots: readonly (string | null)[] = obj.materials.length ? obj.materials : [null];
1137
+ const shown = slots.map((id) => {
1138
+ const authored = id === null ? this.fallback : (this.materials.get(id) ?? this.fallback);
1139
+ return solid ? this.workbenchFor(id, authored.side, used) : authored;
1140
+ });
1141
+ mesh.material = obj.materials.length ? shown : shown[0]!;
1142
+ }
1143
+ // Released as soon as no surface wears it: a dragged viewport colour makes one per value.
1144
+ for (const [key, material] of this.workbenchMaterials) {
1145
+ if (used.has(key)) continue;
1146
+ material.dispose();
1147
+ this.workbenchMaterials.delete(key);
1148
+ }
1149
+ }
1150
+
1151
+ private workbenchFor(id: string | null, side: THREE.Side, used: Set<string>): THREE.Material {
1152
+ const display = (id !== null ? this.frame?.materials[id]?.viewport : undefined) ?? DEFAULT_VIEWPORT_DISPLAY;
1153
+ const key = `${display.color.join(',')}|${display.roughness}|${display.metallic}|${side}`;
1154
+ used.add(key);
1155
+ let material = this.workbenchMaterials.get(key);
1156
+ if (!material) {
1157
+ material = workbenchMaterial(display, side);
1158
+ this.workbenchMaterials.set(key, material);
1159
+ }
1160
+ return material;
1161
+ }
1162
+
1163
+ /** Whether the viewport is held in render lighting (Blender's Rendered shading). */
1164
+ renderedHeld(): boolean {
1165
+ return this.heldRendered !== null;
1166
+ }
1167
+
812
1168
  /**
813
1169
  * Light the model the way a RENDER is lit — by the scene's own lights —
814
1170
  * rather than the way modeling is, by the studio key. `bpy.ops.render.render()`
@@ -816,6 +1172,14 @@ export class BlenderRuntimeView {
816
1172
  * distinction Blender draws between its solid viewport and a render.
817
1173
  */
818
1174
  async setRendered(rendered: boolean, camera?: THREE.Camera): Promise<void> {
1175
+ this.capturing = rendered;
1176
+ // A render ends by returning to what the viewport holds, which in Rendered shading is the
1177
+ // same render lighting, seen through the viewport's own camera again.
1178
+ if (!rendered && this.heldRendered !== null) return this.applyRendered(true, this.heldRendered());
1179
+ return this.applyRendered(rendered, camera);
1180
+ }
1181
+
1182
+ private async applyRendered(rendered: boolean, camera?: THREE.Camera): Promise<void> {
819
1183
  const extinction = (rendered ? worldMedium(this.frame?.world)?.extinction : null) ?? new THREE.Vector3();
820
1184
  for (const material of [...this.materials.values(), this.fallback]) applyWorldExtinction(material, extinction);
821
1185
  // An area light cannot be DRAWN until its lookup tables are uploaded, and a
@@ -827,9 +1191,13 @@ export class BlenderRuntimeView {
827
1191
  this.lighting.setRendered(rendered);
828
1192
  // The scene's world is what a render sees past the geometry AND its
829
1193
  // ambient light; modeling keeps the document's own backdrop and fill.
830
- if (rendered) this.world.apply(this.root, this.frame?.world ?? null, camera);
831
- else this.world.clear();
1194
+ // A photograph always composes its own; the viewport recomposes only when the key changed.
1195
+ const worldKey = rendered && camera ? this.worldKeyFor(camera) : null;
1196
+ if (!rendered) this.world.clear();
1197
+ else if (this.capturing || worldKey !== this.worldApplied) this.world.apply(this.root, this.frame?.world ?? null, camera);
1198
+ this.worldApplied = this.capturing ? null : worldKey;
832
1199
  this.applyVisibility();
1200
+ this.applyWorkbench();
833
1201
  this.applyShadows(rendered, camera);
834
1202
  // AFTER the applies, because they are what REGISTERS the work. Awaiting
835
1203
  // first made every one of these a no-op on the first render: `apply` had
@@ -925,9 +1293,68 @@ export class BlenderRuntimeView {
925
1293
  next.weights ? blenderMatrix(next.weights.object) : null,
926
1294
  );
927
1295
  if (weightWarning !== null) warnings.push(weightWarning);
1296
+ this.cursorOverlay.apply(next.cursor);
1297
+ this.applyExtras(next);
928
1298
  return warnings;
929
1299
  }
930
1300
 
1301
+ /** Pictures the extras asked for before they had decoded. */
1302
+ private readonly awaitedImages = new Set<string>();
1303
+ /** The camera a camera view is looking through, which the extras do not draw. */
1304
+ private lookingThrough: string | null = null;
1305
+
1306
+ /** Told by the document which camera its stage's camera view is looking through. */
1307
+ setCameraViewShowing(camera: string | null): void {
1308
+ if (this.lookingThrough === camera) return;
1309
+ this.lookingThrough = camera;
1310
+ if (this.frame) this.applyExtras(this.frame);
1311
+ presenterChanged();
1312
+ }
1313
+
1314
+ private applyExtras(next: Frame): void {
1315
+ const cameras: Record<string, z.infer<typeof cameraDataSchema>> = {};
1316
+ for (const [name, data] of Object.entries(next.cameras)) {
1317
+ const parsed = cameraDataSchema.safeParse(data);
1318
+ if (parsed.success) cameras[name] = parsed.data;
1319
+ }
1320
+ this.extrasOverlay.apply({
1321
+ lookingThrough: this.lookingThrough,
1322
+ lightDistances: next.light_distances,
1323
+ objects: next.objects.filter((object) => object.visible),
1324
+ active: next.active,
1325
+ cameras,
1326
+ lights: next.lights,
1327
+ empties: next.empties,
1328
+ sceneCamera: next.camera_view?.scene_camera ?? null,
1329
+ renderAspect: next.camera_view?.aspect ?? 1,
1330
+ presented: (name) => {
1331
+ const row = next.objects.find((object) => object.name === name);
1332
+ return row ? (this.objects.get(row.id) ?? null) : null;
1333
+ },
1334
+ image: (name) => {
1335
+ const held = this.textures.get(name);
1336
+ if (!held) return null;
1337
+ const bitmap = held.texture.image as { width?: number; height?: number } | null;
1338
+ const width = held.width || bitmap?.width || 0;
1339
+ const height = held.height || bitmap?.height || 0;
1340
+ // A picture still decoding is drawn again when it has decoded.
1341
+ if ((width === 0 || height === 0) && held.ready && !this.awaitedImages.has(name)) {
1342
+ this.awaitedImages.add(name);
1343
+ // A failed decode leaves the frame alone; the next frame asks again.
1344
+ held.ready.then(
1345
+ () => {
1346
+ this.awaitedImages.delete(name);
1347
+ if (this.frame) this.applyExtras(this.frame);
1348
+ presenterChanged();
1349
+ },
1350
+ () => this.awaitedImages.delete(name),
1351
+ );
1352
+ }
1353
+ return { texture: held.texture, width, height };
1354
+ },
1355
+ });
1356
+ }
1357
+
931
1358
  private applyVisibility(): void {
932
1359
  for (const obj of this.frame?.objects ?? []) {
933
1360
  const object = this.objects.get(obj.id);
@@ -942,8 +1369,12 @@ export class BlenderRuntimeView {
942
1369
  // is being looked at and stand down for the one capture. It is the INNER
943
1370
  // group that yields, never the group the Helpers menu owns — the two facts
944
1371
  // are separate and must not overwrite each other.
945
- this.armatureOverlay.group.visible = !this.rendered;
946
- this.weightOverlay.group.visible = !this.rendered;
1372
+ // Rendered SHADING keeps them, as Blender's viewport does; only the photograph drops them.
1373
+ this.armatureOverlay.group.visible = !this.capturing;
1374
+ this.weightOverlay.group.visible = !this.capturing;
1375
+ this.cursorOverlay.group.visible = !this.capturing;
1376
+ for (const group of [this.extrasOverlay.cameras, this.extrasOverlay.lights, this.extrasOverlay.empties])
1377
+ group.visible = !this.capturing;
947
1378
  }
948
1379
 
949
1380
  applyFrame(input: unknown) {
@@ -1335,6 +1766,10 @@ export class BlenderRuntimeView {
1335
1766
  this.objects.set(obj.id, object);
1336
1767
  }
1337
1768
  object.name = obj.name;
1769
+ // A camera, a light and an empty are drawn by the overlay's extras, selection colour and
1770
+ // all (`blender-runtime-extras.ts`); the editor's own marks for them stand down.
1771
+ if (obj.type === 'CAMERA' || obj.type === 'LIGHT' || obj.type === 'EMPTY') object.userData['volterOwnOverlay'] = true;
1772
+ else delete object.userData['volterOwnOverlay'];
1338
1773
  object.matrixAutoUpdate = false;
1339
1774
  object.matrix.set(...(obj.matrix.flat() as Parameters<THREE.Matrix4['set']>));
1340
1775
  if (obj.parent) {
@@ -1367,6 +1802,9 @@ export class BlenderRuntimeView {
1367
1802
  previous.dispose();
1368
1803
  }
1369
1804
  if (light.parent !== object) object.add(light);
1805
+ // Blender's overlay draws this light (`blender-runtime-extras.ts`); the editor's own
1806
+ // light helper stands down for it.
1807
+ light.userData['volterOwnOverlay'] = true;
1370
1808
  aimLight(light, object);
1371
1809
  this.lights.set(obj.light, light);
1372
1810
  }
@@ -1413,6 +1851,7 @@ export class BlenderRuntimeView {
1413
1851
  // last frame held 850 MB of main-thread heap this way, measured
1414
1852
  // 2026-09-13). Reuse checks compare the per-mesh signatures kept above.
1415
1853
  this.frame = { ...next, meshes: {}, volumes: {} };
1854
+ this.worldKey = JSON.stringify(next.world ?? null);
1416
1855
  // THE OVERLAYS, after the graph stands: the weight drawing is laid over
1417
1856
  // the presented mesh's own geometry and the bones over the armature
1418
1857
  // OBJECT's Blender matrix, so both need this frame's objects in place.
@@ -1421,6 +1860,10 @@ export class BlenderRuntimeView {
1421
1860
  this.frame = { ...this.frame, warnings: [...this.frame.warnings, ...overlayWarnings] };
1422
1861
  // Visibility is read off the frame, so it is applied once the frame stands.
1423
1862
  this.applyVisibility();
1863
+ this.applyWorkbench();
1864
+ // Rendered shading follows the scene: the frame's new materials, lights and shadows, and its
1865
+ // World when that changed.
1866
+ if (this.heldRendered !== null && !this.capturing) this.report(this.applyRendered(true, this.heldRendered()));
1424
1867
  // The model moved: whoever is READING the engine (the Properties sections
1425
1868
  // through the RNA door) re-reads now, with the new graph already standing.
1426
1869
  for (const listener of [...this.frameListeners]) listener();
@@ -1647,6 +2090,10 @@ export class BlenderRuntimeView {
1647
2090
  this.world.dispose();
1648
2091
  this.armatureOverlay.dispose();
1649
2092
  this.weightOverlay.dispose();
2093
+ this.cursorOverlay.dispose();
2094
+ this.extrasOverlay.dispose();
2095
+ for (const material of this.workbenchMaterials.values()) material.dispose();
2096
+ this.workbenchMaterials.clear();
1650
2097
  this.frame = null;
1651
2098
  this.retiredSessions.clear();
1652
2099
  this.submittedMeshes.clear();