# Linear Cut Planner — 1D Stock, Kerf & Offcuts (`ars-technis/linear-cut-planner`) Actor

Allocate 1D bars, tubes and strips in API workflows. Verified cutting layouts, part codes, finite stock, kerf, offcuts, CSV and grouped print output. USD 0.10 per completed plan; optimality reported when proven.

- **URL**: https://apify.com/ars-technis/linear-cut-planner.md
- **Developed by:** [Ars Technis](https://apify.com/ars-technis) (community)
- **Categories:** Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $100.00 / 1,000 complete feasible cutting plans

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Linear Cut Planner — 1D Stock, Kerf & Offcuts

Turn a bill of materials into a complete cutting layout in an Apify API or automation workflow. Supply part lengths and available bars; receive a verified JSON plan, cut-list CSV, stock/offcut CSV and a grouped printable layout.

**USD 0.10 per completed feasible plan**, up to 500 parts, plus a USD 0.00005 start event. No plan charge for invalid input, insufficient stock or a search that finds no complete layout. A feasible plan can be charged without a proof of optimality; the output says whether the minimum was proven.

One material and cross-section per run. For straight cuts in bars, strips, tubes or profiles. No external optimization account, API key or AI model is needed beyond your Apify access.

### Try a small job

```json
{
  "partsCsv": "length,quantity,partCode\n400,4,RAIL-01\n597,4,LEG-02\n",
  "stockCsv": "length,quantity\n1000,unlimited\n",
  "unit": "mm",
  "kerf": 3,
  "trimStart": 0,
  "trimEnd": 0,
  "minReusableOffcut": 100
}
```

This synthetic example needs four 1,000 mm bars. Each bar holds 597 mm + 3 mm kerf + 400 mm, with no leftover. The report proves that four bars is the minimum. The printable page groups them into one pattern marked **Repeat × 4**. Codes `LEG-02` and `RAIL-01` remain attached to their cuts in JSON, CSV and the diagram.

**Zero stock means no stock.** Write `unlimited` explicitly if inventory is unlimited. Use a positive count to limit available bars. All rows must describe the same material and profile.

### Use it in an API workflow

For a ready-to-run example, see the [Python API quickstart](#python-api-quickstart) below.

1. Pass the JSON input to this Actor using its Apify API tab, SDK, task or agent integration.
2. After the run finishes, read the `STATUS` record. `PLAN_COMPLETE` means the verified plan was delivered and its event confirmed. `PLAN_COMPLETE_UNMETERED` is a successful run without pay-per-event pricing, such as a private acceptance run.
3. Read the one default-dataset item, or download `REPORT`, `CUTS`, `STOCK` and `LAYOUT` from the Output tab. Check `status`, `search.optimalityProven` and `summary.verified` before downstream use.
4. Map the generated `partRow` or optional `partCode` back to your own nonpersonal material list. The HTML groups repeated patterns; JSON and CSV retain each individual bar and cut.

A `no_plan` result has a diagnostic report and HTML explanation, an empty dataset and no cut files. An `invalid_input` result has a `STATUS` explanation. `billing_unconfirmed` means a plan was prepared but the event was not confirmed; inspect existing outputs and platform billing before starting another run. Retrying a **new** run is a new job.

### Input reference

| Field | Format and meaning |
| --- | --- |
| `partsCsv` | Header `length,quantity`, optionally `length,quantity,partCode`. At most 100 rows and 500 individual parts. Quantities 1–500. |
| `stockCsv` | Header `length,quantity`; at most 20 rows. Quantity 0–10,000 is finite inventory; `unlimited` is unlimited. |
| `unit` | `mm` (default) or `in`. One unit for every dimension; no conversion. |
| `kerf` | Material lost after each separation cut; defaults to 0. Enter the actual allowance. |
| `trimStart`, `trimEnd` | Reserved losses at the two ends, including any trim-cut kerf. Default 0. |
| `minReusableOffcut` | A positive offcut at or above this length is classified as reusable. Default 0. This does not change the optimization objective. |

Lengths must be positive, up to 1,000,000 units, with at most three decimal places. Allowances may be zero. Use decimal inches, not fractions. No exponents or unit suffixes. Each CSV is limited to 64 KiB.

Optional part codes must be unique workshop codes: **1–4 uppercase letters, a hyphen, and 1–6 digits**, for example `LEG-01` or `SIDE-203`. Omit the column to get generated codes `P-1`, `P-2`, etc. Arbitrary text labels, customer/order identifiers, names, emails, addresses and extra fields are not accepted. Do not put personal data in a code.

For finite inventory, an example is parts `780,3`, `550,2`, `320,4`; stock `1200,1` and `2400,3`; kerf 3; trim 5 at each end. All nine parts fit on one 1,200 mm and two 2,400 mm bars. One 2,400 mm bar remains unused.

### What the result guarantees

Every completed layout passes a separate integer verifier for all requested quantities, part codes, available stock, coordinates and loss accounting. Each used bar satisfies:

`stock length = finished parts + kerf + trims + offcut`

The objective is **least total raw stock length**, then **fewest bars at that length**. It does not minimize purchase price, number of patterns or handling time. Two available 310 mm bars can beat one 1,000 mm bar when both fulfil two 300 mm parts.

The search compares packing candidates, uses bounded cutting-pattern optimization for larger jobs, and performs bounded exhaustive search on jobs of up to 22 parts. Integer lower bounds can certify larger jobs too. `optimalityProven: true` is set only when a separately checked bound matches the layout or exhaustive search establishes the result. A numerical optimizer's status alone is not treated as a proof.

Search effort is limited. A complete layout may have no optimum proof. If no layout is found, definite contradictions such as an oversized part or insufficient total inventory are explained. Otherwise the report says that feasibility remains unresolved. A different solver may find a better layout or solve a job this tool cannot.

`baseline` is first-fit decreasing with the shortest available fitting stock. Any reported difference is against that algorithm, not against your current process or another product. Yield is finished-part length divided by used raw stock length; unused inventory is excluded.

### Kerf and coordinates

A full kerf follows each part unless it ends **exactly** at the trimmed stock end, where no final separation cut is needed. A positive sliver smaller than a full kerf is conservatively refused. This may exclude a cut possible on a machine that allows its blade to extend beyond the stock end.

`start` and `end` are finished-part edges measured from the original stock start. `kerfAfter` is the following loss. These are **not saw-centre coordinates or machine instructions**. Separation-cut counts exclude cuts used to make the reserved trims. Diagrams are schematic; use the numeric table and review actual dimensions and machine allowances.

### Output records

| Output | Contents |
| --- | --- |
| `REPORT` / default dataset | Settings, part codes/rows, proof status and lower bound, diagnostics, baseline, individual bars/cuts, inventory and totals. One dataset item per completed plan. |
| `LAYOUT` | Standalone responsive HTML. Repeated patterns are grouped with bar references. Print or save as PDF in a browser. |
| `CUTS` | CSV with bar, stock row, sequence, part row/code, length, start/end and kerf. |
| `STOCK` | CSV with every used bar and its end offcut/classification. |
| `STATUS` | Input, budget, processing and billing outcome. |

JSON and CSV dimensions are decimal **strings**, preserving exact values. Report schema version is **1.1**. Before release 1.1, the unpublished prototype used stock quantity 0 for unlimited; this release deliberately removes that convention.

### Scope and privacy

No sheet nesting, miter angles, defects, grain matching, tolerance stacks, clamping restrictions, material inspection, engineering design or machine code. Reuse classification checks length only.

Inputs and outputs stay in the initiating Apify account's run storage under its retention/access settings. The Actor has no separate accounts, tracking or customer database; it does not fetch URLs or send the job to another service. Delete unneeded runs and stores using Apify's controls.

For support, use the Actor's Issues tab with a small **synthetic** reproduction and expected/observed behavior. Do not post customer data, confidential files or credentials. Built with AI-assisted development; the running tool uses code and mathematical optimization, not an AI model.

### Python API quickstart

Use Python 3.10+ to submit one job and retrieve its exports without installing an SDK. Save the [complete Python client](#complete-python-client) below as `cut_plan_client.py`, and save the JSON from **Try a small job** as `input.json`.

Copy this Actor's ID or `owner~name` from its **API tab** into a shell variable named `CUT_PLAN_ACTOR`. Supply your own `APIFY_TOKEN` using your environment or secret manager; keep it out of source files and support messages.

```sh
python cut_plan_client.py start input.json job-001 --actor "$CUT_PLAN_ACTOR"
python cut_plan_client.py fetch job-001
```

`start` submits one job with a **USD 0.11 run cap**, 512 MB memory and a 120-second Actor timeout. At the price above, that cap covers one completed plan. A successful start only means the run ID was saved. `fetch` reads that run and downloads the five output records; it does not start another job. Storage reads remain subject to your Apify account's normal terms.

For the small example, `report.json` contains this summary excerpt:

```json
{
  "requestedParts": 8,
  "placedParts": 8,
  "barsUsed": 4,
  "stockLength": "4000",
  "finishedPartLength": "3988",
  "kerfLoss": "12",
  "verified": true
}
```

#### Continue only when the files are ready

Read `job-001/result.json` after `fetch`. **`ready: true`** means all five records were retrieved and the expected completion/verification flags are present. Open `layout.html`, or read `cuts.csv` and `stock.csv`. Check `optimalityProven` separately: a feasible, billable plan can have no proof of optimality. The Actor performs layout verification; this client checks the output contract, not the mathematics again.

| Fetch exit code | Result | Next step |
| --- | --- | --- |
| 0 | Complete output, including a separately named unmetered case | Review the plan and its dimensions. |
| 2 | Invalid input, exhausted budget or no complete plan | Read `status.json` and any diagnostic report. |
| 3 | Run still active after the polling window | Run `fetch job-001` again later. |
| 4 | Connection/platform failure, missing output or uncertain billing | Inspect the existing run and billing before creating a new job. |

The default fetch polling window is 120 seconds; an in-progress HTTP request may take up to another 30 seconds. Waiting does not cancel the run. No-plan results retain their report and HTML explanation without ready cut files.

#### Recover an interrupted job

The client reserves a new job directory **before** its single start request. Another `start` into that directory is refused. If `run.json` exists, resume with `fetch job-001`.

If the start response was lost, inspect Apify Console to identify the existing run. Fetch that exact run into a new directory:

```sh
python cut_plan_client.py fetch recovered-job --run-id YOUR_EXISTING_RUN_ID
```

Do not automatically repeat the start POST or select the Actor's “last run” in a concurrent workflow. Another directory, another computer or a deleted job directory can still create a second paid job; this is a local guard, not a platform idempotency guarantee. `PLAN_ALREADY_PRESENT` and billing uncertainty keep `ready` false for manual review. Interrupted downloads can be resumed with `fetch`.

Keep the job directory private: run IDs can grant access under your Apify settings. The client saves no token or raw input, logs no response bodies, and contacts only Apify. It validates basic input shape; full input validation happens in the Actor. Use nonpersonal workshop data only.

### Agent and MCP workflow

For 1D cutting-stock jobs from your own agent, use [Apify's hosted MCP server](https://docs.apify.com/mcp). Connect and authenticate with Apify through your MCP client. One configuration URL is:

```text
https://mcp.apify.com?tools=actors&telemetry-enabled=false
```

Inspect this Actor's exact identity, input schema and current price with `fetch-actor-details` before running it. `call-actor` puts spending/runtime limits in **`callOptions`**, separate from the cutting input:

```json
{
  "callOptions": {"maxTotalChargeUsd": 0.11, "memory": 512, "timeout": 120},
  "waitSecs": 30
}
```

This is a run-options excerpt, not a complete tool call. Use the small synthetic input above as the Actor input.

Save the returned run ID. If still active, follow that run with `get-actor-run`; do not call the Actor again. `call-actor` returns status and storage IDs, not the plan. Read `STATUS` and `REPORT` with `get-key-value-store-record`, then apply the same completion/proof checks described above. The Actor runs mathematical code; your agent's model access remains your own.

### Complete Python client

Copy the entire code block into `cut_plan_client.py`. It uses only the Python standard library.

```python
#!/usr/bin/env python3
## MIT License
#
## Copyright (c) 2026 Ars Technis GmbH
#
## Permission is hereby granted, free of charge, to any person obtaining a copy
## of this software and associated documentation files (the "Software"), to deal
## in the Software without restriction, including without limitation the rights
## to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
## copies of the Software, and to permit persons to whom the Software is
## furnished to do so, subject to the following conditions:
#
## The above copyright notice and this permission notice shall be included in all
## copies or substantial portions of the Software.
#
## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
## IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
## AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
## LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
## OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
## SOFTWARE.

"""Linear Cut Planner API example. Python 3.10+, standard library only.

Customer-side code: APIFY_TOKEN stays in the customer's environment.
Start once into a new directory; fetch only reads the recorded run.
"""
import argparse
import hashlib
from http.client import HTTPException
import json
import os
from pathlib import Path
import re
import sys
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, Request, build_opener

API = "https://api.apify.com/v2"
TERMINAL = {"SUCCEEDED", "FAILED", "TIMED-OUT", "ABORTED"}
TRANSITIONAL = {"READY", "RUNNING", "TIMING-OUT", "ABORTING"}
EXPORTS = {"STATUS": "status.json", "REPORT": "report.json", "CUTS": "cuts.csv",
           "STOCK": "stock.csv", "LAYOUT": "layout.html"}
MAX_RECORD_BYTES = 8 * 1024 * 1024


class ClientError(Exception):
    """Fixed text only: do not expose response bodies, tokens or input."""


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


class Api:
    def __init__(self, token):
        if not token or not re.fullmatch(r"[!-~]+", token):
            raise ClientError("Set APIFY_TOKEN securely in your environment.")
        self.token = token
        self.opener = build_opener(NoRedirects())

    def request(self, method, path, payload=None, missing_ok=False):
        data = None if payload is None else json.dumps(payload, allow_nan=False).encode()
        req = Request(API + path, data=data, method=method, headers={
            "Authorization": "Bearer " + self.token,
            "Content-Type": "application/json; charset=utf-8",
            "User-Agent": "LinearCutPlannerExample/1.0.1",
        })
        # No automatic retries, including after an ambiguous POST result.
        try:
            with self.opener.open(req, timeout=30) as response:
                # A bounded HTTPResponse.read() can return early at EOF without
                # raising IncompleteRead. Check fixed-length framing explicitly.
                expected = None
                declared = response.headers.get("Content-Length")
                chunked = response.headers.get("Transfer-Encoding", "").lower() == "chunked"
                if declared is not None and not chunked:
                    try:
                        if not re.fullmatch(r"[0-9]+", declared.strip()):
                            raise ValueError
                        expected = int(declared)
                    except ValueError:
                        raise ClientError("Invalid response length. No result is ready.") from None
                    if expected > MAX_RECORD_BYTES:
                        raise ClientError("Response exceeds the example client's size limit.")
                body = response.read(MAX_RECORD_BYTES + 1)
                if len(body) > MAX_RECORD_BYTES:
                    raise ClientError("Response exceeds the example client's size limit.")
                if expected is not None and len(body) != expected:
                    raise ClientError("Response was incomplete. Resume with fetch; do not start another run.")
                return body
        except HTTPError as exc:
            exc.close()
            if missing_ok and exc.code == 404:
                return None
            raise ClientError(f"Apify HTTP {exc.code}. Inspect this run in Console; no automatic retry occurred.") from None
        except (URLError, TimeoutError, OSError, HTTPException):
            raise ClientError("Connection interrupted. Inspect this run in Console; no automatic retry occurred.") from None

    def data(self, method, path, payload=None):
        obj = decode_json(self.request(method, path, payload))
        if not isinstance(obj, dict) or not isinstance(obj.get("data"), dict):
            raise ClientError("Unexpected Apify response shape. Inspect Console before continuing.")
        return obj["data"]


def decode_json(raw):
    try:
        return json.loads(raw)
    except (ValueError, UnicodeError, TypeError):
        raise ClientError("A JSON file or response could not be read.") from None


def checked_id(value):
    if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9]{1,64}", value):
        raise ClientError("Expected an Apify ID, not a URL.")
    return value


def checked_actor(value):
    if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}(?:~[A-Za-z0-9_-]{1,100})?", value):
        raise ClientError("Use the Actor ID or owner~name from the Actor's API tab.")
    return value


def atomic_write(path, data):
    """A failed write never leaves a half-written record under its final name."""
    fd, temp = tempfile.mkstemp(prefix=".write-", dir=path.parent)
    try:
        with os.fdopen(fd, "wb") as stream:
            stream.write(data)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temp, path)
    finally:
        if os.path.exists(temp):
            os.unlink(temp)


def save_json(path, value):
    atomic_write(path, (json.dumps(value, indent=2, allow_nan=False) + "\n").encode())


def new_directory(path):
    try:
        path.mkdir(mode=0o700, parents=False, exist_ok=False)
    except FileExistsError:
        raise ClientError("Job directory already exists. Use fetch; start is refused to avoid another paid job.") from None


def read_input(path):
    raw = path.read_bytes()
    if len(raw) > 160 * 1024:
        raise ClientError("Input exceeds this client's 160 KiB limit.")
    value = decode_json(raw)
    allowed = {"partsCsv", "stockCsv", "unit", "kerf", "trimStart", "trimEnd", "minReusableOffcut"}
    if not isinstance(value, dict) or set(value) - allowed:
        raise ClientError("Input must be a JSON object containing only documented Actor input fields.")
    if not all(isinstance(value.get(k), str) and value[k] for k in ("partsCsv", "stockCsv")):
        raise ClientError("Input requires nonempty partsCsv and stockCsv strings.")
    try:
        json.dumps(value, allow_nan=False)
    except (ValueError, TypeError):
        raise ClientError("Input must contain finite JSON values.") from None
    return value, hashlib.sha256(raw).hexdigest()


def start(api, actor, input_path, directory):
    actor = checked_actor(actor)
    payload, digest = read_input(input_path)
    new_directory(directory)
    # Create durable intent BEFORE the one potentially billable POST. An empty
    # or incomplete directory also blocks a second start into this same path.
    save_json(directory / "intent.json", {"actor": actor, "inputSha256": digest,
        "maxTotalChargeUsd": "0.11", "state": "start_attempted"})
    query = urlencode({"maxTotalChargeUsd": "0.11", "memory": 512, "timeout": 120,
                       "waitForFinish": 0, "restartOnError": "false"})
    try:
        run = api.data("POST", f"/actors/{actor}/runs?{query}", payload)
        run_id = checked_id(run.get("id"))
        save_json(directory / "run.json", {"runId": run_id, "actor": actor})
    except (ClientError, OSError):
        raise ClientError("Start result is uncertain. Do not start again. Check Console for the run; recover with fetch --run-id into a new directory.") from None
    return {"result": "started", "next": "fetch", "runId": run_id}


def bind_run(directory, run_id):
    """Manual recovery by a known run ID; never calls the start endpoint."""
    run_id = checked_id(run_id)
    new_directory(directory)
    save_json(directory / "run.json", {"runId": run_id})


def outcome(run, status, report):
    """Business outcome is separate from the container's terminal status."""
    if (run.get("status") in {"SUCCEEDED", "FAILED"} and isinstance(status, dict)
            and status.get("status") == "invalid_input"):
        return 2, "invalid_input"
    if run.get("status") != "SUCCEEDED":
        return 4, "platform_failed"
    if not isinstance(status, dict):
        return 4, "status_missing"
    state, code = status.get("status"), status.get("code")
    if state in {"no_plan", "invalid_input", "budget_exhausted"}:
        if state == "no_plan" and (not isinstance(report, dict) or report.get("status") != "no_plan"):
            return 4, "output_inconsistent"
        return 2, state
    if state != "completed" or code not in {"PLAN_COMPLETE", "PLAN_COMPLETE_UNMETERED"}:
        return 4, "needs_review"
    if not isinstance(report, dict) or report.get("schemaVersion") != "1.1":
        return 4, "report_missing_or_unsupported"
    summary = report.get("summary")
    search = report.get("search")
    if (report.get("status") != "complete" or not isinstance(summary, dict)
            or summary.get("verified") is not True or not isinstance(search, dict)
            or type(search.get("optimalityProven")) is not bool):
        return 4, "output_inconsistent"
    return 0, "complete" if code == "PLAN_COMPLETE" else "complete_unmetered"


def fetch(api, directory, wait_seconds=120, clock=time.monotonic):
    state = decode_json((directory / "run.json").read_bytes())
    if not isinstance(state, dict):
        raise ClientError("Job record is invalid; use a known run ID to recover into a new directory.")
    run_id = checked_id(state.get("runId"))
    # Invalidate the previous readiness marker before reads, even if interrupted.
    save_json(directory / "result.json", {"ready": False, "result": "fetching", "runId": run_id})
    deadline = clock() + max(0, wait_seconds)
    while True:
        remaining = max(0, min(10, int(deadline - clock())))
        run = api.data("GET", f"/actor-runs/{run_id}?waitForFinish={remaining}")
        if run.get("id") != run_id:
            raise ClientError("Run ID mismatch. No result is ready.")
        run_status = run.get("status")
        if run_status in TERMINAL:
            break
        if run_status not in TRANSITIONAL:
            raise ClientError("Unknown platform status. Resume reads or inspect Console.")
        if clock() >= deadline:
            result = {"ready": False, "result": "pending", "runId": run_id}
            save_json(directory / "result.json", result)
            return 3, result
        # API normally long-polls. Keep a fast early response from busy-looping.
        time.sleep(min(1, max(0, deadline - clock())))
    store_id = checked_id(run.get("defaultKeyValueStoreId"))
    records = {}
    present = []
    for key, filename in EXPORTS.items():
        raw = api.request("GET", f"/key-value-stores/{store_id}/records/{key}?attachment=true", missing_ok=True)
        if raw is None:
            # A repeated fetch must not reuse stale files after storage deletion.
            (directory / filename).unlink(missing_ok=True)
            continue
        if key in {"STATUS", "REPORT"}:
            records[key] = decode_json(raw)
        atomic_write(directory / filename, raw)
        present.append(key)
    code, label = outcome(run, records.get("STATUS"), records.get("REPORT"))
    if code == 0 and set(present) != set(EXPORTS):
        code, label = 4, "exports_incomplete"
    result = {"ready": code == 0, "result": label, "runId": run_id,
              "platformStatus": run_status, "exports": present}
    if code == 0:
        result["optimalityProven"] = records["REPORT"]["search"]["optimalityProven"]
    save_json(directory / "result.json", result)
    return code, result


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)
    s = commands.add_parser("start", help="Start ONE potentially paid job into a NEW directory")
    s.add_argument("input", type=Path)
    s.add_argument("directory", type=Path)
    s.add_argument("--actor", required=True, help="Actor ID or owner~name from the API tab")
    f = commands.add_parser("fetch", help="Read an existing run; never starts or restarts one")
    f.add_argument("directory", type=Path)
    f.add_argument("--run-id", help="Recover a known run into a NEW directory")
    f.add_argument("--wait", type=int, default=120, choices=range(0, 601), metavar="0..600")
    args = parser.parse_args(argv)
    try:
        api = Api(os.environ.get("APIFY_TOKEN"))
        if args.command == "start":
            result = start(api, args.actor, args.input, args.directory)
            code = 0
        else:
            if args.run_id:
                bind_run(args.directory, args.run_id)
            code, result = fetch(api, args.directory, args.wait)
        print(json.dumps(result))
        return code
    except ClientError as exc:
        print(str(exc), file=sys.stderr)
    except OSError:
        print("Local files could not be read or saved. Inspect the job directory and Console before starting any new run.", file=sys.stderr)
    except KeyboardInterrupt:
        print("Interrupted. The remote run may continue. Resume with fetch; do not start again.", file=sys.stderr)
    return 4


if __name__ == "__main__":
    sys.exit(main())
```

# Actor input Schema

## `partsCsv` (type: `string`):

Header length,quantity or length,quantity,partCode. Up to 100 rows / 500 parts. Optional unique workshop codes like LEG-01 (1–4 uppercase letters, hyphen, 1–6 digits). No names, customer/order identifiers or personal data.

## `stockCsv` (type: `string`):

Exact header length,quantity. Up to 20 rows. Quantity 0 means NO stock. Use the literal unlimited for unlimited bars, or 1–10000 for a finite inventory. All rows must be the same material/profile.

## `unit` (type: `string`):

No conversion. Use decimal inches, not fractions, if selecting in.

## `kerf` (type: `number`):

Material lost per separation cut. The final cut is waived only when the finished part ends exactly at the trimmed stock end. Defaults to 0 when omitted; enter your actual allowance. At most three decimal places; same unit as lengths.

## `trimStart` (type: `number`):

Total reserved loss at the stock start, INCLUDING any trim-cut kerf. At most three decimal places; same unit as lengths.

## `trimEnd` (type: `number`):

Total reserved loss at the stock end, INCLUDING any trim-cut kerf. At most three decimal places; same unit as lengths.

## `minReusableOffcut` (type: `number`):

Classify positive leftover at or above this length as reusable. This does not change the search objective. Defaults to 0. At most three decimal places; same unit as lengths.

## Actor input object example

```json
{
  "partsCsv": "length,quantity\n400,4\n597,4\n",
  "stockCsv": "length,quantity\n1000,unlimited\n",
  "unit": "mm",
  "kerf": 3,
  "trimStart": 0,
  "trimEnd": 0,
  "minReusableOffcut": 100
}
```

# Actor output Schema

## `layout` (type: `string`):

Standalone HTML diagram and numeric cut tables. Use browser Print / Save as PDF.

## `report` (type: `string`):

Complete plan or no-plan result; check status and model limits.

## `cutsCsv` (type: `string`):

Every placed part, source row, start/end and kerf. Complete plans only.

## `stockCsv` (type: `string`):

Used stock and remaining offcut per bar. Complete plans only.

## `status` (type: `string`):

Input, budget, storage and billing outcome; sanitized fixed text.

## `dataset` (type: `string`):

One item per complete plan. Empty when no complete plan was delivered.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "partsCsv": `length,quantity
400,4
597,4`,
    "stockCsv": `length,quantity
1000,unlimited`,
    "kerf": 3,
    "trimStart": 0,
    "trimEnd": 0,
    "minReusableOffcut": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("ars-technis/linear-cut-planner").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "partsCsv": """length,quantity
400,4
597,4
""",
    "stockCsv": """length,quantity
1000,unlimited
""",
    "kerf": 3,
    "trimStart": 0,
    "trimEnd": 0,
    "minReusableOffcut": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("ars-technis/linear-cut-planner").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "partsCsv": "length,quantity\\n400,4\\n597,4\\n",
  "stockCsv": "length,quantity\\n1000,unlimited\\n",
  "kerf": 3,
  "trimStart": 0,
  "trimEnd": 0,
  "minReusableOffcut": 100
}' |
apify call ars-technis/linear-cut-planner --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ars-technis/linear-cut-planner"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/MFht0eQkAM2hTOVh1/builds/Z7AZrxSP2fNgQtzlg/openapi.json
