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/workspace.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""A Workspace: the .py files of one folder, with the code, parameters, runs and 3D view of its
|
|
2
|
+
main file, drawn as an ImGui component (the whole pycodecad window is one; experimental in 1.0 for
|
|
3
|
+
other apps, see docs/embedding.md).
|
|
4
|
+
|
|
5
|
+
One file is edited at a time; Run always runs the main file (the part), which may import the
|
|
6
|
+
others (helper modules), with the editor's text for the one being edited. Nothing happens by
|
|
7
|
+
itself: a file is written only on Save and, after the first run at opening, the script runs only
|
|
8
|
+
on Run. When someone else changes the edited file (another editor, an AI assistant) the workspace
|
|
9
|
+
says so and offers Reload. Scripts run in child processes (see runner.py).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import cast
|
|
19
|
+
|
|
20
|
+
import glfw
|
|
21
|
+
|
|
22
|
+
from .api import FPS
|
|
23
|
+
from . import runner, sidecar, textedit, ui, window
|
|
24
|
+
from .cad import Shown
|
|
25
|
+
from .params import LIMIT, Exposed, Param
|
|
26
|
+
from .viewer import Viewer
|
|
27
|
+
|
|
28
|
+
WATCH_EVERY = 0.5 # seconds between checks of the file on disk
|
|
29
|
+
NEW_SCRIPT = """from build123d import *
|
|
30
|
+
from pycodecad import show
|
|
31
|
+
|
|
32
|
+
with BuildPart() as part:
|
|
33
|
+
Box(40, 30, 10)
|
|
34
|
+
fillet(part.edges().filter_by(Axis.Z), radius=4)
|
|
35
|
+
Hole(radius=5)
|
|
36
|
+
|
|
37
|
+
show(part, name="part")
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ZOOMS = (0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) # code zoom steps, like browsers
|
|
42
|
+
preloading = threading.Thread(target=runner.preload, daemon=True) # build123d takes a second or two to import
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def python_files(folder: Path) -> list[Path]:
|
|
46
|
+
"""The .py files of a folder (not its subfolders), by name, without hidden ones: names that start
|
|
47
|
+
with "." or "_" (tools, scratch, private helpers) stay out of the list."""
|
|
48
|
+
try:
|
|
49
|
+
return sorted(path for path in folder.iterdir()
|
|
50
|
+
if path.suffix == ".py" and not path.name.startswith((".", "_")) and path.is_file())
|
|
51
|
+
except OSError:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def input_stamp(folder: Path, skip: set[str]) -> dict[str, tuple[int, int]]:
|
|
56
|
+
"""Modification time and size of every file a run may read: the folder and its subfolders but
|
|
57
|
+
hidden ones, __pycache__, virtual environments and the paths in skip. Export runs the code
|
|
58
|
+
again, so it needs these files as they were at Run."""
|
|
59
|
+
stamp = {}
|
|
60
|
+
for root, dirs, names in os.walk(folder):
|
|
61
|
+
dirs[:] = [name for name in dirs if not name.startswith(".") and name != "__pycache__"
|
|
62
|
+
and not os.path.exists(os.path.join(root, name, "pyvenv.cfg"))]
|
|
63
|
+
for name in names:
|
|
64
|
+
path = str(Path(root) / name)
|
|
65
|
+
if name.startswith(".") or path in skip:
|
|
66
|
+
continue
|
|
67
|
+
if (signature := file_signature(path)) is not None:
|
|
68
|
+
stamp[path] = signature
|
|
69
|
+
return stamp
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def file_signature(path: str) -> tuple[int, int] | None:
|
|
73
|
+
"""Modification time and size of a file, None when it cannot be read."""
|
|
74
|
+
try:
|
|
75
|
+
info = os.stat(path)
|
|
76
|
+
except OSError:
|
|
77
|
+
return None
|
|
78
|
+
return info.st_mtime_ns, info.st_size
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_main(folder: Path) -> Path:
|
|
82
|
+
"""The part of a folder: the first .py file that calls show(), else the first one, else part.py."""
|
|
83
|
+
files = python_files(folder)
|
|
84
|
+
for path in files:
|
|
85
|
+
try:
|
|
86
|
+
if "show(" in sidecar.read_script(path):
|
|
87
|
+
return path
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
return files[0] if files else folder / "part.py"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
Job = tuple[Path, str, dict[str, str]] # a run: the main file, its code, {helper file: unsaved text}
|
|
94
|
+
|
|
95
|
+
class Workspace:
|
|
96
|
+
"""path: the main file, or a folder (its main is find_main's). A missing main is created
|
|
97
|
+
from an example (not when read_only). read_only (--read-only): an order form for scripts that
|
|
98
|
+
must never change: the code is not shown and no script is written; choose() picks the file to
|
|
99
|
+
run, and Run always runs it as it is on disk. run: run the main once, as soon as build123d is loaded. Opening raises OSError (with a
|
|
100
|
+
readable message) when the file cannot be used: a missing folder, a file that is not UTF-8.
|
|
101
|
+
|
|
102
|
+
draw() shows it in the current ImGui region between Window.frame() calls (and updates it:
|
|
103
|
+
finished runs, the files on disk). A Workspace that is not drawn: call update() every frame."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, path: str | Path, read_only: bool = False, run: bool = True):
|
|
106
|
+
path = Path(path).resolve()
|
|
107
|
+
self.main = find_main(path) if path.is_dir() else path # Run runs it; see set_main()
|
|
108
|
+
if not self.main.exists() and not read_only:
|
|
109
|
+
self.main.write_text(NEW_SCRIPT, encoding="utf-8")
|
|
110
|
+
self.read_only = read_only
|
|
111
|
+
self.show_files = True # draw the list of the folder's files
|
|
112
|
+
self.load(self.main) # the edited file: path, editor...
|
|
113
|
+
self.file_list = python_files(self.folder)
|
|
114
|
+
self.opening: tuple[Path, int | None] | None = None # asked to open while the editor had unsaved changes
|
|
115
|
+
self.save_as_open = False # the Save as dialog is showing
|
|
116
|
+
self.save_as_name = ""
|
|
117
|
+
self.next_watch = 0.0
|
|
118
|
+
|
|
119
|
+
self.shown: list[Shown] = [] # objects on screen (from the last successful run)
|
|
120
|
+
self.frames: list[list[Shown]] = [] # the animation of the last successful run (frame() calls)
|
|
121
|
+
self.playing = True
|
|
122
|
+
self.clock = 0.0 # seconds into the animation
|
|
123
|
+
self.ticked = time.monotonic()
|
|
124
|
+
self.child: runner.Run | None = None
|
|
125
|
+
self.run_started = 0.0
|
|
126
|
+
self.run_requested = run # started by update() once build123d is loaded
|
|
127
|
+
self.error: str | None = None
|
|
128
|
+
self.error_file: str | None = None # where the error happened: the script or a helper module
|
|
129
|
+
self.error_line: int | None = None
|
|
130
|
+
self.warnings: list[str] = []
|
|
131
|
+
self.stdout = ""
|
|
132
|
+
self.duration: float | None = None
|
|
133
|
+
# Parameters of exposed functions: what the last successful run used, and the values changed in the
|
|
134
|
+
# panel ("function.param" -> value). They are only in memory and reach the script on Run.
|
|
135
|
+
self.parameters: list[Exposed] = []
|
|
136
|
+
self.values: dict[str, object] = {}
|
|
137
|
+
self.child_job: Job | None = None # what the running child runs (see run_job)
|
|
138
|
+
self.child_values: dict[str, object] = {} # the parameter values sent to it
|
|
139
|
+
# What is on screen: the main, code, sources and parameter values of the last good run; Export
|
|
140
|
+
# exports exactly this, even after the editor, the values or the main changed.
|
|
141
|
+
self.shown_job: tuple[Job, dict[str, object], dict[str, tuple[int, int]]] | None = None
|
|
142
|
+
self.child_stamp: dict[str, tuple[int, int]] = {} # input_stamp at the start of the child
|
|
143
|
+
self.written: dict[str, tuple[int, int]] = {} # file_signature of the exports and pictures written
|
|
144
|
+
self.exports: list[runner.Run] = []
|
|
145
|
+
self.stopped: list[runner.Run] = [] # killed runs still writing their final last-run file
|
|
146
|
+
self.message = "" # last status message
|
|
147
|
+
self.message_is_error = False
|
|
148
|
+
|
|
149
|
+
self.viewer = Viewer()
|
|
150
|
+
self.camera_saved: tuple | None = None
|
|
151
|
+
self.camera_changed_at = 0.0
|
|
152
|
+
self.split = 0.38 # fraction of the width used by the code
|
|
153
|
+
self.code_zoom = 1.0 # size of the code text (Ctrl + / Ctrl - / Ctrl 0), one of ZOOMS
|
|
154
|
+
self.closed = False
|
|
155
|
+
if not preloading.ident:
|
|
156
|
+
preloading.start()
|
|
157
|
+
window.components.add(self)
|
|
158
|
+
|
|
159
|
+
# --- in the frame loop ------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def draw(self) -> None:
|
|
162
|
+
self.update()
|
|
163
|
+
ui.workspace(self, window.active().events)
|
|
164
|
+
|
|
165
|
+
def close_prompt(self, host: window.Window) -> None:
|
|
166
|
+
"""Draw the Save / Discard / Cancel prompt when the user closed the window with unsaved
|
|
167
|
+
changes (host.frame(keep_open=self.dirty()) keeps it open meanwhile); Save or Discard closes it."""
|
|
168
|
+
ui.close_prompt(self, host)
|
|
169
|
+
|
|
170
|
+
def frame_index(self) -> int:
|
|
171
|
+
return int(self.clock * FPS) % len(self.frames) if self.frames else 0
|
|
172
|
+
|
|
173
|
+
def on_screen(self) -> list[Shown]:
|
|
174
|
+
"""What the view shows: the current frame of an animation, else the scene."""
|
|
175
|
+
return self.frames[self.frame_index()] if self.frames else self.shown
|
|
176
|
+
|
|
177
|
+
def seek(self, index: int) -> None:
|
|
178
|
+
"""Show this frame, paused."""
|
|
179
|
+
self.clock, self.playing = index / FPS, False
|
|
180
|
+
|
|
181
|
+
def update(self) -> None:
|
|
182
|
+
"""Start a requested run, collect finished ones, watch the file, remember the camera."""
|
|
183
|
+
now = time.monotonic()
|
|
184
|
+
if self.frames and self.playing:
|
|
185
|
+
self.clock += now - self.ticked
|
|
186
|
+
if window.current is not None:
|
|
187
|
+
window.current.timeout = 0.0
|
|
188
|
+
self.ticked = now
|
|
189
|
+
if self.run_requested and not preloading.is_alive():
|
|
190
|
+
self.run_requested = False
|
|
191
|
+
self.start_run()
|
|
192
|
+
self.check_runs()
|
|
193
|
+
now = time.monotonic()
|
|
194
|
+
if now >= self.next_watch:
|
|
195
|
+
self.next_watch = now + WATCH_EVERY
|
|
196
|
+
self.watch_file()
|
|
197
|
+
self.save_camera(now)
|
|
198
|
+
if window.current is not None and (self.child or self.exports or self.run_requested):
|
|
199
|
+
window.current.timeout = min(window.current.timeout, window.BUSY)
|
|
200
|
+
|
|
201
|
+
def close(self) -> None:
|
|
202
|
+
"""Kill the runs (waiting up to 2 s for them to record it) and free the view (Window.close()
|
|
203
|
+
does it for every open Workspace)."""
|
|
204
|
+
self.closed = True
|
|
205
|
+
window.components.discard(self)
|
|
206
|
+
runs = [run for run in [self.child, *self.exports, *self.stopped] if run is not None]
|
|
207
|
+
for run in runs:
|
|
208
|
+
run.kill()
|
|
209
|
+
deadline = time.monotonic() + 2.0 # let them record "Stopped", but never hang the exit
|
|
210
|
+
for run in runs:
|
|
211
|
+
run.finished.wait(max(0.0, deadline - time.monotonic()))
|
|
212
|
+
self.viewer.close()
|
|
213
|
+
|
|
214
|
+
def wake(self) -> None:
|
|
215
|
+
"""Called from runner threads: make the loop look at finished runs now."""
|
|
216
|
+
if not self.closed and window.current is not None:
|
|
217
|
+
glfw.post_empty_event()
|
|
218
|
+
|
|
219
|
+
def title(self) -> str:
|
|
220
|
+
runs = f" (runs {self.main.name})" if self.path != self.main else ""
|
|
221
|
+
return f"pycodecad - {self.path.name}{' *' if self.dirty() else ''}{runs}"
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def folder(self) -> Path:
|
|
225
|
+
return self.main.parent
|
|
226
|
+
|
|
227
|
+
def dirty(self) -> bool:
|
|
228
|
+
"""The editor has changes that are not saved to the file."""
|
|
229
|
+
return self.editor.text != self.saved_text
|
|
230
|
+
|
|
231
|
+
def say(self, message: str, error: bool = False) -> None:
|
|
232
|
+
"""Show a message in the status line (errors also in the open dialog)."""
|
|
233
|
+
self.message, self.message_is_error = message, error
|
|
234
|
+
|
|
235
|
+
# --- running the script --------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def running(self) -> bool:
|
|
238
|
+
"""A run is going or about to start."""
|
|
239
|
+
return self.child is not None or self.run_requested
|
|
240
|
+
|
|
241
|
+
def ran(self) -> bool:
|
|
242
|
+
"""A run has finished (or was stopped) since the window opened."""
|
|
243
|
+
return self.duration is not None or self.error is not None
|
|
244
|
+
|
|
245
|
+
def zoom_code(self, step: int) -> None:
|
|
246
|
+
"""One zoom step in (+1) or out (-1), or back to 100% (0)."""
|
|
247
|
+
index = ZOOMS.index(self.code_zoom) + step if step else ZOOMS.index(1.0)
|
|
248
|
+
self.code_zoom = ZOOMS[max(0, min(len(ZOOMS) - 1, index))]
|
|
249
|
+
|
|
250
|
+
def run(self) -> None:
|
|
251
|
+
"""Run the main file with the current values (at the next update), with the editor's text
|
|
252
|
+
for the edited file (saved or not)."""
|
|
253
|
+
self.run_requested = True
|
|
254
|
+
|
|
255
|
+
def run_job(self) -> Job:
|
|
256
|
+
"""What Run runs: the main, its code, and {file: text} of the edited file when it is another
|
|
257
|
+
one (a helper module the main may import). Raises OSError when the main cannot be read."""
|
|
258
|
+
if self.read_only: # nothing is edited: the file as it is now
|
|
259
|
+
return self.main, sidecar.read_script(self.main), {}
|
|
260
|
+
if self.path == self.main:
|
|
261
|
+
return self.main, self.editor.text, {}
|
|
262
|
+
return self.main, sidecar.read_script(self.main), {str(self.path): self.editor.text}
|
|
263
|
+
|
|
264
|
+
def start_run(self) -> None:
|
|
265
|
+
if self.child is not None:
|
|
266
|
+
self.child.kill() # a newer version of the code replaces the running one
|
|
267
|
+
self.stopped.append(self.child)
|
|
268
|
+
self.child = None
|
|
269
|
+
try:
|
|
270
|
+
self.child_job = self.run_job()
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
self.error, self.error_file, self.error_line = f"Could not read {sidecar.error_text(exc)}", None, None
|
|
273
|
+
return
|
|
274
|
+
main, code, sources = self.child_job
|
|
275
|
+
self.child_stamp = self.input_stamp(self.child_job)
|
|
276
|
+
self.child_values = dict(self.values)
|
|
277
|
+
self.child = runner.Run(code, str(main), on_done=self.wake, values=self.child_values, strict=False,
|
|
278
|
+
sources=sources)
|
|
279
|
+
self.run_started = time.monotonic()
|
|
280
|
+
|
|
281
|
+
def stop(self) -> None:
|
|
282
|
+
if self.child is not None:
|
|
283
|
+
self.child.kill()
|
|
284
|
+
self.stopped.append(self.child)
|
|
285
|
+
self.child = None
|
|
286
|
+
self.error, self.error_file, self.error_line = "Stopped", None, None
|
|
287
|
+
self.say("Stopped")
|
|
288
|
+
|
|
289
|
+
def check_runs(self) -> None:
|
|
290
|
+
self.stopped = [run for run in self.stopped if not run.done()]
|
|
291
|
+
if self.child is not None and self.child.done():
|
|
292
|
+
result, self.child = self.child.wait(), None # finished: wait() returns at once
|
|
293
|
+
if self.child_job is None or self.child_job[0] != self.main:
|
|
294
|
+
return # a run of the previous main (set_main while it ran): not this main's result
|
|
295
|
+
self.stdout, self.duration = result.stdout, result.duration
|
|
296
|
+
self.error, self.error_file, self.error_line = result.error, result.error_file, result.error_line
|
|
297
|
+
self.warnings = result.warnings
|
|
298
|
+
if result.error is None: # on errors the last good objects (and parameters) stay on screen
|
|
299
|
+
used = {f"{e.function}.{p.name}": p.value for e in result.parameters for p in e.params}
|
|
300
|
+
self.shown_job = (self.child_job, used, self.child_stamp)
|
|
301
|
+
self.set_parameters(result.parameters, used, self.child_values)
|
|
302
|
+
self.shown = result.shown
|
|
303
|
+
self.frames, self.clock, self.playing = result.frames, 0.0, True
|
|
304
|
+
self.say(result.warnings[0] if result.warnings else "")
|
|
305
|
+
for export in [run for run in self.exports if run.done()]:
|
|
306
|
+
self.exports.remove(export)
|
|
307
|
+
result = export.wait()
|
|
308
|
+
error = result.error or (None if result.exported else "Nothing shown: call show() in the script")
|
|
309
|
+
if result.exported and (signature := file_signature(result.exported)) is not None:
|
|
310
|
+
self.written[result.exported] = signature
|
|
311
|
+
self.say(error.splitlines()[-1] if error else f"Exported {result.exported}", error=bool(error))
|
|
312
|
+
|
|
313
|
+
def export(self, extension: str, profile: str = "generic", target: str | Path | None = None) -> None:
|
|
314
|
+
"""Export what is on screen (in a run of its own; the status line says when it is written).
|
|
315
|
+
target: the file (relative to the folder), by default the main's name with extension."""
|
|
316
|
+
if self.shown_job is None:
|
|
317
|
+
self.say("Nothing to export yet: Run the main file first", error=True)
|
|
318
|
+
return
|
|
319
|
+
(main, code, sources), used, stamp = self.shown_job
|
|
320
|
+
target = self.folder / target if target else main.with_suffix(f".{extension}")
|
|
321
|
+
if changed := self.changed_inputs(stamp):
|
|
322
|
+
self.say(f"{Path(changed[0]).name} changed since the last run: Run again, then export", error=True)
|
|
323
|
+
return
|
|
324
|
+
self.exports.append(runner.Run(code, str(main), export=str(target), profile=profile,
|
|
325
|
+
on_done=self.wake, values=used, strict=False, sources=sources))
|
|
326
|
+
self.say(f"Exporting {target.name}...")
|
|
327
|
+
|
|
328
|
+
# --- parameters of exposed functions -------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def set_parameters(self, parameters: list[Exposed], ran: dict[str, object], sent: dict[str, object]) -> None:
|
|
331
|
+
"""The parameters a successful run exposed and the values it used (`ran`), for the values `sent`. A changed value is
|
|
332
|
+
dropped when its parameter is gone or of another kind, or when the run did not take it (e.g.
|
|
333
|
+
beyond a new Max): the control then shows what the run used. Never after a failed run: it
|
|
334
|
+
may have stopped before an expose(), e.g. a syntax error while editing."""
|
|
335
|
+
self.parameters = parameters
|
|
336
|
+
|
|
337
|
+
def kept(key: str, value: object) -> bool:
|
|
338
|
+
if key not in ran or type(ran[key]) is not type(value):
|
|
339
|
+
return False
|
|
340
|
+
return value == ran[key] or key not in sent or sent[key] != value # changed during the run
|
|
341
|
+
|
|
342
|
+
self.values = {key: value for key, value in self.values.items() if kept(key, value)}
|
|
343
|
+
|
|
344
|
+
def value(self, function: str, param: Param) -> object:
|
|
345
|
+
"""What the control shows: the changed value, else the default."""
|
|
346
|
+
return self.values.get(f"{function}.{param.name}", param.default)
|
|
347
|
+
|
|
348
|
+
def set_value(self, function: str, param: Param, value: int | float | bool | str) -> None:
|
|
349
|
+
"""Change a value (kept within the parameter's limits); the script sees it on the next Run."""
|
|
350
|
+
if param.kind == "str":
|
|
351
|
+
value = cast(str, value) # the text control gives a str
|
|
352
|
+
value = value[:int(param.max)] if param.max is not None else value
|
|
353
|
+
elif param.kind != "bool":
|
|
354
|
+
kind = float if param.kind == "float" else int # the type of param.default
|
|
355
|
+
number = kind(value)
|
|
356
|
+
if param.min is not None:
|
|
357
|
+
number = max(number, kind(param.min))
|
|
358
|
+
if param.max is not None:
|
|
359
|
+
number = min(number, kind(param.max))
|
|
360
|
+
value = min(max(number, kind(-LIMIT)), kind(LIMIT))
|
|
361
|
+
self.values[f"{function}.{param.name}"] = value
|
|
362
|
+
|
|
363
|
+
def reset(self, function: str) -> None:
|
|
364
|
+
"""Back to the defaults for one exposed function (on the next Run)."""
|
|
365
|
+
self.values = {key: value for key, value in self.values.items() if not key.startswith(function + ".")}
|
|
366
|
+
|
|
367
|
+
def values_changed(self) -> bool:
|
|
368
|
+
"""The panel differs from the values the last run used: Run to apply them."""
|
|
369
|
+
return any(self.value(e.function, p) != p.value for e in self.parameters for p in e.params)
|
|
370
|
+
|
|
371
|
+
def save_picture(self) -> None:
|
|
372
|
+
target = self.main.with_suffix(".png")
|
|
373
|
+
try:
|
|
374
|
+
target.write_bytes(self.viewer.picture())
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
self.say(f"Could not save the picture: {sidecar.error_text(exc)}", error=True)
|
|
377
|
+
return
|
|
378
|
+
if (signature := file_signature(str(target))) is not None:
|
|
379
|
+
self.written[str(target)] = signature
|
|
380
|
+
self.say(f"Saved {target}")
|
|
381
|
+
|
|
382
|
+
# --- the files of the folder ---------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
def files(self) -> list[Path]:
|
|
385
|
+
"""The .py files of the folder, by name (refreshed with the disk watch), with the edited
|
|
386
|
+
and the main file even when they are not (yet) on disk."""
|
|
387
|
+
return sorted({*self.file_list, self.path, self.main}, key=lambda path: path.name)
|
|
388
|
+
|
|
389
|
+
def load(self, path: Path) -> None:
|
|
390
|
+
"""Edit this file (raises OSError when it cannot be read)."""
|
|
391
|
+
mtime = self._mtime(path) # before reading: a change in between is noticed later
|
|
392
|
+
text = sidecar.read_script(path)
|
|
393
|
+
self.path, self.editor = path, textedit.load(text)
|
|
394
|
+
self.saved_text = text # what the file holds, as far as we know
|
|
395
|
+
self.file_mtime = mtime
|
|
396
|
+
self.disk_changed = False # someone else changed the file: offer Reload
|
|
397
|
+
|
|
398
|
+
def open(self, path: str | Path, line: int | None = None) -> bool:
|
|
399
|
+
"""Edit another file (relative to the folder); Run still runs the main. line: put the
|
|
400
|
+
cursor there (1 is the first line). Refused (False, with a message) while the editor has
|
|
401
|
+
unsaved changes (save() or discard() first) or when the file cannot be read."""
|
|
402
|
+
path = (self.folder / path).resolve()
|
|
403
|
+
if path != self.path:
|
|
404
|
+
if self.dirty():
|
|
405
|
+
self.say(f"Unsaved changes in {self.path.name}: save or discard them first", error=True)
|
|
406
|
+
return False
|
|
407
|
+
try:
|
|
408
|
+
self.load(path)
|
|
409
|
+
except OSError as exc:
|
|
410
|
+
self.say(f"Could not open {sidecar.error_text(exc)}", error=True)
|
|
411
|
+
return False
|
|
412
|
+
self.say("")
|
|
413
|
+
if line is not None:
|
|
414
|
+
at = textedit.offset(self.editor.text, line - 1, 0)
|
|
415
|
+
self.editor = replace(self.editor, cursor=at, anchor=at, reveal=True)
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
def set_main(self, path: str | Path) -> None:
|
|
419
|
+
"""Run runs this file from now on (relative to the folder). The parameters and values of
|
|
420
|
+
the previous main are dropped; what is on screen (and what Export writes) stays until the
|
|
421
|
+
next Run."""
|
|
422
|
+
path = (self.folder / path).resolve()
|
|
423
|
+
if path != self.main:
|
|
424
|
+
self.main, self.parameters, self.values = path, [], {}
|
|
425
|
+
self.camera_saved = None # saved for the new main too
|
|
426
|
+
|
|
427
|
+
def choose(self, path: str | Path) -> None:
|
|
428
|
+
"""Read only: run this file of the folder (relative to it) from now on, now."""
|
|
429
|
+
if self.open(path):
|
|
430
|
+
self.set_main(path)
|
|
431
|
+
self.run()
|
|
432
|
+
|
|
433
|
+
def discard(self) -> None:
|
|
434
|
+
"""Drop the editor's unsaved changes (Undo brings them back)."""
|
|
435
|
+
self.editor = textedit.replace_all(self.editor, self.saved_text)
|
|
436
|
+
|
|
437
|
+
# --- the edited file on disk ---------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def input_stamp(self, job: Job) -> dict[str, tuple[int, int]]:
|
|
440
|
+
"""input_stamp of the folder of job's main, without the main and sources (their text is in
|
|
441
|
+
the job)."""
|
|
442
|
+
main, _, sources = job
|
|
443
|
+
return input_stamp(main.parent, {str(main), *sources})
|
|
444
|
+
|
|
445
|
+
def changed_inputs(self, stamp: dict[str, tuple[int, int]]) -> list[str]:
|
|
446
|
+
"""The files that differ from the stamp of the run on screen. A file that did not exist at
|
|
447
|
+
that Run and is still as this window wrote it (an export, a picture) is an output, not an
|
|
448
|
+
input; any other change counts, also to a file this window wrote (it may be read)."""
|
|
449
|
+
assert self.shown_job is not None
|
|
450
|
+
now = self.input_stamp(self.shown_job[0])
|
|
451
|
+
return sorted(path for path in now.keys() | stamp.keys() if now.get(path) != stamp.get(path)
|
|
452
|
+
and not (path not in stamp and self.written.get(path) == now.get(path)))
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _mtime(path: Path) -> int | None:
|
|
456
|
+
try:
|
|
457
|
+
return path.stat().st_mtime_ns
|
|
458
|
+
except OSError:
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def save(self) -> bool:
|
|
462
|
+
"""Write the editor to the file. On failure the edits stay (unsaved) and the status says why."""
|
|
463
|
+
if self.read_only:
|
|
464
|
+
self.say("Read only: scripts are never written", error=True)
|
|
465
|
+
return False
|
|
466
|
+
return self._write(self.path)
|
|
467
|
+
|
|
468
|
+
def save_as(self, target: str) -> bool:
|
|
469
|
+
"""Write the code to a new file and continue working on it (in normal mode). A copy of the
|
|
470
|
+
main becomes the main."""
|
|
471
|
+
if self.read_only:
|
|
472
|
+
self.say("Read only: scripts are never written", error=True)
|
|
473
|
+
return False
|
|
474
|
+
new = (self.path.parent / target).resolve()
|
|
475
|
+
if new.exists():
|
|
476
|
+
self.say(f"{new.name} already exists: choose another name", error=True)
|
|
477
|
+
return False
|
|
478
|
+
if not self._write(new):
|
|
479
|
+
return False
|
|
480
|
+
if self.path == self.main:
|
|
481
|
+
self.main = new
|
|
482
|
+
self.path = new
|
|
483
|
+
self.file_list = python_files(self.folder)
|
|
484
|
+
self.say(f"Saved as {new}")
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
def _write(self, path: Path) -> bool:
|
|
488
|
+
text = self.editor.text
|
|
489
|
+
try:
|
|
490
|
+
mtime = sidecar.write_atomic(path, text)
|
|
491
|
+
except OSError as exc:
|
|
492
|
+
self.say(f"Could not save: {sidecar.error_text(exc)}", error=True)
|
|
493
|
+
return False
|
|
494
|
+
# The baseline is what was written: a change made right after it is still noticed.
|
|
495
|
+
self.saved_text, self.file_mtime, self.disk_changed = text, mtime, False
|
|
496
|
+
self.say(f"Saved {path.name}")
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
def watch_file(self) -> None:
|
|
500
|
+
"""Notice (never apply) changes made to the edited file by someone else, and files that
|
|
501
|
+
appear in or disappear from the folder."""
|
|
502
|
+
self.file_list = python_files(self.folder)
|
|
503
|
+
mtime = self._mtime(self.path)
|
|
504
|
+
if mtime is None or mtime == self.file_mtime:
|
|
505
|
+
return
|
|
506
|
+
self.file_mtime = mtime
|
|
507
|
+
try:
|
|
508
|
+
text = sidecar.read_script(self.path)
|
|
509
|
+
except OSError:
|
|
510
|
+
return
|
|
511
|
+
if text == self.editor.text:
|
|
512
|
+
self.saved_text, self.disk_changed = text, False
|
|
513
|
+
elif text != self.saved_text:
|
|
514
|
+
self.disk_changed = True
|
|
515
|
+
|
|
516
|
+
def reload(self) -> None:
|
|
517
|
+
"""Replace the editor text with the file (the editor's unsaved changes are lost)."""
|
|
518
|
+
mtime = self._mtime(self.path)
|
|
519
|
+
try:
|
|
520
|
+
text = sidecar.read_script(self.path)
|
|
521
|
+
except OSError as exc:
|
|
522
|
+
self.say(f"Could not reload: {sidecar.error_text(exc)}", error=True)
|
|
523
|
+
return
|
|
524
|
+
self.editor = textedit.replace_all(self.editor, text)
|
|
525
|
+
self.saved_text, self.file_mtime, self.disk_changed = text, mtime, False
|
|
526
|
+
self.say(f"Reloaded {self.path.name}")
|
|
527
|
+
|
|
528
|
+
# --- camera ----------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def save_camera(self, now: float) -> None:
|
|
531
|
+
"""Write camera + view size next to the script, one second after they stop changing."""
|
|
532
|
+
if self.viewer.fit_pending:
|
|
533
|
+
return
|
|
534
|
+
current = (self.viewer.camera, self.viewer.size)
|
|
535
|
+
if current == self.camera_saved:
|
|
536
|
+
return
|
|
537
|
+
if self.camera_changed_at == 0.0:
|
|
538
|
+
self.camera_changed_at = now
|
|
539
|
+
elif now - self.camera_changed_at > 1.0:
|
|
540
|
+
self.camera_changed_at = 0.0
|
|
541
|
+
self.camera_saved = current
|
|
542
|
+
try:
|
|
543
|
+
sidecar.save_camera(self.main, self.viewer.camera, self.viewer.size)
|
|
544
|
+
except OSError:
|
|
545
|
+
pass # a read-only folder: `render --views window` will say there is no camera
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pycodecad
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Code-CAD with build123d: a desktop window that shows and exports what your Python script builds.
|
|
5
|
+
Project-URL: Repository, https://github.com/offerrall/pycodecad
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: 3d,3d-printing,build123d,cad,parametric
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: build123d<0.14,>=0.13
|
|
17
|
+
Requires-Dist: glfw<3,>=2.7
|
|
18
|
+
Requires-Dist: moderngl<6,>=5.10
|
|
19
|
+
Requires-Dist: numpy<3,>=1.26
|
|
20
|
+
Requires-Dist: pytypehint==1.2.0
|
|
21
|
+
Requires-Dist: slimgui<0.9,>=0.8.3
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pycodecad
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/pycodecad/)
|
|
27
|
+
[](https://pypi.org/project/pycodecad/)
|
|
28
|
+
[](docs/design.md#platforms)
|
|
29
|
+
[](LICENSE)
|
|
30
|
+
|
|
31
|
+
Code-CAD with [build123d](https://github.com/gumyr/build123d): write a Python script, see the part,
|
|
32
|
+
export it for 3D printing.
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from build123d import Box, Cylinder
|
|
38
|
+
from pycodecad import show
|
|
39
|
+
|
|
40
|
+
plate = Box(40, 30, 8) - Cylinder(5, 8)
|
|
41
|
+
show(plate, name="plate", color="#F4B02A")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install pycodecad # Python 3.12+, OpenGL 3.3
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pycodecad examples # copy the examples to ./pycodecad-examples and open them
|
|
54
|
+
pycodecad part.py # a window on part.py (created from an example if missing)
|
|
55
|
+
pycodecad parts/gear/ # a window on a folder: a part in several files
|
|
56
|
+
pycodecad check part.py # run it without a window, print the result as JSON
|
|
57
|
+
pycodecad export part.py part.stl # also .3mf .step .glb .brep
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What you get
|
|
61
|
+
|
|
62
|
+
- **A window** on a part: its files, the code and the part side by side. It runs the part once when
|
|
63
|
+
it opens, then only when you press Run, and writes only when you press Save.
|
|
64
|
+
- **Parameters**: `expose(fn)` turns the arguments of a function into sliders, number boxes and
|
|
65
|
+
checkboxes.
|
|
66
|
+
- **Animations**: `frame()` saves the scene as a frame; the window plays them. Parts built once and
|
|
67
|
+
moved with `Pos` are not computed again, so mechanisms play in real time.
|
|
68
|
+
- **A command line**: `check`, `render` and `export` run the same script without a window.
|
|
69
|
+
- **An AI workflow**: an assistant edits the file and checks its own work with `check` and
|
|
70
|
+
`render`.
|
|
71
|
+
- **Parts for your own app** (experimental): the window's code, parameters and 3D view as Dear
|
|
72
|
+
ImGui components, in `pycodecad.embed`.
|
|
73
|
+
|
|
74
|
+
<table>
|
|
75
|
+
<tr>
|
|
76
|
+
<td width="33%"><img src="docs/images/parameters.png" alt="The Parameters panel: sliders, a number box and a checkbox made by expose()"></td>
|
|
77
|
+
<td width="33%"><img src="docs/images/error.png" alt="A failed run: the line marked in the editor and the traceback under it"></td>
|
|
78
|
+
<td width="33%"><img src="docs/images/render-grid.png" alt="pycodecad render: a gear in iso, front, top and right views"></td>
|
|
79
|
+
</tr>
|
|
80
|
+
<tr>
|
|
81
|
+
<td align="center">Parameters from <code>expose()</code></td>
|
|
82
|
+
<td align="center">Errors point at the line</td>
|
|
83
|
+
<td align="center"><code>pycodecad render</code>: what an AI sees</td>
|
|
84
|
+
</tr>
|
|
85
|
+
</table>
|
|
86
|
+
|
|
87
|
+
pycodecad is small on purpose, readable in an afternoon: see [Design](docs/design.md#small-on-purpose).
|
|
88
|
+
|
|
89
|
+
## Documentation
|
|
90
|
+
|
|
91
|
+
- [Getting started](docs/getting-started.md): install, a first part, run, export.
|
|
92
|
+
- [The window](docs/window.md): layout, Run and Save, Reload, read-only mode, shortcuts.
|
|
93
|
+
- [Parameters](docs/parameters.md): `expose()`, its controls and `--set`.
|
|
94
|
+
- [Scripts](docs/scripts.md): `show`, `clear`, `frame` (animations), `import_mesh`, colors, paths, errors.
|
|
95
|
+
- [Command line](docs/cli.md): `check`, `render`, `export`, `context`, exit codes.
|
|
96
|
+
- [Working with an AI assistant](docs/ai.md): the AI context and how an assistant checks its work.
|
|
97
|
+
- [Files](docs/files.md): everything pycodecad writes, and where.
|
|
98
|
+
- [Embedding](docs/embedding.md): the window's parts in your own app (experimental).
|
|
99
|
+
- [Design](docs/design.md): principles, platforms and plans.
|
|
100
|
+
- [Development](docs/development.md): tests, type checks and releases.
|
|
101
|
+
|
|
102
|
+
Examples: [examples/](examples/)
|
|
103
|
+
|
|
104
|
+
## Credits
|
|
105
|
+
|
|
106
|
+
- [build123d](https://github.com/gumyr/build123d): the modeling language
|
|
107
|
+
- [Open CASCADE](https://dev.opencascade.org/) and [OCP](https://github.com/CadQuery/OCP): the geometry kernel
|
|
108
|
+
- [Dear ImGui](https://github.com/ocornut/imgui) and [slimgui](https://github.com/nurpax/slimgui): the interface
|
|
109
|
+
- [ModernGL](https://github.com/moderngl/moderngl), [GLFW](https://www.glfw.org/) and
|
|
110
|
+
[pyGLFW](https://github.com/FlorianRhiem/pyGLFW): rendering and windows
|
|
111
|
+
- [NumPy](https://numpy.org/): meshes
|
|
112
|
+
- [pytypehint](https://github.com/offerrall/pytypehint): the parameters of `expose()`
|
|
113
|
+
- [Lucide](https://lucide.dev): icons (ISC)
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
[MIT](LICENSE)
|