"""Shapely geometry: usable floor polygon, blocked areas, placement footprint. This is load-bearing (spec section 5, stage 3): it decides WHERE furniture goes and, with scaling.py, HOW BIG it is. A fixed-size sprite dropped in the middle of the image is an explicit failure of the spec — placement here is fitted to the empty floor and perspective. """ from __future__ import annotations from dataclasses import dataclass from shapely.geometry import Polygon, box from shapely.ops import unary_union from . import scaling from .detection import Detection from .floor_plane import Perspective @dataclass class SpaceAnalysis: floor: Polygon usable: Polygon blocked: list[Polygon] free_ratio: float def _largest_polygon(geom): if geom.is_empty or geom.geom_type == "Polygon": return geom polys = [g for g in getattr(geom, "geoms", []) if g.geom_type == "Polygon"] return max(polys, key=lambda p: p.area) if polys else geom def compute_spaces( floor_polygon: list[list[float]], detections: list[Detection], persp: Perspective ) -> SpaceAnalysis: """Subtract detected object footprints from the floor to get usable space.""" floor = Polygon(floor_polygon) if not floor.is_valid: floor = floor.buffer(0) blocked: list[Polygon] = [] for det in detections: x1, y1, x2, y2 = det.box # Floor contact ≈ lower ~45% of the bounding box. foot = box(x1, y1 + (y2 - y1) * 0.55, x2, y2) inter = floor.intersection(foot) if not inter.is_empty and inter.area > 0: blocked.append(_largest_polygon(inter)) blocked_union = unary_union(blocked) if blocked else None usable = floor.difference(blocked_union) if blocked_union is not None else floor usable = _largest_polygon(usable) free_ratio = (usable.area / floor.area) if floor.area > 0 else 0.0 return SpaceAnalysis(floor=floor, usable=usable, blocked=blocked, free_ratio=free_ratio) def select_placement( usable: Polygon, persp: Perspective, width_cm: float, depth_cm: float ) -> tuple[tuple[float, float], list[list[float]], bool]: """Find an anchor + perspective footprint that fits inside the usable floor. Scans anchors from near (image bottom) to mid-depth, centered then offset. Returns (anchor_xy, footprint_quad, fitted). If nothing fits cleanly, returns the best-overlap candidate so the caller always has a placement to mask. """ minx, _miny, maxx, maxy = usable.bounds H = persp.height cx = (minx + maxx) / 2.0 span = max(1.0, maxx - minx) best: tuple[float, tuple[float, float], list[list[float]]] | None = None for ry in (0.95, 0.90, 0.85, 0.80, 0.72, 0.64, 0.56): y_a = H * ry if y_a <= persp.horizon_y + 5 or y_a > maxy + 5: continue for dx in (0.0, -0.12, 0.12, -0.24, 0.24): x_a = cx + dx * span quad = scaling.footprint_quad(persp, (x_a, y_a), width_cm, depth_cm) poly = Polygon(quad) if not poly.is_valid or poly.area <= 0: continue if usable.contains(poly): return (x_a, y_a), quad, True ratio = usable.intersection(poly).area / poly.area if best is None or ratio > best[0]: best = (ratio, (x_a, y_a), quad) if best is None: y_a = min(maxy, H * 0.9) quad = scaling.footprint_quad(persp, (cx, y_a), width_cm, depth_cm) return (cx, y_a), quad, False return best[1], best[2], False