fiberpath 0.3.0__py3-none-any.whl

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 (49) hide show
  1. fiberpath/__init__.py +10 -0
  2. fiberpath/config/__init__.py +22 -0
  3. fiberpath/config/schemas.py +72 -0
  4. fiberpath/config/validator.py +31 -0
  5. fiberpath/execution/__init__.py +7 -0
  6. fiberpath/execution/marlin.py +311 -0
  7. fiberpath/gcode/__init__.py +6 -0
  8. fiberpath/gcode/dialects.py +47 -0
  9. fiberpath/gcode/generator.py +31 -0
  10. fiberpath/geometry/__init__.py +13 -0
  11. fiberpath/geometry/curves.py +16 -0
  12. fiberpath/geometry/intersections.py +23 -0
  13. fiberpath/geometry/surfaces.py +17 -0
  14. fiberpath/math_utils.py +20 -0
  15. fiberpath/planning/__init__.py +13 -0
  16. fiberpath/planning/calculations.py +50 -0
  17. fiberpath/planning/exceptions.py +19 -0
  18. fiberpath/planning/helpers.py +55 -0
  19. fiberpath/planning/layer_strategies.py +194 -0
  20. fiberpath/planning/machine.py +151 -0
  21. fiberpath/planning/planner.py +133 -0
  22. fiberpath/planning/validators.py +51 -0
  23. fiberpath/simulation/__init__.py +5 -0
  24. fiberpath/simulation/simulator.py +186 -0
  25. fiberpath/visualization/__init__.py +13 -0
  26. fiberpath/visualization/export_json.py +14 -0
  27. fiberpath/visualization/plotter.py +261 -0
  28. fiberpath-0.3.0.dist-info/METADATA +827 -0
  29. fiberpath-0.3.0.dist-info/RECORD +49 -0
  30. fiberpath-0.3.0.dist-info/WHEEL +4 -0
  31. fiberpath-0.3.0.dist-info/entry_points.txt +2 -0
  32. fiberpath-0.3.0.dist-info/licenses/LICENSE +661 -0
  33. fiberpath_api/__init__.py +5 -0
  34. fiberpath_api/main.py +19 -0
  35. fiberpath_api/routes/__init__.py +1 -0
  36. fiberpath_api/routes/plan.py +41 -0
  37. fiberpath_api/routes/simulate.py +29 -0
  38. fiberpath_api/routes/stream.py +49 -0
  39. fiberpath_api/routes/validate.py +21 -0
  40. fiberpath_api/schemas.py +50 -0
  41. fiberpath_cli/__init__.py +5 -0
  42. fiberpath_cli/__main__.py +6 -0
  43. fiberpath_cli/main.py +27 -0
  44. fiberpath_cli/output.py +14 -0
  45. fiberpath_cli/plan.py +100 -0
  46. fiberpath_cli/plot.py +37 -0
  47. fiberpath_cli/simulate.py +36 -0
  48. fiberpath_cli/stream.py +123 -0
  49. fiberpath_cli/validate.py +26 -0
