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

63 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +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 import OrderedDict 

10from collections.abc import Callable 

11from typing import Any, Literal, TypeAlias, TypedDict 

12 

13from ...constants import log 

14 

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

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

17# texture_source - when resolving texture image index 

18# primitive - after loading primitive, can add face_attributes 

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

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

21Scope: TypeAlias = Literal[ 

22 "material", "texture_source", "primitive", "primitive_preprocess", "primitive_export" 

23] 

24 

25 

26# ---------------------------------------------------------------------- 

27# TypedDict contexts for each scope 

28# ---------------------------------------------------------------------- 

29# 

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

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

32# 

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

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

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

36# reference fields they don't need. 

37# 

38# Example handler pattern: 

39# 

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

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

42# data = context["data"] 

43# images = context["images"] 

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

45# 

46# ---------------------------------------------------------------------- 

47 

48 

49class MaterialContext(TypedDict): 

50 """Context for material scope handlers.""" 

51 

52 data: dict[str, Any] 

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

54 images: list 

55 

56 

57class TextureSourceContext(TypedDict): 

58 """Context for texture_source scope handlers.""" 

59 

60 data: dict[str, Any] 

61 

62 

63class PrimitiveContext(TypedDict): 

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

65 

66 data: dict[str, Any] 

67 primitive: dict 

68 mesh_kwargs: dict 

69 accessors: list 

70 

71 

72class PrimitivePreprocessContext(TypedDict): 

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

74 

75 data: dict[str, Any] 

76 primitive: dict 

77 accessors: list 

78 views: list 

79 

80 

81class PrimitiveExportContext(TypedDict): 

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

83 

84 mesh: Any 

85 name: str 

86 tree: dict 

87 buffer_items: OrderedDict 

88 primitive: dict 

89 include_normals: bool 

90 

91 

92# Handler type alias - handlers receive a context dict 

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

94 

95# callback to parse material dict and resolve texture references 

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

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

98 

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

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

101 

102 

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

104 """ 

105 Recursively merge source dict into target dict. 

106 

107 Parameters 

108 ---------- 

109 target 

110 Dict to merge into (modified in place) 

111 source 

112 Dict to merge from 

113 """ 

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

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

116 # Both are dicts - recurse 

117 _deep_merge(target[key], value) 

118 else: 

119 # Overwrite or set new key 

120 target[key] = value 

121 

122 

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

124 """ 

125 Decorator to register a handler for a glTF extension. 

126 

127 Parameters 

128 ---------- 

129 name 

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

131 scope 

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

133 

134 Returns 

135 ------- 

136 decorator 

137 Function that registers the handler and returns it unchanged. 

138 

139 Example 

140 ------- 

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

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

143 ... data = context["data"] 

144 ... images = context["images"] 

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

146 """ 

147 if scope not in _handlers: 

148 _handlers[scope] = {} 

149 

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

151 _handlers[scope][name] = func 

152 return func 

153 

154 return decorator 

155 

156 

157def handle_extensions( 

158 *, 

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

160 scope: Scope, 

161 **kwargs, 

162) -> Any: 

163 """ 

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

165 

166 Parameters 

167 ---------- 

168 extensions 

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

170 scope 

171 Handler scope to invoke. 

172 **kwargs 

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

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

175 - material: parse_textures, images 

176 - texture_source: (none) 

177 - primitive: primitive, mesh_kwargs, accessors 

178 - primitive_preprocess: primitive, accessors, views 

179 - primitive_export: mesh, name, tree, buffer_items, primitive, include_normals 

180 

181 Returns 

182 ------- 

183 results 

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

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

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

187 """ 

188 if not extensions or scope not in _handlers: 

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

190 

191 results = {} 

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

193 if ext_name not in _handlers[scope]: 

194 continue 

195 try: 

196 # Build context dict with data + all kwargs 

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

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

199 results[ext_name] = result 

200 except Exception as e: 

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

202 

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

204 if scope.endswith("_source"): 

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

206 

207 # for primitive scope, automatically merge results into mesh_kwargs 

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

209 mesh_kwargs = kwargs["mesh_kwargs"] 

210 for ext_result in results.values(): 

211 if not isinstance(ext_result, dict): 

212 continue 

213 # merge extension results, recursively merging nested dicts 

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

215 if isinstance(value, dict): 

216 if key not in mesh_kwargs: 

217 mesh_kwargs[key] = {} 

218 _deep_merge(mesh_kwargs[key], value) 

219 else: 

220 mesh_kwargs[key] = value 

221 

222 return results 

223 

224 

225# ---------------------------------------------------------------------- 

226# Built-in handlers 

227# ---------------------------------------------------------------------- 

228 

229 

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

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

232 """ 

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

234 

235 Parameters 

236 ---------- 

237 context 

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

239 

240 Returns 

241 ------- 

242 pbr_dict 

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

244 """ 

245 try: 

246 from ...visual.gloss import specular_to_pbr 

247 

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

249 except Exception: 

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

251 return None 

252 

253 

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

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

256 """ 

257 Return image source index from EXT_texture_webp. 

258 

259 Parameters 

260 ---------- 

261 context 

262 TextureSourceContext with extension data. 

263 

264 Returns 

265 ------- 

266 source_index 

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

268 """ 

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