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_api/main.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Entry point for the FiberPath FastAPI service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .routes import plan, simulate, stream, validate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_app() -> FastAPI:
|
|
11
|
+
application = FastAPI(title="FiberPath API", version="0.2.0")
|
|
12
|
+
application.include_router(plan.router, prefix="/plan", tags=["planning"])
|
|
13
|
+
application.include_router(simulate.router, prefix="/simulate", tags=["simulation"])
|
|
14
|
+
application.include_router(validate.router, prefix="/validate", tags=["validation"])
|
|
15
|
+
application.include_router(stream.router, prefix="/stream", tags=["stream"])
|
|
16
|
+
return application
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app = create_app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Route modules for the FiberPath API."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Planning endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
from fiberpath.config import WindFileError, load_wind_definition
|
|
10
|
+
from fiberpath.gcode import write_gcode
|
|
11
|
+
from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD, MARLIN_XYZ_LEGACY
|
|
12
|
+
from fiberpath.planning import PlanOptions, plan_wind
|
|
13
|
+
|
|
14
|
+
from ..schemas import FilePathRequest, PlanLayer, PlanResponse
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("/from-file", response_model=PlanResponse)
|
|
20
|
+
def plan_from_file(payload: FilePathRequest) -> PlanResponse:
|
|
21
|
+
file_path = Path(payload.path)
|
|
22
|
+
try:
|
|
23
|
+
definition = load_wind_definition(file_path)
|
|
24
|
+
except WindFileError as exc: # pragma: no cover - HTTP glue
|
|
25
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
26
|
+
|
|
27
|
+
# Select dialect based on axis format
|
|
28
|
+
dialect = MARLIN_XAB_STANDARD if payload.axis_format == "xab" else MARLIN_XYZ_LEGACY
|
|
29
|
+
options = PlanOptions(verbose=payload.verbose, dialect=dialect)
|
|
30
|
+
|
|
31
|
+
result = plan_wind(definition, options)
|
|
32
|
+
temp_file = write_gcode(result.commands, file_path.with_suffix(".gcode"))
|
|
33
|
+
layers = [PlanLayer(**asdict(metric)) for metric in result.layers]
|
|
34
|
+
return PlanResponse(
|
|
35
|
+
commands=len(result.commands),
|
|
36
|
+
output=str(temp_file),
|
|
37
|
+
timeSeconds=result.total_time_s,
|
|
38
|
+
towMeters=result.total_tow_m,
|
|
39
|
+
axisFormat=payload.axis_format,
|
|
40
|
+
layers=layers,
|
|
41
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Simulation endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from fiberpath.simulation import simulate_program
|
|
9
|
+
|
|
10
|
+
from ..schemas import FilePathRequest, SimulationResponse
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.post("/from-file", response_model=SimulationResponse)
|
|
16
|
+
def simulate_from_file(payload: FilePathRequest) -> SimulationResponse:
|
|
17
|
+
target = Path(payload.path)
|
|
18
|
+
if not target.exists():
|
|
19
|
+
raise HTTPException(status_code=404, detail=f"No file found at {payload.path}")
|
|
20
|
+
commands = target.read_text(encoding="utf-8").splitlines()
|
|
21
|
+
result = simulate_program(commands)
|
|
22
|
+
return SimulationResponse(
|
|
23
|
+
commands=result.commands_executed,
|
|
24
|
+
moves=result.moves,
|
|
25
|
+
estimated_time_s=result.estimated_time_s,
|
|
26
|
+
total_distance_mm=result.total_distance_mm,
|
|
27
|
+
tow_length_mm=result.tow_length_mm,
|
|
28
|
+
average_feed_rate_mmpm=result.average_feed_rate_mmpm,
|
|
29
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Serial streaming endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from fiberpath.execution import MarlinStreamer, StreamError
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StreamRequest(BaseModel):
|
|
13
|
+
gcode: str = Field(..., description="Raw G-code to stream, newline separated.")
|
|
14
|
+
port: str | None = Field(
|
|
15
|
+
default=None,
|
|
16
|
+
description="Serial port or pyserial URL. Required when dry_run is false.",
|
|
17
|
+
)
|
|
18
|
+
baud_rate: int = Field(default=250_000, ge=1, description="Controller baud rate.")
|
|
19
|
+
dry_run: bool = Field(default=True, description="Skip serial I/O and only count commands.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StreamResponse(BaseModel):
|
|
23
|
+
commands_streamed: int
|
|
24
|
+
total_commands: int
|
|
25
|
+
dry_run: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post("/", response_model=StreamResponse)
|
|
29
|
+
def start_stream(payload: StreamRequest) -> StreamResponse:
|
|
30
|
+
if not payload.dry_run and payload.port is None:
|
|
31
|
+
raise HTTPException(status_code=400, detail="port is required when dry_run is false")
|
|
32
|
+
|
|
33
|
+
commands = payload.gcode.splitlines()
|
|
34
|
+
streamer = MarlinStreamer(port=payload.port, baud_rate=payload.baud_rate)
|
|
35
|
+
streamer.load_program(commands)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
for _update in streamer.iter_stream(dry_run=payload.dry_run):
|
|
39
|
+
pass
|
|
40
|
+
except StreamError as exc: # pragma: no cover - exercised via API test
|
|
41
|
+
raise HTTPException(status_code=502, detail=f"Streaming failed: {exc}") from exc
|
|
42
|
+
finally:
|
|
43
|
+
streamer.close()
|
|
44
|
+
|
|
45
|
+
return StreamResponse(
|
|
46
|
+
commands_streamed=streamer.commands_sent,
|
|
47
|
+
total_commands=streamer.commands_total,
|
|
48
|
+
dry_run=payload.dry_run,
|
|
49
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Validation endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from fiberpath.config import WindFileError, load_wind_definition
|
|
9
|
+
|
|
10
|
+
from ..schemas import FilePathRequest, ValidateResponse
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.post("/from-file", response_model=ValidateResponse)
|
|
16
|
+
def validate_from_file(payload: FilePathRequest) -> ValidateResponse:
|
|
17
|
+
try:
|
|
18
|
+
load_wind_definition(Path(payload.path))
|
|
19
|
+
except WindFileError as exc: # pragma: no cover - HTTP glue
|
|
20
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
21
|
+
return ValidateResponse(status="ok", path=payload.path)
|
fiberpath_api/schemas.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pydantic schemas shared by API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilePathRequest(BaseModel):
|
|
11
|
+
path: str = Field(..., description="Absolute or workspace path to an input file.")
|
|
12
|
+
axis_format: Literal["xyz", "xab"] = Field(
|
|
13
|
+
default="xab",
|
|
14
|
+
description="Axis coordinate format: xyz (legacy) or xab (standard rotational)",
|
|
15
|
+
)
|
|
16
|
+
verbose: bool = Field(default=False, description="Emit verbose planner output")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlanLayer(BaseModel):
|
|
20
|
+
index: int
|
|
21
|
+
wind_type: str
|
|
22
|
+
commands: int
|
|
23
|
+
time_s: float
|
|
24
|
+
cumulative_time_s: float
|
|
25
|
+
tow_m: float
|
|
26
|
+
cumulative_tow_m: float
|
|
27
|
+
terminal: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlanResponse(BaseModel):
|
|
31
|
+
commands: int
|
|
32
|
+
output: str
|
|
33
|
+
timeSeconds: float
|
|
34
|
+
towMeters: float
|
|
35
|
+
axisFormat: str
|
|
36
|
+
layers: list[PlanLayer]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SimulationResponse(BaseModel):
|
|
40
|
+
commands: int
|
|
41
|
+
moves: int
|
|
42
|
+
estimated_time_s: float
|
|
43
|
+
total_distance_mm: float
|
|
44
|
+
tow_length_mm: float
|
|
45
|
+
average_feed_rate_mmpm: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ValidateResponse(BaseModel):
|
|
49
|
+
status: str
|
|
50
|
+
path: str
|
fiberpath_cli/main.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""FiberPath command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .plan import plan_command
|
|
8
|
+
from .plot import plot_command
|
|
9
|
+
from .simulate import simulate_command
|
|
10
|
+
from .stream import stream_command
|
|
11
|
+
from .validate import validate_command
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="FiberPath",
|
|
15
|
+
help="FiberPath utilities for planning and executing filament winding jobs.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app.command("plan")(plan_command)
|
|
20
|
+
app.command("plot")(plot_command)
|
|
21
|
+
app.command("simulate")(simulate_command)
|
|
22
|
+
app.command("validate")(validate_command)
|
|
23
|
+
app.command("stream")(stream_command)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__": # pragma: no cover
|
|
27
|
+
app()
|
fiberpath_cli/output.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Utilities for CLI output modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def echo_json(payload: Any) -> None:
|
|
12
|
+
"""Pretty-print payload as JSON."""
|
|
13
|
+
|
|
14
|
+
typer.echo(json.dumps(payload, indent=2))
|
fiberpath_cli/plan.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""CLI plan command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from fiberpath.config import WindFileError, load_wind_definition
|
|
10
|
+
from fiberpath.gcode import write_gcode
|
|
11
|
+
from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD, MARLIN_XYZ_LEGACY
|
|
12
|
+
from fiberpath.planning import PlanOptions, plan_wind
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from .output import echo_json
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
WIND_FILE_ARGUMENT = typer.Argument(..., exists=True, readable=True, help="Input .wind file")
|
|
21
|
+
OUTPUT_OPTION = typer.Option(
|
|
22
|
+
Path("output.gcode"), "--output", "-o", help="Destination for generated G-code"
|
|
23
|
+
)
|
|
24
|
+
VERBOSE_OPTION = typer.Option(False, "--verbose", "-v", help="Emit verbose planner output")
|
|
25
|
+
JSON_OPTION = typer.Option(
|
|
26
|
+
False,
|
|
27
|
+
"--json",
|
|
28
|
+
help="Emit machine-readable JSON instead of human-readable text.",
|
|
29
|
+
)
|
|
30
|
+
AXIS_FORMAT_OPTION = typer.Option(
|
|
31
|
+
"xab",
|
|
32
|
+
"--axis-format",
|
|
33
|
+
help="Axis coordinate format: xyz (legacy) or xab (standard rotational)",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def plan_command(
|
|
38
|
+
wind_file: Path = WIND_FILE_ARGUMENT,
|
|
39
|
+
output: Path = OUTPUT_OPTION,
|
|
40
|
+
verbose: bool = VERBOSE_OPTION,
|
|
41
|
+
json_output: bool = JSON_OPTION,
|
|
42
|
+
axis_format: str = AXIS_FORMAT_OPTION,
|
|
43
|
+
) -> None:
|
|
44
|
+
# Validate axis format
|
|
45
|
+
if axis_format not in {"xyz", "xab"}:
|
|
46
|
+
typer.echo(f"Invalid axis format: {axis_format}. Must be 'xyz' or 'xab'.", err=True)
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
# Select dialect based on axis format
|
|
50
|
+
dialect = MARLIN_XAB_STANDARD if axis_format == "xab" else MARLIN_XYZ_LEGACY
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
wind_definition = load_wind_definition(wind_file)
|
|
54
|
+
except WindFileError as exc: # pragma: no cover - CLI glue
|
|
55
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
result = plan_wind(wind_definition, PlanOptions(verbose=verbose, dialect=dialect))
|
|
59
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
60
|
+
typer.echo(f"Planning failed: {exc}", err=True)
|
|
61
|
+
raise typer.Exit(code=1) from exc
|
|
62
|
+
|
|
63
|
+
destination = write_gcode(result.commands, output)
|
|
64
|
+
|
|
65
|
+
summary = {
|
|
66
|
+
"output": str(destination),
|
|
67
|
+
"commands": len(result.commands),
|
|
68
|
+
"timeSeconds": result.total_time_s,
|
|
69
|
+
"towMeters": result.total_tow_m,
|
|
70
|
+
"axisFormat": axis_format,
|
|
71
|
+
"layers": [asdict(metric) for metric in result.layers],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if json_output:
|
|
75
|
+
echo_json(summary)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
console.print(f"[green]Wrote[/green] {summary['commands']} commands to {destination}")
|
|
79
|
+
console.print(f"[dim]Axis format: {axis_format.upper()}[/dim]")
|
|
80
|
+
|
|
81
|
+
if verbose:
|
|
82
|
+
table = Table(title="Layer metrics", expand=False)
|
|
83
|
+
table.add_column("#", justify="right")
|
|
84
|
+
table.add_column("Type")
|
|
85
|
+
table.add_column("Cmds", justify="right")
|
|
86
|
+
table.add_column("Δt (s)", justify="right")
|
|
87
|
+
table.add_column("Tow (m)", justify="right")
|
|
88
|
+
for metric in result.layers:
|
|
89
|
+
table.add_row(
|
|
90
|
+
str(metric.index),
|
|
91
|
+
metric.wind_type,
|
|
92
|
+
str(metric.commands),
|
|
93
|
+
f"{metric.time_s:.2f}",
|
|
94
|
+
f"{metric.tow_m:.3f}",
|
|
95
|
+
)
|
|
96
|
+
console.print(table)
|
|
97
|
+
console.print(
|
|
98
|
+
f"[cyan]Totals[/cyan] time={result.total_time_s:.2f}s tow={result.total_tow_m:.3f}m"
|
|
99
|
+
)
|
|
100
|
+
console.print(wind_definition.model_dump(mode="json"))
|
fiberpath_cli/plot.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""CLI plot command for PNG previews."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from fiberpath.visualization.plotter import PlotConfig, PlotError, render_plot
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
GCODE_ARGUMENT = typer.Argument(..., exists=True, readable=True, help="Input G-code file")
|
|
14
|
+
OUTPUT_OPTION = typer.Option(Path("plot.png"), "--output", "-o", help="PNG destination")
|
|
15
|
+
SCALE_OPTION = typer.Option(
|
|
16
|
+
1.0, "--scale", help="Pixels per millimeter along carriage axis", min=0.1, max=5.0
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def plot_command(
|
|
21
|
+
gcode_file: Path = GCODE_ARGUMENT,
|
|
22
|
+
output: Path = OUTPUT_OPTION,
|
|
23
|
+
scale: float = SCALE_OPTION,
|
|
24
|
+
) -> None:
|
|
25
|
+
program = gcode_file.read_text(encoding="utf-8").splitlines()
|
|
26
|
+
try:
|
|
27
|
+
result = render_plot(program, PlotConfig(scale=scale))
|
|
28
|
+
except PlotError as exc: # pragma: no cover - parameter validation
|
|
29
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
30
|
+
|
|
31
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
result.image.save(output, format="PNG")
|
|
33
|
+
console.print(
|
|
34
|
+
"[green]Rendered[/green] preview to",
|
|
35
|
+
output,
|
|
36
|
+
f"({result.metadata.mandrel_length_mm:.1f} mm × {result.metadata.tow_width_mm:.1f} mm tow)",
|
|
37
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CLI simulate command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from fiberpath.simulation import SimulationError, simulate_program
|
|
10
|
+
|
|
11
|
+
from .output import echo_json
|
|
12
|
+
|
|
13
|
+
GCODE_ARGUMENT = typer.Argument(..., exists=True, readable=True)
|
|
14
|
+
JSON_OPTION = typer.Option(False, "--json", help="Emit machine-readable JSON summary")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def simulate_command(gcode_file: Path = GCODE_ARGUMENT, json_output: bool = JSON_OPTION) -> None:
|
|
18
|
+
commands = Path(gcode_file).read_text(encoding="utf-8").splitlines()
|
|
19
|
+
try:
|
|
20
|
+
result = simulate_program(commands)
|
|
21
|
+
except SimulationError as exc:
|
|
22
|
+
typer.echo(f"Simulation failed: {exc}", err=True)
|
|
23
|
+
raise typer.Exit(code=1) from exc
|
|
24
|
+
|
|
25
|
+
if json_output:
|
|
26
|
+
echo_json(asdict(result))
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
typer.echo(
|
|
30
|
+
"Simulated "
|
|
31
|
+
f"{result.commands_executed} commands / {result.moves} moves in "
|
|
32
|
+
f"{result.estimated_time_s:.2f}s\n"
|
|
33
|
+
f" distance: {result.total_distance_mm:.1f} mm"
|
|
34
|
+
f" tow: {result.tow_length_mm / 1000.0:.3f} m"
|
|
35
|
+
f" avg feed: {result.average_feed_rate_mmpm:.0f} mm/min"
|
|
36
|
+
)
|
fiberpath_cli/stream.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CLI command for streaming G-code to a Marlin controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from fiberpath.execution import MarlinStreamer, StreamError, StreamProgress
|
|
9
|
+
|
|
10
|
+
from .output import echo_json
|
|
11
|
+
|
|
12
|
+
GCODE_ARGUMENT = typer.Argument(..., exists=True, readable=True)
|
|
13
|
+
PROGRESS_INTERVAL = 25
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def stream_command(
|
|
17
|
+
gcode_file: Path = GCODE_ARGUMENT,
|
|
18
|
+
port: str | None = typer.Option(
|
|
19
|
+
None,
|
|
20
|
+
"--port",
|
|
21
|
+
"-p",
|
|
22
|
+
help="Serial port or pyserial URL (required unless --dry-run).",
|
|
23
|
+
),
|
|
24
|
+
baud_rate: int = typer.Option(250_000, "--baud-rate", "-b", help="Marlin baud rate."),
|
|
25
|
+
response_timeout: float = typer.Option(
|
|
26
|
+
10.0,
|
|
27
|
+
"--timeout",
|
|
28
|
+
"-t",
|
|
29
|
+
help="Response timeout in seconds for slow moves.",
|
|
30
|
+
),
|
|
31
|
+
dry_run: bool = typer.Option(
|
|
32
|
+
False,
|
|
33
|
+
"--dry-run",
|
|
34
|
+
help="Skip serial I/O and just report what would be streamed.",
|
|
35
|
+
),
|
|
36
|
+
verbose: bool = typer.Option(False, "--verbose", help="Print every streamed command."),
|
|
37
|
+
json_output: bool = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--json",
|
|
40
|
+
help="Emit final summary as JSON (progress lines suppressed).",
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Stream the provided G-code file to a Marlin device."""
|
|
44
|
+
|
|
45
|
+
if not dry_run and port is None:
|
|
46
|
+
raise typer.BadParameter("--port is required for live streaming", param_hint="--port")
|
|
47
|
+
|
|
48
|
+
commands = gcode_file.read_text(encoding="utf-8").splitlines()
|
|
49
|
+
log_callback = None if json_output else (typer.echo if verbose else None)
|
|
50
|
+
streamer = MarlinStreamer(
|
|
51
|
+
port=port, baud_rate=baud_rate, response_timeout_s=response_timeout, log=log_callback
|
|
52
|
+
)
|
|
53
|
+
streamer.load_program(commands)
|
|
54
|
+
|
|
55
|
+
progress_verbose = verbose or dry_run
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
while streamer.commands_sent < streamer.commands_total:
|
|
59
|
+
try:
|
|
60
|
+
for update in streamer.iter_stream(dry_run=dry_run):
|
|
61
|
+
if not json_output and _should_print_progress(update, verbose=progress_verbose):
|
|
62
|
+
typer.echo(_format_progress(update))
|
|
63
|
+
break
|
|
64
|
+
except KeyboardInterrupt: # pragma: no cover - user-driven flow
|
|
65
|
+
if dry_run:
|
|
66
|
+
raise
|
|
67
|
+
typer.echo("\nPause requested (Ctrl+C). Sending M0 ...")
|
|
68
|
+
_pause_and_prompt(streamer)
|
|
69
|
+
continue
|
|
70
|
+
except StreamError as exc:
|
|
71
|
+
typer.echo(f"Streaming failed: {exc}", err=True)
|
|
72
|
+
raise typer.Exit(code=1) from exc
|
|
73
|
+
finally:
|
|
74
|
+
streamer.close()
|
|
75
|
+
|
|
76
|
+
summary = {
|
|
77
|
+
"status": "dry-run" if dry_run else "live",
|
|
78
|
+
"commands": streamer.commands_sent,
|
|
79
|
+
"total": streamer.commands_total,
|
|
80
|
+
"baudRate": baud_rate,
|
|
81
|
+
"dryRun": dry_run,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if json_output:
|
|
85
|
+
echo_json(summary)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
status = "Dry-run" if dry_run else "Streamed"
|
|
89
|
+
typer.echo(
|
|
90
|
+
f"{status} {streamer.commands_sent}/{streamer.commands_total} commands at {baud_rate} baud."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _should_print_progress(progress: StreamProgress, *, verbose: bool) -> bool:
|
|
95
|
+
if verbose:
|
|
96
|
+
return True
|
|
97
|
+
if progress.commands_sent in {1, progress.commands_total}:
|
|
98
|
+
return True
|
|
99
|
+
if progress.commands_total <= PROGRESS_INTERVAL:
|
|
100
|
+
return False
|
|
101
|
+
return progress.commands_sent % PROGRESS_INTERVAL == 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_progress(progress: StreamProgress) -> str:
|
|
105
|
+
phase = "dry-run" if progress.dry_run else "live"
|
|
106
|
+
return f"[{progress.commands_sent}/{progress.commands_total}] ({phase}) {progress.command}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pause_and_prompt(streamer: MarlinStreamer) -> None:
|
|
110
|
+
try:
|
|
111
|
+
streamer.pause()
|
|
112
|
+
except StreamError as exc:
|
|
113
|
+
raise typer.Exit(code=1) from exc
|
|
114
|
+
|
|
115
|
+
if not typer.confirm("Resume streaming?", default=True):
|
|
116
|
+
typer.echo("Stopping stream without resuming.")
|
|
117
|
+
raise typer.Exit(code=0)
|
|
118
|
+
|
|
119
|
+
typer.echo("Resuming (sending M108)...")
|
|
120
|
+
try:
|
|
121
|
+
streamer.resume()
|
|
122
|
+
except StreamError as exc:
|
|
123
|
+
raise typer.Exit(code=1) from exc
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""CLI validate command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from fiberpath.config import WindFileError, load_wind_definition
|
|
9
|
+
|
|
10
|
+
from .output import echo_json
|
|
11
|
+
|
|
12
|
+
WIND_FILE_ARGUMENT = typer.Argument(..., exists=True, readable=True)
|
|
13
|
+
JSON_OPTION = typer.Option(False, "--json", help="Emit machine-readable JSON summary")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_command(wind_file: Path = WIND_FILE_ARGUMENT, json_output: bool = JSON_OPTION) -> None:
|
|
17
|
+
try:
|
|
18
|
+
load_wind_definition(wind_file)
|
|
19
|
+
except WindFileError as exc: # pragma: no cover - CLI glue
|
|
20
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
21
|
+
|
|
22
|
+
if json_output:
|
|
23
|
+
echo_json({"status": "ok", "path": str(wind_file)})
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
typer.echo(f"{wind_file} is valid.")
|