Coverage for trimesh/parent.py: 90%

150 statements  

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

1""" 

2parent.py 

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

4 

5The base class for Trimesh, PointCloud, and Scene objects 

6""" 

7 

8import abc 

9import os 

10from dataclasses import dataclass 

11from typing import Any 

12 

13import numpy as np 

14 

15from . import bounds, caching 

16from . import transformations as tf 

17from .caching import cache_decorator 

18from .constants import tol 

19from .resolvers import ResolverLike 

20from .typed import ArrayLike, NDArray, float64 

21from .util import ABC 

22 

23 

24@dataclass 

25class LoadSource: 

26 """ 

27 Save information about where a particular object was loaded from. 

28 """ 

29 

30 # a file-like object that can be accessed 

31 file_obj: Any | None = None 

32 

33 # a cleaned file type string, i.e. "stl" 

34 file_type: str | None = None 

35 

36 # if this was originally loaded from a file path 

37 # save it here so we can check it later. 

38 file_path: str | None = None 

39 

40 # did we open `file_obj` ourselves? 

41 was_opened: bool = False 

42 

43 # a resolver for loading assets next to the file 

44 resolver: ResolverLike | None = None 

45 

46 @property 

47 def file_name(self) -> str | None: 

48 """ 

49 Get just the file name from the path if available. 

50 

51 Returns 

52 --------- 

53 file_name 

54 Just the file name, i.e. for file_path="/a/b/c.stl" -> "c.stl" 

55 """ 

56 if self.file_path is None: 

57 return None 

58 return os.path.basename(self.file_path) 

59 

60 def __getstate__(self) -> dict[str, Any]: 

61 # this overrides the `pickle.dump` behavior for this class 

62 # we cannot pickle a file object so return `file_obj: None` for pickles 

63 return {k: v if k != "file_obj" else None for k, v in self.__dict__.items()} 

64 

65 def __deepcopy__(self, *args): 

66 return LoadSource(**self.__getstate__()) 

67 

68 

69class Geometry(ABC): 

70 """ 

71 `Geometry` is the parent class for all geometry. 

72 

73 By decorating a method with `abc.abstractmethod` it means 

74 the objects that inherit from `Geometry` MUST implement 

75 those methods. 

76 """ 

77 

78 # geometry should have a dict to store loose metadata 

79 metadata: dict[str, Any] 

80 

81 @property 

82 def source(self) -> LoadSource: 

83 """ 

84 Where and what was this current geometry loaded from? 

85 

86 Returns 

87 -------- 

88 source 

89 If loaded from a file, has the path, type, etc. 

90 """ 

91 # this should have been tacked on by the loader 

92 # but we want to *always* be able to access 

93 # a value like `mesh.source.file_type` so add a default 

94 current = getattr(self, "_source", None) 

95 if current is not None: 

96 return current 

97 self._source = LoadSource() 

98 return self._source 

99 

100 @property 

101 @abc.abstractmethod 

102 def identifier_hash(self) -> str: 

103 pass 

104 

105 @property 

106 @abc.abstractmethod 

107 def bounds(self) -> NDArray[np.float64]: 

108 pass 

109 

110 @property 

111 @abc.abstractmethod 

112 def extents(self) -> NDArray[np.float64]: 

113 pass 

114 

115 @abc.abstractmethod 

116 def apply_transform(self, matrix: ArrayLike) -> Any: 

117 pass 

118 

119 @property 

120 @abc.abstractmethod 

121 def is_empty(self) -> bool: 

122 pass 

123 

124 def __hash__(self): 

125 """ 

126 Get a hash of the current geometry. 

127 

128 Returns 

129 --------- 

130 hash 

131 Hash of current graph and geometry. 

132 """ 

133 return self._data.__hash__() # type: ignore 

134 

135 @abc.abstractmethod 

136 def copy(self): 

137 pass 

138 

139 @abc.abstractmethod 

140 def show(self): 

141 pass 

142 

143 @abc.abstractmethod 

144 def __add__(self, other): 

145 pass 

146 

147 @abc.abstractmethod 

148 def export(self, file_obj, file_type=None): 

149 pass 

150 

151 def __repr__(self) -> str: 

152 """ 

153 Print quick summary of the current geometry without 

154 computing properties. 

155 

156 Returns 

157 ----------- 

158 repr : str 

159 Human readable quick look at the geometry. 

160 """ 

