Coverage for trimesh/primitives.py: 94%

368 statements  

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

1""" 

2primitives.py 

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

4 

5Subclasses of Trimesh objects that are parameterized as primitives. 

6 

7Useful because you can move boxes and spheres around 

8and then use trimesh operations on them at any point. 

9""" 

10 

11import abc 

12 

13import numpy as np 

14 

15from . import creation, inertia, sample, triangles, util 

16from . import transformations as tf 

17from .base import Trimesh 

18from .caching import cache_decorator 

19from .constants import log, tol 

20from .typed import ArrayLike, Integer, Number, Seed 

21 

22# immutable identity matrix for checks 

23_IDENTITY = np.eye(4) 

24_IDENTITY.flags.writeable = False 

25 

26 

27class Primitive(Trimesh): 

28 """ 

29 Geometric Primitives which are a subclass of Trimesh. 

30 Mesh is generated lazily when vertices or faces are requested. 

31 """ 

32 

33 # ignore superclass copy directives 

34 __copy__ = None 

35 __deepcopy__ = None 

36 

37 def __init__(self): 

38 # run the Trimesh constructor with no arguments 

39 super().__init__() 

40 

41 # remove any data 

42 self._data.clear() 

43 self._validate = False 

44 

45 # make sure any cached numpy arrays have 

46 # set `array.flags.writable = False` 

47 self._cache.force_immutable = True 

48 

49 def __repr__(self): 

50 return f"<trimesh.primitives.{type(self).__name__}>" 

51 

52 @property 

53 def faces(self): 

54 stored = self._cache["faces"] 

55 if util.is_shape(stored, (-1, 3)): 

56 return stored 

57 self._create_mesh() 

58 return self._cache["faces"] 

59 

60 @faces.setter 

61 def faces(self, values): 

62 if values is not None: 

63 raise ValueError("primitive faces are immutable: not setting!") 

64 

65 @property 

66 def vertices(self): 

67 stored = self._cache["vertices"] 

68 if util.is_shape(stored, (-1, 3)): 

69 return stored 

70 

71 self._create_mesh() 

72 return self._cache["vertices"] 

73 

74 @vertices.setter 

75 def vertices(self, values): 

76 if values is not None: 

77 raise ValueError("primitive vertices are immutable: not setting!") 

78 

79 @property 

80 def face_normals(self): 

81 # if the mesh hasn't been created yet do that 

82 # before checking to see if the mesh creation 

83 # already populated the face normals 

84 if "vertices" not in self._cache: 

85 self._create_mesh() 

86 

87 # we need to avoid the logic in the superclass that 

88 # is specific to the data model prioritizing faces 

89 stored = self._cache["face_normals"] 

90 if util.is_shape(stored, (-1, 3)): 

91 return stored 

92 

93 # if the creation did not populate normals we have to do it 

94 # just calculate if not stored 

95 unit, valid = triangles.normals(self.triangles) 

96 normals = np.zeros((len(valid), 3)) 

97 normals[valid] = unit 

98 # store and return 

99 self._cache["face_normals"] = normals 

100 return normals 

101 

102 @face_normals.setter 

103 def face_normals(self, values): 

104 if values is not None: 

105 log.warning("Primitive face normals are immutable!") 

106 

107 @property 

108 def transform(self): 

109 """ 

110 The transform of the Primitive object. 

111 

112 Returns 

113 ------------- 

114 transform : (4, 4) float 

115 Homogeneous transformation matrix 

116 """ 

117 return self.primitive.transform 

118 

119 @abc.abstractmethod 

120 def to_dict(self): 

121 """ 

122 Should be implemented by each primitive. 

123 """ 

124 raise NotImplementedError() 

125 

126 def copy(self, include_visual=True, **kwargs): 

127 """ 

128 Return a copy of the Primitive object. 

129 

130 Returns 

131 ------------- 

132 copied : object 

133 Copy of current primitive 

134 """ 

135 # get the constructor arguments 

136 kwargs.update(self.to_dict()) 

137 # remove the type indicator, i.e. `Cylinder` 

138 kwargs.pop("kind") 

139 # create a new object with kwargs 

140 primitive_copy = type(self)(**kwargs) 

141 

142 if include_visual: 

143 # copy visual information 

144 primitive_copy.visual = self.visual.copy() 

145 

146 # copy metadata 

147 primitive_copy.metadata = self.metadata.copy() 

148 

149 for k, v in self._data.data.items(): 

150 if k not in primitive_copy._data: 

151 primitive_copy._data[k] = v 

152 

153 return primitive_copy 

154 

