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.
- {autoforge-2.2.0 → autoforge-2.2.1}/PKG-INFO +3 -2
- {autoforge-2.2.0 → autoforge-2.2.1}/pyproject.toml +3 -2
- {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/PKG-INFO +3 -2
- {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/SOURCES.txt +3 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/requires.txt +1 -0
- autoforge-2.2.1/src/autoforge/Helper/BorderMesh.py +532 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/OutputHelper.py +24 -2
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/optimization.py +197 -7
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/outputs.py +110 -3
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/sliders.py +37 -2
- autoforge-2.2.1/src/autoforge/webui/helpers/border.py +173 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/pipeline_runner.py +249 -51
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/models.py +55 -1
- autoforge-2.2.1/tests/test_border_mesh.py +356 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bugfixes_2026_09_26.py +4 -1
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_output_helper.py +57 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/README.md +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/setup.cfg +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/dependency_links.txt +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/entry_points.txt +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/AutoForge.egg-info/top_level.txt +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/AmpUtils.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/CAdamW.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/ConstraintHelper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/DeterministicOps.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/DeviceUtils.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/FilamentHelper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/FusedComposite.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/HeightAssign.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/HeightGuidance.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/HeightfieldMesh.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/ChristofidesHeightMap.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/DepthEstimateHeightMap.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/FastTSPHeightMap.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/Heightmaps/_cluster.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/ImageHelper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/OptimizerHelper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/OtherHelper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/PixelHeightRefine.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/PruningHelper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Helper/filamentcolors_library.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Init_comparer/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Init_comparer/auto_forge_init_check.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Init_comparer/plot_results.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Loss/LossFunctions.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Loss/PerceptionLoss.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Loss/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Misc/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Misc/color_fit_all.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Modules/Optimizer.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/Modules/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/__main__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/auto_forge.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/data/filamentcolors_catalog.json +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/default_materials.csv +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/priority_mask_editor.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/filaments.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/guidance.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/images.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/init.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/preview.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/project.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/pruning.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/settings.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/state.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/system.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/workers.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/api/ws.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/config.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/auto_prune.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/colored_mesh.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/edited_export.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/gpu_memory.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/guidance_views.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/mesh_persist.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/project_heightmap.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/result_store.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/slider_render.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/sliders.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/helpers/telemetry.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/server.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/__init__.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/catalog_service.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/filament_service.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/image_service.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/optimization_service.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/src/autoforge/webui/services/project_service.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_amp_utils.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_auto_forge_cli.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bug_regressions.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bugreport_2026_10_01.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_bugreport_2026_10_03.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_cadamw.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_christofides_heightmap.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_composite_image.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_constrained_opt.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_depth_adjusted_height_map.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_depth_estimate_height_map.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_device_utils.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_edge_bleed.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_end_to_end_optimizer.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_fasttsp_heightmap.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_filament_helper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_filamentcolors_library.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_flatforge.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_graph_cache_eviction.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_graph_capture_threads.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_gumbel_nan.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_height_guidance.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_image_helper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_loss_functions.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_optimizer_module.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_optimizer_training.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_other_helper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_perception_loss.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_pipeline_smoke.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_property_based.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_prune_repeatability.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_pruning_helper.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_remove_height_spikes.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_webui_backend.py +0 -0
- {autoforge-2.2.0 → autoforge-2.2.1}/tests/test_webui_regressions.py +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
@@ -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":
|
|
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":
|
|
200
|
+
"uuid": hfp_uuid(),
|
|
179
201
|
}
|
|
180
202
|
)
|
|
181
203
|
|