Coverage for trimesh/primitives.py: 94%

369 statements  

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

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, height=self.primitive.height 

593 ) 

594 mesh.apply_transform(self.primitive.transform) 

595 

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

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

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

599 

600 

601class Sphere(Primitive): 

602 def __init__( 

603 self, 

604 radius: Number = 1.0, 

605 center: ArrayLike | None = None, 

606 transform: ArrayLike | None = None, 

607 subdivisions: Integer = 3, 

608 mutable: bool = True, 

609 ): 

610 """ 

611 Create a Sphere Primitive, a subclass of Trimesh. 

612 

613 Parameters 

614 ---------- 

615 radius 

616 Radius of sphere 

617 center : None or (3,) float 

618 Center of sphere. 

619 transform : None or (4, 4) float 

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

621 subdivisions 

622 Number of subdivisions for icosphere. 

623 mutable 

624 Are extents and transform mutable after creation. 

625 """ 

626 

627 super().__init__() 

628 

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

630 # center is a helper method for "transform" 

631 # since a sphere is rotationally symmetric 

632 if center is not None: 

633 if transform is not None: 

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

635 translate = np.eye(4) 

636 translate[:3, 3] = center 

637 constructor["transform"] = translate 

638 elif transform is not None: 

639 constructor["transform"] = transform 

640 

641 # create the attributes object 

642 self.primitive = PrimitiveAttributes( 

643 self, 

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

645 kwargs=constructor, 

646 mutable=mutable, 

647 ) 

648 

649 @property 

650 def center(self): 

651 return self.primitive.center 

652 

653 @center.setter 

654 def center(self, value): 

655 self.primitive.center = value 

656 

657 def to_dict(self): 

658 """ 

659 Get a copy of the current Sphere primitive as 

660 a JSON-serializable dict that matches the schema 

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

662 

663 Returns 

664 ---------- 

665 as_dict : dict 

666 Serializable data for this primitive. 

667 """ 

668 return { 

669 "kind": "sphere", 

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

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

672 } 

673 

674 @property 

675 def bounds(self): 

676 # no docstring so will inherit Trimesh docstring 

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

678 # self.extents will also use this information 

679 bounds = np.array( 

680 [ 

681 self.primitive.center - self.primitive.radius, 

682 self.primitive.center + self.primitive.radius, 

683 ] 

684 ) 

685 return bounds 

686 

687 @property 

688 def bounding_box_oriented(self): 

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

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

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

692 # be checked 

693 return self.bounding_box 

694 

695 @cache_decorator 

696 def area(self): 

697 """ 

698 Surface area of the current sphere primitive. 

699 

700 Returns 

701 -------- 

702 area: float, surface area of the sphere Primitive 

703 """ 

704 

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

706 return area 

707 

708 @cache_decorator 

709 def volume(self): 

710 """ 

711 Volume of the current sphere primitive. 

712 

713 Returns 

714 -------- 

715 volume: float, volume of the sphere Primitive 

716 """ 

717 

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

719 return volume 

720 

721 @cache_decorator 

722 def moment_inertia(self): 

723 """ 

724 The analytic inertia tensor of the sphere primitive. 

725 

726 Returns 

727 ---------- 

728 tensor: (3, 3) float 

729 3D inertia tensor. 

730 """ 

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

732 

733 def _create_mesh(self): 

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

735 unit = creation.icosphere( 

736 subdivisions=self.primitive.subdivisions, radius=self.primitive.radius 

737 ) 

738 

739 # apply the center offset here 

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

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

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

743 

744 

745class Box(Primitive): 

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

747 """ 

748 Create a Box Primitive as a subclass of Trimesh 

749 

750 Parameters 

751 ---------- 

752 extents : ndarray (3,) float or None 

753 Length of each side of the 3D box. 

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

755 Homogeneous transformation matrix for box center. 

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

757 Axis aligned bounding box, if passed extents and 

758 transform will be derived from this. 

759 mutable : bool 

760 Are extents and transform mutable after creation. 

761 """ 

762 super().__init__() 

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

764 

765 if bounds is not None: 

766 # validate the multiple forms of input available here 

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

768 raise ValueError( 

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

770 ) 

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

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

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

774 # create extents from AABB 

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