155 def to_mesh(self, **kwargs): 

156 """ 

157 Return a copy of the Primitive object as a Trimesh. 

158 

159 Parameters 

160 ----------- 

161 kwargs : dict 

162 Passed to the Trimesh object constructor. 

163 

164 Returns 

165 ------------ 

166 mesh : trimesh.Trimesh 

167 Tessellated version of the primitive. 

168 """ 

169 result = Trimesh( 

170 vertices=self.vertices.copy(), 

171 faces=self.faces.copy(), 

172 face_normals=self.face_normals.copy(), 

173 process=kwargs.pop("process", False), 

174 **kwargs, 

175 ) 

176 return result 

177 

178 def apply_transform(self, matrix): 

179 """ 

180 Apply a transform to the current primitive by 

181 applying a new transform on top of existing 

182 `self.primitive.transform`. If the matrix 

183 contains scaling it will change parameters 

184 like `radius` or `height` automatically. 

185 

186 Parameters 

187 ------------ 

188 matrix: (4, 4) float 

189 Homogeneous transformation 

190 """ 

191 matrix = np.asanyarray(matrix, order="C", dtype=np.float64) 

192 if matrix.shape != (4, 4): 

193 raise ValueError("matrix must be `(4, 4)`!") 

194 if util.allclose(matrix, _IDENTITY, 1e-8): 

195 # identity matrix is a no-op 

196 return self 

197 

198 prim = self.primitive 

199 # copy the current transform 

200 current = prim.transform.copy() 

201 # see if matrix has scaling from the matrix 

202 scale = np.linalg.det(matrix[:3, :3]) ** (1.0 / 3.0) 

203 

204 # the objects we handle re-scaling for 

205 # note that `Extrusion` is NOT supported 

206 kinds = (Box, Cylinder, Capsule, Sphere) 

207 if isinstance(self, kinds) and abs(scale - 1.0) > 1e-8: 

208 # scale the primitive attributes 

209 if hasattr(prim, "height"): 

210 prim.height *= scale 

211 if hasattr(prim, "radius"): 

212 prim.radius *= scale 

213 if hasattr(prim, "extents"): 

214 prim.extents *= scale 

215 # scale the translation of the current matrix 

216 current[:3, 3] *= scale 

217 # apply new matrix, rescale, translate, current 

218 updated = util.multi_dot([matrix, tf.scale_matrix(1.0 / scale), current]) 

219 else: 

220 # without scaling just multiply 

221 updated = np.dot(matrix, current) 

222 

223 # make sure matrix is a rigid transform 

224 if not tf.is_rigid(updated): 

225 raise ValueError("Couldn't produce rigid transform!") 

226 

227 # apply the new matrix 

228 self.primitive.transform = updated 

229 

230 return self 

231 

232 def _create_mesh(self): 

233 raise ValueError("Primitive doesn't define mesh creation!") 

234 

235 

236class PrimitiveAttributes: 

237 """ 

238 Hold the mutable data which defines a primitive. 

239 """ 

240 

241 def __init__(self, parent, defaults, kwargs, mutable=True): 

242 """ 

243 Hold the attributes for a Primitive. 

244 

245 Parameters 

246 ------------ 

247 parent : Primitive 

248 Parent object reference. 

249 defaults : dict 

250 The default values for this primitive type. 

251 kwargs : dict 

252 User-passed values, i.e. {'radius': 10.0} 

253 """ 

254 # store actual data in parent object 

255 self._data = parent._data 

256 # default values define the keys 

257 self._defaults = defaults 

258 # store a reference to the parent ubject 

259 self._parent = parent 

260 # start with a copy of all default objects 

261 self._data.update(defaults) 

262 # store whether this data is mutable after creation 

263 self._mutable = mutable 

264 # assign the keys passed by the user only if 

265 # they are a property of this primitive 

266 for key, default in defaults.items(): 

267 value = kwargs.get(key, None) 

268 if value is not None: 

269 # convert passed data into type of defaults 

270 self._data[key] = util.convert_like(value, default) 

271 # make sure stored values are immutable after setting 

272 if not self._mutable: 

273 self._data.mutable = False 

274 

275 @property 

276 def __doc__(self): 

277 # this is generated dynamically as the format 

278 # operation can be surprisingly slow and most 

279 # people never call it 

280 import pprint 

281 

