Coverage for trimesh/path/arc.py: 91%

89 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-02 20:54 +0000

1from dataclasses import dataclass 

2 

3import numpy as np 

4 

5from .. import util 

6from ..constants import res_path as res 

7from ..constants import tol_path as tol 

8from ..typed import ArrayLike, NDArray, Number, float64 

9 

10# floating point zero 

11_TOL_ZERO = 1e-12 

12 

13 

14@dataclass 

15class ArcInfo: 

16 # What is the radius of the circular arc? 

17 radius: float 

18 

19 # what is the center of the circular arc 

20 # it is either 2D or 3D depending on input. 

21 center: NDArray[float64] 

22 

23 # what is the 3D normal vector of the plane the arc lies on 

24 normal: NDArray[float64] | None = None 

25 

26 # what is the starting and ending angle of the arc. 

27 angles: NDArray[float64] | None = None 

28 

29 # what is the angular span of this circular arc. 

30 span: Number | None = None 

31 

32 def __getitem__(self, item): 

33 # add for backwards compatibility 

34 return getattr(self, item) 

35 

36 

37def arc_center( 

38 points: ArrayLike, return_normal: bool = True, return_angle: bool = True 

39) -> ArcInfo: 

40 """ 

41 Given three points on a 2D or 3D arc find the center, 

42 radius, normal, and angular span. 

43 

44 Parameters 

45 --------- 

46 points : (3, dimension) float 

47 Points in space, where dimension is either 2 or 3 

48 return_normal : bool 

49 If True calculate the 3D normal unit vector 

50 return_angle : bool 

51 If True calculate the start and stop angle and span 

52 

53 Returns 

54 --------- 

55 info 

56 Arc center, radius, and other information. 

57 """ 

58 points = np.asanyarray(points, dtype=np.float64) 

59 

60 # get the non-unit vectors of the three points 

61 vectors = points[[2, 0, 1]] - points[[1, 2, 0]] 

62 # we need both the squared row sum and the non-squared 

63 abc2 = np.dot(vectors**2, [1] * points.shape[1]) 

64 # same as np.linalg.norm(vectors, axis=1) 

65 abc = np.sqrt(abc2) 

66 

67 # perform radius calculation scaled to shortest edge 

68 # to avoid precision issues with small or large arcs 

69 scale = abc.min() 

70 # get the edge lengths scaled to the smallest 

71 edges = abc / scale 

72 # half the total length of the edges 

73 half = edges.sum() / 2.0 

74 # check the denominator for the radius calculation 

75 denom = half * np.prod(half - edges) 

76 if denom < tol.merge: 

77 raise ValueError("arc is colinear!") 

78 # find the radius and scale back after the operation 

79 radius = scale * ((np.prod(edges) / 4.0) / np.sqrt(denom)) 

80 

81 # use a barycentric approach to get the center 

82 ba2 = (abc2[[1, 2, 0, 0, 2, 1, 0, 1, 2]] * [1, 1, -1, 1, 1, -1, 1, 1, -1]).reshape( 

83 (3, 3) 

84 ).sum(axis=1) * abc2 

85 center = points.T.dot(ba2) / ba2.sum() 

86 

87 if tol.strict: 

88 # all points should be at the calculated radius from center 

89 assert util.allclose(np.linalg.norm(points - center, axis=1), radius) 

90 

91 # start with initial results 

92 result = {"center": center, "radius": radius} 

93 if return_normal: 

94 if points.shape == (3, 2): 

95 # for 2D arcs still use the cross product so that 

96 # the sign of the normal vector is consistent 

97 result["normal"] = util.unitize( 

98 np.cross(np.append(-vectors[1], 0), np.append(vectors[2], 0)) 

99 ) 

100 else: 

101 # otherwise just take the cross product 

102 result["normal"] = util.unitize(np.cross(-vectors[1], vectors[2])) 

103 

104 if return_angle: 

105 # vectors from points on arc to center point 

106 vector = util.unitize(points - center) 

107 edge_direction = np.diff(points, axis=0) 

108 # find the angle between the first and last vector 

109 dot = np.dot(*vector[[0, 2]]) 

110 if dot < (_TOL_ZERO - 1): 

111 angle = np.pi 

112 elif dot > 1 - _TOL_ZERO: 

113 angle = 0.0 

114 else: 

115 angle = np.arccos(dot) 

116 # if the angle is nonzero and vectors are opposite direction 

117 # it means we have a long arc rather than the short path 

