Resources Numerical accuracy Calculation policy

Material flow layout geometry

Places conveyors, stations and vehicle paths, finds which ports touch, and turns the layout into the lengths, speeds and times the simulation runs on.

How the precision is kept

Item positions are meters and rotations are degrees about Z, in 64-bit numbers. Port positions are the item's local port turned by its rotation and added to its position. Ports are joined by a fixed distance and angle tolerance, and a snapped item is moved so its port lands on the other port's computed position.

Calculations under this policy

9 calculations in 2 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

addons/flow/lib/catalog.js ModelCraft 0.8.1 64-bit 3 calculations Reviewed

Tests None found by the review

Center line of a conveyor

64-bit

The length of a straight, zoned, inclined or curved conveyor and the point and heading at a distance along it.

incline: L = √(length² + rise²); curve: L = r × |angle|, point = (r sin θ, ± r (1 − cos θ)), θ = s ÷ r

The curve uses 1 − cos θ, which loses digits for a very small θ; the sideways offset it gives is itself very small there. The angle is converted with π ÷ 180 in 64-bit.

  • addons/flow/lib/catalog.js
  • lines 84 to 98
  • ModelCraft 0.8.1
export function localPath(kind, p) {
  switch (kind) {
    case 'conveyor': case 'agv-path': return { length: p.length, at: (s) => ({ p: [s, 0, 0], h: 0 }) };
    case 'zone': { const L = p.zones * p.zoneLength; return { length: L, at: (s) => ({ p: [s, 0, 0], h: 0 }) }; }
    case 'incline': {
      const L = Math.hypot(p.length, p.rise);
      return { length: L, at: (s) => ({ p: [(s / L) * p.length, 0, (s / L) * p.rise], h: 0 }) };
    }
    case 'curve': {
      const r = p.radius, a = p.angle * DEG, sg = a >= 0 ? 1 : -1, L = r * Math.abs(a);
      return { length: L, at: (s) => { const th = s / r; return { p: [r * Math.sin(th), sg * r * (1 - Math.cos(th)), 0], h: (sg * th) / DEG }; } };
    }
  }
  return null;
}

Ports of an item

64-bit

Where an item's in, out and vehicle ports are in its own frame, with their headings.

A conveyor's out port is its path evaluated at its full length. Other items use fixed half sizes from a table in the file.

  • addons/flow/lib/catalog.js
  • lines 100 to 121
  • ModelCraft 0.8.1
export function localPorts(kind, p) {
  if (kind === 'agv-path') return [{ name: 'a', dir: 'agv', p: [0, 0, 0], h: 0 }, { name: 'b', dir: 'agv', p: [p.length, 0, 0], h: 0 }];
  const path = localPath(kind, p);
  if (path) { const e = path.at(path.length); return [{ name: 'in', dir: 'in', p: [0, 0, 0], h: 0 }, { name: 'out', dir: 'out', p: e.p, h: e.h }]; }
  const hl = HALF[kind] ?? 0.3;
  switch (kind) {
    case 'merge': {
      const ins = [{ name: 'in1', dir: 'in', p: [-hl, 0, 0], h: 0 }, { name: 'in2', dir: 'in', p: [0, -hl, 0], h: 90 }, { name: 'in3', dir: 'in', p: [0, hl, 0], h: -90 }];
      return [...ins.slice(0, p.inputs), { name: 'out', dir: 'out', p: [hl, 0, 0], h: 0 }];
    }
    case 'divert': {
      const outs = [{ name: 'out1', dir: 'out', p: [hl, 0, 0], h: 0 }, { name: 'out2', dir: 'out', p: [0, hl, 0], h: 90 }, { name: 'out3', dir: 'out', p: [0, -hl, 0], h: -90 }];
      return [{ name: 'in', dir: 'in', p: [-hl, 0, 0], h: 0 }, ...outs.slice(0, p.outputs)];
    }
    case 'source': return [{ name: 'out', dir: 'out', p: [hl, 0, 0], h: 0 }];
    case 'sink': return [{ name: 'in', dir: 'in', p: [-hl, 0, 0], h: 0 }];
    case 'sensor': return [];
    case 'agv-pick': return [{ name: 'in', dir: 'in', p: [-hl, 0, 0], h: 0 }, { name: 'node', dir: 'agv', p: [hl + 0.5, 0, 0], h: 0 }];
    case 'agv-drop': return [{ name: 'node', dir: 'agv', p: [-hl - 0.5, 0, 0], h: 0 }, { name: 'out', dir: 'out', p: [hl, 0, 0], h: 0 }];
    default: return [{ name: 'in', dir: 'in', p: [-hl, 0, 0], h: 0 }, { name: 'out', dir: 'out', p: [hl, 0, 0], h: 0 }];
  }
}