282 doc = ( 

283 "Store the attributes of a {name} object.\n\n" 

284 + "When these values are changed, the mesh geometry will \n" 

285 + "automatically be updated to reflect the new values.\n\n" 

286 + "Available properties and their default values are:\n {defaults}" 

287 + "\n\nExample\n---------------\n" 

288 + "p = trimesh.primitives.{name}()\n" 

289 + "p.primitive.radius = 10\n" 

290 + "\n" 

291 ).format( 

292 name=self._parent.__class__.__name__, 

293 defaults=pprint.pformat(self._defaults, width=-1)[1:-1], 

294 ) 

295 return doc 

296 

297 def __getattr__(self, key): 

298 if key.startswith("_"): 

299 return super().__getattr__(key) 

300 elif key == "center": 

301 # this whole __getattr__ is a little hacky 

302 return self._data["transform"][:3, 3] 

303 elif key in self._defaults: 

304 return util.convert_like(self._data[key], self._defaults[key]) 

305 raise AttributeError(f"primitive object has no attribute '{key}' ") 

306 

307 def __setattr__(self, key, value): 

308 if key.startswith("_"): 

309 return super().__setattr__(key, value) 

310 elif key == "center": 

311 value = np.array(value, dtype=np.float64) 

312 transform = np.eye(4) 

313 transform[:3, 3] = value 

314 self._data["transform"] = transform 

315 return 

316 elif key in self._defaults: 

317 if self._mutable: 

318 self._data[key] = util.convert_like(value, self._defaults[key]) 

319 else: 

320 raise ValueError( 

321 "Primitive is configured as immutable! Cannot set attribute!" 

322 ) 

323 else: 

324 keys = list(self._defaults.keys()) 

325 raise ValueError(f"Only default attributes {keys} can be set!") 

326 

327 def __dir__(self): 

328 result = sorted(dir(type(self)) + list(self._defaults.keys())) 

329 return result 

330 

331 

332class Cylinder(Primitive): 

333 def __init__(self, radius=1.0, height=1.0, transform=None, sections=32, mutable=True): 

334 """ 

335 Create a Cylinder Primitive, a subclass of Trimesh. 

336 

337 Parameters 

338 ------------- 

339 radius : float 

340 Radius of cylinder 

341 height : float 

342 Height of cylinder 

343 transform : (4, 4) float 

344 Homogeneous transformation matrix 

345 sections : int 

346 Number of facets in circle. 

347 mutable : bool 

348 Are extents and transform mutable after creation. 

349 """ 

350 super().__init__() 

351 

352 defaults = {"height": 10.0, "radius": 1.0, "transform": np.eye(4), "sections": 32} 

353 self.primitive = PrimitiveAttributes( 

354 self, 

355 defaults=defaults, 

356 kwargs={ 

357 "height": height, 

358 "radius": radius, 

359 "transform": transform, 

360 "sections": sections, 

361 }, 

362 mutable=mutable, 

363 ) 

364 

365 @cache_decorator 

366 def volume(self): 

367 """ 

368 The analytic volume of the cylinder primitive. 

369 

370 Returns 

371 --------- 

372 volume : float 

373 Volume of the cylinder 

374 """ 

375 return (np.pi * self.primitive.radius**2) * self.primitive.height 

376 

377 @cache_decorator 

378 def area(self) -> float: 

379 """ 

380 The analytical area of the cylinder primitive 

381 """ 

382 # circumfrence * height + end-cap-area 

383 radius, height = self.primitive.radius, self.primitive.height 

384 return (np.pi * 2 * radius * height) + (2 * np.pi * radius**2) 

385 

386 @cache_decorator 

387 def moment_inertia(self): 

388 """ 

389 The analytic inertia tensor of the cylinder primitive. 

390 

391 Returns 

392 ---------- 

393 tensor: (3, 3) float 

394 3D inertia tensor 

395 """ 

396 

397 tensor = inertia.cylinder_inertia( 

398 mass=self.volume, 

399 radius=self.primitive.radius, 

400 height=self.primitive.height, 

401 transform=self.primitive.transform, 

402 ) 

403 return tensor 

404 

405 @cache_decorator 

406 def direction(self): 

407 """ 

408 The direction of the cylinder's axis. 

409 

410 Returns 

411 -------- 

412 axis: (3,) float, vector along the cylinder axis 

413 """ 

414 axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3] 

415 return axis 

416 

417 @property 

418 def segment(self): 

419 """ 

420 A line segment which if inflated by cylinder radius 

421 would represent the cylinder primitive. 

422 

423 Returns 

424 ------------- 

425 segment : (2, 3) float 

426 Points representing a single line segment 

427 """ 

428 # half the height 

429 half = self.primitive.height / 2.0 

430 # apply the transform to the Z- aligned segment 

