@volter/blender-engine 0.1.8 → 0.1.9

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.
@@ -523,6 +523,10 @@ def draw_camera(obj):
523
523
  "shift_x": float(camera.shift_x),
524
524
  "shift_y": float(camera.shift_y),
525
525
  "matrix": [[float(v) for v in row] for row in obj.matrix_world],
526
+ # What the 3D View's camera view darkens outside the frame (`drawviewborder`).
527
+ "passepartout": float(camera.passepartout_alpha) if camera.show_passepartout else 0.0,
528
+ # The size the overlay draws the camera at (`Camera.display_size`, `cam->drawsize`).
529
+ "display_size": float(camera.display_size),
526
530
  }
527
531
 
528
532
 
@@ -732,6 +736,107 @@ def _refusal(node):
732
736
  return None
733
737
 
734
738
 
739
+
740
+ def _empty_display(obj):
741
+ """An empty's display (`overlay_empty.hh`): its type and size, and for an image empty the
742
+ picture, where it sits on the object (`empty_image_offset`), how it meets depth, which side
743
+ shows, in which projections, its opacity when it blends, the object colour the picture is
744
+ multiplied by (`ucolor`) and whether the image stores premultiplied alpha."""
745
+ display = {"display": obj.empty_display_type, "size": float(obj.empty_display_size)}
746
+ if obj.empty_display_type == "IMAGE":
747
+ display["image"] = {
748
+ "name": obj.data.name if obj.data is not None else None,
749
+ "offset": [float(v) for v in obj.empty_image_offset],
750
+ "depth": obj.empty_image_depth,
751
+ "side": obj.empty_image_side,
752
+ "perspective": bool(obj.show_empty_image_perspective),
753
+ "orthographic": bool(obj.show_empty_image_orthographic),
754
+ "axis_aligned": bool(obj.show_empty_image_only_axis_aligned),
755
+ "opacity": float(obj.color[3]) if obj.use_empty_image_alpha else None,
756
+ "tint": [float(v) for v in obj.color[:3]],
757
+ "premultiplied": obj.data is not None and obj.data.alpha_mode == "PREMUL",
758
+ }
759
+ return display
760
+
761
+
762
+ def _saved_view():
763
+ """The 3D View the file saved, which is where Blender opens it: the `Modeling` workspace's
764
+ (this editor's Model workspace is Blender's Modeling), else the first 3D View any screen
765
+ holds. `RegionView3D`'s pivot, rotation (view to world, `(w, x, y, z)`), distance and
766
+ projection (`PERSP`, `ORTHO`, or `CAMERA` for a view through the scene camera), and the
767
+ `View3D`'s lens."""
768
+ workspace = bpy.data.workspaces.get("Modeling")
769
+ screens = list(workspace.screens) if workspace is not None else []
770
+ screens.extend(bpy.data.screens)
771
+ for screen in screens:
772
+ for area in screen.areas:
773
+ if area.type != "VIEW_3D":
774
+ continue
775
+ space = area.spaces[0]
776
+ region = space.region_3d
777
+ return {
778
+ "lens": float(space.lens),
779
+ "location": [float(v) for v in region.view_location],
780
+ "rotation": [float(v) for v in region.view_rotation],
781
+ "distance": float(region.view_distance),
782
+ "perspective": region.view_perspective,
783
+ }
784
+ return None
785
+
786
+
787
+ def _subject_line(scene, view_layer):
788
+ """The 3D Viewport's second overlay line, as `draw_selected_name` (`view3d_draw.cc`) writes it,
789
+ less the two parts that follow the playhead: the tab draws `(frame)` and the marker on it
790
+ from the frame it is showing, which during playback is not `frame_current` (written once, on
791
+ pause). Here: in Object Mode (or with nothing active) the active collection and a bar, then
792
+ the active object; outside Object Mode its data's name; the active edit or pose bone, or the
793
+ active shape key (` (Soloed)` when pinned). The scene's markers go with it, in list order,
794
+ for `BKE_scene_find_marker_name`. Blender tints the line on a keyframe; that colour is not
795
+ carried, and the ` (Viewer)` suffix belongs to a viewer path this tab does not show."""
796
+ parts = []
797
+ ob = view_layer.objects.active
798
+ if ob is None or ob.mode == "OBJECT":
799
+ # `BKE_collection_ui_name_get`: the master collection reads "Scene Collection", which is
800
+ # also what its RNA name returns.
801
+ parts.append(" " + view_layer.active_layer_collection.collection.name)
802
+ if ob is not None:
803
+ parts.append(" |")
804
+ if ob is not None:
805
+ parts.append(" " + ob.name)
806
+ if ob.mode != "OBJECT" and ob.data is not None:
807
+ parts.append(" | " + ob.data.name)
808
+ if ob.type == "ARMATURE":
809
+ arm = ob.data
810
+ if ob.mode == "EDIT":
811
+ if arm.edit_bones.active is not None:
812
+ parts.append(" : " + arm.edit_bones.active.name)
813
+ elif ob.mode == "POSE" and arm.bones.active is not None and _bone_shown(arm.bones.active):
814
+ parts.append(" : " + arm.bones.active.name)
815
+ elif ob.type in ("MESH", "LATTICE", "CURVE"):
816
+ if ob.type == "MESH" and ob.mode == "WEIGHT_PAINT":
817
+ rig = ob.find_armature()
818
+ if rig is not None and rig.mode == "POSE":
819
+ bone = rig.data.bones.active
820
+ if bone is not None and _bone_shown(bone):
821
+ parts.append(" : " + bone.name)
822
+ key = ob.active_shape_key if getattr(ob.data, "shape_keys", None) is not None else None
823
+ if key is not None:
824
+ parts.append(" : " + key.name)
825
+ if ob.show_only_shape_key:
826
+ parts.append(" (Soloed)")
827
+ return {
828
+ "frame": int(scene.frame_current),
829
+ "body": "".join(parts),
830
+ "markers": [[int(m.frame), m.name] for m in scene.timeline_markers],
831
+ }
832
+
833
+
834
+ def _bone_shown(bone):
835
+ """`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
836
+ collections = getattr(bone, "collections", None)
837
+ return not collections or any(c.is_visible for c in collections)
838
+
839
+
735
840
  class _GraphRefusal(Exception):
736
841
  pass
737
842
 
@@ -1231,9 +1336,13 @@ class Session:
1231
1336
  """