Parameter limits

64-bit

Clamps each numeric parameter to its minimum and maximum and rounds whole-number parameters.

n = min(max, max(min, v)); whole numbers: round(n)

A value that is not a finite number keeps the default. Only parameters of kind 'int' are rounded; lengths, speeds and times are kept as given within their limits.

  • addons/flow/lib/catalog.js
  • lines 59 to 79
  • ModelCraft 0.8.1
export function cleanParams(kind, raw) {
  const out = defaults(kind);
  if (!raw || typeof raw !== 'object') return out;
  for (const [k, d] of Object.entries(KIND[kind].params)) {
    const v = raw[k];
    if (v === undefined) continue;
    switch (d.kind) {
      case 'bool': out[k] = !!v; break;
      case 'choice': if (d.choices.includes(v)) out[k] = v; break;
      case 'text': case 'ref': if (typeof v === 'string') out[k] = v.replace(/[\u0000-\u001f<>]/g, '').slice(0, 200); break;
      case 'dist': out[k] = cleanDist(v, out[k]); break;
      default: {
        if (typeof v !== 'number' || !Number.isFinite(v)) break;
        let n = Math.min(d.max, Math.max(d.min, v));
        if (d.kind === 'int') n = Math.round(n);
        out[k] = n;
      }
    }
  }
  return out;
}
addons/flow/lib/layout.js ModelCraft 0.8.1 64-bit 6 calculations Reviewed

Tests None found by the review

Reading a layout

64-bit

Checks the positions, rotation, fleet and run settings of a saved layout.

x, y, z: kept if finite and |v| < 1e6, else 0; rot = ((rot mod 360) + 360) mod 360

Positions are copied without rounding. The vehicle count, the pool sizes and the seed are rounded to whole numbers; speeds and times are clamped to fixed ranges.

  • addons/flow/lib/layout.js
  • lines 35 to 67
  • ModelCraft 0.8.1
export function cleanLayout(raw) {
  const layout = newLayout();
  let dropped = 0;
  if (!raw || typeof raw !== 'object' || raw.format !== FORMAT || typeof raw.version !== 'number' || raw.version > VERSION) return { layout, dropped: -1 };
  const seen = new Set();
  for (const it of Array.isArray(raw.items) ? raw.items.slice(0, MAX_ITEMS) : []) {
    if (!it || typeof it !== 'object' || !KINDS.includes(it.kind) || typeof it.id !== 'string' || !ID_RE.test(it.id) || seen.has(it.id)) { dropped++; continue; }
    seen.add(it.id);
    layout.items.push({ id: it.id, kind: it.kind, x: fin(it.x), y: fin(it.y), z: fin(it.z), rot: ((fin(it.rot) % 360) + 360) % 360, params: cleanParams(it.kind, it.params) });
  }
  const f = raw.fleet || {};
  layout.fleet = {
    vehicles: Math.round(Math.min(50, Math.max(0, fin(f.vehicles, 1)))), speed: Math.min(10, Math.max(0.05, fin(f.speed, 1))),
    load: Math.min(3600, Math.max(0, fin(f.load, 5))), unload: Math.min(3600, Math.max(0, fin(f.unload, 5))), nodes: !!f.nodes,
    // 0.8 round 4: vehicles break down (hours between, minutes to repair; 0 = never) and idle ones park at the named AGV path ends (ids of agv-path items; the first end of each)
    mtbf: Math.min(1e5, Math.max(0, fin(f.mtbf, 0))), mttr: Math.min(1e5, Math.max(0.01, fin(f.mttr, 15))),
    park: (Array.isArray(f.park) ? f.park : []).filter((x) => typeof x === 'string' && ID_RE.test(x)).slice(0, 20),
  };
  const poolIds = new Set();
  for (const q of Array.isArray(raw.pools) ? raw.pools.slice(0, 20) : []) {
    if (!q || typeof q.id !== 'string' || !ID_RE.test(q.id) || poolIds.has(q.id)) { dropped++; continue; }
    poolIds.add(q.id); layout.pools.push({ id: q.id, name: typeof q.name === 'string' ? q.name.slice(0, 60) : q.id, size: Math.round(Math.min(100, Math.max(1, fin(q.size, 1)))) });
  }
  const calIds = new Set();
  for (const c of Array.isArray(raw.calendars) ? raw.calendars.slice(0, MAX_CALENDARS) : []) {
    const cc = cleanCalendar(c);
    if (!cc || calIds.has(cc.id)) { dropped++; continue; }
    calIds.add(cc.id); layout.calendars.push(cc);
  }
  const r = raw.run || {};
  layout.run = { seed: Math.round(Math.min(2 ** 31, Math.max(0, fin(r.seed, 1)))), hours: Math.min(10000, Math.max(0.01, fin(r.hours, 8))), warmup: Math.min(6e5, Math.max(0, fin(r.warmup, 30))) };
  return { layout, dropped };
}

