Coverage for trimesh/exchange/gltf/extensions.py: 86%

113 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-31 18:21 +0000

1""" 

2gltf_extensions.py 

3------------------ 

4 

5Extension registry for glTF import/export with scope-based handlers. 

6Each scope has a TypedDict defining the context passed to handlers. 

7""" 

8 

9from collections.abc import Callable, Iterable 

10from typing import Any, Literal, TypeAlias, TypedDict 

11 

12import numpy as np 

13 

14from ...constants import log 

15from ...iteration import IndexedDict 

16from ...typed import NDArray 

17 

18# Scopes define where in the glTF load/export process handlers run: 

19# material - after parsing material, can override PBR values 

20# texture_source - when resolving texture image index 

21# primitive - after loading primitive, can add face_attributes 

22# primitive_preprocess - before accessor reads, can modify accessors in-place 

23# primitive_export - during mesh export, can compress/modify primitive data 

24Scope: TypeAlias = Literal[ 

25 "material", "texture_source", "primitive", "primitive_preprocess", "primitive_export" 

26] 

27 

28 

29# ---------------------------------------------------------------------- 

30# TypedDict contexts for each scope 

31# ---------------------------------------------------------------------- 

32# 

33# These TypedDicts define the MINIMUM fields passed to handlers for each scope. 

34# Additional fields may be added in future versions for new functionality. 

35# 

36# FOR FORWARD COMPATIBILITY: Handlers should access only the fields they need 

37# and ignore unknown fields. The context is passed as a plain dict at runtime, 

38# so handlers can safely use dict.get() for optional access or simply not 

39# reference fields they don't need. 

40# 

41# Example handler pattern: 

42# 

43# def my_handler(context: MaterialContext) -> dict | None: 

44# # Access only what you need - additional fields won't break this 

45# data = context["data"] 

46# images = context["images"] 

47# return {"baseColorFactor": [1, 0, 0, 1]} 

48# 

49# ---------------------------------------------------------------------- 

50 

51 

52class MaterialContext(TypedDict): 

53 """Context for material scope handlers.""" 

54 

55 data: dict[str, Any] 

56 parse_textures: Callable[..., dict[str, Any]] 

57 images: list 

58 

59 

60class TextureSourceContext(TypedDict): 

61 """Context for texture_source scope handlers.""" 

62 

63 data: dict[str, Any] 

64 

65 

66class PrimitiveContext(TypedDict): 

67 """Context for primitive scope handlers (post-load).""" 

68 

69 data: dict[str, Any] 

70 primitive: dict 

71 mesh_kwargs: dict 

72 accessors: list 

73 

74 

75class PrimitivePreprocessContext(TypedDict): 

76 """Context for primitive_preprocess scope handlers (pre-load).""" 

77 

78 data: dict[str, Any] 

79 primitive: dict 

80 accessors: list 

81 views: list 

82 

83 

84class PrimitiveExportContext(TypedDict): 

85 """Context for primitive_export scope handlers (during export).""" 

86 

87 mesh: Any 

88 name: str 

89 tree: dict 

90 # a `bufferView` is the position of an entry in here, so the order matters 

91 buffer_items: IndexedDict 

92 primitive: dict 

93 # the arrays the primitive's accessors were built from, in the dtype they 

94 # would have been written with, keyed by accessor index: a handler storing 

95 # them itself never unpacks bytes back into numpy, and the exporter stores 

96 # them after all if no handler claims them 

97 arrays: dict[int, NDArray] 

98 

99 

100# Handler type alias - handlers receive a context dict 

101Handler: TypeAlias = Callable[[Any], Any] 

102 

103# callback to parse material dict and resolve texture references 

104# signature: (*, data: dict) -> dict 

105ParseTextures: TypeAlias = Callable[..., dict[str, Any]] 

106 

107# Registry: {scope: {extension_name: handler}} 

108_handlers: dict[str, dict[str, Handler]] = {} 

109 

110 

111def _deep_merge(target: dict, source: dict) -> None: 

