skilldrop-cli 1.6.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.
Files changed (152) hide show
  1. package/README.md +3 -0
  2. package/catalogue.json +25 -2
  3. package/model-routing.json +20 -0
  4. package/package.json +1 -1
  5. package/packs/event-planner/pack.json +61 -0
  6. package/packs/event-planner/skills/run-of-show/SKILL.md +144 -0
  7. package/packs/event-planner/skills/run-of-show/evals/eval_queries.json +30 -0
  8. package/packs/event-planner/skills/run-of-show/evals/evals.json +103 -0
  9. package/packs/event-planner/skills/run-of-show/evals/files/1/agenda.md +16 -0
  10. package/packs/event-planner/skills/run-of-show/evals/files/2/schedule.csv +8 -0
  11. package/packs/event-planner/skills/run-of-show/evals/files/3/notes.txt +3 -0
  12. package/packs/event-planner/skills/run-of-show/evals/files/4/sessions_export.csv +25 -0
  13. package/packs/event-planner/skills/run-of-show/evals/files/4/venue_and_crew.txt +27 -0
  14. package/packs/event-planner/skills/run-of-show/evals/files/5/runsheet_v4.csv +28 -0
  15. package/packs/event-planner/skills/run-of-show/evals/files/5/venue_and_suppliers.txt +22 -0
  16. package/packs/event-planner/skills/run-of-show/evals/files/6/acts_from_booker.csv +26 -0
  17. package/packs/event-planner/skills/run-of-show/evals/files/6/venue_rooms_and_crew.txt +14 -0
  18. package/packs/event-planner/skills/run-of-show/evals/files/7/runsheet_draft_v4.csv +25 -0
  19. package/packs/event-planner/skills/run-of-show/evals/files/7/venue_and_fixed_times.txt +24 -0
  20. package/packs/event-planner/skills/run-of-show/examples/product-launch.md +77 -0
  21. package/packs/event-planner/skills/run-of-show/examples/product-launch.svg +58 -0
  22. package/packs/event-planner/skills/run-of-show/manifest.json +44 -0
  23. package/packs/event-planner/skills/run-of-show/scripts/show_check.py +295 -0
  24. package/packs/event-planner/skills/run-of-show/templates/show.json +15 -0
  25. package/packs/event-planner/skills/seating-plan/SKILL.md +155 -0
  26. package/packs/event-planner/skills/seating-plan/evals/eval_queries.json +30 -0
  27. package/packs/event-planner/skills/seating-plan/evals/evals.json +164 -0
  28. package/packs/event-planner/skills/seating-plan/evals/files/1/guests.csv +31 -0
  29. package/packs/event-planner/skills/seating-plan/evals/files/10/hall_notes.txt +13 -0
  30. package/packs/event-planner/skills/seating-plan/evals/files/10/quiz_night_registrations.csv +67 -0
  31. package/packs/event-planner/skills/seating-plan/evals/files/11/notes_for_the_plan.txt +21 -0
  32. package/packs/event-planner/skills/seating-plan/evals/files/11/rsvp_list.csv +77 -0
  33. package/packs/event-planner/skills/seating-plan/evals/files/11/table_plan_draft.csv +78 -0
  34. package/packs/event-planner/skills/seating-plan/evals/files/2/table-plan.txt +8 -0
  35. package/packs/event-planner/skills/seating-plan/evals/files/3/brief.txt +4 -0
  36. package/packs/event-planner/skills/seating-plan/evals/files/3/guests.csv +49 -0
  37. package/packs/event-planner/skills/seating-plan/evals/files/4/rsvp_export.csv +127 -0
  38. package/packs/event-planner/skills/seating-plan/evals/files/4/seating_notes.txt +21 -0
  39. package/packs/event-planner/skills/seating-plan/evals/files/5/organiser_notes.txt +10 -0
  40. package/packs/event-planner/skills/seating-plan/evals/files/5/registrations.csv +93 -0
  41. package/packs/event-planner/skills/seating-plan/evals/files/5/table_plan_v3.csv +94 -0
  42. package/packs/event-planner/skills/seating-plan/evals/files/6/bridgets_notes.txt +30 -0
  43. package/packs/event-planner/skills/seating-plan/evals/files/6/ticket_export.csv +117 -0
  44. package/packs/event-planner/skills/seating-plan/evals/files/6/venue_floorplan_notes.txt +14 -0
  45. package/packs/event-planner/skills/seating-plan/evals/files/7/keep_apart_CONFIDENTIAL.txt +9 -0
  46. package/packs/event-planner/skills/seating-plan/evals/files/7/prom_seating_v3.csv +101 -0
  47. package/packs/event-planner/skills/seating-plan/evals/files/7/ticket_list.csv +101 -0
  48. package/packs/event-planner/skills/seating-plan/evals/files/7/venue_info.txt +16 -0
  49. package/packs/event-planner/skills/seating-plan/evals/files/8/registrations_export.csv +147 -0
  50. package/packs/event-planner/skills/seating-plan/evals/files/8/room_layout.md +22 -0
  51. package/packs/event-planner/skills/seating-plan/evals/files/8/seating_requests.txt +31 -0
  52. package/packs/event-planner/skills/seating-plan/evals/files/9/guest_list.csv +84 -0
  53. package/packs/event-planner/skills/seating-plan/evals/files/9/notes.txt +16 -0
  54. package/packs/event-planner/skills/seating-plan/evals/files/9/table_plan.csv +85 -0
  55. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.md +61 -0
  56. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.svg +39 -0
  57. package/packs/event-planner/skills/seating-plan/manifest.json +44 -0
  58. package/packs/event-planner/skills/seating-plan/scripts/seating_check.py +324 -0
  59. package/packs/event-planner/skills/seating-plan/templates/seating.json +28 -0
  60. package/packs/experience-design/pack.json +5 -0
  61. package/packs/experience-design/skills/locale-check/SKILL.md +139 -0
  62. package/packs/experience-design/skills/locale-check/evals/eval_queries.json +38 -0
  63. package/packs/experience-design/skills/locale-check/evals/evals.json +104 -0
  64. package/packs/experience-design/skills/locale-check/evals/files/1/budget.json +1 -0
  65. package/packs/experience-design/skills/locale-check/evals/files/1/locales/ar.json +16 -0
  66. package/packs/experience-design/skills/locale-check/evals/files/1/locales/de.json +12 -0
  67. package/packs/experience-design/skills/locale-check/evals/files/1/locales/en.json +12 -0
  68. package/packs/experience-design/skills/locale-check/evals/files/1/locales/pl.json +12 -0
  69. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values/strings.xml +12 -0
  70. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-es/strings.xml +11 -0
  71. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-fr/strings.xml +12 -0
  72. package/packs/experience-design/skills/locale-check/evals/files/3/po/ja.po +20 -0
  73. package/packs/experience-design/skills/locale-check/evals/files/3/po/messages.pot +23 -0
  74. package/packs/experience-design/skills/locale-check/evals/files/3/po/ru.po +22 -0
  75. package/packs/experience-design/skills/locale-check/evals/files/4/budgets.json +15 -0
  76. package/packs/experience-design/skills/locale-check/evals/files/4/locales/ar.json +265 -0
  77. package/packs/experience-design/skills/locale-check/evals/files/4/locales/de.json +265 -0
  78. package/packs/experience-design/skills/locale-check/evals/files/4/locales/en.json +265 -0
  79. package/packs/experience-design/skills/locale-check/evals/files/4/locales/fr.json +265 -0
  80. package/packs/experience-design/skills/locale-check/evals/files/4/locales/pl.json +265 -0
  81. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values/strings.xml +77 -0
  82. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-es/strings.xml +63 -0
  83. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-ja/strings.xml +61 -0
  84. package/packs/experience-design/skills/locale-check/evals/files/6/po/cs.po +605 -0
  85. package/packs/experience-design/skills/locale-check/evals/files/6/po/marlin.pot +602 -0
  86. package/packs/experience-design/skills/locale-check/evals/files/6/po/pt_BR.po +602 -0
  87. package/packs/experience-design/skills/locale-check/evals/files/6/po/ru.po +608 -0
  88. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.strings +160 -0
  89. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.stringsdict +70 -0
  90. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.strings +157 -0
  91. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.stringsdict +86 -0
  92. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.strings +160 -0
  93. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.stringsdict +68 -0
  94. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.strings +160 -0
  95. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.stringsdict +62 -0
  96. package/packs/experience-design/skills/locale-check/examples/settings-release.md +107 -0
  97. package/packs/experience-design/skills/locale-check/manifest.json +45 -0
  98. package/packs/experience-design/skills/locale-check/scripts/locale_check.py +1105 -0
  99. package/packs/interior-designer/pack.json +60 -0
  100. package/packs/interior-designer/skills/furniture-layout/SKILL.md +182 -0
  101. package/packs/interior-designer/skills/furniture-layout/evals/eval_queries.json +11 -0
  102. package/packs/interior-designer/skills/furniture-layout/evals/evals.json +133 -0
  103. package/packs/interior-designer/skills/furniture-layout/evals/files/2/living-room-notes.md +24 -0
  104. package/packs/interior-designer/skills/furniture-layout/evals/files/3/office-brief.md +18 -0
  105. package/packs/interior-designer/skills/furniture-layout/evals/files/4/furniture_schedule.csv +15 -0
  106. package/packs/interior-designer/skills/furniture-layout/evals/files/4/site_survey.txt +22 -0
  107. package/packs/interior-designer/skills/furniture-layout/evals/files/5/layout_v2.csv +14 -0
  108. package/packs/interior-designer/skills/furniture-layout/evals/files/5/measurements.txt +16 -0
  109. package/packs/interior-designer/skills/furniture-layout/evals/files/6/access_consultant_email.txt +19 -0
  110. package/packs/interior-designer/skills/furniture-layout/evals/files/6/furniture_stock.csv +5 -0
  111. package/packs/interior-designer/skills/furniture-layout/evals/files/6/site_measurements.txt +14 -0
  112. package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_214_layout.csv +11 -0
  113. package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_and_brand_standard.txt +20 -0
  114. package/packs/interior-designer/skills/furniture-layout/evals/files/8/email_from_practice_manager.txt +44 -0
  115. package/packs/interior-designer/skills/furniture-layout/evals/files/8/furniture_we_are_keeping.csv +10 -0
  116. package/packs/interior-designer/skills/furniture-layout/evals/files/9/reading_room_schedule.csv +15 -0
  117. package/packs/interior-designer/skills/furniture-layout/evals/files/9/room_notes.txt +23 -0
  118. package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.md +121 -0
  119. package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.svg +57 -0
  120. package/packs/interior-designer/skills/furniture-layout/manifest.json +44 -0
  121. package/packs/interior-designer/skills/furniture-layout/scripts/layout_check.py +1130 -0
  122. package/packs/interior-designer/skills/furniture-layout/templates/layout.json +26 -0
  123. package/packs/operations-manager/pack.json +60 -0
  124. package/packs/operations-manager/skills/shift-roster/SKILL.md +169 -0
  125. package/packs/operations-manager/skills/shift-roster/evals/eval_queries.json +38 -0
  126. package/packs/operations-manager/skills/shift-roster/evals/evals.json +135 -0
  127. package/packs/operations-manager/skills/shift-roster/evals/files/1/shifts.csv +3 -0
  128. package/packs/operations-manager/skills/shift-roster/evals/files/1/staff.csv +8 -0
  129. package/packs/operations-manager/skills/shift-roster/evals/files/2/rota.csv +22 -0
  130. package/packs/operations-manager/skills/shift-roster/evals/files/2/staff.csv +6 -0
  131. package/packs/operations-manager/skills/shift-roster/evals/files/3/team.md +14 -0
  132. package/packs/operations-manager/skills/shift-roster/evals/files/4/availability_export_wk47.csv +23 -0
  133. package/packs/operations-manager/skills/shift-roster/evals/files/4/rota_notes.txt +20 -0
  134. package/packs/operations-manager/skills/shift-roster/evals/files/5/rota_draft_16-29nov.csv +107 -0
  135. package/packs/operations-manager/skills/shift-roster/evals/files/5/staff_list.csv +17 -0
  136. package/packs/operations-manager/skills/shift-roster/evals/files/6/availability_notes.txt +24 -0
  137. package/packs/operations-manager/skills/shift-roster/evals/files/6/peoplehub_staff_export.csv +14 -0
  138. package/packs/operations-manager/skills/shift-roster/evals/files/6/rota_template.csv +25 -0
  139. package/packs/operations-manager/skills/shift-roster/evals/files/7/leave_and_availability.txt +18 -0
  140. package/packs/operations-manager/skills/shift-roster/evals/files/7/rota_wc_30nov.csv +47 -0
  141. package/packs/operations-manager/skills/shift-roster/evals/files/7/staff_list.csv +15 -0
  142. package/packs/operations-manager/skills/shift-roster/evals/files/8/availability_form_responses.csv +22 -0
  143. package/packs/operations-manager/skills/shift-roster/evals/files/8/cover_requirements.csv +15 -0
  144. package/packs/operations-manager/skills/shift-roster/evals/files/8/staff_export.csv +29 -0
  145. package/packs/operations-manager/skills/shift-roster/evals/files/9/leave_and_restrictions.txt +16 -0
  146. package/packs/operations-manager/skills/shift-roster/evals/files/9/rota_export_07-13Dec.csv +85 -0
  147. package/packs/operations-manager/skills/shift-roster/evals/files/9/staff_list.csv +27 -0
  148. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.md +72 -0
  149. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.svg +123 -0
  150. package/packs/operations-manager/skills/shift-roster/manifest.json +34 -0
  151. package/packs/operations-manager/skills/shift-roster/scripts/roster_check.py +633 -0
  152. package/packs/operations-manager/skills/shift-roster/templates/roster.json +22 -0
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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())