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/cli.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Command line: open the window, or check/render/export a script without it.
|
|
2
|
+
|
|
3
|
+
pycodecad file.py [--read-only] [--no-run] window on the file's folder, file.py runs (created if missing)
|
|
4
|
+
pycodecad folder/ window on a folder (its part runs: see workspace.find_main)
|
|
5
|
+
pycodecad examples [folder] copy the examples there once (default ./pycodecad-examples), open it
|
|
6
|
+
pycodecad check file.py run it, print the result as JSON
|
|
7
|
+
pycodecad render file.py out.png [--views iso | iso,front,top | window] [--size 800x600] [--frame N]
|
|
8
|
+
pycodecad export file.py out.stl [--profile bambu] also .3mf .step .glb .brep
|
|
9
|
+
pycodecad context file.py print the AI context
|
|
10
|
+
|
|
11
|
+
check, render and export take --set KEY=VALUE (repeatable) for the parameters of expose():
|
|
12
|
+
KEY is "param" or "function.param".
|
|
13
|
+
|
|
14
|
+
Ctrl+C (or SIGTERM/SIGHUP) stops the running script and exits with code 130.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import contextlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
|
|
29
|
+
COMMANDS = ("check", "render", "export", "context")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Usage(Exception):
|
|
33
|
+
"""A command line that cannot work: the message is printed and pycodecad exits with 1."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
38
|
+
for stream in (sys.stdout, sys.stderr): # a console that is not UTF-8 (Windows): never fail to print
|
|
39
|
+
if hasattr(stream, "reconfigure"):
|
|
40
|
+
stream.reconfigure(errors="backslashreplace") # pyright: ignore[reportAttributeAccessIssue]
|
|
41
|
+
stops = [number for number in (signal.SIGTERM, getattr(signal, "SIGHUP", None)) if number is not None]
|
|
42
|
+
previous = {number: signal.signal(number, interrupt) for number in stops}
|
|
43
|
+
try:
|
|
44
|
+
from .cad import remove_stale_temp
|
|
45
|
+
|
|
46
|
+
remove_stale_temp()
|
|
47
|
+
if argv and argv[0] in COMMANDS:
|
|
48
|
+
return command(argv[0], argv[1:])
|
|
49
|
+
if argv and argv[0] == "examples":
|
|
50
|
+
return examples(argv[1:])
|
|
51
|
+
return window(argv)
|
|
52
|
+
except KeyboardInterrupt: # the run (if any) is already killed
|
|
53
|
+
print("pycodecad: interrupted", file=sys.stderr)
|
|
54
|
+
return 130
|
|
55
|
+
except (OSError, Usage) as exc:
|
|
56
|
+
from .sidecar import error_text
|
|
57
|
+
|
|
58
|
+
print(f"pycodecad: {error_text(exc) if isinstance(exc, OSError) else exc}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
finally:
|
|
61
|
+
for number, handler in previous.items():
|
|
62
|
+
signal.signal(number, handler)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def interrupt(number, frame) -> None:
|
|
66
|
+
raise KeyboardInterrupt
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def window(argv: list[str]) -> int:
|
|
70
|
+
parser = argparse.ArgumentParser(prog="pycodecad", description="Code-CAD window for build123d scripts. "
|
|
71
|
+
"Other commands: pycodecad {check,render,export,context,examples} --help")
|
|
72
|
+
parser.add_argument("file", help="script to run (created if it does not exist), or a folder")
|
|
73
|
+
parser.add_argument("--read-only", action="store_true", help="an order form: parameters, Run and Export, no "
|
|
74
|
+
"code; scripts are never written")
|
|
75
|
+
parser.add_argument("--no-run", action="store_true", help="do not run the script when the window opens")
|
|
76
|
+
parser.add_argument("--screenshot", metavar="PNG", help="save a picture of the window after the first run and quit")
|
|
77
|
+
parser.add_argument("--version", action="version", version=f"pycodecad {__version__}")
|
|
78
|
+
options = parser.parse_args(argv)
|
|
79
|
+
from .app import open_window
|
|
80
|
+
|
|
81
|
+
open_window(options.file, screenshot=options.screenshot, read_only=options.read_only, run=not options.no_run)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def examples(argv: list[str]) -> int:
|
|
86
|
+
parser = argparse.ArgumentParser(prog="pycodecad examples", description="Copy the examples into a folder the "
|
|
87
|
+
"first time, then open the window on it (an existing folder is opened as it is).")
|
|
88
|
+
parser.add_argument("folder", nargs="?", default="pycodecad-examples", help="default ./pycodecad-examples")
|
|
89
|
+
parser.add_argument("--no-run", action="store_true", help="do not run the script when the window opens")
|
|
90
|
+
options = parser.parse_args(argv)
|
|
91
|
+
target = Path(options.folder)
|
|
92
|
+
if not target.exists():
|
|
93
|
+
shutil.copytree(bundled_examples(), target, ignore=shutil.ignore_patterns("__pycache__", ".pycodecad"))
|
|
94
|
+
print(f"Copied the examples to {target.resolve()}")
|
|
95
|
+
from .app import open_window
|
|
96
|
+
|
|
97
|
+
open_window(str(target), run=not options.no_run)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bundled_examples() -> Path:
|
|
102
|
+
"""The examples inside the installed package, or next to the sources in a checkout."""
|
|
103
|
+
for folder in (Path(__file__).parent / "examples", Path(__file__).parents[2] / "examples"):
|
|
104
|
+
if folder.is_dir():
|
|
105
|
+
return folder
|
|
106
|
+
raise Usage("the examples are not installed with this copy of pycodecad")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def command(name: str, argv: list[str]) -> int:
|
|
110
|
+
parser = argparse.ArgumentParser(prog=f"pycodecad {name}", allow_abbrev=False)
|
|
111
|
+
parser.add_argument("file", help="the script")
|
|
112
|
+
if name == "render":
|
|
113
|
+
parser.add_argument("output", help="PNG file to write")
|
|
114
|
+
parser.add_argument("--views", default="iso", help="iso (default), front, back, left, right, top or "
|
|
115
|
+
"bottom; several make a labeled grid (iso,front,top,right); window is the camera of "
|
|
116
|
+
"the open window")
|
|
117
|
+
parser.add_argument("--size", help="WIDTHxHEIGHT of each view (default 800x600, or the window's)")
|
|
118
|
+
parser.add_argument("--frame", type=int, metavar="N", help="frame N (1, 2, ...) of an animation made "
|
|
119
|
+
"with frame(); by default the scene at the end of the script")
|
|
120
|
+
if name == "export":
|
|
121
|
+
parser.add_argument("output", help="file to write: .stl .3mf .step .glb or .brep")
|
|
122
|
+
from .files import PROFILES
|
|
123
|
+
|
|
124
|
+
parser.add_argument("--profile", choices=PROFILES, help="3MF flavor (default generic)")
|
|
125
|
+
if name != "context":
|
|
126
|
+
parser.add_argument("--set", action="append", default=[], type=setting, metavar="KEY=VALUE",
|
|
127
|
+
help="value of an exposed parameter (\"param\" or \"function.param\"); repeatable")
|
|
128
|
+
options = parser.parse_args(argv)
|
|
129
|
+
script = Path(options.file).resolve()
|
|
130
|
+
from .sidecar import read_script
|
|
131
|
+
|
|
132
|
+
picture = render_plan(script, options) if name == "render" else None
|
|
133
|
+
if name == "export":
|
|
134
|
+
check_export(options)
|
|
135
|
+
code = read_script(script)
|
|
136
|
+
from . import runner
|
|
137
|
+
|
|
138
|
+
export = str(Path(options.output).resolve()) if name == "export" else None
|
|
139
|
+
values = dict(getattr(options, "set", []))
|
|
140
|
+
run = runner.Run(code, str(script), export=export, profile=getattr(options, "profile", None) or "generic",
|
|
141
|
+
values=values)
|
|
142
|
+
try:
|
|
143
|
+
result = run.wait()
|
|
144
|
+
except KeyboardInterrupt:
|
|
145
|
+
run.kill()
|
|
146
|
+
with contextlib.suppress(TimeoutError):
|
|
147
|
+
run.wait(5) # its last-run file says "Stopped"
|
|
148
|
+
raise
|
|
149
|
+
if name == "check":
|
|
150
|
+
print(json.dumps(runner.report(str(script), result), indent=2))
|
|
151
|
+
return 1 if result.error else 0
|
|
152
|
+
if name == "context":
|
|
153
|
+
from .context import ai_context
|
|
154
|
+
|
|
155
|
+
print(ai_context(script, result))
|
|
156
|
+
return 0
|
|
157
|
+
if result.stdout and name == "export":
|
|
158
|
+
print(result.stdout, end="")
|
|
159
|
+
if result.error:
|
|
160
|
+
print(result.error, file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
frame = getattr(options, "frame", None)
|
|
163
|
+
if frame is not None and not 1 <= frame <= len(result.frames):
|
|
164
|
+
raise Usage(f"--frame {frame}: the script made {len(result.frames)} frames (frame() calls)")
|
|
165
|
+
if not result.shown and not frame:
|
|
166
|
+
raise Usage("nothing shown: call show() in the script")
|
|
167
|
+
if name == "export":
|
|
168
|
+
print(f"Wrote {result.exported}")
|
|
169
|
+
return 0
|
|
170
|
+
assert picture is not None # name == "render": planned before running
|
|
171
|
+
return render(result.frames[frame - 1] if frame else result.shown, options.output, *picture)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def setting(text: str) -> tuple[str, str]:
|
|
175
|
+
"""--set KEY=VALUE: the value stays a string, converted by expose() to the parameter's type."""
|
|
176
|
+
key, equals, value = text.partition("=")
|
|
177
|
+
if not equals or not key.strip():
|
|
178
|
+
raise argparse.ArgumentTypeError(f"must look like KEY=VALUE (width=80 or box.width=80), not {text!r}")
|
|
179
|
+
return key.strip(), value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_output(output: str) -> None:
|
|
183
|
+
folder = Path(output).resolve().parent
|
|
184
|
+
if not folder.is_dir():
|
|
185
|
+
raise Usage(f"the folder of {output} does not exist: {folder}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def check_export(options) -> None:
|
|
189
|
+
from .files import FORMATS
|
|
190
|
+
|
|
191
|
+
kind = Path(options.output).suffix.lower().lstrip(".")
|
|
192
|
+
if kind not in FORMATS:
|
|
193
|
+
raise Usage(f"cannot export {options.output}: use one of .{', .'.join(FORMATS)}")
|
|
194
|
+
if options.profile and kind != "3mf":
|
|
195
|
+
raise Usage("--profile is only for .3mf files")
|
|
196
|
+
check_output(options.output)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render_plan(script: Path, options) -> tuple[list, tuple[int, int]]:
|
|
200
|
+
"""The views (names or the window's Camera) and the size of each, checked before running."""
|
|
201
|
+
from .camera import check_view
|
|
202
|
+
from .renderer import check_size
|
|
203
|
+
from .sidecar import load_camera
|
|
204
|
+
|
|
205
|
+
if Path(options.output).suffix.lower() != ".png":
|
|
206
|
+
raise Usage(f"render writes PNG pictures: {options.output} must end in .png")
|
|
207
|
+
check_output(options.output)
|
|
208
|
+
size = (800, 600)
|
|
209
|
+
if options.size:
|
|
210
|
+
try:
|
|
211
|
+
width, height = (int(value) for value in options.size.lower().split("x"))
|
|
212
|
+
size = (width, height)
|
|
213
|
+
check_size(size)
|
|
214
|
+
except ValueError:
|
|
215
|
+
raise Usage(f"--size must look like 800x600 (1 to 8192 pixels per side), not {options.size}") from None
|
|
216
|
+
views: list = options.views.split(",")
|
|
217
|
+
for view in views:
|
|
218
|
+
try:
|
|
219
|
+
check_view(view, "window")
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
raise Usage(str(exc)) from None
|
|
222
|
+
if "window" in views:
|
|
223
|
+
if len(views) > 1:
|
|
224
|
+
raise Usage("--views window shows the window camera alone: do not combine it with other views")
|
|
225
|
+
try:
|
|
226
|
+
camera, window_size = load_camera(script)
|
|
227
|
+
except (OSError, ValueError):
|
|
228
|
+
raise Usage(f"no window camera saved for {script.name} yet: open it with "
|
|
229
|
+
f"`pycodecad {script.name}` and move the view") from None
|
|
230
|
+
views = [camera]
|
|
231
|
+
if options.size is None:
|
|
232
|
+
size = window_size
|
|
233
|
+
return views, size
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def render(shown: list, output: str, views: list, size: tuple[int, int]) -> int:
|
|
237
|
+
from .renderer import png_bytes, render_views
|
|
238
|
+
|
|
239
|
+
Path(output).write_bytes(png_bytes(render_views(shown, views, size)))
|
|
240
|
+
print(f"Wrote {Path(output).resolve()}")
|
|
241
|
+
return 0
|
pycodecad/context.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The "AI context": a text to paste into an AI assistant so it can build and iterate on the part.
|
|
2
|
+
|
|
3
|
+
Used by the "Copy AI context" button and by `pycodecad context file.py`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
SKIP = {"__pycache__", "venv", ".venv", "node_modules"}
|
|
15
|
+
MAX_FILES = 60
|
|
16
|
+
WINDOWS = sys.platform == "win32"
|
|
17
|
+
|
|
18
|
+
GUIDE = """\
|
|
19
|
+
## How pycodecad runs a script
|
|
20
|
+
- `{pycodecad} {file}` runs it in the window; `python {file}` runs it without one (show(), clear() and frame()
|
|
21
|
+
then do nothing).
|
|
22
|
+
- A part always starts from its own folder, so it works on any PC: keep the script, its helper
|
|
23
|
+
modules and its assets together in one folder and use relative paths, never absolute ones
|
|
24
|
+
(/home/...). With plain `python`, relative paths start from the current directory: for files
|
|
25
|
+
that must also work that way use `Path(__file__).parent / "name"`.
|
|
26
|
+
- Every run (window, check, render, export) writes its result to .pycodecad/{name}.last-run.json
|
|
27
|
+
next to the script (same JSON as `check`): read it to see the latest error.
|
|
28
|
+
|
|
29
|
+
## Workflow
|
|
30
|
+
- Edit the file directly. The window runs the script once when it opens, then never reloads or
|
|
31
|
+
runs by itself: it shows "Changed on disk" and the owner presses Reload and Run.
|
|
32
|
+
- Check your work from a terminal in that folder (each command runs the script fresh, no window):
|
|
33
|
+
{pycodecad} check {file} # JSON: ok, error, where (file:line), traceback, stdout, objects
|
|
34
|
+
{pycodecad} render {file} out.png --views iso,front,top,right # then look at out.png
|
|
35
|
+
{pycodecad} render {file} out.png --views window # exactly what the owner sees
|
|
36
|
+
{pycodecad} render {file} out.png --set width=80 # other values for expose()
|
|
37
|
+
{pycodecad} export {file} {stl} # also .3mf .step .glb .brep; --profile bambu (Bambu Studio 3MF)
|
|
38
|
+
- Helper modules: write helper.py next to the script and `import helper` (fresh on every run).
|
|
39
|
+
|
|
40
|
+
## pycodecad API (docs: https://github.com/offerrall/pycodecad/tree/v{version}/docs)
|
|
41
|
+
`from pycodecad import show, clear, frame, import_mesh, expose`
|
|
42
|
+
The script says what is in the scene with show(); the window, check, render and export all look
|
|
43
|
+
at that same scene. Nothing is shown without show().
|
|
44
|
+
- show(*objs, name=None, color=None): add shapes/builders/lists to the scene. color: "#RRGGBB",
|
|
45
|
+
a name ("red", "gold"...) or an RGB triple.
|
|
46
|
+
- clear(): empty the scene.
|
|
47
|
+
- frame(): save the scene now as the next frame of an animation (30/s; the window plays them).
|
|
48
|
+
Build parts once and move them with Pos/Rot per frame (not recomputed); hold = call frame() again.
|
|
49
|
+
`{pycodecad} render {file} out.png --frame N` draws frame N (1-based); check reports "frames".
|
|
50
|
+
- import_mesh(path, solid=False, max_faces=5000): STL/3MF as a fast mesh for show/export;
|
|
51
|
+
solid=True gives a build123d Solid for booleans (small closed meshes only).
|
|
52
|
+
- expose(fn): calls fn and returns its result; the window shows a control per parameter (Run
|
|
53
|
+
applies them). Only int/float/bool/str parameters with a default, annotated only with pytypehint
|
|
54
|
+
Min, Max, Step, Slider, Label, Description: `def box(width: Annotated[float, Min(20.0),
|
|
55
|
+
Max(200.0), Slider()] = 60.0)`, then `show(expose(box))`. Without the window the defaults are
|
|
56
|
+
used, or `--set width=80` (or `box.width=80`) on check/render/export.
|
|
57
|
+
|
|
58
|
+
## build123d essentials (docs: https://build123d.readthedocs.io), units mm, Z up
|
|
59
|
+
```python
|
|
60
|
+
from build123d import *
|
|
61
|
+
from pycodecad import show
|
|
62
|
+
|
|
63
|
+
# Algebra style: shapes are values; + union, - cut, & intersect; Pos/Rot place them.
|
|
64
|
+
plate = Box(60, 40, 5, align=(Align.CENTER, Align.CENTER, Align.MIN)) # sits on Z=0
|
|
65
|
+
plate -= Pos(20, 10, 0) * Cylinder(3, 20) # through hole
|
|
66
|
+
plate = fillet(plate.edges().filter_by(Axis.Z), radius=4)
|
|
67
|
+
|
|
68
|
+
# Builder style: 2D sketch on a plane, then extrude.
|
|
69
|
+
with BuildPart() as bracket:
|
|
70
|
+
with BuildSketch(Plane.XY):
|
|
71
|
+
RectangleRounded(40, 20, 3)
|
|
72
|
+
with Locations((-12, 0), (12, 0)):
|
|
73
|
+
Circle(2.5, mode=Mode.SUBTRACT)
|
|
74
|
+
extrude(amount=4)
|
|
75
|
+
chamfer(bracket.edges().group_by(Axis.Z)[-1], length=0.8) # top edges
|
|
76
|
+
|
|
77
|
+
show(Pos(0, 50, 0) * plate, name="plate", color="#F4B02A")
|
|
78
|
+
show(bracket, name="bracket")
|
|
79
|
+
```
|
|
80
|
+
- Primitives are centered on their location by default; use `align=` to put a face on a plane.
|
|
81
|
+
- Edge/face selection: `.edges().filter_by(Axis.Z)`, `.faces().sort_by(Axis.Z)[-1]` (top face),
|
|
82
|
+
`.group_by(Axis.Z)[-1]`. Apply fillets/chamfers last; a radius too big for a wall fails.
|
|
83
|
+
- Other useful pieces: Text("ABC", font_size=8) in a BuildSketch, revolve(), loft(), sweep(),
|
|
84
|
+
PolarLocations(r, n), GridLocations(dx, dy, nx, ny), mirror(about=Plane.YZ), offset(), import_svg().
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def quote(text: str) -> str:
|
|
89
|
+
"""A literal argument for PowerShell on Windows, or a POSIX shell elsewhere."""
|
|
90
|
+
return "'" + text.replace("'", "''") + "'" if WINDOWS else shlex.quote(text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pycodecad_command() -> str:
|
|
94
|
+
"""How to call this pycodecad from a terminal."""
|
|
95
|
+
script = Path(sys.executable).with_name("pycodecad.exe" if WINDOWS else "pycodecad")
|
|
96
|
+
if script.exists():
|
|
97
|
+
if shutil.which("pycodecad") == str(script):
|
|
98
|
+
return "pycodecad"
|
|
99
|
+
command = quote(str(script))
|
|
100
|
+
else:
|
|
101
|
+
command = f"{quote(sys.executable)} -m pycodecad"
|
|
102
|
+
return "& " + command if WINDOWS else command
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def size_text(size: float) -> str:
|
|
106
|
+
if size < 1024:
|
|
107
|
+
return f"{size:.0f} B"
|
|
108
|
+
return f"{size / 1024:.1f} KB" if size < 1024**2 else f"{size / 1024**2:.1f} MB"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def folder_listing(folder: Path) -> list[str]:
|
|
112
|
+
"""Files of the folder and its subfolders (two levels), with sizes."""
|
|
113
|
+
lines = []
|
|
114
|
+
for path in sorted(folder.glob("*")) + sorted(folder.glob("*/*")):
|
|
115
|
+
relative = path.relative_to(folder)
|
|
116
|
+
if any(part.startswith(".") or part in SKIP for part in relative.parts) or not path.is_file():
|
|
117
|
+
continue
|
|
118
|
+
lines.append(f" {relative.as_posix()} ({size_text(path.stat().st_size)})")
|
|
119
|
+
if len(lines) > MAX_FILES:
|
|
120
|
+
lines = lines[:MAX_FILES] + [f" ... and {len(lines) - MAX_FILES} more"]
|
|
121
|
+
return lines
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def state_lines(state) -> list[str]:
|
|
125
|
+
"""State: anything with shown, error, error_file, error_line, warnings and stdout (Workspace or runner.Result)."""
|
|
126
|
+
lines = []
|
|
127
|
+
for obj in state.shown:
|
|
128
|
+
bbox = obj.bbox()
|
|
129
|
+
size = " x ".join(f"{hi - lo:.4g}" for lo, hi in zip(*bbox)) if bbox else "empty"
|
|
130
|
+
position = ", ".join(f"{(lo + hi) / 2:.4g}" for lo, hi in zip(*bbox)) if bbox else "-"
|
|
131
|
+
volume = f", volume {obj.volume:.6g} mm³" if obj.volume is not None else ""
|
|
132
|
+
lines.append(f"- {obj.name}: size {size} mm, center ({position}){volume}")
|
|
133
|
+
if not lines:
|
|
134
|
+
lines.append("- no objects")
|
|
135
|
+
if state.error:
|
|
136
|
+
where = f" in {state.error_file}, line {state.error_line}" if state.error_file else ""
|
|
137
|
+
lines.append(f"Error{where}:\n```\n{state.error.strip()[-2000:]}\n```")
|
|
138
|
+
for warning in state.warnings:
|
|
139
|
+
lines.append(f"Warning: {warning}")
|
|
140
|
+
if state.stdout.strip():
|
|
141
|
+
lines.append(f"Script output:\n```\n{state.stdout.strip()[-1500:]}\n```")
|
|
142
|
+
return lines
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parameter_lines(exposed) -> list[str]:
|
|
146
|
+
"""The parameters of the exposed functions (params.Exposed) with the values the run used."""
|
|
147
|
+
lines = []
|
|
148
|
+
for group in exposed:
|
|
149
|
+
lines.append(f"- {group.function}():")
|
|
150
|
+
for param in group.params:
|
|
151
|
+
limits = "length " if param.kind == "str" and (param.min is not None or param.max is not None) else ""
|
|
152
|
+
if param.min is not None or param.max is not None:
|
|
153
|
+
low = "" if param.min is None else f"{param.min:g}"
|
|
154
|
+
high = "" if param.max is None else f"{param.max:g}"
|
|
155
|
+
limits += f"{low}..{high}"
|
|
156
|
+
extras = [param.kind, limits, param.step is not None and f"step {param.step:g}",
|
|
157
|
+
param.slider and "slider", param.label and f"label {param.label!r}",
|
|
158
|
+
param.value != param.default and f"default {param.default!r}"]
|
|
159
|
+
text = f" - {param.name} = {param.value!r} ({', '.join(filter(None, extras))})"
|
|
160
|
+
lines.append(text + (f": {param.description}" if param.description else ""))
|
|
161
|
+
return lines
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def ai_context(path: Path, state=None) -> str:
|
|
165
|
+
"""The whole text. path: the script; state: see state_lines."""
|
|
166
|
+
head = ["# pycodecad: code-CAD with build123d",
|
|
167
|
+
f"A pycodecad window shows in 3D what `{path.name}` builds.",
|
|
168
|
+
f"Folder: {path.parent}", f"File: {path}", "Files in the folder:", *folder_listing(path.parent)]
|
|
169
|
+
if WINDOWS:
|
|
170
|
+
head += ["", "Run the commands below in PowerShell."]
|
|
171
|
+
if state is not None:
|
|
172
|
+
head += ["", "## Current state", *state_lines(state)]
|
|
173
|
+
exposed = getattr(state, "parameters", None) or []
|
|
174
|
+
if exposed:
|
|
175
|
+
head += ["", "## Exposed parameters (values of the last run)", *parameter_lines(exposed)]
|
|
176
|
+
commands = dict(file=quote(path.name), name=path.name, stl=quote(path.stem + ".stl"),
|
|
177
|
+
pycodecad=pycodecad_command(), version=__version__)
|
|
178
|
+
return "\n".join(head) + "\n\n" + GUIDE.format(**commands)
|
pycodecad/editor.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The code editor widget: Python highlighting and keyboard/mouse editing, drawn with ImGui."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import builtins
|
|
5
|
+
import io
|
|
6
|
+
import keyword
|
|
7
|
+
import tokenize
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
import glfw
|
|
12
|
+
from slimgui import imgui
|
|
13
|
+
|
|
14
|
+
from . import textedit
|
|
15
|
+
from .imgui_backend import clipboard_text
|
|
16
|
+
from .textedit import Editor
|
|
17
|
+
|
|
18
|
+
COLORS = {
|
|
19
|
+
"text": (0.84, 0.87, 0.91, 1.0),
|
|
20
|
+
"keyword": (0.79, 0.57, 0.95, 1.0),
|
|
21
|
+
"builtin": (0.42, 0.77, 0.96, 1.0),
|
|
22
|
+
"string": (0.66, 0.82, 0.52, 1.0),
|
|
23
|
+
"number": (0.96, 0.71, 0.42, 1.0),
|
|
24
|
+
"comment": (0.48, 0.59, 0.65, 1.0),
|
|
25
|
+
"decorator": (0.95, 0.78, 0.42, 1.0),
|
|
26
|
+
"definition": (0.98, 0.85, 0.56, 1.0),
|
|
27
|
+
"cad": (0.32, 0.84, 0.75, 1.0),
|
|
28
|
+
}
|
|
29
|
+
CAD_NAMES = frozenset("""
|
|
30
|
+
Box Cylinder Sphere Cone Torus Wedge BuildPart BuildSketch BuildLine
|
|
31
|
+
Part Sketch Solid Face Wire Edge Vertex Compound Location Locations
|
|
32
|
+
PolarLocations GridLocations HexLocations Plane Axis Vector Color
|
|
33
|
+
Align Mode GeomType Rectangle RectangleRounded Circle Ellipse Polygon
|
|
34
|
+
RegularPolygon Polyline Line Spline Bezier RadiusArc CenterArc ThreePointArc
|
|
35
|
+
fillet chamfer extrude revolve loft sweep offset mirror scale split
|
|
36
|
+
make_face make_hull add subtract intersect Pos Rot show clear import_mesh render
|
|
37
|
+
""".split())
|
|
38
|
+
BUILTINS = frozenset(dir(builtins))
|
|
39
|
+
SELECTION = (0.25, 0.48, 0.78, 0.45)
|
|
40
|
+
CURRENT_LINE = (0.8, 0.85, 1.0, 0.045)
|
|
41
|
+
ERROR_LINE = (0.8, 0.2, 0.2, 0.23)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=16)
|
|
45
|
+
def highlight(text: str) -> tuple[tuple[tuple[str, str], ...], ...]:
|
|
46
|
+
"""Per line, (text, kind) spans covering the whole line; works on incomplete code too."""
|
|
47
|
+
lines = text.split("\n")
|
|
48
|
+
kinds = [["text"] * len(line) for line in lines]
|
|
49
|
+
|
|
50
|
+
def paint(start: tuple[int, int], end: tuple[int, int], kind: str) -> None:
|
|
51
|
+
for row in range(max(1, start[0]), min(len(lines), end[0]) + 1):
|
|
52
|
+
lo = start[1] if row == start[0] else 0
|
|
53
|
+
hi = min(end[1] if row == end[0] else len(lines[row - 1]), len(lines[row - 1]))
|
|
54
|
+
kinds[row - 1][lo:hi] = [kind] * max(0, hi - lo)
|
|
55
|
+
|
|
56
|
+
definition = decorator = False
|
|
57
|
+
try:
|
|
58
|
+
for token in tokenize.generate_tokens(io.StringIO(text).readline):
|
|
59
|
+
kind = "text"
|
|
60
|
+
if token.type == tokenize.NAME:
|
|
61
|
+
if definition:
|
|
62
|
+
kind, definition = "definition", False
|
|
63
|
+
elif keyword.iskeyword(token.string) or keyword.issoftkeyword(token.string):
|
|
64
|
+
kind, definition = "keyword", token.string in ("class", "def")
|
|
65
|
+
elif decorator:
|
|
66
|
+
kind = "decorator"
|
|
67
|
+
elif token.string in CAD_NAMES:
|
|
68
|
+
kind = "cad"
|
|
69
|
+
elif token.string in BUILTINS:
|
|
70
|
+
kind = "builtin"
|
|
71
|
+
elif token.type == tokenize.STRING or tokenize.tok_name[token.type].startswith("FSTRING"):
|
|
72
|
+
kind = "string"
|
|
73
|
+
elif token.type == tokenize.NUMBER:
|
|
74
|
+
kind = "number"
|
|
75
|
+
elif token.type == tokenize.COMMENT:
|
|
76
|
+
kind = "comment"
|
|
77
|
+
elif token.string == "@" and not lines[token.start[0] - 1][:token.start[1]].strip():
|
|
78
|
+
decorator, kind = True, "decorator"
|
|
79
|
+
if token.type in (tokenize.NEWLINE, tokenize.NL) or token.string == "(":
|
|
80
|
+
decorator = False
|
|
81
|
+
paint(token.start, token.end, kind)
|
|
82
|
+
except (tokenize.TokenError, SyntaxError) as exc:
|
|
83
|
+
if isinstance(exc, tokenize.TokenError) and "multi-line string" in exc.args[0]:
|
|
84
|
+
paint(exc.args[1], (len(lines), len(lines[-1])), "string")
|
|
85
|
+
result = []
|
|
86
|
+
for line, styles in zip(lines, kinds):
|
|
87
|
+
spans = []
|
|
88
|
+
start = 0
|
|
89
|
+
while start < len(line):
|
|
90
|
+
end = start + 1
|
|
91
|
+
while end < len(line) and styles[end] == styles[start]:
|
|
92
|
+
end += 1
|
|
93
|
+
spans.append((line[start:end], styles[start]))
|
|
94
|
+
start = end
|
|
95
|
+
result.append(tuple(spans))
|
|
96
|
+
return tuple(result)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _column(line: str, x: float, char_width: float) -> int:
|
|
100
|
+
"""Character index under a horizontal pixel offset (tabs are 4 columns wide)."""
|
|
101
|
+
target = max(0.0, x / char_width)
|
|
102
|
+
visual = 0
|
|
103
|
+
for index, char in enumerate(line):
|
|
104
|
+
following = visual + (4 - visual % 4 if char == "\t" else 1)
|
|
105
|
+
if target < (visual + following) / 2:
|
|
106
|
+
return index
|
|
107
|
+
visual = following
|
|
108
|
+
return len(line)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
MOVES = { # key -> (textedit key, with Ctrl)
|
|
112
|
+
glfw.KEY_LEFT: ("left", "word_left"), glfw.KEY_RIGHT: ("right", "word_right"),
|
|
113
|
+
glfw.KEY_UP: ("up", "up"), glfw.KEY_DOWN: ("down", "down"),
|
|
114
|
+
glfw.KEY_HOME: ("home", "doc_start"), glfw.KEY_END: ("end", "doc_end"),
|
|
115
|
+
glfw.KEY_PAGE_UP: ("page_up", "page_up"), glfw.KEY_PAGE_DOWN: ("page_down", "page_down"),
|
|
116
|
+
glfw.KEY_BACKSPACE: ("backspace", "word_backspace"), glfw.KEY_DELETE: ("delete", "word_delete"),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def copy_text(text: str) -> None:
|
|
121
|
+
"""Put text on the system clipboard."""
|
|
122
|
+
glfw.set_clipboard_string(None, text) # pyright: ignore[reportArgumentType] # GLFW takes None; its stub does not
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _keyboard(editor: Editor, events: list, page_lines: int, focused: bool = True) -> Editor:
|
|
126
|
+
"""Apply typed text and editing keys (only when focused) and call the window's actions (see
|
|
127
|
+
ui.shortcuts), all in the order they arrived."""
|
|
128
|
+
for event in events:
|
|
129
|
+
if callable(event):
|
|
130
|
+
event(editor)
|
|
131
|
+
elif focused and isinstance(event, str):
|
|
132
|
+
editor = textedit.insert(editor, event, typing=True)
|
|
133
|
+
if callable(event) or not focused or isinstance(event, str):
|
|
134
|
+
continue
|
|
135
|
+
key, mods, repeat = event
|
|
136
|
+
ctrl, shift = bool(mods & glfw.MOD_CONTROL), bool(mods & glfw.MOD_SHIFT)
|
|
137
|
+
if key in MOVES:
|
|
138
|
+
editor = textedit.key(editor, MOVES[key][ctrl], shift, page_lines)
|
|
139
|
+
elif key == glfw.KEY_TAB:
|
|
140
|
+
editor = textedit.key(editor, "untab" if shift else "tab")
|
|
141
|
+
elif key in (glfw.KEY_ENTER, glfw.KEY_KP_ENTER) and not ctrl:
|
|
142
|
+
editor = textedit.key(editor, "enter")
|
|
143
|
+
elif not ctrl:
|
|
144
|
+
continue
|
|
145
|
+
elif key == glfw.KEY_Z:
|
|
146
|
+
editor = textedit.redo(editor) if shift else textedit.undo(editor)
|
|
147
|
+
elif key == glfw.KEY_Y:
|
|
148
|
+
editor = textedit.redo(editor)
|
|
149
|
+
elif key == glfw.KEY_V:
|
|
150
|
+
editor = textedit.insert(editor, clipboard_text())
|
|
151
|
+
elif repeat:
|
|
152
|
+
continue
|
|
153
|
+
elif key == glfw.KEY_A:
|
|
154
|
+
editor = textedit.key(editor, "select_all")
|
|
155
|
+
elif key == glfw.KEY_C:
|
|
156
|
+
lo, hi = textedit.copy_range(editor)
|
|
157
|
+
copy_text(editor.text[lo:hi])
|
|
158
|
+
elif key == glfw.KEY_X:
|
|
159
|
+
editor, text = textedit.cut(editor)
|
|
160
|
+
copy_text(text)
|
|
161
|
+
return editor
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def view(editor: Editor, size: tuple[float, float], events: list, error_line: int | None,
|
|
165
|
+
wheel_scroll: bool = True) -> Editor:
|
|
166
|
+
"""Draw the visible lines and handle input (when focused). Returns the (maybe) edited editor.
|
|
167
|
+
wheel_scroll False: the mouse wheel does not scroll (Ctrl+wheel zooms instead)."""
|
|
168
|
+
flags = imgui.WindowFlags.HORIZONTAL_SCROLLBAR
|
|
169
|
+
if not wheel_scroll:
|
|
170
|
+
flags |= imgui.WindowFlags.NO_SCROLL_WITH_MOUSE
|
|
171
|
+
visible = imgui.begin_child("editor", size, imgui.ChildFlags.NONE, flags)
|
|
172
|
+
try:
|
|
173
|
+
if not visible:
|
|
174
|
+
return _keyboard(editor, events, 1, focused=False)
|
|
175
|
+
lines = editor.text.split("\n")
|
|
176
|
+
spans = highlight(editor.text)
|
|
177
|
+
height = imgui.get_text_line_height_with_spacing()
|
|
178
|
+
char_width = imgui.calc_text_size("M" * 64)[0] / 64 # one character is rounded up; zoomed sizes are fractional
|
|
179
|
+
gutter = char_width * (len(str(len(lines))) + 2)
|
|
180
|
+
origin = imgui.get_cursor_screen_pos()
|
|
181
|
+
available = imgui.get_content_region_avail()
|
|
182
|
+
longest = max(len(line.expandtabs(4)) for line in lines)
|
|
183
|
+
content_width = max(gutter + (longest + 2) * char_width, available[0])
|
|
184
|
+
imgui.invisible_button("##source", (content_width, max(len(lines) * height, available[1])))
|
|
185
|
+
hovered, active = imgui.is_item_hovered(), imgui.is_item_active()
|
|
186
|
+
io_state = imgui.get_io()
|
|
187
|
+
|
|
188
|
+
# Mouse: click places the cursor, drag selects (scrolling at the borders), 2/3 clicks select word/line.
|
|
189
|
+
clicked = hovered and imgui.is_mouse_clicked(imgui.MouseButton.LEFT)
|
|
190
|
+
dragging = active and imgui.is_mouse_dragging(imgui.MouseButton.LEFT)
|
|
191
|
+
if clicked or dragging:
|
|
192
|
+
imgui.set_window_focus()
|
|
193
|
+
x, y = io_state.mouse_pos
|
|
194
|
+
row = max(0, min(len(lines) - 1, int((y - origin[1]) / height)))
|
|
195
|
+
column = _column(lines[row], x - origin[0] - gutter, char_width)
|
|
196
|
+
clicks = max(1, imgui.get_mouse_clicked_count(imgui.MouseButton.LEFT)) if clicked else 1
|
|
197
|
+
editor = textedit.click(editor, row, column, extend=io_state.key_shift or not clicked, clicks=clicks)
|
|
198
|
+
if dragging:
|
|
199
|
+
(left, top), (width, window_height) = imgui.get_window_pos(), imgui.get_window_size()
|
|
200
|
+
if not top <= y <= top + window_height:
|
|
201
|
+
imgui.set_scroll_y(max(0.0, imgui.get_scroll_y() + (y - top if y < top else y - top - window_height)))
|
|
202
|
+
if not left <= x <= left + width:
|
|
203
|
+
imgui.set_scroll_x(max(0.0, imgui.get_scroll_x() + (x - left if x < left else x - left - width)))
|
|
204
|
+
if hovered:
|
|
205
|
+
imgui.set_mouse_cursor(imgui.MouseCursor.TEXT_INPUT)
|
|
206
|
+
|
|
207
|
+
focused = imgui.is_window_focused()
|
|
208
|
+
editor = _keyboard(editor, events, max(1, int(size[1] / height) - 1), focused)
|
|
209
|
+
if editor.text != "\n".join(lines):
|
|
210
|
+
lines = editor.text.split("\n")
|
|
211
|
+
spans = highlight(editor.text)
|
|
212
|
+
|
|
213
|
+
row, col = textedit.line_col(editor.text, editor.cursor)
|
|
214
|
+
cursor_x = gutter + len(lines[row][:col].expandtabs(4)) * char_width
|
|
215
|
+
cursor_y = row * height
|
|
216
|
+
if editor.reveal: # scroll the cursor into view once
|
|
217
|
+
scroll_x, scroll_y = imgui.get_scroll_x(), imgui.get_scroll_y()
|
|
218
|
+
window_w, window_h = imgui.get_window_size()
|
|
219
|
+
if cursor_y < scroll_y or cursor_y + 2 * height > scroll_y + window_h:
|
|
220
|
+
imgui.set_scroll_y(max(0.0, cursor_y - window_h / 2))
|
|
221
|
+
if cursor_x < scroll_x or cursor_x + 3 * char_width > scroll_x + window_w:
|
|
222
|
+
imgui.set_scroll_x(max(0.0, cursor_x - window_w / 2))
|
|
223
|
+
editor = replace(editor, reveal=False)
|
|
224
|
+
|
|
225
|
+
draw = imgui.get_window_draw_list()
|
|
226
|
+
color = imgui.get_color_u32
|
|
227
|
+
lo, hi = textedit.selection(editor)
|
|
228
|
+
first = max(0, int(imgui.get_scroll_y() / height) - 1)
|
|
229
|
+
position = sum(len(line) + 1 for line in lines[:first])
|
|
230
|
+
for index in range(first, min(len(lines), first + int(size[1] / height) + 3)):
|
|
231
|
+
line, y = lines[index], origin[1] + index * height
|
|
232
|
+
right = origin[0] + content_width
|
|
233
|
+
if index == row:
|
|
234
|
+
draw.add_rect_filled((origin[0], y), (right, y + height), color(CURRENT_LINE))
|
|
235
|
+
if index + 1 == error_line:
|
|
236
|
+
draw.add_rect_filled((origin[0], y), (right, y + height), color(ERROR_LINE))
|
|
237
|
+
if lo < hi and hi > position and lo <= position + len(line):
|
|
238
|
+
a = len(line[:max(0, lo - position)].expandtabs(4))
|
|
239
|
+
b = len(line[:max(0, hi - position)].expandtabs(4)) + (hi > position + len(line))
|
|
240
|
+
draw.add_rect_filled((origin[0] + gutter + a * char_width, y),
|
|
241
|
+
(origin[0] + gutter + b * char_width, y + height), color(SELECTION))
|
|
242
|
+
number = str(index + 1)
|
|
243
|
+
draw.add_text((origin[0] + gutter - (len(number) + 1) * char_width, y), color(COLORS["comment"]), number)
|
|
244
|
+
prefix = ""
|
|
245
|
+
for text, kind in spans[index]:
|
|
246
|
+
before = len(prefix.expandtabs(4))
|
|
247
|
+
prefix += text
|
|
248
|
+
draw.add_text((origin[0] + gutter + before * char_width, y), color(COLORS[kind]),
|
|
249
|
+
prefix.expandtabs(4)[before:])
|
|
250
|
+
position += len(line) + 1
|
|
251
|
+
if focused and imgui.get_time() % 1.1 < 0.7: # blinking cursor
|
|
252
|
+
x, y = origin[0] + cursor_x, origin[1] + cursor_y
|
|
253
|
+
draw.add_line((x, y), (x, y + height - 2.0), color(COLORS["text"]), 1.0)
|
|
254
|
+
return editor
|
|
255
|
+
finally:
|
|
256
|
+
imgui.end_child()
|