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/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,7 @@
1
+ """The gearbox of gearbox.py: parts from parts.py composed with normal imports."""
2
+ from pycodecad import show
3
+
4
+ from gearbox import gearbox
5
+
6
+ for name, parts, color in gearbox():
7
+ show(parts, name=name, color=color)
@@ -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,6 @@
1
+ """One loose part made to measure: expose() gives the window a slider per parameter of gear()."""
2
+ from pycodecad import expose, show
3
+
4
+ from parts import gear
5
+
6
+ show(expose(gear), name="gear", color="#F4B02A")
@@ -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))
@@ -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