-
-
Notifications
You must be signed in to change notification settings - Fork 0
Equipment Integration
openWCS drives material-handling equipment through a uniform internal device contract (build.md §8), so the rest of the system is decoupled from any specific PLC/robot protocol.
process / API ─► flow-orchestrator ─(device task by family)─► adapter ─► equipment
◄─────── result ───────────┘
-
flow-orchestrator (port 8085) records a device task through the lifecycle
REQUESTED → DISPATCHED → COMPLETED/FAILEDin itsflowschema, and routes it to the right adapter by equipment family (CONVEYOR, ASRS, AMR, AUTOSTORE). -
POST /api/flow/device-tasksdispatches a task;GET /{id}andGET ?correlationId=read them. RBAC:DEVICE_OPERATEto dispatch,DEVICE_VIEWto read. - A failed adapter call records the task FAILED (never lost), surfaced as 502.
-
Transport: real adapters use synchronous HTTP — they reply
COMPLETED/FAILEDinline and ignorecallbackUrl. The emulator uses async callback (Phase 3b): flow sends acallbackUrlin every task body (built fromOPENWCS_FLOW_SELF_BASE_URL); the emulator acksACCEPTEDimmediately, runs the task in a background goroutine, and POSTs the terminal result toPOST /api/flow/device-tasks/{id}/result. When the device respondsACCEPTED/DISPATCHED, flow leaves the taskDISPATCHEDuntil the callback arrives.DeviceClientis the seam, so swapping transports doesn't touch the lifecycle logic. (Async Kafka —device.tasks/device.results— remains a future path for production adapter connectivity.)
| Adapter | Port | Transport | Status |
|---|---|---|---|
| conveyor | 9091 | Raw TCP / OPC-UA (PLC) |
POST /tasks real-hardware skeleton — FAILED "not connected" |
| asrs | 9096 | telegram (shuttle/crane) |
POST /tasks real-hardware skeleton — FAILED "not connected" |
| amr-geekplus | 9093 | Geek+ RCS (REST + WebSocket) |
POST /tasks real-hardware skeleton — FAILED "not connected" |
| autostore | 9094 | AutoStore grid (REST) |
POST /tasks real-hardware skeleton — FAILED "not connected" |
Adapters are stdlib-first Go services exposing /healthz + /readyz, implementing the device
contract. They are independent Go modules (built with go build, not part of the Gradle build).
flow-orchestrator wiring. Each adapter family must be reachable by flow-orchestrator via an env var — the key is the uppercase family name, the value is the adapter's base URL:
| Env var | Default (standalone) | Compose hostname |
|---|---|---|
OPENWCS_ADAPTER_CONVEYOR |
http://localhost:9091 |
http://conveyor-adapter:9091 |
OPENWCS_ADAPTER_ASRS |
http://localhost:9096 |
http://asrs-adapter:9096 |
OPENWCS_ADAPTER_AMR |
http://localhost:9093 |
http://amr-geekplus-adapter:9093 |
OPENWCS_ADAPTER_AUTOSTORE |
http://localhost:9094 |
http://autostore-adapter:9094 |
Missing an entry causes flow-orchestrator to return 422 "No adapter configured for family: X" when a
task is dispatched for that family. All four are set in platform/docker-compose.yml.
When the hardware-emulator flag is ON, flow-orchestrator bypasses all four adapters and routes
every family to the single equipment-emulator service instead (see
Hardware emulator mode).
A global flag HARDWARE_EMULATOR_ENABLED (OFF by default) is owned by master-data
(GET/POST /api/master-data/emulator, ADMIN-only writes; see Services). An admin flips the
mode from Settings → Hardware emulator in the UI.
Routing (Phase 2 — current architecture). When the flag is ON, flow-orchestrator routes every
device task to the single equipment-emulator service (Go, port 9097) instead of to the
per-family adapters. The emulator handles all four families (ASRS, CONVEYOR, AMR, AUTOSTORE) behind
the same POST /tasks contract, using the family field that flow now includes in every request
body. Flow reads the flag via HttpEmulatorModeClient, which caches the master-data value for 5 s
(lazy TTL refresh) and defaults to OFF on any error. Two new env vars are required on
flow-orchestrator:
| Env var | Default | Purpose |
|---|---|---|
OPENWCS_EMULATOR_URL |
http://localhost:9097 |
equipment-emulator URL (used when flag is ON) |
OPENWCS_MASTER_DATA_BASE_URL |
http://localhost:8081 |
Master-data URL for flag reads |
OPENWCS_FLOW_SELF_BASE_URL |
http://localhost:8085 |
flow-orchestrator's own base URL, sent to the emulator as the async device-result callback target (/api/flow/device-tasks/{id}/result) |
equipment-emulator service (Go, stdlib only, port 9097):
-
POST /tasks— accepts an optionalcallbackUrlfield. When set (flow builds it fromOPENWCS_FLOW_SELF_BASE_URL): acksACCEPTEDimmediately and runs the task asynchronously in a goroutine — sleeps the per-command duration, optionally injects a fault, then POSTs the terminal result back tocallbackUrl(best-effort; a failed callback is logged and not retried; flow keeps the taskDISPATCHED, which is the honest state). When absent: runs synchronously and returns the terminal result inline. In both paths: validatesfamily+command, sleeps a realistic per-family/command duration, then returns/postsCOMPLETEDwithsimulated:trueanddurationMs(the simulated processing time in milliseconds), orFAILEDfor an unknown family or command. Default durations: ASRS STORE/RETRIEVE ≈ 900 ms / RELOCATE ≈ 3 000 ms (full shuttle move — pick blocker, travel to target channel, drop), CONVEYOR CONVEY ≈ 600 ms / DIVERT/MERGE ≈ 400 ms, AMR TRANSPORT ≈ 1 200 ms / MOVE ≈ 600 ms, AUTOSTORE BIN_STORE/BIN_RETRIEVE ≈ 800 ms / BIN_RELOCATE ≈ 3 000 ms, SCAN 150 ms (all families), unknown commands 300 ms.OPENWCS_EMULATOR_LATENCY_MSoverrides every command with a fixed value (0= instant; useful in tests and CI). Fault injection (OPENWCS_EMULATOR_FAULT_RATE=N): when N > 0, every Nth task fails deterministically — the result carries"fault": trueand the per-familyfailedtally increments (0 or unset = no faults; also fails live-walk CONVEYs before the first scan). Conveyor speed (OPENWCS_EMULATOR_SPEED_MPS, default0.5m/s per ADR-0008): sets the live-walk travel speed; live-tunable via/config speedMps(e.g.curl -XPOST .../config -d '{"speedMps":5}'for faster demos). Families and their accepted commands: ASRS (STORE/RETRIEVE/RELOCATE/SCAN), CONVEYOR (CONVEY/DIVERT/MERGE/SCAN), AMR (TRANSPORT/MOVE/SCAN), AUTOSTORE (BIN_STORE/BIN_RETRIEVE/BIN_RELOCATE/SCAN). RELOCATE/BIN_RELOCATE (ADR-0009 dig-out): flow sends the exact blocker move (from → to slot); the emulator sleeps one shuttle-move latency and reports back — no internal decision-making. Loop recirculation (OPENWCS_EMULATOR_RECIRC_EVERY=N, ADR-0007 Phase 3c-2): when N > 0, every Nth atomic CONVEY task (noentryNode) recirculates the conveyor loop once before diverting to its destination (deterministic counter, 0/unset = none), adding one loop's travel time so arrival order visibly diverges from dispatch order (requirement R2). The CONVEY result payload reportsrecirculations(count of extra loop passes) anddecisions— an ordered list of sorter decision points (RECIRCULATED/DIVERTEDevents) that flow writes to the HU transport trace (requirement R4).recircEverydoes not apply to live-walk CONVEYs (see below — recirculation there emerges from flow's own HOLD decisions). ASRS shuttle/lift/handover serialisation (OPENWCS_EMULATOR_ASRS_HANDOVER_MS, default1800ms): a real ASRS (and AutoStore grid) moves ONE tote at a time through its shuttle → lift → handover point. Consecutive tote-moving commands (ASRS: STORE/RETRIEVE/RELOCATE; AutoStore: BIN_STORE/BIN_RETRIEVE/BIN_RELOCATE) on the same device are therefore serialised and spaced byasrsHandoverMs— totes leave storage staggered rather than two retrievals completing at once and entering the conveyor at the same spot (which rendered as a single tote on the digital twin). SCAN is instant and excluded; different devices run in parallel (each has its own shuttle/lift). Live-tunable via/config asrsHandoverMs. Live conveyor walk (ADR-0008 Phase 3d-2): a CONVEY whose payload carries a non-emptyentryNode(pluswarehouseIdand the asynccallbackUrl) is executed as real hardware would — the emulator decides nothing itself. Steps: (1) fetch the conveyor graph once (GET {flow}/api/flow/conveyor/topology?warehouseId=); (2) POST a scan at the current node (POST {flow}/api/flow/conveyor/routing-requests {warehouseId, node, barcode}); (3) obey flow'sRoutingDecision:ROUTE→ travel the next edge atspeedMps(edge.costmetres ÷ speed);HOLD→ dwell ~1 s and rescan the same node;COMPLETE→ arrival callback;NO_ROUTE→ retry the same node up to 4 × 500 ms (absorbs the dispatch-transaction race where the route plan commits milliseconds after the task is acked; each retry appends aRESCANentry to thedecisionstrail) before the walk fails;EXCEPTION→ task FAILED immediately. The flow base URL is derived from the task's owncallbackUrl(scheme+host). Result payload addswalked: true,scans,recirculations(= HOLD count), anddecisions(per-nodeROUTED/HELD/ARRIVED/RESCANentries) in the same shape flow's trace writer already parses. Safety rails: max 500 scans per walk, 5 s timeout per HTTP call, one retry then FAIL.latencyOverrideMs >= 0overrides both edge travel and HOLD dwell in ms (0= instantaneous for tests/demos). CONVEYs withoutentryNodekeep the existing atomic behaviour byte-for-byte. -
GET/POST /config— reads or updates the live{latencyOverrideMs, faultEvery, recircEvery, asrsHandoverMs, speedMps}config without a restart. All fields are optional onPOST; omitted fields are left unchanged. Examples:curl -XPOST .../config -d '{"recircEvery":3}',curl -XPOST .../config -d '{"asrsHandoverMs":0}'(disable spacing for instant tests),curl -XPOST .../config -d '{"speedMps":5}'. Seeded from env at startup. -
GET /state— real per-familycompleted/failedtallies derived from actual task load, plus a liveness heartbeat (ticks, uptimeSeconds, lastHeartbeat). Thedevicesblock also includes awalkscounter (completed live conveyor walks). The response echoes the live config (latencyOverrideMs,faultEvery,recircEvery,asrsHandoverMs,speedMps). Shown on the System info screen. -
GET /healthz,GET /readyz,GET /— health probes and service info (families, version, commit, buildTime). - No flag gate inside the emulator — flow only sends it traffic while the flag is ON. No DB, Kafka, or hardware connection.
Emulator ON: every device task (all families) dispatches to the equipment-emulator; returns
COMPLETED with simulated:true. Ideal for evaluation, onboarding, and CI: the whole automation
flow runs with zero equipment.
Emulator OFF: flow routes by family to the real per-family adapters. POST /tasks on those
adapters currently returns FAILED ("hardware not connected") because no real protocol client
exists yet — the documented seam for real TCP/OPC-UA/REST drivers.
Per-family adapters (Phase 2b — complete). Per-adapter emulation code (emumode.go,
state.go) has been removed from all four adapters. Each adapter validates the incoming command
and returns FAILED "hardware not connected" — the seam for a future real protocol client. GET /state and flag polling (WCS_MASTER_DATA_URL) have been removed; adapters expose only
/healthz, /readyz, and GET / (service info, without emulator fields). When the flag is ON,
flow-orchestrator routes all device tasks to equipment-emulator; the per-family adapters receive
no traffic.
Not yet wired: a BPMN process-engine that originates device tasks for goods-in / outbound; today tasks are driven directly via the API.
Conveyors from different vendors speak different protocols, but they share one pattern: at
nodes (scan/decision points) the hardware scans a handling unit and asks the WCS where
next? The Go adapter normalizes each vendor's protocol into one contract; flow-orchestrator
decides the routing. (/api/flow/conveyor.)
-
Topology is a directed graph: nodes (each with a
codethe hardware sends, ahardwareAddressto reach the equipment, andposX/posYfor the schematic editor) and edges (segments labelled with theexitCodethe hardware applies to traverse them, plus a routingcost). Loaded/saved as a whole graph —GET/PUT /conveyor/topology— which backs the admin schematic editor. -
Route plan: a handling unit carries an ordered list of target node codes (
POST /conveyor/routes {barcode, targets:[…]}). -
On a scan (
POST /conveyor/routing-requests {node, barcode}): the WCS looks up the next hop in a per-warehouse in-memory routing snapshot with precomputed routes — no per-scan pathfinding, no per-scan graph load. Routing is never pushed down to the hardware: the PLC asks at every scan point and the WCS answers from the snapshot in low single-digit milliseconds, fast enough for PLC-grade response budgets. A topology change or a new route-plan assignment rebuilds the snapshot and takes effect at the tote's very next scan. The WCS replies{action: ROUTE, exitCode, toNode}. As each target is reached the plan advances; the final target →COMPLETE. Unknown node →EXCEPTION. Divert defaults (V14): routing is asked fresh at every divert, so it adapts to the physical situation. When an HU has no route plan (or its plan has no path from the scanned node), the WCS follows the node's topology-configured default direction (STRAIGHT / BRANCH, set in the function-point dialog) when one is present; on a plain conveyor segment with a single out-edge it continues along that edge (a plain segment never strands a tote); at a multi-exit divert with no default it HOLDs — the tote stops and is re-evaluated on the next scan, and a plan assigned mid-journey takes over immediately. A planned HU always wins over the default; the default fires only when no plan can steer the HU. A planned HU whose path does not exist from the current node follows the default (and the plan stays ACTIVE, re-evaluated each scan) instead of failing. Scanner read errors: a blank, missing, orNOREADbarcode is treated like an unknown HU — the same divert-default / single-exit / HOLD logic applies;NO_ROUTEis returned only when the node is a dead end. Read-error scans are never traced (no HU identity). Scan trace (ADR-0008 §3d-1): eachdecide()call also appends aSCANNEDrow tohu_transport_tracewhen the barcode belongs to an active induction entry —point= scanned node,decision= routing outcome,toPoint= next hop. Unknown barcodes are answered but never traced; a trace failure is isolated and never breaks the routing answer. Routing latency report: the decision loop measures itself. A live p50 / p95 / p99 latency report is available in the product; any single decision that exceeds budget is logged with its per-step breakdown so the claim can be verified on real hardware. -
Loop capacity: a node can belong to a named loop with a max handling-unit count. When a scan would route an HU into a loop that is at capacity, the WCS either HOLDs it (wait upstream, re-evaluated next scan) or diverts it to the loop's OVERFLOW target — configurable per loop. Occupancy = active route plans whose last-scanned node is in the loop.
-
Routing graph table inspector (
RoutingGraphTables): the Routing graph tab in the Automation Topology screen is a read-mostly table inspector. The node/edge graph is a projection generated wholesale by Generate routing — hand-editing those rows is pointless, so the nodes and edges tables are read-only. The tab includes:- Summary header: node / edge / loop / controller counts; Reload and Discover buttons; a note pointing operators to Generate routing for structural changes.
- Nodes table (read-only): code, name, position, loop, default exit (the projected default neighbour for divert nodes — see Divert defaults above), controller/address; computed in/out-degree; a dead-end badge on nodes with out-degree 0.
- Edges table (read-only): from → to, exit code, cost; filterable by node code matching either end.
-
Loops table (editable): operators can configure
maxHus,whenFull, andoverflowTargetinline — these are policy settings, not projection output. - Controllers table (editable): PLCs; full add/edit/remove affordances.
-
Save re-sends the full topology (nodes + edges unchanged, updated loops + controllers) via
PUT /api/flow/conveyor/topology— all-or-nothing semantics.
-
Topology learning: a sniffer posts observed scans (
POST /conveyor/observations {node, barcode, sourceIp}); the WCS infers a candidate topology — nodes seen, segments (consecutive scans of the same HU), and likely targets (terminal nodes) — flagged against the configured graph (GET /conveyor/discovery). The Discover button appends observed-but-unconfigured nodes/edges (badged "discovered") to the tables for an admin to review and save.
The capture front-end is the conveyor-sniffer adapter (Go, port 9095): it ingests scan telegrams from the defined source IPs (an allowlist + a pluggable per-vendor decoder) and posts them as observations. Today it ingests a controller telegram stream over TCP; a passive libpcap mirror-port tap is a drop-in source later (same Decoder/Forwarder seam) — the same adapter pattern as the device drivers. Daily logs: stream connect/close/break with per-session tallies (forwarded / undecodable / forward-failures); each forwarded scan (barcode, node, controller endpoint); undecodable telegrams at WARNING with the raw line; forward failures at WARNING noting the lost observation for topology learning.
The Automation Topology placement API (/api/flow/automation/topology) is the physical layout
model behind the built-in 3D layout editor (tabbed with the conveyor routing graph under the
Automation Topology screen). It describes a warehouse as four linked
entity types, all scoped per warehouseId:
| Entity | Tables | What it holds |
|---|---|---|
| WarehouseLevel | flow.warehouse_level |
Floor number, optional name, elevation in metres |
| PlacedEquipment | flow.placed_equipment |
A master-data equipment item (or a GTP workstation) placed on a level — x/y/z position (metres), yaw rotation + tilt (degrees), physical envelope length × width × height. Conveyor-family equipment may also carry a path (jsonb [[x,z],…] centreline waypoints in metres) and a closed boolean (loop back from last to first). The category column (conveyor / asrs / sorter / manual-storage / workstation / other) is denormalised on save so the routing projection can classify equipment without an equipment-library round-trip. A workstation placement carries a station_id (uuid, nullable) — a soft reference to the GTP station (gtp_station) it represents; no FK because gtp is a separate service. All other equipment categories leave station_id null. |
| EquipmentConnection | flow.equipment_connection |
Link between two placed pieces, optionally anchored at source/target function points (fromPointId/toPointId) and/or at exact path-point (node) indices (fromPathIndex/toPathIndex, V18): when set, the routing projection stitches exactly those two nodes in both directions at any distance — a connection joins the two systems; the direction of travel is governed by each conveyor's own section edges, not by the connection; legacy connections without path indices fall back to exit/entry resolution |
| EquipmentFunctionPoint | flow.equipment_function_point |
A process point on a placed piece: functionType is a comma-separated set of one or more function names (e.g. "SCAN,DIVERT_LEFT") — a single point can combine functions; the backend stores the string as-is. Types: SCAN / DIVERT_LEFT/RIGHT / INDUCT / DISCHARGE / INFEED / DWS / …; offset along the equipment, optional side, optional nodeCode (ties the point to a conveyor routing node), and — for divert types — an optional defaultExit (STRAIGHT = continue the main line / BRANCH = take the divert's branch / null = stop at the divert until a route arrives) |
API: GET /api/flow/automation/topology?warehouseId= loads the full graph; PUT replaces it.
Save accepts client-assigned temp-ids and remaps all internal cross-references to the persisted ids
in the returned graph, so the client can reference levels/equipment/function-points within a single
payload without a round-trip. RBAC: DEVICE_VIEW on read, DEVICE_OPERATE on write.
The nodeCode on a function point links the placement model to the conveyor routing graph — a
discharge point on a placed piece of equipment can correspond directly to a conveyor routing node,
so both models stay in sync as topology is updated.
Routing-graph projection (POST /api/flow/automation/topology/project?warehouseId): once the
placement model is complete, a single API call (or the Generate routing button in the 3D editor)
runs RoutingProjectionService and writes a full-replace conveyor routing graph from it. Projection
rules:
- One ConveyorNode per path point used by a section (box conveyors → 2-node pair; racks/sorters → single node).
- One directed ConveyorEdge per section — cost = section length in metres;
exitCode= target node. -
Connections are auto-inferred from geometry: when a node of one piece of equipment sits within 1.5 m of a node of another, the projection creates a bidirectional inter-equipment edge automatically (only the closest node pair per equipment pair is linked). Explicit node-level connections (carrying
fromPathIndex/toPathIndex) stitch exactly the chosen path-point nodes in both directions at any distance — a connection links the two systems; actual flow direction is governed by each conveyor's own section edges (a one-way stub stays one-way because its internal section edge points only inward); without path indices the legacy exit/entry fallback applies. A single edge-dedup set spans section edges, explicit connections, and auto-inference — an explicit link that duplicates an auto-inferred edge produces exactly one edge, never two. Explicit role-interaction connections (e.g. GTP workstation function-point links) still produce edges as before. - A function point placed on a waypoint registers its
nodeCodeas an alias for that node. A function point placed mid-section (between waypoints) splits the section into two edges and registers itsnodeCodefor the new split node — so named PLC scan/divert points can sit anywhere along a run, not only at section endpoints. - A closed conveyor (
closed = true) has its last waypoint linked back to the first, and the resulting cycle is registered as a capacity loop (the same loop model as hand-authored loops). - A divert function point whose
defaultExitis set toSTRAIGHTorBRANCHhas its choice resolved geometrically during projection: the straight out-edge is the one best aligned with the travel direction at the divert's offset; the branch is the least aligned (the perpendicular stub). The resolved neighbour node code is stored asdefaultExitCodeon the projected divert node and is visible in the Routing graph nodes table.
The 3D editor's Generate routing button saves the topology first, runs the projection, then reports the resulting node and edge counts. After projection the Routing graph tab reflects the generated graph and no longer needs to be hand-maintained.
GTP node sync (side-effect of projection): as part of every projection, the flow-orchestrator
also calls POST /api/gtp/stations/{id}/nodes/sync for each placed GTP workstation whose
station_id is set. The workstation's STOCK and ORDER conveyor interactions (roles assigned in the
Properties panel) become the station's nodes; the function-point offsetM is carried as
inboundDistanceM on each node, which the station inbound queue uses to time emulated tote
arrivals. This call is best-effort — a GTP failure never aborts the routing projection. See
Goods-to-Person Stations.
Editor views — 3D and 2D plan toggle: The automation topology screen now has a 3D / 2D toggle in the toolbar. Both views operate on the same shared state (placed equipment, conveyor sections, selection) — any edit made in one view is immediately reflected in the other.
3D editor UX: The 3D view loads inside AutomationTopology3D (react-three-fiber + drei). Camera
navigation uses OrbitControls with explicit mouse-button mapping (left-drag = orbit, right-drag = pan,
scroll = zoom), min/max distance limits, and a max polar angle that prevents going below the floor.
An on-screen hint overlay ("Drag to orbit · right-drag to pan · scroll to zoom") makes the controls
discoverable without any tutorial. A ▴/▾ fold toggle in the toolbar collapses the page title and
level-name/elevation meta row, giving the canvas 90 vh of height for more drawing room; the choice
is persisted to localStorage (topoChromeCollapsed) so the editor reopens the way the user left
it. Physical equipment connections are no longer drawn as lines or arrows — they are auto-inferred from geometry by the routing projection: any two equipment nodes within 1.5 m of each other are automatically linked in the routing graph. The manual Connect button has been removed. Live link indicators (nodeLinks.ts, a pure client mirror of the projection's adjacency rules) show where equipment meets: a green ring means the closest node pair will be linked when routing is generated (either auto by proximity within 1.5 m, or via an explicit connection — labelled accordingly); an amber dashed ring with the gap in metres means the pair is within 3 m but too far for auto-inference. Indicators follow drags in both the 3D scene and the 2D plan. A selected equipment's Properties panel gains a Connections section: for each of its endpoint nodes it shows the linked counterpart (code, distance, and whether the link is auto by proximity or explicit), or "not linked" with the nearest candidate. From there an operator can add an explicit node-to-node link from a closest-first candidate list (any distance), or Unlink an existing one (with honest feedback if proximity still auto-links the pair). When working in the 2D plan, clicking a single routable waypoint scopes the Connections section to that node's options only — an inline show all nodes link restores the full list. Clicking a mid-path corner that is not a routable node leaves the full list unchanged. The Properties panel also displays a Connections (N) list of every explicit connection on the selected equipment; clicking a row opens a connection detail/edit dialog (the inline Delete button removes the link without opening it). The dialog shows both endpoints un-truncated — full equipment code plus the exact anchored node (CODE#index) — the link type (hand-drawn node link vs. explicit link with auto-inferred endpoints), the gap in metres between the resolved nodes, and the status. The from/to path point (Auto fallback or any routable node on that end), label, and status are editable; edits mutate the in-memory connection and round-trip through the editor's Save.
Route test mode: The Test route toolbar button (3D view only) lets you verify that a tote can actually travel between any two points on the projected routing graph — without running a live transport. Click Test route to enter the mode (it exits connect / draw mode and forces the 3D view). The routing graph is loaded once and cached until the next reload or Generate routing call.
- Picking: the first left-click in the 3D scene snaps to the nearest routing node (by world XZ) and places a green start marker; the next click places a blue target marker. A third click replaces the start and clears the target so you can test a new pair without leaving the mode. Press Esc or click Exit test / 2D plan to leave.
-
Path found: a glowing lime polyline connects start → target through all intermediate hops, with a small sphere at each hop node. An animated ghost tote travels the polyline at 0.5 m/s on a continuous loop so the route is legible at a glance. The hint bar shows a green chip:
Path: N hop(s) · X.X m · ≈Ys @ 0.5 m/s. -
No path: the hint bar shows a red chip (
No path from A to B) and every routing node reachable from the start is lit dimly — showing exactly where connectivity ends and which nodes are isolated from the target. - The mode is mutually exclusive with connect and draw modes; entering any editing mode automatically exits route testing.
2D plan editor (PlanEditor2D): An SVG-based top-down view of the active level — easier for
laying out equipment and drawing conveyor runs precisely. Key capabilities:
- Grid + snap: selectable step (0.25 / 0.5 / 1 m) and a snap toggle. Snapping is applied to equipment moves and to conveyor section draw clicks so placements stay aligned.
- Pan / zoom: scroll to zoom; drag background to pan. Coordinate system matches the 3D world (X right, Z down, rotationDeg = yaw about Y).
- Place / drag / rotate equipment: selected equipment can be dragged to any grid-snapped position; a quick-rotate button applies 90° yaw steps.
-
Draw conveyor sections: while "Draw sections" mode is active, each grid-snapped click calls
the same
drawSectionAtcallback the 3D editor uses — building the samesectionsarray and path waypoints in the data model. A live hover preview tracks the cursor: a dot marks exactly where the next click will land — lime when the point snaps onto the selected conveyor's centreline (the section will connect), amber for a free grid point. When an anchor exists, a dashed line from the anchor to the cursor shows the pending section and its start → end direction. The snap calculation (drawSnapAt) is shared between the preview and the actual click, so the preview is always accurate. Exit draw mode via the "Draw sections" button, by double-clicking any waypoint, or by pressing Esc. -
Direction chevrons and decision points: each section is rendered with small, semi-transparent
per-metre chevrons (≈ 1 chevron per metre, capped at 30) pointing in the travel direction; waypoints
that are the
fromof two or more sections are rendered in red as automatic decision / divert points. - Draggable waypoints: path control points can be dragged independently to adjust conveyor geometry without re-drawing the whole run. Click a waypoint to select it — an orange ring and a ✕ affordance appear; press Delete / Backspace or click the ✕ to remove it. Deleting drops the point and every section that touched it, re-indexing the survivors (if fewer than two points remain, the path and sections are cleared entirely). The move is gated behind a small pixel threshold so a plain click only selects without nudging the point. Double-click a waypoint to toggle draw-sections mode anchored at that point — a fast shortcut that replaces the "Draw sections" button + re-anchor click without leaving the canvas. Double-clicking again (or pressing Esc) exits draw mode. The waypoint selection clears on equipment deselect, draw-sections toggle, or canvas click.
-
Clickable explicit link lines (2D): green lines between two nodes joined by an explicit
connection are interactive — clicking the line or its midpoint ring selects the link
(the line brightens and thickens; a red ✕ button appears at the midpoint). While a link is
selected pressing Delete / Backspace removes the explicit connection, as does clicking the
✕ directly. Escape or clicking empty canvas space deselects without removing. Auto proximity
links (no explicit connection record) are non-interactive and cannot be removed from the canvas.
The hint bar shows
Link selected · press Delete/Backspace (or click the ✕) to remove the explicit connection · click empty space to deselectwhile a link is selected. -
Function-point palette: the palette bar appears when the level has at least one conveyor or
ASRS block. Buttons: SCAN, DIV (divert — see below), DWS, QUERY_POINT, WRAPPER,
LABEL_APPLICATOR, INDUCT, DISCHARGE, and INFEED. Each button is enabled only when its drop
target exists — INDUCT/DISCHARGE need an ASRS block; every other type needs a conveyor.
INDUCT and DISCHARGE carry an
ASRSbadge to make their ASRS-only constraint immediately visible. Click a button to create a pending point. The marker follows the cursor with a live snap preview so you can see exactly where the click will land before committing. To place it: click anywhere on the canvas — INDUCT/DISCHARGE snap to the nearest ASRS block edge and drop a 1 m IN or OUT stub; all other types snap to the nearest conveyor centreline at any position along a section (not only at an existing node). Drag the staging marker and release for the same result. Esc cancels. While an ASRS port is pending and the cursor is not over an ASRS edge, an inline hint — "IN/OUT snap to ASRS edges · use FEED to join a conveyor" — floats next to the marker as a placement reminder. -
Combined functions per point: after placing a point, the functions panel shows a row of
per-function chips (one per available type). Each chip can be toggled on or off — a point
can carry any combination of functions (e.g. SCAN + DIVERT_LEFT on one physical point).
At least one function must remain active. The
functionTypefield stores the active set as a comma-separated string (e.g."SCAN,DIVERT_LEFT"). The marker colour and label reflect the combined set: a divert function takes precedence (red), then the first port colour, else the first function's colour; the label shows all active short names joined by·(e.g."SCAN · ◀ DIV"). -
Function-point markers (2D): dropped points render as small colour-coded shapes nudged to the
LEFT or RIGHT side of the conveyor centreline (for divert types) with a short stem back to the
centreline. DIVERT_LEFT and DIVERT_RIGHT render in red; INDUCT, DISCHARGE and INFEED (port /
merge types) render as diamonds rotated 45°; all others use the per-type colour from
functionColor(). The same diamond shape is used for the corresponding 3D octahedron marker. Clicking a marker selects the owning equipment. A placed marker can be dragged along the centreline — the point moves live (updating itsoffsetM) and rebinds to a different conveyor when the cursor crosses onto one. -
Divert default direction: the function-point dialog (opened by clicking a placed divert
marker) shows a Default direction select: None (stop) — the tote waits at the divert
until a route plan arrives; Straight (main line) — the tote continues along the main conveyor
run; Branch (divert) — the tote takes the perpendicular stub. The choice is saved as
defaultExiton the function point and projected todefaultExitCodeon the routing node when Generate routing is run next. -
Divert direction picker: dropping the DIV chip on a conveyor opens a small anchored
popover instead of placing a point immediately. The popover shows three checkboxes — ◀ Left,
▲ Straight, Right ▶ — with the cursor's approach side pre-ticked alongside Straight. At
least 2 directions must be checked (OK is disabled otherwise). Straight may be unchecked
only at the physical end of the line (no outgoing section from that vertex); unchecking it
mid-line shows an inline validation error. Pressing OK (or Esc / ✕ / Cancel to discard)
materialises one
DIVERT_LEFTorDIVERT_RIGHTfunction point and a 1 m perpendicular stub per checked side. Straight itself creates nothing — the main line continues unmodified. Each divert function point inserts a new junction (splitting [a, b] into [a, m] and [m, b]) unless the drop projects within 0.12 m of an existing endpoint, in which case that point is reused. When the resulting 1 m stub's free endpoint falls within 0.75 m of another conveyor's centreline, the editor snaps it exactly onto that line — the divert meets the existing conveyor at one shared coordinate and the routing graph connects the two automatically. Deleting a function point whose type includes a divert removes the correct branch even when both a left and right stub share the same junction (thesidefield disambiguates). -
INFEED (conveyor feeder merge): dropping an INFEED on a conveyor works exactly like a divert
but with the directed section reversed — the stub section runs into the junction rather than
out of it (
branch → junctioninstead ofjunction → branch). Extend the stub back to its source and link. The INFEED marker renders as a teal diamond (⇥ FEED). -
ASRS IN/OUT port stubs: INDUCT and DISCHARGE function points snap to an ASRS block's
footprint edge rather than a conveyor centreline. Dropping either creates a 1 m conveyor stub
owned by the ASRS (stored in the ASRS's own
path/sectionsfields): INDUCT flows toward the rack (stub → port); DISCHARGE flows away (port → stub). The stub renders as a conveyor line coming out of the ASRS body in both 2D and 3D, is extendable with the normal Draw sections / waypoint-drag tools; physical connections to neighbouring conveyors are auto-inferred from geometry. ASRS blocks start with no ports; they are added from the function-point palette one at a time. - The library panel and properties panel are shared between the 3D and 2D views.
GTP workplaces in the equipment library: The library panel includes a "GTP workplaces" section
(visible when the warehouse has at least one GTP station) that lists every gtp_station by code and
name. Clicking + Add drops a workstation box (1.2 × 1.2 × 1.0 m, green rounded box in 3D /
green box in 2D) linked to that station via station_id. Each entry shows a placed N / not placed
badge so admins can see at a glance which stations are already on the canvas. The stationId binding
can be changed after placement via the GTP workplace Select in the properties panel. Workstations
are movable like any other equipment; physical links to conveyors are auto-inferred from geometry (the manual Connect tool has been removed). They are not
conveyors, so path/section tools are not available for them. Selecting a placed workstation also reveals
a Conveyor interactions panel in the properties sidebar — add one or more point-level connections to
specific conveyor function points, each tagged with a role:
- STOCK — the stock-feed conveyor (e.g. from an ASRS) that delivers stock HUs to this station
- ORDER — the order conveyor/destination that carries away completed order HUs
- DECANT — a decant point (for DECANTING-mode stations)
A pick-place station typically has two interactions (STOCK + ORDER); a decant station one or none.
Each interaction records a connection from the workstation placement to the function point
(fromPlacedId / toPointId / label = role) using the standard AutomationConnection model.
Interactions can be removed individually with the Remove button.
Picking the conveyor point: the function-point selector next to each pending interaction row offers two ways to choose the target:
- Dropdown — opens an inline list of every conveyor function point in the topology; the full label of the chosen point is echoed below the trigger (the trigger itself may truncate long labels).
- Pick in 3D button — arms a scene mode where every conveyor function-point marker brightens and enlarges slightly as a click-me cue. Clicking a marker delivers its id to the row and disarms the mode instantly. Clicking empty ground, pressing Esc, or switching to another editor mode (Connect / Draw / Route test) cancels the pick without changing the current value. The "Pick in 3D" button is disabled when no conveyor function points exist; while armed it becomes "Picking… (Esc)". The editor switches to the 3D view automatically when pick mode is armed, because the pickable markers only live in the 3D scene.
Conveyor polylines: Equipment whose library family is CONVEYOR can have a centreline path
instead of a single straight box. When a conveyor has a usable path (≥ 2 waypoints), the 3D editor
renders it as a chain of oriented box segments connecting the waypoints; the closing segment is added
automatically when closed = true (for recirculating sorter loops). While the conveyor is selected:
- Draggable sphere handles appear at each waypoint and track pointer movement on a horizontal plane — OrbitControls are disabled for the duration of the drag so the camera stays put.
- The properties panel shows a Conveyor path section with: Draw path (toggles a mode where each left-click on the floor appends a new waypoint), Start path from box (seeds a two-point path from the current box position + rotation + length), Closed loop checkbox, Remove last, and Clear. The hint overlay switches to "Draw mode: click the floor to add conveyor waypoints" while draw mode is active.
- The Length (m) field is hidden when a path is active (length is implicit from the geometry).
- Non-conveyor equipment continues to render as a single box with a drag-to-move gizmo.
Related: Services · Architecture · Outbound Flow · Roadmap and Status.
openWCS — open-source Warehouse Control System · summarized from build.md & docs/AS-BUILT.md (the repo docs are authoritative).
Design
Flows
- Inbound and Inventory
- Slotting and Replenishment
- Goods-to-Person Stations
- Outbound Flow
- Equipment Integration
- Transport Overview
- Hardware Visualisation
- Host Integration
Reporting & Dashboards
Operations