autoforge 2.2.0__tar.gz → 2.2.1__tar.gz

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 (129) hide show
  1. {autoforge-2.2.0 → autoforge-2.2.1}/PKG-INFO +3 -2
  2. {autoforge-2.2.0 → autoforge-2.2.1}/pyproject.toml +3 -2
  3. {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/PKG-INFO +3 -2
  4. {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/SOURCES.txt +3 -0
  5. {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/requires.txt +1 -0
  6. autoforge-2.2.1/src/autoforge/Helper/BorderMesh.py +532 -0
  7. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/OutputHelper.py +24 -2
  8. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/optimization.py +197 -7
  9. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/outputs.py +110 -3
  10. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/sliders.py +37 -2
  11. autoforge-2.2.1/src/autoforge/webui/helpers/border.py +173 -0
  12. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/pipeline_runner.py +249 -51
  13. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/models.py +55 -1
  14. autoforge-2.2.1/tests/test_border_mesh.py +356 -0
  15. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bugfixes_2026_09_26.py +4 -1
  16. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_output_helper.py +57 -0
  17. {autoforge-2.2.0 → autoforge-2.2.1}/README.md +0 -0
  18. {autoforge-2.2.0 → autoforge-2.2.1}/setup.cfg +0 -0
  19. {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/dependency_links.txt +0 -0
  20. {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/entry_points.txt +0 -0
  21. {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/top_level.txt +0 -0
  22. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/AmpUtils.py +0 -0
  23. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/CAdamW.py +0 -0
  24. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/ConstraintHelper.py +0 -0
  25. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/DeterministicOps.py +0 -0
  26. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/DeviceUtils.py +0 -0
  27. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/FilamentHelper.py +0 -0
  28. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/FusedComposite.py +0 -0
  29. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/HeightAssign.py +0 -0
  30. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/HeightGuidance.py +0 -0
  31. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/HeightfieldMesh.py +0 -0
  32. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/ChristofidesHeightMap.py +0 -0
  33. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/DepthEstimateHeightMap.py +0 -0
  34. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/FastTSPHeightMap.py +0 -0
  35. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/__init__.py +0 -0
  36. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/_cluster.py +0 -0
  37. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/ImageHelper.py +0 -0
  38. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/OptimizerHelper.py +0 -0
  39. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/OtherHelper.py +0 -0
  40. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/PixelHeightRefine.py +0 -0
  41. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/PruningHelper.py +0 -0
  42. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/__init__.py +0 -0
  43. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/filamentcolors_library.py +0 -0
  44. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Init_comparer/__init__.py +0 -0
  45. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Init_comparer/auto_forge_init_check.py +0 -0
  46. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Init_comparer/plot_results.py +0 -0
  47. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Loss/LossFunctions.py +0 -0
  48. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Loss/PerceptionLoss.py +0 -0
  49. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Loss/__init__.py +0 -0
  50. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Misc/__init__.py +0 -0
  51. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Misc/color_fit_all.py +0 -0
  52. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Modules/Optimizer.py +0 -0
  53. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Modules/__init__.py +0 -0
  54. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/__init__.py +0 -0
  55. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/__main__.py +0 -0
  56. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/auto_forge.py +0 -0
  57. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/data/filamentcolors_catalog.json +0 -0
  58. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/default_materials.csv +0 -0
  59. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/priority_mask_editor.py +0 -0
  60. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/__init__.py +0 -0
  61. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/__init__.py +0 -0
  62. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/filaments.py +0 -0
  63. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/guidance.py +0 -0
  64. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/images.py +0 -0
  65. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/init.py +0 -0
  66. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/preview.py +0 -0
  67. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/project.py +0 -0
  68. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/pruning.py +0 -0
  69. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/settings.py +0 -0
  70. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/state.py +0 -0
  71. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/system.py +0 -0
  72. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/workers.py +0 -0
  73. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/ws.py +0 -0
  74. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/config.py +0 -0
  75. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/__init__.py +0 -0
  76. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/auto_prune.py +0 -0
  77. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/colored_mesh.py +0 -0
  78. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/edited_export.py +0 -0
  79. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/gpu_memory.py +0 -0
  80. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/guidance_views.py +0 -0
  81. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/mesh_persist.py +0 -0
  82. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/project_heightmap.py +0 -0
  83. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/result_store.py +0 -0
  84. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/slider_render.py +0 -0
  85. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/sliders.py +0 -0
  86. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/telemetry.py +0 -0
  87. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/server.py +0 -0
  88. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/__init__.py +0 -0
  89. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/catalog_service.py +0 -0
  90. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/filament_service.py +0 -0
  91. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/image_service.py +0 -0
  92. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/optimization_service.py +0 -0
  93. {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/project_service.py +0 -0
  94. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_amp_utils.py +0 -0
  95. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_auto_forge_cli.py +0 -0
  96. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bug_regressions.py +0 -0
  97. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bugreport_2026_10_01.py +0 -0
  98. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bugreport_2026_10_03.py +0 -0
  99. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_cadamw.py +0 -0
  100. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_christofides_heightmap.py +0 -0
  101. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_composite_image.py +0 -0
  102. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_constrained_opt.py +0 -0
  103. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_depth_adjusted_height_map.py +0 -0
  104. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_depth_estimate_height_map.py +0 -0
  105. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_device_utils.py +0 -0
  106. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_edge_bleed.py +0 -0
  107. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_end_to_end_optimizer.py +0 -0
  108. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_fasttsp_heightmap.py +0 -0
  109. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_filament_helper.py +0 -0
  110. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_filamentcolors_library.py +0 -0
  111. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_flatforge.py +0 -0
  112. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_graph_cache_eviction.py +0 -0
  113. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_graph_capture_threads.py +0 -0
  114. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_gumbel_nan.py +0 -0
  115. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_height_guidance.py +0 -0
  116. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_image_helper.py +0 -0
  117. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_loss_functions.py +0 -0
  118. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_optimizer_module.py +0 -0
  119. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_optimizer_training.py +0 -0
  120. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_other_helper.py +0 -0
  121. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_perception_loss.py +0 -0
  122. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_pipeline_smoke.py +0 -0
  123. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_property_based.py +0 -0
  124. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_prune_repeatability.py +0 -0
  125. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_pruning_helper.py +0 -0
  126. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_remove_height_spikes.py +0 -0
  127. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_webui_backend.py +0 -0
  128. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_webui_regressions.py +0 -0
  129. {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_worktree_bugs_2026_10_01.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoForge
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: AutoForge - 3D color prints made easy
5
5
  Author-email: Hendric Voss <hvoss@techfak.uni-bielefeld.de>
6
6
  License: CC BY-NC-SA 4.0
7
- Requires-Python: >=3.10
7
+ Requires-Python: <3.15,>=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: torch>=2.9.1
@@ -29,6 +29,7 @@ Requires-Dist: aiofiles>=24.1.0
29
29
  Requires-Dist: websockets>=15.0
30
30
  Requires-Dist: pydantic-settings>=2.0.0
31
31
  Requires-Dist: posthog>=3.7.0
32
+ Requires-Dist: manifold3d>=3.2
32
33
  Provides-Extra: test
33
34
  Requires-Dist: pytest>=7.4; extra == "test"
34
35
  Requires-Dist: pytest-cov; extra == "test"
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "AutoForge"
7
- version="2.2.0"
7
+ version="2.2.1"
8
8
  description = "AutoForge - 3D color prints made easy"
9
9
  authors = [{ name = "Hendric Voss", email = "hvoss@techfak.uni-bielefeld.de" }]
10
10
  readme = "README.md"
11
- requires-python = ">=3.10"
11
+ requires-python = ">=3.10,<3.15"
12
12
  license = { text = "CC BY-NC-SA 4.0" }
13
13
  dependencies = [
14
14
  "numpy",
@@ -34,6 +34,7 @@ dependencies = [
34
34
  "websockets>=15.0",
35
35
  "pydantic-settings>=2.0.0",
36
36
  "posthog>=3.7.0",
37
+ "manifold3d>=3.2",
37
38
  ]
38
39
 
39
40
  [project.optional-dependencies]
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoForge
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: AutoForge - 3D color prints made easy
5
5
  Author-email: Hendric Voss <hvoss@techfak.uni-bielefeld.de>
6
6
  License: CC BY-NC-SA 4.0
7
- Requires-Python: >=3.10
7
+ Requires-Python: <3.15,>=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: torch>=2.9.1
@@ -29,6 +29,7 @@ Requires-Dist: aiofiles>=24.1.0
29
29
  Requires-Dist: websockets>=15.0
30
30
  Requires-Dist: pydantic-settings>=2.0.0
31
31
  Requires-Dist: posthog>=3.7.0
32
+ Requires-Dist: manifold3d>=3.2
32
33
  Provides-Extra: test
33
34
  Requires-Dist: pytest>=7.4; extra == "test"
34
35
  Requires-Dist: pytest-cov; extra == "test"
@@ -12,6 +12,7 @@ src/autoforge/auto_forge.py
12
12
  src/autoforge/default_materials.csv
13
13
  src/autoforge/priority_mask_editor.py
14
14
  src/autoforge/Helper/AmpUtils.py
15
+ src/autoforge/Helper/BorderMesh.py
15
16
  src/autoforge/Helper/CAdamW.py
16
17
  src/autoforge/Helper/ConstraintHelper.py
17
18
  src/autoforge/Helper/DeterministicOps.py
@@ -67,6 +68,7 @@ src/autoforge/webui/api/workers.py
67
68
  src/autoforge/webui/api/ws.py
68
69
  src/autoforge/webui/helpers/__init__.py
69
70
  src/autoforge/webui/helpers/auto_prune.py
71
+ src/autoforge/webui/helpers/border.py
70
72
  src/autoforge/webui/helpers/colored_mesh.py
71
73
  src/autoforge/webui/helpers/edited_export.py
72
74
  src/autoforge/webui/helpers/gpu_memory.py
@@ -86,6 +88,7 @@ src/autoforge/webui/services/optimization_service.py
86
88
  src/autoforge/webui/services/project_service.py
87
89
  tests/test_amp_utils.py
88
90
  tests/test_auto_forge_cli.py
91
+ tests/test_border_mesh.py
89
92
  tests/test_bug_regressions.py
90
93
  tests/test_bugfixes_2026_09_26.py
91
94
  tests/test_bugreport_2026_10_01.py
@@ -21,6 +21,7 @@ aiofiles>=24.1.0
21
21
  websockets>=15.0
22
22
  pydantic-settings>=2.0.0
23
23
  posthog>=3.7.0
24
+ manifold3d>=3.2
24
25
 
25
26
  [test]
26
27
  pytest>=7.4
@@ -0,0 +1,532 @@
1
+ """Borders around a finished model, on the base layer.
2
+
3
+ A border widens the base slab (z = 0 .. background_height, printed in the
4
+ background filament) past the edge of the picture: a rectangle with optional
5
+ rounded corners, an oval, a circle, or a margin that follows the picture's
6
+ outline. Hanging holes can be added on top, in the border or on a tab
7
+ ("ear") that sticks out of it when the border is too narrow to hold them.
8
+
9
+ Everything is built in the model's own coordinates (mm, x right, y up, the
10
+ picture where ``generate_stl`` put it) from the model's footprint, read off
11
+ the mesh itself - so a border never needs the optimizer and can be changed
12
+ at any time.
13
+
14
+ Merging the border into the model is not a 3D boolean: the plate shares the
15
+ model's bottom plane and (where the picture has no layers) its base plane,
16
+ and a union of coplanar faces leaves coincident duplicate vertices that turn
17
+ into non-manifold edges once an STL is read back by position. Instead
18
+ ``stitch_border`` takes the model apart where it stands on the bed - drops
19
+ its bottom face, cuts its outline walls at the base height - and closes it
20
+ with the plate: a top face between the plate's outline and the model's own
21
+ outline vertices, the plate's walls and a new bottom. The result is one
22
+ closed, 2-manifold solid with every original vertex of the model's surface
23
+ above the base kept exactly.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import struct
30
+ from collections import defaultdict
31
+ from dataclasses import dataclass
32
+ from typing import Any, Optional
33
+
34
+ import numpy as np
35
+
36
+ try:
37
+ import manifold3d as mf
38
+ except ImportError: # pragma: no cover - an install from before the dependency
39
+ mf = None
40
+
41
+ SHAPES = ("none", "rectangle", "oval", "circle", "outline")
42
+ HANGERS = ("none", "top", "corners")
43
+
44
+ # Accepted ranges (mm). The webui model clamps into the same ones.
45
+ WIDTH_RANGE = (1.0, 100.0)
46
+ CORNER_RADIUS_RANGE = (0.0, 100.0)
47
+ HANGER_DIAMETER_RANGE = (2.0, 20.0)
48
+
49
+ # A rounded corner wider than this many border widths would leave less than
50
+ # half the width around the picture's corner (at 3.4 it cuts into it).
51
+ MAX_CORNER_RADIUS_PER_WIDTH = 2.2
52
+
53
+ # Space between a hanging hole and the picture, and the least material left
54
+ # around a hole.
55
+ HOLE_CLEARANCE_MM = 1.5
56
+ MIN_HOLE_RING_MM = 2.0
57
+
58
+ # Longest chord of a polygonised arc. Well below a nozzle width, so arcs
59
+ # print as arcs.
60
+ _ARC_CHORD_MM = 0.5
61
+
62
+
63
+ class BorderError(ValueError):
64
+ """The border can't be built for this model (not a closed solid, no base)."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class BorderSpec:
69
+ shape: str = "none"
70
+ width: float = 5.0
71
+ corner_radius: float = 3.0
72
+ # Hanging holes need a border to sit in (or a tab grown out of it).
73
+ hanger: str = "none"
74
+ hanger_diameter: float = 4.0
75
+
76
+ @property
77
+ def active(self) -> bool:
78
+ return self.shape != "none"
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: Optional[dict[str, Any]]) -> "BorderSpec":
82
+ """Spec from a settings dict; unknown shapes fall back to none and
83
+ sizes are clamped into range, so a hand-edited project file can't
84
+ produce a degenerate plate."""
85
+ if not data:
86
+ return cls()
87
+ default = cls()
88
+
89
+ def number(key: str, lo_hi: tuple[float, float]) -> float:
90
+ try:
91
+ value = float(data.get(key, getattr(default, key)))
92
+ except (TypeError, ValueError):
93
+ value = getattr(default, key)
94
+ if not math.isfinite(value):
95
+ value = getattr(default, key)
96
+ return min(max(value, lo_hi[0]), lo_hi[1])
97
+
98
+ shape = data.get("shape", "none")
99
+ hanger = data.get("hanger", "none")
100
+ return cls(
101
+ shape=shape if shape in SHAPES else "none",
102
+ width=number("width", WIDTH_RANGE),
103
+ corner_radius=number("corner_radius", CORNER_RADIUS_RANGE),
104
+ hanger=hanger if hanger in HANGERS else "none",
105
+ hanger_diameter=number("hanger_diameter", HANGER_DIAMETER_RANGE),
106
+ )
107
+
108
+
109
+ def _require_manifold() -> None:
110
+ """A clear error instead of an AttributeError when manifold3d is missing
111
+ (an install that predates the dependency); the rest of the app works."""
112
+ if mf is None:
113
+ raise BorderError("Borders need the manifold3d package: pip install manifold3d")
114
+
115
+
116
+ def _segments(radius: float) -> int:
117
+ """Segments per full turn for an arc of ``radius``."""
118
+ return int(min(720, max(32, math.ceil(2 * math.pi * max(radius, 0.0) / _ARC_CHORD_MM))))
119
+
120
+
121
+ def _round_offset(section: mf.CrossSection, delta: float) -> mf.CrossSection:
122
+ """``section`` grown by ``delta`` with round joins whose polygon stays
123
+ outside the true arc (its corners are on a slightly larger circle)."""
124
+ n = _segments(delta)
125
+ return section.offset(delta / math.cos(math.pi / n), mf.JoinType.Round, circular_segments=n)
126
+
127
+
128
+ def _circle(radius: float) -> mf.CrossSection:
129
+ """A polygon around the circle of ``radius`` (not inscribed in it)."""
130
+ n = _segments(radius)
131
+ return mf.CrossSection.circle(radius / math.cos(math.pi / n), n)
132
+
133
+
134
+ def _vertices(section: mf.CrossSection) -> np.ndarray:
135
+ polygons = [np.asarray(p, dtype=np.float64) for p in section.to_polygons()]
136
+ polygons = [p for p in polygons if len(p)]
137
+ return np.concatenate(polygons) if polygons else np.zeros((0, 2))
138
+
139
+
140
+ def _strip(x0: float, x1: float, y0: float, y1: float) -> mf.CrossSection:
141
+ return mf.CrossSection.square((x1 - x0, y1 - y0)).translate((x0, y0))
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # The model
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def load_mesh(path: str) -> tuple[np.ndarray, np.ndarray]:
149
+ """(vertices float32 [N, 3], faces int64 [M, 3]) of a mesh file (STL or
150
+ PLY), coincident corners merged: an STL stores every triangle's corners
151
+ separately. Exact merge on the float32 bit patterns - the writers emit
152
+ identical coordinates for a shared corner."""
153
+ import trimesh
154
+
155
+ # Not force="mesh": that alone costs seconds on a preview mesh.
156
+ mesh = trimesh.load(path, process=False)
157
+ if isinstance(mesh, trimesh.Scene):
158
+ mesh = mesh.to_geometry()
159
+ vertices = np.ascontiguousarray(mesh.vertices, dtype=np.float32)
160
+ faces = np.asarray(mesh.faces, dtype=np.int64)
161
+ if len(faces) == 0:
162
+ raise BorderError("The model is empty.")
163
+ # Sorted on the bit patterns (several times faster than np.unique on rows).
164
+ bits = vertices.view(np.int32).reshape(-1, 3)
165
+ order = np.lexsort(bits.T[::-1])
166
+ sorted_bits = bits[order]
167
+ starts = np.ones(len(order), dtype=bool)
168
+ starts[1:] = (sorted_bits[1:] != sorted_bits[:-1]).any(axis=1)
169
+ inverse = np.empty(len(order), dtype=np.int64)
170
+ inverse[order] = np.cumsum(starts) - 1
171
+ return vertices[order[starts]], inverse[faces]
172
+
173
+
174
+ def _bottom_loops(vertices: np.ndarray, faces: np.ndarray) -> list[np.ndarray]:
175
+ """Vertex loops around the model's bottom face (the faces at z = 0),
176
+ each in the direction of its bottom faces: seen from above, clockwise
177
+ around the picture and counter-clockwise around a hole in it."""
178
+ on_bed = vertices[:, 2] == 0
179
+ bottom = faces[on_bed[faces].all(axis=1)]
180
+ if len(bottom) == 0:
181
+ raise BorderError("The model has no flat bottom to add a border to.")
182
+ a = bottom.reshape(-1)
183
+ b = bottom[:, [1, 2, 0]].reshape(-1)
184
+ # An inner edge is shared by two bottom faces; the outline by one.
185
+ undirected = np.minimum(a, b) * len(vertices) + np.maximum(a, b)
186
+ order = np.argsort(undirected, kind="stable")
187
+ keys = undirected[order]
188
+ single = np.ones(len(keys), dtype=bool)
189
+ same = keys[1:] == keys[:-1]
190
+ single[1:] &= ~same
191
+ single[:-1] &= ~same
192
+ outline = order[single]
193
+ starts, ends = a[outline], b[outline]
194
+ successors: dict[int, list[int]] = defaultdict(list)
195
+ for s, e in zip(starts.tolist(), ends.tolist()):
196
+ successors[s].append(e)
197
+ loops = []
198
+ # A vertex where two parts of the outline touch (cells meeting at one
199
+ # corner) has two successors; either pairing gives valid loops.
200
+ for start in starts.tolist():
201
+ if not successors[start]:
202
+ continue
203
+ loop = [start]
204
+ current = successors[start].pop()
205
+ while current != start:
206
+ loop.append(current)
207
+ if not successors[current]:
208
+ raise BorderError("The model's bottom outline is not closed.")
209
+ current = successors[current].pop()
210
+ loops.append(np.array(loop, dtype=np.int64))
211
+ return loops
212
+
213
+
214
+ def _signed_area(points: np.ndarray) -> float:
215
+ x, y = points[:, 0], points[:, 1]
216
+ return 0.5 * float(np.dot(x, np.roll(y, -1)) - np.dot(np.roll(x, -1), y))
217
+
218
+
219
+ def mesh_footprint(vertices: np.ndarray, faces: np.ndarray) -> mf.CrossSection:
220
+ """Where the model stands on the bed: the region of its bottom face."""
221
+ _require_manifold()
222
+ loops = _bottom_loops(vertices, faces)
223
+ # Reversed: counter-clockwise around the picture, as CrossSection wants.
224
+ contours = [vertices[loop[::-1], :2].astype(np.float64) for loop in loops]
225
+ footprint = mf.CrossSection(contours, mf.FillRule.Positive)
226
+ if footprint.is_empty():
227
+ raise BorderError("The model has no base to add a border to.")
228
+ return footprint
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # The plate
233
+ # ---------------------------------------------------------------------------
234
+
235
+ def effective_corner_radius(spec: BorderSpec) -> float:
236
+ return min(spec.corner_radius, MAX_CORNER_RADIUS_PER_WIDTH * spec.width)
237
+
238
+
239
+ def _shape_section(footprint: mf.CrossSection, spec: BorderSpec) -> mf.CrossSection:
240
+ """The border's outer shape. Every shape holds the picture grown by the
241
+ border width (corners of a rounded rectangle at least half of it)."""
242
+ w = spec.width
243
+ if spec.shape == "outline":
244
+ return _round_offset(footprint, w)
245
+ x0, y0, x1, y1 = footprint.bounds()
246
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
247
+ if spec.shape == "rectangle":
248
+ width, height = x1 - x0 + 2 * w, y1 - y0 + 2 * w
249
+ r = min(effective_corner_radius(spec), 0.5 * min(width, height) - 1e-3)
250
+ if r <= 1e-3:
251
+ return mf.CrossSection.square((width, height), center=True).translate((cx, cy))
252
+ core = mf.CrossSection.square((width - 2 * r, height - 2 * r), center=True)
253
+ # Inscribed corner arcs keep the sides exactly ``w`` out; the
254
+ # corners' clearance is set by the radius cap, not the arcs.
255
+ return core.offset(r, mf.JoinType.Round, circular_segments=_segments(r)).translate((cx, cy))
256
+ # Oval and circle are fitted around the picture grown by the border
257
+ # width, so the margin is at least ``w`` everywhere - and a round motif on
258
+ # a transparent background gets a tight circle, not one around its
259
+ # square image.
260
+ grown = _vertices(_round_offset(footprint, w))
261
+ if spec.shape == "circle":
262
+ import cv2
263
+
264
+ (ccx, ccy), radius = cv2.minEnclosingCircle(grown.astype(np.float32))
265
+ # minEnclosingCircle works in float32; a hair of slack keeps every
266
+ # point inside.
267
+ radius = float(radius) * (1 + 1e-6) + 1e-4
268
+ return _circle(radius).translate((float(ccx), float(ccy)))
269
+ # Oval: the picture's aspect ratio, scaled until it holds every point.
270
+ a0, b0 = (x1 - x0) / 2 + w, (y1 - y0) / 2 + w
271
+ scale = float(np.sqrt(((grown[:, 0] - cx) / a0) ** 2 + ((grown[:, 1] - cy) / b0) ** 2).max())
272
+ a, b = a0 * scale, b0 * scale
273
+ n = _segments(max(a, b))
274
+ grow = 1 / math.cos(math.pi / n)
275
+ return mf.CrossSection.circle(1.0, n).scale((a * grow, b * grow)).translate((cx, cy))
276
+
277
+
278
+ def _hole_centers(footprint: mf.CrossSection, plate: mf.CrossSection, spec: BorderSpec) -> list[tuple[float, float]]:
279
+ r = spec.hanger_diameter / 2
280
+ big_r = r + max(MIN_HOLE_RING_MM, r)
281
+ x0, _y0, x1, y1 = footprint.bounds()
282
+ if spec.hanger == "top":
283
+ if spec.shape == "outline":
284
+ # Above the picture's highest point, where it hangs straight.
285
+ pts = _vertices(footprint)
286
+ top = pts[pts[:, 1] >= y1 - 1e-6]
287
+ xs = [float(top[:, 0].min() + top[:, 0].max()) / 2]
288
+ else:
289
+ xs = [(x0 + x1) / 2]
290
+ else:
291
+ inset = max(big_r, 0.15 * (x1 - x0))
292
+ xs = [x0 + inset, x1 - inset] if 2 * inset < x1 - x0 else [(x0 + x1) / 2]
293
+ _, py0, _, py1 = plate.bounds()
294
+ centers = []
295
+ for x in xs:
296
+ # Highest point of the picture under the hole (with its clearance),
297
+ # and of the plate above it.
298
+ local = footprint ^ _strip(x - r - HOLE_CLEARANCE_MM, x + r + HOLE_CLEARANCE_MM, py0 - 1, py1 + 1)
299
+ top_picture = local.bounds()[3] if not local.is_empty() else y1
300
+ column = plate ^ _strip(x - r, x + r, py0 - 1, py1 + 1)
301
+ top_plate = column.bounds()[3] if not column.is_empty() else top_picture
302
+ lowest = top_picture + HOLE_CLEARANCE_MM + r
303
+ middle = (top_picture + top_plate) / 2
304
+ # Centred in the border when it is wide enough to hold the hole with
305
+ # its ring, otherwise as close to the picture as allowed (a tab is
306
+ # added around it).
307
+ y = middle if middle >= lowest and top_plate - (middle + r) >= big_r - r else lowest
308
+ centers.append((x, y))
309
+ return centers
310
+
311
+
312
+ def border_outline(footprint: mf.CrossSection, spec: BorderSpec) -> Optional[mf.CrossSection]:
313
+ """The whole base plate with the border: picture footprint, border and
314
+ hanging tabs, minus the holes. None when ``spec`` adds nothing."""
315
+ _require_manifold()
316
+ if not spec.active:
317
+ return None
318
+ footprint = footprint.simplify(1e-4)
319
+ plate = _shape_section(footprint, spec)
320
+ if spec.hanger == "none":
321
+ return plate
322
+ r = spec.hanger_diameter / 2
323
+ big_r = r + max(MIN_HOLE_RING_MM, r)
324
+ holes = mf.CrossSection()
325
+ for x, y in _hole_centers(footprint, plate, spec):
326
+ # The hole is cut as the inscribed polygon: never wider than asked.
327
+ holes = holes + mf.CrossSection.circle(r, _segments(r)).translate((x, y))
328
+ ring = _circle(big_r).translate((x, y))
329
+ if (ring - plate).area() <= 1e-9:
330
+ continue
331
+ # The hole doesn't fit in the border: a tab around it, joined to the
332
+ # plate below by the hull with the plate's own edge there.
333
+ below = plate ^ _strip(x - big_r, x + big_r, y - 2 * big_r, y)
334
+ plate = plate + ((ring + below).hull() if not below.is_empty() else ring)
335
+ return plate - holes
336
+
337
+
338
+ def outline_size_mm(footprint: mf.CrossSection, spec: BorderSpec) -> tuple[float, float]:
339
+ """(width, height) in mm of the print with its border."""
340
+ plate = border_outline(footprint, spec) or footprint
341
+ x0, y0, x1, y1 = plate.bounds()
342
+ return (x1 - x0, y1 - y0)
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Meshes
347
+ # ---------------------------------------------------------------------------
348
+
349
+ def _check_base(background_height: float) -> None:
350
+ if not background_height > 0:
351
+ raise BorderError("A border is part of the base layer; it needs a base height above 0.")
352
+
353
+
354
+ def _mesh_arrays(solid: mf.Manifold) -> tuple[np.ndarray, np.ndarray]:
355
+ mesh = solid.to_mesh()
356
+ vertices = np.asarray(mesh.vert_properties, dtype=np.float32)[:, :3]
357
+ return np.ascontiguousarray(vertices), np.asarray(mesh.tri_verts, dtype=np.uint32)
358
+
359
+
360
+ def border_ring_mesh(
361
+ footprint: mf.CrossSection, spec: BorderSpec, background_height: float
362
+ ) -> Optional[tuple[np.ndarray, np.ndarray]]:
363
+ """(vertices, faces) of just the border - the plate outside the picture,
364
+ z = 0 .. background_height - for drawing next to the model's own mesh.
365
+ None when the spec adds nothing."""
366
+ plate = border_outline(footprint, spec)
367
+ if plate is None:
368
+ return None
369
+ _check_base(background_height)
370
+ ring = plate - footprint
371
+ if ring.is_empty():
372
+ return None
373
+ return _mesh_arrays(mf.Manifold.extrude(ring, background_height))
374
+
375
+
376
+ def plate_mesh(footprint: mf.CrossSection, spec: BorderSpec, background_height: float) -> tuple[np.ndarray, np.ndarray]:
377
+ """The whole plate, z = 0 .. background_height: a FlatForge base layer
378
+ (which is the footprint at that height) with the border."""
379
+ _check_base(background_height)
380
+ plate = border_outline(footprint, spec)
381
+ return _mesh_arrays(mf.Manifold.extrude(footprint if plate is None else plate, background_height))
382
+
383
+
384
+ def _wall_triangles(top: np.ndarray, bottom: np.ndarray) -> np.ndarray:
385
+ """Outward-facing wall between a contour's top and bottom vertex ids
386
+ (the contour counter-clockwise around solid, clockwise around a hole)."""
387
+ t1, b1 = np.roll(top, -1), np.roll(bottom, -1)
388
+ return np.concatenate([np.stack([bottom, b1, t1], axis=1), np.stack([bottom, t1, top], axis=1)])
389
+
390
+
391
+ def stitch_border(
392
+ vertices: np.ndarray, faces: np.ndarray, spec: BorderSpec, background_height: float
393
+ ) -> tuple[np.ndarray, np.ndarray]:
394
+ """The model (an indexed mesh on z = 0, its outline walls vertical) with
395
+ the border plate in place of its own base below ``background_height``,
396
+ as one closed mesh. See the module docstring."""
397
+ _check_base(background_height)
398
+ vertices = np.asarray(vertices, dtype=np.float32)
399
+ faces = np.asarray(faces, dtype=np.int64)
400
+ loops = _bottom_loops(vertices, faces)
401
+ footprint = mesh_footprint(vertices, faces)
402
+ plate = border_outline(footprint, spec)
403
+ if plate is None:
404
+ return vertices, faces
405
+ if (footprint - plate).area() > 1e-9:
406
+ raise BorderError("The border does not hold the whole picture.")
407
+
408
+ z = vertices[:, 2]
409
+ on_bed = z == 0
410
+ n_bed = on_bed[faces].sum(axis=1)
411
+ top_faces = faces[n_bed == 0]
412
+ walls = faces[(n_bed > 0) & (n_bed < 3)]
413
+ if len(top_faces) and z[top_faces].min() < background_height - 1e-5:
414
+ raise BorderError("The model dips below its base height.")
415
+
416
+ # The top end of each bottom vertex's wall: the vertex above it.
417
+ wall_ids = np.unique(walls)
418
+ lows, highs = wall_ids[on_bed[wall_ids]], wall_ids[~on_bed[wall_ids]]
419
+ xy = lambda ids: np.ascontiguousarray(vertices[ids, :2]).view(np.dtype((np.void, 8))).ravel()
420
+ order = np.argsort(xy(highs))
421
+ sorted_keys = xy(highs)[order]
422
+ pos = np.minimum(np.searchsorted(sorted_keys, xy(lows)), len(highs) - 1)
423
+ if len(highs) == 0 or not np.array_equal(sorted_keys[pos], xy(lows)):
424
+ raise BorderError("The model's outline walls are not vertical.")
425
+ above = highs[order][pos]
426
+
427
+ # Each wall is cut at the base height. Where the picture has no layers
428
+ # its top already is there and the wall below it simply goes away.
429
+ out = [vertices]
430
+ count = len(vertices)
431
+ level = np.float32(background_height)
432
+ cut = np.full(len(vertices), -1, dtype=np.int64)
433
+ at_base = np.abs(z[above] - level) <= 1e-5
434
+ cut[lows[at_base]] = above[at_base]
435
+ new = lows[~at_base]
436
+ cut[new] = count + np.arange(len(new))
437
+ cut_vertices = vertices[new].copy()
438
+ cut_vertices[:, 2] = level
439
+ out.append(cut_vertices)
440
+ count += len(new)
441
+
442
+ walls = np.where(on_bed[walls], cut[walls], walls)
443
+ walls = walls[(walls[:, 0] != walls[:, 1]) & (walls[:, 1] != walls[:, 2]) & (walls[:, 0] != walls[:, 2])]
444
+
445
+ # The plate: its contours at the base height and on the bed.
446
+ contours = [np.asarray(p, dtype=np.float64) for p in plate.to_polygons()]
447
+ sizes = [len(c) for c in contours]
448
+ flat = np.concatenate(contours)
449
+ plate_top = count + np.arange(len(flat))
450
+ plate_bottom = plate_top + len(flat)
451
+ out.append(np.column_stack([flat, np.full(len(flat), level)]).astype(np.float32))
452
+ out.append(np.column_stack([flat, np.zeros(len(flat))]).astype(np.float32))
453
+
454
+ # Top of the border: between the plate's contours and the model's
455
+ # outline (its bottom loops, which already run clockwise around the
456
+ # picture, as holes must), through the cut vertices.
457
+ inner = [cut[loop] for loop in loops]
458
+ polygons = contours + [vertices[loop, :2].astype(np.float64) for loop in loops]
459
+ ids = np.concatenate([plate_top] + inner)
460
+ ring_top = ids[np.asarray(mf.triangulate(polygons), dtype=np.int64)]
461
+ bottom = plate_bottom[np.asarray(mf.triangulate(contours), dtype=np.int64)][:, ::-1]
462
+
463
+ side = []
464
+ start = 0
465
+ for size in sizes:
466
+ idx = np.arange(start, start + size)
467
+ side.append(_wall_triangles(plate_top[idx], plate_bottom[idx]))
468
+ start += size
469
+
470
+ all_vertices = np.concatenate(out)
471
+ all_faces = np.concatenate([top_faces, walls, ring_top, bottom] + side)
472
+ used = np.unique(all_faces)
473
+ remap = np.full(len(all_vertices), -1, dtype=np.int64)
474
+ remap[used] = np.arange(len(used))
475
+ return all_vertices[used], remap[all_faces]
476
+
477
+
478
+ def check_closed(vertices: np.ndarray, faces: np.ndarray) -> None:
479
+ """BorderError unless the mesh is a closed, 2-manifold solid *by
480
+ position* - the way a slicer reads an STL."""
481
+ key = np.ascontiguousarray(vertices, dtype=np.float32).view(np.dtype((np.void, 12))).ravel()
482
+ if len(np.unique(key)) != len(vertices):
483
+ raise BorderError("The bordered model has coincident vertices.")
484
+ solid = mf.Manifold(mf.Mesh(
485
+ vert_properties=np.ascontiguousarray(vertices, dtype=np.float32),
486
+ tri_verts=np.ascontiguousarray(faces, dtype=np.uint32),
487
+ ))
488
+ if solid.status() != mf.Error.NoError or solid.volume() <= 0:
489
+ raise BorderError(f"The bordered model is not a closed solid ({solid.status().name}).")
490
+
491
+
492
+ def write_binary_stl(vertices: np.ndarray, faces: np.ndarray, filename: str, header: str = "") -> None:
493
+ """Binary STL of an indexed triangle mesh (normals from the winding)."""
494
+ triangles = np.asarray(vertices, dtype=np.float64)[np.asarray(faces, dtype=np.int64)]
495
+ normals = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
496
+ norms = np.linalg.norm(normals, axis=1)
497
+ norms[norms == 0] = 1 # Prevent division by zero
498
+ normals /= norms[:, np.newaxis]
499
+
500
+ stl_dtype = np.dtype(
501
+ [
502
+ ("normal", np.float32, (3,)),
503
+ ("v1", np.float32, (3,)),
504
+ ("v2", np.float32, (3,)),
505
+ ("v3", np.float32, (3,)),
506
+ ("attr", np.uint16),
507
+ ]
508
+ )
509
+ data = np.empty(len(triangles), dtype=stl_dtype)
510
+ data["normal"] = normals
511
+ data["v1"] = triangles[:, 0]
512
+ data["v2"] = triangles[:, 1]
513
+ data["v3"] = triangles[:, 2]
514
+ data["attr"] = 0
515
+ with open(filename, "wb") as f:
516
+ f.write(header.encode("utf-8")[:80].ljust(80, b" "))
517
+ f.write(struct.pack("<I", len(triangles)))
518
+ f.write(data.tobytes())
519
+
520
+
521
+ def add_border_to_stl(src: str, dst: str, spec: BorderSpec, background_height: float) -> None:
522
+ """Write ``src``'s model with the border merged in to ``dst``."""
523
+ vertices, faces = stitch_border(*load_mesh(src), spec, background_height)
524
+ check_closed(vertices, faces)
525
+ write_binary_stl(vertices, faces, dst, header=f"Binary STL with a {spec.shape} border")
526
+
527
+
528
+ def write_plate_stl(footprint: mf.CrossSection, dst: str, spec: BorderSpec, background_height: float) -> None:
529
+ """A FlatForge base-layer STL (the footprint, ``background_height``
530
+ thick) with the border, at ``dst``."""
531
+ vertices, faces = plate_mesh(footprint, spec, background_height)
532
+ write_binary_stl(vertices, faces, dst, header=f"FlatForge base with a {spec.shape} border")
@@ -69,6 +69,28 @@ def _is_blank(value) -> bool:
69
69
  return isinstance(value, str) and value.strip() == ""
70
70
 
71
71
 
72
+ # Namespace for turning library ids that aren't uuids into stable ones.
73
+ _HFP_UUID_NAMESPACE = uuid.UUID("5b0f6c2e-8a51-4d0e-9c3a-2f6d4b1e7a90")
74
+
75
+
76
+ def hfp_uuid(value=None) -> str:
77
+ """A filament id HueForge can read, in its braced ``{8-4-4-4-12}`` form.
78
+
79
+ HueForge parses ids with QUuid and silently drops every filament_set
80
+ entry it can't parse, so the remaining filaments slide onto the wrong
81
+ sliders - or the whole model comes up white. Catalog filaments are
82
+ stored as ``filamentcolors-<id>``, so ids that aren't uuids get a
83
+ stable uuid5 of themselves: the same filament keeps one id across
84
+ slots and exports. Blank values get a fresh random uuid."""
85
+ if _is_blank(value):
86
+ return "{%s}" % uuid.uuid4()
87
+ text = str(value).strip()
88
+ try:
89
+ return "{%s}" % uuid.UUID(text)
90
+ except ValueError:
91
+ return "{%s}" % uuid.uuid5(_HFP_UUID_NAMESPACE, text)
92
+
93
+
72
94
  def _hfp_filament(mat, brand="", color="#000000", name=""):
73
95
  """One ``filament_set`` entry of a .hfp file from a material record.
74
96
 
@@ -96,7 +118,7 @@ def _hfp_filament(mat, brand="", color="#000000", name=""):
96
118
  "Owned": str(mat.get("Owned", False)).strip().lower() == "true",
97
119
  "Transmissivity": int(td) if td.is_integer() else td,
98
120
  "Type": text("Type", "PLA"),
99
- "uuid": text("Uuid", str(uuid.uuid4())),
121
+ "uuid": hfp_uuid(mat.get("Uuid")),
100
122
  }
101
123
 
102
124
 
@@ -175,7 +197,7 @@ def generate_project_file(
175
197
  "Owned": False,
176
198
  "Transmissivity": 0.1,
177
199
  "Type": "PLA",
178
- "uuid": str(uuid.uuid4()),
200
+ "uuid": hfp_uuid(),
179
201
  }
180
202
  )
181
203