diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index bf43abd..c4ed46a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -160,7 +160,7 @@ Handles the loading of plan data. * **maxItems**: Optional limit on the number of plans to load. * **logger**: Optional callback to receive real-time progress updates (e.g., dependency installation logs, download progress). * **constraints**: Optional `PlanGenerationConstraints` to filter or orient plans. - * **BoundingPolygon**: If set, only plans whose bounds are completely contained within this polygon are returned. + * **BoundingPolygon**: If set, only plans whose geometry is completely contained within this polygon are returned. The check first validates the plan's Axis-Aligned Bounding Box (AABB) for performance, and if that fails, performs a precise geometry-by-geometry containment check to support rotated plans. * **FrontDoorFacing**: If set, plans are rotated so their front door (vector from center to door) aligns with this vector. * **GarageFacing**: Currently unsupported; will log a warning if set. * **Returns**: A list of `Plan` objects. diff --git a/ResPlan.Library/AssemblyInfo.cs b/ResPlan.Library/AssemblyInfo.cs new file mode 100644 index 0000000..a4991f7 --- /dev/null +++ b/ResPlan.Library/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ResPlan.Tests")] diff --git a/ResPlan.Library/PlanLoader.cs b/ResPlan.Library/PlanLoader.cs index 6b281c0..6036012 100644 --- a/ResPlan.Library/PlanLoader.cs +++ b/ResPlan.Library/PlanLoader.cs @@ -175,17 +175,9 @@ private static List ApplyConstraints(List plans, PlanGenerationConst } // Apply Bounding Constraint - if (constraints.BoundingPolygon != null) + if (!IsPlanCompatible(plan, constraints.BoundingPolygon)) { - // Check if plan bounds are within polygon - // Since plan.Bounds is an Envelope, we convert to geometry - var geometryFactory = new GeometryFactory(); - var planGeom = geometryFactory.ToGeometry(plan.Bounds); - - if (!constraints.BoundingPolygon.Contains(planGeom)) - { - continue; // Filter out - } + continue; } result.Add(plan); @@ -193,6 +185,41 @@ private static List ApplyConstraints(List plans, PlanGenerationConst return result; } + internal static bool IsPlanCompatible(Plan plan, Polygon boundingPolygon) + { + if (boundingPolygon == null) return true; + + // Check if plan bounds are within polygon + // Since plan.Bounds is an Envelope, we convert to geometry + var geometryFactory = new GeometryFactory(); + var planGeom = geometryFactory.ToGeometry(plan.Bounds); + + // Fast check: if AABB is contained, we are good. + if (boundingPolygon.Contains(planGeom)) + { + return true; + } + + // Fallback: Precise check using actual geometries + // If the AABB doesn't fit (e.g. rotated plan), check if all individual geometries fit + bool allInside = true; + foreach (var kvp in plan.Geometries) + { + foreach (var geom in kvp.Value) + { + // Use Covers instead of Contains to allow boundary contact + if (!boundingPolygon.Covers(geom)) + { + allInside = false; + break; + } + } + if (!allInside) break; + } + + return allInside; + } + private static Plan ConvertDataToPlan(ResPlanData data) { var plan = new Plan diff --git a/ResPlan.Tests/ConstraintTests.cs b/ResPlan.Tests/ConstraintTests.cs new file mode 100644 index 0000000..72abd53 --- /dev/null +++ b/ResPlan.Tests/ConstraintTests.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using NetTopologySuite.Geometries; +using ResPlan.Library; +using Xunit; + +namespace ResPlan.Tests +{ + public class ConstraintTests + { + [Fact] + public void IsPlanCompatible_RotatedPlanInRotatedLot_ReturnsTrue() + { + // Arrange + var geometryFactory = new GeometryFactory(); + + // Create a Plan with a 10x10 square at (0,0) + // Centered at 5,5 + var coords = new Coordinate[] + { + new Coordinate(0, 0), + new Coordinate(10, 0), + new Coordinate(10, 10), + new Coordinate(0, 10), + new Coordinate(0, 0) + }; + var shell = geometryFactory.CreateLinearRing(coords); + var poly = geometryFactory.CreatePolygon(shell); + + var plan = new Plan + { + Id = 1, + Geometries = new Dictionary> + { + { "room", new List { poly } } + } + }; + // Initial bounds (0,0) to (10,10) + plan.Bounds = new Envelope(0, 10, 0, 10); + + // Rotate the plan by 45 degrees around its center (5,5) + plan.Rotate(System.Math.PI / 4.0, new Coordinate(5, 5)); + + // Now create a bounding polygon that matches the rotated plan geometry exactly + // We can cheat by using the plan's geometry itself (the rotated poly) + // But to be rigorous, let's use the rotated poly from the plan + var rotatedGeom = plan.Geometries["room"][0]; + Assert.True(rotatedGeom is Polygon); + var boundingPolygon = (Polygon)rotatedGeom.Copy(); + + // The bounding polygon now matches the plan's geometry. + // However, plan.Bounds (AABB) will be larger than the rotated geometry. + // AABB of a 45-degree rotated 10x10 square is roughly 14.14x14.14. + // This AABB will NOT be contained in the 10x10 rotated square (which is the boundingPolygon). + + // Act + bool isCompatible = PlanLoader.IsPlanCompatible(plan, boundingPolygon); + + // Assert + // This should be true if we are checking geometry containment correctly. + // It will be false if we are only checking AABB containment. + Assert.True(isCompatible, "Plan should be compatible with a bounding polygon that exactly matches its geometry."); + } + } +}