@deepblend/dsh-blender-provider-local 0.2.20 → 0.3.1

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.
Files changed (43) hide show
  1. package/lib/index.js +50 -35
  2. package/package.json +2 -2
  3. package/python/bootstrap.py +4 -2
  4. package/python/deepblend_anisotropy.py +110 -0
  5. package/python/deepblend_asset_bundle.py +193 -0
  6. package/python/deepblend_avif_headers.py +222 -0
  7. package/python/deepblend_bridge.py +46 -4
  8. package/python/deepblend_capabilities.py +7 -0
  9. package/python/deepblend_frames.py +11 -1
  10. package/python/deepblend_geometry.py +210 -0
  11. package/python/deepblend_gltf_images.py +183 -0
  12. package/python/deepblend_gltf_import.py +95 -0
  13. package/python/deepblend_gltf_roles.py +56 -0
  14. package/python/deepblend_image_headers.py +294 -0
  15. package/python/deepblend_image_roles.py +2 -0
  16. package/python/deepblend_images.py +203 -0
  17. package/python/deepblend_imported_image_headers.py +364 -0
  18. package/python/deepblend_mesh_checks.py +145 -0
  19. package/python/deepblend_obj_images.py +54 -0
  20. package/python/deepblend_obj_import.py +58 -0
  21. package/python/deepblend_obj_resources.py +79 -0
  22. package/python/deepblend_parts.py +106 -0
  23. package/python/deepblend_render.py +53 -0
  24. package/python/deepblend_scene.py +307 -49
  25. package/python/deepblend_vessel.py +192 -0
  26. package/python/deepblend_vessel_math.py +116 -0
  27. package/python/deepblend_vessel_parameters.py +65 -0
  28. package/python/deepblend_views.py +18 -19
  29. package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
  30. package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
  31. package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
  32. package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
  33. package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
  34. package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
  35. package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
  36. package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
  37. package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
  38. package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
  39. package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
  40. package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
  41. package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
  42. package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
  43. package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
