yapCAD 0.3.1__py2.py3-none-any.whl → 0.5.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1012 @@
1
+ """Native boolean engine extracted from yapcad.geom3d."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from yapcad.geom import *
8
+ from yapcad.geom_util import *
9
+ from yapcad.xform import *
10
+ from yapcad.triangulator import triangulate_polygon
11
+ from yapcad.octtree import NTree
12
+
13
+
14
+ def _geom3d():
15
+ from yapcad import geom3d as _g3
16
+ return _g3
17
+
18
+ def _ensure_surface_metadata_dict(s):
19
+ if not isinstance(s, list) or len(s) < 4 or s[0] != 'surface':
20
+ raise ValueError('bad surface passed to _ensure_surface_metadata_dict')
21
+ if len(s) == 4:
22
+ s.extend([[], [], {}])
23
+ return s[6]
24
+ if len(s) == 5:
25
+ s.extend([[], {}])
26
+ return s[6]
27
+ if len(s) == 6:
28
+ s.append({})
29
+ return s[6]
30
+ metadata = s[6]
31
+ if metadata is None:
32
+ metadata = {}
33
+ elif not isinstance(metadata, dict):
34
+ metadata = {'legacy_metadata': metadata}
35
+ s[6] = metadata
36
+ return metadata
37
+
38
+
39
+ def _triangle_bbox(tri, tol):
40
+ mins = [min(pt[i] for pt in tri) - tol for i in range(3)]
41
+ maxs = [max(pt[i] for pt in tri) + tol for i in range(3)]
42
+ return [point(mins[0], mins[1], mins[2]),
43
+ point(maxs[0], maxs[1], maxs[2])]
44
+
45
+
46
+ def _triangle_plane_intersection_points(tri, plane, tol):
47
+ n, d = plane
48
+ points = []
49
+ for idx in range(3):
50
+ p0 = tri[idx]
51
+ p1 = tri[(idx + 1) % 3]
52
+ dir_vec = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]
53
+ denom = n[0] * dir_vec[0] + n[1] * dir_vec[1] + n[2] * dir_vec[2]
54
+ if abs(denom) < tol:
55
+ continue
56
+ numer = d - (n[0] * p0[0] + n[1] * p0[1] + n[2] * p0[2])
57
+ t = numer / denom
58
+ if t < -tol or t > 1.0 + tol:
59
+ continue
60
+ t_clamped = max(0.0, min(1.0, t))
61
+ pt = _lerp_point(p0, p1, t_clamped)
62
+ if abs(n[0] * pt[0] + n[1] * pt[1] + n[2] * pt[2] - d) <= tol * 10.0:
63
+ points.append(pt)
64
+ return _unique_points(points, tol) if points else []
65
+
66
+
67
+ def _candidate_planes_for_triangle(tri, target, tri_plane, tol):
68
+ box = _geom3d().solidbbox(target)
69
+ if box:
70
+ center = point((box[0][0] + box[1][0]) / 2.0,
71
+ (box[0][1] + box[1][1]) / 2.0,
72
+ (box[0][2] + box[1][2]) / 2.0)
73
+ extent = max(box[1][0] - box[0][0],
74
+ box[1][1] - box[0][1],
75
+ box[1][2] - box[0][2])
76
+ else:
77
+ center = point(0, 0, 0)
78
+ extent = 0.0
79
+
80
+ epsilon_dot = max(extent * 1e-6, 1e-6)
81
+
82
+ tri_bbox = _triangle_bbox(tri, tol * 2.0)
83
+ seen = set()
84
+ planes = []
85
+ snap_candidates = []
86
+ for surf in target[1]:
87
+ tree = surface_octree(surf)
88
+ if tree is None:
89
+ candidates = list(_iter_triangles_from_surface(surf))
90
+ else:
91
+ elems = tree.getElements(tri_bbox)
92
+ candidates = []
93
+ for elem in elems:
94
+ if isinstance(elem, list):
95
+ candidates.append(elem)
96
+ elif isinstance(elem, tuple):
97
+ candidates.append(elem[0])
98
+ else:
99
+ candidates.append(elem)
100
+ if not candidates:
101
+ candidates = list(_iter_triangles_from_surface(surf))
102
+ for cand in candidates:
103
+ plane = _triangle_plane(cand)
104
+ n, d = plane
105
+ centroid = point(
106
+ (cand[0][0] + cand[1][0] + cand[2][0]) / 3.0,
107
+ (cand[0][1] + cand[1][1] + cand[2][1]) / 3.0,
108
+ (cand[0][2] + cand[1][2] + cand[2][2]) / 3.0,
109
+ )
110
+ vec = point(centroid[0] - center[0],
111
+ centroid[1] - center[1],
112
+ centroid[2] - center[2])
113
+ dot_sign = n[0] * vec[0] + n[1] * vec[1] + n[2] * vec[2]
114
+ if dot_sign > epsilon_dot:
115
+ sense = -1
116
+ elif dot_sign < -epsilon_dot:
117
+ sense = 1
118
+ else:
119
+ center_eval = _plane_eval(plane, center)
120
+ sense = -1 if center_eval <= 0 else 1
121
+ plane_with_sense = (n, d, sense)
122
+ key = _plane_key(plane_with_sense, tol)
123
+ if key not in seen:
124
+ seen.add(key)
125
+ planes.append(plane_with_sense)
126
+ snap_candidates.extend(_triangle_plane_intersection_points(cand, tri_plane, tol))
127
+ unique_snap = _unique_points(snap_candidates, max(tol * 10.0, 1e-6)) if snap_candidates else []
128
+ return planes, unique_snap
129
+
130
+
131
+ _DEFAULT_RAY_TOL = 1e-7
132
+
133
+
134
+ def invalidate_surface_octree(s):
135
+ meta = _ensure_surface_metadata_dict(s)
136
+ meta.pop('_octree', None)
137
+ meta['_octree_dirty'] = True
138
+
139
+
140
+ def _bbox_overlap(box_a, box_b, tol):
141
+ return not (
142
+ box_a[1][0] < box_b[0][0] - tol or
143
+ box_a[0][0] > box_b[1][0] + tol or
144
+ box_a[1][1] < box_b[0][1] - tol or
145
+ box_a[0][1] > box_b[1][1] + tol or
146
+ box_a[1][2] < box_b[0][2] - tol or
147
+ box_a[0][2] > box_b[1][2] + tol
148
+ )
149
+
150
+
151
+ def _segment_bbox(p0, p1, pad):
152
+ return [
153
+ point(min(p0[0], p1[0]) - pad, min(p0[1], p1[1]) - pad, min(p0[2], p1[2]) - pad),
154
+ point(max(p0[0], p1[0]) + pad, max(p0[1], p1[1]) + pad, max(p0[2], p1[2]) + pad),
155
+ ]
156
+
157
+
158
+ def _plane_eval(plane, p):
159
+ n, d = plane
160
+ return dot(n, p) - d
161
+
162
+
163
+ def _segment_plane_intersection(p1, p2, plane, tol):
164
+ n, d = plane
165
+ direction = sub(p2, p1)
166
+ denom = dot(n, direction)
167
+ if abs(denom) < tol:
168
+ return point((p1[0] + p2[0]) * 0.5, (p1[1] + p2[1]) * 0.5, (p1[2] + p2[2]) * 0.5)
169
+ t = (d - dot(n, p1)) / denom
170
+ t = max(0.0, min(1.0, t))
171
+ return point(p1[0] + direction[0] * t,
172
+ p1[1] + direction[1] * t,
173
+ p1[2] + direction[2] * t)
174
+
175
+
176
+ def _dedupe_polygon(poly, tol):
177
+ if not poly:
178
+ return []
179
+ deduped = [poly[0]]
180
+ for pt in poly[1:]:
181
+ if dist(pt, deduped[-1]) > tol:
182
+ deduped.append(pt)
183
+ if len(deduped) > 2 and dist(deduped[0], deduped[-1]) <= tol:
184
+ deduped[-1] = deduped[0]
185
+ deduped.pop()
186
+ return deduped
187
+
188
+
189
+ def _clip_polygon_against_plane(poly, plane, tol, keep_inside=True):
190
+ if not poly:
191
+ return []
192
+ n, d, sense = plane
193
+ clipped = []
194
+ prev = poly[-1]
195
+ prev_eval = _plane_eval((n, d), prev)
196
+ if sense <= 0:
197
+ prev_inside = prev_eval <= tol if keep_inside else prev_eval >= -tol
198
+ else:
199
+ prev_inside = prev_eval >= -tol if keep_inside else prev_eval <= tol
200
+ for curr in poly:
201
+ curr_eval = _plane_eval((n, d), curr)
202
+ if sense <= 0:
203
+ curr_inside = curr_eval <= tol if keep_inside else curr_eval >= -tol
204
+ else:
205
+ curr_inside = curr_eval >= -tol if keep_inside else curr_eval <= tol
206
+ if curr_inside:
207
+ if not prev_inside:
208
+ clipped.append(_segment_plane_intersection(prev, curr, (n, d), tol))
209
+ clipped.append(point(curr))
210
+ elif prev_inside:
211
+ clipped.append(_segment_plane_intersection(prev, curr, (n, d), tol))
212
+ prev = curr
213
+ prev_eval = curr_eval
214
+ prev_inside = curr_inside
215
+ clipped = _dedupe_polygon(clipped, tol)
216
+ if not keep_inside and len(clipped) >= 3:
217
+ clipped = list(reversed(clipped))
218
+ return clipped
219
+
220
+
221
+ def _split_polygon_by_plane(poly, plane, tol):
222
+ if not poly:
223
+ return [], []
224
+
225
+ n, d, sense = plane
226
+ evals = [_plane_eval((n, d), p) for p in poly]
227
+ max_eval = max(evals)
228
+ min_eval = min(evals)
229
+
230
+ if sense <= 0:
231
+ if max_eval <= tol:
232
+ return [point(p) for p in poly], []
233
+ if min_eval >= -tol:
234
+ return [], [[point(p) for p in poly]]
235
+ else:
236
+ if min_eval >= -tol:
237
+ return [point(p) for p in poly], []
238
+ if max_eval <= tol:
239
+ return [], [[point(p) for p in poly]]
240
+
241
+ inside = _clip_polygon_against_plane(poly, plane, tol, keep_inside=True)
242
+ outside = _clip_polygon_against_plane(poly, plane, tol, keep_inside=False)
243
+ outside_polys = [outside] if outside else []
244
+ return inside, outside_polys
245
+
246
+
247
+ def _split_polygon_by_planes(poly, planes, tol):
248
+ inside_polys = [poly]
249
+ outside_polys = []
250
+ for plane in planes:
251
+ next_inside = []
252
+ for current in inside_polys:
253
+ inside, outside = _split_polygon_by_plane(current, plane, tol)
254
+ outside_polys.extend(outside)
255
+ if inside:
256
+ next_inside.append(inside)
257
+ inside_polys = next_inside
258
+ if not inside_polys:
259
+ break
260
+ return inside_polys, outside_polys
261
+
262
+
263
+ def _triangulate_polygon(poly, reference_normal=None):
264
+ if len(poly) < 3:
265
+ return []
266
+ if len(poly) == 3:
267
+ tri = [point(poly[0]), point(poly[1]), point(poly[2])]
268
+ if reference_normal is not None:
269
+ v01 = sub(tri[1], tri[0])
270
+ v02 = sub(tri[2], tri[0])
271
+ if dot(cross(v01, v02), reference_normal) < 0:
272
+ tri[1], tri[2] = tri[2], tri[1]
273
+ return [tri]
274
+
275
+ anchor = point(poly[0])
276
+ triangles = []
277
+ for i in range(1, len(poly) - 1):
278
+ tri = [anchor, point(poly[i]), point(poly[i + 1])]
279
+ if reference_normal is not None:
280
+ v01 = sub(tri[1], tri[0])
281
+ v02 = sub(tri[2], tri[0])
282
+ if dot(cross(v01, v02), reference_normal) < 0:
283
+ tri[1], tri[2] = tri[2], tri[1]
284
+ triangles.append(tri)
285
+ return triangles
286
+
287
+
288
+ def _triangle_plane(tri):
289
+ p0, n = _geom3d().tri2p0n(tri)
290
+ d = dot(n, p0)
291
+ return n, d
292
+
293
+
294
+ def _plane_key(plane, tol):
295
+ n, d, sense = plane
296
+ scale = 1.0 / tol
297
+ return (round(n[0] * scale), round(n[1] * scale), round(n[2] * scale), round(d * scale), sense)
298
+
299
+
300
+ def _sub3(a, b):
301
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
302
+
303
+
304
+ def _dot3(a, b):
305
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
306
+
307
+
308
+ def _cross3(a, b):
309
+ return [
310
+ a[1] * b[2] - a[2] * b[1],
311
+ a[2] * b[0] - a[0] * b[2],
312
+ a[0] * b[1] - a[1] * b[0],
313
+ ]
314
+
315
+
316
+ def _mag3(v):
317
+ return math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
318
+
319
+
320
+ def _normalize3(v, tol):
321
+ m = _mag3(v)
322
+ if m < tol:
323
+ return [0.0, 0.0, 0.0]
324
+ return [v[0] / m, v[1] / m, v[2] / m]
325
+
326
+
327
+ def _lerp_point(p0, p1, t):
328
+ return point(
329
+ p0[0] + (p1[0] - p0[0]) * t,
330
+ p0[1] + (p1[1] - p0[1]) * t,
331
+ p0[2] + (p1[2] - p0[2]) * t,
332
+ )
333
+
334
+
335
+
336
+ def _point_in_triangle(pt, tri, tol):
337
+ a = [tri[0][0], tri[0][1], tri[0][2]]
338
+ b = [tri[1][0], tri[1][1], tri[1][2]]
339
+ c = [tri[2][0], tri[2][1], tri[2][2]]
340
+ p = [pt[0], pt[1], pt[2]]
341
+
342
+ v0 = _sub3(c, a)
343
+ v1 = _sub3(b, a)
344
+ v2 = _sub3(p, a)
345
+
346
+ dot00 = _dot3(v0, v0)
347
+ dot01 = _dot3(v0, v1)
348
+ dot02 = _dot3(v0, v2)
349
+ dot11 = _dot3(v1, v1)
350
+ dot12 = _dot3(v1, v2)
351
+
352
+ denom = dot00 * dot11 - dot01 * dot01
353
+ if abs(denom) < tol:
354
+ return False
355
+ inv = 1.0 / denom
356
+ u = (dot11 * dot02 - dot01 * dot12) * inv
357
+ v = (dot00 * dot12 - dot01 * dot02) * inv
358
+ return u >= -tol and v >= -tol and (u + v) <= 1.0 + tol
359
+
360
+
361
+ def _segment_triangle_intersection_param(p0, p1, tri, tol):
362
+ n, d = _triangle_plane(tri)
363
+ dir_vec = _sub3([p1[0], p1[1], p1[2]], [p0[0], p0[1], p0[2]])
364
+ denom = _dot3(n, dir_vec)
365
+ if abs(denom) < tol:
366
+ return None
367
+ numer = d - (n[0] * p0[0] + n[1] * p0[1] + n[2] * p0[2])
368
+ t = numer / denom
369
+ if t < -tol or t > 1.0 + tol:
370
+ return None
371
+ t_clamped = max(0.0, min(1.0, t))
372
+ intersection = _lerp_point(p0, p1, t_clamped)
373
+ if not _point_in_triangle(intersection, tri, tol):
374
+ return None
375
+ return (t_clamped, intersection)
376
+
377
+
378
+ def _collect_segment_intersections(p0, p1, solid, tol):
379
+ results = []
380
+ query_box = _segment_bbox(p0, p1, tol * 5)
381
+ for surf in solid[1]:
382
+ tree = surface_octree(surf)
383
+ if tree is None:
384
+ candidates = list(_iter_triangles_from_surface(surf))
385
+ else:
386
+ elems = tree.getElements(query_box)
387
+ candidates = []
388
+ for elem in elems:
389
+ if isinstance(elem, list):
390
+ candidates.append(elem)
391
+ elif isinstance(elem, tuple):
392
+ candidates.append(elem[0])
393
+ else:
394
+ candidates.append(elem)
395
+ for tri in candidates:
396
+ res = _segment_triangle_intersection_param(p0, p1, tri, tol)
397
+ if res is not None:
398
+ results.append(res)
399
+ return results
400
+
401
+
402
+ def _unique_points(points, tol):
403
+ unique = []
404
+ for pt in points:
405
+ candidate = point(pt)
406
+ if not any(dist(candidate, existing) <= tol for existing in unique):
407
+ unique.append(candidate)
408
+ return unique
409
+
410
+
411
+ def _points_to_polygon(points, reference_normal, tol):
412
+ unique = _unique_points(points, tol)
413
+ if len(unique) < 3:
414
+ return []
415
+
416
+ centroid = point(
417
+ sum(p[0] for p in unique) / len(unique),
418
+ sum(p[1] for p in unique) / len(unique),
419
+ sum(p[2] for p in unique) / len(unique),
420
+ )
421
+
422
+ basis_u = None
423
+ for pt in unique:
424
+ vec = _sub3([pt[0], pt[1], pt[2]], [centroid[0], centroid[1], centroid[2]])
425
+ if _mag3(vec) > tol:
426
+ basis_u = _normalize3(vec, tol)
427
+ break
428
+ if basis_u is None:
429
+ return []
430
+
431
+ ref = _normalize3([reference_normal[0], reference_normal[1], reference_normal[2]], tol)
432
+ basis_v = _cross3(ref, basis_u)
433
+ if _mag3(basis_v) < tol:
434
+ basis_v = _cross3(basis_u, ref)
435
+ basis_v = _normalize3(basis_v, tol)
436
+
437
+ def _angle(pt):
438
+ vec = _sub3([pt[0], pt[1], pt[2]], [centroid[0], centroid[1], centroid[2]])
439
+ x = _dot3(vec, basis_u)
440
+ y = _dot3(vec, basis_v)
441
+ return math.atan2(y, x)
442
+
443
+ ordered = sorted(unique, key=_angle)
444
+ compact = [ordered[0]]
445
+ for pt in ordered[1:]:
446
+ if dist(compact[-1], pt) > tol:
447
+ compact.append(pt)
448
+ if dist(compact[0], compact[-1]) <= tol:
449
+ compact[-1] = compact[0]
450
+ compact = compact[:-1]
451
+ if len(compact) < 3:
452
+ return []
453
+
454
+ v0 = _sub3([compact[1][0], compact[1][1], compact[1][2]], [compact[0][0], compact[0][1], compact[0][2]])
455
+ v1 = _sub3([compact[2][0], compact[2][1], compact[2][2]], [compact[1][0], compact[1][1], compact[1][2]])
456
+ normal = _cross3(v0, v1)
457
+ if _mag3(normal) < tol:
458
+ return []
459
+ if _dot3(normal, [reference_normal[0], reference_normal[1], reference_normal[2]]) < 0:
460
+ compact.reverse()
461
+
462
+ return compact
463
+
464
+
465
+ def _orient_triangle(tri, reference_normal, tol):
466
+ pts = [point(tri[0]), point(tri[1]), point(tri[2])]
467
+ v01 = sub(pts[1], pts[0])
468
+ v02 = sub(pts[2], pts[0])
469
+ normal = cross(v01, v02)
470
+ area_mag = mag(normal)
471
+ if area_mag < tol:
472
+ return None
473
+ ref = reference_normal
474
+ if ref is not None:
475
+ ref_vec = [ref[0], ref[1], ref[2]]
476
+ if dot(normal, ref_vec) < 0:
477
+ pts[1], pts[2] = pts[2], pts[1]
478
+ return pts
479
+
480
+
481
+ def _clip_triangle_against_solid(tri, target, tol, reference_normal):
482
+ polygon = [point(tri[0]), point(tri[1]), point(tri[2])]
483
+ centroid = point(
484
+ (tri[0][0] + tri[1][0] + tri[2][0]) / 3.0,
485
+ (tri[0][1] + tri[1][1] + tri[2][1]) / 3.0,
486
+ (tri[0][2] + tri[1][2] + tri[2][2]) / 3.0,
487
+ )
488
+
489
+ tri_plane = _triangle_plane(tri)
490
+ planes, snap_candidates = _candidate_planes_for_triangle(tri, target, tri_plane, tol)
491
+
492
+ inside_polys = []
493
+ if planes:
494
+ inside_raw, _ = _split_polygon_by_planes(polygon, planes, tol)
495
+ for poly in inside_raw:
496
+ cleaned = _dedupe_polygon([point(p) for p in poly], tol)
497
+ if len(cleaned) >= 3:
498
+ inside_polys.append(cleaned)
499
+
500
+ if not inside_polys:
501
+ if solid_contains_point(target, centroid, tol=tol):
502
+ inside_polys = [polygon]
503
+ else:
504
+ oriented = _orient_triangle(tri, reference_normal, tol)
505
+ if oriented:
506
+ return [], [oriented], False
507
+ return [], [], False
508
+
509
+ _, _, to_local, to_world = _geom3d().tri2p0n(tri, basis=True)
510
+
511
+ if snap_candidates:
512
+ snap_tol = max(5e-2, tol * 1e6)
513
+
514
+ def _snap_point(pt):
515
+ best = snap_tol
516
+ best_candidate = None
517
+ for cand in snap_candidates:
518
+ d = dist(pt, cand)
519
+ if d < best:
520
+ best = d
521
+ best_candidate = cand
522
+ return point(best_candidate) if best_candidate is not None else point(pt)
523
+
524
+ snapped = []
525
+ for poly in inside_polys:
526
+ snapped_poly = [_snap_point(pt) for pt in poly]
527
+ snapped_poly = _dedupe_polygon(snapped_poly, tol)
528
+ if len(snapped_poly) >= 3:
529
+ snapped.append(snapped_poly)
530
+ if snapped:
531
+ inside_polys = snapped
532
+
533
+ def _to_local(pt):
534
+ loc = to_local.mul(pt)
535
+ return (loc[0], loc[1])
536
+
537
+ def _from_local(xy):
538
+ local_pt = point(xy[0], xy[1], 0.0)
539
+ world_pt = to_world.mul(local_pt)
540
+ return point(world_pt[0], world_pt[1], world_pt[2])
541
+
542
+ outer_loop = [_to_local(pt) for pt in polygon]
543
+ inside_loops = [[_to_local(pt) for pt in poly] for poly in inside_polys]
544
+
545
+ inside_triangles = []
546
+ for loop in inside_loops:
547
+ tri2d_list = triangulate_polygon(loop)
548
+ for tri2d in tri2d_list:
549
+ tri3d = [_from_local(pt) for pt in tri2d]
550
+ oriented = _orient_triangle(tri3d, reference_normal, tol)
551
+ if oriented:
552
+ inside_triangles.append(oriented)
553
+
554
+ holes = inside_loops if inside_loops else None
555
+ outside_triangles = []
556
+ tri2d_list = triangulate_polygon(outer_loop, holes=holes)
557
+ for tri2d in tri2d_list:
558
+ tri3d = [_from_local(pt) for pt in tri2d]
559
+ # Don't call _orient_triangle! The winding order is already preserved through:
560
+ # 1. Original triangle had correct outward normal
561
+ # 2. Clipping preserves vertex order (just removes parts)
562
+ # 3. Transform to 2D preserves order
563
+ # 4. triangulate_polygon produces CCW triangles in 2D
564
+ # 5. Transform back to 3D should maintain correct orientation
565
+ # Check for degeneracy with better quality metric:
566
+ v01 = sub(tri3d[1], tri3d[0])
567
+ v02 = sub(tri3d[2], tri3d[0])
568
+ cross_prod = cross(v01, v02)
569
+ area = mag(cross_prod)
570
+ if area >= tol:
571
+ # Also filter out very thin slivers by checking aspect ratio
572
+ # A degenerate sliver has tiny area relative to edge lengths
573
+ edge_lengths = [mag(v01), mag(v02), dist(tri3d[1], tri3d[2])]
574
+ max_edge = max(edge_lengths)
575
+ # For a reasonable triangle, area should be at least 1% of max_edge^2
576
+ # This filters out slivers where one edge is extremely small
577
+ quality_threshold = 0.01 * max_edge * max_edge
578
+ if area >= max(tol, quality_threshold):
579
+ outside_triangles.append([point(tri3d[0]), point(tri3d[1]), point(tri3d[2])])
580
+
581
+ split = bool(inside_triangles) and bool(outside_triangles)
582
+ return inside_triangles, outside_triangles, split
583
+
584
+
585
+
586
+
587
+ def stitch_open_edges(triangles, tol):
588
+ """Experimental: attempt to close open edge loops by triangulating them.
589
+
590
+ Parameters
591
+ ----------
592
+ triangles : Iterable
593
+ A sequence of triangle coordinate lists ``[[x, y, z, w], ...]``
594
+ describing a mesh with potential boundary edges.
595
+ tol : float
596
+ Tolerance used when deduplicating vertices and detecting shared
597
+ edges. Typically reuse ``_DEFAULT_RAY_TOL``.
598
+
599
+ Returns
600
+ -------
601
+ list
602
+ A new list of triangles with any stitched faces appended.
603
+ """
604
+ if not triangles:
605
+ return triangles
606
+
607
+ oriented_edges = []
608
+ canonical_counts = {}
609
+ base_triangles = []
610
+ for tri in triangles:
611
+ try:
612
+ n = _triangle_normal(tri)
613
+ except ValueError:
614
+ continue
615
+ pts = [point(tri[0]), point(tri[1]), point(tri[2])]
616
+ base_triangles.append(pts)
617
+ edges = ((pts[0], pts[1]), (pts[1], pts[2]), (pts[2], pts[0]))
618
+ for start, end in edges:
619
+ canonical = _canonical_edge_key(start, end)
620
+ canonical_counts[canonical] = canonical_counts.get(canonical, 0) + 1
621
+ oriented_edges.append((start, end, n))
622
+
623
+ boundary = []
624
+ for start, end, normal in oriented_edges:
625
+ if canonical_counts[_canonical_edge_key(start, end)] == 1:
626
+ boundary.append((start, end, normal))
627
+
628
+ if not boundary:
629
+ return base_triangles
630
+
631
+ def edge_id(start, end):
632
+ return (_point_to_key(start), _point_to_key(end))
633
+
634
+ adjacency = {}
635
+ for start, end, normal in boundary:
636
+ adjacency.setdefault(_point_to_key(start), []).append((start, end, normal))
637
+
638
+ used = set()
639
+ loops = []
640
+ for start, end, normal in boundary:
641
+ eid = edge_id(start, end)
642
+ if eid in used:
643
+ continue
644
+ loop = []
645
+ normals = []
646
+ current_start = start
647
+ current_end = end
648
+ current_normal = normal
649
+ start_key = _point_to_key(current_start)
650
+ while True:
651
+ loop.append(point(current_start))
652
+ normals.append(current_normal)
653
+ used.add(edge_id(current_start, current_end))
654
+ next_key = _point_to_key(current_end)
655
+ if next_key == start_key:
656
+ break
657
+ candidates = adjacency.get(next_key)
658
+ if not candidates:
659
+ loop = []
660
+ break
661
+ next_edge = None
662
+ for candidate in candidates:
663
+ cid = edge_id(candidate[0], candidate[1])
664
+ if cid not in used:
665
+ next_edge = candidate
666
+ break
667
+ if next_edge is None:
668
+ loop = []
669
+ break
670
+ current_start, current_end, current_normal = next_edge
671
+ if loop and len(loop) >= 3:
672
+ loops.append((loop, normals))
673
+
674
+ if not loops:
675
+ return base_triangles
676
+
677
+ stitched = list(base_triangles)
678
+ for loop_points, normals in loops:
679
+ polygon = _unique_points(loop_points, tol)
680
+ if len(polygon) < 3:
681
+ continue
682
+ avg = [0.0, 0.0, 0.0]
683
+ for n in normals:
684
+ avg[0] += n[0]
685
+ avg[1] += n[1]
686
+ avg[2] += n[2]
687
+ avg = _normalize3(avg, tol)
688
+ if _mag3(avg) < tol:
689
+ avg = normals[0]
690
+ poly = _points_to_polygon(polygon, avg, tol)
691
+ if not poly:
692
+ continue
693
+ for tri in _triangulate_polygon(poly, avg):
694
+ try:
695
+ normal = _triangle_normal(tri)
696
+ except ValueError:
697
+ continue
698
+ if _dot3(normal, [avg[0], avg[1], avg[2]]) < 0:
699
+ tri = [tri[0], tri[2], tri[1]]
700
+ try:
701
+ normal = _triangle_normal(tri)
702
+ except ValueError:
703
+ continue
704
+ stitched.append(tri)
705
+ return stitched
706
+
707
+ def stitch_solid(sld, tol=_DEFAULT_RAY_TOL):
708
+ """Experimental helper that runs :func:`stitch_open_edges` on a solid."""
709
+
710
+ if not _geom3d().issolid(sld, fast=False):
711
+ raise ValueError('invalid solid passed to stitch_solid')
712
+
713
+ triangles = list(_iter_triangles_from_solid(sld))
714
+ stitched = stitch_open_edges(triangles, tol)
715
+ if not stitched:
716
+ return sld
717
+
718
+ surface_result = _surface_from_triangles(stitched)
719
+ if surface_result:
720
+ return _geom3d().solid([surface_result], [], ['utility', 'stitch_open_edges'])
721
+ return sld
722
+
723
+
724
+
725
+ def _boolean_fragments(source, target, tol):
726
+ outside_tris = []
727
+ inside_tris = []
728
+ inside_overlap = []
729
+
730
+ for tri in _iter_triangles_from_solid(source):
731
+ reference_normal = _triangle_normal(tri)
732
+ inside_parts, outside_parts, split = _clip_triangle_against_solid(
733
+ tri, target, tol, reference_normal
734
+ )
735
+ if inside_parts:
736
+ inside_tris.extend(inside_parts)
737
+ if outside_parts:
738
+ outside_tris.extend(outside_parts)
739
+ if split and inside_parts:
740
+ inside_overlap.extend(inside_parts)
741
+ return outside_tris, inside_tris, inside_overlap
742
+
743
+
744
+
745
+ def _ray_triangle_intersection(origin, direction, triangle, tol=_DEFAULT_RAY_TOL):
746
+ v0, v1, v2 = triangle
747
+ e1 = sub(v1, v0)
748
+ e2 = sub(v2, v0)
749
+ h = cross(direction, e2)
750
+ a = dot(e1, h)
751
+ if abs(a) < tol:
752
+ return None
753
+ f = 1.0 / a
754
+ s = sub(origin, v0)
755
+ u = f * dot(s, h)
756
+ if u < -tol or u > 1.0 + tol:
757
+ return None
758
+ q = cross(s, e1)
759
+ v = f * dot(direction, q)
760
+ if v < -tol or u + v > 1.0 + tol:
761
+ return None
762
+ t = f * dot(e2, q)
763
+ if t < -tol:
764
+ return None
765
+ w = 1.0 - u - v
766
+ if w < -tol:
767
+ return None
768
+ hit = point(origin[0] + direction[0] * t,
769
+ origin[1] + direction[1] * t,
770
+ origin[2] + direction[2] * t)
771
+ return t, hit, (u, v, w)
772
+
773
+
774
+ def surface_octree(s, rebuild=False):
775
+ meta = _ensure_surface_metadata_dict(s)
776
+ tree = meta.get('_octree')
777
+ if rebuild or meta.get('_octree_dirty') or tree is None:
778
+ verts = s[1]
779
+ faces = s[3]
780
+ if not faces:
781
+ meta['_octree'] = None
782
+ meta['_octree_dirty'] = False
783
+ return None
784
+ extent = 0.0
785
+ for axis in range(3):
786
+ vals = [v[axis] for v in verts]
787
+ extent = max(extent, max(vals) - min(vals))
788
+ mindim = max(extent / 32.0, epsilon)
789
+ tree = NTree(n=8, mindim=mindim)
790
+ for face in faces:
791
+ if len(face) != 3:
792
+ continue
793
+ tri = [verts[face[0]], verts[face[1]], verts[face[2]]]
794
+ tree.addElement(tri)
795
+ tree.updateTree()
796
+ meta['_octree'] = tree
797
+ meta['_octree_dirty'] = False
798
+ return meta['_octree']
799
+
800
+
801
+ def _surface_from_triangles(triangles):
802
+ if not triangles:
803
+ return None
804
+ verts = []
805
+ normals = []
806
+ faces = []
807
+ for tri in triangles:
808
+ v01 = sub(tri[1], tri[0])
809
+ v02 = sub(tri[2], tri[0])
810
+ if mag(cross(v01, v02)) < epsilon:
811
+ continue
812
+ n = _triangle_normal(tri)
813
+ indices = []
814
+ for pt in tri:
815
+ verts.append(point(pt))
816
+ normals.append([n[0], n[1], n[2], 0.0])
817
+ indices.append(len(verts) - 1)
818
+ faces.append(indices)
819
+ surface_obj = ['surface', verts, normals, faces, [], []]
820
+ _ensure_surface_metadata_dict(surface_obj)
821
+ invalidate_surface_octree(surface_obj)
822
+ return surface_obj
823
+
824
+
825
+ def _iter_triangles_from_surface(surf):
826
+ verts = surf[1]
827
+ faces = surf[3]
828
+ for face in faces:
829
+ if len(face) != 3:
830
+ continue
831
+ yield [verts[face[0]], verts[face[1]], verts[face[2]]]
832
+
833
+
834
+ def _iter_triangles_from_solid(sld):
835
+ for surf in sld[1]:
836
+ yield from _iter_triangles_from_surface(surf)
837
+
838
+
839
+ def _group_hits(hits, tol):
840
+ if not hits:
841
+ return []
842
+ hits.sort(key=lambda x: x[0])
843
+ groups = [[hits[0]]]
844
+ for hit in hits[1:]:
845
+ if abs(hit[0] - groups[-1][-1][0]) <= tol:
846
+ groups[-1].append(hit)
847
+ else:
848
+ groups.append([hit])
849
+ return groups
850
+
851
+
852
+ def _triangle_normal(tri):
853
+ _, n = _geom3d().tri2p0n(tri)
854
+ return n
855
+ def solid_contains_point(sld, p, tol=_DEFAULT_RAY_TOL):
856
+ if not _geom3d().issolid(sld, fast=False):
857
+ raise ValueError('invalid solid passed to solid_contains_point')
858
+ if not _geom3d().ispoint(p):
859
+ raise ValueError('invalid point passed to solid_contains_point')
860
+
861
+ surfaces = sld[1]
862
+ if not surfaces:
863
+ return False
864
+
865
+ box = _geom3d().solidbbox(sld)
866
+ if box:
867
+ expanded = [
868
+ point(box[0][0] - tol, box[0][1] - tol, box[0][2] - tol),
869
+ point(box[1][0] + tol, box[1][1] + tol, box[1][2] + tol),
870
+ ]
871
+ if not _geom3d().isinsidebbox(expanded, p):
872
+ return False
873
+ extent = max(box[1][0] - box[0][0],
874
+ box[1][1] - box[0][1],
875
+ box[1][2] - box[0][2])
876
+ ray_length = max(extent * 1.5, 1.0)
877
+ else:
878
+ ray_length = 1.0
879
+
880
+ directions = [
881
+ vect(1, 0, 0, 0),
882
+ vect(-1, 0, 0, 0),
883
+ vect(0, 1, 0, 0),
884
+ vect(0, -1, 0, 0),
885
+ vect(0, 0, 1, 0),
886
+ vect(0, 0, -1, 0),
887
+ ]
888
+
889
+ seen_inside = False
890
+ for direction in directions:
891
+ far_point = point(p[0] + direction[0] * ray_length,
892
+ p[1] + direction[1] * ray_length,
893
+ p[2] + direction[2] * ray_length)
894
+ query_box = _segment_bbox(p, far_point, tol * 5)
895
+ hits = []
896
+ for surf in surfaces:
897
+ tree = surface_octree(surf)
898
+ if tree is None:
899
+ candidates = list(_iter_triangles_from_surface(surf))
900
+ else:
901
+ elems = tree.getElements(query_box)
902
+ candidates = []
903
+ for elem in elems:
904
+ if isinstance(elem, list):
905
+ candidates.append(elem)
906
+ elif isinstance(elem, tuple):
907
+ candidates.append(elem[0])
908
+ else:
909
+ candidates.append(elem)
910
+ if not candidates:
911
+ candidates = list(_iter_triangles_from_surface(surf))
912
+ for tri in candidates:
913
+ result = _ray_triangle_intersection(p, direction, tri, tol=tol)
914
+ if result is None:
915
+ continue
916
+ t_hit, hit_point, _ = result
917
+ if t_hit < -tol or t_hit > ray_length + tol:
918
+ continue
919
+ if t_hit <= tol:
920
+ return True
921
+ normal = _triangle_normal(tri)
922
+ sign = -1 if dot(normal, direction) > 0 else 1
923
+ hits.append((t_hit, hit_point, sign))
924
+ groups = _group_hits(hits, tol)
925
+ parity = 0
926
+ for group in groups:
927
+ sign_sum = sum(hit[2] for hit in group)
928
+ if sign_sum == 0:
929
+ continue
930
+ parity ^= 1
931
+ if parity == 0:
932
+ return False
933
+ seen_inside = True
934
+ return seen_inside
935
+
936
+
937
+ def solids_intersect(a, b, tol=_DEFAULT_RAY_TOL):
938
+ if not _geom3d().issolid(a, fast=False) or not _geom3d().issolid(b, fast=False):
939
+ raise ValueError('invalid solid passed to solids_intersect')
940
+ if not a[1] or not b[1]:
941
+ return False
942
+
943
+ box_a = _geom3d().solidbbox(a)
944
+ box_b = _geom3d().solidbbox(b)
945
+ if box_a and box_b and not _bbox_overlap(box_a, box_b, tol):
946
+ return False
947
+
948
+ center_a = point((box_a[0][0] + box_a[1][0]) / 2.0,
949
+ (box_a[0][1] + box_a[1][1]) / 2.0,
950
+ (box_a[0][2] + box_a[1][2]) / 2.0)
951
+ center_b = point((box_b[0][0] + box_b[1][0]) / 2.0,
952
+ (box_b[0][1] + box_b[1][1]) / 2.0,
953
+ (box_b[0][2] + box_b[1][2]) / 2.0)
954
+ if solid_contains_point(a, center_b, tol=tol) or solid_contains_point(b, center_a, tol=tol):
955
+ return True
956
+
957
+ for tri_a in _iter_triangles_from_solid(a):
958
+ for tri_b in _iter_triangles_from_solid(b):
959
+ if _geom3d().triTriIntersect(tri_a, tri_b):
960
+ return True
961
+ return False
962
+
963
+
964
+ def solid_boolean(a, b, operation, tol=_DEFAULT_RAY_TOL, *, stitch=False):
965
+ if operation not in {'union', 'intersection', 'difference'}:
966
+ raise ValueError(f'unsupported solid boolean operation {operation!r}')
967
+
968
+ outside_a, inside_a, overlap_a = _boolean_fragments(a, b, tol)
969
+ outside_b, inside_b, overlap_b = _boolean_fragments(b, a, tol)
970
+
971
+ if operation == 'union':
972
+ result_tris = outside_a + outside_b
973
+ if not result_tris:
974
+ result_tris = inside_a if inside_a else inside_b
975
+
976
+ # Filter out triangles in the interior of the overlap region
977
+ # For union, if a triangle's center is inside both input solids,
978
+ # it's in the interior and should not be on the surface
979
+ filtered_tris = []
980
+ for tri in result_tris:
981
+ center = point((tri[0][0] + tri[1][0] + tri[2][0]) / 3.0,
982
+ (tri[0][1] + tri[1][1] + tri[2][1]) / 3.0,
983
+ (tri[0][2] + tri[1][2] + tri[2][2]) / 3.0)
984
+ # Use a tighter tolerance for containment check to avoid false positives
985
+ check_tol = tol * 100
986
+ in_a = solid_contains_point(a, center, tol=check_tol)
987
+ in_b = solid_contains_point(b, center, tol=check_tol)
988
+ # Keep triangle only if it's not clearly inside both solids
989
+ if not (in_a and in_b):
990
+ filtered_tris.append(tri)
991
+ result_tris = filtered_tris
992
+
993
+ elif operation == 'intersection':
994
+ result_tris = inside_a + inside_b
995
+ if not result_tris:
996
+ result_tris = overlap_a + overlap_b
997
+ else: # difference
998
+ reversed_inside_b = [[tri[0], tri[2], tri[1]] for tri in inside_b]
999
+ if not reversed_inside_b and overlap_b:
1000
+ reversed_inside_b = [[tri[0], tri[2], tri[1]] for tri in overlap_b]
1001
+ result_tris = outside_a + reversed_inside_b
1002
+
1003
+ if stitch:
1004
+ result_tris = stitch_open_edges(result_tris, tol)
1005
+
1006
+ surface_result = _surface_from_triangles(result_tris)
1007
+ if surface_result:
1008
+ return _geom3d().solid([surface_result], [], ['boolean', operation])
1009
+ return _geom3d().solid([], [], ['boolean', operation])
1010
+
1011
+
1012
+