pycodecad 1.0.0__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.
- pycodecad/__init__.py +18 -0
- pycodecad/__main__.py +3 -0
- pycodecad/api.py +105 -0
- pycodecad/app.py +34 -0
- pycodecad/cad.py +261 -0
- pycodecad/camera.py +136 -0
- pycodecad/cli.py +241 -0
- pycodecad/context.py +178 -0
- pycodecad/editor.py +256 -0
- pycodecad/embed.py +16 -0
- pycodecad/examples/assembly.py +7 -0
- pycodecad/examples/assets/logo.svg +1 -0
- pycodecad/examples/assets/pyramid.stl +44 -0
- pycodecad/examples/embedded_app.py +77 -0
- pycodecad/examples/gear.py +6 -0
- pycodecad/examples/gearbox.py +30 -0
- pycodecad/examples/gears_turning.py +11 -0
- pycodecad/examples/import_files.py +13 -0
- pycodecad/examples/parts.py +46 -0
- pycodecad/examples/tray.py +28 -0
- pycodecad/files.py +262 -0
- pycodecad/icons/LICENSE +43 -0
- pycodecad/icons/__init__.py +31 -0
- pycodecad/icons/lucide.ttf +0 -0
- pycodecad/imgui_backend.py +269 -0
- pycodecad/params.py +177 -0
- pycodecad/renderer.py +327 -0
- pycodecad/runner.py +404 -0
- pycodecad/sidecar.py +86 -0
- pycodecad/textedit.py +290 -0
- pycodecad/ui.py +677 -0
- pycodecad/viewcube.py +120 -0
- pycodecad/viewer.py +70 -0
- pycodecad/window.py +129 -0
- pycodecad/workspace.py +545 -0
- pycodecad-1.0.0.dist-info/METADATA +117 -0
- pycodecad-1.0.0.dist-info/RECORD +40 -0
- pycodecad-1.0.0.dist-info/WHEEL +4 -0
- pycodecad-1.0.0.dist-info/entry_points.txt +2 -0
- pycodecad-1.0.0.dist-info/licenses/LICENSE +21 -0
pycodecad/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Code-CAD with build123d: write a Python script, see the part, export it."""
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
|
|
6
|
+
__all__ = ["show", "clear", "frame", "import_mesh", "expose"]
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .api import clear, expose, frame, import_mesh, show
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str):
|
|
13
|
+
# Lazy, so `import pycodecad` (and the CLI) start instantly.
|
|
14
|
+
if name in __all__:
|
|
15
|
+
from . import api
|
|
16
|
+
|
|
17
|
+
return getattr(api, name)
|
|
18
|
+
raise AttributeError(name)
|
pycodecad/__main__.py
ADDED
pycodecad/api.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The modeling API: show(), clear(), frame(), import_mesh() and expose().
|
|
2
|
+
|
|
3
|
+
While pycodecad runs a script, `scene` is the list that show() fills, `frames` the scenes frame()
|
|
4
|
+
saved and `values` the parameter values of exposed functions. Outside pycodecad (plain
|
|
5
|
+
`python script.py`) scene is None: show()/clear()/frame() do nothing and expose() calls the
|
|
6
|
+
function with its defaults, so scripts still run.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from .cad import Mesh, Shown, copy_shape, flatten, import_mesh, parse_color, placed
|
|
13
|
+
|
|
14
|
+
__all__ = ["show", "clear", "frame", "import_mesh", "expose"]
|
|
15
|
+
|
|
16
|
+
scene: list[Shown] | None = None
|
|
17
|
+
frames: list[list[Shown]] = []
|
|
18
|
+
FPS = 30 # frames per second of the animation
|
|
19
|
+
values: dict[str, object] = {} # "function.param" or "param" -> value, set by the runner
|
|
20
|
+
used: set[str] = set() # keys of `values` some expose() took
|
|
21
|
+
exposed: list = [] # params.Exposed, in call order
|
|
22
|
+
strict = True # False (the window): values the parameter no longer accepts are skipped
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def show(*objs: object, name: str | None = None, color: object = None) -> None:
|
|
26
|
+
"""Add build123d shapes, builders or meshes (or lists of them) to the scene.
|
|
27
|
+
|
|
28
|
+
name: label of the object (numbered when several objects are passed).
|
|
29
|
+
color: "#RRGGBB", a basic name like "red", or an RGB triple (0..1 or 0..255).
|
|
30
|
+
"""
|
|
31
|
+
if scene is None:
|
|
32
|
+
return
|
|
33
|
+
if name is not None and not isinstance(name, str):
|
|
34
|
+
raise TypeError(f"show() name must be a string, not {type(name).__name__}")
|
|
35
|
+
shapes = flatten(list(objs))
|
|
36
|
+
for number, shape in enumerate(shapes, start=1):
|
|
37
|
+
index = len(scene)
|
|
38
|
+
if name is None:
|
|
39
|
+
label = f"{type(shape).__name__} {index + 1}"
|
|
40
|
+
else:
|
|
41
|
+
label = name if len(shapes) == 1 else f"{name}_{number}"
|
|
42
|
+
mesh, matrix, volume = placed(shape)
|
|
43
|
+
source = shape if isinstance(shape, Mesh) else copy_shape(shape) # later moves: not this placement
|
|
44
|
+
scene.append(Shown(label, parse_color(color, index), mesh, volume, source, matrix))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clear() -> None:
|
|
48
|
+
"""Remove everything shown so far."""
|
|
49
|
+
if scene is not None:
|
|
50
|
+
scene.clear()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def frame() -> None:
|
|
54
|
+
"""Save the scene as it is now as the next frame of an animation (1/30 s each). The window plays
|
|
55
|
+
the frames; without any frame() the scene is a still one. The scene stays: clear() it yourself."""
|
|
56
|
+
if scene is not None:
|
|
57
|
+
frames.append([Shown(obj.name, obj.color, obj.mesh, obj.volume, None, obj.matrix) for obj in scene])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def expose(fn):
|
|
61
|
+
"""Call fn with the values of its parameters: from the window's controls (or `--set` on
|
|
62
|
+
the command line), otherwise its defaults. Returns what fn returns.
|
|
63
|
+
|
|
64
|
+
Only simple parameters: int, float, bool or str with a default, annotated with pytypehint
|
|
65
|
+
Min, Max, Step, Slider, Label and Description. Anything else raises TypeError.
|
|
66
|
+
"""
|
|
67
|
+
from . import params
|
|
68
|
+
|
|
69
|
+
signature, found = params.compile_function(fn)
|
|
70
|
+
name = fn.__name__
|
|
71
|
+
if scene is not None and any(e.function == name for e in exposed):
|
|
72
|
+
raise ValueError(f"expose({name}) was already called in this run")
|
|
73
|
+
chosen = {}
|
|
74
|
+
try:
|
|
75
|
+
for param in found:
|
|
76
|
+
keys = [key for key in (f"{name}.{param.name}", param.name) if key in values]
|
|
77
|
+
if keys: # "function.param" wins over "param"; both count as taken
|
|
78
|
+
used.update(keys)
|
|
79
|
+
if strict:
|
|
80
|
+
chosen[param.name] = params.convert(param, values[keys[0]])
|
|
81
|
+
elif (value := accepted(signature, param, values[keys[0]])) is not None:
|
|
82
|
+
chosen[param.name] = value # else the edited script no longer accepts it: default
|
|
83
|
+
kwargs = signature.build(chosen)
|
|
84
|
+
except (TypeError, ValueError) as exc:
|
|
85
|
+
raise ValueError(f"expose({name}): {params.name_parameter(fn, str(exc))}") from None
|
|
86
|
+
if scene is not None: # plain python: nothing to show the parameters to
|
|
87
|
+
ran = []
|
|
88
|
+
for param in found:
|
|
89
|
+
try:
|
|
90
|
+
ran.append(replace(param, value=kwargs[param.name]))
|
|
91
|
+
except ValueError as exc: # e.g. --set beyond what the window's controls handle
|
|
92
|
+
raise ValueError(f"expose({name}): parameter {param.name!r}: {exc}") from None
|
|
93
|
+
exposed.append(params.Exposed(function=name, params=tuple(ran)))
|
|
94
|
+
return fn(**kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def accepted(signature, param, value: object) -> object | None:
|
|
98
|
+
"""The value for param, or None when param does not accept it (any more)."""
|
|
99
|
+
if type(value) is not {"int": int, "float": float, "bool": bool, "str": str}[param.kind]:
|
|
100
|
+
return None # the window sends values of the kind the parameter had: another kind is stale
|
|
101
|
+
try:
|
|
102
|
+
signature.build({param.name: value})
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
return value
|
pycodecad/app.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The pycodecad window (`pycodecad file.py` or `pycodecad folder/`): one Workspace filling a Window."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from slimgui import imgui
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .window import create_window
|
|
8
|
+
from .workspace import Workspace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def open_window(path: str, screenshot: str | None = None, read_only: bool = False, run: bool = True) -> None:
|
|
12
|
+
"""Work on a folder until the window closes. path: its main file, or the folder. screenshot: a
|
|
13
|
+
hidden window that saves a PNG of itself after the first run, then closes."""
|
|
14
|
+
workspace = Workspace(path, read_only=read_only, run=run or screenshot is not None)
|
|
15
|
+
window = create_window(workspace.title(), visible=screenshot is None)
|
|
16
|
+
workspace.say(f"pycodecad {__version__}")
|
|
17
|
+
try:
|
|
18
|
+
while window.frame(keep_open=workspace.dirty() and not screenshot):
|
|
19
|
+
window.set_title(workspace.title())
|
|
20
|
+
viewport = imgui.get_main_viewport()
|
|
21
|
+
imgui.set_next_window_pos(viewport.work_pos)
|
|
22
|
+
imgui.set_next_window_size(viewport.work_size)
|
|
23
|
+
imgui.push_style_var(imgui.StyleVar.WINDOW_PADDING, (6.0, 6.0))
|
|
24
|
+
imgui.begin("pycodecad", flags=imgui.WindowFlags.NO_DECORATION | imgui.WindowFlags.NO_MOVE
|
|
25
|
+
| imgui.WindowFlags.NO_SAVED_SETTINGS | imgui.WindowFlags.NO_BRING_TO_FRONT_ON_FOCUS)
|
|
26
|
+
imgui.pop_style_var()
|
|
27
|
+
workspace.draw()
|
|
28
|
+
workspace.close_prompt(window)
|
|
29
|
+
imgui.end()
|
|
30
|
+
if screenshot and workspace.ran() and not workspace.running():
|
|
31
|
+
window.screenshot(screenshot)
|
|
32
|
+
window.request_close()
|
|
33
|
+
finally:
|
|
34
|
+
window.close()
|
pycodecad/cad.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""CAD objects to triangles: the Mesh type, colors, tessellation and import_mesh.
|
|
2
|
+
|
|
3
|
+
Importing this module is cheap: build123d/OCP are imported inside the functions that need them.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Iterable
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from build123d.topology import Shape
|
|
19
|
+
|
|
20
|
+
Vec = tuple[float, float, float]
|
|
21
|
+
Color = tuple[float, float, float]
|
|
22
|
+
Matrix = tuple[float, ...] # 4x4, row by row: where a mesh in its own coordinates is placed
|
|
23
|
+
# import_mesh(solid=True) needs a temporary STL; it goes here, not to /tmp (often RAM).
|
|
24
|
+
TEMP = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "pycodecad" / "tmp"
|
|
25
|
+
|
|
26
|
+
PALETTE = ("#F4B02A", "#6947AE", "#48B5A4", "#5898DA", "#E87769", "#AAC86A")
|
|
27
|
+
NAMED_COLORS = dict(red="#FF0000", green="#008000", blue="#0000FF", gold="#FFD700", violet="#EE82EE",
|
|
28
|
+
purple="#800080", white="#FFFFFF", black="#000000", gray="#808080", grey="#808080",
|
|
29
|
+
orange="#FFA500", yellow="#FFFF00", cyan="#00FFFF", magenta="#FF00FF")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Mesh:
|
|
34
|
+
"""Triangles (positions/normals, 3 rows per triangle) and edge segments (2 rows per segment)."""
|
|
35
|
+
|
|
36
|
+
positions: np.ndarray
|
|
37
|
+
normals: np.ndarray
|
|
38
|
+
edges: np.ndarray
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
for name in ("positions", "normals", "edges"):
|
|
42
|
+
array = np.array(getattr(self, name), dtype=np.float32).reshape(-1, 3)
|
|
43
|
+
if not np.isfinite(array).all():
|
|
44
|
+
raise ValueError(f"Mesh {name} must be finite")
|
|
45
|
+
object.__setattr__(self, name, array)
|
|
46
|
+
if self.positions.shape != self.normals.shape or len(self.positions) % 3 or len(self.edges) % 2:
|
|
47
|
+
raise ValueError("Mesh requires whole triangles, one normal per vertex and edge pairs")
|
|
48
|
+
|
|
49
|
+
def bbox(self) -> tuple[Vec, Vec] | None:
|
|
50
|
+
points = np.concatenate((self.positions, self.edges))
|
|
51
|
+
if not len(points):
|
|
52
|
+
return None
|
|
53
|
+
return triple(points.min(axis=0)), triple(points.max(axis=0))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Shown:
|
|
58
|
+
"""One object of the scene. `source` is the build123d shape; it never leaves the run process."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
color: Color
|
|
62
|
+
mesh: Mesh
|
|
63
|
+
volume: float | None = None # mm³, for solids
|
|
64
|
+
source: Shape | Mesh | None = None
|
|
65
|
+
matrix: Matrix | None = None # None: the mesh is in world coordinates; else the mesh's placement
|
|
66
|
+
|
|
67
|
+
def world(self) -> Mesh:
|
|
68
|
+
"""The mesh in world coordinates (exports, reports)."""
|
|
69
|
+
return self.mesh if self.matrix is None else moved(self.mesh, self.matrix)
|
|
70
|
+
|
|
71
|
+
def bbox(self) -> tuple[Vec, Vec] | None:
|
|
72
|
+
"""The bounds of the placed mesh (of its points, not a turned box: exact for turned parts)."""
|
|
73
|
+
if self.matrix is None:
|
|
74
|
+
return self.mesh.bbox()
|
|
75
|
+
points = np.concatenate((self.mesh.positions, self.mesh.edges))
|
|
76
|
+
if not len(points):
|
|
77
|
+
return None
|
|
78
|
+
m = np.array(self.matrix).reshape(4, 4)
|
|
79
|
+
placed = points @ m[:3, :3].T + m[:3, 3]
|
|
80
|
+
return triple(placed.min(axis=0)), triple(placed.max(axis=0))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def triple(values: Iterable[float]) -> Vec:
|
|
84
|
+
x, y, z = map(float, values)
|
|
85
|
+
return x, y, z
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_color(color: object, index: int = 0) -> Color:
|
|
89
|
+
"""#RRGGBB, a basic color name, or an RGB triple in 0..1 or 0..255; None picks from the palette."""
|
|
90
|
+
value = PALETTE[index % len(PALETTE)] if color is None else color
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
value = NAMED_COLORS.get(value.lower(), value)
|
|
93
|
+
try:
|
|
94
|
+
if len(value) != 7 or value[0] != "#":
|
|
95
|
+
raise ValueError
|
|
96
|
+
return triple(int(value[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise ValueError(f"Color must be #RRGGBB or a basic color name, not {color!r}") from None
|
|
99
|
+
rgb = np.asarray(value, dtype=float)
|
|
100
|
+
if rgb.shape != (3,) or not np.isfinite(rgb).all() or rgb.min() < 0 or rgb.max() > 255:
|
|
101
|
+
raise ValueError("Color must be three components in 0..1 or 0..255")
|
|
102
|
+
return triple(rgb / 255 if rgb.max() > 1 else rgb)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def hex_color(color: Color) -> str:
|
|
106
|
+
return "#" + "".join(f"{round(c * 255):02X}" for c in color)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def flatten(objs: object) -> list:
|
|
110
|
+
"""Nested lists/tuples of shapes, builders or meshes -> flat list of build123d Shapes and Meshes."""
|
|
111
|
+
import build123d as b
|
|
112
|
+
from build123d.topology import Shape
|
|
113
|
+
|
|
114
|
+
if isinstance(objs, (list, tuple)):
|
|
115
|
+
return [item for child in objs for item in flatten(child)]
|
|
116
|
+
if isinstance(objs, Mesh):
|
|
117
|
+
return [objs]
|
|
118
|
+
for builder, attribute in ((b.BuildPart, "part"), (b.BuildSketch, "sketch"), (b.BuildLine, "line")):
|
|
119
|
+
if isinstance(objs, builder):
|
|
120
|
+
objs = getattr(objs, attribute)
|
|
121
|
+
break
|
|
122
|
+
if not isinstance(objs, Shape):
|
|
123
|
+
raise TypeError(f"Cannot show {type(objs).__name__}: expected build123d shapes, builders or meshes")
|
|
124
|
+
if objs.wrapped is None:
|
|
125
|
+
raise ValueError("Cannot show an empty shape")
|
|
126
|
+
return [objs]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def copy_shape(shape: Shape) -> Shape:
|
|
130
|
+
"""Copy placement and export metadata, sharing the geometry (build123d's copy() deep-copies it)."""
|
|
131
|
+
from OCP.TopLoc import TopLoc_Location # pyright: ignore[reportAttributeAccessIssue]
|
|
132
|
+
from build123d.topology import Shape
|
|
133
|
+
|
|
134
|
+
reference = Shape.cast(shape.wrapped.Moved(TopLoc_Location()))
|
|
135
|
+
reference.label, reference.color = shape.label, shape.color
|
|
136
|
+
if shape.children:
|
|
137
|
+
wrapped = reference.wrapped
|
|
138
|
+
reference.children = [copy_shape(child) for child in shape.children]
|
|
139
|
+
reference.wrapped = wrapped # attaching children rebuilds a compound: keep its original placement
|
|
140
|
+
return reference
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_placed: dict[int, list[tuple[object, Mesh, float | None]]] = {} # this run's, by shape without placement
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def placed(shape) -> tuple[Mesh, Matrix | None, float | None]:
|
|
147
|
+
"""The mesh of a shape in its own coordinates, where the shape is placed and its volume (of its
|
|
148
|
+
solids). Copies moved with Pos/Rot share them: each different shape is computed once per run."""
|
|
149
|
+
if isinstance(shape, Mesh):
|
|
150
|
+
return shape, None, None
|
|
151
|
+
from OCP.TopLoc import TopLoc_Location # pyright: ignore[reportAttributeAccessIssue]
|
|
152
|
+
from build123d.topology import Shape
|
|
153
|
+
|
|
154
|
+
bare = shape.wrapped.Located(TopLoc_Location())
|
|
155
|
+
same = _placed.setdefault(hash(bare), [])
|
|
156
|
+
found = next((entry for entry in same if entry[0].IsEqual(bare)), None) # pyright: ignore[reportAttributeAccessIssue]
|
|
157
|
+
if found is None:
|
|
158
|
+
found = (bare, tessellate(Shape.cast(bare)), float(shape.volume) if shape.solids() else None)
|
|
159
|
+
same.append(found)
|
|
160
|
+
_, mesh, volume = found
|
|
161
|
+
location = shape.wrapped.Location()
|
|
162
|
+
if location.IsIdentity():
|
|
163
|
+
return mesh, None, volume
|
|
164
|
+
trsf = location.Transformation()
|
|
165
|
+
return mesh, tuple(trsf.Value(i, j) if i < 4 else float(j == 4) for i in range(1, 5) for j in range(1, 5)), volume
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def moved(mesh: Mesh, matrix: Matrix) -> Mesh:
|
|
169
|
+
"""The mesh placed by the matrix, in world coordinates."""
|
|
170
|
+
m = np.array(matrix).reshape(4, 4)
|
|
171
|
+
rotation, offset = m[:3, :3].T, m[:3, 3]
|
|
172
|
+
return Mesh(mesh.positions @ rotation + offset, mesh.normals @ rotation, mesh.edges @ rotation + offset)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def tessellate(shape, tolerance: float = 0.05, angular_tolerance: float = 0.2) -> Mesh:
|
|
176
|
+
"""Triangles and edge polylines of a build123d Shape, in world coordinates."""
|
|
177
|
+
from OCP.BRep import BRep_Tool # pyright: ignore[reportAttributeAccessIssue]
|
|
178
|
+
from OCP.BRepAdaptor import BRepAdaptor_Curve # pyright: ignore[reportAttributeAccessIssue]
|
|
179
|
+
from OCP.BRepLib import BRepLib_ToolTriangulatedShape # pyright: ignore[reportAttributeAccessIssue]
|
|
180
|
+
from OCP.GCPnts import GCPnts_QuasiUniformDeflection # pyright: ignore[reportAttributeAccessIssue]
|
|
181
|
+
from OCP.TopAbs import TopAbs_REVERSED # pyright: ignore[reportAttributeAccessIssue]
|
|
182
|
+
from OCP.TopLoc import TopLoc_Location # pyright: ignore[reportAttributeAccessIssue]
|
|
183
|
+
|
|
184
|
+
positions, normals, segments = [], [], []
|
|
185
|
+
faces = shape.faces()
|
|
186
|
+
if faces:
|
|
187
|
+
shape.mesh(tolerance, angular_tolerance)
|
|
188
|
+
for face in faces:
|
|
189
|
+
location = TopLoc_Location()
|
|
190
|
+
poly = BRep_Tool.Triangulation_s(face.wrapped, location)
|
|
191
|
+
if poly is None:
|
|
192
|
+
continue
|
|
193
|
+
transform = location.Transformation()
|
|
194
|
+
reverse = face.wrapped.Orientation() == TopAbs_REVERSED
|
|
195
|
+
BRepLib_ToolTriangulatedShape.ComputeNormals_s(face.wrapped, poly)
|
|
196
|
+
nodes = range(1, poly.NbNodes() + 1)
|
|
197
|
+
vertices = np.array([poly.Node(i).Transformed(transform).Coord() for i in nodes], dtype=np.float32)
|
|
198
|
+
vertex_normals = np.array([poly.Normal(i).Transformed(transform).Coord() for i in nodes], dtype=np.float32)
|
|
199
|
+
triangles = np.array([poly.Triangle(i).Get() for i in range(1, poly.NbTriangles() + 1)], dtype=int) - 1
|
|
200
|
+
if reverse:
|
|
201
|
+
triangles = triangles[:, [0, 2, 1]]
|
|
202
|
+
vertex_normals = -vertex_normals
|
|
203
|
+
positions.append(vertices[triangles].reshape(-1, 3))
|
|
204
|
+
normals.append(vertex_normals[triangles].reshape(-1, 3))
|
|
205
|
+
for edge in shape.edges():
|
|
206
|
+
if edge.length <= 1e-12:
|
|
207
|
+
continue
|
|
208
|
+
samples = GCPnts_QuasiUniformDeflection(BRepAdaptor_Curve(edge.wrapped), tolerance)
|
|
209
|
+
if not samples.IsDone():
|
|
210
|
+
raise RuntimeError("Could not discretize an edge")
|
|
211
|
+
points = [samples.Value(i).Coord() for i in range(1, samples.NbPoints() + 1)]
|
|
212
|
+
for a, c in zip(points, points[1:]):
|
|
213
|
+
segments.extend((a, c))
|
|
214
|
+
return Mesh(np.concatenate(positions) if positions else np.empty((0, 3)),
|
|
215
|
+
np.concatenate(normals) if normals else np.empty((0, 3)),
|
|
216
|
+
np.asarray(segments, dtype=np.float32).reshape(-1, 3))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def import_mesh(path, solid: bool = False, max_faces: int = 5000):
|
|
220
|
+
"""Read an STL or 3MF file.
|
|
221
|
+
|
|
222
|
+
solid=False (default): a fast Mesh you can show() and export, not usable in booleans.
|
|
223
|
+
solid=True: a build123d Solid for booleans; only for small closed meshes (<= max_faces).
|
|
224
|
+
"""
|
|
225
|
+
from .files import read_triangles, write_stl
|
|
226
|
+
|
|
227
|
+
points = read_triangles(path)
|
|
228
|
+
if not len(points):
|
|
229
|
+
raise ValueError(f"{path}: the mesh has no triangles")
|
|
230
|
+
triangles = points.reshape(-1, 3, 3)
|
|
231
|
+
normals = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
|
|
232
|
+
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-20)
|
|
233
|
+
mesh = Mesh(points, np.repeat(normals, 3, axis=0), np.empty((0, 3)))
|
|
234
|
+
if not solid:
|
|
235
|
+
return mesh
|
|
236
|
+
if len(triangles) > max_faces:
|
|
237
|
+
raise ValueError(f"{path} has {len(triangles)} faces (max_faces={max_faces}): booleans on big meshes "
|
|
238
|
+
"are extremely slow. Use solid=False or simplify the mesh.")
|
|
239
|
+
from build123d import Mesher, Solid
|
|
240
|
+
|
|
241
|
+
TEMP.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
with tempfile.TemporaryDirectory(dir=TEMP) as folder:
|
|
243
|
+
stl = Path(folder) / "mesh.stl"
|
|
244
|
+
write_stl([mesh], stl)
|
|
245
|
+
try:
|
|
246
|
+
shapes = Mesher().read(stl)
|
|
247
|
+
except (AssertionError, RuntimeError, ValueError):
|
|
248
|
+
shapes = []
|
|
249
|
+
if len(shapes) != 1 or not isinstance(shapes[0], Solid) or not shapes[0].is_valid:
|
|
250
|
+
raise ValueError(f"{path} is not a single closed valid solid")
|
|
251
|
+
return shapes[0]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def remove_stale_temp(age: float = 600.0) -> None:
|
|
255
|
+
"""Delete temporary folders left behind by runs that were killed (Stop) mid-way."""
|
|
256
|
+
for folder in TEMP.glob("*"):
|
|
257
|
+
try:
|
|
258
|
+
if time.time() - folder.stat().st_mtime > age:
|
|
259
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
pycodecad/camera.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Orbit camera around a target point. Z is up; angles in degrees.
|
|
2
|
+
|
|
3
|
+
Matrices are 16 floats in column-major order, the layout GL uniforms expect.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
from pytypehint import Max, Min, immutable
|
|
12
|
+
|
|
13
|
+
Vec = tuple[float, float, float]
|
|
14
|
+
BBox = tuple[Vec, Vec] # (min corner, max corner)
|
|
15
|
+
|
|
16
|
+
VIEWS = { # name -> (yaw, pitch)
|
|
17
|
+
"iso": (315.0, 35.264), "front": (270.0, 0.0), "back": (90.0, 0.0), "left": (180.0, 0.0),
|
|
18
|
+
"right": (0.0, 0.0), "top": (270.0, 89.0), "bottom": (270.0, -89.0),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@immutable
|
|
23
|
+
class Camera:
|
|
24
|
+
"""Looks at target from distance away; yaw turns around Z (0 looks from +X), pitch tilts up."""
|
|
25
|
+
target: Vec = (0.0, 0.0, 0.0)
|
|
26
|
+
distance: Annotated[float, Min(1e-6), Max(1e12)] = 100.0
|
|
27
|
+
yaw: Annotated[float, Min(0.0), Max(360.0, exclusive=True)] = 315.0
|
|
28
|
+
pitch: Annotated[float, Min(-89.0), Max(89.0)] = 35.264
|
|
29
|
+
fov: Annotated[float, Min(0.0, exclusive=True), Max(180.0, exclusive=True)] = 45.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wrap_yaw(degrees: float) -> float:
|
|
33
|
+
"""Any angle as a yaw in [0, 360)."""
|
|
34
|
+
yaw = float(degrees) % 360.0
|
|
35
|
+
return 0.0 if yaw == 360.0 else yaw # a tiny negative angle rounds up to 360
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clamp(value: float, low: float, high: float) -> float:
|
|
39
|
+
return min(high, max(low, float(value)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def basis(cam: Camera) -> tuple[Vec, Vec, Vec]:
|
|
43
|
+
"""Right, up and back unit vectors."""
|
|
44
|
+
yaw, pitch = math.radians(cam.yaw), math.radians(cam.pitch)
|
|
45
|
+
back = (math.cos(pitch) * math.cos(yaw), math.cos(pitch) * math.sin(yaw), math.sin(pitch))
|
|
46
|
+
right = (-math.sin(yaw), math.cos(yaw), 0.0)
|
|
47
|
+
up = (back[1] * right[2] - back[2] * right[1], back[2] * right[0] - back[0] * right[2],
|
|
48
|
+
back[0] * right[1] - back[1] * right[0])
|
|
49
|
+
return right, up, back
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def orbit(cam: Camera, dx: float, dy: float) -> Camera:
|
|
53
|
+
return replace(cam, yaw=wrap_yaw(cam.yaw - dx * 0.35), pitch=clamp(cam.pitch + dy * 0.35, -89.0, 89.0))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pan(cam: Camera, dx: float, dy: float, height: float) -> Camera:
|
|
57
|
+
"""Move by pixels; height is the shorter viewport side (the axis the fov spans)."""
|
|
58
|
+
right, up, _ = basis(cam)
|
|
59
|
+
scale = 2 * cam.distance * math.tan(math.radians(cam.fov / 2)) / max(height, 1.0)
|
|
60
|
+
target = tuple(float(t - r * dx * scale + u * dy * scale) for t, r, u in zip(cam.target, right, up))
|
|
61
|
+
return replace(cam, target=target)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def zoom(cam: Camera, wheel: float) -> Camera:
|
|
65
|
+
return replace(cam, distance=clamp(cam.distance * math.exp(-wheel * 0.12), 1e-6, 1e12))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_view(view: str, *others: str) -> None:
|
|
69
|
+
"""Raise ValueError unless `view` is one of VIEWS (or of `others`)."""
|
|
70
|
+
if view not in VIEWS and view not in others:
|
|
71
|
+
raise ValueError(f"Unknown view {view!r}: use one of {', '.join([*VIEWS, *others])}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_view(cam: Camera, view: str) -> Camera:
|
|
75
|
+
check_view(view)
|
|
76
|
+
yaw, pitch = VIEWS[view]
|
|
77
|
+
return replace(cam, yaw=yaw, pitch=pitch)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def look_from(cam: Camera, direction: Vec) -> Camera:
|
|
81
|
+
"""Look at the target from a direction (any length): (0, -1, 0) is the front view, (1, -1, 1) iso."""
|
|
82
|
+
x, y, z = direction
|
|
83
|
+
flat = math.hypot(x, y)
|
|
84
|
+
yaw = wrap_yaw(math.degrees(math.atan2(y, x))) if flat > 1e-9 else VIEWS["top"][0] # straight down: front at the bottom
|
|
85
|
+
pitch = clamp(math.degrees(math.atan2(z, flat)), -89.0, 89.0)
|
|
86
|
+
return replace(cam, yaw=yaw, pitch=pitch)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fit(cam: Camera, bbox: BBox) -> Camera:
|
|
90
|
+
"""Frame the bounding sphere of the box with a small margin."""
|
|
91
|
+
lo, hi = bbox
|
|
92
|
+
radius = max(math.dist(lo, hi) / 2, 1e-5)
|
|
93
|
+
target = tuple(float((a + b) / 2) for a, b in zip(lo, hi))
|
|
94
|
+
distance = clamp(radius / math.sin(math.radians(cam.fov / 2)) * 1.15, 1e-6, 1e12)
|
|
95
|
+
return replace(cam, target=target, distance=distance)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def needs_fit(previous: BBox | None, current: BBox | None) -> bool:
|
|
99
|
+
"""Keep the view for small edits; refit after a 2x size change or a large move."""
|
|
100
|
+
if current is None:
|
|
101
|
+
return False
|
|
102
|
+
if previous is None:
|
|
103
|
+
return True
|
|
104
|
+
old_size = max(math.dist(*previous), 1e-5)
|
|
105
|
+
new_size = max(math.dist(*current), 1e-5)
|
|
106
|
+
old_center = [(a + b) / 2 for a, b in zip(*previous)]
|
|
107
|
+
new_center = [(a + b) / 2 for a, b in zip(*current)]
|
|
108
|
+
return not old_size / 2 < new_size < old_size * 2 or math.dist(old_center, new_center) > old_size / 2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def matrices(cam: Camera, aspect: float) -> tuple[tuple[float, ...], tuple[float, ...]]:
|
|
112
|
+
"""(view, projection) matrices; the fov spans the shorter side of the viewport."""
|
|
113
|
+
right, up, back = basis(cam)
|
|
114
|
+
eye = tuple(t + b * cam.distance for t, b in zip(cam.target, back))
|
|
115
|
+
dot = lambda a, b: a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
116
|
+
view = (right[0], up[0], back[0], 0.0,
|
|
117
|
+
right[1], up[1], back[1], 0.0,
|
|
118
|
+
right[2], up[2], back[2], 0.0,
|
|
119
|
+
-dot(right, eye), -dot(up, eye), -dot(back, eye), 1.0)
|
|
120
|
+
near, far = max(cam.distance * 0.001, 1e-8), cam.distance * 100
|
|
121
|
+
aspect = max(aspect, 1e-6)
|
|
122
|
+
f = min(1.0, aspect) / math.tan(math.radians(cam.fov / 2))
|
|
123
|
+
projection = (f / aspect, 0.0, 0.0, 0.0,
|
|
124
|
+
0.0, f, 0.0, 0.0,
|
|
125
|
+
0.0, 0.0, (far + near) / (near - far), -1.0,
|
|
126
|
+
0.0, 0.0, 2 * far * near / (near - far), 0.0)
|
|
127
|
+
return view, projection
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def scene_bbox(boxes: list[BBox | None]) -> BBox | None:
|
|
131
|
+
present = [box for box in boxes if box is not None]
|
|
132
|
+
if not present:
|
|
133
|
+
return None
|
|
134
|
+
low_x, low_y, low_z = zip(*(low for low, _ in present))
|
|
135
|
+
high_x, high_y, high_z = zip(*(high for _, high in present))
|
|
136
|
+
return (min(low_x), min(low_y), min(low_z)), (max(high_x), max(high_y), max(high_z))
|