Local point to world

64-bit

Turns a point in an item's own frame by the item's rotation and moves it to the item's position.

x′ = x cos r − y sin r + item x; y′ = x sin r + y cos r + item y; heading = ((h mod 360) + 540) mod 360 − 180

Sine and cosine of the rotation in degrees × π ÷ 180. For 90° the cosine is about 6e-17 and not exactly 0, so right-angle layouts carry that rounding into port positions; the 0.01 m join tolerance is far larger.

  • addons/flow/lib/layout.js
  • lines 77 to 79
  • ModelCraft 0.8.1
const rotXY = (p, rot) => { const c = Math.cos(rot * DEG), s = Math.sin(rot * DEG); return [p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]; };
const toWorld = (it, p) => { const q = rotXY(p, it.rot); return [q[0] + it.x, q[1] + it.y, q[2] + it.z]; };
const normDeg = (h) => ((h % 360) + 540) % 360 - 180;

Placing an item on a port

64-bit

The position and rotation that put one of an item's ports on a given point with a given heading.

rot = ((H − port heading) mod 360 + 360) mod 360; position = P − rotated port offset

One rotation and one subtraction per axis in 64-bit. The stored position is not rounded.

  • addons/flow/lib/layout.js
  • lines 90 to 97
  • ModelCraft 0.8.1
/** Where an item of `kind` goes so that its port `port` sits on point P with heading H. */
export function place(kind, params, port, P, H) {
  const lp = localPorts(kind, params).find((x) => x.name === port);
  if (!lp) return null;
  const rot = ((H - lp.h) % 360 + 360) % 360;
  const q = rotXY(lp.p, rot);
  return { x: P[0] - q[0], y: P[1] - q[1], z: P[2] - q[2], rot };
}

Which ports are joined

64-bit

Finds the out and in ports that touch, and merges vehicle path ends into nodes.

joined when |Δx|, |Δy|, |Δz| ≤ 0.01 m and |Δheading| ≤ 1°; path ends merge when each |Δ| ≤ 0.05 m

The tolerances are absolute and tested per axis. An out port takes the first matching free in port in list order, not the nearest. A vehicle segment's length is the item's length parameter, not the distance between its merged nodes.

  • addons/flow/lib/layout.js
  • lines 118 to 151
  • ModelCraft 0.8.1
