skilldrop-cli 1.7.0 → 1.8.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/README.md +1 -0
- package/catalogue.json +8 -1
- package/model-routing.json +4 -0
- package/package.json +1 -1
- package/packs/event-planner/skills/run-of-show/SKILL.md +6 -3
- package/packs/event-planner/skills/run-of-show/manifest.json +1 -1
- package/packs/event-planner/skills/seating-plan/SKILL.md +6 -3
- package/packs/event-planner/skills/seating-plan/evals/evals.json +30 -0
- package/packs/event-planner/skills/seating-plan/evals/files/10/hall_notes.txt +13 -0
- package/packs/event-planner/skills/seating-plan/evals/files/10/quiz_night_registrations.csv +67 -0
- package/packs/event-planner/skills/seating-plan/evals/files/11/notes_for_the_plan.txt +21 -0
- package/packs/event-planner/skills/seating-plan/evals/files/11/rsvp_list.csv +77 -0
- package/packs/event-planner/skills/seating-plan/evals/files/11/table_plan_draft.csv +78 -0
- package/packs/event-planner/skills/seating-plan/manifest.json +1 -1
- package/packs/interior-designer/pack.json +60 -0
- package/packs/interior-designer/skills/furniture-layout/SKILL.md +182 -0
- package/packs/interior-designer/skills/furniture-layout/evals/eval_queries.json +11 -0
- package/packs/interior-designer/skills/furniture-layout/evals/evals.json +133 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/2/living-room-notes.md +24 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/3/office-brief.md +18 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/4/furniture_schedule.csv +15 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/4/site_survey.txt +22 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/5/layout_v2.csv +14 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/5/measurements.txt +16 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/6/access_consultant_email.txt +19 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/6/furniture_stock.csv +5 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/6/site_measurements.txt +14 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_214_layout.csv +11 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_and_brand_standard.txt +20 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/8/email_from_practice_manager.txt +44 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/8/furniture_we_are_keeping.csv +10 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/9/reading_room_schedule.csv +15 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/9/room_notes.txt +23 -0
- package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.md +121 -0
- package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.svg +57 -0
- package/packs/interior-designer/skills/furniture-layout/manifest.json +44 -0
- package/packs/interior-designer/skills/furniture-layout/scripts/layout_check.py +1130 -0
- package/packs/interior-designer/skills/furniture-layout/templates/layout.json +26 -0
- package/packs/operations-manager/skills/shift-roster/SKILL.md +6 -3
- package/packs/operations-manager/skills/shift-roster/manifest.json +1 -1
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check a room's furniture layout and draw it as a dimensioned SVG. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads a layout.json: the room's outline (a rectangle or a rectilinear polygon), its doors
|
|
5
|
+
with their swing, its windows with their sill height, fixed obstacles (columns, radiators)
|
|
6
|
+
and the furniture, each piece with a type, a size, a position and the way its front faces.
|
|
7
|
+
It fails a layout where
|
|
8
|
+
- two pieces overlap, a piece runs outside the room or sits on an obstacle
|
|
9
|
+
- a piece sits in the swing of a door that opens into the room
|
|
10
|
+
- a piece's use clearance (a bed side, a chair pull-back, a desk chair, a wardrobe door,
|
|
11
|
+
a sofa's legroom) is blocked by another piece, an obstacle or a wall
|
|
12
|
+
- there is no clear route at the walkway width from an entrance door to each piece's
|
|
13
|
+
access side, or nothing clears the first stretch inside an entrance
|
|
14
|
+
- with "accessible": true, there is no clear 1.5 m turning circle the route reaches
|
|
15
|
+
- a turning circle named in "turning_circles" is not clear, or no route reaches it. Give it
|
|
16
|
+
at a point ({"at": [x, y]}) or tied to a place ({"near": "WC door", "side": "front",
|
|
17
|
+
"within": 1.0}): a place-tied circle is searched for only in that zone, so a circle clear
|
|
18
|
+
somewhere else in the room never passes it. "diameter" defaults to 1.5 m
|
|
19
|
+
and warns where
|
|
20
|
+
- a piece taller than a window's sill stands in front of the window
|
|
21
|
+
- the furniture covers more of the floor than max_coverage (default 50 %)
|
|
22
|
+
- a piece stands against a radiator
|
|
23
|
+
Then it draws the plan: walls, door swings, windows, obstacles, every piece with its name
|
|
24
|
+
and size, use clearances shaded, the walking routes, failures in red, overall dimension
|
|
25
|
+
strings, a scale bar and a north arrow.
|
|
26
|
+
|
|
27
|
+
Routes are found on a grid of 2.5 to 10 cm, so a gap is measured to within one grid step.
|
|
28
|
+
A room up to 60 m across checks in a second or two; --max-seconds (default 20) stops the
|
|
29
|
+
route and turning checks if they run long, and says so.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
python3 layout_check.py layout.json [--svg layout.svg] [--json] [--max-seconds 20]
|
|
33
|
+
Exit codes: 0 no failures, 1 a failure, 2 bad input or a check that didn't finish.
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
from collections import deque
|
|
41
|
+
|
|
42
|
+
EPS = 1e-6
|
|
43
|
+
FT = 0.3048
|
|
44
|
+
|
|
45
|
+
# Use clearances (metres) by type, measured from the piece's sides as seen from its front:
|
|
46
|
+
# front, back, left, right. "access" says which clearance the walking route has to reach:
|
|
47
|
+
# "front", "sides" (every side with a clearance), "any" (at least one), or None.
|
|
48
|
+
TYPES = {
|
|
49
|
+
"bed": {"left": 0.6, "right": 0.6, "front": 0.6, "height": 0.6, "access": "sides"},
|
|
50
|
+
"single_bed": {"right": 0.6, "front": 0.5, "height": 0.6, "access": "any"},
|
|
51
|
+
"sofa": {"front": 0.35, "height": 0.85, "access": "front"},
|
|
52
|
+
"armchair": {"front": 0.35, "height": 0.9, "access": "front"},
|
|
53
|
+
"coffee_table": {"height": 0.45, "access": None},
|
|
54
|
+
"side_table": {"height": 0.55, "access": None},
|
|
55
|
+
"nightstand": {"height": 0.55, "access": None},
|
|
56
|
+
"dining_table": {"front": 0.9, "back": 0.9, "left": 0.9, "right": 0.9, "height": 0.75, "access": "any"},
|
|
57
|
+
"desk": {"front": 0.9, "height": 0.75, "access": "front"},
|
|
58
|
+
"wardrobe": {"front": 0.6, "height": 2.0, "access": "front"},
|
|
59
|
+
"dresser": {"front": 0.6, "height": 0.85, "access": "front"},
|
|
60
|
+
"sideboard": {"front": 0.6, "height": 0.85, "access": "front"},
|
|
61
|
+
"bookcase": {"front": 0.45, "height": 1.8, "access": "front"},
|
|
62
|
+
"tv_unit": {"height": 0.5, "access": None},
|
|
63
|
+
"rug": {"height": 0.0, "access": None},
|
|
64
|
+
"other": {"height": 0.75, "access": None},
|
|
65
|
+
}
|
|
66
|
+
SIDES = ("front", "back", "left", "right")
|
|
67
|
+
FACING = {"S": 0, "E": 90, "N": 180, "W": 270}
|
|
68
|
+
BED_HEAD = 0.55 # a bed's side clearance starts this far from the head, where nightstands go
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InputError(Exception):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TimeUp(Exception):
|
|
76
|
+
"""The route and turning checks ran past --max-seconds."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
DEADLINE = None # time.monotonic() after which the long checks stop; set by main
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tick():
|
|
83
|
+
if DEADLINE is not None and time.monotonic() > DEADLINE:
|
|
84
|
+
raise TimeUp()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def number(v, what):
|
|
88
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v):
|
|
89
|
+
raise InputError(f"{what} must be a number")
|
|
90
|
+
if abs(v) > 1000:
|
|
91
|
+
raise InputError(f"{what} is {v:g}; nothing in one room is over 1000 across")
|
|
92
|
+
return float(v)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def positive(v, what):
|
|
96
|
+
v = number(v, what)
|
|
97
|
+
if v <= EPS:
|
|
98
|
+
raise InputError(f"{what} must be above zero")
|
|
99
|
+
return v
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------- geometry ----------
|
|
103
|
+
|
|
104
|
+
def on_segment(p, a, b, tol=1e-4):
|
|
105
|
+
(x, y), (x1, y1), (x2, y2) = p, a, b
|
|
106
|
+
L = math.hypot(x2 - x1, y2 - y1)
|
|
107
|
+
if L < EPS:
|
|
108
|
+
return math.hypot(x - x1, y - y1) <= tol
|
|
109
|
+
cross = abs((x2 - x1) * (y - y1) - (y2 - y1) * (x - x1)) / L
|
|
110
|
+
dot = ((x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)) / L
|
|
111
|
+
return cross <= tol and -tol <= dot <= L + tol
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def point_in_poly(p, poly):
|
|
115
|
+
x, y = p
|
|
116
|
+
inside = False
|
|
117
|
+
for i, (x1, y1) in enumerate(poly):
|
|
118
|
+
x2, y2 = poly[(i + 1) % len(poly)]
|
|
119
|
+
if on_segment(p, (x1, y1), (x2, y2)):
|
|
120
|
+
return True
|
|
121
|
+
if (y1 > y) != (y2 > y):
|
|
122
|
+
if x1 + (y - y1) * (x2 - x1) / (y2 - y1) > x:
|
|
123
|
+
inside = not inside
|
|
124
|
+
return inside
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def poly_area(poly):
|
|
128
|
+
return abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]))) / 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def overlap(a, b):
|
|
132
|
+
w = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
|
|
133
|
+
h = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
|
|
134
|
+
return w * h if w > EPS and h > EPS else 0.0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rect_in_poly(r, poly):
|
|
138
|
+
x, y, w, h = r
|
|
139
|
+
if not all(point_in_poly(c, poly) for c in ((x, y), (x + w, y), (x + w, y + h), (x, y + h))):
|
|
140
|
+
return False
|
|
141
|
+
return not any(x + EPS < px < x + w - EPS and y + EPS < py < y + h - EPS for px, py in poly)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rect_gap(a, b):
|
|
145
|
+
"""Shortest distance between two rectangles (0 when they touch or overlap)."""
|
|
146
|
+
dx = max(b[0] - (a[0] + a[2]), a[0] - (b[0] + b[2]), 0)
|
|
147
|
+
dy = max(b[1] - (a[1] + a[3]), a[1] - (b[1] + b[3]), 0)
|
|
148
|
+
return math.hypot(dx, dy)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def inward_normal(a, b, poly):
|
|
152
|
+
"""The unit normal of wall a-b that points into the room."""
|
|
153
|
+
tx, ty = b[0] - a[0], b[1] - a[1]
|
|
154
|
+
L = math.hypot(tx, ty)
|
|
155
|
+
n = (-ty / L, tx / L)
|
|
156
|
+
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
|
|
157
|
+
probe = (mid[0] + n[0] * 0.01, mid[1] + n[1] * 0.01)
|
|
158
|
+
return n if point_in_poly(probe, poly) and not on_segment(probe, a, b) else (-n[0], -n[1])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def wall_at(p, poly):
|
|
162
|
+
for i in range(len(poly)):
|
|
163
|
+
a, b = poly[i], poly[(i + 1) % len(poly)]
|
|
164
|
+
if on_segment(p, a, b):
|
|
165
|
+
return a, b
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------- input ----------
|
|
170
|
+
|
|
171
|
+
MAX_SPAN = 60.0 # metres; a bigger "room" is a floor, and the route grid would grow past what's useful
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def items(data, key):
|
|
175
|
+
v = data.get(key)
|
|
176
|
+
if v is None:
|
|
177
|
+
return []
|
|
178
|
+
if not isinstance(v, list):
|
|
179
|
+
raise InputError(f"`{key}` must be a list")
|
|
180
|
+
return v
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load(data):
|
|
184
|
+
if not isinstance(data, dict):
|
|
185
|
+
raise InputError("layout.json must be a JSON object")
|
|
186
|
+
units = data.get("units", "m")
|
|
187
|
+
if units not in ("m", "ft"):
|
|
188
|
+
raise InputError("`units` must be \"m\" or \"ft\"")
|
|
189
|
+
k = FT if units == "ft" else 1.0
|
|
190
|
+
|
|
191
|
+
def L(v, what):
|
|
192
|
+
return number(v, what) * k
|
|
193
|
+
|
|
194
|
+
if "size" in data and "outline" not in data:
|
|
195
|
+
s = data["size"]
|
|
196
|
+
if not isinstance(s, list) or len(s) != 2:
|
|
197
|
+
raise InputError("`size` must be [width, depth]")
|
|
198
|
+
w, d = positive(s[0], "size width") * k, positive(s[1], "size depth") * k
|
|
199
|
+
outline = [(0.0, 0.0), (w, 0.0), (w, d), (0.0, d)]
|
|
200
|
+
else:
|
|
201
|
+
o = data.get("outline")
|
|
202
|
+
if not isinstance(o, list) or len(o) < 4:
|
|
203
|
+
raise InputError("give `size` [width, depth] or an `outline` of at least four [x, y] corners")
|
|
204
|
+
try:
|
|
205
|
+
outline = [(L(p[0], "outline x"), L(p[1], "outline y")) for p in o]
|
|
206
|
+
except (TypeError, IndexError, KeyError):
|
|
207
|
+
raise InputError("every corner in `outline` must be [x, y]")
|
|
208
|
+
for i, a in enumerate(outline):
|
|
209
|
+
b = outline[(i + 1) % len(outline)]
|
|
210
|
+
if abs(a[0] - b[0]) > EPS and abs(a[1] - b[1]) > EPS:
|
|
211
|
+
raise InputError("the outline must be rectilinear: every wall runs along x or along y")
|
|
212
|
+
if poly_area(outline) <= EPS:
|
|
213
|
+
raise InputError("the outline encloses no floor")
|
|
214
|
+
xs, ys = [p[0] for p in outline], [p[1] for p in outline]
|
|
215
|
+
if max(xs) - min(xs) > MAX_SPAN or max(ys) - min(ys) > MAX_SPAN:
|
|
216
|
+
raise InputError(f"the room is more than {MAX_SPAN:g} m across; lay out one room at a time")
|
|
217
|
+
|
|
218
|
+
doors = []
|
|
219
|
+
for i, d in enumerate(items(data, "doors"), 1):
|
|
220
|
+
if not isinstance(d, dict) or not isinstance(d.get("at"), list) or len(d["at"]) != 2:
|
|
221
|
+
raise InputError(f"door {i}: `at` must be the [x, y] of the door's centre on a wall")
|
|
222
|
+
hinge = d.get("hinge", "left")
|
|
223
|
+
swing = d.get("swing", "in")
|
|
224
|
+
if hinge not in ("left", "right") or swing not in ("in", "out", "sliding"):
|
|
225
|
+
raise InputError(f"door {i}: `hinge` is left or right, `swing` is in, out or sliding")
|
|
226
|
+
doors.append({"name": str(d.get("name") or f"Door {i}"), "at": (L(d["at"][0], "door x"), L(d["at"][1], "door y")),
|
|
227
|
+
"width": positive(d.get("width", 0.9 / k), f"door {i}: width") * k,
|
|
228
|
+
"hinge": hinge, "swing": swing, "entrance": bool(d.get("entrance", False))})
|
|
229
|
+
if not doors:
|
|
230
|
+
raise InputError("`doors` must list at least one door, so the route has somewhere to start")
|
|
231
|
+
if not any(d["entrance"] for d in doors):
|
|
232
|
+
for d in doors:
|
|
233
|
+
d["entrance"] = True
|
|
234
|
+
|
|
235
|
+
windows = []
|
|
236
|
+
for i, w in enumerate(items(data, "windows"), 1):
|
|
237
|
+
if not isinstance(w, dict) or not isinstance(w.get("at"), list) or len(w["at"]) != 2:
|
|
238
|
+
raise InputError(f"window {i}: `at` must be the [x, y] of the window's centre on a wall")
|
|
239
|
+
windows.append({"at": (L(w["at"][0], "window x"), L(w["at"][1], "window y")),
|
|
240
|
+
"width": positive(w.get("width", 1.2 / k), f"window {i}: width") * k,
|
|
241
|
+
"sill": number(w.get("sill", 0.9 / k), f"window {i}: sill") * k})
|
|
242
|
+
|
|
243
|
+
obstacles = []
|
|
244
|
+
for i, o in enumerate(items(data, "obstacles"), 1):
|
|
245
|
+
if not isinstance(o, dict) or not isinstance(o.get("rect"), list) or len(o["rect"]) != 4:
|
|
246
|
+
raise InputError(f"obstacle {i}: `rect` must be [x, y, width, depth]")
|
|
247
|
+
r = [L(v, f"obstacle {i}") for v in o["rect"]]
|
|
248
|
+
if r[2] <= EPS or r[3] <= EPS:
|
|
249
|
+
raise InputError(f"obstacle {i}: width and depth must be above zero")
|
|
250
|
+
kind = str(o.get("type", "column"))
|
|
251
|
+
obstacles.append({"name": str(o.get("name") or kind.capitalize()), "type": kind, "rect": r})
|
|
252
|
+
|
|
253
|
+
pieces, seen = [], set()
|
|
254
|
+
for i, f in enumerate(items(data, "furniture"), 1):
|
|
255
|
+
if not isinstance(f, dict) or not str(f.get("name", "")).strip():
|
|
256
|
+
raise InputError(f"furniture {i} needs a `name`")
|
|
257
|
+
name = str(f["name"]).strip()
|
|
258
|
+
if name.lower() in seen:
|
|
259
|
+
raise InputError(f"two pieces are called {name!r}")
|
|
260
|
+
seen.add(name.lower())
|
|
261
|
+
kind = f.get("type", "other")
|
|
262
|
+
if not isinstance(kind, str) or kind not in TYPES:
|
|
263
|
+
raise InputError(f"{name}: unknown type {kind!r}; use one of {', '.join(sorted(TYPES))}")
|
|
264
|
+
if not isinstance(f.get("at"), list) or len(f["at"]) != 2:
|
|
265
|
+
raise InputError(f"{name}: `at` must be the [x, y] of the piece's corner nearest the origin")
|
|
266
|
+
if "facing" in f:
|
|
267
|
+
if not isinstance(f["facing"], str) or f["facing"] not in FACING:
|
|
268
|
+
raise InputError(f"{name}: `facing` is N, S, E or W")
|
|
269
|
+
rot = FACING[f["facing"]]
|
|
270
|
+
else:
|
|
271
|
+
rot = int(number(f.get("rotation", 0), f"{name}: rotation")) % 360
|
|
272
|
+
if rot not in (0, 90, 180, 270):
|
|
273
|
+
raise InputError(f"{name}: rotation must be 0, 90, 180 or 270")
|
|
274
|
+
w = positive(f.get("width"), f"{name}: width") * k
|
|
275
|
+
dpt = positive(f.get("depth"), f"{name}: depth") * k
|
|
276
|
+
x, y = L(f["at"][0], f"{name}: x"), L(f["at"][1], f"{name}: y")
|
|
277
|
+
rect = [x, y, w, dpt] if rot in (0, 180) else [x, y, dpt, w]
|
|
278
|
+
spec = TYPES[kind]
|
|
279
|
+
clear = {s: spec.get(s, 0.0) for s in SIDES}
|
|
280
|
+
given = f.get("clearance") or {}
|
|
281
|
+
if not isinstance(given, dict) or any(s not in SIDES for s in given):
|
|
282
|
+
raise InputError(f"{name}: `clearance` takes front, back, left and right")
|
|
283
|
+
for s, v in given.items():
|
|
284
|
+
clear[s] = number(v, f"{name}: clearance {s}") * k
|
|
285
|
+
access = f.get("access", spec["access"])
|
|
286
|
+
if access not in ("front", "sides", "any", None, False):
|
|
287
|
+
raise InputError(f"{name}: `access` is front, sides, any or false")
|
|
288
|
+
pieces.append({"name": name, "type": kind, "rect": rect, "rot": rot, "size": (w, dpt),
|
|
289
|
+
"height": number(f.get("height", spec["height"] / k), f"{name}: height") * k,
|
|
290
|
+
"clear": clear, "access": access or None})
|
|
291
|
+
if not pieces:
|
|
292
|
+
raise InputError("`furniture` must list at least one piece")
|
|
293
|
+
|
|
294
|
+
turning = []
|
|
295
|
+
raw_tc = data.get("turning_circles") or []
|
|
296
|
+
if not isinstance(raw_tc, list):
|
|
297
|
+
raise InputError("`turning_circles` must be a list of {\"at\": [x, y]} or {\"near\": \"door or piece\"}")
|
|
298
|
+
doors_by = {d["name"].lower(): d for d in doors}
|
|
299
|
+
pieces_by = {p["name"].lower(): p for p in pieces}
|
|
300
|
+
for t in raw_tc:
|
|
301
|
+
if not isinstance(t, dict):
|
|
302
|
+
raise InputError("each turning circle needs `at` as [x, y], or `near` naming a door or a piece")
|
|
303
|
+
diam = positive(t.get("diameter", 1.5 / k), "turning circle diameter") * k
|
|
304
|
+
who = str(t.get("for", "turning space"))
|
|
305
|
+
at = t.get("at")
|
|
306
|
+
if at is not None:
|
|
307
|
+
if not isinstance(at, (list, tuple)) or len(at) != 2:
|
|
308
|
+
raise InputError("each turning circle needs `at` as [x, y], or `near` naming a door or a piece")
|
|
309
|
+
turning.append({"at": (number(at[0], "turning circle x") * k, number(at[1], "turning circle y") * k),
|
|
310
|
+
"for": who, "d": diam})
|
|
311
|
+
continue
|
|
312
|
+
near = t.get("near")
|
|
313
|
+
if not isinstance(near, str) or not near.strip():
|
|
314
|
+
raise InputError("each turning circle needs `at` as [x, y], or `near` naming a door or a piece")
|
|
315
|
+
side = t.get("side", "any")
|
|
316
|
+
if side not in ("front", "back", "left", "right", "any"):
|
|
317
|
+
raise InputError(f"turning circle near {near!r}: `side` is front, back, left, right or any")
|
|
318
|
+
target = doors_by.get(near.strip().lower()) or pieces_by.get(near.strip().lower())
|
|
319
|
+
if target is None:
|
|
320
|
+
raise InputError(f"turning circle near {near!r}: no door or piece has that name")
|
|
321
|
+
kind = "door" if near.strip().lower() in doors_by else "piece"
|
|
322
|
+
if kind == "door" and side == "back":
|
|
323
|
+
raise InputError(f"turning circle near {near!r}: a door's `side` is front, left, right or any")
|
|
324
|
+
within = positive(t.get("within", diam / k), f"turning circle near {near!r}: within") * k
|
|
325
|
+
turning.append({"near": target["name"], "kind": kind, "side": side, "within": within, "for": who, "d": diam})
|
|
326
|
+
|
|
327
|
+
walkway = positive(data.get("min_walkway", 0.9 / k), "min_walkway") * k
|
|
328
|
+
accessible = bool(data.get("accessible", False))
|
|
329
|
+
if accessible:
|
|
330
|
+
walkway = max(walkway, 0.9)
|
|
331
|
+
return {"units": units, "k": k, "title": str(data.get("title", "Furniture layout")), "outline": outline,
|
|
332
|
+
"doors": doors, "windows": windows, "obstacles": obstacles, "pieces": pieces,
|
|
333
|
+
"walkway": walkway, "accessible": accessible, "turning_circles": turning,
|
|
334
|
+
"max_coverage": number(data.get("max_coverage", 0.5), "max_coverage"),
|
|
335
|
+
"north": number(data.get("north", 0), "north")}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------- a piece's frame ----------
|
|
339
|
+
|
|
340
|
+
def frame(p):
|
|
341
|
+
"""front, right and back unit vectors (right as seen standing at the front, facing the piece)."""
|
|
342
|
+
f = {0: (0, -1), 90: (1, 0), 180: (0, 1), 270: (-1, 0)}[p["rot"]]
|
|
343
|
+
return {"front": f, "back": (-f[0], -f[1]), "right": (-f[1], f[0]), "left": (f[1], -f[0])}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def side_zone(rect, direction, depth):
|
|
347
|
+
x, y, w, h = rect
|
|
348
|
+
return {(0, -1): (x, y - depth, w, depth), (0, 1): (x, y + h, w, depth),
|
|
349
|
+
(1, 0): (x + w, y, depth, h), (-1, 0): (x - depth, y, depth, h)}[direction]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def zones(p):
|
|
353
|
+
"""Each use clearance as (side, rect). A bed's sides start clear of the head, where nightstands go."""
|
|
354
|
+
out = []
|
|
355
|
+
fr = frame(p)
|
|
356
|
+
for s in SIDES:
|
|
357
|
+
d = p["clear"][s]
|
|
358
|
+
if d <= EPS:
|
|
359
|
+
continue
|
|
360
|
+
z = side_zone(p["rect"], fr[s], d)
|
|
361
|
+
if p["type"] in ("bed", "single_bed") and s in ("left", "right"):
|
|
362
|
+
z = trim_bed(p, z)
|
|
363
|
+
out.append((s, z))
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def chairs(p):
|
|
368
|
+
"""The chairs a dining table or desk implies, as rects in its clearance zone, so the drawing
|
|
369
|
+
shows every seat. Drawn only: the clearance zone is what the check keeps free for them."""
|
|
370
|
+
if p["type"] not in ("dining_table", "desk"):
|
|
371
|
+
return []
|
|
372
|
+
fr, out = frame(p), []
|
|
373
|
+
cw, cd, gap = 0.45, 0.45, 0.05
|
|
374
|
+
sides = ["front"] if p["type"] == "desk" else ["front", "back"]
|
|
375
|
+
x, y, w, h = p["rect"]
|
|
376
|
+
for side in sides:
|
|
377
|
+
if p["clear"].get(side, 0) < cd - EPS and p["type"] == "dining_table":
|
|
378
|
+
continue
|
|
379
|
+
d = fr[side]
|
|
380
|
+
along_x = d[1] != 0 # the side runs along x when its normal is vertical
|
|
381
|
+
span = w if along_x else h
|
|
382
|
+
n = 1 if p["type"] == "desk" else max(1, int((span + EPS) // 0.6))
|
|
383
|
+
step = span / n
|
|
384
|
+
for k in range(n):
|
|
385
|
+
c = (x if along_x else y) + step * (k + 0.5)
|
|
386
|
+
if along_x:
|
|
387
|
+
cy = y - gap - cd if d == (0, -1) else y + h + gap
|
|
388
|
+
out.append((c - cw / 2, cy, cw, cd))
|
|
389
|
+
else:
|
|
390
|
+
cx = x - gap - cd if d == (-1, 0) else x + w + gap
|
|
391
|
+
out.append((cx, c - cw / 2, cd, cw))
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def trim_bed(p, z):
|
|
396
|
+
"""Cut the head end off a bed's side clearance."""
|
|
397
|
+
back = frame(p)["back"]
|
|
398
|
+
x, y, w, h = z
|
|
399
|
+
if back[0]: # the bed runs along x; its head is at +x or -x
|
|
400
|
+
cut = min(BED_HEAD, w / 2)
|
|
401
|
+
return (x, y, w - cut, h) if back[0] > 0 else (x + cut, y, w - cut, h)
|
|
402
|
+
cut = min(BED_HEAD, h / 2)
|
|
403
|
+
return (x, y, w, h - cut) if back[1] > 0 else (x, y + cut, w, h - cut)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------- the checks ----------
|
|
407
|
+
|
|
408
|
+
def door_geometry(d, outline):
|
|
409
|
+
wall = wall_at(d["at"], outline)
|
|
410
|
+
if not wall:
|
|
411
|
+
return None
|
|
412
|
+
a, b = wall
|
|
413
|
+
n = inward_normal(a, b, outline)
|
|
414
|
+
t = ((b[0] - a[0]), (b[1] - a[1]))
|
|
415
|
+
tl = math.hypot(*t)
|
|
416
|
+
t = (t[0] / tl, t[1] / tl)
|
|
417
|
+
# standing inside facing the door, you look along d = -n; your right hand is (d.y, -d.x) = (-n.y, n.x)
|
|
418
|
+
right = (-n[1], n[0])
|
|
419
|
+
half = d["width"] / 2
|
|
420
|
+
side = 1 if d["hinge"] == "right" else -1
|
|
421
|
+
hinge = (d["at"][0] + right[0] * half * side, d["at"][1] + right[1] * half * side)
|
|
422
|
+
latch = (d["at"][0] - right[0] * half * side, d["at"][1] - right[1] * half * side)
|
|
423
|
+
return {"n": n, "t": t, "hinge": hinge, "latch": latch}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def in_swing(pt, d, g):
|
|
427
|
+
if d["swing"] != "in":
|
|
428
|
+
return False
|
|
429
|
+
vx, vy = pt[0] - g["hinge"][0], pt[1] - g["hinge"][1]
|
|
430
|
+
if math.hypot(vx, vy) >= d["width"] - 1e-3:
|
|
431
|
+
return False
|
|
432
|
+
lx, ly = g["latch"][0] - g["hinge"][0], g["latch"][1] - g["hinge"][1]
|
|
433
|
+
return vx * g["n"][0] + vy * g["n"][1] > 1e-3 and vx * lx + vy * ly > -1e-3
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def rect_in_swing(r, d, g, step=0.025):
|
|
437
|
+
x, y, w, h = r
|
|
438
|
+
nx, ny = max(2, int(w / step) + 1), max(2, int(h / step) + 1)
|
|
439
|
+
return any(in_swing((x + w * i / (nx - 1), y + h * j / (ny - 1)), d, g) for i in range(nx) for j in range(ny))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def edt1(f):
|
|
443
|
+
"""1-D squared distance transform of a sampled function (Felzenszwalb and Huttenlocher)."""
|
|
444
|
+
n = len(f)
|
|
445
|
+
d, v, z = [0.0] * n, [0] * n, [0.0] * (n + 1)
|
|
446
|
+
k, z[0], z[1] = 0, -1e30, 1e30
|
|
447
|
+
for q in range(1, n):
|
|
448
|
+
s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k])
|
|
449
|
+
while s <= z[k]:
|
|
450
|
+
k -= 1
|
|
451
|
+
s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k])
|
|
452
|
+
k += 1
|
|
453
|
+
v[k], z[k], z[k + 1] = q, s, 1e30
|
|
454
|
+
k = 0
|
|
455
|
+
for q in range(n):
|
|
456
|
+
while z[k + 1] < q:
|
|
457
|
+
k += 1
|
|
458
|
+
d[q] = (q - v[k]) ** 2 + f[v[k]]
|
|
459
|
+
return d
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class Grid(object):
|
|
463
|
+
def __init__(self, plan):
|
|
464
|
+
xs, ys = [p[0] for p in plan["outline"]], [p[1] for p in plan["outline"]]
|
|
465
|
+
self.x0, self.y0 = min(xs), min(ys)
|
|
466
|
+
span = (max(xs) - self.x0) * (max(ys) - self.y0)
|
|
467
|
+
self.c = 0.025 if span <= 60 else 0.05 if span <= 250 else 0.1
|
|
468
|
+
# one cell of outside all round, so the walls count as obstacles in the distance transform
|
|
469
|
+
self.x0, self.y0 = self.x0 - self.c, self.y0 - self.c
|
|
470
|
+
self.nx = int(math.ceil((max(xs) - self.x0) / self.c)) + 1
|
|
471
|
+
self.ny = int(math.ceil((max(ys) - self.y0) / self.c)) + 1
|
|
472
|
+
self.blocked = [[False] * self.nx for _ in range(self.ny)]
|
|
473
|
+
for j in range(self.ny):
|
|
474
|
+
tick()
|
|
475
|
+
yc = self.y0 + (j + 0.5) * self.c
|
|
476
|
+
# scanline: the outline's crossings at this height
|
|
477
|
+
xs_in = []
|
|
478
|
+
o = plan["outline"]
|
|
479
|
+
for i, (x1, y1) in enumerate(o):
|
|
480
|
+
x2, y2 = o[(i + 1) % len(o)]
|
|
481
|
+
if (y1 > yc) != (y2 > yc):
|
|
482
|
+
xs_in.append(x1 + (yc - y1) * (x2 - x1) / (y2 - y1))
|
|
483
|
+
xs_in.sort()
|
|
484
|
+
row = self.blocked[j]
|
|
485
|
+
for i in range(self.nx):
|
|
486
|
+
xc = self.x0 + (i + 0.5) * self.c
|
|
487
|
+
row[i] = sum(1 for v in xs_in if v < xc) % 2 == 0
|
|
488
|
+
for r in [p["rect"] for p in plan["pieces"] if p["type"] != "rug"] + [o["rect"] for o in plan["obstacles"]]:
|
|
489
|
+
self.fill(r)
|
|
490
|
+
|
|
491
|
+
def cell(self, x, y):
|
|
492
|
+
return int((x - self.x0) / self.c), int((y - self.y0) / self.c)
|
|
493
|
+
|
|
494
|
+
def centre(self, i, j):
|
|
495
|
+
return self.x0 + (i + 0.5) * self.c, self.y0 + (j + 0.5) * self.c
|
|
496
|
+
|
|
497
|
+
def fill(self, r):
|
|
498
|
+
x, y, w, h = r
|
|
499
|
+
i0 = max(0, int(math.floor((x - self.x0) / self.c + 0.5 - EPS)))
|
|
500
|
+
i1 = min(self.nx - 1, int(math.ceil((x + w - self.x0) / self.c - 0.5 + EPS)) - 1)
|
|
501
|
+
j0 = max(0, int(math.floor((y - self.y0) / self.c + 0.5 - EPS)))
|
|
502
|
+
j1 = min(self.ny - 1, int(math.ceil((y + h - self.y0) / self.c - 0.5 + EPS)) - 1)
|
|
503
|
+
for j in range(j0, j1 + 1):
|
|
504
|
+
row = self.blocked[j]
|
|
505
|
+
for i in range(i0, i1 + 1):
|
|
506
|
+
row[i] = True
|
|
507
|
+
|
|
508
|
+
def distances(self):
|
|
509
|
+
"""Euclidean distance, in metres, from each cell's centre to the nearest blocked cell's
|
|
510
|
+
centre (Felzenszwalb and Huttenlocher's two-pass transform)."""
|
|
511
|
+
if getattr(self, "_dist", None) is None:
|
|
512
|
+
INF = float(self.nx * self.nx + self.ny * self.ny)
|
|
513
|
+
cols = []
|
|
514
|
+
for i in range(self.nx):
|
|
515
|
+
tick()
|
|
516
|
+
cols.append(edt1([0.0 if self.blocked[j][i] else INF for j in range(self.ny)]))
|
|
517
|
+
rows = []
|
|
518
|
+
for j in range(self.ny):
|
|
519
|
+
tick()
|
|
520
|
+
rows.append(edt1([cols[i][j] for i in range(self.nx)]))
|
|
521
|
+
self._dist = [[math.sqrt(v) * self.c for v in row] for row in rows]
|
|
522
|
+
return self._dist
|
|
523
|
+
|
|
524
|
+
def clear(self, width):
|
|
525
|
+
"""Cells where a walker `width` across (a disc) stands clear of everything, to within a grid step."""
|
|
526
|
+
dist, r = self.distances(), width / 2
|
|
527
|
+
return [[dist[j][i] >= r - 1e-9 for i in range(self.nx)] for j in range(self.ny)]
|
|
528
|
+
|
|
529
|
+
def reach(self, ok, starts):
|
|
530
|
+
parent = {}
|
|
531
|
+
q = deque()
|
|
532
|
+
for s in starts:
|
|
533
|
+
parent[s] = None
|
|
534
|
+
q.append(s)
|
|
535
|
+
n = 0
|
|
536
|
+
while q:
|
|
537
|
+
n += 1
|
|
538
|
+
if n % 4096 == 0:
|
|
539
|
+
tick()
|
|
540
|
+
i, j = q.popleft()
|
|
541
|
+
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)):
|
|
542
|
+
a, b = i + di, j + dj
|
|
543
|
+
if 0 <= a < self.nx and 0 <= b < self.ny and ok[b][a] and (a, b) not in parent:
|
|
544
|
+
if di and dj and not (ok[j][a] and ok[b][i]):
|
|
545
|
+
continue
|
|
546
|
+
parent[(a, b)] = (i, j)
|
|
547
|
+
q.append((a, b))
|
|
548
|
+
return parent
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def check(plan):
|
|
552
|
+
fails, warns = [], []
|
|
553
|
+
pieces, outline = plan["pieces"], plan["outline"]
|
|
554
|
+
solid = [p for p in pieces if p["type"] != "rug"]
|
|
555
|
+
bad = set()
|
|
556
|
+
blocked_zones = []
|
|
557
|
+
for i, a in enumerate(solid):
|
|
558
|
+
for b in solid[i + 1:]:
|
|
559
|
+
ov = overlap(a["rect"], b["rect"])
|
|
560
|
+
if ov > EPS:
|
|
561
|
+
fails.append(f"{a['name']} and {b['name']} overlap by {fmt_a(plan, ov)}")
|
|
562
|
+
bad.update((a["name"], b["name"]))
|
|
563
|
+
for p in pieces:
|
|
564
|
+
if not rect_in_poly(p["rect"], outline):
|
|
565
|
+
fails.append(f"{p['name']} runs outside the room")
|
|
566
|
+
bad.add(p["name"])
|
|
567
|
+
for o in plan["obstacles"]:
|
|
568
|
+
if p["type"] != "rug" and overlap(p["rect"], o["rect"]) > EPS:
|
|
569
|
+
fails.append(f"{p['name']} sits on the {o['name'].lower()}")
|
|
570
|
+
bad.add(p["name"])
|
|
571
|
+
elif o["type"] == "radiator" and p["type"] != "rug" and rect_gap(p["rect"], o["rect"]) < 0.1:
|
|
572
|
+
warns.append(f"{p['name']} stands against the {o['name'].lower()}: it blocks the heat and dries out")
|
|
573
|
+
geo = []
|
|
574
|
+
for d in plan["doors"]:
|
|
575
|
+
g = door_geometry(d, outline)
|
|
576
|
+
geo.append(g)
|
|
577
|
+
if not g:
|
|
578
|
+
fails.append(f"{d['name']} at {fmt_pt(plan, d['at'])} is not on a wall")
|
|
579
|
+
continue
|
|
580
|
+
for p in solid:
|
|
581
|
+
if rect_in_swing(p["rect"], d, g):
|
|
582
|
+
fails.append(f"{p['name']} is in the swing of {d['name']}: the door can't open fully")
|
|
583
|
+
bad.add(p["name"])
|
|
584
|
+
for p in solid:
|
|
585
|
+
for side, z in zones(p):
|
|
586
|
+
why = None
|
|
587
|
+
if not rect_in_poly(z, outline):
|
|
588
|
+
why = "runs into the wall"
|
|
589
|
+
else:
|
|
590
|
+
for o in solid:
|
|
591
|
+
if o is not p and overlap(z, o["rect"]) > EPS:
|
|
592
|
+
why = f"is blocked by {o['name']}"
|
|
593
|
+
break
|
|
594
|
+
else:
|
|
595
|
+
for o in plan["obstacles"]:
|
|
596
|
+
if overlap(z, o["rect"]) > EPS:
|
|
597
|
+
why = f"is blocked by the {o['name'].lower()}"
|
|
598
|
+
break
|
|
599
|
+
if why:
|
|
600
|
+
fails.append(f"{p['name']}'s {side} clearance ({fmt_l(plan, p['clear'][side])}) {why}")
|
|
601
|
+
bad.add(p["name"])
|
|
602
|
+
blocked_zones.append(z)
|
|
603
|
+
|
|
604
|
+
routes, turns, grid_c, incomplete = [], [], None, False
|
|
605
|
+
try:
|
|
606
|
+
grid_c = _routes_and_turns(plan, geo, fails, bad, routes, turns)
|
|
607
|
+
except TimeUp:
|
|
608
|
+
incomplete = True
|
|
609
|
+
return _finish(plan, fails, warns, bad, blocked_zones, routes, turns, geo, grid_c, incomplete)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _routes_and_turns(plan, geo, fails, bad, routes, turns):
|
|
613
|
+
"""The grid checks: walking routes at the walkway width, and the turning circles. These are
|
|
614
|
+
the slow part, so they stop at the deadline."""
|
|
615
|
+
outline, solid = plan["outline"], [p for p in plan["pieces"] if p["type"] != "rug"]
|
|
616
|
+
grid = Grid(plan)
|
|
617
|
+
ok = grid.clear(plan["walkway"])
|
|
618
|
+
starts, door_of = [], {}
|
|
619
|
+
for d, g in zip(plan["doors"], geo):
|
|
620
|
+
if not g or not d["entrance"]:
|
|
621
|
+
continue
|
|
622
|
+
px = d["at"][0] + g["n"][0] * (plan["walkway"] / 2 + grid.c)
|
|
623
|
+
py = d["at"][1] + g["n"][1] * (plan["walkway"] / 2 + grid.c)
|
|
624
|
+
found = []
|
|
625
|
+
reach_r = d["width"] / 2 + grid.c
|
|
626
|
+
ci, cj = grid.cell(px, py)
|
|
627
|
+
span = int(reach_r / grid.c) + 1
|
|
628
|
+
for j in range(cj - span, cj + span + 1):
|
|
629
|
+
for i in range(ci - span, ci + span + 1):
|
|
630
|
+
if 0 <= i < grid.nx and 0 <= j < grid.ny and ok[j][i]:
|
|
631
|
+
x, y = grid.centre(i, j)
|
|
632
|
+
if math.hypot(x - px, y - py) <= reach_r:
|
|
633
|
+
found.append((i, j))
|
|
634
|
+
if not found:
|
|
635
|
+
fails.append(f"nothing {fmt_l(plan, plan['walkway'])} wide clears the way in at {d['name']}")
|
|
636
|
+
continue
|
|
637
|
+
best = min(found, key=lambda c: math.hypot(grid.centre(*c)[0] - px, grid.centre(*c)[1] - py))
|
|
638
|
+
starts.append(best)
|
|
639
|
+
door_of[best] = d["at"]
|
|
640
|
+
parent = grid.reach(ok, starts) if starts else {}
|
|
641
|
+
half = plan["walkway"] / 2 + grid.c + 1e-6 # the walker touches the clearance, to within a grid step
|
|
642
|
+
for p in solid:
|
|
643
|
+
tick()
|
|
644
|
+
if not p["access"] or not starts:
|
|
645
|
+
continue
|
|
646
|
+
targets = [(s, z) for s, z in zones(p)]
|
|
647
|
+
if p["access"] == "front" or not targets:
|
|
648
|
+
fr = frame(p)
|
|
649
|
+
z = [z for s, z in targets if s == "front"] or [side_zone(p["rect"], fr["front"], 0.05)]
|
|
650
|
+
targets = [("front", z[0])]
|
|
651
|
+
need = targets if p["access"] == "sides" else [None]
|
|
652
|
+
missing = []
|
|
653
|
+
hits = []
|
|
654
|
+
for want in need:
|
|
655
|
+
cand = targets if want is None else [want]
|
|
656
|
+
hit = None
|
|
657
|
+
for side, z in cand:
|
|
658
|
+
hit = nearest_reached(grid, parent, z, half)
|
|
659
|
+
if hit:
|
|
660
|
+
hits.append((hit, z))
|
|
661
|
+
break
|
|
662
|
+
if not hit:
|
|
663
|
+
missing.append("its " + (want[0] if want else " or ".join(s for s, _ in cand)) + " side")
|
|
664
|
+
if missing:
|
|
665
|
+
fails.append(f"no clear {fmt_l(plan, plan['walkway'])} route from the entrance reaches {p['name']} ({', '.join(missing)})")
|
|
666
|
+
bad.add(p["name"])
|
|
667
|
+
for h, z in hits:
|
|
668
|
+
pts = path(grid, parent, h, door_of)
|
|
669
|
+
cx, cy = pts[-1]
|
|
670
|
+
x, y, w, hh = z
|
|
671
|
+
pts.append((min(max(cx, x), x + w), min(max(cy, y), y + hh)))
|
|
672
|
+
routes.append(pts)
|
|
673
|
+
# (centre, for whom, clear, diameter): drawn exactly where they are checked
|
|
674
|
+
if plan.get("turning_circles"):
|
|
675
|
+
dist = grid.distances()
|
|
676
|
+
for t in plan["turning_circles"]:
|
|
677
|
+
d, r = t["d"], t["d"] / 2
|
|
678
|
+
if "at" in t:
|
|
679
|
+
i, j = grid.cell(*t["at"])
|
|
680
|
+
where = f"for {t['for']} at {fmt_pt(plan, t['at'])}"
|
|
681
|
+
if not (0 <= i < grid.nx and 0 <= j < grid.ny) or grid.blocked[j][i]:
|
|
682
|
+
fails.append(f"the {fmt_d(plan, d)} turning circle {where} sits on furniture or outside the room")
|
|
683
|
+
turns.append((t["at"], t["for"], False, d))
|
|
684
|
+
elif dist[j][i] < r - grid.c / 2 - 1e-9:
|
|
685
|
+
fails.append(f"the {fmt_d(plan, d)} turning circle {where} is not clear: the clear circle there is "
|
|
686
|
+
f"{fmt_l(plan, 2 * dist[j][i])} across")
|
|
687
|
+
turns.append((t["at"], t["for"], False, d))
|
|
688
|
+
elif (i, j) not in parent:
|
|
689
|
+
fails.append(f"no clear {fmt_l(plan, plan['walkway'])} route from the entrance reaches the turning circle {where}")
|
|
690
|
+
turns.append((t["at"], t["for"], False, d))
|
|
691
|
+
else:
|
|
692
|
+
turns.append((t["at"], t["for"], True, d))
|
|
693
|
+
continue
|
|
694
|
+
rel = {"front": "in front of", "back": "behind", "left": "to the left of", "right": "to the right of",
|
|
695
|
+
"any": "by"}[t["side"]]
|
|
696
|
+
where = (f"for {t['for']} " if t["for"] != "turning space" else "") + \
|
|
697
|
+
f"{rel} {t['near']}, within {fmt_l(plan, t['within'])}"
|
|
698
|
+
best, best_reached = near_search(plan, grid, dist, parent, t, geo)
|
|
699
|
+
if best is None:
|
|
700
|
+
fails.append(f"the {fmt_d(plan, d)} turning circle {where}: there is no clear floor there at all")
|
|
701
|
+
continue
|
|
702
|
+
if best_reached and best_reached[0] >= r - grid.c / 2 - 1e-9:
|
|
703
|
+
turns.append((best_reached[1], t["for"], True, d))
|
|
704
|
+
elif best[0] >= r - grid.c / 2 - 1e-9:
|
|
705
|
+
fails.append(f"no clear {fmt_l(plan, plan['walkway'])} route from the entrance reaches the "
|
|
706
|
+
f"{fmt_d(plan, d)} turning circle {where}")
|
|
707
|
+
turns.append((best[1], t["for"], False, d))
|
|
708
|
+
else:
|
|
709
|
+
fails.append(f"the {fmt_d(plan, d)} turning circle {where} doesn't fit: the largest clear circle there "
|
|
710
|
+
f"is {fmt_l(plan, 2 * best[0])} across, centred at {fmt_pt(plan, best[1])}")
|
|
711
|
+
turns.append((best[1], t["for"], False, d))
|
|
712
|
+
elif plan["accessible"]:
|
|
713
|
+
found = turning_circle(grid, parent, 0.75)
|
|
714
|
+
if found:
|
|
715
|
+
turns.append((found, "turning space", True, 1.5))
|
|
716
|
+
else:
|
|
717
|
+
fails.append("there is no clear 1.5 m turning circle the route reaches")
|
|
718
|
+
return grid.c
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _finish(plan, fails, warns, bad, blocked_zones, routes, turns, geo, grid_c, incomplete):
|
|
722
|
+
outline = plan["outline"]
|
|
723
|
+
solid = [p for p in plan["pieces"] if p["type"] != "rug"]
|
|
724
|
+
for w in plan["windows"]:
|
|
725
|
+
wall = wall_at(w["at"], outline)
|
|
726
|
+
if not wall:
|
|
727
|
+
warns.append(f"window at {fmt_pt(plan, w['at'])} is not on a wall")
|
|
728
|
+
continue
|
|
729
|
+
a, b = wall
|
|
730
|
+
n = inward_normal(a, b, outline)
|
|
731
|
+
horiz = abs(a[1] - b[1]) < EPS
|
|
732
|
+
hw = w["width"] / 2
|
|
733
|
+
win = (w["at"][0] - hw, w["at"][1], w["width"], 0) if horiz else (w["at"][0], w["at"][1] - hw, 0, w["width"])
|
|
734
|
+
for p in solid:
|
|
735
|
+
if p["height"] > w["sill"] + EPS and rect_gap(p["rect"], win) < 0.3:
|
|
736
|
+
x, y, pw, ph = p["rect"]
|
|
737
|
+
along = (min(x + pw, win[0] + win[2]) - max(x, win[0])) if horiz else (min(y + ph, win[1] + win[3]) - max(y, win[1]))
|
|
738
|
+
if along > EPS:
|
|
739
|
+
warns.append(f"{p['name']} ({fmt_l(plan, p['height'])} tall) stands in front of the window at "
|
|
740
|
+
f"{fmt_pt(plan, w['at'])}, whose sill is {fmt_l(plan, w['sill'])}")
|
|
741
|
+
floor = poly_area(outline)
|
|
742
|
+
covered = sum(p["rect"][2] * p["rect"][3] for p in solid)
|
|
743
|
+
if floor > EPS and covered / floor > plan["max_coverage"] + EPS:
|
|
744
|
+
warns.append(f"the furniture covers {covered / floor * 100:.0f} % of the floor, over {plan['max_coverage'] * 100:.0f} %")
|
|
745
|
+
return {"fails": fails, "warns": warns, "floor": floor, "covered": covered, "bad": bad,
|
|
746
|
+
"routes": routes, "blocked_zones": blocked_zones, "turns": turns, "geo": geo, "grid": grid_c,
|
|
747
|
+
"incomplete": incomplete}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def nearest_reached(grid, parent, z, half):
|
|
751
|
+
x, y, w, h = z
|
|
752
|
+
i0, j0 = grid.cell(x - half, y - half)
|
|
753
|
+
i1, j1 = grid.cell(x + w + half, y + h + half)
|
|
754
|
+
best = None
|
|
755
|
+
for j in range(max(0, j0), min(grid.ny - 1, j1) + 1):
|
|
756
|
+
for i in range(max(0, i0), min(grid.nx - 1, i1) + 1):
|
|
757
|
+
if (i, j) in parent:
|
|
758
|
+
cx, cy = grid.centre(i, j)
|
|
759
|
+
gap = math.hypot(max(x - cx, cx - (x + w), 0), max(y - cy, cy - (y + h), 0))
|
|
760
|
+
if gap <= half and (best is None or gap < best[0]):
|
|
761
|
+
best = (gap, (i, j))
|
|
762
|
+
return best[1] if best else None
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def path(grid, parent, cell, door_of=None):
|
|
766
|
+
pts = []
|
|
767
|
+
while cell is not None:
|
|
768
|
+
pts.append(grid.centre(*cell))
|
|
769
|
+
first = cell
|
|
770
|
+
cell = parent[cell]
|
|
771
|
+
pts = pts[::-1]
|
|
772
|
+
if door_of and first in door_of:
|
|
773
|
+
pts.insert(0, door_of[first])
|
|
774
|
+
return simplify(pts, 0.08)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def simplify(pts, tol):
|
|
778
|
+
if len(pts) < 3:
|
|
779
|
+
return pts
|
|
780
|
+
(x1, y1), (x2, y2) = pts[0], pts[-1]
|
|
781
|
+
L = math.hypot(x2 - x1, y2 - y1) or 1e-9
|
|
782
|
+
far, idx = 0, 0
|
|
783
|
+
for i, (x, y) in enumerate(pts[1:-1], 1):
|
|
784
|
+
d = abs((x2 - x1) * (y1 - y) - (x1 - x) * (y2 - y1)) / L
|
|
785
|
+
if d > far:
|
|
786
|
+
far, idx = d, i
|
|
787
|
+
if far <= tol:
|
|
788
|
+
return [pts[0], pts[-1]]
|
|
789
|
+
return simplify(pts[:idx + 1], tol)[:-1] + simplify(pts[idx:], tol)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def near_search(plan, grid, dist, parent, t, geo):
|
|
793
|
+
"""The largest clear circle whose centre lies in the zone a place-tied turning circle names:
|
|
794
|
+
within `within` of the door (on its room side, or to its left or right as you face it from
|
|
795
|
+
inside) or of the piece's given side. Returns (best, best reached by the route), each as
|
|
796
|
+
(clear radius, centre) or None."""
|
|
797
|
+
w = t["within"]
|
|
798
|
+
if t["kind"] == "door":
|
|
799
|
+
d = next(x for x in plan["doors"] if x["name"] == t["near"])
|
|
800
|
+
g = geo[plan["doors"].index(d)]
|
|
801
|
+
if not g:
|
|
802
|
+
return None, None
|
|
803
|
+
cx, cy = d["at"]
|
|
804
|
+
right = (-g["n"][1], g["n"][0])
|
|
805
|
+
box = (cx - w, cy - w, 2 * w, 2 * w)
|
|
806
|
+
|
|
807
|
+
def inside(x, y):
|
|
808
|
+
vx, vy = x - cx, y - cy
|
|
809
|
+
if math.hypot(vx, vy) > w + 1e-9 or vx * g["n"][0] + vy * g["n"][1] <= 0:
|
|
810
|
+
return False
|
|
811
|
+
if t["side"] == "right":
|
|
812
|
+
return vx * right[0] + vy * right[1] > 0
|
|
813
|
+
if t["side"] == "left":
|
|
814
|
+
return vx * right[0] + vy * right[1] < 0
|
|
815
|
+
return True
|
|
816
|
+
else:
|
|
817
|
+
p = next(x for x in plan["pieces"] if x["name"] == t["near"])
|
|
818
|
+
if t["side"] == "any":
|
|
819
|
+
x0, y0, pw, ph = p["rect"]
|
|
820
|
+
box = (x0 - w, y0 - w, pw + 2 * w, ph + 2 * w)
|
|
821
|
+
|
|
822
|
+
def inside(x, y):
|
|
823
|
+
gap = math.hypot(max(x0 - x, x - (x0 + pw), 0), max(y0 - y, y - (y0 + ph), 0))
|
|
824
|
+
return 0 < gap <= w + 1e-9
|
|
825
|
+
else:
|
|
826
|
+
box = side_zone(p["rect"], frame(p)[t["side"]], w)
|
|
827
|
+
|
|
828
|
+
def inside(x, y):
|
|
829
|
+
bx, by, bw, bh = box
|
|
830
|
+
return bx - 1e-9 <= x <= bx + bw + 1e-9 and by - 1e-9 <= y <= by + bh + 1e-9
|
|
831
|
+
i0, j0 = grid.cell(box[0], box[1])
|
|
832
|
+
i1, j1 = grid.cell(box[0] + box[2], box[1] + box[3])
|
|
833
|
+
best = best_reached = None
|
|
834
|
+
for j in range(max(0, j0), min(grid.ny - 1, j1) + 1):
|
|
835
|
+
tick()
|
|
836
|
+
for i in range(max(0, i0), min(grid.nx - 1, i1) + 1):
|
|
837
|
+
if grid.blocked[j][i]:
|
|
838
|
+
continue
|
|
839
|
+
x, y = grid.centre(i, j)
|
|
840
|
+
if not inside(x, y):
|
|
841
|
+
continue
|
|
842
|
+
cand = (dist[j][i], (round(x, 3), round(y, 3)))
|
|
843
|
+
if best is None or cand[0] > best[0]:
|
|
844
|
+
best = cand
|
|
845
|
+
if (i, j) in parent and (best_reached is None or cand[0] > best_reached[0]):
|
|
846
|
+
best_reached = cand
|
|
847
|
+
return best, best_reached
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def turning_circle(grid, parent, radius):
|
|
851
|
+
"""A reached cell with a clear disc of `radius` round it (the centre nearest the entrance)."""
|
|
852
|
+
dist = grid.distances()
|
|
853
|
+
for cell in parent: # in breadth-first order, so the first is the nearest to the door
|
|
854
|
+
i, j = cell
|
|
855
|
+
if dist[j][i] >= radius - 1e-9:
|
|
856
|
+
return grid.centre(i, j)
|
|
857
|
+
return None
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# ---------- report ----------
|
|
861
|
+
|
|
862
|
+
def disp(plan, v):
|
|
863
|
+
return v / plan["k"]
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def fmt_l(plan, v):
|
|
867
|
+
return f"{disp(plan, v):.2f} {plan['units']}".replace(".00 ", " ")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def fmt_d(plan, v):
|
|
871
|
+
"""A circle's diameter as people write it: 1.5 m, 5 ft."""
|
|
872
|
+
return f"{disp(plan, v):g} {plan['units']}"
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def fmt_a(plan, a):
|
|
876
|
+
return f"{a / plan['k'] ** 2:.2f} {'m²' if plan['units'] == 'm' else 'sq ft'}"
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def fmt_pt(plan, p):
|
|
880
|
+
return f"({disp(plan, p[0]):g}, {disp(plan, p[1]):g})"
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def facing(p):
|
|
884
|
+
return {0: "S", 90: "E", 180: "N", 270: "W"}[p["rot"]]
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def report(plan, r):
|
|
888
|
+
u = plan["units"]
|
|
889
|
+
L = [f"# {plan['title']}: layout check", "",
|
|
890
|
+
f"| Piece | Type | Size ({u}) | At ({u}) | Front faces | Clearances ({u}) |", "|---|---|---|---|---|---|"]
|
|
891
|
+
for p in plan["pieces"]:
|
|
892
|
+
w, d = p["size"]
|
|
893
|
+
cl = ", ".join(f"{s} {disp(plan, v):g}" for s, v in p["clear"].items() if v > EPS) or "none"
|
|
894
|
+
mark = " ✗" if p["name"] in r["bad"] else ""
|
|
895
|
+
L.append(f"| {p['name']}{mark} | {p['type']} | {disp(plan, w):g} × {disp(plan, d):g} | "
|
|
896
|
+
f"{disp(plan, p['rect'][0]):g}, {disp(plan, p['rect'][1]):g} | {facing(p)} | {cl} |")
|
|
897
|
+
L += ["", f"Floor {fmt_a(plan, r['floor'])} · furniture {fmt_a(plan, r['covered'])} "
|
|
898
|
+
f"({r['covered'] / r['floor'] * 100:.0f} %) · walkway {fmt_l(plan, plan['walkway'])}"
|
|
899
|
+
+ (" · accessible" if plan["accessible"] else ""), ""]
|
|
900
|
+
if not r["fails"] and not r.get("incomplete"):
|
|
901
|
+
L.append("PASS no overlaps, every piece in the room, no door swing blocked, every clearance clear, "
|
|
902
|
+
"every piece reached at the walkway width" + (", a turning circle reached." if plan["accessible"] else "."))
|
|
903
|
+
for at, who, ok, dm in r["turns"]:
|
|
904
|
+
L.append(f"Turning circle ({fmt_d(plan, dm)}) for {who}: centre at {fmt_pt(plan, at)}, {'clear' if ok else 'not clear'}")
|
|
905
|
+
if r["turns"]:
|
|
906
|
+
L.append("")
|
|
907
|
+
L += [f"FAIL {f}" for f in r["fails"]] + [f"WARN {w}" for w in r["warns"]]
|
|
908
|
+
if r.get("incomplete"):
|
|
909
|
+
L += ["", "INCOMPLETE the walking-route and turning-circle checks did not finish in time, so this is not a pass. "
|
|
910
|
+
"Rerun with a larger --max-seconds, or split the room."]
|
|
911
|
+
return "\n".join(L) + "\n"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def as_json(plan, r):
|
|
915
|
+
return {"title": plan["title"], "units": plan["units"], "pass": not r["fails"] and not r.get("incomplete"),
|
|
916
|
+
"incomplete": bool(r.get("incomplete")), "fails": r["fails"], "warns": r["warns"],
|
|
917
|
+
"floor_area": round(r["floor"] / plan["k"] ** 2, 3), "furniture_area": round(r["covered"] / plan["k"] ** 2, 3),
|
|
918
|
+
"walkway": round(disp(plan, plan["walkway"]), 3),
|
|
919
|
+
"pieces": [{"name": p["name"], "type": p["type"], "rect": [round(disp(plan, v), 3) for v in p["rect"]],
|
|
920
|
+
"facing": facing(p), "ok": p["name"] not in r["bad"]} for p in plan["pieces"]],
|
|
921
|
+
"turning_circles": [{"for": who, "at": [round(disp(plan, at[0]), 3), round(disp(plan, at[1]), 3)], "clear": ok,
|
|
922
|
+
"diameter": round(disp(plan, dm), 3)} for at, who, ok, dm in r["turns"]]}
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
# ---------- drawing ----------
|
|
926
|
+
|
|
927
|
+
def esc(t):
|
|
928
|
+
return str(t).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def svg(plan, r, draw=720):
|
|
932
|
+
o, u = plan["outline"], plan["units"]
|
|
933
|
+
xs, ys = [p[0] for p in o], [p[1] for p in o]
|
|
934
|
+
x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys)
|
|
935
|
+
s = draw / max(x1 - x0, y1 - y0)
|
|
936
|
+
m = 80
|
|
937
|
+
W = (x1 - x0) * s + 2 * m + 40
|
|
938
|
+
H = (y1 - y0) * s + 2 * m + 70
|
|
939
|
+
|
|
940
|
+
def P(x, y):
|
|
941
|
+
return (m + (x - x0) * s, H - m - 30 - (y - y0) * s)
|
|
942
|
+
|
|
943
|
+
def R(rect, **attrs):
|
|
944
|
+
x, y, w, h = rect
|
|
945
|
+
X, Y = P(x, y + h)
|
|
946
|
+
a = " ".join(f'{k.replace("_", "-")}="{v}"' for k, v in attrs.items())
|
|
947
|
+
return f'<rect x="{X:.1f}" y="{Y:.1f}" width="{w * s:.1f}" height="{h * s:.1f}" {a}/>'
|
|
948
|
+
|
|
949
|
+
wall = max(0.12 * s, 3)
|
|
950
|
+
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W:.0f}" height="{H:.0f}" viewBox="0 0 {W:.0f} {H:.0f}" '
|
|
951
|
+
'font-family="Helvetica, Arial, sans-serif" font-size="11">',
|
|
952
|
+
f'<rect width="{W:.0f}" height="{H:.0f}" fill="#fff"/>',
|
|
953
|
+
f'<text x="20" y="28" font-size="16" font-weight="bold">{esc(plan["title"])}</text>',
|
|
954
|
+
f'<text x="20" y="46" fill="#444">Floor {esc(fmt_a(plan, r["floor"]))} · walkway {esc(fmt_l(plan, plan["walkway"]))} · '
|
|
955
|
+
f'{"checked: passes" if not r["fails"] else str(len(r["fails"])) + " failure(s)"} · schematic</text>',
|
|
956
|
+
f'<polygon points="{" ".join("%.1f,%.1f" % P(*p) for p in o)}" fill="#faf8f4"/>']
|
|
957
|
+
for p in plan["pieces"]:
|
|
958
|
+
if p["type"] == "rug":
|
|
959
|
+
out.append(R(p["rect"], fill="#efe6d6", stroke="#c8b999", stroke_width="0.8", stroke_dasharray="1 2"))
|
|
960
|
+
for p in plan["pieces"]:
|
|
961
|
+
for side, z in zones(p):
|
|
962
|
+
hit = z in r["blocked_zones"]
|
|
963
|
+
out.append(R(z, fill="#e05a47" if hit else "#7fb3d5", fill_opacity="0.25" if hit else "0.18",
|
|
964
|
+
stroke="#c0392b" if hit else "#5d8fb0", stroke_width="0.6", stroke_dasharray="3 2"))
|
|
965
|
+
labels = []
|
|
966
|
+
for ob in plan["obstacles"]:
|
|
967
|
+
out.append(R(ob["rect"], fill="#bbb", stroke="#555", stroke_width="1"))
|
|
968
|
+
x, y, w, h = ob["rect"]
|
|
969
|
+
X, Y = P(x + w / 2, y + h / 2)
|
|
970
|
+
labels.append((X, Y + 3 if min(w, h) >= 0.25 else Y + (14 if P(x, y)[1] < H / 2 else -8), ob["name"]))
|
|
971
|
+
for route in r["routes"]:
|
|
972
|
+
if len(route) > 1:
|
|
973
|
+
out.append('<polyline points="' + " ".join("%.1f,%.1f" % P(*q) for q in route) +
|
|
974
|
+
'" fill="none" stroke="#2e8b57" stroke-width="1.6" stroke-dasharray="6 3" stroke-linecap="round"/>')
|
|
975
|
+
for at, who, ok, dm in r["turns"]:
|
|
976
|
+
X, Y = P(*at)
|
|
977
|
+
col = "#2e8b57" if ok else "#c0392b"
|
|
978
|
+
out.append(f'<circle cx="{X:.1f}" cy="{Y:.1f}" r="{dm / 2 * s:.1f}" fill="{col}" fill-opacity="0.06" stroke="{col}" stroke-width="1.2" stroke-dasharray="3 2"/>')
|
|
979
|
+
out.append(f'<text x="{X:.1f}" y="{Y - 2:.1f}" text-anchor="middle" font-size="9" fill="{col}">⌀{esc(fmt_d(plan, dm))} turn</text>')
|
|
980
|
+
if who != "turning space":
|
|
981
|
+
out.append(f'<text x="{X:.1f}" y="{Y + 9:.1f}" text-anchor="middle" font-size="8" fill="{col}">{esc(who)}</text>')
|
|
982
|
+
for p in plan["pieces"]:
|
|
983
|
+
for c in chairs(p):
|
|
984
|
+
out.append(R(c, fill="#fff", stroke="#6b6b6b", stroke_width="0.9", rx="2"))
|
|
985
|
+
for p in plan["pieces"]:
|
|
986
|
+
failed = p["name"] in r["bad"]
|
|
987
|
+
if p["type"] != "rug":
|
|
988
|
+
out.append(R(p["rect"], fill="#e9e4dc", stroke="#c0392b" if failed else "#4a4a4a",
|
|
989
|
+
stroke_width="2.2" if failed else "1.2"))
|
|
990
|
+
x, y, w, h = p["rect"]
|
|
991
|
+
f = frame(p)["front"]
|
|
992
|
+
a = {(0, -1): ((x, y), (x + w, y)), (0, 1): ((x, y + h), (x + w, y + h)),
|
|
993
|
+
(1, 0): ((x + w, y), (x + w, y + h)), (-1, 0): ((x, y), (x, y + h))}[f]
|
|
994
|
+
A, B = P(*a[0]), P(*a[1])
|
|
995
|
+
out.append(f'<line x1="{A[0]:.1f}" y1="{A[1]:.1f}" x2="{B[0]:.1f}" y2="{B[1]:.1f}" stroke="#1f1f1f" stroke-width="2.4"/>')
|
|
996
|
+
x, y, w, h = p["rect"]
|
|
997
|
+
X, Y = P(x + w / 2, y + h / 2)
|
|
998
|
+
pw, pd = p["size"]
|
|
999
|
+
if p["type"] == "rug":
|
|
1000
|
+
X, Y = P(x + 0.08, y + h - 0.12)
|
|
1001
|
+
out.append(f'<text x="{X:.1f}" y="{Y:.1f}" font-size="8" fill="#8a7a5c">{esc(p["name"])} '
|
|
1002
|
+
f'{esc(f"{disp(plan, pw):g}×{disp(plan, pd):g}")}</text>')
|
|
1003
|
+
continue
|
|
1004
|
+
size = f"{disp(plan, pw):g}×{disp(plan, pd):g}"
|
|
1005
|
+
fs = 11 if min(w, h) * s > 45 else 8
|
|
1006
|
+
out.append(f'<text x="{X:.1f}" y="{Y - 2:.1f}" text-anchor="middle" font-weight="bold" font-size="{fs}">{esc(p["name"])}</text>')
|
|
1007
|
+
out.append(f'<text x="{X:.1f}" y="{Y + fs:.1f}" text-anchor="middle" font-size="{fs - 1}" fill="#444">{esc(size)}</text>')
|
|
1008
|
+
out.append(f'<polygon points="{" ".join("%.1f,%.1f" % P(*p) for p in o)}" fill="none" stroke="#111" stroke-width="{wall:.1f}" stroke-linejoin="miter"/>')
|
|
1009
|
+
for w in plan["windows"]:
|
|
1010
|
+
out += opening(P, s, w["at"], w["width"], o, wall, None, None)
|
|
1011
|
+
for d, g in zip(plan["doors"], r["geo"]):
|
|
1012
|
+
out += opening(P, s, d["at"], d["width"], o, wall, d, g)
|
|
1013
|
+
for X, Y, name in labels:
|
|
1014
|
+
out.append(f'<text x="{X:.1f}" y="{Y:.1f}" text-anchor="middle" font-size="8" fill="#333">{esc(name)}</text>')
|
|
1015
|
+
bx, by = P(x0, y0)
|
|
1016
|
+
ex, _ = P(x1, y0)
|
|
1017
|
+
out += dim_line(bx, by + 30, ex, by + 30, f"{disp(plan, x1 - x0):g} {u}")
|
|
1018
|
+
_, ty = P(x0, y1)
|
|
1019
|
+
out += dim_line(bx - 30, by, bx - 30, ty, f"{disp(plan, y1 - y0):g} {u}", vertical=True)
|
|
1020
|
+
out += north(W - 40, 40, plan["north"]) + scale(m, H - 18, s, max(x1 - x0, y1 - y0), plan)
|
|
1021
|
+
out.append("</svg>")
|
|
1022
|
+
return "\n".join(out) + "\n"
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def opening(P, s, at, width, outline, wall, door, g):
|
|
1026
|
+
seg = wall_at(at, outline)
|
|
1027
|
+
vertical = bool(seg) and abs(seg[0][0] - seg[1][0]) < EPS
|
|
1028
|
+
x, y = at
|
|
1029
|
+
half = width / 2
|
|
1030
|
+
a = P(x, y - half) if vertical else P(x - half, y)
|
|
1031
|
+
b = P(x, y + half) if vertical else P(x + half, y)
|
|
1032
|
+
out = [f'<line x1="{a[0]:.1f}" y1="{a[1]:.1f}" x2="{b[0]:.1f}" y2="{b[1]:.1f}" stroke="#fff" stroke-width="{wall + 1:.1f}"/>']
|
|
1033
|
+
if door is None:
|
|
1034
|
+
for off in (-wall / 3, 0, wall / 3):
|
|
1035
|
+
dx, dy = (off, 0) if vertical else (0, off)
|
|
1036
|
+
out.append(f'<line x1="{a[0] + dx:.1f}" y1="{a[1] + dy:.1f}" x2="{b[0] + dx:.1f}" y2="{b[1] + dy:.1f}" stroke="#2b6cb0" stroke-width="1"/>')
|
|
1037
|
+
return out
|
|
1038
|
+
if not g or door["swing"] == "sliding":
|
|
1039
|
+
out.append(f'<line x1="{a[0]:.1f}" y1="{a[1]:.1f}" x2="{b[0]:.1f}" y2="{b[1]:.1f}" stroke="#b5531a" stroke-width="2" stroke-dasharray="4 2"/>')
|
|
1040
|
+
return out
|
|
1041
|
+
n = g["n"] if door["swing"] == "in" else (-g["n"][0], -g["n"][1])
|
|
1042
|
+
hx, hy = g["hinge"]
|
|
1043
|
+
leaf = (hx + n[0] * width, hy + n[1] * width)
|
|
1044
|
+
H0, L0, T0 = P(hx, hy), P(*leaf), P(*g["latch"])
|
|
1045
|
+
# sweep from the open leaf to the latch; the screen flips y, so pick the flag from the cross product
|
|
1046
|
+
cross = (leaf[0] - hx) * (g["latch"][1] - hy) - (leaf[1] - hy) * (g["latch"][0] - hx)
|
|
1047
|
+
sweep = 0 if cross > 0 else 1
|
|
1048
|
+
out.append(f'<line x1="{H0[0]:.1f}" y1="{H0[1]:.1f}" x2="{L0[0]:.1f}" y2="{L0[1]:.1f}" stroke="#b5531a" stroke-width="1.6"/>')
|
|
1049
|
+
out.append(f'<path d="M {L0[0]:.1f} {L0[1]:.1f} A {width * s:.1f} {width * s:.1f} 0 0 {sweep} {T0[0]:.1f} {T0[1]:.1f}" '
|
|
1050
|
+
'fill="none" stroke="#b5531a" stroke-width="0.8" stroke-dasharray="3 2"/>')
|
|
1051
|
+
if door["entrance"]:
|
|
1052
|
+
mx, my = P(at[0] - g["n"][0] * 0.35, at[1] - g["n"][1] * 0.35)
|
|
1053
|
+
out.append(f'<text x="{mx:.1f}" y="{my + 3:.1f}" text-anchor="middle" font-size="9" fill="#b5531a">{esc(door["name"])}</text>')
|
|
1054
|
+
return out
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def dim_line(x1, y1, x2, y2, label, vertical=False):
|
|
1058
|
+
out = [f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="#555" stroke-width="0.8"/>']
|
|
1059
|
+
for x, y in ((x1, y1), (x2, y2)):
|
|
1060
|
+
out.append(f'<line x1="{x - 4:.1f}" y1="{y + 4:.1f}" x2="{x + 4:.1f}" y2="{y - 4:.1f}" stroke="#555" stroke-width="1.2"/>')
|
|
1061
|
+
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
|
|
1062
|
+
if vertical:
|
|
1063
|
+
out.append(f'<text x="{mx - 6:.1f}" y="{my:.1f}" text-anchor="middle" transform="rotate(-90 {mx - 6:.1f} {my:.1f})">{esc(label)}</text>')
|
|
1064
|
+
else:
|
|
1065
|
+
out.append(f'<text x="{mx:.1f}" y="{my - 5:.1f}" text-anchor="middle">{esc(label)}</text>')
|
|
1066
|
+
return out
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def north(x, y, deg):
|
|
1070
|
+
return [f'<g transform="translate({x:.0f} {y:.0f}) rotate({deg:.1f})"><polygon points="0,-20 7,9 0,4 -7,9" fill="#111"/>'
|
|
1071
|
+
'<text x="0" y="-25" text-anchor="middle" font-weight="bold">N</text></g>']
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def scale(x, y, s, span, plan):
|
|
1075
|
+
u, k = plan["units"], plan["k"]
|
|
1076
|
+
span_u = span / k
|
|
1077
|
+
step = 1.0
|
|
1078
|
+
for mlt in (1, 2, 5):
|
|
1079
|
+
for e in range(-1, 4):
|
|
1080
|
+
v = mlt * 10 ** e
|
|
1081
|
+
if step < v <= span_u / 4:
|
|
1082
|
+
step = v
|
|
1083
|
+
w = step * k * s
|
|
1084
|
+
return [f'<rect x="{x}" y="{y - 6:.1f}" width="{w / 2:.1f}" height="6" fill="#111"/>',
|
|
1085
|
+
f'<rect x="{x + w / 2:.1f}" y="{y - 6:.1f}" width="{w / 2:.1f}" height="6" fill="#fff" stroke="#111"/>',
|
|
1086
|
+
f'<text x="{x + w + 6:.1f}" y="{y:.1f}">{step:g} {u}</text>']
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def main(argv=None):
|
|
1090
|
+
ap = argparse.ArgumentParser(description="Check a room's furniture layout and draw it.")
|
|
1091
|
+
ap.add_argument("layout")
|
|
1092
|
+
ap.add_argument("--svg", help="write the dimensioned plan here")
|
|
1093
|
+
ap.add_argument("--json", action="store_true", help="print the result as JSON instead of a table")
|
|
1094
|
+
ap.add_argument("--max-seconds", type=float, default=20.0,
|
|
1095
|
+
help="stop the route and turning checks after this long, and say they are incomplete (default 20)")
|
|
1096
|
+
args = ap.parse_args(argv)
|
|
1097
|
+
global DEADLINE
|
|
1098
|
+
if args.max_seconds <= 0:
|
|
1099
|
+
print("FAIL --max-seconds must be above zero")
|
|
1100
|
+
return 2
|
|
1101
|
+
try:
|
|
1102
|
+
with open(args.layout, encoding="utf-8-sig") as f:
|
|
1103
|
+
plan = load(json.load(f))
|
|
1104
|
+
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
1105
|
+
print(f"FAIL cannot read {args.layout}: {e}")
|
|
1106
|
+
return 2
|
|
1107
|
+
except InputError as e:
|
|
1108
|
+
print(f"FAIL {e}")
|
|
1109
|
+
return 2
|
|
1110
|
+
DEADLINE = time.monotonic() + args.max_seconds
|
|
1111
|
+
try:
|
|
1112
|
+
r = check(plan)
|
|
1113
|
+
finally:
|
|
1114
|
+
DEADLINE = None
|
|
1115
|
+
if args.json:
|
|
1116
|
+
print(json.dumps(as_json(plan, r), indent=2, ensure_ascii=False))
|
|
1117
|
+
else:
|
|
1118
|
+
print(report(plan, r), end="")
|
|
1119
|
+
if args.svg:
|
|
1120
|
+
with open(args.svg, "w", encoding="utf-8", newline="\n") as f:
|
|
1121
|
+
f.write(svg(plan, r))
|
|
1122
|
+
if not args.json:
|
|
1123
|
+
print(f"\nWrote {args.svg}")
|
|
1124
|
+
if r["incomplete"]:
|
|
1125
|
+
return 2
|
|
1126
|
+
return 1 if r["fails"] else 0
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
if __name__ == "__main__":
|
|
1130
|
+
sys.exit(main())
|