diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 1ebd610..36cd1ca 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -663,7 +663,8 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory) transition.TransitionType, transition.Notes, transition.IsLocked, - transition.DisplayLabel + transition.DisplayLabel, + transition.PositionWithinLocation }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index a9dc228..52336fa 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1201,8 +1201,12 @@ public static class FloorPlanTransitionTypes public const string DoubleDoor = "Double Door"; public const string Archway = "Archway"; public const string OpenPassage = "Open Passage"; + public const string HiddenPassage = "Hidden Passage"; public const string FrenchDoors = "French Doors"; public const string SlidingDoors = "Sliding Doors"; + public const string ExteriorDoor = "Exterior Door"; + public const string HiddenDoor = "Hidden Door"; + public const string BlockedDoor = "Blocked Door"; public const string StairsUp = "Stairs Up"; public const string StairsDown = "Stairs Down"; public const string Ladder = "Ladder"; @@ -1215,8 +1219,12 @@ public static class FloorPlanTransitionTypes DoubleDoor, Archway, OpenPassage, + HiddenPassage, FrenchDoors, SlidingDoors, + ExteriorDoor, + HiddenDoor, + BlockedDoor, StairsUp, StairsDown, Ladder, @@ -1224,7 +1232,44 @@ public static class FloorPlanTransitionTypes Window ]; - public static bool IsValid(string? transitionType) => All.Contains(transitionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + public static bool IsValid(string? transitionType) + { + if (All.Contains(transitionType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + + var normalized = new string((transitionType ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + return normalized is "frenchdoor" or "slidingdoor"; + } +} + +public static class FloorPlanTransitionPositions +{ + public const string Centre = "Centre"; + public const string North = "North"; + public const string South = "South"; + public const string East = "East"; + public const string West = "West"; + public const string NorthWest = "North West"; + public const string NorthEast = "North East"; + public const string SouthWest = "South West"; + public const string SouthEast = "South East"; + + public static readonly IReadOnlyList All = + [ + Centre, + North, + South, + East, + West, + NorthWest, + NorthEast, + SouthWest, + SouthEast + ]; + + public static bool IsValid(string? position) => All.Contains(position ?? string.Empty, StringComparer.OrdinalIgnoreCase); } public sealed class FloorPlanTransition @@ -1241,6 +1286,7 @@ public sealed class FloorPlanTransition public string? Notes { get; set; } public bool IsLocked { get; set; } public string? DisplayLabel { get; set; } + public string PositionWithinLocation { get; set; } = FloorPlanTransitionPositions.Centre; public DateTime CreatedDate { get; set; } public DateTime? ModifiedDate { get; set; } } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 8db386a..ed68c2c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -7598,7 +7598,8 @@ public sealed class FloorPlanService( NewTransition = new FloorPlanTransitionEditViewModel { FloorPlanFloorID = orderedFloors.FirstOrDefault()?.FloorPlanFloorID ?? 0, - TransitionType = FloorPlanTransitionTypes.Door + TransitionType = FloorPlanTransitionTypes.Door, + PositionWithinLocation = FloorPlanTransitionPositions.Centre }, LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"), Locations = projectLocations, @@ -7732,7 +7733,11 @@ public sealed class FloorPlanService( var floor = data.Floors.FirstOrDefault(x => x.FloorPlanFloorID == model.FloorPlanFloorID) ?? throw new InvalidOperationException("Floor not found."); - ValidateTransition(model, floor, data.Blocks, data.Transitions); + ValidateTransition(model, floor, data.Blocks, data.Transitions, data.Floors); + if (!IsStairTransition(model.TransitionType)) + { + model.PositionWithinLocation = FloorPlanTransitionPositions.Centre; + } var transitionId = await floorPlans.SaveTransitionAsync(ToTransition(model)); await activity.RecordAsync(data.FloorPlan.ProjectID, model.FloorPlanTransitionID == 0 ? "Created" : "Updated", "Floor Plan Transition", transitionId, model.TransitionType); return transitionId; @@ -7860,11 +7865,43 @@ public sealed class FloorPlanService( } } + private static string NormalizeTransitionType(string? transitionType) + => new((transitionType ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private static bool IsSameFloorAdjacentTransition(string? transitionType) + => NormalizeTransitionType(transitionType) is "door" + or "doubledoor" + or "frenchdoor" + or "frenchdoors" + or "slidingdoor" + or "slidingdoors" + or "window" + or "archway" + or "exteriordoor" + or "hiddendoor" + or "blockeddoor"; + + private static bool IsUnrestrictedTransition(string? transitionType) + => NormalizeTransitionType(transitionType) is "hiddenpassage" or "openpassage" or "secretpassage"; + + private static bool IsStairTransition(string? transitionType) + => NormalizeTransitionType(transitionType) is "stairsup" or "stairsdown"; + + private static bool BlocksTouchAlongEdge(FloorPlanBlock a, FloorPlanBlock b) + { + var verticalTouch = a.X + a.WidthCells == b.X || b.X + b.WidthCells == a.X; + var verticalOverlap = a.Y < b.Y + b.HeightCells && b.Y < a.Y + a.HeightCells; + var horizontalTouch = a.Y + a.HeightCells == b.Y || b.Y + b.HeightCells == a.Y; + var horizontalOverlap = a.X < b.X + b.WidthCells && b.X < a.X + a.WidthCells; + return (verticalTouch && verticalOverlap) || (horizontalTouch && horizontalOverlap); + } + private static void ValidateTransition( FloorPlanTransitionEditViewModel model, FloorPlanFloor floor, IReadOnlyList blocks, - IReadOnlyList transitions) + IReadOnlyList transitions, + IReadOnlyList floors) { if (!FloorPlanTransitionTypes.IsValid(model.TransitionType)) { @@ -7881,16 +7918,65 @@ public sealed class FloorPlanService( throw new InvalidOperationException("From and To locations must be different."); } - var floorLocationIds = blocks - .Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID) - .Select(x => x.LocationID) - .ToHashSet(); - - if (!floorLocationIds.Contains(model.FromLocationID.Value) || !floorLocationIds.Contains(model.ToLocationID.Value)) + if (!FloorPlanTransitionPositions.IsValid(model.PositionWithinLocation)) { - throw new InvalidOperationException("Transition locations must both exist on the active floor."); + throw new InvalidOperationException("Choose a valid stair position."); } + var currentFloorBlocks = blocks + .Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID) + .ToList(); + var floorLocationIds = currentFloorBlocks.Select(x => x.LocationID).ToHashSet(); + + if (!floorLocationIds.Contains(model.FromLocationID.Value)) + { + throw new InvalidOperationException("From Location must exist on the active floor."); + } + + if (IsStairTransition(model.TransitionType)) + { + var targetLevel = floor.SortOrder + (NormalizeTransitionType(model.TransitionType) == "stairsup" ? 1 : -1); + var targetFloorIds = floors.Where(x => x.SortOrder == targetLevel).Select(x => x.FloorPlanFloorID).ToHashSet(); + var targetLocationIds = blocks + .Where(x => targetFloorIds.Contains(x.FloorPlanFloorID)) + .Select(x => x.LocationID) + .ToHashSet(); + + if (!targetLocationIds.Contains(model.ToLocationID.Value)) + { + throw new InvalidOperationException("Stairs must connect to a location on the adjacent floor level."); + } + + return; + } + + if (IsUnrestrictedTransition(model.TransitionType)) + { + var planLocationIds = blocks.Select(x => x.LocationID).ToHashSet(); + if (!planLocationIds.Contains(model.ToLocationID.Value)) + { + throw new InvalidOperationException("To Location must exist in this floor plan."); + } + + return; + } + + if (!IsSameFloorAdjacentTransition(model.TransitionType)) + { + throw new InvalidOperationException("Choose a valid transition type."); + } + + if (!floorLocationIds.Contains(model.ToLocationID.Value)) + { + throw new InvalidOperationException("To Location must exist on the active floor."); + } + + var fromBlocks = currentFloorBlocks.Where(x => x.LocationID == model.FromLocationID.Value).ToList(); + var toBlocks = currentFloorBlocks.Where(x => x.LocationID == model.ToLocationID.Value).ToList(); + if (!fromBlocks.Any(from => toBlocks.Any(to => BlocksTouchAlongEdge(from, to)))) + { + throw new InvalidOperationException("Door and window transitions must connect adjacent locations."); + } } private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId) @@ -8111,7 +8197,10 @@ public sealed class FloorPlanService( TransitionType = transition.TransitionType, Notes = transition.Notes, IsLocked = transition.IsLocked, - DisplayLabel = transition.DisplayLabel + DisplayLabel = transition.DisplayLabel, + PositionWithinLocation = string.IsNullOrWhiteSpace(transition.PositionWithinLocation) + ? FloorPlanTransitionPositions.Centre + : transition.PositionWithinLocation }; private static FloorPlanTransition ToTransition(FloorPlanTransitionEditViewModel model) => new() @@ -8123,7 +8212,10 @@ public sealed class FloorPlanService( TransitionType = model.TransitionType, Notes = model.Notes, IsLocked = model.IsLocked, - DisplayLabel = model.DisplayLabel + DisplayLabel = model.DisplayLabel, + PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation) + ? FloorPlanTransitionPositions.Centre + : model.PositionWithinLocation }; private static string BlockTypeLabel(string blockType) => blockType switch diff --git a/PlotLine/Sql/077_FloorPlanStairTransitionPlacement.sql b/PlotLine/Sql/077_FloorPlanStairTransitionPlacement.sql new file mode 100644 index 0000000..8363014 --- /dev/null +++ b/PlotLine/Sql/077_FloorPlanStairTransitionPlacement.sql @@ -0,0 +1,145 @@ +IF COL_LENGTH(N'dbo.FloorPlanTransitions', N'PositionWithinLocation') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanTransitions + ADD PositionWithinLocation nvarchar(20) NOT NULL + CONSTRAINT DF_FloorPlanTransitions_PositionWithinLocation DEFAULT N'Centre'; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor + @FloorPlanID int +AS +BEGIN + SET NOCOUNT ON; + + EXEC dbo.FloorPlan_Get @FloorPlanID; + + SELECT + fpf.FloorPlanFloorID, + fpf.FloorPlanID, + fpf.LocationID, + lp.LocationName, + lp.LocationPath, + fpf.Name, + fpf.SortOrder, + fpf.GridWidth, + fpf.GridHeight, + fpf.BackgroundImagePath, + fpf.BackgroundImageOriginalFileName, + COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity, + COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale, + COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX, + COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY, + fpf.BackgroundLocked, + fpf.CreatedDate, + fpf.ModifiedDate + FROM dbo.FloorPlanFloors fpf + LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fpf.Name; + + SELECT + fpb.FloorPlanBlockID, + fpb.FloorPlanFloorID, + fpb.LocationID, + lp.LocationName, + COALESCE(NULLIF(lp.LocationPath, N''), lp.LocationName) AS LocationPath, + fpb.X, + fpb.Y, + fpb.WidthCells, + fpb.HeightCells, + fpb.BlockType, + fpb.LabelOverride, + fpb.Notes, + fpb.CreatedDate, + fpb.ModifiedDate + FROM dbo.FloorPlanBlocks fpb + INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID + INNER JOIN dbo.LocationPaths lp ON lp.LocationID = fpb.LocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fpb.Y, fpb.X, lp.LocationName; + + SELECT + fpt.FloorPlanTransitionID, + fpt.FloorPlanFloorID, + fpt.FromLocationID, + fromPath.LocationName AS FromLocationName, + COALESCE(NULLIF(fromPath.LocationPath, N''), fromPath.LocationName) AS FromLocationPath, + fpt.ToLocationID, + toPath.LocationName AS ToLocationName, + COALESCE(NULLIF(toPath.LocationPath, N''), toPath.LocationName) AS ToLocationPath, + fpt.TransitionType, + fpt.Notes, + fpt.IsLocked, + fpt.DisplayLabel, + fpt.PositionWithinLocation, + fpt.CreatedDate, + fpt.ModifiedDate + FROM dbo.FloorPlanTransitions fpt + INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpt.FloorPlanFloorID + INNER JOIN dbo.LocationPaths fromPath ON fromPath.LocationID = fpt.FromLocationID + INNER JOIN dbo.LocationPaths toPath ON toPath.LocationID = fpt.ToLocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fromPath.LocationName, toPath.LocationName, fpt.TransitionType; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanTransition_Save + @FloorPlanTransitionID int, + @FloorPlanFloorID int, + @FromLocationID int, + @ToLocationID int, + @TransitionType nvarchar(50), + @Notes nvarchar(max) = NULL, + @IsLocked bit = 0, + @DisplayLabel nvarchar(255) = NULL, + @PositionWithinLocation nvarchar(20) = N'Centre' +AS +BEGIN + SET NOCOUNT ON; + + IF @FloorPlanTransitionID = 0 + BEGIN + INSERT dbo.FloorPlanTransitions + ( + FloorPlanFloorID, + FromLocationID, + ToLocationID, + TransitionType, + Notes, + IsLocked, + DisplayLabel, + PositionWithinLocation + ) + VALUES + ( + @FloorPlanFloorID, + @FromLocationID, + @ToLocationID, + @TransitionType, + @Notes, + @IsLocked, + @DisplayLabel, + COALESCE(NULLIF(@PositionWithinLocation, N''), N'Centre') + ); + + SELECT CONVERT(int, SCOPE_IDENTITY()) AS FloorPlanTransitionID; + RETURN; + END; + + UPDATE dbo.FloorPlanTransitions + SET + FromLocationID = @FromLocationID, + ToLocationID = @ToLocationID, + TransitionType = @TransitionType, + Notes = @Notes, + IsLocked = @IsLocked, + DisplayLabel = @DisplayLabel, + PositionWithinLocation = COALESCE(NULLIF(@PositionWithinLocation, N''), N'Centre'), + ModifiedDate = SYSUTCDATETIME() + WHERE FloorPlanTransitionID = @FloorPlanTransitionID + AND FloorPlanFloorID = @FloorPlanFloorID; + + SELECT @FloorPlanTransitionID AS FloorPlanTransitionID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index fe5964f..0776c3c 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1975,6 +1975,9 @@ public sealed class FloorPlanTransitionEditViewModel [StringLength(255)] public string? DisplayLabel { get; set; } + [StringLength(20)] + public string PositionWithinLocation { get; set; } = FloorPlanTransitionPositions.Centre; + public string Label => string.IsNullOrWhiteSpace(DisplayLabel) ? TransitionType : DisplayLabel; } diff --git a/PlotLine/Views/FloorPlans/Edit.cshtml b/PlotLine/Views/FloorPlans/Edit.cshtml index 14873a3..7507841 100644 --- a/PlotLine/Views/FloorPlans/Edit.cshtml +++ b/PlotLine/Views/FloorPlans/Edit.cshtml @@ -28,10 +28,10 @@ "doubledoor" => "double-door", "window" => "window", "archway" or "opening" => "archway", - "secretdoor" => "secret-door", + "secretdoor" or "hiddendoor" => "secret-door", "blockeddoor" => "blocked-door", "exteriordoor" => "exterior-door", - "stairs" or "stair" => "stairs", + "stairs" or "stair" or "stairsup" or "stairsdown" => "stairs", "passage" => "passage", _ => "other" }; @@ -50,6 +50,10 @@ "passage" => @"", _ => @"x" }; + + var transitionPositions = FloorPlanTransitionPositions.All + .Select(x => new SelectListItem(x, x)) + .ToList(); }