rikhoffbauer2 commited on
Commit
75c5ed1
·
verified ·
1 Parent(s): 8ec6c72

Upload floorplan/schema.py

Browse files
Files changed (1) hide show
  1. floorplan/schema.py +257 -0
floorplan/schema.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Floor plan schema — Pydantic models for the structured representation.
3
+
4
+ Design principles:
5
+ - Walls are first-class entities with thickness
6
+ - Openings (doors/windows) live ON walls, defined by start position + length along centerline
7
+ - Rooms are topological — defined by ordered wall-face references, not duplicate coordinates
8
+ - Curved walls are polyline-approximated centerlines (many sample points)
9
+ - Everything in meters after scale is applied
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from enum import Enum
15
+ from typing import Optional
16
+ from pydantic import BaseModel, Field, model_validator
17
+
18
+
19
+ class Point2D(BaseModel):
20
+ """A 2D point in floor plan coordinates (meters from origin)."""
21
+ x: float
22
+ y: float
23
+
24
+ def to_tuple(self) -> tuple[float, float]:
25
+ return (self.x, self.y)
26
+
27
+ @classmethod
28
+ def from_tuple(cls, t: tuple[float, float]) -> Point2D:
29
+ return cls(x=t[0], y=t[1])
30
+
31
+
32
+ class OpeningType(str, Enum):
33
+ DOOR = "door"
34
+ WINDOW = "window"
35
+
36
+
37
+ class DoorSwing(str, Enum):
38
+ INWARD_LEFT = "inward_left"
39
+ INWARD_RIGHT = "inward_right"
40
+ OUTWARD_LEFT = "outward_left"
41
+ OUTWARD_RIGHT = "outward_right"
42
+ SLIDING = "sliding"
43
+ DOUBLE = "double"
44
+ UNKNOWN = "unknown"
45
+
46
+
47
+ class Opening(BaseModel):
48
+ """A door or window on a wall, defined by position along the wall centerline."""
49
+ id: str
50
+ type: OpeningType
51
+ start: float = Field(
52
+ ...,
53
+ description="Distance in meters along the wall centerline from the first point to the start of the opening"
54
+ )
55
+ length: float = Field(
56
+ ...,
57
+ gt=0,
58
+ description="Length of the opening in meters along the wall centerline"
59
+ )
60
+ # Optional properties
61
+ swing: Optional[DoorSwing] = None
62
+ sill_height: Optional[float] = Field(
63
+ None,
64
+ description="Window sill height from floor in meters (typically 0.9-1.1m for windows, 0 for doors)"
65
+ )
66
+ head_height: Optional[float] = Field(
67
+ None,
68
+ description="Top of opening from floor in meters (typically 2.1m for doors, 2.0-2.4m for windows)"
69
+ )
70
+
71
+
72
+ class WallSide(str, Enum):
73
+ """Which face of a wall forms part of a room boundary.
74
+
75
+ Left/right determined by walking along the centerline from first to last point:
76
+ - LEFT = the face to your left
77
+ - RIGHT = the face to your right
78
+ """
79
+ LEFT = "left"
80
+ RIGHT = "right"
81
+
82
+
83
+ class Wall(BaseModel):
84
+ """A wall defined by a centerline polyline + thickness.
85
+
86
+ Straight walls have exactly 2 centerline points.
87
+ Curved/angled walls have 3+ points (polyline approximation).
88
+ The thick wall polygon is computed by buffering the centerline by thickness/2.
89
+ """
90
+ id: str
91
+ centerline: list[Point2D] = Field(
92
+ ...,
93
+ min_length=2,
94
+ description="Ordered points defining the wall centerline. 2 points = straight wall, 3+ = curved/angled"
95
+ )
96
+ thickness: float = Field(
97
+ ...,
98
+ gt=0,
99
+ description="Wall thickness in meters (exterior typically 0.20-0.30, interior 0.10-0.15)"
100
+ )
101
+ openings: list[Opening] = Field(default_factory=list)
102
+
103
+ @property
104
+ def centerline_coords(self) -> list[tuple[float, float]]:
105
+ """Centerline as list of (x, y) tuples for Shapely."""
106
+ return [p.to_tuple() for p in self.centerline]
107
+
108
+ @property
109
+ def is_straight(self) -> bool:
110
+ return len(self.centerline) == 2
111
+
112
+ @model_validator(mode="after")
113
+ def validate_openings_within_wall(self) -> Wall:
114
+ """Ensure all openings fit within the wall length."""
115
+ from .geometry import compute_centerline_length
116
+ wall_length = compute_centerline_length(self.centerline_coords)
117
+ for opening in self.openings:
118
+ end = opening.start + opening.length
119
+ if opening.start < -0.001:
120
+ raise ValueError(
121
+ f"Opening {opening.id} starts before wall start "
122
+ f"(start={opening.start})"
123
+ )
124
+ if end > wall_length + 0.001:
125
+ raise ValueError(
126
+ f"Opening {opening.id} extends beyond wall end "
127
+ f"(end={end:.3f} > wall_length={wall_length:.3f})"
128
+ )
129
+ return self
130
+
131
+
132
+ class WallFaceRef(BaseModel):
133
+ """Reference to one face (side) of a wall, used to define room boundaries."""
134
+ wall_id: str
135
+ side: WallSide
136
+
137
+
138
+ class RoomLabel(str, Enum):
139
+ BEDROOM = "bedroom"
140
+ BATHROOM = "bathroom"
141
+ KITCHEN = "kitchen"
142
+ LIVING_ROOM = "living_room"
143
+ DINING_ROOM = "dining_room"
144
+ HALLWAY = "hallway"
145
+ CLOSET = "closet"
146
+ LAUNDRY = "laundry"
147
+ GARAGE = "garage"
148
+ BALCONY = "balcony"
149
+ OFFICE = "office"
150
+ STORAGE = "storage"
151
+ ENTRANCE = "entrance"
152
+ OTHER = "other"
153
+ UNKNOWN = "unknown"
154
+
155
+
156
+ class Room(BaseModel):
157
+ """A room defined topologically by ordered wall-face references forming a closed boundary."""
158
+ id: str
159
+ label: Optional[RoomLabel] = None
160
+ boundary: list[WallFaceRef] = Field(
161
+ ...,
162
+ min_length=2,
163
+ description="Ordered wall-face references forming the room boundary (must form a closed loop). "
164
+ "Minimum 2 walls (e.g., a curved wall + closing straight wall)."
165
+ )
166
+ area: Optional[float] = Field(
167
+ None,
168
+ description="Room area in square meters (computed from geometry)"
169
+ )
170
+
171
+
172
+ class FloorPlan(BaseModel):
173
+ """Complete floor plan representation."""
174
+ scale: Optional[float] = Field(
175
+ None,
176
+ description="Meters per pixel/unit — used to convert from image coordinates to real-world meters. "
177
+ "None if coordinates are already in meters."
178
+ )
179
+ origin: Point2D = Field(
180
+ default_factory=lambda: Point2D(x=0, y=0),
181
+ description="Coordinate system origin in the source image"
182
+ )
183
+ walls: list[Wall] = Field(default_factory=list)
184
+ rooms: list[Room] = Field(default_factory=list)
185
+
186
+ def get_wall(self, wall_id: str) -> Wall | None:
187
+ """Look up a wall by ID."""
188
+ for w in self.walls:
189
+ if w.id == wall_id:
190
+ return w
191
+ return None
192
+
193
+ def get_room(self, room_id: str) -> Room | None:
194
+ """Look up a room by ID."""
195
+ for r in self.rooms:
196
+ if r.id == room_id:
197
+ return r
198
+ return None
199
+
200
+ @property
201
+ def wall_ids(self) -> set[str]:
202
+ return {w.id for w in self.walls}
203
+
204
+ @model_validator(mode="after")
205
+ def validate_room_wall_refs(self) -> FloorPlan:
206
+ """Ensure all wall references in rooms point to existing walls."""
207
+ wall_ids = self.wall_ids
208
+ for room in self.rooms:
209
+ for face_ref in room.boundary:
210
+ if face_ref.wall_id not in wall_ids:
211
+ raise ValueError(
212
+ f"Room {room.id} references non-existent wall {face_ref.wall_id}"
213
+ )
214
+ return self
215
+
216
+
217
+ class CorrectionAction(str, Enum):
218
+ MODIFY = "modify"
219
+ ADD = "add"
220
+ DELETE = "delete"
221
+
222
+
223
+ class Correction(BaseModel):
224
+ """A single field-level correction to the floor plan schema."""
225
+ action: CorrectionAction
226
+ target: Optional[str] = Field(
227
+ None,
228
+ description="Target element ID (e.g., 'w3' or 'w1.openings.d1'). Required for modify/delete."
229
+ )
230
+ field: Optional[str] = Field(
231
+ None,
232
+ description="Field to modify (e.g., 'centerline', 'thickness', 'start'). Required for modify."
233
+ )
234
+ value: Optional[dict | list | float | str] = Field(
235
+ None,
236
+ description="New value for modify, or full element spec for add."
237
+ )
238
+ reason: str = Field(
239
+ ...,
240
+ description="Human-readable explanation of why this correction is needed"
241
+ )
242
+
243
+
244
+ class CorrectionResult(BaseModel):
245
+ """Result of a VLM comparison between rendered overlay and original image."""
246
+ iteration: int
247
+ score: float = Field(
248
+ ...,
249
+ ge=0,
250
+ le=1,
251
+ description="Overall alignment confidence (0=terrible, 1=perfect)"
252
+ )
253
+ converged: bool = Field(
254
+ False,
255
+ description="Whether the schema is good enough to stop iterating"
256
+ )
257
+ corrections: list[Correction] = Field(default_factory=list)