@volter/blender-engine 0.1.0
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/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Draw the Python-owned density grid directly as a Three.js volume. */
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const scalar = z.number().finite();
|
|
6
|
+
const vector = z.tuple([scalar, scalar, scalar]);
|
|
7
|
+
export const volumeSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
min: z.tuple([z.number().int(), z.number().int(), z.number().int()]),
|
|
10
|
+
max: z.tuple([z.number().int(), z.number().int(), z.number().int()]),
|
|
11
|
+
density: z.array(scalar),
|
|
12
|
+
voxelSize: scalar.positive(),
|
|
13
|
+
color: vector,
|
|
14
|
+
emission: vector,
|
|
15
|
+
densityScale: scalar.nonnegative(),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
export type VolumeData = z.infer<typeof volumeSchema>;
|
|
19
|
+
|
|
20
|
+
export function volumeMesh(data: VolumeData): THREE.Mesh<THREE.BoxGeometry, THREE.ShaderMaterial> {
|
|
21
|
+
const size = data.max.map((v, i) => v - data.min[i]! + 1);
|
|
22
|
+
if (size.some((v) => v <= 0) || size.reduce((a, b) => a * b, 1) !== data.density.length)
|
|
23
|
+
throw new Error('Volume density dimensions do not match the evaluated grid');
|
|
24
|
+
const texture = new THREE.Data3DTexture(
|
|
25
|
+
new Float32Array(data.density),
|
|
26
|
+
size[0]!,
|
|
27
|
+
size[1]!,
|
|
28
|
+
size[2]!,
|
|
29
|
+
);
|
|
30
|
+
texture.format = THREE.RedFormat;
|
|
31
|
+
texture.type = THREE.FloatType;
|
|
32
|
+
texture.minFilter = texture.magFilter = THREE.LinearFilter;
|
|
33
|
+
texture.unpackAlignment = 1;
|
|
34
|
+
texture.needsUpdate = true;
|
|
35
|
+
const minimum = new THREE.Vector3(...data.min).addScalar(-0.5).multiplyScalar(data.voxelSize);
|
|
36
|
+
const extent = new THREE.Vector3(size[0], size[1], size[2]).multiplyScalar(data.voxelSize);
|
|
37
|
+
const geometry = new THREE.BoxGeometry(extent.x, extent.y, extent.z);
|
|
38
|
+
const positions = geometry.getAttribute('position');
|
|
39
|
+
for (let i = 0; i < positions.count; i++)
|
|
40
|
+
positions.setXYZ(
|
|
41
|
+
i,
|
|
42
|
+
minimum.x + (positions.getX(i) > 0 ? extent.x : 0),
|
|
43
|
+
minimum.y + (positions.getY(i) > 0 ? extent.y : 0),
|
|
44
|
+
minimum.z + (positions.getZ(i) > 0 ? extent.z : 0),
|
|
45
|
+
);
|
|
46
|
+
positions.needsUpdate = true;
|
|
47
|
+
geometry.computeBoundingBox();
|
|
48
|
+
geometry.computeBoundingSphere();
|
|
49
|
+
const material = new THREE.ShaderMaterial({
|
|
50
|
+
glslVersion: THREE.GLSL3,
|
|
51
|
+
side: THREE.BackSide,
|
|
52
|
+
transparent: true,
|
|
53
|
+
depthWrite: false,
|
|
54
|
+
premultipliedAlpha: true,
|
|
55
|
+
uniforms: {
|
|
56
|
+
densityGrid: { value: texture },
|
|
57
|
+
minimum: { value: minimum },
|
|
58
|
+
extent: { value: extent },
|
|
59
|
+
voxelSize: { value: data.voxelSize },
|
|
60
|
+
densityScale: { value: data.densityScale },
|
|
61
|
+
volumeColor: { value: new THREE.Vector3(...data.color) },
|
|
62
|
+
emission: { value: new THREE.Vector3(...data.emission) },
|
|
63
|
+
cameraLocal: { value: new THREE.Vector3() },
|
|
64
|
+
rayLocal: { value: new THREE.Vector3() },
|
|
65
|
+
orthographic: { value: false },
|
|
66
|
+
worldScale: { value: new THREE.Matrix3() },
|
|
67
|
+
},
|
|
68
|
+
vertexShader: `
|
|
69
|
+
out vec3 localPosition;
|
|
70
|
+
void main() {
|
|
71
|
+
localPosition = position;
|
|
72
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
73
|
+
}`,
|
|
74
|
+
fragmentShader: `
|
|
75
|
+
precision highp sampler3D;
|
|
76
|
+
uniform sampler3D densityGrid;
|
|
77
|
+
uniform vec3 minimum, extent, volumeColor, emission, cameraLocal, rayLocal;
|
|
78
|
+
uniform float voxelSize, densityScale;
|
|
79
|
+
uniform bool orthographic;
|
|
80
|
+
uniform mat3 worldScale;
|
|
81
|
+
in vec3 localPosition;
|
|
82
|
+
out vec4 outputColor;
|
|
83
|
+
#define gl_FragColor outputColor
|
|
84
|
+
void main() {
|
|
85
|
+
vec3 direction = normalize(orthographic ? rayLocal : localPosition - cameraLocal);
|
|
86
|
+
vec3 origin = orthographic ? localPosition - direction * length(extent) * 2.0 : cameraLocal;
|
|
87
|
+
vec3 inverseRay = 1.0 / direction;
|
|
88
|
+
vec3 nearPlane = (minimum - origin) * inverseRay;
|
|
89
|
+
vec3 farPlane = (minimum + extent - origin) * inverseRay;
|
|
90
|
+
vec3 lo = min(nearPlane, farPlane), hi = max(nearPlane, farPlane);
|
|
91
|
+
float start = max(0.0, max(lo.x, max(lo.y, lo.z)));
|
|
92
|
+
float end = min(hi.x, min(hi.y, hi.z));
|
|
93
|
+
if (end <= start) discard;
|
|
94
|
+
float stepSize = min(voxelSize * 0.5, end - start);
|
|
95
|
+
float worldStep = length(worldScale * direction) * stepSize;
|
|
96
|
+
vec3 radiance = vec3(0.0);
|
|
97
|
+
float transmission = 1.0;
|
|
98
|
+
for (float t = start + stepSize * 0.5; t < end; t += stepSize) {
|
|
99
|
+
vec3 uvw = (origin + direction * t - minimum) / extent;
|
|
100
|
+
float density = max(0.0, texture(densityGrid, uvw).r);
|
|
101
|
+
float extinction = density * densityScale;
|
|
102
|
+
float attenuation = exp(-extinction * worldStep);
|
|
103
|
+
float integral = extinction > 0.000001 ? (1.0 - attenuation) / extinction : worldStep;
|
|
104
|
+
radiance += transmission * (volumeColor * extinction + emission * density) * integral;
|
|
105
|
+
transmission *= attenuation;
|
|
106
|
+
if (transmission < 0.001) break;
|
|
107
|
+
}
|
|
108
|
+
outputColor = vec4(radiance, 1.0 - transmission);
|
|
109
|
+
#include <tonemapping_fragment>
|
|
110
|
+
#include <colorspace_fragment>
|
|
111
|
+
}`,
|
|
112
|
+
});
|
|
113
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
114
|
+
const inverse = new THREE.Matrix4();
|
|
115
|
+
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
|
116
|
+
inverse.copy(mesh.matrixWorld).invert();
|
|
117
|
+
material.uniforms['cameraLocal']!.value.setFromMatrixPosition(camera.matrixWorld).applyMatrix4(
|
|
118
|
+
inverse,
|
|
119
|
+
);
|
|
120
|
+
camera.getWorldDirection(material.uniforms['rayLocal']!.value).transformDirection(inverse);
|
|
121
|
+
material.uniforms['orthographic']!.value = Boolean(
|
|
122
|
+
(camera as THREE.OrthographicCamera).isOrthographicCamera,
|
|
123
|
+
);
|
|
124
|
+
material.uniforms['worldScale']!.value.setFromMatrix4(mesh.matrixWorld);
|
|
125
|
+
};
|
|
126
|
+
material.addEventListener('dispose', () => texture.dispose());
|
|
127
|
+
return mesh;
|
|
128
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WEIGHT OVERLAY — the active vertex group's weights, coloured with
|
|
3
|
+
* Blender's own ramp (ARCHITECTURE-CORE §Blender north star, "Inspection
|
|
4
|
+
* parity, not editing parity" and "The reference is Blender's SOURCE as well
|
|
5
|
+
* as its frames"; WORK.md §Blender in the tab is Blender, "Inspection parity",
|
|
6
|
+
* I4).
|
|
7
|
+
*
|
|
8
|
+
* IT IS INSPECTION, NOT PAINT MODE. Blender shows these colours by entering
|
|
9
|
+
* Weight Paint; here it is a viewport overlay toggle beside Bones, because the
|
|
10
|
+
* Model document has no brushes to enter a paint mode for and looking at the
|
|
11
|
+
* weights is the whole point.
|
|
12
|
+
*
|
|
13
|
+
* WHICH RAMP, and the correction it cost. The brief for this unit named
|
|
14
|
+
* `BKE_defvert_weight_to_rgb` / `weight_to_rgb`
|
|
15
|
+
* (`blenkernel/intern/deform.cc:1559-1590`) — the blue → cyan → green →
|
|
16
|
+
* yellow → red piecewise ramp. MEASURED at the engine's pin, that function is
|
|
17
|
+
* NOT what paints a vertex group: its only callers in the whole tree are
|
|
18
|
+
* `blenkernel/intern/particle.cc:3602,3624-3625`, the HAIR KEY weight colour.
|
|
19
|
+
* The weight-paint viewport samples a 256-texel 1D table built in
|
|
20
|
+
* `draw/engines/overlay/overlay_instance.cc:186-221`, and that table is an HSV
|
|
21
|
+
* sweep with a gamma correction — a different curve with the same endpoints,
|
|
22
|
+
* which is why the two look alike until you compare mid-tones. This file is
|
|
23
|
+
* the table Blender actually samples; the piecewise ramp is named here so the
|
|
24
|
+
* next reader does not "fix" it back.
|
|
25
|
+
*/
|
|
26
|
+
import * as THREE from 'three';
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
|
|
29
|
+
/** What `session.py`'s `_weights` answers. `weightsBase64` / `alertBase64` are
|
|
30
|
+
* absent on a REFERENCE (`unchanged`), which is the same contract the meshes
|
|
31
|
+
* have (`blender-runtime-frame.ts`): the array is the size of a vertex column
|
|
32
|
+
* and every mutation presents. */
|
|
33
|
+
export const weightsSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
object: z.string(),
|
|
36
|
+
group: z.string(),
|
|
37
|
+
groupIndex: z.number().int().nonnegative(),
|
|
38
|
+
count: z.number().int().nonnegative(),
|
|
39
|
+
/** sha1 of the packed weight + alert bytes. The reference key, and the
|
|
40
|
+
* presenter's staleness signal — never the mesh's revision, which does
|
|
41
|
+
* NOT move when only deform weights change (`session.py::_weights` states
|
|
42
|
+
* the measurement). */
|
|
43
|
+
digest: z.string(),
|
|
44
|
+
/** `scene.tool_settings.vertex_group_user` (`rna_scene.cc:3428-3434`). */
|
|
45
|
+
alertMode: z.enum(['NONE', 'ACTIVE', 'ALL']),
|
|
46
|
+
unchanged: z.literal(true).optional(),
|
|
47
|
+
/** Float32, one per vertex, clamped to [0,1]. */
|
|
48
|
+
weightsBase64: z.string().optional(),
|
|
49
|
+
/** Uint8, one per vertex: 1 where Blender paints the unreferenced colour. */
|
|
50
|
+
alertBase64: z.string().optional(),
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
|
|
54
|
+
export type BlenderWeights = z.infer<typeof weightsSchema>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* BLENDER'S WEIGHT RAMP, the formula from `overlay_instance.cc:186-203`.
|
|
58
|
+
*
|
|
59
|
+
* hsv = { (2/3)·(1 − weight), 1, (0.5 + 0.5·weight)^γ }, γ = 1.5
|
|
60
|
+
* rgb = hsv_to_rgb(hsv)
|
|
61
|
+
* rgb = rgb^(1/γ)
|
|
62
|
+
*
|
|
63
|
+
* The comment there states the intent: "Use gamma correction to even out the
|
|
64
|
+
* color bands: increasing widens yellow/cyan vs red/green/blue. Gamma 1.0
|
|
65
|
+
* produces the original 2.79 color ramp."
|
|
66
|
+
*
|
|
67
|
+
* The STOPS the hue sweep passes through, at γ = 1.5 and rounded to a byte —
|
|
68
|
+
* the values this function returns, which is what a frame can be read against:
|
|
69
|
+
*
|
|
70
|
+
* 0.00 hue 240° #000080 blue (V = 0.5^1.5 = 0.3536 → ^(1/1.5) = 0.500)
|
|
71
|
+
* 0.25 hue 180° #009f9f cyan (V = 0.625^1.5 → 0.625)
|
|
72
|
+
* 0.50 hue 120° #00bf00 green (V = 0.75^1.5 → 0.750)
|
|
73
|
+
* 0.75 hue 60° #dfdf00 yellow (V = 0.875^1.5 → 0.875)
|
|
74
|
+
* 1.00 hue 0° #ff0000 red (V = 1 → 1.000)
|
|
75
|
+
*
|
|
76
|
+
* The channel value at each stop is `((0.5 + 0.5·w)^1.5)^(1/1.5)`, which is
|
|
77
|
+
* just `0.5 + 0.5·w` — the gamma cancels on a fully saturated channel and only
|
|
78
|
+
* bends the MIXED ones between the stops, which is exactly the "even out the
|
|
79
|
+
* color bands" the comment claims. So the five stops above are 0.5, 0.625,
|
|
80
|
+
* 0.75, 0.875, 1.0 of full, and any reading of a frame can be checked against
|
|
81
|
+
* those five bytes: 0x80, 0x9f, 0xbf, 0xdf, 0xff.
|
|
82
|
+
*
|
|
83
|
+
* THE TABLE IS AN sRGB TEXTURE in Blender (`ensure_1d(TextureFormat::
|
|
84
|
+
* SRGBA_8_8_8_8, 256, …)`, `:219-220`), so the sampler linearises it on read
|
|
85
|
+
* and the values above are the sRGB bytes. The colours built here are handed
|
|
86
|
+
* to three the same way — read as sRGB, so the pixel matches.
|
|
87
|
+
*/
|
|
88
|
+
export function weightColor(weight: number): THREE.Color {
|
|
89
|
+
const gamma = 1.5;
|
|
90
|
+
const clamped = weight < 0 ? 0 : weight > 1 ? 1 : weight;
|
|
91
|
+
const value = (0.5 + 0.5 * clamped) ** gamma;
|
|
92
|
+
// HSV, not HSL: `hsv_to_rgb_v` with saturation 1 is the hue's own unit ramp
|
|
93
|
+
// scaled by V, which three's `setHSL` cannot express — so the sweep is done
|
|
94
|
+
// here and only the colour space is three's.
|
|
95
|
+
const [r, g, b] = hueToRgb((2 / 3) * (1 - clamped));
|
|
96
|
+
const color = new THREE.Color();
|
|
97
|
+
color.setRGB(
|
|
98
|
+
(r * value) ** (1 / gamma),
|
|
99
|
+
(g * value) ** (1 / gamma),
|
|
100
|
+
(b * value) ** (1 / gamma),
|
|
101
|
+
THREE.SRGBColorSpace,
|
|
102
|
+
);
|
|
103
|
+
return color;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `hsv_to_rgb` with saturation 1 and value 1 — the six-sector hue ramp
|
|
107
|
+
* (`BLI_math_color.c`'s own sector table, reduced to the s=v=1 case Blender's
|
|
108
|
+
* weight table is the only caller of). */
|
|
109
|
+
function hueToRgb(hue: number): [number, number, number] {
|
|
110
|
+
const h = ((hue % 1) + 1) % 1;
|
|
111
|
+
const i = Math.floor(h * 6);
|
|
112
|
+
const f = h * 6 - i;
|
|
113
|
+
switch (i % 6) {
|
|
114
|
+
case 0:
|
|
115
|
+
return [1, f, 0];
|
|
116
|
+
case 1:
|
|
117
|
+
return [1 - f, 1, 0];
|
|
118
|
+
case 2:
|
|
119
|
+
return [0, 1, f];
|
|
120
|
+
case 3:
|
|
121
|
+
return [0, 1 - f, 1];
|
|
122
|
+
case 4:
|
|
123
|
+
return [f, 0, 1];
|
|
124
|
+
default:
|
|
125
|
+
return [1, 0, 1 - f];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `TH_VERTEX_UNREFERENCED`, the colour a zero-weight vertex is painted
|
|
131
|
+
* (`overlay_paint_weight_frag.glsl:98-100`, `mix(weight_color,
|
|
132
|
+
* color_unreferenced, alert * alert)` at alert 1).
|
|
133
|
+
*
|
|
134
|
+
* MEASURED: `vertex_unreferenced` appears NOWHERE in
|
|
135
|
+
* `release/datafiles/userdef/userdef_default_theme.c` — the only mention of
|
|
136
|
+
* the member in the whole checkout is its RNA declaration
|
|
137
|
+
* (`rna_userdef.cc:3104`). The default theme therefore leaves the struct's
|
|
138
|
+
* zero standing, which is BLACK, and black is what Blender draws for an
|
|
139
|
+
* unweighted vertex. Stated rather than eyedropped.
|
|
140
|
+
*/
|
|
141
|
+
export const VERTEX_UNREFERENCED = 0x000000;
|
|
142
|
+
|
|
143
|
+
function bytesFrom(base64: string): Uint8Array {
|
|
144
|
+
const binary = atob(base64);
|
|
145
|
+
const bytes = new Uint8Array(binary.length);
|
|
146
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
147
|
+
return bytes;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* THE OVERLAY: the painted object's own geometry, drawn again in the weight
|
|
152
|
+
* colours.
|
|
153
|
+
*
|
|
154
|
+
* A SECOND MESH rather than a material swap on the presented one, and the
|
|
155
|
+
* reason is ownership: the presented mesh belongs to `BlenderRuntimeView`,
|
|
156
|
+
* which replaces its geometry and material whenever the engine says so, and a
|
|
157
|
+
* toggle that reached in and swapped the material would be fighting it every
|
|
158
|
+
* frame. The overlay holds its own mesh over the same geometry, one polygon
|
|
159
|
+
* offset in front, and the Helpers toggle turns it on and off the way it turns
|
|
160
|
+
* every other helper on and off.
|
|
161
|
+
*/
|
|
162
|
+
export class WeightOverlay {
|
|
163
|
+
readonly group = new THREE.Group();
|
|
164
|
+
private mesh: THREE.Mesh | null = null;
|
|
165
|
+
private material: THREE.MeshBasicMaterial | null = null;
|
|
166
|
+
private geometry: THREE.BufferGeometry | null = null;
|
|
167
|
+
/** The last array that crossed, kept because a present ships a REFERENCE for
|
|
168
|
+
* everything it has already sent. Keyed by `<object>:<group>`. */
|
|
169
|
+
private held: { key: string; weights: Float32Array; alerts: Uint8Array } | null = null;
|
|
170
|
+
private signature = '';
|
|
171
|
+
|
|
172
|
+
constructor() {
|
|
173
|
+
this.group.name = 'BlenderWeightOverlay';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Draw the weights over `source`, the presented mesh of the painted object.
|
|
178
|
+
* `null` weights (no active vertex group, no active mesh) clears it.
|
|
179
|
+
*
|
|
180
|
+
* `objectMatrix` is the object's matrix in BLENDER's frame — the frame's own
|
|
181
|
+
* `objects[].matrix`, exactly as the armature overlay takes it, and NOT the
|
|
182
|
+
* presented mesh's `matrixWorld`. Measured live 2026-09-19: `matrixWorld`
|
|
183
|
+
* already carries the model root's Z-up permutation, and this group carries
|
|
184
|
+
* it too, so the drawing landed with the permutation applied TWICE — the
|
|
185
|
+
* Body's colours lay flat on the floor beside the standing cylinder.
|
|
186
|
+
*
|
|
187
|
+
* Returns the warning this drawing could not honour, or null.
|
|
188
|
+
*/
|
|
189
|
+
apply(
|
|
190
|
+
weights: BlenderWeights | null,
|
|
191
|
+
source: THREE.Mesh | null,
|
|
192
|
+
objectMatrix: THREE.Matrix4 | null,
|
|
193
|
+
): string | null {
|
|
194
|
+
if (weights === null || source === null || !source.isMesh || objectMatrix === null) {
|
|
195
|
+
// THE DRAWING GOES, THE ARRAY STAYS. Dropping `held` here is what the
|
|
196
|
+
// walk caught (2026-09-19): `_weights` answers null whenever the active
|
|
197
|
+
// object is not a mesh — entering POSE MODE on the rig is enough — while
|
|
198
|
+
// the session's `_known` still records the array as sent, so the next
|
|
199
|
+
// frame that names the Body again ships a REFERENCE to bytes this side
|
|
200
|
+
// had just thrown away, and the colours never came back. The array is
|
|
201
|
+
// still the truth about that object's group; only the drawing is stale.
|
|
202
|
+
// It is the same rule the meshes follow: a presenter holds its geometry
|
|
203
|
+
// until the SESSION replaces it, never until a frame stops mentioning it.
|
|
204
|
+
this.clear();
|
|
205
|
+
this.signature = '';
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const key = `${weights.object}:${weights.group}`;
|
|
209
|
+
if (weights.unchanged === true) {
|
|
210
|
+
if (this.held?.key !== key) {
|
|
211
|
+
// The session's record is ahead of this overlay — the document was
|
|
212
|
+
// rebuilt under a still-running session. Named, and answered by the
|
|
213
|
+
// next present: the weight array is re-sent whenever the mesh moves.
|
|
214
|
+
this.clear();
|
|
215
|
+
return (
|
|
216
|
+
`weights: the session sent a reference to ${key} that this overlay does not hold; ` +
|
|
217
|
+
'the colours reappear on the next change to the mesh or the group'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
if (weights.weightsBase64 === undefined || weights.alertBase64 === undefined) {
|
|
222
|
+
this.clear();
|
|
223
|
+
return `weights: ${key} carried neither an array nor a reference`;
|
|
224
|
+
}
|
|
225
|
+
const raw = bytesFrom(weights.weightsBase64);
|
|
226
|
+
this.held = {
|
|
227
|
+
key,
|
|
228
|
+
weights: new Float32Array(raw.buffer, raw.byteOffset, weights.count),
|
|
229
|
+
alerts: bytesFrom(weights.alertBase64),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const held = this.held;
|
|
233
|
+
if (held === null) return null;
|
|
234
|
+
// WHAT MAKES THE DRAWING STALE: the array, the group, and the GEOMETRY the
|
|
235
|
+
// colours are laid over (the presenter replaces it when the mesh moves).
|
|
236
|
+
const signature = `${key}:${weights.digest}:${source.geometry.uuid}:${weights.alertMode}:${objectMatrix.elements.join(',')}`;
|
|
237
|
+
if (signature === this.signature && this.mesh !== null) return null;
|
|
238
|
+
this.signature = signature;
|
|
239
|
+
this.clear();
|
|
240
|
+
const geometry = source.geometry.clone();
|
|
241
|
+
const position = geometry.getAttribute('position');
|
|
242
|
+
// THE PRESENTED GEOMETRY IS DRAWN-OUT: the draw splits one Blender vertex
|
|
243
|
+
// into as many drawn ones as its corners need (a UV seam, a sharp edge, a
|
|
244
|
+
// flat face), so a per-vertex fact off the engine cannot be laid over it by
|
|
245
|
+
// index. `blenderVertex` is the draw's own map, written by the one function
|
|
246
|
+
// that creates a drawn vertex (`blender-runtime-geometry.ts`, `emit`).
|
|
247
|
+
// Without it the overlay refuses by name rather than colouring by a guess.
|
|
248
|
+
const sourceIndex = geometry.getAttribute('blenderVertex');
|
|
249
|
+
if (sourceIndex === undefined) {
|
|
250
|
+
geometry.dispose();
|
|
251
|
+
return (
|
|
252
|
+
'weights: the presented geometry carries no per-vertex Blender index ' +
|
|
253
|
+
'(`blenderVertex`), so the weights cannot be laid over it'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const colors = new Float32Array(position.count * 3);
|
|
257
|
+
const unreferenced = new THREE.Color().setHex(VERTEX_UNREFERENCED, THREE.SRGBColorSpace);
|
|
258
|
+
for (let i = 0; i < position.count; i++) {
|
|
259
|
+
const vertex = sourceIndex.getX(i);
|
|
260
|
+
const alert = weights.alertMode !== 'NONE' && held.alerts[vertex] === 1;
|
|
261
|
+
const color = alert ? unreferenced : weightColor(held.weights[vertex] ?? 0);
|
|
262
|
+
colors[i * 3] = color.r;
|
|
263
|
+
colors[i * 3 + 1] = color.g;
|
|
264
|
+
colors[i * 3 + 2] = color.b;
|
|
265
|
+
}
|
|
266
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
267
|
+
// Read and dropped: a `Uint32Array` attribute nothing's shader declares is
|
|
268
|
+
// dead weight on the upload, and the colours it produced are the drawing.
|
|
269
|
+
geometry.deleteAttribute('blenderVertex');
|
|
270
|
+
const material = new THREE.MeshBasicMaterial({
|
|
271
|
+
vertexColors: true,
|
|
272
|
+
// Blender's weight pass replaces the surface's shading with the ramp and
|
|
273
|
+
// multiplies only by `color_fac`, which is 1 unless Fake Shading is on
|
|
274
|
+
// (`overlay_paint_weight_vert.glsl:20-28`). An unlit material IS that.
|
|
275
|
+
polygonOffset: true,
|
|
276
|
+
polygonOffsetFactor: -1,
|
|
277
|
+
polygonOffsetUnits: -1,
|
|
278
|
+
});
|
|
279
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
280
|
+
mesh.name = `${weights.object}:weights`;
|
|
281
|
+
mesh.frustumCulled = false;
|
|
282
|
+
mesh.matrixAutoUpdate = false;
|
|
283
|
+
mesh.matrix.copy(objectMatrix);
|
|
284
|
+
mesh.matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
|
|
285
|
+
this.geometry = geometry;
|
|
286
|
+
this.material = material;
|
|
287
|
+
this.mesh = mesh;
|
|
288
|
+
this.group.add(mesh);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private clear(): void {
|
|
293
|
+
if (this.mesh) this.group.remove(this.mesh);
|
|
294
|
+
this.geometry?.dispose();
|
|
295
|
+
this.material?.dispose();
|
|
296
|
+
this.mesh = null;
|
|
297
|
+
this.geometry = null;
|
|
298
|
+
this.material = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
dispose(): void {
|
|
302
|
+
this.clear();
|
|
303
|
+
this.held = null;
|
|
304
|
+
this.signature = '';
|
|
305
|
+
}
|
|
306
|
+
}
|