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
|
@@ -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
|