1232
1337
  scene = bpy.context.scene
1233
1338
  graphs = material_graphs(scene)
1339
+ # An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
1340
+ # (`overlay_empty.hh` `image_sync`).
1341
+ empty_images = {obj.data.name for obj in scene.objects
1342
+ if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
1234
1343
  options = {"session": self.session, "evaluate": True, "known": self._known,
1235
1344
  "graph_materials": sorted(graphs),
1236
- "graph_images": sorted({i for g in graphs.values() for i in g["images"]}),
1345
+ "graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
1237
1346
  "graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
1238
1347
  # THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
1239
1348
  # an ordered stream of filesystem patches that is the whole
@@ -1288,14 +1397,55 @@ class Session:
1288
1397
  default_color = getattr(getattr(data, "color_attributes", None), "default_color_name", "")
1289
1398
  if default_color:
1290
1399
  row["default_color"] = default_color
1400
+ # THE VIEWPORT DISPLAY of each material -- what Blender's Solid shading colours a surface
1401
+ # by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
1402
+ # `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
1403
+ for entry in frame["materials"].values():
1404
+ material = bpy.data.materials.get(entry.get("name", ""))
1405
+ if material is not None:
1406
+ entry["viewport"] = {
1407
+ "color": [float(v) for v in material.diffuse_color],
1408
+ "roughness": float(material.roughness),
1409
+ "metallic": float(material.metallic),
1410
+ }
1291
1411
  frame["world"] = draw_world(scene)
1292
1412
  frame["cameras"] = {
1293
1413
  obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
1294
1414
  }
1295
1415
  frame["volumes"] = {}
1416
+ # THE CAMERA A CAMERA VIEW LOOKS THROUGH (`view3d.view_camera`: the scene's, else a
1417
+ # camera that is active, else the view layer's first) and the frame it draws, the
1418
+ # render's shape with its pixel aspect.
1419
+ render = scene.render
1420
+ frame["camera_view"] = {
1421
+ "scene_camera": scene.camera.name if scene.camera is not None else None,
1422
+ "view_layer_cameras": [o.name for o in bpy.context.view_layer.objects if o.type == "CAMERA"],
1423
+ "aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
1424
+ }
1425
+ # THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
1426
+ # operators place and snap, drawn by the tab as Blender's overlay does
1427
+ # (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
1428
+ # turn with the cursor's rotation in whichever `rotation_mode` it has.
1429
+ frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
1296
1430
  # THE OVERLAYS, after the door's frame stands: `_weights` reads the
1297
1431
  # mesh's own revision out of it (see there), so it cannot run before.
1298
1432
  view_layer = bpy.context.view_layer
1433
+ # THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
1434
+ # both from the scene, so they come from here (`_subject_line`).
1435
+ frame["subject"] = _subject_line(scene, view_layer)
1436
+ # A LIGHT'S DISTANCES, for the overlay's direction line (`overlay_light.hh`: from
1437
+ # `clipsta` to `att_dist` down the light's -Z, for a spot or an area light).
1438
+ frame["light_distances"] = {
1439
+ light.name: [float(light.shadow_buffer_clip_start), float(light.cutoff_distance)]
1440
+ for light in bpy.data.lights
1441
+ }
1442
+ # WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
1443
+ frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
1444
+ frame["view"] = _saved_view()
1445
+ frame["units"] = {
1446
+ "system": scene.unit_settings.system,
1447
+ "scale_length": float(scene.unit_settings.scale_length),
1448
+ }
1299
1449
  frame["armatures"] = _armatures(view_layer)
1300
1450
  frame["weights"] = _weights(scene, view_layer, frame, self._known)
1301
1451
  warnings = list(frame.get("warnings", ()))
@@ -4625,7 +4775,32 @@ def outliner_set(path, column, value):
4625
4775
  % (column, target.bl_rna.identifier, path))
4626
4776
 
4627
4777
 
4778
+ # REQUESTS THAT CAN REMOVE OBJECTS, watched: a removal is reported with its cause, because an
4779
+ # object that disappears without a named act is the loss this session must never hide. A scene
4780
+ # was once found emptied and saved over its file by a burst of three edits at boot, with nothing
4781
+ # recording which requests they were.
4782
+ _WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step")
4783
+
4784
+
4628
4785
  def dispatch(request):
4786
+ op = request.get("op")
4787
+ if op not in _WATCHED_OPS:
4788
+ return _dispatch_request(request)
4789
+ before = {o.name for o in bpy.data.objects}
4790
+ try:
4791
+ return _dispatch_request(request)
4792
+ finally:
4793
+ removed = before - {o.name for o in bpy.data.objects}
4794
+ if removed:
4795
+ cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
4796
+ if k in request}
4797
+ if op == "execute":
4798
+ cause["code"] = str(request.get("code", ""))[:160]
4799
+ _say("@@VGAI-WARN %s removed objects %s (%s); %d remain" % (
4800
+ op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
4801
+
4802
+
4803
+ def _dispatch_request(request):
4629
4804
  op = request.get("op")
4630
4805
  if op == "history-begin":
4631
4806
  HISTORY.begin()
@@ -823,15 +823,24 @@ class Compiler {
823
823
  if (extension === 'CLIP')
824
824
  body.push(`if (any(lessThan(${co}.xy, vec2(0.0))) || any(greaterThan(${co}.xy, vec2(1.0)))) { ${color} = vec4(0.0); ${alpha} = 0.0; }`);
825
825
  }
826
- // node_shader_gpu_tex_image's Color output, for three's straight-alpha
827
- // upload: cleared for ignored/packed/data alpha; premultiplied (and
828
- // cleared) when Alpha is used; straight otherwise.
826
+ // node_shader_gpu_tex_image's Color output over the file's own texels (straight, or
827
+ // premultiplied for a PREMUL image, as Blender's GPU texture holds them): alpha cleared for
828
+ // ignored, packed or data alpha; otherwise straight when Alpha is used, so a factor does
829
+ // not multiply it in twice, and premultiplied when it is not. Measured in Blender 5.2's
830
+ // EEVEE: a straight red texel at alpha 0.5 emits 0.502 with Alpha unused, 1.0 with it used.
831
+ // A PREMUL sRGB image differs: the sampler decodes the premultiplied bytes, `decode(r·a)`,
832
+ // where Blender's texture holds `linear(r)·a`, so its partly transparent texels read darker.
829
833
  const mode = prop<string>(node, 'image_alpha_mode');
830
834
  const alphaUsed = this.uses(key, 1);
831
- if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data') || !alphaUsed)
832
- body.push(`${outs([0])}.a = 1.0;`);
835
+ const c = outs([0]);
836
+ if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data'))
837
+ body.push(`${c}.a = 1.0;`);
838
+ else if (mode === 'PREMUL')
839
+ body.push(alphaUsed
840
+ ? `if (${c}.a != 0.0 && ${c}.a != 1.0) ${c}.rgb /= ${c}.a;`
841
+ : `${c}.a = 1.0;`);
833
842
  else
834
- body.push(`${outs([0])} = vec4(${outs([0])}.rgb * ${outs([0])}.a, 1.0);`);
843
+ body.push(alphaUsed ? `${c}.a = 1.0;` : `${c}.rgb *= ${c}.a;`);
835
844
  break;
836
845
  }
837
846
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * BLENDER'S CAMERA VIEW — `view3d.view_camera`, the view through a scene camera — as a window
3
+ * and a frame for a region, transcribed from `BKE_camera_params_from_view3d` (the `RV3D_CAMOB`
4
+ * branch), `BKE_camera_params_compute_viewplane` and `ED_view3d_calc_camera_border`.
5
+ *
6
+ * THE REGION sees the camera's sensor, fitted to the REGION's shape (`AUTO` fits its larger
7
+ * side), at a zoom of `1 / fac`, where `fac` is the view's `BKE_screen_view3d_zoom_to_fac(
8
+ * camzoom)`: 0.5 at the factory `camzoom` of 0, so the region spans twice the sensor. THE
9
+ * FRAME is the same camera fitted to the RENDER's shape at zoom 1, so at `fac` 0.5 it spans
10
+ * half the region along the fitted side. The lens shift moves both alike (`shift × zoom` then
11
+ * divided back out). THE PAN is Blender's own `camdx`/`camdy` (`view_move`: `camdx += -dx /
12
+ * (winx * 2 * fac)`, held to ±1), which moves the region's window by `2 · camdx · fac` of its
13
+ * width — so a zoom scales a panned frame's distance from the centre, as Blender's does.
14
+ *
15
+ * A panoramic camera is drawn as a perspective one.
16
+ */
17
+ import * as THREE from 'three';
18
+ import { z } from 'zod';
19
+
20
+ const scalar = z.number().finite();
21
+ /** `session.py`'s `draw_camera`. */
22
+ export const cameraDataSchema = z.object({
23
+ name: z.string(),
24
+ type: z.string(),
25
+ lens: scalar,
26
+ sensor_width: scalar,
27
+ sensor_height: scalar,
28
+ sensor_fit: z.enum(['AUTO', 'HORIZONTAL', 'VERTICAL']),
29
+ ortho_scale: scalar,
30
+ clip_start: scalar,
31
+ clip_end: scalar,
32
+ shift_x: scalar,
33
+ shift_y: scalar,
34
+ matrix: z.array(z.tuple([scalar, scalar, scalar, scalar])).length(4),
35
+ passepartout: scalar.default(0),
36
+ display_size: scalar.default(1),
37
+ });
38
+ export type CameraData = z.infer<typeof cameraDataSchema>;
39
+
40
+ /** `RV3D_CAMZOOM_MIN_FACTOR` .. `RV3D_CAMZOOM_MAX_FACTOR`, and `camzoom` 0's factor. */
41
+ export const CAMERA_ZOOM = { opening: 0.5, min: 0.1657359312880714853, max: 44.9852813742385702928 } as const;
42
+
43
+ /** Blender 5.2's default theme, read back from the installed Blender: the passepartout
44
+ * (`camera_passepartout`), the solid edge (the 3D View's `back`), the dashed one
45
+ * (`view_overlay`) and a locked view's outer box (`TH_REDALERT`, the error state). */
46
+ const THEME = { passepartout: '#000000', back: '#3d3d3d', overlay: '#000000', redalert: '#991616' } as const;
47
+
48
+ /** `view_move` in a camera view: the pan after the pointer moves `dx`, `dy` (fractions of the
49
+ * region, down and right positive) at zoom factor `zoom`. */
50
+ export function panCameraView(
51
+ offset: readonly [number, number],
52
+ zoom: number,
53
+ dx: number,
54
+ dy: number,
55
+ ): readonly [number, number] {
56
+ const clamp = (value: number) => Math.min(1, Math.max(-1, value));
57
+ return [clamp(offset[0] - dx / (2 * zoom)), clamp(offset[1] + dy / (2 * zoom))];
58
+ }
59
+
60
+ /** Half the window along each side, for a camera fitted to a `width` × `height` shape. */
61
+ function halfExtents(camera: CameraData, width: number, height: number, zoom: number): [number, number] {
62
+ const orthographic = camera.type === 'ORTHO';
63
+ const sensor = camera.sensor_fit === 'VERTICAL' ? camera.sensor_height : camera.sensor_width;
64
+ const horizontal = camera.sensor_fit === 'AUTO' ? width >= height : camera.sensor_fit === 'HORIZONTAL';
65
+ const half = ((orthographic ? camera.ortho_scale : sensor / camera.lens) / 2) * zoom;
66
+ return horizontal ? [half, (half * height) / width] : [(half * width) / height, half];
67
+ }
68
+
69
+ export interface BlenderCameraView {
70
+ readonly name: string;
71
+ readonly position: readonly [number, number, number];
72
+ readonly quaternion: readonly [number, number, number, number];
73
+ readonly projection: 'perspective' | 'orthographic';
74
+ readonly window: { readonly left: number; readonly right: number; readonly top: number; readonly bottom: number };
75
+ readonly near: number;
76
+ readonly far: number;
77
+ readonly frame: { readonly left: number; readonly top: number; readonly width: number; readonly height: number };
78
+ readonly passepartout: { readonly color: string; readonly opacity: number };
79
+ readonly border: { readonly solid: string; readonly dashed: string; readonly locked: string };
80
+ }
81
+
82
+ /**
83
+ * `camera`'s view on a region, with `toStage` the Blender → stage matrix. `zoom` is the view's
84
+ * `fac`; `offset` its `[camdx, camdy]`; `renderAspect` the render's width over its height,
85
+ * pixel aspect included.
86
+ */
87
+ export function blenderCameraView(
88
+ camera: CameraData,
89
+ toStage: THREE.Matrix4,
90
+ region: { readonly width: number; readonly height: number },
91
+ zoom: number,
92
+ offset: readonly [number, number],
93
+ renderAspect: number,
94
+ ): BlenderCameraView {
95
+ const width = Math.max(region.width, 1);
96
+ const height = Math.max(region.height, 1);
97
+ const [regionHalfWidth, regionHalfHeight] = halfExtents(camera, width, height, 1 / zoom);
98
+ const [frameHalfWidth, frameHalfHeight] = halfExtents(camera, renderAspect, 1, 1);
99
+ // The shift is a share of the frame's fitted side, on both axes (`dx = shiftx * viewfac`).
100
+ const fitted = camera.sensor_fit === 'AUTO' ? renderAspect >= 1 : camera.sensor_fit === 'HORIZONTAL';
101
+ const fittedSide = 2 * (fitted ? frameHalfWidth : frameHalfHeight);
102
+ const frameWidth = frameHalfWidth / regionHalfWidth;
103
+ const frameHeight = frameHalfHeight / regionHalfHeight;
104
+ // The frame's movement on the region, in fractions of it (down and right positive).
105
+ const shiftRight = -2 * offset[0] * zoom;
106
+ const shiftDown = 2 * offset[1] * zoom;
107
+ const centerX = camera.shift_x * fittedSide - shiftRight * 2 * regionHalfWidth;
108
+ const centerY = camera.shift_y * fittedSide + shiftDown * 2 * regionHalfHeight;
109
+ const matrix = new THREE.Matrix4()
110
+ .set(...(camera.matrix.flat() as Parameters<THREE.Matrix4['set']>))
111
+ .premultiply(toStage);
112
+ const position = new THREE.Vector3();
113
+ const quaternion = new THREE.Quaternion();
114
+ matrix.decompose(position, quaternion, new THREE.Vector3());
115
+ return {
116
+ name: camera.name,
117
+ position: position.toArray(),
118
+ quaternion: [quaternion.x, quaternion.y, quaternion.z, quaternion.w],
119
+ projection: camera.type === 'ORTHO' ? 'orthographic' : 'perspective',
120
+ window: {
121
+ left: centerX - regionHalfWidth,
122
+ right: centerX + regionHalfWidth,
123
+ top: centerY + regionHalfHeight,
124
+ bottom: centerY - regionHalfHeight,
125
+ },
126
+ near: camera.clip_start,
127
+ far: camera.clip_end,
128
+ frame: {
129
+ left: 0.5 - frameWidth / 2 + shiftRight,
130
+ top: 0.5 - frameHeight / 2 + shiftDown,
131
+ width: frameWidth,
132
+ height: frameHeight,
133
+ },
134
+ passepartout: { color: THEME.passepartout, opacity: camera.passepartout },
135
+ border: { solid: THEME.back, dashed: THEME.overlay, locked: THEME.redalert },
136
+ };
137
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * THE 3D CURSOR — `Scene.cursor`, drawn the way Blender's overlay engine draws
3
+ * it (`draw/engines/overlay/overlay_cursor.hh`, shapes in `overlay_shape.cc`
4
+ * "cursor circle" / "cursor lines", read at the engine's pin).
5
+ *
6
+ * TWO SHAPES, both one `U.widget_unit` (20 CSS px) across whatever the zoom:
7
+ *
8
+ * - THE RING is screen space: a 12-segment circle of radius 0.5, segments
9
+ * alternating red `(1, 0, 0)` and white, one pixel wide.
10
+ * - THE AXIS LINES are drawn IN 3D along the cursor's own rotation, scaled by
11
+ * the pixel size at the cursor (`ED_view3d_pixel_size_no_ui_scale`): per
12
+ * axis, black (`TH_VIEW_OVERLAY`) from 0.25 to 0.85 and the theme's axis
13
+ * colour from 0.85 to 1; the negative half's colour is the axis blended a
14
+ * quarter to white and shaded −60 (`get_color_blend_shade_3fv`).
15
+ *
16
+ * Here the ring faces the camera at the cursor's depth rather than being
17
+ * drawn in pixel space; at a 20 px size the two are the same picture.
18
+ *
19
+ * PLACEMENT is `view3d.cursor3d` as Shift+Right-click runs it
20
+ * (`space_view3d/view3d_edit.cc`): `use_depth` projects onto the surface under
21
+ * the pointer and, with nothing there, onto the view plane through the current
22
+ * cursor; `orientation` VIEW turns the cursor to the view (`rv3d->viewquat`
23
+ * with its w negated — the view's own rotation). Blender pushes no undo step
24
+ * for it (the operator's `OPTYPE_UNDO` is commented out), and neither does
25
+ * the caller.
26
+ */
27
+ import * as THREE from 'three';
28
+ import { z } from 'zod';
29
+
30
+ const scalar = z.number().finite();
31
+ /** `session.py`'s `frame["cursor"]`: `Scene.cursor.matrix`, row-major. */
32
+ export const cursorSchema = z.array(z.tuple([scalar, scalar, scalar, scalar])).length(4);
33
+ export type BlenderCursor = z.infer<typeof cursorSchema>;
34
+
35
+ /** `U.widget_unit` at a UI scale of 1, in CSS px. */
36
+ const WIDGET_UNIT = 20;
37
+
38
+ /** The default theme's axis colours (`userdef_default_theme.c` `xaxis`/`yaxis`/`zaxis`). */
39
+ const AXES = [0xff3352, 0x8bdc00, 0x2890ff] as const;
40
+
41
+ function negativeShade(hex: number): THREE.Color {
42
+ const channel = (value: number) => Math.min(255, Math.max(0, Math.floor(0.75 * value + 0.25 * 255) - 60)) / 255;
43
+ return new THREE.Color().setRGB(channel((hex >> 16) & 255), channel((hex >> 8) & 255), channel(hex & 255), THREE.SRGBColorSpace);
44
+ }
45
+
46
+ function lineMaterial(): THREE.LineBasicMaterial {
47
+ return new THREE.LineBasicMaterial({ vertexColors: true, depthTest: false, depthWrite: false, transparent: true, toneMapped: false });
48
+ }
49
+
50
+ function axisLines(): THREE.LineSegments {
51
+ const positions: number[] = [];
52
+ const colors: number[] = [];
53
+ const black = new THREE.Color(0, 0, 0);
54
+ const push = (from: number, to: number, axis: number, sign: number, color: THREE.Color) => {
55
+ for (const at of [from, to]) {
56
+ const point = [0, 0, 0];
57
+ point[axis] = at * sign;
58
+ positions.push(...point);
59
+ colors.push(color.r, color.g, color.b);
60
+ }
61
+ };
62
+ for (let axis = 0; axis < 3; axis++) {
63
+ const positive = new THREE.Color().setHex(AXES[axis]!, THREE.SRGBColorSpace);
64
+ push(1, 0.85, axis, 1, positive);
65
+ push(0.85, 0.25, axis, 1, black);
66
+ push(1, 0.85, axis, -1, negativeShade(AXES[axis]!));
67
+ push(0.85, 0.25, axis, -1, black);
68
+ }
69
+ const geometry = new THREE.BufferGeometry();
70
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
71
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
72
+ return new THREE.LineSegments(geometry, lineMaterial());
73
+ }
74
+
75
+ function ring(): THREE.LineSegments {
76
+ const segments = 12;
77
+ const positions: number[] = [];
78
+ const colors: number[] = [];
79
+ const red = new THREE.Color(1, 0, 0);
80
+ const white = new THREE.Color(1, 1, 1);
81
+ for (let i = 0; i < segments; i++) {
82
+ // Blender's strip is flat-coloured with the segment's SECOND vertex as the provoking one
83
+ // (`gpu_shader_3D_polyline_vert.glsl`, `FLAT`: `geom_in[1]`), so segment i wears vertex i+1's.
84
+ const color = (i + 1) % 2 === 0 ? white : red;
85
+ for (const k of [i, i + 1]) {
86
+ const angle = (2 * Math.PI * k) / segments;
87
+ positions.push(0.5 * Math.cos(angle), 0.5 * Math.sin(angle), 0);
88
+ colors.push(color.r, color.g, color.b);
89
+ }
90
+ }
91
+ const geometry = new THREE.BufferGeometry();
92
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
93
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
94
+ return new THREE.LineSegments(geometry, lineMaterial());
95
+ }
96
+
97
+ /** The cursor placed by a click, in Blender's own frame. */
98
+ export interface CursorPlacement {
99
+ readonly location: readonly [number, number, number];
100
+ /** `(w, x, y, z)`. */
101
+ readonly rotation: readonly [number, number, number, number];
102
+ }
103
+
104
+ export class CursorOverlay {
105
+ /** Carries the Blender → document permutation from its parent; see the view.
106
+ * Its `visible` is the view's (a render stands it down); whether there is a
107
+ * cursor to draw is the shapes' own. */
108
+ readonly group = new THREE.Group();
109
+ private readonly anchor = new THREE.Group();
110
+ private readonly lines = axisLines();
111
+ private readonly circle = ring();
112
+ /** What the cursor was last drawn with: placement reads the view the person sees. */
113
+ private seen: { camera: THREE.Camera; canvas: HTMLCanvasElement } | null = null;
114
+ private readonly size = new THREE.Vector2();
115
+ private readonly scratch = new THREE.Vector3();
116
+ private readonly parentQuat = new THREE.Quaternion();
117
+
118
+ constructor() {
119
+ this.group.name = 'BlenderCursor';
120
+ this.present(false);
121
+ this.anchor.matrixAutoUpdate = false;
122
+ this.group.add(this.anchor, this.circle);
123
+ this.anchor.add(this.lines);
124
+ for (const shape of [this.lines, this.circle]) {
125
+ shape.renderOrder = 1001;
126
+ shape.frustumCulled = false;
127
+ }
128
+ this.lines.onBeforeRender = (renderer, _scene, camera) => this.fit(renderer, camera);
129
+ this.circle.onBeforeRender = (renderer, _scene, camera) => this.fit(renderer, camera);
130
+ }
131
+
132
+ /** Show the frame's cursor; an engine that sends none draws none. */
133
+ apply(cursor: BlenderCursor | undefined): void {
134
+ if (cursor === undefined) {
135
+ this.present(false);
136
+ return;
137
+ }
138
+ this.anchor.matrix.set(...(cursor.flat() as Parameters<THREE.Matrix4['set']>));
139
+ this.anchor.updateMatrixWorld(true);
140
+ this.present(true);
141
+ }
142
+
143
+ private present(shown: boolean): void {
144
+ this.anchor.visible = shown;
145
+ this.circle.visible = shown;
146
+ }
147
+
148
+ /** Move the drawn cursor ahead of the engine's frame, which then confirms it. */
149
+ show(placement: CursorPlacement): void {
150
+ const [w, x, y, z] = placement.rotation;
151
+ this.anchor.matrix.compose(
152
+ new THREE.Vector3(...placement.location),
153
+ new THREE.Quaternion(x, y, z, w),
154
+ new THREE.Vector3(1, 1, 1),
155
+ );
156
+ this.anchor.updateMatrixWorld(true);
157
+ this.present(true);
158
+ }
159
+
160
+ /** One widget unit at the cursor's depth, for the camera drawing it now. */
161
+ private fit(renderer: THREE.WebGLRenderer, camera: THREE.Camera): void {
162
+ this.seen = { camera, canvas: renderer.domElement };
163
+ renderer.getSize(this.size);
164
+ const height = Math.max(this.size.y, 1);
165
+ const at = this.scratch.setFromMatrixPosition(this.anchor.matrixWorld);
166
+ let perPixel: number;
167
+ if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
168
+ const ortho = camera as THREE.OrthographicCamera;
169
+ perPixel = (ortho.top - ortho.bottom) / ortho.zoom / height;
170
+ } else {
171
+ const perspective = camera as THREE.PerspectiveCamera;
172
+ const depth = Math.max(-at.applyMatrix4(camera.matrixWorldInverse).z, 1e-6);
173
+ perPixel = (2 * depth * Math.tan(THREE.MathUtils.degToRad(perspective.fov) / 2)) / perspective.zoom / height;
174
+ }
175
+ const scale = perPixel * WIDGET_UNIT;
176
+ this.lines.scale.setScalar(scale);
177
+ this.lines.updateMatrixWorld(true);
178
+ // The ring faces the camera: its world rotation is the camera's.
179
+ this.circle.position.setFromMatrixPosition(this.anchor.matrix);
180
+ this.group.getWorldQuaternion(this.parentQuat).invert();
181
+ camera.getWorldQuaternion(this.circle.quaternion).premultiply(this.parentQuat);
182
+ this.circle.scale.setScalar(scale);
183
+ this.circle.updateMatrixWorld(true);
184
+ }
185
+
186
+ /**
187
+ * Where Shift+Right-click at `clientX/clientY` puts the cursor, or null when
188
+ * the point is not on the view the cursor was last drawn in. `model` is the
189
+ * presented model root, whose local frame is Blender's; `isSurface` says which of its meshes
190
+ * are the scene's own geometry, the only thing Blender's depth pick sees.
191
+ */
192
+ placementAt(
193
+ clientX: number,
194
+ clientY: number,
195
+ target: EventTarget | null,
196
+ model: THREE.Object3D,
197
+ isSurface: (object: THREE.Object3D) => boolean,
198
+ ): CursorPlacement | null {
199
+ const seen = this.seen;
200
+ if (seen === null || target !== seen.canvas) return null;
201
+ const rect = seen.canvas.getBoundingClientRect();
202
+ const pointer = new THREE.Vector2(
203
+ ((clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1,
204
+ -((clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1,
205
+ );
206
+ const raycaster = new THREE.Raycaster();
207
+ raycaster.setFromCamera(pointer, seen.camera);
208
+ const shown = (object: THREE.Object3D | null): boolean => {
209
+ for (let at = object; at !== null; at = at.parent) if (!at.visible) return false;
210
+ return true;
211
+ };
212
+ const hit = raycaster
213
+ .intersectObject(model, true)
214
+ .find((one) => (one.object as THREE.Mesh).isMesh && isSurface(one.object) && shown(one.object));
215
+ let world: THREE.Vector3 | null = hit ? hit.point.clone() : null;
216
+ if (world === null) {
217
+ // No surface: the view plane through the current cursor. A cursor behind the view gives
218
+ // its depth in front instead, as `ED_view3d_win_to_3d` flips a negative `zfac`.
219
+ const through = new THREE.Vector3().setFromMatrixPosition(this.anchor.matrixWorld);
220
+ const normal = seen.camera.getWorldDirection(new THREE.Vector3());
221
+ const eye = seen.camera.getWorldPosition(new THREE.Vector3());
222
+ const depth = through.clone().sub(eye).dot(normal);
223
+ if (depth <= 0) through.addScaledVector(normal, -2 * depth + (depth === 0 ? 1 : 0));
224
+ world = raycaster.ray.intersectPlane(new THREE.Plane().setFromNormalAndCoplanarPoint(normal, through), new THREE.Vector3());
225
+ }
226
+ if (world === null) return null;
227
+ model.updateWorldMatrix(true, false);
228
+ const local = model.worldToLocal(world);
229
+ const rotation = model
230
+ .getWorldQuaternion(new THREE.Quaternion())
231
+ .invert()
232
+ .multiply(seen.camera.getWorldQuaternion(new THREE.Quaternion()));
233
+ return { location: [local.x, local.y, local.z], rotation: [rotation.w, rotation.x, rotation.y, rotation.z] };
234
+ }
235
+
236
+ dispose(): void {
237
+ for (const shape of [this.lines, this.circle]) {
238
+ shape.geometry.dispose();
239
+ (shape.material as THREE.Material).dispose();
240
+ }
241
+ this.seen = null;
242
+ }
243
+ }