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/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
+ [![PyPI](https://img.shields.io/pypi/v/pycodecad)](https://pypi.org/project/pycodecad/)
27
+ [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://pypi.org/project/pycodecad/)
28
+ [![Platform](https://img.shields.io/badge/platform-Linux%20native%20%7C%20Windows%20%26%20macOS%20compatible-lightgrey)](docs/design.md#platforms)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+ ![The pycodecad window: the files of the folder, parameters and code on the left, a gear in the 3D view on the right](docs/images/window.png)
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)