431 points = np.dot( 

432 self.primitive.transform, np.transpose([[0, 0, -half, 1], [0, 0, half, 1]]) 

433 ).T[:, :3] 

434 return points 

435 

436 def to_dict(self): 

437 """ 

438 Get a copy of the current Cylinder primitive as 

439 a JSON-serializable dict that matches the schema 

440 in `trimesh/resources/schema/cylinder.schema.json` 

441 

442 Returns 

443 ---------- 

444 as_dict : dict 

445 Serializable data for this primitive. 

446 """ 

447 return { 

448 "kind": "cylinder", 

449 "transform": self.primitive.transform.tolist(), 

450 "radius": float(self.primitive.radius), 

451 "height": float(self.primitive.height), 

452 } 

453 

454 def buffer(self, distance): 

455 """ 

456 Return a cylinder primitive which covers the source 

457 cylinder by distance: radius is inflated by distance 

458 height by twice the distance. 

459 

460 Parameters 

461 ------------ 

462 distance : float 

463 Distance to inflate cylinder radius and height 

464 

465 Returns 

466 ------------- 

467 buffered : Cylinder 

468 Cylinder primitive inflated by distance 

469 """ 

470 distance = float(distance) 

471 buffered = Cylinder( 

472 height=self.primitive.height + distance * 2, 

473 radius=self.primitive.radius + distance, 

474 transform=self.primitive.transform.copy(), 

475 ) 

476 return buffered 

477 

478 def _create_mesh(self): 

479 log.debug("creating mesh for Cylinder primitive") 

480 mesh = creation.cylinder( 

481 radius=self.primitive.radius, 

482 height=self.primitive.height, 

483 sections=self.primitive.sections, 

484 transform=self.primitive.transform, 

485 ) 

486 

487 self._cache["vertices"] = mesh.vertices 

488 self._cache["faces"] = mesh.faces 

489 self._cache["face_normals"] = mesh.face_normals 

490 

491 

492class Capsule(Primitive): 

493 def __init__( 

494 self, radius=1.0, height=10.0, transform=None, sections=32, mutable=True 

495 ): 

496 """ 

497 Create a Capsule Primitive, a subclass of Trimesh. 

498 

499 Parameters 

500 ---------- 

501 radius : float 

502 Radius of cylinder 

503 height : float 

504 Height of cylinder 

505 transform : (4, 4) float 

506 Transformation matrix 

507 sections : int 

508 Number of facets in circle 

509 mutable : bool 

510 Are extents and transform mutable after creation. 

511 """ 

512 super().__init__() 

513 

514 defaults = {"height": 1.0, "radius": 1.0, "transform": np.eye(4), "sections": 32} 

515 self.primitive = PrimitiveAttributes( 

516 self, 

517 defaults=defaults, 

518 kwargs={ 

519 "height": height, 

520 "radius": radius, 

521 "transform": transform, 

522 "sections": sections, 

523 }, 

524 mutable=mutable, 

525 ) 

526 

527 @property 

528 def transform(self): 

529 return self.primitive.transform 

530 

531 @cache_decorator 

532 def volume(self) -> float: 

533 """ 

534 The analytic volume of the capsule primitive. 

535 

536 Returns 

537 --------- 

538 volume : float 

539 Volume of the capsule 

540 """ 

541 radius, height = self.primitive.radius, self.primitive.height 

542 return (np.pi * radius**2) * ((4.0 / 3.0) * radius + height) 

543 

544 @cache_decorator 

545 def area(self) -> float: 

546 """ 

547 The analytic area of the capsule primitive. 

548 

549 Returns 

550 --------- 

551 area : float 

552 Area of the capsule 

553 """ 

554 radius, height = self.primitive.radius, self.primitive.height 

555 return (2 * np.pi * radius * height) + (4 * np.pi * radius**2) 

556 

557 def to_dict(self): 

558 """ 

559 Get a copy of the current Capsule primitive as 

560 a JSON-serializable dict that matches the schema 

561 in `trimesh/resources/schema/capsule.schema.json` 

562 

563 Returns 

564 ---------- 

565 as_dict : dict 

566 Serializable data for this primitive. 

567 """ 

568 return { 

569 "kind": "capsule", 

570 "transform": self.primitive.transform.tolist(), 

571 "height": float(self.primitive.height), 

572 "radius": float(self.primitive.radius), 

573 } 

574 

575 @cache_decorator 

576 def direction(self): 

577 """ 

578 The direction of the capsule's axis. 

579 

580 Returns 

581 -------- 

582 axis : (3,) float 

583 Vector along the cylinder axis 

584 """ 

