@7365admin1/layer-common 4.49.0 → 4.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,756 @@
1
+ /**
2
+ * The map editor <-> the building's HERO Fleet Core data (the map's `rmf`
3
+ * part: world metres, y up, vertices shared by walls / doors / lanes). Pure
4
+ * functions - the editor and the 3D view only draw what these return, and
5
+ * `test/rmf-editor.test.mjs` pins them.
6
+ *
7
+ * The data model follows Open-RMF's building format (rmf_traffic_editor
8
+ * building.yaml: levels with vertices, walls, doors, lanes on graphs 0-8,
9
+ * measurements and fiducials; lifts with cabin doors and the doors that open
10
+ * on each level). The level alignment below is a port of
11
+ * rmf_building_map_tools transform.py `set_from_fiducials`. Those projects are
12
+ * Copyright Open Source Robotics Foundation, licensed under the Apache
13
+ * License, Version 2.0.
14
+ */
15
+ import {
16
+ DEFAULT_FRAME,
17
+ shapesFromMap,
18
+ shareToWorld,
19
+ worldToShare,
20
+ type TEditorShape,
21
+ type TFrame,
22
+ type TPlanPlacement,
23
+ type TRouteGraph,
24
+ } from "./robot-building";
25
+ import { floorName } from "./robot-display";
26
+
27
+ type TPt = { x: number; y: number };
28
+ const r3 = (v: number) => Math.round(v * 1000) / 1000;
29
+ const r2 = (p: TPt) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 });
30
+ /** Shares with sub-millimetre precision for any frame up to 5 km (what a map opens with; an edit rounds what it moves). */
31
+ const r6 = (p: TPt) => ({ x: Math.round(p.x * 1e6) / 1e6, y: Math.round(p.y * 1e6) / 1e6 });
32
+ const WELD_M = 0.01;
33
+
34
+ export const VERTEX_PARAMS = [
35
+ "is_charger", "is_parking_spot", "is_holding_point", "is_passthrough_point", "is_cleaning_zone",
36
+ "dock_name", "spawn_robot_name", "spawn_robot_type", "pickup_dispenser", "dropoff_ingestor",
37
+ "lift_cabin", "merge_radius", "mutex", "human_goal_set_name",
38
+ ] as const;
39
+ export const DOOR_TYPES = ["hinged", "double_hinged", "sliding", "double_sliding"] as const;
40
+ export const LIFT_DOOR_TYPES = ["sliding", "double_sliding", "telescope", "double_telescope"] as const;
41
+
42
+ /** The door types in plain words (the building file's names stay in the data). */
43
+ export const DOOR_TYPE_LABEL: Record<string, string> = {
44
+ hinged: "Single swing",
45
+ double_hinged: "Double swing",
46
+ sliding: "Single sliding",
47
+ double_sliding: "Double sliding",
48
+ };
49
+ export const LIFT_DOOR_TYPE_LABEL: Record<string, string> = {
50
+ sliding: "Single sliding",
51
+ double_sliding: "Double sliding",
52
+ telescope: "Single telescopic",
53
+ double_telescope: "Double telescopic",
54
+ };
55
+
56
+ /* -------------------------------------------------------------- frames */
57
+
58
+ /** A floor plan's four corners in world metres (top-left at (tx, -ty), turned yawRad about it). */
59
+ export function planCorners(p: { widthPx: number; heightPx: number } & TPlanPlacement): TPt[] {
60
+ const w = p.widthPx * p.metersPerPixel;
61
+ const h = p.heightPx * p.metersPerPixel;
62
+ const a = p.yawRad ?? 0;
63
+ const o = { x: p.tx, y: -p.ty };
64
+ // Image x runs along +x turned by yaw; image y (down) runs along -y turned by yaw.
65
+ const at = (u: number, v: number) => ({ x: o.x + u * Math.cos(a) + v * Math.sin(a), y: o.y + u * Math.sin(a) - v * Math.cos(a) });
66
+ return [at(0, 0), at(w, 0), at(w, h), at(0, h)];
67
+ }
68
+
69
+ const union = (a: TFrame | null, xs: number[], ys: number[]): TFrame | null => {
70
+ if (!xs.length) return a;
71
+ const minX = Math.min(...xs, ...(a ? [a.minX] : []));
72
+ const maxX = Math.max(...xs, ...(a ? [a.minX + a.w] : []));
73
+ const minY = Math.min(...ys, ...(a ? [a.maxY - a.h] : []));
74
+ const maxY = Math.max(...ys, ...(a ? [a.maxY] : []));
75
+ return { minX: r3(minX), maxY: r3(maxY), w: r3(maxX - minX), h: r3(maxY - minY) };
76
+ };
77
+
78
+ /**
79
+ * The frame the editor opens with: every floor's stored plan frame (a map
80
+ * drawn before the building-file work: the 40 x 26 m frame), grown to take in
81
+ * every floor plan image - so a big plan is never cut off.
82
+ */
83
+ export function frameForMap(map: Record<string, any> | null | undefined, plans: Array<{ widthPx: number; heightPx: number } & TPlanPlacement> = []): TFrame {
84
+ let f: TFrame | null = null;
85
+ for (const lv of (map?.rmf?.levels ?? []) as Array<Record<string, any>>) {
86
+ const pf = lv.planFrame;
87
+ if (pf && pf.maxX > pf.minX && pf.maxY > pf.minY) f = union(f, [pf.minX, pf.maxX], [pf.minY, pf.maxY]);
88
+ }
89
+ if (!f) f = { ...DEFAULT_FRAME };
90
+ return growFrame(f, plans);
91
+ }
92
+
93
+ /** The frame grown (never shrunk) to take in these floor plans. */
94
+ export function growFrame(f: TFrame, plans: Array<{ widthPx: number; heightPx: number } & TPlanPlacement>): TFrame {
95
+ const xs: number[] = [];
96
+ const ys: number[] = [];
97
+ for (const p of plans) for (const c of planCorners(p)) xs.push(c.x), ys.push(c.y);
98
+ const g = union(f, xs, ys)!;
99
+ return g.minX === f.minX && g.maxY === f.maxY && g.w === f.w && g.h === f.h ? f : g;
100
+ }
101
+
102
+ /** The frame fitted round everything drawn and every plan, 2 m round, at least 10 m a side. */
103
+ export function fitFrame(shapes: TEditorShape[], f: TFrame, plans: Array<{ widthPx: number; heightPx: number } & TPlanPlacement> = [], pad = 2): TFrame {
104
+ const xs: number[] = [];
105
+ const ys: number[] = [];
106
+ for (const s of shapes) for (const p of s.points) {
107
+ const w = shareToWorld(f, p);
108
+ xs.push(w.x);
109
+ ys.push(w.y);
110
+ }
111
+ for (const p of plans) for (const c of planCorners(p)) xs.push(c.x), ys.push(c.y);
112
+ if (!xs.length) return f;
113
+ let minX = Math.min(...xs) - pad;
114
+ let maxX = Math.max(...xs) + pad;
115
+ let minY = Math.min(...ys) - pad;
116
+ let maxY = Math.max(...ys) + pad;
117
+ if (maxX - minX < 10) [minX, maxX] = [(minX + maxX) / 2 - 5, (minX + maxX) / 2 + 5];
118
+ if (maxY - minY < 10) [minY, maxY] = [(minY + maxY) / 2 - 5, (minY + maxY) / 2 + 5];
119
+ return { minX: r3(minX), maxY: r3(maxY), w: r3(maxX - minX), h: r3(maxY - minY) };
120
+ }
121
+
122
+ /**
123
+ * Where a floor plan sits over the editor's plan, in shares of the frame
124
+ * (the editor draws everything in %), and its turn in degrees for CSS
125
+ * (clockwise on screen).
126
+ */
127
+ export function planBoxIn(f: TFrame, p: { widthPx: number; heightPx: number } & TPlanPlacement) {
128
+ const tl = worldToShare(f, { x: p.tx, y: -p.ty });
129
+ return {
130
+ left: tl.x,
131
+ top: tl.y,
132
+ width: ((p.widthPx * p.metersPerPixel) / f.w) * 100,
133
+ height: ((p.heightPx * p.metersPerPixel) / f.h) * 100,
134
+ rotateDeg: -((p.yawRad ?? 0) * 180) / Math.PI,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * A stored floor plan's placement in world metres. A plan placed by this
140
+ * editor says so (`space: "world"`). An older placement is in the floor's
141
+ * 40 x 26 m plan frame, which for a map with an rmf part stands for that
142
+ * floor's `planFrame` - it is converted through it (the older view stretched
143
+ * a non-40:26 frame; the width's scale is used). Maps drawn in the default
144
+ * frame read the same either way.
145
+ */
146
+ export function placementInWorld<T extends TPlanPlacement & { space?: string }>(p: T, planFrame?: { minX: number; maxX: number; minY: number; maxY: number } | null): T {
147
+ if (p.space === "world" || !planFrame) return p;
148
+ const w = planFrame.maxX - planFrame.minX;
149
+ const h = planFrame.maxY - planFrame.minY;
150
+ if (!(w > 0 && h > 0) || (planFrame.minX === 0 && planFrame.maxY === 0 && w === 40 && h === 26)) return p;
151
+ const sx = w / 40;
152
+ const sy = h / 26;
153
+ return { ...p, metersPerPixel: p.metersPerPixel * sx, tx: planFrame.minX + p.tx * sx, ty: -(planFrame.maxY - p.ty * sy) };
154
+ }
155
+
156
+ /** Every floor plan of a map, placed in world metres (see `placementInWorld`). */
157
+ export function worldPlans(map: Record<string, any> | null | undefined) {
158
+ const frames = new Map(((map?.rmf?.levels ?? []) as Array<Record<string, any>>).map((l) => [Number(l.floorNo), l.planFrame]));
159
+ return ((map?.floorPlans ?? []) as Array<Record<string, any>>).map((p) =>
160
+ placementInWorld({ floorNo: Number(p.floorNo), widthPx: p.widthPx, heightPx: p.heightPx, metersPerPixel: p.metersPerPixel, tx: p.tx, ty: p.ty, yawRad: p.yawRad ?? 0, space: p.space }, frames.get(Number(p.floorNo))),
161
+ );
162
+ }
163
+
164
+ /** A new plan fitted inside the frame, top-left aligned, aspect kept. */
165
+ export function fitPlanIn(f: TFrame, widthPx: number, heightPx: number): TPlanPlacement {
166
+ return { metersPerPixel: Math.round(Math.min(f.w / widthPx, f.h / heightPx) * 1e6) / 1e6, tx: f.minX, ty: -f.maxY, yawRad: 0 };
167
+ }
168
+
169
+ /* ---------------------------------------------------- measure and align */
170
+
171
+ type TXform = { scale: number; rot: number; tx: number; ty: number };
172
+ type TNamed = TPt & { name: string };
173
+
174
+ /** transform.py transform_point: rotate, scale, then shift. */
175
+ export const applyXform = (t: TXform, p: TPt) => ({
176
+ x: (p.x * Math.cos(t.rot) - p.y * Math.sin(t.rot)) * t.scale + t.tx,
177
+ y: (p.x * Math.sin(t.rot) + p.y * Math.cos(t.rot)) * t.scale + t.ty,
178
+ });
179
+
180
+ /**
181
+ * transform.py set_from_fiducials: the transform that puts the floor being
182
+ * aligned (`pairs[i][1]`) onto the reference floor (`pairs[i][0]`): turn =
183
+ * circular mean of the bearing differences, scale = mean distance ratio,
184
+ * shift = mean offset. Null with fewer than two pairs.
185
+ */
186
+ export function fiducialTransform(pairs: Array<[TNamed, TNamed]>, refScale = 1): TXform | null {
187
+ const bearings: Array<[number, number]> = [];
188
+ const distances: Array<[number, number]> = [];
189
+ for (let i = 0; i < pairs.length; i++) {
190
+ for (let j = i + 1; j < pairs.length; j++) {
191
+ const [a0, a1] = pairs[i]!;
192
+ const [b0, b1] = pairs[j]!;
193
+ bearings.push([Math.atan2(b0.y - a0.y, b0.x - a0.x), Math.atan2(b1.y - a1.y, b1.x - a1.x)]);
194
+ distances.push([Math.hypot(b0.x - a0.x, b0.y - a0.y), Math.hypot(b1.x - a1.x, b1.y - a1.y)]);
195
+ }
196
+ }
197
+ if (!bearings.length) return null;
198
+ let sin = 0;
199
+ let cos = 0;
200
+ for (const [ref, target] of bearings) {
201
+ sin += Math.sin(target - ref);
202
+ cos += Math.cos(target - ref);
203
+ }
204
+ const rot = -Math.atan2(sin, cos);
205
+ const rel = distances.reduce((n, [ref, target]) => n + target / (ref || 1e-9), 0) / distances.length;
206
+ if (!(rel > 0) || !Number.isFinite(rel)) return null;
207
+ let tx = 0;
208
+ let ty = 0;
209
+ for (const [f0, f1] of pairs) {
210
+ const q = { x: (f1.x / rel) * Math.cos(rot) - (f1.y / rel) * Math.sin(rot), y: (f1.x / rel) * Math.sin(rot) + (f1.y / rel) * Math.cos(rot) };
211
+ tx += q.x - f0.x;
212
+ ty += q.y - f0.y;
213
+ }
214
+ return { scale: refScale / rel, rot, tx: (tx * -refScale) / pairs.length, ty: (ty * -refScale) / pairs.length };
215
+ }
216
+
217
+ /** A floor plan placement moved by a world transform (its corner moves; it scales and turns with it). */
218
+ export function transformPlacement(p: TPlanPlacement, t: TXform): TPlanPlacement {
219
+ const o = applyXform(t, { x: p.tx, y: -p.ty });
220
+ return { metersPerPixel: Math.round(p.metersPerPixel * t.scale * 1e6) / 1e6, tx: r3(o.x), ty: r3(-o.y), yawRad: Math.round(((p.yawRad ?? 0) + t.rot) * 10000) / 10000 };
221
+ }
222
+
223
+ /**
224
+ * Line one floor up with another: the fiducials the two floors share by name
225
+ * (at least two) give the turn, scale and shift. The result also says how far
226
+ * off the marks still are after it (metres), in plain words for the panel.
227
+ */
228
+ export function alignFloor(shapes: TEditorShape[], f: TFrame, floorNo: number, refFloorNo: number) {
229
+ const marks = (fl: number) =>
230
+ shapes.filter((s) => s.kind === "fiducial" && s.floorNo === fl && s.name).map((s) => ({ ...shareToWorld(f, s.points[0]!), name: s.name! }));
231
+ const ref = marks(refFloorNo);
232
+ const mine = marks(floorNo);
233
+ const pairs: Array<[TNamed, TNamed]> = [];
234
+ for (const a of ref) for (const b of mine) if (a.name === b.name) pairs.push([a, b]);
235
+ if (pairs.length < 2) {
236
+ return { error: `Place at least two fiducials with the same names on ${floorName(floorNo)} and ${floorName(refFloorNo)} first (they share ${pairs.length}).` } as const;
237
+ }
238
+ const t = fiducialTransform(pairs, 1)!;
239
+ const residual = Math.max(...pairs.map(([a, b]) => {
240
+ const q = applyXform(t, b);
241
+ return Math.hypot(q.x - a.x, q.y - a.y);
242
+ }));
243
+ return { t, pairs: pairs.length, residual, text: `${floorName(floorNo)} lined up with ${floorName(refFloorNo)} on ${pairs.length} fiducials: turned ${((t.rot * 180) / Math.PI).toFixed(1)}°, scaled ${(t.scale * 100).toFixed(1)}%, largest mismatch ${residual.toFixed(2)} m.` } as const;
244
+ }
245
+
246
+ /**
247
+ * Scale by measurement: the measured line's real length gives the factor;
248
+ * the floor's plan (and, when asked, what is drawn on it) grows or shrinks
249
+ * about the plan's top-left corner - so the plan and the drawing stay
250
+ * together. Null factor with a reason when it cannot be done.
251
+ */
252
+ export function measureScale(f: TFrame, measure: TEditorShape, realM: number, placement: TPlanPlacement | null) {
253
+ if (!(realM > 0) || !Number.isFinite(realM)) return { error: "Enter the real length in metres (more than 0)." } as const;
254
+ const [a, b] = measure.points.map((p) => shareToWorld(f, p));
255
+ const drawn = Math.hypot(b!.x - a!.x, b!.y - a!.y);
256
+ if (drawn < 0.05) return { error: "The two ends are too close together - measure a longer length." } as const;
257
+ const k = realM / drawn;
258
+ if (placement && (placement.metersPerPixel * k < 0.0001 || placement.metersPerPixel * k > 10)) {
259
+ return { error: "That length gives an impossible scale. Check the two ends and the number." } as const;
260
+ }
261
+ const o = placement ? { x: placement.tx, y: -placement.ty } : { x: f.minX, y: f.maxY };
262
+ // x' = k (x - o) + o, as a transform (no turn).
263
+ const t = { scale: k, rot: 0, tx: o.x * (1 - k), ty: o.y * (1 - k) };
264
+ return { k, t, placement: placement ? { ...placement, metersPerPixel: Math.round(placement.metersPerPixel * k * 1e6) / 1e6 } : null } as const;
265
+ }
266
+
267
+ /* ------------------------------------------------------- lift cabin doors */
268
+
269
+ export type TCabinDoor = { name: string; type: (typeof LIFT_DOOR_TYPES)[number]; x: number; y: number; widthM: number; axisYawRad: number };
270
+ export type TLiftSide = "front" | "back" | "left" | "right";
271
+ export const LIFT_SIDES: TLiftSide[] = ["front", "back", "right", "left"];
272
+ export const LIFT_SIDE_LABEL: Record<TLiftSide, string> = { front: "Front", back: "Back", right: "Right", left: "Left" };
273
+
274
+ /** A cabin door on one side of the cabin (cabin frame: x across the width, y along the depth; lift.py sides). */
275
+ export function cabinDoorOn(side: TLiftSide, widthM: number, depthM: number, door: Partial<TCabinDoor> = {}): TCabinDoor {
276
+ const width = Math.min(door.widthM ?? Math.min(1.2, (side === "front" || side === "back" ? widthM : depthM) * 0.8), 10);
277
+ const pos: Record<TLiftSide, { x: number; y: number; a: number }> = {
278
+ front: { x: 0, y: depthM / 2, a: 0 },
279
+ back: { x: 0, y: -depthM / 2, a: 0 },
280
+ right: { x: widthM / 2, y: 0, a: Math.PI / 2 },
281
+ left: { x: -widthM / 2, y: 0, a: Math.PI / 2 },
282
+ };
283
+ const p = pos[side];
284
+ return { name: door.name ?? side, type: door.type ?? "double_sliding", x: r3(p.x), y: r3(p.y), widthM: r3(width), axisYawRad: r3(p.a) };
285
+ }
286
+
287
+ /** Which side of the cabin a door is on - lift.py's rule (nearest side wall). */
288
+ export function cabinDoorSide(d: Pick<TCabinDoor, "x" | "y">, widthM: number, depthM: number): TLiftSide {
289
+ const dx = Math.abs(Math.abs(d.x) - widthM / 2);
290
+ const dy = Math.abs(Math.abs(d.y) - depthM / 2);
291
+ if (dx <= dy) return d.x > 0 ? "right" : "left";
292
+ return d.y > 0 ? "front" : "back";
293
+ }
294
+
295
+ /** A lift's default cabin door, as the server gives a lift saved without one (core `mergeLegacyIntoRmf`). */
296
+ export const defaultCabinDoor = (widthM: number, depthM: number): TCabinDoor => ({
297
+ name: "door1",
298
+ type: "double_sliding",
299
+ x: r3(widthM / 2),
300
+ y: 0,
301
+ widthM: r3(Math.min(1.2, depthM * 0.8)),
302
+ axisYawRad: r3(Math.PI / 2),
303
+ });
304
+
305
+ /* --------------------------------------------------- map -> editor model */
306
+
307
+ export type TLevelMeta = { floorNo: number; name: string; elevationM: number; floors: string[][]; holes: string[][]; floorPolys: TPt[][]; holePolys: TPt[][] };
308
+ export type TEditorModel = {
309
+ shapes: TEditorShape[];
310
+ frame: TFrame;
311
+ graphs: TRouteGraph[];
312
+ /** Per floor: name, height and the floor / hole outlines the editor does not draw (kept on save). */
313
+ levels: Record<string, { name: string; elevationM: number; floorPolys: TPt[][]; holePolys: TPt[][] }>;
314
+ referenceLevel?: string;
315
+ source?: string;
316
+ };
317
+
318
+ let seq = 0;
319
+ const nid = (k: string) => `${k}${(seq++).toString(36)}`;
320
+
321
+ /**
322
+ * A saved map as the editor's shapes. A map with an `rmf` part opens from it
323
+ * (lanes, waypoints, fiducials, measurements, door types, lift angle and
324
+ * cabin doors); an older map opens from its walls, zones, doors and lifts in
325
+ * the 40 x 26 m frame, exactly as before.
326
+ */
327
+ export function editorModelFromMap(map: Record<string, any> | null | undefined, frame?: TFrame): TEditorModel {
328
+ const rmf = map?.rmf;
329
+ const levels: TEditorModel["levels"] = {};
330
+ if (!rmf?.levels?.length) {
331
+ const shapes = shapesFromMap(map).map((s) => {
332
+ if (s.kind === "lift") {
333
+ const w = (shapeW(s) * 40) / 100;
334
+ const d = (shapeH(s) * 26) / 100;
335
+ const door = defaultCabinDoor(w, d);
336
+ return { ...s, meta: { ...(s.meta ?? {}), yawRad: 0, doors: [door], levelDoors: Object.fromEntries((s.floors ?? []).map((fl) => [String(fl), [door.name]])) } };
337
+ }
338
+ return s;
339
+ });
340
+ for (const l of (map?.levels ?? []) as Array<Record<string, any>>) {
341
+ levels[String(l.floorNo)] = { name: String(l.name || floorName(l.floorNo)), elevationM: typeof l.elevationM === "number" ? l.elevationM : l.floorNo * 4, floorPolys: [], holePolys: [] };
342
+ }
343
+ const f0 = frame ?? { ...DEFAULT_FRAME };
344
+ return { shapes: f0 === DEFAULT_FRAME || sameFrame(f0, DEFAULT_FRAME) ? shapes : reframe(shapes, DEFAULT_FRAME, f0), frame: f0, graphs: [], levels };
345
+ }
346
+ const f = frame ?? frameForMap(map);
347
+ const at = (p: TPt) => r6(worldToShare(f, p));
348
+ const shapes: TEditorShape[] = [];
349
+ for (const lv of rmf.levels as Array<Record<string, any>>) {
350
+ const floorNo = Number(lv.floorNo);
351
+ const vx = new Map<string, Record<string, any>>((lv.vertices ?? []).map((v: any) => [String(v.id), v]));
352
+ const p = (id: string) => {
353
+ const v = vx.get(id);
354
+ return v ? at(v as TPt) : null;
355
+ };
356
+ for (const w of lv.walls ?? []) {
357
+ const a = p(w.v1);
358
+ const b = p(w.v2);
359
+ if (!a || !b) continue;
360
+ const { v1: _a, v2: _b, ...look } = w;
361
+ shapes.push({ id: nid("w"), kind: "wall", floorNo, points: [a, b], ...(Object.keys(look).length ? { meta: look } : {}) });
362
+ }
363
+ for (const z of lv.zones ?? []) {
364
+ const pts = (z.vertices ?? []).map(p).filter(Boolean) as TPt[];
365
+ if (pts.length >= 3) shapes.push({ id: nid("z"), kind: "zone", floorNo, name: String(z.name ?? ""), points: pts });
366
+ }
367
+ for (const d of lv.doors ?? []) {
368
+ const a = p(d.v1);
369
+ const b = p(d.v2);
370
+ if (!a || !b) continue;
371
+ const { v1: _a, v2: _b, ...meta } = d;
372
+ shapes.push({ id: nid("d"), kind: "door", floorNo, points: [a, b], meta });
373
+ }
374
+ // A waypoint for every vertex a lane uses or that has a name / settings.
375
+ const wp = new Map<string, string>();
376
+ const used = new Set<string>((lv.lanes ?? []).flatMap((l: any) => [String(l.v1), String(l.v2)]));
377
+ for (const v of lv.vertices ?? []) {
378
+ const id = String(v.id);
379
+ const params = v.params && Object.keys(v.params).length ? v.params : null;
380
+ if (!used.has(id) && !v.name && !params) continue;
381
+ const sid = nid("p");
382
+ wp.set(id, sid);
383
+ shapes.push({ id: sid, kind: "waypoint", floorNo, points: [at(v)], name: String(v.name ?? ""), meta: { params: { ...(params ?? {}) } } });
384
+ }
385
+ for (const l of lv.lanes ?? []) {
386
+ const a = wp.get(String(l.v1));
387
+ const b = wp.get(String(l.v2));
388
+ const pa = p(l.v1);
389
+ const pb = p(l.v2);
390
+ if (!a || !b || !pa || !pb) continue;
391
+ const { v1: _a, v2: _b, ...opts } = l;
392
+ shapes.push({ id: nid("n"), kind: "lane", floorNo, points: [pa, pb], meta: { ...opts, from: a, to: b } });
393
+ }
394
+ for (const m of lv.measurements ?? []) {
395
+ const a = p(m.v1);
396
+ const b = p(m.v2);
397
+ if (a && b) shapes.push({ id: nid("m"), kind: "measure", floorNo, points: [a, b], meta: { distanceM: Number(m.distanceM) } });
398
+ }
399
+ for (const fd of lv.fiducials ?? []) shapes.push({ id: nid("f"), kind: "fiducial", floorNo, points: [at(fd)], name: String(fd.name ?? "") });
400
+ const poly = (list: any[]) => (list ?? []).map((x: any) => (x.vertices ?? []).map((id: string) => vx.get(String(id))).filter(Boolean).map((v: any) => ({ x: v.x, y: v.y })));
401
+ levels[String(floorNo)] = { name: String(lv.name), elevationM: Number(lv.elevationM ?? 0), floorPolys: poly(lv.floors), holePolys: poly(lv.holes) };
402
+ }
403
+ const floorOf = new Map((rmf.levels as Array<Record<string, any>>).map((l) => [String(l.name), Number(l.floorNo)]));
404
+ for (const l of (rmf.lifts ?? []) as Array<Record<string, any>>) {
405
+ const c = worldToShare(f, { x: Number(l.x), y: Number(l.y) });
406
+ const w = (Number(l.widthM) / f.w) * 100;
407
+ const h = (Number(l.depthM) / f.h) * 100;
408
+ const floors: number[] = [...new Set<number>((l.levelDoors ?? []).map((x: any) => floorOf.get(String(x.level))).filter((n: unknown): n is number => typeof n === "number"))].sort((a, b) => a - b);
409
+ const levelDoors = Object.fromEntries((l.levelDoors ?? []).flatMap((x: any) => (floorOf.has(String(x.level)) ? [[String(floorOf.get(String(x.level))), [...(x.doors ?? [])]]] : [])));
410
+ shapes.push({
411
+ id: nid("l"),
412
+ kind: "lift",
413
+ floorNo: floors[0] ?? 1,
414
+ floors,
415
+ points: [
416
+ { x: c.x - w / 2, y: c.y - h / 2 },
417
+ { x: c.x + w / 2, y: c.y - h / 2 },
418
+ { x: c.x + w / 2, y: c.y + h / 2 },
419
+ { x: c.x - w / 2, y: c.y + h / 2 },
420
+ ].map(r6),
421
+ meta: {
422
+ name: l.name,
423
+ yawRad: Number(l.yawRad ?? 0),
424
+ doors: (l.doors ?? []).map((d: any) => ({ ...d })),
425
+ levelDoors,
426
+ ...(l.referenceFloor ? { referenceFloor: l.referenceFloor } : {}),
427
+ ...(l.initialFloor ? { initialFloor: l.initialFloor } : {}),
428
+ ...(l.lowestFloor ? { lowestFloor: l.lowestFloor } : {}),
429
+ ...(l.highestFloor ? { highestFloor: l.highestFloor } : {}),
430
+ ...(l.plugins === false ? { plugins: false } : {}),
431
+ },
432
+ });
433
+ }
434
+ return {
435
+ shapes,
436
+ frame: f,
437
+ graphs: ((rmf.graphs ?? []) as TRouteGraph[]).map((g) => ({ idx: g.idx, name: g.name, brands: [...(g.brands ?? [])] })),
438
+ levels,
439
+ referenceLevel: rmf.referenceLevel,
440
+ source: rmf.source,
441
+ };
442
+ }
443
+
444
+ const shapeW = (s: TEditorShape) => Math.max(...s.points.map((p) => p.x)) - Math.min(...s.points.map((p) => p.x));
445
+ const shapeH = (s: TEditorShape) => Math.max(...s.points.map((p) => p.y)) - Math.min(...s.points.map((p) => p.y));
446
+ const sameFrame = (a: TFrame, b: TFrame) => a.minX === b.minX && a.maxY === b.maxY && a.w === b.w && a.h === b.h;
447
+ function reframe(shapes: TEditorShape[], from: TFrame, to: TFrame) {
448
+ return shapes.map((s) => ({ ...s, points: s.points.map((p) => r6(worldToShare(to, shareToWorld(from, p)))) }));
449
+ }
450
+
451
+ /* --------------------------------------------------- editor model -> map */
452
+
453
+ /**
454
+ * The editor's shapes as the map's `rmf` part - what a save sends (the server
455
+ * derives the older walls / doors / lifts from it for every existing reader).
456
+ * Points closer than 1 cm share one vertex (a door on a wall's end, a lane
457
+ * ending on a waypoint); every floor gets the editor's frame as its plan frame.
458
+ */
459
+ export function rmfFromEditor(model: {
460
+ shapes: TEditorShape[];
461
+ frame: TFrame;
462
+ graphs: TRouteGraph[];
463
+ levels?: TEditorModel["levels"];
464
+ floors?: number[];
465
+ referenceLevel?: string;
466
+ source?: string;
467
+ }) {
468
+ const f = model.frame;
469
+ const planFrame = { minX: r3(f.minX), maxX: r3(f.minX + f.w), minY: r3(f.maxY - f.h), maxY: r3(f.maxY) };
470
+ const floorSet = new Set<number>([
471
+ ...Object.keys(model.levels ?? {}).map(Number),
472
+ ...model.shapes.filter((s) => s.kind !== "lift").map((s) => s.floorNo),
473
+ ...model.shapes.filter((s) => s.kind === "lift").flatMap((s) => s.floors ?? []),
474
+ ]);
475
+ const floors = [...floorSet].filter(Number.isFinite).sort((a, b) => a - b);
476
+ const levelName = (fl: number) => model.levels?.[String(fl)]?.name || floorName(fl);
477
+ // Centimetres in a frame up to 40 x 26 m (its shares are 2-decimal, as the older save body was), millimetres in a bigger one.
478
+ const rw = f.w > 40 || f.h > 26 ? r3 : (v: number) => Math.round(v * 100) / 100;
479
+ const world = (p: TPt) => {
480
+ const w = shareToWorld(f, p);
481
+ return { x: rw(w.x), y: rw(w.y) };
482
+ };
483
+ const doorNames: string[] = model.shapes.filter((s) => s.kind === "door" && s.meta?.name).map((s) => String(s.meta!.name));
484
+ const liftNames: string[] = model.shapes.filter((s) => s.kind === "lift" && s.meta?.name).map((s) => String(s.meta!.name));
485
+ const nextName = (list: string[], word: string, alpha: boolean) => {
486
+ for (let i = 1; ; i++) {
487
+ const n = `${word} ${alpha ? String.fromCharCode(64 + (((i - 1) % 26) + 1)) + (i > 26 ? Math.ceil(i / 26) : "") : i}`;
488
+ if (!list.includes(n)) {
489
+ list.push(n);
490
+ return n;
491
+ }
492
+ }
493
+ };
494
+
495
+ const levels = floors.map((floorNo) => {
496
+ const meta = model.levels?.[String(floorNo)];
497
+ const vertices: Array<{ id: string; x: number; y: number; name?: string; params?: Record<string, unknown> }> = [];
498
+ let next = 1;
499
+ const vid = (p: TPt, extra?: { name?: string; params?: Record<string, unknown> }) => {
500
+ const q = { x: r3(p.x), y: r3(p.y) };
501
+ const hit = vertices.find((v) => Math.abs(v.x - q.x) < WELD_M && Math.abs(v.y - q.y) < WELD_M);
502
+ if (hit) {
503
+ if (extra?.name && !hit.name) hit.name = extra.name;
504
+ if (extra?.params && Object.keys(extra.params).length) hit.params = { ...(hit.params ?? {}), ...extra.params };
505
+ return hit.id;
506
+ }
507
+ const v = { id: `v${next++}`, ...q, ...(extra?.name ? { name: extra.name } : {}), ...(extra?.params && Object.keys(extra.params).length ? { params: extra.params } : {}) };
508
+ vertices.push(v);
509
+ return v.id;
510
+ };
511
+ const mine = model.shapes.filter((s) => s.floorNo === floorNo && s.kind !== "lift");
512
+ // Waypoints first, so their names and settings sit on the vertex a lane or wall shares.
513
+ const wpVertex = new Map<string, string>();
514
+ for (const s of mine.filter((x) => x.kind === "waypoint")) {
515
+ wpVertex.set(s.id, vid(world(s.points[0]!), { name: (s.name ?? "").trim() || undefined, params: cleanParams(s.meta?.params) }));
516
+ }
517
+ const walls = mine.filter((s) => s.kind === "wall").map((s) => ({ v1: vid(world(s.points[0]!)), v2: vid(world(s.points[1]!)), ...wallLook(s.meta) }));
518
+ const doors = mine
519
+ .filter((s) => s.kind === "door")
520
+ .map((s) => {
521
+ const m = s.meta ?? {};
522
+ return {
523
+ name: m.name ? String(m.name) : nextName(doorNames, "Door", false),
524
+ v1: vid(world(s.points[0]!)),
525
+ v2: vid(world(s.points[1]!)),
526
+ type: (DOOR_TYPES as readonly string[]).includes(m.type) ? m.type : "hinged",
527
+ ...(m.motionAxis === "end" ? { motionAxis: "end" } : {}),
528
+ ...(m.motionDegrees !== undefined && Number(m.motionDegrees) !== 90 ? { motionDegrees: Number(m.motionDegrees) } : {}),
529
+ ...(Number(m.motionDirection ?? 1) < 0 ? { motionDirection: -1 } : {}),
530
+ ...(m.rightLeftRatio !== undefined && Number(m.rightLeftRatio) !== 1 ? { rightLeftRatio: Number(m.rightLeftRatio) } : {}),
531
+ ...(m.plugin && m.plugin !== "normal" ? { plugin: String(m.plugin) } : {}),
532
+ };
533
+ });
534
+ const lanes = mine
535
+ .filter((s) => s.kind === "lane")
536
+ .flatMap((s) => {
537
+ const m = s.meta ?? {};
538
+ const v1 = wpVertex.get(String(m.from));
539
+ const v2 = wpVertex.get(String(m.to));
540
+ if (!v1 || !v2 || v1 === v2) return [];
541
+ return [{
542
+ v1,
543
+ v2,
544
+ graphIdx: Math.max(0, Math.min(8, Number(m.graphIdx ?? 0))),
545
+ bidirectional: m.bidirectional !== false,
546
+ ...(m.orientation === "forward" || m.orientation === "backward" ? { orientation: m.orientation } : {}),
547
+ ...(Number(m.speedLimit) > 0 ? { speedLimit: Number(m.speedLimit) } : {}),
548
+ ...(m.mutex ? { mutex: String(m.mutex).trim() } : {}),
549
+ ...(m.demoMockFloorName ? { demoMockFloorName: String(m.demoMockFloorName).trim() } : {}),
550
+ ...(m.demoMockLiftName ? { demoMockLiftName: String(m.demoMockLiftName).trim() } : {}),
551
+ }];
552
+ });
553
+ const measurements = mine
554
+ .filter((s) => s.kind === "measure" && Number(s.meta?.distanceM) >= 0.01)
555
+ .map((s) => ({ v1: vid(world(s.points[0]!)), v2: vid(world(s.points[1]!)), distanceM: Math.round(Number(s.meta!.distanceM) * 1000) / 1000 }));
556
+ const zones = mine.filter((s) => s.kind === "zone").map((s) => ({ name: (s.name ?? "").trim(), vertices: s.points.map((p) => vid(world(p))) }));
557
+ const floorsPoly = (meta?.floorPolys ?? []).map((poly) => ({ vertices: poly.map((p) => vid(p)) })).filter((x) => x.vertices.length >= 3);
558
+ const holesPoly = (meta?.holePolys ?? []).map((poly) => ({ vertices: poly.map((p) => vid(p)) })).filter((x) => x.vertices.length >= 3);
559
+ const fiducials = mine.filter((s) => s.kind === "fiducial").map((s) => ({ name: (s.name ?? "").trim() || "fiducial", ...world(s.points[0]!) }));
560
+ return {
561
+ name: levelName(floorNo),
562
+ floorNo,
563
+ elevationM: meta?.elevationM ?? floorNo * 4,
564
+ planFrame: { ...planFrame },
565
+ vertices,
566
+ walls,
567
+ floors: floorsPoly,
568
+ holes: holesPoly,
569
+ doors,
570
+ lanes,
571
+ measurements,
572
+ fiducials,
573
+ zones,
574
+ };
575
+ });
576
+
577
+ const nameOf = new Map(levels.map((l) => [l.floorNo, l.name]));
578
+ const lifts = model.shapes
579
+ .filter((s) => s.kind === "lift")
580
+ .map((s) => {
581
+ const m = s.meta ?? {};
582
+ const xs = s.points.map((p) => p.x);
583
+ const ys = s.points.map((p) => p.y);
584
+ const c = world({ x: (Math.min(...xs) + Math.max(...xs)) / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2 });
585
+ const widthM = r3(Math.min(20, Math.max(0.3, ((Math.max(...xs) - Math.min(...xs)) / 100) * f.w)));
586
+ const depthM = r3(Math.min(20, Math.max(0.3, ((Math.max(...ys) - Math.min(...ys)) / 100) * f.h)));
587
+ const doors: TCabinDoor[] = (Array.isArray(m.doors) && m.doors.length ? m.doors : [defaultCabinDoor(widthM, depthM)]).slice(0, 8);
588
+ const doorSet = new Set(doors.map((d) => d.name));
589
+ const served = [...new Set(s.floors ?? [])].filter((fl) => nameOf.has(fl)).sort((a, b) => a - b);
590
+ const levelDoors = served.map((fl) => {
591
+ const list = (m.levelDoors ?? {})[String(fl)];
592
+ return { level: nameOf.get(fl)!, doors: (Array.isArray(list) ? list : doors.map((d) => d.name)).filter((d: string) => doorSet.has(d)) };
593
+ });
594
+ const servedNames = new Set(levelDoors.map((x) => x.level));
595
+ const keep = (v: unknown) => (typeof v === "string" && servedNames.has(v) ? v : undefined);
596
+ return {
597
+ name: m.name ? String(m.name) : nextName(liftNames, "Lift", true),
598
+ x: c.x,
599
+ y: c.y,
600
+ yawRad: Math.round(Number(m.yawRad ?? 0) * 10000) / 10000,
601
+ widthM,
602
+ depthM,
603
+ referenceFloor: keep(m.referenceFloor) ?? levelDoors[0]?.level ?? "",
604
+ ...(keep(m.initialFloor) ? { initialFloor: keep(m.initialFloor) } : {}),
605
+ ...(keep(m.lowestFloor) ? { lowestFloor: keep(m.lowestFloor) } : {}),
606
+ ...(keep(m.highestFloor) ? { highestFloor: keep(m.highestFloor) } : {}),
607
+ ...(m.plugins === false ? { plugins: false } : {}),
608
+ doors: doors.map((d) => ({ name: d.name, type: d.type, x: r3(d.x), y: r3(d.y), widthM: r3(d.widthM), axisYawRad: r3(d.axisYawRad) })),
609
+ levelDoors,
610
+ };
611
+ });
612
+
613
+ const usedGraphs = new Set(levels.flatMap((l) => l.lanes.map((x) => x.graphIdx)));
614
+ const graphs = [...model.graphs.filter((g) => g.idx >= 0 && g.idx <= 8)];
615
+ for (const idx of usedGraphs) if (!graphs.some((g) => g.idx === idx)) graphs.push({ idx, name: `Graph ${idx}`, brands: [] });
616
+ graphs.sort((a, b) => a.idx - b.idx);
617
+ const names = new Set(levels.map((l) => l.name));
618
+ return {
619
+ formatVersion: 1 as const,
620
+ coordinateSystem: "cartesian_meters" as const,
621
+ source: (model.source === "fleet-core" || model.source === "file" ? model.source : "editor") as "fleet-core" | "file" | "editor",
622
+ referenceLevel: model.referenceLevel && names.has(model.referenceLevel) ? model.referenceLevel : levels[0]?.name ?? "",
623
+ graphs: graphs.map((g) => ({ idx: g.idx, name: (g.name || `Graph ${g.idx}`).slice(0, 60), brands: (g.brands ?? []).slice(0, 20) })),
624
+ levels,
625
+ lifts,
626
+ };
627
+ }
628
+
629
+ function cleanParams(raw: unknown): Record<string, unknown> {
630
+ const out: Record<string, unknown> = {};
631
+ if (!raw || typeof raw !== "object") return out;
632
+ for (const k of VERTEX_PARAMS) {
633
+ const v = (raw as Record<string, unknown>)[k];
634
+ if (v === undefined || v === null || v === "" || v === false) continue;
635
+ if (k.startsWith("is_")) out[k] = true;
636
+ else if (k === "merge_radius") {
637
+ if (Number(v) > 0) out[k] = Math.min(100, Number(v));
638
+ } else out[k] = String(v).trim().slice(0, 60);
639
+ }
640
+ return out;
641
+ }
642
+
643
+ function wallLook(meta: Record<string, any> | undefined) {
644
+ const out: Record<string, unknown> = {};
645
+ for (const k of ["textureName", "alpha", "textureHeight", "textureWidth", "textureScale"]) if (meta?.[k] !== undefined) out[k] = meta[k];
646
+ return out;
647
+ }
648
+
649
+ /* ----------------------------------------------- 3D view and mini-map */
650
+
651
+ export type TRouteOverlay = {
652
+ lanes: Array<{ a: TPt; b: TPt; oneWay: boolean; graph: number | string }>;
653
+ waypoints: Array<TPt & { name: string; kind: "charger" | "parking" | "holding" | "point" }>;
654
+ fiducials: Array<TPt & { name: string }>;
655
+ };
656
+
657
+ /**
658
+ * One floor's robot routes for the 3D view and the mini-map, in shares of
659
+ * the floor's plan frame (the same shares the map's walls are drawn in): the
660
+ * saved map's lanes and waypoints, or the live HERO Fleet Core building's.
661
+ */
662
+ export function routeOverlay(map: Record<string, any> | null | undefined, floorNo: number): TRouteOverlay {
663
+ const out: TRouteOverlay = { lanes: [], waypoints: [], fiducials: [] };
664
+ if (!map) return out;
665
+ const kindOf = (p: Record<string, unknown>) => (p.is_charger ? "charger" : p.is_parking_spot ? "parking" : p.is_holding_point ? "holding" : "point");
666
+ const lv = (map.rmf?.levels ?? []).find((l: any) => Number(l.floorNo) === floorNo);
667
+ if (lv) {
668
+ const pf = lv.planFrame ?? { minX: 0, maxX: 40, minY: -26, maxY: 0 };
669
+ const f: TFrame = { minX: pf.minX, maxY: pf.maxY, w: pf.maxX - pf.minX || 1, h: pf.maxY - pf.minY || 1 };
670
+ const v = new Map<string, any>((lv.vertices ?? []).map((x: any) => [String(x.id), x]));
671
+ for (const l of lv.lanes ?? []) {
672
+ const a = v.get(String(l.v1));
673
+ const b = v.get(String(l.v2));
674
+ if (a && b) out.lanes.push({ a: r2(worldToShare(f, a)), b: r2(worldToShare(f, b)), oneWay: l.bidirectional === false, graph: Number(l.graphIdx ?? 0) });
675
+ }
676
+ for (const x of lv.vertices ?? []) {
677
+ if (x.name || (x.params && Object.keys(x.params).length)) out.waypoints.push({ ...r2(worldToShare(f, x)), name: String(x.name ?? ""), kind: kindOf(x.params ?? {}) });
678
+ }
679
+ for (const x of lv.fiducials ?? []) out.fiducials.push({ ...r2(worldToShare(f, x)), name: String(x.name ?? "") });
680
+ return out;
681
+ }
682
+ // The live fleet building: lanes and waypoints already in the 40 x 26 m plan frame.
683
+ const level = (map.levels ?? []).find((l: any) => Number(l.floorNo) === floorNo);
684
+ const share = (p: TPt) => r2({ x: (p.x / 40) * 100, y: (p.y / 26) * 100 });
685
+ for (const l of level?.lanes ?? []) out.lanes.push({ a: share(l.start), b: share(l.end), oneWay: !!l.oneWay, graph: l.graph ?? 0 });
686
+ for (const w of level?.waypoints ?? []) out.waypoints.push({ ...share(w), name: String(w.name ?? ""), kind: w.charger ? "charger" : w.parking ? "parking" : w.holding ? "holding" : "point" });
687
+ return out;
688
+ }
689
+
690
+ /** The plan frame a floor's shapes are drawn in, for the 3D view's plane shape: width / height in metres. */
691
+ export function floorAspect(map: Record<string, any> | null | undefined): number {
692
+ const pf = map?.rmf?.levels?.[0]?.planFrame;
693
+ if (pf && pf.maxX > pf.minX && pf.maxY > pf.minY) return (pf.maxX - pf.minX) / (pf.maxY - pf.minY);
694
+ return 40 / 26;
695
+ }
696
+
697
+ /**
698
+ * The saved map's doors of one floor for the 3D view, with their Open-RMF
699
+ * type (the older `doors` list only knows hinged / sliding), and each lift's
700
+ * cabin doors that open on this floor - in shares of the floor's frame.
701
+ */
702
+ export function floorDoors(map: Record<string, any> | null | undefined, floorNo: number) {
703
+ const lv = (map?.rmf?.levels ?? []).find((l: any) => Number(l.floorNo) === floorNo);
704
+ if (!lv) return null;
705
+ const pf = lv.planFrame;
706
+ const f: TFrame = { minX: pf.minX, maxY: pf.maxY, w: pf.maxX - pf.minX || 1, h: pf.maxY - pf.minY || 1 };
707
+ const v = new Map<string, any>((lv.vertices ?? []).map((x: any) => [String(x.id), x]));
708
+ const doors = (lv.doors ?? []).flatMap((d: any) => {
709
+ const a = v.get(String(d.v1));
710
+ const b = v.get(String(d.v2));
711
+ return a && b ? [{ id: `${floorNo}-${d.name}`, kind: "door" as const, floorNo, points: [r2(worldToShare(f, a)), r2(worldToShare(f, b))], meta: { ...d } }] : [];
712
+ });
713
+ const cabin: Array<{ lift: string; a: TPt; b: TPt }> = [];
714
+ for (const l of (map?.rmf?.lifts ?? []) as Array<Record<string, any>>) {
715
+ const open = new Set(((l.levelDoors ?? []).find((x: any) => x.level === lv.name)?.doors ?? []) as string[]);
716
+ for (const d of (l.doors ?? []) as TCabinDoor[]) {
717
+ if (!open.has(d.name)) continue;
718
+ const turn = (p: TPt, a: number) => ({ x: p.x * Math.cos(a) - p.y * Math.sin(a), y: p.x * Math.sin(a) + p.y * Math.cos(a) });
719
+ const half = turn({ x: d.widthM / 2, y: 0 }, d.axisYawRad);
720
+ const c = turn({ x: d.x, y: d.y }, Number(l.yawRad ?? 0));
721
+ const h = turn(half, Number(l.yawRad ?? 0));
722
+ const mid = { x: Number(l.x) + c.x, y: Number(l.y) + c.y };
723
+ cabin.push({ lift: String(l.name), a: r2(worldToShare(f, { x: mid.x - h.x, y: mid.y - h.y })), b: r2(worldToShare(f, { x: mid.x + h.x, y: mid.y + h.y })) });
724
+ }
725
+ }
726
+ return { doors, cabin, frame: f };
727
+ }
728
+
729
+ /**
730
+ * The lift shafts for the 3D view, in shares of the floor frame: from the
731
+ * map's `rmf` part when it has one (real size and angle - the older `lifts`
732
+ * list is squeezed into 40 x 26 m and has no angle), else from `lifts`.
733
+ */
734
+ export function viewerLifts(map: Record<string, any> | null | undefined): Array<{ name: string; x: number; y: number; w: number; d: number; yawDeg: number; floors: number[] }> {
735
+ const rmf = map?.rmf;
736
+ if (rmf?.levels?.length && rmf.lifts?.length) {
737
+ const byName = new Map((rmf.levels as Array<Record<string, any>>).map((l) => [String(l.name), l]));
738
+ return (rmf.lifts as Array<Record<string, any>>).map((l) => {
739
+ const ref = byName.get(String(l.referenceFloor)) ?? rmf.levels[0];
740
+ const pf = ref.planFrame;
741
+ const f: TFrame = { minX: pf.minX, maxY: pf.maxY, w: pf.maxX - pf.minX || 1, h: pf.maxY - pf.minY || 1 };
742
+ const c = worldToShare(f, { x: Number(l.x), y: Number(l.y) });
743
+ const floors: number[] = (l.levelDoors ?? []).map((x: any) => Number(byName.get(String(x.level))?.floorNo)).filter(Number.isFinite);
744
+ return { name: String(l.name), x: c.x, y: c.y, w: (Number(l.widthM) / f.w) * 100, d: (Number(l.depthM) / f.h) * 100, yawDeg: (Number(l.yawRad ?? 0) * 180) / Math.PI, floors };
745
+ });
746
+ }
747
+ return ((map?.lifts ?? []) as Array<Record<string, any>>).map((l) => ({
748
+ name: String(l.name),
749
+ x: (Number(l.x) / 40) * 100,
750
+ y: (Number(l.y) / 26) * 100,
751
+ w: (Number(l.widthM) / 40) * 100,
752
+ d: (Number(l.depthM) / 26) * 100,
753
+ yawDeg: 0,
754
+ floors: [...(l.floors ?? [])],
755
+ }));
756
+ }