@@ -0,0 +1,186 @@
1
+ """Feed-rate aware simulator for generated G-code programs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections.abc import Iterable, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from fiberpath.gcode.dialects import MarlinDialect
13
+
14
+
15
+ class SimulationError(RuntimeError):
16
+ """Raised when a program cannot be simulated."""
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class SimulationResult:
21
+ commands_executed: int
22
+ moves: int
23
+ estimated_time_s: float
24
+ total_distance_mm: float
25
+ tow_length_mm: float
26
+ average_feed_rate_mmpm: float
27
+
28
+
29
+ HEADER_PREFIX = "; Parameters "
30
+ DEFAULT_FEED_RATE = 6000.0
31
+
32
+
33
+ def simulate_program(
34
+ commands: Iterable[str],
35
+ *,
36
+ default_feed_rate: float = DEFAULT_FEED_RATE,
37
+ dialect: MarlinDialect | None = None,
38
+ ) -> SimulationResult:
39
+ """Estimate execution time/tow usage for a G-code program.
40
+
41
+ Parameters
42
+ ----------
43
+ commands:
44
+ Iterable of G-code lines (typically `plan_wind(...).commands`).
45
+ default_feed_rate:
46
+ Fallback feed rate to use until the program sets one explicitly.
47
+ dialect:
48
+ Dialect specifying axis mapping. If None, attempts auto-detection from G-code.
49
+ """
50
+
51
+ program = list(commands)
52
+ if not program:
53
+ raise SimulationError("Program is empty")
54
+
55
+ # Auto-detect dialect if not provided
56
+ if dialect is None:
57
+ dialect = _detect_dialect(program)
58
+
59
+ metadata = _extract_metadata(program)
60
+ mandrel_circumference = math.pi * metadata["mandrel_diameter"]
61
+
62
+ feed_rate = default_feed_rate
63
+ if feed_rate <= 0:
64
+ raise SimulationError("Default feed rate must be positive")
65
+
66
+ # Get axis letters from dialect
67
+ mapping = dialect.axis_mapping
68
+ carriage_axis = mapping.carriage
69
+ mandrel_axis = mapping.mandrel
70
+ delivery_axis = mapping.delivery_head
71
+
72
+ last_carriage = 0.0
73
+ last_mandrel = 0.0
74
+ last_delivery = 0.0
75
+
76
+ commands_executed = 0
77
+ moves = 0
78
+ total_distance = 0.0
79
+ tow_length = 0.0
80
+ total_time = 0.0
81
+
82
+ for raw_line in program:
83
+ line = raw_line.strip()
84
+ if not line:
85
+ continue
86
+ if line.startswith(";"):
87
+ commands_executed += 1
88
+ continue
89
+
90
+ parts = line.split()
91
+ opcode = parts[0]
92
+ commands_executed += 1
93
+
94
+ if opcode not in {"G0", "G1"}:
95
+ # Control commands still count toward execution but contain no motion.
96
+ for token in parts[1:]:
97
+ if token.startswith("F"):
98
+ feed_rate = float(token[1:])
99
+ continue
100
+
101
+ next_carriage = last_carriage
102
+ next_mandrel = last_mandrel
103
+ next_delivery = last_delivery
104
+
105
+ for token in parts[1:]:
106
+ axis = token[0]
107
+ value = token[1:]
108
+
109
+ if axis == carriage_axis:
110
+ next_carriage = float(value)
111
+ elif axis == mandrel_axis:
112
+ next_mandrel = float(value)
113
+ elif axis == delivery_axis:
114
+ next_delivery = float(value)
115
+ elif axis == "F":
116
+ feed_rate = float(value)
117
+
118
+ carriage_delta = next_carriage - last_carriage
119
+ mandrel_delta_deg = next_mandrel - last_mandrel
120
+ delivery_delta = next_delivery - last_delivery
121
+
122
+ mandrel_delta_mm = mandrel_delta_deg / 360.0 * mandrel_circumference
123
+ distance_sq = carriage_delta**2 + mandrel_delta_mm**2
124
+ tow_length_sq = carriage_delta**2 + mandrel_delta_mm**2
125
+
126
+ if math.isclose(distance_sq, 0.0) and math.isclose(delivery_delta, 0.0):
127
+ last_carriage, last_mandrel, last_delivery = next_carriage, next_mandrel, next_delivery
128
+ continue
129
+
130
+ distance = math.sqrt(distance_sq)
131
+ if feed_rate <= 0:
132
+ raise SimulationError("Encountered non-positive feed rate during simulation")
133
+
134
+ total_time += distance / feed_rate * 60.0
135
+ total_distance += distance
136
+ tow_length += math.sqrt(tow_length_sq)
137
+ moves += 1
138
+
139
+ last_carriage, last_mandrel, last_delivery = next_carriage, next_mandrel, next_delivery
140
+
141
+ average_feed_rate = total_distance / total_time * 60.0 if total_time > 0 else feed_rate
142
+
143
+ return SimulationResult(
144
+ commands_executed=commands_executed,
145
+ moves=moves,
146
+ estimated_time_s=total_time,
147
+ total_distance_mm=total_distance,
148
+ tow_length_mm=tow_length,
149
+ average_feed_rate_mmpm=average_feed_rate,
150
+ )
151
+
152
+
153
+ def _extract_metadata(program: Sequence[str]) -> dict[str, float]:
154
+ for line in program:
155
+ stripped = line.strip()
156
+ if stripped.startswith(HEADER_PREFIX):
157
+ payload = stripped[len(HEADER_PREFIX) :]
158
+ data = json.loads(payload)
159
+ mandrel = data["mandrel"]
160
+ return {"mandrel_diameter": float(mandrel["diameter"])}
161
+ raise SimulationError("Unable to locate Parameters header in program")
162
+
163
+
164
+ def _detect_dialect(program: Sequence[str]) -> MarlinDialect:
165
+ """Auto-detect dialect from G-code by examining axis letters in first move command."""
166
+ from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD, MARLIN_XYZ_LEGACY
167
+
168
+ for line in program:
169
+ stripped = line.strip()
170
+ if not stripped or stripped.startswith(";"):
171
+ continue
172
+ parts = stripped.split()
173
+ if parts[0] in {"G0", "G1", "G92"}:
174
+ # Check which axes are present
175
+ axes_found = {token[0] for token in parts[1:] if token[0].isalpha() and token[0] != "F"}
176
+
177
+ # Check for rotational axes
178
+ if "A" in axes_found or "B" in axes_found:
179
+ # XAB format
180
+ return MARLIN_XAB_STANDARD
181
+ elif "Y" in axes_found or "Z" in axes_found:
182
+ # XYZ format
183
+ return MARLIN_XYZ_LEGACY
184
+
185
+ # Default to legacy if can't detect
186
+ return MARLIN_XYZ_LEGACY
@@ -0,0 +1,13 @@
1
+ """Visualization helpers."""
2
+
3
+ from .export_json import gcode_to_json
4
+ from .plotter import PlotConfig, PlotError, PlotResult, render_plot, save_plot
5
+
6
+ __all__ = [
7
+ "gcode_to_json",
8
+ "PlotConfig",
9
+ "PlotError",
10
+ "PlotResult",
11
+ "render_plot",
12
+ "save_plot",
13
+ ]
@@ -0,0 +1,14 @@
1
+ """Convert G-code streams into a lightweight JSON representation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+
10
+ def gcode_to_json(commands: Iterable[str], destination: str | Path) -> Path:
11
+ target = Path(destination)
12
+ payload = {"commands": [line.strip() for line in commands if line.strip()]}
13
+ target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
14
+ return target
@@ -0,0 +1,261 @@
1
+ """2D plotting helpers for unwrapped mandrel views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import json
7
+ import math
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from hashlib import sha256
11
+ from io import BytesIO
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from PIL import Image, ImageDraw
16
+
17
+ if TYPE_CHECKING:
18
+ from fiberpath.gcode.dialects import MarlinDialect
19
+
20
+ HEIGHT_DEGREES = 360.0
21
+
22
+
23
+ class PlotError(RuntimeError):
24
+ """Raised when plotting fails due to malformed input."""
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class PlotMetadata:
29
+ mandrel_length_mm: float
30
+ tow_width_mm: float
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class PlotConfig:
35
+ scale: float = 1.0
36
+ height_degrees: float = HEIGHT_DEGREES
37
+ background_color: tuple[int, int, int] = (255, 255, 255)
38
+ primary_color: tuple[int, int, int] = (73, 0, 168)
39
+ secondary_color: tuple[int, int, int] = (252, 211, 3)
40
+ secondary_width_scale: float = 0.75
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class PlotResult:
45
+ image: Image.Image
46
+ metadata: PlotMetadata
47
+ segments_rendered: int
48
+
49
+ def to_png_bytes(self) -> bytes:
50
+ buffer = BytesIO()
51
+ self.image.save(buffer, format="PNG")
52
+ return buffer.getvalue()
53
+
54
+
55
+ def render_plot(
56
+ program: Sequence[str],
57
+ config: PlotConfig | None = None,
58
+ dialect: MarlinDialect | None = None,
59
+ ) -> PlotResult:
60
+ if not program:
61
+ raise PlotError("Program is empty; cannot plot")
62
+ config = config or PlotConfig()
63
+ if config.scale <= 0:
64
+ raise PlotError("Scale must be positive")
65
+
66
+ # Auto-detect dialect if not provided
67
+ if dialect is None:
68
+ dialect = _detect_dialect(program)
69
+
70
+ metadata = _extract_metadata(program)
71
+ segments = _collect_segments(program, config.height_degrees, dialect)
72
+
73
+ width_px = max(1, int(round(metadata.mandrel_length_mm * config.scale)))
74
+ height_px = max(1, int(round(config.height_degrees * config.scale)))
75
+ image = Image.new("RGB", (width_px, height_px), color=config.background_color)
76
+
77
+ primary_width = max(1, int(round(metadata.tow_width_mm * config.scale)))
78
+ secondary_width = max(1, int(round(primary_width * config.secondary_width_scale)))
79
+
80
+ drawer = ImageDraw.Draw(image)
81
+ for segment in segments:
82
+ points = [_screen_point(point, config.scale, config.height_degrees) for point in segment]
83
+ drawer.line(points, fill=config.primary_color, width=primary_width)
84
+ drawer.line(points, fill=config.secondary_color, width=secondary_width)
85
+
86
+ return PlotResult(image=image, metadata=metadata, segments_rendered=len(segments))
87
+
88
+
89
+ @dataclass(slots=True, frozen=True)
90
+ class PlotSignature:
91
+ metadata: PlotMetadata
92
+ segments_rendered: int
93
+ digest: str
94
+
95
+
96
+ def compute_plot_signature(
97
+ program: Sequence[str],
98
+ height_degrees: float = HEIGHT_DEGREES,
99
+ dialect: MarlinDialect | None = None,
100
+ ) -> PlotSignature:
101
+ if dialect is None:
102
+ dialect = _detect_dialect(program)
103
+ metadata = _extract_metadata(program)
104
+ segments = _collect_segments(program, height_degrees, dialect)
105
+ digest = _hash_segments(segments)
106
+ return PlotSignature(metadata=metadata, segments_rendered=len(segments), digest=digest)
107
+
108
+
109
+ def save_plot(
110
+ program: Sequence[str],
111
+ destination: Path,
112
+ config: PlotConfig | None = None,
113
+ dialect: MarlinDialect | None = None,
114
+ ) -> Path:
115
+ result = render_plot(program, config, dialect)
116
+ destination.parent.mkdir(parents=True, exist_ok=True)
117
+ result.image.save(destination, format="PNG")
118
+ return destination
119
+
120
+
121
+ def _extract_metadata(program: Sequence[str]) -> PlotMetadata:
122
+ for line in program:
123
+ stripped = line.strip()
124
+ if stripped.startswith("; Parameters "):
125
+ payload = stripped.split(" ", 2)[2]
126
+ data = ast.literal_eval(payload)
127
+ mandrel = data["mandrel"]
128
+ tow = data["tow"]
129
+ return PlotMetadata(
130
+ mandrel_length_mm=float(mandrel["windLength"]),
131
+ tow_width_mm=float(tow["width"]),
132
+ )
133
+ raise PlotError("Unable to find Parameters header in program")
134
+
135
+
136
+ def _collect_segments(
137
+ program: Sequence[str],
138
+ height_degrees: float,
139
+ dialect: MarlinDialect,
140
+ ) -> list[list[tuple[float, float]]]:
141
+ """Extract segments with axis-aware parsing."""
142
+ # Get axis letters from dialect
143
+ mapping = dialect.axis_mapping
144
+ carriage_axis = mapping.carriage
145
+ mandrel_axis = mapping.mandrel
146
+
147
+ x_pos = 0.0
148
+ y_pos = 0.0
149
+ segments: list[list[tuple[float, float]]] = []
150
+
151
+ for raw_line in program:
152
+ line = raw_line.strip()
153
+ if not line or line.startswith(";"):
154
+ continue
155
+ parts = line.split()
156
+ if parts[0] != "G0":
157
+ continue
158
+ next_x = x_pos
159
+ next_y = y_pos
160
+ for token in parts[1:]:
161
+ if token.startswith(carriage_axis):
162
+ next_x = float(token[1:])
163
+ elif token.startswith(mandrel_axis):
164
+ next_y = float(token[1:])
165
+ if math.isclose(next_x, x_pos) and math.isclose(next_y, y_pos):
166
+ continue
167
+ segments.extend(_split_segment((x_pos, y_pos), (next_x, next_y), height_degrees))
168
+ x_pos, y_pos = next_x, next_y
169
+ return segments
170
+
171
+
172
+ def _hash_segments(segments: Sequence[list[tuple[float, float]]]) -> str:
173
+ normalized = [
174
+ [[round(point[0], 6), round(point[1], 6)] for point in segment] for segment in segments
175
+ ]
176
+ payload = json.dumps(normalized, separators=(",", ":")).encode("utf-8")
177
+ return sha256(payload).hexdigest()
178
+
179
+
180
+ def _split_segment(
181
+ start: tuple[float, float],
182
+ end: tuple[float, float],
183
+ height_degrees: float,
184
+ ) -> list[list[tuple[float, float]]]:
185
+ start_band = math.floor(start[1] / height_degrees)
186
+ end_band = math.floor(end[1] / height_degrees)
187
+ if start_band == end_band or math.isclose(start[1], end[1]):
188
+ return [
189
+ [
190
+ (_wrap_x(start[0]), _wrap(start[1], height_degrees)),
191
+ (_wrap_x(end[0]), _wrap(end[1], height_degrees)),
192
+ ]
193
+ ]
194
+
195
+ direction = 1.0 if end[1] > start[1] else -1.0
196
+ boundary_band = math.floor(start[1] / height_degrees) + (1 if direction > 0 else 0)
197
+ boundary_y = boundary_band * height_degrees
198
+ dx = end[0] - start[0]
199
+ if math.isclose(dx, 0.0):
200
+ boundary_x = start[0]
201
+ else:
202
+ slope = (end[1] - start[1]) / dx
203
+ boundary_x = start[0] + (boundary_y - start[1]) / slope
204
+
205
+ exit_y = height_degrees if direction > 0 else 0.0
206
+ first_segment = [
207
+ [
208
+ (_wrap_x(start[0]), _wrap(start[1], height_degrees)),
209
+ (_wrap_x(boundary_x), exit_y),
210
+ ]
211
+ ]
212
+ epsilon = 0.001 * direction
213
+ remainder_start = (boundary_x, boundary_y + epsilon)
214
+ remainder = _split_segment(remainder_start, end, height_degrees)
215
+ return first_segment + remainder
216
+
217
+
218
+ def _wrap(value: float, height_degrees: float) -> float:
219
+ wrapped = value % height_degrees
220
+ if math.isclose(wrapped, height_degrees):
221
+ return 0.0
222
+ return wrapped
223
+
224
+
225
+ def _wrap_x(x_value: float) -> float:
226
+ return x_value
227
+
228
+
229
+ def _screen_point(
230
+ point: tuple[float, float],
231
+ scale: float,
232
+ height_degrees: float,
233
+ ) -> tuple[float, float]:
234
+ x_px = point[0] * scale
235
+ y_px = (point[1] % height_degrees) * scale
236
+ return (x_px, y_px)
237
+
238
+
239
+ def _detect_dialect(program: Sequence[str]) -> MarlinDialect:
240
+ """Auto-detect dialect from G-code by examining axis letters in first move command."""
241
+ from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD, MARLIN_XYZ_LEGACY
242
+
243
+ for line in program:
244
+ stripped = line.strip()
245
+ if not stripped or stripped.startswith(";"):
246
+ continue
247
+ parts = stripped.split()
248
+ if parts[0] in {"G0", "G1", "G92"}:
249
+ # Check which axes are present
250
+ axes_found = {token[0] for token in parts[1:] if token[0].isalpha() and token[0] != "F"}
251
+
252
+ # Check for rotational axes
253
+ if "A" in axes_found or "B" in axes_found:
254
+ # XAB format
255
+ return MARLIN_XAB_STANDARD
256
+ elif "Y" in axes_found or "Z" in axes_found:
257
+ # XYZ format
258
+ return MARLIN_XYZ_LEGACY
259
+
260
+ # Default to legacy if can't detect
261
+ return MARLIN_XYZ_LEGACY