@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
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!--
|
|
3
3
|
Map editor (README §4 "Edit mode"): Wall · Zone · Door · Lift shaft · Move
|
|
4
|
-
· Delete | Undo · Redo | Save, one floor at a time on a flat 2D plan
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
4
|
+
· Delete | Undo · Redo | Save, one floor at a time on a flat 2D plan, and
|
|
5
|
+
the robot-route tools: Waypoint · Lane · Fiducial · Measure (HERO Fleet
|
|
6
|
+
Core building data, Open-RMF shaped). A drawing tool places its shape where
|
|
7
|
+
you click (the new shape comes selected with its handles). Move: click
|
|
8
|
+
selects (Shift adds), drag moves the selection, drag on empty space selects
|
|
9
|
+
an area; handles resize a lift shaft or zone, drag a wall's or door's ends
|
|
10
|
+
and a zone's corners, and turn a door or lift shaft (any angle). Lane:
|
|
11
|
+
click waypoints in turn (empty space makes one) on the chosen route graph.
|
|
12
|
+
Snaps to the grid (Shift while dragging = free). Wheel zooms, right- or
|
|
13
|
+
middle-drag pans. Keyboard: arrows move the cursor or nudge the selection,
|
|
14
|
+
Enter places or selects, Delete removes, Esc clears (and ends a lane
|
|
15
|
+
chain), Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z or Ctrl+Y redo. Save checks in
|
|
16
|
+
plain words first, then writes the building map through the API
|
|
17
|
+
(versioned). Floor plan (per floor): upload / replace / remove an image
|
|
18
|
+
kept privately in the map, scale by measuring a known length, fit, turn.
|
|
19
|
+
Building file: import the HERO Fleet Core building or a building / route
|
|
20
|
+
file, download the building file or a route map.
|
|
18
21
|
-->
|
|
19
22
|
<div ref="root" class="hero-editor" :class="{ 'hero-editor--fs': isFs }">
|
|
20
23
|
<div class="hero-editor__bar">
|
|
@@ -24,6 +27,9 @@
|
|
|
24
27
|
<button type="button" class="hero-seg__opt hero-seg__opt--redo" :disabled="!state.redo.length" @click="dispatch({ type: 'redo' })">Redo <span class="hero-seg__kbd">⌃⇧Z</span></button>
|
|
25
28
|
<button type="button" class="hero-seg__opt hero-seg__opt--save" :disabled="saving" @click="save">{{ saving ? "Saving…" : "Save" }}</button>
|
|
26
29
|
</div>
|
|
30
|
+
<div class="hero-seg" role="toolbar" aria-label="Robot route tools">
|
|
31
|
+
<button v-for="t in ROUTE_TOOLS" :key="t.id" type="button" class="hero-seg__opt" :aria-pressed="tool === t.id" @click="pickTool(t.id)">{{ t.label }}</button>
|
|
32
|
+
</div>
|
|
27
33
|
<div class="hero-seg" role="group" aria-label="Grid">
|
|
28
34
|
<button type="button" class="hero-seg__opt" :aria-pressed="snapOn" title="Snap to the grid while dragging (hold Shift to place freely)" @click="snapOn = !snapOn">Snap</button>
|
|
29
35
|
<select v-model.number="gridM" class="hero-editor__grid" aria-label="Grid step">
|
|
@@ -31,8 +37,19 @@
|
|
|
31
37
|
</select>
|
|
32
38
|
</div>
|
|
33
39
|
<select v-model.number="floor" class="hero-input hero-select hero-editor__floor" aria-label="Floor">
|
|
34
|
-
<option v-for="f in
|
|
40
|
+
<option v-for="f in allFloors" :key="f" :value="f">{{ floorName(f) }}</option>
|
|
35
41
|
</select>
|
|
42
|
+
<label class="hero-editor__graphpick">
|
|
43
|
+
<span>Route graph</span>
|
|
44
|
+
<select v-model.number="graph" class="hero-input hero-select hero-editor__floor" aria-label="Route graph">
|
|
45
|
+
<option v-for="g in graphOptions" :key="g.idx" :value="g.idx">{{ g.name }} ({{ g.idx }})</option>
|
|
46
|
+
</select>
|
|
47
|
+
</label>
|
|
48
|
+
<div class="hero-seg" role="group" aria-label="Zoom">
|
|
49
|
+
<button type="button" class="hero-seg__opt" title="Zoom out" aria-label="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
|
|
50
|
+
<button type="button" class="hero-seg__opt hero-editor__zoomread" title="Fit the plan" aria-label="Fit the plan" @click="resetView">{{ Math.round(view.zoom * 100) }}%</button>
|
|
51
|
+
<button type="button" class="hero-seg__opt" title="Zoom in" aria-label="Zoom in" @click="zoomBy(1.25)">+</button>
|
|
52
|
+
</div>
|
|
36
53
|
<input
|
|
37
54
|
v-if="tool === 'zone' || selectedZone"
|
|
38
55
|
v-model="zoneName"
|
|
@@ -41,7 +58,7 @@
|
|
|
41
58
|
placeholder="Zone name (required to save)"
|
|
42
59
|
@input="dispatch({ type: 'rename', name: zoneName })"
|
|
43
60
|
/>
|
|
44
|
-
<span class="hero-editor__hint">{{
|
|
61
|
+
<span class="hero-editor__hint">{{ hint }}</span>
|
|
45
62
|
</div>
|
|
46
63
|
|
|
47
64
|
<div v-if="state.notice" class="hero-editor__notice" :class="{ 'hero-editor__notice--ok': state.notice.ok }" role="status" aria-live="polite">{{ state.notice.text }}</div>
|
|
@@ -50,8 +67,7 @@
|
|
|
50
67
|
<div
|
|
51
68
|
ref="plan"
|
|
52
69
|
class="hero-editor__plan"
|
|
53
|
-
:class="`hero-editor__plan--${planCursor}`"
|
|
54
|
-
:style="gridStyle"
|
|
70
|
+
:class="[`hero-editor__plan--${planCursor}`]"
|
|
55
71
|
tabindex="0"
|
|
56
72
|
role="application"
|
|
57
73
|
:aria-label="`Floor plan ${floorName(floor)}. Arrow keys move the cursor or nudge the selection, Enter places or selects, Delete removes, Escape clears.`"
|
|
@@ -60,87 +76,290 @@
|
|
|
60
76
|
@pointerup="onPointerUp"
|
|
61
77
|
@pointercancel="onPointerCancel"
|
|
62
78
|
@dblclick="onDoubleClick"
|
|
79
|
+
@wheel.prevent="onWheel"
|
|
80
|
+
@contextmenu.prevent
|
|
63
81
|
@keydown="onKey"
|
|
64
82
|
@blur="keyboard = false"
|
|
65
83
|
>
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
84
|
+
<div ref="canvas" class="hero-editor__canvas" :style="canvasStyle">
|
|
85
|
+
<img v-if="floorPlan && planUrl" class="hero-editor__planimg" :src="planUrl" :style="planImgStyle" alt="" aria-hidden="true" draggable="false" />
|
|
86
|
+
<template v-else-if="!floorPlan && !hasRmf">
|
|
87
|
+
<span class="hero-editor__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
|
|
88
|
+
<span class="hero-editor__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
|
|
89
|
+
<span class="hero-editor__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
|
|
90
|
+
</template>
|
|
91
|
+
<svg class="hero-editor__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
92
|
+
<template v-for="s in visible" :key="s.id">
|
|
93
|
+
<polygon v-if="s.kind === 'zone'" class="hero-editor__zone-shape" :class="{ 'hero-editor__sel': isSel(s.id) }" :points="pts(s.points)" vector-effect="non-scaling-stroke" />
|
|
94
|
+
<line v-else-if="s.kind === 'wall'" class="hero-editor__wall" :class="{ 'hero-editor__sel': isSel(s.id) }" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
|
|
95
|
+
<template v-else-if="s.kind === 'door'">
|
|
96
|
+
<polyline v-for="(g, gi) in glyphs[s.id] ?? []" :key="gi" class="hero-editor__swing" :points="pts(g)" vector-effect="non-scaling-stroke" />
|
|
97
|
+
<line class="hero-editor__door" :class="{ 'hero-editor__sel': isSel(s.id) }" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
|
|
98
|
+
</template>
|
|
99
|
+
<line
|
|
100
|
+
v-else-if="s.kind === 'lane'"
|
|
101
|
+
class="hero-editor__lane"
|
|
102
|
+
:class="{ 'hero-editor__sel': isSel(s.id), 'hero-editor__lane--other': laneGraph(s) !== graph }"
|
|
103
|
+
:x1="s.points[0]!.x"
|
|
104
|
+
:y1="s.points[0]!.y"
|
|
105
|
+
:x2="s.points[1]!.x"
|
|
106
|
+
:y2="s.points[1]!.y"
|
|
107
|
+
vector-effect="non-scaling-stroke"
|
|
108
|
+
/>
|
|
109
|
+
<line v-else-if="s.kind === 'measure'" class="hero-editor__measure" :class="{ 'hero-editor__sel': isSel(s.id) }" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
|
|
79
110
|
</template>
|
|
111
|
+
<line v-if="rotateLine" class="hero-editor__rotline" :x1="rotateLine.x1" :y1="rotateLine.y1" :x2="rotateLine.x2" :y2="rotateLine.y2" vector-effect="non-scaling-stroke" />
|
|
112
|
+
<line v-if="lanePreview" class="hero-editor__lane hero-editor__lane--preview" :x1="lanePreview.a.x" :y1="lanePreview.a.y" :x2="lanePreview.b.x" :y2="lanePreview.b.y" vector-effect="non-scaling-stroke" />
|
|
113
|
+
<line v-if="measurePreview" class="hero-editor__measure" :x1="measurePreview.a.x" :y1="measurePreview.a.y" :x2="measurePreview.b.x" :y2="measurePreview.b.y" vector-effect="non-scaling-stroke" />
|
|
114
|
+
</svg>
|
|
115
|
+
<span v-for="a in laneArrows" :key="`a-${a.id}`" class="hero-editor__arrow" :class="{ 'hero-editor__arrow--other': a.other }" :style="{ left: `${a.x}%`, top: `${a.y}%`, transform: `translate(-50%, -50%) rotate(${a.deg}deg)` }" aria-hidden="true" />
|
|
116
|
+
<template v-for="s in visible" :key="`l-${s.id}`">
|
|
117
|
+
<div v-if="s.kind === 'zone'" class="hero-editor__label" :style="boxStyle(s)" :title="shapeTitle(s)">{{ s.name || "Unnamed zone" }}</div>
|
|
118
|
+
<div v-else-if="s.kind === 'lift'" class="hero-editor__lift" :class="{ 'hero-editor__sel': isSel(s.id), 'hero-editor__lift--passes': !(s.floors ?? []).includes(floor) }" :style="liftStyle(s)" :title="shapeTitle(s)">{{ s.meta?.name || "Lift" }}</div>
|
|
119
|
+
<div v-else-if="s.kind === 'waypoint'" class="hero-editor__wp" :class="[`hero-editor__wp--${wpKind(s)}`, { 'hero-editor__sel': isSel(s.id), 'hero-editor__wp--from': state.laneFrom === s.id }]" :style="{ left: `${s.points[0]!.x}%`, top: `${s.points[0]!.y}%` }" :title="shapeTitle(s)">
|
|
120
|
+
<span v-if="s.name" class="hero-editor__wpname">{{ s.name }}</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div v-else-if="s.kind === 'fiducial'" class="hero-editor__fid" :class="{ 'hero-editor__sel': isSel(s.id) }" :style="{ left: `${s.points[0]!.x}%`, top: `${s.points[0]!.y}%` }" :title="shapeTitle(s)">
|
|
123
|
+
<span class="hero-editor__wpname">{{ s.name }}</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div v-else-if="s.kind === 'measure'" class="hero-editor__mlabel" :style="midStyle(s)">{{ Number(s.meta?.distanceM ?? 0).toFixed(2) }} m</div>
|
|
80
126
|
</template>
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
</template>
|
|
89
|
-
<div v-for="h in handles" :key="h.key" class="hero-editor__handle" :class="[`hero-editor__handle--${h.kind}`, { 'hero-editor__handle--on': h.kind === 'vertex' && state.vertex?.index === h.index }]" :style="{ left: `${h.x}%`, top: `${h.y}%`, cursor: h.kind === 'rotate' ? undefined : h.cursor }" aria-hidden="true" @pointerdown.stop="onHandleDown($event, h)" />
|
|
90
|
-
<span v-if="marquee" class="hero-editor__marquee" :style="rectStyle(marquee)" aria-hidden="true" />
|
|
91
|
-
<span v-if="keyboard" class="hero-editor__cursor" :style="{ left: `${cursor.x}%`, top: `${cursor.y}%` }" aria-hidden="true" />
|
|
127
|
+
<span v-for="d in cabinDoors" :key="d.key" class="hero-editor__cabindoor" :class="{ 'hero-editor__cabindoor--shut': !d.open }" :style="d.style" :title="d.title" aria-hidden="true" />
|
|
128
|
+
<span v-if="state.measureFrom" class="hero-editor__mpoint" :style="{ left: `${state.measureFrom.x}%`, top: `${state.measureFrom.y}%` }" aria-hidden="true" />
|
|
129
|
+
<div v-for="h in handles" :key="h.key" class="hero-editor__handle" :class="[`hero-editor__handle--${h.kind}`, { 'hero-editor__handle--on': h.kind === 'vertex' && state.vertex?.index === h.index }]" :style="{ left: `${h.x}%`, top: `${h.y}%`, cursor: h.kind === 'rotate' ? undefined : h.cursor }" aria-hidden="true" @pointerdown.stop="onHandleDown($event, h)" />
|
|
130
|
+
<span v-if="marquee" class="hero-editor__marquee" :style="rectStyle(marquee)" aria-hidden="true" />
|
|
131
|
+
<span v-if="keyboard" class="hero-editor__cursor" :style="{ left: `${cursor.x}%`, top: `${cursor.y}%` }" aria-hidden="true" />
|
|
132
|
+
</div>
|
|
133
|
+
<span class="hero-editor__scale" aria-hidden="true">{{ frameText }}</span>
|
|
92
134
|
</div>
|
|
93
135
|
|
|
94
136
|
<aside class="hero-editor__aside">
|
|
137
|
+
<!-- ---- the selection ---- -->
|
|
95
138
|
<template v-if="selectedShapes.length">
|
|
96
|
-
<div class="hero-aside__title hero-editor__asidetitle">{{
|
|
139
|
+
<div class="hero-aside__title hero-editor__asidetitle">{{ single ? shapeTitle(single) : `${selectedShapes.length} selected` }}</div>
|
|
140
|
+
|
|
141
|
+
<template v-if="single?.kind === 'door'">
|
|
142
|
+
<label class="hero-editor__field"><span>Name</span><input class="hero-input" maxlength="60" :value="single.meta?.name ?? ''" placeholder="Named on save" @input="setMeta('name', val($event), 'name')" /></label>
|
|
143
|
+
<label class="hero-editor__field">
|
|
144
|
+
<span>Door type</span>
|
|
145
|
+
<select class="hero-input hero-select" :value="single.meta?.type ?? 'hinged'" @change="setMeta('type', val($event))">
|
|
146
|
+
<option v-for="t in DOOR_TYPES" :key="t" :value="t">{{ DOOR_TYPE_LABEL[t] }}</option>
|
|
147
|
+
</select>
|
|
148
|
+
</label>
|
|
149
|
+
<template v-if="String(single.meta?.type ?? 'hinged').endsWith('hinged')">
|
|
150
|
+
<label v-if="(single.meta?.type ?? 'hinged') === 'hinged'" class="hero-editor__field">
|
|
151
|
+
<span>Hinge at</span>
|
|
152
|
+
<select class="hero-input hero-select" :value="single.meta?.motionAxis ?? 'start'" @change="setMeta('motionAxis', val($event))">
|
|
153
|
+
<option value="start">First end</option>
|
|
154
|
+
<option value="end">Second end</option>
|
|
155
|
+
</select>
|
|
156
|
+
</label>
|
|
157
|
+
<label class="hero-editor__field"><span>Opens up to (°)</span><input class="hero-input" type="number" min="1" max="180" step="5" :value="Math.abs(Number(single.meta?.motionDegrees ?? 90))" @change="setMeta('motionDegrees', clampNum(val($event), 1, 180, 90))" /></label>
|
|
158
|
+
</template>
|
|
159
|
+
<label v-if="String(single.meta?.type ?? '').startsWith('double')" class="hero-editor__field">
|
|
160
|
+
<span>Left / right leaf ratio</span>
|
|
161
|
+
<input class="hero-input" type="number" min="0.1" max="10" step="0.1" :value="Number(single.meta?.rightLeftRatio ?? 1)" @change="setMeta('rightLeftRatio', clampNum(val($event), 0.1, 10, 1))" />
|
|
162
|
+
</label>
|
|
163
|
+
<div class="hero-editor__actions">
|
|
164
|
+
<button type="button" class="hero-editor__act" @click="dispatch({ type: 'flip' })">Flip swing <span class="hero-seg__kbd">F</span></button>
|
|
165
|
+
<button type="button" class="hero-editor__act" @click="dispatch({ type: 'turn' })">Turn 90° <span class="hero-seg__kbd">R</span></button>
|
|
166
|
+
</div>
|
|
167
|
+
</template>
|
|
168
|
+
|
|
169
|
+
<template v-else-if="single?.kind === 'lift'">
|
|
170
|
+
<label class="hero-editor__field"><span>Name</span><input class="hero-input" maxlength="60" :value="single.meta?.name ?? ''" placeholder="Named on save" @input="setMeta('name', val($event), 'name')" /></label>
|
|
171
|
+
<div class="hero-editor__row3">
|
|
172
|
+
<label class="hero-editor__field"><span>Width (m)</span><input class="hero-input" type="number" :min="LIFT_MIN_M" :max="LIFT_MAX_M" step="0.1" :value="liftSize.w.toFixed(2)" @change="setLiftSize(Number(val($event)), null)" /></label>
|
|
173
|
+
<label class="hero-editor__field"><span>Depth (m)</span><input class="hero-input" type="number" :min="LIFT_MIN_M" :max="LIFT_MAX_M" step="0.1" :value="liftSize.d.toFixed(2)" @change="setLiftSize(null, Number(val($event)))" /></label>
|
|
174
|
+
<label class="hero-editor__field"><span>Angle (°)</span><input class="hero-input" type="number" min="-180" max="180" step="1" :value="liftAngle" @change="setLiftAngle(Number(val($event)))" /></label>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="hero-editor__sub">Cabin doors</div>
|
|
177
|
+
<div v-for="(d, i) in liftDoors" :key="i" class="hero-editor__cabinrow">
|
|
178
|
+
<input class="hero-input" maxlength="60" :value="d.name" aria-label="Cabin door name" @change="renameCabinDoor(i, val($event))" />
|
|
179
|
+
<select class="hero-input hero-select" :value="cabinDoorSide(d, liftSize.w, liftSize.d)" aria-label="Side" @change="setCabinDoor(i, { side: val($event) })">
|
|
180
|
+
<option v-for="sd in LIFT_SIDES" :key="sd" :value="sd">{{ LIFT_SIDE_LABEL[sd] }}</option>
|
|
181
|
+
</select>
|
|
182
|
+
<button type="button" class="hero-editor__act hero-editor__act--del" :disabled="liftDoors.length <= 1" aria-label="Remove cabin door" @click="removeCabinDoor(i)">×</button>
|
|
183
|
+
<select class="hero-input hero-select" :value="d.type" aria-label="Door type" @change="setCabinDoor(i, { type: val($event) })">
|
|
184
|
+
<option v-for="t in LIFT_DOOR_TYPES" :key="t" :value="t">{{ LIFT_DOOR_TYPE_LABEL[t] }}</option>
|
|
185
|
+
</select>
|
|
186
|
+
<label class="hero-editor__cabinw"><input class="hero-input" type="number" min="0.3" max="10" step="0.1" :value="d.widthM" aria-label="Door width (m)" @change="setCabinDoor(i, { widthM: clampNum(val($event), 0.3, 10, 1.2) })" /> m</label>
|
|
187
|
+
</div>
|
|
188
|
+
<button type="button" class="hero-editor__act" :disabled="liftDoors.length >= 8" @click="addCabinDoor">Add cabin door</button>
|
|
189
|
+
<div class="hero-editor__sub">Floors · which door opens</div>
|
|
190
|
+
<table class="hero-editor__grid2">
|
|
191
|
+
<thead>
|
|
192
|
+
<tr><th>Floor</th><th>Stops</th><th v-for="d in liftDoors" :key="d.name">{{ d.name }}</th></tr>
|
|
193
|
+
</thead>
|
|
194
|
+
<tbody>
|
|
195
|
+
<tr v-for="fl in [...allFloors].reverse()" :key="fl">
|
|
196
|
+
<td>{{ floorName(fl) }}</td>
|
|
197
|
+
<td><input type="checkbox" :checked="(single.floors ?? []).includes(fl)" :aria-label="`Stops at ${floorName(fl)}`" @change="toggleLiftFloor(fl)" /></td>
|
|
198
|
+
<td v-for="d in liftDoors" :key="d.name">
|
|
199
|
+
<input type="checkbox" :disabled="!(single.floors ?? []).includes(fl)" :checked="doorOpensOn(fl, d.name)" :aria-label="`${d.name} opens on ${floorName(fl)}`" @change="toggleLevelDoor(fl, d.name)" />
|
|
200
|
+
</td>
|
|
201
|
+
</tr>
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
<label class="hero-editor__field">
|
|
205
|
+
<span>Reference floor</span>
|
|
206
|
+
<select class="hero-input hero-select" :value="single.meta?.referenceFloor ?? ''" @change="setMeta('referenceFloor', val($event) || undefined)">
|
|
207
|
+
<option value="">Lowest served floor</option>
|
|
208
|
+
<option v-for="fl in single.floors ?? []" :key="fl" :value="levelNameOf(fl)">{{ levelNameOf(fl) }}</option>
|
|
209
|
+
</select>
|
|
210
|
+
</label>
|
|
211
|
+
<div class="hero-editor__actions">
|
|
212
|
+
<button type="button" class="hero-editor__act" @click="dispatch({ type: 'turn' })">Turn 90° <span class="hero-seg__kbd">R</span></button>
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
215
|
+
|
|
216
|
+
<template v-else-if="single?.kind === 'waypoint'">
|
|
217
|
+
<label class="hero-editor__field"><span>Name</span><input class="hero-input" maxlength="60" :value="single.name ?? ''" placeholder="e.g. lobby, charger_1" @input="setName(val($event))" /></label>
|
|
218
|
+
<div class="hero-editor__checks">
|
|
219
|
+
<label v-for="c in WP_FLAGS" :key="c.key"><input type="checkbox" :checked="!!wpParams[c.key]" @change="setParam(c.key, ($event.target as HTMLInputElement).checked)" /> {{ c.label }}</label>
|
|
220
|
+
</div>
|
|
221
|
+
<label v-for="c in WP_TEXTS" :key="c.key" class="hero-editor__field">
|
|
222
|
+
<span>{{ c.label }}</span>
|
|
223
|
+
<input class="hero-input" maxlength="60" :value="String(wpParams[c.key] ?? '')" :placeholder="c.hint" @input="setParam(c.key, val($event), true)" />
|
|
224
|
+
</label>
|
|
225
|
+
<label class="hero-editor__field"><span>Merge radius (m)</span><input class="hero-input" type="number" min="0" max="100" step="0.1" :value="wpParams.merge_radius ?? ''" placeholder="none" @change="setParam('merge_radius', val($event) === '' ? undefined : clampNum(val($event), 0, 100, 0))" /></label>
|
|
226
|
+
<div class="hero-muted">{{ laneCountText }}</div>
|
|
227
|
+
</template>
|
|
228
|
+
|
|
229
|
+
<template v-else-if="single?.kind === 'lane'">
|
|
230
|
+
<label class="hero-editor__field">
|
|
231
|
+
<span>Route graph</span>
|
|
232
|
+
<select class="hero-input hero-select" :value="laneGraph(single)" @change="setMeta('graphIdx', Number(val($event)))">
|
|
233
|
+
<option v-for="g in graphOptions" :key="g.idx" :value="g.idx">{{ g.name }} ({{ g.idx }})</option>
|
|
234
|
+
</select>
|
|
235
|
+
</label>
|
|
236
|
+
<label class="hero-editor__field">
|
|
237
|
+
<span>Direction</span>
|
|
238
|
+
<select class="hero-input hero-select" :value="single.meta?.bidirectional === false ? 'one' : 'two'" @change="setMeta('bidirectional', val($event) !== 'one')">
|
|
239
|
+
<option value="two">Two-way</option>
|
|
240
|
+
<option value="one">One-way ({{ laneEnds }})</option>
|
|
241
|
+
</select>
|
|
242
|
+
</label>
|
|
243
|
+
<label class="hero-editor__field"><span>Speed limit (m/s)</span><input class="hero-input" type="number" min="0" max="20" step="0.1" :value="single.meta?.speedLimit ?? ''" placeholder="none" @change="setMeta('speedLimit', val($event) === '' ? undefined : clampNum(val($event), 0, 20, 0) || undefined)" /></label>
|
|
244
|
+
<label class="hero-editor__field">
|
|
245
|
+
<span>Robot must face</span>
|
|
246
|
+
<select class="hero-input hero-select" :value="single.meta?.orientation ?? ''" @change="setMeta('orientation', val($event) || undefined)">
|
|
247
|
+
<option value="">Any way</option>
|
|
248
|
+
<option value="forward">Forward along the lane</option>
|
|
249
|
+
<option value="backward">Backward along the lane</option>
|
|
250
|
+
</select>
|
|
251
|
+
</label>
|
|
252
|
+
<label class="hero-editor__field"><span>Shared-space group</span><input class="hero-input" maxlength="60" :value="single.meta?.mutex ?? ''" placeholder="one robot at a time" @input="setMeta('mutex', val($event) || undefined, 'mutex')" /></label>
|
|
253
|
+
<details class="hero-editor__details">
|
|
254
|
+
<summary>Simulation</summary>
|
|
255
|
+
<label class="hero-editor__field"><span>Mock floor name</span><input class="hero-input" maxlength="60" :value="single.meta?.demoMockFloorName ?? ''" @input="setMeta('demoMockFloorName', val($event) || undefined, 'mockfloor')" /></label>
|
|
256
|
+
<label class="hero-editor__field"><span>Mock lift name</span><input class="hero-input" maxlength="60" :value="single.meta?.demoMockLiftName ?? ''" @input="setMeta('demoMockLiftName', val($event) || undefined, 'mocklift')" /></label>
|
|
257
|
+
</details>
|
|
258
|
+
<div class="hero-editor__actions">
|
|
259
|
+
<button type="button" class="hero-editor__act" @click="reverseLane">Reverse direction</button>
|
|
260
|
+
</div>
|
|
261
|
+
</template>
|
|
262
|
+
|
|
263
|
+
<template v-else-if="single?.kind === 'fiducial'">
|
|
264
|
+
<label class="hero-editor__field"><span>Name</span><input class="hero-input" maxlength="60" :value="single.name ?? ''" @input="setName(val($event))" /></label>
|
|
265
|
+
<div class="hero-muted">Use the same name for the same mark on every floor; "Line up with" below then turns, scales and moves this floor onto the other.</div>
|
|
266
|
+
</template>
|
|
267
|
+
|
|
268
|
+
<template v-else-if="single?.kind === 'measure'">
|
|
269
|
+
<label class="hero-editor__field"><span>Real length (m)</span><input class="hero-input" type="number" min="0.01" step="0.01" :value="Number(single.meta?.distanceM ?? 0)" @change="setMeta('distanceM', clampNum(val($event), 0.01, 10000, 1))" /></label>
|
|
270
|
+
<div class="hero-muted">Drawn length now {{ measuredLength.toFixed(2) }} m.</div>
|
|
271
|
+
<div class="hero-editor__actions">
|
|
272
|
+
<button type="button" class="hero-editor__act" :disabled="!floorPlan" @click="applyScale(false)">Scale floor plan to match</button>
|
|
273
|
+
<button type="button" class="hero-editor__act" @click="applyScale(true)">{{ floorPlan ? "Scale plan and drawing" : "Scale drawing to match" }}</button>
|
|
274
|
+
</div>
|
|
275
|
+
</template>
|
|
276
|
+
|
|
97
277
|
<div class="hero-editor__actions">
|
|
98
|
-
<button v-if="selectedShapes.some((s) => s.kind === 'door')" type="button" class="hero-editor__act" @click="dispatch({ type: 'flip' })">Flip swing <span class="hero-seg__kbd">F</span></button>
|
|
99
|
-
<button v-if="selectedShapes.some((s) => s.kind === 'door' || s.kind === 'lift')" type="button" class="hero-editor__act" @click="dispatch({ type: 'turn' })">Turn 90° <span class="hero-seg__kbd">R</span></button>
|
|
100
278
|
<template v-if="selectedZone">
|
|
101
279
|
<button type="button" class="hero-editor__act" @click="addCorner">Add corner</button>
|
|
102
280
|
<button type="button" class="hero-editor__act" :disabled="state.vertex?.id !== selectedZone.id || selectedZone.points.length <= 3" @click="dispatch({ type: 'remove' })">Remove corner</button>
|
|
103
281
|
</template>
|
|
282
|
+
<button v-if="!single" type="button" class="hero-editor__act" :disabled="!selectedShapes.some((s) => s.kind === 'door')" @click="dispatch({ type: 'flip' })">Flip swing <span class="hero-seg__kbd">F</span></button>
|
|
104
283
|
<button type="button" class="hero-editor__act hero-editor__act--del" @click="removeSelection">Delete <span class="hero-seg__kbd">Del</span></button>
|
|
105
284
|
</div>
|
|
106
285
|
<div v-if="selectedZone" class="hero-muted">Double-click an edge to add a corner; double-click a corner to remove it.</div>
|
|
107
|
-
<div v-if="selectedShapes.some((s) => s.kind === 'lift')" class="hero-muted">A lift shaft turns a quarter at a time.</div>
|
|
108
286
|
</template>
|
|
287
|
+
|
|
288
|
+
<!-- ---- checks ---- -->
|
|
289
|
+
<template v-if="checks.errors.length || checks.warnings.length">
|
|
290
|
+
<div class="hero-aside__title hero-editor__asidetitle">Checks</div>
|
|
291
|
+
<button v-for="(c, i) in checks.errors" :key="`e${i}`" type="button" class="hero-editor__check hero-editor__check--error" @click="showCheck(c)">{{ sentence(c.text) }}</button>
|
|
292
|
+
<button v-for="(c, i) in checks.warnings" :key="`w${i}`" type="button" class="hero-editor__check" @click="showCheck(c)">{{ sentence(c.text) }}</button>
|
|
293
|
+
</template>
|
|
294
|
+
|
|
109
295
|
<div class="hero-aside__title hero-editor__asidetitle">On {{ floorName(floor) }}</div>
|
|
110
|
-
<
|
|
111
|
-
<
|
|
112
|
-
|
|
296
|
+
<div class="hero-editor__list">
|
|
297
|
+
<button v-for="s in listed" :key="`r-${s.id}`" type="button" class="hero-editor__row" :class="{ 'hero-editor__row--sel': isSel(s.id) }" :aria-pressed="isSel(s.id)" @click="onRow($event, s)">
|
|
298
|
+
<span>{{ shapeTitle(s) }}</span><span class="hero-editor__pos">{{ shapeAnchor(s).x }}, {{ shapeAnchor(s).y }}</span>
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
113
301
|
<div v-if="!visible.length" class="hero-muted">Nothing drawn yet. Pick a tool and click the plan.</div>
|
|
114
302
|
|
|
303
|
+
<!-- ---- route graphs ---- -->
|
|
304
|
+
<div class="hero-aside__title hero-editor__asidetitle">Route graphs</div>
|
|
305
|
+
<div v-for="g in graphOptions" :key="`g${g.idx}`" class="hero-editor__graphrow" :class="{ 'hero-editor__graphrow--on': g.idx === graph }">
|
|
306
|
+
<input class="hero-input" maxlength="60" :value="g.name" :aria-label="`Name of route graph ${g.idx}`" @change="renameGraph(g.idx, val($event))" />
|
|
307
|
+
<input class="hero-input" maxlength="200" :value="g.brands.join(', ')" placeholder="robot brands" :aria-label="`Robot brands on route graph ${g.idx}`" @change="setGraphBrands(g.idx, val($event))" />
|
|
308
|
+
<span class="hero-editor__pos">{{ laneCount(g.idx) }} lanes</span>
|
|
309
|
+
<button type="button" class="hero-editor__act hero-editor__act--del" :aria-label="`Remove route graph ${g.idx}`" @click="removeGraph(g.idx)">×</button>
|
|
310
|
+
</div>
|
|
311
|
+
<label class="hero-editor__check2"><input v-model="showOtherGraphs" type="checkbox" /> Show lanes of other graphs</label>
|
|
312
|
+
<button type="button" class="hero-editor__act" :disabled="graphOptions.length >= 9" @click="addGraph">Add route graph</button>
|
|
313
|
+
|
|
314
|
+
<!-- ---- this floor ---- -->
|
|
315
|
+
<div class="hero-aside__title hero-editor__asidetitle">Floor · {{ floorName(floor) }}</div>
|
|
316
|
+
<div class="hero-editor__row3">
|
|
317
|
+
<label class="hero-editor__field"><span>Floor name</span><input class="hero-input" maxlength="60" :value="levelNameOf(floor)" @change="setLevel('name', val($event))" /></label>
|
|
318
|
+
<label class="hero-editor__field"><span>Height (m)</span><input class="hero-input" type="number" min="-100" max="1000" step="0.1" :value="levelMeta(floor).elevationM" @change="setLevel('elevationM', clampNum(val($event), -100, 1000, 0))" /></label>
|
|
319
|
+
</div>
|
|
320
|
+
<div class="hero-editor__actions">
|
|
321
|
+
<select v-model.number="alignTo" class="hero-input hero-select hero-editor__floor" aria-label="Floor to line up with">
|
|
322
|
+
<option v-for="f in allFloors.filter((x) => x !== floor)" :key="f" :value="f">{{ floorName(f) }}</option>
|
|
323
|
+
</select>
|
|
324
|
+
<button type="button" class="hero-editor__act" :disabled="allFloors.length < 2" @click="alignNow">Line up with it</button>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="hero-muted">Lines this floor up with the other using fiducials with the same names (two or more).</div>
|
|
327
|
+
|
|
328
|
+
<!-- ---- drawing area ---- -->
|
|
329
|
+
<div class="hero-aside__title hero-editor__asidetitle">Drawing area</div>
|
|
330
|
+
<div class="hero-editor__row3">
|
|
331
|
+
<label class="hero-editor__field"><span>Width (m)</span><input class="hero-input" type="number" min="10" max="5000" step="1" :value="state.frame.w" @change="resizeFrame(Number(val($event)), null)" /></label>
|
|
332
|
+
<label class="hero-editor__field"><span>Height (m)</span><input class="hero-input" type="number" min="10" max="5000" step="1" :value="state.frame.h" @change="resizeFrame(null, Number(val($event)))" /></label>
|
|
333
|
+
</div>
|
|
334
|
+
<button type="button" class="hero-editor__act" @click="fitFrameNow">Fit to the drawing and plans</button>
|
|
335
|
+
|
|
336
|
+
<!-- ---- floor plan ---- -->
|
|
115
337
|
<div class="hero-aside__title hero-editor__asidetitle">Floor plan · {{ floorName(floor) }}</div>
|
|
116
338
|
<div class="hero-editor__actions">
|
|
117
339
|
<button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="pickPlan">{{ planBusy ? "Saving…" : floorPlan ? "Replace plan" : "Upload plan" }}</button>
|
|
118
340
|
<template v-if="floorPlan">
|
|
119
|
-
<button type="button" class="hero-editor__act" :
|
|
120
|
-
<button type="button" class="hero-editor__act"
|
|
341
|
+
<button type="button" class="hero-editor__act" :aria-pressed="tool === 'measure'" @click="pickTool('measure')">Set scale</button>
|
|
342
|
+
<button type="button" class="hero-editor__act" @click="fitPlanNow">Fit</button>
|
|
121
343
|
<button type="button" class="hero-editor__act hero-editor__act--del" :disabled="planLocked" :title="planLockText" @click="removePlanNow">Remove plan</button>
|
|
122
344
|
</template>
|
|
123
345
|
</div>
|
|
346
|
+
<label v-if="floorPlan" class="hero-editor__field"><span>Plan turn (°)</span><input class="hero-input" type="number" min="-180" max="180" step="0.5" :value="planTurnDeg" @change="turnPlan(Number(val($event)))" /></label>
|
|
124
347
|
<input ref="planInput" type="file" accept="image/png,image/jpeg,image/webp" hidden @change="onPlanFile" />
|
|
125
348
|
<div v-if="planLockText && !planBusy" class="hero-muted">{{ planLockText }}</div>
|
|
126
|
-
<div v-if="floorPlan" class="hero-muted">{{ scaleText(
|
|
127
|
-
<div v-if="measuring" class="hero-editor__measurebox" role="group" aria-label="Set scale">
|
|
128
|
-
<span>{{ measure.b ? "Real length between the two points, in metres:" : measure.a ? "Click the other end." : "Click one end of a length you know on the plan (a wall, a doorway)." }}</span>
|
|
129
|
-
<div class="hero-editor__actions">
|
|
130
|
-
<input v-if="measure.b" v-model.number="measureM" type="number" min="0.01" step="0.01" class="hero-input hero-editor__measurein" aria-label="Real length in metres" placeholder="e.g. 12.5" @keydown.enter.prevent="applyMeasure" />
|
|
131
|
-
<button v-if="measure.b" type="button" class="hero-editor__act" @click="applyMeasure">Apply</button>
|
|
132
|
-
<button type="button" class="hero-editor__act" @click="cancelMeasure">Cancel</button>
|
|
133
|
-
</div>
|
|
134
|
-
</div>
|
|
349
|
+
<div v-if="floorPlan" class="hero-muted">{{ scaleText(placedPlan) }}{{ state.plans[String(floor)] ? " · not saved yet" : "" }}</div>
|
|
135
350
|
|
|
351
|
+
<!-- ---- building file ---- -->
|
|
136
352
|
<div class="hero-aside__title hero-editor__asidetitle">Building file · HERO Fleet Core</div>
|
|
137
353
|
<div v-if="map?.rmf" class="hero-muted">{{ rmfLine(map.rmf) }}</div>
|
|
138
354
|
<div class="hero-editor__actions">
|
|
139
355
|
<button v-if="fleetSite" type="button" class="hero-editor__act" :disabled="planBusy || saving || state.dirty" :title="state.dirty ? 'Save your drawing first.' : ''" @click="importFleet">Import from HERO Fleet Core</button>
|
|
356
|
+
<button type="button" class="hero-editor__act" :disabled="planBusy || saving" @click="pickFile('building')">Import building file</button>
|
|
357
|
+
<button type="button" class="hero-editor__act" :disabled="planBusy || saving" @click="pickFile('nav')">Import route map into {{ graphName(graph) }}</button>
|
|
140
358
|
<button type="button" class="hero-editor__act" :disabled="!map?._id" @click="download('building')">Download building file</button>
|
|
141
|
-
<button v-for="g in
|
|
359
|
+
<button v-for="g in savedGraphs" :key="`dl${g}`" type="button" class="hero-editor__act" @click="download('nav', g)">Download route map{{ savedGraphs.length > 1 ? ` ${g}` : "" }}</button>
|
|
142
360
|
</div>
|
|
143
|
-
<
|
|
361
|
+
<input ref="fileInput" type="file" accept=".yaml,.yml,.json,application/x-yaml,text/yaml,application/json" hidden @change="onBuildingFile" />
|
|
362
|
+
<div class="hero-editor__note">The plan image comes from the robot; everything here is drawn on top of it. Keyboard: Tab to the plan; arrow keys move the cursor (or nudge what is selected, Shift for 1 m); Enter places or selects; Delete removes; Esc clears the selection. Wheel zooms; right- or middle-drag pans.</div>
|
|
144
363
|
</aside>
|
|
145
364
|
</div>
|
|
146
365
|
</div>
|
|
@@ -150,53 +369,69 @@
|
|
|
150
369
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
151
370
|
import { floorName, MAP_TOOLS } from "../../utils/robot-display";
|
|
152
371
|
import useFloorPlanImages from "../../composables/useFloorPlanImages";
|
|
372
|
+
import { compressSize, exportFileName, fleetImportBody, floorPlanFileProblem, floorPlanFor, FLOOR_PLAN_MAX_BYTES, rmfGraphs, rmfLine, scaleText } from "../../utils/robot-floorplan";
|
|
153
373
|
import {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
374
|
+
alignFloor,
|
|
375
|
+
cabinDoorOn,
|
|
376
|
+
cabinDoorSide,
|
|
377
|
+
DOOR_TYPE_LABEL,
|
|
378
|
+
DOOR_TYPES,
|
|
379
|
+
editorModelFromMap,
|
|
380
|
+
fitFrame,
|
|
381
|
+
fitPlanIn,
|
|
382
|
+
frameForMap,
|
|
383
|
+
growFrame,
|
|
384
|
+
LIFT_DOOR_TYPE_LABEL,
|
|
385
|
+
LIFT_DOOR_TYPES,
|
|
386
|
+
LIFT_SIDE_LABEL,
|
|
387
|
+
LIFT_SIDES,
|
|
388
|
+
measureScale,
|
|
389
|
+
planBoxIn,
|
|
390
|
+
placementInWorld,
|
|
391
|
+
rmfFromEditor,
|
|
392
|
+
worldPlans,
|
|
393
|
+
transformPlacement,
|
|
394
|
+
type TCabinDoor,
|
|
395
|
+
type TEditorModel,
|
|
396
|
+
type TLiftSide,
|
|
397
|
+
} from "../../utils/rmf-editor";
|
|
169
398
|
import {
|
|
170
399
|
DEFAULT_GRID_M,
|
|
171
|
-
|
|
400
|
+
doorGlyph,
|
|
172
401
|
editorProblem,
|
|
173
402
|
editorReduce,
|
|
174
403
|
emptyEditor,
|
|
175
404
|
GRID_STEPS_M,
|
|
176
|
-
gridShares,
|
|
177
405
|
hitShape,
|
|
406
|
+
liftGeom,
|
|
407
|
+
LIFT_MAX_M,
|
|
408
|
+
LIFT_MIN_M,
|
|
178
409
|
NUDGE_BIG_M,
|
|
179
410
|
NUDGE_M,
|
|
180
|
-
|
|
181
|
-
|
|
411
|
+
ROUTE_TOOLS,
|
|
412
|
+
routeProblems,
|
|
182
413
|
saveErrorText,
|
|
183
414
|
savedNotice,
|
|
184
415
|
shapeAnchor,
|
|
185
416
|
shapeBox,
|
|
186
417
|
shapeHandles,
|
|
187
|
-
shapesFromMap,
|
|
188
418
|
shapesInRect,
|
|
189
419
|
shapesOnFloor,
|
|
190
420
|
shapeTitle,
|
|
421
|
+
shareToWorld,
|
|
191
422
|
snapToGrid,
|
|
192
|
-
toMapBody,
|
|
193
423
|
TOOL_HINTS,
|
|
194
424
|
turnCentre,
|
|
425
|
+
worldToShare,
|
|
426
|
+
type TCheck,
|
|
195
427
|
type TDragOp,
|
|
196
428
|
type TEditorAction,
|
|
197
429
|
type TEditorShape,
|
|
198
430
|
type TEditorState,
|
|
199
431
|
type TEditorTool,
|
|
432
|
+
type TFrame,
|
|
433
|
+
type TPlanPlacement,
|
|
434
|
+
type TRouteGraph,
|
|
200
435
|
type TShapeHandle,
|
|
201
436
|
} from "../../utils/robot-building";
|
|
202
437
|
|
|
@@ -218,15 +453,29 @@ const props = withDefaults(
|
|
|
218
453
|
{ map: null, siteName: "", ownerRobotId: "", startFloor: null, site: "", fleetSite: false },
|
|
219
454
|
);
|
|
220
455
|
const emit = defineEmits<{ saved: [] }>();
|
|
221
|
-
const { createBuildingMap, updateBuildingMap, saveFloorPlan, removeFloorPlan, exportBuildingMap, getFleetCoreBuildingData } = useRobots();
|
|
456
|
+
const { createBuildingMap, updateBuildingMap, saveFloorPlan, removeFloorPlan, exportBuildingMap, getFleetCoreBuildingData, importBuildingFile } = useRobots();
|
|
222
457
|
|
|
223
|
-
|
|
458
|
+
/* ---- the model: shapes + frame + graphs, and the floors' names / heights ---- */
|
|
459
|
+
const storedPlans = () => worldPlans(props.map);
|
|
460
|
+
const levels = ref<TEditorModel["levels"]>({});
|
|
461
|
+
const meta = ref<{ referenceLevel?: string; source?: string }>({});
|
|
462
|
+
function modelFrom(map: Record<string, any> | null | undefined) {
|
|
463
|
+
const m = editorModelFromMap(map, frameForMap(map, storedPlans()));
|
|
464
|
+
levels.value = m.levels;
|
|
465
|
+
meta.value = { referenceLevel: m.referenceLevel, source: m.source };
|
|
466
|
+
return m;
|
|
467
|
+
}
|
|
468
|
+
const first = modelFrom(props.map);
|
|
469
|
+
const state = ref<TEditorState>(emptyEditor(first.shapes, { frame: first.frame, graphs: first.graphs }));
|
|
224
470
|
const dispatch = (a: TEditorAction) => (state.value = editorReduce(state.value, a));
|
|
225
|
-
|
|
471
|
+
const hasRmf = computed(() => !!props.map?.rmf?.levels?.length || state.value.shapes.some((s) => s.kind === "lane" || s.kind === "waypoint"));
|
|
472
|
+
/** A newer map (after our own save, or another tab's) replaces the drawing only while nothing is unsaved. */
|
|
226
473
|
watch(
|
|
227
474
|
() => props.map,
|
|
228
475
|
(m) => {
|
|
229
|
-
if (
|
|
476
|
+
if (state.value.dirty || state.value.drag) return;
|
|
477
|
+
const next = modelFrom(m);
|
|
478
|
+
dispatch({ type: "loadAll", shapes: next.shapes, frame: next.frame, graphs: next.graphs, notice: state.value.notice });
|
|
230
479
|
},
|
|
231
480
|
);
|
|
232
481
|
|
|
@@ -234,16 +483,38 @@ const tool = ref<TEditorTool>("wall");
|
|
|
234
483
|
const zoneName = ref("");
|
|
235
484
|
const snapOn = ref(true);
|
|
236
485
|
const gridM = ref(DEFAULT_GRID_M);
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
486
|
+
const graph = ref<number>(first.graphs[0]?.idx ?? 0);
|
|
487
|
+
const showOtherGraphs = ref(true);
|
|
488
|
+
const graphOptions = computed<TRouteGraph[]>(() => {
|
|
489
|
+
const list = [...state.value.graphs];
|
|
490
|
+
if (!list.some((g) => g.idx === graph.value)) list.push({ idx: graph.value, name: `Graph ${graph.value}`, brands: [] });
|
|
491
|
+
return list.sort((a, b) => a.idx - b.idx);
|
|
492
|
+
});
|
|
493
|
+
const graphName = (idx: number) => graphOptions.value.find((g) => g.idx === idx)?.name ?? `Graph ${idx}`;
|
|
494
|
+
|
|
495
|
+
const allFloors = computed(() => {
|
|
496
|
+
const set = new Set<number>(props.floors);
|
|
497
|
+
for (const k of Object.keys(levels.value)) set.add(Number(k));
|
|
498
|
+
for (const s of state.value.shapes) if (s.kind !== "lift") set.add(s.floorNo);
|
|
499
|
+
return [...set].filter(Number.isFinite).sort((a, b) => a - b);
|
|
240
500
|
});
|
|
241
501
|
const floor = ref<number>(props.startFloor !== null && props.floors.includes(props.startFloor) ? props.startFloor : props.floors[0] ?? 1);
|
|
242
|
-
watch(
|
|
502
|
+
watch(allFloors, (f) => {
|
|
243
503
|
if (!f.includes(floor.value)) floor.value = f[0] ?? 1;
|
|
244
504
|
});
|
|
245
|
-
watch(floor, () =>
|
|
246
|
-
|
|
505
|
+
watch(floor, () => {
|
|
506
|
+
// Changing floor clears the selection but keeps what the editor just said (an import's result).
|
|
507
|
+
const said = state.value.notice;
|
|
508
|
+
dispatch({ type: "select", ids: [] });
|
|
509
|
+
dispatch({ type: "laneEnd" });
|
|
510
|
+
if (said) state.value = { ...state.value, notice: said };
|
|
511
|
+
alignTo.value = allFloors.value.find((f) => f !== floor.value) ?? floor.value;
|
|
512
|
+
});
|
|
513
|
+
const alignTo = ref<number>(allFloors.value.find((f) => f !== floor.value) ?? floor.value);
|
|
514
|
+
|
|
515
|
+
const laneGraph = (s: TEditorShape) => Number(s.meta?.graphIdx ?? 0);
|
|
516
|
+
const visible = computed(() => shapesOnFloor(state.value.shapes, floor.value).filter((s) => s.kind !== "lane" || showOtherGraphs.value || laneGraph(s) === graph.value));
|
|
517
|
+
const listed = computed(() => visible.value.slice(0, 400));
|
|
247
518
|
const isSel = (id: string) => state.value.selected.includes(id);
|
|
248
519
|
const selectedShapes = computed(() => visible.value.filter((s) => isSel(s.id)));
|
|
249
520
|
const single = computed(() => (selectedShapes.value.length === 1 ? selectedShapes.value[0]! : null));
|
|
@@ -251,62 +522,120 @@ const selectedZone = computed(() => (single.value?.kind === "zone" ? single.valu
|
|
|
251
522
|
watch(selectedZone, (z) => {
|
|
252
523
|
if (z) zoneName.value = z.name ?? "";
|
|
253
524
|
});
|
|
254
|
-
const
|
|
525
|
+
const frameSize = computed(() => ({ w: state.value.frame.w, h: state.value.frame.h }));
|
|
526
|
+
const handles = computed<TShapeHandle[]>(() => (single.value ? shapeHandles(single.value, frameSize.value) : []));
|
|
255
527
|
const rotateLine = computed(() => {
|
|
256
528
|
const r = handles.value.find((h) => h.kind === "rotate");
|
|
257
529
|
if (!r || !single.value) return null;
|
|
258
530
|
const c = turnCentre(single.value);
|
|
259
531
|
return { x1: c.x, y1: c.y, x2: r.x, y2: r.y };
|
|
260
532
|
});
|
|
261
|
-
const
|
|
533
|
+
const glyphs = computed(() => Object.fromEntries(visible.value.filter((s) => s.kind === "door").map((s) => [s.id, doorGlyph(s, frameSize.value)])));
|
|
534
|
+
const checks = computed(() => routeProblems(state.value));
|
|
535
|
+
const hint = computed(() => (tool.value === "lane" && state.value.laneFrom ? "Click the next waypoint (or empty space) to add a lane. Esc or click this waypoint again to stop." : tool.value === "measure" && state.value.measureFrom ? "Click the other end." : TOOL_HINTS[tool.value]));
|
|
536
|
+
const frameText = computed(() => `${state.value.frame.w.toFixed(0)} × ${state.value.frame.h.toFixed(0)} m`);
|
|
262
537
|
|
|
263
538
|
function pickTool(id: TEditorTool) {
|
|
264
539
|
tool.value = id;
|
|
265
540
|
dispatch({ type: "tool", tool: id });
|
|
266
541
|
}
|
|
267
542
|
|
|
268
|
-
/* ----
|
|
543
|
+
/* ---- view: the canvas fits the viewport with the frame's real shape; wheel zooms, right/middle drag pans ---- */
|
|
269
544
|
const plan = ref<HTMLElement | null>(null);
|
|
545
|
+
const canvas = ref<HTMLElement | null>(null);
|
|
546
|
+
const box = ref({ w: 800, h: 520 });
|
|
547
|
+
const view = ref({ zoom: 1, panX: 0, panY: 0 });
|
|
548
|
+
let ro: ResizeObserver | null = null;
|
|
549
|
+
onMounted(() => {
|
|
550
|
+
if (!plan.value || typeof ResizeObserver === "undefined") return;
|
|
551
|
+
ro = new ResizeObserver(([e]) => {
|
|
552
|
+
if (e) box.value = { w: e.contentRect.width, h: e.contentRect.height };
|
|
553
|
+
});
|
|
554
|
+
ro.observe(plan.value);
|
|
555
|
+
});
|
|
556
|
+
onBeforeUnmount(() => ro?.disconnect());
|
|
557
|
+
const canvasStyle = computed(() => {
|
|
558
|
+
const f = state.value.frame;
|
|
559
|
+
const s0 = Math.min(box.value.w / f.w, box.value.h / f.h) * 0.96;
|
|
560
|
+
const w = f.w * s0 * view.value.zoom;
|
|
561
|
+
const h = f.h * s0 * view.value.zoom;
|
|
562
|
+
const g = { x: (gridM.value / f.w) * 100, y: (gridM.value / f.h) * 100 };
|
|
563
|
+
return {
|
|
564
|
+
width: `${w}px`,
|
|
565
|
+
height: `${h}px`,
|
|
566
|
+
left: `${(box.value.w - w) / 2 + view.value.panX}px`,
|
|
567
|
+
top: `${(box.value.h - h) / 2 + view.value.panY}px`,
|
|
568
|
+
backgroundSize: `${g.x}% ${g.y}%`,
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
function zoomBy(k: number, at?: { x: number; y: number }) {
|
|
572
|
+
const z0 = view.value.zoom;
|
|
573
|
+
const z = Math.min(40, Math.max(0.5, +(z0 * k).toFixed(3)));
|
|
574
|
+
if (z === z0) return;
|
|
575
|
+
// Keep the point under the pointer still.
|
|
576
|
+
const p = at ?? { x: box.value.w / 2, y: box.value.h / 2 };
|
|
577
|
+
const cx = box.value.w / 2 + view.value.panX;
|
|
578
|
+
const cy = box.value.h / 2 + view.value.panY;
|
|
579
|
+
view.value = { zoom: z, panX: view.value.panX - (p.x - cx) * (z / z0 - 1), panY: view.value.panY - (p.y - cy) * (z / z0 - 1) };
|
|
580
|
+
}
|
|
581
|
+
const resetView = () => (view.value = { zoom: 1, panX: 0, panY: 0 });
|
|
582
|
+
function onWheel(e: WheelEvent) {
|
|
583
|
+
const r = plan.value!.getBoundingClientRect();
|
|
584
|
+
zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, { x: e.clientX - r.left, y: e.clientY - r.top });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* ---- pointer: mouse, touch and pen alike ---- */
|
|
270
588
|
const cursor = ref({ x: 50, y: 50 });
|
|
271
589
|
const keyboard = ref(false);
|
|
272
590
|
const marquee = ref<{ x: number; y: number; w: number; h: number } | null>(null);
|
|
273
591
|
const hoverId = ref<string | null>(null);
|
|
274
|
-
|
|
592
|
+
const pointerAt = ref<{ x: number; y: number } | null>(null);
|
|
593
|
+
type TPress = { pointer: number; x: number; y: number; cx: number; cy: number; mode: "drag" | "marquee" | "click" | "pan"; op?: TDragOp; shift: boolean; started: boolean; pan0?: { x: number; y: number } };
|
|
275
594
|
let press: TPress | null = null;
|
|
276
595
|
/** The cursor a drag in progress shows: move, rotate or a resize direction. */
|
|
277
596
|
const dragKind = ref("");
|
|
278
597
|
|
|
279
598
|
function at(e: { clientX: number; clientY: number }) {
|
|
280
|
-
const r = plan.value!.getBoundingClientRect();
|
|
599
|
+
const r = (canvas.value ?? plan.value)!.getBoundingClientRect();
|
|
281
600
|
const c = (v: number) => Math.max(0, Math.min(100, v));
|
|
282
601
|
return { x: c(((e.clientX - r.left) / r.width) * 100), y: c(((e.clientY - r.top) / r.height) * 100) };
|
|
283
602
|
}
|
|
284
603
|
/** How near counts as "on" a line or corner: 10 screen pixels (16 for a finger), in metres. */
|
|
285
604
|
function tolerance(pointerType = "mouse") {
|
|
286
|
-
const r =
|
|
605
|
+
const r = canvas.value?.getBoundingClientRect();
|
|
287
606
|
if (!r?.width) return 0.4;
|
|
288
|
-
return
|
|
607
|
+
return (state.value.frame.w / r.width) * (pointerType === "touch" ? 16 : 10);
|
|
289
608
|
}
|
|
290
609
|
|
|
291
610
|
function startPress(e: PointerEvent, mode: TPress["mode"], op?: TDragOp) {
|
|
292
611
|
const p = at(e);
|
|
293
|
-
press = { pointer: e.pointerId, x: p.x, y: p.y, cx: e.clientX, cy: e.clientY, mode, op, shift: e.shiftKey, started: false };
|
|
612
|
+
press = { pointer: e.pointerId, x: p.x, y: p.y, cx: e.clientX, cy: e.clientY, mode, op, shift: e.shiftKey, started: false, pan0: { x: view.value.panX, y: view.value.panY } };
|
|
294
613
|
plan.value?.setPointerCapture?.(e.pointerId);
|
|
295
614
|
}
|
|
296
615
|
|
|
297
616
|
function onPlanDown(e: PointerEvent) {
|
|
298
|
-
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
299
617
|
plan.value?.focus({ preventScroll: true });
|
|
618
|
+
if (e.pointerType === "mouse" && (e.button === 1 || e.button === 2)) return startPress(e, "pan");
|
|
619
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
300
620
|
keyboard.value = false;
|
|
301
621
|
const p = at(e);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const target = hitShape(visible.value, p, tolerance(e.pointerType));
|
|
622
|
+
const f = frameSize.value;
|
|
623
|
+
const target = hitShape(visible.value, p, tolerance(e.pointerType), f);
|
|
305
624
|
const t = tool.value;
|
|
625
|
+
const snapped = snapOn.value && !e.shiftKey ? snapToGrid(p, gridM.value, f) : p;
|
|
306
626
|
if (t === "delete") {
|
|
307
627
|
if (target) dispatch({ type: "pick", id: target.id, tool: "delete" });
|
|
308
628
|
return;
|
|
309
629
|
}
|
|
630
|
+
if (t === "lane") {
|
|
631
|
+
e.preventDefault();
|
|
632
|
+
const hit = hitShape(visible.value.filter((s) => s.kind === "waypoint"), p, tolerance(e.pointerType), f);
|
|
633
|
+
return void dispatch({ type: "laneClick", floorNo: floor.value, x: snapped.x, y: snapped.y, graphIdx: graph.value, hitId: hit?.id ?? null });
|
|
634
|
+
}
|
|
635
|
+
if (t === "measure") {
|
|
636
|
+
e.preventDefault();
|
|
637
|
+
return void dispatch({ type: "measureClick", floorNo: floor.value, ...snapped });
|
|
638
|
+
}
|
|
310
639
|
if (t === "zone" && target?.kind === "zone" && zoneName.value.trim() && !isSel(target.id)) {
|
|
311
640
|
dispatch({ type: "pick", id: target.id, tool: "zone", zoneName: zoneName.value });
|
|
312
641
|
return;
|
|
@@ -323,8 +652,7 @@ function onPlanDown(e: PointerEvent) {
|
|
|
323
652
|
}
|
|
324
653
|
// A drawing tool: place (on the grid while snapping), and select the new shape.
|
|
325
654
|
e.preventDefault();
|
|
326
|
-
|
|
327
|
-
dispatch({ type: "place", tool: t, floorNo: floor.value, x: Math.min(98, q.x), y: Math.min(98, q.y), zoneName: zoneName.value, floors: props.floors });
|
|
655
|
+
dispatch({ type: "place", tool: t, floorNo: floor.value, x: Math.min(98, snapped.x), y: Math.min(98, snapped.y), zoneName: zoneName.value, floors: props.floors });
|
|
328
656
|
}
|
|
329
657
|
|
|
330
658
|
function onHandleDown(e: PointerEvent, h: TShapeHandle) {
|
|
@@ -339,12 +667,17 @@ function onHandleDown(e: PointerEvent, h: TShapeHandle) {
|
|
|
339
667
|
}
|
|
340
668
|
|
|
341
669
|
function onPointerMove(e: PointerEvent) {
|
|
670
|
+
const p = at(e);
|
|
671
|
+
pointerAt.value = p;
|
|
342
672
|
if (!press) {
|
|
343
|
-
if (e.pointerType === "mouse") hoverId.value = hitShape(visible.value,
|
|
673
|
+
if (e.pointerType === "mouse") hoverId.value = hitShape(visible.value, p, tolerance(), frameSize.value)?.id ?? null;
|
|
344
674
|
return;
|
|
345
675
|
}
|
|
346
676
|
if (e.pointerId !== press.pointer) return;
|
|
347
|
-
|
|
677
|
+
if (press.mode === "pan") {
|
|
678
|
+
view.value = { ...view.value, panX: press.pan0!.x + e.clientX - press.cx, panY: press.pan0!.y + e.clientY - press.cy };
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
348
681
|
if (!press.started) {
|
|
349
682
|
if (Math.hypot(e.clientX - press.cx, e.clientY - press.cy) < 3) return;
|
|
350
683
|
press.started = true;
|
|
@@ -385,14 +718,14 @@ function onPointerCancel() {
|
|
|
385
718
|
|
|
386
719
|
/**
|
|
387
720
|
* Double-click on a selected zone: on a corner removes it, on an edge adds a
|
|
388
|
-
* corner there.
|
|
389
|
-
* click always arrives here, even over a handle.)
|
|
721
|
+
* corner there. With the lane tool a double-click ends the chain.
|
|
390
722
|
*/
|
|
391
723
|
function onDoubleClick(e: MouseEvent) {
|
|
724
|
+
if (tool.value === "lane") return void dispatch({ type: "laneEnd" });
|
|
392
725
|
const z = selectedZone.value;
|
|
393
726
|
if (!z || tool.value === "delete") return;
|
|
394
727
|
const p = at(e);
|
|
395
|
-
const r =
|
|
728
|
+
const r = canvas.value!.getBoundingClientRect();
|
|
396
729
|
const reach = 14; // px
|
|
397
730
|
const corner = z.points.findIndex((q) => Math.hypot(((q.x - p.x) / 100) * r.width, ((q.y - p.y) / 100) * r.height) <= reach);
|
|
398
731
|
if (corner >= 0) dispatch({ type: "removeVertex", id: z.id, index: corner });
|
|
@@ -403,10 +736,11 @@ function onDoubleClick(e: MouseEvent) {
|
|
|
403
736
|
function addCorner() {
|
|
404
737
|
const z = selectedZone.value;
|
|
405
738
|
if (!z) return;
|
|
739
|
+
const f = state.value.frame;
|
|
406
740
|
let best = { len: -1, x: 0, y: 0 };
|
|
407
741
|
z.points.forEach((a, i) => {
|
|
408
742
|
const b = z.points[(i + 1) % z.points.length]!;
|
|
409
|
-
const len = Math.hypot((b.x - a.x) *
|
|
743
|
+
const len = Math.hypot((b.x - a.x) * f.w, (b.y - a.y) * f.h);
|
|
410
744
|
if (len > best.len) best = { len, x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
411
745
|
});
|
|
412
746
|
dispatch({ type: "addVertex", id: z.id, x: best.x, y: best.y });
|
|
@@ -422,6 +756,15 @@ function onRow(e: MouseEvent, s: TEditorShape) {
|
|
|
422
756
|
dispatch({ type: "select", ids: [s.id], mode: e.shiftKey ? "toggle" : "replace" });
|
|
423
757
|
}
|
|
424
758
|
|
|
759
|
+
function showCheck(c: TCheck) {
|
|
760
|
+
const s = state.value.shapes.find((x) => c.ids.includes(x.id));
|
|
761
|
+
if (!s) return;
|
|
762
|
+
if (s.kind !== "lift") floor.value = s.floorNo;
|
|
763
|
+
pickTool("move");
|
|
764
|
+
dispatch({ type: "select", ids: c.ids });
|
|
765
|
+
}
|
|
766
|
+
const sentence = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
767
|
+
|
|
425
768
|
const handleCursor = (h: string) => (h === "n" || h === "s" ? "ns" : h === "e" || h === "w" ? "ew" : h === "nw" || h === "se" ? "nwse" : "nesw");
|
|
426
769
|
/** The plan's cursor: what a drag is doing, else what a press would do here. */
|
|
427
770
|
const planCursor = computed(() => {
|
|
@@ -434,9 +777,11 @@ const planCursor = computed(() => {
|
|
|
434
777
|
|
|
435
778
|
/* ---- keyboard ---- */
|
|
436
779
|
function act(x: number, y: number, shift = false) {
|
|
437
|
-
const target = hitShape(visible.value, { x, y }, tolerance());
|
|
780
|
+
const target = hitShape(visible.value, { x, y }, tolerance(), frameSize.value);
|
|
438
781
|
const t = tool.value;
|
|
439
782
|
if (t === "delete") return void (target && dispatch({ type: "pick", id: target.id, tool: "delete" }));
|
|
783
|
+
if (t === "lane") return void dispatch({ type: "laneClick", floorNo: floor.value, x, y, graphIdx: graph.value, hitId: target?.kind === "waypoint" ? target.id : null });
|
|
784
|
+
if (t === "measure") return void dispatch({ type: "measureClick", floorNo: floor.value, x, y });
|
|
440
785
|
if (t === "zone" && target?.kind === "zone" && zoneName.value.trim() && !isSel(target.id)) {
|
|
441
786
|
return void dispatch({ type: "pick", id: target.id, tool: "zone", zoneName: zoneName.value });
|
|
442
787
|
}
|
|
@@ -451,6 +796,7 @@ function onKey(e: KeyboardEvent) {
|
|
|
451
796
|
const k = e.key;
|
|
452
797
|
if (k === "Escape") {
|
|
453
798
|
if (state.value.drag) onPointerCancel();
|
|
799
|
+
else if (state.value.laneFrom || state.value.measureFrom) state.value = { ...state.value, laneFrom: null, measureFrom: null };
|
|
454
800
|
else if (state.value.selected.length) dispatch({ type: "select", ids: [] });
|
|
455
801
|
else keyboard.value = false;
|
|
456
802
|
return;
|
|
@@ -465,6 +811,8 @@ function onKey(e: KeyboardEvent) {
|
|
|
465
811
|
}
|
|
466
812
|
if (!e.ctrlKey && !e.metaKey && !e.altKey && (k === "f" || k === "F")) return void dispatch({ type: "flip" });
|
|
467
813
|
if (!e.ctrlKey && !e.metaKey && !e.altKey && (k === "r" || k === "R")) return void dispatch({ type: "turn" });
|
|
814
|
+
if (!e.ctrlKey && !e.metaKey && (k === "+" || k === "=")) return void zoomBy(1.25);
|
|
815
|
+
if (!e.ctrlKey && !e.metaKey && (k === "-" || k === "_")) return void zoomBy(1 / 1.25);
|
|
468
816
|
const dir: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
469
817
|
if (dir[k]) {
|
|
470
818
|
e.preventDefault();
|
|
@@ -498,11 +846,248 @@ function onUndoKey(e: KeyboardEvent) {
|
|
|
498
846
|
onMounted(() => window.addEventListener("keydown", onUndoKey));
|
|
499
847
|
onBeforeUnmount(() => window.removeEventListener("keydown", onUndoKey));
|
|
500
848
|
|
|
849
|
+
/* ---- side panel edits ---- */
|
|
850
|
+
const val = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value;
|
|
851
|
+
const clampNum = (v: unknown, lo: number, hi: number, fallback: number) => {
|
|
852
|
+
const n = Number(v);
|
|
853
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : fallback;
|
|
854
|
+
};
|
|
855
|
+
function setMeta(key: string, value: unknown, field?: string) {
|
|
856
|
+
if (!single.value) return;
|
|
857
|
+
dispatch({ type: "setProps", id: single.value.id, meta: { [key]: value }, field });
|
|
858
|
+
}
|
|
859
|
+
function setName(name: string) {
|
|
860
|
+
if (single.value) dispatch({ type: "setProps", id: single.value.id, name, field: "name" });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const WP_FLAGS = [
|
|
864
|
+
{ key: "is_charger", label: "Charger" },
|
|
865
|
+
{ key: "is_parking_spot", label: "Parking spot" },
|
|
866
|
+
{ key: "is_holding_point", label: "Holding point (robots may wait here)" },
|
|
867
|
+
{ key: "is_passthrough_point", label: "Pass-through only (robots never stop)" },
|
|
868
|
+
{ key: "is_cleaning_zone", label: "Cleaning zone" },
|
|
869
|
+
] as const;
|
|
870
|
+
const WP_TEXTS = [
|
|
871
|
+
{ key: "dock_name", label: "Dock name", hint: "needed for a charger" },
|
|
872
|
+
{ key: "spawn_robot_name", label: "Robot starts here - name", hint: "simulation" },
|
|
873
|
+
{ key: "spawn_robot_type", label: "Robot starts here - model", hint: "simulation" },
|
|
874
|
+
{ key: "pickup_dispenser", label: "Pick-up point (dispenser)", hint: "" },
|
|
875
|
+
{ key: "dropoff_ingestor", label: "Drop-off point (ingestor)", hint: "" },
|
|
876
|
+
{ key: "mutex", label: "Shared-space group", hint: "one robot at a time" },
|
|
877
|
+
] as const;
|
|
878
|
+
const wpParams = computed<Record<string, any>>(() => (single.value?.meta?.params ?? {}) as Record<string, any>);
|
|
879
|
+
function setParam(key: string, value: unknown, typing = false) {
|
|
880
|
+
if (!single.value) return;
|
|
881
|
+
const params = { ...wpParams.value };
|
|
882
|
+
if (value === undefined || value === "" || value === false) delete params[key];
|
|
883
|
+
else params[key] = value;
|
|
884
|
+
dispatch({ type: "setProps", id: single.value.id, meta: { params }, field: typing ? `p:${key}` : undefined });
|
|
885
|
+
}
|
|
886
|
+
const wpKind = (s: TEditorShape) => {
|
|
887
|
+
const p = (s.meta?.params ?? {}) as Record<string, unknown>;
|
|
888
|
+
return p.is_charger ? "charger" : p.is_parking_spot ? "parking" : p.is_holding_point ? "holding" : "point";
|
|
889
|
+
};
|
|
890
|
+
const laneCountText = computed(() => {
|
|
891
|
+
const s = single.value;
|
|
892
|
+
if (!s) return "";
|
|
893
|
+
const n = state.value.shapes.filter((l) => l.kind === "lane" && (l.meta?.from === s.id || l.meta?.to === s.id)).length;
|
|
894
|
+
return n ? `On ${n} lane${n === 1 ? "" : "s"}.` : "Not on any lane yet - use the Lane tool to connect it.";
|
|
895
|
+
});
|
|
896
|
+
const laneEnds = computed(() => {
|
|
897
|
+
const s = single.value;
|
|
898
|
+
if (!s || s.kind !== "lane") return "";
|
|
899
|
+
const nm = (id: unknown) => state.value.shapes.find((x) => x.id === id)?.name || "waypoint";
|
|
900
|
+
return `${nm(s.meta?.from)} → ${nm(s.meta?.to)}`;
|
|
901
|
+
});
|
|
902
|
+
function reverseLane() {
|
|
903
|
+
const s = single.value;
|
|
904
|
+
if (s?.kind === "lane") {
|
|
905
|
+
const o = s.meta?.orientation;
|
|
906
|
+
dispatch({ type: "setProps", id: s.id, meta: { from: s.meta?.to, to: s.meta?.from, ...(o ? { orientation: o === "forward" ? "backward" : "forward" } : {}) } });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/* lift */
|
|
911
|
+
const liftSize = computed(() => (single.value?.kind === "lift" ? liftGeom(single.value, frameSize.value) : { w: 0, d: 0, yaw: 0, c: { x: 0, y: 0 } }));
|
|
912
|
+
const liftAngle = computed(() => Math.round(((Number(single.value?.meta?.yawRad ?? 0) * 180) / Math.PI) * 10) / 10);
|
|
913
|
+
const liftDoors = computed<TCabinDoor[]>(() => (single.value?.meta?.doors ?? []) as TCabinDoor[]);
|
|
914
|
+
function setLiftSize(w: number | null, d: number | null) {
|
|
915
|
+
const s = single.value;
|
|
916
|
+
if (s?.kind !== "lift") return;
|
|
917
|
+
const f = state.value.frame;
|
|
918
|
+
const g = liftGeom(s, f);
|
|
919
|
+
const nw = w === null ? g.w : clampNum(w, LIFT_MIN_M, LIFT_MAX_M, g.w);
|
|
920
|
+
const nd = d === null ? g.d : clampNum(d, LIFT_MIN_M, LIFT_MAX_M, g.d);
|
|
921
|
+
const b = shapeBox(s);
|
|
922
|
+
const cx = b.x + b.w / 2;
|
|
923
|
+
const cy = b.y + b.h / 2;
|
|
924
|
+
const hw = (nw / f.w) * 50;
|
|
925
|
+
const hd = (nd / f.h) * 50;
|
|
926
|
+
const points = [{ x: cx - hw, y: cy - hd }, { x: cx + hw, y: cy - hd }, { x: cx + hw, y: cy + hd }, { x: cx - hw, y: cy + hd }].map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }));
|
|
927
|
+
// Cabin doors stay on their side of the resized cabin.
|
|
928
|
+
const doors = liftDoors.value.map((dr) => cabinDoorOn(cabinDoorSide(dr, g.w, g.d), nw, nd, dr));
|
|
929
|
+
dispatch({ type: "setShapes", shapes: state.value.shapes.map((x) => (x.id === s.id ? { ...x, points, meta: { ...(x.meta ?? {}), doors } } : x)) });
|
|
930
|
+
}
|
|
931
|
+
function setLiftAngle(deg: number) {
|
|
932
|
+
if (!Number.isFinite(deg)) return;
|
|
933
|
+
setMeta("yawRad", Math.round((((((deg + 180) % 360) + 360) % 360) - 180) * (Math.PI / 180) * 10000) / 10000);
|
|
934
|
+
}
|
|
935
|
+
function setCabinDoor(i: number, change: { side?: string; type?: string; widthM?: number }) {
|
|
936
|
+
const doors = liftDoors.value.map((d, k) => {
|
|
937
|
+
if (k !== i) return d;
|
|
938
|
+
const side = (change.side ?? cabinDoorSide(d, liftSize.value.w, liftSize.value.d)) as TLiftSide;
|
|
939
|
+
return cabinDoorOn(side, liftSize.value.w, liftSize.value.d, { ...d, ...(change.type ? { type: change.type as TCabinDoor["type"] } : {}), ...(change.widthM ? { widthM: change.widthM } : {}) });
|
|
940
|
+
});
|
|
941
|
+
setMeta("doors", doors);
|
|
942
|
+
}
|
|
943
|
+
function renameCabinDoor(i: number, name: string) {
|
|
944
|
+
const n = name.trim().slice(0, 60);
|
|
945
|
+
const old = liftDoors.value[i]?.name;
|
|
946
|
+
if (!n || !old || liftDoors.value.some((d, k) => k !== i && d.name === n)) return;
|
|
947
|
+
const doors = liftDoors.value.map((d, k) => (k === i ? { ...d, name: n } : d));
|
|
948
|
+
const levelDoors = Object.fromEntries(Object.entries((single.value?.meta?.levelDoors ?? {}) as Record<string, string[]>).map(([fl, list]) => [fl, list.map((x) => (x === old ? n : x))]));
|
|
949
|
+
if (single.value) dispatch({ type: "setProps", id: single.value.id, meta: { doors, levelDoors } });
|
|
950
|
+
}
|
|
951
|
+
function addCabinDoor() {
|
|
952
|
+
const used = new Set(liftDoors.value.map((d) => cabinDoorSide(d, liftSize.value.w, liftSize.value.d)));
|
|
953
|
+
const side = LIFT_SIDES.find((s) => !used.has(s)) ?? "back";
|
|
954
|
+
let name: string = side;
|
|
955
|
+
for (let n = 2; liftDoors.value.some((d) => d.name === name); n++) name = `${side}${n}`;
|
|
956
|
+
setMeta("doors", [...liftDoors.value, cabinDoorOn(side, liftSize.value.w, liftSize.value.d, { name })]);
|
|
957
|
+
}
|
|
958
|
+
function removeCabinDoor(i: number) {
|
|
959
|
+
const gone = liftDoors.value[i]?.name;
|
|
960
|
+
const doors = liftDoors.value.filter((_, k) => k !== i);
|
|
961
|
+
const levelDoors = Object.fromEntries(Object.entries((single.value?.meta?.levelDoors ?? {}) as Record<string, string[]>).map(([fl, list]) => [fl, list.filter((x) => x !== gone)]));
|
|
962
|
+
if (single.value) dispatch({ type: "setProps", id: single.value.id, meta: { doors, levelDoors } });
|
|
963
|
+
}
|
|
964
|
+
const doorOpensOn = (fl: number, name: string) => {
|
|
965
|
+
const list = (single.value?.meta?.levelDoors ?? {})[String(fl)];
|
|
966
|
+
return (single.value?.floors ?? []).includes(fl) && (Array.isArray(list) ? list.includes(name) : true);
|
|
967
|
+
};
|
|
968
|
+
function toggleLiftFloor(fl: number) {
|
|
969
|
+
const s = single.value;
|
|
970
|
+
if (s?.kind !== "lift") return;
|
|
971
|
+
const has = (s.floors ?? []).includes(fl);
|
|
972
|
+
const floors = has ? (s.floors ?? []).filter((x) => x !== fl) : [...(s.floors ?? []), fl];
|
|
973
|
+
const levelDoors = { ...((s.meta?.levelDoors ?? {}) as Record<string, string[]>) };
|
|
974
|
+
if (has) delete levelDoors[String(fl)];
|
|
975
|
+
else levelDoors[String(fl)] = liftDoors.value.slice(0, 1).map((d) => d.name);
|
|
976
|
+
dispatch({ type: "setProps", id: s.id, floors, meta: { levelDoors } });
|
|
977
|
+
}
|
|
978
|
+
function toggleLevelDoor(fl: number, name: string) {
|
|
979
|
+
const s = single.value;
|
|
980
|
+
if (s?.kind !== "lift") return;
|
|
981
|
+
const all = liftDoors.value.map((d) => d.name);
|
|
982
|
+
const cur = ((s.meta?.levelDoors ?? {})[String(fl)] as string[] | undefined) ?? all;
|
|
983
|
+
const next = cur.includes(name) ? cur.filter((x) => x !== name) : [...cur, name];
|
|
984
|
+
setMeta("levelDoors", { ...((s.meta?.levelDoors ?? {}) as Record<string, string[]>), [String(fl)]: next });
|
|
985
|
+
}
|
|
986
|
+
/** The cabin doors drawn on the current floor: solid where they open here, faint where they stay shut. */
|
|
987
|
+
const cabinDoors = computed(() => {
|
|
988
|
+
const f = state.value.frame;
|
|
989
|
+
const out: Array<{ key: string; open: boolean; style: Record<string, string>; title: string }> = [];
|
|
990
|
+
for (const s of visible.value.filter((x) => x.kind === "lift")) {
|
|
991
|
+
const g = liftGeom(s, f);
|
|
992
|
+
const served = (s.floors ?? []).includes(floor.value);
|
|
993
|
+
const list = (s.meta?.levelDoors ?? {})[String(floor.value)] as string[] | undefined;
|
|
994
|
+
for (const d of (s.meta?.doors ?? []) as TCabinDoor[]) {
|
|
995
|
+
// Cabin frame (y up) -> screen metres (y down) turned by the shaft's angle.
|
|
996
|
+
const lx = d.x;
|
|
997
|
+
const ly = -d.y;
|
|
998
|
+
const a = -g.yaw;
|
|
999
|
+
const p = { x: g.c.x + lx * Math.cos(a) - ly * Math.sin(a), y: g.c.y + lx * Math.sin(a) + ly * Math.cos(a) };
|
|
1000
|
+
const open = served && (Array.isArray(list) ? list.includes(d.name) : true);
|
|
1001
|
+
out.push({
|
|
1002
|
+
key: `${s.id}-${d.name}`,
|
|
1003
|
+
open,
|
|
1004
|
+
title: `${s.meta?.name ?? "Lift"} · cabin door ${d.name}${open ? " opens here" : " stays shut here"}`,
|
|
1005
|
+
style: {
|
|
1006
|
+
left: `${(p.x / f.w) * 100}%`,
|
|
1007
|
+
top: `${(p.y / f.h) * 100}%`,
|
|
1008
|
+
width: `${(d.widthM / f.w) * 100}%`,
|
|
1009
|
+
transform: `translate(-50%, -50%) rotate(${(-(g.yaw + d.axisYawRad) * 180) / Math.PI}deg)`,
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return out;
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
/* graphs */
|
|
1018
|
+
const laneCount = (idx: number) => state.value.shapes.filter((s) => s.kind === "lane" && laneGraph(s) === idx).length;
|
|
1019
|
+
const setGraphs = (graphs: TRouteGraph[]) => dispatch({ type: "graphs", graphs });
|
|
1020
|
+
function renameGraph(idx: number, name: string) {
|
|
1021
|
+
const n = name.trim().slice(0, 60) || `Graph ${idx}`;
|
|
1022
|
+
setGraphs(graphOptions.value.map((g) => (g.idx === idx ? { ...g, name: n } : g)));
|
|
1023
|
+
}
|
|
1024
|
+
function setGraphBrands(idx: number, text: string) {
|
|
1025
|
+
const brands = [...new Set(text.split(",").map((b) => b.trim().toLowerCase().slice(0, 60)).filter(Boolean))].slice(0, 20);
|
|
1026
|
+
setGraphs(graphOptions.value.map((g) => (g.idx === idx ? { ...g, brands } : g)));
|
|
1027
|
+
}
|
|
1028
|
+
function addGraph() {
|
|
1029
|
+
const idx = [...Array(9).keys()].find((i) => !graphOptions.value.some((g) => g.idx === i));
|
|
1030
|
+
if (idx === undefined) return;
|
|
1031
|
+
setGraphs([...graphOptions.value, { idx, name: `Graph ${idx}`, brands: [] }]);
|
|
1032
|
+
graph.value = idx;
|
|
1033
|
+
}
|
|
1034
|
+
function removeGraph(idx: number) {
|
|
1035
|
+
const n = laneCount(idx);
|
|
1036
|
+
if (n && !window.confirm(`Remove ${graphName(idx)} and its ${n} lane${n === 1 ? "" : "s"}?`)) return;
|
|
1037
|
+
setGraphs(graphOptions.value.filter((g) => g.idx !== idx));
|
|
1038
|
+
if (graph.value === idx) graph.value = graphOptions.value.find((g) => g.idx !== idx)?.idx ?? 0;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/* this floor */
|
|
1042
|
+
const levelMeta = (fl: number) => levels.value[String(fl)] ?? { name: floorName(fl), elevationM: fl * 4, floorPolys: [], holePolys: [] };
|
|
1043
|
+
const levelNameOf = (fl: number) => levelMeta(fl).name || floorName(fl);
|
|
1044
|
+
function setLevel(key: "name" | "elevationM", value: string | number) {
|
|
1045
|
+
const cur = levelMeta(floor.value);
|
|
1046
|
+
if (key === "name") {
|
|
1047
|
+
const n = String(value).trim().slice(0, 60);
|
|
1048
|
+
if (!n || /^[.$]/.test(n)) return void dispatch({ type: "notice", text: "A floor name cannot be empty or start with . or $.", ok: false });
|
|
1049
|
+
if (allFloors.value.some((f) => f !== floor.value && levelNameOf(f) === n)) return void dispatch({ type: "notice", text: `Another floor is already called "${n}".`, ok: false });
|
|
1050
|
+
}
|
|
1051
|
+
if (key === "name" && cur.name && cur.name !== value) {
|
|
1052
|
+
// Lifts name their reference / first / lowest / highest floor: follow the rename.
|
|
1053
|
+
const from = cur.name;
|
|
1054
|
+
const shapes = state.value.shapes.map((s) => {
|
|
1055
|
+
if (s.kind !== "lift" || !s.meta) return s;
|
|
1056
|
+
const m = { ...s.meta };
|
|
1057
|
+
for (const k of ["referenceFloor", "initialFloor", "lowestFloor", "highestFloor"]) if (m[k] === from) m[k] = value;
|
|
1058
|
+
return { ...s, meta: m };
|
|
1059
|
+
});
|
|
1060
|
+
state.value = { ...state.value, shapes };
|
|
1061
|
+
}
|
|
1062
|
+
levels.value = { ...levels.value, [String(floor.value)]: { ...cur, [key]: value } };
|
|
1063
|
+
state.value = { ...state.value, dirty: true };
|
|
1064
|
+
}
|
|
1065
|
+
function alignNow() {
|
|
1066
|
+
const r = alignFloor(state.value.shapes, state.value.frame, floor.value, alignTo.value);
|
|
1067
|
+
if ("error" in r) return void dispatch({ type: "notice", text: r.error!, ok: false });
|
|
1068
|
+
const placement = placedPlan.value ? transformPlacement(placedPlan.value, r.t) : null;
|
|
1069
|
+
dispatch({ type: "transformFloor", floorNo: floor.value, t: r.t, placement });
|
|
1070
|
+
dispatch({ type: "notice", text: `${r.text} Save to keep it.`, ok: true });
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/* drawing area */
|
|
1074
|
+
function resizeFrame(w: number | null, h: number | null) {
|
|
1075
|
+
const f = state.value.frame;
|
|
1076
|
+
const nw = w === null ? f.w : clampNum(w, 10, 5000, f.w);
|
|
1077
|
+
const nh = h === null ? f.h : clampNum(h, 10, 5000, f.h);
|
|
1078
|
+
// Grows / shrinks from the top-left corner; nothing drawn moves in the building.
|
|
1079
|
+
dispatch({ type: "reframe", frame: { ...f, w: nw, h: nh } });
|
|
1080
|
+
}
|
|
1081
|
+
function fitFrameNow() {
|
|
1082
|
+
dispatch({ type: "reframe", frame: fitFrame(state.value.shapes, state.value.frame, planRecords.value) });
|
|
1083
|
+
resetView();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
501
1086
|
/* ---- save ---- */
|
|
502
1087
|
const saving = ref(false);
|
|
503
1088
|
async function save() {
|
|
504
1089
|
const problem = editorProblem(state.value);
|
|
505
|
-
if (problem) return void dispatch({ type: "notice", text: problem, ok: false });
|
|
1090
|
+
if (problem && !(problem.startsWith("Nothing to save") && Object.keys(state.value.plans).length)) return void dispatch({ type: "notice", text: problem, ok: false });
|
|
506
1091
|
if (!props.map?._id && !props.ownerRobotId) {
|
|
507
1092
|
return void dispatch({
|
|
508
1093
|
type: "notice",
|
|
@@ -511,12 +1096,27 @@ async function save() {
|
|
|
511
1096
|
});
|
|
512
1097
|
}
|
|
513
1098
|
const name = String(props.map?.name || `${props.siteName || "Site"} building`).slice(0, 60);
|
|
514
|
-
const
|
|
1099
|
+
const rmf = rmfFromEditor({ shapes: state.value.shapes, frame: state.value.frame, graphs: state.value.graphs, levels: levels.value, referenceLevel: meta.value.referenceLevel, source: meta.value.source });
|
|
1100
|
+
const body = { name, levels: [], doors: [], lifts: [], rmf };
|
|
515
1101
|
saving.value = true;
|
|
516
1102
|
try {
|
|
517
|
-
|
|
1103
|
+
let version = Number(props.map?.version) || 1;
|
|
1104
|
+
if (props.map?._id) version = Number((await updateBuildingMap(String(props.map._id), { ...body, version }))?.version) || version + 1;
|
|
518
1105
|
else await createBuildingMap({ ...body, robotId: props.ownerRobotId });
|
|
519
|
-
|
|
1106
|
+
// Floor plans re-placed here (scale, fit, turn, line-up) follow the map save, one version at a time.
|
|
1107
|
+
const failed: string[] = [];
|
|
1108
|
+
if (props.map?._id) {
|
|
1109
|
+
for (const [fl, placement] of Object.entries(state.value.plans)) {
|
|
1110
|
+
const stored = floorPlanFor(props.map, Number(fl));
|
|
1111
|
+
if (!stored) continue;
|
|
1112
|
+
try {
|
|
1113
|
+
version = Number((await saveFloorPlan(String(props.map._id), Number(fl), { version, widthPx: stored.widthPx, heightPx: stored.heightPx, ...placement, yawRad: placement.yawRad ?? 0, space: "world" }))?.version) || version + 1;
|
|
1114
|
+
} catch {
|
|
1115
|
+
failed.push(floorName(Number(fl)));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
dispatch({ type: "saved", text: savedNotice(new Date(), state.value.shapes.length, props.siteName) + (failed.length ? ` The floor plan position of ${failed.join(", ")} was not saved - set it again.` : "") });
|
|
520
1120
|
emit("saved");
|
|
521
1121
|
} catch (e) {
|
|
522
1122
|
dispatch({ type: "notice", text: saveErrorText(e), ok: false });
|
|
@@ -528,15 +1128,29 @@ async function save() {
|
|
|
528
1128
|
/* ---- floor plan: stored in the map (not the public file store), per floor ---- */
|
|
529
1129
|
const { urls: planUrls } = useFloorPlanImages(computed(() => props.map));
|
|
530
1130
|
const floorPlan = computed(() => floorPlanFor(props.map, floor.value));
|
|
531
|
-
|
|
1131
|
+
const levelFrame = (fl: number) => ((props.map?.rmf?.levels ?? []) as Array<Record<string, any>>).find((l) => Number(l.floorNo) === fl)?.planFrame ?? null;
|
|
1132
|
+
/** This floor's plan as placed now, in world metres (stored, or re-placed here and not saved yet). */
|
|
1133
|
+
const placedPlan = computed(() => {
|
|
1134
|
+
const p = floorPlan.value;
|
|
1135
|
+
if (!p) return null;
|
|
1136
|
+
return { ...placementInWorld(p as typeof p & { space?: string }, levelFrame(floor.value)), ...(state.value.plans[String(floor.value)] ?? {}) } as typeof p & TPlanPlacement;
|
|
1137
|
+
});
|
|
1138
|
+
/** Every floor's plan as placed now, in world metres. */
|
|
1139
|
+
const planRecords = computed(() => worldPlans(props.map).map((p) => ({ ...p, ...(state.value.plans[String(p.floorNo)] ?? {}) })));
|
|
1140
|
+
// A plan bigger than the drawing area grows the area - it is never cut off.
|
|
1141
|
+
watch(planRecords, (plans) => {
|
|
1142
|
+
const g = growFrame(state.value.frame, plans);
|
|
1143
|
+
if (g !== state.value.frame) dispatch({ type: "reframe", frame: g, auto: true });
|
|
1144
|
+
}, { immediate: true });
|
|
532
1145
|
const planUrl = computed(() => planUrls.value[floor.value] ?? "");
|
|
533
1146
|
const planImgStyle = computed(() => {
|
|
534
|
-
const b =
|
|
535
|
-
return { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}
|
|
1147
|
+
const b = planBoxIn(state.value.frame, placedPlan.value as any);
|
|
1148
|
+
return { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%`, transform: `rotate(${b.rotateDeg}deg)`, transformOrigin: "0 0" };
|
|
536
1149
|
});
|
|
1150
|
+
const planTurnDeg = computed(() => Math.round((((placedPlan.value?.yawRad ?? 0) * 180) / Math.PI) * 10) / 10);
|
|
537
1151
|
const planBusy = ref(false);
|
|
538
1152
|
const planLockText = computed(() =>
|
|
539
|
-
!props.map?._id ? "Save the map once, then add a floor plan." : state.value.dirty ? "Save your drawing first, then change the floor plan." : "",
|
|
1153
|
+
!props.map?._id ? "Save the map once, then add a floor plan." : state.value.dirty ? "Save your drawing first, then change the floor plan image." : "",
|
|
540
1154
|
);
|
|
541
1155
|
const planLocked = computed(() => planBusy.value || saving.value || Boolean(planLockText.value));
|
|
542
1156
|
const planInput = ref<HTMLInputElement | null>(null);
|
|
@@ -559,18 +1173,18 @@ const blobToDataUrl = (b: Blob) =>
|
|
|
559
1173
|
async function compressPlan(file: File) {
|
|
560
1174
|
const bmp = await createImageBitmap(file);
|
|
561
1175
|
let { width, height } = compressSize(bmp.width, bmp.height);
|
|
562
|
-
const
|
|
1176
|
+
const cv = document.createElement("canvas");
|
|
563
1177
|
try {
|
|
564
1178
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const ctx =
|
|
1179
|
+
cv.width = width;
|
|
1180
|
+
cv.height = height;
|
|
1181
|
+
const ctx = cv.getContext("2d")!;
|
|
568
1182
|
ctx.fillStyle = "#fff";
|
|
569
1183
|
ctx.fillRect(0, 0, width, height);
|
|
570
1184
|
ctx.drawImage(bmp, 0, 0, width, height);
|
|
571
1185
|
for (const q of [0.85, 0.7, 0.55]) {
|
|
572
|
-
let out = await new Promise<Blob | null>((r) =>
|
|
573
|
-
if (out?.type !== "image/webp") out = await new Promise<Blob | null>((r) =>
|
|
1186
|
+
let out = await new Promise<Blob | null>((r) => cv.toBlob(r, "image/webp", q));
|
|
1187
|
+
if (out?.type !== "image/webp") out = await new Promise<Blob | null>((r) => cv.toBlob(r, "image/jpeg", q));
|
|
574
1188
|
if (out && out.size <= FLOOR_PLAN_MAX_BYTES) return { image: await blobToDataUrl(out), widthPx: width, heightPx: height };
|
|
575
1189
|
}
|
|
576
1190
|
width = Math.max(1, Math.round(width * 0.8));
|
|
@@ -592,11 +1206,10 @@ async function onPlanFile(e: Event) {
|
|
|
592
1206
|
planBusy.value = true;
|
|
593
1207
|
try {
|
|
594
1208
|
const img = await compressPlan(file);
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
});
|
|
1209
|
+
const old = placedPlan.value;
|
|
1210
|
+
// A replacement keeps the building's real size and place; a first plan fits the drawing area.
|
|
1211
|
+
const placement = old ? { metersPerPixel: Math.round(((old.widthPx * old.metersPerPixel) / img.widthPx) * 1e6) / 1e6, tx: old.tx, ty: old.ty, yawRad: old.yawRad ?? 0 } : fitPlanIn(state.value.frame, img.widthPx, img.heightPx);
|
|
1212
|
+
await saveFloorPlan(String(props.map._id), floor.value, { version: Number(props.map.version) || 1, ...img, ...placement, space: "world" });
|
|
600
1213
|
note(`Floor plan saved for ${floorName(floor.value)}. Use Set scale to measure a known length.`, true);
|
|
601
1214
|
emit("saved");
|
|
602
1215
|
} catch (err) {
|
|
@@ -606,22 +1219,18 @@ async function onPlanFile(e: Event) {
|
|
|
606
1219
|
}
|
|
607
1220
|
}
|
|
608
1221
|
|
|
609
|
-
|
|
1222
|
+
function fitPlanNow() {
|
|
610
1223
|
const p = floorPlan.value;
|
|
611
|
-
if (
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
await saveFloorPlan(String(props.map._id), floor.value, { version: Number(props.map.version) || 1, widthPx: p.widthPx, heightPx: p.heightPx, ...next });
|
|
615
|
-
note(done, true);
|
|
616
|
-
emit("saved");
|
|
617
|
-
} catch (err) {
|
|
618
|
-
note(errText(err, "Could not save the floor plan scale."));
|
|
619
|
-
} finally {
|
|
620
|
-
planBusy.value = false;
|
|
1224
|
+
if (p) {
|
|
1225
|
+
dispatch({ type: "plan", floorNo: floor.value, placement: fitPlanIn(state.value.frame, p.widthPx, p.heightPx) });
|
|
1226
|
+
note("Floor plan fitted to the drawing area. Save to keep it.", true);
|
|
621
1227
|
}
|
|
622
1228
|
}
|
|
623
|
-
|
|
624
|
-
const
|
|
1229
|
+
function turnPlan(deg: number) {
|
|
1230
|
+
const p = placedPlan.value;
|
|
1231
|
+
if (!p || !Number.isFinite(deg)) return;
|
|
1232
|
+
dispatch({ type: "plan", floorNo: floor.value, placement: { metersPerPixel: p.metersPerPixel, tx: p.tx, ty: p.ty, yawRad: Math.round(deg * (Math.PI / 180) * 10000) / 10000 } });
|
|
1233
|
+
}
|
|
625
1234
|
|
|
626
1235
|
async function removePlanNow() {
|
|
627
1236
|
if (!props.map?._id || !floorPlan.value) return;
|
|
@@ -638,34 +1247,38 @@ async function removePlanNow() {
|
|
|
638
1247
|
}
|
|
639
1248
|
}
|
|
640
1249
|
|
|
641
|
-
/* ----
|
|
642
|
-
const
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1250
|
+
/* ---- measure: the real length of a drawn line sets the scale ---- */
|
|
1251
|
+
const measuredLength = computed(() => {
|
|
1252
|
+
const s = single.value;
|
|
1253
|
+
if (s?.kind !== "measure") return 0;
|
|
1254
|
+
const [a, b] = s.points.map((p) => shareToWorld(state.value.frame, p));
|
|
1255
|
+
return Math.hypot(b!.x - a!.x, b!.y - a!.y);
|
|
1256
|
+
});
|
|
1257
|
+
function applyScale(withDrawing: boolean) {
|
|
1258
|
+
const s = single.value;
|
|
1259
|
+
if (s?.kind !== "measure") return;
|
|
1260
|
+
const r = measureScale(state.value.frame, s, Number(s.meta?.distanceM), placedPlan.value);
|
|
1261
|
+
if ("error" in r) return void note(r.error!);
|
|
1262
|
+
if (withDrawing) dispatch({ type: "transformFloor", floorNo: floor.value, t: r.t, placement: r.placement });
|
|
1263
|
+
else {
|
|
1264
|
+
// The plan alone: the measured line is on the plan, so it scales with it; the rest of the drawing stays.
|
|
1265
|
+
const moved = state.value.shapes.map((x) => {
|
|
1266
|
+
if (x.id !== s.id) return x;
|
|
1267
|
+
const points = x.points.map((p) => {
|
|
1268
|
+
const w = shareToWorld(state.value.frame, p);
|
|
1269
|
+
const q = worldToShare(state.value.frame, { x: w.x * r.t.scale + r.t.tx, y: w.y * r.t.scale + r.t.ty });
|
|
1270
|
+
return { x: Math.round(q.x * 100) / 100, y: Math.round(q.y * 100) / 100 };
|
|
1271
|
+
});
|
|
1272
|
+
return { ...x, points };
|
|
1273
|
+
});
|
|
1274
|
+
dispatch({ type: "setShapes", shapes: moved });
|
|
1275
|
+
if (r.placement) dispatch({ type: "plan", floorNo: floor.value, placement: r.placement });
|
|
1276
|
+
}
|
|
1277
|
+
note(`Scale set: ${r.k > 1 ? "grown" : "shrunk"} ${Math.abs((r.k - 1) * 100).toFixed(1)}%${r.placement ? ` · ${scaleText({ ...floorPlan.value!, ...r.placement })}` : ""}. Save to keep it.`, true);
|
|
664
1278
|
}
|
|
665
|
-
watch(floor, cancelMeasure);
|
|
666
1279
|
|
|
667
|
-
/* ---- building file: HERO Fleet Core import, downloads ---- */
|
|
668
|
-
const
|
|
1280
|
+
/* ---- building file: HERO Fleet Core import, file import, downloads ---- */
|
|
1281
|
+
const savedGraphs = computed(() => rmfGraphs(props.map?.rmf));
|
|
669
1282
|
|
|
670
1283
|
async function importFleet() {
|
|
671
1284
|
if (!props.site) return;
|
|
@@ -686,14 +1299,92 @@ async function importFleet() {
|
|
|
686
1299
|
}
|
|
687
1300
|
}
|
|
688
1301
|
|
|
689
|
-
|
|
1302
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
1303
|
+
const fileKind = ref<"building" | "nav">("building");
|
|
1304
|
+
function pickFile(kind: "building" | "nav") {
|
|
1305
|
+
fileKind.value = kind;
|
|
1306
|
+
fileInput.value?.click();
|
|
1307
|
+
}
|
|
1308
|
+
async function onBuildingFile(e: Event) {
|
|
1309
|
+
const input = e.target as HTMLInputElement;
|
|
1310
|
+
const file = input.files?.[0];
|
|
1311
|
+
input.value = "";
|
|
1312
|
+
if (!file) return;
|
|
1313
|
+
if (file.size > 1_900_000) return void note("That file is too large (1.9 MB at most).");
|
|
1314
|
+
planBusy.value = true;
|
|
1315
|
+
try {
|
|
1316
|
+
const text = await file.text();
|
|
1317
|
+
const kind = fileKind.value;
|
|
1318
|
+
const res = await importBuildingFile(kind === "nav" ? { format: "nav", text, graph: graph.value } : { format: "building", text });
|
|
1319
|
+
const warn = (res?.warnings ?? []).length ? ` Check: ${(res.warnings as string[]).join(" ")}` : "";
|
|
1320
|
+
if (kind === "building") {
|
|
1321
|
+
if (state.value.shapes.length && !window.confirm("Replace everything drawn on every floor with this building file? Nothing is saved until you press Save.")) return;
|
|
1322
|
+
const m = editorModelFromMap({ rmf: res.rmf });
|
|
1323
|
+
levels.value = m.levels;
|
|
1324
|
+
meta.value = { referenceLevel: m.referenceLevel, source: "file" };
|
|
1325
|
+
const frame = growFrame(m.frame, planRecords.value);
|
|
1326
|
+
const shapes = frame === m.frame ? m.shapes : editorModelFromMap({ rmf: res.rmf }, frame).shapes;
|
|
1327
|
+
dispatch({ type: "loadAll", shapes, frame, graphs: m.graphs, dirty: true, notice: { text: `Building file read: ${rmfLine(res.rmf)}. Press Save to keep it.${warn}`, ok: !warn } });
|
|
1328
|
+
graph.value = m.graphs[0]?.idx ?? 0;
|
|
1329
|
+
// Open on the building's own first floor (not a floor only the robots report).
|
|
1330
|
+
const own = Object.keys(m.levels).map(Number).sort((a, b) => a - b);
|
|
1331
|
+
floor.value = own.find((f) => f >= 1) ?? own[0] ?? floor.value;
|
|
1332
|
+
resetView();
|
|
1333
|
+
} else {
|
|
1334
|
+
mergeRouteFile(res.rmf, warn);
|
|
1335
|
+
}
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
note(errText(err, "Could not read that file."));
|
|
1338
|
+
} finally {
|
|
1339
|
+
planBusy.value = false;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/** A route file's lanes replace this graph's lanes on the floors it names; its waypoints join (or reuse) the drawing's. */
|
|
1343
|
+
function mergeRouteFile(rmf: Record<string, any>, warn: string) {
|
|
1344
|
+
const f = state.value.frame;
|
|
1345
|
+
const byName = new Map(allFloors.value.map((fl) => [levelNameOf(fl), fl]));
|
|
1346
|
+
let shapes = [...state.value.shapes];
|
|
1347
|
+
let added = 0;
|
|
1348
|
+
let floorsHit = 0;
|
|
1349
|
+
let made = 0;
|
|
1350
|
+
const stamp = Date.now().toString(36);
|
|
1351
|
+
for (const lv of (rmf.levels ?? []) as Array<Record<string, any>>) {
|
|
1352
|
+
const fl = byName.get(String(lv.name)) ?? (allFloors.value.includes(Number(lv.floorNo)) ? Number(lv.floorNo) : null);
|
|
1353
|
+
if (fl === null || fl === undefined) continue;
|
|
1354
|
+
floorsHit++;
|
|
1355
|
+
shapes = shapes.filter((s) => !(s.kind === "lane" && s.floorNo === fl && laneGraph(s) === graph.value));
|
|
1356
|
+
const ids = new Map<string, string>();
|
|
1357
|
+
for (const v of (lv.vertices ?? []) as Array<Record<string, any>>) {
|
|
1358
|
+
const p = worldToShare(f, v);
|
|
1359
|
+
const near = shapes.find((s) => s.kind === "waypoint" && s.floorNo === fl && Math.abs(shareToWorld(f, s.points[0]!).x - v.x) < 0.01 && Math.abs(shareToWorld(f, s.points[0]!).y - v.y) < 0.01);
|
|
1360
|
+
if (near) ids.set(String(v.id), near.id);
|
|
1361
|
+
else {
|
|
1362
|
+
const id = `rf${stamp}w${made++}`;
|
|
1363
|
+
ids.set(String(v.id), id);
|
|
1364
|
+
shapes.push({ id, kind: "waypoint", floorNo: fl, points: [{ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }], name: String(v.name ?? ""), meta: { params: { ...(v.params ?? {}) } } });
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
for (const l of (lv.lanes ?? []) as Array<Record<string, any>>) {
|
|
1368
|
+
const from = ids.get(String(l.v1));
|
|
1369
|
+
const to = ids.get(String(l.v2));
|
|
1370
|
+
if (!from || !to) continue;
|
|
1371
|
+
const { v1: _a, v2: _b, ...opts } = l;
|
|
1372
|
+
shapes.push({ id: `rf${stamp}l${added++}`, kind: "lane", floorNo: fl, points: [{ x: 0, y: 0 }, { x: 0, y: 0 }], meta: { ...opts, graphIdx: graph.value, from, to } });
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (!floorsHit) return void note("None of the route map's floors match this building's floor names.");
|
|
1376
|
+
const graphs = state.value.graphs.some((g) => g.idx === graph.value) ? state.value.graphs : [...state.value.graphs, { idx: graph.value, name: `Graph ${graph.value}`, brands: [] }];
|
|
1377
|
+
dispatch({ type: "setShapes", shapes, graphs, notice: { text: `Route map read into ${graphName(graph.value)}: ${added} lanes on ${floorsHit} floor${floorsHit === 1 ? "" : "s"}. Press Save to keep it.${warn}`, ok: !warn } });
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
async function download(format: "building" | "nav", g = 0) {
|
|
690
1381
|
if (!props.map?._id) return;
|
|
691
1382
|
try {
|
|
692
|
-
const blob = await exportBuildingMap(String(props.map._id), format, format === "nav" ?
|
|
1383
|
+
const blob = await exportBuildingMap(String(props.map._id), format, format === "nav" ? g : undefined);
|
|
693
1384
|
const url = URL.createObjectURL(blob);
|
|
694
1385
|
const a = document.createElement("a");
|
|
695
1386
|
a.href = url;
|
|
696
|
-
a.download = exportFileName(String(props.map.name ?? ""), format,
|
|
1387
|
+
a.download = exportFileName(String(props.map.name ?? ""), format, g);
|
|
697
1388
|
a.click();
|
|
698
1389
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
699
1390
|
} catch (err) {
|
|
@@ -707,9 +1398,33 @@ function boxStyle(s: TEditorShape) {
|
|
|
707
1398
|
const b = shapeBox(s);
|
|
708
1399
|
return { left: `${b.x}%`, top: `${b.y}%`, width: `${b.w}%`, height: `${b.h}%` };
|
|
709
1400
|
}
|
|
1401
|
+
function liftStyle(s: TEditorShape) {
|
|
1402
|
+
return { ...boxStyle(s), transform: `rotate(${(-Number(s.meta?.yawRad ?? 0) * 180) / Math.PI}deg)` };
|
|
1403
|
+
}
|
|
1404
|
+
function midStyle(s: TEditorShape) {
|
|
1405
|
+
const [a, b] = s.points;
|
|
1406
|
+
return { left: `${((a?.x ?? 0) + (b?.x ?? 0)) / 2}%`, top: `${((a?.y ?? 0) + (b?.y ?? 0)) / 2}%` };
|
|
1407
|
+
}
|
|
710
1408
|
function rectStyle(r: { x: number; y: number; w: number; h: number }) {
|
|
711
1409
|
return { left: `${Math.min(r.x, r.x + r.w)}%`, top: `${Math.min(r.y, r.y + r.h)}%`, width: `${Math.abs(r.w)}%`, height: `${Math.abs(r.h)}%` };
|
|
712
1410
|
}
|
|
1411
|
+
/** One-way lanes get an arrow at their middle, pointing the way robots may drive (angle in real metres). */
|
|
1412
|
+
const laneArrows = computed(() => {
|
|
1413
|
+
const f = state.value.frame;
|
|
1414
|
+
return visible.value
|
|
1415
|
+
.filter((s) => s.kind === "lane" && s.meta?.bidirectional === false)
|
|
1416
|
+
.map((s) => {
|
|
1417
|
+
const [a, b] = s.points;
|
|
1418
|
+
return { id: s.id, x: ((a!.x + b!.x) / 2), y: ((a!.y + b!.y) / 2), deg: (Math.atan2(((b!.y - a!.y) * f.h) / 100, ((b!.x - a!.x) * f.w) / 100) * 180) / Math.PI, other: laneGraph(s) !== graph.value };
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
const lanePreview = computed(() => {
|
|
1422
|
+
const from = state.value.laneFrom ? state.value.shapes.find((s) => s.id === state.value.laneFrom) : null;
|
|
1423
|
+
if (!from || tool.value !== "lane" || !pointerAt.value || from.floorNo !== floor.value) return null;
|
|
1424
|
+
return { a: from.points[0]!, b: pointerAt.value };
|
|
1425
|
+
});
|
|
1426
|
+
const measurePreview = computed(() => (state.value.measureFrom && tool.value === "measure" && pointerAt.value ? { a: state.value.measureFrom, b: pointerAt.value } : null));
|
|
1427
|
+
|
|
713
1428
|
const root = ref<HTMLElement | null>(null);
|
|
714
1429
|
const isFs = ref(false);
|
|
715
1430
|
const onFs = () => (isFs.value = document.fullscreenElement === root.value);
|