Implemented the targeted Scene Occupancy refinement pass.

This commit is contained in:
Nick Beckley 2026-06-22 11:34:38 +01:00
parent cea82a922f
commit a943ac3da4
5 changed files with 192 additions and 55 deletions

View File

@ -7771,7 +7771,10 @@ public sealed class FloorPlanService(
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());
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
var firstBlockByFloorLocation = data.Blocks
.GroupBy(x => (x.FloorPlanFloorID, x.LocationID))
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
return new FloorPlanEditorViewModel
{
Project = project,
@ -7825,10 +7828,23 @@ public sealed class FloorPlanService(
})
.ToList(),
OccupancyTokens = occupancyRows
.Where(x => firstBlockByLocation.ContainsKey(x.LocationID))
.Select(x =>
{
var block = firstBlockByLocation[x.LocationID];
FloorPlanBlock? block = null;
if (x.FloorPlanFloorID.HasValue)
{
firstBlockByFloorLocation.TryGetValue((x.FloorPlanFloorID.Value, x.LocationID), out block);
}
else
{
firstBlockByLocation.TryGetValue(x.LocationID, out block);
}
if (block is null)
{
return null;
}
return new FloorPlanOccupancyTokenViewModel
{
SceneID = x.SceneID,
@ -7842,6 +7858,8 @@ public sealed class FloorPlanService(
ThumbnailPath = x.CharacterThumbnailPath ?? x.AssetThumbnailPath
};
})
.Where(x => x is not null)
.Cast<FloorPlanOccupancyTokenViewModel>()
.ToList()
};
}

View File

