yapCAD 0.5.0__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.
yapcad/geom3d_util.py CHANGED
@@ -81,6 +81,7 @@ icaIndices = [ [1,11,3],[3,11,5],[5,11,7],[7,11,9],[9,11,1],
81
81
  [0,2,4],[0,4,6],[0,6,8],[0,8,10],[0,10,2] ]
82
82
 
83
83
  vertexHash = {}
84
+ _vertexHash_owner = None
84
85
  def addVertex(nv,nn,verts,normals):
85
86
  """
86
87
  Utility function that takes a vertex and associated normal and a
@@ -91,18 +92,29 @@ def addVertex(nv,nn,verts,normals):
91
92
 
92
93
  returns the index, and the (potentiall updated) lists
93
94
  """
94
- global vertexHash
95
- if len(verts) == 0:
95
+ global vertexHash, _vertexHash_owner
96
+ owner_id = id(verts)
97
+ if _vertexHash_owner != owner_id or len(verts) == 0:
96
98
  vertexHash = {}
99
+ _vertexHash_owner = owner_id
97
100
 
98
101
  found = False
99
- vkey = f"{nv[0]:.2f}{nv[1]:.2f}{nv[2]:.2f}"
102
+ # Normalize tiny values to avoid "-0.00" != "0.00" hash key mismatch
103
+ x = 0.0 if abs(nv[0]) < epsilon else nv[0]
104
+ y = 0.0 if abs(nv[1]) < epsilon else nv[1]
105
+ z = 0.0 if abs(nv[2]) < epsilon else nv[2]
106
+ vkey = f"{x:.2f}{y:.2f}{z:.2f}"
100
107
  if vkey in vertexHash:
101
108
  found = True
102
109
  inds = vertexHash[vkey]
110
+ valid_inds = []
103
111
  for i in inds:
112
+ if i >= len(verts):
113
+ continue
104
114
  if vclose(nv,verts[i]):
105
115
  return i,verts,normals
116
+ valid_inds.append(i)
117
+ vertexHash[vkey] = valid_inds
106
118
 
107
119
  verts.append(nv)
108
120
  normals.append(nn)
@@ -347,20 +359,32 @@ def conic(baser,topr,height, center=point(0,0,0),angr=10):
347
359
  ['procedure',call])
348
360
  else:
349
361
  topP = add(center,point(0,0,height))
350
- conV = [ topP ] + baseV
362
+ # Only use perimeter vertices (baseV[1:]), skip the center point
363
+ conV = [ topP ] + baseV[1:]
351
364
  ll = len(conV)
352
- conN = [[0,0,1,0]]
365
+ # Initialize all normals to a default value
366
+ conN = [[0,0,1,0] for _ in range(ll)]
353
367
  conF = []
354
368
 
355
- for i in range(1,ll):
356
- p0= conV[0]
357
- p1= conV[(i-1)%ll]
358
- p2= conV[(i+1)%ll]
369
+ # ll = apex(1) + perimeter vertices(36) = 37
370
+ # Perimeter vertex indices: 1 to ll-1
371
+ num_perimeter = ll - 1
372
+ for i in range(1, ll):
373
+ p0 = conV[0] # apex
374
+ # Wrap indices within perimeter range [1, ll-1]
375
+ prev_idx = ((i - 2) % num_perimeter) + 1
376
+ next_idx = (i % num_perimeter) + 1
377
+ p1 = conV[prev_idx]
378
+ p2 = conV[next_idx]
359
379
 
360
- conF.append([0,i,(i+1)%ll])
361
- pp,n0 = tri2p0n([p0,p1,p2])
380
+ try:
381
+ pp, n0 = tri2p0n([p0, p1, p2])
382
+ except ValueError:
383
+ # Skip degenerate faces near the apex
384
+ continue
362
385
 
363
- conN.append(n0)
386
+ conF.append([0, i, next_idx])
387
+ conN[i] = n0
364
388
 
365
389
  conS = surface(conV,conN,conF)
366
390
 
@@ -384,28 +408,112 @@ def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36):
384
408
 
385
409
  degStep = 360.0/arcSamples
386
410
  radStep = pi2/arcSamples
411
+
412
+ # Pre-compute cos/sin values to avoid floating point errors at the seam
413
+ # Explicitly ensure that index 0 uses exact values
414
+ angle_cos = []
415
+ angle_sin = []
416
+ for i in range(arcSamples):
417
+ if i == 0:
418
+ angle_cos.append(1.0)
419
+ angle_sin.append(0.0)
420
+ else:
421
+ angle = i * radStep
422
+ angle_cos.append(math.cos(angle))
423
+ angle_sin.append(math.sin(angle))
424
+
425
+ # Check if we need pole caps
426
+ r_start = contour(zStart)
427
+ r_end = contour(zEnd)
428
+ need_start_cap = r_start < epsilon * 10
429
+ need_end_cap = r_end < epsilon * 10
430
+
431
+ # Add pole vertices if needed
432
+ start_pole_idx = None
433
+ end_pole_idx = None
434
+ if need_start_cap:
435
+ pole_point = [0.0, 0.0, zStart, 1.0]
436
+ pole_normal = [0.0, 0.0, -1.0, 0.0] # Points downward for bottom pole
437
+ start_pole_idx, sV, sN = addVertex(pole_point, pole_normal, sV, sN)
438
+
439
+ if need_end_cap:
440
+ pole_point = [0.0, 0.0, zEnd, 1.0]
441
+ pole_normal = [0.0, 0.0, 1.0, 0.0] # Points upward for top pole
442
+ end_pole_idx, sV, sN = addVertex(pole_point, pole_normal, sV, sN)
443
+
387
444
  for i in range(steps):
388
445
  z = i*zD+zStart
389
446
  r0 = contour(z)
390
447
  r1 = contour(z+zD)
391
- if r0 < epsilon*10:
392
- r0 = epsilon*10
393
- if r1 < epsilon*10:
394
- r1 = epsilon*10
448
+
449
+ # Handle pole caps
450
+ if i == 0 and need_start_cap:
451
+ # Create triangular faces from pole to first ring
452
+ if r1 < epsilon:
453
+ r1 = epsilon
454
+ for j in range(arcSamples):
455
+ a1_idx = j
456
+ a2_idx = (j+1) % arcSamples
457
+
458
+ pp1 = [angle_cos[a1_idx]*r1, angle_sin[a1_idx]*r1, z+zD, 1.0]
459
+ pp2 = [angle_cos[a2_idx]*r1, angle_sin[a2_idx]*r1, z+zD, 1.0]
460
+
461
+ try:
462
+ _, n = tri2p0n([sV[start_pole_idx], pp1, pp2])
463
+ except ValueError:
464
+ continue
465
+
466
+ k1, sV, sN = addVertex(pp1, n, sV, sN)
467
+ k2, sV, sN = addVertex(pp2, n, sV, sN)
468
+ sF.append([start_pole_idx, k1, k2])
469
+ continue
470
+
471
+ if i == steps - 1 and need_end_cap:
472
+ # Create triangular faces from last ring to pole
473
+ if r0 < epsilon:
474
+ r0 = epsilon
475
+ for j in range(arcSamples):
476
+ a1_idx = j
477
+ a2_idx = (j+1) % arcSamples
478
+
479
+ p1 = [angle_cos[a1_idx]*r0, angle_sin[a1_idx]*r0, z, 1.0]
480
+ p2 = [angle_cos[a2_idx]*r0, angle_sin[a2_idx]*r0, z, 1.0]
481
+
482
+ try:
483
+ _, n = tri2p0n([p1, sV[end_pole_idx], p2])
484
+ except ValueError:
485
+ continue
486
+
487
+ k1, sV, sN = addVertex(p1, n, sV, sN)
488
+ k2, sV, sN = addVertex(p2, n, sV, sN)
489
+ sF.append([k1, end_pole_idx, k2])
490
+ continue
491
+
492
+ # Regular quad strips for non-pole sections
493
+ if r0 < epsilon:
494
+ r0 = epsilon
495
+ if r1 < epsilon:
496
+ r1 = epsilon
497
+
395
498
  for j in range(arcSamples):
396
- a0 = (j-1)*radStep
397
- a1 = j*radStep
398
- a2 = (j+1)*radStep
499
+ # Use pre-computed values with proper wrapping
500
+ a0_idx = (j-1) % arcSamples
501
+ a1_idx = j
502
+ a2_idx = (j+1) % arcSamples
399
503
 
400
- p0 = [math.cos(a0)*r0,math.sin(a0)*r0,z,1.0]
401
- p1 = [math.cos(a1)*r0,math.sin(a1)*r0,z,1.0]
402
- p2 = [math.cos(a2)*r0,math.sin(a2)*r0,z,1.0]
504
+ p0 = [angle_cos[a0_idx]*r0, angle_sin[a0_idx]*r0, z, 1.0]
505
+ p1 = [angle_cos[a1_idx]*r0, angle_sin[a1_idx]*r0, z, 1.0]
506
+ p2 = [angle_cos[a2_idx]*r0, angle_sin[a2_idx]*r0, z, 1.0]
403
507
 
404
- pp1 = [math.cos(a1)*r1,math.sin(a1)*r1,z+zD,1.0]
405
- pp2 = [math.cos(a2)*r1,math.sin(a2)*r1,z+zD,1.0]
508
+ pp1 = [angle_cos[a1_idx]*r1, angle_sin[a1_idx]*r1, z+zD, 1.0]
509
+ pp2 = [angle_cos[a2_idx]*r1, angle_sin[a2_idx]*r1, z+zD, 1.0]
510
+
511
+ try:
512
+ p,n = tri2p0n([p0,p2,pp1])
513
+ except ValueError:
514
+ # Skip degenerate faces
515
+ continue
406
516
 
407
- p,n = tri2p0n([p0,p2,pp1])
408
-
409
517
  k1,sV,sN = addVertex(p1,n,sV,sN)
410
518
  k2,sV,sN = addVertex(p2,n,sV,sN)
411
519
  k3,sV,sN = addVertex(pp2,n,sV,sN)
@@ -499,14 +607,51 @@ def extrude(surf,distance,direction=vect(0,0,1,0)):
499
607
 
500
608
  loops = []
501
609
  if s2[4]:
502
- loops.append(s2[4])
503
- loops.extend([loop for loop in s2[5] if loop])
610
+ loops.append(list(s2[4]))
611
+ loops.extend([list(loop) for loop in s2[5] if loop])
504
612
 
505
613
  stripV = s2[1] + s1[1] # vertices for the edge strips
506
614
  stripN = [vect(0, 0, 1, 0)] * len(stripV) # placeholder normals
507
615
  stripF: list[list[int]] = []
508
616
  offset = len(s2[1])
509
617
 
618
+ def _project(idx):
619
+ point3 = stripV[idx]
620
+ return point3[0], point3[1]
621
+
622
+ if surf[3]:
623
+ try:
624
+ tri = surf[3][0]
625
+ basis = tri2p0n([surf[1][tri[0]], surf[1][tri[1]], surf[1][tri[2]]], basis=True)
626
+ if basis:
627
+ forward = basis[2]
628
+
629
+ def _project(idx): # type: ignore[redefinition]
630
+ vec = forward.mul(stripV[idx])
631
+ return vec[0], vec[1]
632
+ except Exception: # pragma: no cover
633
+ pass
634
+
635
+ def loop_area(loop):
636
+ if len(loop) < 3:
637
+ return 0.0
638
+ area = 0.0
639
+ for i in range(len(loop)):
640
+ x0, y0 = _project(loop[i])
641
+ x1, y1 = _project(loop[(i + 1) % len(loop)])
642
+ area += x0 * y1 - x1 * y0
643
+ return area / 2.0
644
+
645
+ if loops:
646
+ outer_area = loop_area(loops[0])
647
+ if outer_area < 0:
648
+ loops[0].reverse()
649
+ outer_area = -outer_area
650
+ outer_sign = 1 if outer_area >= 0 else -1
651
+ for loop in loops[1:]:
652
+ if loop_area(loop) * outer_sign > 0:
653
+ loop.reverse()
654
+
510
655
  for bndry in loops:
511
656
  if len(bndry) < 2:
512
657
  continue
@@ -534,8 +679,319 @@ def extrude(surf,distance,direction=vect(0,0,1,0)):
534
679
  return solid([s2,strip,s1],
535
680
  [],
536
681
  ['procedure',call])
537
-
538
-
682
+
683
+
684
+
685
+ def _loft_surface(lower_loop, upper_loop, invert=False):
686
+ """Create a surface connecting two loops.
687
+
688
+ Args:
689
+ lower_loop: List of points forming the lower loop (must be open, not closed)
690
+ upper_loop: List of points forming the upper loop (must be open, not closed)
691
+ invert: If True, reverse the face winding order
692
+
693
+ Returns:
694
+ A yapCAD surface connecting the two loops with triangle strips
695
+ """
696
+
697
+ if not lower_loop or not upper_loop:
698
+ raise ValueError('invalid loops passed to loft surface')
699
+ lower = [point(p) for p in lower_loop]
700
+ upper = [point(p) for p in upper_loop]
701
+ if len(lower) != len(upper):
702
+ raise ValueError('loop length mismatch in loft surface')
703
+
704
+ vertices = lower + upper
705
+ normals = [[0, 0, 1, 0] for _ in vertices]
706
+ faces = []
707
+ count = len(lower)
708
+
709
+ for idx in range(count):
710
+ j0 = idx
711
+ j1 = (idx + 1) % count
712
+ j2 = j1 + count
713
+ j3 = idx + count
714
+
715
+ tri1 = [j0, j1, j2]
716
+ tri2 = [j0, j2, j3]
717
+ if invert:
718
+ tri1 = list(reversed(tri1))
719
+ tri2 = list(reversed(tri2))
720
+
721
+ try:
722
+ _, normal = tri2p0n([vertices[tri1[0]],
723
+ vertices[tri1[1]],
724
+ vertices[tri1[2]]])
725
+ except ValueError:
726
+ continue
727
+
728
+ for vid in {tri1[0], tri1[1], tri1[2], tri2[0], tri2[1], tri2[2]}:
729
+ normals[vid] = normal
730
+
731
+ faces.append(tri1)
732
+ faces.append(tri2)
733
+
734
+ return surface(vertices, normals, faces)
735
+
736
+
539
737
 
540
738
 
541
739
 
