/** * Warehouse Builder — pure geometry: bay slicing, access points, rotation, walls, * collision, zone lookup. Metres, y-down. DOM-free (shared with the server). * * Local frame of a shelf: length along local +x (bay 0 at local x = 0), "front" = local +y * (the bottom edge at rotation 0). `rotation` is clockwise in SVG coordinates: * 0 → local +x = world +x (bay 0 left), front = S * 90 → local +x = world +y (bay 0 top), front = W * 180 → local +x = world −x (bay 0 right), front = N * 270 → local +x = world −y (bay 0 bottom), front = E */ import type { WbAccess, WbLayout, WbObject, WbPallet, WbRotation, WbShelf, WbSide, WbWall, WbZone, WbCamera } from './schema' import { WB_SNAP, isBlockingType, round2, snap } from './schema' export const SIDES: WbSide[] = ['N', 'E', 'S', 'W'] export const sideVector = (side: WbSide): { dx: number; dy: number } => side === 'N' ? { dx: 0, dy: -1 } : side === 'E' ? { dx: 1, dy: 0 } : side === 'S' ? { dx: 0, dy: 1 } : { dx: -1, dy: 0 } export const worldSide = (access: WbAccess, rotation: WbRotation): WbSide => { const local = access === 'front' ? 2 : 0 // S / N return SIDES[(local + rotation / 90) % 4] } export const lengthAxis = (rotation: WbRotation): 'x' | 'y' => (rotation === 0 || rotation === 180 ? 'x' : 'y') export interface WbBay { key: string // `${objectKey}#${index}` objectKey: string objectType: 'shelf' | 'pallet' index: number code: string // Y code (pallet: '') x: number; y: number; w: number; d: number cx: number; cy: number side: WbSide access: { x: number; y: number } levels: string[] // Z codes (pallet: []) skip?: boolean } /** Y codes of a bay spec: explicit list, numeric range (zero padded) or letter stepping. */ export function bayCodes (spec: WbShelf['bays']): string[] { const n = Math.max(1, Math.round(spec.count || 1)) if (Array.isArray(spec.codes) && spec.codes.length) { const out = spec.codes.slice(0, n).map(String) while (out.length < n) out.push(String(out.length + 1).padStart(spec.pad || 2, '0')) return spec.direction === 'desc' ? out.reverse() : out } const start = String(spec.start ?? '01').trim() const step = Math.max(1, Math.round(spec.step || 1)) const out: string[] = [] if (/^\d+$/.test(start)) { const pad = Math.max(spec.pad || 0, start.length) const s = parseInt(start, 10) for (let i = 0; i < n; i++) out.push(String(s + i * step).padStart(pad, '0')) } else if (/^[A-Za-z]$/.test(start)) { const s = start.toUpperCase().charCodeAt(0) for (let i = 0; i < n; i++) { const c = s + i * step out.push(c <= 90 ? String.fromCharCode(c) : String.fromCharCode(65 + Math.floor((c - 65) / 26) - 1) + String.fromCharCode(65 + ((c - 65) % 26))) } } else { // alphanumeric like "A1": keep the alpha prefix, step the numeric tail const m = start.match(/^(.*?)(\d+)$/) if (m) { const pad = m[2].length const s = parseInt(m[2], 10) for (let i = 0; i < n; i++) out.push(m[1] + String(s + i * step).padStart(pad, '0')) } else { for (let i = 0; i < n; i++) out.push(i === 0 ? start : start + (i + 1)) } } return spec.direction === 'desc' ? out.reverse() : out } const accessPoint = (cx: number, cy: number, halfDepth: number, side: WbSide) => { const v = sideVector(side) return { x: round2(cx + v.dx * (halfDepth + 0.5)), y: round2(cy + v.dy * (halfDepth + 0.5)) } } export function shelfBays (s: WbShelf): WbBay[] { const codes = bayCodes(s.bays) const n = codes.length const axis = lengthAxis(s.rotation) const side = worldSide(s.accessSide, s.rotation) const reversed = s.rotation === 180 || s.rotation === 270 const out: WbBay[] = [] for (let i = 0; i < n; i++) { const slot = reversed ? n - 1 - i : i let bx = s.x, by = s.y, bw = s.w, bd = s.d if (axis === 'x') { bw = s.w / n; bx = s.x + slot * bw } else { bd = s.d / n; by = s.y + slot * bd } const cx = bx + bw / 2, cy = by + bd / 2 const halfDepth = axis === 'x' ? s.d / 2 : s.w / 2 const pb = s.perBay?.[i] out.push({ key: `${s.key}#${i}`, objectKey: s.key, objectType: 'shelf', index: i, code: codes[i], x: round2(bx), y: round2(by), w: round2(bw), d: round2(bd), cx: round2(cx), cy: round2(cy), side, access: accessPoint(cx, cy, halfDepth, side), levels: pb?.levels?.length ? pb.levels : s.levels, ...(pb?.skip ? { skip: true } : {}) }) } return out } export function palletBay (p: WbPallet): WbBay { const side = worldSide(p.accessSide, p.rotation) const cx = p.x + p.w / 2, cy = p.y + p.d / 2 const halfDepth = (side === 'N' || side === 'S') ? p.d / 2 : p.w / 2 return { key: `${p.key}#0`, objectKey: p.key, objectType: 'pallet', index: 0, code: '', x: p.x, y: p.y, w: p.w, d: p.d, cx: round2(cx), cy: round2(cy), side, access: accessPoint(cx, cy, halfDepth, side), levels: [] } } /** All bays of a layout (shelves + pallets), in object order. */ export function layoutBays (layout: WbLayout): WbBay[] { const out: WbBay[] = [] for (const o of layout.objects) { if (o.type === 'shelf') out.push(...shelfBays(o)) else if (o.type === 'pallet') out.push(palletBay(o)) } return out } // ---- rects / collision --------------------------------------------------------------------- export interface Rect { x: number; y: number; w: number; d: number } export const rectsIntersect = (a: Rect, b: Rect, eps = 1e-6): boolean => a.x < b.x + b.w - eps && a.x + a.w > b.x + eps && a.y < b.y + b.d - eps && a.y + a.d > b.y + eps export const pointInRect = (px: number, py: number, r: Rect): boolean => px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.d /** Key of the first blocking object overlapping `rect` (ignoring `selfKeys`), or null. */ export function findCollision (rect: Rect, objects: WbObject[], selfKeys: Set | string[] = [], onlyIfBlocking = true): string | null { const self = selfKeys instanceof Set ? selfKeys : new Set(selfKeys) for (const o of objects) { if (self.has(o.key)) continue if (onlyIfBlocking && !isBlockingType(o.type)) continue if (rectsIntersect(rect, o)) return o.key } return null } export const insideGrid = (r: Rect, grid: { width: number; height: number }): boolean => r.x >= 0 && r.y >= 0 && r.x + r.w <= grid.width + 1e-6 && r.y + r.d <= grid.height + 1e-6 /** Rotate by ±90°: swap w/d around the centre, re-snap the top-left to the grid. */ export function rotatedPatch (o: WbPlacedLike, dir: 1 | -1 = 1): { x: number; y: number; w: number; d: number; rotation: WbRotation } { const rotation = (((o.rotation + dir * 90) % 360) + 360) % 360 as WbRotation const cx = o.x + o.w / 2, cy = o.y + o.d / 2 const w = o.d, d = o.w return { x: snap(cx - w / 2), y: snap(cy - d / 2), w, d, rotation } } type WbPlacedLike = { x: number; y: number; w: number; d: number; rotation: WbRotation } // ---- walls ----------------------------------------------------------------------------------- export const normalizeWall = (w: WbWall): WbWall => { const [x1, y1, x2, y2] = w.map(round2) if (x1 === x2) return [x1, Math.min(y1, y2), x2, Math.max(y1, y2)] return [Math.min(x1, x2), y1, Math.max(x1, x2), y2] } /** Lock a dragged wall to the dominant axis and the 0.5 grid. Returns null for zero length. */ export function orthoWall (x1: number, y1: number, x2: number, y2: number): WbWall | null { const sx = snap(x1), sy = snap(y1) let ex = snap(x2), ey = snap(y2) if (Math.abs(ex - sx) >= Math.abs(ey - sy)) ey = sy; else ex = sx if (ex === sx && ey === sy) return null return normalizeWall([sx, sy, ex, ey]) } /** Append a wall, merging collinear segments that touch or overlap. */ export function mergeWall (walls: WbWall[], wall: WbWall): WbWall[] { let cur = normalizeWall(wall) const vertical = cur[0] === cur[2] const rest: WbWall[] = [] for (const w of walls) { const wv = w[0] === w[2] const sameLine = vertical ? (wv && w[0] === cur[0]) : (!wv && w[1] === cur[1]) if (!sameLine) { rest.push(w); continue } const [a1, a2] = vertical ? [cur[1], cur[3]] : [cur[0], cur[2]] const [b1, b2] = vertical ? [w[1], w[3]] : [w[0], w[2]] if (a2 >= b1 && b2 >= a1) { const lo = Math.min(a1, b1), hi = Math.max(a2, b2) cur = vertical ? [cur[0], lo, cur[0], hi] : [lo, cur[1], hi, cur[1]] } else rest.push(w) } rest.push(cur) return rest } export const roomWalls = (r: Rect): WbWall[] => { const x1 = snap(r.x), y1 = snap(r.y), x2 = snap(r.x + r.w), y2 = snap(r.y + r.d) if (x1 === x2 || y1 === y2) return [] return [[x1, y1, x2, y1], [x2, y1, x2, y2], [x1, y2, x2, y2], [x1, y1, x1, y2]] } /** Distance from a point to a wall segment. */ export function wallDistance (w: WbWall, px: number, py: number): number { const [x1, y1, x2, y2] = w const dx = x2 - x1, dy = y2 - y1 const len2 = dx * dx + dy * dy || 1 const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2)) const qx = x1 + t * dx, qy = y1 + t * dy return Math.hypot(px - qx, py - qy) } export function wallIndexAt (walls: WbWall[], px: number, py: number, tol = 0.3): number { let best = -1, bestD = tol walls.forEach((w, i) => { const d = wallDistance(w, px, py); if (d <= bestD) { best = i; bestD = d } }) return best } /** Does `rect` cross a wall? (wall strictly inside the rect interior, not on its border) */ export function rectCrossesWall (r: Rect, walls: WbWall[], eps = 0.01): boolean { for (const [x1, y1, x2, y2] of walls) { if (x1 === x2) { // vertical if (x1 > r.x + eps && x1 < r.x + r.w - eps && y1 < r.y + r.d - eps && y2 > r.y + eps) return true } else if (y1 > r.y + eps && y1 < r.y + r.d - eps && x1 < r.x + r.w - eps && x2 > r.x + eps) return true } return false } // ---- zones ------------------------------------------------------------------------------------- /** Zone containing the point: higher priority first, then the smaller area. */ export function zoneAt (zones: WbZone[], px: number, py: number): WbZone | null { let best: WbZone | null = null for (const z of zones) { if (!pointInRect(px, py, z)) continue if (!best) { best = z; continue } const pb = best.priority || 0, pz = z.priority || 0 if (pz > pb || (pz === pb && z.w * z.d < best.w * best.d)) best = z } return best } /** Zone code per bay key (object override wins; `null` override = none). */ export function zoneMembership (layout: WbLayout, bays: WbBay[]): Map { const byKey = new Map(layout.objects.map(o => [o.key, o])) const out = new Map() for (const b of bays) { const o = byKey.get(b.objectKey) as WbShelf | WbPallet | undefined if (o && o.zoneOverride !== undefined) { out.set(b.key, o.zoneOverride); continue } out.set(b.key, zoneAt(layout.zones, b.cx, b.cy)?.code ?? null) } return out } // ---- misc --------------------------------------------------------------------------------------- /** Next unused single/double letter rack code (A…Z, AA…). */ export function nextRackCode (layout: WbLayout): string { const used = new Set(layout.objects.filter(o => o.type === 'shelf').map(o => (o as WbShelf).rackCode.toUpperCase())) for (let i = 0; i < 26 * 27; i++) { const code = i < 26 ? String.fromCharCode(65 + i) : String.fromCharCode(65 + Math.floor(i / 26) - 1) + String.fromCharCode(65 + (i % 26)) if (!used.has(code)) return code } return 'X' + (layout.objects.length + 1) } export const bboxOf = (items: Rect[]): Rect | null => { if (!items.length) return null let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity for (const r of items) { x1 = Math.min(x1, r.x); y1 = Math.min(y1, r.y); x2 = Math.max(x2, r.x + r.w); y2 = Math.max(y2, r.y + r.d) } return { x: x1, y: y1, w: x2 - x1, d: y2 - y1 } } export { WB_SNAP } // ---- magnetic snapping (walls, object edges, zone edges, hall boundary) --------------------------------- /** A straight edge something can snap to: `axis: 'x'` = a vertical line at x = v spanning y ∈ [lo, hi]. */ export interface SnapLine { axis: 'x' | 'y'; v: number; lo: number; hi: number; src: string } const edgesOf = (r: Rect, src: string): SnapLine[] => [ { axis: 'x', v: r.x, lo: r.y, hi: r.y + r.d, src }, { axis: 'x', v: r.x + r.w, lo: r.y, hi: r.y + r.d, src }, { axis: 'y', v: r.y, lo: r.x, hi: r.x + r.w, src }, { axis: 'y', v: r.y + r.d, lo: r.x, hi: r.x + r.w, src } ] /** Every edge in the plan except the excluded keys (the things being moved). */ export function snapLines (layout: { grid: { width: number; height: number }; walls: WbWall[]; objects: WbObject[]; zones: WbZone[] }, exclude: Set | string[] = []): SnapLine[] { const ex = exclude instanceof Set ? exclude : new Set(exclude) const out: SnapLine[] = [] const g = layout.grid out.push({ axis: 'x', v: 0, lo: 0, hi: g.height, src: 'grid' }, { axis: 'x', v: g.width, lo: 0, hi: g.height, src: 'grid' }, { axis: 'y', v: 0, lo: 0, hi: g.width, src: 'grid' }, { axis: 'y', v: g.height, lo: 0, hi: g.width, src: 'grid' }) for (const [x1, y1, x2, y2] of layout.walls) { if (x1 === x2) out.push({ axis: 'x', v: x1, lo: Math.min(y1, y2), hi: Math.max(y1, y2), src: 'wall' }) else out.push({ axis: 'y', v: y1, lo: Math.min(x1, x2), hi: Math.max(x1, x2), src: 'wall' }) } for (const o of layout.objects) if (!ex.has(o.key)) out.push(...edgesOf(o, 'object')) for (const z of layout.zones) if (!ex.has(z.key)) out.push(...edgesOf(z, 'zone')) return out } /** Nearest line on `axis` within `tol` of `v` whose span overlaps [lo, hi] (± tol). */ export function snapCoord (lines: SnapLine[], axis: 'x' | 'y', v: number, lo: number, hi: number, tol: number): SnapLine | null { let best: SnapLine | null = null, bd = tol for (const l of lines) { if (l.axis !== axis || l.lo - tol > hi || l.hi + tol < lo) continue const d = Math.abs(l.v - v) if (d < bd) { bd = d; best = l } } return best } /** Snap a free point (drawing): each coordinate independently. */ export function snapPoint (lines: SnapLine[], x: number, y: number, tol: number): { x: number; y: number; guides: SnapLine[] } { const gx = snapCoord(lines, 'x', x, y, y, tol), gy = snapCoord(lines, 'y', y, x, x, tol) return { x: gx ? gx.v : x, y: gy ? gy.v : y, guides: [gx, gy].filter(Boolean) as SnapLine[] } } /** Snap a rect that is being moved: whichever of its two edges per axis is closest to a line wins. */ export function snapRectMove (lines: SnapLine[], r: Rect, tol: number): { dx: number; dy: number; guides: SnapLine[] } { const pick = (axis: 'x' | 'y', a: number, b: number, lo: number, hi: number) => { const la = snapCoord(lines, axis, a, lo, hi, tol), lb = snapCoord(lines, axis, b, lo, hi, tol) const da = la ? Math.abs(la.v - a) : Infinity, db = lb ? Math.abs(lb.v - b) : Infinity if (da === Infinity && db === Infinity) return { delta: 0, line: null as SnapLine | null } return da <= db ? { delta: la!.v - a, line: la } : { delta: lb!.v - b, line: lb } } const sx = pick('x', r.x, r.x + r.w, r.y, r.y + r.d), sy = pick('y', r.y, r.y + r.d, r.x, r.x + r.w) return { dx: sx.delta, dy: sy.delta, guides: [sx.line, sy.line].filter(Boolean) as SnapLine[] } } // ---- camera coverage (ray-cast view cone, shadowed by walls, shelves and obstacles) --------------------------- export interface CoveragePoly { key: string; origin: [number, number]; points: Array<[number, number]>; area: number } type Seg = [number, number, number, number] const rayHit = (ox: number, oy: number, dx: number, dy: number, s: Seg): number | null => { const ex = s[2] - s[0], ey = s[3] - s[1] const den = dx * ey - dy * ex if (Math.abs(den) < 1e-9) return null const fx = s[0] - ox, fy = s[1] - oy const t = (fx * ey - fy * ex) / den const u = (fx * dy - fy * dx) / den return t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 ? t : null } export function polygonArea (pts: Array<[number, number]>): number { let a = 0 for (let i = 0, n = pts.length; i < n; i++) { const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % n]; a += x1 * y2 - x2 * y1 } return Math.abs(a) / 2 } /** What a camera sees from its centre: one ray per `stepDeg` across the opening angle, stopped by walls, shelves, * obstacles, the hall boundary or the range. Heading 0 = north (−y), clockwise. */ export function cameraCoverage (cam: WbCamera, layout: { grid: { width: number; height: number }; walls: WbWall[]; objects: WbObject[] }, stepDeg = 2): CoveragePoly { const ox = cam.x + cam.w / 2, oy = cam.y + cam.d / 2 const segs: Seg[] = [...layout.walls] const g = layout.grid segs.push([0, 0, g.width, 0], [g.width, 0, g.width, g.height], [g.width, g.height, 0, g.height], [0, g.height, 0, 0]) for (const o of layout.objects) { if (o.key === cam.key || (o.type !== 'shelf' && o.type !== 'obstacle')) continue segs.push([o.x, o.y, o.x + o.w, o.y], [o.x + o.w, o.y, o.x + o.w, o.y + o.d], [o.x + o.w, o.y + o.d, o.x, o.y + o.d], [o.x, o.y + o.d, o.x, o.y]) } const fov = Math.min(360, Math.max(1, cam.fov)), range = Math.max(0.5, cam.range) const n = Math.max(8, Math.ceil(fov / stepDeg)) const pts: Array<[number, number]> = [] for (let i = 0; i <= n; i++) { const a = ((cam.heading - fov / 2 + (fov * i) / n) * Math.PI) / 180 const dx = Math.sin(a), dy = -Math.cos(a) let t = range for (const s of segs) { const h = rayHit(ox, oy, dx, dy, s); if (h !== null && h < t) t = h } pts.push([Math.round((ox + dx * t) * 100) / 100, Math.round((oy + dy * t) * 100) / 100]) } const points: Array<[number, number]> = fov >= 360 ? pts : [[Math.round(ox * 100) / 100, Math.round(oy * 100) / 100], ...pts] return { key: cam.key, origin: [ox, oy], points, area: Math.round(polygonArea(points) * 100) / 100 } } const pointInPoly = (x: number, y: number, pts: Array<[number, number]>): boolean => { let inside = false for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const [xi, yi] = pts[i], [xj, yj] = pts[j] if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside } return inside } /** Area inside `bounds` covered by at least one polygon (0.5 m raster, overlaps counted once). */ export function coverageUnionArea (polys: CoveragePoly[], bounds: Rect, step = 0.5): number { if (!polys.length || bounds.w <= 0 || bounds.d <= 0) return 0 let cells = 0 for (let y = bounds.y + step / 2; y < bounds.y + bounds.d; y += step) { for (let x = bounds.x + step / 2; x < bounds.x + bounds.w; x += step) { for (const p of polys) if (pointInPoly(x, y, p.points)) { cells++; break } } } return Math.round(cells * step * step * 100) / 100 } // ---- doors / gates live IN walls ----------------------------------------------------------------------------- export interface DoorDock { rect: Rect; wall: WbWall; horizontal: boolean } const r2d = (v: number) => Math.round(v * 100) / 100 /** Nearest wall (within `tol` m of the door centre) long enough for the door: the door is turned along the wall, * centred on the wall line and kept inside the wall segment. */ export function dockDoorToWall (r: Rect, walls: WbWall[], tol = 2): DoorDock | null { const len = Math.max(r.w, r.d), thick = Math.min(r.w, r.d) const cx = r.x + r.w / 2, cy = r.y + r.d / 2 let best: DoorDock | null = null, bd = tol for (const w of walls) { const [x1, y1, x2, y2] = w if (y1 === y2) { const lo = Math.min(x1, x2), hi = Math.max(x1, x2) if (hi - lo < len - 1e-6) continue const d = Math.hypot(cx - Math.min(Math.max(cx, lo), hi), cy - y1) if (d < bd) { bd = d; best = { rect: { x: r2d(Math.min(Math.max(cx - len / 2, lo), hi - len)), y: r2d(y1 - thick / 2), w: len, d: thick }, wall: w, horizontal: true } } } else { const lo = Math.min(y1, y2), hi = Math.max(y1, y2) if (hi - lo < len - 1e-6) continue const d = Math.hypot(cx - x1, cy - Math.min(Math.max(cy, lo), hi)) if (d < bd) { bd = d; best = { rect: { x: r2d(x1 - thick / 2), y: r2d(Math.min(Math.max(cy - len / 2, lo), hi - len)), w: thick, d: len }, wall: w, horizontal: false } } } } return best } /** True when a wall of the door's orientation runs through the door over its whole length. */ export function doorOnWall (r: Rect, walls: WbWall[], eps = 0.05): boolean { const horizontal = r.w >= r.d, cx = r.x + r.w / 2, cy = r.y + r.d / 2 return walls.some(([x1, y1, x2, y2]) => horizontal ? y1 === y2 && Math.abs(y1 - cy) <= r.d / 2 + eps && Math.min(x1, x2) <= r.x + eps && Math.max(x1, x2) >= r.x + r.w - eps : x1 === x2 && Math.abs(x1 - cx) <= r.w / 2 + eps && Math.min(y1, y2) <= r.y + eps && Math.max(y1, y2) >= r.y + r.d - eps) }