Skip to content

Equipment Integration

openwcs-docs-agent edited this page Jun 13, 2026 · 51 revisions

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 ───────────┘

Uniform device contract

  • flow-orchestrator (port 8085) records a device task through the lifecycle REQUESTED → DISPATCHED → COMPLETED/FAILED in its flow schema, and routes it to the right adapter by equipment family (CONVEYOR, ASRS, AMR, AUTOSTORE).
  • POST /api/flow/device-tasks dispatches a task; GET /{id} and GET ?correlationId= read them. RBAC: DEVICE_OPERATE to dispatch, DEVICE_VIEW to read.
  • A failed adapter call records the task FAILED (never lost), surfaced as 502.
  • Transport: real adapters use synchronous HTTP — they reply COMPLETED/FAILED inline and ignore callbackUrl. The emulator uses async callback (Phase 3b): flow sends a callbackUrl in every task body (built from OPENWCS_FLOW_SELF_BASE_URL); the emulator acks ACCEPTED immediately, runs the task in a background goroutine, and POSTs the terminal result to POST /api/flow/device-tasks/{id}/result. When the device responds ACCEPTED/DISPATCHED, flow leaves the task DISPATCHED until the callback arrives. DeviceClient is 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.)

Adapters (Go)

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).

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 optional callbackUrl field. When set (flow builds it from OPENWCS_FLOW_SELF_BASE_URL): acks ACCEPTED immediately and runs the task asynchronously in a goroutine — sleeps the per-command duration, optionally injects a fault, then POSTs the terminal result back to callbackUrl (best-effort; a failed callback is logged and not retried; flow keeps the task DISPATCHED, which is the honest state). When absent: runs synchronously and returns the terminal result inline. In both paths: validates family + command, sleeps a realistic per-family/command duration, then returns/posts COMPLETED with simulated:true and durationMs (the simulated processing time in milliseconds), or FAILED for 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_MS overrides 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": true and the per-family failed tally increments (0 or unset = no faults; also fails live-walk CONVEYs before the first scan). Conveyor speed (OPENWCS_EMULATOR_SPEED_MPS, default 0.5 m/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 (no entryNode) 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 reports recirculations (count of extra loop passes) and decisions — an ordered list of sorter decision points (RECIRCULATED/DIVERTED events) that flow writes to the HU transport trace (requirement R4). recircEvery does 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, default 1800 ms): 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 by asrsHandoverMs — 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-empty entryNode (plus warehouseId and the async callbackUrl) 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's RoutingDecision: ROUTE → travel the next edge at speedMps (edge.cost metres ÷ 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 a RESCAN entry to the decisions trail) before the walk fails; EXCEPTION → task FAILED immediately. The flow base URL is derived from the task's own callbackUrl (scheme+host). Result payload adds walked: true, scans, recirculations (= HOLD count), and decisions (per-node ROUTED/HELD/ARRIVED/RESCAN entries) 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 >= 0 overrides both edge travel and HOLD dwell in ms (0 = instantaneous for tests/demos). CONVEYs without entryNode keep 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 on POST; 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-family completed/failed tallies derived from actual task load, plus a liveness heartbeat (ticks, uptimeSeconds, lastHeartbeat). The devices block also includes a walks counter (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.

Conveyor routing

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 code the hardware sends, a hardwareAddress to reach the equipment, and posX/posY for the schematic editor) and edges (segments labelled with the exitCode the hardware applies to traverse them, plus a routing cost). 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, or NOREAD barcode is treated like an unknown HU — the same divert-default / single-exit / HOLD logic applies; NO_ROUTE is returned only when the node is a dead end. Read-error scans are never traced (no HU identity). Scan trace (ADR-0008 §3d-1): each decide() call also appends a SCANNED row to hu_transport_trace when 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, and overflowTarget inline — 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.

Automation Topology (physical layout)

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 nodeCode as an alias for that node. A function point placed mid-section (between waypoints) splits the section into two edges and registers its nodeCode for 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 defaultExit is set to STRAIGHT or BRANCH has 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 as defaultExitCode on 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 drawSectionAt callback the 3D editor uses — building the same sections array 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 from of 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 deselect while 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 ASRS badge 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 functionType field 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 its offsetM) 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 defaultExit on the function point and projected to defaultExitCode on 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_LEFT or DIVERT_RIGHT function 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 (the side field 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 → junction instead of junction → 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/sections fields): 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.

Clone this wiki locally