slicerbridge 0.2.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.
Files changed (39) hide show
  1. slicerbridge/__init__.py +3 -0
  2. slicerbridge/__main__.py +5 -0
  3. slicerbridge/_vendor/__init__.py +1 -0
  4. slicerbridge/_vendor/panda2prusa/LICENSE +21 -0
  5. slicerbridge/_vendor/panda2prusa/VENDORED.md +13 -0
  6. slicerbridge/_vendor/panda2prusa/__init__.py +7 -0
  7. slicerbridge/_vendor/panda2prusa/autobrim.py +149 -0
  8. slicerbridge/_vendor/panda2prusa/convert.py +168 -0
  9. slicerbridge/_vendor/panda2prusa/ns.py +26 -0
  10. slicerbridge/_vendor/panda2prusa/paint.py +104 -0
  11. slicerbridge/_vendor/panda2prusa/prusa.py +457 -0
  12. slicerbridge/_vendor/panda2prusa/reader.py +323 -0
  13. slicerbridge/_vendor/panda2prusa/settings.py +364 -0
  14. slicerbridge/_vendor/panda2prusa/transform.py +122 -0
  15. slicerbridge/_vendor/panda2prusa/writer.py +930 -0
  16. slicerbridge/brandcheck.py +83 -0
  17. slicerbridge/checks/__init__.py +1 -0
  18. slicerbridge/checks/orca.py +639 -0
  19. slicerbridge/checks/u1.py +456 -0
  20. slicerbridge/cli.py +349 -0
  21. slicerbridge/data/u1/BL2U1_COMMIT +1 -0
  22. slicerbridge/data/u1/filament_types.json +15 -0
  23. slicerbridge/data/u1/u1_template.3mf +0 -0
  24. slicerbridge/data/u1/u1_template_supports.3mf +0 -0
  25. slicerbridge/engines/__init__.py +1 -0
  26. slicerbridge/engines/orca.py +938 -0
  27. slicerbridge/engines/prusa.py +1107 -0
  28. slicerbridge/engines/u1.py +669 -0
  29. slicerbridge/errors.py +10 -0
  30. slicerbridge/mcp_server.py +150 -0
  31. slicerbridge/printers.py +329 -0
  32. slicerbridge/templates.py +398 -0
  33. slicerbridge-0.2.0.dist-info/METADATA +207 -0
  34. slicerbridge-0.2.0.dist-info/RECORD +39 -0
  35. slicerbridge-0.2.0.dist-info/WHEEL +5 -0
  36. slicerbridge-0.2.0.dist-info/entry_points.txt +3 -0
  37. slicerbridge-0.2.0.dist-info/licenses/LICENSE +674 -0
  38. slicerbridge-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +22 -0
  39. slicerbridge-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """SlicerBridge: any multicolor 3MF project in, a ready file for every multicolor printer out."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Vendored third-party code; see THIRD_PARTY_NOTICES.md."""
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph (CupsOhJoe)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ # Panda2Prusa (vendored)
2
+
3
+ From https://github.com/liutang/Panda2Prusa at commit
4
+ `b6cd13143fb5ea29b9111a11bd329c7e25f25b8d` (2026-10-20), MIT License (LICENSE here).
5
+
6
+ Only the library modules are copied, unmodified: `__init__`, `ns`, `paint`,
7
+ `transform`, `reader`, `convert`, `writer`, `settings`, `autobrim`, `prusa`.
8
+ The web app, GUI and CLI are left out.
9
+
10
+ Vendored rather than installed because that commit's `pyproject.toml` can't be
11
+ built by pip (`keywords` and `classifiers` sit under
12
+ `[project.optional-dependencies]`). To update: copy the same modules from a
13
+ newer commit, update the hash above, and run the full test suite.
@@ -0,0 +1,7 @@
1
+ """panda2prusa - convert Bambu Studio / OrcaSlicer 3mf files to PrusaSlicer-compatible 3mf."""
2
+
3
+ from .convert import convert_file, ConversionResult
4
+ from .reader import read_bambu_3mf, BambuProject
5
+
6
+ __all__ = ["convert_file", "ConversionResult", "read_bambu_3mf", "BambuProject"]
7
+ __version__ = "1.0.0"
@@ -0,0 +1,149 @@
1
+ """Approximate Bambu Studio / OrcaSlicer's automatic brim width for PrusaSlicer.
2
+
3
+ Bambu's "Auto" brim type picks a brim width per object from how likely it is to tip
4
+ over (tall and slender, printed fast) or to warp (a large footprint in a shrinking
5
+ material). PrusaSlicer has no such mode, so the width is computed here with Orca's
6
+ formula (``configBrimWidthByVolumeGroups`` in Brim.cpp) and written as an outer brim.
7
+
8
+ Differences from Orca: the footprint is a cut of the mesh at half the first layer
9
+ height, not the sliced first layer after elephant foot compensation, and the whole
10
+ object is one group, where Orca computes a width per group of touching parts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from dataclasses import dataclass
17
+ from typing import Optional
18
+
19
+ # Orca's MaterialType table: filament type -> (adhesion coefficient, thermal length).
20
+ _MATERIALS = {
21
+ "ABS": (1, 100), "ABS-CF": (1, 100), "ABS-GF": (1, 100), "ASA": (1, 100), "ASA-CF": (1, 100),
22
+ "ASA-GF": (1, 100), "ASA-AERO": (1, 100), "BVOH": (1, 200), "CoPE": (1, 200), "EVA": (1, 200),
23
+ "FLEX": (0.5, 1000), "HIPS": (1, 200), "PA": (1, 100), "PA-CF": (1, 100), "PA-GF": (1, 100),
24
+ "PA6": (1, 100), "PA6-CF": (1, 100), "PA6-GF": (1, 100), "PA11": (1, 100), "PA11-CF": (1, 100),
25
+ "PA11-GF": (1, 100), "PA12": (1, 100), "PA12-CF": (1, 100), "PA12-GF": (1, 100),
26
+ "PAHT": (1, 200), "PAHT-CF": (1, 200), "PAHT-GF": (1, 200), "PC": (1, 40), "PC-ABS": (1, 80),
27
+ "PC-CF": (1, 80), "PC-PBT": (1, 40), "PCL": (1, 200), "PCTG": (2, 200), "PE": (1, 200),
28
+ "PE-CF": (1, 200), "PE-GF": (1, 200), "PEI-1010": (1, 200), "PEI-1010-CF": (1, 200),
29
+ "PEI-1010-GF": (1, 200), "PEI-9085": (1, 200), "PEI-9085-CF": (1, 200), "PEI-9085-GF": (1, 200),
30
+ "PEEK": (1, 200), "PEEK-CF": (1, 200), "PEEK-GF": (1, 200), "PEKK": (1, 200), "PEKK-CF": (1, 200),
31
+ "PES": (1, 200), "PET": (2, 100), "PET-CF": (2, 100), "PET-GF": (2, 100), "PETG": (2, 100),
32
+ "PETG-CF": (1, 100), "PETG-GF": (1, 100), "PHA": (1, 200), "PI": (1, 200), "PLA": (1, 200),
33
+ "PLA-AERO": (1, 200), "PLA-CF": (1, 200), "POM": (1, 200), "PP": (1, 200), "PP-CF": (1, 200),
34
+ "PP-GF": (1, 200), "PPA-CF": (1, 200), "PPA-GF": (1, 200), "PPS": (1, 200), "PPS-CF": (1, 200),
35
+ "PPSU": (1, 200), "PSU": (1, 200), "PVA": (1, 200), "PVB": (1, 200), "PVDF": (1, 200),
36
+ "SBS": (1, 200), "TPI": (1, 200), "TPU": (0.5, 1000),
37
+ }
38
+ _DEFAULT_MATERIAL = (1.0, 200.0)
39
+
40
+ # Speeds whose maximum feeds the tipping estimate (Orca's Model::findMaxSpeed).
41
+ SPEED_KEYS = ("inner_wall_speed", "outer_wall_speed", "sparse_infill_speed",
42
+ "internal_solid_infill_speed", "top_surface_speed", "support_speed")
43
+
44
+
45
+ def material(filament_type: Optional[str]) -> tuple[float, float]:
46
+ """(adhesion coefficient, thermal length in mm) of a filament type."""
47
+ return _MATERIALS.get(filament_type or "", _DEFAULT_MATERIAL)
48
+
49
+
50
+ @dataclass
51
+ class Footprint:
52
+ """Area properties of a horizontal cut through an object, in mm."""
53
+
54
+ area: float = 0.0
55
+ perimeter: float = 0.0
56
+ ixx: float = 0.0 # second moment of area about the horizontal axis through the centroid
57
+ iyy: float = 0.0 # ... about the vertical axis through the centroid
58
+ width_x: float = 0.0
59
+ width_y: float = 0.0
60
+
61
+
62
+ class FootprintBuilder:
63
+ """Accumulates a cut at height ``z`` from triangles, without joining the outlines.
64
+
65
+ Each triangle that crosses the plane adds one edge of the cut, oriented so the
66
+ material is on its left (from the triangle's outward normal). Area, first and second
67
+ moments are sums over edges (Green's theorem), so holes subtract on their own.
68
+ """
69
+
70
+ def __init__(self, z: float):
71
+ self.z = z
72
+ self.a = self.sx = self.sy = self.sxx = self.syy = self.length = 0.0
73
+ self.min_x = self.min_y = math.inf
74
+ self.max_x = self.max_y = -math.inf
75
+
76
+ def add_triangle(self, p1, p2, p3) -> None:
77
+ """Add a triangle with outward-facing counter-clockwise vertices (x, y, z)."""
78
+ z = self.z
79
+ pts = []
80
+ for a, b in ((p1, p2), (p2, p3), (p3, p1)):
81
+ if (a[2] < z) != (b[2] < z):
82
+ t = (z - a[2]) / (b[2] - a[2])
83
+ pts.append((a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])))
84
+ if len(pts) != 2:
85
+ return
86
+ (xa, ya), (xb, yb) = pts
87
+ # Outward normal's horizontal part; the edge runs along (-ny, nx).
88
+ ux, uy, uz = p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]
89
+ vx, vy, vz = p3[0] - p1[0], p3[1] - p1[1], p3[2] - p1[2]
90
+ nx, ny = uy * vz - uz * vy, uz * vx - ux * vz
91
+ if (xb - xa) * -ny + (yb - ya) * nx < 0:
92
+ xa, ya, xb, yb = xb, yb, xa, ya
93
+ cross = xa * yb - xb * ya
94
+ self.a += cross
95
+ self.sx += (xa + xb) * cross
96
+ self.sy += (ya + yb) * cross
97
+ self.sxx += (xa * xa + xa * xb + xb * xb) * cross
98
+ self.syy += (ya * ya + ya * yb + yb * yb) * cross
99
+ self.length += math.hypot(xb - xa, yb - ya)
100
+ self.min_x, self.max_x = min(self.min_x, xa, xb), max(self.max_x, xa, xb)
101
+ self.min_y, self.max_y = min(self.min_y, ya, yb), max(self.max_y, ya, yb)
102
+
103
+ def result(self) -> Optional[Footprint]:
104
+ area = self.a / 2
105
+ if area <= 1e-9:
106
+ return None
107
+ cx, cy = self.sx / (6 * area), self.sy / (6 * area)
108
+ return Footprint(
109
+ area=area,
110
+ perimeter=self.length,
111
+ ixx=self.syy / 12 - area * cy * cy,
112
+ iyy=self.sxx / 12 - area * cx * cx,
113
+ width_x=self.max_x - self.min_x,
114
+ width_y=self.max_y - self.min_y,
115
+ )
116
+
117
+
118
+ def brim_width(
119
+ footprint: Footprint,
120
+ height: float,
121
+ max_speed: float,
122
+ adhesion: float,
123
+ thermal_length_ref: float,
124
+ line_spacing: float,
125
+ ) -> float:
126
+ """Orca's automatic brim width in mm (0 for no brim), rounded to whole brim lines."""
127
+ fp = footprint
128
+ if fp.ixx <= 0 or fp.iyy <= 0:
129
+ return 0.0
130
+ thermal_length = math.hypot(fp.width_x, fp.width_y)
131
+ height_to_area = max(height / fp.ixx * fp.width_y, height / fp.iyy * fp.width_x) * height / 1920
132
+ raw = adhesion * min(
133
+ max(height_to_area * max_speed, thermal_length * 8.0 / thermal_length_ref * min(height, 30.0) / 30.0),
134
+ 18.0,
135
+ 1.5 * thermal_length,
136
+ )
137
+ if raw < 5 and raw < 1.5 * thermal_length:
138
+ raw = 0.0 # small brims are omitted
139
+ raw = min(raw, 18.0)
140
+
141
+ def lines(w: float) -> float:
142
+ return math.floor(w / line_spacing / 2) * line_spacing * 2 if line_spacing > 0 else w
143
+
144
+ width = lines(raw)
145
+ # A tall, thin part (area / perimeter under 1.1 mm) without a brim gets 5 mm.
146
+ if width < 5 and height > 10 and width < line_spacing and fp.area / fp.perimeter < 1.1:
147
+ width += lines(5.0)
148
+ # Never wider than the footprint itself.
149
+ return lines(min(width, max(fp.width_x, fp.width_y)))
@@ -0,0 +1,168 @@
1
+ """High-level conversion orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from . import ns
9
+ from .paint import used_states, PaintError
10
+ from .prusa import PrusaProject, is_prusa_3mf, read_prusa_3mf, remap_prusa_3mf
11
+ from .reader import read_bambu_3mf
12
+ from .writer import write_prusa_3mf, WriteStats
13
+
14
+
15
+ @dataclass
16
+ class ConversionResult:
17
+ input_path: str
18
+ output_path: str
19
+ stats: WriteStats
20
+ producer: str
21
+ plates_available: list
22
+ source: str = "bambu" # "prusa" when the input was remapped rather than converted
23
+
24
+
25
+ def used_filaments(project) -> list[int]:
26
+ """Sorted 1-based filament slots a project actually uses.
27
+
28
+ Union of object/part extruder assignments and every state referenced by
29
+ paint data. Bambu leaves unassigned objects on filament 1.
30
+ """
31
+ if isinstance(project, PrusaProject):
32
+ return list(project.used)
33
+ used: set[int] = set()
34
+ for so in project.settings_objects.values():
35
+ for ext in [so.extruder, *(p.extruder for p in so.parts)]:
36
+ if ext:
37
+ try:
38
+ used.add(int(ext))
39
+ except ValueError:
40
+ pass
41
+ codes: set[str] = set()
42
+ for tree in [project.root_tree, *project.object_trees.values()]:
43
+ for tri in tree.getroot().iter(ns.q(ns.CORE, "triangle")):
44
+ code = tri.get("paint_color")
45
+ if code:
46
+ codes.add(code)
47
+ for code in codes:
48
+ try:
49
+ used |= used_states(code)
50
+ except PaintError:
51
+ pass
52
+ used.discard(0)
53
+ if not used and (project.build_items or codes):
54
+ used.add(1) # something is printed and it defaults to filament 1
55
+ return sorted(used)
56
+
57
+
58
+ def suggest_extruder_map(used: list[int]) -> dict[int, int]:
59
+ """Compact sparse filament slots to sequential extruders 1..K."""
60
+ return {old: i for i, old in enumerate(sorted(used), start=1)}
61
+
62
+
63
+ def default_extruder_map(project) -> dict[int, int]:
64
+ """The mapping to offer first: Bambu slots compacted, Prusa tools left where they are."""
65
+ used = used_filaments(project)
66
+ if project.source == "prusa":
67
+ return {f: f for f in used}
68
+ return suggest_extruder_map(used)
69
+
70
+
71
+ def needs_mapping(project) -> bool:
72
+ """Whether there is a filament mapping worth asking about.
73
+
74
+ A Bambu project with one filament on slot 1 needs no decision; in a Prusa project
75
+ even a single filament may be wanted on a different tool head.
76
+ """
77
+ used = used_filaments(project)
78
+ return bool(used) and (project.source == "prusa" or used != [1])
79
+
80
+
81
+ def filament_noun(project) -> str:
82
+ """What a filament slot of this project is called, e.g. 'Bambu filament'."""
83
+ return "Tool" if project.source == "prusa" else "Bambu filament"
84
+
85
+
86
+ def output_suffix(source: str) -> str:
87
+ return "_remapped" if source == "prusa" else "_prusa"
88
+
89
+
90
+ def painted_triangles(project) -> int:
91
+ if isinstance(project, PrusaProject):
92
+ return project.painted
93
+ return sum(
94
+ 1
95
+ for tree in [project.root_tree, *project.object_trees.values()]
96
+ for tri in tree.getroot().iter(ns.q(ns.CORE, "triangle"))
97
+ if "paint_color" in tri.attrib
98
+ )
99
+
100
+
101
+ def structure(project) -> tuple[int, int]:
102
+ """(model parts, build items) of a project."""
103
+ if isinstance(project, PrusaProject):
104
+ return project.objects, project.build_items
105
+ return len(project.object_trees) + 1, len(project.build_items)
106
+
107
+
108
+ def filament_label(project, slot: int) -> str:
109
+ """Human-readable description of a filament slot, e.g. '3 (PLA #8E2929)'."""
110
+ bits = []
111
+ if 0 < slot <= len(project.filament_types):
112
+ bits.append(project.filament_types[slot - 1])
113
+ if 0 < slot <= len(project.filament_colors):
114
+ bits.append(project.filament_colors[slot - 1])
115
+ return f"{slot} ({' '.join(bits)})" if bits else str(slot)
116
+
117
+
118
+ def convert_file(
119
+ input_path: str,
120
+ output_path: str,
121
+ plates: Optional[list] = None,
122
+ extruder_map: Optional[dict] = None,
123
+ compact_extruders: bool = True,
124
+ ) -> ConversionResult:
125
+ """Convert a Bambu/Orca .3mf at ``input_path`` to a Prusa .3mf at ``output_path``.
126
+
127
+ ``plates`` optionally restricts output to the given 1-based plate numbers.
128
+ ``extruder_map`` explicitly maps Bambu filament slots to Prusa extruders
129
+ (``{}`` keeps the original numbering); when None and ``compact_extruders`` is
130
+ true, sparse slots are compacted to sequential extruders automatically.
131
+
132
+ A PrusaSlicer .3mf is accepted too: it is copied with its tools remapped by
133
+ ``extruder_map`` (filament settings follow), and is never compacted or split by
134
+ plate.
135
+ """
136
+ if is_prusa_3mf(input_path):
137
+ prusa = read_prusa_3mf(input_path)
138
+ stats = remap_prusa_3mf(prusa, output_path, extruder_map)
139
+ return ConversionResult(
140
+ input_path=input_path,
141
+ output_path=output_path,
142
+ stats=stats,
143
+ producer=prusa.producer,
144
+ plates_available=[],
145
+ source="prusa",
146
+ )
147
+ project = read_bambu_3mf(input_path)
148
+ stats = write_prusa_3mf(
149
+ project,
150
+ output_path,
151
+ plates=plates,
152
+ extruder_map=extruder_map,
153
+ compact_extruders=compact_extruders,
154
+ )
155
+ return ConversionResult(
156
+ input_path=input_path,
157
+ output_path=output_path,
158
+ stats=stats,
159
+ producer=project.producer,
160
+ plates_available=project.plate_ids,
161
+ )
162
+
163
+
164
+ def describe(input_path: str):
165
+ """Read a project for inspection (used by --info and interactive prompts)."""
166
+ if is_prusa_3mf(input_path):
167
+ return read_prusa_3mf(input_path)
168
+ return read_bambu_3mf(input_path)
@@ -0,0 +1,26 @@
1
+ """3MF namespace URIs and qualified-name helpers."""
2
+
3
+ CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
4
+ PRODUCTION = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06"
5
+ MATERIAL = "http://schemas.microsoft.com/3dmanufacturing/material/2015/02"
6
+ BAMBU = "http://schemas.bambulab.com/package/2021"
7
+ SLIC3RPE = "http://schemas.slic3r.org/3mf/2017/06"
8
+
9
+ RELS = "http://schemas.openxmlformats.org/package/2006/relationships"
10
+ CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types"
11
+
12
+ # Relationship type used to point at the primary 3D model part.
13
+ REL_TYPE_STARTPART = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
14
+ REL_TYPE_THUMBNAIL = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"
15
+
16
+
17
+ def q(uri: str, tag: str) -> str:
18
+ """Clark-notation qualified name, e.g. q(CORE, 'object') -> '{...}object'."""
19
+ return f"{{{uri}}}{tag}"
20
+
21
+
22
+ def localname(tag) -> str:
23
+ """Return the local part of a possibly-namespaced tag."""
24
+ if not isinstance(tag, str):
25
+ return ""
26
+ return tag.rsplit("}", 1)[-1]
@@ -0,0 +1,104 @@
1
+ """Decode / re-encode triangle paint RLE strings (TriangleSelector serialization).
2
+
3
+ Bambu Studio's ``paint_color`` and PrusaSlicer's ``slic3rpe:mmu_segmentation`` share
4
+ this format (Bambu forked PrusaSlicer's TriangleSelector). The hex string is a
5
+ bitstream read nibble-by-nibble from the END of the string, bits LSB-first within
6
+ each nibble. Node grammar::
7
+
8
+ node := split:2 ( state:2 [ext:4 if state==0b11, value ext+3] -- leaf
9
+ | special_side:2 node{split+1} ) -- split
10
+
11
+ State 0 is "use the volume's extruder"; state N paints extruder N. Remapping paint
12
+ to different extruder numbers therefore requires rewriting leaf states in the
13
+ bitstream — the strings cannot be value-preserved when extruders are renumbered.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+
19
+ class PaintError(ValueError):
20
+ pass
21
+
22
+
23
+ def _bits_of(code: str) -> list[int]:
24
+ bits: list[int] = []
25
+ for ch in reversed(code):
26
+ try:
27
+ n = int(ch, 16)
28
+ except ValueError:
29
+ raise PaintError(f"non-hex character {ch!r} in paint string")
30
+ bits.extend((n >> i) & 1 for i in range(4))
31
+ return bits
32
+
33
+
34
+ def _string_of(bits: list[int]) -> str:
35
+ while len(bits) % 4:
36
+ bits.append(0)
37
+ chars = []
38
+ for i in range(0, len(bits), 4):
39
+ nibble = bits[i] | bits[i + 1] << 1 | bits[i + 2] << 2 | bits[i + 3] << 3
40
+ chars.append("0123456789ABCDEF"[nibble])
41
+ return "".join(reversed(chars))
42
+
43
+
44
+ def _transform(code: str, map_fn) -> str:
45
+ bits = _bits_of(code)
46
+ out: list[int] = []
47
+ pos = 0
48
+
49
+ def take(k: int) -> int:
50
+ nonlocal pos
51
+ if pos + k > len(bits):
52
+ raise PaintError("truncated paint bitstream")
53
+ v = sum(bits[pos + i] << i for i in range(k))
54
+ pos += k
55
+ return v
56
+
57
+ def emit(v: int, k: int) -> None:
58
+ out.extend((v >> i) & 1 for i in range(k))
59
+
60
+ def node() -> None:
61
+ split = take(2)
62
+ emit(split, 2)
63
+ if split == 0:
64
+ s = take(2)
65
+ if s == 0b11:
66
+ s = take(4) + 3
67
+ s = map_fn(s)
68
+ if s < 0 or s > 18:
69
+ raise PaintError(f"state {s} out of range for TriangleSelector encoding")
70
+ if s <= 2:
71
+ emit(s, 2)
72
+ else:
73
+ emit(0b11, 2)
74
+ emit(s - 3, 4)
75
+ else:
76
+ emit(take(2), 2) # special side index
77
+ for _ in range(split + 1):
78
+ node()
79
+
80
+ node()
81
+ if any(bits[pos:]) and len(bits) - pos >= 4:
82
+ raise PaintError("leftover data bits after root node")
83
+ return _string_of(out)
84
+
85
+
86
+ def transform_states(code: str, mapping: dict[int, int]) -> str:
87
+ """Rewrite every leaf state through ``mapping`` (states absent stay unchanged).
88
+
89
+ With an identity mapping this round-trips byte-for-byte (modulo zero padding,
90
+ which producers already emit as zeros).
91
+ """
92
+ return _transform(code, lambda s: mapping.get(s, s))
93
+
94
+
95
+ def used_states(code: str) -> set[int]:
96
+ """Return the set of leaf states referenced by one paint string."""
97
+ states: set[int] = set()
98
+
99
+ def spy(s: int) -> int:
100
+ states.add(s)
101
+ return s
102
+
103
+ _transform(code, spy)
104
+ return states