Documentation de l'API
URL de base https://api.cutoptim.com · version du contrat v1
Créez une clé sur votre tableau de bord, puis envoyez-la sous la forme Authorization: Bearer <key>.
Spécification OpenAPI: /engine/openapi.json
Endpoints
| POST | /v1/optimize/2d | optimisation de panneaux 2D |
| POST | /v1/optimize/1d | optimisation 1D / linéaire (barres, profilés, tubes) |
| GET | /v1/usage | consommation du mois en cours et quota de la clé appelante |
| GET | /v1/health | état de disponibilité — sans clé, sans limite de débit, sans base de données |
Unités
L'API est agnostique en unités. Choisissez une unité — millimètres, pouces, peu importe —, utilisez-la pour tous les nombres que vous envoyez, et tous les nombres renvoyés seront exprimés dans cette même unité. Rien n'est converti côté serveur, et aucun nom de champ n'impose une unité.
Cela vaut pour les dimensions des pièces et du stock, kerf, tolerance, trim et minOffcut en entrée, et pour chaque coordonnée, position, longueur restante, chute et longueur de coupe en sortie. Mélanger les unités dans une même requête produit un plan qui passe la validation et qui est physiquement faux, sans que le serveur puisse le détecter.
Système de coordonnées
L'origine est le coin supérieur gauche du panneau : x croît vers la droite le long de la largeur du panneau, y croît vers le bas le long de sa hauteur. Les x et y d'une pièce désignent son coin supérieur gauche, et ses w et h sont les dimensions telles que posées — déjà permutées lorsque rotated vaut true — de sorte que le rectangle x, y, w, h est l'empreinte sur le panneau, sans calcul supplémentaire. Les rectangles de chutes utilisent le même système.
Le délignage (trim) déplace les placements : trim.left décale toutes les pièces vers la droite et trim.top les décale vers le bas, car les pièces sont imbriquées dans la zone utile, puis ramenées par décalage sur le panneau entier. trim.right et trim.bottom réduisent la zone utile sans déplacer l'origine. Les w et h d'un panneau sont toujours les dimensions complètes du stock, délignage inclus — ce qui explique aussi pourquoi le délignage compte comme de la perte dans yieldPct.
2D — requête
POST /v1/optimize/2d
Authorization: Bearer co_live_…
Content-Type: application/json
{
"parts": [{ "name": "Door", "w": 600, "h": 400, "qty": 4, "rotatable": true }],
"stock": [{ "w": 2440, "h": 1220, "qty": 10, "price": 42 }],
"options": { "kerf": 3, "trim": { "left": 10, "top": 10 } },
"engine": "heuristic",
"include": ["cutPlan", "offcuts"]
} Champs d'une pièce
| w, h | obligatoires — dimensions de la pièce |
| qty | 1 par défaut — développé côté serveur ; compte dans le plafond de 2,000 |
| name | libellé facultatif, repris sur chaque placement |
| rotatable | true par défaut — la pièce peut-elle être tournée de 90° |
| grainGroup | les membres d'un groupe restent sur un même panneau (correspondance du grain) |
| priority | à découper impérativement : obtient la place sur le panneau quand le stock est plafonné (avec respectStock) |
Champs du stock
w et h sont obligatoires. qty vaut 1 par défaut et n'est une limite stricte qu'avec respectStock. price s'entend par panneau et alimente totalPrice ainsi que le mode coût.
Options
| kerf | largeur du trait de scie (0 par défaut) |
| tolerance | accepter les coupes qui dépassent jusqu'à cette valeur |
| trim | délignage par côté : left, right, top, bottom |
| firstCut | 'auto' | 'horizontal' | 'vertical' ('auto' par défaut) |
| minimizeCost | classer les candidats par prix total du stock plutôt que par nombre de panneaux |
| respectStock | traiter le qty de chaque ligne de stock comme une limite stricte |
| minOffcut | ne remonter que les chutes dont le petit côté atteint au moins cette valeur |
| maxCutStages | limite de phases pour scie à panneaux — un nombre de phases, pas la profondeur brute de l'arbre |
| minimizeRotations | préférer les dispositions qui tournent moins de pièces |
include réduit la réponse : passez ["cutPlan","offcuts"] (les deux par défaut), ou omettez l'un des deux pour qu'il ne figure pas dans la charge utile.
2D — réponse
{
"engine": "heuristic",
"engineVersion": "1.0.0+10e0c941",
"contractVersion": "1",
"deterministic": true,
"sheets": [
{
"w": 2440,
"h": 1220,
"price": 42,
"parts": [
{ "name": "Door", "x": 10, "y": 10, "w": 400, "h": 600, "rotated": true },
{ "name": "Door", "x": 413, "y": 10, "w": 400, "h": 600, "rotated": true },
{ "name": "Door", "x": 816, "y": 10, "w": 400, "h": 600, "rotated": true },
{ "name": "Door", "x": 1219, "y": 10, "w": 400, "h": 600, "rotated": true }
],
"offcuts": [
{ "x": 1622, "y": 10, "w": 818, "h": 600 },
{ "x": 10, "y": 613, "w": 2430, "h": 607 }
]
}
],
"metrics": {
"sheetCount": 1,
"yieldPct": 32.25,
"placed": 4,
"total": 4,
"cutLines": 5,
"sawPasses": 5,
"cutLength": 4830,
"totalPrice": 42
},
"unplaced": [],
"warnings": [],
"guillotineValid": true,
"timing": { "solveMs": 3.13 },
"cutPlan": [
{ "sheet": 0, "step": 1, "axis": "h", "pos": 610, "length": 2430, "stage": 1 },
{ "sheet": 0, "step": 2, "axis": "v", "pos": 410, "length": 600, "stage": 2 },
{ "sheet": 0, "step": 3, "axis": "v", "pos": 813, "length": 600, "stage": 2 },
{ "sheet": 0, "step": 4, "axis": "v", "pos": 1216, "length": 600, "stage": 2 },
{ "sheet": 0, "step": 5, "axis": "v", "pos": 1619, "length": 600, "stage": 2 }
]
} - cutLines vs sawPasses — cutLines fusionne les coupes colinéaires (un seul réglage de butée) ; sawPasses compte chaque passe. Deux mesures honnêtes du même plan, et non la prétention de retrouver le décompte d'un concurrent.
- guillotineValid / cutPlan — Quand une disposition ne peut pas être coupée de rive à rive, guillotineValid vaut false et cutPlan vaut null. C'est une information réelle — le plan n'est pas réalisable sur une scie à panneaux — et non une erreur.
- unplaced + warnings — Un travail impossible à satisfaire renvoie 200, avec les pièces listées dans unplaced et une note dans warnings. Un plan exploitable vaut mieux qu'un code de statut.
Champs de la réponse
Les noms et les types des champs constituent le contrat : les tableaux ci-dessous restent donc en anglais dans toutes les langues — un nom de champ traduit documenterait une API qui n'existe pas.
POST /v1/optimize/2d — top level
| engine | string | Which engine ran: "heuristic" or "balanced". |
| engineVersion | string | Algorithm identity — package version + a content hash of the algorithm source. Moves automatically on any packer change, and independently per engine. |
| contractVersion | string | Shape version, matching the /v1/ in the path. Currently "1". |
| deterministic | boolean | Always true. Present so a client can assert the guarantee it relies on. |
| sheets | array | One entry per sheet used, in cutting order. |
| metrics | object | Aggregate numbers for the whole job. |
| cutPlan | array | null | The sawing plan. null when guillotineValid is false; absent when excluded via include. |
| unplaced | array | Parts that did not fit, aggregated by name + size. Empty when everything fitted. |
| warnings | string[] | Free-text notes about the plan. Do not parse — branch on unplaced, guillotineValid and metrics. |
| guillotineValid | boolean | True when the layout is producible with edge-to-edge cuts, i.e. on a panel saw. |
| timing.solveMs | number | Milliseconds inside the packer, 2 decimals. Excludes parsing, auth and queueing. |
sheets[]
| w, h | number | FULL stock dimensions, trim included — not the usable area. |
| price | number | null | Price of the stock row this sheet came from, or null if none was given. |
| parts | array | Placements on this sheet. |
| offcuts | array | Usable leftover rectangles, filtered by options.minOffcut. Absent (not empty) when excluded via include. |
sheets[].parts[]
| name | string | The requested name, or the generated default "Part <row>". |
| x, y | number | Top-left corner of the part, from the top-left corner of the sheet. |
| w, h | number | Dimensions AS PLACED — already swapped when rotated is true. No client-side swap needed. |
| rotated | boolean | True if the part was turned 90° from the requested w×h. Informational only. |
sheets[].offcuts[]
| x, y, w, h | number | A leftover rectangle, in the same coordinate system as the placements. |
metrics (2D)
| sheetCount | integer | Sheets used. Equals sheets.length. |
| yieldPct | number | Placed part area ÷ total FULL sheet area × 100, 2 decimals. Trim and kerf count as waste. |
| placed | integer | Pieces placed, after qty expansion. |
| total | integer | Pieces requested, after qty expansion. |
| cutLines | integer | Collinear cuts merged: same axis, same coordinate, same stage counted once — one fence setting. |
| sawPasses | integer | Every cut, one per strip crossed — how many separate passes the saw makes. |
| cutLength | number | Total distance sawn, 3 decimals. Same under either counting convention. In your unit. |
| totalPrice | number | Sum of the used sheets’ prices, 2 decimals. 0 when no stock row carried a price. |
unplaced[] (2D)
| name | string | The part name. |
| w, h | number | As requested. |
| qty | integer | How many of this part could not be placed. |
cutPlan[]
| sheet | integer | 0-based index into sheets (2D) or rods (1D). Named sheet in both. |
| step | integer | 1-based order within THIS sheet — it restarts at 1 on every sheet. |
| axis | "h" | "v" | "h": blade travels along x, separating top from bottom. "v": along y, separating left from right. |
| pos | number | The blade’s LOW-COORDINATE edge — the y value for "h", the x value for "v" — not its centre line. The kerf occupies pos to pos + kerf. |
| length | number | Distance the blade travels on this cut: the extent of the region crossed. Always 0 in 1D. |
| stage | integer | 1-based machine pass. Increments only when the axis flips relative to the parent cut. |
cutPlan — ce que représente physiquement une étape
Une étape (step) correspond à un seul mouvement de lame, et la liste suit l'ordre dans lequel vous pouvez réellement scier : une coupe parente avant les coupes internes à la pièce qu'elle a produite, car vous ne pouvez pas tronçonner une bande avant de l'avoir délignée. axis "h" signifie que la lame se déplace le long de x et sépare le haut du bas ; axis "v" signifie qu'elle se déplace le long de y et sépare la gauche de la droite. pos est le bord de la lame situé du côté des COORDONNÉES FAIBLES — la valeur de y pour "h", celle de x pour "v" — et non son axe médian : le kerf occupe la plage pos à pos + kerf, si bien que la lame mord dans le sens où croît la coordonnée, c'est-à-dire vers le bas pour "h" et vers la droite pour "v". La matière située du côté faible de la ligne — au-dessus d'elle pour "h", à sa gauche pour "v" — est la pièce que cette coupe libère. length est la distance parcourue par la lame sur cette seule coupe : l'étendue de la région traversée, et non la largeur du panneau entier.
stage est une passe machine. Il commence à 1 et n'augmente que lorsque axis change par rapport à la coupe parente : déligner un panneau en six bandes est donc une seule phase, et les tronçonner est la suivante. C'est le sens que la scie à panneaux donne à la « coupe en trois phases », et non la profondeur de l'arbre de coupe — et c'est ce que maxCutStages contraint. sheet est un index à base 0 dans sheets, et step repart à 1 sur chaque panneau au lieu de se poursuivre sur tout le travail.
cutPlan vaut null — ni absent, ni vide — dès que guillotineValid vaut false : une disposition qui ne peut pas être coupée de rive à rive n'a aucune séquence de coupes à renvoyer. Le champ ne figure pas du tout dans la charge utile si vous l'avez exclu de include.
1D — linéaire
POST /v1/optimize/1d
{
"parts": [{ "name": "Rail", "length": 1200, "qty": 6 }],
"stock": [{ "length": 3000, "qty": 5, "price": 12.5 }],
"options": { "kerf": 3, "trim": { "start": 10, "end": 0 } }
} Les pièces prennent length (plus qty, name, priority) ; le stock prend length, qty et price. Les options sont kerf, tolerance, trim.start / trim.end, minimizeCost, respectStock et minOffcut. La réponse renvoie rods au lieu de sheets, chaque barre avec ses pièces, sa longueur restante et ses chutes.
1D — réponse
{
"engine": "heuristic",
"engineVersion": "1.0.0+10e0c941",
"contractVersion": "1",
"deterministic": true,
"rods": [
{
"length": 3000,
"price": 12.5,
"remaining": 587,
"parts": [
{ "name": "Rail", "pos": 10, "length": 1200 },
{ "name": "Rail", "pos": 1213, "length": 1200 }
],
"offcuts": [587]
},
{
"length": 3000,
"price": 12.5,
"remaining": 587,
"parts": [
{ "name": "Rail", "pos": 10, "length": 1200 },
{ "name": "Rail", "pos": 1213, "length": 1200 }
],
"offcuts": [587]
},
{
"length": 3000,
"price": 12.5,
"remaining": 587,
"parts": [
{ "name": "Rail", "pos": 10, "length": 1200 },
{ "name": "Rail", "pos": 1213, "length": 1200 }
],
"offcuts": [587]
}
],
"metrics": {
"rodCount": 3,
"yieldPct": 80,
"placed": 6,
"total": 6,
"cuts": 6,
"totalPrice": 37.5,
"toleranceAcceptedCount": 0
},
"unplaced": [],
"warnings": [],
"timing": { "solveMs": 2.19 },
"cutPlan": [
{ "sheet": 0, "step": 1, "axis": "v", "pos": 1210, "length": 0, "stage": 1 },
{ "sheet": 0, "step": 2, "axis": "v", "pos": 2413, "length": 0, "stage": 1 },
{ "sheet": 1, "step": 1, "axis": "v", "pos": 1210, "length": 0, "stage": 1 },
{ "sheet": 1, "step": 2, "axis": "v", "pos": 2413, "length": 0, "stage": 1 },
{ "sheet": 2, "step": 1, "axis": "v", "pos": 1210, "length": 0, "stage": 1 },
{ "sheet": 2, "step": 2, "axis": "v", "pos": 2413, "length": 0, "stage": 1 }
]
} rods remplace sheets et il n'y a pas de guillotineValid, car une coupe linéaire est toujours réalisable. Le pos de chaque pièce est le décalage de son extrémité proche par rapport à l'extrémité de barre rognée par trim.start : la première pièce commence donc exactement à trim.start, et chaque pos suivant ajoute la valeur de kerf. remaining est indiqué sur chaque barre, même lorsqu'il est inférieur à minOffcut — minOffcut ne filtre que le tableau offcuts, qui contient au plus une entrée. Dans le plan de découpe, sheet est l'index de la barre, axis vaut toujours "v", stage vaut toujours 1 et length vaut toujours 0 : un tronçonnage de barre n'a aucune distance de déplacement à indiquer, ce qui explique aussi pourquoi les metrics 1D portent cuts mais pas cutLength.
rods[]
| length | number | FULL bar length as supplied in stock. |
| price | number | null | Price of the stock row, or null. |
| remaining | number | Unused length left on this bar, 3 decimals. Reported even when below minOffcut. |
| parts | array | Placements, in cutting order along the bar. |
| offcuts | number[] | At most one entry: [remaining] when it is > 0 and ≥ minOffcut, else []. Absent when excluded via include. |
rods[].parts[]
| name | string | The requested name, or the generated default. |
| pos | number | Offset of the part’s NEAR end from the bar end that trim.start trims. |
| length | number | The part length, as requested. |
metrics (1D)
| rodCount | integer | Bars used. Equals rods.length. |
| yieldPct | number | Placed length ÷ total FULL bar length × 100, 2 decimals. Trim and kerf count as waste. |
| placed | integer | Pieces placed, after qty expansion. |
| total | integer | Pieces requested, after qty expansion. |
| cuts | integer | Total crosscuts across all used bars — one per placed piece. |
| totalPrice | number | Sum of used bar prices, 2 decimals. |
| toleranceAcceptedCount | integer | Pieces that fitted only because options.tolerance allowed an overshoot. |
unplaced[] (1D)
| name | string | The part name. |
| length | number | As requested. |
| qty | integer | How many could not be placed. |
Moteurs
- heuristic (par défaut) — le placeur guillotine multi-stratégies. Utilisation maximale, toute disposition sciable, toujours un cutPlan complet.
- balanced — un placeur MaxRects à imbrication libre. Beaucoup plus rapide sur les gros travaux (mesuré à ~25× pour 2 000 pièces) au prix d'une utilisation légèrement plus faible, et ses dispositions ne sont souvent pas guillotine (guillotineValid: false, cutPlan: null). Il ne modélise ni tolerance, ni minimizeCost, ni grainGroup, ni maxCutStages, ni minimizeRotations — si vous en définissez une, un avertissement vous indique qu'elle a été ignorée.
Déterminisme et versionnage
Chaque réponse porte engineVersion. L'algorithme est déterministe : l'améliorer change donc le résultat pour une même entrée — ce qui constitue une rupture de compatibilité si vous mettez en cache. Figez le comportement en envoyant engine explicitement et en surveillant engineVersion ; la version du chemin /v1/ ne change que si la structure de la réponse change.
Chaque moteur est versionné indépendamment : une modification de l'un ne fait jamais bouger la version de l'autre.
Erreurs
| 400 | invalid_request | Erreur de schéma. details.path indique le champ fautif. |
| 401 | unauthorized | Clé API absente ou inconnue. |
| 402 | quota_exceeded | Quota mensuel atteint. Retry-After donne le nombre de secondes avant le passage au mois suivant. |
| 403 | key_revoked | La clé existe mais a été révoquée. |
| 404 | not_found | Route inexistante — c'est aussi ce que vous obtenez pour le bon chemin avec la mauvaise méthode. |
| 413 | too_large | Entrée au-delà d'une limite (voir Limites). |
| 429 | busy | Capacité momentanément saturée. Retry-After en secondes — cela ne compte jamais dans votre quota. |
| 500 | internal | Erreur inattendue, ou backend d'authentification injoignable (les requêtes échouent en mode fermé). |
| 504 | solve_timeout | Le calcul a dépassé la limite stricte de temps réel (appliquée au niveau du proxy). |
Corps d'erreur
Toutes les erreurs produites par le moteur lui-même utilisent la même enveloppe. Branchez votre code sur error, qui est un code stable ; jamais sur message, dont la formulation peut changer d'une version à l'autre. details est présent sur invalid_request, où path nomme le champ fautif, et sur too_large, où max et got donnent le plafond et ce que vous avez envoyé.
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{
"error": "invalid_request",
"message": "parts[0].h: must be greater than 0",
"details": { "path": "parts[0].h" }
} HTTP/1.1 402 Payment Required
Retry-After: 41400
Content-Type: application/json; charset=utf-8
{ "error": "quota_exceeded", "message": "monthly quota exhausted" } - 402 et 429 portent tous deux un en-tête Retry-After en secondes. Sur 402, il décompte jusqu'à la réinitialisation du quota, à 00:00 UTC le 1er du mois suivant ; sur 429, il s'agit d'un court délai d'attente, et un 429 ne consomme jamais de quota — l'appel réservé est restitué.
- Un échec de routage répond not_found, un code délibérément absent de la liste ci-dessus parce qu'il est produit par le routeur et non par le contrat de l'API. Vous obtenez 404 et non 405 lorsque le chemin est bon mais la méthode mauvaise : les deux endpoints d'optimisation sont uniquement en POST.
- 504 provient du reverse proxy et non du moteur : son corps est donc celui du proxy et non cette enveloppe JSON. Dans les limites d'entrée ci-dessous, il devrait être inatteignable.
En-têtes de limite de débit
Un appel d'optimisation réussi porte X-RateLimit-Limit (le plafond mensuel de la clé) et X-RateLimit-Remaining (les appels restants ce mois-ci, celui-ci déduit). Ils ne sont envoyés que par les endpoints d'optimisation : le compteur est réservé au moment d'autoriser un calcul, /v1/usage et /v1/health n'ont donc rien à signaler.
| X-RateLimit-Limit | integer | The key’s monthly quota. |
| X-RateLimit-Remaining | integer | Calls left this month, after this one. |
| Retry-After | integer | Seconds to wait. Sent with 402 and 429 only. |
GET /v1/usage
{
"plan": "studio",
"used": 137,
"limit": 10000,
"remaining": 9863,
"periodEnd": "2026-08-01",
"keyPrefix": "co_live_ab12",
"contractVersion": "1"
} En lecture seule : cet appel ne consomme pas de requête et n'envoie aucun en-tête de limite de débit. plan et limit sont l'offre et le plafond figés sur la clé au moment de sa création — re-tarifer le produit plus tard ne réécrit donc jamais une clé active. used compte le mois calendaire UTC en cours, remaining vaut limit moins used et ne devient jamais négatif, periodEnd est le jour de réinitialisation sous forme de simple date YYYY-MM-DD, et keyPrefix est le préfixe d'affichage non secret de la clé avec laquelle vous avez appelé. La clé elle-même n'est jamais renvoyée par aucun endpoint — seul son hachage est stocké : une clé perdue se remplace, elle ne se récupère pas.
| plan | string | Tier slug frozen onto the key when it was created. |
| used | integer | Calls counted in the current UTC calendar month. |
| limit | integer | The monthly cap frozen onto the key. |
| remaining | integer | limit − used, never negative. |
| periodEnd | string | Reset day as YYYY-MM-DD — a date, not a timestamp. |
| keyPrefix | string | Non-secret display prefix of the calling key. |
| contractVersion | string | Shape version. Currently "1". |
GET /v1/health
{
"status": "healthy",
"service": "cutoptim-engine",
"contractVersion": "1",
"engineVersion": "1.0.0+10e0c941",
"engines": ["heuristic", "balanced"],
"uptimeSec": 16
} Sans clé, sans quota, sans base de données. Cet endpoint ne touche volontairement à rien de persistant : une panne du magasin de clés ne peut donc pas faire passer le service pour mort aux yeux d'un orchestrateur. engines liste les identifiants que ce déploiement accepte dans engine, et engineVersion est la version du moteur par défaut.
| status | string | Always "healthy" when the process answers. |
| service | string | Always "cutoptim-engine". |
| contractVersion | string | Shape version. Currently "1". |
| engineVersion | string | The DEFAULT engine’s version, not a per-engine list. |
| engines | string[] | Engine ids this deployment accepts in engine. |
| uptimeSec | integer | Whole seconds since process start. |
Limites
- 2,000 pièces par requête (quantité totale, après développement des qty)
- 50 lignes de stock · corps de requête jusqu'à 1 Mo
- 10 clés actives par compte
- la concurrence est bornée côté serveur — une rafale reçoit 429, jamais une file d'attente lente
Spécification OpenAPI
Un document OpenAPI 3.1 exploitable par une machine décrit les quatre endpoints, les deux corps de requête, chaque structure de réponse et chaque erreur. Pointez votre générateur de client dessus plutôt que de recopier cette page. Le document lui-même est uniquement en anglais : il est constitué de jetons du contrat, et OpenAPI ne dispose d'aucun mécanisme de localisation.
curl -s https://cutoptim.com/engine/openapi.json > cutoptim-engine.json