From 2255271c406ed23e579fa2a1dc95528f0f5cefe7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 28 Apr 2026 14:55:55 +0000 Subject: [PATCH 01/21] magicplan xml dataclasses --- backend/magic_plan/magic_plan_client.py | 0 backend/magic_plan/models.py | 188 +++++ backend/magic_plan/xml_example.xml | 985 ++++++++++++++++++++++++ backend/magic_plan/xml_example_2.xml | 1 + backend/magic_plan/xml_example_3.xml | 1 + backend/magic_plan/xml_schema.py | 48 ++ 6 files changed, 1223 insertions(+) create mode 100644 backend/magic_plan/magic_plan_client.py create mode 100644 backend/magic_plan/models.py create mode 100644 backend/magic_plan/xml_example.xml create mode 100644 backend/magic_plan/xml_example_2.xml create mode 100644 backend/magic_plan/xml_example_3.xml create mode 100644 backend/magic_plan/xml_schema.py diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/models.py b/backend/magic_plan/models.py new file mode 100644 index 00000000..46cf0fef --- /dev/null +++ b/backend/magic_plan/models.py @@ -0,0 +1,188 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class MagicPlanRoomPoint: + snapped_x: float + snapped_y: float + height: float + uid: str + values: dict[str, str] # e.g. loadBearingWall, addElevationToReport + + +@dataclass +class MagicPlanWallPoint: + """Point in — absolute coords, no uid or values.""" + x: float + y: float + height: float + + +@dataclass +class MagicPlanDoor: + """Door in context — wall-relative position.""" + wall_point_index: int + type: str + u: float # position along wall (0-1) + width: float # m + depth: float # m + height: float # m + orientation: int + snapped_type: str + snapped_position: float + snapped_width: float # m + snapped_depth: float # m + snapped_height: float # m + snapped_orientation: int + inset_x: float + inset_y: float + inset_z: float + symbol_instance: str + twin_wall_item_uid: Optional[str] = None + + +@dataclass +class MagicPlanWindow: + """Window in context — wall-relative position.""" + wall_point_index: int + type: str + u: float # position along wall (0-1) + width: float # m + depth: float # m + height: float # m + orientation: int + snapped_type: str + snapped_position: float + snapped_width: float # m + snapped_depth: float # m + snapped_height: float # m + snapped_orientation: int + inset_x: float + inset_y: float + inset_z: float + symbol_instance: str + + +@dataclass +class MagicPlanExplodedOpening: + """Door or window in context — absolute coords, no snapped* fields.""" + type: str + x1: float + y1: float + x2: float + y2: float + width: float # m + depth: float # m + height: float # m + inset_x: float + inset_y: float + orientation: int + symbol_instance: str + + +@dataclass +class MagicPlanFurniture: + type: str + x: float + y: float + snapped_x: float + snapped_y: float + angle: float + width: float # m + depth: float # m + height: float # m + snapped_width: float # m + snapped_depth: float # m + snapped_height: float # m + size_lock_0: str + size_lock_1: str + size_lock_2: str + symbol_instance: str + + +@dataclass +class MagicPlanMainDimension: + from_point: int + to_point: int + dir_x: float + dir_y: float + value: float + actual_value: float + is_set: bool + + +@dataclass +class MagicPlanExplodedWall: + wall_type: str # "exterior" | "interior" + points: list[MagicPlanWallPoint] + + +@dataclass +class MagicPlanExploded: + walls: list[MagicPlanExplodedWall] + doors: list[MagicPlanExplodedOpening] + windows: list[MagicPlanExplodedOpening] + furniture: list[MagicPlanFurniture] + + +@dataclass +class MagicPlanSymbolInstance: + id: str + uid: str + parent_uid: str + symbol: str + values: dict[str, str] # ceilingHeight, width, height, depth, distanceUnit, etc. + + +@dataclass +class MagicPlanRoom: + uid: str + type: str + x: float + y: float + rotation: float + was_modified: bool + linked_room_0: str + linked_room_1: str + area: float # m² + perimeter: float # m + values: dict[str, str] # ceilingHeight, label, etc. + points: list[MagicPlanRoomPoint] + doors: list[MagicPlanDoor] + windows: list[MagicPlanWindow] + furniture: list[MagicPlanFurniture] + main_dimensions: list[MagicPlanMainDimension] + + +@dataclass +class MagicPlanFloor: + uid: str + name: str + floor_type: str # "0"=ground, "1"=upper, "2"=basement + rotation: float + compass_angle: float + area_without_walls: float # m² + area_with_interior_walls_only: float # m² + area_with_walls: float # m² + symbol_instance: MagicPlanSymbolInstance + rooms: list[MagicPlanRoom] + furniture: list[MagicPlanFurniture] # floor-level furniture (not inside any room) + exploded: MagicPlanExploded + + +@dataclass +class MagicPlanPlan: + id: str + uid: str + name: str + type: str + interior_wall_width: float # m + exterior_wall_width: float # m + schematic: bool + has_land_survey_address: bool + last_patch_identifier: str + last_roll_identifier: str + values: dict[str, str] # date, statistics.*, distanceUnit, etc. + floors: list[MagicPlanFloor] + interior_room_floors: list[MagicPlanFloor] # from diff --git a/backend/magic_plan/xml_example.xml b/backend/magic_plan/xml_example.xml new file mode 100644 index 00000000..28088fc8 --- /dev/null +++ b/backend/magic_plan/xml_example.xml @@ -0,0 +1,985 @@ + + + 2026-04-20 + 2.134 + 100 + 0 + 0 + + + Ground Floor + + + 2.323164 + + + + + doors + 2.084107 + + + + + doors + 2.02225 + + + + + doors + 1.124639 + 0.949396 + + + + + 0.397258 + 0.746826 + furniture + 1.279293 + 0.746826 + 0.677733 + + + + + 0.368615 + 0.723145 + furniture + 1.311519 + 0.723145 + 0.398439 + + + + + 0.604444 + 0.1 + furniture + 0.851311 + 0.1 + 0.601075 + + + + + furniture + + + + + appliances + + + + + appliances + + + + + appliances + + + + + hvac + 0.1500 + + + + + doors + 2.084107 + + + + + doors + 2.006357 + + + + + doors + 1.00597 + 1.026259 + + + + + appliances + + + + + furniture + + + + + hvac + 0.0000 + + + + + hvac + 0.1500 + + + + + hvac + 0.1500 + + + + + 2.323164 + + + + + + + + + + + + + + + + + + + + + + 3.285283 + + + + + + + + + + + + + + + + + + + + + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + interior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + + + + + + + + + + + + + + + + + + + 1st Floor + + + 2.382467 + + + + + doors + 2.074757 + + + + + plumbing + 1.5000 + + + + + doors + 2.081704 + + + + + doors + 1.227592 + 0.88319 + + + + + hvac + 0.1500 + + + + + 677d01685458a + + + + + doors + 2.081704 + + + + + doors + 2.074757 + + + + + doors + 2.195494 + + + + + doors + 2.195494 + + + + + doors + 0.915306 + 1.180143 + + + + + 0.1 + 0.676758 + appliances + 1.399472 + 0.676758 + 1.253952 + + + + + hvac + 0.1500 + + + + + 2.382467 + + + + + + + + + + + + + 2.382467 + + + + + + + + + + + + + + + 2.382467 + + + + + + + + + + + + + + 2.382467 + + + + + + + + + + + + + + + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + interior + + + + + interior + + + + + interior + + + + + interior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + interior + + + + + exterior + + + + + + + + + + + + + + + + Ground Floor + + + 2.323164 + + + + + doors + 2.084107 + + + + + doors + 2.02225 + + + + + doors + 1.124639 + 0.949396 + + + + + 0.397258 + 0.746826 + furniture + 1.279293 + 0.746826 + 0.677733 + + + + + 0.368615 + 0.723145 + furniture + 1.311519 + 0.723145 + 0.398439 + + + + + 0.604444 + 0.1 + furniture + 0.851311 + 0.1 + 0.601075 + + + + + furniture + + + + + appliances + + + + + appliances + + + + + appliances + + + + + hvac + 0.1500 + + + + + doors + 2.084107 + + + + + doors + 2.006357 + + + + + doors + 1.00597 + 1.026259 + + + + + appliances + + + + + furniture + + + + + hvac + 0.0000 + + + + + hvac + 0.1500 + + + + + hvac + 0.1500 + + + + + 2.323164 + + + + + + + + + + + + + + + + + + + + + + 3.285283 + + + + + + + + + + + + + + + + + + + + + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + interior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + + + + + + + + + + + + + + + + + + + 1st Floor + + + 2.382467 + + + + + doors + 2.074757 + + + + + plumbing + 1.5000 + + + + + doors + 2.081704 + + + + + doors + 1.227592 + 0.88319 + + + + + hvac + 0.1500 + + + + + 677d01685458a + + + + + doors + 2.081704 + + + + + doors + 2.074757 + + + + + doors + 2.195494 + + + + + doors + 2.195494 + + + + + doors + 0.915306 + 1.180143 + + + + + 0.1 + 0.676758 + appliances + 1.399472 + 0.676758 + 1.253952 + + + + + hvac + 0.1500 + + + + + 2.382467 + + + + + + + + + + + + + 2.382467 + + + + + + + + + + + + + + + 2.382467 + + + + + + + + + + + + + + 2.382467 + + + + + + + + + + + + + + + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + interior + + + + + interior + + + + + interior + + + + + interior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + exterior + + + + + interior + + + + + exterior + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/magic_plan/xml_example_2.xml b/backend/magic_plan/xml_example_2.xml new file mode 100644 index 00000000..b4014e86 --- /dev/null +++ b/backend/magic_plan/xml_example_2.xml @@ -0,0 +1 @@ +"\n2026-04-162.13410000Ground Floor2.423333doors2.095132doors2.116748doors2.07892doors1.0413441.129445appliancesappliancesdoors1.868133doors0.8742110.939139doors0.7959061.0349912.4233332.415RoomexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorGround Floor2.423333doors2.095132doors2.116748doors2.07892doors1.0413441.129445appliancesappliancesdoors1.868133doors0.8742110.939139doors0.7959061.0349912.4233332.415Roomexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexteriorexterior\n" \ No newline at end of file diff --git a/backend/magic_plan/xml_example_3.xml b/backend/magic_plan/xml_example_3.xml new file mode 100644 index 00000000..22dcd696 --- /dev/null +++ b/backend/magic_plan/xml_example_3.xml @@ -0,0 +1 @@ +"\n2026-04-162.13410000Ground Floor2.366039677d01685458am1mm2doorsm32.046953m1mm2doorsm32.049024mmm2doorsm32.08851m1mm2doorsm31.998668m1mm2doorsm32.099428m1mm2doorsm32.086379m1mm2doorsm32.118055m1mm2doorsm32.123336m1mm2doorsm31.986039677d01685458a677d01685458ammm2doorsm32.08851m1mm2doorsm30.9267931.19895677d01685458ammm2hvacm30.0000m1mm2doorsm31.998668m1mm2doorsm30.9400381.190655677d01685458ammm2hvacm30.0000m1mm2doorsm32.020562m1mm2doorsm32.086379677d01685458adoors2.01773m1mm2doorsm32.020562m1mm2doorsm32.004822677d01685458adoors2.01773m1mm2doorsm32.046953m1mm2doorsm30.8718441.200662677d01685458ammm2hvacm30.0000m1mm2doorsm32.099428m1mm2doorsm31.1390810.932646677d01685458a677d01685458am1mm2doorsm32.118055m1mm2doorsm32.123336677d01685458am1mm2doorsm32.049024m1mm2doorsm31.0796020.985677677d01685458ammm2hvacm30.0000677d01685458a2.3660392.366039112.366039112.3660392.3660392.3660392.3660392.3660392.366039exteriorexteriorexteriorexteriorexteriorexteriorexteriorinteriorinteriorinteriorinteriorinteriorexteriorexteriorexteriorexteriorexteriorexteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorGround Floor2.366039677d01685458am1mm2doorsm32.046953m1mm2doorsm32.049024mmm2doorsm32.08851m1mm2doorsm31.998668m1mm2doorsm32.099428m1mm2doorsm32.086379m1mm2doorsm32.118055m1mm2doorsm32.123336m1mm2doorsm31.986039677d01685458a677d01685458ammm2doorsm32.08851m1mm2doorsm30.9267931.19895677d01685458ammm2hvacm30.0000m1mm2doorsm31.998668m1mm2doorsm30.9400381.190655677d01685458ammm2hvacm30.0000m1mm2doorsm32.020562m1mm2doorsm32.086379677d01685458adoors2.01773m1mm2doorsm32.020562m1mm2doorsm32.004822677d01685458adoors2.01773m1mm2doorsm32.046953m1mm2doorsm30.8718441.200662677d01685458ammm2hvacm30.0000m1mm2doorsm32.099428m1mm2doorsm31.1390810.932646677d01685458a677d01685458am1mm2doorsm32.118055m1mm2doorsm32.123336677d01685458am1mm2doorsm32.049024m1mm2doorsm31.0796020.985677677d01685458ammm2hvacm30.0000677d01685458a2.3660392.366039112.366039112.3660392.3660392.3660392.3660392.3660392.366039exteriorexteriorexteriorexteriorexteriorexteriorexteriorinteriorinteriorinteriorinteriorinteriorexteriorexteriorexteriorexteriorexteriorexteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinteriorinterior\n" \ No newline at end of file diff --git a/backend/magic_plan/xml_schema.py b/backend/magic_plan/xml_schema.py new file mode 100644 index 00000000..add2292b --- /dev/null +++ b/backend/magic_plan/xml_schema.py @@ -0,0 +1,48 @@ +# MagicPlan Exchange XML Schema Reference +# Derived from xml_example.xml and https://apidocs.magicplan.app/guide/basic-concepts/plan-exchange-xml-format +# +# +# attrs: name, id, uid, type, interiorWallWidth (m), exteriorWallWidth (m), +# schematic, hasLandSurveyAddress, lastPatchIdentifier, lastRollIdentifier +# children: , +, +# +# (plan-level metadata) +# children: text +# known keys: date, statistics.areaOfHeight, statistics.basement.account, +# statistics.exteriorWalls, statistics.interiorWalls +# +# +# attrs: uid, floorType (0=ground 1=upper), rotation, compassAngle, +# areaWithoutWalls (m²), areaWithInteriorWallsOnly (m²), areaWithWalls (m²) +# children: , , +, +# +# +# attrs: type (room label e.g. "Kitchen"), uid, x, y, rotation, +# wasModified, linkedRoom0, linkedRoom1, area (m²), perimeter (m) +# children: (key: ceilingHeight m), +, *, *, *, * +# +# (room corner polygon) +# attrs: snappedX (m), snappedY (m), height (m), uid +# +# +# attrs: point, type, u, width (m), depth (m), height (m), orientation, +# snappedType, snappedPosition, snappedWidth, snappedDepth, snappedHeight, snappedOrientation, +# insetX, insetY, insetZ, twinWallItemUid, symbolInstance +# +# +# attrs: point, type, u, width (m), depth (m), height (m), orientation, +# snappedType, snappedPosition, snappedWidth, snappedDepth, snappedHeight, snappedOrientation, +# insetX, insetY, insetZ, symbolInstance +# +# (wall geometry per floor) +# children: , , , +# children: , (text: "exterior" | "interior") +# +# +# children: (same structure as top-level floor, interior wall room shapes) +# +# All distances in metres. All areas in m². + +FLOOR_TYPE_GROUND = "0" +FLOOR_TYPE_UPPER = "1" +FLOOR_TYPE_BASEMENT = "2" From 4b3b100d633e053862d1209c461a120ac9b49292 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 28 Apr 2026 15:01:32 +0000 Subject: [PATCH 02/21] =?UTF-8?q?Parse=20Magic=20Plan=20XML=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/tests/__init__.py | 0 backend/magic_plan/tests/test_xml_parser.py | 480 ++++++++++++++++++++ backend/magic_plan/xml_parser.py | 5 + 3 files changed, 485 insertions(+) create mode 100644 backend/magic_plan/tests/__init__.py create mode 100644 backend/magic_plan/tests/test_xml_parser.py create mode 100644 backend/magic_plan/xml_parser.py diff --git a/backend/magic_plan/tests/__init__.py b/backend/magic_plan/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/tests/test_xml_parser.py b/backend/magic_plan/tests/test_xml_parser.py new file mode 100644 index 00000000..2dcf6b63 --- /dev/null +++ b/backend/magic_plan/tests/test_xml_parser.py @@ -0,0 +1,480 @@ +import json +import xml.etree.ElementTree as ET +from pathlib import Path +import pytest + +from backend.magic_plan.models import ( + MagicPlanDoor, + MagicPlanExploded, + MagicPlanExplodedOpening, + MagicPlanExplodedWall, + MagicPlanFloor, + MagicPlanFurniture, + MagicPlanMainDimension, + MagicPlanPlan, + MagicPlanRoom, + MagicPlanRoomPoint, + MagicPlanSymbolInstance, + MagicPlanWallPoint, + MagicPlanWindow, +) +from backend.magic_plan.xml_parser import parse_magicplan_xml + +FIXTURES = Path(__file__).parent.parent + + +def _load(filename: str) -> str: + content = (FIXTURES / filename).read_text().strip() + try: + ET.fromstring(content) + return content + except ET.ParseError: + return json.loads(content) + + +@pytest.fixture(scope="module") +def plan1() -> MagicPlanPlan: + return parse_magicplan_xml(_load("xml_example.xml")) + + +@pytest.fixture(scope="module") +def plan2() -> MagicPlanPlan: + return parse_magicplan_xml(_load("xml_example_2.xml")) + + +@pytest.fixture(scope="module") +def plan3() -> MagicPlanPlan: + return parse_magicplan_xml(_load("xml_example_3.xml")) + + +# --------------------------------------------------------------------------- +# Plan-level attributes +# --------------------------------------------------------------------------- + +class TestPlanAttributes: + + def test_id(self, plan1: MagicPlanPlan): + assert plan1.id == "b66bb427-33fe-4865-8d57-bad7d9d3f2e5" + + def test_uid(self, plan1: MagicPlanPlan): + assert plan1.uid == "69e5fafc.0890b3ff" + + def test_name(self, plan1: MagicPlanPlan): + assert plan1.name == "275 Carr Hill Rd NE9 5ND" + + def test_type(self, plan1: MagicPlanPlan): + assert plan1.type == "0" + + def test_interior_wall_width(self, plan1: MagicPlanPlan): + assert plan1.interior_wall_width == pytest.approx(0.12) + + def test_exterior_wall_width(self, plan1: MagicPlanPlan): + assert plan1.exterior_wall_width == pytest.approx(0.25) + + def test_schematic_false(self, plan1: MagicPlanPlan): + assert plan1.schematic is False + + def test_has_land_survey_address_false(self, plan1: MagicPlanPlan): + assert plan1.has_land_survey_address is False + + def test_last_patch_identifier(self, plan1: MagicPlanPlan): + assert plan1.last_patch_identifier == "0" + + def test_last_roll_identifier(self, plan1: MagicPlanPlan): + assert plan1.last_roll_identifier == "0" + + +class TestPlanValues: + + def test_date(self, plan1: MagicPlanPlan): + assert plan1.values["date"] == "2026-04-20" + + def test_statistics_area_of_height(self, plan1: MagicPlanPlan): + assert plan1.values["statistics.areaOfHeight"] == "2.134" + + def test_statistics_basement_account(self, plan1: MagicPlanPlan): + assert plan1.values["statistics.basement.account"] == "100" + + def test_statistics_exterior_walls(self, plan1: MagicPlanPlan): + assert plan1.values["statistics.exteriorWalls"] == "0" + + def test_statistics_interior_walls(self, plan1: MagicPlanPlan): + assert plan1.values["statistics.interiorWalls"] == "0" + + def test_plan2_date(self, plan2: MagicPlanPlan): + assert plan2.values["date"] == "2026-04-16" + + +# --------------------------------------------------------------------------- +# Floors +# --------------------------------------------------------------------------- + +class TestFloors: + + def test_floor_count_plan1(self, plan1: MagicPlanPlan): + assert len(plan1.floors) == 2 + + def test_floor_count_plan2(self, plan2: MagicPlanPlan): + assert len(plan2.floors) == 1 + + def test_floor_count_plan3(self, plan3: MagicPlanPlan): + assert len(plan3.floors) == 1 + + def test_ground_floor_name(self, plan1: MagicPlanPlan): + assert plan1.floors[0].name == "Ground Floor" + + def test_first_floor_name(self, plan1: MagicPlanPlan): + assert plan1.floors[1].name == "1st Floor" + + def test_ground_floor_type(self, plan1: MagicPlanPlan): + assert plan1.floors[0].floor_type == "0" + + def test_upper_floor_type(self, plan1: MagicPlanPlan): + assert plan1.floors[1].floor_type == "1" + + def test_floor_uid(self, plan1: MagicPlanPlan): + assert plan1.floors[0].uid == "69e5fb20.4feef7ff" + + def test_ground_floor_area_without_walls(self, plan1: MagicPlanPlan): + assert plan1.floors[0].area_without_walls == pytest.approx(40.20736) + + def test_ground_floor_area_with_walls(self, plan1: MagicPlanPlan): + assert plan1.floors[0].area_with_walls == pytest.approx(48.40593) + + def test_ground_floor_area_with_interior_walls_only(self, plan1: MagicPlanPlan): + assert plan1.floors[0].area_with_interior_walls_only == pytest.approx(40.67878) + + def test_floor_rotation(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rotation == pytest.approx(0.0) + + def test_floor_compass_angle(self, plan1: MagicPlanPlan): + assert plan1.floors[0].compass_angle == pytest.approx(-1.0) + + +# --------------------------------------------------------------------------- +# Symbol instance +# --------------------------------------------------------------------------- + +class TestSymbolInstance: + + def test_symbol_instance_id(self, plan1: MagicPlanPlan): + assert plan1.floors[0].symbol_instance.id == "floor" + + def test_symbol_instance_uid(self, plan1: MagicPlanPlan): + assert plan1.floors[0].symbol_instance.uid == "69e5fb20.4feef7ff" + + def test_symbol_instance_symbol(self, plan1: MagicPlanPlan): + assert plan1.floors[0].symbol_instance.symbol == "floor" + + def test_symbol_instance_parent_uid(self, plan1: MagicPlanPlan): + assert plan1.floors[0].symbol_instance.parent_uid == "" + + def test_symbol_instance_ceiling_height_value(self, plan1: MagicPlanPlan): + assert plan1.floors[0].symbol_instance.values["ceilingHeight"] == "2.323164" + + +# --------------------------------------------------------------------------- +# Rooms +# --------------------------------------------------------------------------- + +class TestRooms: + + def test_ground_floor_room_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms) == 2 + + def test_first_floor_room_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[1].rooms) == 4 + + def test_plan3_room_count(self, plan3: MagicPlanPlan): + assert len(plan3.floors[0].rooms) == 9 + + def test_room_type(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].type == "Kitchen" + + def test_room_uid(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].uid == "69e5fbc8.71027bff" + + def test_room_area(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].area == pytest.approx(10.78332) + + def test_room_perimeter(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].perimeter == pytest.approx(13.32812) + + def test_room_x(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].x == pytest.approx(3.80616) + + def test_room_y(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].y == pytest.approx(0.23162) + + def test_room_rotation(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].rotation == pytest.approx(0.0) + + def test_room_was_modified_false(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].was_modified is False + + def test_room_was_modified_true(self, plan1: MagicPlanPlan): + # Closet on 1st floor has wasModified=1 + closet = plan1.floors[1].rooms[1] + assert closet.type == "Closet" + assert closet.was_modified is True + + def test_room_linked_room_0(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].linked_room_0 == "-1" + + def test_room_linked_room_1(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].linked_room_1 == "-1" + + def test_room_ceiling_height_value(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].values["ceilingHeight"] == "2.323164" + + def test_room_label_value(self, plan2: MagicPlanPlan): + dining = plan2.floors[0].rooms[1] + assert dining.type == "Dining Room" + assert dining.values["label"] == "Room" + + def test_room_no_label_when_absent(self, plan1: MagicPlanPlan): + assert "label" not in plan1.floors[0].rooms[0].values + + +# --------------------------------------------------------------------------- +# Room points +# --------------------------------------------------------------------------- + +class TestRoomPoints: + + def test_kitchen_point_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms[0].points) == 4 + + def test_living_room_point_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms[1].points) == 8 + + def test_point_snapped_x(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].points[0].snapped_x == pytest.approx(-1.44357) + + def test_point_snapped_y(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].points[0].snapped_y == pytest.approx(-2.00846) + + def test_point_height(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].points[0].height == pytest.approx(2.323164) + + def test_point_uid(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].points[0].uid == "69e5fbc8.710363ff" + + def test_point_values_empty_when_absent(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].points[0].values == {} + + +# --------------------------------------------------------------------------- +# Doors (floorRoom context) +# --------------------------------------------------------------------------- + +class TestDoors: + + def test_kitchen_door_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms[0].doors) == 5 + + def test_corridor_door_count(self, plan3: MagicPlanPlan): + assert len(plan3.floors[0].rooms[0].doors) == 9 + + def test_door_wall_point_index(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].wall_point_index == 3 + + def test_door_type(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].type == "4" + + def test_door_u(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].u == pytest.approx(0.47585) + + def test_door_snapped_width(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].snapped_width == pytest.approx(1.82394) + + def test_door_snapped_height(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].snapped_height == pytest.approx(2.08411) + + def test_door_snapped_orientation(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].snapped_orientation == 3 + + def test_door_twin_wall_item_uid_present(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].twin_wall_item_uid == "69e5fbc8.74614fff" + + def test_door_twin_wall_item_uid_absent(self, plan2: MagicPlanPlan): + # Example 2 doors have no twinWallItemUid + assert plan2.floors[0].rooms[0].doors[0].twin_wall_item_uid is None + + def test_door_symbol_instance(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].symbol_instance == "W-0-0" + + def test_door_inset_x(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].doors[0].inset_x == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# Windows (floorRoom context) +# --------------------------------------------------------------------------- + +class TestWindows: + + def test_kitchen_window_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms[0].windows) == 1 + + def test_corridor_window_count_zero(self, plan3: MagicPlanPlan): + corridor = plan3.floors[0].rooms[0] + assert corridor.type == "Corridor" + assert len(corridor.windows) == 0 + + def test_window_wall_point_index(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].windows[0].wall_point_index == 1 + + def test_window_type(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].windows[0].type == "1" + + def test_window_u(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].windows[0].u == pytest.approx(0.68463) + + def test_window_snapped_width(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].windows[0].snapped_width == pytest.approx(1.71972) + + def test_window_snapped_height(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].windows[0].snapped_height == pytest.approx(0.94940) + + def test_window_symbol_instance(self, plan1: MagicPlanPlan): + assert plan1.floors[0].rooms[0].windows[0].symbol_instance == "W-0-2" + + +# --------------------------------------------------------------------------- +# Furniture (room-level) +# --------------------------------------------------------------------------- + +class TestRoomFurniture: + + def test_kitchen_furniture_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms[0].furniture) == 5 + + def test_bathroom_furniture_count_zero(self, plan1: MagicPlanPlan): + bathroom = plan1.floors[1].rooms[0] + assert bathroom.type == "Bathroom" + assert len(bathroom.furniture) == 0 + + def test_furniture_type(self, plan1: MagicPlanPlan): + furn = plan1.floors[0].rooms[0].furniture[0] + assert isinstance(furn.type, str) + + def test_furniture_symbol_instance(self, plan1: MagicPlanPlan): + furn = plan1.floors[0].rooms[0].furniture[0] + assert isinstance(furn.symbol_instance, str) + + +# --------------------------------------------------------------------------- +# Floor-level furniture (example 3) +# --------------------------------------------------------------------------- + +class TestFloorLevelFurniture: + + def test_floor_furniture_count(self, plan3: MagicPlanPlan): + assert len(plan3.floors[0].furniture) == 1 + + def test_floor_furniture_x(self, plan3: MagicPlanPlan): + assert plan3.floors[0].furniture[0].x == pytest.approx(-2.09376) + + def test_floor_furniture_y(self, plan3: MagicPlanPlan): + assert plan3.floors[0].furniture[0].y == pytest.approx(3.13664) + + def test_floor_furniture_absent_plan1(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].furniture) == 0 + + +# --------------------------------------------------------------------------- +# Main dimensions +# --------------------------------------------------------------------------- + +class TestMainDimensions: + + def test_main_dimension_count(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].rooms[0].main_dimensions) == 2 + + def test_main_dimension_from_point(self, plan1: MagicPlanPlan): + dim = plan1.floors[0].rooms[0].main_dimensions[0] + assert isinstance(dim.from_point, int) + + def test_main_dimension_is_set(self, plan1: MagicPlanPlan): + dim = plan1.floors[0].rooms[0].main_dimensions[0] + assert isinstance(dim.is_set, bool) + + +# --------------------------------------------------------------------------- +# Exploded section +# --------------------------------------------------------------------------- + +class TestExploded: + + def test_exploded_wall_count_ground_floor(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].exploded.walls) == 11 + + def test_exploded_door_count_ground_floor(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].exploded.doors) == 6 + + def test_exploded_window_count_ground_floor(self, plan1: MagicPlanPlan): + assert len(plan1.floors[0].exploded.windows) == 2 + + def test_exploded_wall_type(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.walls[0].wall_type == "exterior" + + def test_exploded_wall_points(self, plan1: MagicPlanPlan): + pts = plan1.floors[0].exploded.walls[0].points + assert len(pts) == 2 + assert pts[0].x == pytest.approx(2.363) + assert pts[0].y == pytest.approx(-1.778) + assert pts[0].height == pytest.approx(2.323164) + + def test_exploded_door_x1(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.doors[0].x1 == pytest.approx(5.24973) + + def test_exploded_door_y1(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.doors[0].y1 == pytest.approx(-1.333153) + + def test_exploded_door_width(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.doors[0].width == pytest.approx(0.773) + + def test_exploded_door_height(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.doors[0].height == pytest.approx(2.02225) + + def test_exploded_door_symbol_instance(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.doors[0].symbol_instance == "W-0-1" + + def test_exploded_window_x1(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.windows[0].x1 == pytest.approx(5.24973) + + def test_exploded_window_width(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.windows[0].width == pytest.approx(1.71972) + + def test_exploded_window_height(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.windows[0].height == pytest.approx(0.949396) + + def test_exploded_window_symbol_instance(self, plan1: MagicPlanPlan): + assert plan1.floors[0].exploded.windows[0].symbol_instance == "W-0-2" + + def test_plan3_exploded_wall_count(self, plan3: MagicPlanPlan): + assert len(plan3.floors[0].exploded.walls) == 33 + + def test_plan3_exploded_door_count(self, plan3: MagicPlanPlan): + assert len(plan3.floors[0].exploded.doors) == 12 + + def test_plan3_exploded_window_count(self, plan3: MagicPlanPlan): + assert len(plan3.floors[0].exploded.windows) == 5 + + +# --------------------------------------------------------------------------- +# Interior room points +# --------------------------------------------------------------------------- + +class TestInteriorRoomPoints: + + def test_interior_room_floor_count_plan1(self, plan1: MagicPlanPlan): + assert len(plan1.interior_room_floors) == 2 + + def test_interior_room_floor_count_plan2(self, plan2: MagicPlanPlan): + assert len(plan2.interior_room_floors) == 1 + + def test_interior_room_floor_uid_matches(self, plan1: MagicPlanPlan): + # interiorRoomPoints floors share uids with the main floors + assert plan1.interior_room_floors[0].uid == plan1.floors[0].uid diff --git a/backend/magic_plan/xml_parser.py b/backend/magic_plan/xml_parser.py new file mode 100644 index 00000000..13d30cdd --- /dev/null +++ b/backend/magic_plan/xml_parser.py @@ -0,0 +1,5 @@ +from backend.magic_plan.models import MagicPlanPlan + + +def parse_magicplan_xml(xml_str: str) -> MagicPlanPlan: + raise NotImplementedError From f6ab407b33743293f0480e09f3362478c984a78d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 28 Apr 2026 15:11:03 +0000 Subject: [PATCH 03/21] =?UTF-8?q?Parse=20Magic=20Plan=20XML=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/__init__.py | 0 backend/magic_plan/tests/test_xml_parser.py | 321 +++++++++----------- backend/magic_plan/xml_parser.py | 223 +++++++++++++- pytest.ini | 2 +- 4 files changed, 373 insertions(+), 173 deletions(-) create mode 100644 backend/magic_plan/__init__.py diff --git a/backend/magic_plan/__init__.py b/backend/magic_plan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/tests/test_xml_parser.py b/backend/magic_plan/tests/test_xml_parser.py index 2dcf6b63..7c4f4d82 100644 --- a/backend/magic_plan/tests/test_xml_parser.py +++ b/backend/magic_plan/tests/test_xml_parser.py @@ -1,23 +1,11 @@ import json +import math import xml.etree.ElementTree as ET from pathlib import Path + import pytest -from backend.magic_plan.models import ( - MagicPlanDoor, - MagicPlanExploded, - MagicPlanExplodedOpening, - MagicPlanExplodedWall, - MagicPlanFloor, - MagicPlanFurniture, - MagicPlanMainDimension, - MagicPlanPlan, - MagicPlanRoom, - MagicPlanRoomPoint, - MagicPlanSymbolInstance, - MagicPlanWallPoint, - MagicPlanWindow, -) +from backend.magic_plan.models import MagicPlanPlan from backend.magic_plan.xml_parser import parse_magicplan_xml FIXTURES = Path(__file__).parent.parent @@ -29,7 +17,7 @@ def _load(filename: str) -> str: ET.fromstring(content) return content except ET.ParseError: - return json.loads(content) + return json.loads(content) # type: ignore[return-value] @pytest.fixture(scope="module") @@ -53,55 +41,55 @@ def plan3() -> MagicPlanPlan: class TestPlanAttributes: - def test_id(self, plan1: MagicPlanPlan): + def test_id(self, plan1: MagicPlanPlan) -> None: assert plan1.id == "b66bb427-33fe-4865-8d57-bad7d9d3f2e5" - def test_uid(self, plan1: MagicPlanPlan): + def test_uid(self, plan1: MagicPlanPlan) -> None: assert plan1.uid == "69e5fafc.0890b3ff" - def test_name(self, plan1: MagicPlanPlan): + def test_name(self, plan1: MagicPlanPlan) -> None: assert plan1.name == "275 Carr Hill Rd NE9 5ND" - def test_type(self, plan1: MagicPlanPlan): + def test_type(self, plan1: MagicPlanPlan) -> None: assert plan1.type == "0" - def test_interior_wall_width(self, plan1: MagicPlanPlan): - assert plan1.interior_wall_width == pytest.approx(0.12) + def test_interior_wall_width(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.interior_wall_width, 0.12) - def test_exterior_wall_width(self, plan1: MagicPlanPlan): - assert plan1.exterior_wall_width == pytest.approx(0.25) + def test_exterior_wall_width(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.exterior_wall_width, 0.25) - def test_schematic_false(self, plan1: MagicPlanPlan): + def test_schematic_false(self, plan1: MagicPlanPlan) -> None: assert plan1.schematic is False - def test_has_land_survey_address_false(self, plan1: MagicPlanPlan): + def test_has_land_survey_address_false(self, plan1: MagicPlanPlan) -> None: assert plan1.has_land_survey_address is False - def test_last_patch_identifier(self, plan1: MagicPlanPlan): + def test_last_patch_identifier(self, plan1: MagicPlanPlan) -> None: assert plan1.last_patch_identifier == "0" - def test_last_roll_identifier(self, plan1: MagicPlanPlan): + def test_last_roll_identifier(self, plan1: MagicPlanPlan) -> None: assert plan1.last_roll_identifier == "0" class TestPlanValues: - def test_date(self, plan1: MagicPlanPlan): + def test_date(self, plan1: MagicPlanPlan) -> None: assert plan1.values["date"] == "2026-04-20" - def test_statistics_area_of_height(self, plan1: MagicPlanPlan): + def test_statistics_area_of_height(self, plan1: MagicPlanPlan) -> None: assert plan1.values["statistics.areaOfHeight"] == "2.134" - def test_statistics_basement_account(self, plan1: MagicPlanPlan): + def test_statistics_basement_account(self, plan1: MagicPlanPlan) -> None: assert plan1.values["statistics.basement.account"] == "100" - def test_statistics_exterior_walls(self, plan1: MagicPlanPlan): + def test_statistics_exterior_walls(self, plan1: MagicPlanPlan) -> None: assert plan1.values["statistics.exteriorWalls"] == "0" - def test_statistics_interior_walls(self, plan1: MagicPlanPlan): + def test_statistics_interior_walls(self, plan1: MagicPlanPlan) -> None: assert plan1.values["statistics.interiorWalls"] == "0" - def test_plan2_date(self, plan2: MagicPlanPlan): + def test_plan2_date(self, plan2: MagicPlanPlan) -> None: assert plan2.values["date"] == "2026-04-16" @@ -111,44 +99,44 @@ class TestPlanValues: class TestFloors: - def test_floor_count_plan1(self, plan1: MagicPlanPlan): + def test_floor_count_plan1(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors) == 2 - def test_floor_count_plan2(self, plan2: MagicPlanPlan): + def test_floor_count_plan2(self, plan2: MagicPlanPlan) -> None: assert len(plan2.floors) == 1 - def test_floor_count_plan3(self, plan3: MagicPlanPlan): + def test_floor_count_plan3(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors) == 1 - def test_ground_floor_name(self, plan1: MagicPlanPlan): + def test_ground_floor_name(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].name == "Ground Floor" - def test_first_floor_name(self, plan1: MagicPlanPlan): + def test_first_floor_name(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[1].name == "1st Floor" - def test_ground_floor_type(self, plan1: MagicPlanPlan): + def test_ground_floor_type(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].floor_type == "0" - def test_upper_floor_type(self, plan1: MagicPlanPlan): + def test_upper_floor_type(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[1].floor_type == "1" - def test_floor_uid(self, plan1: MagicPlanPlan): + def test_floor_uid(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].uid == "69e5fb20.4feef7ff" - def test_ground_floor_area_without_walls(self, plan1: MagicPlanPlan): - assert plan1.floors[0].area_without_walls == pytest.approx(40.20736) + def test_ground_floor_area_without_walls(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].area_without_walls, 40.20736) - def test_ground_floor_area_with_walls(self, plan1: MagicPlanPlan): - assert plan1.floors[0].area_with_walls == pytest.approx(48.40593) + def test_ground_floor_area_with_walls(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].area_with_walls, 48.40593) - def test_ground_floor_area_with_interior_walls_only(self, plan1: MagicPlanPlan): - assert plan1.floors[0].area_with_interior_walls_only == pytest.approx(40.67878) + def test_ground_floor_area_with_interior_walls_only(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].area_with_interior_walls_only, 40.67878) - def test_floor_rotation(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rotation == pytest.approx(0.0) + def test_floor_rotation(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rotation, 0.0, abs_tol=1e-9) - def test_floor_compass_angle(self, plan1: MagicPlanPlan): - assert plan1.floors[0].compass_angle == pytest.approx(-1.0) + def test_floor_compass_angle(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].compass_angle, -1.0) # --------------------------------------------------------------------------- @@ -157,19 +145,19 @@ class TestFloors: class TestSymbolInstance: - def test_symbol_instance_id(self, plan1: MagicPlanPlan): + def test_symbol_instance_id(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].symbol_instance.id == "floor" - def test_symbol_instance_uid(self, plan1: MagicPlanPlan): + def test_symbol_instance_uid(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].symbol_instance.uid == "69e5fb20.4feef7ff" - def test_symbol_instance_symbol(self, plan1: MagicPlanPlan): + def test_symbol_instance_symbol(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].symbol_instance.symbol == "floor" - def test_symbol_instance_parent_uid(self, plan1: MagicPlanPlan): + def test_symbol_instance_parent_uid(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].symbol_instance.parent_uid == "" - def test_symbol_instance_ceiling_height_value(self, plan1: MagicPlanPlan): + def test_symbol_instance_ceiling_height_value(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].symbol_instance.values["ceilingHeight"] == "2.323164" @@ -179,60 +167,59 @@ class TestSymbolInstance: class TestRooms: - def test_ground_floor_room_count(self, plan1: MagicPlanPlan): + def test_ground_floor_room_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms) == 2 - def test_first_floor_room_count(self, plan1: MagicPlanPlan): + def test_first_floor_room_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[1].rooms) == 4 - def test_plan3_room_count(self, plan3: MagicPlanPlan): + def test_plan3_room_count(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors[0].rooms) == 9 - def test_room_type(self, plan1: MagicPlanPlan): + def test_room_type(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].type == "Kitchen" - def test_room_uid(self, plan1: MagicPlanPlan): + def test_room_uid(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].uid == "69e5fbc8.71027bff" - def test_room_area(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].area == pytest.approx(10.78332) + def test_room_area(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].area, 10.78332) - def test_room_perimeter(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].perimeter == pytest.approx(13.32812) + def test_room_perimeter(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].perimeter, 13.32812) - def test_room_x(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].x == pytest.approx(3.80616) + def test_room_x(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].x, 3.80616) - def test_room_y(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].y == pytest.approx(0.23162) + def test_room_y(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].y, 0.23162) - def test_room_rotation(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].rotation == pytest.approx(0.0) + def test_room_rotation(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].rotation, 0.0, abs_tol=1e-9) - def test_room_was_modified_false(self, plan1: MagicPlanPlan): + def test_room_was_modified_false(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].was_modified is False - def test_room_was_modified_true(self, plan1: MagicPlanPlan): - # Closet on 1st floor has wasModified=1 + def test_room_was_modified_true(self, plan1: MagicPlanPlan) -> None: closet = plan1.floors[1].rooms[1] assert closet.type == "Closet" assert closet.was_modified is True - def test_room_linked_room_0(self, plan1: MagicPlanPlan): + def test_room_linked_room_0(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].linked_room_0 == "-1" - def test_room_linked_room_1(self, plan1: MagicPlanPlan): + def test_room_linked_room_1(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].linked_room_1 == "-1" - def test_room_ceiling_height_value(self, plan1: MagicPlanPlan): + def test_room_ceiling_height_value(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].values["ceilingHeight"] == "2.323164" - def test_room_label_value(self, plan2: MagicPlanPlan): + def test_room_label_value(self, plan2: MagicPlanPlan) -> None: dining = plan2.floors[0].rooms[1] assert dining.type == "Dining Room" assert dining.values["label"] == "Room" - def test_room_no_label_when_absent(self, plan1: MagicPlanPlan): + def test_room_no_label_when_absent(self, plan1: MagicPlanPlan) -> None: assert "label" not in plan1.floors[0].rooms[0].values @@ -242,25 +229,25 @@ class TestRooms: class TestRoomPoints: - def test_kitchen_point_count(self, plan1: MagicPlanPlan): + def test_kitchen_point_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms[0].points) == 4 - def test_living_room_point_count(self, plan1: MagicPlanPlan): + def test_living_room_point_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms[1].points) == 8 - def test_point_snapped_x(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].points[0].snapped_x == pytest.approx(-1.44357) + def test_point_snapped_x(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].points[0].snapped_x, -1.44357) - def test_point_snapped_y(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].points[0].snapped_y == pytest.approx(-2.00846) + def test_point_snapped_y(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].points[0].snapped_y, -2.00846) - def test_point_height(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].points[0].height == pytest.approx(2.323164) + def test_point_height(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].points[0].height, 2.323164) - def test_point_uid(self, plan1: MagicPlanPlan): + def test_point_uid(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].points[0].uid == "69e5fbc8.710363ff" - def test_point_values_empty_when_absent(self, plan1: MagicPlanPlan): + def test_point_values_empty_when_absent(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].points[0].values == {} @@ -270,42 +257,41 @@ class TestRoomPoints: class TestDoors: - def test_kitchen_door_count(self, plan1: MagicPlanPlan): + def test_kitchen_door_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms[0].doors) == 5 - def test_corridor_door_count(self, plan3: MagicPlanPlan): + def test_corridor_door_count(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors[0].rooms[0].doors) == 9 - def test_door_wall_point_index(self, plan1: MagicPlanPlan): + def test_door_wall_point_index(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].doors[0].wall_point_index == 3 - def test_door_type(self, plan1: MagicPlanPlan): + def test_door_type(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].doors[0].type == "4" - def test_door_u(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].doors[0].u == pytest.approx(0.47585) + def test_door_u(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].doors[0].u, 0.47585) - def test_door_snapped_width(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].doors[0].snapped_width == pytest.approx(1.82394) + def test_door_snapped_width(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].doors[0].snapped_width, 1.82394) - def test_door_snapped_height(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].doors[0].snapped_height == pytest.approx(2.08411) + def test_door_snapped_height(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].doors[0].snapped_height, 2.08411) - def test_door_snapped_orientation(self, plan1: MagicPlanPlan): + def test_door_snapped_orientation(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].doors[0].snapped_orientation == 3 - def test_door_twin_wall_item_uid_present(self, plan1: MagicPlanPlan): + def test_door_twin_wall_item_uid_present(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].doors[0].twin_wall_item_uid == "69e5fbc8.74614fff" - def test_door_twin_wall_item_uid_absent(self, plan2: MagicPlanPlan): - # Example 2 doors have no twinWallItemUid + def test_door_twin_wall_item_uid_absent(self, plan2: MagicPlanPlan) -> None: assert plan2.floors[0].rooms[0].doors[0].twin_wall_item_uid is None - def test_door_symbol_instance(self, plan1: MagicPlanPlan): + def test_door_symbol_instance(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].doors[0].symbol_instance == "W-0-0" - def test_door_inset_x(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].doors[0].inset_x == pytest.approx(0.0) + def test_door_inset_x(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].doors[0].inset_x, 0.0, abs_tol=1e-9) # --------------------------------------------------------------------------- @@ -314,30 +300,30 @@ class TestDoors: class TestWindows: - def test_kitchen_window_count(self, plan1: MagicPlanPlan): + def test_kitchen_window_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms[0].windows) == 1 - def test_corridor_window_count_zero(self, plan3: MagicPlanPlan): + def test_corridor_window_count_zero(self, plan3: MagicPlanPlan) -> None: corridor = plan3.floors[0].rooms[0] assert corridor.type == "Corridor" assert len(corridor.windows) == 0 - def test_window_wall_point_index(self, plan1: MagicPlanPlan): + def test_window_wall_point_index(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].windows[0].wall_point_index == 1 - def test_window_type(self, plan1: MagicPlanPlan): + def test_window_type(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].windows[0].type == "1" - def test_window_u(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].windows[0].u == pytest.approx(0.68463) + def test_window_u(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].windows[0].u, 0.68463) - def test_window_snapped_width(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].windows[0].snapped_width == pytest.approx(1.71972) + def test_window_snapped_width(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].windows[0].snapped_width, 1.71972) - def test_window_snapped_height(self, plan1: MagicPlanPlan): - assert plan1.floors[0].rooms[0].windows[0].snapped_height == pytest.approx(0.94940) + def test_window_snapped_height(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].rooms[0].windows[0].snapped_height, 0.94940) - def test_window_symbol_instance(self, plan1: MagicPlanPlan): + def test_window_symbol_instance(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].rooms[0].windows[0].symbol_instance == "W-0-2" @@ -347,21 +333,19 @@ class TestWindows: class TestRoomFurniture: - def test_kitchen_furniture_count(self, plan1: MagicPlanPlan): + def test_kitchen_furniture_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms[0].furniture) == 5 - def test_bathroom_furniture_count_zero(self, plan1: MagicPlanPlan): + def test_bathroom_furniture_count_zero(self, plan1: MagicPlanPlan) -> None: bathroom = plan1.floors[1].rooms[0] assert bathroom.type == "Bathroom" assert len(bathroom.furniture) == 0 - def test_furniture_type(self, plan1: MagicPlanPlan): - furn = plan1.floors[0].rooms[0].furniture[0] - assert isinstance(furn.type, str) + def test_furniture_type(self, plan1: MagicPlanPlan) -> None: + assert isinstance(plan1.floors[0].rooms[0].furniture[0].type, str) - def test_furniture_symbol_instance(self, plan1: MagicPlanPlan): - furn = plan1.floors[0].rooms[0].furniture[0] - assert isinstance(furn.symbol_instance, str) + def test_furniture_symbol_instance(self, plan1: MagicPlanPlan) -> None: + assert isinstance(plan1.floors[0].rooms[0].furniture[0].symbol_instance, str) # --------------------------------------------------------------------------- @@ -370,16 +354,16 @@ class TestRoomFurniture: class TestFloorLevelFurniture: - def test_floor_furniture_count(self, plan3: MagicPlanPlan): + def test_floor_furniture_count(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors[0].furniture) == 1 - def test_floor_furniture_x(self, plan3: MagicPlanPlan): - assert plan3.floors[0].furniture[0].x == pytest.approx(-2.09376) + def test_floor_furniture_x(self, plan3: MagicPlanPlan) -> None: + assert math.isclose(plan3.floors[0].furniture[0].x, -2.09376) - def test_floor_furniture_y(self, plan3: MagicPlanPlan): - assert plan3.floors[0].furniture[0].y == pytest.approx(3.13664) + def test_floor_furniture_y(self, plan3: MagicPlanPlan) -> None: + assert math.isclose(plan3.floors[0].furniture[0].y, 3.13664) - def test_floor_furniture_absent_plan1(self, plan1: MagicPlanPlan): + def test_floor_furniture_absent_plan1(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].furniture) == 0 @@ -389,16 +373,14 @@ class TestFloorLevelFurniture: class TestMainDimensions: - def test_main_dimension_count(self, plan1: MagicPlanPlan): + def test_main_dimension_count(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].rooms[0].main_dimensions) == 2 - def test_main_dimension_from_point(self, plan1: MagicPlanPlan): - dim = plan1.floors[0].rooms[0].main_dimensions[0] - assert isinstance(dim.from_point, int) + def test_main_dimension_from_point(self, plan1: MagicPlanPlan) -> None: + assert isinstance(plan1.floors[0].rooms[0].main_dimensions[0].from_point, int) - def test_main_dimension_is_set(self, plan1: MagicPlanPlan): - dim = plan1.floors[0].rooms[0].main_dimensions[0] - assert isinstance(dim.is_set, bool) + def test_main_dimension_is_set(self, plan1: MagicPlanPlan) -> None: + assert isinstance(plan1.floors[0].rooms[0].main_dimensions[0].is_set, bool) # --------------------------------------------------------------------------- @@ -407,59 +389,59 @@ class TestMainDimensions: class TestExploded: - def test_exploded_wall_count_ground_floor(self, plan1: MagicPlanPlan): + def test_exploded_wall_count_ground_floor(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].exploded.walls) == 11 - def test_exploded_door_count_ground_floor(self, plan1: MagicPlanPlan): + def test_exploded_door_count_ground_floor(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].exploded.doors) == 6 - def test_exploded_window_count_ground_floor(self, plan1: MagicPlanPlan): + def test_exploded_window_count_ground_floor(self, plan1: MagicPlanPlan) -> None: assert len(plan1.floors[0].exploded.windows) == 2 - def test_exploded_wall_type(self, plan1: MagicPlanPlan): + def test_exploded_wall_type(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].exploded.walls[0].wall_type == "exterior" - def test_exploded_wall_points(self, plan1: MagicPlanPlan): + def test_exploded_wall_points(self, plan1: MagicPlanPlan) -> None: pts = plan1.floors[0].exploded.walls[0].points assert len(pts) == 2 - assert pts[0].x == pytest.approx(2.363) - assert pts[0].y == pytest.approx(-1.778) - assert pts[0].height == pytest.approx(2.323164) + assert math.isclose(pts[0].x, 2.363) + assert math.isclose(pts[0].y, -1.778) + assert math.isclose(pts[0].height, 2.323164) - def test_exploded_door_x1(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.doors[0].x1 == pytest.approx(5.24973) + def test_exploded_door_x1(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.doors[0].x1, 5.24973) - def test_exploded_door_y1(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.doors[0].y1 == pytest.approx(-1.333153) + def test_exploded_door_y1(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.doors[0].y1, -1.333153) - def test_exploded_door_width(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.doors[0].width == pytest.approx(0.773) + def test_exploded_door_width(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.doors[0].width, 0.773) - def test_exploded_door_height(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.doors[0].height == pytest.approx(2.02225) + def test_exploded_door_height(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.doors[0].height, 2.02225) - def test_exploded_door_symbol_instance(self, plan1: MagicPlanPlan): + def test_exploded_door_symbol_instance(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].exploded.doors[0].symbol_instance == "W-0-1" - def test_exploded_window_x1(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.windows[0].x1 == pytest.approx(5.24973) + def test_exploded_window_x1(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.windows[0].x1, 5.24973) - def test_exploded_window_width(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.windows[0].width == pytest.approx(1.71972) + def test_exploded_window_width(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.windows[0].width, 1.71972) - def test_exploded_window_height(self, plan1: MagicPlanPlan): - assert plan1.floors[0].exploded.windows[0].height == pytest.approx(0.949396) + def test_exploded_window_height(self, plan1: MagicPlanPlan) -> None: + assert math.isclose(plan1.floors[0].exploded.windows[0].height, 0.949396) - def test_exploded_window_symbol_instance(self, plan1: MagicPlanPlan): + def test_exploded_window_symbol_instance(self, plan1: MagicPlanPlan) -> None: assert plan1.floors[0].exploded.windows[0].symbol_instance == "W-0-2" - def test_plan3_exploded_wall_count(self, plan3: MagicPlanPlan): + def test_plan3_exploded_wall_count(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors[0].exploded.walls) == 33 - def test_plan3_exploded_door_count(self, plan3: MagicPlanPlan): + def test_plan3_exploded_door_count(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors[0].exploded.doors) == 12 - def test_plan3_exploded_window_count(self, plan3: MagicPlanPlan): + def test_plan3_exploded_window_count(self, plan3: MagicPlanPlan) -> None: assert len(plan3.floors[0].exploded.windows) == 5 @@ -469,12 +451,11 @@ class TestExploded: class TestInteriorRoomPoints: - def test_interior_room_floor_count_plan1(self, plan1: MagicPlanPlan): + def test_interior_room_floor_count_plan1(self, plan1: MagicPlanPlan) -> None: assert len(plan1.interior_room_floors) == 2 - def test_interior_room_floor_count_plan2(self, plan2: MagicPlanPlan): + def test_interior_room_floor_count_plan2(self, plan2: MagicPlanPlan) -> None: assert len(plan2.interior_room_floors) == 1 - def test_interior_room_floor_uid_matches(self, plan1: MagicPlanPlan): - # interiorRoomPoints floors share uids with the main floors + def test_interior_room_floor_uid_matches(self, plan1: MagicPlanPlan) -> None: assert plan1.interior_room_floors[0].uid == plan1.floors[0].uid diff --git a/backend/magic_plan/xml_parser.py b/backend/magic_plan/xml_parser.py index 13d30cdd..878eb1fd 100644 --- a/backend/magic_plan/xml_parser.py +++ b/backend/magic_plan/xml_parser.py @@ -1,5 +1,224 @@ -from backend.magic_plan.models import MagicPlanPlan +import xml.etree.ElementTree as ET + +from backend.magic_plan.models import ( + MagicPlanDoor, + MagicPlanExploded, + MagicPlanExplodedOpening, + MagicPlanExplodedWall, + MagicPlanFloor, + MagicPlanFurniture, + MagicPlanMainDimension, + MagicPlanPlan, + MagicPlanRoom, + MagicPlanRoomPoint, + MagicPlanSymbolInstance, + MagicPlanWallPoint, + MagicPlanWindow, +) + + +def _values(el: ET.Element) -> dict[str, str]: + return {v.get("key", ""): (v.text or "") for v in el.findall("values/value")} + + +def _parse_room_point(el: ET.Element) -> MagicPlanRoomPoint: + return MagicPlanRoomPoint( + snapped_x=float(el.get("snappedX", "0")), + snapped_y=float(el.get("snappedY", "0")), + height=float(el.get("height", "0")), + uid=el.get("uid", ""), + values=_values(el), + ) + + +def _parse_wall_point(el: ET.Element) -> MagicPlanWallPoint: + return MagicPlanWallPoint( + x=float(el.get("x", "0")), + y=float(el.get("y", "0")), + height=float(el.get("height", "0")), + ) + + +def _parse_door(el: ET.Element) -> MagicPlanDoor: + return MagicPlanDoor( + wall_point_index=int(el.get("point", "0")), + type=el.get("type", ""), + u=float(el.get("u", "0")), + width=float(el.get("width", "0")), + depth=float(el.get("depth", "0")), + height=float(el.get("height", "0")), + orientation=int(el.get("orientation", "0")), + snapped_type=el.get("snappedType", ""), + snapped_position=float(el.get("snappedPosition", "0")), + snapped_width=float(el.get("snappedWidth", "0")), + snapped_depth=float(el.get("snappedDepth", "0")), + snapped_height=float(el.get("snappedHeight", "0")), + snapped_orientation=int(el.get("snappedOrientation", "0")), + inset_x=float(el.get("insetX", "0")), + inset_y=float(el.get("insetY", "0")), + inset_z=float(el.get("insetZ", "0")), + symbol_instance=el.get("symbolInstance", ""), + twin_wall_item_uid=el.get("twinWallItemUid"), + ) + + +def _parse_window(el: ET.Element) -> MagicPlanWindow: + return MagicPlanWindow( + wall_point_index=int(el.get("point", "0")), + type=el.get("type", ""), + u=float(el.get("u", "0")), + width=float(el.get("width", "0")), + depth=float(el.get("depth", "0")), + height=float(el.get("height", "0")), + orientation=int(el.get("orientation", "0")), + snapped_type=el.get("snappedType", ""), + snapped_position=float(el.get("snappedPosition", "0")), + snapped_width=float(el.get("snappedWidth", "0")), + snapped_depth=float(el.get("snappedDepth", "0")), + snapped_height=float(el.get("snappedHeight", "0")), + snapped_orientation=int(el.get("snappedOrientation", "0")), + inset_x=float(el.get("insetX", "0")), + inset_y=float(el.get("insetY", "0")), + inset_z=float(el.get("insetZ", "0")), + symbol_instance=el.get("symbolInstance", ""), + ) + + +def _parse_exploded_opening(el: ET.Element) -> MagicPlanExplodedOpening: + return MagicPlanExplodedOpening( + type=el.get("type", ""), + x1=float(el.get("x1", "0")), + y1=float(el.get("y1", "0")), + x2=float(el.get("x2", "0")), + y2=float(el.get("y2", "0")), + width=float(el.get("width", "0")), + depth=float(el.get("depth", "0")), + height=float(el.get("height", "0")), + inset_x=float(el.get("insetX", "0")), + inset_y=float(el.get("insetY", "0")), + orientation=int(el.get("orientation", "0")), + symbol_instance=el.get("symbolInstance", ""), + ) + + +def _parse_furniture(el: ET.Element) -> MagicPlanFurniture: + return MagicPlanFurniture( + type=el.get("type", ""), + x=float(el.get("x", "0")), + y=float(el.get("y", "0")), + snapped_x=float(el.get("snappedX", "0")), + snapped_y=float(el.get("snappedY", "0")), + angle=float(el.get("angle", "0")), + width=float(el.get("width", "0")), + depth=float(el.get("depth", "0")), + height=float(el.get("height", "0")), + snapped_width=float(el.get("snappedWidth", "0")), + snapped_depth=float(el.get("snappedDepth", "0")), + snapped_height=float(el.get("snappedHeight", "0")), + size_lock_0=el.get("sizeLock0", ""), + size_lock_1=el.get("sizeLock1", ""), + size_lock_2=el.get("sizeLock2", ""), + symbol_instance=el.get("symbolInstance", ""), + ) + + +def _parse_main_dimension(el: ET.Element) -> MagicPlanMainDimension: + return MagicPlanMainDimension( + from_point=int(el.get("from", "0")), + to_point=int(el.get("to", "0")), + dir_x=float(el.get("dir.x", "0")), + dir_y=float(el.get("dir.y", "0")), + value=float(el.get("value", "0")), + actual_value=float(el.get("actualValue", "0")), + is_set=el.get("isSet", "0") == "1", + ) + + +def _parse_exploded_wall(el: ET.Element) -> MagicPlanExplodedWall: + type_el = el.find("type") + return MagicPlanExplodedWall( + wall_type=(type_el.text or "") if type_el is not None else "", + points=[_parse_wall_point(p) for p in el.findall("point")], + ) + + +def _parse_exploded(el: ET.Element) -> MagicPlanExploded: + return MagicPlanExploded( + walls=[_parse_exploded_wall(w) for w in el.findall("wall")], + doors=[_parse_exploded_opening(d) for d in el.findall("door")], + windows=[_parse_exploded_opening(w) for w in el.findall("window")], + furniture=[_parse_furniture(f) for f in el.findall("furniture")], + ) + + +def _parse_symbol_instance(el: ET.Element) -> MagicPlanSymbolInstance: + return MagicPlanSymbolInstance( + id=el.get("id", ""), + uid=el.get("uid", ""), + parent_uid=el.get("parentUid", ""), + symbol=el.get("symbol", ""), + values=_values(el), + ) + + +def _parse_floor(el: ET.Element) -> MagicPlanFloor: + si_el = el.find("symbolInstance") + exploded_el = el.find("exploded") + return MagicPlanFloor( + uid=el.get("uid", ""), + name=el.findtext("name") or "", + floor_type=el.get("floorType", "0"), + rotation=float(el.get("rotation", "0")), + compass_angle=float(el.get("compassAngle", "0")), + area_without_walls=float(el.get("areaWithoutWalls", "0")), + area_with_interior_walls_only=float(el.get("areaWithInteriorWallsOnly", "0")), + area_with_walls=float(el.get("areaWithWalls", "0")), + symbol_instance=_parse_symbol_instance(si_el) if si_el is not None + else MagicPlanSymbolInstance(id="", uid="", parent_uid="", symbol="", values={}), + rooms=[_parse_room(r) for r in el.findall("floorRoom")], + furniture=[_parse_furniture(f) for f in el.findall("furniture")], + exploded=_parse_exploded(exploded_el) if exploded_el is not None + else MagicPlanExploded(walls=[], doors=[], windows=[], furniture=[]), + ) + + +def _parse_room(el: ET.Element) -> MagicPlanRoom: + return MagicPlanRoom( + uid=el.get("uid", ""), + type=el.get("type", ""), + x=float(el.get("x", "0")), + y=float(el.get("y", "0")), + rotation=float(el.get("rotation", "0")), + was_modified=el.get("wasModified", "0") == "1", + linked_room_0=el.get("linkedRoom0", "-1"), + linked_room_1=el.get("linkedRoom1", "-1"), + area=float(el.get("area", "0")), + perimeter=float(el.get("perimeter", "0")), + values=_values(el), + points=[_parse_room_point(p) for p in el.findall("point")], + doors=[_parse_door(d) for d in el.findall("door")], + windows=[_parse_window(w) for w in el.findall("window")], + furniture=[_parse_furniture(f) for f in el.findall("furniture")], + main_dimensions=[_parse_main_dimension(d) for d in el.findall("mainDimension")], + ) def parse_magicplan_xml(xml_str: str) -> MagicPlanPlan: - raise NotImplementedError + root = ET.fromstring(xml_str) + irp_el = root.find("interiorRoomPoints") + return MagicPlanPlan( + id=root.get("id", ""), + uid=root.get("uid", ""), + name=root.get("name", ""), + type=root.get("type", ""), + interior_wall_width=float(root.get("interiorWallWidth", "0")), + exterior_wall_width=float(root.get("exteriorWallWidth", "0")), + schematic=root.get("schematic", "0") == "1", + has_land_survey_address=root.get("hasLandSurveyAddress", "0") == "1", + last_patch_identifier=root.get("lastPatchIdentifier", ""), + last_roll_identifier=root.get("lastRollIdentifier", ""), + values=_values(root), + floors=[_parse_floor(f) for f in root.findall("floor")], + interior_room_floors=[_parse_floor(f) for f in irp_el.findall("floor")] + if irp_el is not None else [], + ) diff --git a/pytest.ini b/pytest.ini index 33231c61..064a3a59 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,6 @@ pythonpath = . log_cli = true log_cli_level = INFO addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial -testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/documents_parser/tests +testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/documents_parser/tests backend/magic_plan/tests markers = integration: mark a test as an integration test From 3ba9a310abccae00ee5d6dd5eacc6ce2c55b3466 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 28 Apr 2026 15:24:17 +0000 Subject: [PATCH 04/21] start defining magic plan client --- backend/magic_plan/magic_plan_client.py | 14 ++ backend/magic_plan/models.py | 25 +++ .../tests/test_magic_plan_client.py | 195 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 backend/magic_plan/tests/test_magic_plan_client.py diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py index e69de29b..29ad078a 100644 --- a/backend/magic_plan/magic_plan_client.py +++ b/backend/magic_plan/magic_plan_client.py @@ -0,0 +1,14 @@ +from backend.magic_plan.models import MagicPlanDetail, MagicPlanSummary + + +class MagicPlanClient: + BASE_URL = "https://cloud.magicplan.app/api/v2" + + def __init__(self, _api_key: str) -> None: + raise NotImplementedError + + def get_plans(self, _project_id: str) -> list[MagicPlanSummary]: + raise NotImplementedError + + def get_plan_xml(self, _plan_id: str) -> MagicPlanDetail: + raise NotImplementedError diff --git a/backend/magic_plan/models.py b/backend/magic_plan/models.py index 46cf0fef..5bb5e413 100644 --- a/backend/magic_plan/models.py +++ b/backend/magic_plan/models.py @@ -171,6 +171,31 @@ class MagicPlanFloor: exploded: MagicPlanExploded +@dataclass +class MagicPlanSummary: + """Plan metadata returned by the list-plans API endpoint.""" + id: str + project_id: str + name: str + address: str + creation_date: str + update_date: str + workgroup_id: str + team_id: str + created_by: str + thumbnail_url: str + public_url: str + cloud_url: str + url_3d: str + + +@dataclass +class MagicPlanDetail: + """Full plan response: summary metadata + parsed XML plan.""" + summary: MagicPlanSummary + plan_xml: "MagicPlanPlan" + + @dataclass class MagicPlanPlan: id: str diff --git a/backend/magic_plan/tests/test_magic_plan_client.py b/backend/magic_plan/tests/test_magic_plan_client.py new file mode 100644 index 00000000..f30417fe --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_client.py @@ -0,0 +1,195 @@ +import json +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from backend.magic_plan.magic_plan_client import MagicPlanClient +from backend.magic_plan.models import MagicPlanDetail, MagicPlanPlan, MagicPlanSummary + +FIXTURES = Path(__file__).parent.parent +API_KEY = "test-api-key" +PLAN_ID = "b66bb427-33fe-4865-8d57-bad7d9d3f2e5" +PROJECT_ID = "test-project-123" +BASE_URL = "https://cloud.magicplan.app/api/v2" + + +def _load_xml() -> str: + content = (FIXTURES / "xml_example.xml").read_text().strip() + try: + ET.fromstring(content) + return content + except ET.ParseError: + return json.loads(content) # type: ignore[return-value] + + +def _summary_payload(plan_id: str = PLAN_ID, index: int = 0) -> dict[str, str]: + return { + "id": plan_id, + "project_id": PROJECT_ID, + "name": f"Plan {index}", + "address": f"Address {index}", + "creation_date": "2026-04-20", + "update_date": "2026-04-20", + "workgroup_id": "wg-123", + "team_id": "team-123", + "created_by": "user-123", + "thumbnail_url": "https://example.com/thumb.jpg", + "public_url": "https://example.com/plan", + "cloud_url": "https://example.com/cloud", + "3d_url": "https://example.com/3d", + } + + +def _get_plan_response(xml_str: str) -> dict: # type: ignore[type-arg] + return { + "message": "success", + "data": { + "plan": _summary_payload(), + "plan_detail": { + "magicplan_format_xml": xml_str, + }, + }, + } + + +def _get_plans_response(plan_ids: list[str]) -> dict: # type: ignore[type-arg] + return { + "message": "success", + "data": [_summary_payload(pid, i) for i, pid in enumerate(plan_ids)], + } + + +@pytest.fixture +def mock_session() -> MagicMock: + with patch("backend.magic_plan.magic_plan_client.requests.Session") as MockSession: + session: MagicMock = MagicMock() + MockSession.return_value = session + yield session + + +@pytest.fixture +def client(mock_session: MagicMock) -> MagicPlanClient: + return MagicPlanClient(api_key=API_KEY) + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + +class TestInit: + + def test_sets_bearer_auth_header(self, mock_session: MagicMock) -> None: + MagicPlanClient(api_key=API_KEY) + mock_session.headers.update.assert_called_once_with( + {"Authorization": f"Bearer {API_KEY}"} + ) + + +# --------------------------------------------------------------------------- +# get_plan_xml +# --------------------------------------------------------------------------- + +class TestGetPlanXml: + + def test_calls_correct_url(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + client.get_plan_xml(PLAN_ID) + mock_session.get.assert_called_once_with(f"{BASE_URL}/plans/get/{PLAN_ID}") + + def test_calls_raise_for_status(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + client.get_plan_xml(PLAN_ID) + mock_session.get.return_value.raise_for_status.assert_called_once() + + def test_returns_magic_plan_detail(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert isinstance(result, MagicPlanDetail) + + def test_detail_contains_summary(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert isinstance(result.summary, MagicPlanSummary) + + def test_detail_summary_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert result.summary.id == PLAN_ID + + def test_detail_summary_project_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert result.summary.project_id == PROJECT_ID + + def test_detail_contains_parsed_plan(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert isinstance(result.plan_xml, MagicPlanPlan) + + def test_plan_xml_id_matches_xml(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert result.plan_xml.id == PLAN_ID + + def test_plan_xml_floor_count(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert len(result.plan_xml.floors) == 2 + + def test_raises_on_http_error(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError("404") + with pytest.raises(requests.HTTPError): + client.get_plan_xml(PLAN_ID) + + +# --------------------------------------------------------------------------- +# get_plans +# --------------------------------------------------------------------------- + +class TestGetPlans: + + def test_calls_correct_url(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([]) + client.get_plans(PROJECT_ID) + mock_session.get.assert_called_once_with( + f"{BASE_URL}/plans", params={"project_id": PROJECT_ID} + ) + + def test_calls_raise_for_status(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([]) + client.get_plans(PROJECT_ID) + mock_session.get.return_value.raise_for_status.assert_called_once() + + def test_returns_list_of_summaries(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID, "other-id"]) + result = client.get_plans(PROJECT_ID) + assert len(result) == 2 + assert all(isinstance(s, MagicPlanSummary) for s in result) + + def test_summary_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID]) + result = client.get_plans(PROJECT_ID) + assert result[0].id == PLAN_ID + + def test_summary_project_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID]) + result = client.get_plans(PROJECT_ID) + assert result[0].project_id == PROJECT_ID + + def test_summary_name(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID]) + result = client.get_plans(PROJECT_ID) + assert result[0].name == "Plan 0" + + def test_returns_empty_list_when_no_plans(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([]) + result = client.get_plans(PROJECT_ID) + assert result == [] + + def test_raises_on_http_error(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError("401") + with pytest.raises(requests.HTTPError): + client.get_plans(PROJECT_ID) From f908c3ba9bcbecdce92e577321cade3f011b13b8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 28 Apr 2026 15:36:27 +0000 Subject: [PATCH 05/21] remove incorrect claude comments --- backend/magic_plan/models.py | 74 +++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/backend/magic_plan/models.py b/backend/magic_plan/models.py index 5bb5e413..016f2c65 100644 --- a/backend/magic_plan/models.py +++ b/backend/magic_plan/models.py @@ -14,6 +14,7 @@ class MagicPlanRoomPoint: @dataclass class MagicPlanWallPoint: """Point in — absolute coords, no uid or values.""" + x: float y: float height: float @@ -22,18 +23,19 @@ class MagicPlanWallPoint: @dataclass class MagicPlanDoor: """Door in context — wall-relative position.""" + wall_point_index: int type: str - u: float # position along wall (0-1) - width: float # m - depth: float # m - height: float # m + u: float + width: float # m + depth: float # m + height: float # m orientation: int snapped_type: str snapped_position: float - snapped_width: float # m - snapped_depth: float # m - snapped_height: float # m + snapped_width: float # m + snapped_depth: float # m + snapped_height: float # m snapped_orientation: int inset_x: float inset_y: float @@ -45,18 +47,19 @@ class MagicPlanDoor: @dataclass class MagicPlanWindow: """Window in context — wall-relative position.""" + wall_point_index: int type: str - u: float # position along wall (0-1) - width: float # m - depth: float # m - height: float # m + u: float + width: float # m + depth: float # m + height: float # m orientation: int snapped_type: str snapped_position: float - snapped_width: float # m - snapped_depth: float # m - snapped_height: float # m + snapped_width: float # m + snapped_depth: float # m + snapped_height: float # m snapped_orientation: int inset_x: float inset_y: float @@ -67,14 +70,15 @@ class MagicPlanWindow: @dataclass class MagicPlanExplodedOpening: """Door or window in context — absolute coords, no snapped* fields.""" + type: str x1: float y1: float x2: float y2: float - width: float # m - depth: float # m - height: float # m + width: float # m + depth: float # m + height: float # m inset_x: float inset_y: float orientation: int @@ -89,12 +93,12 @@ class MagicPlanFurniture: snapped_x: float snapped_y: float angle: float - width: float # m - depth: float # m - height: float # m - snapped_width: float # m - snapped_depth: float # m - snapped_height: float # m + width: float # m + depth: float # m + height: float # m + snapped_width: float # m + snapped_depth: float # m + snapped_height: float # m size_lock_0: str size_lock_1: str size_lock_2: str @@ -114,7 +118,7 @@ class MagicPlanMainDimension: @dataclass class MagicPlanExplodedWall: - wall_type: str # "exterior" | "interior" + wall_type: str # "exterior" | "interior" points: list[MagicPlanWallPoint] @@ -132,7 +136,7 @@ class MagicPlanSymbolInstance: uid: str parent_uid: str symbol: str - values: dict[str, str] # ceilingHeight, width, height, depth, distanceUnit, etc. + values: dict[str, str] # ceilingHeight, width, height, depth, distanceUnit, etc. @dataclass @@ -145,9 +149,9 @@ class MagicPlanRoom: was_modified: bool linked_room_0: str linked_room_1: str - area: float # m² - perimeter: float # m - values: dict[str, str] # ceilingHeight, label, etc. + area: float # m² + perimeter: float # m + values: dict[str, str] # ceilingHeight, label, etc. points: list[MagicPlanRoomPoint] doors: list[MagicPlanDoor] windows: list[MagicPlanWindow] @@ -159,12 +163,12 @@ class MagicPlanRoom: class MagicPlanFloor: uid: str name: str - floor_type: str # "0"=ground, "1"=upper, "2"=basement + floor_type: str # "0"=ground, "1"=upper, "2"=basement rotation: float compass_angle: float - area_without_walls: float # m² + area_without_walls: float # m² area_with_interior_walls_only: float # m² - area_with_walls: float # m² + area_with_walls: float # m² symbol_instance: MagicPlanSymbolInstance rooms: list[MagicPlanRoom] furniture: list[MagicPlanFurniture] # floor-level furniture (not inside any room) @@ -174,6 +178,7 @@ class MagicPlanFloor: @dataclass class MagicPlanSummary: """Plan metadata returned by the list-plans API endpoint.""" + id: str project_id: str name: str @@ -192,6 +197,7 @@ class MagicPlanSummary: @dataclass class MagicPlanDetail: """Full plan response: summary metadata + parsed XML plan.""" + summary: MagicPlanSummary plan_xml: "MagicPlanPlan" @@ -202,12 +208,12 @@ class MagicPlanPlan: uid: str name: str type: str - interior_wall_width: float # m - exterior_wall_width: float # m + interior_wall_width: float # m + exterior_wall_width: float # m schematic: bool has_land_survey_address: bool last_patch_identifier: str last_roll_identifier: str - values: dict[str, str] # date, statistics.*, distanceUnit, etc. + values: dict[str, str] # date, statistics.*, distanceUnit, etc. floors: list[MagicPlanFloor] interior_room_floors: list[MagicPlanFloor] # from From ff67297646a47ea02b77a3217930867237476035 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 1 May 2026 16:20:55 +0000 Subject: [PATCH 06/21] start mapping json --- backend/magic_plan/magic_plan_client.py | 45 +- .../magicplan_api_plan_response_example.json | 136742 +++++++++++++++ backend/magic_plan/models.py | 334 +- .../tests/test_magic_plan_client.py | 10 +- backend/magic_plan/tests/test_models.py | 50 + backend/magic_plan/tests/test_xml_parser.py | 226 +- backend/magic_plan/xml_parser.py | 82 +- 7 files changed, 137258 insertions(+), 231 deletions(-) create mode 100644 backend/magic_plan/magicplan_api_plan_response_example.json create mode 100644 backend/magic_plan/tests/test_models.py diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py index 29ad078a..9398f9b8 100644 --- a/backend/magic_plan/magic_plan_client.py +++ b/backend/magic_plan/magic_plan_client.py @@ -1,14 +1,51 @@ -from backend.magic_plan.models import MagicPlanDetail, MagicPlanSummary +import requests + +from backend.magic_plan.models import ( + MagicPlanPlan, + MagicPlanSummary, + MagicPlanXMLDetail, + MagicPlanXMLSummary, +) +from backend.magic_plan.xml_parser import parse_magicplan_xml + + +def _parse_xml_summary(data: dict[str, object]) -> MagicPlanXMLSummary: + return MagicPlanXMLSummary( + id=str(data.get("id", "")), + project_id=str(data.get("project_id", "")), + name=str(data.get("name", "")), + address=str(data.get("address", "")), + creation_date=str(data.get("creation_date", "")), + update_date=str(data.get("update_date", "")), + workgroup_id=str(data.get("workgroup_id", "")), + team_id=str(data.get("team_id", "")), + created_by=str(data.get("created_by", "")), + thumbnail_url=str(data.get("thumbnail_url", "")), + public_url=str(data.get("public_url", "")), + cloud_url=str(data.get("cloud_url", "")), + url_3d=str(data.get("3d_url", "")), + ) class MagicPlanClient: BASE_URL = "https://cloud.magicplan.app/api/v2" - def __init__(self, _api_key: str) -> None: - raise NotImplementedError + def __init__(self, api_key: str) -> None: + self._session = requests.Session() + self._session.headers.update({"Authorization": f"Bearer {api_key}"}) def get_plans(self, _project_id: str) -> list[MagicPlanSummary]: raise NotImplementedError - def get_plan_xml(self, _plan_id: str) -> MagicPlanDetail: + def get_plan_xml(self, plan_id: str) -> MagicPlanXMLDetail: + resp = self._session.get(f"{self.BASE_URL}/plans/get/{plan_id}") + resp.raise_for_status() + data: dict[str, object] = resp.json()["data"] + plan_data = data["plan"] # type: ignore[index] + detail_data = data["plan_detail"] # type: ignore[index] + summary = _parse_xml_summary(plan_data) # type: ignore[arg-type] + plan_xml = parse_magicplan_xml(detail_data["magicplan_format_xml"]) # type: ignore[index] + return MagicPlanXMLDetail(summary=summary, plan_xml=plan_xml) + + def get_plan(self, _plan_id: str) -> MagicPlanPlan: raise NotImplementedError diff --git a/backend/magic_plan/magicplan_api_plan_response_example.json b/backend/magic_plan/magicplan_api_plan_response_example.json new file mode 100644 index 00000000..d76b3540 --- /dev/null +++ b/backend/magic_plan/magicplan_api_plan_response_example.json @@ -0,0 +1,136742 @@ +{ + "message": "OK", + "data": { + "plan": { + "id": "a7285ed1-878d-47eb-8aa6-85ef9e187516", + "project_id": "9f8f3208-0f04-466f-9c4c-e776532183c8", + "name": "2, Br2 8bz", + "address": { + "street": "2 Laburnum Way", + "street_number": null, + "postal_code": "BR2 8BZ", + "city": "Bromley", + "country": "GB", + "longitude": 0.0616749, + "latitude": 51.3835182 + }, + "creation_date": "2026-04-28T08:32:58+00:00", + "update_date": "2026-04-29T14:58:54+00:00", + "thumbnail_url": "https:\/\/s3.amazonaws.com\/prod.plans.sensopia.com\/a7285ed1-878d-47eb-8aa6-85ef9e187516\/plan.thumb", + "public_url": "https:\/\/cloud.magicplan.app\/plan\/a7285ed1-878d-47eb-8aa6-85ef9e187516", + "cloud_url": "https:\/\/cloud.magicplan.app\/projects\/a7285ed1-878d-47eb-8aa6-85ef9e187516", + "3d_url": "https:\/\/3d.magicplan.app\/#embed\/?key=YzBkMTQyZDRlY2E5MmEzMWQ4NWE1NWJmMGE4OTQ5ZjMwOTNlZjcwNjhkN2U4ODg5ZDZiMDI1OTRkNWU5ZTY0N%2B9n3Xg%2FF422BetMnabb%2FwQI3XiEQbNltioOXI05WueYapFlJvuxgPLnzxjLI1eFcsii6s7vRgs71gHD1LPsSBcNGjF424hTkMCt9hxbCryf", + "workgroup_id": "677d01685458a", + "team_id": null, + "created_by": { + "id": "49c5fd0d-5031-4a7d-aa59-3cc1b64d18aa", + "firstname": null, + "lastname": null, + "email": "sebastian@osmosis-acd.com" + } + }, + "plan_detail": { + "magicplan_format_xml": "\n2026-04-24<\/value>2.134<\/value>100<\/value>0<\/value>0<\/value><\/values>Ground Floor<\/name>2.450007<\/value>Total m2 =1.196 yd\u00b2 <\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>outdoors<\/value>m3<\/value><\/values><\/symbolInstance>annotations<\/value>3<\/value>left<\/value>M2 - 44.19\nHeight - 2.43\nHLP - 20.56\nPWL - 6.12<\/value>top<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>annotations<\/value>7.22m<\/value><\/values><\/symbolInstance>annotations<\/value>6.12m<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.026217<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.905517<\/value>1.20394<\/value><\/values><\/symbolInstance>0.496099<\/value>0.241025<\/value>plumbing<\/value>0.682423<\/value>0.241025<\/value>0.454712<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.014143<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.963297<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.014739<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.985417<\/value>1.099043<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.911601<\/value>1.123649<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.057803<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.942764<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.968262<\/value>1.063659<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.014143<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.133701<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.014739<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.942764<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.867147<\/value>1.202909<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>electrical<\/value>m3<\/value>1.1<\/value><\/values><\/symbolInstance>structure<\/value>0<\/value>1<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>2.057803<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.993003<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.963297<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>2.450007<\/value>0.5x1.2 (x2)\n<\/value>2500 (x2)<\/value>Between.15.and.30.degrees<\/value>0<\/value>9.X.6<\/value>0<\/value>0<\/value>9<\/value><\/values><\/floorRoom>