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.
Files changed (33) hide show
  1. warp_engine-0.1.0/PKG-INFO +21 -0
  2. warp_engine-0.1.0/pyproject.toml +47 -0
  3. warp_engine-0.1.0/setup.cfg +4 -0
  4. warp_engine-0.1.0/tests/test_engine.py +58 -0
  5. warp_engine-0.1.0/tests/test_materials.py +56 -0
  6. warp_engine-0.1.0/tests/test_models.py +103 -0
  7. warp_engine-0.1.0/tests/test_printers.py +52 -0
  8. warp_engine-0.1.0/warp_engine/__init__.py +2 -0
  9. warp_engine-0.1.0/warp_engine/analysis/__init__.py +1 -0
  10. warp_engine-0.1.0/warp_engine/analysis/adhesion.py +108 -0
  11. warp_engine-0.1.0/warp_engine/analysis/airflow.py +104 -0
  12. warp_engine-0.1.0/warp_engine/analysis/combiner.py +335 -0
  13. warp_engine-0.1.0/warp_engine/analysis/deformation.py +312 -0
  14. warp_engine-0.1.0/warp_engine/analysis/geometry.py +149 -0
  15. warp_engine-0.1.0/warp_engine/analysis/settings_analyzer.py +73 -0
  16. warp_engine-0.1.0/warp_engine/analysis/thermal.py +460 -0
  17. warp_engine-0.1.0/warp_engine/engine.py +89 -0
  18. warp_engine-0.1.0/warp_engine/materials/__init__.py +15 -0
  19. warp_engine-0.1.0/warp_engine/materials/profiles.py +205 -0
  20. warp_engine-0.1.0/warp_engine/models.py +248 -0
  21. warp_engine-0.1.0/warp_engine/parser/__init__.py +1 -0
  22. warp_engine-0.1.0/warp_engine/parser/command_parser.py +101 -0
  23. warp_engine-0.1.0/warp_engine/parser/flavor_detector.py +31 -0
  24. warp_engine-0.1.0/warp_engine/parser/ir_builder.py +87 -0
  25. warp_engine-0.1.0/warp_engine/parser/tokenizer.py +65 -0
  26. warp_engine-0.1.0/warp_engine/printers/__init__.py +1 -0
  27. warp_engine-0.1.0/warp_engine/printers/profiles.py +189 -0
  28. warp_engine-0.1.0/warp_engine/py.typed +0 -0
  29. warp_engine-0.1.0/warp_engine.egg-info/PKG-INFO +21 -0
  30. warp_engine-0.1.0/warp_engine.egg-info/SOURCES.txt +31 -0
  31. warp_engine-0.1.0/warp_engine.egg-info/dependency_links.txt +1 -0
  32. warp_engine-0.1.0/warp_engine.egg-info/requires.txt +7 -0
  33. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ """G-code warp prediction engine."""
2
+ __version__ = "0.1.0"
@@ -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