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