design_settings.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """Carry a 3MF designer's own process tweaks across a re-slice (#2622).
  2. A MakerWorld model is often published with deliberate deviations from the stock
  3. Bambu process preset — 5 walls, 100% infill, a 0.1mm first layer. Re-slicing that
  4. file for a different printer used to drop every one of them: ``--load-settings``
  5. is authoritative, so the picked process preset wins over the 3MF's embedded
  6. ``Metadata/project_settings.config``.
  7. We do not have to *compute* what the designer changed. BambuStudio already did,
  8. and wrote the answer into the file:
  9. different_settings_to_system = [
  10. "enable_support;inner_wall_speed;sparse_infill_density;...", # [0] process
  11. "filament_change_length;filament_prime_volume", # [1..N] filaments
  12. "machine_start_gcode;bed_custom_model;...", # [-1] printer
  13. ]
  14. The array is ``1 + len(filament_settings_id) + 1`` long — verified against real
  15. files at 2, 3 and 4 filament slots. Index 0 is exactly the set of process keys
  16. that differ from the system preset, which is the reporter's step 1 for free: no
  17. baseline resolution, no shipping BBL profiles into Bambuddy, and no new endpoint
  18. on the slicer sidecar (which exposes bundled presets by name only, with no way to
  19. flatten one).
  20. Delivery is the mechanism ``_patch_process_support_settings`` already proved in
  21. #1881: write the values into the process JSON that goes out as ``--load-settings``.
  22. For a "standard" preset pick that JSON is a ``{inherits: …}`` stub, so the keys we
  23. write are the *child* in the inherits chain and win over the flattened parent.
  24. Not every key is safe to carry, though. Real files put ``inner_wall_speed``,
  25. ``outer_wall_speed`` and ``prime_tower_max_speed`` in that list — values tuned for
  26. the designer's machine that can be plain wrong, or out of range, on the target.
  27. Those are classified :data:`PRINTER_COUPLED` and offered unticked; the caller
  28. decides. Nothing is applied that the caller did not ask for by name.
  29. """
  30. from __future__ import annotations
  31. import json
  32. import logging
  33. import zipfile
  34. from io import BytesIO
  35. from typing import Any, NamedTuple
  36. logger = logging.getLogger(__name__)
  37. _PROJECT_SETTINGS = "Metadata/project_settings.config"
  38. class DesignOverride(NamedTuple):
  39. """One process setting the designer changed away from the system preset."""
  40. key: str
  41. value: Any
  42. printer_coupled: bool
  43. # Set for the handful of keys that *define* the picked process preset —
  44. # see :data:`_PRESET_DEFINING`. Offered like printer-coupled ones, never
  45. # pre-selected, because the user's preset pick has to win over the file.
  46. preset_defining: bool = False
  47. # Process keys whose sane value depends on the machine, not on the design intent.
  48. # The designer picked these for *their* printer's kinematics, chamber and hotend;
  49. # carrying them onto another model risks a slice that is merely slower/uglier —
  50. # or a hard range-validation reject from the CLI, which is how the very first
  51. # slicer spike died. Offered, but never pre-selected.
  52. #
  53. # Matching is by exact key OR by suffix/substring rule below, because Bambu's
  54. # process schema has dozens of per-feature speed keys and an exhaustive literal
  55. # list would rot on every slicer release.
  56. _PRINTER_COUPLED_EXACT: frozenset[str] = frozenset(
  57. {
  58. "default_acceleration",
  59. "independent_support_layer_height",
  60. "precise_z_height",
  61. "travel_acceleration",
  62. "enable_wrapping_detection",
  63. }
  64. )
  65. # Substring rules for the families that are always machine-coupled. Kept
  66. # deliberately narrow: "speed", "acceleration"/"accel" and "jerk" are the
  67. # kinematic families, "fan"/"temperature" follow the hotend and chamber, and
  68. # "prime_tower" follows the target's toolchange hardware.
  69. _PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
  70. # Prime-tower geometry (and whether there is one at all) follows the target's
  71. # extruder count and bed, not the design — a real file carries five of these.
  72. "prime_tower",
  73. "_speed",
  74. "speed_",
  75. "acceleration",
  76. "_accel",
  77. "jerk",
  78. "fan_speed",
  79. "_temperature",
  80. "temperature_",
  81. )
  82. # Process keys whose value *is* the preset the user picked. "0.08mm High
  83. # Quality" is not a name with a layer height attached — the layer height is
  84. # what the preset is, and the same holds for the first layer it starts on.
  85. #
  86. # Carrying these from the file would quietly undo an explicit pick: choose the
  87. # 0.08 preset for a MakerWorld file whose designer moved layer height to 0.2
  88. # and, with every non-printer-coupled key pre-selected, the slice comes out at
  89. # 0.2 while the dropdown still reads 0.08. The designer's value stays on offer
  90. # — a re-slice that genuinely wants the design's layer height is one tick away
  91. # — but nothing here is applied without the user saying so.
  92. _PRESET_DEFINING: frozenset[str] = frozenset(
  93. {
  94. "layer_height",
  95. "initial_layer_print_height",
  96. }
  97. )
  98. def is_preset_defining(key: str) -> bool:
  99. """Whether this key is the identity of the picked process preset."""
  100. return key in _PRESET_DEFINING
  101. def is_printer_coupled(key: str) -> bool:
  102. """Whether carrying this process key across printer models is risky."""
  103. if key in _PRINTER_COUPLED_EXACT:
  104. return True
  105. lowered = key.lower()
  106. return any(token in lowered for token in _PRINTER_COUPLED_SUBSTRINGS)
  107. def _split_changed_keys(entry: Any) -> list[str]:
  108. """Parse one ``different_settings_to_system`` entry into its key names."""
  109. if not isinstance(entry, str):
  110. return []
  111. return [part.strip() for part in entry.split(";") if part.strip()]
  112. def extract_design_process_overrides(zip_bytes: bytes) -> list[DesignOverride]:
  113. """Process settings the 3MF's designer changed away from the system preset.
  114. Returns an empty list for anything that is not a BambuStudio-style 3MF
  115. carrying both ``project_settings.config`` and a well-formed
  116. ``different_settings_to_system`` — including OrcaSlicer files and older
  117. exports that predate the field. Callers treat empty as "nothing to offer",
  118. which is the pre-feature behaviour.
  119. """
  120. try:
  121. with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
  122. if _PROJECT_SETTINGS not in zf.namelist():
  123. return []
  124. config = json.loads(zf.read(_PROJECT_SETTINGS).decode("utf-8"))
  125. except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
  126. return []
  127. return overrides_from_config(config)
  128. def overrides_from_config(config: Any) -> list[DesignOverride]:
  129. """``extract_design_process_overrides`` on an already-parsed config dict."""
  130. if not isinstance(config, dict):
  131. return []
  132. changed = config.get("different_settings_to_system")
  133. if not isinstance(changed, list) or not changed:
  134. return []
  135. # Sanity-check the layout before trusting index 0. The array should be
  136. # [process, *filaments, printer]; a file whose length disagrees with its own
  137. # filament count is one we do not understand, and guessing there could carry
  138. # printer G-code into the process slot.
  139. filaments = config.get("filament_settings_id")
  140. if isinstance(filaments, list) and len(changed) != len(filaments) + 2:
  141. logger.debug(
  142. "3MF different_settings_to_system has %d entries for %d filaments "
  143. "(expected %d) — skipping design-settings carry-over",
  144. len(changed),
  145. len(filaments),
  146. len(filaments) + 2,
  147. )
  148. return []
  149. overrides: list[DesignOverride] = []
  150. # Index 0 is the process slot — see the layout in the module docstring. The
  151. # length check above is what earns the right to index it blindly.
  152. for key in _split_changed_keys(changed[0]):
  153. if key not in config:
  154. # Listed as changed but absent from the flattened config — nothing
  155. # to carry. Seen with keys the slicer renamed between versions.
  156. continue
  157. overrides.append(
  158. DesignOverride(
  159. key=key,
  160. value=config[key],
  161. printer_coupled=is_printer_coupled(key),
  162. preset_defining=is_preset_defining(key),
  163. )
  164. )
  165. overrides.sort(key=lambda o: o.key)
  166. return overrides
  167. def apply_design_overrides(process_json: str, overrides: list[DesignOverride], selected_keys: list[str]) -> str:
  168. """Write the selected designer values into the outgoing process JSON.
  169. ``selected_keys`` is authoritative — a key the caller did not name is not
  170. applied even when it is present in ``overrides``. Returns ``process_json``
  171. unchanged when nothing is selected or the JSON is unparseable, so a bad
  172. input degrades to a plain profile slice rather than failing it.
  173. """
  174. if not selected_keys or not overrides:
  175. return process_json
  176. wanted = set(selected_keys)
  177. by_key = {o.key: o.value for o in overrides if o.key in wanted}
  178. if not by_key:
  179. return process_json
  180. try:
  181. process_cfg = json.loads(process_json)
  182. except json.JSONDecodeError:
  183. return process_json
  184. if not isinstance(process_cfg, dict):
  185. return process_json
  186. process_cfg.update(by_key)
  187. logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
  188. return json.dumps(process_cfg)
  189. def changed_process_keys(before_json: str, after_json: str) -> list[str]:
  190. """The keys Bambuddy set on a process JSON between two points (#3497).
  191. Added keys and keys whose value changed. Empty when either side does not
  192. parse as a JSON object, so a caller then records nothing rather than a
  193. guess.
  194. """
  195. try:
  196. before = json.loads(before_json)
  197. after = json.loads(after_json)
  198. except json.JSONDecodeError:
  199. return []
  200. if not isinstance(before, dict) or not isinstance(after, dict):
  201. return []
  202. return sorted(key for key, value in after.items() if key not in before or before[key] != value)
  203. def record_process_changes(zip_bytes: bytes, keys: list[str]) -> bytes:
  204. """List *keys* as changed from the system process preset in a sliced 3MF (#3497).
  205. The slicer CLI writes ``different_settings_to_system`` but never fills it,
  206. so the settings Bambuddy put on the picked preset are in the file without
  207. being marked. Bambu Studio then opens the project with the stock preset's
  208. values for them, and a re-slice there drops them.
  209. Merges into the process entry (index 0) only: keys already listed stay,
  210. the filament and printer entries are left alone. Returns the bytes
  211. unchanged when there is nothing to add or the file is not one whose layout
  212. we understand, the same checks :func:`overrides_from_config` reads it with.
  213. """
  214. if not keys:
  215. return zip_bytes
  216. try:
  217. with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
  218. if _PROJECT_SETTINGS not in zin.namelist():
  219. return zip_bytes
  220. config = json.loads(zin.read(_PROJECT_SETTINGS).decode("utf-8"))
  221. if not isinstance(config, dict):
  222. return zip_bytes
  223. changed = config.get("different_settings_to_system")
  224. filaments = config.get("filament_settings_id")
  225. if (
  226. not isinstance(changed, list)
  227. or not isinstance(filaments, list)
  228. or len(changed) != len(filaments) + 2
  229. or not isinstance(changed[0], str)
  230. ):
  231. logger.info("Sliced 3MF has no usable different_settings_to_system; process changes not recorded")
  232. return zip_bytes
  233. listed = _split_changed_keys(changed[0])
  234. merged = sorted(set(listed) | set(keys))
  235. if merged == sorted(listed):
  236. return zip_bytes
  237. changed[0] = ";".join(merged)
  238. patched = json.dumps(config, indent=4)
  239. dst = BytesIO()
  240. with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
  241. for item in zin.infolist():
  242. if item.filename == _PROJECT_SETTINGS:
  243. zout.writestr(item, patched)
  244. else:
  245. zout.writestr(item, zin.read(item.filename))
  246. except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError):
  247. return zip_bytes
  248. logger.info("Recorded %d process setting(s) as changed from the system preset", len(keys))
  249. return dst.getvalue()