@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 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.13.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.13.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", "boolean dependency cycle: " + entity_id)
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
- scene.camera = cameras[spec["cameras"][0]["id"]]
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: