Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cadquery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from .occ_impl import exporters
from .occ_impl import importers
from .occ_impl import swig_runtime # registers the exit-time guard for #1911

# these items are the common implementation

Expand Down
2 changes: 1 addition & 1 deletion cadquery/occ_impl/sketch_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from numpy import array, full, inf, sign
from numpy.linalg import norm
import nlopt

from OCP.gp import gp_Vec2d

Expand Down Expand Up @@ -345,6 +344,7 @@ def grad(x, rv) -> None:

def solve(self) -> Tuple[Sequence[Sequence[float]], Dict[str, Any]]:

import nlopt
x0 = array(list(chain.from_iterable(self.entities))).ravel()
f, grad, lb, ub = self._cost(x0)

Expand Down
18 changes: 17 additions & 1 deletion cadquery/occ_impl/solver.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from typing import (
List,
Tuple,
Expand All @@ -14,7 +16,10 @@

from math import radians, pi

import casadi as ca
# casadi is imported inside the functions that use it rather than at module
# level: importing it together with nlopt at `import cadquery` time crashes
# the interpreter at exit on Windows (#1911), and neither is needed unless
# constraints are actually solved.

from OCP.gp import (
gp_Vec,
Expand Down Expand Up @@ -308,6 +313,7 @@ def toPODs(self) -> Tuple[Constraint, ...]:
# Cost functions of simple constraints
def Quaternion(R):

import casadi as ca
m = ca.sumsqr(R)

u = 2 * R / (1 + m)
Expand All @@ -318,6 +324,7 @@ def Quaternion(R):

def Rotate(v, R):

import casadi as ca
s, u = Quaternion(R)

return 2 * ca.dot(u, v) * u + (s ** 2 - ca.dot(u, u)) * v + 2 * s * ca.cross(u, v)
Expand All @@ -344,6 +351,7 @@ def point_cost(
scale: float = 1,
) -> float:

import casadi as ca
val = 0 if val is None else val

m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z()))
Expand Down Expand Up @@ -375,6 +383,7 @@ def axis_cost(
scale: float = 1,
) -> float:

import casadi as ca
val = pi if val is None else val

m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z()))
Expand Down Expand Up @@ -413,6 +422,7 @@ def point_in_plane_cost(
scale: float = 1,
) -> float:

import casadi as ca
val = 0 if val is None else val

m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z()))
Expand Down Expand Up @@ -451,6 +461,7 @@ def point_on_line_cost(
scale: float = 1,
) -> float:

import casadi as ca
val = 0 if val is None else val

m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z()))
Expand Down Expand Up @@ -500,6 +511,7 @@ def fixed_point_cost(
scale: float = 1,
):

import casadi as ca
m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z()))

dummy = (Transform(m1_dm, T1_0 + T1, R1_0 + R1) - ca.DM(val)) / scale
Expand All @@ -518,6 +530,7 @@ def fixed_axis_cost(
scale: float = 1,
):

import casadi as ca
m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z()))
m_val = ca.DM(val) / ca.norm_2(ca.DM(val))

Expand All @@ -537,6 +550,7 @@ def fixed_rotation_cost(
scale: float = 1,
):

import casadi as ca
q = gp_Quaternion()
q.SetEulerAngles(gp_Extrinsic_XYZ, *val)
q_dm = ca.DM((q.W(), q.X(), q.Y(), q.Z()))
Expand Down Expand Up @@ -591,6 +605,7 @@ def __init__(
scale: float = 1,
):

import casadi as ca
self.scale = scale
self.opti = opti = ca.Opti()
self.variables = [
Expand Down Expand Up @@ -660,6 +675,7 @@ def _build_transform(self, T: ca.MX, R: ca.MX) -> gp_Trsf:

def solve(self, verbosity: int = 0) -> Tuple[List[Location], Dict[str, Any]]:

import casadi as ca
suppress_banner = "yes" if verbosity == 0 else "no"

opti = self.opti
Expand Down
44 changes: 44 additions & 0 deletions cadquery/occ_impl/swig_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Keep the interpreter from crashing at exit when casadi and nlopt are both loaded.

casadi and nlopt are SWIG-generated extension modules, and on Windows their PyPI
wheels are built against different C runtimes (nlopt: MSVC/UCRT; casadi: MinGW-w64
on the legacy msvcrt). SWIG modules in one process share a runtime type table
through the ``swig_runtime_data<N>.type_pointer_capsule`` capsule; at interpreter
exit the capsule's destructor walks the shared table and frees every module's
records with the C runtime of whichever module created the capsule. Freeing memory
that the other runtime allocated corrupts the heap (#1911: exit 0xC0000005 or
0xC0000374, after all output). Leaving those few records unfreed at exit is
harmless, so this disarms the destructor once both modules are present.
"""

import atexit
import ctypes
import sys


def _real(name: str) -> bool:
module = sys.modules.get(name)
return module is not None and getattr(module, "__file__", None) is not None


def disarm_swig_runtime_capsule() -> bool:
"""Clear the SWIG runtime capsule's destructor if casadi and nlopt are both loaded.

Returns True if a capsule was disarmed. Safe to call more than once.
"""

if not (_real("casadi") and _real("nlopt")):
return False
setter = ctypes.pythonapi.PyCapsule_SetDestructor
setter.argtypes = [ctypes.py_object, ctypes.c_void_p]
setter.restype = ctypes.c_int
disarmed = False
for name, module in list(sys.modules.items()):
if name.startswith("swig_runtime_data"):
capsule = getattr(module, "type_pointer_capsule", None)
if capsule is not None and setter(capsule, None) == 0:
disarmed = True
return disarmed


atexit.register(disarm_swig_runtime_capsule)
31 changes: 31 additions & 0 deletions tests/test_lazy_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import subprocess
import sys


def test_import_cadquery_does_not_load_solver_backends():
"""casadi and nlopt are only needed when constraints are solved. Loading both at
import time crashes the interpreter at exit on Windows (#1911), so `import
cadquery` must leave them unloaded. Checked in a fresh process so this test does
not depend on what the rest of the suite imported first."""
code = "import sys, cadquery; print('casadi' in sys.modules, 'nlopt' in sys.modules)"
out = subprocess.check_output([sys.executable, "-c", code], text=True)
assert out.split() == ["False", "False"]


def test_process_exits_cleanly_with_both_solver_backends_loaded():
"""casadi and nlopt are SWIG modules that share SWIG's runtime capsule; on Windows
their wheels use different C runtimes and the capsule's exit-time destructor
corrupts the heap (#1911). cadquery.occ_impl.swig_runtime disarms it at exit, so
a process that solved an assembly and loaded nlopt must still exit 0."""
code = (
"import cadquery as cq, nlopt, sys\n"
"a = cq.Assembly()\n"
"a.add(cq.Workplane().box(10, 10, 10), name='b1')\n"
"a.add(cq.Workplane().box(5, 5, 5), name='b2')\n"
"a.constrain('b1@faces@>Z', 'b2@faces@<Z', 'Plane')\n"
"a.solve()\n"
"print('casadi' in sys.modules and 'nlopt' in sys.modules)\n"
)
proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
assert proc.stdout.split() == ["True"], proc.stderr
assert proc.returncode == 0, f"exit {proc.returncode}: {proc.stderr}"