776 # translate to the center of the box 

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

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

779 transform = np.eye(4) 

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

781 

782 self.primitive = PrimitiveAttributes( 

783 self, 

784 defaults=defaults, 

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

786 mutable=mutable, 

787 ) 

788 

789 def to_dict(self): 

790 """ 

791 Get a copy of the current Box primitive as 

792 a JSON-serializable dict that matches the schema 

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

794 

795 Returns 

796 ---------- 

797 as_dict : dict 

798 Serializable data for this primitive. 

799 """ 

800 return { 

801 "kind": "box", 

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

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

804 } 

805 

806 @property 

807 def transform(self): 

808 return self.primitive.transform 

809 

810 def sample_volume(self, count): 

811 """ 

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

813 

814 Parameters 

815 ------------- 

816 count : int 

817 Number of samples to return 

818 

819 Returns 

820 ---------- 

821 samples : (count, 3) float 

822 Points inside the volume 

823 """ 

824 samples = sample.volume_rectangular( 

825 extents=self.primitive.extents, 

826 count=count, 

827 transform=self.primitive.transform, 

828 ) 

829 return samples 

830 

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

832 """ 

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

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

835 'count' samples per box side. 

836 

837 Parameters 

838 ----------- 

839 count : int or (3,) int 

840 If specified samples are spaced with np.linspace 

841 step : float or (3,) float 

842 If specified samples are spaced with np.arange 

843 

844 Returns 

845 ----------- 

846 grid : (n, 3) float 

847 Points inside the box 

848 """ 

849 

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

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

852 

853 # create pre- transform bounds from extents 

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

855 

856 if step is not None: 

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

858 elif count is not None: 

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

860 else: 

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

862 

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

864 return transformed 

865 

866 @property 

867 def is_oriented(self): 

868 """ 

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

870 """ 

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

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

873 else: 

874 return False 

875 

876 @cache_decorator 

877 def volume(self): 

878 """ 

879 Volume of the box Primitive. 

880 

881 Returns 

882 -------- 

883 volume : float 

884 Volume of box. 

885 """ 

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

887 return volume 

888 

889 def _create_mesh(self): 

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

891 box = creation.box( 

892 extents=self.primitive.extents, transform=self.primitive.transform 

893 ) 

894 

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

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

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

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

899 

900 def as_outline(self): 

901 """ 

902 Return a Path3D containing the outline of the box. 

903 

904 Returns 

905 ----------- 

906 outline : trimesh.path.Path3D 

907 Outline of box primitive 

908 """ 

909 # do the import in function to keep soft dependency 

910 from .path.creation import box_outline 

911 

912 # return outline with same size as primitive 

913 return box_outline( 

914 extents=self.primitive.extents, transform=self.primitive.transform 

915 ) 

916 

917 

918class Extrusion(Primitive): 

919 def __init__( 

920 self, 

921 polygon=None, 

922 transform: ArrayLike | None = None, 

923 height: Number = 1.0, 

924 mutable: bool = True, 

925 mid_plane: bool = False, 

926 ): 

927 """ 

928 Create an Extrusion primitive, which 

929 is a subclass of Trimesh. 

930 

931 Parameters 

932 ---------- 

933 polygon : shapely.geometry.Polygon 

934 Polygon to extrude 

935 transform : (4, 4) float 

936 Transform to apply after extrusion 

937 height : float 

938 Height to extrude polygon by 

939 mutable : bool 

940 Are extents and transform mutable after creation. 

941 """ 

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

943 from shapely.geometry import Point 

944 

945 # run the Trimesh init 

946 super().__init__() 

947 # set default values 

948 defaults = { 

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

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

951 "height": 1.0, 

952 "mid_plane": False, 

953 } 

954 

955 self.primitive = PrimitiveAttributes( 

956 self, 

957 defaults=defaults, 

958 kwargs={ 

959 "transform": transform, 

960 "polygon": polygon, 

961 "height": height, 

962 "mid_plane": mid_plane, 

963 }, 

964 mutable=mutable, 

965 ) 

966 

967 @cache_decorator 

968 def area(self): 

969 """ 

970 The surface area of the primitive extrusion. 

971 

972 Calculated from polygon and height to avoid mesh creation. 

973 

974 Returns 

975 ---------- 

976 area: float 

977 Surface area of 3D extrusion 

978 """ 

