Coverage for trimesh/exchange/dae.py: 86%

218 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import copy 

2import io 

3import uuid 

4 

5import numpy as np 

6 

7from .. import util, visual 

8from ..constants import log 

9from ..util import unique_name 

10 

11_EYE = np.eye(4) 

12_EYE.flags.writeable = False 

13 

14 

15def load_collada(file_obj, resolver=None, ignore_broken=True, **kwargs): 

16 """ 

17 Load a COLLADA (.dae) file into a list of trimesh kwargs. 

18 

19 Parameters 

20 ---------- 

21 file_obj : file object 

22 Containing a COLLADA file 

23 resolver : trimesh.visual.Resolver or None 

24 For loading referenced files, like texture images 

25 ignore_broken: bool 

26 Ignores broken references during loading: 

27 [collada.common.DaeUnsupportedError, 

28 collada.common.DaeBrokenRefError] 

29 kwargs : ** 

30 Passed to trimesh.Trimesh.__init__ 

31 

32 Returns 

33 ------- 

34 loaded : list of dict 

35 kwargs for Trimesh constructor 

36 """ 

37 import collada 

38 

39 if ignore_broken: 

40 ignores = [ 

41 collada.common.DaeError, 

42 collada.common.DaeIncompleteError, 

43 collada.common.DaeMalformedError, 

44 collada.common.DaeBrokenRefError, 

45 collada.common.DaeUnsupportedError, 

46 collada.common.DaeIncompleteError, 

47 ] 

48 else: 

49 ignores = None 

50 

51 # load scene using pycollada 

52 c = collada.Collada(file_obj, ignore=ignores) 

53 

54 # Create material map from Material ID to trimesh material 

55 material_map = {} 

56 for m in c.materials: 

57 effect = m.effect 

58 material_map[m.id] = _parse_material(effect, resolver) 

59 

60 unit = c.assetInfo.unitmeter 

61 if unit is None or np.isclose(unit, 1.0): 

62 metadata = {"units": "meters"} 

63 else: 

64 metadata = {"units": f"{unit} * meters"} 

65 

66 # name : kwargs 

67 meshes = {} 

68 # increments to enable `unique_name` to avoid n^2 behavior 

69 meshes_count = {} 

70 # list of dict 

71 graph = [] 

72 

73 for node in c.scene.nodes: 

74 _parse_node( 

75 node=node, 

76 parent_matrix=_EYE, 

77 material_map=material_map, 

78 meshes=meshes, 

79 meshes_count=meshes_count, 

80 graph=graph, 

81 resolver=resolver, 

82 metadata=metadata, 

83 ) 

84 

85 return {"class": "Scene", "graph": graph, "geometry": meshes} 

86 

87 

88def export_collada(mesh, **kwargs): 

89 """ 

90 Export a mesh or a list of meshes as a COLLADA .dae file. 

91 

92 Parameters 

93 ----------- 

94 mesh: Trimesh object or list of Trimesh objects 

95 The mesh(es) to export. 

96 

97 Returns 

98 ----------- 

99 export: str, string of COLLADA format output 

100 """ 

101 import collada 

102 

103 meshes = mesh 

104 if not isinstance(mesh, (list, tuple, set, np.ndarray)): 

105 meshes = [mesh] 

106 

107 c = collada.Collada() 

108 nodes = [] 

109 for i, m in enumerate(meshes): 

110 # Load uv, colors, materials 

111 uv = None 

112 colors = None 

113 mat = _unparse_material(None) 

114 if m.visual.defined: 

115 if m.visual.kind == "texture": 

116 mat = _unparse_material(m.visual.material) 

117 uv = m.visual.uv 

118 elif m.visual.kind == "vertex": 

119 colors = (m.visual.vertex_colors / 255.0)[:, :3] 

120 mat.effect.diffuse = np.array(m.visual.main_color) / 255.0 

121 elif m.visual.kind == "face": 

122 mat.effect.diffuse = np.array(m.visual.main_color) / 255.0 

123 c.effects.append(mat.effect) 

124 c.materials.append(mat) 

125 

126 # Create geometry object 

