Metadata-Version: 2.3
Name: fdm-price-calc
Version: 0.1.0
Summary: CLI and library for calculating FDM 3D print job costs
Author: malakai
Author-email: malakai <kaifungaming@proton.me>
Requires-Dist: appdirs>=1.4.4
Requires-Dist: gcode-lib>=1.1.13
Requires-Dist: rich>=13
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# fdm-price-calc

A CLI tool and Python library for calculating FDM 3D print job costs.
Implements the same pricing model as a standard product pricing worksheet:
filament cost × efficiency factor + machine time cost + labour = total landed cost,
with suggested sell prices at 50 / 60 / 70 % margin.

## Installation

```bash
uv tool install fdm-price-calc   # install globally
# or inside a project
uv add fdm-price-calc
```
## AI Usage policy
- If you use an AI agent to contrubute to this project you must varify that your changes are tested thoroughly and YOU read an understand the code before merging.
- You must write commit messages, not the AI angent.
- When you use an AI agent you must add the `Co-authored-by:` tag to your commit messages.
- No AI-generated media is allowed (art, images, videos, audio, etc.). Text and code are the only acceptable AI-generated content.

## CLI

```
fdmpricecalc [FILE] [PRINTER] [FILAMENT] [options]
fdmpricecalc new-printer | new-filament | list
```

