Implemented Phase 1 Scene Occupancy.

What Changed
Added scene-owned occupancy data:Scenes.FloorPlanID
Scenes.InitialFloorPlanFloorID
new SceneFloorPlanOccupancy table

Added Occupancy section to the Scene Inspector with AJAX save/refresh.
Added Floor Plan editor Structure / Occupancy mode.
Occupancy mode shows linked scenes, previous/next scene controls, and character/asset tokens inside assigned locations.
Character tokens use image thumbnail, then initials fallback. Asset tokens do the same, smaller.
No Features, no new floor-plan architecture, no transition changes.
Files Changed
[CoreModels.cs](C:/Source/PlotLine/PlotLine/Models/CoreModels.cs)
[CoreViewModels.cs](C:/Source/PlotLine/PlotLine/ViewModels/CoreViewModels.cs)
[Repositories.cs](C:/Source/PlotLine/PlotLine/Data/Repositories.cs)
[CoreServices.cs](C:/Source/PlotLine/PlotLine/Services/CoreServices.cs)
[ScenesController.cs](C:/Source/PlotLine/PlotLine/Controllers/ScenesController.cs)
[Program.cs](C:/Source/PlotLine/PlotLine/Program.cs)
[Edit.cshtml](C:/Source/PlotLine/PlotLine/Views/FloorPlans/Edit.cshtml)
[_SceneInspector.cshtml](C:/Source/PlotLine/PlotLine/Views/Scenes/_SceneInspector.cshtml)
[_SceneOccupancy.cshtml](C:/Source/PlotLine/PlotLine/Views/Scenes/_SceneOccupancy.cshtml)
[site.js](C:/Source/PlotLine/PlotLine/wwwroot/js/site.js)
[site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css)
minified CSS/JS updated.
Database
Added new migration only: [080_SceneFloorPlanOccupancy.sql](C:/Source/PlotLine/PlotLine/Sql/080_SceneFloorPlanOccupancy.sql)
Applied locally.
Added constraints/FKs and filtered unique indexes for one character/asset occupancy row per scene.
Stored Procedures Added/Updated
Scene_Save
Scene_Get
Scene_FloorPlanLink_Save
Scene_ListByFloorPlan
FloorPlanFloor_ListByPlan
FloorPlanBlock_ListByPlan
SceneFloorPlanOccupancy_ListByScene
SceneFloorPlanOccupancy_ListByFloorPlan
SceneFloorPlanOccupancy_Save
Test Data
Permanent Calendar House seed data added:
Book: The Calendar House
Chapters 1-3
14 scenes
Characters: Ellie, Grant, Stephen, Housekeeper, Gardener
Assets: Brass Key, Journal, Candle, Map, Letter
45 occupancy assignments
Verification
dotnet build PlotLine.slnx passes with 0 warnings/errors.
Migration applied locally.
Local app boots and returns HTTP 200.
DB checks confirm 14 linked scenes and 45 occupancy rows.
Manual browser click-through of the authenticated UI still needs a human pass for the AJAX controls and visual token placement.
This commit is contained in:
Nick Beckley 2026-06-22 10:45:30 +01:00
parent 34a8d94cb4
commit cea82a922f
14 changed files with 1177 additions and 5 deletions

View File