979 # area of the sides of the extrusion 

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

981 # area of the two caps of the extrusion 

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

983 return area 

984 

985 @cache_decorator 

986 def volume(self): 

987 """ 

988 The volume of the Extrusion primitive. 

989 Calculated from polygon and height to avoid mesh creation. 

990 

991 Returns 

992 ---------- 

993 volume : float 

994 Volume of 3D extrusion 

995 """ 

996 # height may be negative 

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

998 return volume 

999 

1000 @cache_decorator 

1001 def direction(self): 

1002 """ 

1003 Based on the extrudes transform what is the 

1004 vector along which the polygon will be extruded. 

1005 

1006 Returns 

1007 --------- 

1008 direction : (3,) float 

1009 Unit direction vector 

1010 """ 

1011 # only consider rotation and signed height 

1012 direction = np.dot( 

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

1014 ) 

1015 return direction 

1016 

1017 @property 

1018 def origin(self): 

1019 """ 

1020 Based on the extrude transform what is the 

1021 origin of the plane it is extruded from. 

1022 

1023 Returns 

1024 ----------- 

1025 origin : (3,) float 

1026 Origin of extrusion plane 

1027 """ 

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

1029 

1030 @property 

1031 def transform(self): 

1032 return self.primitive.transform 

1033 

1034 @cache_decorator 

1035 def bounding_box_oriented(self): 

1036 # no docstring for inheritance 

1037 # calculate OBB using 2D polygon and known axis 

1038 from . import bounds 

1039 

1040 # find the 2D bounding box using the polygon 

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

1042 # 3D extents 

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

1044 # calculate to_3D transform from 2D obb 

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

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

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

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

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

1050 

1051 def slide(self, distance): 

1052 """ 

1053 Alter the transform of the current extrusion to slide it 

1054 along its extrude_direction vector 

1055 

1056 Parameters 

1057 ----------- 

1058 distance : float 

1059 Distance along self.extrude_direction to move 

1060 """ 

1061 distance = float(distance) 

1062 translation = np.eye(4) 

1063 translation[2, 3] = distance 

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

1065 self.primitive.transform = new_transform 

1066 

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

1068 """ 

1069 Return a new Extrusion object which is expanded in profile 

1070 and in height by a specified distance. 

1071 

1072 Parameters 

1073 -------------- 

1074 distance : float 

1075 Distance to buffer polygon 

1076 distance_height : float 

1077 Distance to buffer above and below extrusion 

1078 kwargs : dict 

1079 Passed to Extrusion constructor 

1080 

1081 Returns 

1082 ---------- 

1083 buffered : primitives.Extrusion 

1084 Extrusion object with new values 

1085 """ 

1086 distance = float(distance) 

1087 # if not specified use same distance for everything 

1088 if distance_height is None: 

1089 distance_height = distance 

1090 

1091 # start with current height 

1092 height = self.primitive.height 

1093 # if current height is negative offset by negative amount 

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

1095 

1096 # create a new extrusion with a buffered polygon 

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

1098 buffered = type(self)( 

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

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

1101 height=height, 

1102 **kwargs, 

1103 ) 

1104 

1105 # slide the stock along the axis 

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

1107 

1108 return buffered 

1109 

1110 def to_dict(self): 

1111 """ 

1112 Get a copy of the current Extrusion primitive as 

1113 a JSON-serializable dict that matches the schema 

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

1115 

1116 Returns 

1117 ---------- 

1118 as_dict : dict 

1119 Serializable data for this primitive. 

1120 """ 

1121 return { 

1122 "kind": "extrusion", 

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

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

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

1126 } 

1127 

1128 def _create_mesh(self): 

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

1130 # extrude the polygon along Z 

1131 mesh = creation.extrude_polygon( 

1132 polygon=self.primitive.polygon, 

1133 height=self.primitive.height, 

1134 transform=self.primitive.transform, 

1135 mid_plane=self.primitive.mid_plane, 

1136 ) 

1137 

1138 # check volume here in unit tests 

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

1140 raise ValueError("matrix inverted mesh!") 

1141 

1142 # cache mesh geometry in the primitive 

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

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