112 """ 

113 Recursively merge source dict into target dict. 

114 

115 Parameters 

116 ---------- 

117 target 

118 Dict to merge into (modified in place) 

119 source 

120 Dict to merge from 

121 """ 

122 for key, value in source.items(): 

123 if isinstance(value, dict) and key in target and isinstance(target[key], dict): 

124 # Both are dicts - recurse 

125 _deep_merge(target[key], value) 

126 else: 

127 # Overwrite or set new key 

128 target[key] = value 

129 

130 

131def register_handler(name: str, scope: Scope) -> Callable[[Handler], Handler]: 

132 """ 

133 Decorator to register a handler for a glTF extension. 

134 

135 Parameters 

136 ---------- 

137 name 

138 Extension name, e.g. "KHR_materials_pbrSpecularGlossiness". 

139 scope 

140 Handler scope, e.g. "material", "texture_source", "primitive". 

141 

142 Returns 

143 ------- 

144 decorator 

145 Function that registers the handler and returns it unchanged. 

146 

147 Example 

148 ------- 

149 >>> @register_handler("MY_extension", scope="material") 

150 ... def my_handler(context: MaterialContext) -> dict | None: 

151 ... data = context["data"] 

152 ... images = context["images"] 

153 ... return {"baseColorFactor": [1, 0, 0, 1]} 

154 """ 

155 if scope not in _handlers: 

156 _handlers[scope] = {} 

157 

158 def decorator(func: Handler) -> Handler: 

159 _handlers[scope][name] = func 

160 return func 

161 

162 return decorator 

163 

164 

165def unregistered(extensions: Iterable[str], scope: Scope) -> set: 

166 """ 

167 Find extension names with no registered handler for a scope. 

168 

169 Parameters 

170 ---------- 

171 extensions 

172 Extension names, i.e. the keys of a glTF "extensions" dict. 

173 scope 

174 Handler scope to check against. 

175 

176 Returns 

177 ------- 

178 missing 

179 Extension names with no handler registered for the scope. 

180 """ 

181 return set(extensions) - _handlers.get(scope, {}).keys() 

182 

183 

184def handle_extensions( 

185 *, 

186 extensions: dict[str, Any] | None, 

187 scope: Scope, 

188 failed: set | None = None, 

189 **kwargs, 

190) -> Any: 

191 """ 

192 Process extensions dict for a given scope, calling registered handlers. 

193 

194 Parameters 

195 ---------- 

196 extensions 

197 The "extensions" dict from a glTF element, or None. 

198 scope 

199 Handler scope to invoke. 

200 failed 

201 If passed the name of any extension whose handler raised is added here. 

202 **kwargs 

203 Scope-specific arguments that will be combined with extension data 

204 into a typed context dict. Required kwargs by scope: 

205 - material: parse_textures, images 

206 - texture_source: (none) 

207 - primitive: primitive, mesh_kwargs, accessors 

208 - primitive_preprocess: primitive, accessors, views 

209 - primitive_export: mesh, name, tree, buffer_items, primitive, arrays 

210 

211 Returns 

212 ------- 

213 results 

214 Dict of {extension_name: result} for most scopes. 

215 For scopes ending in "_source", returns first non-None result. 

216 For "primitive" scope, automatically merges results into mesh_kwargs. 

217 """ 

218 if not extensions or scope not in _handlers: 

219 return {} if not scope.endswith("_source") else None 

220 

221 results = {} 

222 for ext_name, data in extensions.items(): 

223 if ext_name not in _handlers[scope]: 

224 continue 

225 try: 

226 # Build context dict with data + all kwargs 

227 context = {"data": data, **kwargs} 

228 if (result := _handlers[scope][ext_name](context)) is not None: 

229 results[ext_name] = result 

230 except Exception as e: 

231 if failed is not None: 

232 failed.add(ext_name) 

233 log.warning(f"failed to process extension {ext_name}: {e}") 

234 

235 # for _source scopes return first result, otherwise return all results 

236 if scope.endswith("_source"): 

237 return next(iter(results.values()), None) 

238 

239 # for primitive scope, automatically merge results into mesh_kwargs 

