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/embed.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""pycodecad's parts as Dear ImGui components for your own app (experimental in 1.0: the names may
|
|
2
|
+
change in a minor version). See docs/embedding.md.
|
|
3
|
+
|
|
4
|
+
window = create_window("My app")
|
|
5
|
+
part = Workspace("part.py") # code + parameters + 3D view, like the pycodecad window
|
|
6
|
+
viewer = Viewer() # only a 3D view
|
|
7
|
+
while window.frame(keep_open=part.dirty()):
|
|
8
|
+
imgui.begin("Part"); part.draw(); part.close_prompt(window); imgui.end()
|
|
9
|
+
imgui.begin("Preview"); viewer.draw(part.shown); imgui.end()
|
|
10
|
+
window.close()
|
|
11
|
+
"""
|
|
12
|
+
from .viewer import Viewer
|
|
13
|
+
from .window import Window, create_window
|
|
14
|
+
from .workspace import Workspace
|
|
15
|
+
|
|
16
|
+
__all__ = ["Viewer", "Window", "Workspace", "create_window"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="20mm" viewBox="0 0 20 20"><path d="M0 0 H20 V5 H5 V20 H0 Z"/></svg>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
solid pyramid
|
|
2
|
+
facet normal 0 0 -1
|
|
3
|
+
outer loop
|
|
4
|
+
vertex 30 -10 0
|
|
5
|
+
vertex 50 10 0
|
|
6
|
+
vertex 50 -10 0
|
|
7
|
+
endloop
|
|
8
|
+
endfacet
|
|
9
|
+
facet normal 0 0 -1
|
|
10
|
+
outer loop
|
|
11
|
+
vertex 30 -10 0
|
|
12
|
+
vertex 30 10 0
|
|
13
|
+
vertex 50 10 0
|
|
14
|
+
endloop
|
|
15
|
+
endfacet
|
|
16
|
+
facet normal 0 -0.83205 0.5547
|
|
17
|
+
outer loop
|
|
18
|
+
vertex 30 -10 0
|
|
19
|
+
vertex 50 -10 0
|
|
20
|
+
vertex 40 0 15
|
|
21
|
+
endloop
|
|
22
|
+
endfacet
|
|
23
|
+
facet normal 0.83205 -0 0.5547
|
|
24
|
+
outer loop
|
|
25
|
+
vertex 50 -10 0
|
|
26
|
+
vertex 50 10 0
|
|
27
|
+
vertex 40 0 15
|
|
28
|
+
endloop
|
|
29
|
+
endfacet
|
|
30
|
+
facet normal 0 0.83205 0.5547
|
|
31
|
+
outer loop
|
|
32
|
+
vertex 50 10 0
|
|
33
|
+
vertex 30 10 0
|
|
34
|
+
vertex 40 0 15
|
|
35
|
+
endloop
|
|
36
|
+
endfacet
|
|
37
|
+
facet normal -0.83205 0 0.5547
|
|
38
|
+
outer loop
|
|
39
|
+
vertex 30 10 0
|
|
40
|
+
vertex 30 -10 0
|
|
41
|
+
vertex 40 0 15
|
|
42
|
+
endloop
|
|
43
|
+
endfacet
|
|
44
|
+
endsolid pyramid
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""An order desk built on pycodecad's ImGui components (pycodecad.embed, experimental in 1.0): pick an
|
|
2
|
+
order and the tray of tray.py is rebuilt to its measures; export it for the printer.
|
|
3
|
+
|
|
4
|
+
python examples/embedded_app.py # the app
|
|
5
|
+
python examples/embedded_app.py shot.png # hidden: save a picture after the first run, then quit
|
|
6
|
+
|
|
7
|
+
tray.py opens read only (a template): the orders never change it. Exports are written next
|
|
8
|
+
to it, one file per order (order_1041.stl ...).
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from slimgui import imgui
|
|
14
|
+
|
|
15
|
+
import pycodecad.embed as p3
|
|
16
|
+
|
|
17
|
+
ORDERS = ( # number, customer, width, depth, height (mm), rounded corners
|
|
18
|
+
("1041", "Cafe Norte: cutlery", 120.0, 80.0, 30.0, True),
|
|
19
|
+
("1042", "Workshop: screws", 60.0, 40.0, 20.0, False),
|
|
20
|
+
("1043", "Studio: pencils", 180.0, 50.0, 25.0, True),
|
|
21
|
+
("1044", "Kitchen: spices", 150.0, 150.0, 60.0, True),
|
|
22
|
+
)
|
|
23
|
+
FIRST_USE = imgui.Cond.FIRST_USE_EVER
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def place(title: str, x: float, y: float, width: float, height: float) -> None:
|
|
27
|
+
imgui.set_next_window_pos((x, y), FIRST_USE)
|
|
28
|
+
imgui.set_next_window_size((width, height), FIRST_USE)
|
|
29
|
+
imgui.begin(title)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(screenshot: str | None = None) -> None:
|
|
33
|
+
window = p3.create_window("Tray orders", size=(1400, 860), visible=screenshot is None)
|
|
34
|
+
part = p3.Workspace(Path(__file__).with_name("tray.py"), read_only=True, run=False)
|
|
35
|
+
part.show_files = False # only the template, not the other files of its folder
|
|
36
|
+
preview = p3.Viewer() # the same tray from the front, with a camera of its own
|
|
37
|
+
preview.look_from((0.0, -1.0, 0.0))
|
|
38
|
+
chosen = None
|
|
39
|
+
while window.frame(keep_open=part.dirty()):
|
|
40
|
+
place("Template", 350, 10, 1040, 840) # first: draw() updates it, what follows sees this frame's state
|
|
41
|
+
part.draw()
|
|
42
|
+
part.close_prompt(window)
|
|
43
|
+
imgui.end()
|
|
44
|
+
|
|
45
|
+
place("Orders", 10, 10, 330, 400)
|
|
46
|
+
for index, (number, customer, width, depth, height, rounded) in enumerate(ORDERS):
|
|
47
|
+
if imgui.selectable(f"{number} {customer}", chosen == index)[0] or (screenshot and chosen is None):
|
|
48
|
+
chosen = index
|
|
49
|
+
part.values.update({"tray.width": width, "tray.depth": depth, "tray.height": height,
|
|
50
|
+
"tray.rounded": rounded})
|
|
51
|
+
part.run() # explicit, as always
|
|
52
|
+
if chosen is not None:
|
|
53
|
+
number, _, width, depth, height, _ = ORDERS[chosen]
|
|
54
|
+
imgui.separator()
|
|
55
|
+
imgui.text(f"Order {number}: {width:g} x {depth:g} x {height:g} mm")
|
|
56
|
+
imgui.begin_disabled(part.running() or part.error is not None)
|
|
57
|
+
if imgui.button("Export STL"):
|
|
58
|
+
part.export("stl", target=f"order_{number}.stl")
|
|
59
|
+
imgui.same_line()
|
|
60
|
+
if imgui.button("Export 3MF (Bambu)"):
|
|
61
|
+
part.export("3mf", "bambu", target=f"order_{number}.3mf")
|
|
62
|
+
imgui.end_disabled()
|
|
63
|
+
imgui.text_wrapped("Running..." if part.running() else part.error or part.message)
|
|
64
|
+
imgui.end()
|
|
65
|
+
|
|
66
|
+
place("Preview", 10, 420, 330, 430)
|
|
67
|
+
preview.draw(part.shown)
|
|
68
|
+
imgui.end()
|
|
69
|
+
|
|
70
|
+
if screenshot and part.ran() and not part.running():
|
|
71
|
+
window.screenshot(screenshot)
|
|
72
|
+
window.request_close()
|
|
73
|
+
window.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main(*sys.argv[1:2])
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The parts composed into a gearbox: two meshing gears on shafts, on a plate held by brackets.
|
|
2
|
+
Each part is built once here; gearbox() only places them, so an animation of it is cheap."""
|
|
3
|
+
from build123d import Pos, Rot
|
|
4
|
+
from build123d.topology import Shape
|
|
5
|
+
|
|
6
|
+
from parts import bracket, gear, pitch_radius, plate, shaft
|
|
7
|
+
|
|
8
|
+
SMALL, LARGE, MODULE = 12, 24, 2.0
|
|
9
|
+
BACKLASH = 0.3
|
|
10
|
+
DISTANCE = pitch_radius(SMALL, MODULE) + pitch_radius(LARGE, MODULE) + BACKLASH
|
|
11
|
+
AXES = [(-DISTANCE / 2, 0.0), (DISTANCE / 2, 0.0)]
|
|
12
|
+
PLATE = 5.0
|
|
13
|
+
|
|
14
|
+
PINION, WHEEL = gear(SMALL, MODULE), gear(LARGE, MODULE)
|
|
15
|
+
SHAFT, BRACKET = shaft(6.0, 30.0), bracket(40.0, 4.0)
|
|
16
|
+
WIDTH = DISTANCE + 80
|
|
17
|
+
BASE = plate(WIDTH, 70, PLATE, AXES)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def gearbox(angle: float = 0.0) -> list[tuple[str, Shape | list[Shape], str]]:
|
|
21
|
+
"""(name, parts, color) with the pinion turned by angle degrees (the wheel follows)."""
|
|
22
|
+
(x0, y0), (x1, y1) = AXES
|
|
23
|
+
half_tooth = 180 / LARGE
|
|
24
|
+
return [
|
|
25
|
+
("plate", BASE, "#9AA3AB"),
|
|
26
|
+
("shaft", [Pos(x, y, PLATE) * SHAFT for x, y in AXES], "#D0D4D8"),
|
|
27
|
+
("pinion", Pos(x0, y0, PLATE + 12) * Rot(0, 0, angle) * PINION, "#F4B02A"),
|
|
28
|
+
("wheel", Pos(x1, y1, PLATE + 12) * Rot(0, 0, half_tooth - angle * SMALL / LARGE) * WHEEL, "#6947AE"),
|
|
29
|
+
("bracket", [Pos(-WIDTH / 2, 0, 0) * Rot(0, 0, 180) * BRACKET, Pos(WIDTH / 2, 0, 0) * BRACKET], "#48B5A4"),
|
|
30
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""An animation with frame(): the gearbox turning. The parts are built once (in gearbox.py) and
|
|
2
|
+
only placed in each frame, so 90 frames cost little more than one."""
|
|
3
|
+
from pycodecad import clear, frame, show
|
|
4
|
+
|
|
5
|
+
from gearbox import gearbox
|
|
6
|
+
|
|
7
|
+
for step in range(90):
|
|
8
|
+
clear()
|
|
9
|
+
for name, parts, color in gearbox(angle=360 * step / 90):
|
|
10
|
+
show(parts, name=name, color=color)
|
|
11
|
+
frame()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Extruded SVG next to a reference STL. The paths start from this file's folder, so the script
|
|
2
|
+
also works with plain `python examples/import_files.py` from any directory."""
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from build123d import extrude, import_svg
|
|
6
|
+
from pycodecad import import_mesh, show
|
|
7
|
+
|
|
8
|
+
assets = Path(__file__).parent / "assets"
|
|
9
|
+
height = 3.0
|
|
10
|
+
|
|
11
|
+
logo = extrude(import_svg(assets / "logo.svg"), amount=height) # pyright: ignore[reportArgumentType]
|
|
12
|
+
show(logo, name="logo", color="gold")
|
|
13
|
+
show(import_mesh(assets / "pyramid.stl"), name="reference", color="blue")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The loose parts: one function per part, no show(). The other scripts compose them."""
|
|
2
|
+
from math import cos, pi, sin
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from build123d import Align, Box, Cylinder, Polygon, Pos, extrude
|
|
6
|
+
from build123d.topology import Shape
|
|
7
|
+
from pytypehint import Label, Max, Min, Slider
|
|
8
|
+
|
|
9
|
+
BOTTOM = (Align.CENTER, Align.CENTER, Align.MIN)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pitch_radius(teeth: int, module: float) -> float:
|
|
13
|
+
return module * teeth / 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def gear(teeth: Annotated[int, Min(8), Max(60), Slider(), Label("Teeth")] = 20,
|
|
17
|
+
module: Annotated[float, Min(0.5), Max(4.0), Slider(), Label("Module")] = 2.0,
|
|
18
|
+
thickness: Annotated[float, Min(2.0), Max(30.0), Slider(), Label("Thickness")] = 8.0,
|
|
19
|
+
bore: Annotated[float, Min(2.0), Max(20.0), Label("Bore")] = 6.0) -> Shape:
|
|
20
|
+
"""Spur gear with straight flanks and a hub, centred on the origin, standing on z=0."""
|
|
21
|
+
root, tip = pitch_radius(teeth, module) - 1.25 * module, pitch_radius(teeth, module) + module
|
|
22
|
+
outline = [(r * cos(a), r * sin(a))
|
|
23
|
+
for i in range(teeth)
|
|
24
|
+
for r, a in ((root, (2 * i - 0.62) * pi / teeth), (tip, (2 * i - 0.3) * pi / teeth),
|
|
25
|
+
(tip, (2 * i + 0.3) * pi / teeth), (root, (2 * i + 0.62) * pi / teeth))]
|
|
26
|
+
body = extrude(Polygon(*outline, align=None), thickness)
|
|
27
|
+
body += Pos(0, 0, thickness) * Cylinder(bore, thickness / 2, align=BOTTOM)
|
|
28
|
+
return body - Cylinder(bore / 2, thickness * 4)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def shaft(diameter: float = 6.0, length: float = 30.0) -> Shape:
|
|
32
|
+
return Cylinder(diameter / 2, length, align=BOTTOM)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plate(width: float, depth: float, thickness: float, holes: list[tuple[float, float]]) -> Shape:
|
|
36
|
+
body = Box(width, depth, thickness, align=BOTTOM)
|
|
37
|
+
for x, y in holes:
|
|
38
|
+
body -= Pos(x, y, 0) * Cylinder(3.2, thickness, align=BOTTOM)
|
|
39
|
+
return body
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bracket(length: float = 40.0, thickness: float = 4.0) -> Shape:
|
|
43
|
+
"""L bracket with a mounting hole, its corner on the origin."""
|
|
44
|
+
base = Pos(length / 2, 0, thickness / 2) * Box(length, 20, thickness)
|
|
45
|
+
wall = Pos(thickness / 2, 0, 12) * Box(thickness, 20, 24)
|
|
46
|
+
return base + wall - Pos(length * 0.6, 0, thickness / 2) * Cylinder(3, thickness)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Open tray made to measure: expose() gives the window a control per parameter (units: mm).
|
|
2
|
+
|
|
3
|
+
Change the values in the window and press Run (Ctrl+R); outside the window the defaults are used,
|
|
4
|
+
or `--set` on the command line: pycodecad export tray.py tray.stl --set width=120
|
|
5
|
+
"""
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from build123d import Axis, Box, Pos, fillet
|
|
9
|
+
from pytypehint import Description, Label, Max, Min, Slider
|
|
10
|
+
from pycodecad import expose, show
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def tray(width: Annotated[float, Min(20.0), Max(200.0), Slider(), Label("Width")] = 80.0,
|
|
14
|
+
depth: Annotated[float, Min(20.0), Max(200.0), Slider(), Label("Depth")] = 60.0,
|
|
15
|
+
height: Annotated[float, Min(5.0), Max(100.0), Slider(), Label("Height")] = 25.0,
|
|
16
|
+
wall: Annotated[float, Min(0.8), Max(5.0), Label("Wall"),
|
|
17
|
+
Description("Also the thickness of the floor")] = 2.0,
|
|
18
|
+
rounded: Annotated[bool, Label("Rounded corners")] = True):
|
|
19
|
+
"""A box without lid: the outside measures width x depth x height, the floor is one wall thick."""
|
|
20
|
+
outside = Pos(0, 0, height / 2) * Box(width, depth, height)
|
|
21
|
+
inside = Pos(0, 0, wall + height / 2) * Box(width - 2 * wall, depth - 2 * wall, height)
|
|
22
|
+
if rounded:
|
|
23
|
+
outside = fillet(outside.edges().filter_by(Axis.Z), radius=min(width, depth) / 8)
|
|
24
|
+
inside = fillet(inside.edges().filter_by(Axis.Z), radius=max(min(width, depth) / 8 - wall, 0.5))
|
|
25
|
+
return outside - inside
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
show(expose(tray), name="tray", color="#4C9BE8")
|
pycodecad/files.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""CAD files: export the shown objects (STL, 3MF generic or Bambu Studio, STEP, BREP, GLB) and
|
|
2
|
+
read triangle meshes (STL, 3MF).
|
|
3
|
+
|
|
4
|
+
Exports run where the script ran, so they write the real build123d shapes, not preview triangles.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import struct
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
from xml.sax.saxutils import quoteattr
|
|
16
|
+
from zipfile import ZIP_DEFLATED, ZipFile
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .cad import Mesh, Shown, copy_shape, hex_color
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from build123d.topology import Shape
|
|
24
|
+
|
|
25
|
+
FORMATS = ("stl", "3mf", "step", "brep", "glb")
|
|
26
|
+
PROFILES = ("generic", "bambu")
|
|
27
|
+
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
|
|
28
|
+
MATERIAL = "http://schemas.microsoft.com/3dmanufacturing/material/2015/02"
|
|
29
|
+
CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def export(shown: list[Shown], path: str | Path, profile: str = "generic") -> Path:
|
|
33
|
+
"""Write the objects to `path`; the format comes from its extension."""
|
|
34
|
+
path = Path(path)
|
|
35
|
+
kind = path.suffix.lower().lstrip(".")
|
|
36
|
+
if kind not in FORMATS:
|
|
37
|
+
raise ValueError(f"Unknown export format {path.suffix!r}: use one of {', '.join(FORMATS)}")
|
|
38
|
+
if profile not in PROFILES:
|
|
39
|
+
raise ValueError(f"Unknown 3MF profile {profile!r}: use generic or bambu")
|
|
40
|
+
if not shown:
|
|
41
|
+
raise ValueError("Nothing to export: the script shows no objects")
|
|
42
|
+
if kind == "glb":
|
|
43
|
+
path.write_bytes(encode_glb(shown))
|
|
44
|
+
elif any(isinstance(obj.source, Mesh) for obj in shown):
|
|
45
|
+
_export_meshes(shown, path, kind)
|
|
46
|
+
else:
|
|
47
|
+
_export_shapes(shown, path, kind)
|
|
48
|
+
if kind == "3mf" and profile == "bambu":
|
|
49
|
+
adapt_bambu(path)
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _export_meshes(shown: list[Shown], path: Path, kind: str) -> None:
|
|
54
|
+
"""A scene with imported meshes: write triangles (STL/3MF only)."""
|
|
55
|
+
if kind in ("step", "brep"):
|
|
56
|
+
raise ValueError(f"{kind.upper()} cannot hold triangle meshes: export STL, 3MF or GLB, "
|
|
57
|
+
"or use import_mesh(path, solid=True) for small closed meshes")
|
|
58
|
+
if kind == "stl":
|
|
59
|
+
write_stl([obj.world() for obj in shown], path)
|
|
60
|
+
else:
|
|
61
|
+
write_3mf(shown, path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _export_shapes(shown: list[Shown], path: Path, kind: str) -> None:
|
|
65
|
+
import build123d as b
|
|
66
|
+
|
|
67
|
+
if kind == "3mf":
|
|
68
|
+
mesher = b.Mesher()
|
|
69
|
+
mesher.add_shape([_labeled(obj) for obj in shown]) # one named, colored 3MF object each
|
|
70
|
+
mesher.write(str(path))
|
|
71
|
+
return
|
|
72
|
+
# Independent wrappers keep the source shapes unparented without copying their geometry.
|
|
73
|
+
sources = [_shape(obj) for obj in shown]
|
|
74
|
+
shape = sources[0] if len(sources) == 1 else b.Compound(children=[copy_shape(s) for s in sources])
|
|
75
|
+
writer = {"stl": b.export_stl, "step": b.export_step, "brep": b.export_brep}[kind]
|
|
76
|
+
if not writer(shape, str(path)):
|
|
77
|
+
raise RuntimeError(f"Could not write {path}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _shape(obj: Shown) -> Shape:
|
|
81
|
+
# Without meshes in the scene every source is the shape that show() received.
|
|
82
|
+
assert obj.source is not None and not isinstance(obj.source, Mesh)
|
|
83
|
+
return obj.source
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _labeled(obj: Shown) -> Shape:
|
|
87
|
+
import build123d as b
|
|
88
|
+
|
|
89
|
+
# A childless Compound stays one mesh; Mesher would split a compound with children.
|
|
90
|
+
source = _shape(obj)
|
|
91
|
+
shape = b.Compound(source.wrapped) if isinstance(source, b.Compound) else copy_shape(source)
|
|
92
|
+
shape.label = obj.name
|
|
93
|
+
shape.color = b.Color(*obj.color)
|
|
94
|
+
return shape
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- triangle meshes -------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
STL_TRIANGLE = np.dtype([("normal", "<f4", (3,)), ("points", "<f4", (3, 3)), ("attribute", "<u2")])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_triangles(path) -> np.ndarray:
|
|
103
|
+
"""Vertices of an STL (binary or ASCII) or 3MF file, 3 rows per triangle, in millimetres."""
|
|
104
|
+
path = Path(path)
|
|
105
|
+
if path.suffix.lower() == ".stl":
|
|
106
|
+
data = path.read_bytes()
|
|
107
|
+
if len(data) >= 84 and len(data) == 84 + struct.unpack_from("<I", data, 80)[0] * 50:
|
|
108
|
+
return np.frombuffer(data, dtype=STL_TRIANGLE, offset=84)["points"].reshape(-1, 3).copy()
|
|
109
|
+
vertices = re.findall(rb"\bvertex\s+(\S+\s+\S+\s+\S+)", data)
|
|
110
|
+
if not data.lstrip().lower().startswith(b"solid") or not vertices or len(vertices) % 3:
|
|
111
|
+
raise ValueError(f"{path}: not a binary or ASCII STL file")
|
|
112
|
+
return np.array(b" ".join(vertices).split(), dtype=np.float32).reshape(-1, 3)
|
|
113
|
+
if path.suffix.lower() != ".3mf":
|
|
114
|
+
raise ValueError(f"{path}: meshes must be STL or 3MF files")
|
|
115
|
+
from build123d import Mesher
|
|
116
|
+
|
|
117
|
+
reader = Mesher()
|
|
118
|
+
assert reader.model is not None # Mesher() always creates its lib3mf model
|
|
119
|
+
reader.model.QueryReader("3mf").ReadFromFile(str(path))
|
|
120
|
+
model = reader.model.MergeToModel() # resolves components and their transforms
|
|
121
|
+
meshes, points = model.GetMeshObjects(), []
|
|
122
|
+
while meshes.MoveNext():
|
|
123
|
+
mesh = meshes.GetCurrentMeshObject()
|
|
124
|
+
vertices = np.array([vertex.Coordinates[:] for vertex in mesh.GetVertices()], dtype=np.float32)
|
|
125
|
+
triangles = np.array([triangle.Indices[:] for triangle in mesh.GetTriangleIndices()], dtype=np.int64)
|
|
126
|
+
if triangles.size:
|
|
127
|
+
points.append(vertices[triangles].reshape(-1, 3))
|
|
128
|
+
unit = (0.001, 1.0, 10.0, 25.4, 304.8, 1000.0)[model.GetUnit()] # lib3mf units, in mm
|
|
129
|
+
return np.concatenate(points) * unit if points else np.empty((0, 3), dtype=np.float32)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_stl(meshes: list[Mesh], path) -> None:
|
|
133
|
+
triangles = np.zeros(sum(len(mesh.positions) // 3 for mesh in meshes), dtype=STL_TRIANGLE)
|
|
134
|
+
triangles["points"] = np.concatenate([mesh.positions for mesh in meshes]).reshape(-1, 3, 3)
|
|
135
|
+
triangles["normal"] = np.concatenate([mesh.normals[::3] for mesh in meshes])
|
|
136
|
+
Path(path).write_bytes(b"pycodecad".ljust(80, b"\0") + struct.pack("<I", len(triangles)) + triangles.tobytes())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_3mf(shown: list[Shown], path) -> None:
|
|
140
|
+
"""A plain 3MF of triangle meshes: one named, colored object per shown object."""
|
|
141
|
+
with ZipFile(path, "w", ZIP_DEFLATED, compresslevel=1) as archive:
|
|
142
|
+
archive.writestr("[Content_Types].xml", f'<Types xmlns="{CONTENT_TYPES}"><Default Extension="rels" '
|
|
143
|
+
'ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default '
|
|
144
|
+
'Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>')
|
|
145
|
+
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/'
|
|
146
|
+
'relationships"><Relationship Id="rel0" Target="/3D/3dmodel.model" Type="http://schemas.'
|
|
147
|
+
'microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>')
|
|
148
|
+
with archive.open("3D/3dmodel.model", "w") as model:
|
|
149
|
+
def write(text: str) -> None:
|
|
150
|
+
model.write(text.encode())
|
|
151
|
+
|
|
152
|
+
def write_rows(template: str, rows: np.ndarray) -> None:
|
|
153
|
+
for start in range(0, len(rows), 8192): # format in blocks: fast for big meshes
|
|
154
|
+
block = rows[start:start + 8192]
|
|
155
|
+
write((template * len(block)) % tuple(block.ravel().tolist()))
|
|
156
|
+
|
|
157
|
+
write(f'<model xmlns="{CORE}" unit="millimeter"><resources><basematerials id="1">')
|
|
158
|
+
for obj in shown:
|
|
159
|
+
write(f'<base name={quoteattr(obj.name)} displaycolor="{hex_color(obj.color)}FF"/>')
|
|
160
|
+
write("</basematerials>")
|
|
161
|
+
for index, obj in enumerate(shown):
|
|
162
|
+
vertices, triangles = np.unique(obj.world().positions, axis=0, return_inverse=True)
|
|
163
|
+
triangles = triangles.reshape(-1, 3)
|
|
164
|
+
# 3MF forbids triangles that repeat a vertex; STL files often have such degenerate ones.
|
|
165
|
+
triangles = triangles[(triangles[:, 0] != triangles[:, 1]) & (triangles[:, 1] != triangles[:, 2])
|
|
166
|
+
& (triangles[:, 0] != triangles[:, 2])]
|
|
167
|
+
write(f'<object id="{index + 2}" type="model" name={quoteattr(obj.name)} pid="1" pindex="{index}">'
|
|
168
|
+
"<mesh><vertices>")
|
|
169
|
+
write_rows('<vertex x="%.9g" y="%.9g" z="%.9g"/>', vertices)
|
|
170
|
+
write("</vertices><triangles>")
|
|
171
|
+
write_rows('<triangle v1="%d" v2="%d" v3="%d"/>', triangles)
|
|
172
|
+
write("</triangles></mesh></object>")
|
|
173
|
+
write("</resources><build>" + "".join(f'<item objectid="{i + 2}"/>' for i in range(len(shown))) + "</build></model>")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- Bambu Studio 3MF ----------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def adapt_bambu(path: Path) -> None:
|
|
179
|
+
"""Rewrite colors and object names the way Bambu Studio reads them. Geometry only: no printer
|
|
180
|
+
or filament presets are embedded."""
|
|
181
|
+
with ZipFile(path) as archive:
|
|
182
|
+
entries = {name: archive.read(name) for name in archive.namelist()}
|
|
183
|
+
ET.register_namespace("", CORE)
|
|
184
|
+
ET.register_namespace("m", MATERIAL)
|
|
185
|
+
ET.register_namespace("p", "http://schemas.microsoft.com/3dmanufacturing/production/2015/06")
|
|
186
|
+
model = ET.fromstring(entries["3D/3dmodel.model"])
|
|
187
|
+
for materials in model.findall("{*}resources/{*}basematerials"):
|
|
188
|
+
materials.tag = f"{{{MATERIAL}}}colorgroup"
|
|
189
|
+
for color in materials:
|
|
190
|
+
value = color.attrib["displaycolor"]
|
|
191
|
+
color.tag = f"{{{MATERIAL}}}color"
|
|
192
|
+
color.attrib.clear()
|
|
193
|
+
color.set("color", value)
|
|
194
|
+
config = ET.Element("config")
|
|
195
|
+
for obj in model.findall("{*}resources/{*}object"):
|
|
196
|
+
if "name" in obj.attrib:
|
|
197
|
+
item = ET.SubElement(config, "object", id=obj.attrib["id"])
|
|
198
|
+
ET.SubElement(item, "metadata", key="name", value=obj.attrib["name"])
|
|
199
|
+
application = ET.Element(f"{{{CORE}}}metadata", name="Application")
|
|
200
|
+
application.text = "pycodecad"
|
|
201
|
+
model.insert(0, application)
|
|
202
|
+
ET.register_namespace("ct", CONTENT_TYPES)
|
|
203
|
+
types = ET.fromstring(entries["[Content_Types].xml"])
|
|
204
|
+
ET.SubElement(types, f"{{{CONTENT_TYPES}}}Default", Extension="config", ContentType="application/xml")
|
|
205
|
+
entries["3D/3dmodel.model"] = ET.tostring(model, encoding="utf-8", xml_declaration=True)
|
|
206
|
+
entries["Metadata/model_settings.config"] = ET.tostring(config, encoding="utf-8", xml_declaration=True)
|
|
207
|
+
entries["[Content_Types].xml"] = ET.tostring(types, encoding="utf-8", xml_declaration=True)
|
|
208
|
+
output = BytesIO()
|
|
209
|
+
with ZipFile(output, "w", ZIP_DEFLATED) as archive:
|
|
210
|
+
for name, data in entries.items():
|
|
211
|
+
archive.writestr(name, data)
|
|
212
|
+
path.write_bytes(output.getvalue())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --- GLB (glTF 2.0 binary) -----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def encode_glb(shown: list[Shown]) -> bytes:
|
|
218
|
+
"""The preview triangles and edges, converted from millimetres/Z-up to glTF metres/Y-up."""
|
|
219
|
+
doc: dict[str, Any] = dict(asset=dict(version="2.0", generator="pycodecad"), scene=0, scenes=[dict(nodes=[])],
|
|
220
|
+
nodes=[], meshes=[], materials=[], accessors=[], bufferViews=[], buffers=[])
|
|
221
|
+
binary = bytearray()
|
|
222
|
+
|
|
223
|
+
def accessor(values: np.ndarray, normal: bool = False) -> int:
|
|
224
|
+
data = np.asarray(values, dtype="<f4")[:, [0, 2, 1]].copy()
|
|
225
|
+
data[:, 2] *= -1
|
|
226
|
+
if not normal:
|
|
227
|
+
data *= 0.001
|
|
228
|
+
doc["bufferViews"].append(dict(buffer=0, byteOffset=len(binary), byteLength=data.nbytes, target=34962))
|
|
229
|
+
binary.extend(data.tobytes())
|
|
230
|
+
result = dict(bufferView=len(doc["bufferViews"]) - 1, componentType=5126, count=len(data), type="VEC3")
|
|
231
|
+
if not normal:
|
|
232
|
+
result.update(min=data.min(axis=0).tolist(), max=data.max(axis=0).tolist())
|
|
233
|
+
doc["accessors"].append(result)
|
|
234
|
+
return len(doc["accessors"]) - 1
|
|
235
|
+
|
|
236
|
+
def linear(c: float) -> float:
|
|
237
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
238
|
+
|
|
239
|
+
for obj in shown:
|
|
240
|
+
material = len(doc["materials"])
|
|
241
|
+
doc["materials"].append(dict(name=obj.name, doubleSided=True, pbrMetallicRoughness=dict(
|
|
242
|
+
baseColorFactor=[*map(linear, obj.color), 1], metallicFactor=0, roughnessFactor=0.6)))
|
|
243
|
+
primitives = []
|
|
244
|
+
mesh = obj.world()
|
|
245
|
+
if len(mesh.positions):
|
|
246
|
+
attributes = dict(POSITION=accessor(mesh.positions), NORMAL=accessor(mesh.normals, True))
|
|
247
|
+
primitives.append(dict(attributes=attributes, mode=4, material=material))
|
|
248
|
+
if len(mesh.edges):
|
|
249
|
+
primitives.append(dict(attributes=dict(POSITION=accessor(mesh.edges)), mode=1, material=material))
|
|
250
|
+
if primitives:
|
|
251
|
+
doc["meshes"].append(dict(name=obj.name, primitives=primitives))
|
|
252
|
+
doc["nodes"].append(dict(name=obj.name, mesh=len(doc["meshes"]) - 1))
|
|
253
|
+
doc["scenes"][0]["nodes"].append(len(doc["nodes"]) - 1)
|
|
254
|
+
if not binary:
|
|
255
|
+
raise ValueError("Nothing to export: the objects have no geometry")
|
|
256
|
+
doc["buffers"].append(dict(byteLength=len(binary)))
|
|
257
|
+
payload = json.dumps(doc, separators=(",", ":")).encode()
|
|
258
|
+
payload += b" " * (-len(payload) % 4)
|
|
259
|
+
binary.extend(b"\0" * (-len(binary) % 4))
|
|
260
|
+
return (struct.pack("<III", 0x46546C67, 2, 28 + len(payload) + len(binary))
|
|
261
|
+
+ struct.pack("<I4s", len(payload), b"JSON") + payload
|
|
262
|
+
+ struct.pack("<I4s", len(binary), b"BIN\0") + bytes(binary))
|
pycodecad/icons/LICENSE
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucide Icons and Contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
The following Lucide icons are derived from the Feather project:
|
|
20
|
+
|
|
21
|
+
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
|
22
|
+
|
|
23
|
+
The MIT License (MIT) (for the icons listed above)
|
|
24
|
+
|
|
25
|
+
Copyright (c) 2013-present Cole Bemis
|
|
26
|
+
|
|
27
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
28
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
29
|
+
in the Software without restriction, including without limitation the rights
|
|
30
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
31
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
32
|
+
furnished to do so, subject to the following conditions:
|
|
33
|
+
|
|
34
|
+
The above copyright notice and this permission notice shall be included in all
|
|
35
|
+
copies or substantial portions of the Software.
|
|
36
|
+
|
|
37
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
38
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
39
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
40
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
41
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
42
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
43
|
+
SOFTWARE.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Button icons: the Lucide icon font (https://lucide.dev, ISC license, see LICENSE here), merged into
|
|
2
|
+
the ImGui font so an icon is just a character in a label: imgui.button(icon("play"))."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
# Lucide 1.48.0 codepoints of the icons the window uses (the font has them all; only these are named).
|
|
8
|
+
CODEPOINTS = {
|
|
9
|
+
"play": 0xE13C,
|
|
10
|
+
"pause": 0xE12E,
|
|
11
|
+
"square": 0xE167,
|
|
12
|
+
"save": 0xE14D,
|
|
13
|
+
"file-plus-2": 0xE0CA,
|
|
14
|
+
"sparkles": 0xE412,
|
|
15
|
+
"copy": 0xE09E,
|
|
16
|
+
"clipboard-paste": 0xE3E8,
|
|
17
|
+
"download": 0xE0B2,
|
|
18
|
+
"refresh-cw": 0xE145,
|
|
19
|
+
"scan": 0xE257,
|
|
20
|
+
"settings-2": 0xE245,
|
|
21
|
+
"rotate-ccw": 0xE148,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def icon(name: str) -> str:
|
|
26
|
+
"""The character that draws the icon (a KeyError names an icon missing from CODEPOINTS)."""
|
|
27
|
+
return chr(CODEPOINTS[name])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def font_data() -> bytes:
|
|
31
|
+
return resources.files(__name__).joinpath("lucide.ttf").read_bytes()
|
|
Binary file
|