@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.
- package/CHANGELOG.md +16 -4
- package/assets/css/robot-hero.css +71 -6
- package/components/MemberMain.vue +61 -3
- package/components/Robot/BuildingMap.vue +78 -26
- package/components/Robot/MapEditor.vue +890 -175
- package/components/Robot/MiniMap.vue +16 -1
- package/components/Robot/SiteFleet.vue +23 -1
- package/composables/useMember.ts +3 -0
- package/composables/useRobots.ts +15 -1
- package/package.json +2 -2
- package/utils/rmf-editor.ts +756 -0
- package/utils/robot-building.ts +692 -170
- package/utils/robot-floorplan.ts +9 -8
- package/utils/role.ts +13 -0
package/utils/robot-building.ts
CHANGED
|
@@ -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
|
|
140
|
-
* metres.
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
165
|
-
meta?: Record<string,
|
|
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
|
-
|
|
220
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
320
|
-
export const gridShares = (stepM: number) => ({ x: (stepM /
|
|
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
|
|
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
|
|
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):
|
|
338
|
-
* zones (the smallest first);
|
|
339
|
-
*
|
|
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> = {
|
|
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 === "
|
|
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
|
|
349
|
-
|
|
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
|
|
411
|
-
* + a turn handle 2 m out on the side away from its swing; a
|
|
412
|
-
* eight box handles + a turn handle above it; a
|
|
413
|
-
* handle on every corner the box handles don't
|
|
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
|
-
|
|
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
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
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,
|
|
454
|
-
if (handle.includes("e")) x1 = Math.max(Math.min(p.x, x0 + max.w,
|
|
455
|
-
if (handle.includes("n")) y0 = Math.min(Math.max(p.y, y1 - max.h,
|
|
456
|
-
if (handle.includes("s")) y1 = Math.max(Math.min(p.y, y0 + max.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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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) =>
|
|
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
|
|
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 ?
|
|
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) =>
|
|
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 (
|
|
499
|
-
*
|
|
500
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
|
518
|
-
|
|
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:
|
|
534
|
-
export const flipDoor = (s: TEditorShape): TEditorShape =>
|
|
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,
|
|
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
|
-
/**
|
|
563
|
-
|
|
564
|
-
|
|
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) => (
|
|
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
|
-
/**
|
|
578
|
-
export function
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
if (!
|
|
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
|
|
597
|
-
|
|
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
|
|
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 (
|
|
624
|
-
|
|
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
|
|
652
|
-
|
|
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
|
-
|
|
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 /
|
|
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 (
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
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
|
-
|
|
781
|
-
const
|
|
782
|
-
const
|
|
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
|
}
|