export function connect(layout) {
  const ports = layout.items.flatMap(worldPorts);
  const outs = ports.filter((p) => p.dir === 'out'), ins = ports.filter((p) => p.dir === 'in');
  const usedIn = new Set(), links = [];
  for (const o of outs) {
    const i = ins.find((x) => x.id !== o.id && !usedIn.has(x.id + ':' + x.name) && samePt(x.p, o.p, JOIN_TOL) && sameH(x.h, o.h));
    if (!i) continue;
    usedIn.add(i.id + ':' + i.name);
    links.push({ from: o.id, out: o.name, to: i.id, in: i.name });
  }
  // AGV network: path ends merged into nodes
  const nodes = [];
  const nodeAt = (p, make) => {
    let n = nodes.find((x) => samePt(x.p, p, AGV_TOL));
    if (!n && make) { n = { id: 'n' + (nodes.length + 1), p }; nodes.push(n); }
    return n;
  };
  const edges = [];
  for (const it of layout.items) {
    if (it.kind !== 'agv-path') continue;
    const [a, b] = worldPorts(it);
    const na = nodeAt(a.p, true).id, nb = nodeAt(b.p, true).id;
    edges.push({ id: it.id, from: na, to: nb, length: it.params.length, ...(it.params.twoWay ? { lane: it.id } : {}) });
    if (it.params.twoWay) edges.push({ id: it.id + REVERSE, from: nb, to: na, length: it.params.length, lane: it.id });   // one lane: only one vehicle at a time
  }
  const nodeOf = {};
  for (const it of layout.items) {
    if (it.kind !== 'agv-pick' && it.kind !== 'agv-drop') continue;
    const np = worldPorts(it).find((x) => x.dir === 'agv');
    const n = nodeAt(np.p, false);
    if (n) nodeOf[it.id] = n.id;
  }
  return { links, agv: { nodes, edges, nodeOf } };
}

Snap an item to the nearest free port

64-bit

Moves an item so one of its free ports sits on the nearest matching free port within a radius.

d = √(Δx² + Δy² + Δz²); nearest pair with d ≤ radius (0.5 m by default)

Distances by Math.hypot. The item is then placed with the other port's computed point and heading, so the two ports agree to rounding of the rotation.

  • addons/flow/lib/layout.js
  • lines 160 to 177
  • ModelCraft 0.8.1
/** Move item `id` so that one of its free ports sits exactly on the nearest matching free port of another item (within radius). */
export function snapItem(layout, id, radius = 0.5) {
  const it = layout.items.find((i) => i.id === id);
  if (!it) return false;
  const free = freePorts(layout);
  const mine = free.filter((p) => p.id === id), others = free.filter((p) => p.id !== id);
  let best = null, bd = radius;
  for (const m of mine) {
    for (const o of others) {
      if (o.dir === m.dir) continue;
      const d = Math.hypot(o.p[0] - m.p[0], o.p[1] - m.p[1], o.p[2] - m.p[2]);
      if (d <= bd) { bd = d; best = { m, o }; }
    }
  }
  if (!best) return false;
  Object.assign(it, place(it.kind, it.params, best.m.name, best.o.p, best.o.h));
  return true;
}

Lengths and times handed to the simulation

64-bit

Turns each conveyor into its length, speed and gap, and breakdown times from hours and minutes into seconds.

zone length = zones × zone length; mtbf (s) = hours × 3600; mttr (s) = minutes × 60

3600 and 60 are exact whole-number factors. The conveyor length comes from the same path function that places its out port.

  • addons/flow/lib/layout.js
  • lines 195 to 198
  • ModelCraft 0.8.1
case 'conveyor': case 'curve': case 'incline':
  elements.push({ ...base, length: pathLength(it), speed: p.speed, gap: p.gap, accumulate: p.accumulate, ...(p.mtbf > 0 ? { mtbf: p.mtbf * 3600, mttr: p.mttr * 60 } : {}) }); break;
case 'zone':
  elements.push({ ...base, length: p.zones * p.zoneLength, speed: p.speed, gap: 0, pitch: p.zoneLength, accumulate: true }); break;

In detail

Method
Rotation by sine and cosine of the angle in degrees × π ÷ 180; curve path as a circular arc; incline length by Math.hypot.
Number types
64-bit numbers.
Tolerances
Ports join within 0.01 m on each axis and 1° of heading; vehicle path ends join within 0.05 m on each axis. All are absolute.
Limits
Coordinates outside ±1e6 m are replaced by 0 when a layout file is read. A join is tested per axis (a box), not by distance.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach