@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.
- package/browser/session.py +176 -1
- package/browser/three/blender-node-graph.ts +15 -6
- package/browser/three/blender-runtime-camera-view.ts +137 -0
- package/browser/three/blender-runtime-cursor.ts +243 -0
- package/browser/three/blender-runtime-extras.ts +772 -0
- package/browser/three/blender-runtime-lighting.ts +19 -4
- package/browser/three/blender-runtime-view.ts +452 -5
- package/browser/three/blender-workbench-material.ts +115 -0
- package/package.json +1 -1
package/browser/session.py
CHANGED
|
@@ -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
|
|
827
|
-
//
|
|
828
|
-
//
|
|
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
|
-
|
|
832
|
-
|
|
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(`${
|
|
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
|
+
}
|