diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 7985fbe..d1db51f 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1040,6 +1040,12 @@ QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifac timing["t90Seconds"] = optionalDoubleToJson(artifacts.timingSummary.t90Seconds); timing["t95Seconds"] = optionalDoubleToJson(artifacts.timingSummary.t95Seconds); timing["finalEvacuationTimeSeconds"] = optionalDoubleToJson(artifacts.timingSummary.finalEvacuationTimeSeconds); + if (artifacts.timingSummary.t90Frame.has_value()) { + timing["t90Frame"] = simulationFrameToJson(*artifacts.timingSummary.t90Frame); + } + if (artifacts.timingSummary.t95Frame.has_value()) { + timing["t95Frame"] = simulationFrameToJson(*artifacts.timingSummary.t95Frame); + } object["timingSummary"] = timing; return object; } @@ -1064,6 +1070,12 @@ safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonOb artifacts.timingSummary.t90Seconds = optionalDoubleFromJson(timing.value("t90Seconds")); artifacts.timingSummary.t95Seconds = optionalDoubleFromJson(timing.value("t95Seconds")); artifacts.timingSummary.finalEvacuationTimeSeconds = optionalDoubleFromJson(timing.value("finalEvacuationTimeSeconds")); + if (timing.value("t90Frame").isObject()) { + artifacts.timingSummary.t90Frame = simulationFrameFromJson(timing.value("t90Frame").toObject()); + } + if (timing.value("t95Frame").isObject()) { + artifacts.timingSummary.t95Frame = simulationFrameFromJson(timing.value("t95Frame").toObject()); + } return artifacts; } diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index 39ac032..19975ec 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -15,9 +16,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -97,6 +100,7 @@ class EvacuationProgressWidget final : public QWidget { setMinimumHeight(150); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); setToolTip("Remaining occupant curve. T90/T95 indicate when 90%/95% of occupants have evacuated."); + setMouseTracking(true); } void setCurrentTimeSeconds(std::optional seconds) { @@ -104,6 +108,10 @@ class EvacuationProgressWidget final : public QWidget { update(); } + void setTimingMarkerActivatedHandler(std::function handler) { + timingMarkerActivatedHandler_ = std::move(handler); + } + protected: void paintEvent(QPaintEvent* event) override { (void)event; @@ -175,10 +183,99 @@ class EvacuationProgressWidget final : public QWidget { QString("%1 / %2 remaining by %3 sec") .arg(static_cast(remainingCount)) .arg(static_cast(last.totalCount)) - .arg(last.timeSeconds, 0, 'f', 1)); + .arg(last.timeSeconds, 0, 'f', 1)); + } + + void mouseMoveEvent(QMouseEvent* event) override { + if (event == nullptr) { + return; + } + const bool hover = timingMarkerHitSeconds(event->position()).has_value(); + setCursor(hover ? Qt::PointingHandCursor : Qt::ArrowCursor); + QWidget::mouseMoveEvent(event); + } + + void leaveEvent(QEvent* event) override { + unsetCursor(); + QWidget::leaveEvent(event); + } + + void mousePressEvent(QMouseEvent* event) override { + if (event == nullptr || event->button() != Qt::LeftButton) { + QWidget::mousePressEvent(event); + return; + } + const auto seconds = timingMarkerHitSeconds(event->position()); + if (!seconds.has_value()) { + QWidget::mousePressEvent(event); + return; + } + event->accept(); + if (timingMarkerActivatedHandler_) { + timingMarkerActivatedHandler_(*seconds); + } } private: + QRectF plotRect() const { + return QRectF(rect()).adjusted(34, 18, -14, -28); + } + + double maxTimeSeconds() const { + if (artifacts_.evacuationProgress.empty()) { + return 1.0; + } + const auto maxTimeIt = std::max_element( + artifacts_.evacuationProgress.begin(), + artifacts_.evacuationProgress.end(), + [](const auto& lhs, const auto& rhs) { + return lhs.timeSeconds < rhs.timeSeconds; + }); + return std::max(1.0, maxTimeIt->timeSeconds); + } + + QRectF markerHitRegion(const QRectF& plot, double maxTime, double seconds) const { + const auto x = plot.left() + (std::clamp(seconds / maxTime, 0.0, 1.0) * plot.width()); + const QRectF lineHit(x - 6.0, plot.top(), 12.0, plot.height()); + const QRectF labelHit(x - 4.0, plot.top() - 2.0, 70.0, 22.0); + return lineHit.united(labelHit); + } + + std::optional timingMarkerHitSeconds(const QPointF& position) const { + const QRectF plot = plotRect(); + if (!plot.contains(position)) { + return std::nullopt; + } + + const auto maxTime = maxTimeSeconds(); + struct Candidate { + double seconds; + double distance; + }; + std::optional best; + auto consider = [&](const std::optional& markerSeconds) { + if (!markerSeconds.has_value()) { + return; + } + const auto region = markerHitRegion(plot, maxTime, *markerSeconds); + if (!region.contains(position)) { + return; + } + const auto x = plot.left() + (std::clamp(*markerSeconds / maxTime, 0.0, 1.0) * plot.width()); + const auto distance = std::abs(position.x() - x); + if (!best.has_value() || distance < best->distance) { + best = Candidate{.seconds = *markerSeconds, .distance = distance}; + } + }; + + consider(artifacts_.timingSummary.t90Seconds); + consider(artifacts_.timingSummary.t95Seconds); + if (!best.has_value()) { + return std::nullopt; + } + return best->seconds; + } + void drawTimingMarker( QPainter& painter, const QRectF& plot, @@ -215,6 +312,7 @@ class EvacuationProgressWidget final : public QWidget { safecrowd::domain::ScenarioResultArtifacts artifacts_{}; std::optional currentTimeSeconds_{}; + std::function timingMarkerActivatedHandler_{}; }; class DensityLegendWidget final : public QWidget { @@ -970,6 +1068,30 @@ QWidget* createResultCanvasPanel( if (replayControlsOut != nullptr) { *replayControlsOut = replayControls; } + if (progressWidget != nullptr) { + const QPointer replayControlsGuard(replayControls); + const auto t90Seconds = artifacts.timingSummary.t90Seconds; + const auto t95Seconds = artifacts.timingSummary.t95Seconds; + const auto t90Frame = artifacts.timingSummary.t90Frame; + const auto t95Frame = artifacts.timingSummary.t95Frame; + progressWidget->setTimingMarkerActivatedHandler([replayControlsGuard, t90Seconds, t95Seconds, t90Frame, t95Frame](double seconds) { + if (replayControlsGuard != nullptr) { + if (t90Seconds.has_value() + && t90Frame.has_value() + && std::abs(seconds - *t90Seconds) <= 1e-6) { + replayControlsGuard->showFrame(*t90Frame); + return; + } + if (t95Seconds.has_value() + && t95Frame.has_value() + && std::abs(seconds - *t95Seconds) <= 1e-6) { + replayControlsGuard->showFrame(*t95Frame); + return; + } + replayControlsGuard->showClosestFrameAtSeconds(seconds); + } + }); + } layout->addWidget(replayControls); layout->addWidget(graphPanel, 1); return panel; diff --git a/src/domain/ScenarioResultArtifacts.h b/src/domain/ScenarioResultArtifacts.h index 9959631..2bf2352 100644 --- a/src/domain/ScenarioResultArtifacts.h +++ b/src/domain/ScenarioResultArtifacts.h @@ -24,6 +24,8 @@ struct EvacuationTimingSummary { std::optional finalEvacuationTimeSeconds{}; double targetTimeSeconds{0.0}; std::optional marginSeconds{}; + std::optional t90Frame{}; + std::optional t95Frame{}; }; struct DensityCellMetric { diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index a3ca5be..2ebb2ee 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -68,14 +68,27 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { advanceRoutesForWaypointProgress(query, 0.0, entities, layoutCache); replanBlockedExitRoutes(query, entities, layoutCache, clock.elapsedSeconds, layoutRevision); replanBlockedRouteSegments(query, entities, layoutCache, clock.elapsedSeconds, layoutRevision); - - for (const auto entity : entities) { - auto& position = query.get(entity); - const auto& agent = query.get(entity); - auto& velocity = query.get(entity); - auto& route = query.get(entity); - auto& status = query.get(entity); + + if (!resources.contains()) { + resources.set(ScenarioTimingKeyframesResource{}); + } + auto& timingKeyframes = resources.get(); + const auto totalAgentCount = entities.size(); + const auto t90TargetCount = static_cast(std::ceil(static_cast(totalAgentCount) * 0.90)); + const auto t95TargetCount = static_cast(std::ceil(static_cast(totalAgentCount) * 0.95)); + + std::size_t evacuatedAtStartCount = 0; + std::size_t newlyEvacuatedCount = 0; + for (const auto entity : entities) { + auto& position = query.get(entity); + auto& velocity = query.get(entity); + auto& route = query.get(entity); + auto& status = query.get(entity); if (status.evacuated) { + ++evacuatedAtStartCount; + continue; + } + if (route.destinationZoneId.empty()) { continue; } @@ -83,15 +96,85 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto* destinationZone = findZone(floorLayout, route.destinationZoneId); if (destinationZone != nullptr && pointInRing(destinationZone->area.outline, position.value)) { status.evacuated = true; - status.completionTimeSeconds = clock.elapsedSeconds; - velocity.value = {}; - continue; - } - - if (route.nextWaypointIndex >= route.waypoints.size()) { - velocity.value = {}; - continue; - } + status.completionTimeSeconds = clock.elapsedSeconds; + velocity.value = {}; + ++newlyEvacuatedCount; + } + } + + const auto evacuatedAfterCount = evacuatedAtStartCount + newlyEvacuatedCount; + const bool shouldCaptureT90 = t90TargetCount > 0 + && !timingKeyframes.t90Frame.has_value() + && evacuatedAtStartCount < t90TargetCount + && evacuatedAfterCount >= t90TargetCount; + const bool shouldCaptureT95 = t95TargetCount > 0 + && !timingKeyframes.t95Frame.has_value() + && evacuatedAtStartCount < t95TargetCount + && evacuatedAfterCount >= t95TargetCount; + if (shouldCaptureT90 || shouldCaptureT95) { + SimulationFrame keyframe; + keyframe.elapsedSeconds = clock.elapsedSeconds; + keyframe.totalAgentCount = totalAgentCount; + keyframe.evacuatedAgentCount = evacuatedAfterCount; + keyframe.complete = totalAgentCount > 0 && evacuatedAfterCount >= totalAgentCount; + + const auto view = query.view(); + keyframe.agents.reserve(view.size()); + for (const auto entity : view) { + const auto& status = query.get(entity); + if (status.evacuated) { + continue; + } + const auto& position = query.get(entity); + const auto& velocity = query.get(entity); + const auto& agent = query.get(entity); + const auto* route = query.contains(entity) ? &query.get(entity) : nullptr; + keyframe.agents.push_back({ + .id = entity.index, + .position = position.value, + .velocity = velocity.value, + .radius = agent.radius, + .floorId = route != nullptr + ? (!route->displayFloorId.empty() + ? route->displayFloorId + : route->currentFloorId) + : std::string{}, + .stalled = route != nullptr + && scenarioAgentStalled(simulation_internal::lengthOf(velocity.value), route->stalledSeconds), + }); + } + + if (shouldCaptureT90) { + timingKeyframes.t90Frame = keyframe; + } + if (shouldCaptureT95) { + timingKeyframes.t95Frame = keyframe; + } + if (resources.contains()) { + auto& result = resources.get(); + if (shouldCaptureT90 && !result.artifacts.timingSummary.t90Frame.has_value()) { + result.artifacts.timingSummary.t90Frame = timingKeyframes.t90Frame; + } + if (shouldCaptureT95 && !result.artifacts.timingSummary.t95Frame.has_value()) { + result.artifacts.timingSummary.t95Frame = timingKeyframes.t95Frame; + } + } + } + + for (const auto entity : entities) { + auto& position = query.get(entity); + const auto& agent = query.get(entity); + auto& velocity = query.get(entity); + auto& route = query.get(entity); + auto& status = query.get(entity); + if (status.evacuated) { + continue; + } + + if (route.nextWaypointIndex >= route.waypoints.size()) { + velocity.value = {}; + continue; + } const auto target = routeWaypointTarget(route, position.value); const auto distance = distanceBetween(position.value, target); @@ -105,11 +188,12 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { velocity.value = {}; continue; } - + + const auto& floorLayout = cachedLayoutForFloor(layoutCache, route.currentFloorId); const auto routeDirection = (target - position.value) * (1.0 / distance); const auto maxSpeed = effectiveMaxSpeed(layoutCache, agent, route, position.value); const auto desiredVelocity = routeDirection * maxSpeed; - double speedScale = 1.0; + double speedScale = 1.0; const auto neighborRadius = std::max( static_cast(agent.radius) + kDefaultAgentRadius + kPersonalSpaceBuffer, kHeadOnLookAheadDistance); diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index 4551827..37201da 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -541,6 +541,21 @@ void ScenarioResultArtifactsSystem::update(engine::EngineWorld& world, const eng percentileCompletionTime(completionTimes, totalAgentCount, 0.90); result.artifacts.timingSummary.t95Seconds = percentileCompletionTime(completionTimes, totalAgentCount, 0.95); + if (resources.contains()) { + const auto& keyframes = resources.get(); + if (!result.artifacts.timingSummary.t90Frame.has_value() + && result.artifacts.timingSummary.t90Seconds.has_value() + && keyframes.t90Frame.has_value() + && std::abs(keyframes.t90Frame->elapsedSeconds - *result.artifacts.timingSummary.t90Seconds) <= 1e-9) { + result.artifacts.timingSummary.t90Frame = keyframes.t90Frame; + } + if (!result.artifacts.timingSummary.t95Frame.has_value() + && result.artifacts.timingSummary.t95Seconds.has_value() + && keyframes.t95Frame.has_value() + && std::abs(keyframes.t95Frame->elapsedSeconds - *result.artifacts.timingSummary.t95Seconds) <= 1e-9) { + result.artifacts.timingSummary.t95Frame = keyframes.t95Frame; + } + } if (totalAgentCount > 0 && completionTimes.size() == totalAgentCount) { result.artifacts.timingSummary.finalEvacuationTimeSeconds = *std::max_element(completionTimes.begin(), completionTimes.end()); diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index 19e8ca8..32d9a7e 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -71,6 +72,11 @@ struct ScenarioResultArtifactsResource { std::unordered_map peakDensityCellsByAddress{}; }; +struct ScenarioTimingKeyframesResource { + std::optional t90Frame{}; + std::optional t95Frame{}; +}; + struct ScenarioAgentSeed { Position position{}; Agent agent{};