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
fiberpath/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Core FiberPath tooling package."""
2
+
3
+ from importlib import metadata
4
+
5
+ try:
6
+ __version__ = metadata.version("fiberpath")
7
+ except metadata.PackageNotFoundError: # pragma: no cover
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -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,7 @@
1
+ """Execution helpers for streaming G-code to Marlin-based controllers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .marlin import MarlinStreamer, StreamError, StreamProgress
6
+
7
+ __all__ = ["MarlinStreamer", "StreamError", "StreamProgress"]
@@ -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,6 @@
1
+ """G-code utilities."""
2
+
3
+ from .dialects import MarlinDialect
4
+ from .generator import GCodeProgram, sanitize_program, write_gcode
5
+
6
+ __all__ = ["GCodeProgram", "sanitize_program", "write_gcode", "MarlinDialect"]
@@ -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
@@ -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
+ ]