127 vertices = collada.source.FloatSource( 

128 "verts-array", m.vertices.flatten(), ("X", "Y", "Z") 

129 ) 

130 normals = collada.source.FloatSource( 

131 "normals-array", m.vertex_normals.flatten(), ("X", "Y", "Z") 

132 ) 

133 input_list = collada.source.InputList() 

134 input_list.addInput(0, "VERTEX", "#verts-array") 

135 input_list.addInput(1, "NORMAL", "#normals-array") 

136 arrays = [vertices, normals] 

137 if (uv is not None) and (len(uv) > 0): 

138 texcoords = collada.source.FloatSource( 

139 "texcoords-array", uv.flatten(), ("U", "V") 

140 ) 

141 input_list.addInput(2, "TEXCOORD", "#texcoords-array") 

142 arrays.append(texcoords) 

143 if colors is not None: 

144 idx = 2 

145 if uv: 

146 idx = 3 

147 colors = collada.source.FloatSource( 

148 "colors-array", colors.flatten(), ("R", "G", "B") 

149 ) 

150 input_list.addInput(idx, "COLOR", "#colors-array") 

151 arrays.append(colors) 

152 geom = collada.geometry.Geometry(c, uuid.uuid4().hex, uuid.uuid4().hex, arrays) 

153 indices = np.repeat(m.faces.flatten(), len(arrays)) 

154 

155 matref = f"material{i}" 

156 triset = geom.createTriangleSet(indices, input_list, matref) 

157 geom.primitives.append(triset) 

158 c.geometries.append(geom) 

159 

160 matnode = collada.scene.MaterialNode(matref, mat, inputs=[]) 

161 geomnode = collada.scene.GeometryNode(geom, [matnode]) 

162 node = collada.scene.Node(f"node{i}", children=[geomnode]) 

163 nodes.append(node) 

164 scene = collada.scene.Scene("scene", nodes) 

165 c.scenes.append(scene) 

166 c.scene = scene 

167 

168 b = io.BytesIO() 

169 c.write(b) 

170 b.seek(0) 

171 return b.read() 

172 

173 

174def _parse_node( 

175 node, parent_matrix, material_map, meshes, meshes_count, graph, resolver, metadata 

176): 

177 """ 

178 Recursively parse COLLADA scene nodes. 

179 """ 

180 import collada 

181 

182 # Parse mesh node 

183 if isinstance(node, collada.scene.GeometryNode): 

184 geometry = node.geometry 

185 

186 # Create local material map from material symbol to actual material 

187 local_material_map = {} 

188 for mn in node.materials: 

189 symbol = mn.symbol 

190 m = mn.target 

191 if m.id in material_map: 

192 local_material_map[symbol] = material_map[m.id] 

193 else: 

194 local_material_map[symbol] = _parse_material(m, resolver) 

195 

196 # Iterate over primitives of geometry 

197 for primitive in geometry.primitives: 

198 if isinstance(primitive, collada.polylist.Polylist): 

199 primitive = primitive.triangleset() 

200 if isinstance(primitive, collada.triangleset.TriangleSet): 

201 vertex = primitive.vertex 

202 if vertex is None: 

203 continue 

204 vertex_index = primitive.vertex_index 

205 vertices = vertex[vertex_index].reshape(len(vertex_index) * 3, 3) 

206 

207 # Get normals if present 

208 normals = None 

209 if primitive.normal is not None: 

210 normal = primitive.normal 

211 normal_index = primitive.normal_index 

212 normals = normal[normal_index].reshape(len(normal_index) * 3, 3) 

213 

214 # Get colors if present 

215 colors = None 

216 s = primitive.sources 

217 if "COLOR" in s and len(s["COLOR"]) > 0 and len(primitive.index) > 0: 

218 color = s["COLOR"][0][4].data 

219 color_index = primitive.index[:, :, s["COLOR"][0][0]] 

220 colors = color[color_index].reshape(len(color_index) * 3, -1) 

221 