740
+
741
+ def _circle_loop(center_xy, radius, minang):
742
+ arc_geom = [arc(point(center_xy[0], center_xy[1]), radius)]
743
+ loop = geomlist2poly(arc_geom, minang=minang, minlen=0.0)
744
+ if not loop:
745
+ raise ValueError('failed to generate circle loop')
746
+ return loop
747
+
748
+
749
+ def tube(outer_diameter, wall_thickness, length,
750
+ center=None, *, base_point=None, minang=5.0, include_caps=True):
751
+ """Create a cylindrical tube solid.
752
+
753
+ ``base_point`` (or legacy ``center`` argument) identifies the base of the
754
+ cylindrical wall, i.e. the plane where ``z == base_point[2]``.
755
+ """
756
+
757
+ if base_point is not None and center is not None:
758
+ raise ValueError('specify only base_point (preferred) or center, not both')
759
+ if base_point is None:
760
+ base_point = center if center is not None else point(0, 0, 0)
761
+ if len(base_point) < 3:
762
+ raise ValueError('base_point must contain x, y, z components')
763
+ base_point = point(base_point)
764
+
765
+ if wall_thickness <= epsilon:
766
+ raise ValueError('wall thickness must be positive')
767
+
768
+ outer_radius = outer_diameter / 2.0
769
+ inner_radius = outer_radius - wall_thickness
770
+ if inner_radius <= epsilon:
771
+ raise ValueError('wall thickness too large for tube')
772
+
773
+ base_z = base_point[2]
774
+ center_xy = (base_point[0], base_point[1])
775
+
776
+ base_loop_xy = _circle_loop(center_xy, outer_radius, minang)
777
+ inner_loop_xy = list(reversed(_circle_loop(center_xy, inner_radius, minang)))
778
+
779
+ base_surface, _ = poly2surfaceXY(base_loop_xy, holepolys=[inner_loop_xy])
780
+ base_surface = reversesurface(base_surface)
781
+ base_surface = translatesurface(base_surface, point(0, 0, base_z))
782
+
783
+ top_surface, _ = poly2surfaceXY(base_loop_xy, holepolys=[inner_loop_xy])
784
+ top_surface = translatesurface(top_surface, point(0, 0, base_z + length))
785
+
786
+ outer_base = [point(p[0], p[1], base_z, 1.0) for p in base_loop_xy[:-1]]
787
+ inner_base = [point(p[0], p[1], base_z, 1.0) for p in inner_loop_xy[:-1]]
788
+ outer_top = [point(p[0], p[1], base_z + length, 1.0) for p in base_loop_xy[:-1]]
789
+ inner_top = [point(p[0], p[1], base_z + length, 1.0) for p in inner_loop_xy[:-1]]
790
+
791
+ outer_side = _loft_surface(outer_base, outer_top)
792
+ inner_side = _loft_surface(inner_top, inner_base, invert=True)
793
+
794
+ call = f"yapcad.geom3d_util.tube({outer_diameter}, {wall_thickness}, {length}, base_point={base_point})"
795
+ surfaces = [base_surface, outer_side, top_surface, inner_side]
796
+ if not include_caps:
797
+ surfaces = [outer_side, inner_side]
798
+ return solid(surfaces,
799
+ [],
800
+ ['procedure', call])
801
+
802
+
803
+ def conic_tube(bottom_outer_diameter, top_outer_diameter, wall_thickness,
804
+ length, center=None, *, base_point=None, minang=5.0, include_caps=True):
805
+ """Create a conic tube with varying outer diameter.
806
+
807
+ ``base_point`` (or ``center`` legacy argument) marks the axial base of the
808
+ frustum (the larger-diameter end when stacked)."""
809
+
810
+ if base_point is not None and center is not None:
811
+ raise ValueError('specify only base_point (preferred) or center, not both')
812
+ if base_point is None:
813
+ base_point = center if center is not None else point(0, 0, 0)
814
+ base_point = point(base_point)
815
+
816
+ if wall_thickness <= epsilon:
817
+ raise ValueError('wall thickness must be positive')
818
+
819
+ r0_outer = bottom_outer_diameter / 2.0
820
+ r1_outer = top_outer_diameter / 2.0
821
+ r0_inner = r0_outer - wall_thickness
822
+ r1_inner = r1_outer - wall_thickness
823
+ if r0_inner <= epsilon or r1_inner <= epsilon:
824
+ raise ValueError('wall thickness too large for conic tube')
825
+
826
+ base_z = base_point[2]
827
+ center_xy = (base_point[0], base_point[1])
828
+
829
+ base_outer_loop = _circle_loop(center_xy, r0_outer, minang)
830
+ base_inner_loop = list(reversed(_circle_loop(center_xy, r0_inner, minang)))
831
+
832
+ top_outer_loop = _circle_loop(center_xy, r1_outer, minang)
833
+ top_inner_loop = list(reversed(_circle_loop(center_xy, r1_inner, minang)))
834
+
835
+ base_surface, _ = poly2surfaceXY(base_outer_loop, holepolys=[base_inner_loop])
836
+ base_surface = reversesurface(base_surface)
837
+ base_surface = translatesurface(base_surface, point(0, 0, base_z))
838
+
839
+ top_surface, _ = poly2surfaceXY(top_outer_loop, holepolys=[top_inner_loop])
840
+ top_surface = translatesurface(top_surface, point(0, 0, base_z + length))
841
+
842
+ outer_base = [point(p[0], p[1], base_z, 1.0) for p in base_outer_loop[:-1]]
843
+ outer_top = [point(p[0], p[1], base_z + length, 1.0) for p in top_outer_loop[:-1]]
844
+ inner_base = [point(p[0], p[1], base_z, 1.0) for p in base_inner_loop[:-1]]
845
+ inner_top = [point(p[0], p[1], base_z + length, 1.0) for p in top_inner_loop[:-1]]
846
+
847
+ outer_side = _loft_surface(outer_base, outer_top)
848
+ inner_side = _loft_surface(inner_top, inner_base, invert=True)
849
+
850
+ call = ("yapcad.geom3d_util.conic_tube("
851
+ f"{bottom_outer_diameter}, {top_outer_diameter}, {wall_thickness}, {length}, base_point={base_point})")
852
+ surfaces = [base_surface, outer_side, top_surface, inner_side]
853
+ if not include_caps:
854
+ surfaces = [outer_side, inner_side]
855
+ return solid(surfaces,
856
+ [],
857
+ ['procedure', call])
858
+
859
+
860
+ def spherical_shell(outer_diameter, wall_thickness,
861
+ solid_angle=4 * math.pi, center=point(0, 0, 0),
862
+ *, minang=5.0, steps=24):
863
+ """Create a spherical shell or cap defined by a solid angle."""
864
+
865
+ if wall_thickness <= epsilon:
866
+ raise ValueError('wall thickness must be positive')
867
+
868
+ outer_radius = outer_diameter / 2.0
869
+ inner_radius = outer_radius - wall_thickness
870
+ if inner_radius <= epsilon:
871
+ raise ValueError('wall thickness too large for spherical shell')
872
+
873
+ solid_angle = max(min(solid_angle, 4 * math.pi), epsilon)
874
+ cos_theta = 1.0 - solid_angle / (2.0 * math.pi)
875
+ cos_theta = max(-1.0, min(1.0, cos_theta))
876
+ theta = math.acos(cos_theta)
877
+
878
+ arc_samples = max(12, int(round(360.0 / minang)))
879
+ lat_steps = max(4, int(math.ceil(theta / math.radians(minang))))
880
+
881
+ cz = center[2]
882
+
883
+ def _sphere_contour(radius):
884
+ def contour(z):
885
+ dz = z - cz
886
+ dz = max(min(dz, radius), -radius)
887
+ return math.sqrt(max(radius * radius - dz * dz, 0.0))
888
+ return contour
889
+
890
+ z_start_outer = cz + outer_radius * math.cos(theta)
891
+ z_start_inner = cz + inner_radius * math.cos(theta)
892
+ z_end_outer = cz + outer_radius
893
+ z_end_inner = cz + inner_radius
894
+
895
+ outer_surface = makeRevolutionSurface(_sphere_contour(outer_radius),
896
+ z_start_outer, z_end_outer,
897
+ max(steps, lat_steps),
898
+ arcSamples=arc_samples)
899
+ outer_surface = translatesurface(outer_surface, point(center[0], center[1], 0))
900
+
901
+ inner_surface = makeRevolutionSurface(_sphere_contour(inner_radius),
902
+ z_start_inner, z_end_inner,
903
+ max(steps, lat_steps),
904
+ arcSamples=arc_samples)
905
+ inner_surface = translatesurface(inner_surface, point(center[0], center[1], 0))
906
+ inner_surface = reversesurface(inner_surface)
907
+
908
+ surfaces = [outer_surface, inner_surface]
909
+
910
+ if theta < math.pi - epsilon:
911
+ r_outer_ring = outer_radius * math.sin(theta)
912
+ r_inner_ring = inner_radius * math.sin(theta)
913
+
914
+ base_outer = [point(center[0] + p[0], center[1] + p[1], cz + outer_radius * math.cos(theta), 1.0)
915
+ for p in _circle_loop((0, 0), r_outer_ring, minang)[:-1]]
916
+ base_inner = [point(center[0] + p[0], center[1] + p[1], cz + inner_radius * math.cos(theta), 1.0)
917
+ # for p in list(reversed(_circle_loop((0, 0), r_inner_ring, minang)))[:-1]]
918
+ for p in _circle_loop((0, 0), r_inner_ring, minang)[:-1]]
919
+
920
+ conic_surface = _loft_surface(base_outer, base_inner, invert=False)
921
+ surfaces.append(conic_surface)
922
+
923
+ call = ("yapcad.geom3d_util.spherical_shell("
924
+ f"{outer_diameter}, {wall_thickness}, {solid_angle}, center={center})")
925
+ return solid(surfaces,
926
+ [],
927
+ ['procedure', call])
928
+
929
+
930
+ def stack_solids(solids, *, axis='z', start=0.0, gap=0.0, align='center'):
931
+ """Return translated copies of ``solids`` stacked along an axis."""
932
+
933
+ if not solids:
934
+ return []
935
+
936
+ axis = axis.lower()
937
+ if axis not in ('x', 'y', 'z'):
938
+ raise ValueError('axis must be one of x, y, or z')
939
+
940
+ axis_idx = {'x': 0, 'y': 1, 'z': 2}[axis]
941
+ other_idx = [i for i in range(3) if i != axis_idx]
942
+
943
+ placed = []
944
+ cursor = start
945
+ reference = None
946
+ pending_gap = 0.0
947
+
948
+ for entry in solids:
949
+ if isinstance(entry, str):
950
+ directive = entry.strip().lower()
951
+ if directive.startswith('space:'):
952
+ try:
953
+ value = float(directive.split(':', 1)[1])
954
+ except ValueError as exc:
955
+ raise ValueError(f'bad spacing directive {entry}') from exc
956
+ pending_gap += value
957
+ continue
958
+ raise ValueError(f'unsupported directive {entry!r} in stack_solids')
959
+
960
+ solid_obj = entry
961
+ bbox = solidbbox(solid_obj)
962
+ length = bbox[1][axis_idx] - bbox[0][axis_idx]
963
+ if length < epsilon:
964
+ raise ValueError('solid has zero length along stacking axis')
965
+
966
+ cursor += pending_gap
967
+ pending_gap = 0.0
968
+
969
+ translation = [0.0, 0.0, 0.0]
970
+ translation[axis_idx] = cursor - bbox[0][axis_idx]
971
+
972
+ if reference is None:
973
+ if align == 'center':
974
+ reference = [
975
+ (bbox[0][idx] + bbox[1][idx]) / 2.0 for idx in other_idx
976
+ ]
977
+ elif align == 'min':
978
+ reference = [bbox[0][idx] for idx in other_idx]
979
+ elif align == 'max':
980
+ reference = [bbox[1][idx] for idx in other_idx]
981
+ else:
982
+ raise ValueError('align must be center, min, or max')
983
+
984
+ if align == 'center':
985
+ for ref_val, idx in zip(reference, other_idx):
986
+ translation[idx] = ref_val - (bbox[0][idx] + bbox[1][idx]) / 2.0
987
+ elif align == 'min':
988
+ for ref_val, idx in zip(reference, other_idx):
989
+ translation[idx] = ref_val - bbox[0][idx]
990
+ elif align == 'max':
991
+ for ref_val, idx in zip(reference, other_idx):
992
+ translation[idx] = ref_val - bbox[1][idx]
993
+
994
+ placed.append(translatesolid(solid_obj, vect(translation[0], translation[1], translation[2], 0)))
995
+ cursor += length + gap
996
+
997
+ return placed