@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.
@@ -136,11 +136,12 @@ export function buildingFloors(robotFloors: number[], map?: Record<string, any>
136
136
  /* ============================================================ plan frame */
137
137
 
138
138
  /**
139
- * The HERO plan is drawn in shares of the floor (0-100); the API stores site
140
- * metres. The plan frame is fixed at 40 x 26 m (the 400 x 260 plane's shape),
141
- * so a saved shape lands in the same place every time it is opened.
142
- * ponytail: fixed frame; use the robot's reported plan extent once brands
143
- * report one (HERO-PENDING_INTEGRATIONS "Plan position").
139
+ * The HERO plan is drawn in shares of the floor (0-100); the API stores
140
+ * metres. A map saved before the building-file work used a fixed 40 x 26 m
141
+ * frame (decision 30) - `toMetres` / `toShare` and the legacy save body keep
142
+ * that frame so old maps open exactly where they were. The editor itself works
143
+ * in shares of the building's REAL frame (`TFrame`: world metres, any size),
144
+ * so a plan bigger than 40 x 26 m is no longer cut at the edge.
144
145
  */
145
146
  export const PLAN_W_M = 40;
146
147
  export const PLAN_H_M = 26;
@@ -148,12 +149,34 @@ const round2 = (n: number) => Math.round(n * 100) / 100;
148
149
  export const toMetres = (p: { x: number; y: number }): TMapPointM => ({ x: round2((p.x / 100) * PLAN_W_M), y: round2((p.y / 100) * PLAN_H_M) });
149
150
  export const toShare = (p: { x: number; y: number }) => ({ x: round2((p.x / PLAN_W_M) * 100), y: round2((p.y / PLAN_H_M) * 100) });
150
151
 
152
+ /** The frame the editor draws: its top-left corner in world metres (y up) and its size. */
153
+ export type TFrame = { minX: number; maxY: number; w: number; h: number };
154
+ /** The frame of a map drawn before the building-file work: 40 x 26 m from the world origin. */
155
+ export const DEFAULT_FRAME: TFrame = { minX: 0, maxY: 0, w: PLAN_W_M, h: PLAN_H_M };
156
+ type TSize = { w: number; h: number };
157
+
151
158
  /* ================================================================ editor */
152
159
 
153
- export type TEditorTool = "wall" | "zone" | "door" | "lift" | "move" | "delete";
154
- export type TShapeKind = "wall" | "zone" | "door" | "lift";
160
+ export type TEditorTool = "wall" | "zone" | "door" | "lift" | "move" | "delete" | "waypoint" | "lane" | "fiducial" | "measure";
161
+ export type TShapeKind = "wall" | "zone" | "door" | "lift" | "waypoint" | "lane" | "fiducial" | "measure";
155
162
 
156
- /** One drawn element, in plan shares. A lift runs through `floors`; the rest sit on `floorNo`. */
163
+ /**
164
+ * The robot-route tools, beside the drawing tools. Kept out of `MAP_TOOLS`
165
+ * (robot-display.ts), which is copied verbatim into the mobile apps.
166
+ */
167
+ export const ROUTE_TOOLS: Array<{ id: TEditorTool; label: string }> = [
168
+ { id: "waypoint", label: "Waypoint" },
169
+ { id: "lane", label: "Lane" },
170
+ { id: "fiducial", label: "Fiducial" },
171
+ { id: "measure", label: "Measure" },
172
+ ];
173
+
174
+ /**
175
+ * One drawn element, in plan shares. A lift runs through `floors`; the rest sit on `floorNo`.
176
+ * - waypoint / fiducial: one point; `name`; a waypoint's `meta.params` are its robot settings.
177
+ * - lane: two points copied from its waypoints `meta.from` / `meta.to` (kept in step by `syncLanes`).
178
+ * - measure: two points and `meta.distanceM`, the real length between them.
179
+ */
157
180
  export type TEditorShape = {
158
181
  id: string;
159
182
  kind: TShapeKind;
@@ -161,8 +184,8 @@ export type TEditorShape = {
161
184
  points: Array<{ x: number; y: number }>;
162
185
  name?: string;
163
186
  floors?: number[];
164
- /** What the API had that the editor does not draw (door type), kept on save. */
165
- meta?: Record<string, unknown>;
187
+ /** What the shape carries beyond its outline (door type and motion, lift angle and cabin doors, lane options...), kept on save. */
188
+ meta?: Record<string, any>;
166
189
  };
167
190
 
168
191
  /** A drag handle: the eight box handles (corners + edges). */
@@ -175,6 +198,14 @@ export type TDragOp =
175
198
  | { kind: "vertex"; id: string; index: number }
176
199
  | { kind: "rotate"; id: string };
177
200
 
201
+ /** A route graph: one road network (Open-RMF graph_idx 0-8) and the robot brands that drive it. */
202
+ export type TRouteGraph = { idx: number; name: string; brands: string[] };
203
+ /** Where a floor-plan image sits: top-left at world (tx, -ty), `metersPerPixel`, turned `yawRad` about that corner. */
204
+ export type TPlanPlacement = { metersPerPixel: number; tx: number; ty: number; yawRad?: number };
205
+
206
+ /** What undo / redo put back: the drawing and everything saved with it. */
207
+ type TSnapshot = { shapes: TEditorShape[]; frame: TFrame; graphs: TRouteGraph[]; plans: Record<string, TPlanPlacement> };
208
+
178
209
  export type TEditorState = {
179
210
  shapes: TEditorShape[];
180
211
  undo: string[];
@@ -188,10 +219,22 @@ export type TEditorState = {
188
219
  drag: { base: TEditorShape[]; op: TDragOp; x: number; y: number } | null;
189
220
  notice: { text: string; ok: boolean } | null;
190
221
  dirty: boolean;
222
+ /** The building's frame (every floor shares it). */
223
+ frame: TFrame;
224
+ /** The route graphs lanes belong to. */
225
+ graphs: TRouteGraph[];
226
+ /** Floor-plan placements changed here and not saved yet, by floor number. */
227
+ plans: Record<string, TPlanPlacement>;
228
+ /** Lane tool: the waypoint the next lane starts from. */
229
+ laneFrom: string | null;
230
+ /** Measure tool: the first end clicked. */
231
+ measureFrom: { x: number; y: number } | null;
232
+ /** The field last typed into - typing in one field is one undo step. */
233
+ lastEdit: string | null;
191
234
  };
192
235
 
193
236
  export type TEditorAction =
194
- | { type: "place"; tool: TEditorTool; floorNo: number; x: number; y: number; zoneName?: string; floors?: number[]; id?: string }
237
+ | { type: "place"; tool: TEditorTool; floorNo: number; x: number; y: number; zoneName?: string; floors?: number[]; id?: string; name?: string }
195
238
  | { type: "pick"; id: string; tool: TEditorTool; zoneName?: string }
196
239
  | { type: "rename"; name: string }
197
240
  | { type: "tool"; tool?: TEditorTool }
@@ -200,6 +243,7 @@ export type TEditorAction =
200
243
  | { type: "notice"; text: string; ok: boolean }
201
244
  | { type: "saved"; text: string }
202
245
  | { type: "load"; shapes: TEditorShape[] }
246
+ | { type: "loadAll"; shapes: TEditorShape[]; frame: TFrame; graphs: TRouteGraph[]; notice?: TEditorState["notice"]; dirty?: boolean }
203
247
  | { type: "select"; ids: string[]; mode?: "replace" | "add" | "toggle" }
204
248
  | { type: "selectAll"; floorNo: number }
205
249
  | { type: "dragStart"; op: TDragOp; x: number; y: number }
@@ -214,15 +258,38 @@ export type TEditorAction =
214
258
  | { type: "flip" }
215
259
  | { type: "turn" }
216
260
  | { type: "addVertex"; id: string; x: number; y: number; within?: number }
217
- | { type: "removeVertex"; id: string; index: number };
218
-
219
- /** The handoff's default sizes (shares of the plan): wall 16 x 1.5, zone 22 x 24, door 3, lift 8 x 14. */
220
- export const SHAPE_SIZE: Record<TShapeKind, { w: number; h: number }> = {
261
+ | { type: "removeVertex"; id: string; index: number }
262
+ /** Lane tool: a click on a waypoint (`hitId`) or on empty plan (a waypoint is made there). */
263
+ | { type: "laneClick"; floorNo: number; x: number; y: number; graphIdx: number; hitId?: string | null; id?: string; laneId?: string }
264
+ | { type: "laneEnd" }
265
+ /** Measure tool: first click marks one end, the second makes the measurement. */
266
+ | { type: "measureClick"; floorNo: number; x: number; y: number; id?: string }
267
+ /** The side panel: a shape's name, floors or meta (merged). Typing into one field is one undo step. */
268
+ | { type: "setProps"; id: string; name?: string; floors?: number[]; meta?: Record<string, any>; field?: string }
269
+ | { type: "graphs"; graphs: TRouteGraph[] }
270
+ /** Replace the drawing in one step (a route file merged in). */
271
+ | { type: "setShapes"; shapes: TEditorShape[]; graphs?: TRouteGraph[]; notice?: { text: string; ok: boolean } }
272
+ /** A new frame; every shape keeps its place in the building. `auto` (the area grown to fit a plan) is not an edit. */
273
+ | { type: "reframe"; frame: TFrame; auto?: boolean }
274
+ /** A floor plan re-placed (scale, position, turn) - saved with the map. */
275
+ | { type: "plan"; floorNo: number; placement: TPlanPlacement }
276
+ /** Move / scale / turn every shape on one floor (and its plan) by a transform in world metres: level alignment and scale-by-measurement. */
277
+ | { type: "transformFloor"; floorNo: number; t: { scale: number; rot: number; tx: number; ty: number }; placement?: TPlanPlacement | null };
278
+
279
+ /** The handoff's default sizes (shares of the 40 x 26 m plan): wall 16 x 1.5, zone 22 x 24, door 3, lift 8 x 14. */
280
+ export const SHAPE_SIZE: Record<"wall" | "zone" | "door" | "lift", { w: number; h: number }> = {
221
281
  wall: { w: 16, h: 1.5 },
222
282
  zone: { w: 22, h: 24 },
223
283
  door: { w: 3, h: 3 },
224
284
  lift: { w: 8, h: 14 },
225
285
  };
286
+ /** The same sizes in metres, so a new shape is the same real size in any frame. */
287
+ const SHAPE_SIZE_M: Record<"wall" | "zone" | "door" | "lift", { w: number; h: number }> = {
288
+ wall: { w: 6.4, h: 0.39 },
289
+ zone: { w: 8.8, h: 6.24 },
290
+ door: { w: 1.2, h: 0.78 },
291
+ lift: { w: 3.2, h: 3.64 },
292
+ };
226
293
 
227
294
  export const TOOL_HINTS: Record<TEditorTool, string> = {
228
295
  wall: "Click to place a wall segment.",
@@ -231,6 +298,10 @@ export const TOOL_HINTS: Record<TEditorTool, string> = {
231
298
  lift: "Click to place a lift shaft; it runs through every floor.",
232
299
  move: "Click to select (Shift adds), drag to move, drag a handle to resize or turn. Drag on empty space to select an area. Shift while dragging turns snapping off.",
233
300
  delete: "Click a shape to delete it.",
301
+ waypoint: "Click to place a waypoint - a named spot robots drive to. Set its name and what it is (charger, parking...) on the right.",
302
+ lane: "Click a waypoint (or empty space) to start, then click the next one to draw a lane; keep clicking to chain. Esc or click the last waypoint again to stop.",
303
+ fiducial: "Click to place a fiducial - a mark you can find on every floor (a column corner). Give it the same name on each floor to line the floors up.",
304
+ measure: "Click both ends of a length you know (a wall, a doorway), then type its real length on the right.",
234
305
  };
235
306
 
236
307
  const clampShare = (n: number) => Math.max(0, Math.min(98, n));
@@ -240,17 +311,42 @@ const rect = (x: number, y: number, w: number, h: number) => [
240
311
  { x: x + w, y: y + h },
241
312
  { x, y: y + h },
242
313
  ];
314
+ const inM = (p: { x: number; y: number }, f: TSize = DEFAULT_FRAME) => ({ x: (p.x * f.w) / 100, y: (p.y * f.h) / 100 });
315
+ const inShare = (p: { x: number; y: number }, f: TSize = DEFAULT_FRAME) => ({ x: (p.x / f.w) * 100, y: (p.y / f.h) * 100 });
316
+ const r2 = (p: { x: number; y: number }) => ({ x: round2(p.x), y: round2(p.y) });
317
+ const r4 = (p: { x: number; y: number }) => ({ x: Math.round(p.x * 1e4) / 1e4, y: Math.round(p.y * 1e4) / 1e4 });
318
+ /** Share rounding: 2 decimals in the 40 x 26 m frame (4 mm, as before); 4 decimals in a bigger one (under 5 mm up to 5 km). */
319
+ const rs = (p: { x: number; y: number }, f: TSize = DEFAULT_FRAME) => (f.w > PLAN_W_M || f.h > PLAN_H_M ? r4(p) : r2(p));
320
+ const clamp100 = (n: number) => Math.max(0, Math.min(100, n));
321
+ /** A turn on screen (y down): positive = clockwise. */
322
+ const rotS = (p: { x: number; y: number }, a: number) => ({ x: p.x * Math.cos(a) - p.y * Math.sin(a), y: p.x * Math.sin(a) + p.y * Math.cos(a) });
323
+ const DEG = Math.PI / 180;
324
+ /** An angle folded into -180..180 degrees (as radians). */
325
+ const wrapRad = (a: number) => {
326
+ const t = ((((a + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
327
+ return Math.round(t * 10000) / 10000;
328
+ };
243
329
 
244
- /** The shape a click drops at (x, y): its top-left there, except a door, which centres on the click. */
245
- export function newShape(kind: TShapeKind, id: string, floorNo: number, x: number, y: number, extra: { name?: string; floors?: number[] } = {}): TEditorShape {
330
+ /** The shape a click drops at (x, y): its top-left there, except a door, which centres on the click; a point shape sits on it. */
331
+ export function newShape(
332
+ kind: TShapeKind,
333
+ id: string,
334
+ floorNo: number,
335
+ x: number,
336
+ y: number,
337
+ extra: { name?: string; floors?: number[] } = {},
338
+ f: TSize = DEFAULT_FRAME,
339
+ ): TEditorShape {
246
340
  const px = clampShare(x);
247
341
  const py = clampShare(y);
248
- const { w, h } = SHAPE_SIZE[kind];
342
+ if (kind === "waypoint" || kind === "fiducial") return { id, kind, floorNo, points: [rs({ x: clamp100(x), y: clamp100(y) }, f)], name: (extra.name ?? "").trim() };
343
+ const size = SHAPE_SIZE_M[kind as keyof typeof SHAPE_SIZE_M] ?? SHAPE_SIZE_M.wall;
344
+ const { x: w, y: h } = inShare({ x: size.w, y: size.h }, f);
249
345
  const points =
250
346
  kind === "wall" ? [{ x: px, y: py + h / 2 }, { x: Math.min(100, px + w), y: py + h / 2 }]
251
347
  : kind === "door" ? [{ x: Math.max(0, px - w / 2), y: py }, { x: Math.min(100, px + w / 2), y: py }]
252
348
  : rect(px, py, Math.min(w, 100 - px), Math.min(h, 100 - py));
253
- const shape: TEditorShape = { id, kind, floorNo, points };
349
+ const shape: TEditorShape = { id, kind, floorNo, points: points.map((q) => rs(q, f)) };
254
350
  if (kind === "zone") shape.name = (extra.name ?? "").trim();
255
351
  if (kind === "lift") shape.floors = extra.floors ?? [floorNo];
256
352
  return shape;
@@ -264,9 +360,9 @@ export function shapeBox(s: TEditorShape) {
264
360
  return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
265
361
  }
266
362
 
267
- /** A shape's anchor: a door's middle, a wall's start (top of its bar), otherwise the top-left corner. */
363
+ /** A shape's anchor: a door's middle, a wall's start (top of its bar), a point's own place, otherwise the top-left corner. */
268
364
  function anchorOf(s: TEditorShape) {
269
- if (s.kind === "door") {
365
+ if (s.kind === "door" || s.kind === "lane" || s.kind === "measure") {
270
366
  const [a, b] = s.points;
271
367
  return { x: ((a?.x ?? 0) + (b?.x ?? 0)) / 2, y: ((a?.y ?? 0) + (b?.y ?? 0)) / 2 };
272
368
  }
@@ -280,10 +376,17 @@ export function shapeAnchor(s: TEditorShape) {
280
376
  return { x: Math.round(a.x), y: Math.round(a.y) };
281
377
  }
282
378
 
283
- /** "Wall" · "Door" · "Lift shaft (all floors)" · "Zone · Lobby" / "Zone · unnamed" */
379
+ const DOOR_TYPE_WORDS: Record<string, string> = { hinged: "single swing", double_hinged: "double swing", sliding: "single sliding", double_sliding: "double sliding" };
380
+
381
+ /** "Wall" · "Door" · "Lift shaft (all floors)" · "Zone · Lobby" · "Waypoint · Lobby" · "Lane" · "Fiducial · F1" · "Measurement · 12.5 m" */
284
382
  export function shapeTitle(s: TEditorShape): string {
285
383
  if (s.kind === "zone") return `Zone · ${s.name || "unnamed"}`;
286
384
  if (s.kind === "lift") return "Lift shaft (all floors)";
385
+ if (s.kind === "waypoint") return s.name ? `Waypoint · ${s.name}` : "Waypoint";
386
+ if (s.kind === "fiducial") return `Fiducial · ${s.name || "unnamed"}`;
387
+ if (s.kind === "measure") return `Measurement · ${Number(s.meta?.distanceM ?? 0).toFixed(2)} m`;
388
+ if (s.kind === "lane") return s.meta?.bidirectional === false ? "Lane (one-way)" : "Lane (two-way)";
389
+ if (s.kind === "door" && s.meta?.type && s.meta.type !== "hinged" && s.meta.type !== "sliding") return `Door · ${DOOR_TYPE_WORDS[s.meta.type] ?? s.meta.type}`;
287
390
  return s.kind === "door" ? "Door" : "Wall";
288
391
  }
289
392
 
@@ -291,66 +394,97 @@ export function shapeTitle(s: TEditorShape): string {
291
394
  export const shapesOnFloor = (shapes: TEditorShape[], floorNo: number) =>
292
395
  shapes.filter((s) => s.kind === "lift" || s.floorNo === floorNo);
293
396
 
294
- export function emptyEditor(shapes: TEditorShape[] = []): TEditorState {
295
- return { shapes, undo: [], redo: [], selected: [], vertex: null, drag: null, notice: null, dirty: false };
397
+ export function emptyEditor(shapes: TEditorShape[] = [], extra: Partial<Pick<TEditorState, "frame" | "graphs">> = {}): TEditorState {
398
+ return {
399
+ shapes,
400
+ undo: [],
401
+ redo: [],
402
+ selected: [],
403
+ vertex: null,
404
+ drag: null,
405
+ notice: null,
406
+ dirty: false,
407
+ frame: extra.frame ?? { ...DEFAULT_FRAME },
408
+ graphs: extra.graphs ?? [],
409
+ plans: {},
410
+ laneFrom: null,
411
+ measureFrom: null,
412
+ lastEdit: null,
413
+ };
296
414
  }
297
415
 
298
- /* ---- editing geometry: the plan is in shares (0-100 each way); angles and distances are real metres ---- */
299
-
300
- const MX = PLAN_W_M / 100;
301
- const MY = PLAN_H_M / 100;
302
- const inM = (p: { x: number; y: number }) => ({ x: p.x * MX, y: p.y * MY });
303
- const inShare = (p: { x: number; y: number }) => ({ x: p.x / MX, y: p.y / MY });
304
- const r2 = (p: { x: number; y: number }) => ({ x: round2(p.x), y: round2(p.y) });
305
- const clamp100 = (n: number) => Math.max(0, Math.min(100, n));
416
+ /* ---- editing geometry: the plan is in shares (0-100 each way) of the frame; angles and distances are real metres ---- */
306
417
 
307
418
  /** Lift shafts: what the API accepts (0.5-10 m a side). Zones: at least 0.5 m a side while resizing. */
308
419
  export const LIFT_MIN_M = 0.5;
309
420
  export const LIFT_MAX_M = 10;
310
421
  export const ZONE_MIN_M = 0.5;
311
422
  /** Grid steps offered (metres) and the default. */
312
- export const GRID_STEPS_M = [0.25, 0.5, 1, 2];
423
+ export const GRID_STEPS_M = [0.25, 0.5, 1, 2, 5];
313
424
  export const DEFAULT_GRID_M = 1;
314
- /** Arrow-key nudge: 0.1 m, Shift 1 m. A door turn snaps to 15 degrees while snapping is on. */
425
+ /** Arrow-key nudge: 0.1 m, Shift 1 m. A turn snaps to 15 degrees while snapping is on. */
315
426
  export const NUDGE_M = 0.1;
316
427
  export const NUDGE_BIG_M = 1;
317
428
  export const ROTATE_SNAP_DEG = 15;
318
429
 
319
- /** One grid step in shares across and down (the plan is 40 x 26 m). */
320
- export const gridShares = (stepM: number) => ({ x: (stepM / PLAN_W_M) * 100, y: (stepM / PLAN_H_M) * 100 });
430
+ /** One grid step in shares across and down. */
431
+ export const gridShares = (stepM: number, f: TSize = DEFAULT_FRAME) => ({ x: (stepM / f.w) * 100, y: (stepM / f.h) * 100 });
321
432
 
322
433
  /** The nearest grid crossing, in shares, inside the plan. No step = only rounded. */
323
- export function snapToGrid(p: { x: number; y: number }, stepM: number): { x: number; y: number } {
324
- if (!(stepM > 0)) return r2({ x: clamp100(p.x), y: clamp100(p.y) });
325
- const m = inM(p);
326
- const q = inShare({ x: Math.round(m.x / stepM) * stepM, y: Math.round(m.y / stepM) * stepM });
327
- return r2({ x: clamp100(q.x), y: clamp100(q.y) });
434
+ export function snapToGrid(p: { x: number; y: number }, stepM: number, f: TSize = DEFAULT_FRAME): { x: number; y: number } {
435
+ if (!(stepM > 0)) return rs({ x: clamp100(p.x), y: clamp100(p.y) }, f);
436
+ const m = inM(p, f);
437
+ const q = inShare({ x: Math.round(m.x / stepM) * stepM, y: Math.round(m.y / stepM) * stepM }, f);
438
+ return rs({ x: clamp100(q.x), y: clamp100(q.y) }, f);
328
439
  }
329
440
 
330
441
  /** A dragged corner or end: onto another shape's corner or end within `toleranceM`, else the grid (if on). */
331
- export function snapPoint(p: { x: number; y: number }, gridM: number, targets: Array<{ x: number; y: number }>, toleranceM: number) {
332
- const i = targets.length && toleranceM > 0 ? nearestPoint(inM(p), targets.map(inM), toleranceM) : -1;
333
- return i >= 0 ? { ...targets[i]! } : snapToGrid(p, gridM);
442
+ export function snapPoint(p: { x: number; y: number }, gridM: number, targets: Array<{ x: number; y: number }>, toleranceM: number, f: TSize = DEFAULT_FRAME) {
443
+ const i = targets.length && toleranceM > 0 ? nearestPoint(inM(p, f), targets.map((t) => inM(t, f)), toleranceM) : -1;
444
+ return i >= 0 ? { ...targets[i]! } : snapToGrid(p, gridM, f);
445
+ }
446
+
447
+ /** A lift shaft in metres: its centre, width (across), depth (down) and angle (world, anticlockwise). */
448
+ export function liftGeom(s: TEditorShape, f: TSize = DEFAULT_FRAME) {
449
+ const b = shapeBox(s);
450
+ return { c: inM({ x: b.x + b.w / 2, y: b.y + b.h / 2 }, f), w: (b.w * f.w) / 100, d: (b.h * f.h) / 100, yaw: Number(s.meta?.yawRad ?? 0) };
451
+ }
452
+
453
+ /** A lift shaft's four corners in shares (turned by its angle). */
454
+ export function liftCorners(s: TEditorShape, f: TSize = DEFAULT_FRAME) {
455
+ const g = liftGeom(s, f);
456
+ return [
457
+ { x: -g.w / 2, y: -g.d / 2 },
458
+ { x: g.w / 2, y: -g.d / 2 },
459
+ { x: g.w / 2, y: g.d / 2 },
460
+ { x: -g.w / 2, y: g.d / 2 },
461
+ ].map((q) => {
462
+ const r = rotS(q, -g.yaw);
463
+ return inShare({ x: g.c.x + r.x, y: g.c.y + r.y }, f);
464
+ });
334
465
  }
335
466
 
336
467
  /**
337
- * The shape under a point (shares): doors, then lift shafts, walls, then
338
- * zones (the smallest first); the newest first within a kind. Walls and doors
339
- * count within `toleranceM` of their line; a zone inside or near its edge.
468
+ * The shape under a point (shares): waypoints and fiducials, then doors and
469
+ * measurements, lift shafts, lanes, walls, then zones (the smallest first);
470
+ * the newest first within a kind. Lines count within `toleranceM`; a point
471
+ * within 1.5 times that; a zone inside or near its edge.
340
472
  */
341
- export function hitShape(shapes: TEditorShape[], p: { x: number; y: number }, toleranceM: number): TEditorShape | null {
342
- const q = inM(p);
343
- const rank: Record<TShapeKind, number> = { door: 0, lift: 1, wall: 2, zone: 3 };
473
+ export function hitShape(shapes: TEditorShape[], p: { x: number; y: number }, toleranceM: number, f: TSize = DEFAULT_FRAME): TEditorShape | null {
474
+ const q = inM(p, f);
475
+ const rank: Record<TShapeKind, number> = { waypoint: 0, fiducial: 0, door: 1, measure: 1, lift: 2, lane: 3, wall: 4, zone: 5 };
344
476
  const hits = [...shapes].reverse().filter((s) => {
345
- const pts = s.points.map(inM);
346
- if (s.kind === "wall" || s.kind === "door") return pts.length > 1 && distanceToSegment(q, pts[0]!, pts[1]!) <= toleranceM;
477
+ const pts = s.points.map((t) => inM(t, f));
478
+ if (s.kind === "waypoint" || s.kind === "fiducial") return pts.length > 0 && Math.hypot(pts[0]!.x - q.x, pts[0]!.y - q.y) <= toleranceM * 1.5;
479
+ if (s.kind === "wall" || s.kind === "door" || s.kind === "measure" || s.kind === "lane") return pts.length > 1 && distanceToSegment(q, pts[0]!, pts[1]!) <= toleranceM;
347
480
  if (s.kind === "lift") {
348
- const b = shapeBox(s);
349
- return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
481
+ const g = liftGeom(s, f);
482
+ const l = rotS({ x: q.x - g.c.x, y: q.y - g.c.y }, g.yaw);
483
+ return Math.abs(l.x) <= g.w / 2 && Math.abs(l.y) <= g.d / 2;
350
484
  }
351
485
  return pointInPolygon(q, pts) || pts.some((a, i) => distanceToSegment(q, a, pts[(i + 1) % pts.length]!) <= toleranceM);
352
486
  });
353
- const area = (s: TEditorShape) => (s.kind === "zone" ? polygonArea(s.points.map(inM)) : 0);
487
+ const area = (s: TEditorShape) => (s.kind === "zone" ? polygonArea(s.points.map((t) => inM(t, f))) : 0);
354
488
  hits.sort((a, b) => rank[a.kind] - rank[b.kind] || area(a) - area(b));
355
489
  return hits[0] ?? null;
356
490
  }
@@ -383,6 +517,7 @@ const HANDLE_CURSOR: Record<THandle, string> = {
383
517
  nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
384
518
  };
385
519
  const HANDLES: THandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
520
+ const handleSign = (h: THandle) => ({ x: h.includes("w") ? -1 : h.includes("e") ? 1 : 0, y: h.includes("n") ? -1 : h.includes("s") ? 1 : 0 });
386
521
 
387
522
  function boxHandles(s: TEditorShape): TShapeHandle[] {
388
523
  const b = shapeBox(s);
@@ -407,28 +542,42 @@ export function turnCentre(s: TEditorShape) {
407
542
  }
408
543
 
409
544
  /**
410
- * The handles one selected shape shows: a wall's two ends; a door's two ends
411
- * + a turn handle 2 m out on the side away from its swing; a lift shaft's
412
- * eight box handles + a turn handle above it; a zone's eight box handles + a
413
- * handle on every corner the box handles don't already cover.
545
+ * The handles one selected shape shows: a wall's or measurement's two ends; a
546
+ * door's two ends + a turn handle 2 m out on the side away from its swing; a
547
+ * lift shaft's eight box handles (turned with it) + a turn handle above it; a
548
+ * zone's eight box handles + a handle on every corner the box handles don't
549
+ * already cover. Waypoints, fiducials and lanes have none (they are dragged;
550
+ * a lane follows its waypoints).
414
551
  */
415
- export function shapeHandles(s: TEditorShape): TShapeHandle[] {
552
+ export function shapeHandles(s: TEditorShape, f: TSize = DEFAULT_FRAME): TShapeHandle[] {
416
553
  const ends = (): TShapeHandle[] => s.points.map((p, i) => ({ key: `v${i}`, kind: "vertex", index: i, x: p.x, y: p.y, cursor: "move" }));
417
- if (s.kind === "wall") return ends();
554
+ if (s.kind === "wall" || s.kind === "measure") return ends();
555
+ if (s.kind === "waypoint" || s.kind === "fiducial" || s.kind === "lane") return [];
418
556
  if (s.kind === "door") {
419
- const [a, b] = s.points.map(inM);
557
+ const [a, b] = s.points.map((t) => inM(t, f));
420
558
  const len = Math.hypot(b!.x - a!.x, b!.y - a!.y) || 1;
421
- const c = inM(turnCentre(s));
422
- const t = inShare({ x: c.x - ((b!.y - a!.y) / len) * 2, y: c.y + ((b!.x - a!.x) / len) * 2 });
559
+ const c = inM(turnCentre(s), f);
560
+ // Opposite the swing: the swing opens to the left of start->end (world) for direction +1.
561
+ const side = Number(s.meta?.motionDirection ?? 1) < 0 ? -1 : 1;
562
+ const t = inShare({ x: c.x - ((b!.y - a!.y) / len) * 2 * side, y: c.y + ((b!.x - a!.x) / len) * 2 * side }, f);
423
563
  return [...ends(), { key: "rot", kind: "rotate", x: round2(clamp100(t.x)), y: round2(clamp100(t.y)), cursor: "grab" }];
424
564
  }
425
- const box = boxHandles(s);
426
565
  if (s.kind === "lift") {
427
- const b = shapeBox(s);
428
- const gap = gridShares(1.5).y;
429
- const y = b.y - gap >= 0 ? b.y - gap : b.y + b.h + gap;
430
- return [...box, { key: "rot", kind: "rotate", x: round2(b.x + b.w / 2), y: round2(y), cursor: "grab" }];
566
+ const g = liftGeom(s, f);
567
+ const at = (lx: number, ly: number) => {
568
+ const r = rotS({ x: lx, y: ly }, -g.yaw);
569
+ return r2(inShare({ x: g.c.x + r.x, y: g.c.y + r.y }, f));
570
+ };
571
+ const box: TShapeHandle[] = HANDLES.map((h) => {
572
+ const k = handleSign(h);
573
+ return { key: h, kind: "resize", handle: h, ...at((k.x * g.w) / 2, (k.y * g.d) / 2), cursor: HANDLE_CURSOR[h] };
574
+ });
575
+ const gap = 1.5;
576
+ let rot = at(0, -g.d / 2 - gap);
577
+ if (rot.y < 0 || rot.y > 100 || rot.x < 0 || rot.x > 100) rot = at(0, g.d / 2 + gap);
578
+ return [...box, { key: "rot", kind: "rotate", x: rot.x, y: rot.y, cursor: "grab" }];
431
579
  }
580
+ const box = boxHandles(s);
432
581
  const corners = box.filter((h) => h.handle!.length === 2);
433
582
  const own = ends().filter((v) => !corners.some((c) => Math.abs(c.x - v.x) < 0.3 && Math.abs(c.y - v.y) < 0.3));
434
583
  return [...box, ...own];
@@ -445,99 +594,162 @@ export function resizeBox(
445
594
  p: { x: number; y: number },
446
595
  min: { w: number; h: number },
447
596
  max: { w: number; h: number } = { w: Infinity, h: Infinity },
597
+ bounds: { lo: number; hi: number } = { lo: 0, hi: 100 },
448
598
  ) {
449
599
  let x0 = b.x;
450
600
  let y0 = b.y;
451
601
  let x1 = b.x + b.w;
452
602
  let y1 = b.y + b.h;
453
- if (handle.includes("w")) x0 = Math.min(Math.max(p.x, x1 - max.w, 0), x1 - min.w);
454
- if (handle.includes("e")) x1 = Math.max(Math.min(p.x, x0 + max.w, 100), x0 + min.w);
455
- if (handle.includes("n")) y0 = Math.min(Math.max(p.y, y1 - max.h, 0), y1 - min.h);
456
- if (handle.includes("s")) y1 = Math.max(Math.min(p.y, y0 + max.h, 100), y0 + min.h);
603
+ if (handle.includes("w")) x0 = Math.min(Math.max(p.x, x1 - max.w, bounds.lo), x1 - min.w);
604
+ if (handle.includes("e")) x1 = Math.max(Math.min(p.x, x0 + max.w, bounds.hi), x0 + min.w);
605
+ if (handle.includes("n")) y0 = Math.min(Math.max(p.y, y1 - max.h, bounds.lo), y1 - min.h);
606
+ if (handle.includes("s")) y1 = Math.max(Math.min(p.y, y0 + max.h, bounds.hi), y0 + min.h);
457
607
  return { x: round2(x0), y: round2(y0), w: round2(x1 - x0), h: round2(y1 - y0) };
458
608
  }
459
609
 
460
- /** A lift shaft or zone resized from a box handle; a zone's corners scale with its box. */
461
- export function resizeShape(s: TEditorShape, handle: THandle, p: { x: number; y: number }): TEditorShape {
610
+ /** A lift shaft or zone resized from a box handle; a zone's corners scale with its box; a turned shaft resizes along its own sides. */
611
+ export function resizeShape(s: TEditorShape, handle: THandle, p: { x: number; y: number }, f: TSize = DEFAULT_FRAME): TEditorShape {
462
612
  const b = shapeBox(s);
463
613
  if (s.kind === "lift") {
464
- const min = gridShares(LIFT_MIN_M);
465
- const max = gridShares(LIFT_MAX_M);
466
- const n = resizeBox(b, handle, p, { w: min.x, h: min.y }, { w: max.x, h: max.y });
467
- return { ...s, points: rect(n.x, n.y, n.w, n.h).map(r2) };
614
+ const g = liftGeom(s, f);
615
+ if (!g.yaw) {
616
+ const min = gridShares(LIFT_MIN_M, f);
617
+ const max = gridShares(LIFT_MAX_M, f);
618
+ const n = resizeBox(b, handle, p, { w: min.x, h: min.y }, { w: max.x, h: max.y });
619
+ return { ...s, points: rect(n.x, n.y, n.w, n.h).map((q) => rs(q, f)) };
620
+ }
621
+ // In the shaft's own frame (metres, centre at 0): resize, then turn the new centre back.
622
+ const q = inM(p, f);
623
+ const l = rotS({ x: q.x - g.c.x, y: q.y - g.c.y }, g.yaw);
624
+ const n = resizeBox({ x: -g.w / 2, y: -g.d / 2, w: g.w, h: g.d }, handle, l, { w: LIFT_MIN_M, h: LIFT_MIN_M }, { w: LIFT_MAX_M, h: LIFT_MAX_M }, { lo: -Infinity, hi: Infinity });
625
+ const lc = rotS({ x: n.x + n.w / 2, y: n.y + n.h / 2 }, -g.yaw);
626
+ const c = inShare({ x: g.c.x + lc.x, y: g.c.y + lc.y }, f);
627
+ const sz = inShare({ x: n.w, y: n.h }, f);
628
+ return { ...s, points: rect(c.x - sz.x / 2, c.y - sz.y / 2, sz.x, sz.y).map((q) => rs(q, f)) };
468
629
  }
469
630
  if (s.kind !== "zone") return s;
470
- const min = gridShares(ZONE_MIN_M);
631
+ const min = gridShares(ZONE_MIN_M, f);
471
632
  const n = resizeBox(b, handle, p, { w: min.x, h: min.y });
472
633
  return {
473
634
  ...s,
474
- points: s.points.map((q) => r2({ x: n.x + (b.w ? ((q.x - b.x) / b.w) * n.w : 0), y: n.y + (b.h ? ((q.y - b.y) / b.h) * n.h : 0) })),
635
+ points: s.points.map((q) => rs({ x: n.x + (b.w ? ((q.x - b.x) / b.w) * n.w : 0), y: n.y + (b.h ? ((q.y - b.y) / b.h) * n.h : 0) }, f)),
475
636
  };
476
637
  }
477
638
 
478
- /** Drag one corner of a zone or one end of a wall / door. A wall or door never gets shorter than 0.1 m. */
479
- export function moveVertex(s: TEditorShape, index: number, p: { x: number; y: number }): TEditorShape {
639
+ /** Drag one corner of a zone or one end of a wall / door / measurement. A line never gets shorter than 0.1 m. */
640
+ export function moveVertex(s: TEditorShape, index: number, p: { x: number; y: number }, f: TSize = DEFAULT_FRAME): TEditorShape {
480
641
  if (!s.points[index]) return s;
481
- const points = s.points.map((q, i) => (i === index ? r2({ x: clamp100(p.x), y: clamp100(p.y) }) : q));
482
- if (s.kind === "wall" || s.kind === "door") {
483
- const [a, b] = points.map(inM);
642
+ const points = s.points.map((q, i) => (i === index ? rs({ x: clamp100(p.x), y: clamp100(p.y) }, f) : q));
643
+ if (s.kind === "wall" || s.kind === "door" || s.kind === "measure") {
644
+ const [a, b] = points.map((t) => inM(t, f));
484
645
  if (Math.hypot(b!.x - a!.x, b!.y - a!.y) < MIN_WALL_M) return s;
485
646
  }
486
647
  return { ...s, points };
487
648
  }
488
649
 
489
650
  /** Shifted back inside the plan if a turn pushed it over an edge. */
490
- function keepInside(s: TEditorShape): TEditorShape {
491
- const b = shapeBox(s);
651
+ function keepInside(s: TEditorShape, f: TSize = DEFAULT_FRAME): TEditorShape {
652
+ const b = s.kind === "lift" ? shapeBox({ ...s, points: liftCorners(s, f) }) : shapeBox(s);
492
653
  const dx = b.x < 0 ? -b.x : b.x + b.w > 100 ? 100 - (b.x + b.w) : 0;
493
654
  const dy = b.y < 0 ? -b.y : b.y + b.h > 100 ? 100 - (b.y + b.h) : 0;
494
- return dx || dy ? { ...s, points: s.points.map((q) => r2({ x: q.x + dx, y: q.y + dy })) } : s;
655
+ return dx || dy ? { ...s, points: s.points.map((q) => rs({ x: q.x + dx, y: q.y + dy }, f)) } : s;
495
656
  }
496
657
 
497
658
  /**
498
- * Turn a door (any angle, round its middle) or a lift shaft (quarter turns
499
- * round its centre - width and depth swap). The building map stores a lift
500
- * shaft as width x depth with no angle, so a shaft only turns in 90 degree
501
- * steps. Other shapes do not turn.
502
- * ponytail: quarter turns only until the map stores a lift angle (spec batch 2).
659
+ * Turn a door (any angle, round its middle) or a lift shaft (any angle round
660
+ * its centre - the map keeps the shaft's angle). `deg` is on screen,
661
+ * clockwise positive. Other shapes do not turn.
503
662
  */
504
- export function rotateShape(s: TEditorShape, deg: number): TEditorShape {
505
- const c = inM(turnCentre(s));
663
+ export function rotateShape(s: TEditorShape, deg: number, f: TSize = DEFAULT_FRAME): TEditorShape {
664
+ const c = inM(turnCentre(s), f);
506
665
  if (s.kind === "door") {
507
- const rad = (deg * Math.PI) / 180;
508
- const cos = Math.cos(rad);
509
- const sin = Math.sin(rad);
666
+ const rad = deg * DEG;
510
667
  const points = s.points.map((q) => {
511
- const m = inM(q);
512
- return r2(inShare({ x: c.x + (m.x - c.x) * cos - (m.y - c.y) * sin, y: c.y + (m.x - c.x) * sin + (m.y - c.y) * cos }));
668
+ const m = inM(q, f);
669
+ const r = rotS({ x: m.x - c.x, y: m.y - c.y }, rad);
670
+ return rs(inShare({ x: c.x + r.x, y: c.y + r.y }, f), f);
513
671
  });
514
- return keepInside({ ...s, points });
672
+ return keepInside({ ...s, points }, f);
515
673
  }
516
674
  if (s.kind !== "lift") return s;
517
- const quarter = ((Math.round(deg / 90) % 4) + 4) % 4;
518
- if (quarter % 2 === 0) return s;
519
- const b = shapeBox(s);
520
- const w = (b.h * MY) / MX;
521
- const h = (b.w * MX) / MY;
522
- const ctr = turnCentre(s);
523
- return keepInside({ ...s, points: rect(ctr.x - w / 2, ctr.y - h / 2, w, h).map(r2) });
675
+ const yaw = wrapRad(Number(s.meta?.yawRad ?? 0) - deg * DEG);
676
+ return keepInside({ ...s, meta: { ...(s.meta ?? {}), yawRad: yaw } }, f);
524
677
  }
525
678
 
526
- /** The angle (degrees, in real metres) from `c` to `p`. */
527
- export function angleAt(c: { x: number; y: number }, p: { x: number; y: number }): number {
528
- const a = inM(c);
529
- const b = inM(p);
679
+ /** The angle (degrees, in real metres, on screen) from `c` to `p`. */
680
+ export function angleAt(c: { x: number; y: number }, p: { x: number; y: number }, f: TSize = DEFAULT_FRAME): number {
681
+ const a = inM(c, f);
682
+ const b = inM(p, f);
530
683
  return (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
531
684
  }
532
685
 
533
- /** A door's swing flipped: its two ends swap, so the hinge moves to the other end and it opens to the other side. */
534
- export const flipDoor = (s: TEditorShape): TEditorShape => (s.kind === "door" ? { ...s, points: [s.points[1]!, s.points[0]!] } : s);
686
+ /** A door's swing flipped: it opens to the other side (Open-RMF motion_direction), hinge unchanged. */
687
+ export const flipDoor = (s: TEditorShape): TEditorShape =>
688
+ s.kind === "door" ? { ...s, meta: { ...(s.meta ?? {}), motionDirection: Number(s.meta?.motionDirection ?? 1) < 0 ? 1 : -1 } } : s;
689
+
690
+ /**
691
+ * A door's drawing, as Open-RMF's traffic-editor draws it (level.cpp
692
+ * draw_door): a swing door's leaf and arc from its hinge (the start, or the
693
+ * end with motion_axis "end") through `motion_degrees` in `motion_direction`;
694
+ * a double swing door's two leaves split by `right_left_ratio`; a sliding
695
+ * door's panel(s) and the way they slide. Polylines in shares.
696
+ */
697
+ export function doorGlyph(s: TEditorShape, f: TSize = DEFAULT_FRAME): Array<Array<{ x: number; y: number }>> {
698
+ if (s.kind !== "door" || s.points.length < 2) return [];
699
+ // World metres (y up) so the angles mean what they mean in the building file.
700
+ const [a, b] = s.points.map((t) => {
701
+ const m = inM(t, f);
702
+ return { x: m.x, y: -m.y };
703
+ });
704
+ const back = (p: { x: number; y: number }) => r2(inShare({ x: p.x, y: -p.y }, f));
705
+ const len = Math.hypot(b!.x - a!.x, b!.y - a!.y);
706
+ if (!len) return [];
707
+ const ang = Math.atan2(b!.y - a!.y, b!.x - a!.x);
708
+ const type = String(s.meta?.type ?? "hinged");
709
+ const dir = Number(s.meta?.motionDirection ?? 1) < 0 ? -1 : 1;
710
+ const deg = Math.abs(Number(s.meta?.motionDegrees ?? 90)) * DEG;
711
+ const ratio = Number(s.meta?.rightLeftRatio ?? 1) > 0 ? Number(s.meta?.rightLeftRatio ?? 1) : 1;
712
+ const swing = (h: { x: number; y: number }, r: number, a0: number, a1: number) => {
713
+ const at = (t: number) => back({ x: h.x + Math.cos(t) * r, y: h.y + Math.sin(t) * r });
714
+ return [back(h), ...Array.from({ length: 9 }, (_, i) => at(a0 + ((a1 - a0) * i) / 8)), back(h)];
715
+ };
716
+ if (type === "hinged") {
717
+ const end = s.meta?.motionAxis === "end";
718
+ const a0 = ang + (end ? Math.PI : 0);
719
+ return [swing(end ? b! : a!, len, a0, a0 + dir * deg)];
720
+ }
721
+ if (type === "double_hinged") {
722
+ return [swing(a!, (ratio / (1 + ratio)) * len, ang, ang + dir * deg), swing(b!, (1 / (1 + ratio)) * len, ang + Math.PI, ang + Math.PI - dir * deg)];
723
+ }
724
+ // Sliding: each panel drawn 0.15 m off the wall line, beside the opening it slides over.
725
+ const n = { x: -Math.sin(ang) * 0.15 * dir, y: Math.cos(ang) * 0.15 * dir };
726
+ const u = { x: Math.cos(ang), y: Math.sin(ang) };
727
+ const off = (p: { x: number; y: number }, k: number) => ({ x: p.x + n.x + u.x * k, y: p.y + n.y + u.y * k });
728
+ if (type === "double_sliding") {
729
+ const m = { x: (a!.x + b!.x) / 2, y: (a!.y + b!.y) / 2 };
730
+ return [
731
+ [back(off(a!, -len / 2)), back(off(m, -len / 2))],
732
+ [back(off(m, len / 2)), back(off(b!, len / 2))],
733
+ ];
734
+ }
735
+ return [[back(off(a!, -len)), back(off(a!, 0))]];
736
+ }
737
+
738
+ /** A single swing door's leaf and quarter arc (shares), for the plain door drawing; null for other kinds. */
739
+ export function doorSwing(s: TEditorShape, f: TSize = DEFAULT_FRAME): { leaf: Array<{ x: number; y: number }>; arc: Array<{ x: number; y: number }> } | null {
740
+ const type = String(s.meta?.type ?? "hinged");
741
+ if (s.kind !== "door" || type !== "hinged" || s.points.length < 2) return null;
742
+ const g = doorGlyph(s, f)[0];
743
+ if (!g) return null;
744
+ const arc = g.slice(1, -1);
745
+ return { leaf: [g[0]!, arc[arc.length - 1]!], arc };
746
+ }
535
747
 
536
748
  /** A new zone corner at the point, on the edge nearest it (and its index) - or null when the zone is full or no edge is within `withinM`. */
537
- export function addVertex(s: TEditorShape, p: { x: number; y: number }, withinM = Infinity): { shape: TEditorShape; index: number } | null {
749
+ export function addVertex(s: TEditorShape, p: { x: number; y: number }, withinM = Infinity, f: TSize = DEFAULT_FRAME): { shape: TEditorShape; index: number } | null {
538
750
  if (s.kind !== "zone" || s.points.length >= MAX_ZONE_POINTS) return null;
539
- const q = inM(p);
540
- const pts = s.points.map(inM);
751
+ const q = inM(p, f);
752
+ const pts = s.points.map((t) => inM(t, f));
541
753
  let best = 0;
542
754
  let bestD = Infinity;
543
755
  pts.forEach((a, i) => {
@@ -549,7 +761,7 @@ export function addVertex(s: TEditorShape, p: { x: number; y: number }, withinM
549
761
  });
550
762
  if (bestD > withinM) return null;
551
763
  const points = [...s.points];
552
- points.splice(best + 1, 0, r2({ x: clamp100(p.x), y: clamp100(p.y) }));
764
+ points.splice(best + 1, 0, rs({ x: clamp100(p.x), y: clamp100(p.y) }, f));
553
765
  return { shape: { ...s, points }, index: best + 1 };
554
766
  }
555
767
 
@@ -559,9 +771,49 @@ export function removeVertex(s: TEditorShape, index: number): TEditorShape | nul
559
771
  return { ...s, points: s.points.filter((_, i) => i !== index) };
560
772
  }
561
773
 
562
- /** The listed shapes moved together by (dx, dy) shares, stopping at the plan's edge as a group. */
563
- export function translateShapes(shapes: TEditorShape[], ids: string[], dx: number, dy: number): TEditorShape[] {
564
- const moving = shapes.filter((s) => ids.includes(s.id));
774
+ /**
775
+ * Lanes follow their waypoints: each lane's two points are copied from its
776
+ * `meta.from` / `meta.to` waypoints; a lane whose waypoint is gone (or on
777
+ * another floor) is dropped. Returns the same array when nothing changed.
778
+ */
779
+ export function syncLanes(shapes: TEditorShape[]): TEditorShape[] {
780
+ const byId = new Map(shapes.map((s) => [s.id, s]));
781
+ let changed = false;
782
+ const out: TEditorShape[] = [];
783
+ for (const s of shapes) {
784
+ if (s.kind !== "lane") {
785
+ out.push(s);
786
+ continue;
787
+ }
788
+ const a = byId.get(String(s.meta?.from));
789
+ const b = byId.get(String(s.meta?.to));
790
+ if (!a || !b || a.kind !== "waypoint" || b.kind !== "waypoint" || a.floorNo !== s.floorNo || b.floorNo !== s.floorNo || a === b) {
791
+ changed = true;
792
+ continue;
793
+ }
794
+ const pa = a.points[0]!;
795
+ const pb = b.points[0]!;
796
+ if (s.points[0]?.x === pa.x && s.points[0]?.y === pa.y && s.points[1]?.x === pb.x && s.points[1]?.y === pb.y) out.push(s);
797
+ else {
798
+ changed = true;
799
+ out.push({ ...s, points: [{ ...pa }, { ...pb }] });
800
+ }
801
+ }
802
+ return changed ? out : shapes;
803
+ }
804
+
805
+ /** What actually moves when `ids` move: a lane moves its two waypoints (and follows them). */
806
+ function movingIds(shapes: TEditorShape[], ids: string[]) {
807
+ const set = new Set(ids);
808
+ for (const s of shapes) if (s.kind === "lane" && set.has(s.id)) set.add(String(s.meta?.from)).add(String(s.meta?.to));
809
+ for (const s of shapes) if (s.kind === "lane") set.delete(s.id);
810
+ return set;
811
+ }
812
+
813
+ /** The listed shapes moved together by (dx, dy) shares, stopping at the plan's edge as a group; lanes follow their waypoints. */
814
+ export function translateShapes(shapes: TEditorShape[], ids: string[], dx: number, dy: number, f: TSize = DEFAULT_FRAME): TEditorShape[] {
815
+ const set = movingIds(shapes, ids);
816
+ const moving = shapes.filter((s) => set.has(s.id));
565
817
  if (!moving.length) return shapes;
566
818
  const boxes = moving.map(shapeBox);
567
819
  const x0 = Math.min(...boxes.map((b) => b.x));
@@ -571,30 +823,58 @@ export function translateShapes(shapes: TEditorShape[], ids: string[], dx: numbe
571
823
  const mx = Math.max(-x0, Math.min(100 - x1, dx));
572
824
  const my = Math.max(-y0, Math.min(100 - y1, dy));
573
825
  if (!mx && !my) return shapes;
574
- return shapes.map((s) => (ids.includes(s.id) ? { ...s, points: s.points.map((q) => r2({ x: q.x + mx, y: q.y + my })) } : s));
826
+ return syncLanes(shapes.map((s) => (set.has(s.id) ? { ...s, points: s.points.map((q) => rs({ x: q.x + mx, y: q.y + my }, f)) } : s)));
575
827
  }
576
828
 
577
- /** A hinged door's swing (shares): the leaf standing open from the hinge (its start) and the quarter arc. Sliding doors have none. */
578
- export function doorSwing(s: TEditorShape): { leaf: Array<{ x: number; y: number }>; arc: Array<{ x: number; y: number }> } | null {
579
- if (s.kind !== "door" || s.meta?.type === "sliding" || s.points.length < 2) return null;
580
- const [h, e] = s.points.map(inM);
581
- const len = Math.hypot(e!.x - h!.x, e!.y - h!.y);
582
- if (!len) return null;
583
- const a0 = Math.atan2(e!.y - h!.y, e!.x - h!.x);
584
- const at = (a: number) => r2(inShare({ x: h!.x + Math.cos(a) * len, y: h!.y + Math.sin(a) * len }));
585
- const arc = Array.from({ length: 9 }, (_, i) => at(a0 - (Math.PI / 2) * (i / 8)));
586
- return { leaf: [r2(inShare(h!)), arc[8]!], arc };
829
+ /** The next free fiducial name on a floor: a name used on another floor first (so floors can be lined up), else F1, F2... */
830
+ export function nextFiducialName(shapes: TEditorShape[], floorNo: number): string {
831
+ const here = new Set(shapes.filter((s) => s.kind === "fiducial" && s.floorNo === floorNo).map((s) => s.name));
832
+ const elsewhere = shapes.filter((s) => s.kind === "fiducial" && s.floorNo !== floorNo && s.name && !here.has(s.name)).map((s) => s.name!);
833
+ if (elsewhere.length) return elsewhere[0]!;
834
+ for (let i = 1; ; i++) if (!here.has(`F${i}`)) return `F${i}`;
587
835
  }
588
836
 
837
+ /**
838
+ * Every shape re-expressed in a new frame: each keeps its place in the
839
+ * building (world metres), so growing the drawing area never moves anything.
840
+ */
841
+ export function reframeShapes(shapes: TEditorShape[], from: TFrame, to: TFrame): TEditorShape[] {
842
+ const map = (p: { x: number; y: number }) => {
843
+ const wx = from.minX + (p.x / 100) * from.w;
844
+ const wy = from.maxY - (p.y / 100) * from.h;
845
+ return rs({ x: ((wx - to.minX) / to.w) * 100, y: ((to.maxY - wy) / to.h) * 100 }, to);
846
+ };
847
+ return shapes.map((s) => ({ ...s, points: s.points.map(map) }));
848
+ }
849
+
850
+ /** A point in shares of `f` as world metres (y up), and back. */
851
+ export const shareToWorld = (f: TFrame, p: { x: number; y: number }) => ({ x: f.minX + (p.x / 100) * f.w, y: f.maxY - (p.y / 100) * f.h });
852
+ export const worldToShare = (f: TFrame, p: { x: number; y: number }) => ({ x: ((p.x - f.minX) / f.w) * 100, y: ((f.maxY - p.y) / f.h) * 100 });
853
+
589
854
  /* ---- the reducer ---- */
590
855
 
591
856
  const sameLayout = (a: TEditorShape[], b: TEditorShape[]) => a === b || JSON.stringify(a) === JSON.stringify(b);
592
857
  const keepSelected = (ids: string[], shapes: TEditorShape[]) => ids.filter((id) => shapes.some((s) => s.id === id));
858
+ const snapshot = (s: TEditorState): TSnapshot => ({ shapes: s.shapes, frame: s.frame, graphs: s.graphs, plans: s.plans });
593
859
 
594
- function commit(state: TEditorState, shapes: TEditorShape[]): TEditorState {
860
+ function commit(state: TEditorState, shapes: TEditorShape[], extra: Partial<TEditorState> = {}): TEditorState {
595
861
  const undo = [...state.undo];
596
- pushUndo(undo, state.shapes);
597
- return { ...state, shapes, undo, redo: [], selected: keepSelected(state.selected, shapes), vertex: null, drag: null, notice: null, dirty: true };
862
+ pushUndo(undo, snapshot(state));
863
+ const next = syncLanes(shapes);
864
+ return {
865
+ ...state,
866
+ ...extra,
867
+ shapes: next,
868
+ undo,
869
+ redo: [],
870
+ selected: keepSelected(extra.selected ?? state.selected, next),
871
+ vertex: null,
872
+ drag: null,
873
+ notice: null,
874
+ dirty: true,
875
+ lastEdit: null,
876
+ laneFrom: extra.laneFrom !== undefined ? extra.laneFrom : state.laneFrom && next.some((s) => s.id === state.laneFrom) ? state.laneFrom : null,
877
+ };
598
878
  }
599
879
 
600
880
  /** The layout a drag gives, worked out from where it started - so snapping never drifts and one drag is one step. */
@@ -602,28 +882,38 @@ function dragShapes(state: TEditorState, x: number, y: number, gridM: number, to
602
882
  const d = state.drag!;
603
883
  const base = d.base;
604
884
  const op = d.op;
885
+ const f = state.frame;
605
886
  if (op.kind === "move") {
606
- const first = base.find((s) => state.selected.includes(s.id));
887
+ const ids = movingIds(base, state.selected);
888
+ const first = base.find((s) => ids.has(s.id));
607
889
  if (!first) return base;
608
890
  const ref = first.points[0]!;
609
891
  const raw = { x: ref.x + x - d.x, y: ref.y + y - d.y };
610
- const to = gridM > 0 ? snapToGrid(raw, gridM) : raw;
611
- return translateShapes(base, state.selected, to.x - ref.x, to.y - ref.y);
892
+ const to = gridM > 0 ? snapToGrid(raw, gridM, f) : raw;
893
+ return translateShapes(base, state.selected, to.x - ref.x, to.y - ref.y, f);
612
894
  }
613
895
  const target = base.find((s) => s.id === op.id);
614
896
  if (!target) return base;
615
897
  let next: TEditorShape;
616
- if (op.kind === "handle") next = resizeShape(target, op.handle, snapToGrid({ x, y }, gridM));
898
+ if (op.kind === "handle") next = resizeShape(target, op.handle, target.kind === "lift" && target.meta?.yawRad ? { x, y } : snapToGrid({ x, y }, gridM, f), f);
617
899
  else if (op.kind === "vertex") {
618
- const ends = shapesOnFloor(base, target.floorNo).filter((s) => s.id !== target.id).flatMap((s) => s.points);
619
- next = moveVertex(target, op.index, snapPoint({ x, y }, gridM, ends, toleranceM));
900
+ const ends = shapesOnFloor(base, target.floorNo).filter((s) => s.id !== target.id && s.kind !== "lane").flatMap((s) => s.points);
901
+ next = moveVertex(target, op.index, snapPoint({ x, y }, gridM, ends, toleranceM, f), f);
620
902
  } else {
621
903
  const c = turnCentre(target);
622
- let deg = angleAt(c, { x, y }) - angleAt(c, { x: d.x, y: d.y });
623
- if (target.kind === "door" && gridM > 0) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG;
624
- next = rotateShape(target, deg);
904
+ let deg = angleAt(c, { x, y }, f) - angleAt(c, { x: d.x, y: d.y }, f);
905
+ if (gridM > 0) {
906
+ if (target.kind === "door") deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG;
907
+ else if (target.kind === "lift") {
908
+ // Snap the shaft's resulting angle (not the sweep) to 15 degrees.
909
+ const start = Number(target.meta?.yawRad ?? 0) / DEG;
910
+ const end = Math.round((start - deg) / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG;
911
+ deg = start - end;
912
+ }
913
+ }
914
+ next = rotateShape(target, deg, f);
625
915
  }
626
- return base.map((s) => (s.id === target.id ? next : s));
916
+ return syncLanes(base.map((s) => (s.id === target.id ? next : s)));
627
917
  }
628
918
 
629
919
  const TOO_FEW_CORNERS = "A zone needs at least three corners - this one can't lose another.";
@@ -631,35 +921,76 @@ const TOO_FEW_CORNERS = "A zone needs at least three corners - this one can't lo
631
921
  let seq = 0;
632
922
  const nextId = () => `sh${Date.now().toString(36)}${(seq++).toString(36)}`;
633
923
 
924
+ /** Every point of every shape moved by a world transform (rotate, scale, then shift - Open-RMF transform.py order). */
925
+ function transformPoints(shapes: TEditorShape[], ids: Set<string>, f: TFrame, t: { scale: number; rot: number; tx: number; ty: number }) {
926
+ return shapes.map((s) => {
927
+ if (!ids.has(s.id)) return s;
928
+ const points = s.points.map((p) => {
929
+ const w = shareToWorld(f, p);
930
+ const q = { x: (w.x * Math.cos(t.rot) - w.y * Math.sin(t.rot)) * t.scale + t.tx, y: (w.x * Math.sin(t.rot) + w.y * Math.cos(t.rot)) * t.scale + t.ty };
931
+ const sh = worldToShare(f, q);
932
+ return rs({ x: clamp100(sh.x), y: clamp100(sh.y) }, f);
933
+ });
934
+ return { ...s, points };
935
+ });
936
+ }
937
+
938
+ /** The drawing in a new frame (world positions kept); undo / redo snapshots are moved too, so they stay consistent. */
939
+ function reframeState(state: TEditorState, to: TFrame): TEditorState {
940
+ const from = state.frame;
941
+ if (to.minX === from.minX && to.maxY === from.maxY && to.w === from.w && to.h === from.h) return state;
942
+ if (!(to.w > 0 && to.h > 0)) return state;
943
+ const restack = (stack: string[]) =>
944
+ stack.map((raw) => {
945
+ const snap = JSON.parse(raw) as TSnapshot | TEditorShape[];
946
+ return Array.isArray(snap) ? raw : JSON.stringify({ ...snap, shapes: reframeShapes(snap.shapes, snap.frame, to), frame: to });
947
+ });
948
+ return {
949
+ ...state,
950
+ shapes: reframeShapes(state.shapes, from, to),
951
+ frame: { ...to },
952
+ undo: restack(state.undo),
953
+ redo: restack(state.redo),
954
+ measureFrom: null,
955
+ dirty: true,
956
+ };
957
+ }
958
+
634
959
  export function editorReduce(state: TEditorState, action: TEditorAction): TEditorState {
635
960
  switch (action.type) {
636
961
  case "load":
637
- return emptyEditor(action.shapes);
962
+ return emptyEditor(action.shapes, { frame: state.frame, graphs: state.graphs });
963
+ case "loadAll":
964
+ return { ...emptyEditor(action.shapes, { frame: action.frame, graphs: action.graphs }), notice: action.notice ?? null, dirty: action.dirty ?? false };
638
965
  case "tool":
639
966
  // Move keeps the selection (place, then adjust); a drawing tool starts clean.
640
- return { ...state, selected: action.tool === "move" ? state.selected : [], vertex: null, notice: null };
967
+ return { ...state, selected: action.tool === "move" ? state.selected : [], vertex: null, notice: null, laneFrom: null, measureFrom: null };
641
968
  case "notice":
642
969
  return { ...state, notice: { text: action.text, ok: action.ok } };
643
970
  case "saved":
644
- return { ...state, undo: [], redo: [], drag: null, dirty: false, notice: { text: action.text, ok: true } };
971
+ return { ...state, undo: [], redo: [], drag: null, dirty: false, plans: {}, lastEdit: null, notice: { text: action.text, ok: true } };
645
972
  case "undo":
646
973
  case "redo": {
647
974
  const isUndo = action.type === "undo";
648
975
  const from = [...(isUndo ? state.undo : state.redo)];
649
976
  if (!from.length) return { ...state, notice: { text: isUndo ? "Nothing to undo." : "Nothing to redo.", ok: false } };
650
977
  const to = [...(isUndo ? state.redo : state.undo)];
651
- const shapes = popUndo<TEditorShape[]>(from) ?? state.shapes;
652
- pushUndo(to, state.shapes);
978
+ const raw = popUndo<TSnapshot | TEditorShape[]>(from);
979
+ const snap: TSnapshot = Array.isArray(raw) ? { ...snapshot(state), shapes: raw } : raw ?? snapshot(state);
980
+ pushUndo(to, snapshot(state));
653
981
  return {
654
982
  ...state,
655
- shapes,
983
+ ...snap,
656
984
  undo: isUndo ? from : to,
657
985
  redo: isUndo ? to : from,
658
- selected: keepSelected(state.selected, shapes),
986
+ selected: keepSelected(state.selected, snap.shapes),
659
987
  vertex: null,
660
988
  drag: null,
661
989
  notice: null,
662
990
  dirty: true,
991
+ lastEdit: null,
992
+ laneFrom: null,
993
+ measureFrom: null,
663
994
  };
664
995
  }
665
996
  case "rename": {
@@ -676,7 +1007,7 @@ export function editorReduce(state: TEditorState, action: TEditorAction): TEdito
676
1007
  if (i >= 0 && mode === "toggle") sel.splice(i, 1);
677
1008
  else if (i < 0) sel.push(id);
678
1009
  }
679
- return { ...state, selected: keepSelected(sel, state.shapes), vertex: null, notice: null };
1010
+ return { ...state, selected: keepSelected(sel, state.shapes), vertex: null, notice: null, lastEdit: null };
680
1011
  }
681
1012
  case "selectAll":
682
1013
  return { ...state, selected: shapesOnFloor(state.shapes, action.floorNo).map((s) => s.id), vertex: null };
@@ -693,14 +1024,14 @@ export function editorReduce(state: TEditorState, action: TEditorAction): TEdito
693
1024
  const base = state.drag.base;
694
1025
  if (sameLayout(base, state.shapes)) return { ...state, drag: null };
695
1026
  const undo = [...state.undo];
696
- pushUndo(undo, base);
697
- return { ...state, undo, redo: [], drag: null, dirty: true };
1027
+ pushUndo(undo, { ...snapshot(state), shapes: base });
1028
+ return { ...state, undo, redo: [], drag: null, dirty: true, lastEdit: null };
698
1029
  }
699
1030
  case "dragCancel":
700
1031
  return state.drag ? { ...state, shapes: state.drag.base, drag: null } : state;
701
1032
  case "nudge": {
702
1033
  if (!state.selected.length) return state;
703
- const shapes = translateShapes(state.shapes, state.selected, action.dx / MX, action.dy / MY);
1034
+ const shapes = translateShapes(state.shapes, state.selected, (action.dx / state.frame.w) * 100, (action.dy / state.frame.h) * 100, state.frame);
704
1035
  return shapes === state.shapes ? state : commit(state, shapes);
705
1036
  }
706
1037
  case "remove": {
@@ -719,11 +1050,11 @@ export function editorReduce(state: TEditorState, action: TEditorAction): TEdito
719
1050
  const kinds: TShapeKind[] = action.type === "flip" ? ["door"] : ["door", "lift"];
720
1051
  const targets = new Set(state.shapes.filter((s) => state.selected.includes(s.id) && kinds.includes(s.kind)).map((s) => s.id));
721
1052
  if (!targets.size) return state;
722
- return commit(state, state.shapes.map((s) => (targets.has(s.id) ? (action.type === "flip" ? flipDoor(s) : rotateShape(s, 90)) : s)));
1053
+ return commit(state, state.shapes.map((s) => (targets.has(s.id) ? (action.type === "flip" ? flipDoor(s) : rotateShape(s, 90, state.frame)) : s)));
723
1054
  }
724
1055
  case "addVertex": {
725
1056
  const zone = state.shapes.find((s) => s.id === action.id);
726
- const added = zone ? addVertex(zone, { x: action.x, y: action.y }, action.within) : null;
1057
+ const added = zone ? addVertex(zone, { x: action.x, y: action.y }, action.within, state.frame) : null;
727
1058
  if (!added) return state;
728
1059
  return { ...commit(state, state.shapes.map((s) => (s.id === action.id ? added.shape : s))), selected: [action.id], vertex: { id: action.id, index: added.index } };
729
1060
  }
@@ -745,14 +1076,115 @@ export function editorReduce(state: TEditorState, action: TEditorAction): TEdito
745
1076
  return state;
746
1077
  }
747
1078
  case "place": {
748
- if (action.tool === "move" || action.tool === "delete") return state;
749
- const shape = newShape(action.tool, action.id ?? nextId(), action.floorNo, action.x, action.y, {
750
- name: action.zoneName,
751
- floors: action.floors,
752
- });
1079
+ if (["move", "delete", "lane", "measure"].includes(action.tool)) return state;
1080
+ const kind = action.tool as TShapeKind;
1081
+ const name = kind === "fiducial" ? action.name ?? nextFiducialName(state.shapes, action.floorNo) : kind === "zone" ? action.zoneName : action.name;
1082
+ const shape = newShape(kind, action.id ?? nextId(), action.floorNo, action.x, action.y, { name, floors: action.floors }, state.frame);
1083
+ if (kind === "waypoint") shape.meta = { params: {} };
753
1084
  // The new shape comes selected, so its handles are there to size it straight away.
754
1085
  return { ...commit(state, [...state.shapes, shape]), selected: [shape.id], vertex: null };
755
1086
  }
1087
+ case "laneClick": {
1088
+ const hit = action.hitId ? state.shapes.find((s) => s.id === action.hitId && s.kind === "waypoint" && s.floorNo === action.floorNo) : undefined;
1089
+ // A click on the waypoint the chain is at ends the chain.
1090
+ if (hit && hit.id === state.laneFrom) return { ...state, laneFrom: null, notice: null };
1091
+ let shapes = state.shapes;
1092
+ let at = hit;
1093
+ if (!at) {
1094
+ at = newShape("waypoint", action.id ?? nextId(), action.floorNo, action.x, action.y, {}, state.frame);
1095
+ at.meta = { params: {} };
1096
+ shapes = [...shapes, at];
1097
+ }
1098
+ const from = state.laneFrom ? state.shapes.find((s) => s.id === state.laneFrom) : undefined;
1099
+ if (!from || from.floorNo !== action.floorNo) {
1100
+ if (shapes === state.shapes) return { ...state, laneFrom: at.id, selected: [at.id], notice: null };
1101
+ return { ...commit(state, shapes), laneFrom: at.id, selected: [at.id] };
1102
+ }
1103
+ const exists = state.shapes.some(
1104
+ (s) =>
1105
+ s.kind === "lane" &&
1106
+ Number(s.meta?.graphIdx ?? 0) === action.graphIdx &&
1107
+ ((s.meta?.from === from.id && s.meta?.to === at!.id) || (s.meta?.from === at!.id && s.meta?.to === from.id)),
1108
+ );
1109
+ if (exists) return { ...state, laneFrom: at.id, notice: { text: "There is already a lane between these two waypoints on this route graph.", ok: false } };
1110
+ const lane: TEditorShape = {
1111
+ id: action.laneId ?? nextId(),
1112
+ kind: "lane",
1113
+ floorNo: action.floorNo,
1114
+ points: [{ ...from.points[0]! }, { ...at.points[0]! }],
1115
+ meta: { from: from.id, to: at.id, graphIdx: action.graphIdx, bidirectional: true },
1116
+ };
1117
+ const graphs = state.graphs.some((g) => g.idx === action.graphIdx)
1118
+ ? state.graphs
1119
+ : [...state.graphs, { idx: action.graphIdx, name: `Graph ${action.graphIdx}`, brands: [] }].sort((x, y) => x.idx - y.idx);
1120
+ return { ...commit(state, [...shapes, lane], { graphs }), laneFrom: at.id, selected: [lane.id] };
1121
+ }
1122
+ case "laneEnd":
1123
+ return state.laneFrom ? { ...state, laneFrom: null } : state;
1124
+ case "measureClick": {
1125
+ const p = rs({ x: clamp100(action.x), y: clamp100(action.y) }, state.frame);
1126
+ if (!state.measureFrom) return { ...state, measureFrom: p, notice: null };
1127
+ const a = state.measureFrom;
1128
+ const f = state.frame;
1129
+ const len = Math.hypot(((p.x - a.x) / 100) * f.w, ((p.y - a.y) / 100) * f.h);
1130
+ if (len < 0.05) return { ...state, measureFrom: null, notice: { text: "Pick two points further apart.", ok: false } };
1131
+ const shape: TEditorShape = { id: action.id ?? nextId(), kind: "measure", floorNo: action.floorNo, points: [a, p], meta: { distanceM: Math.round(len * 100) / 100 } };
1132
+ return { ...commit(state, [...state.shapes, shape]), measureFrom: null, selected: [shape.id] };
1133
+ }
1134
+ case "setProps": {
1135
+ const target = state.shapes.find((s) => s.id === action.id);
1136
+ if (!target) return state;
1137
+ const next: TEditorShape = {
1138
+ ...target,
1139
+ ...(action.name !== undefined ? { name: action.name } : {}),
1140
+ ...(action.floors !== undefined ? { floors: [...new Set(action.floors)].sort((a, b) => a - b) } : {}),
1141
+ ...(action.meta ? { meta: { ...(target.meta ?? {}), ...action.meta } } : {}),
1142
+ };
1143
+ const shapes = state.shapes.map((s) => (s.id === target.id ? next : s));
1144
+ const key = action.field ? `${target.id}:${action.field}` : null;
1145
+ // Typing into the same field again is part of the same step.
1146
+ if (key && key === state.lastEdit) return { ...state, shapes: syncLanes(shapes), dirty: true };
1147
+ return { ...commit(state, shapes), lastEdit: key };
1148
+ }
1149
+ case "graphs": {
1150
+ const idx = new Set(action.graphs.map((g) => g.idx));
1151
+ // A removed graph takes its lanes with it.
1152
+ const shapes = state.shapes.filter((s) => s.kind !== "lane" || idx.has(Number(s.meta?.graphIdx ?? 0)));
1153
+ return commit(state, shapes, { graphs: [...action.graphs].sort((a, b) => a.idx - b.idx) });
1154
+ }
1155
+ case "setShapes":
1156
+ return { ...commit(state, action.shapes, action.graphs ? { graphs: [...action.graphs].sort((a, b) => a.idx - b.idx) } : {}), notice: action.notice ?? null };
1157
+ case "reframe": {
1158
+ const next = reframeState(state, action.frame);
1159
+ return action.auto && next !== state ? { ...next, dirty: state.dirty } : next;
1160
+ }
1161
+ case "plan":
1162
+ return commit(state, state.shapes, { plans: { ...state.plans, [String(action.floorNo)]: action.placement } });
1163
+ case "transformFloor": {
1164
+ const ids = new Set(state.shapes.filter((s) => s.floorNo === action.floorNo && s.kind !== "lift").map((s) => s.id));
1165
+ // Grow the drawing area first if the moved floor would leave it, so nothing is squashed against the edge.
1166
+ const t = action.t;
1167
+ const f0 = state.frame;
1168
+ const moved = state.shapes
1169
+ .filter((s) => ids.has(s.id))
1170
+ .flatMap((s) => s.points)
1171
+ .map((p) => {
1172
+ const w = shareToWorld(f0, p);
1173
+ return { x: (w.x * Math.cos(t.rot) - w.y * Math.sin(t.rot)) * t.scale + t.tx, y: (w.x * Math.sin(t.rot) + w.y * Math.cos(t.rot)) * t.scale + t.ty };
1174
+ });
1175
+ let base = state;
1176
+ if (moved.length) {
1177
+ const minX = Math.min(f0.minX, ...moved.map((q) => q.x - 1));
1178
+ const maxX = Math.max(f0.minX + f0.w, ...moved.map((q) => q.x + 1));
1179
+ const minY = Math.min(f0.maxY - f0.h, ...moved.map((q) => q.y - 1));
1180
+ const maxY = Math.max(f0.maxY, ...moved.map((q) => q.y + 1));
1181
+ const grown = { minX: round2(minX), maxY: round2(maxY), w: round2(maxX - minX), h: round2(maxY - minY) };
1182
+ if (grown.minX !== f0.minX || grown.maxY !== f0.maxY || grown.w !== f0.w || grown.h !== f0.h) base = reframeState(state, grown);
1183
+ }
1184
+ const shapes = transformPoints(base.shapes, ids, base.frame, t);
1185
+ const plans = action.placement ? { ...base.plans, [String(action.floorNo)]: action.placement } : base.plans;
1186
+ return commit(base, shapes, { plans });
1187
+ }
756
1188
  default:
757
1189
  return state;
758
1190
  }
@@ -761,7 +1193,8 @@ export function editorReduce(state: TEditorState, action: TEditorAction): TEdito
761
1193
  /**
762
1194
  * Why Save must wait, in plain words - or null. The handoff's sentence for
763
1195
  * an unnamed zone; then what the API would refuse (a lift on one floor,
764
- * shapes past its limits).
1196
+ * shapes past its limits, names that must be unique, lanes that lost a
1197
+ * waypoint).
765
1198
  */
766
1199
  export function editorProblem(state: TEditorState): string | null {
767
1200
  const unnamed = state.shapes.find((s) => s.kind === "zone" && !s.name);
@@ -774,16 +1207,104 @@ export function editorProblem(state: TEditorState): string | null {
774
1207
  const a = shapeAnchor(lonely);
775
1208
  return `Can't save yet: the lift shaft at (${a.x}, ${a.y}) serves only one floor. A lift needs at least two floors - this building's robots report only ${floorName(lonely.floorNo)}.`;
776
1209
  }
777
- const long = state.shapes.find((s) => s.kind === "zone" && (s.name ?? "").length > 60);
778
- if (long) return `Can't save yet: the zone name "${long.name!.slice(0, 20)}…" is too long. Use 60 characters or fewer.`;
1210
+ const long = state.shapes.find((s) => (s.kind === "zone" || s.kind === "waypoint" || s.kind === "fiducial") && (s.name ?? "").length > 60);
1211
+ if (long) return `Can't save yet: the name "${long.name!.slice(0, 20)}…" is too long. Use 60 characters or fewer.`;
1212
+ const routes = routeProblems(state);
1213
+ if (routes.errors.length) return `Can't save yet: ${routes.errors[0]!.text}`;
779
1214
  if (!state.dirty) return "Nothing to save — the map hasn't changed.";
780
- const body = toMapBody(state.shapes, "check");
781
- const walls = body.levels.flatMap((l) => (l.walls ?? []).map((w) => ({ floorNo: l.floorNo, ...w })));
782
- const zones = body.levels.flatMap((l) => (l.zones ?? []).map((z) => ({ floorNo: l.floorNo, ...z })));
1215
+ // In real metres (whatever the frame), relative to the frame's corner.
1216
+ const m = (p: { x: number; y: number }) => r2(inM(p, state.frame));
1217
+ const walls = state.shapes.filter((s) => s.kind === "wall").map((s) => ({ floorNo: s.floorNo, start: m(s.points[0]!), end: m(s.points[1]!) }));
1218
+ const zones = state.shapes.filter((s) => s.kind === "zone").map((s) => ({ floorNo: s.floorNo, name: s.name ?? "", points: s.points.map(m) }));
783
1219
  const problem = layoutProblem(walls, zones);
784
1220
  return problem ? `Can't save yet: ${problem}` : null;
785
1221
  }
786
1222
 
1223
+ export type TCheck = { text: string; ids: string[] };
1224
+
1225
+ function segmentsCross(a: { x: number; y: number }, b: { x: number; y: number }, c: { x: number; y: number }, d: { x: number; y: number }) {
1226
+ const cross = (o: { x: number; y: number }, p: { x: number; y: number }, q: { x: number; y: number }) => (p.x - o.x) * (q.y - o.y) - (p.y - o.y) * (q.x - o.x);
1227
+ const d1 = cross(c, d, a);
1228
+ const d2 = cross(c, d, b);
1229
+ const d3 = cross(a, b, c);
1230
+ const d4 = cross(a, b, d);
1231
+ // Proper crossings only: touching at an end (a lane ending on a wall corner) is not a crossing.
1232
+ return ((d1 > 1e-9 && d2 < -1e-9) || (d1 < -1e-9 && d2 > 1e-9)) && ((d3 > 1e-9 && d4 < -1e-9) || (d3 < -1e-9 && d4 > 1e-9));
1233
+ }
1234
+
1235
+ /**
1236
+ * The robot-route checks, in plain words, each naming the shapes it is about
1237
+ * (the side panel selects them). Errors stop a save (the fleet software would
1238
+ * refuse them); warnings are shown and can be saved anyway.
1239
+ */
1240
+ export function routeProblems(state: Pick<TEditorState, "shapes" | "frame">): { errors: TCheck[]; warnings: TCheck[] } {
1241
+ const errors: TCheck[] = [];
1242
+ const warnings: TCheck[] = [];
1243
+ const f = state.frame;
1244
+ const shapes = state.shapes;
1245
+ const byId = new Map(shapes.map((s) => [s.id, s]));
1246
+ const dupes = (list: TEditorShape[], word: string, scope: string) => {
1247
+ const seen = new Map<string, TEditorShape>();
1248
+ for (const s of list) {
1249
+ const n = (s.name ?? "").trim();
1250
+ if (!n) continue;
1251
+ const other = seen.get(n.toLowerCase());
1252
+ if (other) errors.push({ text: `two ${word} ${scope} are both called "${n}" - give each its own name.`, ids: [other.id, s.id] });
1253
+ else seen.set(n.toLowerCase(), s);
1254
+ }
1255
+ };
1256
+ const floors = [...new Set(shapes.map((s) => s.floorNo))].sort((a, b) => a - b);
1257
+ for (const fl of floors) {
1258
+ dupes(shapes.filter((s) => s.kind === "waypoint" && s.floorNo === fl), "waypoints", `on ${floorName(fl)}`);
1259
+ dupes(shapes.filter((s) => s.kind === "fiducial" && s.floorNo === fl), "fiducials", `on ${floorName(fl)}`);
1260
+ }
1261
+ const doorNames = new Map<string, TEditorShape>();
1262
+ for (const d of shapes.filter((s) => s.kind === "door" && s.meta?.name)) {
1263
+ const n = String(d.meta!.name).trim().toLowerCase();
1264
+ const other = doorNames.get(n);
1265
+ if (other) errors.push({ text: `two doors are both called "${d.meta!.name}" - door names must be unique in the building.`, ids: [other.id, d.id] });
1266
+ else doorNames.set(n, d);
1267
+ }
1268
+ // Lanes that lost a waypoint (a file import can carry them; the editor drops them as they happen).
1269
+ for (const l of shapes.filter((s) => s.kind === "lane")) {
1270
+ const a = byId.get(String(l.meta?.from));
1271
+ const b = byId.get(String(l.meta?.to));
1272
+ if (!a || !b) errors.push({ text: `a lane on ${floorName(l.floorNo)} ends at a waypoint that no longer exists - delete it or draw it again.`, ids: [l.id] });
1273
+ else if (a === b) errors.push({ text: `a lane on ${floorName(l.floorNo)} starts and ends at the same waypoint.`, ids: [l.id] });
1274
+ }
1275
+ // Same name on different floors: allowed, but robots are sent to a waypoint by name.
1276
+ const named = new Map<string, TEditorShape[]>();
1277
+ for (const w of shapes.filter((s) => s.kind === "waypoint" && s.name?.trim())) named.set(w.name!.trim().toLowerCase(), [...(named.get(w.name!.trim().toLowerCase()) ?? []), w]);
1278
+ for (const [, list] of named) {
1279
+ const floorsOf = [...new Set(list.map((w) => w.floorNo))];
1280
+ if (floorsOf.length > 1) warnings.push({ text: `"${list[0]!.name}" is a waypoint name on ${floorsOf.map(floorName).join(" and ")} - robots are sent to a waypoint by its name, so a unique name is safer.`, ids: list.map((w) => w.id) });
1281
+ }
1282
+ for (const w of shapes.filter((s) => s.kind === "waypoint")) {
1283
+ const p = (w.meta?.params ?? {}) as Record<string, unknown>;
1284
+ if (p.is_charger && !String(p.dock_name ?? "").trim()) warnings.push({ text: `the charger ${w.name ? `"${w.name}"` : `on ${floorName(w.floorNo)}`} has no dock name - robots need it to dock there.`, ids: [w.id] });
1285
+ if (p.is_charger && !w.name?.trim()) warnings.push({ text: `a charger on ${floorName(w.floorNo)} has no name - robots are sent to a charger by its name.`, ids: [w.id] });
1286
+ const lanes = shapes.filter((s) => s.kind === "lane" && (s.meta?.from === w.id || s.meta?.to === w.id));
1287
+ if (!lanes.length && (p.is_charger || p.is_parking_spot || p.is_holding_point)) warnings.push({ text: `the waypoint ${w.name ? `"${w.name}"` : `on ${floorName(w.floorNo)}`} is not on any lane, so robots cannot reach it.`, ids: [w.id] });
1288
+ }
1289
+ // A lane through a wall: robots follow lanes, so this is almost always a drawing slip.
1290
+ const m = (p: { x: number; y: number }) => inM(p, f);
1291
+ for (const l of shapes.filter((s) => s.kind === "lane" && s.points.length === 2)) {
1292
+ const [a, b] = l.points.map(m);
1293
+ const wall = shapes.find((s) => s.kind === "wall" && s.floorNo === l.floorNo && s.points.length === 2 && segmentsCross(a!, b!, m(s.points[0]!), m(s.points[1]!)));
1294
+ if (!wall) continue;
1295
+ const door = shapes.some((s) => s.kind === "door" && s.floorNo === l.floorNo && s.points.length === 2 && segmentsCross(a!, b!, m(s.points[0]!), m(s.points[1]!)));
1296
+ if (!door) warnings.push({ text: `a lane on ${floorName(l.floorNo)} crosses a wall with no door in it.`, ids: [l.id, wall.id] });
1297
+ }
1298
+ for (const lift of shapes.filter((s) => s.kind === "lift")) {
1299
+ const levelDoors = (lift.meta?.levelDoors ?? {}) as Record<string, string[]>;
1300
+ const doors = (lift.meta?.doors ?? []) as Array<{ name: string }>;
1301
+ if (!doors.length) continue;
1302
+ const silent = (lift.floors ?? []).filter((fl) => Array.isArray(levelDoors[String(fl)]) && !levelDoors[String(fl)]!.length);
1303
+ if (silent.length) warnings.push({ text: `${lift.meta?.name ?? "A lift"} stops at ${silent.map(floorName).join(", ")} but no cabin door opens there.`, ids: [lift.id] });
1304
+ }
1305
+ return { errors, warnings };
1306
+ }
1307
+
787
1308
  /** "Saved at 14:02 · 5 elements on Tower A." */
788
1309
  export function savedNotice(at: Date, count: number, siteName: string): string {
789
1310
  const hh = String(at.getHours()).padStart(2, "0");
@@ -798,6 +1319,7 @@ export function saveErrorText(e: any): string {
798
1319
  if (status === 409) return "Someone else saved this map after you opened it. Your changes are still here - reload the page to see theirs, then make yours again.";
799
1320
  if (status === 401) return "Your session has ended. Sign in again, then save - your changes stay on this page until you leave it.";
800
1321
  if (status === 403 || status === 404) return "You can't change this building's map. Ask a manager with robot set-up rights to save it.";
1322
+ if (status === 413) return "This building is too large to save in one go (2 MB). Remove some floors or points, or split it into two maps.";
801
1323
  if (typeof msg === "string" && msg.trim()) return `The map was not saved: ${msg.trim()}`;
802
1324
  return "The map was not saved - the server could not be reached. Your changes are still here; try Save again.";
803
1325
  }