@@ -0,0 +1,192 @@
1
+ """Shared-boundary cup geometry. Construction is isolated from runtime actions.
2
+
3
+ Root strips use quintic endpoint jets. Numeric output checks do not certify
4
+ arbitrary continuous surfaces or artistic quality.
5
+ """
6
+ import math
7
+ import bpy
8
+ import bmesh
9
+ from mathutils import Vector
10
+ from mathutils.geometry import delaunay_2d_cdt
11
+ from deepblend_vessel_parameters import handled_cup_parameters
12
+ from deepblend_mesh_checks import validate_handled_cup_mesh
13
+ from deepblend_util import ActionError
14
+ from deepblend_vessel_math import CupSurface, add, unit
15
+
16
+
17
+ class MeshBuilder:
18
+
19
+ def __init__(self):
20
+ self.vertices = []
21
+ self.faces = []
22
+ self.uv = []
23
+ self.regions = []
24
+ self.keys = {}
25
+ self.normals = []
26
+
27
+ def vertex(self, p, normal):
28
+ normal = unit(normal)
29
+ key = tuple((round(c, 10) for c in p))
30
+ if key not in self.keys:
31
+ self.keys[key] = len(self.vertices)
32
+ self.vertices.append(tuple(p))
33
+ self.normals.append(normal)
34
+ else:
35
+ previous = self.normals[self.keys[key]]
36
+ if sum((a-b)**2 for a,b in zip(previous, normal)) > 1e-10:
37
+ raise ActionError('SCENE_SPEC_INVALID', 'handled_cup output: incompatible shared surface normals')
38
+ return self.keys[key]
39
+
40
+ def face(self, ids, uv, region):
41
+ unique = []
42
+ coords = []
43
+ for (i, c) in zip(ids, uv):
44
+ if i not in unique:
45
+ unique.append(i)
46
+ coords.append(c)
47
+ if len(unique) < 3:
48
+ return
49
+ self.faces.append(unique)
50
+ self.uv.append(coords)
51
+ self.regions.append(region)
52
+
53
+ def finish(self, name):
54
+ mesh = bpy.data.meshes.new(name)
55
+ mesh.from_pydata(self.vertices, [], self.faces)
56
+ mesh.update()
57
+ uv = mesh.uv_layers.new(name='UVMap')
58
+ attr = mesh.attributes.new('deepblend_cup_region', 'INT', 'FACE')
59
+ for (i, (polygon, coords)) in enumerate(zip(mesh.polygons, self.uv)):
60
+ attr.data[i].value = self.regions[i]
61
+ for (loop, c) in zip(polygon.loop_indices, coords):
62
+ uv.data[loop].uv = c
63
+ bm = bmesh.new()
64
+ bm.from_mesh(mesh)
65
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
66
+ bm.to_mesh(mesh)
67
+ bm.free()
68
+ obj = bpy.data.objects.new(name, mesh)
69
+ bpy.context.scene.collection.objects.link(obj)
70
+ select(obj)
71
+ bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30))
72
+ mesh.normals_split_custom_set([self.normals[loop.vertex_index] for loop in mesh.loops])
73
+ return obj
74
+
75
+ def select(obj):
76
+ bpy.ops.object.select_all(action='DESELECT')
77
+ obj.select_set(True)
78
+ bpy.context.view_layer.objects.active = obj
79
+
80
+ def inside(point, polygon):
81
+ (x, y) = point
82
+ hit = False
83
+ for (a, b) in zip(polygon, polygon[1:] + polygon[:1]):
84
+ if (a[1] > y) != (b[1] > y) and x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]:
85
+ hit = not hit
86
+ return hit
87
+
88
+ class _HandledCupMesh(CupSurface):
89
+ """Analytic profiles sampled into one mesh; roots share cup/handle vertices.
90
+
91
+ R/T/B are cup radius/wall/base, r is the tube radius, A/L are root
92
+ footprint/length, H is attachment half-gap, N/M are angular resolutions.
93
+ """
94
+
95
+
96
+
97
+
98
+ def add_wall(self, builder, holes):
99
+ (lower, upper) = (self.C, self.HEIGHT - self.T / 2)
100
+ p = self.profile()
101
+ dist = [0]
102
+ for (a, b) in zip(p, p[1:]):
103
+ dist.append(dist[-1] + math.dist(a, b))
104
+ (v0, v1) = (dist[9] / dist[-1], dist[10] / dist[-1])
105
+ hole_coords = []
106
+ for z in (self.P['lower'], self.P['upper']):
107
+ hole_coords.append([(self.R * math.asin(self.A * math.cos(self.TAU * j / self.M) / self.R), z + self.A * math.sin(self.TAU * j / self.M)) for j in range(self.M)])
108
+ coords = []
109
+ edges = []
110
+ for row in range(self.P['wallRows'] + 1):
111
+ z = lower + (upper - lower) * row / self.P['wallRows']
112
+ for col in range(self.N + 1):
113
+ u = -math.pi * self.R + self.TAU * self.R * col / self.N
114
+ if holes and any((inside((u, z), p) for p in hole_coords)):
115
+ continue
116
+ coords.append((u, z))
117
+ if holes:
118
+ for polygon in hole_coords:
119
+ start = len(coords)
120
+ coords.extend(polygon)
121
+ edges.extend(((start + j, start + (j + 1) % self.M) for j in range(self.M)))
122
+ output = delaunay_2d_cdt([Vector(p) for p in coords], edges, [], 0, 1e-09, True)
123
+ (points, _, faces, orig, _, _) = output
124
+ points = [coords[ids[0]] if len(ids) == 1 else tuple(p) for (p, ids) in zip(points, orig)]
125
+ for face in faces:
126
+ center = tuple((sum((points[i][axis] for i in face)) / len(face) for axis in range(2)))
127
+ if holes and any((inside(center, p) for p in hole_coords)):
128
+ continue
129
+ ids = [builder.vertex((self.R * math.cos(points[i][0] / self.R), self.R * math.sin(points[i][0] / self.R), points[i][1]),
130
+ (math.cos(points[i][0] / self.R), math.sin(points[i][0] / self.R), 0)) for i in face]
131
+ builder.face(ids, [(0.25 * (points[i][0] / (self.TAU * self.R) + 0.5), v0 + (v1 - v0) * (points[i][1] - lower) / (upper - lower)) for i in face], 0)
132
+
133
+ def add_cup(self, builder, holes):
134
+ p = self.profile()
135
+ rings = []
136
+ dist = [0]
137
+ for (a, b) in zip(p, p[1:]):
138
+ dist.append(dist[-1] + math.dist(a, b))
139
+ vs = [v / dist[-1] for v in dist]
140
+ for (radius, z), (radial, vertical) in zip(p, self.profile_normals()):
141
+ rings.append([builder.vertex((radius * math.cos(-math.pi + self.TAU * j / self.N), radius * math.sin(-math.pi + self.TAU * j / self.N), z),
142
+ (radial * math.cos(-math.pi + self.TAU * j / self.N), radial * math.sin(-math.pi + self.TAU * j / self.N), vertical)) for j in range(self.N)])
143
+ for row in range(len(p) - 1):
144
+ if row == 9:
145
+ continue
146
+ for j in range(self.N):
147
+ k = (j + 1) % self.N
148
+ builder.face([rings[row][j], rings[row][k], rings[row + 1][k], rings[row + 1][j]], [(0.25 * j / self.N, vs[row]), (0.25 * (j + 1) / self.N, vs[row]), (0.25 * (j + 1) / self.N, vs[row + 1]), (0.25 * j / self.N, vs[row + 1])], 1)
149
+ self.add_wall(builder, holes)
150
+
151
+ def add_roots(self, builder):
152
+ for upper in (False, True):
153
+ rings = []
154
+ for i in range(self.P['rootSegments'] + 1):
155
+ rings.append([builder.vertex(self.root_point(i / self.P['rootSegments'], self.TAU * j / self.M, upper),
156
+ self.root_normal(i / self.P['rootSegments'], self.TAU * j / self.M, upper)) for j in range(self.M)])
157
+ for i in range(self.P['rootSegments']):
158
+ for j in range(self.M):
159
+ k = (j + 1) % self.M
160
+ u = 0.3 if upper else 0.45
161
+ builder.face([rings[i][j], rings[i][k], rings[i + 1][k], rings[i + 1][j]], [(u + 0.1 * j / self.M, i / self.P['rootSegments']), (u + 0.1 * (j + 1) / self.M, i / self.P['rootSegments']), (u + 0.1 * (j + 1) / self.M, (i + 1) / self.P['rootSegments']), (u + 0.1 * j / self.M, (i + 1) / self.P['rootSegments'])], 2 if upper else 3)
162
+
163
+ def add_handle(self, builder):
164
+ sections = []
165
+ for i in range(self.P['handleSegments'] + 1):
166
+ a = math.pi * i / self.P['handleSegments']
167
+ sections.append(((self.R + self.L + self.H * math.sin(a), 0, self.MID + self.H * math.cos(a)), (math.sin(a), 0, math.cos(a))))
168
+ rings = []
169
+ for (center, normal) in sections:
170
+ rings.append([builder.vertex(add(center, (self.r * math.sin(self.TAU * j / self.M) * normal[0], self.r * math.cos(self.TAU * j / self.M), self.r * math.sin(self.TAU * j / self.M) * normal[2])),
171
+ (math.sin(self.TAU * j / self.M) * normal[0], math.cos(self.TAU * j / self.M), math.sin(self.TAU * j / self.M) * normal[2])) for j in range(self.M)])
172
+ for i in range(len(rings) - 1):
173
+ for j in range(self.M):
174
+ k = (j + 1) % self.M
175
+ builder.face([rings[i][j], rings[i][k], rings[i + 1][k], rings[i + 1][j]], [(0.6 + 0.4 * j / self.M, i / (len(rings) - 1)), (0.6 + 0.4 * (j + 1) / self.M, i / (len(rings) - 1)), (0.6 + 0.4 * (j + 1) / self.M, (i + 1) / (len(rings) - 1)), (0.6 + 0.4 * j / self.M, (i + 1) / (len(rings) - 1))], 4)
176
+
177
+ def create_handled_cup(name, spec):
178
+ parameters=handled_cup_parameters(spec)
179
+ shape=_HandledCupMesh(parameters)
180
+ builder=MeshBuilder()
181
+ shape.add_cup(builder, True)
182
+ shape.add_roots(builder)
183
+ shape.add_handle(builder)
184
+ obj=builder.finish(name)
185
+ try:
186
+ validate_handled_cup_mesh(obj.data, parameters)
187
+ except Exception:
188
+ mesh=obj.data
189
+ bpy.data.objects.remove(obj, do_unlink=True)
190
+ bpy.data.meshes.remove(mesh)
191
+ raise
192
+ return obj
@@ -0,0 +1,116 @@
1
+ """Analytic cup surfaces, independent of Blender and mesh tessellation."""
2
+ import math
3
+
4
+ def unit(vector):
5
+ size = math.sqrt(sum(value * value for value in vector))
6
+ if size == 0 or not math.isfinite(size):
7
+ raise ValueError('invalid surface normal')
8
+ return tuple(value / size for value in vector)
9
+
10
+ def add(a, b):
11
+ return tuple((x + y for (x, y) in zip(a, b)))
12
+
13
+ def mul(a, s):
14
+ return tuple((x * s for x in a))
15
+
16
+ def sub(a, b):
17
+ return add(a, mul(b, -1))
18
+
19
+ def bezier(controls, t):
20
+ n = len(controls) - 1
21
+ return tuple((sum((math.comb(n, i) * (1 - t) ** (n - i) * t ** i * p[k] for (i, p) in enumerate(controls))) for k in range(3)))
22
+
23
+ class CupSurface:
24
+
25
+ def __init__(self, parameters):
26
+ self.P = parameters
27
+ for (key, source) in {'R': 'radius', 'T': 'wall', 'B': 'bottom', 'HEIGHT': 'height', 'C': 'footRound', 'r': 'handleRadius', 'A': 'rootRadius', 'L': 'rootLength', 'N': 'bodySegments', 'M': 'sectionSegments'}.items():
28
+ setattr(self, key, parameters[source])
29
+ self.H = (parameters['upper'] - parameters['lower']) / 2
30
+ self.MID = (parameters['upper'] + parameters['lower']) / 2
31
+ self.TAU = math.tau
32
+
33
+ def root_controls(self, theta, upper):
34
+ """Quintic Hermite jets from the cylindrical wall and half-torus surface.
35
+
36
+ rho decreases at the wall. At the tube end, alpha increases along the
37
+ outgoing torus (opposite traversal for the lower attachment).
38
+ Tension scales endpoint speeds; the second jets scale quadratically,
39
+ keeping the same adjacent-surface tangent and curvature at each join.
40
+ Missing tension resolves to 1 to retain the original surface.
41
+ """
42
+ (ct, st) = (math.cos(theta), math.sin(theta))
43
+ z = self.P['upper'] if upper else self.P['lower']
44
+ q0 = (math.sqrt(self.R * self.R - self.A * self.A * ct * ct), self.A * ct, z + self.A * st)
45
+ speed = (self.A - self.r) * self.P['rootTension']
46
+ d0 = (self.A * speed * ct * ct / q0[0], -speed * ct, -speed * st)
47
+ dd0 = (-speed * speed * ct * ct * self.R * self.R / q0[0] ** 3, 0, 0)
48
+ q1 = (self.R + self.L, self.r * ct, z + self.r * st)
49
+ sign = 1 if upper else -1
50
+ alpha_speed = self.L / self.H * self.P['rootTension']
51
+ d1 = (alpha_speed * (self.H + sign * self.r * st), 0, 0)
52
+ dd1 = (0, 0, -sign * alpha_speed ** 2 * (self.H + sign * self.r * st))
53
+ return [q0, add(q0, mul(d0, 0.2)), add(add(q0, mul(d0, 0.4)), mul(dd0, 0.05)), add(sub(q1, mul(d1, 0.4)), mul(dd1, 0.05)), sub(q1, mul(d1, 0.2)), q1]
54
+
55
+ def root_point(self, t, theta, upper):
56
+ return bezier(self.root_controls(theta, upper), t)
57
+
58
+ def root_dt(self, t, theta, upper):
59
+ c = self.root_controls(theta, upper)
60
+ return bezier([mul(sub(c[i + 1], c[i]), 5) for i in range(5)], t)
61
+
62
+ def profile(self):
63
+ points = [(0, 0), (self.R - self.C, 0)]
64
+ for k in range(1, 9):
65
+ a = -math.pi / 2 + k * math.pi / 16
66
+ points.append((self.R - self.C + self.C * math.cos(a), self.C + self.C * math.sin(a)))
67
+ points.append((self.R, self.HEIGHT - self.T / 2))
68
+ for k in range(1, 17):
69
+ a = k * math.pi / 16
70
+ points.append((self.R - self.T / 2 + self.T / 2 * math.cos(a), self.HEIGHT - self.T / 2 + self.T / 2 * math.sin(a)))
71
+ points.append((self.R - self.T, self.B + self.C))
72
+ for k in range(1, 9):
73
+ a = k * math.pi / 16
74
+ points.append((self.R - self.T - self.C + self.C * math.cos(a), self.B + self.C - self.C * math.sin(a)))
75
+ points.append((0, self.B))
76
+ return points
77
+
78
+ def root_dtheta(self, t, theta, upper):
79
+ """Exact theta partial of the quintic root, including endpoint jets."""
80
+ ct, st = math.cos(theta), math.sin(theta)
81
+ x = math.sqrt(self.R * self.R - self.A * self.A * ct * ct)
82
+ speed = (self.A - self.r) * self.P['rootTension']
83
+ q0 = (self.A * self.A * ct * st / x, -self.A * st, self.A * ct)
84
+ d0 = (-self.A * speed * ct * st * (2 / x + self.A * self.A * ct * ct / x**3),
85
+ speed * st, -speed * ct)
86
+ dd0 = (speed * speed * self.R * self.R * ct * st *
87
+ (2 / x**3 + 3 * self.A * self.A * ct * ct / x**5), 0, 0)
88
+ q1 = (0, -self.r * st, self.r * ct)
89
+ sign = 1 if upper else -1
90
+ alpha_speed = self.L / self.H * self.P['rootTension']
91
+ d1 = (alpha_speed * sign * self.r * ct, 0, 0)
92
+ dd1 = (0, 0, -alpha_speed * alpha_speed * self.r * ct)
93
+ controls = [q0, add(q0, mul(d0, .2)), add(add(q0, mul(d0, .4)), mul(dd0, .05)),
94
+ add(sub(q1, mul(d1, .4)), mul(dd1, .05)), sub(q1, mul(d1, .2)), q1]
95
+ return bezier(controls, t)
96
+
97
+ def root_normal(self, t, theta, upper):
98
+ a, b = self.root_dtheta(t, theta, upper), self.root_dt(t, theta, upper)
99
+ return unit((a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]))
100
+
101
+ def profile_normals(self):
102
+ """Outward radial/Z normal at each exact profile sample."""
103
+ result = [(0, -1), (0, -1)]
104
+ for k in range(1, 9):
105
+ angle = -math.pi / 2 + k * math.pi / 16
106
+ result.append((math.cos(angle), math.sin(angle)))
107
+ result.append((1, 0))
108
+ for k in range(1, 17):
109
+ angle = k * math.pi / 16
110
+ result.append((math.cos(angle), math.sin(angle)))
111
+ result.append((-1, 0))
112
+ for k in range(1, 9):
113
+ angle = k * math.pi / 16
114
+ result.append((-math.cos(angle), math.sin(angle)))
115
+ result.append((0, 1))
116
+ return result
@@ -0,0 +1,65 @@
1
+ """Handled-cup inputs, validated before creating Blender data blocks."""
2
+ import math
3
+ from deepblend_util import ActionError
4
+
5
+
6
+ def handled_cup_parameters(spec):
7
+ def invalid(message):
8
+ raise ActionError('SCENE_SPEC_INVALID', 'handled_cup: '+message)
9
+ fields=['radius','height','wallThickness','baseThickness','footRound','handleRadius','handleLower','handleUpper',
10
+ 'rootRadius','rootLength','rootTension','segments','sectionSegments','handleSegments','rootSegments','wallRows']
11
+ for key in fields:
12
+ if key in spec and (isinstance(spec[key],bool) or not isinstance(spec[key],(int,float))
13
+ or not math.isfinite(spec[key]) or spec[key]<=0):
14
+ invalid(key+' must be a finite positive number')
15
+ radius = spec.get('radius', .04)
16
+ height = spec.get('height', radius*2.625)
17
+ wall = spec.get('wallThickness', radius*.075)
18
+ bottom = spec.get('baseThickness', radius*.125)
19
+ values = {
20
+ 'radius': radius, 'height': height, 'wall': wall, 'bottom': bottom,
21
+ 'footRound': spec.get('footRound', min(wall, bottom*.8)),
22
+ 'handleRadius': spec.get('handleRadius', radius*.1375),
23
+ 'lower': spec.get('handleLower', height*(4/15)),
24
+ 'upper': spec.get('handleUpper', height*(26/35)),
25
+ 'rootRadius': spec.get('rootRadius', radius*.2625),
26
+ 'rootLength': spec.get('rootLength', radius*.2),
27
+ 'rootTension': spec.get('rootTension', 1),
28
+ 'bodySegments': spec.get('segments', 192),
29
+ 'sectionSegments': spec.get('sectionSegments', 96),
30
+ 'handleSegments': spec.get('handleSegments', 96),
31
+ 'rootSegments': spec.get('rootSegments', 32),
32
+ 'wallRows': spec.get('wallRows', 40),
33
+ }
34
+ if any(isinstance(v, bool) or not isinstance(v, (int,float)) or not math.isfinite(v) or v<=0 for v in values.values()):
35
+ invalid('dimensions and resolutions must be finite positive numbers')
36
+ def within(v,lo,hi): return lo-1e-12<=v<=hi+1e-12
37
+ if not 1 <= values['rootTension'] <= 2.5:
38
+ invalid('rootTension must be within 1–2.5')
39
+ ratios=[('radius',radius,.02,.08),('height/radius',height/radius,1.8,4),
40
+ ('wallThickness/radius',wall/radius,.025,.15),('baseThickness/radius',bottom/radius,.05,.25),
41
+ ('handleRadius/radius',values['handleRadius']/radius,.05,.2),
42
+ ('rootRadius/radius',values['rootRadius']/radius,.12,.35),
43
+ ('rootRadius/handleRadius',values['rootRadius']/values['handleRadius'],1.4,3),
44
+ ('rootLength/rootRadius',values['rootLength']/values['rootRadius'],.4,1.2),
45
+ ('footRound/radius',values['footRound']/radius,.01,.15)]
46
+ for name,value,lo,hi in ratios:
47
+ if not within(value,lo,hi):invalid('%s must be within %s–%s' % (name,lo,hi))
48
+ gap=(values['upper']-values['lower'])/2
49
+ if gap<=0 or values['rootLength']/gap>.8+1e-12:
50
+ invalid('handleUpper must exceed handleLower; rootLength/half-gap must be <=0.8')
51
+ if gap+radius*1e-12<max(1.1*values['rootRadius'],2*values['handleRadius']):
52
+ invalid('attachment half-gap must be >=1.1*rootRadius and >=2*handleRadius')
53
+ if values['lower']-values['rootRadius']+radius*1e-12<values['footRound']+.01*radius:
54
+ invalid('lower root footprint must clear the rounded foot by 0.01*radius')
55
+ if values['upper']+values['rootRadius']>height-wall/2-.01*radius+radius*1e-12:
56
+ invalid('upper root footprint must clear the rounded lip by 0.01*radius')
57
+ for key,lo,hi,multiple in [('bodySegments',64,256,4),('sectionSegments',32,96,4),
58
+ ('handleSegments',24,128,4),('rootSegments',8,48,1),('wallRows',16,64,1)]:
59
+ value=values[key]
60
+ if value!=int(value) or not lo<=value<=hi or value%multiple:
61
+ invalid('%s must be an integer within %s–%s, divisible by %s' % (key,lo,hi,multiple))
62
+ values[key] = int(value)
63
+ if values['footRound']>min(wall,bottom*.8)+radius*1e-12:
64
+ invalid('footRound must not exceed wallThickness or 0.8*baseThickness')
65
+ return values
@@ -40,6 +40,8 @@ import bpy
40
40
  import numpy as np
