@volter/blender-engine 0.1.7 → 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.
@@ -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
+ }