warp-engine 0.1.0__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.
- warp_engine-0.1.0/PKG-INFO +21 -0
- warp_engine-0.1.0/pyproject.toml +47 -0
- warp_engine-0.1.0/setup.cfg +4 -0
- warp_engine-0.1.0/tests/test_engine.py +58 -0
- warp_engine-0.1.0/tests/test_materials.py +56 -0
- warp_engine-0.1.0/tests/test_models.py +103 -0
- warp_engine-0.1.0/tests/test_printers.py +52 -0
- warp_engine-0.1.0/warp_engine/__init__.py +2 -0
- warp_engine-0.1.0/warp_engine/analysis/__init__.py +1 -0
- warp_engine-0.1.0/warp_engine/analysis/adhesion.py +108 -0
- warp_engine-0.1.0/warp_engine/analysis/airflow.py +104 -0
- warp_engine-0.1.0/warp_engine/analysis/combiner.py +335 -0
- warp_engine-0.1.0/warp_engine/analysis/deformation.py +312 -0
- warp_engine-0.1.0/warp_engine/analysis/geometry.py +149 -0
- warp_engine-0.1.0/warp_engine/analysis/settings_analyzer.py +73 -0
- warp_engine-0.1.0/warp_engine/analysis/thermal.py +460 -0
- warp_engine-0.1.0/warp_engine/engine.py +89 -0
- warp_engine-0.1.0/warp_engine/materials/__init__.py +15 -0
- warp_engine-0.1.0/warp_engine/materials/profiles.py +205 -0
- warp_engine-0.1.0/warp_engine/models.py +248 -0
- warp_engine-0.1.0/warp_engine/parser/__init__.py +1 -0
- warp_engine-0.1.0/warp_engine/parser/command_parser.py +101 -0
- warp_engine-0.1.0/warp_engine/parser/flavor_detector.py +31 -0
- warp_engine-0.1.0/warp_engine/parser/ir_builder.py +87 -0
- warp_engine-0.1.0/warp_engine/parser/tokenizer.py +65 -0
- warp_engine-0.1.0/warp_engine/printers/__init__.py +1 -0
- warp_engine-0.1.0/warp_engine/printers/profiles.py +189 -0
- warp_engine-0.1.0/warp_engine/py.typed +0 -0
- warp_engine-0.1.0/warp_engine.egg-info/PKG-INFO +21 -0
- warp_engine-0.1.0/warp_engine.egg-info/SOURCES.txt +31 -0
- warp_engine-0.1.0/warp_engine.egg-info/dependency_links.txt +1 -0
- warp_engine-0.1.0/warp_engine.egg-info/requires.txt +7 -0
- warp_engine-0.1.0/warp_engine.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: warp-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: G-code warp prediction engine for 3D printing
|
|
5
|
+
Author: RM Productions LLC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Intended Audience :: Manufacturing
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: numpy>=2.0
|
|
17
|
+
Requires-Dist: scipy>=1.12
|
|
18
|
+
Requires-Dist: trimesh>=4.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools.packages.find]
|
|
6
|
+
include = ["warp_engine*"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "warp-engine"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "G-code warp prediction engine for 3D printing"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
authors = [
|
|
15
|
+
{ name = "RM Productions LLC" },
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Manufacturing",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Scientific/Engineering",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy>=2.0",
|
|
29
|
+
"scipy>=1.12",
|
|
30
|
+
"trimesh>=4.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-cov>=5.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
python_files = ["test_*.py"]
|
|
42
|
+
python_functions = ["test_*"]
|
|
43
|
+
addopts = "--strict-markers --tb=short"
|
|
44
|
+
markers = [
|
|
45
|
+
"slow: marks tests as slow",
|
|
46
|
+
"integration: integration tests",
|
|
47
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tests for the main engine entry point."""
|
|
2
|
+
from warp_engine.engine import Engine
|
|
3
|
+
from warp_engine.models import WarpReport, RiskLevel, AnalysisTier
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SIMPLE_GCODE = """\
|
|
7
|
+
;FLAVOR:Marlin
|
|
8
|
+
M140 S60
|
|
9
|
+
M104 S210
|
|
10
|
+
M190 S60
|
|
11
|
+
M109 S210
|
|
12
|
+
G28
|
|
13
|
+
M83
|
|
14
|
+
G1 Z0.3 F600
|
|
15
|
+
G1 X80 Y0 E5.0 F1200
|
|
16
|
+
G1 X80 Y80 E10.0
|
|
17
|
+
G1 X0 Y80 E15.0
|
|
18
|
+
G1 X0 Y0 E20.0
|
|
19
|
+
G1 Z0.5
|
|
20
|
+
G1 X80 Y0 E25.0
|
|
21
|
+
G1 X80 Y80 E30.0
|
|
22
|
+
G1 X0 Y80 E35.0
|
|
23
|
+
G1 X0 Y0 E40.0
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_engine_analyze_returns_report():
|
|
28
|
+
engine = Engine()
|
|
29
|
+
result = engine.analyze(gcode=SIMPLE_GCODE, material="PLA")
|
|
30
|
+
assert isinstance(result, WarpReport)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_engine_default_tier_is_quick():
|
|
34
|
+
engine = Engine()
|
|
35
|
+
result = engine.analyze(gcode=SIMPLE_GCODE, material="PLA")
|
|
36
|
+
assert result.metadata.tier == AnalysisTier.QUICK
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_engine_with_printer_profile():
|
|
40
|
+
engine = Engine()
|
|
41
|
+
result = engine.analyze(
|
|
42
|
+
gcode=SIMPLE_GCODE, material="PLA", printer_profile="bambu_x1c",
|
|
43
|
+
)
|
|
44
|
+
assert isinstance(result, WarpReport)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_engine_abs_higher_risk_than_pla():
|
|
48
|
+
engine = Engine()
|
|
49
|
+
pla = engine.analyze(gcode=SIMPLE_GCODE, material="PLA")
|
|
50
|
+
abs_r = engine.analyze(gcode=SIMPLE_GCODE, material="ABS")
|
|
51
|
+
assert abs_r.overall_score >= pla.overall_score
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_engine_empty_gcode():
|
|
55
|
+
engine = Engine()
|
|
56
|
+
result = engine.analyze(gcode="", material="PLA")
|
|
57
|
+
assert result.overall_score == 0.0
|
|
58
|
+
assert result.risk_level == RiskLevel.LOW
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tests for material property database."""
|
|
2
|
+
from warp_engine.materials.profiles import (
|
|
3
|
+
MaterialProfile, get_material, list_materials, MATERIAL_DB,
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_pla_exists():
|
|
8
|
+
pla = get_material("PLA")
|
|
9
|
+
assert pla is not None
|
|
10
|
+
assert pla.name == "PLA"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_abs_exists():
|
|
14
|
+
abs_mat = get_material("ABS")
|
|
15
|
+
assert abs_mat is not None
|
|
16
|
+
assert abs_mat.glass_transition_temp > 100
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_pla_glass_transition():
|
|
20
|
+
pla = get_material("PLA")
|
|
21
|
+
assert 55 <= pla.glass_transition_temp <= 65
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_abs_higher_warp_susceptibility_than_pla():
|
|
25
|
+
pla = get_material("PLA")
|
|
26
|
+
abs_mat = get_material("ABS")
|
|
27
|
+
assert abs_mat.warp_susceptibility > pla.warp_susceptibility
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_list_materials_returns_all():
|
|
31
|
+
materials = list_materials()
|
|
32
|
+
assert "PLA" in materials
|
|
33
|
+
assert "ABS" in materials
|
|
34
|
+
assert "PETG" in materials
|
|
35
|
+
assert len(materials) >= 7
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_material_case_insensitive():
|
|
39
|
+
assert get_material("pla") is not None
|
|
40
|
+
assert get_material("Pla") is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_get_unknown_material_returns_none():
|
|
44
|
+
assert get_material("unobtainium") is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_all_materials_have_valid_cte():
|
|
48
|
+
for name in list_materials():
|
|
49
|
+
mat = get_material(name)
|
|
50
|
+
assert mat.cte > 0, f"{name} has invalid CTE"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_thermal_diffusivity_derived():
|
|
54
|
+
pla = get_material("PLA")
|
|
55
|
+
expected = pla.thermal_conductivity / (pla.density * pla.specific_heat)
|
|
56
|
+
assert abs(pla.thermal_diffusivity - expected) < 1e-12
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Tests for core data models."""
|
|
2
|
+
from warp_engine.models import (
|
|
3
|
+
Vec3, BBox3D, MoveType, AdhesionType, GcodeFlavor, BedShape,
|
|
4
|
+
RiskLevel, AirflowType, AnalysisTier,
|
|
5
|
+
MoveSegment, Layer, PrintSettings, PrintIR,
|
|
6
|
+
AirflowSource, ThermalEnvironment, LayerThermalState, ThermalField,
|
|
7
|
+
Region, GeometryRisk, AdhesionRisk, SettingsRisk,
|
|
8
|
+
RiskRegion, Recommendation, AnalysisMetadata, WarpReport,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_vec3_creation():
|
|
13
|
+
v = Vec3(1.0, 2.0, 3.0)
|
|
14
|
+
assert v.x == 1.0
|
|
15
|
+
assert v.y == 2.0
|
|
16
|
+
assert v.z == 3.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_vec3_distance():
|
|
20
|
+
a = Vec3(0.0, 0.0, 0.0)
|
|
21
|
+
b = Vec3(3.0, 4.0, 0.0)
|
|
22
|
+
assert abs(a.distance_to(b) - 5.0) < 1e-9
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_bbox3d_volume():
|
|
26
|
+
box = BBox3D(min=Vec3(0, 0, 0), max=Vec3(10, 20, 30))
|
|
27
|
+
assert box.volume() == 6000.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_move_segment_length():
|
|
31
|
+
seg = MoveSegment(
|
|
32
|
+
start=Vec3(0, 0, 0), end=Vec3(10, 0, 0),
|
|
33
|
+
extrusion=1.0, speed=60.0, move_type=MoveType.PERIMETER,
|
|
34
|
+
)
|
|
35
|
+
assert abs(seg.length - 10.0) < 1e-9
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_layer_total_extrusion():
|
|
39
|
+
seg1 = MoveSegment(
|
|
40
|
+
start=Vec3(0, 0, 0), end=Vec3(10, 0, 0),
|
|
41
|
+
extrusion=1.5, speed=60.0, move_type=MoveType.PERIMETER,
|
|
42
|
+
)
|
|
43
|
+
seg2 = MoveSegment(
|
|
44
|
+
start=Vec3(10, 0, 0), end=Vec3(20, 0, 0),
|
|
45
|
+
extrusion=1.5, speed=60.0, move_type=MoveType.INFILL,
|
|
46
|
+
)
|
|
47
|
+
layer = Layer(z_height=0.2, segments=[seg1, seg2], layer_time=10.0, fan_speed=0.0)
|
|
48
|
+
assert abs(layer.total_extrusion - 3.0) < 1e-9
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_print_settings_defaults():
|
|
52
|
+
ps = PrintSettings(
|
|
53
|
+
bed_temp=60.0, nozzle_temp=210.0, material="PLA",
|
|
54
|
+
layer_height=0.2, first_layer_height=0.3,
|
|
55
|
+
adhesion_type=AdhesionType.SKIRT,
|
|
56
|
+
infill_density=0.2, infill_pattern="grid",
|
|
57
|
+
)
|
|
58
|
+
assert ps.material == "PLA"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_print_ir_layer_count():
|
|
62
|
+
ir = PrintIR(
|
|
63
|
+
layers=[], settings=PrintSettings(
|
|
64
|
+
bed_temp=60, nozzle_temp=210, material="PLA",
|
|
65
|
+
layer_height=0.2, first_layer_height=0.3,
|
|
66
|
+
adhesion_type=AdhesionType.NONE,
|
|
67
|
+
infill_density=0.2, infill_pattern="grid",
|
|
68
|
+
),
|
|
69
|
+
bed_shape=BedShape.RECTANGULAR, flavor=GcodeFlavor.MARLIN,
|
|
70
|
+
)
|
|
71
|
+
assert ir.layer_count == 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_thermal_field_creation():
|
|
75
|
+
tf = ThermalField(layer_temps=[], hotspots=[], stress_map={})
|
|
76
|
+
assert tf.layer_temps == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_warp_report_creation():
|
|
80
|
+
report = WarpReport(
|
|
81
|
+
overall_score=0.5, risk_level=RiskLevel.MODERATE,
|
|
82
|
+
regions=[], recommendations=[],
|
|
83
|
+
thermal_field=ThermalField(layer_temps=[], hotspots=[], stress_map={}),
|
|
84
|
+
metadata=AnalysisMetadata(tier=AnalysisTier.QUICK, time_ms=100, version="0.1.0"),
|
|
85
|
+
)
|
|
86
|
+
assert report.risk_level == RiskLevel.MODERATE
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_risk_level_ordering():
|
|
90
|
+
assert RiskLevel.LOW.value < RiskLevel.MODERATE.value
|
|
91
|
+
assert RiskLevel.MODERATE.value < RiskLevel.HIGH.value
|
|
92
|
+
assert RiskLevel.HIGH.value < RiskLevel.CRITICAL.value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_recommendation_creation():
|
|
96
|
+
rec = Recommendation(
|
|
97
|
+
priority=1, category="temperature",
|
|
98
|
+
message="Increase bed temp to 65C",
|
|
99
|
+
expected_improvement=0.15,
|
|
100
|
+
setting_change={"bed_temp": 65},
|
|
101
|
+
)
|
|
102
|
+
assert rec.priority == 1
|
|
103
|
+
assert rec.setting_change == {"bed_temp": 65}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for printer profile database."""
|
|
2
|
+
from warp_engine.printers.profiles import (
|
|
3
|
+
PrinterProfile, get_printer, list_printers,
|
|
4
|
+
)
|
|
5
|
+
from warp_engine.models import AirflowType, BedShape
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_bambu_x1c_exists():
|
|
9
|
+
printer = get_printer("bambu_x1c")
|
|
10
|
+
assert printer is not None
|
|
11
|
+
assert "X1" in printer.name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_bambu_h2d_has_side_fan():
|
|
15
|
+
printer = get_printer("bambu_h2d")
|
|
16
|
+
assert printer is not None
|
|
17
|
+
fan_types = [af.type for af in printer.airflow_sources]
|
|
18
|
+
assert AirflowType.SIDE_FAN in fan_types
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_bambu_a1_no_enclosure():
|
|
22
|
+
printer = get_printer("bambu_a1")
|
|
23
|
+
assert printer is not None
|
|
24
|
+
assert printer.has_enclosure is False
|
|
25
|
+
assert printer.default_chamber_temp is None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_enclosed_printer_has_chamber_temp():
|
|
29
|
+
printer = get_printer("bambu_x1c")
|
|
30
|
+
assert printer.has_enclosure is True
|
|
31
|
+
assert printer.default_chamber_temp is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_list_printers_has_entries():
|
|
35
|
+
printers = list_printers()
|
|
36
|
+
assert len(printers) >= 4
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get_unknown_printer_returns_none():
|
|
40
|
+
assert get_printer("nonexistent_printer") is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_all_printers_have_part_cooling():
|
|
44
|
+
for pid in list_printers():
|
|
45
|
+
printer = get_printer(pid)
|
|
46
|
+
fan_types = [af.type for af in printer.airflow_sources]
|
|
47
|
+
assert AirflowType.PART_COOLING in fan_types, f"{pid} missing part cooling"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_bed_shape():
|
|
51
|
+
printer = get_printer("bambu_x1c")
|
|
52
|
+
assert printer.bed_shape == BedShape.RECTANGULAR
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis engine package."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Adhesion risk analyzer.
|
|
2
|
+
|
|
3
|
+
Evaluates bed adhesion risk by examining first-layer contact area,
|
|
4
|
+
adhesion type, perimeter length, and estimated lift forces.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from warp_engine.models import AdhesionRisk, AdhesionType, MoveType, PrintIR
|
|
9
|
+
|
|
10
|
+
# Assumed nozzle width for contact area estimation (mm).
|
|
11
|
+
_NOZZLE_WIDTH = 0.4
|
|
12
|
+
|
|
13
|
+
# Adhesion type scores: higher means better adhesion assistance.
|
|
14
|
+
_ADHESION_SCORES: dict[AdhesionType, float] = {
|
|
15
|
+
AdhesionType.NONE: 0.0,
|
|
16
|
+
AdhesionType.SKIRT: 0.2,
|
|
17
|
+
AdhesionType.BRIM: 0.6,
|
|
18
|
+
AdhesionType.RAFT: 0.9,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def analyze_adhesion(ir: PrintIR) -> AdhesionRisk:
|
|
23
|
+
"""Analyze adhesion risk for a print.
|
|
24
|
+
|
|
25
|
+
Examines the first layer to estimate bed contact area, perimeter length,
|
|
26
|
+
and coverage. Scores the chosen adhesion type and estimates a rough
|
|
27
|
+
lift-force metric.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
ir:
|
|
32
|
+
The intermediate representation of the parsed G-code.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
AdhesionRisk
|
|
37
|
+
Populated adhesion risk dataclass.
|
|
38
|
+
"""
|
|
39
|
+
if not ir.layers:
|
|
40
|
+
return AdhesionRisk(
|
|
41
|
+
bed_contact_area=0.0,
|
|
42
|
+
contact_ratio=0.0,
|
|
43
|
+
first_layer_coverage=0.0,
|
|
44
|
+
adhesion_type_score=_ADHESION_SCORES.get(
|
|
45
|
+
ir.settings.adhesion_type, 0.0
|
|
46
|
+
),
|
|
47
|
+
perimeter_length=0.0,
|
|
48
|
+
lift_force_estimate=0.0,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
first_layer = ir.layers[0]
|
|
52
|
+
|
|
53
|
+
# Sum path lengths for extrusion moves (non-travel) on the first layer.
|
|
54
|
+
total_path_length = 0.0
|
|
55
|
+
perimeter_length = 0.0
|
|
56
|
+
|
|
57
|
+
for seg in first_layer.segments:
|
|
58
|
+
if seg.move_type == MoveType.TRAVEL:
|
|
59
|
+
continue
|
|
60
|
+
seg_len = seg.length
|
|
61
|
+
total_path_length += seg_len
|
|
62
|
+
if seg.move_type == MoveType.PERIMETER:
|
|
63
|
+
perimeter_length += seg_len
|
|
64
|
+
|
|
65
|
+
# Contact area: path length * nozzle width (mm^2).
|
|
66
|
+
bed_contact_area = total_path_length * _NOZZLE_WIDTH
|
|
67
|
+
|
|
68
|
+
# Bounding-box area of the first layer for coverage ratio.
|
|
69
|
+
xs = []
|
|
70
|
+
ys = []
|
|
71
|
+
for seg in first_layer.segments:
|
|
72
|
+
if seg.move_type == MoveType.TRAVEL:
|
|
73
|
+
continue
|
|
74
|
+
xs.extend([seg.start.x, seg.end.x])
|
|
75
|
+
ys.extend([seg.start.y, seg.end.y])
|
|
76
|
+
|
|
77
|
+
if xs and ys:
|
|
78
|
+
bbox_area = (max(xs) - min(xs)) * (max(ys) - min(ys))
|
|
79
|
+
else:
|
|
80
|
+
bbox_area = 0.0
|
|
81
|
+
|
|
82
|
+
# Contact ratio: fraction of bounding box covered by extrusion paths.
|
|
83
|
+
contact_ratio = bed_contact_area / bbox_area if bbox_area > 0 else 0.0
|
|
84
|
+
|
|
85
|
+
# First layer coverage is the same as contact ratio (0-1 range, clamped).
|
|
86
|
+
first_layer_coverage = min(contact_ratio, 1.0)
|
|
87
|
+
|
|
88
|
+
# Adhesion type score.
|
|
89
|
+
adhesion_type_score = _ADHESION_SCORES.get(
|
|
90
|
+
ir.settings.adhesion_type, 0.0
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Lift-force estimate: a heuristic proportional to perimeter length
|
|
94
|
+
# divided by contact area. Longer perimeters relative to contact area
|
|
95
|
+
# mean higher edge-peel risk.
|
|
96
|
+
if bed_contact_area > 0:
|
|
97
|
+
lift_force_estimate = perimeter_length / bed_contact_area
|
|
98
|
+
else:
|
|
99
|
+
lift_force_estimate = 0.0
|
|
100
|
+
|
|
101
|
+
return AdhesionRisk(
|
|
102
|
+
bed_contact_area=bed_contact_area,
|
|
103
|
+
contact_ratio=contact_ratio,
|
|
104
|
+
first_layer_coverage=first_layer_coverage,
|
|
105
|
+
adhesion_type_score=adhesion_type_score,
|
|
106
|
+
perimeter_length=perimeter_length,
|
|
107
|
+
lift_force_estimate=lift_force_estimate,
|
|
108
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Airflow-aware convection coefficient computation.
|
|
2
|
+
|
|
3
|
+
Computes spatially varying convective heat transfer coefficient h(x,y,z)
|
|
4
|
+
based on distance and angle from each AirflowSource.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from warp_engine.models import AirflowSource, AirflowType, Vec3
|
|
11
|
+
|
|
12
|
+
# Base natural convection coefficient (no forced airflow)
|
|
13
|
+
H_NATURAL = 8.0 # W/(m**2*K)
|
|
14
|
+
|
|
15
|
+
# Maximum forced convection from a fan at 100% speed
|
|
16
|
+
H_FAN_MAX = 40.0 # W/(m**2*K)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_convection_coefficient(
|
|
20
|
+
point: Vec3,
|
|
21
|
+
airflow_sources: list[AirflowSource],
|
|
22
|
+
layer_index: int = 0,
|
|
23
|
+
) -> float:
|
|
24
|
+
"""Compute the convective heat transfer coefficient at a point.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
point: The (x,y,z) position to evaluate.
|
|
28
|
+
airflow_sources: List of fan/airflow sources.
|
|
29
|
+
layer_index: Current layer index (used to look up fan speed curves).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
h in W/(m**2*K) -- sum of natural convection + all fan contributions.
|
|
33
|
+
"""
|
|
34
|
+
h = H_NATURAL
|
|
35
|
+
|
|
36
|
+
for source in airflow_sources:
|
|
37
|
+
h += _fan_contribution(point, source, layer_index)
|
|
38
|
+
|
|
39
|
+
return h
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fan_contribution(point: Vec3, source: AirflowSource, layer_index: int) -> float:
|
|
43
|
+
"""Compute convection contribution from a single airflow source."""
|
|
44
|
+
# Get fan speed at this layer
|
|
45
|
+
speed_pct = _get_speed_at_layer(source.speed_curve, layer_index)
|
|
46
|
+
if speed_pct <= 0:
|
|
47
|
+
return 0.0
|
|
48
|
+
|
|
49
|
+
speed_factor = speed_pct / 100.0
|
|
50
|
+
|
|
51
|
+
# Vector from fan to point
|
|
52
|
+
dx = point.x - source.position.x
|
|
53
|
+
dy = point.y - source.position.y
|
|
54
|
+
dz = point.z - source.position.z
|
|
55
|
+
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
56
|
+
|
|
57
|
+
if dist < 1e-6:
|
|
58
|
+
return H_FAN_MAX * speed_factor
|
|
59
|
+
|
|
60
|
+
# Angle between fan direction and vector to point
|
|
61
|
+
dot = (
|
|
62
|
+
dx * source.direction.x + dy * source.direction.y + dz * source.direction.z
|
|
63
|
+
) / dist
|
|
64
|
+
dir_mag = math.sqrt(
|
|
65
|
+
source.direction.x ** 2
|
|
66
|
+
+ source.direction.y ** 2
|
|
67
|
+
+ source.direction.z ** 2
|
|
68
|
+
)
|
|
69
|
+
if dir_mag > 0:
|
|
70
|
+
dot /= dir_mag
|
|
71
|
+
|
|
72
|
+
# Cosine of angle -- 1.0 = directly in line, 0 = perpendicular, -1 = behind
|
|
73
|
+
cos_angle = max(0.0, dot) # No contribution from behind the fan
|
|
74
|
+
|
|
75
|
+
# Angular falloff -- within spread angle: full effect, outside: decays
|
|
76
|
+
spread_rad = math.radians(source.spread_angle)
|
|
77
|
+
angle = math.acos(min(1.0, cos_angle))
|
|
78
|
+
if angle <= spread_rad:
|
|
79
|
+
angle_factor = 1.0
|
|
80
|
+
else:
|
|
81
|
+
# Exponential decay outside spread cone
|
|
82
|
+
angle_factor = math.exp(-(angle - spread_rad) / spread_rad)
|
|
83
|
+
|
|
84
|
+
# Distance falloff -- inverse square but capped
|
|
85
|
+
dist_factor = 1.0 / (1.0 + (dist / 100.0) ** 2)
|
|
86
|
+
|
|
87
|
+
return H_FAN_MAX * speed_factor * angle_factor * dist_factor
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get_speed_at_layer(
|
|
91
|
+
speed_curve: list[tuple[int, float]], layer_index: int
|
|
92
|
+
) -> float:
|
|
93
|
+
"""Look up fan speed percentage at a given layer from the speed curve."""
|
|
94
|
+
if not speed_curve:
|
|
95
|
+
return 0.0
|
|
96
|
+
|
|
97
|
+
# Find the last entry at or before this layer
|
|
98
|
+
result = speed_curve[0][1]
|
|
99
|
+
for layer, speed in speed_curve:
|
|
100
|
+
if layer <= layer_index:
|
|
101
|
+
result = speed
|
|
102
|
+
else:
|
|
103
|
+
break
|
|
104
|
+
return result
|