@ -5,13 +5,19 @@
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
var firstBlockIdByFloorLocation = 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}")
.GroupBy(x => $"{x.FloorPlanFloorID}:{x.LocationID!.Value}")
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First().FloorPlanBlockID);
var occupancyTokensByFloorLocation = Model.OccupancyTokens
.GroupBy(x => $"{x.SceneID}:{x.FloorPlanFloorID}:{x.LocationID}")
.ToDictionary(x => x.Key, x => x.OrderBy(token => token.EntityType == "Character" ? 0 : 1).ThenBy(token => token.DisplayName).ToList());
var occupancyTokenCountByScene = Model.OccupancyTokens
.GroupBy(x => x.SceneID)
.ToDictionary(x => x.Key, x => x.Count());
var occupancyTokenCountBySceneFloor = Model.OccupancyTokens
.GroupBy(x => $"{x.SceneID}:{x.FloorPlanFloorID}")
.ToDictionary(x => x.Key, x => x.Count());
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()}";
@ -170,36 +176,46 @@
<input asp-for="FloorPlan.Description" type="hidden" />
<section class="floor-plan-editor-toolbar">
<div>
<span class="muted">Active floor</span>
<strong data-current-floor-name>@activeFloor?.Name</strong>
</div>
<div class="floor-plan-tabs" role="tablist" aria-label="Floors">
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
{
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name" data-floor-order="@floor.SortOrder" data-floor-location-id="@floor.LocationID">@floor.Name</button>
}
</div>
<div class="button-row">
<label class="floor-plan-toolbar-check">
<input type="checkbox" data-grid-coordinates-toggle />
Show grid coordinates
</label>
<label class="floor-plan-reference-control">
<span class="muted">Reference floor</span>
<select class="form-select form-select-sm" data-reference-floor-select>
<option value="none">None</option>
<option value="below">Floor below</option>
<option value="above">Floor above</option>
</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 class="floor-plan-toolbar-main">
<div class="floor-plan-toolbar-floor-group">
<div class="floor-plan-active-floor">
<span class="muted">Active floor</span>
<strong data-current-floor-name>@activeFloor?.Name</strong>
</div>
<div class="floor-plan-tabs" role="tablist" aria-label="Floors">
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
{
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name" data-floor-order="@floor.SortOrder" data-floor-location-id="@floor.LocationID">@floor.Name</button>
}
</div>
</div>
<label class="floor-plan-reference-control floor-plan-occupancy-control" data-occupancy-controls hidden>
<div class="floor-plan-toolbar-control-group">
<label class="floor-plan-toolbar-check">
<input type="checkbox" data-grid-coordinates-toggle />
Show grid coordinates
</label>
<label class="floor-plan-reference-control">
<span class="muted">Reference floor</span>
<select class="form-select form-select-sm" data-reference-floor-select>
<option value="none">None</option>
<option value="below">Floor below</option>
<option value="above">Floor above</option>
</select>
</label>
</div>
<div class="floor-plan-toolbar-action-group">
<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>
<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>
</div>
<div class="floor-plan-occupancy-scene-row" data-occupancy-controls hidden>
<label class="floor-plan-reference-control">
<span class="muted">Scene</span>
<select class="form-select form-select-sm" data-occupancy-scene-select>
@foreach (var scene in Model.OccupancyScenes)
@ -208,10 +224,8 @@
}
</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>
<button class="btn btn-outline-secondary btn-sm" type="button" data-occupancy-prev>Previous Scene</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-occupancy-next>Next Scene</button>
</div>
</section>
@ -255,10 +269,10 @@
<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)
if (block.LocationID.HasValue && firstBlockIdByFloorLocation.GetValueOrDefault($"{block.FloorPlanFloorID}:{block.LocationID.Value}") == block.FloorPlanBlockID)
{
var locationTokens = Model.OccupancyScenes
.SelectMany(scene => occupancyTokensByLocation.GetValueOrDefault($"{scene.SceneID}:{block.LocationID.Value}") ?? [])
.SelectMany(scene => occupancyTokensByFloorLocation.GetValueOrDefault($"{scene.SceneID}:{block.FloorPlanFloorID}:{block.LocationID.Value}") ?? [])
.ToList();
if (locationTokens.Any())
{
@ -268,6 +282,8 @@
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-tray
data-occupancy-floor="@block.FloorPlanFloorID"
data-occupancy-location="@block.LocationID"
data-occupancy-scene="@group.Key"
title="@tokenTitle">
@ -294,6 +310,23 @@
}
}
}
@foreach (var scene in Model.OccupancyScenes)
{
var totalTokens = occupancyTokenCountByScene.GetValueOrDefault(scene.SceneID);
var floorTokens = occupancyTokenCountBySceneFloor.GetValueOrDefault($"{scene.SceneID}:{floor.FloorPlanFloorID}");
if (totalTokens == 0)
{
<div class="floor-plan-occupancy-message" data-occupancy-message data-occupancy-scene="@scene.SceneID" data-occupancy-floor="@floor.FloorPlanFloorID" hidden>No occupancy has been set for this scene.</div>
}
else if (floorTokens == 0)
{
<div class="floor-plan-occupancy-message" data-occupancy-message data-occupancy-scene="@scene.SceneID" data-occupancy-floor="@floor.FloorPlanFloorID" hidden>Scene has occupancy, but none of the assigned locations are placed on this floor.</div>
}
else if (floorTokens < totalTokens)
{
<div class="floor-plan-occupancy-message" data-occupancy-message data-occupancy-scene="@scene.SceneID" data-occupancy-floor="@floor.FloorPlanFloorID" hidden>Some occupants are on other floors.</div>
}
}
@foreach (var transition in floorTransitions)
{
var transitionDetails = TransitionDetails(transition);
@ -975,11 +1008,12 @@
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 occupancyControls = [...editor.querySelectorAll("[data-occupancy-controls]")];
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 occupancyTrays = [...editor.querySelectorAll("[data-occupancy-tray]")];
const occupancyMessages = [...editor.querySelectorAll("[data-occupancy-message]")];
const initialSelectedBlockId = options.selectedBlockId || "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
@ -1057,8 +1091,12 @@
function refreshOccupancyTrays() {
const sceneId = selectedOccupancySceneId();
const showOccupancy = editor.classList.contains("floor-plan-editor--occupancy");
const activeFloorId = tabs.find(tab => tab.classList.contains("active"))?.dataset.floorTab || "";
occupancyTrays.forEach(tray => {
tray.hidden = !showOccupancy || tray.dataset.occupancyScene !== sceneId;
tray.hidden = !showOccupancy || tray.dataset.occupancyScene !== sceneId || tray.dataset.occupancyFloor !== activeFloorId;
});
occupancyMessages.forEach(message => {
message.hidden = !showOccupancy || message.dataset.occupancyScene !== sceneId || message.dataset.occupancyFloor !== activeFloorId;
});
}
@ -1411,6 +1449,7 @@
checkOverlaps();
refreshReferenceOverlay();
refreshTransitionDestination(floorId);
refreshOccupancyTrays();
}
function backgroundFields(floorId) {

View File

@ -4511,13 +4511,11 @@ body.dragging-location [data-drag-type="location"] {
}
.floor-plan-editor-toolbar {
align-items: center;
background: rgba(255, 255, 255, .92);
border: 1px solid var(--border-color, #d8ded9);
border-radius: 8px;
display: grid;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
margin-bottom: 12px;
padding: 10px 12px;
position: sticky;
@ -4525,6 +4523,58 @@ body.dragging-location [data-drag-type="location"] {
z-index: 20;
}
.floor-plan-toolbar-main,
.floor-plan-toolbar-floor-group,
.floor-plan-toolbar-control-group,
.floor-plan-toolbar-action-group,
.floor-plan-occupancy-scene-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.floor-plan-toolbar-main {
justify-content: space-between;
}
.floor-plan-toolbar-floor-group {
flex: 1 1 420px;
min-width: min(100%, 320px);
}
.floor-plan-active-floor {
display: grid;
gap: 2px;
}
.floor-plan-toolbar-control-group {
flex: 0 1 auto;
}
.floor-plan-toolbar-action-group {
flex: 1 1 360px;
justify-content: flex-end;
}
.floor-plan-occupancy-scene-row {
border-top: 1px solid rgba(47, 111, 99, .12);
padding-top: 9px;
}
.floor-plan-occupancy-scene-row[hidden] {
display: none;
}
.floor-plan-occupancy-scene-row .floor-plan-reference-control {
flex: 1 1 320px;
min-width: min(100%, 320px);
}
.floor-plan-occupancy-scene-row .form-select {
width: 100%;
}
.floor-plan-editor-toolbar .muted {
display: block;
font-size: .76rem;
@ -5244,16 +5294,16 @@ body.dragging-location [data-drag-type="location"] {
.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;
height: calc(var(--h) * var(--cell-size));
left: calc(var(--x) * var(--cell-size));
top: calc(var(--y) * var(--cell-size));
width: calc(var(--w) * var(--cell-size));
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.18rem;
margin: 0 0 0.25rem;
padding: 0 0 0.25rem;
pointer-events: auto;
}
@ -5299,6 +5349,29 @@ body.dragging-location [data-drag-type="location"] {
font-weight: 800;
}
.floor-plan-occupancy-message {
align-self: start;
background: rgba(255, 255, 255, .94);
border: 1px solid rgba(47, 111, 99, .18);
border-radius: 8px;
box-shadow: 0 6px 16px rgba(30, 37, 43, .08);
color: var(--muted-color, #6b756f);
font-size: .82rem;
font-weight: 700;
grid-column: 1 / -1;
grid-row: 1;
justify-self: center;
margin-top: .55rem;
max-width: min(92%, 520px);
padding: .45rem .7rem;
position: relative;
z-index: 16;
}
.floor-plan-occupancy-message[hidden] {
display: none;
}
.floor-plan-transition-accordion .accordion-item {
background: transparent;
border-color: var(--border-color, #d8ded9);
@ -5502,6 +5575,13 @@ body.dragging-location [data-drag-type="location"] {
background: rgba(31, 38, 48, .92);
}
[data-bs-theme="dark"] .floor-plan-occupancy-message,
.dark .floor-plan-occupancy-message {
background: rgba(31, 38, 48, .94);
border-color: rgba(212, 154, 98, .24);
color: #d9c8b8;
}
[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