Linear Cut Planner — 1D Stock, Kerf & Offcuts
Pricing
from $100.00 / 1,000 complete feasible cutting plans
Linear Cut Planner — 1D Stock, Kerf & Offcuts
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.
Pricing
from $100.00 / 1,000 complete feasible cutting plans
Rating
0.0
(0)
Developer
Ars Technis
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
7 days ago
Last modified
Categories
Share
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
{"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 below.
- Pass the JSON input to this Actor using its Apify API tab, SDK, task or agent integration.
- After the run finishes, read the
STATUSrecord.PLAN_COMPLETEmeans the verified plan was delivered and its event confirmed.PLAN_COMPLETE_UNMETEREDis a successful run without pay-per-event pricing, such as a private acceptance run. - Read the one default-dataset item, or download
REPORT,CUTS,STOCKandLAYOUTfrom the Output tab. Checkstatus,search.optimalityProvenandsummary.verifiedbefore downstream use. - Map the generated
partRowor optionalpartCodeback 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 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.
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:
{"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:
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. Connect and authenticate with Apify through your MCP client. One configuration URL is:
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:
{"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.
#!/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 argparseimport hashlibfrom http.client import HTTPExceptionimport jsonimport osfrom pathlib import Pathimport reimport sysimport tempfileimport timefrom urllib.error import HTTPError, URLErrorfrom urllib.parse import urlencodefrom urllib.request import HTTPRedirectHandler, Request, build_openerAPI = "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 * 1024class 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 Noneclass 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 = tokenself.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 = Nonedeclared = 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 ValueErrorexpected = int(declared)except ValueError:raise ClientError("Invalid response length. No result is ready.") from Noneif 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 bodyexcept HTTPError as exc:exc.close()if missing_ok and exc.code == 404:return Noneraise ClientError(f"Apify HTTP {exc.code}. Inspect this run in Console; no automatic retry occurred.") from Noneexcept (URLError, TimeoutError, OSError, HTTPException):raise ClientError("Connection interrupted. Inspect this run in Console; no automatic retry occurred.") from Nonedef 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 Nonedef 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 valuedef 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 valuedef 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 Nonedef 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 Nonereturn 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 Nonereturn {"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, stateif 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:breakif 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)continueif 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, resultdef 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 = 0else:if args.run_id:bind_run(args.directory, args.run_id)code, result = fetch(api, args.directory, args.wait)print(json.dumps(result))return codeexcept 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 4if __name__ == "__main__":sys.exit(main())