585 axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3] 

586 return axis 

587 

588 def _create_mesh(self): 

589 log.debug("creating mesh for `Capsule` primitive") 

590 

591 mesh = creation.capsule( 

592 radius=self.primitive.radius, 

593 height=self.primitive.height, 

594 count=[self.primitive.sections, self.primitive.sections * 2], 

595 transform=self.primitive.transform, 

596 ) 

597 

598 self._cache["vertices"] = mesh.vertices 

599 self._cache["faces"] = mesh.faces 

600 self._cache["face_normals"] = mesh.face_normals 

601 

602 

603class Sphere(Primitive): 

604 def __init__( 

605 self, 

606 radius: Number = 1.0, 

607 center: ArrayLike | None = None, 

608 transform: ArrayLike | None = None, 

609 subdivisions: Integer = 3, 

610 mutable: bool = True, 

611 ): 

612 """ 

613 Create a Sphere Primitive, a subclass of Trimesh. 

614 

615 Parameters 

616 ---------- 

617 radius 

618 Radius of sphere 

619 center : None or (3,) float 

620 Center of sphere. 

621 transform : None or (4, 4) float 

622 Full homogeneous transform. Pass `center` OR `transform. 

623 subdivisions 

624 Number of subdivisions for icosphere. 

625 mutable 

626 Are extents and transform mutable after creation. 

627 """ 

628 

629 super().__init__() 

630 

631 constructor = {"radius": float(radius), "subdivisions": int(subdivisions)} 

632 # center is a helper method for "transform" 

633 # since a sphere is rotationally symmetric 

634 if center is not None: 

635 if transform is not None: 

636 raise ValueError("only one of `center` and `transform` may be passed!") 

637 translate = np.eye(4) 

638 translate[:3, 3] = center 

639 constructor["transform"] = translate 

640 elif transform is not None: 

641 constructor["transform"] = transform 

642 

643 # create the attributes object 

644 self.primitive = PrimitiveAttributes( 

645 self, 

646 defaults={"radius": 1.0, "transform": np.eye(4), "subdivisions": 3}, 

647 kwargs=constructor, 

648 mutable=mutable, 

649 ) 

650 

651 @property 

652 def center(self): 

653 return self.primitive.center 

654 

655 @center.setter 

656 def center(self, value): 

657 self.primitive.center = value 

658 

659 def to_dict(self): 

660 """ 

661 Get a copy of the current Sphere primitive as 

662 a JSON-serializable dict that matches the schema 

663 in `trimesh/resources/schema/sphere.schema.json` 

664 

665 Returns 

666 ---------- 

667 as_dict : dict 

668 Serializable data for this primitive. 

669 """ 

670 return { 

671 "kind": "sphere", 

672 "transform": self.primitive.transform.tolist(), 

673 "radius": float(self.primitive.radius), 

674 } 

675 

676 @property 

677 def bounds(self): 

678 # no docstring so will inherit Trimesh docstring 

679 # return exact bounds from primitive center and radius (rather than faces) 

680 # self.extents will also use this information 

681 bounds = np.array( 

682 [ 

683 self.primitive.center - self.primitive.radius, 

684 self.primitive.center + self.primitive.radius, 

685 ] 

686 ) 

687 return bounds 

688 

689 @property 

690 def bounding_box_oriented(self): 

691 # for a sphere the oriented bounding box is the same as the axis aligned 

692 # bounding box, and a sphere is the absolute slowest case for the OBB calculation 

693 # as it is a convex surface with a ton of face normals that all need to 

694 # be checked 

695 return self.bounding_box 

696 

697 @cache_decorator 

698 def area(self): 

699 """ 

700 Surface area of the current sphere primitive. 

701 

702 Returns 

703 -------- 

704 area: float, surface area of the sphere Primitive 

705 """ 

706 

707 area = 4.0 * np.pi * (self.primitive.radius**2) 

708 return area 

709 

710 @cache_decorator 

711 def volume(self): 

712 """ 

713 Volume of the current sphere primitive. 

714 

715 Returns 

716 -------- 

717 volume: float, volume of the sphere Primitive 

718 """ 

719 

720 volume = (4.0 * np.pi * (self.primitive.radius**3)) / 3.0 

721 return volume 

722 

723 @cache_decorator 

724 def moment_inertia(self): 

725 """ 

726 The analytic inertia tensor of the sphere primitive. 

727 

728 Returns 

729 ---------- 

730 tensor: (3, 3) float 

731 3D inertia tensor. 

732 """ 

733 return inertia.sphere_inertia(mass=self.volume, radius=self.primitive.radius) 

734 

735 def _create_mesh(self): 

736 log.debug("creating mesh for Sphere primitive") 

737 unit = creation.icosphere( 

738 subdivisions=self.primitive.subdivisions, radius=self.primitive.radius 

739 ) 

740 

741 # apply the center offset here 

742 self._cache["vertices"] = unit.vertices + self.primitive.center 

743 self._cache["faces"] = unit.faces 

744 self._cache["face_normals"] = unit.face_normals 

745 

746 

747class Box(Primitive): 

748 def __init__(self, extents=None, transform=None, bounds=None, mutable=True): 

749 """ 

750 Create a Box Primitive as a subclass of Trimesh 

751 

752 Parameters 

753 ---------- 

754 extents : ndarray (3,) float or None 

755 Length of each side of the 3D box. 

756 transform : ndarray (4, 4) float or None 

757 Homogeneous transformation matrix for box center. 

758 bounds : ndarray (2, 3) float or None 

759 Axis aligned bounding box, if passed extents and 

760 transform will be derived from this. 

761 mutable : bool 

762 Are extents and transform mutable after creation. 

763 """ 

764 super().__init__() 

765 defaults = {"transform": np.eye(4), "extents": np.ones(3)} 

766 

767 if bounds is not None: 

768 # validate the multiple forms of input available here 

769 if extents is not None or transform is not None: 

770 raise ValueError( 

771 "if `bounds` is passed `extents` and `transform` must not be!" 

772 ) 

773 bounds = np.array(bounds, dtype=np.float64) 

774 if bounds.shape != (2, 3): 

775 raise ValueError("`bounds` must be (2, 3) float") 

776 # create extents from AABB 

777 extents = np.ptp(bounds, axis=0) 

778 # translate to the center of the box 

779 # use the min corner (not `bounds[0]`) so the result is 

780 # independent of the order the two corners are passed in 

781 transform = np.eye(4) 

782 transform[:3, 3] = np.min(bounds, axis=0) + extents / 2.0 

783 

784 self.primitive = PrimitiveAttributes( 

785 self, 

786 defaults=defaults, 

787 kwargs={"extents": extents, "transform": transform}, 

788 mutable=mutable, 

789 ) 

790 

791 def to_dict(self): 

792 """ 

793 Get a copy of the current Box primitive as 

794 a JSON-serializable dict that matches the schema 

795 in `trimesh/resources/schema/box.schema.json` 

796 

797 Returns 

798 ---------- 

799 as_dict : dict 

800 Serializable data for this primitive. 

801 """ 

802 return { 

803 "kind": "box", 

804 "transform": self.primitive.transform.tolist(), 

805 "extents": self.primitive.extents.tolist(), 

806 } 

807 

808 @property 

809 def transform(self): 

810 return self.primitive.transform 

811 

812 def sample_volume(self, count, seed: Seed = None): 

813 """ 

814 Return random samples from inside the volume of the box. 

815 

816 Parameters 

817 ------------- 

818 count : int 

819 Number of samples to return 

820 seed : None or int 

821 Seed for deterministic results, otherwise OS entropy. 

822 

823 Returns 

824 ---------- 

825 samples : (count, 3) float 

826 Points inside the volume 

827 """ 

828 samples = sample.volume_rectangular( 

829 extents=self.primitive.extents, 

830 count=count, 

831 transform=self.primitive.transform, 

832 seed=seed, 

833 ) 

834 return samples 

835 

836 def sample_grid(self, count=None, step=None): 

837 """ 

838 Return a 3D grid which is contained by the box. 

839 Samples are either 'step' distance apart, or there are 

840 'count' samples per box side. 

841 

842 Parameters 

843 ----------- 

844 count : int or (3,) int 

845 If specified samples are spaced with np.linspace 

846 step : float or (3,) float 

847 If specified samples are spaced with np.arange 

848 

849 Returns 

850 ----------- 

851 grid : (n, 3) float 

852 Points inside the box 

853 """ 

854 

855 if count is not None and step is not None: 

856 raise ValueError("only step OR count can be specified!") 

857 

858 # create pre- transform bounds from extents 

859 bounds = np.array([-self.primitive.extents, self.primitive.extents]) * 0.5 

860 

861 if step is not None: 

862 grid = util.grid_arange(bounds, step=step) 

863 elif count is not None: 

864 grid = util.grid_linspace(bounds, count=count) 

865 else: 

866 raise ValueError("either count or step must be specified!") 

867 

868 transformed = tf.transform_points(grid, matrix=self.primitive.transform) 

