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.
- fiberpath/__init__.py +10 -0
- fiberpath/config/__init__.py +22 -0
- fiberpath/config/schemas.py +72 -0
- fiberpath/config/validator.py +31 -0
- fiberpath/execution/__init__.py +7 -0
- fiberpath/execution/marlin.py +311 -0
- fiberpath/gcode/__init__.py +6 -0
- fiberpath/gcode/dialects.py +47 -0
- fiberpath/gcode/generator.py +31 -0
- fiberpath/geometry/__init__.py +13 -0
- fiberpath/geometry/curves.py +16 -0
- fiberpath/geometry/intersections.py +23 -0
- fiberpath/geometry/surfaces.py +17 -0
- fiberpath/math_utils.py +20 -0
- fiberpath/planning/__init__.py +13 -0
- fiberpath/planning/calculations.py +50 -0
- fiberpath/planning/exceptions.py +19 -0
- fiberpath/planning/helpers.py +55 -0
- fiberpath/planning/layer_strategies.py +194 -0
- fiberpath/planning/machine.py +151 -0
- fiberpath/planning/planner.py +133 -0
- fiberpath/planning/validators.py +51 -0
- fiberpath/simulation/__init__.py +5 -0
- fiberpath/simulation/simulator.py +186 -0
- fiberpath/visualization/__init__.py +13 -0
- fiberpath/visualization/export_json.py +14 -0
- fiberpath/visualization/plotter.py +261 -0
- fiberpath-0.3.0.dist-info/METADATA +827 -0
- fiberpath-0.3.0.dist-info/RECORD +49 -0
- fiberpath-0.3.0.dist-info/WHEEL +4 -0
- fiberpath-0.3.0.dist-info/entry_points.txt +2 -0
- fiberpath-0.3.0.dist-info/licenses/LICENSE +661 -0
- fiberpath_api/__init__.py +5 -0
- fiberpath_api/main.py +19 -0
- fiberpath_api/routes/__init__.py +1 -0
- fiberpath_api/routes/plan.py +41 -0
- fiberpath_api/routes/simulate.py +29 -0
- fiberpath_api/routes/stream.py +49 -0
- fiberpath_api/routes/validate.py +21 -0
- fiberpath_api/schemas.py +50 -0
- fiberpath_cli/__init__.py +5 -0
- fiberpath_cli/__main__.py +6 -0
- fiberpath_cli/main.py +27 -0
- fiberpath_cli/output.py +14 -0
- fiberpath_cli/plan.py +100 -0
- fiberpath_cli/plot.py +37 -0
- fiberpath_cli/simulate.py +36 -0
- fiberpath_cli/stream.py +123 -0
- fiberpath_cli/validate.py +26 -0
fiberpath/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration schemas and validators for FiberPath."""
|
|
2
|
+
|
|
3
|
+
from .schemas import (
|
|
4
|
+
HelicalLayer,
|
|
5
|
+
HoopLayer,
|
|
6
|
+
MandrelParameters,
|
|
7
|
+
SkipLayer,
|
|
8
|
+
TowParameters,
|
|
9
|
+
WindDefinition,
|
|
10
|
+
)
|
|
11
|
+
from .validator import WindFileError, load_wind_definition
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"HelicalLayer",
|
|
15
|
+
"HoopLayer",
|
|
16
|
+
"MandrelParameters",
|
|
17
|
+
"SkipLayer",
|
|
18
|
+
"TowParameters",
|
|
19
|
+
"WindDefinition",
|
|
20
|
+
"WindFileError",
|
|
21
|
+
"load_wind_definition",
|
|
22
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Typed configuration models shared across the FiberPath toolchain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseFiberPathModel(BaseModel):
|
|
12
|
+
"""Base class that applies shared Pydantic configuration."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MandrelParameters(BaseFiberPathModel):
|
|
18
|
+
diameter: PositiveFloat
|
|
19
|
+
wind_length: PositiveFloat = Field(alias="windLength")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TowParameters(BaseFiberPathModel):
|
|
23
|
+
width: PositiveFloat
|
|
24
|
+
thickness: PositiveFloat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HoopLayer(BaseFiberPathModel):
|
|
28
|
+
wind_type: Literal["hoop"] = Field(alias="windType", default="hoop")
|
|
29
|
+
terminal: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HelicalLayer(BaseFiberPathModel):
|
|
33
|
+
wind_type: Literal["helical"] = Field(alias="windType", default="helical")
|
|
34
|
+
wind_angle: PositiveFloat = Field(alias="windAngle")
|
|
35
|
+
pattern_number: PositiveInt = Field(alias="patternNumber")
|
|
36
|
+
skip_index: PositiveInt = Field(alias="skipIndex")
|
|
37
|
+
lock_degrees: PositiveFloat = Field(alias="lockDegrees")
|
|
38
|
+
lead_in_mm: PositiveFloat = Field(alias="leadInMM")
|
|
39
|
+
lead_out_degrees: PositiveFloat = Field(alias="leadOutDegrees")
|
|
40
|
+
skip_initial_near_lock: bool | None = Field(default=None, alias="skipInitialNearLock")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkipLayer(BaseFiberPathModel):
|
|
44
|
+
wind_type: Literal["skip"] = Field(alias="windType", default="skip")
|
|
45
|
+
mandrel_rotation: float = Field(alias="mandrelRotation")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
LayerModel = Annotated[
|
|
49
|
+
HoopLayer | HelicalLayer | SkipLayer,
|
|
50
|
+
Field(discriminator="wind_type"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WindDefinition(BaseFiberPathModel):
|
|
55
|
+
layers: list[LayerModel]
|
|
56
|
+
mandrel_parameters: Annotated[MandrelParameters, Field(alias="mandrelParameters")]
|
|
57
|
+
tow_parameters: Annotated[TowParameters, Field(alias="towParameters")]
|
|
58
|
+
default_feed_rate: PositiveFloat = Field(alias="defaultFeedRate")
|
|
59
|
+
|
|
60
|
+
def dump_header(self) -> str:
|
|
61
|
+
def _normalize(value: object) -> object:
|
|
62
|
+
if isinstance(value, float) and value.is_integer():
|
|
63
|
+
return int(value)
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
return {k: _normalize(v) for k, v in value.items()}
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
payload = {
|
|
69
|
+
"mandrel": _normalize(self.mandrel_parameters.model_dump(by_alias=True)),
|
|
70
|
+
"tow": _normalize(self.tow_parameters.model_dump(by_alias=True)),
|
|
71
|
+
}
|
|
72
|
+
return f"; Parameters {json.dumps(payload, separators=(',', ':'))}"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Helpers for loading and validating FiberPath input files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from .schemas import WindDefinition
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WindFileError(RuntimeError):
|
|
14
|
+
"""Raised when a wind definition file cannot be parsed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_wind_definition(path: str | Path) -> WindDefinition:
|
|
18
|
+
"""Load, parse, and validate a ``.wind`` definition file."""
|
|
19
|
+
|
|
20
|
+
location = Path(path)
|
|
21
|
+
if not location.exists():
|
|
22
|
+
raise WindFileError(f"No wind definition found at {location}")
|
|
23
|
+
try:
|
|
24
|
+
payload = json.loads(location.read_text(encoding="utf-8"))
|
|
25
|
+
except json.JSONDecodeError as exc: # pragma: no cover - extremely rare
|
|
26
|
+
raise WindFileError(f"Invalid JSON in {location}: {exc}") from exc
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return WindDefinition.model_validate(payload)
|
|
30
|
+
except ValidationError as exc:
|
|
31
|
+
raise WindFileError(f"Wind definition at {location} failed validation: {exc}") from exc
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Utilities for streaming G-code to Marlin-based controllers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol, cast
|
|
9
|
+
|
|
10
|
+
DEFAULT_BAUD_RATE = 250_000
|
|
11
|
+
DEFAULT_RESPONSE_TIMEOUT = 10.0 # Allow time for slow moves (e.g., large rotations)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StreamError(RuntimeError):
|
|
15
|
+
"""Raised when streaming cannot proceed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SerialTransport(Protocol):
|
|
19
|
+
"""Minimal interface expected by :class:`MarlinStreamer`."""
|
|
20
|
+
|
|
21
|
+
def write_line(self, data: str) -> None:
|
|
22
|
+
"""Write a G-code line to the transport."""
|
|
23
|
+
|
|
24
|
+
def readline(self, timeout: float | None = None) -> str | None:
|
|
25
|
+
"""Return a single response line or ``None`` on timeout."""
|
|
26
|
+
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
"""Close the transport."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PySerialTransport:
|
|
32
|
+
"""Serial transport backed by :mod:`pyserial`."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, port: str, baud_rate: int, timeout: float) -> None:
|
|
35
|
+
try:
|
|
36
|
+
import serial # type: ignore
|
|
37
|
+
except ImportError as exc: # pragma: no cover - dependency error surfaced to caller
|
|
38
|
+
raise StreamError(
|
|
39
|
+
"pyserial is required for live streaming; install fiberpath with the CLI extras"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
self._serial = serial.serial_for_url(
|
|
43
|
+
port,
|
|
44
|
+
baudrate=baud_rate,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
write_timeout=timeout,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def write_line(self, data: str) -> None:
|
|
50
|
+
payload = (data + "\n").encode("utf-8")
|
|
51
|
+
self._serial.write(payload)
|
|
52
|
+
self._serial.flush()
|
|
53
|
+
|
|
54
|
+
def readline(self, timeout: float | None = None) -> str | None:
|
|
55
|
+
previous_timeout: float | None = None
|
|
56
|
+
if timeout is not None:
|
|
57
|
+
previous_timeout = self._serial.timeout
|
|
58
|
+
self._serial.timeout = timeout
|
|
59
|
+
raw = cast(bytes, self._serial.readline())
|
|
60
|
+
if timeout is not None and previous_timeout is not None:
|
|
61
|
+
self._serial.timeout = previous_timeout
|
|
62
|
+
if not raw:
|
|
63
|
+
return None
|
|
64
|
+
return raw.decode("utf-8", errors="ignore").strip()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._serial.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class StreamProgress:
|
|
72
|
+
"""Per-command streaming progress."""
|
|
73
|
+
|
|
74
|
+
commands_sent: int
|
|
75
|
+
commands_total: int
|
|
76
|
+
command: str
|
|
77
|
+
dry_run: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MarlinStreamer:
|
|
81
|
+
"""Queue and stream G-code to a Marlin controller."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
port: str | None = None,
|
|
87
|
+
baud_rate: int = DEFAULT_BAUD_RATE,
|
|
88
|
+
response_timeout_s: float = DEFAULT_RESPONSE_TIMEOUT,
|
|
89
|
+
log: Callable[[str], None] | None = None,
|
|
90
|
+
transport: SerialTransport | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._port = port
|
|
93
|
+
self._baud_rate = baud_rate
|
|
94
|
+
self._response_timeout = response_timeout_s
|
|
95
|
+
self._log = log
|
|
96
|
+
self._transport = transport
|
|
97
|
+
self._connected = False
|
|
98
|
+
self._startup_handled = transport is not None # Mock transports skip startup
|
|
99
|
+
|
|
100
|
+
self._program: list[str] = []
|
|
101
|
+
self._cursor = 0
|
|
102
|
+
self._commands_sent = 0
|
|
103
|
+
self._total_commands = 0
|
|
104
|
+
self._paused = False
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Public API
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
def load_program(self, commands: Sequence[str]) -> None:
|
|
110
|
+
"""Load and sanitize a G-code program for streaming."""
|
|
111
|
+
|
|
112
|
+
sanitized: list[str] = []
|
|
113
|
+
total = 0
|
|
114
|
+
for raw in commands:
|
|
115
|
+
line = raw.strip()
|
|
116
|
+
if not line:
|
|
117
|
+
continue
|
|
118
|
+
sanitized.append(line)
|
|
119
|
+
if not line.startswith(";"):
|
|
120
|
+
total += 1
|
|
121
|
+
if not sanitized:
|
|
122
|
+
raise StreamError("G-code program contained no commands")
|
|
123
|
+
self._program = sanitized
|
|
124
|
+
self._cursor = 0
|
|
125
|
+
self._commands_sent = 0
|
|
126
|
+
self._total_commands = total
|
|
127
|
+
|
|
128
|
+
def iter_stream(self, *, dry_run: bool = False) -> Iterator[StreamProgress]:
|
|
129
|
+
"""Yield progress as commands are streamed."""
|
|
130
|
+
|
|
131
|
+
if not self._program:
|
|
132
|
+
raise StreamError("No program loaded")
|
|
133
|
+
|
|
134
|
+
while self._cursor < len(self._program):
|
|
135
|
+
line = self._program[self._cursor]
|
|
136
|
+
self._cursor += 1
|
|
137
|
+
|
|
138
|
+
if not line:
|
|
139
|
+
continue
|
|
140
|
+
if line.startswith(";"):
|
|
141
|
+
if self._log is not None:
|
|
142
|
+
self._log(line[1:].strip())
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if not dry_run:
|
|
146
|
+
self._ensure_connection()
|
|
147
|
+
self._send_command(line)
|
|
148
|
+
|
|
149
|
+
self._commands_sent += 1
|
|
150
|
+
yield StreamProgress(
|
|
151
|
+
commands_sent=self._commands_sent,
|
|
152
|
+
commands_total=self._total_commands,
|
|
153
|
+
command=line,
|
|
154
|
+
dry_run=dry_run,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def pause(self) -> None:
|
|
158
|
+
"""Send ``M0`` to request a pause."""
|
|
159
|
+
|
|
160
|
+
if self._paused:
|
|
161
|
+
raise StreamError("Stream is already paused")
|
|
162
|
+
self._ensure_connection()
|
|
163
|
+
self._send_command("M0")
|
|
164
|
+
self._paused = True
|
|
165
|
+
|
|
166
|
+
def resume(self) -> None:
|
|
167
|
+
"""Send ``M108`` to resume after :meth:`pause`."""
|
|
168
|
+
|
|
169
|
+
if not self._paused:
|
|
170
|
+
raise StreamError("Stream is not paused")
|
|
171
|
+
self._ensure_connection()
|
|
172
|
+
self._send_command("M108")
|
|
173
|
+
self._paused = False
|
|
174
|
+
|
|
175
|
+
def reset_progress(self) -> None:
|
|
176
|
+
"""Restart streaming from the first command."""
|
|
177
|
+
|
|
178
|
+
self._cursor = 0
|
|
179
|
+
self._commands_sent = 0
|
|
180
|
+
self._paused = False
|
|
181
|
+
|
|
182
|
+
def close(self) -> None:
|
|
183
|
+
"""Close the underlying transport."""
|
|
184
|
+
|
|
185
|
+
if self._transport is not None:
|
|
186
|
+
self._transport.close()
|
|
187
|
+
self._connected = False
|
|
188
|
+
self._startup_handled = False
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> MarlinStreamer:
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *_exc: object) -> None: # pragma: no cover - trivial
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Properties
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
@property
|
|
200
|
+
def commands_total(self) -> int:
|
|
201
|
+
return self._total_commands
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def commands_sent(self) -> int:
|
|
205
|
+
return self._commands_sent
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def commands_remaining(self) -> int:
|
|
209
|
+
return max(self._total_commands - self._commands_sent, 0)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def paused(self) -> bool:
|
|
213
|
+
return self._paused
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Internal helpers
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
def _ensure_connection(self) -> None:
|
|
219
|
+
if self._connected and self._startup_handled:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
if self._transport is None:
|
|
223
|
+
if self._port is None:
|
|
224
|
+
raise StreamError("Serial port is required for live streaming")
|
|
225
|
+
self._transport = PySerialTransport(self._port, self._baud_rate, self._response_timeout)
|
|
226
|
+
|
|
227
|
+
if not self._startup_handled:
|
|
228
|
+
self._wait_for_marlin_ready()
|
|
229
|
+
self._startup_handled = True
|
|
230
|
+
|
|
231
|
+
self._connected = True
|
|
232
|
+
|
|
233
|
+
def _wait_for_marlin_ready(self) -> None:
|
|
234
|
+
"""Wait for Marlin to complete its startup sequence and become ready."""
|
|
235
|
+
assert self._transport is not None
|
|
236
|
+
|
|
237
|
+
if self._log is not None:
|
|
238
|
+
self._log("Waiting for Marlin to initialize...")
|
|
239
|
+
|
|
240
|
+
# Marlin sends a startup banner on connection. Wait for it to finish.
|
|
241
|
+
# The startup typically ends with configuration dump (M206, M200, etc.)
|
|
242
|
+
# We'll wait up to 5 seconds for the startup to complete, consuming all lines.
|
|
243
|
+
start_time = time.monotonic()
|
|
244
|
+
startup_timeout = 5.0
|
|
245
|
+
last_line_time = start_time
|
|
246
|
+
quiet_period = 0.5 # Wait for 0.5s of silence to confirm startup is done
|
|
247
|
+
|
|
248
|
+
startup_lines: list[str] = []
|
|
249
|
+
seen_first_line = False
|
|
250
|
+
|
|
251
|
+
while time.monotonic() - start_time < startup_timeout:
|
|
252
|
+
remaining = startup_timeout - (time.monotonic() - start_time)
|
|
253
|
+
# Use a shorter timeout for responsive checking
|
|
254
|
+
line = self._transport.readline(min(remaining, 0.05))
|
|
255
|
+
|
|
256
|
+
if line is not None and line.strip():
|
|
257
|
+
startup_lines.append(line.strip())
|
|
258
|
+
last_line_time = time.monotonic()
|
|
259
|
+
seen_first_line = True
|
|
260
|
+
if self._log is not None:
|
|
261
|
+
self._log(f"[marlin startup] {line.strip()}")
|
|
262
|
+
|
|
263
|
+
# Only check for quiet period after we've seen the first line
|
|
264
|
+
# and enough time has passed since the last line
|
|
265
|
+
if seen_first_line:
|
|
266
|
+
time_since_last_line = time.monotonic() - last_line_time
|
|
267
|
+
if time_since_last_line >= quiet_period:
|
|
268
|
+
if self._log is not None:
|
|
269
|
+
self._log(f"Marlin ready (received {len(startup_lines)} startup lines)")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# If we got here, we either saw no startup or timed out
|
|
273
|
+
if not startup_lines:
|
|
274
|
+
# No startup lines seen - might be already initialized, wrong port, or wrong baud rate
|
|
275
|
+
if self._log is not None:
|
|
276
|
+
self._log("No Marlin startup detected. Controller may already be initialized.")
|
|
277
|
+
else:
|
|
278
|
+
# We timed out while receiving startup - this could be problematic
|
|
279
|
+
if self._log is not None:
|
|
280
|
+
self._log(
|
|
281
|
+
f"Warning: Startup timeout after {len(startup_lines)} lines. "
|
|
282
|
+
"Proceeding, but stream may fail if controller is not ready."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _send_command(self, command: str) -> None:
|
|
286
|
+
assert self._transport is not None # for type checkers
|
|
287
|
+
self._transport.write_line(command)
|
|
288
|
+
self._await_ok()
|
|
289
|
+
|
|
290
|
+
def _await_ok(self) -> None:
|
|
291
|
+
assert self._transport is not None
|
|
292
|
+
deadline = time.monotonic() + self._response_timeout
|
|
293
|
+
while True:
|
|
294
|
+
remaining = deadline - time.monotonic()
|
|
295
|
+
if remaining <= 0:
|
|
296
|
+
raise StreamError("Timed out waiting for Marlin response")
|
|
297
|
+
line = self._transport.readline(remaining)
|
|
298
|
+
if line is None:
|
|
299
|
+
continue
|
|
300
|
+
line = line.strip()
|
|
301
|
+
if not line:
|
|
302
|
+
continue
|
|
303
|
+
if line == "ok":
|
|
304
|
+
return
|
|
305
|
+
if line.startswith("echo:busy"):
|
|
306
|
+
deadline = time.monotonic() + self._response_timeout
|
|
307
|
+
continue
|
|
308
|
+
if line.startswith("Error"):
|
|
309
|
+
raise StreamError(f"Marlin reported: {line}")
|
|
310
|
+
if self._log is not None:
|
|
311
|
+
self._log(f"[marlin] {line}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Dialects encapsulate controller-specific behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class AxisMapping:
|
|
10
|
+
"""Maps logical axes to G-code axis letters."""
|
|
11
|
+
|
|
12
|
+
carriage: str = "X" # Linear motion along mandrel
|
|
13
|
+
mandrel: str = "Y" # Mandrel rotation
|
|
14
|
+
delivery_head: str = "Z" # Delivery head rotation
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_rotational_mandrel(self) -> bool:
|
|
18
|
+
"""True if mandrel uses a rotational axis (A/B/C)."""
|
|
19
|
+
return self.mandrel in {"A", "B", "C"}
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_rotational_delivery(self) -> bool:
|
|
23
|
+
"""True if delivery head uses a rotational axis (A/B/C)."""
|
|
24
|
+
return self.delivery_head in {"A", "B", "C"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class MarlinDialect:
|
|
29
|
+
"""G-code dialect configuration for Marlin controllers."""
|
|
30
|
+
|
|
31
|
+
units: str = "mm"
|
|
32
|
+
feed_mode: str = "G94" # Units per minute
|
|
33
|
+
axis_mapping: AxisMapping = field(default_factory=AxisMapping)
|
|
34
|
+
|
|
35
|
+
def prologue(self) -> list[str]:
|
|
36
|
+
"""Return G-code commands for controller initialization."""
|
|
37
|
+
return ["G21" if self.units == "mm" else "G20", self.feed_mode]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Predefined dialects
|
|
41
|
+
MARLIN_XYZ_LEGACY = MarlinDialect(
|
|
42
|
+
axis_mapping=AxisMapping(carriage="X", mandrel="Y", delivery_head="Z"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
MARLIN_XAB_STANDARD = MarlinDialect(
|
|
46
|
+
axis_mapping=AxisMapping(carriage="X", mandrel="A", delivery_head="B"),
|
|
47
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Utilities for generating and persisting G-code programs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class GCodeProgram:
|
|
12
|
+
commands: list[str]
|
|
13
|
+
|
|
14
|
+
def as_text(self) -> str:
|
|
15
|
+
return "\n".join(self.commands) + "\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sanitize_program(commands: Iterable[str]) -> list[str]:
|
|
19
|
+
sanitized: list[str] = []
|
|
20
|
+
for line in commands:
|
|
21
|
+
stripped = line.strip()
|
|
22
|
+
if stripped:
|
|
23
|
+
sanitized.append(stripped)
|
|
24
|
+
return sanitized
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_gcode(program: GCodeProgram | Sequence[str], destination: str | Path) -> Path:
|
|
28
|
+
target = Path(destination)
|
|
29
|
+
lines = program.commands if isinstance(program, GCodeProgram) else list(program)
|
|
30
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
31
|
+
return target
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Geometry helpers for FiberPath."""
|
|
2
|
+
|
|
3
|
+
from .curves import CurveProfile, HelicalCurve
|
|
4
|
+
from .intersections import IntersectionResult, intersect_curve_with_plane
|
|
5
|
+
from .surfaces import CylindricalSurface
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CurveProfile",
|
|
9
|
+
"HelicalCurve",
|
|
10
|
+
"IntersectionResult",
|
|
11
|
+
"intersect_curve_with_plane",
|
|
12
|
+
"CylindricalSurface",
|
|
13
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Curve primitives for tow paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class CurveProfile:
|
|
10
|
+
axial_mm: float
|
|
11
|
+
mandrel_degrees: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class HelicalCurve(CurveProfile):
|
|
16
|
+
delivery_head_angle: float
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Intersection helpers for slicing tow curves into machine segments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class IntersectionResult:
|
|
11
|
+
axial_mm: float
|
|
12
|
+
mandrel_degrees: float
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def intersect_curve_with_plane(*args: Any, **kwargs: Any) -> IntersectionResult:
|
|
16
|
+
"""Placeholder for the eventual intersection math.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
*args: Positional arguments describing the curve and slicing plane.
|
|
20
|
+
**kwargs: Keyword arguments forwarded from callers (e.g., tolerances).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
raise NotImplementedError("Intersection computation is not yet implemented")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Surface primitives used by the planner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class CylindricalSurface:
|
|
10
|
+
"""Represents a straight cylindrical mandrel."""
|
|
11
|
+
|
|
12
|
+
diameter_mm: float
|
|
13
|
+
length_mm: float
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def circumference_mm(self) -> float:
|
|
17
|
+
return self.diameter_mm * 3.141592653589793
|
fiberpath/math_utils.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Small math helpers shared across modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def deg_to_rad(degrees: float) -> float:
|
|
9
|
+
return math.radians(degrees)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def rad_to_deg(radians: float) -> float:
|
|
13
|
+
return math.degrees(radians)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def strip_precision(value: float, digits: int = 6) -> str:
|
|
17
|
+
text = f"{value:.{digits}f}"
|
|
18
|
+
if "." in text:
|
|
19
|
+
text = text.rstrip("0").rstrip(".")
|
|
20
|
+
return text or "0"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Planning orchestration module."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import LayerValidationError, PlanningError
|
|
4
|
+
from .planner import LayerMetrics, PlanOptions, PlanResult, plan_wind
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"PlanOptions",
|
|
8
|
+
"PlanResult",
|
|
9
|
+
"LayerMetrics",
|
|
10
|
+
"plan_wind",
|
|
11
|
+
"PlanningError",
|
|
12
|
+
"LayerValidationError",
|
|
13
|
+
]
|