From 18858f3fbaf26498dbdf302cc7594d98f5ccba21 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 20 Jun 2026 21:41:40 +0100 Subject: [PATCH] Implemented the Floor Plans multi-block room and transition phase. --- PlotLine/Controllers/FloorPlansController.cs | 37 +++ PlotLine/Data/Repositories.cs | 34 +- PlotLine/Models/CoreModels.cs | 50 +++ PlotLine/Services/CoreServices.cs | 113 ++++++- PlotLine/Sql/075_FloorPlanTransitions.sql | 189 +++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 32 ++ PlotLine/Views/FloorPlans/Edit.cshtml | 319 ++++++++++++++++++- PlotLine/wwwroot/css/site.css | 93 +++++- PlotLine/wwwroot/css/site.min.css | 2 +- 9 files changed, 859 insertions(+), 10 deletions(-) create mode 100644 PlotLine/Sql/075_FloorPlanTransitions.sql diff --git a/PlotLine/Controllers/FloorPlansController.cs b/PlotLine/Controllers/FloorPlansController.cs index 62081ba..df295b5 100644 --- a/PlotLine/Controllers/FloorPlansController.cs +++ b/PlotLine/Controllers/FloorPlansController.cs @@ -178,6 +178,43 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control return RedirectToAction(nameof(Edit), new { id = floorPlanId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SaveTransition(int floorPlanId, FloorPlanTransitionEditViewModel model) + { + try + { + if (ModelState.IsValid) + { + await floorPlans.SaveTransitionAsync(floorPlanId, model); + TempData["FloorPlanMessage"] = model.FloorPlanTransitionID == 0 ? "Transition created." : "Transition updated."; + } + } + catch (InvalidOperationException ex) + { + TempData["FloorPlanError"] = ex.Message; + } + + return RedirectToAction(nameof(Edit), new { id = floorPlanId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteTransition(int floorPlanId, int floorPlanTransitionId) + { + try + { + await floorPlans.DeleteTransitionAsync(floorPlanId, floorPlanTransitionId); + TempData["FloorPlanMessage"] = "Transition deleted."; + } + catch (InvalidOperationException ex) + { + TempData["FloorPlanError"] = ex.Message; + } + + return RedirectToAction(nameof(Edit), new { id = floorPlanId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 8a73819..1ebd610 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -218,7 +218,7 @@ public interface IFloorPlanRepository { Task> ListByProjectAsync(int projectId); Task GetAsync(int floorPlanId); - Task<(FloorPlan? FloorPlan, IReadOnlyList Floors, IReadOnlyList Blocks)> GetEditorAsync(int floorPlanId); + Task<(FloorPlan? FloorPlan, IReadOnlyList Floors, IReadOnlyList Blocks, IReadOnlyList Transitions)> GetEditorAsync(int floorPlanId); Task GetFloorAsync(int floorPlanFloorId); Task SavePlanAsync(FloorPlan floorPlan); Task DeletePlanAsync(int floorPlanId); @@ -227,6 +227,8 @@ public interface IFloorPlanRepository Task DeleteFloorAsync(int floorPlanFloorId); Task SaveBlockAsync(FloorPlanBlock block); Task DeleteBlockAsync(int floorPlanBlockId); + Task SaveTransitionAsync(FloorPlanTransition transition); + Task DeleteTransitionAsync(int floorPlanTransitionId); } public interface IWarningRepository @@ -552,14 +554,15 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory) return await connection.QuerySingleOrDefaultAsync("dbo.FloorPlan_Get", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure); } - public async Task<(FloorPlan? FloorPlan, IReadOnlyList Floors, IReadOnlyList Blocks)> GetEditorAsync(int floorPlanId) + public async Task<(FloorPlan? FloorPlan, IReadOnlyList Floors, IReadOnlyList Blocks, IReadOnlyList Transitions)> GetEditorAsync(int floorPlanId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.FloorPlan_GetEditor", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure); var floorPlan = await result.ReadSingleOrDefaultAsync(); var floors = (await result.ReadAsync()).ToList(); var blocks = (await result.ReadAsync()).ToList(); - return (floorPlan, floors, blocks); + var transitions = (await result.ReadAsync()).ToList(); + return (floorPlan, floors, blocks, transitions); } public async Task GetFloorAsync(int floorPlanFloorId) @@ -645,6 +648,31 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory) using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.FloorPlanBlock_Delete", new { FloorPlanBlockID = floorPlanBlockId }, commandType: CommandType.StoredProcedure); } + + public async Task SaveTransitionAsync(FloorPlanTransition transition) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.FloorPlanTransition_Save", + new + { + transition.FloorPlanTransitionID, + transition.FloorPlanFloorID, + transition.FromLocationID, + transition.ToLocationID, + transition.TransitionType, + transition.Notes, + transition.IsLocked, + transition.DisplayLabel + }, + commandType: CommandType.StoredProcedure); + } + + public async Task DeleteTransitionAsync(int floorPlanTransitionId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync("dbo.FloorPlanTransition_Delete", new { FloorPlanTransitionID = floorPlanTransitionId }, commandType: CommandType.StoredProcedure); + } } public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) : IWarningRepository diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index d322103..a9dc228 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1195,6 +1195,56 @@ public sealed class FloorPlanBlock public string DisplayLabel => string.IsNullOrWhiteSpace(LabelOverride) ? LocationName : LabelOverride; } +public static class FloorPlanTransitionTypes +{ + public const string Door = "Door"; + public const string DoubleDoor = "Double Door"; + public const string Archway = "Archway"; + public const string OpenPassage = "Open Passage"; + public const string FrenchDoors = "French Doors"; + public const string SlidingDoors = "Sliding Doors"; + public const string StairsUp = "Stairs Up"; + public const string StairsDown = "Stairs Down"; + public const string Ladder = "Ladder"; + public const string SecretPassage = "Secret Passage"; + public const string Window = "Window"; + + public static readonly IReadOnlyList All = + [ + Door, + DoubleDoor, + Archway, + OpenPassage, + FrenchDoors, + SlidingDoors, + StairsUp, + StairsDown, + Ladder, + SecretPassage, + Window + ]; + + public static bool IsValid(string? transitionType) => All.Contains(transitionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); +} + +public sealed class FloorPlanTransition +{ + public int FloorPlanTransitionID { get; set; } + public int FloorPlanFloorID { get; set; } + public int FromLocationID { get; set; } + public string FromLocationName { get; set; } = string.Empty; + public string FromLocationPath { get; set; } = string.Empty; + public int ToLocationID { get; set; } + public string ToLocationName { get; set; } = string.Empty; + public string ToLocationPath { get; set; } = string.Empty; + public string TransitionType { get; set; } = FloorPlanTransitionTypes.Door; + public string? Notes { get; set; } + public bool IsLocked { get; set; } + public string? DisplayLabel { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime? ModifiedDate { get; set; } +} + public sealed class SceneAssetLocation { public int SceneAssetLocationID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 523a630..6eb6931 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -191,6 +191,8 @@ public interface IFloorPlanService Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model); Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId); Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId); + Task SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model); + Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId); Task DeleteBlockAsync(int floorPlanBlockId); } @@ -7576,6 +7578,7 @@ public sealed class FloorPlanService( }, Floors = data.Floors.Select(ToFloorEdit).ToList(), Blocks = data.Blocks.Select(ToBlockEdit).ToList(), + Transitions = data.Transitions.Select(ToTransitionEdit).ToList(), NewFloor = new FloorPlanFloorEditViewModel { FloorPlanID = data.FloorPlan.FloorPlanID, @@ -7591,9 +7594,15 @@ public sealed class FloorPlanService( WidthCells = 2, HeightCells = 1 }, + NewTransition = new FloorPlanTransitionEditViewModel + { + FloorPlanFloorID = data.Floors.FirstOrDefault()?.FloorPlanFloorID ?? 0, + TransitionType = FloorPlanTransitionTypes.Door + }, LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"), Locations = projectLocations, - BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList() + BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList(), + TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList() }; } @@ -7711,6 +7720,37 @@ public sealed class FloorPlanService( public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId); + public async Task SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model) + { + var data = await floorPlans.GetEditorAsync(floorPlanId); + if (data.FloorPlan is null) + { + throw new InvalidOperationException("Floor plan not found."); + } + + var floor = data.Floors.FirstOrDefault(x => x.FloorPlanFloorID == model.FloorPlanFloorID) + ?? throw new InvalidOperationException("Floor not found."); + + ValidateTransition(model, floor, data.Blocks, data.Transitions); + 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; + } + + public async Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId) + { + var data = await floorPlans.GetEditorAsync(floorPlanId); + if (data.FloorPlan is null) + { + throw new InvalidOperationException("Floor plan not found."); + } + + var transition = data.Transitions.FirstOrDefault(x => x.FloorPlanTransitionID == floorPlanTransitionId) + ?? throw new InvalidOperationException("Transition not found."); + await floorPlans.DeleteTransitionAsync(floorPlanTransitionId); + await activity.RecordAsync(data.FloorPlan.ProjectID, "Deleted", "Floor Plan Transition", floorPlanTransitionId, transition.TransitionType); + } + public async Task UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload) { var (plan, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId); @@ -7819,6 +7859,49 @@ public sealed class FloorPlanService( } } + private static void ValidateTransition( + FloorPlanTransitionEditViewModel model, + FloorPlanFloor floor, + IReadOnlyList blocks, + IReadOnlyList transitions) + { + if (!FloorPlanTransitionTypes.IsValid(model.TransitionType)) + { + throw new InvalidOperationException("Choose a valid transition type."); + } + + if (!model.FromLocationID.HasValue || !model.ToLocationID.HasValue) + { + throw new InvalidOperationException("Choose both transition locations."); + } + + if (model.FromLocationID.Value == model.ToLocationID.Value) + { + 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)) + { + throw new InvalidOperationException("Transition locations must both exist on the active floor."); + } + + var duplicate = transitions.Any(x => x.FloorPlanFloorID == floor.FloorPlanFloorID + && x.FloorPlanTransitionID != model.FloorPlanTransitionID + && x.FromLocationID == model.FromLocationID.Value + && x.ToLocationID == model.ToLocationID.Value + && string.Equals(x.TransitionType, model.TransitionType, StringComparison.OrdinalIgnoreCase)); + + if (duplicate) + { + throw new InvalidOperationException("That transition already exists on this floor."); + } + } + private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId) { var plan = await floorPlans.GetAsync(floorPlanId) @@ -8019,6 +8102,34 @@ public sealed class FloorPlanService( Notes = model.Notes }; + private static FloorPlanTransitionEditViewModel ToTransitionEdit(FloorPlanTransition transition) => new() + { + FloorPlanTransitionID = transition.FloorPlanTransitionID, + FloorPlanFloorID = transition.FloorPlanFloorID, + FromLocationID = transition.FromLocationID, + FromLocationName = transition.FromLocationName, + FromLocationPath = transition.FromLocationPath, + ToLocationID = transition.ToLocationID, + ToLocationName = transition.ToLocationName, + ToLocationPath = transition.ToLocationPath, + TransitionType = transition.TransitionType, + Notes = transition.Notes, + IsLocked = transition.IsLocked, + DisplayLabel = transition.DisplayLabel + }; + + private static FloorPlanTransition ToTransition(FloorPlanTransitionEditViewModel model) => new() + { + FloorPlanTransitionID = model.FloorPlanTransitionID, + FloorPlanFloorID = model.FloorPlanFloorID, + FromLocationID = model.FromLocationID!.Value, + ToLocationID = model.ToLocationID!.Value, + TransitionType = model.TransitionType, + Notes = model.Notes, + IsLocked = model.IsLocked, + DisplayLabel = model.DisplayLabel + }; + private static string BlockTypeLabel(string blockType) => blockType switch { FloorPlanBlockTypes.OpenSpace => "Open space", diff --git a/PlotLine/Sql/075_FloorPlanTransitions.sql b/PlotLine/Sql/075_FloorPlanTransitions.sql new file mode 100644 index 0000000..a388d88 --- /dev/null +++ b/PlotLine/Sql/075_FloorPlanTransitions.sql @@ -0,0 +1,189 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.FloorPlanTransitions', N'U') IS NULL +BEGIN + CREATE TABLE dbo.FloorPlanTransitions + ( + FloorPlanTransitionID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FloorPlanTransitions PRIMARY KEY, + FloorPlanFloorID int NOT NULL, + FromLocationID int NOT NULL, + ToLocationID int NOT NULL, + TransitionType nvarchar(50) NOT NULL, + Notes nvarchar(max) NULL, + IsLocked bit NOT NULL CONSTRAINT DF_FloorPlanTransitions_IsLocked DEFAULT 0, + DisplayLabel nvarchar(255) NULL, + CreatedDate datetime2 NOT NULL CONSTRAINT DF_FloorPlanTransitions_CreatedDate DEFAULT SYSUTCDATETIME(), + ModifiedDate datetime2 NULL + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanTransitions_FloorPlanFloors') +BEGIN + ALTER TABLE dbo.FloorPlanTransitions + ADD CONSTRAINT FK_FloorPlanTransitions_FloorPlanFloors FOREIGN KEY (FloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID) ON DELETE CASCADE; +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanTransitions_FromLocations') +BEGIN + ALTER TABLE dbo.FloorPlanTransitions + ADD CONSTRAINT FK_FloorPlanTransitions_FromLocations FOREIGN KEY (FromLocationID) REFERENCES dbo.Locations(LocationID); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanTransitions_ToLocations') +BEGIN + ALTER TABLE dbo.FloorPlanTransitions + ADD CONSTRAINT FK_FloorPlanTransitions_ToLocations FOREIGN KEY (ToLocationID) REFERENCES dbo.Locations(LocationID); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanTransitions_Floor' AND object_id = OBJECT_ID(N'dbo.FloorPlanTransitions')) +BEGIN + CREATE INDEX IX_FloorPlanTransitions_Floor ON dbo.FloorPlanTransitions(FloorPlanFloorID); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanTransitions_Locations' AND object_id = OBJECT_ID(N'dbo.FloorPlanTransitions')) +BEGIN + CREATE INDEX IX_FloorPlanTransitions_Locations ON dbo.FloorPlanTransitions(FloorPlanFloorID, FromLocationID, ToLocationID, TransitionType); +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.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 +AS +BEGIN + SET NOCOUNT ON; + + SET @TransitionType = NULLIF(LTRIM(RTRIM(@TransitionType)), N''); + SET @DisplayLabel = NULLIF(LTRIM(RTRIM(@DisplayLabel)), N''); + + IF @TransitionType IS NULL + THROW 51020, 'Transition type is required.', 1; + + IF @FromLocationID = @ToLocationID + THROW 51021, 'From and To locations must be different.', 1; + + IF @FloorPlanTransitionID = 0 + BEGIN + INSERT dbo.FloorPlanTransitions (FloorPlanFloorID, FromLocationID, ToLocationID, TransitionType, Notes, IsLocked, DisplayLabel) + VALUES (@FloorPlanFloorID, @FromLocationID, @ToLocationID, @TransitionType, @Notes, @IsLocked, @DisplayLabel); + + SELECT CONVERT(int, SCOPE_IDENTITY()); + RETURN; + END; + + UPDATE dbo.FloorPlanTransitions + SET FromLocationID = @FromLocationID, + ToLocationID = @ToLocationID, + TransitionType = @TransitionType, + Notes = @Notes, + IsLocked = @IsLocked, + DisplayLabel = @DisplayLabel, + ModifiedDate = SYSUTCDATETIME() + WHERE FloorPlanTransitionID = @FloorPlanTransitionID + AND FloorPlanFloorID = @FloorPlanFloorID; + + SELECT @FloorPlanTransitionID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanTransition_Delete + @FloorPlanTransitionID int +AS +BEGIN + SET NOCOUNT ON; + + DELETE dbo.FloorPlanTransitions + WHERE FloorPlanTransitionID = @FloorPlanTransitionID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index f811100..543a098 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1844,11 +1844,14 @@ public sealed class FloorPlanEditorViewModel public FloorPlanEditViewModel FloorPlan { get; set; } = new(); public List Floors { get; set; } = []; public List Blocks { get; set; } = []; + public List Transitions { get; set; } = []; public FloorPlanFloorEditViewModel NewFloor { get; set; } = new(); public FloorPlanBlockEditViewModel NewBlock { get; set; } = new(); + public FloorPlanTransitionEditViewModel NewTransition { get; set; } = new(); public IReadOnlyList LocationOptions { get; set; } = []; public IReadOnlyList Locations { get; set; } = []; public IReadOnlyList BlockTypeOptions { get; set; } = []; + public IReadOnlyList TransitionTypeOptions { get; set; } = []; } public sealed class FloorPlanFloorEditViewModel @@ -1942,6 +1945,35 @@ public sealed class FloorPlanBlockEditViewModel public string DisplayLabel => string.IsNullOrWhiteSpace(LabelOverride) ? LocationName : LabelOverride; } +public sealed class FloorPlanTransitionEditViewModel +{ + public int FloorPlanTransitionID { get; set; } + public int FloorPlanFloorID { get; set; } + + [Required] + public int? FromLocationID { get; set; } + + public string FromLocationName { get; set; } = string.Empty; + public string FromLocationPath { get; set; } = string.Empty; + + [Required] + public int? ToLocationID { get; set; } + + public string ToLocationName { get; set; } = string.Empty; + public string ToLocationPath { get; set; } = string.Empty; + + [Required, StringLength(50)] + public string TransitionType { get; set; } = FloorPlanTransitionTypes.Door; + + public string? Notes { get; set; } + public bool IsLocked { get; set; } + + [StringLength(255)] + public string? DisplayLabel { get; set; } + + public string Label => string.IsNullOrWhiteSpace(DisplayLabel) ? TransitionType : DisplayLabel; +} + public sealed class WarningDashboardViewModel { public Project Project { get; set; } = new(); diff --git a/PlotLine/Views/FloorPlans/Edit.cshtml b/PlotLine/Views/FloorPlans/Edit.cshtml index dd0b193..975b1fb 100644 --- a/PlotLine/Views/FloorPlans/Edit.cshtml +++ b/PlotLine/Views/FloorPlans/Edit.cshtml @@ -4,9 +4,50 @@ ViewData["ProjectSection"] = "Floor Plans"; var activeFloor = Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).FirstOrDefault(); var blocksByFloor = Model.Blocks.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList()); + var transitionsByFloor = Model.Transitions.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList()); var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : Model.Blocks.FirstOrDefault()?.FloorPlanBlockID ?? 0; string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}"; string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + bool RangesOverlap(int aStart, int aLength, int bStart, int bLength) => aStart < bStart + bLength && bStart < aStart + aLength; + string SharedWallClasses(FloorPlanBlockEditViewModel block, IReadOnlyList floorBlocks) + { + var classes = new List(); + foreach (var other in floorBlocks) + { + if (other.FloorPlanBlockID == block.FloorPlanBlockID || other.LocationID != block.LocationID) + { + continue; + } + + if (block.X + block.WidthCells == other.X && RangesOverlap(block.Y, block.HeightCells, other.Y, other.HeightCells)) + { + classes.Add("merge-east"); + } + if (other.X + other.WidthCells == block.X && RangesOverlap(block.Y, block.HeightCells, other.Y, other.HeightCells)) + { + classes.Add("merge-west"); + } + if (block.Y + block.HeightCells == other.Y && RangesOverlap(block.X, block.WidthCells, other.X, other.WidthCells)) + { + classes.Add("merge-south"); + } + if (other.Y + other.HeightCells == block.Y && RangesOverlap(block.X, block.WidthCells, other.X, other.WidthCells)) + { + classes.Add("merge-north"); + } + } + + return string.Join(" ", classes.Distinct()); + } + string TransitionIcon(string transitionType) => transitionType switch + { + FloorPlanTransitionTypes.Archway => "Arch", + FloorPlanTransitionTypes.StairsUp => "Up", + FloorPlanTransitionTypes.StairsDown => "Down", + FloorPlanTransitionTypes.Ladder => "Ldr", + FloorPlanTransitionTypes.Window => "Win", + _ => "Door" + }; }