222 faces = np.arange(vertices.shape[0]).reshape(vertices.shape[0] // 3, 3) 

223 

224 # Get UV coordinates if possible 

225 vis = None 

226 if primitive.material in local_material_map: 

227 material = copy.copy(local_material_map[primitive.material]) 

228 uv = None 

229 if len(primitive.texcoordset) > 0: 

230 texcoord = primitive.texcoordset[0] 

231 texcoord_index = primitive.texcoord_indexset[0] 

232 uv = texcoord[texcoord_index].reshape( 

233 (len(texcoord_index) * 3, 2) 

234 ) 

235 vis = visual.texture.TextureVisuals(uv=uv, material=material) 

236 

237 geom_name = unique_name(geometry.id, contains=meshes, counts=meshes_count) 

238 meshes[geom_name] = { 

239 "vertices": vertices, 

240 "faces": faces, 

241 "vertex_normals": normals, 

242 "vertex_colors": colors, 

243 "visual": vis, 

244 "metadata": metadata, 

245 } 

246 

247 graph.append( 

248 { 

249 "frame_to": geom_name, 

250 "matrix": parent_matrix, 

251 "geometry": geom_name, 

252 } 

253 ) 

254 

255 # recurse down tree for nodes with children 

256 elif isinstance(node, collada.scene.Node): 

257 if node.children is not None: 

258 for child in node.children: 

259 # create the new matrix 

260 matrix = np.dot(parent_matrix, node.matrix) 

261 # parse the child node 

262 _parse_node( 

263 node=child, 

264 parent_matrix=matrix, 

265 material_map=material_map, 

266 meshes=meshes, 

267 meshes_count=meshes_count, 

268 graph=graph, 

269 resolver=resolver, 

270 metadata=metadata, 

271 ) 

272 

273 elif isinstance(node, collada.scene.CameraNode): 

274 # TODO: convert collada cameras to trimesh cameras 

275 pass 

276 elif isinstance(node, collada.scene.LightNode): 

277 # TODO: convert collada lights to trimesh lights 

278 pass 

279 

280 

281def _load_texture(file_name, resolver): 

282 """ 

283 Load a texture from a file into a PIL image. 

284 """ 

285 from PIL import Image 

286 

287 file_data = resolver.get(file_name) 

288 image = Image.open(util.wrap_as_stream(file_data)) 

289 return image 

290 

291 

292def _parse_material(effect, resolver): 

293 """ 

294 Turn a COLLADA effect into a trimesh material. 

295 """ 

296 import collada 

297 

298 # Compute base color 

299 baseColorFactor = np.ones(4) 

300 baseColorTexture = None 

301 if isinstance(effect.diffuse, collada.material.Map): 

302 try: 

303 baseColorTexture = _load_texture( 

304 effect.diffuse.sampler.surface.image.path, resolver 

305 ) 

306 except BaseException: 

307 log.debug("unable to load base texture", exc_info=True) 

308 elif effect.diffuse is not None: 

309 baseColorFactor = effect.diffuse 

310 

311 # Compute emission color 

312 emissiveFactor = np.zeros(3) 

313 emissiveTexture = None 

314 if isinstance(effect.emission, collada.material.Map): 

315 try: 

316 emissiveTexture = _load_texture( 

317 effect.diffuse.sampler.surface.image.path, resolver 

318 ) 

319 except BaseException: 

320 log.warning("unable to load emissive texture", exc_info=True) 

321 elif effect.emission is not None: 

322 emissiveFactor = effect.emission[:3] 

323 

324 # Compute roughness 

325 roughnessFactor = 1.0 

326 if ( 

327 not isinstance(effect.shininess, collada.material.Map) 

328 and effect.shininess is not None 

329 ): 

330 try: 

331 shininess_value = float(effect.shininess) 

332 roughnessFactor = np.sqrt(2.0 / (2.0 + shininess_value)) 

333 except (TypeError, ValueError): 

334 log.warning( 

335 f"Invalid shininess value: {effect.shininess}, using default roughness" 

336 ) 

337 

338 # Compute metallic factor 

339 metallicFactor = 0.0 

340 

341 # Compute normal texture 

342 normalTexture = None 

343 if effect.bumpmap is not None: 

344 try: 

345 normalTexture = _load_texture( 

346 effect.bumpmap.sampler.surface.image.path, resolver 

347 ) 

348 except BaseException: 

349 log.warning("unable to load bumpmap", exc_info=True) 

350 

351 # Compute opacity 

352 if effect.transparent is not None and not isinstance( 

353 effect.transparent, collada.material.Map 

354 ): 

355 baseColorFactor = tuple( 

356 np.append(baseColorFactor[:3], float(effect.transparent[3])) 

357 ) 

358 

359 return visual.material.PBRMaterial( 

360 emissiveFactor=emissiveFactor, 

361 emissiveTexture=emissiveTexture, 

362 normalTexture=normalTexture, 

363 baseColorTexture=baseColorTexture, 

364 baseColorFactor=baseColorFactor, 

365 metallicFactor=metallicFactor, 

366 roughnessFactor=roughnessFactor, 

367 ) 

368 

369 

370def _unparse_material(material): 

371 """ 

372 Turn a trimesh material into a COLLADA material. 

373 """ 

374 import collada 

375 

376 # TODO EXPORT TEXTURES 

377 if isinstance(material, visual.material.PBRMaterial): 

378 diffuse = material.baseColorFactor 

379 if diffuse is None: 

380 diffuse = np.array([255.0, 255.0, 255.0, 255.0]) 

381 diffuse = diffuse / 255.0 

382 if diffuse is not None: 

383 diffuse = list(diffuse) 

384 

385 emission = material.emissiveFactor 

386 if emission is not None: 

387 emission = [float(emission[0]), float(emission[1]), float(emission[2]), 1.0] 

388 

389 shininess = material.roughnessFactor 

390 if shininess is None: 

391 shininess = 1.0 

392 if shininess is not None: 

393 shininess = 2.0 / shininess**2 - 2.0 

394 

395 effect = collada.material.Effect( 

396 uuid.uuid4().hex, 

397 params=[], 

398 shadingtype="phong", 

399 diffuse=diffuse, 

400 emission=emission, 

401 specular=[1.0, 1.0, 1.0, 1.0], 

402 shininess=float(shininess), 

403 ) 

404 material = collada.material.Material(uuid.uuid4().hex, "pbrmaterial", effect) 

405 else: 

406 effect = collada.material.Effect(uuid.uuid4().hex, params=[], shadingtype="phong") 

407 material = collada.material.Material(uuid.uuid4().hex, "defaultmaterial", effect) 

408 return material 

409 

410 

411def load_zae(file_obj, resolver=None, **kwargs): 

412 """ 

413 Load a ZAE file, which is just a zipped DAE file. 

414 

415 Parameters 

416 ------------- 

417 file_obj : file object 

418 Contains ZAE data 

419 resolver : trimesh.visual.Resolver 

420 Resolver to load additional assets 

421 kwargs : dict 

422 Passed to load_collada 

423 

424 Returns 

425 ------------ 

426 loaded : dict 

427 Results of loading 

428 """ 

429 

430 # a dict, {file name : file object} 

431 archive = util.decompress(file_obj, file_type="zip") 

432 

433 # load the first file with a .dae extension 

434 file_name = next(i for i in archive.keys() if i.lower().endswith(".dae")) 

435 

436 # a resolver so the loader can load textures / etc 

437 resolver = visual.resolvers.ZipResolver(archive) 

438 

439 # run the regular collada loader 

440 loaded = load_collada(archive[file_name], resolver=resolver, **kwargs) 

441 return loaded 

442 

443 

444# only provide loaders if `pycollada` is installed 

445_collada_loaders = {} 

446_collada_exporters = {} 

447if util.has_module("collada"): 

448 _collada_loaders["dae"] = load_collada 

449 _collada_loaders["zae"] = load_zae 

450 _collada_exporters["dae"] = export_collada 

451else: 

452 # store an exception to raise later 

453 from ..exceptions import ExceptionWrapper 

454 

455 _exc = ExceptionWrapper(ImportError("missing `pip install pycollada`")) 

456 _collada_loaders.update({"dae": _exc, "zae": _exc}) 

457 _collada_exporters["dae"] = _exc