@deepblend/dsh-blender-provider-local 0.13.0 → 0.14.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/lib/index.js +4 -0
- package/package.json +2 -2
- package/python/deepblend_features.py +1 -1
- package/python/deepblend_geometry.py +46 -4
- package/python/deepblend_inspect_geometry.py +31 -1
- package/python/deepblend_mesh.py +80 -0
- package/python/deepblend_scatter.py +213 -0
- package/python/deepblend_scene.py +18 -4
- package/python/deepblend_views.py +3 -1
package/lib/index.js
CHANGED
|
@@ -1587,12 +1587,16 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1587
1587
|
assertDigest(await digest(snapshot))
|
|
1588
1588
|
const run = await this.runBootstrap({ action: 'inspect_geometry', jobId: request.jobId,
|
|
1589
1589
|
payload: { entityIds: request.entityIds, frame: request.frame, checkpointSha256: expected,
|
|
1590
|
+
...(request.objectAxes === true ? { objectAxes: true } : {}),
|
|
1590
1591
|
...(request.surfaceSamples === undefined ? {} : { surfaceSamples: request.surfaceSamples }) } },
|
|
1591
1592
|
{ signal: request.signal, session: false, args: ['--blend', snapshot] })
|
|
1592
1593
|
aborted()
|
|
1593
1594
|
if (request.surfaceSamples !== undefined && run.envelope?.result?.schemaVersion === 'deepblend.geometry-inspection/v1') {
|
|
1594
1595
|
throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support local surface sampling. Upgrade DeepBlend; ordinary native geometry reads remain available.')
|
|
1595
1596
|
}
|
|
1597
|
+
if (request.objectAxes === true && run.envelope?.result?.schemaVersion !== 'deepblend.geometry-inspection/v3') {
|
|
1598
|
+
throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support object-local native bounds. Upgrade DeepBlend; ordinary reads remain available.')
|
|
1599
|
+
}
|
|
1596
1600
|
const report = verifyGeometryReport(run.envelope?.result, request)
|
|
1597
1601
|
assertDigest(await digest(source))
|
|
1598
1602
|
aborted()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-provider-local",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@deepblend/dsh-blender-contracts": "0.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.14.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""Scene vocabulary implemented by this runtime's compiler, not a Blender hardware claim."""
|
|
2
|
-
SCENE_FEATURES = ("geometry.primitive.dimensions",)
|
|
2
|
+
SCENE_FEATURES = ("geometry.primitive.dimensions", "geometry.procedural.mesh", "geometry.modifier.subdivision", "geometry.modifier.surface_scatter")
|
|
@@ -5,7 +5,7 @@ import bpy
|
|
|
5
5
|
from deepblend_util import ActionError
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def apply_model_modifiers(entities, entity_meshes):
|
|
8
|
+
def apply_model_modifiers(entities, entity_meshes, animation_tracks=None):
|
|
9
9
|
"""Apply dependency-ordered, bounded mesh operations to actual geometry."""
|
|
10
10
|
by_id = {entity["id"]: entity for entity in entities}
|
|
11
11
|
done, active = set(), set()
|
|
@@ -21,12 +21,43 @@ def apply_model_modifiers(entities, entity_meshes):
|
|
|
21
21
|
if entity_id in done:
|
|
22
22
|
return
|
|
23
23
|
if entity_id in active:
|
|
24
|
-
raise ActionError("SCENE_SPEC_INVALID", "
|
|
24
|
+
raise ActionError("SCENE_SPEC_INVALID", "modifier dependency cycle: " + entity_id)
|
|
25
25
|
active.add(entity_id)
|
|
26
26
|
entity = by_id[entity_id]
|
|
27
|
-
for entry in entity.get("modifiers", []):
|
|
27
|
+
for index, entry in enumerate(entity.get("modifiers", [])):
|
|
28
28
|
kind = entry["type"]
|
|
29
29
|
obj = single_mesh(entity_id)
|
|
30
|
+
if kind == 'surface_scatter':
|
|
31
|
+
from deepblend_scatter import apply_surface_scatter
|
|
32
|
+
targets = entry.get('targetEntityIds')
|
|
33
|
+
if not isinstance(targets, list) or not 1 <= len(targets) <= 8 or any(not isinstance(key, str) for key in targets) or len(set(targets)) != len(targets):
|
|
34
|
+
raise ActionError('SCENE_SPEC_INVALID', 'surface_scatter needs 1 to 8 distinct target IDs')
|
|
35
|
+
if index != len(entity.get('modifiers', [])) - 1 or entity_id in targets:
|
|
36
|
+
raise ActionError('SCENE_SPEC_INVALID', 'surface_scatter must be last and cannot target itself')
|
|
37
|
+
animated = {track.get('targetEntityId') for track in animation_tracks or []
|
|
38
|
+
if str(track.get('property', '')).split('.')[0] in ('location', 'rotationEuler', 'scale')}
|
|
39
|
+
dependency_keys = set()
|
|
40
|
+
def collect(key):
|
|
41
|
+
if key in dependency_keys:
|
|
42
|
+
return
|
|
43
|
+
dependency_keys.add(key)
|
|
44
|
+
for value in by_id.get(key, {}).get('modifiers', []):
|
|
45
|
+
if value.get('type') == 'boolean':
|
|
46
|
+
collect(value.get('targetEntityId'))
|
|
47
|
+
for key in [entity_id] + targets:
|
|
48
|
+
collect(key)
|
|
49
|
+
for key in dependency_keys:
|
|
50
|
+
part = by_id.get(key)
|
|
51
|
+
if part is None or part.get('type') != 'generator' or key in animated or any(
|
|
52
|
+
part.get(field) is not None for field in ('armatureId', 'rigidBody', 'cloth', 'softBody', 'fluid')):
|
|
53
|
+
raise ActionError('SCENE_SPEC_INVALID', 'surface_scatter requires static generated mesh parts without rigs or physics')
|
|
54
|
+
if key != entity_id and any(value.get('type') == 'surface_scatter' for value in part.get('modifiers', [])):
|
|
55
|
+
raise ActionError('SCENE_SPEC_INVALID', 'nested surface_scatter targets are unavailable')
|
|
56
|
+
for target in targets:
|
|
57
|
+
visit(target)
|
|
58
|
+
apply_surface_scatter(obj, [(key, single_mesh(key)) for key in targets], entry)
|
|
59
|
+
bpy.context.view_layer.update()
|
|
60
|
+
continue
|
|
30
61
|
if kind == "boolean":
|
|
31
62
|
target = entry["targetEntityId"]
|
|
32
63
|
if target not in by_id:
|
|
@@ -34,9 +65,16 @@ def apply_model_modifiers(entities, entity_meshes):
|
|
|
34
65
|
visit(target)
|
|
35
66
|
operand = single_mesh(target)
|
|
36
67
|
estimate = len(obj.data.polygons) * (entry.get("count", 1) if kind == "array" else 1)
|
|
68
|
+
if kind == "subdivision":
|
|
69
|
+
levels = entry.get('levels')
|
|
70
|
+
if isinstance(levels, bool) or not isinstance(levels, int) or not 1 <= levels <= 3:
|
|
71
|
+
raise ActionError('SCENE_SPEC_INVALID', 'subdivision needs 1 to 3 levels')
|
|
72
|
+
if entry.get('scheme', 'catmull-clark') not in ('catmull-clark', 'simple') or entry.get('boundaryMode', 'all') not in ('all', 'preserve-corners'):
|
|
73
|
+
raise ActionError('SCENE_SPEC_INVALID', 'invalid subdivision scheme or boundary mode')
|
|
74
|
+
estimate = sum(len(polygon.vertices) for polygon in obj.data.polygons) * 4 ** (levels - 1)
|
|
37
75
|
if estimate > 500_000:
|
|
38
76
|
raise ActionError("SCENE_SPEC_INVALID", "modifier exceeds the 500000 polygon per-entity budget")
|
|
39
|
-
modifier = obj.modifiers.new("db_model_" + kind, kind.upper())
|
|
77
|
+
modifier = obj.modifiers.new("db_model_" + kind, 'SUBSURF' if kind == 'subdivision' else kind.upper())
|
|
40
78
|
if kind == "solidify":
|
|
41
79
|
modifier.thickness = float(entry["thickness"])
|
|
42
80
|
modifier.offset = float(entry.get("offset", -1))
|
|
@@ -49,6 +87,10 @@ def apply_model_modifiers(entities, entity_meshes):
|
|
|
49
87
|
modifier.use_relative_offset = False
|
|
50
88
|
modifier.use_constant_offset = True
|
|
51
89
|
modifier.constant_offset_displace = entry["offset"]
|
|
90
|
+
elif kind == 'subdivision':
|
|
91
|
+
modifier.subdivision_type = 'SIMPLE' if entry.get('scheme') == 'simple' else 'CATMULL_CLARK'
|
|
92
|
+
modifier.levels = modifier.render_levels = entry['levels']
|
|
93
|
+
modifier.boundary_smooth = 'PRESERVE_CORNERS' if entry.get('boundaryMode') == 'preserve-corners' else 'ALL'
|
|
52
94
|
elif kind == "boolean":
|
|
53
95
|
modifier.operation = entry["operation"].upper()
|
|
54
96
|
modifier.solver = "EXACT"
|
|
@@ -15,6 +15,20 @@ def file_digest(path):
|
|
|
15
15
|
return digest.hexdigest()
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def object_axis_frame(matrix):
|
|
19
|
+
values = [[float(x) for x in row] for row in matrix]
|
|
20
|
+
if not all(math.isfinite(x) for row in values for x in row) or values[3] != [0, 0, 0, 1]:
|
|
21
|
+
return None
|
|
22
|
+
lengths = [math.sqrt(sum(values[row][axis] ** 2 for row in range(3))) for axis in range(3)]
|
|
23
|
+
if any(not math.isfinite(x) or x <= 1e-12 for x in lengths):
|
|
24
|
+
return None
|
|
25
|
+
columns = [[values[row][axis] / lengths[axis] for row in range(3)] for axis in range(3)]
|
|
26
|
+
if any(abs(sum(columns[a][row] * columns[b][row] for row in range(3))) > 1e-6 for a in range(3) for b in range(a + 1, 3)):
|
|
27
|
+
return None
|
|
28
|
+
basis = [[columns[axis][row] for axis in range(3)] + [values[row][3]] for row in range(3)] + [[0, 0, 0, 1]]
|
|
29
|
+
return lengths, basis
|
|
30
|
+
|
|
31
|
+
|
|
18
32
|
def inspect_geometry(request, options):
|
|
19
33
|
import bpy
|
|
20
34
|
payload = request.get('payload')
|
|
@@ -26,6 +40,9 @@ def inspect_geometry(request, options):
|
|
|
26
40
|
len(set(ids)) != len(ids) or type(frame) is not int or not 0 <= frame <= 1000000 or
|
|
27
41
|
not isinstance(expected, str) or re.fullmatch('[a-f0-9]{64}', expected) is None):
|
|
28
42
|
raise ActionError('BLENDER_SCRIPT_ERROR', 'Geometry inspection requires 1–8 distinct entity IDs, an explicit frame and checkpoint SHA256.')
|
|
43
|
+
object_axes = payload.get('objectAxes', False)
|
|
44
|
+
if type(object_axes) is not bool:
|
|
45
|
+
raise ActionError('BLENDER_SCRIPT_ERROR', 'objectAxes must be boolean.')
|
|
29
46
|
samples = payload.get('surfaceSamples')
|
|
30
47
|
if 'surfaceSamples' in payload:
|
|
31
48
|
from deepblend_inspect_surface import validate_surface_selection
|
|
@@ -61,6 +78,7 @@ def inspect_geometry(request, options):
|
|
|
61
78
|
entry['unavailableReason'] = 'viewport-disabled'
|
|
62
79
|
else:
|
|
63
80
|
evaluated = obj.evaluated_get(graph)
|
|
81
|
+
axis_frame = object_axis_frame(evaluated.matrix_world) if object_axes else None
|
|
64
82
|
mesh = evaluated.to_mesh()
|
|
65
83
|
try:
|
|
66
84
|
if mesh is None or not mesh.vertices:
|
|
@@ -68,13 +86,17 @@ def inspect_geometry(request, options):
|
|
|
68
86
|
else:
|
|
69
87
|
low = [math.inf] * 3
|
|
70
88
|
high = [-math.inf] * 3
|
|
89
|
+
local_low, local_high = [math.inf] * 3, [-math.inf] * 3
|
|
71
90
|
finite = True
|
|
72
91
|
for vertex in mesh.vertices:
|
|
73
92
|
point = evaluated.matrix_world @ vertex.co
|
|
74
|
-
if not all(math.isfinite(x) for x in point):
|
|
93
|
+
if not all(math.isfinite(x) for x in point) or not all(math.isfinite(x) for x in vertex.co):
|
|
75
94
|
finite = False
|
|
76
95
|
break
|
|
77
96
|
for axis in range(3):
|
|
97
|
+
if object_axes and axis_frame is not None:
|
|
98
|
+
local_low[axis] = min(local_low[axis], float(vertex.co[axis]) * axis_frame[0][axis])
|
|
99
|
+
local_high[axis] = max(local_high[axis], float(vertex.co[axis]) * axis_frame[0][axis])
|
|
78
100
|
low[axis] = min(low[axis], float(point[axis]))
|
|
79
101
|
high[axis] = max(high[axis], float(point[axis]))
|
|
80
102
|
matrix = [[float(x) for x in row] for row in evaluated.matrix_world]
|
|
@@ -85,6 +107,11 @@ def inspect_geometry(request, options):
|
|
|
85
107
|
entry.update(available=True, worldBoundsMeters={'min': low, 'max': high},
|
|
86
108
|
worldAxisExtentMm=[(high[i] - low[i]) * 1000 for i in range(3)],
|
|
87
109
|
worldMatrix=matrix, scale=scale, vertexCount=len(mesh.vertices), polygonCount=len(mesh.polygons))
|
|
110
|
+
if object_axes and axis_frame is None:
|
|
111
|
+
entry['objectAxisStatus'] = 'unsupported-object-axis-basis'
|
|
112
|
+
elif object_axes:
|
|
113
|
+
entry.update(objectAxisStatus='measured', objectAxisWorldMatrix=axis_frame[1], objectAxisBoundsMeters={'min': local_low, 'max': local_high},
|
|
114
|
+
objectAxisExtentMm=[(local_high[i] - local_low[i]) * 1000 for i in range(3)])
|
|
88
115
|
finally:
|
|
89
116
|
evaluated.to_mesh_clear()
|
|
90
117
|
objects.append(entry)
|
|
@@ -95,6 +122,9 @@ def inspect_geometry(request, options):
|
|
|
95
122
|
from deepblend_inspect_surface import inspect_surface_samples
|
|
96
123
|
report['schemaVersion'] = 'deepblend.geometry-inspection/v2'
|
|
97
124
|
report['surfaceSamples'] = inspect_surface_samples(samples, graph)
|
|
125
|
+
if object_axes:
|
|
126
|
+
report['schemaVersion'] = 'deepblend.geometry-inspection/v3'
|
|
127
|
+
report['objectAxisBasis'] = 'evaluated-object-axes-scaled'
|
|
98
128
|
if file_digest(checkpoint) != expected:
|
|
99
129
|
raise ActionError('SCENE_VALIDATION_FAILED', 'The checkpoint changed during geometry inspection.')
|
|
100
130
|
return report, [], []
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Bounded indexed control geometry. Inputs are data, never executable text."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
import bpy
|
|
5
|
+
from deepblend_util import ActionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_mesh(name, spec):
|
|
9
|
+
vertices, faces = spec.get('vertices'), spec.get('faces')
|
|
10
|
+
|
|
11
|
+
def invalid(message):
|
|
12
|
+
raise ActionError('SCENE_SPEC_INVALID', 'mesh: ' + message)
|
|
13
|
+
|
|
14
|
+
def number(value):
|
|
15
|
+
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value)
|
|
16
|
+
|
|
17
|
+
if not isinstance(vertices, list) or not 3 <= len(vertices) <= 32768:
|
|
18
|
+
invalid('requires 3 to 32768 local-space vertices')
|
|
19
|
+
if any(not isinstance(point, list) or len(point) != 3 or
|
|
20
|
+
any(not number(value) or abs(value) > 1e6 for value in point) for point in vertices):
|
|
21
|
+
invalid('vertices need finite bounded local coordinates')
|
|
22
|
+
if not isinstance(faces, list) or not 1 <= len(faces) <= 32768:
|
|
23
|
+
invalid('requires 1 to 32768 indexed faces')
|
|
24
|
+
if any(not isinstance(face, list) or not 3 <= len(face) <= 32 for face in faces):
|
|
25
|
+
invalid('faces need 3 to 32 corners')
|
|
26
|
+
if sum(len(face) for face in faces) > 131072:
|
|
27
|
+
invalid('exceeds the 131072 face-corner budget')
|
|
28
|
+
seen = set()
|
|
29
|
+
for face in faces:
|
|
30
|
+
if any(isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(vertices) for index in face):
|
|
31
|
+
invalid('face index outside vertices')
|
|
32
|
+
key = frozenset(face)
|
|
33
|
+
if len(key) != len(face) or key in seen:
|
|
34
|
+
invalid('repeated face or face vertex')
|
|
35
|
+
seen.add(key)
|
|
36
|
+
origin = vertices[face[0]]
|
|
37
|
+
points = [[value - origin[axis] for axis, value in enumerate(vertices[index])] for index in face]
|
|
38
|
+
normal = [0.0, 0.0, 0.0]
|
|
39
|
+
for a, b in zip(points[1:-1], points[2:]):
|
|
40
|
+
normal[0] += a[1] * b[2] - a[2] * b[1]
|
|
41
|
+
normal[1] += a[2] * b[0] - a[0] * b[2]
|
|
42
|
+
normal[2] += a[0] * b[1] - a[1] * b[0]
|
|
43
|
+
scale_squared = max(sum(value * value for value in point) for point in points)
|
|
44
|
+
if math.hypot(*normal) <= scale_squared * 1e-12:
|
|
45
|
+
invalid('zero or unstable face area')
|
|
46
|
+
face_uvs = spec.get('faceUvs')
|
|
47
|
+
if face_uvs is not None and (not isinstance(face_uvs, list) or len(face_uvs) != len(faces) or
|
|
48
|
+
any(not isinstance(corners, list) or len(corners) != len(face) or
|
|
49
|
+
any(not isinstance(uv, list) or len(uv) != 2 or any(not number(v) for v in uv) for uv in corners)
|
|
50
|
+
for face, corners in zip(faces, face_uvs))):
|
|
51
|
+
invalid('faceUvs need one finite UV pair per face corner')
|
|
52
|
+
if 'smooth' in spec and not isinstance(spec['smooth'], bool):
|
|
53
|
+
invalid('smooth must be boolean')
|
|
54
|
+
|
|
55
|
+
mesh = bpy.data.meshes.new(name + '_mesh')
|
|
56
|
+
try:
|
|
57
|
+
mesh.from_pydata(vertices, [], faces)
|
|
58
|
+
mesh.update(calc_edges=True)
|
|
59
|
+
if mesh.validate(verbose=False, clean_customdata=False):
|
|
60
|
+
invalid('Blender refused the authored topology')
|
|
61
|
+
uv_layer = mesh.uv_layers.new(name='UVMap')
|
|
62
|
+
low = [min(point[axis] for point in vertices) for axis in range(3)]
|
|
63
|
+
extent = [max(point[axis] for point in vertices) - low[axis] for axis in range(3)]
|
|
64
|
+
axes = sorted(range(3), key=lambda axis: (-extent[axis], axis))[:2]
|
|
65
|
+
for index, polygon in enumerate(mesh.polygons):
|
|
66
|
+
polygon.use_smooth = spec.get('smooth', False)
|
|
67
|
+
for corner, loop in enumerate(polygon.loop_indices):
|
|
68
|
+
coordinate = face_uvs[index][corner] if face_uvs is not None else [
|
|
69
|
+
(vertices[mesh.loops[loop].vertex_index][axis] - low[axis]) / (extent[axis] or 1) for axis in axes]
|
|
70
|
+
uv_layer.data[loop].uv = coordinate
|
|
71
|
+
except Exception:
|
|
72
|
+
bpy.data.meshes.remove(mesh)
|
|
73
|
+
raise
|
|
74
|
+
for selected in bpy.context.selected_objects:
|
|
75
|
+
selected.select_set(False)
|
|
76
|
+
obj = bpy.data.objects.new(name, mesh)
|
|
77
|
+
bpy.context.scene.collection.objects.link(obj)
|
|
78
|
+
bpy.context.view_layer.objects.active = obj
|
|
79
|
+
obj.select_set(True)
|
|
80
|
+
return obj
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Bounded, deterministic placement of a prototype on static closed meshes.
|
|
2
|
+
|
|
3
|
+
This is a runtime modeling primitive, not a case/recipe script. Only structured
|
|
4
|
+
SceneSpec data enters it. Every emitted attachment vertex is checked against
|
|
5
|
+
the actual target solid before the new mesh replaces the prototype.
|
|
6
|
+
"""
|
|
7
|
+
import bisect
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import random
|
|
11
|
+
from array import array
|
|
12
|
+
|
|
13
|
+
import bmesh
|
|
14
|
+
import bpy
|
|
15
|
+
from mathutils import Quaternion, Vector
|
|
16
|
+
from mathutils.bvhtree import BVHTree
|
|
17
|
+
|
|
18
|
+
from deepblend_util import ActionError
|
|
19
|
+
|
|
20
|
+
MAX_POLYGONS = 500_000
|
|
21
|
+
MAX_VERTICES = 1_000_000
|
|
22
|
+
MAX_LOOPS = 2_000_000
|
|
23
|
+
MAX_TARGET_POLYGONS = 131_072
|
|
24
|
+
MAX_TARGET_TRIANGLES = 262_144
|
|
25
|
+
RAY_DIRECTIONS = tuple(Vector(value).normalized() for value in
|
|
26
|
+
((1, .173, .287), (.219, 1, .131), (.157, .271, 1)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _refuse(message):
|
|
30
|
+
raise ActionError('SCENE_SPEC_INVALID', 'surface_scatter: ' + message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _finite(value):
|
|
34
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _inside(target, point):
|
|
38
|
+
"""Three independent parity rays; ambiguous/excessive crossings are refused."""
|
|
39
|
+
votes = 0
|
|
40
|
+
for direction in RAY_DIRECTIONS:
|
|
41
|
+
origin, hits = point.copy(), 0
|
|
42
|
+
for _ in range(256):
|
|
43
|
+
location, _, _, _ = target['bvh'].ray_cast(origin, direction, target['ray_length'])
|
|
44
|
+
if location is None:
|
|
45
|
+
break
|
|
46
|
+
hits += 1
|
|
47
|
+
origin = location + direction * target['epsilon'] * 4
|
|
48
|
+
else:
|
|
49
|
+
return False
|
|
50
|
+
votes += hits % 2
|
|
51
|
+
return votes >= 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _target(entity_id, obj):
|
|
55
|
+
mesh = obj.data
|
|
56
|
+
bm = bmesh.new()
|
|
57
|
+
try:
|
|
58
|
+
bm.from_mesh(mesh)
|
|
59
|
+
if not bm.faces or any(not edge.is_manifold for edge in bm.edges) or abs(bm.calc_volume(signed=True)) <= 1e-18:
|
|
60
|
+
_refuse('target "%s" needs a closed, non-degenerate solid mesh' % entity_id)
|
|
61
|
+
finally:
|
|
62
|
+
bm.free()
|
|
63
|
+
points = [obj.matrix_world @ vertex.co for vertex in mesh.vertices]
|
|
64
|
+
if any(not all(math.isfinite(value) for value in point) for point in points):
|
|
65
|
+
_refuse('target has non-finite world coordinates')
|
|
66
|
+
mesh.calc_loop_triangles()
|
|
67
|
+
triangles = [tuple(triangle.vertices) for triangle in mesh.loop_triangles]
|
|
68
|
+
diagonal = math.sqrt(sum((max(point[axis] for point in points) - min(point[axis] for point in points)) ** 2 for axis in range(3)))
|
|
69
|
+
epsilon = max(1e-6, max(abs(value) for point in points for value in point) * 4e-7, diagonal * 1e-7)
|
|
70
|
+
return {'id': entity_id, 'points': points, 'triangles': triangles,
|
|
71
|
+
'bvh': BVHTree.FromPolygons(points, triangles, all_triangles=True),
|
|
72
|
+
'epsilon': epsilon, 'ray_length': max(1.0, diagonal * 4)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply_surface_scatter(obj, targets, entry):
|
|
76
|
+
count = entry.get('count')
|
|
77
|
+
seed = entry.get('seed', 0)
|
|
78
|
+
anchors = entry.get('anchorVertexIndices')
|
|
79
|
+
scales = entry.get('scaleRange', [1.0, 1.0])
|
|
80
|
+
depth_mm = entry.get('embedDepthMm', 5.0)
|
|
81
|
+
bias = entry.get('upwardBias', 0.0)
|
|
82
|
+
if isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= 512:
|
|
83
|
+
_refuse('count must be an integer from 1 to 512')
|
|
84
|
+
if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 2_147_483_647:
|
|
85
|
+
_refuse('seed must be an integer from 0 to 2147483647')
|
|
86
|
+
if not isinstance(targets, list) or not 1 <= len(targets) <= 8 or len({key for key, _ in targets}) != len(targets):
|
|
87
|
+
_refuse('select 1 to 8 distinct static target meshes')
|
|
88
|
+
if not isinstance(anchors, list) or not 1 <= len(anchors) <= 8 or any(
|
|
89
|
+
isinstance(index, bool) or not isinstance(index, int) or index < 0 for index in anchors) or len(set(anchors)) != len(anchors):
|
|
90
|
+
_refuse('anchorVertexIndices needs 1 to 8 distinct evaluated prototype vertex indices')
|
|
91
|
+
if not isinstance(scales, list) or len(scales) != 2 or any(not _finite(value) for value in scales) or not .05 <= scales[0] <= scales[1] <= 20:
|
|
92
|
+
_refuse('scaleRange must be an ordered positive range within 0.05 to 20')
|
|
93
|
+
if not _finite(depth_mm) or not 0 < depth_mm <= 100 or not _finite(bias) or not 0 <= bias <= 1:
|
|
94
|
+
_refuse('embedDepthMm must be within (0,100] and upwardBias within [0,1]')
|
|
95
|
+
source = obj.data
|
|
96
|
+
if not source.polygons or len(source.polygons) * count > MAX_POLYGONS or len(source.vertices) * count > MAX_VERTICES or len(source.loops) * count > MAX_LOOPS:
|
|
97
|
+
_refuse('prototype times count exceeds the vertex, polygon or UV-corner budget before allocation')
|
|
98
|
+
if any(index >= len(source.vertices) for index in anchors):
|
|
99
|
+
_refuse('attachment index exceeds the evaluated prototype vertex count')
|
|
100
|
+
used = {index for polygon in source.polygons for index in polygon.vertices}
|
|
101
|
+
if any(index not in used for index in anchors):
|
|
102
|
+
_refuse('attachment vertices must belong to actual prototype faces')
|
|
103
|
+
if sum(len(target.data.polygons) for _, target in targets) > MAX_TARGET_POLYGONS:
|
|
104
|
+
_refuse('targets exceed the 131072-polygon surface budget')
|
|
105
|
+
if sum(max(0, len(polygon.vertices) - 2) for _, target in targets for polygon in target.data.polygons) > MAX_TARGET_TRIANGLES:
|
|
106
|
+
_refuse('targets exceed the 262144-triangle surface budget before triangulation')
|
|
107
|
+
target_records = [_target(key, target) for key, target in targets]
|
|
108
|
+
if sum(len(target['triangles']) for target in target_records) > MAX_TARGET_TRIANGLES:
|
|
109
|
+
_refuse('targets exceed the 262144-triangle surface budget')
|
|
110
|
+
weighted, areas, total = [], [], 0.0
|
|
111
|
+
for target in target_records:
|
|
112
|
+
for triangle in target['triangles']:
|
|
113
|
+
a, b, c = (target['points'][index] for index in triangle)
|
|
114
|
+
normal = (b - a).cross(c - a)
|
|
115
|
+
area = normal.length * .5
|
|
116
|
+
if area <= 1e-14:
|
|
117
|
+
continue
|
|
118
|
+
normal.normalize()
|
|
119
|
+
total += area
|
|
120
|
+
weighted.append((target, a, b, c, normal))
|
|
121
|
+
areas.append(total)
|
|
122
|
+
if not weighted or not math.isfinite(total):
|
|
123
|
+
_refuse('targets have no measurable triangle surface')
|
|
124
|
+
prototype = [vertex.co.copy() for vertex in source.vertices]
|
|
125
|
+
if any(not all(math.isfinite(value) for value in point) for point in prototype):
|
|
126
|
+
_refuse('prototype has non-finite coordinates')
|
|
127
|
+
anchor = sum((prototype[index] for index in anchors), Vector()) / len(anchors)
|
|
128
|
+
relative = [point - anchor for point in prototype]
|
|
129
|
+
faces = [tuple(polygon.vertices) for polygon in source.polygons]
|
|
130
|
+
smooth = [polygon.use_smooth for polygon in source.polygons]
|
|
131
|
+
material_indices = [polygon.material_index for polygon in source.polygons]
|
|
132
|
+
materials = list(source.materials)
|
|
133
|
+
uv_maps = [(layer.name, [(item.uv.x, item.uv.y) for item in layer.data]) for layer in source.uv_layers]
|
|
134
|
+
matrix = obj.matrix_world.copy()
|
|
135
|
+
try:
|
|
136
|
+
inverse = matrix.inverted()
|
|
137
|
+
except ValueError:
|
|
138
|
+
_refuse('prototype transform is singular')
|
|
139
|
+
linear = matrix.to_3x3()
|
|
140
|
+
axis = linear @ Vector((0, 0, 1))
|
|
141
|
+
if axis.length <= 1e-12:
|
|
142
|
+
_refuse('prototype Z axis is degenerate')
|
|
143
|
+
axis.normalize()
|
|
144
|
+
rng = random.Random(seed)
|
|
145
|
+
vertices, output_faces, placements = [], [], []
|
|
146
|
+
for _ in range(count * 16):
|
|
147
|
+
if len(placements) == count:
|
|
148
|
+
break
|
|
149
|
+
target, a, b, c, face_normal = weighted[min(len(weighted) - 1, bisect.bisect_right(areas, rng.random() * total))]
|
|
150
|
+
u, v = math.sqrt(rng.random()), rng.random()
|
|
151
|
+
surface = a * (1 - u) + b * (u * (1 - v)) + c * (u * v)
|
|
152
|
+
normal = face_normal.copy()
|
|
153
|
+
epsilon = target['epsilon']
|
|
154
|
+
if not _inside(target, surface - normal * epsilon):
|
|
155
|
+
if not _inside(target, surface + normal * epsilon):
|
|
156
|
+
continue
|
|
157
|
+
normal.negate()
|
|
158
|
+
_, _, _, chord = target['bvh'].ray_cast(surface - normal * epsilon, -normal, target['ray_length'])
|
|
159
|
+
if chord is None or not math.isfinite(chord):
|
|
160
|
+
continue
|
|
161
|
+
depth = min(depth_mm / 1000, (chord + epsilon) * .25)
|
|
162
|
+
if depth < epsilon * 8:
|
|
163
|
+
continue
|
|
164
|
+
point = surface - normal * depth
|
|
165
|
+
direction = normal * (1 - bias) + Vector((0, 0, 1)) * bias
|
|
166
|
+
if direction.length <= 1e-10:
|
|
167
|
+
direction = Vector((0, 0, 1))
|
|
168
|
+
direction.normalize()
|
|
169
|
+
scale = rng.uniform(*scales)
|
|
170
|
+
rotation = Quaternion(direction, rng.random() * math.tau).to_matrix() @ axis.rotation_difference(direction).to_matrix()
|
|
171
|
+
transform = rotation @ linear * scale
|
|
172
|
+
# Check the actual float32 round trip that will be stored in the mesh,
|
|
173
|
+
# not only an ideal surface point or the object's declared origin.
|
|
174
|
+
attachment = [matrix @ (inverse @ (point + transform @ relative[index])) for index in anchors]
|
|
175
|
+
distances = [target['bvh'].find_nearest(value)[3] for value in attachment]
|
|
176
|
+
if any(distance is None or distance < epsilon * 4 for distance in distances) or any(not _inside(target, value) for value in attachment):
|
|
177
|
+
continue
|
|
178
|
+
offset = len(vertices)
|
|
179
|
+
emitted = [inverse @ (point + transform @ value) for value in relative]
|
|
180
|
+
if any(not all(math.isfinite(value) for value in point) for point in emitted):
|
|
181
|
+
_refuse('placement produced non-finite coordinates')
|
|
182
|
+
vertices.extend(emitted)
|
|
183
|
+
output_faces.extend(tuple(index + offset for index in face) for face in faces)
|
|
184
|
+
placements.append({'index': len(placements), 'targetEntityId': target['id'],
|
|
185
|
+
'anchorWorldMeters': list(point), 'embedDepthMm': depth * 1000, 'scale': scale})
|
|
186
|
+
if len(placements) != count:
|
|
187
|
+
_refuse('could place only %d/%d instances with all selected attachment vertices inside the actual targets; choose fitting anchors, scale or targets' % (len(placements), count))
|
|
188
|
+
mesh = bpy.data.meshes.new(source.name + '_surface_scatter')
|
|
189
|
+
try:
|
|
190
|
+
mesh.from_pydata(vertices, [], output_faces)
|
|
191
|
+
mesh.update()
|
|
192
|
+
if mesh.validate(verbose=False, clean_customdata=False):
|
|
193
|
+
_refuse('emitted geometry required a native repair')
|
|
194
|
+
for material in materials:
|
|
195
|
+
mesh.materials.append(material)
|
|
196
|
+
for index, polygon in enumerate(mesh.polygons):
|
|
197
|
+
prototype_index = index % len(faces)
|
|
198
|
+
polygon.use_smooth = smooth[prototype_index]
|
|
199
|
+
polygon.material_index = material_indices[prototype_index]
|
|
200
|
+
for name, coordinates in uv_maps:
|
|
201
|
+
layer = mesh.uv_layers.new(name=name)
|
|
202
|
+
layer.data.foreach_set('uv', array('f', (coordinate for _ in range(count) for uv in coordinates for coordinate in uv)))
|
|
203
|
+
obj.data = mesh
|
|
204
|
+
obj['deepblend_surface_scatter'] = json.dumps({'schemaVersion': 'deepblend.surface-scatter/v1',
|
|
205
|
+
'mode': 'static-closed-surface', 'count': count, 'seed': seed,
|
|
206
|
+
'prototypeVertexCount': len(prototype), 'prototypePolygonCount': len(faces),
|
|
207
|
+
'anchorVertexIndices': anchors, 'targetEntityIds': [key for key, _ in targets], 'placements': placements}, allow_nan=False)
|
|
208
|
+
except Exception:
|
|
209
|
+
if mesh.users == 0:
|
|
210
|
+
bpy.data.meshes.remove(mesh)
|
|
211
|
+
raise
|
|
212
|
+
if source.users == 0:
|
|
213
|
+
bpy.data.meshes.remove(source)
|
|
@@ -385,11 +385,14 @@ def create_generator(name, spec):
|
|
|
385
385
|
elif shape == "handled_cup":
|
|
386
386
|
from deepblend_vessel import create_handled_cup
|
|
387
387
|
create_handled_cup(name, spec)
|
|
388
|
+
elif shape == "mesh":
|
|
389
|
+
from deepblend_mesh import create_mesh
|
|
390
|
+
create_mesh(name, spec)
|
|
388
391
|
else:
|
|
389
392
|
raise ActionError(
|
|
390
393
|
"BLENDER_SCRIPT_ERROR",
|
|
391
394
|
'unknown generator shape "%s"' % (shape,),
|
|
392
|
-
{"supported": ["cube", "rounded_box", "uv_sphere", "cylinder", "cone", "plane", "torus", "lathe", "curve", "handled_cup"]},
|
|
395
|
+
{"supported": ["cube", "rounded_box", "uv_sphere", "cylinder", "cone", "plane", "torus", "lathe", "curve", "handled_cup", "mesh"]},
|
|
393
396
|
)
|
|
394
397
|
|
|
395
398
|
obj = bpy.context.active_object
|
|
@@ -2105,7 +2108,7 @@ def build_scene(spec, options, guard):
|
|
|
2105
2108
|
for polygon in getattr(obj.data, "polygons", []):
|
|
2106
2109
|
polygon.material_index = 0
|
|
2107
2110
|
generator = entity.get("generator", {})
|
|
2108
|
-
if generator.get("bevel") or generator.get("shape") in ("uv_sphere", "cylinder", "cone", "torus", "lathe", "curve", "handled_cup"):
|
|
2111
|
+
if (generator.get("bevel") and generator.get("shape") != "mesh") or generator.get("shape") in ("uv_sphere", "cylinder", "cone", "torus", "lathe", "curve", "handled_cup"):
|
|
2109
2112
|
_shade_smooth(obj)
|
|
2110
2113
|
|
|
2111
2114
|
if entity.get("materialBindings"):
|
|
@@ -2128,7 +2131,7 @@ def build_scene(spec, options, guard):
|
|
|
2128
2131
|
simulation_objects[entity_id] = meshes[0]
|
|
2129
2132
|
entity_positions[entity_id] = [float(root.location[0]), float(root.location[1]), float(root.location[2])]
|
|
2130
2133
|
|
|
2131
|
-
apply_model_modifiers(spec.get("entities") or [], entity_meshes)
|
|
2134
|
+
apply_model_modifiers(spec.get("entities") or [], entity_meshes, spec.get('animationTracks') or [])
|
|
2132
2135
|
scene['deepblend_requires_cycles'] = False
|
|
2133
2136
|
for entry in spec.get('materials') or []:
|
|
2134
2137
|
for surface in native_material_surfaces:
|
|
@@ -2173,7 +2176,18 @@ def build_scene(spec, options, guard):
|
|
|
2173
2176
|
for camera in spec.get("cameras") or []:
|
|
2174
2177
|
cameras[camera["id"]] = build_camera(camera, entity_positions)
|
|
2175
2178
|
if cameras:
|
|
2176
|
-
|
|
2179
|
+
active_camera = (spec.get("project") or {}).get("activeCamera")
|
|
2180
|
+
if active_camera is not None and active_camera not in cameras:
|
|
2181
|
+
# Older accepted projects can contain a stale activeCamera reference.
|
|
2182
|
+
# Preserve their first-camera fallback and make that limit explicit.
|
|
2183
|
+
guard.note("SCENE_COMPILER_DECISION",
|
|
2184
|
+
'The declared active camera "%s" was not found; using first camera "%s"' %
|
|
2185
|
+
(active_camera, spec["cameras"][0]["id"]),
|
|
2186
|
+
{"requestedCameraId": active_camera, "cameraId": spec["cameras"][0]["id"]})
|
|
2187
|
+
active_camera = None
|
|
2188
|
+
# Camera arrays are canonically sorted after a patch. Adding a delivery
|
|
2189
|
+
# camera must not replace the authored active camera in the saved file.
|
|
2190
|
+
scene.camera = cameras[active_camera if active_camera is not None else spec["cameras"][0]["id"]]
|
|
2177
2191
|
|
|
2178
2192
|
report_progress("build_animation", 75)
|
|
2179
2193
|
actions = []
|
|
@@ -447,8 +447,10 @@ def _visibility(scene, target, mask, tracked, index, parts):
|
|
|
447
447
|
if silhouette == 0:
|
|
448
448
|
return 0, 1.0, []
|
|
449
449
|
|
|
450
|
+
# Measured-mask selection must not change authored body membership: a
|
|
451
|
+
# fitted panel still belongs to this body when it is not measured separately.
|
|
450
452
|
actors = [
|
|
451
|
-
candidate for candidate in (_tracked_objects(target, tracked, index))
|
|
453
|
+
candidate for candidate in (_tracked_objects(target, [*tracked, *parts], index))
|
|
452
454
|
if candidate is not target and not candidate.hide_render
|
|
453
455
|
]
|
|
454
456
|
if not actors:
|