@ -492,6 +492,32 @@ public sealed class ScenesController(
return RedirectToAction(nameof(Edit), new { id = sceneId });
}
[HttpGet]
public async Task<IActionResult> OccupancyEditor(int sceneId, int? floorPlanId)
{
var model = await scenes.GetOccupancyEditorAsync(sceneId, floorPlanId);
return model is null ? NotFound() : PartialView("_SceneOccupancy", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveOccupancy(SceneOccupancySaveViewModel model)
{
try
{
await scenes.SaveOccupancyAsync(model);
var refreshed = await scenes.GetOccupancyEditorAsync(model.SceneID, model.FloorPlanID);
return refreshed is null
? NotFound()
: PartialView("_SceneOccupancy", refreshed);
}
catch (InvalidOperationException ex)
{
Response.StatusCode = StatusCodes.Status400BadRequest;
return Json(new { ok = false, message = ex.Message });
}
}
private string? GetLocalReturnUrl(string? returnUrl = null)
{
if (string.IsNullOrWhiteSpace(returnUrl)
@ -563,6 +589,8 @@ public sealed class ScenesController(
target.SceneOutcomeNotes = source.SceneOutcomeNotes;
target.RevisionStatusID = source.RevisionStatusID;
target.PrimaryLocationID = source.PrimaryLocationID;
target.FloorPlanID = source.FloorPlanID;
target.InitialFloorPlanFloorID = source.InitialFloorPlanFloorID;
target.SelectedPurposeTypeIds = source.SelectedPurposeTypeIds;
target.ReturnToTimeline = source.ReturnToTimeline;
target.ReturnProjectID = source.ReturnProjectID;

View File

@ -69,8 +69,10 @@ public interface IChapterRepository
public interface ISceneRepository
{
Task<IReadOnlyList<Scene>> ListByChapterAsync(int chapterId);
Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId);
Task<Scene?> GetAsync(int sceneId);
Task<int> SaveAsync(Scene scene);
Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId);
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value);
@ -83,6 +85,13 @@ public interface ISceneRepository
Task RenumberChapterAsync(int chapterId);
}
public interface ISceneFloorPlanOccupancyRepository
{
Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListBySceneAsync(int sceneId);
Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListByFloorPlanAsync(int floorPlanId);
Task SaveAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId, IEnumerable<SceneFloorPlanOccupancy> occupancyRows);
}
public interface ILookupRepository
{
Task<LookupData> GetAllAsync();
@ -223,6 +232,8 @@ public interface IFloorPlanRepository
Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks, IReadOnlyList<FloorPlanTransition> Transitions)> GetEditorAsync(int floorPlanId);
Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId);
Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByFloorAsync(int floorPlanFloorId);
Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByPlanAsync(int floorPlanId);
Task<IReadOnlyList<FloorPlanFloor>> ListFloorsByPlanAsync(int floorPlanId);
Task<FloorPlanBlock?> GetBlockAsync(int floorPlanBlockId);
Task<int> SavePlanAsync(FloorPlan floorPlan);
Task DeletePlanAsync(int floorPlanId);
@ -582,6 +593,20 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
return rows.ToList();
}
public async Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByPlanAsync(int floorPlanId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FloorPlanBlock>("dbo.FloorPlanBlock_ListByPlan", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<FloorPlanFloor>> ListFloorsByPlanAsync(int floorPlanId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FloorPlanFloor>("dbo.FloorPlanFloor_ListByPlan", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<FloorPlanBlock?> GetBlockAsync(int floorPlanBlockId)
{
using var connection = connectionFactory.CreateConnection();
@ -2446,6 +2471,13 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
return rows.ToList();
}
public async Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Scene>("dbo.Scene_ListByFloorPlan", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<Scene?> GetAsync(int sceneId)
{
using var connection = connectionFactory.CreateConnection();
@ -2483,11 +2515,22 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
scene.ScenePurposeNotes,
scene.SceneOutcomeNotes,
scene.RevisionStatusID,
scene.PrimaryLocationID
scene.PrimaryLocationID,
scene.FloorPlanID,
scene.InitialFloorPlanFloorID
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Scene_FloorPlanLink_Save",
new { SceneID = sceneId, FloorPlanID = floorPlanId, InitialFloorPlanFloorID = initialFloorPlanFloorId },
commandType: CommandType.StoredProcedure);
}
public async Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds)
{
using var connection = connectionFactory.CreateConnection();
@ -2584,6 +2627,53 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
}
}
public sealed class SceneFloorPlanOccupancyRepository(ISqlConnectionFactory connectionFactory) : ISceneFloorPlanOccupancyRepository
{
public async Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListBySceneAsync(int sceneId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneFloorPlanOccupancy>(
"dbo.SceneFloorPlanOccupancy_ListByScene",
new { SceneID = sceneId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListByFloorPlanAsync(int floorPlanId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneFloorPlanOccupancy>(
"dbo.SceneFloorPlanOccupancy_ListByFloorPlan",
new { FloorPlanID = floorPlanId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task SaveAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId, IEnumerable<SceneFloorPlanOccupancy> occupancyRows)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Scene_FloorPlanLink_Save",
new { SceneID = sceneId, FloorPlanID = floorPlanId, InitialFloorPlanFloorID = initialFloorPlanFloorId },
commandType: CommandType.StoredProcedure);
foreach (var row in occupancyRows)
{
await connection.ExecuteAsync(
"dbo.SceneFloorPlanOccupancy_Save",
new
{
SceneID = sceneId,
FloorPlanID = floorPlanId,
row.CharacterID,
row.StoryAssetID,
LocationID = row.LocationID == 0 ? (int?)null : row.LocationID
},
commandType: CommandType.StoredProcedure);
}
}
}
public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) : ILookupRepository
{
public async Task<LookupData> GetAllAsync()

View File

@ -313,6 +313,8 @@ public sealed class Scene
public string? Summary { get; set; }
public int? POVCharacterID { get; set; }
public int? PrimaryLocationID { get; set; }
public int? FloorPlanID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
public string? PrimaryLocationName { get; set; }
public string? PrimaryLocationPath { get; set; }
public int SortOrder { get; set; }
@ -1248,6 +1250,29 @@ public static class FloorPlanTransitionTypes
}
}
public sealed class SceneFloorPlanOccupancy
{
public int SceneFloorPlanOccupancyID { get; set; }
public int SceneID { get; set; }
public int FloorPlanID { get; set; }
public int? FloorPlanFloorID { get; set; }
public string? FloorName { get; set; }
public int? CharacterID { get; set; }
public string? CharacterName { get; set; }
public string? CharacterShortName { get; set; }
public string? CharacterImagePath { get; set; }
public string? CharacterThumbnailPath { get; set; }
public int? StoryAssetID { get; set; }
public string? AssetName { get; set; }
public string? AssetImagePath { get; set; }
public string? AssetThumbnailPath { get; set; }
public int LocationID { get; set; }
public string LocationName { get; set; } = string.Empty;
public string LocationPath { get; set; } = string.Empty;
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
}
public static class FloorPlanTransitionPositions
{
public const string Centre = "Centre";

View File

@ -97,6 +97,7 @@ public class Program
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
builder.Services.AddScoped<ISceneRepository, SceneRepository>();
builder.Services.AddScoped<ISceneFloorPlanOccupancyRepository, SceneFloorPlanOccupancyRepository>();
builder.Services.AddScoped<ILookupRepository, LookupRepository>();
builder.Services.AddScoped<ITimelineRepository, TimelineRepository>();
builder.Services.AddScoped<ITimelinePresetRepository, TimelinePresetRepository>();

View File

@ -109,6 +109,8 @@ public interface ISceneService
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
Task<int> SaveAsync(SceneEditViewModel model);
Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null);
Task SaveOccupancyAsync(SceneOccupancySaveViewModel model);
Task ArchiveAsync(int sceneId);
Task MoveAsync(int sceneId, string direction);
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
@ -1360,6 +1362,8 @@ public sealed class SceneService(
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IFloorPlanRepository floorPlans,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies,
IWriterWorkspaceRepository writerWorkspace,
@ -1427,6 +1431,8 @@ public sealed class SceneService(
SceneOutcomeNotes = scene.SceneOutcomeNotes,
RevisionStatusID = scene.RevisionStatusID,
PrimaryLocationID = scene.PrimaryLocationID,
FloorPlanID = scene.FloorPlanID,
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
{
@ -1475,7 +1481,9 @@ public sealed class SceneService(
ScenePurposeNotes = model.ScenePurposeNotes,
SceneOutcomeNotes = model.SceneOutcomeNotes,
RevisionStatusID = model.RevisionStatusID,
PrimaryLocationID = model.PrimaryLocationID
PrimaryLocationID = model.PrimaryLocationID,
FloorPlanID = model.FloorPlanID,
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
});
await scenes.SavePurposesAsync(sceneId, model.SelectedPurposeTypeIds);
@ -1724,10 +1732,172 @@ public sealed class SceneService(
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.Occupancy = model.SceneID == 0
? new SceneOccupancyEditorViewModel()
: await BuildOccupancyEditorAsync(model, projectAssets);
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
return model;
}
public async Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null)
{
var sceneModel = await GetEditAsync(sceneId);
if (sceneModel is null)
{
return null;
}
if (floorPlanId.HasValue)
{
sceneModel.FloorPlanID = floorPlanId;
}
var projectAssets = sceneModel.Project is null ? [] : await assets.ListAssetsAsync(sceneModel.Project.ProjectID);
return await BuildOccupancyEditorAsync(sceneModel, projectAssets);
}
public async Task SaveOccupancyAsync(SceneOccupancySaveViewModel model)
{
var scene = await scenes.GetAsync(model.SceneID) ?? throw new InvalidOperationException("Scene not found.");
var context = await GetContextAsync(scene.ChapterID) ?? throw new InvalidOperationException("Scene context not found.");
if (model.FloorPlanID.HasValue)
{
var floorPlan = await floorPlans.GetAsync(model.FloorPlanID.Value);
if (floorPlan is null || floorPlan.ProjectID != context.Project.ProjectID)
{
throw new InvalidOperationException("Floor plan not found.");
}
if (model.InitialFloorPlanFloorID.HasValue)
{
var floors = await floorPlans.ListFloorsByPlanAsync(model.FloorPlanID.Value);
if (!floors.Any(x => x.FloorPlanFloorID == model.InitialFloorPlanFloorID.Value))
{
throw new InvalidOperationException("Initial floor does not belong to the selected floor plan.");
}
}
}
else
{
model.InitialFloorPlanFloorID = null;
}
var allowedLocations = model.FloorPlanID.HasValue
? (await floorPlans.ListBlocksByPlanAsync(model.FloorPlanID.Value)).Select(x => x.LocationID).ToHashSet()
: [];
var sceneCharacterIds = (await characters.ListSceneCharactersAsync(model.SceneID)).Select(x => x.CharacterID).ToHashSet();
var sceneAssetIds = (await assets.ListEventsBySceneAsync(model.SceneID)).Select(x => x.StoryAssetID).ToHashSet();
var rows = new List<SceneFloorPlanOccupancy>();
foreach (var character in model.Characters.Where(x => sceneCharacterIds.Contains(x.CharacterID)))
{
if (character.LocationID.HasValue && !allowedLocations.Contains(character.LocationID.Value))
{
throw new InvalidOperationException("Character location must be on the selected floor plan.");
}
rows.Add(new SceneFloorPlanOccupancy
{
CharacterID = character.CharacterID,
LocationID = character.LocationID ?? 0
});
}
foreach (var asset in model.Assets.Where(x => sceneAssetIds.Contains(x.StoryAssetID)))
{
if (asset.LocationID.HasValue && !allowedLocations.Contains(asset.LocationID.Value))
{
throw new InvalidOperationException("Asset location must be on the selected floor plan.");
}
rows.Add(new SceneFloorPlanOccupancy
{
StoryAssetID = asset.StoryAssetID,
LocationID = asset.LocationID ?? 0
});
}
await sceneOccupancy.SaveAsync(model.SceneID, model.FloorPlanID, model.InitialFloorPlanFloorID, rows);
await activity.RecordAsync(context.Project.ProjectID, "Updated", "Scene Occupancy", model.SceneID, scene.SceneTitle);
}
private async Task<SceneOccupancyEditorViewModel> BuildOccupancyEditorAsync(SceneEditViewModel model, IReadOnlyList<StoryAsset> projectAssets)
{
var floorPlanList = model.Project is null ? [] : await floorPlans.ListByProjectAsync(model.Project.ProjectID);
var selectedFloorPlanId = model.FloorPlanID ?? floorPlanList.FirstOrDefault()?.FloorPlanID;
var existingRows = model.SceneID == 0 ? [] : await sceneOccupancy.ListBySceneAsync(model.SceneID);
var floors = new List<SceneOccupancyFloorOption>();
var locationsOnPlans = new List<SceneOccupancyLocationOption>();
foreach (var plan in floorPlanList)
{
var planFloors = await floorPlans.ListFloorsByPlanAsync(plan.FloorPlanID);
var blocks = await floorPlans.ListBlocksByPlanAsync(plan.FloorPlanID);
var floorsById = planFloors.ToDictionary(x => x.FloorPlanFloorID);
floors.AddRange(planFloors.Select(floor => new SceneOccupancyFloorOption
{
FloorPlanID = floor.FloorPlanID,
FloorPlanFloorID = floor.FloorPlanFloorID,
Name = floor.Name,
SortOrder = floor.SortOrder
}));
locationsOnPlans.AddRange(blocks
.GroupBy(x => new { x.LocationID, x.FloorPlanFloorID })
.Select(group =>
{
floorsById.TryGetValue(group.Key.FloorPlanFloorID, out var floor);
var first = group.First();
return new SceneOccupancyLocationOption
{
FloorPlanID = plan.FloorPlanID,
FloorPlanFloorID = group.Key.FloorPlanFloorID,
FloorName = floor?.Name ?? "Floor",
FloorSortOrder = floor?.SortOrder ?? 0,
LocationID = group.Key.LocationID,
LocationName = first.LocationName,
LocationPath = first.LocationPath
};
}));
}
var existingByCharacter = existingRows.Where(x => x.CharacterID.HasValue).ToDictionary(x => x.CharacterID!.Value, x => x.LocationID);
var existingByAsset = existingRows.Where(x => x.StoryAssetID.HasValue).ToDictionary(x => x.StoryAssetID!.Value, x => x.LocationID);
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
return new SceneOccupancyEditorViewModel
{
SceneID = model.SceneID,
FloorPlanID = selectedFloorPlanId,
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID,
FloorPlanOptions = floorPlanList.OrderBy(x => x.Name).Select(x => new SelectListItem(x.Name, x.FloorPlanID.ToString())).ToList(),
Floors = floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList(),
Locations = locationsOnPlans.OrderBy(x => x.FloorSortOrder).ThenBy(x => x.FloorName).ThenBy(x => x.LocationPath).ToList(),
Characters = model.SceneCharacters
.GroupBy(x => x.CharacterID)
.Select(group => new SceneCharacterOccupancyRowViewModel
{
CharacterID = group.Key,
CharacterName = group.First().CharacterName,
LocationID = existingByCharacter.GetValueOrDefault(group.Key)
})
.OrderBy(x => x.CharacterName)
.ToList(),
Assets = model.AssetEvents
.GroupBy(x => x.StoryAssetID)
.Select(group => new SceneAssetOccupancyRowViewModel
{
StoryAssetID = group.Key,
AssetName = assetNames.GetValueOrDefault(group.Key, group.First().AssetName),
LocationID = existingByAsset.GetValueOrDefault(group.Key)
})
.OrderBy(x => x.AssetName)
.ToList()
};
}
private async Task<IReadOnlyList<ContinuityWarning>> BuildAgeContinuityWarningsAsync(SceneEditViewModel model)
{
if (model.SceneID == 0 || model.Project is null || model.Book is null)
@ -7561,6 +7731,8 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
public sealed class FloorPlanService(
IProjectRepository projects,
ISceneRepository scenes,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
IFloorPlanRepository floorPlans,
ILocationRepository locations,
IFloorPlanBackgroundImageService backgroundImages,
@ -7595,6 +7767,11 @@ public sealed class FloorPlanService(
var projectLocations = await locations.ListByProjectAsync(project.ProjectID);
var orderedFloors = data.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList();
var occupancyScenes = await scenes.ListByFloorPlanAsync(floorPlanId);
var occupancyRows = await sceneOccupancy.ListByFloorPlanAsync(floorPlanId);
var firstBlockByLocation = data.Blocks
.GroupBy(x => x.LocationID)
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.FloorPlanFloorID).ThenBy(block => block.Y).ThenBy(block => block.X).First());
return new FloorPlanEditorViewModel
{
Project = project,
@ -7635,7 +7812,37 @@ public sealed class FloorPlanService(
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
Locations = projectLocations,
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList(),
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList()
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList(),
OccupancyScenes = occupancyScenes
.OrderBy(x => x.StartDateTime ?? DateTime.MaxValue)
.ThenBy(x => x.SceneNumber)
.ThenBy(x => x.SceneTitle)
.Select(x => new FloorPlanOccupancySceneViewModel
{
SceneID = x.SceneID,
InitialFloorPlanFloorID = x.InitialFloorPlanFloorID,
Label = $"Scene {x.SceneNumber}: {x.SceneTitle}"
})
.ToList(),
OccupancyTokens = occupancyRows
.Where(x => firstBlockByLocation.ContainsKey(x.LocationID))
.Select(x =>
{
var block = firstBlockByLocation[x.LocationID];
return new FloorPlanOccupancyTokenViewModel
{
SceneID = x.SceneID,
FloorPlanFloorID = block.FloorPlanFloorID,
LocationID = x.LocationID,
LocationName = x.LocationName,
EntityType = x.CharacterID.HasValue ? "Character" : "Asset",
EntityID = x.CharacterID ?? x.StoryAssetID ?? 0,
DisplayName = x.CharacterName ?? x.AssetName ?? "Occupant",
ImagePath = x.CharacterImagePath ?? x.AssetImagePath,
ThumbnailPath = x.CharacterThumbnailPath ?? x.AssetThumbnailPath
};
})
.ToList()
};
}

View File

@ -0,0 +1,278 @@
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.Scenes', N'FloorPlanID') IS NULL
ALTER TABLE dbo.Scenes ADD FloorPlanID int NULL;
GO
IF COL_LENGTH(N'dbo.Scenes', N'InitialFloorPlanFloorID') IS NULL
ALTER TABLE dbo.Scenes ADD InitialFloorPlanFloorID int NULL;
GO
IF OBJECT_ID(N'dbo.FK_Scenes_FloorPlans', N'F') IS NULL
ALTER TABLE dbo.Scenes WITH CHECK ADD CONSTRAINT FK_Scenes_FloorPlans FOREIGN KEY (FloorPlanID) REFERENCES dbo.FloorPlans(FloorPlanID);
GO
IF OBJECT_ID(N'dbo.FK_Scenes_InitialFloorPlanFloors', N'F') IS NULL
ALTER TABLE dbo.Scenes WITH CHECK ADD CONSTRAINT FK_Scenes_InitialFloorPlanFloors FOREIGN KEY (InitialFloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID);
GO
IF OBJECT_ID(N'dbo.SceneFloorPlanOccupancy', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SceneFloorPlanOccupancy
(
SceneFloorPlanOccupancyID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_SceneFloorPlanOccupancy PRIMARY KEY,
SceneID int NOT NULL,
FloorPlanID int NOT NULL,
FloorPlanFloorID int NULL,
CharacterID int NULL,
StoryAssetID int NULL,
LocationID int NOT NULL,
CreatedDate datetime2 NOT NULL CONSTRAINT DF_SceneFloorPlanOccupancy_CreatedDate DEFAULT SYSUTCDATETIME(),
ModifiedDate datetime2 NULL,
CONSTRAINT CK_SceneFloorPlanOccupancy_OneEntity CHECK
(
(CharacterID IS NOT NULL AND StoryAssetID IS NULL)
OR (CharacterID IS NULL AND StoryAssetID IS NOT NULL)
),
CONSTRAINT FK_SceneFloorPlanOccupancy_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID) ON DELETE CASCADE,
CONSTRAINT FK_SceneFloorPlanOccupancy_FloorPlans FOREIGN KEY (FloorPlanID) REFERENCES dbo.FloorPlans(FloorPlanID),
CONSTRAINT FK_SceneFloorPlanOccupancy_FloorPlanFloors FOREIGN KEY (FloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID),
CONSTRAINT FK_SceneFloorPlanOccupancy_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
CONSTRAINT FK_SceneFloorPlanOccupancy_StoryAssets FOREIGN KEY (StoryAssetID) REFERENCES dbo.StoryAssets(StoryAssetID),
CONSTRAINT FK_SceneFloorPlanOccupancy_Locations FOREIGN KEY (LocationID) REFERENCES dbo.Locations(LocationID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SceneFloorPlanOccupancy_Character' AND object_id = OBJECT_ID(N'dbo.SceneFloorPlanOccupancy'))
CREATE UNIQUE INDEX UX_SceneFloorPlanOccupancy_Character ON dbo.SceneFloorPlanOccupancy(SceneID, CharacterID) WHERE CharacterID IS NOT NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SceneFloorPlanOccupancy_Asset' AND object_id = OBJECT_ID(N'dbo.SceneFloorPlanOccupancy'))
CREATE UNIQUE INDEX UX_SceneFloorPlanOccupancy_Asset ON dbo.SceneFloorPlanOccupancy(SceneID, StoryAssetID) WHERE StoryAssetID IS NOT NULL;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_Save
@SceneID int = NULL, @ChapterID int, @SceneNumber decimal(10,2), @SceneTitle nvarchar(200), @Summary nvarchar(max) = NULL,
@TimeModeID int, @StartDateTime datetime2 = NULL, @EndDateTime datetime2 = NULL, @DurationAmount decimal(10,2) = NULL,
@DurationUnitID int = NULL, @RelativeTimeText nvarchar(200) = NULL, @TimeConfidenceID int,
@ScenePurposeNotes nvarchar(max) = NULL, @SceneOutcomeNotes nvarchar(max) = NULL, @RevisionStatusID int,
@PrimaryLocationID int = NULL, @FloorPlanID int = NULL, @InitialFloorPlanFloorID int = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @NextSortOrder int;
IF @SceneID IS NULL OR @SceneID = 0
BEGIN
SELECT @NextSortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.Scenes WHERE ChapterID = @ChapterID;
INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, Summary, SortOrder, TimeModeID, StartDateTime, EndDateTime,
DurationAmount, DurationUnitID, RelativeTimeText, TimeConfidenceID, ScenePurposeNotes, SceneOutcomeNotes,
RevisionStatusID, PrimaryLocationID, FloorPlanID, InitialFloorPlanFloorID)
VALUES (@ChapterID, @SceneNumber, @SceneTitle, @Summary, @NextSortOrder, @TimeModeID, @StartDateTime, @EndDateTime,
@DurationAmount, @DurationUnitID, @RelativeTimeText, @TimeConfidenceID, @ScenePurposeNotes, @SceneOutcomeNotes,
@RevisionStatusID, @PrimaryLocationID, @FloorPlanID, @InitialFloorPlanFloorID);
SET @SceneID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.Scenes
SET SceneNumber = @SceneNumber, SceneTitle = @SceneTitle, Summary = @Summary, TimeModeID = @TimeModeID,
StartDateTime = @StartDateTime, EndDateTime = @EndDateTime, DurationAmount = @DurationAmount, DurationUnitID = @DurationUnitID,
RelativeTimeText = @RelativeTimeText, TimeConfidenceID = @TimeConfidenceID, ScenePurposeNotes = @ScenePurposeNotes,
SceneOutcomeNotes = @SceneOutcomeNotes, RevisionStatusID = @RevisionStatusID, PrimaryLocationID = @PrimaryLocationID,
FloorPlanID = @FloorPlanID, InitialFloorPlanFloorID = @InitialFloorPlanFloorID,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END
SELECT @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_Get
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
EXEC dbo.SceneMetric_EnsureDefaults @SceneID;
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID, s.PrimaryLocationID,
s.FloorPlanID, s.InitialFloorPlanFloorID,
location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath,
s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID,
du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes,
s.SceneOutcomeNotes, s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived
FROM dbo.Scenes s
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID
WHERE s.SceneID = @SceneID AND s.IsArchived = 0;
SELECT ScenePurposeTypeID FROM dbo.ScenePurposes WHERE SceneID = @SceneID;
EXEC dbo.SceneMetric_ListByScene @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_FloorPlanLink_Save
@SceneID int,
@FloorPlanID int = NULL,
@InitialFloorPlanFloorID int = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @FloorPlanID IS NULL
SET @InitialFloorPlanFloorID = NULL;
UPDATE dbo.Scenes
SET FloorPlanID = @FloorPlanID,
InitialFloorPlanFloorID = @InitialFloorPlanFloorID,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
IF @FloorPlanID IS NULL
DELETE FROM dbo.SceneFloorPlanOccupancy WHERE SceneID = @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_ListByFloorPlan
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID, s.PrimaryLocationID,
s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime,
s.EndDateTime, s.DurationAmount, s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID,
tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID,
rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived
FROM dbo.Scenes s
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
WHERE s.FloorPlanID = @FloorPlanID AND s.IsArchived = 0
ORDER BY s.StartDateTime, s.SceneNumber, s.SortOrder, s.SceneTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_ListByPlan
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
SELECT FloorPlanFloorID, FloorPlanID, LocationID, Name, SortOrder, GridWidth, GridHeight,
BackgroundImagePath, BackgroundImageOriginalFileName, BackgroundOpacity, BackgroundScale,
BackgroundOffsetX, BackgroundOffsetY, BackgroundLocked, CreatedDate, ModifiedDate
FROM dbo.FloorPlanFloors
WHERE FloorPlanID = @FloorPlanID
ORDER BY SortOrder, Name;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_ListByPlan
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fpb.FloorPlanBlockID, fpb.FloorPlanFloorID, fpb.LocationID,
COALESCE(lp.LocationName, l.LocationName) AS LocationName,
COALESCE(lp.LocationPath, l.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.Locations l ON l.LocationID = fpb.LocationID
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpb.LocationID
WHERE fpf.FloorPlanID = @FloorPlanID
ORDER BY fpf.SortOrder, fpf.Name, fpb.Y, fpb.X;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_ListByScene
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
SELECT o.SceneFloorPlanOccupancyID, o.SceneID, o.FloorPlanID, o.FloorPlanFloorID,
o.CharacterID, c.CharacterName, c.ShortName AS CharacterShortName, c.ImagePath AS CharacterImagePath, c.ThumbnailPath AS CharacterThumbnailPath,
o.StoryAssetID, sa.AssetName, sa.ImagePath AS AssetImagePath, sa.ThumbnailPath AS AssetThumbnailPath,
o.LocationID, COALESCE(lp.LocationName, l.LocationName) AS LocationName, COALESCE(lp.LocationPath, l.LocationName) AS LocationPath,
fpf.Name AS FloorName, o.CreatedDate, o.ModifiedDate
FROM dbo.SceneFloorPlanOccupancy o
INNER JOIN dbo.Locations l ON l.LocationID = o.LocationID
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = o.LocationID
LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = o.FloorPlanFloorID
LEFT JOIN dbo.Characters c ON c.CharacterID = o.CharacterID
LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = o.StoryAssetID
WHERE o.SceneID = @SceneID
ORDER BY COALESCE(c.CharacterName, sa.AssetName);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_ListByFloorPlan
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
SELECT o.SceneFloorPlanOccupancyID, o.SceneID, o.FloorPlanID, o.FloorPlanFloorID,
o.CharacterID, c.CharacterName, c.ShortName AS CharacterShortName, c.ImagePath AS CharacterImagePath, c.ThumbnailPath AS CharacterThumbnailPath,
o.StoryAssetID, sa.AssetName, sa.ImagePath AS AssetImagePath, sa.ThumbnailPath AS AssetThumbnailPath,
o.LocationID, COALESCE(lp.LocationName, l.LocationName) AS LocationName, COALESCE(lp.LocationPath, l.LocationName) AS LocationPath,
fpf.Name AS FloorName, o.CreatedDate, o.ModifiedDate
FROM dbo.SceneFloorPlanOccupancy o
INNER JOIN dbo.Locations l ON l.LocationID = o.LocationID
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = o.LocationID
LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = o.FloorPlanFloorID
LEFT JOIN dbo.Characters c ON c.CharacterID = o.CharacterID
LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = o.StoryAssetID
WHERE o.FloorPlanID = @FloorPlanID
ORDER BY o.SceneID, COALESCE(c.CharacterName, sa.AssetName);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_Save
@SceneID int,
@FloorPlanID int = NULL,
@CharacterID int = NULL,
@StoryAssetID int = NULL,
@LocationID int = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @FloorPlanID IS NULL OR @LocationID IS NULL
BEGIN
DELETE FROM dbo.SceneFloorPlanOccupancy
WHERE SceneID = @SceneID
AND ((@CharacterID IS NOT NULL AND CharacterID = @CharacterID)
OR (@StoryAssetID IS NOT NULL AND StoryAssetID = @StoryAssetID));
RETURN;
END;
DECLARE @FloorPlanFloorID int;
SELECT TOP (1) @FloorPlanFloorID = fpb.FloorPlanFloorID
FROM dbo.FloorPlanBlocks fpb
INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID
WHERE fpf.FloorPlanID = @FloorPlanID
AND fpb.LocationID = @LocationID
ORDER BY fpf.SortOrder, fpf.Name, fpb.Y, fpb.X;
IF @FloorPlanFloorID IS NULL
THROW 51000, 'Location must be placed on the selected floor plan.', 1;
MERGE dbo.SceneFloorPlanOccupancy AS target
USING
(
SELECT @SceneID AS SceneID, @FloorPlanID AS FloorPlanID, @FloorPlanFloorID AS FloorPlanFloorID,
@CharacterID AS CharacterID, @StoryAssetID AS StoryAssetID, @LocationID AS LocationID
) AS source
ON target.SceneID = source.SceneID
AND ((source.CharacterID IS NOT NULL AND target.CharacterID = source.CharacterID)
OR (source.StoryAssetID IS NOT NULL AND target.StoryAssetID = source.StoryAssetID))
WHEN MATCHED THEN
UPDATE SET FloorPlanID = source.FloorPlanID, FloorPlanFloorID = source.FloorPlanFloorID,
LocationID = source.LocationID, ModifiedDate = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (SceneID, FloorPlanID, FloorPlanFloorID, CharacterID, StoryAssetID, LocationID)
VALUES (source.SceneID, source.FloorPlanID, source.FloorPlanFloorID, source.CharacterID, source.StoryAssetID, source.LocationID);
END;
GO

View File

@ -294,6 +294,12 @@ public sealed class SceneEditViewModel
[Display(Name = "Primary location")]
public int? PrimaryLocationID { get; set; }
[Display(Name = "Floor plan")]
public int? FloorPlanID { get; set; }
[Display(Name = "Initial floor")]
public int? InitialFloorPlanFloorID { get; set; }
[Display(Name = "Scene purposes")]
public List<int> SelectedPurposeTypeIds { get; set; } = [];
@ -358,6 +364,7 @@ public sealed class SceneEditViewModel
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
public StorageUsage? StorageUsage { get; set; }
public StoryStateViewModel StoryState { get; set; } = new();
public SceneOccupancyEditorViewModel Occupancy { get; set; } = new();
public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; }
@ -373,6 +380,60 @@ public sealed class SceneEditViewModel
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = [];
}
public sealed class SceneOccupancyEditorViewModel
{
public int SceneID { get; set; }
public int? FloorPlanID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
public IReadOnlyList<SelectListItem> FloorPlanOptions { get; set; } = [];
public IReadOnlyList<SceneOccupancyFloorOption> Floors { get; set; } = [];
public IReadOnlyList<SceneOccupancyLocationOption> Locations { get; set; } = [];
public IReadOnlyList<SceneCharacterOccupancyRowViewModel> Characters { get; set; } = [];
public IReadOnlyList<SceneAssetOccupancyRowViewModel> Assets { get; set; } = [];
}
public sealed class SceneOccupancyFloorOption
{
public int FloorPlanFloorID { get; set; }
public int FloorPlanID { get; set; }
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
}
public sealed class SceneOccupancyLocationOption
{
public int LocationID { get; set; }
public int FloorPlanID { get; set; }
public int FloorPlanFloorID { get; set; }
public string FloorName { get; set; } = string.Empty;
public int FloorSortOrder { get; set; }
public string LocationName { get; set; } = string.Empty;
public string LocationPath { get; set; } = string.Empty;
}
public sealed class SceneCharacterOccupancyRowViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? LocationID { get; set; }
}
public sealed class SceneAssetOccupancyRowViewModel
{
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int? LocationID { get; set; }
}
public sealed class SceneOccupancySaveViewModel
{
public int SceneID { get; set; }
public int? FloorPlanID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
public List<SceneCharacterOccupancyRowViewModel> Characters { get; set; } = [];
public List<SceneAssetOccupancyRowViewModel> Assets { get; set; } = [];
}
public sealed class StoryStateViewModel
{
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
@ -1872,6 +1933,28 @@ public sealed class FloorPlanEditorViewModel
public IReadOnlyList<LocationItem> Locations { get; set; } = [];
public IReadOnlyList<SelectListItem> BlockTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> TransitionTypeOptions { get; set; } = [];
public IReadOnlyList<FloorPlanOccupancySceneViewModel> OccupancyScenes { get; set; } = [];
public IReadOnlyList<FloorPlanOccupancyTokenViewModel> OccupancyTokens { get; set; } = [];
}
public sealed class FloorPlanOccupancySceneViewModel
{
public int SceneID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
public string Label { get; set; } = string.Empty;
}
public sealed class FloorPlanOccupancyTokenViewModel
{
public int SceneID { get; set; }
public int FloorPlanFloorID { get; set; }
public int LocationID { get; set; }
public string LocationName { get; set; } = string.Empty;
public string EntityType { get; set; } = string.Empty;
public int EntityID { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
}
public sealed class FloorPlanFloorEditViewModel

View File

@ -5,6 +5,14 @@
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 firstBlockIdByLocation = Model.Blocks
.Where(x => x.LocationID.HasValue)
.GroupBy(x => x.LocationID)
.ToDictionary(x => x.Key!.Value, x => x.OrderBy(block => block.FloorPlanFloorID).ThenBy(block => block.Y).ThenBy(block => block.X).First().FloorPlanBlockID);
var occupancyTokensByLocation = Model.OccupancyTokens
.GroupBy(x => $"{x.SceneID}:{x.LocationID}")
.ToDictionary(x => x.Key, x => x.OrderBy(token => token.EntityType == "Character" ? 0 : 1).ThenBy(token => token.DisplayName).ToList());
var activeOccupancyScene = Model.OccupancyScenes.FirstOrDefault();
var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : 0;
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
@ -187,6 +195,21 @@
</select>
</label>
<span class="floor-plan-save-status" data-save-status>Saved</span>
<div class="btn-group btn-group-sm" role="group" aria-label="Floor plan mode">
<button class="btn btn-outline-secondary active" type="button" data-floor-plan-mode="structure">Structure</button>
<button class="btn btn-outline-secondary" type="button" data-floor-plan-mode="occupancy">Occupancy</button>
</div>
<label class="floor-plan-reference-control floor-plan-occupancy-control" data-occupancy-controls hidden>
<span class="muted">Scene</span>
<select class="form-select form-select-sm" data-occupancy-scene-select>
@foreach (var scene in Model.OccupancyScenes)
{
<option value="@scene.SceneID" data-initial-floor-id="@scene.InitialFloorPlanFloorID" selected="@(scene.SceneID == activeOccupancyScene?.SceneID)">@scene.Label</option>
}
</select>
</label>
<button class="btn btn-outline-secondary btn-sm floor-plan-occupancy-control" type="button" data-occupancy-prev hidden>Previous Scene</button>
<button class="btn btn-outline-secondary btn-sm floor-plan-occupancy-control" type="button" data-occupancy-next hidden>Next Scene</button>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
<button class="btn btn-primary btn-sm" type="submit" data-save-layout-button>Save layout</button>
</div>
@ -232,6 +255,44 @@
<span class="floor-plan-block-label">@block.DisplayLabel</span>
<span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>
</button>
if (block.LocationID.HasValue && firstBlockIdByLocation.GetValueOrDefault(block.LocationID.Value) == block.FloorPlanBlockID)
{
var locationTokens = Model.OccupancyScenes
.SelectMany(scene => occupancyTokensByLocation.GetValueOrDefault($"{scene.SceneID}:{block.LocationID.Value}") ?? [])
.ToList();
if (locationTokens.Any())
{
foreach (var group in locationTokens.GroupBy(x => x.SceneID))
{
var tokens = group.ToList();
var tokenTitle = string.Join(", ", tokens.Select(x => x.DisplayName));
<div class="floor-plan-occupancy-tray"
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
data-occupancy-location="@block.LocationID"
data-occupancy-scene="@group.Key"
title="@tokenTitle">
@foreach (var token in tokens.Take(3))
{
var imagePath = !string.IsNullOrWhiteSpace(token.ThumbnailPath) ? token.ThumbnailPath : token.ImagePath;
<span class="floor-plan-occupancy-token floor-plan-occupancy-token--@token.EntityType.ToLowerInvariant()" title="@token.DisplayName">
@if (!string.IsNullOrWhiteSpace(imagePath))
{
<img src="@imagePath" alt="@token.DisplayName" loading="lazy" />
}
else
{
<span>@AvatarHelper.Initials(token.DisplayName)</span>
}
</span>
}
@if (tokens.Count > 3)
{
<span class="floor-plan-occupancy-more">+@(tokens.Count - 3)</span>
}
</div>
}
}
}
}
@foreach (var transition in floorTransitions)
{
@ -913,6 +974,12 @@
const saveButtons = [...editor.querySelectorAll("[data-save-layout-button]")];
const gridCoordinatesToggle = editor.querySelector("[data-grid-coordinates-toggle]");
const referenceFloorSelect = editor.querySelector("[data-reference-floor-select]");
const modeButtons = [...editor.querySelectorAll("[data-floor-plan-mode]")];
const occupancyControls = [...editor.querySelectorAll("[data-occupancy-controls], .floor-plan-occupancy-control")];
const occupancySceneSelect = editor.querySelector("[data-occupancy-scene-select]");
const occupancyPrev = editor.querySelector("[data-occupancy-prev]");
const occupancyNext = editor.querySelector("[data-occupancy-next]");
const occupancyTrays = [...editor.querySelectorAll("[data-occupancy-scene]")];
const initialSelectedBlockId = options.selectedBlockId || "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
@ -983,6 +1050,54 @@
saveStatus.textContent = message || saveStatusText[state] || state;
}
function selectedOccupancySceneId() {
return occupancySceneSelect?.value || "";
}
function refreshOccupancyTrays() {
const sceneId = selectedOccupancySceneId();
const showOccupancy = editor.classList.contains("floor-plan-editor--occupancy");
occupancyTrays.forEach(tray => {
tray.hidden = !showOccupancy || tray.dataset.occupancyScene !== sceneId;
});
}
function setFloorPlanMode(mode) {
const isOccupancy = mode === "occupancy";
editor.classList.toggle("floor-plan-editor--occupancy", isOccupancy);
modeButtons.forEach(button => {
const active = button.dataset.floorPlanMode === mode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
occupancyControls.forEach(control => {
control.hidden = !isOccupancy;
});
saveButtons.forEach(button => {
button.hidden = isOccupancy;
});
refreshOccupancyTrays();
}
function moveOccupancyScene(direction) {
if (!occupancySceneSelect || occupancySceneSelect.options.length === 0) {
return;
}
const nextIndex = Math.max(0, Math.min(occupancySceneSelect.options.length - 1, occupancySceneSelect.selectedIndex + direction));
occupancySceneSelect.selectedIndex = nextIndex;
applyOccupancySceneFloor();
refreshOccupancyTrays();
}
function applyOccupancySceneFloor() {
const option = occupancySceneSelect?.selectedOptions?.[0];
const initialFloorId = option?.dataset.initialFloorId;
if (initialFloorId) {
setActiveFloor(initialFloorId);
}
}
function gridColumnLabel(index) {
let label = "";
let value = index + 1;
@ -2177,6 +2292,13 @@
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
modeButtons.forEach(button => button.addEventListener("click", () => setFloorPlanMode(button.dataset.floorPlanMode)));
occupancySceneSelect?.addEventListener("change", () => {
applyOccupancySceneFloor();
refreshOccupancyTrays();
});
occupancyPrev?.addEventListener("click", () => moveOccupancyScene(-1));
occupancyNext?.addEventListener("click", () => moveOccupancyScene(1));
editor.querySelectorAll("[data-transition-type-select],[data-transition-from-select]").forEach(input => {
const floorId = input.dataset.transitionTypeSelect || input.dataset.transitionFromSelect;
input.addEventListener("change", () => refreshTransitionDestination(floorId));
@ -2386,6 +2508,7 @@
}
refreshGridCoordinateLabels();
refreshReferenceOverlay();
refreshOccupancyTrays();
checkOverlaps();
};

View File

@ -85,6 +85,7 @@
<a href="#inspector-thread-events">Threads</a>
<a href="#inspector-asset-events">Assets</a>
<a href="#inspector-asset-locations">Locations</a>
<a href="#inspector-occupancy">Occupancy</a>
<a href="#inspector-asset-custody">Custody</a>
<a href="#inspector-story-state">Story State</a>
@ -106,6 +107,8 @@
<input asp-for="ReturnProjectID" type="hidden" />
<input asp-for="ReturnBookID" type="hidden" />
<input asp-for="ReturnToTimeline" type="hidden" />
<input asp-for="FloorPlanID" type="hidden" />
<input asp-for="InitialFloorPlanFloorID" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="inspector-section" data-section-title="Timing & Structure" data-section-id="inspector-timing">
@ -1161,6 +1164,11 @@
</form>
</section>
<section class="inspector-section asset-events-section" data-section-title="Occupancy" data-section-id="inspector-occupancy">
<h3>Occupancy <help-icon key="sceneInspector.occupancy" /></h3>
<partial name="_SceneOccupancy" model="Model.Occupancy" />
</section>
<section class="inspector-section asset-events-section" data-section-title="Asset Custody" data-section-id="inspector-asset-custody">
<h3>Asset Custody <help-icon key="sceneInspector.custody" /></h3>
@if (!Model.AssetCustodyEvents.Any())

View File

@ -0,0 +1,98 @@
@model SceneOccupancyEditorViewModel
@{
var selectedPlanId = Model.FloorPlanID;
var locationOptions = Model.Locations
.Where(x => !selectedPlanId.HasValue || x.FloorPlanID == selectedPlanId.Value)
.OrderBy(x => x.FloorSortOrder)
.ThenBy(x => x.FloorName)
.ThenBy(x => x.LocationPath)
.ToList();
}
<form asp-controller="Scenes" asp-action="SaveOccupancy" method="post" class="scene-occupancy-form" data-scene-occupancy-form data-editor-url="@Url.Action("OccupancyEditor", "Scenes")">
<input type="hidden" name="SceneID" value="@Model.SceneID" />
<div class="scene-occupancy-status" data-scene-occupancy-status>Saved</div>
<div class="row g-2">
<div class="col-sm-6">
<label class="form-label" for="OccupancyFloorPlanID">Floor Plan</label>
<select class="form-select form-select-sm" id="OccupancyFloorPlanID" name="FloorPlanID" data-occupancy-plan-select>
<option value="">No floor plan</option>
@foreach (var option in Model.FloorPlanOptions)
{
<option value="@option.Value" selected="@(option.Value == Model.FloorPlanID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-sm-6">
<label class="form-label" for="OccupancyInitialFloorID">Initial Floor</label>
<select class="form-select form-select-sm" id="OccupancyInitialFloorID" name="InitialFloorPlanFloorID" data-occupancy-floor-select>
<option value="">Use first floor</option>
@foreach (var floor in Model.Floors.Where(x => !selectedPlanId.HasValue || x.FloorPlanID == selectedPlanId.Value))
{
<option value="@floor.FloorPlanFloorID" selected="@(floor.FloorPlanFloorID == Model.InitialFloorPlanFloorID)">@floor.Name</option>
}
</select>
</div>
</div>
@if (!Model.FloorPlanOptions.Any())
{
<p class="muted mt-2 mb-0">No floor plans exist for this project yet.</p>
}
else
{
<div class="scene-occupancy-grid mt-3">
<section>
<h4>Characters</h4>
@if (!Model.Characters.Any())
{
<p class="muted">No characters appear in this scene yet.</p>
}
else
{
@for (var i = 0; i < Model.Characters.Count; i++)
{
<div class="scene-occupancy-row">
<input type="hidden" name="Characters[@i].CharacterID" value="@Model.Characters.ElementAt(i).CharacterID" />
<span>@Model.Characters.ElementAt(i).CharacterName</span>
<select class="form-select form-select-sm" name="Characters[@i].LocationID">
<option value="">Not placed</option>
@foreach (var location in locationOptions)
{
<option value="@location.LocationID" selected="@(location.LocationID == Model.Characters.ElementAt(i).LocationID)">@location.FloorName &gt; @location.LocationPath</option>
}
</select>
</div>
}
}
</section>
<section>
<h4>Assets</h4>
@if (!Model.Assets.Any())
{
<p class="muted">No story assets appear in this scene yet.</p>
}
else
{
@for (var i = 0; i < Model.Assets.Count; i++)
{
<div class="scene-occupancy-row">
<input type="hidden" name="Assets[@i].StoryAssetID" value="@Model.Assets.ElementAt(i).StoryAssetID" />
<span>@Model.Assets.ElementAt(i).AssetName</span>
<select class="form-select form-select-sm" name="Assets[@i].LocationID">
<option value="">Not placed</option>
@foreach (var location in locationOptions)
{
<option value="@location.LocationID" selected="@(location.LocationID == Model.Assets.ElementAt(i).LocationID)">@location.FloorName &gt; @location.LocationPath</option>
}
</select>
</div>
}
}
</section>
</div>
<button class="btn btn-primary btn-sm mt-3" type="submit">Save occupancy</button>
}
</form>

View File

@ -5189,6 +5189,116 @@ body.dragging-location [data-drag-type="location"] {
color: var(--muted-text, #5d6f65);
}
.scene-occupancy-form {
display: grid;
gap: 0.5rem;
}
.scene-occupancy-status {
justify-self: start;
border-radius: 999px;
padding: 0.2rem 0.55rem;
color: var(--plotline-muted);
background: var(--plotline-soft);
font-size: 0.78rem;
font-weight: 700;
}
.scene-occupancy-status[data-save-state="saving"] {
color: #7a4d08;
background: #fff4d6;
}
.scene-occupancy-status[data-save-state="failed"] {
color: #842029;
background: #f8d7da;
}
.scene-occupancy-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.scene-occupancy-grid h4 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.scene-occupancy-row {
display: grid;
grid-template-columns: minmax(8rem, 0.45fr) minmax(0, 0.55fr);
align-items: center;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.floor-plan-editor--occupancy .floor-plan-block {
cursor: default;
}
.floor-plan-editor--occupancy .floor-plan-resize-handle {
display: none;
}
.floor-plan-occupancy-tray {
position: absolute;
z-index: 14;
grid-column: calc(var(--x) + 1) / span var(--w);
grid-row: calc(var(--y) + 1) / span var(--h);
align-self: end;
justify-self: center;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.18rem;
margin: 0 0 0.25rem;
pointer-events: auto;
}
.floor-plan-occupancy-tray[hidden] {
display: none;
}
.floor-plan-occupancy-token {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
overflow: hidden;
border: 2px solid #fff;
border-radius: 999px;
color: #fff;
background: var(--plotline-accent);
box-shadow: 0 4px 12px rgba(30, 37, 43, 0.18);
font-size: 0.68rem;
font-weight: 800;
}
.floor-plan-occupancy-token img {
width: 100%;
height: 100%;
object-fit: cover;
}
.floor-plan-occupancy-token--asset {
width: 1.45rem;
height: 1.45rem;
background: #805c15;
font-size: 0.58rem;
}
.floor-plan-occupancy-more {
border-radius: 999px;
padding: 0.15rem 0.36rem;
color: var(--plotline-ink);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 4px 12px rgba(30, 37, 43, 0.12);
font-size: 0.72rem;
font-weight: 800;
}
.floor-plan-transition-accordion .accordion-item {
background: transparent;
border-color: var(--border-color, #d8ded9);
@ -5374,6 +5484,24 @@ body.dragging-location [data-drag-type="location"] {
border-color: rgba(212, 154, 98, .22);
}
[data-bs-theme="dark"] .scene-occupancy-status,
.dark .scene-occupancy-status {
color: #d9c8b8;
background: rgba(212, 154, 98, .12);
}
[data-bs-theme="dark"] .floor-plan-occupancy-token,
.dark .floor-plan-occupancy-token {
border-color: #1f2630;
box-shadow: 0 4px 12px rgba(0, 0, 0, .28);
}
[data-bs-theme="dark"] .floor-plan-occupancy-more,
.dark .floor-plan-occupancy-more {
color: #f1e7dd;
background: rgba(31, 38, 48, .92);
}
[data-bs-theme="dark"] .floor-plan-transition-accordion .accordion-item,
.dark .floor-plan-transition-accordion .accordion-item {
border-color: rgba(212, 154, 98, .22);

File diff suppressed because one or more lines are too long

View File

@ -91,6 +91,109 @@
});
})();
(() => {
const roots = document.querySelectorAll(".scene-inspector-root");
if (!roots.length) {
return;
}
const setStatus = (form, text, state) => {
const status = form.querySelector("[data-scene-occupancy-status]");
if (!status) {
return;
}
status.textContent = text;
status.dataset.saveState = state || "";
};
const replaceOccupancyForm = (form, html) => {
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
const nextForm = wrapper.querySelector("[data-scene-occupancy-form]");
if (nextForm) {
form.replaceWith(nextForm);
const sceneForm = nextForm.closest(".scene-inspector-root")?.querySelector(".scene-inspector-form");
const floorPlanInput = sceneForm?.querySelector("[name='FloorPlanID']");
const initialFloorInput = sceneForm?.querySelector("[name='InitialFloorPlanFloorID']");
const nextFloorPlan = nextForm.querySelector("[name='FloorPlanID']");
const nextInitialFloor = nextForm.querySelector("[name='InitialFloorPlanFloorID']");
if (floorPlanInput && nextFloorPlan) {
floorPlanInput.value = nextFloorPlan.value;
}
if (initialFloorInput && nextInitialFloor) {
initialFloorInput.value = nextInitialFloor.value;
}
}
};
roots.forEach((root) => {
root.addEventListener("change", async (event) => {
const planSelect = event.target.closest?.("[data-occupancy-plan-select]");
if (!planSelect) {
return;
}
const form = planSelect.closest("[data-scene-occupancy-form]");
const sceneId = form?.querySelector("[name='SceneID']")?.value;
const editorUrl = form?.dataset.editorUrl;
if (!form || !sceneId || !editorUrl) {
return;
}
setStatus(form, "Loading...", "saving");
const url = new URL(editorUrl, window.location.origin);
url.searchParams.set("sceneId", sceneId);
if (planSelect.value) {
url.searchParams.set("floorPlanId", planSelect.value);
}
try {
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
if (!response.ok) {
throw new Error("The occupancy editor could not be refreshed.");
}
replaceOccupancyForm(form, await response.text());
} catch (error) {
setStatus(form, error.message || "Load failed", "failed");
}
});
root.addEventListener("submit", async (event) => {
const form = event.target.closest?.("[data-scene-occupancy-form]");
if (!form) {
return;
}
event.preventDefault();
setStatus(form, "Saving...", "saving");
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { "X-Requested-With": "XMLHttpRequest" }
});
if (!response.ok) {
let message = "Save failed";
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const payload = await response.json();
message = payload.message || message;
}
throw new Error(message);
}
replaceOccupancyForm(form, await response.text());
} catch (error) {
setStatus(form, error.message || "Save failed", "failed");
}
});
});
})();
(() => {
const modalElement = document.getElementById("plotlineConfirmModal");
const modal = modalElement && window.bootstrap ? new bootstrap.Modal(modalElement) : null;

File diff suppressed because one or more lines are too long