869 return transformed 

870 

871 @property 

872 def is_oriented(self): 

873 """ 

874 Returns whether or not the current box is rotated at all. 

875 """ 

876 if util.is_shape(self.primitive.transform, (4, 4)): 

877 return not np.allclose(self.primitive.transform[0:3, 0:3], np.eye(3)) 

878 else: 

879 return False 

880 

881 @cache_decorator 

882 def volume(self): 

883 """ 

884 Volume of the box Primitive. 

885 

886 Returns 

887 -------- 

888 volume : float 

889 Volume of box. 

890 """ 

891 volume = float(np.prod(self.primitive.extents)) 

892 return volume 

893 

894 def _create_mesh(self): 

895 log.debug("creating mesh for Box primitive") 

896 box = creation.box( 

897 extents=self.primitive.extents, transform=self.primitive.transform 

898 ) 

899 

900 self._cache.cache.update(box._cache.cache) 

901 self._cache["vertices"] = box.vertices 

902 self._cache["faces"] = box.faces 

903 self._cache["face_normals"] = box.face_normals 

904 

905 def as_outline(self): 

906 """ 

907 Return a Path3D containing the outline of the box. 

908 

909 Returns 

910 ----------- 

911 outline : trimesh.path.Path3D 

912 Outline of box primitive 

913 """ 

914 # do the import in function to keep soft dependency 

915 from .path.creation import box_outline 

916 

917 # return outline with same size as primitive 

918 return box_outline( 

919 extents=self.primitive.extents, transform=self.primitive.transform 

920 ) 

921 

922 

923class Extrusion(Primitive): 

924 def __init__( 

925 self, 

926 polygon=None, 

927 transform: ArrayLike | None = None, 

928 height: Number = 1.0, 

929 mutable: bool = True, 

930 mid_plane: bool = False, 

931 ): 

932 """ 

933 Create an Extrusion primitive, which 

934 is a subclass of Trimesh. 

935 

936 Parameters 

937 ---------- 

938 polygon : shapely.geometry.Polygon 

939 Polygon to extrude 

940 transform : (4, 4) float 

941 Transform to apply after extrusion 

942 height : float 

943 Height to extrude polygon by 

944 mutable : bool 

945 Are extents and transform mutable after creation. 

946 """ 

947 # do the import here, fail early if Shapely isn't installed 

948 from shapely.geometry import Point 

949 

950 # run the Trimesh init 

951 super().__init__() 

952 # set default values 

953 defaults = { 

954 "polygon": Point([0, 0]).buffer(1.0), 

955 "transform": np.eye(4), 

956 "height": 1.0, 

957 "mid_plane": False, 

958 } 

959 

960 self.primitive = PrimitiveAttributes( 

961 self, 

962 defaults=defaults, 

963 kwargs={ 

964 "transform": transform, 

965 "polygon": polygon, 

966 "height": height, 

967 "mid_plane": mid_plane, 

968 }, 

969 mutable=mutable, 

970 ) 

971 

972 @cache_decorator 

973 def area(self): 

974 """ 

975 The surface area of the primitive extrusion. 

976 

977 Calculated from polygon and height to avoid mesh creation. 

978 

979 Returns 

980 ---------- 

981 area: float 

982 Surface area of 3D extrusion 

983 """ 

984 # area of the sides of the extrusion 

985 area = abs(self.primitive.height * self.primitive.polygon.length) 

986 # area of the two caps of the extrusion 

987 area += self.primitive.polygon.area * 2 

988 return area 

989 

990 @cache_decorator 

991 def volume(self): 

992 """ 

993 The volume of the Extrusion primitive. 

994 Calculated from polygon and height to avoid mesh creation. 

995 

996 Returns 

997 ---------- 

998 volume : float 

999 Volume of 3D extrusion 

1000 """ 

1001 # height may be negative 

1002 volume = abs(self.primitive.polygon.area * self.primitive.height) 

1003 return volume 

1004 

1005 @cache_decorator 

1006 def direction(self): 

1007 """ 

1008 Based on the extrudes transform what is the 

1009 vector along which the polygon will be extruded. 

1010 

1011 Returns 

1012 --------- 

1013 direction : (3,) float 

1014 Unit direction vector 

1015 """ 

1016 # only consider rotation and signed height 

1017 direction = np.dot( 

1018 self.primitive.transform[:3, :3], [0.0, 0.0, np.sign(self.primitive.height)] 

1019 ) 

1020 return direction 

1021 

1022 @property 

1023 def origin(self): 

