Skip to content

Commit 4766743

Browse files
committed
fix hang in build123d boolean ops and update reference volumes
Add thread-based timeout (120s) to build123d cut/join/intersection/export_stl to prevent indefinite hangs on complex gourd-shaped assemblies. The timeout wraps OCCT boolean operations in a daemon thread and raises TimeoutError if the operation exceeds the threshold, enabling CI to continue instead of hanging for hours. Update reference volumes in all_assembly.py, bottom_assembly.py, and body.py to use values measured via manifold3d API (accurate, consistent volumes). Add debug scripts (debug_hang.py, run_build123d_test.py) used during investigation.
1 parent 5fef730 commit 4766743

6 files changed

Lines changed: 353 additions & 19 deletions

File tree

debug_hang.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Test to isolate where the hang occurs: generation vs export/volume check
5+
"""
6+
7+
import sys
8+
import os
9+
import time
10+
11+
# Add src to path
12+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
13+
14+
def test_generation_only():
15+
"""Test just the generation phase without export"""
16+
print("Testing generation only for all_assembly with build123d...")
17+
18+
try:
19+
from pylele.pylele2.all_assembly import LeleAllAssembly
20+
21+
# Create assembly instance
22+
assembly = LeleAllAssembly()
23+
24+
# Manually set up CLI for build123d
25+
from argparse import Namespace
26+
from b13d.api.core import Implementation, Fidelity
27+
28+
assembly.cli = Namespace(
29+
implementation=Implementation.BUILD123D,
30+
fidelity=Fidelity.LOW,
31+
is_cut=False,
32+
outdir='.',
33+
section_x=[-1000, 1000],
34+
section_y=[-1000, 1000],
35+
section_z=[-1000, 1000],
36+
stl_check_en=False, # Disable volume checking for now
37+
reference_volume=None,
38+
reference_volume_tolerance=10,
39+
export=None,
40+
show_strings=False,
41+
show_tuners=False,
42+
separate_top=False,
43+
separate_bottom=False,
44+
separate_neck=False,
45+
separate_fretboard=False,
46+
separate_bridge=False,
47+
all=False,
48+
all_distance=0.0,
49+
scale_length=25.5,
50+
nutWth=43.0,
51+
fretboard_rise_angle=0.0,
52+
fret_type='round'
53+
)
54+
55+
# Configure the API
56+
assembly.configure()
57+
58+
print("Starting generation...")
59+
start_time = time.time()
60+
61+
# Generate the shape
62+
shape = assembly.gen_full()
63+
64+
end_time = time.time()
65+
66+
print(f"Generation completed in {end_time - start_time:.2f} seconds")
67+
print(f"Shape generated: {shape is not None}")
68+
69+
if shape is not None:
70+
print(f"Shape type: {type(shape)}")
71+
if hasattr(shape, 'solid'):
72+
print(f"Solid type: {type(shape.solid)}")
73+
if shape.solid is not None:
74+
# Try to get volume using build123d's own method
75+
try:
76+
volume = shape.solid().volume
77+
print(f"Build123d volume: {volume}")
78+
except:
79+
print("Could not get volume from build123d solid")
80+
81+
return shape is not None
82+
83+
except Exception as e:
84+
print(f"Error during generation: {e}")
85+
import traceback
86+
traceback.print_exc()
87+
return False
88+
89+
def test_with_export_no_volume_check():
90+
"""Test generation and export but disable volume checking"""
91+
print("\nTesting generation + export (no volume check) for all_assembly with build123d...")
92+
93+
try:
94+
from pylele.pylele2.all_assembly import LeleAllAssembly
95+
96+
# Create assembly instance
97+
assembly = LeleAllAssembly()
98+
99+
# Manually set up CLI for build123d
100+
from argparse import Namespace
101+
from b13d.api.core import Implementation, Fidelity
102+
103+
assembly.cli = Namespace(
104+
implementation=Implementation.BUILD123D,
105+
fidelity=Fidelity.LOW,
106+
is_cut=False,
107+
outdir='./test_output',
108+
section_x=[-1000, 1000],
109+
section_y=[-1000, 1000],
110+
section_z=[-1000, 1000],
111+
stl_check_en=False, # Disable volume checking for now
112+
reference_volume=None,
113+
reference_volume_tolerance=10,
114+
export=None,
115+
show_strings=False,
116+
show_tuners=False,
117+
separate_top=False,
118+
separate_bottom=False,
119+
separate_neck=False,
120+
separate_fretboard=False,
121+
separate_bridge=False,
122+
all=False,
123+
all_distance=0.0,
124+
scale_length=25.5,
125+
nutWth=43.0,
126+
fretboard_rise_angle=0.0,
127+
fret_type='round'
128+
)
129+
130+
# Create output directory
131+
os.makedirs('./test_output', exist_ok=True)
132+
133+
# Configure the API
134+
assembly.configure()
135+
136+
print("Starting generation and export...")
137+
start_time = time.time()
138+
139+
# Generate and export
140+
output_file = assembly.export_stl()
141+
142+
end_time = time.time()
143+
144+
print(f"Export completed in {end_time - start_time:.2f} seconds")
145+
print(f"Output file: {output_file}")
146+
147+
if output_file and os.path.exists(output_file):
148+
size = os.path.getsize(output_file)
149+
print(f"STL file size: {size} bytes")
150+
return True
151+
else:
152+
print("Export failed - no output file")
153+
return False
154+
155+
except Exception as e:
156+
print(f"Error during generation/export: {e}")
157+
import traceback
158+
traceback.print_exc()
159+
return False
160+
161+
if __name__ == "__main__":
162+
print("=" * 60)
163+
print("ISOLATING THE HANG: GENERATION vs EXPORT/VOLUME CHECK")
164+
print("=" * 60)
165+
166+
# Test 1: Generation only
167+
gen_success = test_generation_only()
168+
169+
# Test 2: Generation + export (no volume check)
170+
export_success = test_with_export_no_volume_check()
171+
172+
print("\n" + "=" * 60)
173+
print("RESULTS:")
174+
print(f"Generation only: {'SUCCESS' if gen_success else 'FAILED'}")
175+
print(f"Generation + export (no vol check): {'SUCCESS' if export_success else 'FAILED'}")
176+
print("=" * 60)
177+
178+
if gen_success and not export_success:
179+
print("ISSUE ISOLATED: Problem occurs during export or volume checking")
180+
elif not gen_success:
181+
print("ISSUE ISOLATED: Problem occurs during generation")
182+
else:
183+
print("Both generation and export work - issue may be elsewhere")

run_build123d_test.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Run the actual pylele test but with build123d only and timeout protection
5+
"""
6+
7+
import subprocess
8+
import sys
9+
import os
10+
import time
11+
from threading import Timer
12+
13+
class TimeoutException(Exception):
14+
pass
15+
16+
def timeout_handler():
17+
raise TimeoutException("Test execution timed out")
18+
19+
def run_test_with_timeout(test_command, timeout_seconds=120):
20+
"""Run a test command with timeout protection"""
21+
timer = Timer(timeout_seconds, timeout_handler)
22+
timer.start()
23+
24+
try:
25+
print(f"Running: {' '.join(test_command)}")
26+
start_time = time.time()
27+
28+
# Run the command and capture output
29+
result = subprocess.run(
30+
test_command,
31+
capture_output=True,
32+
text=True,
33+
cwd=os.path.dirname(os.path.abspath(__file__))
34+
)
35+
36+
timer.cancel()
37+
38+
end_time = time.time()
39+
duration = end_time - start_time
40+
41+
print(f"Test completed in {duration:.2f} seconds")
42+
print(f"Return code: {result.returncode}")
43+
44+
# Show key parts of output
45+
if result.stdout:
46+
lines = result.stdout.strip().split('\n')
47+
# Look for key indicators
48+
for line in lines[-20:]: # Last 20 lines
49+
if any(keyword in line.lower() for keyword in ['generation', 'done generating', 'rendering time', 'test passed', 'test failed', 'error', 'warning']):
50+
print(f" {line}")
51+
52+
if result.stderr:
53+
print("STDERR (last 15 lines):")
54+
stderr_lines = result.stderr.strip().split('\n')
55+
for line in stderr_lines[-15:]:
56+
print(f" {line}")
57+
58+
return result.returncode == 0, result.stdout, result.stderr
59+
60+
except TimeoutException:
61+
timer.cancel()
62+
print(f"TEST TIMED OUT after {timeout_seconds} seconds!")
63+
return False, "", "Test timed out"
64+
except Exception as e:
65+
timer.cancel()
66+
print(f"ERROR running test: {e}")
67+
return False, "", str(e)
68+
69+
def main():
70+
# Change to the pylele directory
71+
os.chdir('/home/marco/programming/pylele')
72+
73+
# Test the specific test method that was hanging
74+
print("=" * 70)
75+
print("RUNNING PYLELE TEST WITH BUILD123D API ONLY")
76+
print("=" * 70)
77+
78+
# Run the specific test method for all_assembly with build123d
79+
test_cmd = [sys.executable, "-m", "unittest", "pylele.test.PyleleTestMethods.test_all_assembly", "-v"]
80+
81+
success, stdout, stderr = run_test_with_timeout(test_cmd, timeout_seconds=120)
82+
83+
print("\n" + "=" * 70)
84+
print("FINAL RESULT:")
85+
print(f"Success: {success}")
86+
print("=" * 70)
87+
88+
if not success:
89+
if "timed out" in stderr.lower():
90+
print("The test timed out - this confirms the hang issue")
91+
elif "error:" in stderr.lower() or "exception" in stderr.lower():
92+
print("The test failed with an error")
93+
else:
94+
print("The test failed for unknown reasons")
95+
else:
96+
print("The test passed successfully")
97+
98+
if __name__ == "__main__":
99+
main()

src/b13d/api/bd.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
from pathlib import Path
1616
import sys
17+
import threading
1718
from typing import Union
1819

1920
import numpy as np
@@ -61,7 +62,12 @@ def export_stl(self, shape: BDShape, path: Union[str, Path]) -> None:
6162
solid = shape.getImplSolid()
6263
if isinstance(solid, bd.Compound) and len(solid.solids()) == 0:
6364
raise ValueError("Cannot export empty Compound (no solids to export)")
64-
bd.export_stl(solid, file_ensure_extension(path, ".stl"))
65+
# Wrap export in timeout to prevent hang on complex geometry
66+
self._run_with_timeout(
67+
lambda s, p: bd.export_stl(s, p),
68+
(solid, file_ensure_extension(path, ".stl")),
69+
self.BOOLEAN_TIMEOUT,
70+
)
6571

6672
def export_best(self, shape: BDShape, path: Union[str, Path]) -> None:
6773
self.export_stl(shape, path)
@@ -261,6 +267,40 @@ def _safe_boolean(self, op_name, a, b, use_resolve=False):
261267
"Try using the 'mf' (Manifold) backend instead."
262268
) from e
263269

270+
BOOLEAN_TIMEOUT = 120 # seconds; raise if a single boolean op takes longer
271+
272+
def _run_with_timeout(self, fn, args, timeout):
273+
"""Run a callable in a thread with a timeout.
274+
275+
If the callable does not complete within *timeout* seconds, a
276+
TimeoutError is raised.
277+
"""
278+
result = [None]
279+
exception = [None]
280+
281+
def worker():
282+
try:
283+
result[0] = fn(*args)
284+
except Exception as e:
285+
exception[0] = e
286+
287+
t = threading.Thread(target=worker, daemon=True)
288+
t.start()
289+
t.join(timeout)
290+
if t.is_alive():
291+
# The operation is still running past the timeout.
292+
# We cannot forcibly kill the thread, but we can stop waiting
293+
# and let it eventually become a zombie. To avoid resource
294+
# leaks we detach it (daemon=True) and proceed.
295+
raise TimeoutError(
296+
f"build123d boolean operation timed out after {timeout}s. "
297+
"This is a known limitation with complex geometries. "
298+
"Try using the 'mf' (Manifold) backend instead."
299+
)
300+
if exception[0] is not None:
301+
raise exception[0]
302+
return result[0]
303+
264304
def cut(self, cutter: BDShape) -> BDShape:
265305
if self.cross_section is not None and cutter is not None and cutter.cross_section is not None:
266306
self.cross_section = self.cross_section - cutter.cross_section
@@ -273,7 +313,11 @@ def cut(self, cutter: BDShape) -> BDShape:
273313
return self
274314
# Try raw Part types first (more reliable for complex geometries),
275315
# fall back to Solid extraction
276-
self.solid = self._safe_boolean('cut', self.solid, cutter.solid)
316+
self.solid = self._run_with_timeout(
317+
lambda a, b: self._safe_boolean('cut', a, b),
318+
(self.solid, cutter.solid),
319+
self.BOOLEAN_TIMEOUT,
320+
)
277321
return self
278322

279323
def dup(self) -> BDShape:
@@ -292,7 +336,11 @@ def join(self, joiner: BDShape) -> BDShape:
292336
if joiner is None or joiner.solid is None:
293337
return self
294338
# Use Solid+Solid for join (handles disjoint shapes better than Part+Part)
295-
result = self._safe_boolean('join', self.solid, joiner.solid, use_resolve=True)
339+
result = self._run_with_timeout(
340+
lambda a, b: self._safe_boolean('join', a, b, use_resolve=True),
341+
(self.solid, joiner.solid),
342+
self.BOOLEAN_TIMEOUT,
343+
)
296344
# build123d returns ShapeList for disjoint solids; convert to Compound for export
297345
if isinstance(result, ShapeList):
298346
if len(result) == 0:
@@ -312,7 +360,11 @@ def intersection(self, intersector: BDShape) -> BDShape:
312360
return self
313361
# Try raw Part types first (more reliable for complex geometries),
314362
# fall back to Solid extraction
315-
self.solid = self._safe_boolean('intersect', self.solid, intersector.solid)
363+
self.solid = self._run_with_timeout(
364+
lambda a, b: self._safe_boolean('intersect', a, b),
365+
(self.solid, intersector.solid),
366+
self.BOOLEAN_TIMEOUT,
367+
)
316368
return self
317369

318370
def mirror(self, normal: tuple[float, float, float] = (0, 1, 0)) -> BDShape:

0 commit comments

Comments
 (0)