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

Linear Cut Planner — 1D Stock, Kerf & Offcuts

Pricing

from $100.00 / 1,000 complete feasible cutting plans

Go to Apify Store
Linear Cut Planner — 1D Stock, Kerf & Offcuts

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

Ars Technis

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

7 days ago

Last modified

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.

  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

FieldFormat and meaning
partsCsvHeader length,quantity, optionally length,quantity,partCode. At most 100 rows and 500 individual parts. Quantities 1–500.
stockCsvHeader length,quantity; at most 20 rows. Quantity 0–10,000 is finite inventory; unlimited is unlimited.
unitmm (default) or in. One unit for every dimension; no conversion.
kerfMaterial lost after each separation cut; defaults to 0. Enter the actual allowance.
trimStart, trimEndReserved losses at the two ends, including any trim-cut kerf. Default 0.
minReusableOffcutA 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

OutputContents
REPORT / default datasetSettings, part codes/rows, proof status and lower bound, diagnostics, baseline, individual bars/cuts, inventory and totals. One dataset item per completed plan.
LAYOUTStandalone responsive HTML. Repeated patterns are grouped with bar references. Print or save as PDF in a browser.
CUTSCSV with bar, stock row, sequence, part row/code, length, start/end and kerf.
STOCKCSV with every used bar and its end offcut/classification.
STATUSInput, 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 codeResultNext step
0Complete output, including a separately named unmetered caseReview the plan and its dimensions.
2Invalid input, exhausted budget or no complete planRead status.json and any diagnostic report.
3Run still active after the polling windowRun fetch job-001 again later.
4Connection/platform failure, missing output or uncertain billingInspect 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 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())