@deepblend/dsh-blender-provider-local 0.2.20 → 0.3.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 +50 -35
- package/package.json +2 -2
- package/python/bootstrap.py +4 -2
- package/python/deepblend_anisotropy.py +110 -0
- package/python/deepblend_asset_bundle.py +193 -0
- package/python/deepblend_avif_headers.py +222 -0
- package/python/deepblend_bridge.py +46 -4
- package/python/deepblend_capabilities.py +7 -0
- package/python/deepblend_frames.py +11 -1
- package/python/deepblend_geometry.py +210 -0
- package/python/deepblend_gltf_images.py +183 -0
- package/python/deepblend_gltf_import.py +95 -0
- package/python/deepblend_gltf_roles.py +56 -0
- package/python/deepblend_image_headers.py +294 -0
- package/python/deepblend_image_roles.py +2 -0
- package/python/deepblend_images.py +203 -0
- package/python/deepblend_imported_image_headers.py +364 -0
- package/python/deepblend_mesh_checks.py +145 -0
- package/python/deepblend_obj_images.py +54 -0
- package/python/deepblend_obj_import.py +58 -0
- package/python/deepblend_obj_resources.py +79 -0
- package/python/deepblend_parts.py +106 -0
- package/python/deepblend_render.py +53 -0
- package/python/deepblend_scene.py +307 -49
- package/python/deepblend_vessel.py +192 -0
- package/python/deepblend_vessel_math.py +116 -0
- package/python/deepblend_vessel_parameters.py +65 -0
- package/python/deepblend_views.py +18 -19
- package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Double-precision arithmetic over the actual float32 Blender mesh positions.
|
|
2
|
+
|
|
3
|
+
All shared-vertex neighbors enter the narrow phase. Tolerances are explicit;
|
|
4
|
+
this remains a finite mesh test rather than exact algebraic certification.
|
|
5
|
+
"""
|
|
6
|
+
import math
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
def sub(a,b): return tuple(x-y for x,y in zip(a,b))
|
|
10
|
+
def dot(a,b): return sum(x*y for x,y in zip(a,b))
|
|
11
|
+
def cross(a,b): return (a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0])
|
|
12
|
+
def length(a): return math.sqrt(dot(a,a))
|
|
13
|
+
def normal(a,b,c):
|
|
14
|
+
n=cross(sub(b,a),sub(c,a));size=length(n)
|
|
15
|
+
return tuple(x/size for x in n) if size>0 else None
|
|
16
|
+
|
|
17
|
+
def segment_triangle(p,q,a,b,c):
|
|
18
|
+
direction=sub(q,p);ab=sub(b,a);ac=sub(c,a)
|
|
19
|
+
v=cross(direction,ac);det=dot(ab,v)
|
|
20
|
+
scale=length(direction)*length(ab)*length(ac)
|
|
21
|
+
if scale==0 or abs(det)<=scale*1e-14:return None
|
|
22
|
+
origin=sub(p,a);u=dot(origin,v)/det
|
|
23
|
+
v=cross(origin,ab);w=dot(direction,v)/det;t=dot(ac,v)/det
|
|
24
|
+
eps=1e-12
|
|
25
|
+
if u < -eps or w < -eps or u+w > 1+eps or t < -eps or t > 1+eps:return None
|
|
26
|
+
return tuple(p[i]+t*direction[i] for i in range(3))
|
|
27
|
+
|
|
28
|
+
def clip_area(a,b):
|
|
29
|
+
def side(p,x,y):return (y[0]-x[0])*(p[1]-x[1])-(y[1]-x[1])*(p[0]-x[0])
|
|
30
|
+
sign=1 if side(b[2],b[0],b[1])>0 else -1
|
|
31
|
+
poly=list(a)
|
|
32
|
+
for q,w in zip(b,b[1:]+b[:1]):
|
|
33
|
+
out=[]
|
|
34
|
+
for s,e in zip(poly,poly[1:]+poly[:1]):
|
|
35
|
+
ds,de=sign*side(s,q,w),sign*side(e,q,w)
|
|
36
|
+
if (ds>=0)!=(de>=0):
|
|
37
|
+
f=ds/(ds-de);out.append((s[0]+f*(e[0]-s[0]),s[1]+f*(e[1]-s[1])))
|
|
38
|
+
if de>=0:out.append(e)
|
|
39
|
+
poly=out
|
|
40
|
+
if not poly:return 0
|
|
41
|
+
return abs(sum(a[0]*b[1]-a[1]*b[0] for a,b in zip(poly,poly[1:]+poly[:1])))*.5
|
|
42
|
+
|
|
43
|
+
def intersection_facts(mesh, max_candidates=2_000_000):
|
|
44
|
+
mesh.calc_loop_triangles();triangles=[tuple(t.vertices) for t in mesh.loop_triangles]
|
|
45
|
+
verts=[tuple(float(x) for x in v.co) for v in mesh.vertices]
|
|
46
|
+
extent=max(max(v[i] for v in verts)-min(v[i] for v in verts) for i in range(3))
|
|
47
|
+
cell=extent/24;bins=defaultdict(list);boxes=[]
|
|
48
|
+
for index,tri in enumerate(triangles):
|
|
49
|
+
lo=tuple(min(verts[j][i] for j in tri)-1e-10 for i in range(3))
|
|
50
|
+
hi=tuple(max(verts[j][i] for j in tri)+1e-10 for i in range(3));boxes.append((lo,hi))
|
|
51
|
+
for x in range(math.floor(lo[0]/cell),math.floor(hi[0]/cell)+1):
|
|
52
|
+
for y in range(math.floor(lo[1]/cell),math.floor(hi[1]/cell)+1):
|
|
53
|
+
for z in range(math.floor(lo[2]/cell),math.floor(hi[2]/cell)+1):bins[(x,y,z)].append(index)
|
|
54
|
+
pairs=set()
|
|
55
|
+
for values in bins.values():
|
|
56
|
+
for k,a in enumerate(values):
|
|
57
|
+
alo,ahi=boxes[a]
|
|
58
|
+
for b in values[k+1:]:
|
|
59
|
+
blo,bhi=boxes[b]
|
|
60
|
+
if all(alo[i]<=bhi[i] and blo[i]<=ahi[i] for i in range(3)):
|
|
61
|
+
pairs.add((a,b))
|
|
62
|
+
if len(pairs)>max_candidates:raise ValueError('mesh intersection validation exceeds its candidate-pair budget')
|
|
63
|
+
bad=[];coplanar=0;tested=0;normals=[]
|
|
64
|
+
for t in triangles:normals.append(normal(*(verts[i] for i in t)))
|
|
65
|
+
eps=1e-8
|
|
66
|
+
for ai,bi in pairs:
|
|
67
|
+
a,b=triangles[ai],triangles[bi];shared=set(a)&set(b);av=[verts[i] for i in a];bv=[verts[i] for i in b]
|
|
68
|
+
na,nb=normals[ai],normals[bi]
|
|
69
|
+
if na is None or nb is None:continue
|
|
70
|
+
is_coplanar=length(cross(na,nb))<1e-5 and max(abs(dot(na,sub(p,av[0]))) for p in bv)<1e-9
|
|
71
|
+
hit=False
|
|
72
|
+
if is_coplanar:
|
|
73
|
+
coplanar+=1;drop=max(range(3),key=lambda i:abs(na[i]));axes=[i for i in range(3) if i!=drop]
|
|
74
|
+
hit=clip_area([tuple(p[i] for i in axes) for p in av],[tuple(p[i] for i in axes) for p in bv])>1e-14
|
|
75
|
+
elif len(shared)<2:
|
|
76
|
+
tested+=1
|
|
77
|
+
for points,target in ((av,bv),(bv,av)):
|
|
78
|
+
for p,q in zip(points,points[1:]+points[:1]):
|
|
79
|
+
where=segment_triangle(p,q,*target)
|
|
80
|
+
if where is not None and not any(length(sub(where,verts[i]))<eps for i in shared):hit=True
|
|
81
|
+
if hit:bad.append({'triangles':[ai,bi],'sharedVertices':len(shared),'coplanar':is_coplanar})
|
|
82
|
+
return {'aabbCandidatePairs':len(pairs),'coplanarPairsChecked':coplanar,'noncoplanarPairsChecked':tested,
|
|
83
|
+
'intersectionsBeyondSharedBoundary':len(bad),'examples':bad[:12],
|
|
84
|
+
'method':'Explicit AABB bins include shared vertices; float64 segment/triangle and coplanar polygon clipping over actual mesh coordinates; only noncoplanar shared-edge adjacency exempt',
|
|
85
|
+
'toleranceM':eps,'coplanarAreaToleranceM2':1e-14,'planeToleranceM':1e-9,'barycentricTolerance':1e-12}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_corner_normals(mesh):
|
|
89
|
+
"""Check actual stored normals, including their orientation on each triangle."""
|
|
90
|
+
normals = [tuple(float(c) for c in item.vector) for item in mesh.corner_normals]
|
|
91
|
+
if len(normals) != len(mesh.loops):
|
|
92
|
+
raise ValueError('missing corner normals')
|
|
93
|
+
if any(not all(math.isfinite(c) for c in n) or abs(length(n)-1)>1e-5 for n in normals):
|
|
94
|
+
raise ValueError('non-finite or non-unit corner normals')
|
|
95
|
+
mesh.calc_loop_triangles()
|
|
96
|
+
for triangle in mesh.loop_triangles:
|
|
97
|
+
points = [tuple(float(c) for c in mesh.vertices[i].co) for i in triangle.vertices]
|
|
98
|
+
face_normal = normal(*points)
|
|
99
|
+
if face_normal is None or any(dot(normals[i], face_normal)<=0 for i in triangle.loops):
|
|
100
|
+
raise ValueError('corner normal opposes its geometry triangle')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_handled_cup_mesh(mesh, parameters):
|
|
104
|
+
"""Refuse invalid discrete output; do not publish a folded or open cup."""
|
|
105
|
+
import bmesh
|
|
106
|
+
from deepblend_util import ActionError
|
|
107
|
+
def invalid(message):
|
|
108
|
+
raise ActionError('SCENE_SPEC_INVALID', 'handled_cup output: '+message)
|
|
109
|
+
if not mesh.polygons or len(mesh.polygons)>100_000:
|
|
110
|
+
invalid('empty mesh or polygon budget exceeded')
|
|
111
|
+
if any(not math.isfinite(c) for vertex in mesh.vertices for c in vertex.co):
|
|
112
|
+
invalid('non-finite coordinates')
|
|
113
|
+
bm=bmesh.new()
|
|
114
|
+
try:
|
|
115
|
+
bm.from_mesh(mesh)
|
|
116
|
+
if any(not edge.is_manifold for edge in bm.edges):invalid('open or non-manifold boundary')
|
|
117
|
+
if any(face.calc_area()<=0 for face in bm.faces):invalid('zero-area face')
|
|
118
|
+
if len(bm.verts)-len(bm.edges)+len(bm.faces)!=0:invalid('unexpected cup/handle topology')
|
|
119
|
+
unseen=set(bm.verts);stack=[unseen.pop()]
|
|
120
|
+
while stack:
|
|
121
|
+
for edge in stack.pop().link_edges:
|
|
122
|
+
for vertex in edge.verts:
|
|
123
|
+
if vertex in unseen:unseen.remove(vertex);stack.append(vertex)
|
|
124
|
+
if unseen:invalid('disconnected geometry')
|
|
125
|
+
if bm.calc_volume(signed=True)<=0:invalid('nonpositive signed volume')
|
|
126
|
+
finally:
|
|
127
|
+
bm.free()
|
|
128
|
+
uv=mesh.uv_layers.active
|
|
129
|
+
if uv is None or any(not math.isfinite(c) for item in uv.data for c in item.uv):invalid('missing or invalid UVs')
|
|
130
|
+
mesh.calc_loop_triangles()
|
|
131
|
+
for tri in mesh.loop_triangles:
|
|
132
|
+
points = [tuple(float(x) for x in mesh.vertices[i].co) for i in tri.vertices]
|
|
133
|
+
if length(cross(sub(points[1], points[0]), sub(points[2], points[0]))) <= 0:
|
|
134
|
+
invalid('zero-area geometry triangle')
|
|
135
|
+
a,b,c=(uv.data[i].uv for i in tri.loops)
|
|
136
|
+
if abs((b-a).cross(c-a))<1e-12:invalid('zero-area UV triangle')
|
|
137
|
+
if not mesh.has_custom_normals:invalid('missing analytic corner normals')
|
|
138
|
+
try:
|
|
139
|
+
validate_corner_normals(mesh)
|
|
140
|
+
intersections=intersection_facts(mesh)
|
|
141
|
+
except ValueError as error:
|
|
142
|
+
invalid(str(error))
|
|
143
|
+
if intersections['intersectionsBeyondSharedBoundary']:
|
|
144
|
+
invalid('triangles intersect beyond their shared boundary')
|
|
145
|
+
return intersections
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Inspect native-selected OBJ material textures before scene reset or decode."""
|
|
2
|
+
import os
|
|
3
|
+
import posixpath
|
|
4
|
+
|
|
5
|
+
from deepblend_asset_bundle import inside, obj_reference, MAX_FILES
|
|
6
|
+
from deepblend_obj_resources import obj_declarations, mtl_image_records
|
|
7
|
+
from deepblend_imported_image_headers import check_imported_image_header
|
|
8
|
+
from deepblend_image_headers import MAX_IMAGE_DECODE_BYTES
|
|
9
|
+
from deepblend_util import ActionError
|
|
10
|
+
|
|
11
|
+
# Native aliases share one map slot; later definitions replace that slot.
|
|
12
|
+
ALIASES = {'map_refl': 'refl', 'map_Bump': 'bump', 'map_bump': 'bump'}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inspect_obj_images(project_root, asset):
|
|
16
|
+
entrypoint = asset['path']
|
|
17
|
+
libraries, names, used = [], set(), set()
|
|
18
|
+
for kind, name in obj_declarations(inside(project_root, entrypoint)):
|
|
19
|
+
if kind == 'usemtl':
|
|
20
|
+
used.add(name)
|
|
21
|
+
else:
|
|
22
|
+
# Native library de-duplication compares the declared spelling, before
|
|
23
|
+
# resolving paths. Distinct spellings may reapply the same MTL later.
|
|
24
|
+
if name not in names:
|
|
25
|
+
names.add(name)
|
|
26
|
+
libraries.append(obj_reference(entrypoint, name))
|
|
27
|
+
fallback = posixpath.splitext(entrypoint)[0] + '.mtl'
|
|
28
|
+
if os.path.exists(inside(project_root, fallback)) and posixpath.basename(fallback) not in names:
|
|
29
|
+
libraries.append(fallback)
|
|
30
|
+
if len(libraries) > MAX_FILES:
|
|
31
|
+
raise ActionError('ASSET_TOO_LARGE', 'OBJ material libraries exceed their resource limit')
|
|
32
|
+
selected = {}
|
|
33
|
+
for library in libraries:
|
|
34
|
+
for material, kind, name in mtl_image_records(inside(project_root, library)):
|
|
35
|
+
if material in used:
|
|
36
|
+
selected.setdefault(material, {})[ALIASES.get(kind, kind)] = obj_reference(library, name)
|
|
37
|
+
roles = {}
|
|
38
|
+
for maps in selected.values():
|
|
39
|
+
for kind, path in maps.items():
|
|
40
|
+
role = 'color' if kind in ('map_Kd', 'map_Ke') else 'alpha' if kind == 'map_d' else 'data'
|
|
41
|
+
roles.setdefault(path, set()).add(role)
|
|
42
|
+
paths = sorted(roles)
|
|
43
|
+
if len(paths) > MAX_FILES:
|
|
44
|
+
raise ActionError('ASSET_TOO_LARGE', 'OBJ textures exceed their resource limit')
|
|
45
|
+
facts, total, allocations = [], 0, 0
|
|
46
|
+
for path in paths:
|
|
47
|
+
header = check_imported_image_header(inside(project_root, path))
|
|
48
|
+
count = max(1, len(roles[path] - {'alpha'}))
|
|
49
|
+
facts.append({'path': path, 'roles': sorted(roles[path]), 'allocations': count, **header})
|
|
50
|
+
allocations += count
|
|
51
|
+
total += count * header['decodedBytes']
|
|
52
|
+
if total > MAX_IMAGE_DECODE_BYTES:
|
|
53
|
+
raise ActionError('ASSET_CONTENT_MISMATCH', 'imported OBJ images exceed the 1 GiB decoded pixel budget')
|
|
54
|
+
return {'images': len(facts), 'allocations': allocations, 'decodedBytes': total, 'facts': facts}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Use linear OBJ numeric/normal maps while retaining native color images."""
|
|
2
|
+
import bpy
|
|
3
|
+
from deepblend_image_roles import SOURCE_SPACE
|
|
4
|
+
|
|
5
|
+
# A later import can reuse an image first encountered as data. Remember its
|
|
6
|
+
# native color interpretation before changing it, including float-file spaces.
|
|
7
|
+
def image_role(node):
|
|
8
|
+
roles = set()
|
|
9
|
+
for link in node.outputs['Color'].links:
|
|
10
|
+
numeric = (link.to_node.type == 'BSDF_PRINCIPLED' and link.to_socket.type == 'VALUE')
|
|
11
|
+
normal = (link.to_node.type == 'NORMAL_MAP' and link.to_socket.name == 'Color')
|
|
12
|
+
roles.add('data' if numeric or normal else 'color')
|
|
13
|
+
if len(roles) > 1:
|
|
14
|
+
raise ValueError('native OBJ image node has conflicting RGB uses')
|
|
15
|
+
return next(iter(roles)) if roles else 'neutral'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assign_image_roles(objects, protected_images):
|
|
19
|
+
materials = {slot.material for obj in objects for slot in getattr(obj, 'material_slots', [])
|
|
20
|
+
if slot.material and slot.material.use_nodes}
|
|
21
|
+
uses = {}
|
|
22
|
+
for material in materials:
|
|
23
|
+
for node in material.node_tree.nodes:
|
|
24
|
+
if node.type == 'TEX_IMAGE' and node.image:
|
|
25
|
+
uses.setdefault(node.image, []).append((node, image_role(node)))
|
|
26
|
+
for original, nodes in uses.items():
|
|
27
|
+
roles = {role for _, role in nodes if role != 'neutral'}
|
|
28
|
+
if not roles:
|
|
29
|
+
continue
|
|
30
|
+
native_space = original.get(SOURCE_SPACE, original.colorspace_settings.name)
|
|
31
|
+
spaces = {'color': native_space, 'data': 'Non-Color'}
|
|
32
|
+
images = {}
|
|
33
|
+
for role in sorted(roles):
|
|
34
|
+
space = spaces[role]
|
|
35
|
+
if original.colorspace_settings.name == space:
|
|
36
|
+
image = original
|
|
37
|
+
elif (len(roles) == 1 and original not in protected_images
|
|
38
|
+
and original.users == len(nodes)):
|
|
39
|
+
# All users are this import. Alpha and disconnected nodes can
|
|
40
|
+
# share its RGB interpretation without another allocation.
|
|
41
|
+
image = original
|
|
42
|
+
image[SOURCE_SPACE] = native_space
|
|
43
|
+
image.colorspace_settings.name = space
|
|
44
|
+
else:
|
|
45
|
+
image = original.copy()
|
|
46
|
+
image[SOURCE_SPACE] = native_space
|
|
47
|
+
image.colorspace_settings.name = space
|
|
48
|
+
images[role] = image
|
|
49
|
+
neutral_role = 'color' if 'color' in images else 'data'
|
|
50
|
+
for node, role in nodes:
|
|
51
|
+
node.image = images[neutral_role if role == 'neutral' else role]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def import_obj(path):
|
|
55
|
+
before_objects, before_images = set(bpy.data.objects), set(bpy.data.images)
|
|
56
|
+
result = bpy.ops.wm.obj_import(filepath=path)
|
|
57
|
+
assign_image_roles([obj for obj in bpy.data.objects if obj not in before_objects], before_images)
|
|
58
|
+
return result
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Bounded OBJ/MTL reference inspection; independent of Node and bpy."""
|
|
2
|
+
import os,re
|
|
3
|
+
from deepblend_util import ActionError
|
|
4
|
+
MAX_OBJ=1024*1024*1024
|
|
5
|
+
MAX_MTL=16*1024*1024
|
|
6
|
+
MAX_LINE=1024*1024
|
|
7
|
+
MAP_KEYS={'map_Kd','map_Ks','map_Ns','map_d','refl','map_refl','map_Ke','bump','map_Bump','map_bump','map_Pr','map_Pm','map_Ps'}
|
|
8
|
+
COUNTS={'-bm':1,'-type':1,'-blendu':1,'-blendv':1,'-boost':1,'-cc':1,'-clamp':1,'-imfchan':1,'-mm':2,'-t':3,'-texres':1}
|
|
9
|
+
|
|
10
|
+
def fail(message,code='ASSET_REQUEST_INVALID'):raise ActionError(code,message)
|
|
11
|
+
def numeric(value):
|
|
12
|
+
if not re.fullmatch(r'[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:e[+-]?[0-9]+)?',value,re.I):return False
|
|
13
|
+
import math
|
|
14
|
+
return math.isfinite(float(value))
|
|
15
|
+
|
|
16
|
+
def lines(path,limit,continuation=False):
|
|
17
|
+
try:
|
|
18
|
+
if os.path.getsize(path)>limit:fail('OBJ/MTL text exceeds its byte limit.','ASSET_TOO_LARGE')
|
|
19
|
+
total=0;logical='';first=True
|
|
20
|
+
with open(path,'rb') as source:
|
|
21
|
+
while True:
|
|
22
|
+
raw=source.readline(MAX_LINE+2)
|
|
23
|
+
if not raw:
|
|
24
|
+
if logical:yield logical
|
|
25
|
+
break
|
|
26
|
+
total+=len(raw)
|
|
27
|
+
if total>limit:fail('OBJ/MTL text exceeds its byte limit.','ASSET_TOO_LARGE')
|
|
28
|
+
physical=raw.rstrip(b'\n')
|
|
29
|
+
if len(physical)>MAX_LINE:fail('OBJ/MTL lines exceed 1 MiB.','ASSET_TOO_LARGE')
|
|
30
|
+
line=physical.decode('utf-8-sig' if first else 'utf-8');first=False
|
|
31
|
+
continued=continuation and re.search(r'\\[\t\r ]*$',line)
|
|
32
|
+
logical+=re.sub(r'\\[\t\r ]*$',' ',line) if continued else line
|
|
33
|
+
if len(logical.encode('utf-8'))>MAX_LINE:fail('OBJ/MTL logical lines exceed 1 MiB.','ASSET_TOO_LARGE')
|
|
34
|
+
if not continued:yield logical;logical=''
|
|
35
|
+
except UnicodeError:fail('OBJ/MTL text must be valid UTF-8.','ASSET_CONTENT_MISMATCH')
|
|
36
|
+
except OSError:fail('An OBJ/MTL file is missing.','ASSET_MISSING')
|
|
37
|
+
|
|
38
|
+
def obj_declarations(path):
|
|
39
|
+
for line in lines(path,MAX_OBJ,True):
|
|
40
|
+
field=line.split(None,1)
|
|
41
|
+
if not field or field[0] not in ('mtllib','usemtl'):continue
|
|
42
|
+
name=field[1].strip() if len(field)>1 else ''
|
|
43
|
+
if field[0]=='mtllib':
|
|
44
|
+
if len(name)>2 and name.startswith('"') and name.endswith('"'):name=name[1:-1]
|
|
45
|
+
if not name:fail('An OBJ material library reference is empty.')
|
|
46
|
+
yield field[0],name
|
|
47
|
+
|
|
48
|
+
def obj_libraries(path):
|
|
49
|
+
return {name for kind,name in obj_declarations(path) if kind=='mtllib'}
|
|
50
|
+
|
|
51
|
+
def mtl_image_records(path):
|
|
52
|
+
material=None
|
|
53
|
+
for line in lines(path,MAX_MTL):
|
|
54
|
+
field=line.split(None,1)
|
|
55
|
+
if not field:continue
|
|
56
|
+
if field[0]=='newmtl':material=field[1].strip() if len(field)>1 else '';continue
|
|
57
|
+
if material is None or field[0] not in MAP_KEYS:continue
|
|
58
|
+
rest=field[1].strip() if len(field)>1 else ''
|
|
59
|
+
while rest:
|
|
60
|
+
token=rest.split(None,1)[0]
|
|
61
|
+
if token in ('-o','-s'):
|
|
62
|
+
rest=rest[len(token):].lstrip()
|
|
63
|
+
for i in range(3):
|
|
64
|
+
value=rest.split(None,1)[0] if rest else ''
|
|
65
|
+
if not numeric(value):break
|
|
66
|
+
rest=rest[len(value):].lstrip()
|
|
67
|
+
elif token in COUNTS:
|
|
68
|
+
rest=rest[len(token):].lstrip()
|
|
69
|
+
for i in range(COUNTS[token]):
|
|
70
|
+
value=rest.split(None,1)[0] if rest else ''
|
|
71
|
+
if not value or (token=='-bm' and not numeric(value)):fail('An MTL texture option is incomplete or invalid.')
|
|
72
|
+
rest=rest[len(value):].lstrip()
|
|
73
|
+
else:break
|
|
74
|
+
name=rest.strip().replace('"','')
|
|
75
|
+
if not name:fail('An MTL texture reference is empty.')
|
|
76
|
+
yield material,field[0],name
|
|
77
|
+
|
|
78
|
+
def mtl_images(path):
|
|
79
|
+
return {name for material,kind,name in mtl_image_records(path)}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Stable imported-part selectors and object-local material slot overrides."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
import uuid
|
|
4
|
+
import json
|
|
5
|
+
import bpy
|
|
6
|
+
from deepblend_util import ActionError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@contextmanager
|
|
10
|
+
def isolated_import_names(instance_name):
|
|
11
|
+
# Importers may name unnamed nodes after meshes, cameras, or other data.
|
|
12
|
+
# Reserve all existing names so prior instances cannot change source paths.
|
|
13
|
+
names = ('objects', 'meshes', 'curves', 'armatures', 'cameras', 'lights',
|
|
14
|
+
'metaballs', 'lattices', 'collections')
|
|
15
|
+
snapshots = [(getattr(bpy.data, name), [(item, item.name) for item in getattr(bpy.data, name)])
|
|
16
|
+
for name in names]
|
|
17
|
+
nonce = uuid.uuid4().hex[:16]
|
|
18
|
+
try:
|
|
19
|
+
for collection_index, (_, entries) in enumerate(snapshots):
|
|
20
|
+
for index, (item, _) in enumerate(entries):
|
|
21
|
+
item.name = '_db_%s_%d_%d' % (nonce, collection_index, index)
|
|
22
|
+
yield
|
|
23
|
+
finally:
|
|
24
|
+
for collection_index, (collection, entries) in enumerate(snapshots):
|
|
25
|
+
old_items = {item for item, _ in entries}
|
|
26
|
+
old_names = {name for _, name in entries}
|
|
27
|
+
displaced = []
|
|
28
|
+
for index, item in enumerate(list(collection)):
|
|
29
|
+
if item not in old_items and item.name in old_names:
|
|
30
|
+
desired = '%s_%s' % (instance_name, item.name)
|
|
31
|
+
item.name = '_db_%s_new_%d_%d' % (nonce, collection_index, index)
|
|
32
|
+
displaced.append((item, desired))
|
|
33
|
+
for item, original in entries:
|
|
34
|
+
item.name = original
|
|
35
|
+
for item, desired in displaced:
|
|
36
|
+
item.name = desired
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def stamp_imported_parts(objects):
|
|
41
|
+
imported = set(objects)
|
|
42
|
+
paths = {}
|
|
43
|
+
def path(obj):
|
|
44
|
+
if obj not in paths:
|
|
45
|
+
segment = obj.name.replace('~', '~0').replace('/', '~1')
|
|
46
|
+
paths[obj] = (path(obj.parent) if obj.parent in imported else '') + '/' + segment
|
|
47
|
+
return paths[obj]
|
|
48
|
+
for obj in objects:
|
|
49
|
+
obj['deepblend_part_id'] = path(obj)
|
|
50
|
+
obj['deepblend_parent_part_id'] = path(obj.parent) if obj.parent in imported else ''
|
|
51
|
+
if obj.type == 'MESH':
|
|
52
|
+
obj['deepblend_source_material_slots'] = json.dumps([
|
|
53
|
+
{'index': index, 'materialName': slot.material.name if slot.material else None}
|
|
54
|
+
for index, slot in enumerate(obj.material_slots)])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def apply_material_bindings(entity, objects, materials):
|
|
58
|
+
bindings = entity.get('materialBindings') or []
|
|
59
|
+
meshes = [obj for obj in objects if obj.type == 'MESH']
|
|
60
|
+
inventory = [{'partId': obj.get('deepblend_part_id'), 'slotCount': len(obj.material_slots)} for obj in meshes]
|
|
61
|
+
def fail(message):
|
|
62
|
+
raise ActionError('SCENE_VALIDATION_FAILED', 'entity "%s": %s' % (entity['id'], message),
|
|
63
|
+
{'entityId': entity['id'], 'availableParts': inventory})
|
|
64
|
+
selected = []
|
|
65
|
+
seen = set()
|
|
66
|
+
for binding in bindings:
|
|
67
|
+
key = (binding['partId'], binding.get('slotIndex'))
|
|
68
|
+
if key in seen:
|
|
69
|
+
fail('duplicate material binding for %s' % (key,))
|
|
70
|
+
seen.add(key)
|
|
71
|
+
matches = [obj for obj in meshes if obj.get('deepblend_part_id') == binding['partId']]
|
|
72
|
+
if len(matches) != 1:
|
|
73
|
+
fail('material partId "%s" must select exactly one mesh' % binding['partId'])
|
|
74
|
+
obj = matches[0]
|
|
75
|
+
slot_index = binding.get('slotIndex')
|
|
76
|
+
if slot_index is not None and (slot_index < 0 or slot_index >= len(obj.material_slots)):
|
|
77
|
+
fail('slotIndex %s is outside the original slots of "%s"' % (slot_index, binding['partId']))
|
|
78
|
+
material = materials.get(binding['materialId'])
|
|
79
|
+
if material is None:
|
|
80
|
+
fail('material "%s" was not built' % binding['materialId'])
|
|
81
|
+
selected.append((obj, slot_index, material))
|
|
82
|
+
|
|
83
|
+
def assign(obj, material, slot_index=None):
|
|
84
|
+
if not obj.material_slots:
|
|
85
|
+
# A new slot changes shared mesh data; isolate only this zero-slot case.
|
|
86
|
+
if obj.data.users > 1:
|
|
87
|
+
obj.data = obj.data.copy()
|
|
88
|
+
obj.data.materials.append(material)
|
|
89
|
+
indices = range(len(obj.material_slots)) if slot_index is None else [slot_index]
|
|
90
|
+
for index in indices:
|
|
91
|
+
slot = obj.material_slots[index]
|
|
92
|
+
slot.link = 'OBJECT'
|
|
93
|
+
slot.material = material
|
|
94
|
+
|
|
95
|
+
if entity.get('materialId') is not None:
|
|
96
|
+
material = materials.get(entity['materialId'])
|
|
97
|
+
if material is None:
|
|
98
|
+
fail('material "%s" was not built' % entity['materialId'])
|
|
99
|
+
for obj in objects:
|
|
100
|
+
if hasattr(obj.data, "materials"):
|
|
101
|
+
assign(obj, material)
|
|
102
|
+
# A slot-specific override always wins, independently of declaration order.
|
|
103
|
+
for specific in (False, True):
|
|
104
|
+
for obj, slot_index, material in selected:
|
|
105
|
+
if (slot_index is not None) == specific:
|
|
106
|
+
assign(obj, material, slot_index)
|
|
@@ -25,9 +25,58 @@ import os
|
|
|
25
25
|
import bpy
|
|
26
26
|
|
|
27
27
|
from deepblend_scene import describe_objects, resolve_engine
|
|
28
|
+
from deepblend_anisotropy import validate_anisotropy_render_engine
|
|
28
29
|
from deepblend_util import ActionError, error_text, report_progress
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def _camera_facts(scene, camera):
|
|
33
|
+
"""Evaluated camera optics and pose for one rendered frame.
|
|
34
|
+
|
|
35
|
+
Matrix rows are copied to plain numbers so later frames cannot mutate the
|
|
36
|
+
record. A focus object is evaluated too: its animation changes depth of field
|
|
37
|
+
even when the camera's stored focus_distance is unchanged.
|
|
38
|
+
Lens and sensor dimensions are millimetres; transforms use scene units.
|
|
39
|
+
"""
|
|
40
|
+
if camera is None:
|
|
41
|
+
raise ActionError("SCENE_CAMERA_MISSING", "the rendered scene has no active camera")
|
|
42
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
43
|
+
evaluated = camera.evaluated_get(depsgraph)
|
|
44
|
+
data = evaluated.data
|
|
45
|
+
dof = data.dof
|
|
46
|
+
focus = dof.focus_object
|
|
47
|
+
focus_facts = None
|
|
48
|
+
if focus is not None:
|
|
49
|
+
focus_facts = {
|
|
50
|
+
"name": focus.name,
|
|
51
|
+
"entityId": focus.get("deepblend_id"),
|
|
52
|
+
"matrixWorld": [[float(value) for value in row]
|
|
53
|
+
for row in focus.evaluated_get(depsgraph).matrix_world],
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
"frame": int(scene.frame_current),
|
|
57
|
+
"matrixWorld": [[float(value) for value in row] for row in evaluated.matrix_world],
|
|
58
|
+
"type": data.type,
|
|
59
|
+
"lens": float(data.lens),
|
|
60
|
+
"orthoScale": float(data.ortho_scale),
|
|
61
|
+
"sensorWidth": float(data.sensor_width),
|
|
62
|
+
"sensorHeight": float(data.sensor_height),
|
|
63
|
+
"sensorFit": data.sensor_fit,
|
|
64
|
+
"shift": [float(data.shift_x), float(data.shift_y)],
|
|
65
|
+
"clip": [float(data.clip_start), float(data.clip_end)],
|
|
66
|
+
"dof": {
|
|
67
|
+
"enabled": bool(dof.use_dof),
|
|
68
|
+
"focusDistance": float(dof.focus_distance),
|
|
69
|
+
"focusObject": focus_facts,
|
|
70
|
+
"focusSubtarget": getattr(dof, "focus_subtarget", "") or None,
|
|
71
|
+
"apertureFstop": float(dof.aperture_fstop),
|
|
72
|
+
"apertureBlades": int(dof.aperture_blades),
|
|
73
|
+
"apertureRotation": float(dof.aperture_rotation),
|
|
74
|
+
"apertureRatio": float(dof.aperture_ratio),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
31
80
|
def open_checkpoint(path):
|
|
32
81
|
"""Open a ``.blend`` checkpoint and return its scene.
|
|
33
82
|
|
|
@@ -107,6 +156,7 @@ def apply_render_overrides(scene, options, guard):
|
|
|
107
156
|
engine, downgrade = resolve_engine(engine_key, guard)
|
|
108
157
|
if downgrade is not None:
|
|
109
158
|
guard.warnings.append(downgrade)
|
|
159
|
+
validate_anisotropy_render_engine(scene, engine)
|
|
110
160
|
if scene.render.engine != engine:
|
|
111
161
|
scene.render.engine = engine
|
|
112
162
|
applied["engine"] = engine
|
|
@@ -116,6 +166,8 @@ def apply_render_overrides(scene, options, guard):
|
|
|
116
166
|
% (engine, checkpoint_profile(scene).get("engine", "<unknown>")),
|
|
117
167
|
{"engine": engine},
|
|
118
168
|
)
|
|
169
|
+
else:
|
|
170
|
+
validate_anisotropy_render_engine(scene, scene.render.engine)
|
|
119
171
|
|
|
120
172
|
width = options.get("width")
|
|
121
173
|
height = options.get("height")
|
|
@@ -241,6 +293,7 @@ def render_preview(options=None, guard=None, checkpoint=None, output=None, camer
|
|
|
241
293
|
"cameraId": camera.get("deepblend_id") or camera.name,
|
|
242
294
|
"cameraName": camera.name,
|
|
243
295
|
"lens": round(float(camera.data.lens), 6),
|
|
296
|
+
"cameraFacts": _camera_facts(scene, camera),
|
|
244
297
|
"overrides": overrides,
|
|
245
298
|
"renderConfig": _render_config(scene),
|
|
246
299
|
"objects": describe_objects(),
|