118 if abs(angle) > _TOL_ZERO and np.dot(*edge_direction) < 0.0: 

119 angle = (np.pi * 2) - angle 

120 # convoluted angle logic 

121 angles = np.arctan2(*vector[:, :2].T[::-1]) + np.pi * 2 

122 angles_sorted = np.sort(angles[[0, 2]]) 

123 reverse = angles_sorted[0] < angles[1] < angles_sorted[1] 

124 angles_sorted = angles_sorted[:: (1 - int(not reverse) * 2)] 

125 result["angles"] = angles_sorted 

126 result["span"] = angle 

127 

128 return ArcInfo(**result) 

129 

130 

131def discretize_arc(points, close=False, scale=1.0): 

132 """ 

133 Returns a version of a three point arc consisting of 

134 line segments. 

135 

136 Parameters 

137 --------- 

138 points : (3, d) float 

139 Points on the arc where d in [2,3] 

140 close : boolean 

141 If True close the arc into a circle 

142 scale : float 

143 What is the approximate overall drawing scale 

144 Used to establish order of magnitude for precision 

145 

146 Returns 

147 --------- 

148 discrete : (m, d) float 

149 Connected points in space 

150 """ 

151 # make sure points are (n, 3) 

152 points, is_2D = util.stack_3D(points, return_2D=True) 

153 # find the center of the points 

154 try: 

155 # try to find the center from the arc points 

156 center_info = arc_center(points) 

157 except BaseException: 

158 # if we hit an exception return a very bad but 

159 # technically correct discretization of the arc 

160 if is_2D: 

161 return points[:, :2] 

162 return points 

163 

164 center, R, N, angle = ( 

165 center_info.center, 

166 center_info.radius, 

167 center_info.normal, 

168 center_info.span, 

169 ) 

170 

171 # if requested, close arc into a circle 

172 if close: 

173 angle = np.pi * 2 

174 

175 # the number of facets for the arc 

176 # which is the maximum of angle and linear criteria 

177 count = max(angle / res.seg_angle, (R * angle) / (res.seg_frac * scale)) 

178 

179 # force at LEAST 4 points for the arc 

180 # otherwise the endpoints will diverge 

181 count = int(np.ceil(np.clip(count, 4, np.inf))) 

182 

183 V1 = util.unitize(points[0] - center) 

184 V2 = util.unitize(np.cross(-N, V1)) 

185 # angle spacing 

186 t = np.linspace(0.0, angle, count).reshape((-1, 1)) 

187 

188 # apply the vector formula 

189 discrete = (np.cos(t) * V1 + np.sin(t) * V2) * R + center 

190 

191 if close: 

192 # snap closed circles to exactly float-equal 

193 discrete[-1] = discrete[0] 

194 else: 

195 # snap the discrete result to exact control points 

196 discrete[[0, -1]] = points[[0, -1]] 

197 

198 # clip to the dimension of input 

199 discrete = discrete[:, : (3 - is_2D)] 

200 

201 return discrete 

202 

203 

204def to_threepoint(center, radius, angles=None): 

205 """ 

206 For 2D arcs, given a center and radius convert them to three 

207 points on the arc. 

208 

209 Parameters 

210 ----------- 

211 center : (2,) float 

212 Center point on the plane 

213 radius : float 

214 Radius of arc 

215 angles : (2,) float 

216 Angles in radians for start and end angle 

217 if not specified, will default to (0.0, pi) 

218 

219 Returns 

220 ---------- 

221 three : (3, 2) float 

222 Arc control points 

223 """ 

224 # if no angles provided assume we want a half circle 

225 if angles is None: 

226 angles = [0.0, np.pi] 

227 # force angles to float64 

228 angles = np.asanyarray(angles, dtype=np.float64) 

229 if angles.shape != (2,): 

230 raise ValueError("angles must be (2,)!") 

231 # provide the wrap around 

232 if angles[1] < angles[0]: 

233 angles[1] += np.pi * 2 

234 

235 center = np.asanyarray(center, dtype=np.float64) 

236 if center.shape != (2,): 

237 raise ValueError("only valid on 2D arcs!") 

238 

239 # turn the angles of [start, end] 

240 # into [start, middle, end] 

241 angles = np.array([angles[0], angles.mean(), angles[1]], dtype=np.float64) 

242 # turn angles into (3, 2) points 

243 three = (np.column_stack((np.cos(angles), np.sin(angles))) * radius) + center 

244 

245 return three