1024 """ 

1025 Based on the extrude transform what is the 

1026 origin of the plane it is extruded from. 

1027 

1028 Returns 

1029 ----------- 

1030 origin : (3,) float 

1031 Origin of extrusion plane 

1032 """ 

1033 return self.primitive.transform[:3, 3] 

1034 

1035 @property 

1036 def transform(self): 

1037 return self.primitive.transform 

1038 

1039 @cache_decorator 

1040 def bounding_box_oriented(self): 

1041 # no docstring for inheritance 

1042 # calculate OBB using 2D polygon and known axis 

1043 from . import bounds 

1044 

1045 # find the 2D bounding box using the polygon 

1046 to_origin, box = bounds.oriented_bounds_2D(self.primitive.polygon.exterior.coords) 

1047 # 3D extents 

1048 extents = np.append(box, abs(self.primitive.height)) 

1049 # calculate to_3D transform from 2D obb 

1050 rotation_Z = np.linalg.inv(tf.planar_matrix_to_3D(to_origin)) 

1051 rotation_Z[2, 3] = self.primitive.height / 2.0 

1052 # combine the 2D OBB transformation with the 2D projection transform 

1053 to_3D = np.dot(self.primitive.transform, rotation_Z) 

1054 return Box(transform=to_3D, extents=extents, mutable=False) 

1055 

1056 def slide(self, distance): 

1057 """ 

1058 Alter the transform of the current extrusion to slide it 

1059 along its extrude_direction vector 

1060 

1061 Parameters 

1062 ----------- 

1063 distance : float 

1064 Distance along self.extrude_direction to move 

1065 """ 

1066 distance = float(distance) 

1067 translation = np.eye(4) 

1068 translation[2, 3] = distance 

1069 new_transform = np.dot(self.primitive.transform.copy(), translation.copy()) 

1070 self.primitive.transform = new_transform 

1071 

1072 def buffer(self, distance, distance_height=None, **kwargs): 

1073 """ 

1074 Return a new Extrusion object which is expanded in profile 

1075 and in height by a specified distance. 

1076 

1077 Parameters 

1078 -------------- 

1079 distance : float 

1080 Distance to buffer polygon 

1081 distance_height : float 

1082 Distance to buffer above and below extrusion 

1083 kwargs : dict 

1084 Passed to Extrusion constructor 

1085 

1086 Returns 

1087 ---------- 

1088 buffered : primitives.Extrusion 

1089 Extrusion object with new values 

1090 """ 

1091 distance = float(distance) 

1092 # if not specified use same distance for everything 

1093 if distance_height is None: 

1094 distance_height = distance 

1095 

1096 # start with current height 

1097 height = self.primitive.height 

1098 # if current height is negative offset by negative amount 

1099 height += np.sign(height) * 2.0 * distance_height 

1100 

1101 # create a new extrusion with a buffered polygon 

1102 # use type(self) vs Extrusion to handle subclasses 

1103 buffered = type(self)( 

1104 transform=self.primitive.transform.copy(), 

1105 polygon=self.primitive.polygon.buffer(distance), 

1106 height=height, 

1107 **kwargs, 

1108 ) 

1109 

1110 # slide the stock along the axis 

1111 buffered.slide(-np.sign(height) * distance_height) 

1112 

1113 return buffered 

1114 

1115 def to_dict(self): 

1116 """ 

1117 Get a copy of the current Extrusion primitive as 

1118 a JSON-serializable dict that matches the schema 

1119 in `trimesh/resources/schema/extrusion.schema.json` 

1120 

1121 Returns 

1122 ---------- 

1123 as_dict : dict 

1124 Serializable data for this primitive. 

1125 """ 

1126 return { 

1127 "kind": "extrusion", 

1128 "polygon": self.primitive.polygon.wkt, 

1129 "transform": self.primitive.transform.tolist(), 

1130 "height": float(self.primitive.height), 

1131 } 

1132 

1133 def _create_mesh(self): 

1134 log.debug("creating mesh for Extrusion primitive") 

1135 # extrude the polygon along Z 

1136 mesh = creation.extrude_polygon( 

1137 polygon=self.primitive.polygon, 

1138 height=self.primitive.height, 

1139 transform=self.primitive.transform, 

1140 mid_plane=self.primitive.mid_plane, 

1141 ) 

1142 

1143 # check volume here in unit tests 

1144 if tol.strict and mesh.volume < 0.0: 

1145 raise ValueError("matrix inverted mesh!") 

1146 

1147 # cache mesh geometry in the primitive 

1148 self._cache["vertices"] = mesh.vertices 

1149 self._cache["faces"] = mesh.faces