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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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))