161 elements = [] 

162 if hasattr(self, "vertices"): 

163 # for Trimesh and PointCloud 

164 elements.append(f"vertices.shape={self.vertices.shape}") 

165 if hasattr(self, "faces"): 

166 # for Trimesh 

167 elements.append(f"faces.shape={self.faces.shape}") 

168 if hasattr(self, "geometry") and isinstance(self.geometry, dict): 

169 # for Scene 

170 elements.append(f"len(geometry)={len(self.geometry)}") 

171 if "Voxel" in type(self).__name__: 

172 # for VoxelGrid objects 

173 elements.append(str(self.shape)[1:-1]) 

174 if "file_name" in self.metadata: 

175 display = self.metadata["file_name"] 

176 elements.append(f"name=`{display}`") 

177 return "<trimesh.{}({})>".format(type(self).__name__, ", ".join(elements)) 

178 

179 def apply_translation(self, translation: ArrayLike): 

180 """ 

181 Translate the current mesh. 

182 

183 Parameters 

184 ---------- 

185 translation : (3,) float 

186 Translation in XYZ 

187 """ 

188 translation = np.asanyarray(translation, dtype=np.float64) 

189 if translation.shape == (2,): 

190 # create a planar matrix if we were passed a 2D offset 

191 return self.apply_transform(tf.planar_matrix(offset=translation)) 

192 elif translation.shape != (3,): 

193 raise ValueError("Translation must be (3,) or (2,)!") 

194 

195 # manually create a translation matrix 

196 matrix = np.eye(4) 

197 matrix[:3, 3] = translation 

198 return self.apply_transform(matrix) 

199 

200 def apply_scale(self, scaling): 

201 """ 

202 Scale the mesh. 

203 

204 Parameters 

205 ---------- 

206 scaling : float or (3,) float 

207 Scale factor to apply to the mesh 

208 """ 

209 matrix = tf.scale_and_translate(scale=scaling) 

210 # apply_transform will work nicely even on negative scales 

211 return self.apply_transform(matrix) 

212 

213 def __radd__(self, other): 

214 """ 

215 Concatenate the geometry allowing concatenation with 

216 built in `sum()` function: 

217 `sum(Iterable[trimesh.Trimesh])` 

218 

219 Parameters 

220 ------------ 

221 other : Geometry 

222 Geometry or 0 

223 

224 Returns 

225 ---------- 

226 concat : Geometry 

227 Geometry of combined result 

228 """ 

229 

230 if other == 0: 

231 # adding 0 to a geometry never makes sense 

232 return self 

233 # otherwise just use the regular add function 

234 return self.__add__(type(self)(other)) 

235 

236 @cache_decorator 

237 def scale(self) -> float: 

238 """ 

239 A loosely specified "order of magnitude scale" for the 

240 geometry which always returns a value and can be used 

241 to make code more robust to large scaling differences. 

242 

243 It returns the diagonal of the axis aligned bounding box 

244 or if anything is invalid or undefined, `1.0`. 

245 

246 Returns 

247 ---------- 

248 scale : float 

249 Approximate order of magnitude scale of the geometry. 

250 """ 

251 # if geometry is empty return 1.0 

252 if self.extents is None: 

253 return 1.0 

254 

255 # get the length of the AABB diagonal 

256 scale = float((self.extents**2).sum() ** 0.5) 

257 if scale < tol.zero: 

258 return 1.0 

259 

260 return scale 

261 

262 @property 

263 def units(self) -> str | None: 

264 """ 

265 Definition of units for the mesh. 

266 

267 Returns 

268 ---------- 

269 units : str 

270 Unit system mesh is in, or None if not defined 

271 """ 

272 return self.metadata.get("units", None) 

273 

274 @units.setter 

275 def units(self, value: str) -> None: 

276 """ 

277 Define the units of the current mesh. 

278 """ 

279 self.metadata["units"] = str(value).lower().strip() 

280 

281 

282class Geometry3D(Geometry): 

283 """ 

284 The `Geometry3D` object is the parent object of geometry objects 

285 which are three dimensional, including Trimesh, PointCloud, 

286 and Scene objects. 

287 """ 

288 

289 @caching.cache_decorator 

290 def bounding_box(self): 

