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/viewcube.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The view cube in the corner of the 3D view: where its faces land on screen for a camera, and which
|
|
2
|
+
view a click on it asks for. Pure geometry; ui.py draws it with ImGui.
|
|
3
|
+
|
|
4
|
+
The cube spans -1..1 on each axis, turned like the scene. Each face is split in 3 x 3 cells: the
|
|
5
|
+
centre looks from that face, a side cell from that edge and a corner cell from that corner (the
|
|
6
|
+
corner at +X -Y +Z is the iso view). A cell's direction is where the camera goes, e.g. (0, -1, 1).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from itertools import product
|
|
11
|
+
from typing import Annotated, Literal
|
|
12
|
+
|
|
13
|
+
from pytypehint import Max, Min, immutable
|
|
14
|
+
|
|
15
|
+
from . import camera as cam
|
|
16
|
+
|
|
17
|
+
Point = tuple[float, float]
|
|
18
|
+
Quad = tuple[Point, Point, Point, Point]
|
|
19
|
+
Step = Annotated[int, Min(-1), Max(1)]
|
|
20
|
+
Direction = tuple[Step, Step, Step]
|
|
21
|
+
Label = Literal["RIGHT", "LEFT", "FRONT", "BACK", "TOP", "BOTTOM"]
|
|
22
|
+
|
|
23
|
+
FACES: tuple[tuple[Direction, Label], ...] = (
|
|
24
|
+
((1, 0, 0), "RIGHT"), ((-1, 0, 0), "LEFT"), ((0, -1, 0), "FRONT"), ((0, 1, 0), "BACK"),
|
|
25
|
+
((0, 0, 1), "TOP"), ((0, 0, -1), "BOTTOM"))
|
|
26
|
+
NORMALS = {label: normal for normal, label in FACES}
|
|
27
|
+
EDGE = 0.62 # the centre cell spans -EDGE..EDGE of a face; beyond are the edge and corner cells
|
|
28
|
+
BOUNDS = ((-1.0, -EDGE, -1), (-EDGE, EDGE, 0), (EDGE, 1.0, 1)) # (from, to, direction) along a face axis
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cell_directions(normal: Direction) -> frozenset[Direction]:
|
|
32
|
+
"""The 9 directions of a face's cells: its normal, plus -1, 0 or 1 along each other axis."""
|
|
33
|
+
return frozenset((normal[0] or x, normal[1] or y, normal[2] or z) for x, y, z in product((-1, 0, 1), repeat=3))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CELL_DIRECTIONS = {label: _cell_directions(normal) for normal, label in FACES}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@immutable
|
|
40
|
+
class Face:
|
|
41
|
+
label: Label
|
|
42
|
+
normal: Direction # the label's own, a unit axis direction
|
|
43
|
+
facing: float # above 0 (seen edge-on) .. 1 (seen straight on)
|
|
44
|
+
light: Annotated[float, Min(0.0), Max(1.0)] # lit from the upper left of the screen, so neighbouring faces differ
|
|
45
|
+
quad: Quad # on screen
|
|
46
|
+
center: Point
|
|
47
|
+
cells: tuple[tuple[Direction, Quad], ...] # (direction, quad on screen): the face's 9 cells
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if self.label not in NORMALS:
|
|
51
|
+
raise ValueError(f"unknown face {self.label!r}")
|
|
52
|
+
if self.normal != NORMALS[self.label]:
|
|
53
|
+
raise ValueError(f"face {self.label}: normal {self.normal} is not {NORMALS[self.label]}")
|
|
54
|
+
if not 0.0 < self.facing <= 1.0:
|
|
55
|
+
raise ValueError(f"face {self.label}: facing {self.facing} not in (0, 1]")
|
|
56
|
+
directions = [direction for direction, _ in self.cells]
|
|
57
|
+
if len(directions) != 9 or set(directions) != CELL_DIRECTIONS[self.label]:
|
|
58
|
+
raise ValueError(f"face {self.label}: cells {directions} are not its 9 directions")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def faces(camera: cam.Camera, center: Point, radius: float) -> list[Face]:
|
|
62
|
+
"""The faces the camera sees, for a cube of half-size radius (pixels) drawn around center."""
|
|
63
|
+
right, up, back = cam.basis(camera)
|
|
64
|
+
lamp = [-0.35 * r + 0.75 * u + 0.56 * b for r, u, b in zip(right, up, back)] # about unit length
|
|
65
|
+
|
|
66
|
+
def screen(point) -> Point:
|
|
67
|
+
return (center[0] + sum(p * r for p, r in zip(point, right)) * radius,
|
|
68
|
+
center[1] - sum(p * u for p, u in zip(point, up)) * radius)
|
|
69
|
+
|
|
70
|
+
result = []
|
|
71
|
+
for normal, label in FACES:
|
|
72
|
+
facing = min(1.0, sum(n * b for n, b in zip(normal, back)))
|
|
73
|
+
if facing <= 1e-6:
|
|
74
|
+
continue
|
|
75
|
+
axis = next(i for i in range(3) if normal[i])
|
|
76
|
+
first, second = [i for i in range(3) if i != axis]
|
|
77
|
+
|
|
78
|
+
def at(u: float, v: float):
|
|
79
|
+
point = [0.0, 0.0, 0.0]
|
|
80
|
+
point[axis], point[first], point[second] = normal[axis], u, v
|
|
81
|
+
return screen(point)
|
|
82
|
+
|
|
83
|
+
cells = []
|
|
84
|
+
for u0, u1, du in BOUNDS:
|
|
85
|
+
for v0, v1, dv in BOUNDS:
|
|
86
|
+
direction = [0, 0, 0]
|
|
87
|
+
direction[axis], direction[first], direction[second] = normal[axis], du, dv
|
|
88
|
+
cells.append((tuple(direction), (at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1))))
|
|
89
|
+
light = max(0.0, min(1.0, sum(n * l for n, l in zip(normal, lamp))))
|
|
90
|
+
result.append(Face(label=label, normal=normal, facing=facing, light=light,
|
|
91
|
+
quad=(at(-1, -1), at(1, -1), at(1, 1), at(-1, 1)), center=at(0, 0), cells=tuple(cells)))
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def inside(point: Point, quad) -> bool:
|
|
96
|
+
"""Whether point is in a convex quad (either winding), borders included."""
|
|
97
|
+
signs = set()
|
|
98
|
+
for (x0, y0), (x1, y1) in zip(quad, quad[1:] + quad[:1]):
|
|
99
|
+
cross = (x1 - x0) * (point[1] - y0) - (y1 - y0) * (point[0] - x0)
|
|
100
|
+
if abs(cross) > 1e-9:
|
|
101
|
+
signs.add(cross > 0)
|
|
102
|
+
return len(signs) <= 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def hit(visible: list[Face], point: Point) -> Direction | None:
|
|
106
|
+
"""The direction of the cell under point (visible faces never overlap), or None."""
|
|
107
|
+
for face in visible:
|
|
108
|
+
for direction, quad in face.cells:
|
|
109
|
+
if inside(point, quad):
|
|
110
|
+
return direction
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def name(direction: Direction) -> str:
|
|
115
|
+
"""'Front', 'Top front', 'Top front right (iso)'..."""
|
|
116
|
+
x, y, z = direction
|
|
117
|
+
words = [word for value, word in ((z, "top" if z > 0 else "bottom"), (y, "back" if y > 0 else "front"),
|
|
118
|
+
(x, "right" if x > 0 else "left")) if value]
|
|
119
|
+
text = " ".join(words).capitalize()
|
|
120
|
+
return text + " (iso)" if direction == (1, -1, 1) else text
|
pycodecad/viewer.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The 3D view as an ImGui component: a camera of its own, the view cube, Fit and the display toggles
|
|
2
|
+
(experimental in 1.0, see docs/embedding.md)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from . import camera as cam, ui, window
|
|
8
|
+
from .cad import Shown
|
|
9
|
+
from .renderer import Display, Renderer, png_bytes
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Viewer:
|
|
13
|
+
"""Draws the objects it is given (e.g. `Workspace.shown`) into the current ImGui window, between
|
|
14
|
+
`Window.frame()` calls. Left drag orbits, right/middle (or Shift+left) drag pans, the wheel
|
|
15
|
+
zooms, a double click fits. It fits the first objects it gets, and again after a large change."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.camera = cam.Camera()
|
|
19
|
+
self.display = Display()
|
|
20
|
+
self.size = (1, 1) # pixels of the last drawn picture
|
|
21
|
+
self.fit_pending = True
|
|
22
|
+
self.objects: list[Shown] = [] # what the renderer holds
|
|
23
|
+
self.renderer: Renderer | None = None # created at the first draw, on the window's GL context
|
|
24
|
+
window.components.add(self)
|
|
25
|
+
|
|
26
|
+
def draw(self, objects: Sequence[Shown], size: tuple[float, float] | None = None) -> None:
|
|
27
|
+
"""Draw the objects at size (ImGui units), by default the available region."""
|
|
28
|
+
if self.renderer is None:
|
|
29
|
+
self.renderer = Renderer(window.active().ctx)
|
|
30
|
+
if len(objects) != len(self.objects) or any(a is not b for a, b in zip(objects, self.objects)):
|
|
31
|
+
before = self.bbox()
|
|
32
|
+
self.objects = list(objects)
|
|
33
|
+
self.renderer.set_meshes([(obj.color, obj.mesh, obj.matrix) for obj in self.objects])
|
|
34
|
+
bbox = self.bbox()
|
|
35
|
+
if bbox and (self.fit_pending or cam.needs_fit(before, bbox)):
|
|
36
|
+
self.fit(bbox)
|
|
37
|
+
ui.view(self, size or ui.region())
|
|
38
|
+
|
|
39
|
+
def bbox(self) -> cam.BBox | None:
|
|
40
|
+
return cam.scene_bbox([obj.bbox() for obj in self.objects])
|
|
41
|
+
|
|
42
|
+
def fit(self, bbox: cam.BBox | None = None) -> None:
|
|
43
|
+
bbox = bbox or self.bbox()
|
|
44
|
+
if bbox:
|
|
45
|
+
self.camera = cam.fit(self.camera, bbox)
|
|
46
|
+
self.fit_pending = False
|
|
47
|
+
|
|
48
|
+
def look_from(self, direction: cam.Vec) -> None:
|
|
49
|
+
"""Turn the camera to look from a direction (a view cube click) and fit the view."""
|
|
50
|
+
self.camera = cam.look_from(self.camera, direction)
|
|
51
|
+
self.fit()
|
|
52
|
+
|
|
53
|
+
def texture(self, width: int, height: int) -> int:
|
|
54
|
+
"""Render at this size (pixels); returns the ImGui texture id (the OpenGL texture name)."""
|
|
55
|
+
assert self.renderer is not None
|
|
56
|
+
self.size = (width, height)
|
|
57
|
+
return self.renderer.draw(self.camera, self.display, width, height).glo
|
|
58
|
+
|
|
59
|
+
def picture(self) -> bytes:
|
|
60
|
+
"""A PNG of the view as last drawn."""
|
|
61
|
+
assert self.renderer is not None
|
|
62
|
+
self.renderer.draw(self.camera, self.display, *self.size)
|
|
63
|
+
return png_bytes(self.renderer.read_image())
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""Free the GL resources (Window.close() does it for every open Viewer)."""
|
|
67
|
+
window.components.discard(self)
|
|
68
|
+
if self.renderer is not None:
|
|
69
|
+
self.renderer.release()
|
|
70
|
+
self.renderer = None
|
pycodecad/window.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""A GLFW window with Dear ImGui drawn by pycodecad's backend: the frame loop that Workspaces and Viewers
|
|
2
|
+
draw in (experimental in 1.0, see docs/embedding.md). One Window per process."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import weakref
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import glfw
|
|
9
|
+
import moderngl
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from . import ui
|
|
13
|
+
from .imgui_backend import ImguiBackend
|
|
14
|
+
from .renderer import png_bytes
|
|
15
|
+
|
|
16
|
+
IDLE, BUSY = 0.25, 0.05 # seconds a frame waits for input (BUSY: while a script runs)
|
|
17
|
+
|
|
18
|
+
current: Window | None = None # the window whose frame is being built
|
|
19
|
+
components: weakref.WeakSet = weakref.WeakSet() # open Workspaces and Viewers: Window.close() closes them
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def active() -> Window:
|
|
23
|
+
if current is None:
|
|
24
|
+
raise RuntimeError("pycodecad components draw between Window.frame() calls")
|
|
25
|
+
return current
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Window:
|
|
29
|
+
"""handle: a GLFW window whose OpenGL 3.3 core context is current. pycodecad installs its ImGui
|
|
30
|
+
backend on it (an ImGui context, fonts, style, the window's input callbacks; close() puts back
|
|
31
|
+
the callbacks it replaced). ctx: the host's moderngl context on it, if it has one (close() never
|
|
32
|
+
releases it); else the Window makes its own. owns: close() also destroys the window and
|
|
33
|
+
terminates GLFW (create_window's windows)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, handle, ctx: moderngl.Context | None = None, owns: bool = False) -> None:
|
|
36
|
+
self.handle, self.owns, self.owns_ctx = handle, owns, ctx is None
|
|
37
|
+
self.ctx = moderngl.create_context() if ctx is None else ctx
|
|
38
|
+
self.gui = ImguiBackend(handle, self.ctx)
|
|
39
|
+
ui.apply_style()
|
|
40
|
+
self.events: list = [] # typed text and key presses of this frame (see ImguiBackend.events)
|
|
41
|
+
self.timeout = 0.0 # how long the next frame() waits for input; the first one does not
|
|
42
|
+
self.close_requested = False # the user asked to close while frame(keep_open=True)
|
|
43
|
+
self.drawing = False # between frame() and the next one
|
|
44
|
+
self.picture: str | None = None
|
|
45
|
+
self.title = ""
|
|
46
|
+
self.closed = False
|
|
47
|
+
|
|
48
|
+
def frame(self, keep_open: bool = False) -> bool:
|
|
49
|
+
"""Show the frame built since the last call, wait for input and begin the next ImGui frame.
|
|
50
|
+
False when the window closes. keep_open (e.g. unsaved changes): a close request only sets
|
|
51
|
+
close_requested, and the frames go on."""
|
|
52
|
+
global current
|
|
53
|
+
if self.drawing:
|
|
54
|
+
self.finish()
|
|
55
|
+
glfw.wait_events_timeout(self.timeout)
|
|
56
|
+
self.timeout = IDLE
|
|
57
|
+
if glfw.window_should_close(self.handle):
|
|
58
|
+
if not keep_open:
|
|
59
|
+
current = None
|
|
60
|
+
return False
|
|
61
|
+
glfw.set_window_should_close(self.handle, False)
|
|
62
|
+
self.close_requested = True
|
|
63
|
+
self.events = self.gui.input_events()
|
|
64
|
+
self.gui.new_frame()
|
|
65
|
+
self.drawing, current = True, self
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def finish(self) -> None:
|
|
69
|
+
width, height = glfw.get_framebuffer_size(self.handle)
|
|
70
|
+
self.ctx.screen.use()
|
|
71
|
+
self.ctx.viewport = (0, 0, width, height)
|
|
72
|
+
self.ctx.clear(*ui.BACKGROUND[:3])
|
|
73
|
+
self.gui.render()
|
|
74
|
+
self.drawing = False
|
|
75
|
+
if self.picture:
|
|
76
|
+
pixels = self.ctx.screen.read(viewport=(0, 0, width, height), components=3, alignment=1)
|
|
77
|
+
image = np.flipud(np.frombuffer(pixels, dtype=np.uint8).reshape(height, width, 3))
|
|
78
|
+
Path(self.picture).write_bytes(png_bytes(image))
|
|
79
|
+
self.picture = None
|
|
80
|
+
glfw.swap_buffers(self.handle)
|
|
81
|
+
|
|
82
|
+
def screenshot(self, path: str) -> None:
|
|
83
|
+
"""Save this frame as a PNG when it is shown (at the next frame())."""
|
|
84
|
+
self.picture = path
|
|
85
|
+
|
|
86
|
+
def set_title(self, title: str) -> None:
|
|
87
|
+
if title != self.title:
|
|
88
|
+
self.title = title
|
|
89
|
+
glfw.set_window_title(self.handle, title)
|
|
90
|
+
|
|
91
|
+
def request_close(self) -> None:
|
|
92
|
+
"""Close as if the user did (frame(keep_open=True) still keeps it open)."""
|
|
93
|
+
glfw.set_window_should_close(self.handle, True)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
"""Close the open Workspaces and Viewers, then pycodecad's ImGui backend (and the window it owns)."""
|
|
97
|
+
global current
|
|
98
|
+
if self.closed:
|
|
99
|
+
return
|
|
100
|
+
self.closed, current = True, None
|
|
101
|
+
for component in list(components):
|
|
102
|
+
component.close()
|
|
103
|
+
self.gui.shutdown()
|
|
104
|
+
if self.owns_ctx:
|
|
105
|
+
self.ctx.release()
|
|
106
|
+
if self.owns:
|
|
107
|
+
glfw.destroy_window(self.handle)
|
|
108
|
+
glfw.terminate()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_window(title: str = "pycodecad", size: tuple[int, int] = (1440, 880), visible: bool = True) -> Window:
|
|
112
|
+
"""A new GLFW window with an OpenGL 3.3 context, ready for ImGui. visible False: a hidden window
|
|
113
|
+
(tests, screenshots)."""
|
|
114
|
+
if not glfw.init():
|
|
115
|
+
raise RuntimeError("Could not initialize GLFW")
|
|
116
|
+
glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
|
|
117
|
+
glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
|
|
118
|
+
glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
|
|
119
|
+
glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True)
|
|
120
|
+
glfw.window_hint(glfw.VISIBLE, visible)
|
|
121
|
+
handle = glfw.create_window(*size, title, None, None)
|
|
122
|
+
if not handle:
|
|
123
|
+
glfw.terminate()
|
|
124
|
+
raise RuntimeError("Could not create an OpenGL 3.3 window")
|
|
125
|
+
glfw.make_context_current(handle)
|
|
126
|
+
glfw.swap_interval(1 if visible else 0) # a hidden window never gets vsync frames
|
|
127
|
+
window = Window(handle, owns=True)
|
|
128
|
+
window.title = title
|
|
129
|
+
return window
|