41
41
 
42
42
  from deepblend_render import (
43
+ _camera_facts,
44
+ _render_config as _actual_render_config,
43
45
  apply_render_overrides,
44
46
  checkpoint_profile,
45
47
  find_camera,
@@ -202,6 +204,11 @@ def _render_one(scene, entry, tracked, parts, object_index, guard, base_percent,
202
204
  {"view": view_id, "output": output},
203
205
  )
204
206
 
207
+ # Capture the frame and evaluated camera that produced these bytes before
208
+ # isolation measurements temporarily change the scene's render settings.
209
+ actual_camera = scene.camera
210
+ render_config = _render_config(scene)
211
+ camera_facts = _camera_facts(scene, actual_camera)
205
212
  report_progress("measure", base_percent + 5.0, {"view": view_id})
206
213
  width, height = png_dimensions(output) or (
207
214
  int(scene.render.resolution_x),
@@ -215,16 +222,20 @@ def _render_one(scene, entry, tracked, parts, object_index, guard, base_percent,
215
222
  "bytes": os.path.getsize(output),
216
223
  "width": width,
217
224
  "height": height,
218
- "frame": frame,
219
- "cameraId": camera.get("deepblend_id") or camera.name,
220
- "cameraName": camera.name,
221
- "lens": round(float(camera.data.lens), 6),
222
- "engine": scene.render.engine,
225
+ "frame": int(scene.frame_current),
226
+ "cameraId": actual_camera.get("deepblend_id") or actual_camera.name,
227
+ "cameraName": actual_camera.name,
228
+ "lens": round(camera_facts["lens"], 6),
229
+ "engine": render_config["engine"],
230
+ "renderConfig": render_config,
231
+ "cameraFacts": camera_facts,
223
232
  "metrics": measure_view(scene, output, tracked, parts, object_index, guard, view_id, scratch),
224
233
  }
225
234
  return entry_report
226
235
 
227
236
 
237
+
238
+
228
239
  def measure_view(scene, image_path, tracked, parts, object_index, guard, view_id, scratch):
229
240
  """Compute the deterministic measurements for one rendered frame.
230
241
 
@@ -716,21 +727,9 @@ def _load_rgb(path):
716
727
 
717
728
 
718
729
  def _render_config(scene):
719
- """The render settings in force for this plan, for the manifest."""
720
- samples = None
721
- try:
722
- if scene.render.engine == "CYCLES":
723
- samples = int(scene.cycles.samples)
724
- except Exception:
725
- samples = None
730
+ """Actual settings, retaining the plan report's checkpoint profile field."""
726
731
  return {
727
- "engine": scene.render.engine,
728
- "resolution": [int(scene.render.resolution_x), int(scene.render.resolution_y)],
729
- "samples": samples,
730
- "viewTransform": scene.view_settings.view_transform,
731
- "frameStart": int(scene.frame_start),
732
- "frameEnd": int(scene.frame_end),
733
- "fps": int(scene.render.fps),
732
+ **_actual_render_config(scene),
734
733
  "checkpointProfile": checkpoint_profile(scene),
735
734
  }
736
735