`FILE` is a sliced `.3mf` file. `PRINTER` and `FILAMENT` are the names of saved
presets (see [Presets](#presets)). All three positional arguments are optional —
missing values are filled in interactively.

```bash
# Full run from a sliced file with saved presets
fdmpricecalc job.3mf my_printer pla_basic -n "Widget v2" -a Alice

# No file — enter print stats manually via prompts
fdmpricecalc --no-file my_printer pla_basic

# No file — fully scripted, zero prompts
fdmpricecalc --no-file my_printer pla_basic -w 224.93 -t "5h6m12s" \
  --material PLA --color "#FF8800" -n "Pages" -a me -l 10 -q 2 -y

# Override a preset value for this run only
fdmpricecalc job.3mf my_printer pla_basic --efficiency 1.2 --filament-cost 35

# Save result to JSON — minimal by default (no plate data, presets by name)
fdmpricecalc job.3mf bambu_p1s bambu_pla_basic -n "Widget" -o result.json

# Include raw per-plate slicer data
fdmpricecalc job.3mf bambu_p1s bambu_pla_basic -n "Widget" -o result.json -f

# Embed full preset data so the file is self-contained
fdmpricecalc job.3mf bambu_p1s bambu_pla_basic -n "Widget" -o result.json -s
```

If an unsupported file type is given, the tool falls back to manual entry
automatically.

The default JSON output is minimal — just the inputs you provided (printer,
filament, labor, quantity, extra costs) and the calculated costs. The raw
per-plate slicer data is omitted since it can always be re-read from the source
file. Add `-f` to include it, or `-s` to embed full preset data for archiving.

### Multi-filament

For `.3mf` files with multiple filament slots, the CLI will automatically try
to match each slot to a saved preset by material type (e.g. a slot reporting
`PLA` will match any preset whose `material` field is `PLA`). You can also
map slots manually:

```bash
# Map slot 1 to orange PLA, slot 2 to white PLA; all other slots fall back to pla_basic
fdmpricecalc job.3mf my_printer pla_basic \
  --filament-map 1:pla_orange \
  --filament-map 2:pla_white

# Override cost per kg for all PLA slots in this run only (doesn't change saved presets)
fdmpricecalc job.3mf my_printer pla_basic --material-cost PLA:18
```

### All flags

```
  -y, --non-interactive   Never prompt — use provided args and defaults.
                          Errors if required args (printer, filament) are
                          missing. Defaults: labor=0, qty=1, no extra costs.
                          Multi-filament slots are auto-matched to saved presets.

job metadata:
  -n, --name NAME       Job name
  -a, --author AUTHOR   Job author
  -o, --output FILE     Save results to JSON file
  -f, --full            Include raw per-plate slicer data in the JSON
                        (plates + totals; omitted by default as they can be
                        re-read from the source file)
  -s, --self-contained  Embed full preset data in the JSON instead of
                        referencing by name (useful for archiving jobs)
  -l, --labor MIN       Post-processing labour time in minutes
  --labor-rate $/HR     Labour hourly rate (overrides preset value for this run)
  -q, --qty N           Quantity to produce
  --extra NAME:COST[:QTY]
                        Extra per-unit cost (hardware, inserts, etc.).
                        Repeatable: --extra 'Magnet:0.50:2' --extra 'Insert:0.08'

manual print stats (use with --no-file or as file fallback):
  -m, --no-file         Skip file parsing; enter stats via args or prompts
  -w, --weight G        Total filament weight in grams
  -t, --time DURATION   Total print time (90, 1h30m, 1:30, 5h6m12s, …)
  --material TYPE       Filament material (PLA, PETG, …)
  --color HEX           Filament color hex (e.g. #FF8800)

filament preset overrides:
  --filament-cost $/KG  Override filament cost per kg (default/single preset)
  --filament-map ID:PRESET
                        Map a filament slot ID to a preset (repeatable)
  --material-cost MATERIAL:$/KG
                        Override cost per kg for all slots of a given material
                        type (e.g. PLA:18). Repeatable.

printer preset overrides:
  --efficiency X        Material efficiency factor (default 1.5)
  --print-rate $/HR     Override machine cost rate ($/hr)
  --printer-cost $      Printer purchase cost
  --upfront $           Additional upfront cost
  --maintenance $/YR    Estimated annual maintenance
  --life YRS            Estimated printer life in years
  --uptime PCT          Estimated uptime 0–100
  --power W             Printer power consumption in watts
  --electricity $/KWH   Electricity cost per kWh
  --buffer X            Printer cost buffer factor
```

### Other commands

```bash
fdmpricecalc new-printer           # interactive printer preset wizard
fdmpricecalc new-filament          # interactive filament preset wizard
fdmpricecalc list                  # list all saved presets
fdmpricecalc list printers
fdmpricecalc list filaments
```

## Presets

The package ships with built-in presets for common Bambu and Prusa printers and
filaments. They are available immediately after install with no setup required.

**Bundled printers:** `bambu_a1_mini`, `bambu_a1`, `bambu_p1p`, `bambu_p1s`,
`bambu_p2s`, `bambu_x1c`, `bambu_x1e`, `bambu_h2d`, `bambu_h2s`, `bambu_h2c`,
`prusa_mini_plus`, `prusa_mk4s`, `prusa_xl`, `prusa_core_one`

**Bundled filaments:** `bambu_pla_basic`, `bambu_petg_basic`, `bambu_abs`,
`bambu_tpu`, `prusament_pla`, `prusament_petg`, `prusament_asa`

User presets are stored as plain JSON files and always take priority over
bundled presets with the same name:

```
~/.local/share/fdmpricecalc/
    printers/    ← one .json per printer preset
    filaments/   ← one .json per filament preset
```

To customise a bundled preset, save it under the same name and your version
will be used from then on:

```bash
fdmpricecalc new-printer   # wizard saves to your user dir
# or edit the JSON directly at the path shown by fdmpricecalc --help
```

Run `fdmpricecalc list` to see all available presets (bundled + user).
Run `fdmpricecalc --help` to see the exact user preset paths on your system.

**Printer preset fields** (`printers/my_printer.json`):

| Field | Default | Description |
|---|---|---|
| `name` | — | Display name |
| `printer_cost` | 800 | Purchase cost ($) |
| `additional_upfront_cost` | 0 | Upgrades at purchase ($) |
| `annual_maintenance` | 80 | Estimated yearly upkeep ($) |
| `estimated_life_years` | 5 | Expected printer lifespan |
| `estimated_uptime_pct` | 0.5 | Fraction of hours the printer runs |
| `power_watts` | 160 | Average power draw (W) |
| `electricity_cost_per_kwh` | 0.14 | Electricity rate ($/kWh) |
| `printer_cost_buffer_factor` | 1.3 | Multiplier for unexpected costs |
| `material_efficiency_factor` | 1.5 | Filament waste multiplier |
| `labor_hourly_rate` | 15 | Post-processing labour rate ($/hr) |
| `print_time_rate_override` | null | Skip auto-calc and use a fixed $/hr |

**Filament preset fields** (`filaments/my_filament.json`):

| Field | Default | Description |
|---|---|---|
| `name` | — | Display name |
| `material` | PLA | Material type string |
| `cost_per_kg` | 20 | Cost per kilogram ($) |

## Pricing model

```
filament_cost  = (cost_per_kg / 1000) × weight_g × efficiency_factor × qty
machine_cost   = print_time_rate × print_time_hr × qty
labour_cost    = (labour_min / 60) × labour_hourly_rate × qty
extra_cost     = Σ (unit_cost × item_qty) × qty   ← hardware, inserts, etc.

total_landed   = filament_cost + machine_cost + labour_cost + extra_cost

sell_price     = total_landed / (1 − margin)
                 e.g. 50% margin → total_landed × 2
                      60% margin → total_landed × 2.5
                      70% margin → total_landed × 3.33

print_time_rate (auto) = (capital_cost_per_hr + electrical_cost_per_hr)
                         × buffer_factor
```

## Supported file formats

| Format | Notes |
|---|---|
| `.gcode.3mf` | Bambu Studio sliced projects; reads `slice_info.config` |
| `.json` | Json file produced by the `--output` option |
| `.gcode` | PrusaSlicer text G-code; reads embedded config comments |
| `.bgcode` | PrusaSlicer binary G-code; reads PRINTER_METADATA and PRINT_METADATA blocks |

Adding a new format takes one class:

```python
from fdm_price_calc import BaseParser, JobData

class MyParser(BaseParser):
    # Set True if your format provides per-slot filament breakdown data
    multi_filament: bool = False

    @staticmethod
    def supports(path: str) -> bool:
        return path.endswith(".myformat")

    def parse(self) -> JobData:
        ...  # return a JobData

# Register so fdm.parse() picks it up
import fdm_price_calc.parsers as _p
_p._PARSERS.append(MyParser)
```

## Python library

All pricing inputs are set directly on the `JobData` object before calling
`calculate()`. Parsers populate the file data (plates, weight, print time);
your code attaches the pricing config.

```python
import fdm_price_calc as fdm

# Parse a sliced .3mf
job = fdm.parse("job.3mf")

# Or build job data manually
job = fdm.manual_job(
    weight_g=224.93,
    print_time_s=fdm.parse_duration("5h6m12s"),
    material="PLA",
)

# Load saved presets (or create inline)
printer  = fdm.PrinterPreset.load("bambu_p1s")
filament = fdm.FilamentPreset.load("bambu_pla_basic")

# Attach pricing config to the job
job.printer       = printer
job.filament      = filament
job.labor_minutes = 10
job.quantity      = 3
job.extra_costs   = [
    fdm.ExtraCost(name="Magnet",    unit_cost=0.50, qty=2),
    fdm.ExtraCost(name="M3 insert", unit_cost=0.08, qty=4),
]

result = fdm.calculate(job)

print(f"Landed cost:  ${result.total_landed_cost:.2f}")
print(f"50% margin:   ${result.price_at_margin['50%']:.2f}")
print(f"60% margin:   ${result.price_at_margin['60%']:.2f}")

# Save presets for later
printer.save()
filament.save()
```

### Multi-filament (per-slot pricing)

For `.3mf` files where each slot may use a different filament:

```python
job = fdm.parse("multi_color.gcode.3mf")

orange = fdm.FilamentPreset(name="Orange PLA", cost_per_kg=22)
white  = fdm.FilamentPreset(name="White PLA",  cost_per_kg=25)
pla    = fdm.FilamentPreset.load("pla_basic")

job.printer          = printer
job.filament         = {1: orange, 2: white}  # dict[slot_id, preset]
job.default_filament = pla                     # fallback for unmapped slots
job.labor_minutes    = 5
job.quantity         = 1

result = fdm.calculate(job)
# result.filament_breakdown — list of (label, cost) per slot
```

### Public API

| Symbol | Type | Description |
|---|---|---|
| `parse(path)` | function | Parse a supported file → `JobData` |
| `manual_job(weight_g, print_time_s, ...)` | function | Build `JobData` without a file |
| `calculate(job)` | function | Returns `CostBreakdown`; all inputs read from `job` |
| `parse_duration(s)` | function | `"1h30m"` → seconds (`int`) |
| `JobData` | dataclass | Job data + pricing config (see fields below) |
| `PlateData` | dataclass | Per-plate print time, weight, filaments |
| `FilamentUsage` | dataclass | Per-filament usage within a plate |
| `ExtraCost` | dataclass | Extra per-unit cost item (`name`, `unit_cost`, `qty`) |
| `PrinterPreset` | dataclass | Printer config with cost calculations |
| `FilamentPreset` | dataclass | Filament name, material, $/kg |
| `CostBreakdown` | dataclass | Cost components + margin prices |
| `BaseParser` | ABC | Base class for adding new file formats |
| `list_printers()` | function | Names of saved printer presets |
| `list_filaments()` | function | Names of saved filament presets |
| `data_dir()` | function | Root preset storage path |
| `printers_dir()` | function | Printer preset directory |
| `filaments_dir()` | function | Filament preset directory |

**`JobData` pricing fields** (set by caller, not by parsers):

| Field | Type | Default | Description |
|---|---|---|---|
| `printer` | `PrinterPreset \| None` | `None` | Required before `calculate()` |
| `filament` | `FilamentPreset \| dict[int, FilamentPreset] \| None` | `None` | Single preset or per-slot mapping |
| `default_filament` | `FilamentPreset \| None` | `None` | Fallback for unmapped slots (dict mode) |
| `labor_minutes` | `float` | `0.0` | Post-processing labour time |
| `quantity` | `int` | `1` | Number of copies |
| `extra_costs` | `list[ExtraCost]` | `[]` | Extra per-unit cost items (hardware, inserts, etc.) |
| `name` | `str` | `""` | Job display name |
| `author` | `str` | `""` | Job author |