240 if scope == "primitive" and "mesh_kwargs" in kwargs: 

241 mesh_kwargs = kwargs["mesh_kwargs"] 

242 for ext_result in results.values(): 

243 if not isinstance(ext_result, dict): 

244 continue 

245 # merge extension results, recursively merging nested dicts 

246 for key, value in ext_result.items(): 

247 if isinstance(value, dict): 

248 if key not in mesh_kwargs: 

249 mesh_kwargs[key] = {} 

250 _deep_merge(mesh_kwargs[key], value) 

251 else: 

252 mesh_kwargs[key] = value 

253 

254 return results 

255 

256 

257# ---------------------------------------------------------------------- 

258# Built-in handlers 

259# ---------------------------------------------------------------------- 

260 

261 

262@register_handler("KHR_materials_pbrSpecularGlossiness", scope="material") 

263def _specular_glossiness(context: MaterialContext) -> dict[str, Any] | None: 

264 """ 

265 Convert specular-glossiness material to PBR metallic-roughness. 

266 

267 Parameters 

268 ---------- 

269 context 

270 MaterialContext with extension data, parse_textures function, and images. 

271 

272 Returns 

273 ------- 

274 pbr_dict 

275 PBR metallic-roughness parameters, or None on failure. 

276 """ 

277 try: 

278 from ...visual.gloss import specular_to_pbr 

279 

280 return specular_to_pbr(**context["parse_textures"](data=context["data"])) 

281 except Exception: 

282 log.debug("failed to convert specular-glossiness", exc_info=True) 

283 return None 

284 

285 

286@register_handler("EXT_texture_webp", scope="texture_source") 

287def _texture_webp_source(context: TextureSourceContext) -> int | None: 

288 """ 

289 Return image source index from EXT_texture_webp. 

290 

291 Parameters 

292 ---------- 

293 context 

294 TextureSourceContext with extension data. 

295 

296 Returns 

297 ------- 

298 source_index 

299 Index into glTF images array, or None if not present. 

300 """ 

301 return context["data"].get("source") 

302 

303 

304# the optional attributes draco can absorb, as 

305# (glTF name, `DracoPy.encode` keyword, `DracoPy.AttributeType`) 

306_draco_optional = ( 

307 ("COLOR_0", "colors", "COLOR"), 

308 ("TEXCOORD_0", "tex_coord", "TEX_COORD"), 

309 ("NORMAL", "normals", "NORMAL"), 

310) 

311 

312# how hard draco tries, which trades export time for size and is not lossy 

313_DRACO_COMPRESSION = 6 

314 

315# bits draco quantizes positions onto, which is what blender emits: a vertex 

316# moves up to half a step, i.e. `mesh.extents.max() * 2**-(bits + 1)` 

317_DRACO_QUANTIZATION = 14 

318 

319 

320@register_handler("KHR_draco_mesh_compression", scope="primitive_preprocess") 

321def draco_decode(context: PrimitivePreprocessContext) -> None: 

322 """ 

323 Replace a primitive's placeholder accessors with decompressed draco data. 

324 

325 The accessors of a draco-compressed primitive have no `bufferView`, so the 

326 loader filled them with zeros before calling us. All of the geometry is in 

327 a single opaque blob, and the extension carries the indirection we need to 

328 unpack it: a mapping of glTF attribute name to draco attribute id. 

329 

330 Parameters 

331 ---------- 

332 context 

333 PrimitivePreprocessContext, whose `accessors` we mutate in-place. 

334 """ 

335 import DracoPy 

336 

337 data = context["data"] 

338 accessors = context["accessors"] 

339 attributes = context["primitive"].get("attributes", {}) 

340 

341 # one blob holds every compressed attribute for this primitive 

342 decoded = DracoPy.decode(context["views"][data["bufferView"]]) 

343 

344 # the extension stores name -> draco id and we look up the other way 

345 names = {ident: name for name, ident in data["attributes"].items()} 

346 

347 # overwrite the zero placeholders in-place by accessor index 

348 for attr in decoded.attributes: 

