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/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