291 """ 

292 An axis aligned bounding box for the current mesh. 

293 

294 Returns 

295 ---------- 

296 aabb : trimesh.primitives.Box 

297 Box object with transform and extents defined 

298 representing the axis aligned bounding box of the mesh 

299 """ 

300 from . import primitives 

301 

302 transform = np.eye(4) 

303 # translate to center of axis aligned bounds 

304 transform[:3, 3] = self.bounds.mean(axis=0) 

305 

306 return primitives.Box(transform=transform, extents=self.extents, mutable=False) 

307 

308 @caching.cache_decorator 

309 def bounding_box_oriented(self): 

310 """ 

311 An oriented bounding box for the current mesh. 

312 

313 Returns 

314 --------- 

315 obb : trimesh.primitives.Box 

316 Box object with transform and extents defined 

317 representing the minimum volume oriented 

318 bounding box of the mesh 

319 """ 

320 from . import bounds, primitives 

321 

322 to_origin, extents = bounds.oriented_bounds(self) 

323 return primitives.Box( 

324 transform=np.linalg.inv(to_origin), extents=extents, mutable=False 

325 ) 

326 

327 @caching.cache_decorator 

328 def bounding_sphere(self): 

329 """ 

330 A minimum volume bounding sphere for the current mesh. 

331 

332 Note that the Sphere primitive returned has an unpadded 

333 exact `sphere_radius` so while the distance of every vertex 

334 of the current mesh from sphere_center will be less than 

335 sphere_radius, the faceted sphere primitive may not 

336 contain every vertex. 

337 

338 Returns 

339 -------- 

340 minball : trimesh.primitives.Sphere 

341 Sphere primitive containing current mesh 

342 """ 

343 from . import nsphere, primitives 

344 

345 center, radius = nsphere.minimum_nsphere(self) 

346 return primitives.Sphere(center=center, radius=radius, mutable=False) 

347 

348 @caching.cache_decorator 

349 def bounding_cylinder(self): 

350 """ 

351 A minimum volume bounding cylinder for the current mesh. 

352 

353 Returns 

354 -------- 

355 mincyl : trimesh.primitives.Cylinder 

356 Cylinder primitive containing current mesh 

357 """ 

358 from . import bounds, primitives 

359 

360 kwargs = bounds.minimum_cylinder(self) 

361 return primitives.Cylinder(mutable=False, **kwargs) 

362 

363 @caching.cache_decorator 

364 def bounding_primitive(self): 

365 """ 

366 The minimum volume primitive (box, sphere, or cylinder) that 

367 bounds the mesh. 

368 

369 Returns 

370 --------- 

371 bounding_primitive : object 

372 Smallest primitive which bounds the mesh: 

373 trimesh.primitives.Sphere 

374 trimesh.primitives.Box 

375 trimesh.primitives.Cylinder 

376 """ 

377 options = [ 

378 self.bounding_box_oriented, 

379 self.bounding_sphere, 

380 self.bounding_cylinder, 

381 ] 

382 volume_min = np.argmin([i.volume for i in options]) 

383 return options[volume_min] 

384 

385 def apply_obb(self, **kwargs) -> NDArray[float64]: 

386 """ 

387 Apply the oriented bounding box transform to the current mesh. 

388 

389 This will result in a mesh with an AABB centered at the 

390 origin and the same dimensions as the OBB. 

391 

392 Parameters 

393 ------------ 

394 kwargs 

395 Passed through to `bounds.oriented_bounds` 

396 

397 Returns 

398 ---------- 

399 matrix : (4, 4) float 

400 Transformation matrix that was applied 

401 to mesh to move it into OBB frame 

402 """ 

403 # save the pre-transform volume 

404 if tol.strict and hasattr(self, "volume"): 

405 volume = self.volume 

406 

407 # calculate the OBB passing keyword arguments through 

408 matrix, extents = bounds.oriented_bounds(self, **kwargs) 

409 # apply the transform 

410 self.apply_transform(matrix) 

411 

412 if tol.strict: 

413 # obb transform should not have changed volume 

414 if hasattr(self, "volume") and getattr(self, "is_watertight", False): 

415 assert np.isclose(self.volume, volume) 

416 # overall extents should match what we expected 

417 assert np.allclose(self.extents, extents) 

418 

419 return matrix