349 name = names.get(attr["unique_id"]) 

350 if name in attributes: 

351 accessors[attributes[name]] = attr["data"] 

352 

353 # indices aren't an attribute so they're not in the extension mapping 

354 indices = context["primitive"].get("indices") 

355 faces = getattr(decoded, "faces", None) 

356 if indices is not None and faces is not None: 

357 accessors[indices] = faces 

358 

359 

360@register_handler("KHR_draco_mesh_compression", scope="primitive_export") 

361def draco_encode(context: PrimitiveExportContext) -> bool | None: 

362 """ 

363 Compress a primitive's geometry into a single draco buffer. 

364 

365 Every array in `arrays` is absorbed, so the exporter left their accessors 

366 with no `bufferView` rather than storing the same data twice. Returning 

367 `None` makes it store them after all, so a failure here exports the 

368 primitive uncompressed rather than pointing at accessors full of zeros. 

369 

370 Parameters 

371 ---------- 

372 context 

373 PrimitiveExportContext, whose `primitive` and `buffer_items` we mutate. 

374 

375 Returns 

376 ------- 

377 compressed 

378 True if the geometry is now inside a draco buffer, None if not. 

379 """ 

380 import DracoPy 

381 

382 from . import _buffer_append 

383 

384 primitive = context["primitive"] 

385 attributes = primitive["attributes"] 

386 # the arrays the accessors were built from keyed by accessor index, so we 

387 # never unpack bytes back into numpy and can only claim what was recorded 

388 arrays = context["arrays"] 

389 

390 # the optional attributes this primitive actually has: anything the 

391 # exporter stored itself, like a custom `_ATTRIBUTE`, is not in `arrays` 

392 absorb, optional = [], {} 

393 for name, keyword, kind in _draco_optional: 

394 value = arrays.get(attributes.get(name)) 

395 if value is None: 

396 continue 

397 absorb.append((name, kind)) 

398 # DracoPy asserts on float64 for UV and normals, colors stay uint8 

399 optional[keyword] = value if value.dtype == np.uint8 else value.astype(np.float64) 

400 

401 position = arrays[attributes["POSITION"]] 

402 indices = arrays[primitive["indices"]] 

403 

404 # `preserve_order` is load-bearing: without it draco rewelds and permutes 

405 # vertices, which would invalidate the count/min/max already written into 

406 # every accessor and silently misindex any attribute we didn't compress 

407 buffer = DracoPy.encode( 

408 points=position, 

409 faces=indices, 

410 preserve_order=True, 

411 quantization_bits=_DRACO_QUANTIZATION, 

412 compression_level=_DRACO_COMPRESSION, 

413 **optional, 

414 ) 

415 

416 # decode what we just encoded, which does two things we can't get otherwise: 

417 # it reports the attribute ids draco assigned, and it proves the round trip 

418 # preserved our counts before we let the exporter skip storing the source. 

419 # DO NOT remove this check: it is all that stands between a lossy encoder 

420 # and silently corrupt geometry in the exported file. 

421 check = DracoPy.decode(buffer) 

422 if len(check.points) != len(position) or len(check.faces) != len(indices): 

423 log.warning("draco round trip changed the vertex count, not compressing") 

424 return None 

425 

426 # draco identifies attributes by kind, the extension identifies them by id. 

427 # an attribute draco silently dropped is missing from `ids`, and the 

428 # `KeyError` leaves this primitive uncompressed rather than pointing at 

429 # an accessor the exporter is about to stop storing 

430 ids = {attr["attribute_type"]: attr["unique_id"] for attr in check.attributes} 

431 absorbed = { 

432 name: ids[getattr(DracoPy.AttributeType, kind)] 

433 for name, kind in [("POSITION", "POSITION"), *absorb] 

434 } 

435 

436 primitive.setdefault("extensions", {})["KHR_draco_mesh_compression"] = { 

437 # identical geometry encodes identically so this dedupes for free 

438 "bufferView": _buffer_append(context["buffer_items"], buffer), 

439 "attributes": absorbed, 

440 } 

441 return True