From 876354ae5ac0f35f787213ad552ece05eac9d9e1 Mon Sep 17 00:00:00 2001 From: 95x8x9 Date: Tue, 5 May 2026 18:02:15 +0900 Subject: [PATCH 1/4] [Application] Show block schedule tooltip --- src/application/ScenarioCanvasWidget.cpp | 96 ++++++++++++++++++ src/application/ScenarioCanvasWidget.h | 2 + src/application/SimulationCanvasWidget.cpp | 108 +++++++++++++++++++++ src/application/SimulationCanvasWidget.h | 3 + 4 files changed, 209 insertions(+) diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index d0f748a..ef80f2e 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -55,6 +56,66 @@ bool matchesFloor(const std::string& elementFloorId, const QString& floorId) { return floorId.isEmpty() || elementFloorId.empty() || QString::fromStdString(elementFloorId) == floorId; } +QString formatConnectionBlockTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { + if (block.connectionId.empty()) { + return {}; + } + + QString text = QStringLiteral("차단 스케줄"); + if (block.intervals.empty()) { + text.append("\n- 항상 차단"); + return text; + } + + for (const auto& interval : block.intervals) { + const auto start = std::max(0.0, interval.startSeconds); + const auto end = std::max(start, interval.endSeconds); + text.append(QString("\n- %1s ~ %2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } + return text; +} + +std::optional hoveredConnectionBlockIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const QString& currentFloorId, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 14.0; + + std::optional closestIndex; + double closestDistanceSq = kHoverRadiusPixels * kHoverRadiusPixels; + + for (std::size_t index = 0; index < blocks.size(); ++index) { + const auto& block = blocks[index]; + if (block.connectionId.empty()) { + continue; + } + + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + }); + if (it == layout.connections.end()) { + continue; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + continue; + } + + const auto center = transform.map({.x = (it->centerSpan.start.x + it->centerSpan.end.x) * 0.5, + .y = (it->centerSpan.start.y + it->centerSpan.end.y) * 0.5}); + const auto dx = center.x() - screenPosition.x(); + const auto dy = center.y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= closestDistanceSq) { + closestDistanceSq = distanceSq; + closestIndex = index; + } + } + + return closestIndex; +} + QString defaultFloorId(const safecrowd::domain::FacilityLayout2D& layout) { if (!layout.floors.empty() && !layout.floors.front().id.empty()) { return QString::fromStdString(layout.floors.front().id); @@ -699,6 +760,12 @@ void ScenarioCanvasWidget::keyReleaseEvent(QKeyEvent* event) { QWidget::keyReleaseEvent(event); } +void ScenarioCanvasWidget::leaveEvent(QEvent* event) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + QWidget::leaveEvent(event); +} + void ScenarioCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { camera_.reset(); @@ -716,17 +783,46 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { } if (dragging_) { + if (!hoveredConnectionBlockId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } dragCurrent_ = event->position(); update(); event->accept(); return; } if (selectionDragging_) { + if (!hoveredConnectionBlockId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } selectionDragCurrent_ = event->position(); update(); event->accept(); return; } + + if (const auto bounds = collectBounds(); bounds.has_value()) { + const auto transform = currentTransform(*bounds); + const auto hoveredIndex = hoveredConnectionBlockIndex(layout_, connectionBlocks_, transform, currentFloorId_, event->position()); + if (!hoveredIndex.has_value()) { + if (!hoveredConnectionBlockId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } + } else { + const auto& block = connectionBlocks_[*hoveredIndex]; + const auto tooltip = formatConnectionBlockTooltip(block); + if (!tooltip.isEmpty()) { + const auto hoveredId = QString::fromStdString(block.id.empty() ? block.connectionId : block.id); + if (hoveredId != hoveredConnectionBlockId_) { + hoveredConnectionBlockId_ = hoveredId; + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); + } + } + } + } QWidget::mouseMoveEvent(event); } diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index 284d353..595d5b7 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -67,6 +67,7 @@ class ScenarioCanvasWidget : public QWidget { bool eventFilter(QObject* watched, QEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void keyReleaseEvent(QKeyEvent* event) override; + void leaveEvent(QEvent* event) override; void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mousePressEvent(QMouseEvent* event) override; @@ -145,6 +146,7 @@ class ScenarioCanvasWidget : public QWidget { QSpinBox* groupCountSpinBox_{nullptr}; QLabel* groupDistributionLabel_{nullptr}; QComboBox* groupDistributionComboBox_{nullptr}; + QString hoveredConnectionBlockId_{}; std::function layoutElementActivatedHandler_{}; std::function crowdSelectionChangedHandler_{}; std::function&)> placementsChangedHandler_{}; diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index 0707ed6..efc0ee3 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include namespace safecrowd::application { @@ -93,6 +94,65 @@ QColor densityHeatmapColor(double ratio, int alpha) { return QColor(220, 38, 38, alpha); } +QString formatScheduleTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { + if (block.connectionId.empty()) { + return {}; + } + + QString text = QStringLiteral("차단 스케줄"); + if (block.intervals.empty()) { + text.append("\n- 항상 차단"); + return text; + } + + for (const auto& interval : block.intervals) { + const auto start = std::max(0.0, interval.startSeconds); + const auto end = std::max(start, interval.endSeconds); + text.append(QString("\n- %1s ~ %2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } + return text; +} + +std::optional hoveredBlockedConnectionIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const std::string& currentFloorId, + double elapsedSeconds, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 14.0; + + std::optional closestIndex; + double closestDistanceSq = kHoverRadiusPixels * kHoverRadiusPixels; + + for (std::size_t index = 0; index < blocks.size(); ++index) { + const auto& block = blocks[index]; + if (!connectionShouldBeBlocked(block, elapsedSeconds)) { + continue; + } + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + }); + if (it == layout.connections.end()) { + continue; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + continue; + } + + const auto center = transform.map(connectionCenter(*it)); + const auto dx = center.x() - screenPosition.x(); + const auto dy = center.y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= closestDistanceSq) { + closestDistanceSq = distanceSq; + closestIndex = index; + } + } + + return closestIndex; +} + } // namespace SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout2D layout, QWidget* parent) @@ -211,6 +271,12 @@ void SimulationCanvasWidget::keyReleaseEvent(QKeyEvent* event) { QWidget::keyReleaseEvent(event); } +void SimulationCanvasWidget::leaveEvent(QEvent* event) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + QWidget::leaveEvent(event); +} + void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { camera_.reset(); @@ -228,6 +294,48 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { update(); return; } + + const auto bounds = collectBounds(); + if (!bounds.has_value()) { + if (!hoveredConnectionBlockId_.empty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } + QWidget::mouseMoveEvent(event); + return; + } + + const auto transform = currentTransform(*bounds); + const auto elapsedSeconds = std::max(0.0, frame_.elapsedSeconds); + const auto hoveredIndex = hoveredBlockedConnectionIndex( + layout_, + connectionBlocks_, + transform, + currentFloorId_, + elapsedSeconds, + event->position()); + + if (!hoveredIndex.has_value()) { + if (!hoveredConnectionBlockId_.empty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } + QWidget::mouseMoveEvent(event); + return; + } + + const auto& block = connectionBlocks_[*hoveredIndex]; + const auto tooltip = formatScheduleTooltip(block); + if (tooltip.isEmpty()) { + QWidget::mouseMoveEvent(event); + return; + } + + const auto hoveredId = block.id.empty() ? block.connectionId : block.id; + if (hoveredId != hoveredConnectionBlockId_) { + hoveredConnectionBlockId_ = hoveredId; + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); + } QWidget::mouseMoveEvent(event); } diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 21f143f..5f5fa64 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -51,6 +51,7 @@ class SimulationCanvasWidget : public QWidget { bool eventFilter(QObject* watched, QEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void keyReleaseEvent(QKeyEvent* event) override; + void leaveEvent(QEvent* event) override; void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mousePressEvent(QMouseEvent* event) override; @@ -95,6 +96,8 @@ class SimulationCanvasWidget : public QWidget { double layoutCacheZoom_{0.0}; double layoutCacheDevicePixelRatio_{0.0}; bool layoutCacheValid_{false}; + + std::string hoveredConnectionBlockId_{}; }; } // namespace safecrowd::application From 4892be98e7eac27f82815b185508faf71f2b7040 Mon Sep 17 00:00:00 2001 From: 95x8x9 Date: Wed, 6 May 2026 03:45:14 +0900 Subject: [PATCH 2/4] [Application/Domain] Route guidance periods + door install --- src/application/ProjectPersistence.cpp | 57 ++ src/application/ScenarioAuthoringWidget.cpp | 81 +- src/application/ScenarioCanvasWidget.cpp | 877 +++++++++++++++++- src/application/ScenarioCanvasWidget.h | 14 + src/application/ScenarioRunWidget.cpp | 1 + src/application/SimulationCanvasWidget.cpp | 278 +++++- src/application/SimulationCanvasWidget.h | 5 + src/domain/AgentComponents.h | 4 + src/domain/ScenarioAuthoring.h | 18 + src/domain/ScenarioSimulationInternal.cpp | 148 +++ src/domain/ScenarioSimulationInternal.h | 10 + src/domain/ScenarioSimulationMotionSystem.cpp | 369 +++++++- src/domain/ScenarioSimulationRunner.cpp | 46 +- src/domain/ScenarioSimulationSystems.h | 3 + 14 files changed, 1874 insertions(+), 37 deletions(-) diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 7985fbe..21a12bd 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -752,6 +752,54 @@ safecrowd::domain::OperationalEventDraft eventFromJson(const QJsonObject& object }; } +QJsonObject routeGuidanceToJson(const safecrowd::domain::RouteGuidanceDraft& guidance) { + QJsonObject object; + object["id"] = QString::fromStdString(guidance.id); + object["startSeconds"] = guidance.startSeconds; + object["endSeconds"] = guidance.endSeconds; + QJsonArray periods; + for (const auto& period : guidance.periods) { + QJsonObject periodObject; + periodObject["startSeconds"] = period.startSeconds; + periodObject["endSeconds"] = period.endSeconds; + periods.append(periodObject); + } + object["periods"] = periods; + object["guidedExitZoneId"] = QString::fromStdString(guidance.guidedExitZoneId); + object["installConnectionId"] = QString::fromStdString(guidance.installConnectionId); + object["baseComplianceRate"] = guidance.baseComplianceRate; + object["guidanceStrength"] = guidance.guidanceStrength; + object["maxDetourMeters"] = guidance.maxDetourMeters; + return object; +} + +safecrowd::domain::RouteGuidanceDraft routeGuidanceFromJson(const QJsonObject& object) { + safecrowd::domain::RouteGuidanceDraft guidance; + guidance.id = object.value("id").toString().toStdString(); + guidance.startSeconds = object.value("startSeconds").toDouble(0.0); + guidance.endSeconds = object.value("endSeconds").toDouble(10.0); + for (const auto& value : object.value("periods").toArray()) { + const auto periodObject = value.toObject(); + guidance.periods.push_back({ + .startSeconds = periodObject.value("startSeconds").toDouble(0.0), + .endSeconds = periodObject.value("endSeconds").toDouble(0.0), + }); + } + if (guidance.periods.empty() && (object.contains("startSeconds") || object.contains("endSeconds"))) { + // Backward compatibility: older projects stored a single scalar period. + guidance.periods.push_back({ + .startSeconds = guidance.startSeconds, + .endSeconds = guidance.endSeconds, + }); + } + guidance.guidedExitZoneId = object.value("guidedExitZoneId").toString().toStdString(); + guidance.installConnectionId = object.value("installConnectionId").toString().toStdString(); + guidance.baseComplianceRate = object.value("baseComplianceRate").toDouble(0.5); + guidance.guidanceStrength = object.value("guidanceStrength").toDouble(0.55); + guidance.maxDetourMeters = object.value("maxDetourMeters").toDouble(20.0); + return guidance; +} + QJsonObject connectionBlockIntervalToJson(const safecrowd::domain::ConnectionBlockIntervalDraft& interval) { QJsonObject object; object["startSeconds"] = interval.startSeconds; @@ -796,6 +844,12 @@ QJsonObject controlPlanToJson(const safecrowd::domain::ControlPlan& control) { } object["events"] = events; + QJsonArray routeGuidances; + for (const auto& guidance : control.routeGuidances) { + routeGuidances.append(routeGuidanceToJson(guidance)); + } + object["routeGuidances"] = routeGuidances; + QJsonArray connectionBlocks; for (const auto& block : control.connectionBlocks) { connectionBlocks.append(connectionBlockToJson(block)); @@ -809,6 +863,9 @@ safecrowd::domain::ControlPlan controlPlanFromJson(const QJsonObject& object) { for (const auto& value : object.value("events").toArray()) { control.events.push_back(eventFromJson(value.toObject())); } + for (const auto& value : object.value("routeGuidances").toArray()) { + control.routeGuidances.push_back(routeGuidanceFromJson(value.toObject())); + } for (const auto& value : object.value("connectionBlocks").toArray()) { control.connectionBlocks.push_back(connectionBlockFromJson(value.toObject())); } diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 120a804..91b3ee2 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -283,6 +283,69 @@ std::vector buildEventsTree( }); } + const auto& routeGuidances = scenario->draft.control.routeGuidances; + if (!routeGuidances.empty()) { + std::vector nodes; + nodes.reserve(routeGuidances.size()); + for (const auto& guidance : routeGuidances) { + const auto guidanceId = QString::fromStdString(guidance.id); + const auto doorLabel = guidance.installConnectionId.empty() + ? QString{} + : connectionLabelForId(layout, guidance.installConnectionId); + const auto exitLabel = guidance.guidedExitZoneId.empty() + ? QStringLiteral("Nearest exit") + : zoneName(layout, guidance.guidedExitZoneId); + QString periodSummary = QStringLiteral("Always"); + if (!guidance.periods.empty()) { + periodSummary = blockScheduleSummary(safecrowd::domain::ConnectionBlockDraft{ + .intervals = [&]() { + std::vector intervals; + intervals.reserve(guidance.periods.size()); + for (const auto& period : guidance.periods) { + intervals.push_back({ + .startSeconds = std::max(0.0, period.startSeconds), + .endSeconds = std::max(0.0, period.endSeconds), + }); + } + return intervals; + }(), + }); + } + + std::vector children; + children.reserve(doorLabel.isEmpty() ? 2u : 3u); + children.push_back({ + .label = QString("Exit - %1").arg(exitLabel), + .id = QString("%1/exit").arg(guidanceId), + }); + if (!doorLabel.isEmpty()) { + children.push_back({ + .label = QString("Door - %1").arg(doorLabel), + .id = QString("%1/door").arg(guidanceId), + }); + } + children.push_back({ + .label = QString("Period - %1").arg(periodSummary), + .id = QString("%1/period").arg(guidanceId), + }); + + nodes.push_back({ + .label = QString("Guidance - %1").arg(doorLabel.isEmpty() ? exitLabel : doorLabel), + .id = guidanceId, + .detail = QString("Period: %1").arg(periodSummary), + .children = std::move(children), + .expanded = true, + }); + } + + sections.push_back({ + .label = QString("Route Guidance (%1)").arg(static_cast(routeGuidances.size())), + .children = std::move(nodes), + .expanded = true, + .selectable = false, + }); + } + const auto& connectionBlocks = scenario->draft.control.connectionBlocks; if (!connectionBlocks.empty()) { std::vector blocks; @@ -558,6 +621,16 @@ void ScenarioAuthoringWidget::refreshCanvas() { refreshNavigationPanel(); refreshInspector(); }); + canvas_->setRouteGuidances(scenario->draft.control.routeGuidances); + canvas_->setRouteGuidancesChangedHandler([this](const std::vector& guidances) { + auto* current = currentScenario(); + if (current == nullptr) { + return; + } + current->draft.control.routeGuidances = guidances; + refreshNavigationPanel(); + refreshInspector(); + }); if (!selectedLayoutElementId_.isEmpty()) { canvas_->focusLayoutElement(selectedLayoutElementId_); } else if (!selectedCrowdElementId_.isEmpty()) { @@ -576,13 +649,15 @@ void ScenarioAuthoringWidget::refreshInspector() { } else { const int people = totalOccupantCount(*scenario); const auto blockCount = static_cast(scenario->draft.control.connectionBlocks.size()); - scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6\nBlocked exits: %7") + const auto guidanceCount = static_cast(scenario->draft.control.routeGuidances.size()); + scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6\nRoute guidance: %7\nBlocked exits: %8") .arg( QString::fromStdString(scenario->draft.name), scenario->draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative") .arg(people) .arg(scenario->startText, scenario->destinationText) .arg(static_cast(scenario->events.size())) + .arg(guidanceCount) .arg(blockCount)); } } @@ -599,6 +674,10 @@ void ScenarioAuthoringWidget::refreshInspector() { changes << QString("Blocked exits: %1 configured") .arg(static_cast(scenario->draft.control.connectionBlocks.size())); } + if (!scenario->draft.control.routeGuidances.empty()) { + changes << QString("Route guidance: %1 configured") + .arg(static_cast(scenario->draft.control.routeGuidances.size())); + } if (changes.isEmpty()) { changes << "No changed fields yet"; } diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index ef80f2e..4334630 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -1,11 +1,14 @@ #include "application/ScenarioCanvasWidget.h" #include "application/ToolIconResources.h" +#include "application/UiStyle.h" #include #include #include #include +#include +#include #include #include @@ -13,11 +16,14 @@ #include #include #include +#include #include #include +#include #include #include #include +#include #include #include #include @@ -45,6 +51,8 @@ constexpr double kGeometryEpsilon = 1e-9; constexpr double kSelectionDragThresholdPixels = 4.0; const QColor kSelectionHighlightColor("#0b3d78"); +[[nodiscard]] safecrowd::domain::Point2D polygonCenter(const safecrowd::domain::Polygon2D& polygon); + struct PointBounds { double minX{0.0}; double minY{0.0}; @@ -56,14 +64,72 @@ bool matchesFloor(const std::string& elementFloorId, const QString& floorId) { return floorId.isEmpty() || elementFloorId.empty() || QString::fromStdString(elementFloorId) == floorId; } +safecrowd::domain::Point2D connectionMarkerCenter(const safecrowd::domain::Connection2D& connection) { + return { + .x = (connection.centerSpan.start.x + connection.centerSpan.end.x) * 0.5, + .y = (connection.centerSpan.start.y + connection.centerSpan.end.y) * 0.5, + }; +} + +std::string pickNearestExitZoneIdForConnection( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::Connection2D& connection) { + const auto pickAdjacentExit = [&]() -> std::string { + if (connection.fromZoneId.empty() && connection.toZoneId.empty()) { + return {}; + } + const auto exitIt = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + if (zone.kind != safecrowd::domain::ZoneKind::Exit) { + return false; + } + return zone.id == connection.fromZoneId || zone.id == connection.toZoneId; + }); + return exitIt == layout.zones.end() ? std::string{} : exitIt->id; + }; + + if (auto adjacent = pickAdjacentExit(); !adjacent.empty()) { + return adjacent; + } + + const auto doorCenter = connectionMarkerCenter(connection); + + const auto pickNearest = [&](bool sameFloorOnly) -> std::string { + double bestDistanceSq = std::numeric_limits::infinity(); + const safecrowd::domain::Zone2D* bestZone = nullptr; + for (const auto& zone : layout.zones) { + if (zone.kind != safecrowd::domain::ZoneKind::Exit) { + continue; + } + if (sameFloorOnly && !connection.floorId.empty() && !zone.floorId.empty() && zone.floorId != connection.floorId) { + continue; + } + + const auto exitCenter = polygonCenter(zone.area); + const auto dx = exitCenter.x - doorCenter.x; + const auto dy = exitCenter.y - doorCenter.y; + const auto d2 = (dx * dx) + (dy * dy); + if (d2 < bestDistanceSq) { + bestDistanceSq = d2; + bestZone = &zone; + } + } + return bestZone == nullptr ? std::string{} : bestZone->id; + }; + + if (auto sameFloor = pickNearest(true); !sameFloor.empty()) { + return sameFloor; + } + return pickNearest(false); +} + QString formatConnectionBlockTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { if (block.connectionId.empty()) { return {}; } - QString text = QStringLiteral("차단 스케줄"); + QString text = QStringLiteral("Block schedule"); if (block.intervals.empty()) { - text.append("\n- 항상 차단"); + text.append("\n- Block always"); return text; } @@ -116,6 +182,135 @@ std::optional hoveredConnectionBlockIndex( return closestIndex; } +QString formatRouteGuidanceTooltip( + const safecrowd::domain::RouteGuidanceDraft& guidance) { + QString text = QStringLiteral("Route guidance"); + if (guidance.periods.empty()) { + text.append(QStringLiteral("\n Always")); + } else { + const auto& period = guidance.periods.front(); + const auto start = std::max(0.0, period.startSeconds); + const auto end = std::max(start, std::max(0.0, period.endSeconds)); + text.append(QString("\n %1s~%2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } + text.append(QString("\n Base compliance: %1").arg(std::clamp(guidance.baseComplianceRate, 0.0, 1.0), 0, 'f', 2)); + text.append(QString("\n Strength: %1").arg(std::clamp(guidance.guidanceStrength, 0.0, 1.0), 0, 'f', 2)); + text.append(QString("\n Max detour:%1m").arg(std::max(0.0, guidance.maxDetourMeters), 0, 'f', 1)); + return text; +} + +std::optional routeGuidanceMarkerCenter( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::RouteGuidanceDraft& guidance, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const QString& currentFloorId) { + std::optional center; + if (!guidance.installConnectionId.empty()) { + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == guidance.installConnectionId; + }); + if (it == layout.connections.end()) { + return std::nullopt; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + return std::nullopt; + } + center = transform.map(connectionMarkerCenter(*it)); + } else if (!guidance.guidedExitZoneId.empty()) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + return zone.id == guidance.guidedExitZoneId; + }); + if (it == layout.zones.end() || it->kind != safecrowd::domain::ZoneKind::Exit) { + return std::nullopt; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + return std::nullopt; + } + center = transform.map(polygonCenter(it->area)); + } else { + return std::nullopt; + } + + if (!center.has_value() || blocks.empty()) { + return center; + } + + constexpr double kMinSeparationPixels = 28.0; + constexpr double kStackOffsetPixels = 34.0; + + std::vector blockedCenters; + blockedCenters.reserve(blocks.size()); + for (const auto& block : blocks) { + if (block.connectionId.empty()) { + continue; + } + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + }); + if (it == layout.connections.end()) { + continue; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + continue; + } + blockedCenters.push_back(transform.map(connectionMarkerCenter(*it))); + } + if (blockedCenters.empty()) { + return center; + } + + const auto minDistanceToBlocks = [&](const QPointF& candidate) { + double minDistance = std::numeric_limits::infinity(); + for (const auto& blocked : blockedCenters) { + minDistance = std::min(minDistance, QLineF(candidate, blocked).length()); + } + return minDistance; + }; + + const auto baseMinDistance = minDistanceToBlocks(*center); + if (baseMinDistance >= kMinSeparationPixels) { + return center; + } + + const QPointF up = *center + QPointF(0.0, -kStackOffsetPixels); + const QPointF down = *center + QPointF(0.0, kStackOffsetPixels); + const auto upDistance = minDistanceToBlocks(up); + const auto downDistance = minDistanceToBlocks(down); + return upDistance >= downDistance ? up : down; +} + +std::optional hoveredRouteGuidanceIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& guidances, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const QString& currentFloorId, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 14.0; + + std::optional closestIndex; + double closestDistanceSq = kHoverRadiusPixels * kHoverRadiusPixels; + + for (std::size_t index = 0; index < guidances.size(); ++index) { + const auto& guidance = guidances[index]; + const auto center = routeGuidanceMarkerCenter(layout, guidance, blocks, transform, currentFloorId); + if (!center.has_value()) { + continue; + } + + const auto dx = center->x() - screenPosition.x(); + const auto dy = center->y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= closestDistanceSq) { + closestDistanceSq = distanceSq; + closestIndex = index; + } + } + + return closestIndex; +} + QString defaultFloorId(const safecrowd::domain::FacilityLayout2D& layout) { if (!layout.floors.empty() && !layout.floors.front().id.empty()) { return QString::fromStdString(layout.floors.front().id); @@ -449,6 +644,24 @@ QIcon makeToolIcon(const QString& type, const QColor& color) { return QIcon(pixmap); } + if (type == "guidance") { + painter.setPen(Qt::NoPen); + painter.setBrush(color); + painter.save(); + painter.translate(QPointF(22, 22)); + painter.rotate(-25.0); + painter.translate(QPointF(-22, -22)); + painter.drawRoundedRect(QRectF(18, 8, 10, 20), 3.0, 3.0); + painter.drawRoundedRect(QRectF(19, 28, 8, 9), 2.5, 2.5); + painter.restore(); + + painter.setPen(QPen(color, 2.8, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawLine(QPointF(32, 10), QPointF(36, 6)); + painter.drawLine(QPointF(34, 15), QPointF(39, 13)); + painter.drawLine(QPointF(29, 7), QPointF(31, 2)); + return QIcon(pixmap); + } + if (type != "group") { return QIcon(pixmap); } @@ -633,6 +846,358 @@ class ConnectionBlockScheduleDialog final : public QDialog { std::vector intervals_{}; }; +class RouteGuidanceSettingsDialog final : public QDialog { +public: + explicit RouteGuidanceSettingsDialog( + safecrowd::domain::RouteGuidanceDraft guidance, + QWidget* parent = nullptr) + : QDialog(parent), + guidance_(std::move(guidance)) { + setWindowTitle("Route guidance settings"); + setModal(true); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(12, 12, 12, 12); + root->setSpacing(10); + + auto* caption = new QLabel( + "Route guidance settings.\n" + "Period defines when the event is active.\n" + "Parameters control how strongly agents follow the guidance.\n" + "Target exit is selected by clicking an exit or a door on the canvas.", + this); + caption->setWordWrap(true); + root->addWidget(caption); + + auto* form = new QWidget(this); + auto* formLayout = new QGridLayout(form); + formLayout->setContentsMargins(0, 0, 0, 0); + formLayout->setHorizontalSpacing(10); + formLayout->setVerticalSpacing(8); + root->addWidget(form); + + int row = 0; + periodRowsContainer_ = nullptr; + alwaysPeriodLabel_ = nullptr; + + { + auto* title = new QLabel("Period", this); + title->setStyleSheet("QLabel { font-weight: 600; color: #16202b; }"); + formLayout->addWidget(title, row * 2, 0, Qt::AlignLeft); + + periodRowsContainer_ = new QWidget(this); + periodRowsLayout_ = new QVBoxLayout(periodRowsContainer_); + periodRowsLayout_->setContentsMargins(0, 0, 0, 0); + periodRowsLayout_->setSpacing(6); + formLayout->addWidget(periodRowsContainer_, row * 2, 1, Qt::AlignLeft); + + alwaysPeriodLabel_ = new QLabel("Always (no periods configured).", this); + alwaysPeriodLabel_->setStyleSheet("QLabel { color: #4f5d6b; }"); + periodRowsLayout_->addWidget(alwaysPeriodLabel_); + + auto* helpRow = new QWidget(this); + auto* helpLayout = new QHBoxLayout(helpRow); + helpLayout->setContentsMargins(0, 0, 0, 0); + helpLayout->setSpacing(8); + + auto* helpLabel = new QLabel("Active time range for this guidance event.", this); + helpLabel->setWordWrap(true); + helpLabel->setStyleSheet("QLabel { color: #4f5d6b; font-size: 12px; }"); + helpLayout->addWidget(helpLabel, 1); + + addPeriodButton_ = new QPushButton("+", this); + addPeriodButton_->setToolTip("Add a period interval."); + addPeriodButton_->setStyleSheet(ui::secondaryButtonStyleSheet()); + { + auto font = addPeriodButton_->font(); + font.setPointSize(std::max(9, font.pointSize() - 1)); + addPeriodButton_->setFont(font); + } + helpLayout->addWidget(addPeriodButton_, 0); + + removePeriodButton_ = new QPushButton("-", this); + removePeriodButton_->setToolTip("Remove the last period interval."); + removePeriodButton_->setStyleSheet(ui::secondaryButtonStyleSheet()); + { + auto font = removePeriodButton_->font(); + font.setPointSize(std::max(9, font.pointSize() - 1)); + removePeriodButton_->setFont(font); + } + helpLayout->addWidget(removePeriodButton_, 0); + + formLayout->addWidget(helpRow, (row * 2) + 1, 0, 1, 2); + + connect(addPeriodButton_, &QPushButton::clicked, this, [this]() { + addPeriodRow(std::nullopt); + }); + connect(removePeriodButton_, &QPushButton::clicked, this, [this]() { + removeLastPeriodRow(); + }); + + // Seed UI rows from existing guidance data. + if (!guidance_.periods.empty()) { + for (const auto& period : guidance_.periods) { + addPeriodRow(period); + } + } else if (guidance_.endSeconds > 0.0 || guidance_.startSeconds > 0.0) { + addPeriodRow(safecrowd::domain::RouteGuidancePeriodDraft{ + .startSeconds = guidance_.startSeconds, + .endSeconds = guidance_.endSeconds, + }); + } + + refreshPeriodUiState(); + row++; + } + + auto* paramsHeader = new QLabel("Parameters", this); + paramsHeader->setStyleSheet("QLabel { font-weight: 600; color: #16202b; }"); + formLayout->addWidget(paramsHeader, row * 2, 0, 1, 2, Qt::AlignLeft); + row++; + + baseComplianceRate_ = addField( + formLayout, + row++, + "Base compliance", + "Baseline compliance (0~1). Target group-average probability of following the guidance.", + 0.0, + 1.0, + 0.01, + 2, + std::clamp(guidance_.baseComplianceRate, 0.0, 1.0)); + + guidanceStrength_ = addField( + formLayout, + row++, + "Guidance strength", + "Guidance strength (0~1). Examples: signage 0.25 / broadcast 0.55 / staff control 0.85.", + 0.0, + 1.0, + 0.01, + 2, + std::clamp(guidance_.guidanceStrength, 0.0, 1.0)); + + maxDetourMeters_ = addField( + formLayout, + row++, + "Max detour (m)", + "Max detour tolerance in meters. Larger detours reduce compliance.", + 0.0, + 10'000.0, + 1.0, + 1, + std::max(0.0, guidance_.maxDetourMeters)); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + root->addWidget(buttons); + connect(buttons, &QDialogButtonBox::accepted, this, [this]() { + if (!applyFromUi()) { + return; + } + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, [this]() { + reject(); + }); + } + + safecrowd::domain::RouteGuidanceDraft guidance() const { + return guidance_; + } + +private: + struct PeriodRowWidgets { + QWidget* root{nullptr}; + QDoubleSpinBox* start{nullptr}; + QDoubleSpinBox* end{nullptr}; + QString startRaw{}; + QString endRaw{}; + }; + + void refreshPeriodUiState() { + const bool hasPeriods = !periodRows_.empty(); + if (alwaysPeriodLabel_ != nullptr) { + alwaysPeriodLabel_->setVisible(!hasPeriods); + } + if (removePeriodButton_ != nullptr) { + removePeriodButton_->setEnabled(hasPeriods); + } + } + + void addPeriodRow(std::optional period) { + if (periodRowsContainer_ == nullptr || periodRowsLayout_ == nullptr) { + return; + } + + auto row = std::make_unique(); + row->root = new QWidget(this); + auto* layout = new QHBoxLayout(row->root); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + auto* startLabel = new QLabel("Start", this); + startLabel->setStyleSheet("QLabel { color: #4f5d6b; }"); + layout->addWidget(startLabel); + + row->start = new QDoubleSpinBox(this); + row->start->setRange(0.0, 1'000'000.0); + row->start->setDecimals(1); + row->start->setSingleStep(0.5); + row->start->setMinimumWidth(120); + row->start->setValue(period.has_value() ? std::max(0.0, period->startSeconds) : 0.0); + layout->addWidget(row->start); + + auto* startUnit = new QLabel("sec", this); + startUnit->setStyleSheet("QLabel { color: #4f5d6b; }"); + layout->addWidget(startUnit); + + auto* endLabel = new QLabel("End", this); + endLabel->setStyleSheet("QLabel { color: #4f5d6b; }"); + layout->addWidget(endLabel); + + row->end = new QDoubleSpinBox(this); + row->end->setRange(0.0, 1'000'000.0); + row->end->setDecimals(1); + row->end->setSingleStep(0.5); + row->end->setMinimumWidth(120); + const auto seedEnd = period.has_value() + ? std::max(std::max(0.0, period->startSeconds), std::max(0.0, period->endSeconds)) + : 10.0; + row->end->setValue(seedEnd); + layout->addWidget(row->end); + + auto* endUnit = new QLabel("sec", this); + endUnit->setStyleSheet("QLabel { color: #4f5d6b; }"); + layout->addWidget(endUnit); + + layout->addStretch(1); + + periodRowsLayout_->addWidget(row->root); + row->startRaw = row->start->text(); + if (auto* edit = row->start->findChild(); edit != nullptr) { + connect(edit, &QLineEdit::textEdited, this, [rowPtr = row.get()](const QString& text) { + if (rowPtr != nullptr) { + rowPtr->startRaw = text; + } + }); + } + row->endRaw = row->end->text(); + if (auto* edit = row->end->findChild(); edit != nullptr) { + connect(edit, &QLineEdit::textEdited, this, [rowPtr = row.get()](const QString& text) { + if (rowPtr != nullptr) { + rowPtr->endRaw = text; + } + }); + } + + periodRows_.push_back(std::move(row)); + + refreshPeriodUiState(); + } + + void removeLastPeriodRow() { + if (periodRows_.empty()) { + return; + } + auto row = std::move(periodRows_.back()); + periodRows_.pop_back(); + if (row != nullptr && row->root != nullptr) { + row->root->deleteLater(); + } + refreshPeriodUiState(); + } + + QDoubleSpinBox* addField( + QGridLayout* layout, + int row, + const QString& label, + const QString& help, + double min, + double max, + double step, + int decimals, + double value) { + auto* title = new QLabel(label, this); + title->setStyleSheet("QLabel { color: #16202b; }"); + layout->addWidget(title, row * 2, 0, Qt::AlignLeft); + + auto* spin = new QDoubleSpinBox(this); + spin->setRange(min, max); + spin->setDecimals(decimals); + spin->setSingleStep(step); + spin->setValue(value); + spin->setMinimumWidth(140); + spin->setToolTip(help); + layout->addWidget(spin, row * 2, 1, Qt::AlignLeft); + + auto* helpLabel = new QLabel(help, this); + helpLabel->setWordWrap(true); + helpLabel->setStyleSheet("QLabel { color: #4f5d6b; font-size: 12px; }"); + layout->addWidget(helpLabel, (row * 2) + 1, 0, 1, 2); + return spin; + } + + bool applyFromUi() { + auto validateNonNegative = [&](const QString& rawText, QDoubleSpinBox* spin, QString* rawOut) -> bool { + if (rawText.isEmpty()) { + return true; + } + bool ok = false; + const auto typedValue = rawText.toDouble(&ok); + if (ok && typedValue < 0.0) { + QMessageBox::information(this, "Invalid value", "Only numbers greater than or equal to 0 can be entered."); + spin->setValue(0.0); + if (rawOut != nullptr) { + *rawOut = spin->text(); + } + return false; + } + return true; + }; + + guidance_.periods.clear(); + for (const auto& rowPtr : periodRows_) { + if (rowPtr == nullptr || rowPtr->start == nullptr || rowPtr->end == nullptr) { + continue; + } + if (!validateNonNegative(rowPtr->startRaw, rowPtr->start, &rowPtr->startRaw)) { + return false; + } + if (!validateNonNegative(rowPtr->endRaw, rowPtr->end, &rowPtr->endRaw)) { + return false; + } + const auto start = std::max(0.0, rowPtr->start->value()); + const auto end = std::max(start, std::max(0.0, rowPtr->end->value())); + guidance_.periods.push_back({.startSeconds = start, .endSeconds = end}); + } + + // Keep legacy scalar fields in sync for older views. + if (!guidance_.periods.empty()) { + guidance_.startSeconds = guidance_.periods.front().startSeconds; + guidance_.endSeconds = guidance_.periods.front().endSeconds; + } else { + guidance_.startSeconds = 0.0; + guidance_.endSeconds = 0.0; + } + + guidance_.baseComplianceRate = std::clamp(baseComplianceRate_->value(), 0.0, 1.0); + guidance_.guidanceStrength = std::clamp(guidanceStrength_->value(), 0.0, 1.0); + guidance_.maxDetourMeters = std::max(0.0, maxDetourMeters_->value()); + return true; + } + + safecrowd::domain::RouteGuidanceDraft guidance_{}; + QWidget* periodRowsContainer_{nullptr}; + QVBoxLayout* periodRowsLayout_{nullptr}; + QLabel* alwaysPeriodLabel_{nullptr}; + QPushButton* addPeriodButton_{nullptr}; + QPushButton* removePeriodButton_{nullptr}; + std::vector> periodRows_{}; + QDoubleSpinBox* baseComplianceRate_{nullptr}; + QDoubleSpinBox* guidanceStrength_{nullptr}; + QDoubleSpinBox* maxDetourMeters_{nullptr}; +}; + } // namespace ScenarioCanvasWidget::ScenarioCanvasWidget( @@ -679,6 +1244,16 @@ void ScenarioCanvasWidget::setConnectionBlocksChangedHandler(std::function guidances) { + routeGuidances_ = std::move(guidances); + update(); +} + +void ScenarioCanvasWidget::setRouteGuidancesChangedHandler( + std::function&)> handler) { + routeGuidancesChangedHandler_ = std::move(handler); +} + void ScenarioCanvasWidget::setLayoutElementActivatedHandler(std::function handler) { layoutElementActivatedHandler_ = std::move(handler); } @@ -710,19 +1285,36 @@ void ScenarioCanvasWidget::focusLayoutElement(const QString& elementId) { void ScenarioCanvasWidget::activateLayoutElement(const QString& elementId) { focusLayoutElement(elementId); - if (toolMode_ != ToolMode::BlockDoor) { + if (toolMode_ == ToolMode::BlockDoor) { + const auto targetId = elementId.toStdString(); + const auto it = std::find_if(layout_.connections.begin(), layout_.connections.end(), [&](const auto& connection) { + return connection.id == targetId; + }); + if (it == layout_.connections.end()) { + return; + } + addConnectionBlockForConnection(*it); return; } - const auto targetId = elementId.toStdString(); - const auto it = std::find_if(layout_.connections.begin(), layout_.connections.end(), [&](const auto& connection) { - return connection.id == targetId; - }); - if (it == layout_.connections.end()) { - return; - } + if (toolMode_ == ToolMode::RouteGuidance) { + const auto targetId = elementId.toStdString(); + const auto it = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { + return zone.id == targetId; + }); + if (it != layout_.zones.end() && it->kind == safecrowd::domain::ZoneKind::Exit) { + addRouteGuidanceForExitZone(*it); + return; + } - addConnectionBlockForConnection(*it); + const auto connectionIt = std::find_if(layout_.connections.begin(), layout_.connections.end(), [&](const auto& connection) { + return connection.id == targetId; + }); + if (connectionIt == layout_.connections.end()) { + return; + } + addRouteGuidanceForConnection(*connectionIt); + } } void ScenarioCanvasWidget::focusPlacement(const QString& placementId) { @@ -762,6 +1354,7 @@ void ScenarioCanvasWidget::keyReleaseEvent(QKeyEvent* event) { void ScenarioCanvasWidget::leaveEvent(QEvent* event) { hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); QWidget::leaveEvent(event); } @@ -783,8 +1376,9 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { } if (dragging_) { - if (!hoveredConnectionBlockId_.isEmpty()) { + if (!hoveredConnectionBlockId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); } dragCurrent_ = event->position(); @@ -793,8 +1387,9 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { return; } if (selectionDragging_) { - if (!hoveredConnectionBlockId_.isEmpty()) { + if (!hoveredConnectionBlockId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); } selectionDragCurrent_ = event->position(); @@ -805,22 +1400,43 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (const auto bounds = collectBounds(); bounds.has_value()) { const auto transform = currentTransform(*bounds); - const auto hoveredIndex = hoveredConnectionBlockIndex(layout_, connectionBlocks_, transform, currentFloorId_, event->position()); - if (!hoveredIndex.has_value()) { - if (!hoveredConnectionBlockId_.isEmpty()) { + const auto hoveredGuidance = hoveredRouteGuidanceIndex( + layout_, + routeGuidances_, + connectionBlocks_, + transform, + currentFloorId_, + event->position()); + const auto hoveredBlock = hoveredConnectionBlockIndex(layout_, connectionBlocks_, transform, currentFloorId_, event->position()); + + if (hoveredGuidance.has_value()) { + const auto& guidance = routeGuidances_[*hoveredGuidance]; + const auto tooltip = formatRouteGuidanceTooltip(guidance); + const auto hoveredId = QString::fromStdString(guidance.id.empty() + ? (!guidance.installConnectionId.empty() ? guidance.installConnectionId : guidance.guidedExitZoneId) + : guidance.id); + if (hoveredId != hoveredRouteGuidanceId_) { + hoveredRouteGuidanceId_ = hoveredId; hoveredConnectionBlockId_.clear(); - QToolTip::hideText(); + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); } - } else { - const auto& block = connectionBlocks_[*hoveredIndex]; + } else if (hoveredBlock.has_value()) { + const auto& block = connectionBlocks_[*hoveredBlock]; const auto tooltip = formatConnectionBlockTooltip(block); if (!tooltip.isEmpty()) { const auto hoveredId = QString::fromStdString(block.id.empty() ? block.connectionId : block.id); if (hoveredId != hoveredConnectionBlockId_) { hoveredConnectionBlockId_ = hoveredId; + hoveredRouteGuidanceId_.clear(); QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); } } + } else { + if (!hoveredConnectionBlockId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); + QToolTip::hideText(); + } } } QWidget::mouseMoveEvent(event); @@ -854,6 +1470,28 @@ void ScenarioCanvasWidget::mousePressEvent(QMouseEvent* event) { const auto dx = offsetPoint.x - point.x; const auto dy = offsetPoint.y - point.y; const auto hitTolerance = std::max(1.2, std::hypot(dx, dy)); + + if (const auto bounds = collectBounds(); bounds.has_value()) { + const auto transform = currentTransform(*bounds); + constexpr double kHitTolerancePixels = 18.0; + for (const auto& guidance : routeGuidances_) { + const auto center = routeGuidanceMarkerCenter( + layout_, + guidance, + connectionBlocks_, + transform, + currentFloorId_); + if (!center.has_value()) { + continue; + } + if (QLineF(event->position(), *center).length() <= kHitTolerancePixels) { + openRouteGuidanceEditor(QString::fromStdString(guidance.id), event->globalPosition().toPoint()); + event->accept(); + return; + } + } + } + for (const auto& block : connectionBlocks_) { if (block.connectionId.empty()) { continue; @@ -908,6 +1546,12 @@ void ScenarioCanvasWidget::mousePressEvent(QMouseEvent* event) { return; } + if (toolMode_ == ToolMode::RouteGuidance) { + addRouteGuidance(event->position()); + event->accept(); + return; + } + if (toolMode_ == ToolMode::Select) { selectionDragging_ = true; selectionDragStart_ = event->position(); @@ -1018,6 +1662,7 @@ void ScenarioCanvasWidget::paintEvent(QPaintEvent* event) { } drawFocusedPlacement(painter, transform); drawConnectionBlocks(painter, transform); + drawRouteGuidances(painter, transform); if (dragging_ || selectionDragging_) { const auto start = dragging_ ? dragStart_ : selectionDragStart_; @@ -1189,6 +1834,44 @@ void ScenarioCanvasWidget::drawConnectionBlocks(QPainter& painter, const LayoutC } } +void ScenarioCanvasWidget::drawRouteGuidances(QPainter& painter, const LayoutCanvasTransform& transform) const { + painter.setPen(Qt::NoPen); + painter.setBrush(QColor("#1f5fae")); + + for (const auto& guidance : routeGuidances_) { + const auto center = routeGuidanceMarkerCenter( + layout_, + guidance, + connectionBlocks_, + transform, + currentFloorId_); + if (!center.has_value()) { + continue; + } + + const QPointF markerCenter = *center; + + const double r = 10.0; + painter.setBrush(QColor("#1f5fae")); + painter.drawEllipse(markerCenter, r, r); + + painter.save(); + painter.translate(markerCenter); + painter.rotate(-25.0); + painter.translate(-markerCenter); + painter.setBrush(Qt::white); + painter.drawRoundedRect(QRectF(markerCenter.x() - 1.8, markerCenter.y() - 7.0, 3.6, 10.5), 1.4, 1.4); + painter.drawRoundedRect(QRectF(markerCenter.x() - 1.5, markerCenter.y() + 2.2, 3.0, 5.2), 1.2, 1.2); + painter.restore(); + + painter.setPen(QPen(Qt::white, 1.7, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawLine(QPointF(markerCenter.x() + 5.3, markerCenter.y() - 5.0), QPointF(markerCenter.x() + 8.2, markerCenter.y() - 7.7)); + painter.drawLine(QPointF(markerCenter.x() + 6.3, markerCenter.y() - 2.0), QPointF(markerCenter.x() + 9.2, markerCenter.y() - 2.8)); + painter.drawLine(QPointF(markerCenter.x() + 3.7, markerCenter.y() - 7.2), QPointF(markerCenter.x() + 4.8, markerCenter.y() - 9.8)); + painter.setPen(Qt::NoPen); + } +} + std::optional ScenarioCanvasWidget::collectBounds() const { return collectLayoutCanvasBounds(layout_, currentFloorId_.toStdString()); } @@ -1530,6 +2213,10 @@ QString ScenarioCanvasWidget::nextConnectionBlockId() const { return QString("block-%1").arg(static_cast(connectionBlocks_.size()) + 1); } +QString ScenarioCanvasWidget::nextRouteGuidanceId() const { + return QString("guidance-%1").arg(static_cast(routeGuidances_.size()) + 1); +} + void ScenarioCanvasWidget::addGroupPlacement(const QPointF& start, const QPointF& end) { if ((QLineF(start, end).length()) < 8.0) { return; @@ -1691,6 +2378,114 @@ void ScenarioCanvasWidget::addConnectionBlockForConnection(const safecrowd::doma update(); } +void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) { + const auto point = unmapPoint(position); + const auto zoneId = zoneAt(point); + if (!zoneId.isEmpty()) { + const auto zoneIdStd = zoneId.toStdString(); + const auto it = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { + return zone.id == zoneIdStd; + }); + if (it != layout_.zones.end() && it->kind == safecrowd::domain::ZoneKind::Exit) { + addRouteGuidanceForExitZone(*it); + return; + } + // If the user clicked inside a non-exit zone, still allow installing guidance by selecting a nearby door. + } + + constexpr double kPickRadiusPixels = 18.0; + const auto offsetPoint = unmapPoint(position + QPointF(kPickRadiusPixels, 0.0)); + const auto dx = offsetPoint.x - point.x; + const auto dy = offsetPoint.y - point.y; + const auto pixelToleranceWorldUnits = std::hypot(dx, dy); + const auto toleranceWorldUnits = std::max(1.2, pixelToleranceWorldUnits); + + const safecrowd::domain::Connection2D* connection = nullptr; + double bestDistance = toleranceWorldUnits; + for (const auto& candidate : layout_.connections) { + if (!matchesFloor(candidate.floorId, currentFloorId_)) { + continue; + } + if (candidate.kind != safecrowd::domain::ConnectionKind::Doorway + && candidate.kind != safecrowd::domain::ConnectionKind::Exit) { + continue; + } + const auto halfWidth = std::max(0.0, candidate.effectiveWidth * 0.5); + const auto distance = + std::max(0.0, distancePointToSegment(point, candidate.centerSpan.start, candidate.centerSpan.end) - halfWidth); + if (distance <= bestDistance) { + bestDistance = distance; + connection = &candidate; + } + } + + if (connection == nullptr) { + QMessageBox::information(this, "Route guidance", "Click an exit zone or a door to install guidance."); + return; + } + + addRouteGuidanceForConnection(*connection); +} + +void ScenarioCanvasWidget::addRouteGuidanceForExitZone(const safecrowd::domain::Zone2D& zone) { + if (zone.kind != safecrowd::domain::ZoneKind::Exit) { + QMessageBox::information(this, "Route guidance", "This tool can only be used on exit zones."); + return; + } + + for (const auto& existing : routeGuidances_) { + if (existing.installConnectionId.empty() && existing.guidedExitZoneId == zone.id) { + QMessageBox::information(this, "Route guidance", "Guidance is already installed on this exit."); + return; + } + } + + safecrowd::domain::RouteGuidanceDraft draft; + draft.id = nextRouteGuidanceId().toStdString(); + draft.startSeconds = 0.0; + draft.endSeconds = 0.0; + draft.periods.clear(); + draft.guidedExitZoneId = zone.id; + draft.installConnectionId.clear(); + draft.baseComplianceRate = 0.5; + draft.guidanceStrength = 0.55; + draft.maxDetourMeters = 20.0; + routeGuidances_.push_back(std::move(draft)); + emitRouteGuidancesChanged(); + update(); +} + +void ScenarioCanvasWidget::addRouteGuidanceForConnection(const safecrowd::domain::Connection2D& connection) { + if (connection.kind != safecrowd::domain::ConnectionKind::Doorway + && connection.kind != safecrowd::domain::ConnectionKind::Exit) { + QMessageBox::information(this, "Route guidance", "This tool can only be used on exit zones or doors."); + return; + } + + for (const auto& existing : routeGuidances_) { + if (!existing.installConnectionId.empty() && existing.installConnectionId == connection.id) { + QMessageBox::information(this, "Route guidance", "Guidance is already installed on this door."); + return; + } + } + + const auto exitZoneId = pickNearestExitZoneIdForConnection(layout_, connection); + + safecrowd::domain::RouteGuidanceDraft draft; + draft.id = nextRouteGuidanceId().toStdString(); + draft.startSeconds = 0.0; + draft.endSeconds = 0.0; + draft.periods.clear(); + draft.guidedExitZoneId = exitZoneId; + draft.installConnectionId = connection.id; + draft.baseComplianceRate = 0.5; + draft.guidanceStrength = 0.55; + draft.maxDetourMeters = 20.0; + routeGuidances_.push_back(std::move(draft)); + emitRouteGuidancesChanged(); + update(); +} + void ScenarioCanvasWidget::selectSingleAt(const QPointF& position, const LayoutCanvasTransform& transform) { const auto crowdElementId = placementAt(position, transform); if (!crowdElementId.isEmpty()) { @@ -1817,6 +2612,39 @@ void ScenarioCanvasWidget::openConnectionBlockScheduleEditor(const QString& bloc update(); } +void ScenarioCanvasWidget::openRouteGuidanceEditor(const QString& guidanceId, const QPoint& screenPosition) { + QMenu menu(this); + auto* settingsAction = menu.addAction("Settings..."); + auto* deleteAction = menu.addAction("Delete"); + const auto* selected = menu.exec(screenPosition); + if (selected != settingsAction && selected != deleteAction) { + return; + } + + auto it = std::find_if(routeGuidances_.begin(), routeGuidances_.end(), [&](const auto& guidance) { + return QString::fromStdString(guidance.id) == guidanceId; + }); + if (it == routeGuidances_.end()) { + return; + } + + if (selected == deleteAction) { + routeGuidances_.erase(it); + emitRouteGuidancesChanged(); + update(); + return; + } + + RouteGuidanceSettingsDialog dialog(*it, this); + dialog.move(screenPosition); + if (dialog.exec() != QDialog::Accepted) { + return; + } + *it = dialog.guidance(); + emitRouteGuidancesChanged(); + update(); +} + void ScenarioCanvasWidget::openCrowdPlacementContextMenu(const QString& crowdElementId, const QPoint& screenPosition) { QMenu menu(this); auto* deleteAction = menu.addAction("Delete"); @@ -1910,6 +2738,12 @@ void ScenarioCanvasWidget::emitConnectionBlocksChanged() { } } +void ScenarioCanvasWidget::emitRouteGuidancesChanged() { + if (routeGuidancesChangedHandler_) { + routeGuidancesChangedHandler_(routeGuidances_); + } +} + void ScenarioCanvasWidget::repositionToolbars() { if (topToolbar_ != nullptr) { topToolbar_->setGeometry(0, 0, width(), kTopToolbarHeight); @@ -1935,6 +2769,9 @@ void ScenarioCanvasWidget::setToolMode(ToolMode mode) { if (blockDoorToolButton_ != nullptr) { blockDoorToolButton_->setChecked(mode == ToolMode::BlockDoor); } + if (routeGuidanceToolButton_ != nullptr) { + routeGuidanceToolButton_->setChecked(mode == ToolMode::RouteGuidance); + } if (groupCountLabel_ != nullptr) { groupCountLabel_->setVisible(mode == ToolMode::GroupPlacement); } @@ -1986,6 +2823,7 @@ void ScenarioCanvasWidget::setupToolbars() { individualToolButton_ = makeButton(makeToolIcon("individual", QColor("#1f5fae")), "Add Individual Occupant"); groupToolButton_ = makeButton(makeToolIcon("group", QColor("#1f5fae")), "Add Occupant Group"); blockDoorToolButton_ = makeButton(makeToolIcon("block", QColor("#c0392b")), "block door"); + routeGuidanceToolButton_ = makeButton(makeToolIcon("guidance", QColor("#1f5fae")), "Route guidance"); topLayout->addStretch(1); groupCountLabel_ = new QLabel("Group count", propertyPanel_); @@ -2009,6 +2847,7 @@ void ScenarioCanvasWidget::setupToolbars() { connect(individualToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::IndividualPlacement); }); connect(groupToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::GroupPlacement); }); connect(blockDoorToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::BlockDoor); }); + connect(routeGuidanceToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::RouteGuidance); }); setToolMode(ToolMode::Select); repositionToolbars(); diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index 595d5b7..77dd4f5 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -57,6 +57,8 @@ class ScenarioCanvasWidget : public QWidget { void setPlacementsChangedHandler(std::function&)> handler); void setConnectionBlocks(std::vector blocks); void setConnectionBlocksChangedHandler(std::function&)> handler); + void setRouteGuidances(std::vector guidances); + void setRouteGuidancesChangedHandler(std::function&)> handler); void setLayoutElementActivatedHandler(std::function handler); void setCrowdSelectionChangedHandler(std::function handler); void focusLayoutElement(const QString& elementId); @@ -82,6 +84,7 @@ class ScenarioCanvasWidget : public QWidget { IndividualPlacement, GroupPlacement, BlockDoor, + RouteGuidance, }; std::optional collectBounds() const; @@ -101,10 +104,15 @@ class ScenarioCanvasWidget : public QWidget { safecrowd::domain::Point2D defaultVelocityFrom(const safecrowd::domain::Point2D& point) const; QString nextPlacementId(ScenarioCrowdPlacementKind kind) const; QString nextConnectionBlockId() const; + QString nextRouteGuidanceId() const; void addGroupPlacement(const QPointF& start, const QPointF& end); void addIndividualPlacement(const QPointF& position); void addConnectionBlock(const QPointF& position); void addConnectionBlockForConnection(const safecrowd::domain::Connection2D& connection); + void addRouteGuidance(const QPointF& position); + void addRouteGuidanceForExitZone(const safecrowd::domain::Zone2D& zone); + void addRouteGuidanceForConnection(const safecrowd::domain::Connection2D& connection); + void openRouteGuidanceEditor(const QString& guidanceId, const QPoint& screenPosition); void selectSingleAt(const QPointF& position, const LayoutCanvasTransform& transform); void selectPlacementsInRect(const QRectF& screenRect, const LayoutCanvasTransform& transform); void selectLayoutElementAt(const QPointF& position); @@ -114,8 +122,10 @@ class ScenarioCanvasWidget : public QWidget { void drawFocusedLayoutElement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawFocusedPlacement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawConnectionBlocks(QPainter& painter, const LayoutCanvasTransform& transform) const; + void drawRouteGuidances(QPainter& painter, const LayoutCanvasTransform& transform) const; void emitPlacementsChanged(); void emitConnectionBlocksChanged(); + void emitRouteGuidancesChanged(); void repositionToolbars(); void setToolMode(ToolMode mode); void setupToolbars(); @@ -123,6 +133,7 @@ class ScenarioCanvasWidget : public QWidget { safecrowd::domain::FacilityLayout2D layout_{}; std::vector placements_{}; std::vector connectionBlocks_{}; + std::vector routeGuidances_{}; QString currentFloorId_{}; QString focusedLayoutElementId_{}; QString focusedCrowdElementId_{}; @@ -142,15 +153,18 @@ class ScenarioCanvasWidget : public QWidget { QToolButton* individualToolButton_{nullptr}; QToolButton* groupToolButton_{nullptr}; QToolButton* blockDoorToolButton_{nullptr}; + QToolButton* routeGuidanceToolButton_{nullptr}; QLabel* groupCountLabel_{nullptr}; QSpinBox* groupCountSpinBox_{nullptr}; QLabel* groupDistributionLabel_{nullptr}; QComboBox* groupDistributionComboBox_{nullptr}; QString hoveredConnectionBlockId_{}; + QString hoveredRouteGuidanceId_{}; std::function layoutElementActivatedHandler_{}; std::function crowdSelectionChangedHandler_{}; std::function&)> placementsChangedHandler_{}; std::function&)> connectionBlocksChangedHandler_{}; + std::function&)> routeGuidancesChangedHandler_{}; }; } // namespace safecrowd::application diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index d3d8795..775cd1c 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -199,6 +199,7 @@ ScenarioRunWidget::ScenarioRunWidget( }); canvas_ = new SimulationCanvasWidget(layout_, shell_); canvas_->setConnectionBlocks(scenario_.control.connectionBlocks); + canvas_->setRouteGuidances(scenario_.control.routeGuidances); canvas_->setFrame(runner_.frame()); shell_->setCanvas(canvas_); shell_->setReviewPanel(createRunPanel()); diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index efc0ee3..a3cb1ef 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -74,6 +74,21 @@ safecrowd::domain::Point2D connectionCenter(const safecrowd::domain::Connection2 }; } +safecrowd::domain::Point2D polygonCenter(const safecrowd::domain::Polygon2D& polygon) { + if (polygon.outline.empty()) { + return {}; + } + + double x = 0.0; + double y = 0.0; + for (const auto& point : polygon.outline) { + x += point.x; + y += point.y; + } + const auto count = static_cast(polygon.outline.size()); + return {.x = x / count, .y = y / count}; +} + QColor densityHeatmapColor(double ratio, int alpha) { const auto t = std::clamp(ratio, 0.0, 1.0); if (t < 0.22) { @@ -99,9 +114,9 @@ QString formatScheduleTooltip(const safecrowd::domain::ConnectionBlockDraft& blo return {}; } - QString text = QStringLiteral("차단 스케줄"); + QString text = QStringLiteral("Block schedule"); if (block.intervals.empty()) { - text.append("\n- 항상 차단"); + text.append("\n- Block always"); return text; } @@ -113,6 +128,22 @@ QString formatScheduleTooltip(const safecrowd::domain::ConnectionBlockDraft& blo return text; } +QString formatRouteGuidanceTooltip(const safecrowd::domain::RouteGuidanceDraft& guidance) { + QString text = QStringLiteral("Route guidance"); + if (guidance.periods.empty()) { + text.append(QStringLiteral("\n Always")); + } else { + const auto& period = guidance.periods.front(); + const auto start = std::max(0.0, period.startSeconds); + const auto end = std::max(start, std::max(0.0, period.endSeconds)); + text.append(QString("\n %1s~%2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } + text.append(QString("\n Base compliance: %1").arg(std::clamp(guidance.baseComplianceRate, 0.0, 1.0), 0, 'f', 2)); + text.append(QString("\n Strength: %1").arg(std::clamp(guidance.guidanceStrength, 0.0, 1.0), 0, 'f', 2)); + text.append(QString("\n Max detour:%1m").arg(std::max(0.0, guidance.maxDetourMeters), 0, 'f', 1)); + return text; +} + std::optional hoveredBlockedConnectionIndex( const safecrowd::domain::FacilityLayout2D& layout, const std::vector& blocks, @@ -153,6 +184,167 @@ std::optional hoveredBlockedConnectionIndex( return closestIndex; } +struct ActiveRouteGuidanceSelection { + std::size_t guidanceIndex{0}; + std::size_t periodIndex{0}; + double startSeconds{0.0}; + double endSeconds{0.0}; +}; + +std::optional activeRouteGuidanceSelection( + const std::vector& guidances, + double elapsedSeconds) { + std::optional best; + double bestStart = -1.0; + + for (std::size_t guidanceIndex = 0; guidanceIndex < guidances.size(); ++guidanceIndex) { + const auto& guidance = guidances[guidanceIndex]; + if (guidance.periods.empty()) { + const double start = 0.0; + const double end = 1e18; + if (elapsedSeconds + 1e-9 < start || elapsedSeconds > end + 1e-9) { + continue; + } + if (!best.has_value() || start >= bestStart) { + bestStart = start; + best = ActiveRouteGuidanceSelection{.guidanceIndex = guidanceIndex, .periodIndex = 0, .startSeconds = start, .endSeconds = end}; + } + continue; + } + + for (std::size_t periodIndex = 0; periodIndex < guidance.periods.size(); ++periodIndex) { + const auto& period = guidance.periods[periodIndex]; + const auto start = std::max(0.0, period.startSeconds); + const auto end = std::max(start, std::max(0.0, period.endSeconds)); + if (elapsedSeconds + 1e-9 < start) { + continue; + } + if (elapsedSeconds > end + 1e-9) { + continue; + } + if (!best.has_value() || start >= bestStart) { + bestStart = start; + best = ActiveRouteGuidanceSelection{.guidanceIndex = guidanceIndex, .periodIndex = periodIndex, .startSeconds = start, .endSeconds = end}; + } + } + } + + return best; +} + +std::optional routeGuidanceMarkerCenter( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::RouteGuidanceDraft& guidance, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const std::string& currentFloorId, + double elapsedSeconds) { + QPointF center; + if (!guidance.installConnectionId.empty()) { + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == guidance.installConnectionId; + }); + if (it == layout.connections.end()) { + return std::nullopt; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + return std::nullopt; + } + center = transform.map(connectionCenter(*it)); + } else if (!guidance.guidedExitZoneId.empty()) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + return zone.id == guidance.guidedExitZoneId; + }); + if (it == layout.zones.end() || it->kind != safecrowd::domain::ZoneKind::Exit) { + return std::nullopt; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + return std::nullopt; + } + center = transform.map(polygonCenter(it->area)); + } else { + return std::nullopt; + } + + constexpr double kMinSeparationPixels = 28.0; + constexpr double kStackOffsetPixels = 34.0; + + std::vector blockedCenters; + blockedCenters.reserve(blocks.size()); + for (const auto& block : blocks) { + if (!connectionShouldBeBlocked(block, elapsedSeconds)) { + continue; + } + const auto connectionIt = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + }); + if (connectionIt == layout.connections.end()) { + continue; + } + if (!matchesFloor(connectionIt->floorId, currentFloorId)) { + continue; + } + blockedCenters.push_back(transform.map(connectionCenter(*connectionIt))); + } + if (blockedCenters.empty()) { + return center; + } + + const auto minDistanceToBlocks = [&](const QPointF& candidate) { + double minDistance = 1e12; + for (const auto& blocked : blockedCenters) { + minDistance = std::min(minDistance, QLineF(candidate, blocked).length()); + } + return minDistance; + }; + + const auto baseMinDistance = minDistanceToBlocks(center); + if (baseMinDistance >= kMinSeparationPixels) { + return center; + } + + const QPointF up = center + QPointF(0.0, -kStackOffsetPixels); + const QPointF down = center + QPointF(0.0, kStackOffsetPixels); + const auto upDistance = minDistanceToBlocks(up); + const auto downDistance = minDistanceToBlocks(down); + return upDistance >= downDistance ? up : down; +} + +std::optional hoveredActiveRouteGuidanceIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& guidances, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const std::string& currentFloorId, + double elapsedSeconds, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 14.0; + + const auto active = activeRouteGuidanceSelection(guidances, elapsedSeconds); + if (!active.has_value()) { + return std::nullopt; + } + const auto& guidance = guidances[active->guidanceIndex]; + const auto center = routeGuidanceMarkerCenter( + layout, + guidance, + blocks, + transform, + currentFloorId, + elapsedSeconds); + if (!center.has_value()) { + return std::nullopt; + } + + const auto dx = center->x() - screenPosition.x(); + const auto dy = center->y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= kHoverRadiusPixels * kHoverRadiusPixels) { + return active->guidanceIndex; + } + return std::nullopt; +} + } // namespace SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout2D layout, QWidget* parent) @@ -196,6 +388,11 @@ void SimulationCanvasWidget::setConnectionBlocks(std::vector guidances) { + routeGuidances_ = std::move(guidances); + update(); +} + void SimulationCanvasWidget::setDensityOverlay(std::vector densityCells) { densityOverlay_ = std::move(densityCells); update(); @@ -273,6 +470,7 @@ void SimulationCanvasWidget::keyReleaseEvent(QKeyEvent* event) { void SimulationCanvasWidget::leaveEvent(QEvent* event) { hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); QWidget::leaveEvent(event); } @@ -290,6 +488,11 @@ void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (camera_.updatePan(event)) { + if (!hoveredConnectionBlockId_.empty() || !hoveredRouteGuidanceId_.empty()) { + hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); + QToolTip::hideText(); + } layoutCacheValid_ = false; update(); return; @@ -307,6 +510,14 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { const auto transform = currentTransform(*bounds); const auto elapsedSeconds = std::max(0.0, frame_.elapsedSeconds); + const auto hoveredGuidance = hoveredActiveRouteGuidanceIndex( + layout_, + routeGuidances_, + connectionBlocks_, + transform, + currentFloorId_, + elapsedSeconds, + event->position()); const auto hoveredIndex = hoveredBlockedConnectionIndex( layout_, connectionBlocks_, @@ -315,8 +526,24 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { elapsedSeconds, event->position()); + if (hoveredGuidance.has_value()) { + const auto& guidance = routeGuidances_[*hoveredGuidance]; + const auto tooltip = formatRouteGuidanceTooltip(guidance); + const auto hoveredId = guidance.id.empty() + ? (!guidance.installConnectionId.empty() ? guidance.installConnectionId : guidance.guidedExitZoneId) + : guidance.id; + if (hoveredId != hoveredRouteGuidanceId_) { + hoveredRouteGuidanceId_ = hoveredId; + hoveredConnectionBlockId_.clear(); + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); + } + QWidget::mouseMoveEvent(event); + return; + } + if (!hoveredIndex.has_value()) { - if (!hoveredConnectionBlockId_.empty()) { + if (!hoveredConnectionBlockId_.empty() || !hoveredRouteGuidanceId_.empty()) { + hoveredRouteGuidanceId_.clear(); hoveredConnectionBlockId_.clear(); QToolTip::hideText(); } @@ -334,6 +561,7 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { const auto hoveredId = block.id.empty() ? block.connectionId : block.id; if (hoveredId != hoveredConnectionBlockId_) { hoveredConnectionBlockId_ = hoveredId; + hoveredRouteGuidanceId_.clear(); QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); } QWidget::mouseMoveEvent(event); @@ -376,6 +604,7 @@ void SimulationCanvasWidget::paintEvent(QPaintEvent* event) { const auto transform = currentTransform(*bounds); drawConnectionBlockOverlay(painter, transform); + drawRouteGuidanceOverlay(painter, transform); if (overlayMode_ == ResultOverlayMode::Density) { drawDensityOverlay(painter, transform); } else if (overlayMode_ == ResultOverlayMode::Hotspots) { @@ -524,6 +753,49 @@ void SimulationCanvasWidget::drawConnectionBlockOverlay(QPainter& painter, const painter.restore(); } +void SimulationCanvasWidget::drawRouteGuidanceOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { + const auto elapsedSeconds = std::max(0.0, frame_.elapsedSeconds); + const auto active = activeRouteGuidanceSelection(routeGuidances_, elapsedSeconds); + if (!active.has_value()) { + return; + } + + const auto& guidance = routeGuidances_[active->guidanceIndex]; + const auto center = routeGuidanceMarkerCenter( + layout_, + guidance, + connectionBlocks_, + transform, + currentFloorId_, + elapsedSeconds); + if (!center.has_value()) { + return; + } + + painter.save(); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor("#1f5fae")); + + const double r = 10.0; + painter.drawEllipse(*center, r, r); + + painter.save(); + painter.translate(*center); + painter.rotate(-25.0); + painter.translate(-(*center)); + painter.setBrush(Qt::white); + painter.drawRoundedRect(QRectF(center->x() - 1.8, center->y() - 7.0, 3.6, 10.5), 1.4, 1.4); + painter.drawRoundedRect(QRectF(center->x() - 1.5, center->y() + 2.2, 3.0, 5.2), 1.2, 1.2); + painter.restore(); + + painter.setPen(QPen(Qt::white, 1.7, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawLine(QPointF(center->x() + 5.3, center->y() - 5.0), QPointF(center->x() + 8.2, center->y() - 7.7)); + painter.drawLine(QPointF(center->x() + 6.3, center->y() - 2.0), QPointF(center->x() + 9.2, center->y() - 2.8)); + painter.drawLine(QPointF(center->x() + 3.7, center->y() - 7.2), QPointF(center->x() + 4.8, center->y() - 9.8)); + + painter.restore(); +} + void SimulationCanvasWidget::drawDensityOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { if (densityOverlay_.empty()) { return; diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 5f5fa64..1584efc 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -10,6 +10,7 @@ #include "application/LayoutCanvasRendering.h" #include "domain/FacilityLayout2D.h" +#include "domain/ScenarioAuthoring.h" #include "domain/ScenarioResultArtifacts.h" #include "domain/ScenarioRiskMetrics.h" #include "domain/ScenarioSimulationRunner.h" @@ -40,6 +41,7 @@ class SimulationCanvasWidget : public QWidget { void setFrame(safecrowd::domain::SimulationFrame frame); void setConnectionBlocks(std::vector blocks); + void setRouteGuidances(std::vector guidances); void setDensityOverlay(std::vector densityCells); void setHotspotOverlay(std::vector hotspots); void setBottleneckOverlay(std::vector bottlenecks); @@ -67,6 +69,7 @@ class SimulationCanvasWidget : public QWidget { QRectF previewViewport() const; void focusWorldPoint(const safecrowd::domain::Point2D& point, double zoom); void drawConnectionBlockOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; + void drawRouteGuidanceOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawDensityOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawHotspotOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawBottleneckOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; @@ -78,6 +81,7 @@ class SimulationCanvasWidget : public QWidget { safecrowd::domain::FacilityLayout2D layout_{}; safecrowd::domain::SimulationFrame frame_{}; std::vector connectionBlocks_{}; + std::vector routeGuidances_{}; std::vector densityOverlay_{}; std::vector hotspotOverlay_{}; std::vector bottleneckOverlay_{}; @@ -98,6 +102,7 @@ class SimulationCanvasWidget : public QWidget { bool layoutCacheValid_{false}; std::string hoveredConnectionBlockId_{}; + std::string hoveredRouteGuidanceId_{}; }; } // namespace safecrowd::application diff --git a/src/domain/AgentComponents.h b/src/domain/AgentComponents.h index 4700e69..408c8ee 100644 --- a/src/domain/AgentComponents.h +++ b/src/domain/AgentComponents.h @@ -18,6 +18,7 @@ struct Agent { float maxSpeed{1.5f}; std::string sourcePlacementId{}; std::string sourceZoneId{}; + double guidancePropensity{0.5}; }; struct Velocity { @@ -46,7 +47,10 @@ struct EvacuationRoute { double nextSegmentReplanSeconds{0.0}; std::uint64_t observedLayoutRevision{0}; bool noExitAvailable{false}; + bool followsGuidance{false}; std::string destinationZoneId{}; + std::string originalDestinationZoneId{}; + std::string guidanceEventId{}; std::string currentFloorId{}; std::string displayFloorId{}; }; diff --git a/src/domain/ScenarioAuthoring.h b/src/domain/ScenarioAuthoring.h index d10bace..c697277 100644 --- a/src/domain/ScenarioAuthoring.h +++ b/src/domain/ScenarioAuthoring.h @@ -28,6 +28,23 @@ struct OperationalEventDraft { std::string targetSummary{}; }; +struct RouteGuidancePeriodDraft { + double startSeconds{0.0}; + double endSeconds{0.0}; +}; + +struct RouteGuidanceDraft { + std::string id{}; + double startSeconds{0.0}; + double endSeconds{10.0}; + std::vector periods{}; + std::string guidedExitZoneId{}; + std::string installConnectionId{}; + double baseComplianceRate{0.5}; + double guidanceStrength{0.55}; + double maxDetourMeters{20.0}; +}; + struct ConnectionBlockIntervalDraft { double startSeconds{0.0}; double endSeconds{0.0}; @@ -41,6 +58,7 @@ struct ConnectionBlockDraft { struct ControlPlan { std::vector events{}; + std::vector routeGuidances{}; std::vector connectionBlocks{}; }; diff --git a/src/domain/ScenarioSimulationInternal.cpp b/src/domain/ScenarioSimulationInternal.cpp index ed81447..bbd33c1 100644 --- a/src/domain/ScenarioSimulationInternal.cpp +++ b/src/domain/ScenarioSimulationInternal.cpp @@ -849,6 +849,154 @@ std::optional zoneRouteToNearestExit( return route; } +std::optional zoneRouteToExit( + const ScenarioLayoutCacheResource& cache, + const Point2D& startPosition, + const std::string& startZoneId, + const std::string& exitZoneId) { + if (startZoneId.empty() || exitZoneId.empty()) { + return std::nullopt; + } + + if (startZoneId == exitZoneId) { + return ZoneRouteResult{.route = ZoneRouteToExit{.zoneIds = {startZoneId}}, .distance = 0.0}; + } + + if (const auto* exitZone = findCachedZone(cache, exitZoneId); exitZone == nullptr || exitZone->kind != ZoneKind::Exit) { + return std::nullopt; + } + + auto stateKey = [](const std::string& zoneId, std::size_t entryConnectionIndex) { + return zoneId + '\x1f' + std::to_string(entryConnectionIndex); + }; + + constexpr double kDefaultVerticalRouteCost = 3.0; + constexpr std::size_t kStartConnectionIndex = static_cast(-1); + + auto verticalRouteCost = [&](const Connection2D& connection) { + if (!isVerticalConnection(connection)) { + return 0.0; + } + + const auto fromFloorId = cachedFloorIdForZone(cache, connection.fromZoneId); + const auto toFloorId = cachedFloorIdForZone(cache, connection.toZoneId); + if (fromFloorId.empty() || toFloorId.empty() || fromFloorId == toFloorId) { + return kDefaultVerticalRouteCost; + } + + const auto elevationDelta = std::fabs(floorElevation(cache.layout, fromFloorId) - floorElevation(cache.layout, toFloorId)); + return elevationDelta > kGeometryEpsilon ? elevationDelta : kDefaultVerticalRouteCost; + }; + + struct QueueItem { + double distance{0.0}; + std::string key{}; + std::string zoneId{}; + Point2D point{}; + std::size_t entryConnectionIndex{static_cast(-1)}; + + bool operator>(const QueueItem& other) const noexcept { + return distance > other.distance; + } + }; + + std::unordered_map distances; + distances.reserve(cache.layout.connections.size() + 1); + std::unordered_map previous; + previous.reserve(cache.layout.connections.size() + 1); + std::unordered_map stateZones; + stateZones.reserve(cache.layout.connections.size() + 1); + std::unordered_map stateConnectionIndices; + stateConnectionIndices.reserve(cache.layout.connections.size() + 1); + std::priority_queue, std::greater> queue; + + const auto startKey = stateKey(startZoneId, kStartConnectionIndex); + distances[startKey] = 0.0; + stateZones[startKey] = startZoneId; + stateConnectionIndices[startKey] = kStartConnectionIndex; + queue.push({ + .distance = 0.0, + .key = startKey, + .zoneId = startZoneId, + .point = startPosition, + .entryConnectionIndex = kStartConnectionIndex, + }); + + double bestExitDistance = std::numeric_limits::max(); + std::string bestExitKey; + + while (!queue.empty()) { + const auto current = queue.top(); + queue.pop(); + + if (current.distance > bestExitDistance + 1e-12) { + continue; + } + + const auto bestIt = distances.find(current.key); + if (bestIt == distances.end() || current.distance > bestIt->second + 1e-12) { + continue; + } + + if (current.zoneId == exitZoneId) { + if (current.distance + 1e-12 < bestExitDistance) { + bestExitDistance = current.distance; + bestExitKey = current.key; + } + continue; + } + + for (const auto& traversal : cachedTraversalsForZone(cache, current.zoneId)) { + if (traversal.nextZoneId.empty() || traversal.connectionIndex >= cache.layout.connections.size()) { + continue; + } + + const auto& connection = cache.layout.connections[traversal.connectionIndex]; + const auto portal = midpoint(connection.centerSpan); + const auto nextDistance = current.distance + distanceBetween(current.point, portal) + verticalRouteCost(connection); + const auto nextKey = stateKey(traversal.nextZoneId, traversal.connectionIndex); + const auto distanceIt = distances.find(nextKey); + if (distanceIt == distances.end() || nextDistance + 1e-12 < distanceIt->second) { + distances[nextKey] = nextDistance; + previous[nextKey] = current.key; + stateZones[nextKey] = traversal.nextZoneId; + stateConnectionIndices[nextKey] = traversal.connectionIndex; + queue.push({ + .distance = nextDistance, + .key = nextKey, + .zoneId = traversal.nextZoneId, + .point = portal, + .entryConnectionIndex = traversal.connectionIndex, + }); + } + } + } + + if (bestExitKey.empty()) { + return std::nullopt; + } + + ZoneRouteToExit route; + for (auto key = bestExitKey; !key.empty();) { + const auto zoneIt = stateZones.find(key); + if (zoneIt != stateZones.end()) { + route.zoneIds.push_back(zoneIt->second); + } + const auto connectionIt = stateConnectionIndices.find(key); + if (connectionIt != stateConnectionIndices.end() && connectionIt->second != kStartConnectionIndex) { + route.connectionIndices.push_back(connectionIt->second); + } + const auto previousIt = previous.find(key); + key = previousIt == previous.end() ? std::string{} : previousIt->second; + } + std::reverse(route.zoneIds.begin(), route.zoneIds.end()); + std::reverse(route.connectionIndices.begin(), route.connectionIndices.end()); + if (route.zoneIds.empty() || route.connectionIndices.size() + 1 != route.zoneIds.size()) { + return std::nullopt; + } + return ZoneRouteResult{.route = std::move(route), .distance = bestExitDistance}; +} + double speedOf(const Point2D& velocity) { const auto speed = std::hypot(velocity.x, velocity.y); return speed > 0.0 ? speed : kDefaultAgentSpeed; diff --git a/src/domain/ScenarioSimulationInternal.h b/src/domain/ScenarioSimulationInternal.h index e42a6d8..a4637df 100644 --- a/src/domain/ScenarioSimulationInternal.h +++ b/src/domain/ScenarioSimulationInternal.h @@ -99,6 +99,11 @@ struct ZoneRouteToExit { } }; +struct ZoneRouteResult { + ZoneRouteToExit route{}; + double distance{0.0}; +}; + long long spatialKey(const SpatialCell& cell); SpatialCell spatialCellFor(const Point2D& point, double cellSize); Bounds boundsOf(const Polygon2D& polygon); @@ -127,6 +132,11 @@ std::optional zoneRouteToNearestExit( const ScenarioLayoutCacheResource& cache, const Point2D& startPosition, const std::string& startZoneId); +std::optional zoneRouteToExit( + const ScenarioLayoutCacheResource& cache, + const Point2D& startPosition, + const std::string& startZoneId, + const std::string& exitZoneId); std::string floorIdForZone(const FacilityLayout2D& layout, const std::string& zoneId); bool isVerticalConnection(const Connection2D& connection); bool canTraverseConnection(const FacilityLayout2D& layout, const Connection2D& connection); diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index a3ca5be..48ab24f 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -25,6 +25,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { : layoutCache_(buildScenarioLayoutCache(std::move(layout))) { } + ScenarioSimulationMotionSystem(FacilityLayout2D layout, std::vector routeGuidances) + : layoutCache_(buildScenarioLayoutCache(std::move(layout))), + routeGuidances_(std::move(routeGuidances)) { + } + void configure(engine::EngineWorld& world) override { if (layoutCache_.has_value() && !world.resources().contains()) { world.resources().set(*layoutCache_); @@ -32,8 +37,6 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } void update(engine::EngineWorld& world, const engine::EngineStepContext& step) override { - (void)step; - auto& resources = world.resources(); if (!resources.contains()) { return; @@ -56,14 +59,15 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return; } - auto& query = world.query(); - const auto entities = simulationEntities(query); - const auto localNeighborIndex = resources.contains() - ? AgentSpatialIndex{} - : buildAgentSpatialIndex(query, entities, 1.0); - std::vector plans; - plans.reserve(entities.size()); - + auto& query = world.query(); + const auto entities = simulationEntities(query); + const auto localNeighborIndex = resources.contains() + ? AgentSpatialIndex{} + : buildAgentSpatialIndex(query, entities, 1.0); + std::vector plans; + plans.reserve(entities.size()); + + applyRouteGuidance(query, entities, layoutCache, clock.elapsedSeconds, step.derivedSeed); advanceRoutesForCurrentZones(query, entities, layoutCache); advanceRoutesForWaypointProgress(query, 0.0, entities, layoutCache); replanBlockedExitRoutes(query, entities, layoutCache, clock.elapsedSeconds, layoutRevision); @@ -523,6 +527,247 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return targetFloorId; } + static std::uint64_t fnv1a64(const std::string& value) { + std::uint64_t hash = 1469598103934665603ULL; + for (const unsigned char ch : value) { + hash ^= static_cast(ch); + hash *= 1099511628211ULL; + } + return hash; + } + + static std::uint64_t mix64(std::uint64_t value) { + value += 0x9e3779b97f4a7c15ULL; + value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL; + value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL; + return value ^ (value >> 31U); + } + + static double uniform01(std::uint64_t value) { + const auto mixed = mix64(value); + const auto mantissa = mixed >> 11U; + return static_cast(mantissa) * (1.0 / 9007199254740992.0); + } + + static double clamp01(double value) { + return std::clamp(value, 0.0, 1.0); + } + + static double logit(double p) { + const auto clamped = std::clamp(p, 1e-6, 1.0 - 1e-6); + return std::log(clamped / (1.0 - clamped)); + } + + static double sigmoid(double x) { + if (x >= 0.0) { + const auto z = std::exp(-x); + return 1.0 / (1.0 + z); + } + const auto z = std::exp(x); + return z / (1.0 + z); + } + + struct ActiveRouteGuidance { + const RouteGuidanceDraft* guidance{nullptr}; + std::size_t periodIndex{0}; + double startSeconds{0.0}; + double endSeconds{0.0}; + }; + + std::optional activeRouteGuidance(double elapsedSeconds) const { + std::optional best; + double bestStart = -1.0; + + for (const auto& guidance : routeGuidances_) { + if (guidance.periods.empty()) { + // No periods configured => always active (like connection blocks with no intervals). + const double start = 0.0; + const double end = 1e18; + if (elapsedSeconds + 1e-9 < start || elapsedSeconds > end + 1e-9) { + continue; + } + if (!best.has_value() || start >= bestStart) { + bestStart = start; + best = ActiveRouteGuidance{.guidance = &guidance, .periodIndex = 0, .startSeconds = start, .endSeconds = end}; + } + continue; + } + + for (std::size_t index = 0; index < guidance.periods.size(); ++index) { + const auto& period = guidance.periods[index]; + const auto start = std::max(0.0, period.startSeconds); + const auto end = std::max(start, std::max(0.0, period.endSeconds)); + if (elapsedSeconds + 1e-9 < start) { + continue; + } + if (elapsedSeconds > end + 1e-9) { + continue; + } + if (!best.has_value() || start >= bestStart) { + bestStart = start; + best = ActiveRouteGuidance{.guidance = &guidance, .periodIndex = index, .startSeconds = start, .endSeconds = end}; + } + } + } + + return best; + } + + std::optional zoneDistanceToExit( + const ScenarioLayoutCacheResource& layoutCache, + const Point2D& start, + const std::string& startZoneId, + const std::string& exitZoneId) const { + const auto result = zoneRouteToExit(layoutCache, start, startZoneId, exitZoneId); + if (!result.has_value()) { + return std::nullopt; + } + return result->distance; + } + + double complianceProbability( + const RouteGuidanceDraft& guidance, + const Agent& agent, + double detourMeters) const { + constexpr double kStrengthBaseline = 0.55; + constexpr double kStrengthWeight = 4.0; + constexpr double kDetourWeight = 2.0; + constexpr double kPropensityWeight = 1.0; + + const auto base = logit(clamp01(guidance.baseComplianceRate)); + const auto strength = clamp01(guidance.guidanceStrength); + const auto detourRatio = guidance.maxDetourMeters > 1e-6 + ? std::max(0.0, detourMeters) / std::max(1e-6, guidance.maxDetourMeters) + : 0.0; + const auto propensity = clamp01(agent.guidancePropensity); + const auto score = + base + + (kStrengthWeight * (strength - kStrengthBaseline)) + - (kDetourWeight * detourRatio) + + (kPropensityWeight * logit(propensity)); + return clamp01(sigmoid(score)); + } + + void applyRouteGuidance( + engine::WorldQuery& query, + const std::vector& entities, + const ScenarioLayoutCacheResource& layoutCache, + double elapsedSeconds, + std::uint64_t derivedSeed) { + const auto active = activeRouteGuidance(elapsedSeconds); + std::string activeId; + if (active.has_value() && active->guidance != nullptr) { + activeId = active->guidance->id; + if (!active->guidance->periods.empty()) { + activeId.append(":p"); + activeId.append(std::to_string(active->periodIndex)); + } + } + if (activeId == activeRouteGuidanceId_) { + return; + } + activeRouteGuidanceId_ = activeId; + + if (!active.has_value() || active->guidance == nullptr) { + for (const auto entity : entities) { + const auto& status = query.get(entity); + if (status.evacuated) { + continue; + } + + const auto& position = query.get(entity); + auto& route = query.get(entity); + route.guidanceEventId.clear(); + route.followsGuidance = false; + + if (!route.originalDestinationZoneId.empty()) { + const auto startZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); + if (!startZoneId.empty()) { + RoutePlan plan = routePlanToExit(layoutCache, position.value, startZoneId, route.originalDestinationZoneId); + if (plan.destinationZoneId.empty()) { + plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); + } + if (!plan.destinationZoneId.empty()) { + replaceRouteWithPlan(route, plan, position.value); + } + } + } + } + return; + } + + const auto* activeGuidance = active->guidance; + const auto activeIdHash = fnv1a64(activeId); + for (const auto entity : entities) { + const auto& status = query.get(entity); + if (status.evacuated) { + continue; + } + + const auto& position = query.get(entity); + const auto& agent = query.get(entity); + auto& route = query.get(entity); + if (route.originalDestinationZoneId.empty()) { + route.originalDestinationZoneId = route.destinationZoneId; + } + + const auto startZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); + if (startZoneId.empty()) { + continue; + } + + bool guidedExitValid = false; + if (!activeGuidance->guidedExitZoneId.empty()) { + if (const auto* exitZone = findCachedZone(layoutCache, activeGuidance->guidedExitZoneId); + exitZone != nullptr && exitZone->kind == ZoneKind::Exit) { + guidedExitValid = true; + } + } + + double detourMeters = 0.0; + if (guidedExitValid && !route.originalDestinationZoneId.empty()) { + const auto originalDistance = + zoneDistanceToExit(layoutCache, position.value, startZoneId, route.originalDestinationZoneId); + const auto guidedDistance = + zoneDistanceToExit(layoutCache, position.value, startZoneId, activeGuidance->guidedExitZoneId); + if (originalDistance.has_value() && guidedDistance.has_value()) { + detourMeters = std::max(0.0, *guidedDistance - *originalDistance); + } + } + + const auto pFollow = complianceProbability(*activeGuidance, agent, detourMeters); + const auto u = uniform01( + derivedSeed + ^ activeIdHash + ^ (static_cast(entity.index) << 1U) + ^ static_cast(entity.generation)); + const bool follows = u < pFollow; + + route.guidanceEventId = activeId; + route.followsGuidance = follows; + + std::string desiredExit; + if (follows && guidedExitValid) { + desiredExit = activeGuidance->guidedExitZoneId; + } else if (!follows) { + desiredExit = route.originalDestinationZoneId; + } + + RoutePlan plan; + if (!desiredExit.empty()) { + plan = routePlanToExit(layoutCache, position.value, startZoneId, desiredExit); + } + if (plan.destinationZoneId.empty()) { + plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); + } + if (plan.destinationZoneId.empty()) { + continue; + } + replaceRouteWithPlan(route, plan, position.value); + route.nextExitReplanSeconds = elapsedSeconds + 0.25; + } + } + RoutePlan routePlanToNearestExit( const ScenarioLayoutCacheResource& layoutCache, const Point2D& start, @@ -601,6 +846,86 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return plan; } + RoutePlan routePlanToExit( + const ScenarioLayoutCacheResource& layoutCache, + const Point2D& start, + const std::string& startZoneId, + const std::string& exitZoneId) const { + RoutePlan plan; + const auto zoneRouteResult = zoneRouteToExit(layoutCache, start, startZoneId, exitZoneId); + if (!zoneRouteResult.has_value() || zoneRouteResult->route.empty()) { + return plan; + } + + const auto& zoneRoute = zoneRouteResult->route; + plan.destinationZoneId = zoneRoute.zoneIds.back(); + + Point2D segmentStart = start; + auto appendSegment = [&](const std::vector& segment, + const LineSegment2D& finalPassage, + const std::string& finalFromZoneId, + const std::string& finalZoneId, + const std::string& finalFloorId, + const std::string& finalConnectionId, + bool finalVerticalTransition) { + for (std::size_t waypointIndex = 0; waypointIndex < segment.size(); ++waypointIndex) { + const bool isFinalWaypoint = waypointIndex + 1 == segment.size(); + plan.waypoints.push_back(segment[waypointIndex]); + plan.waypointPassages.push_back(isFinalWaypoint ? finalPassage : pointPassage(segment[waypointIndex])); + plan.waypointFromZoneIds.push_back(isFinalWaypoint ? finalFromZoneId : std::string{}); + plan.waypointZoneIds.push_back(isFinalWaypoint ? finalZoneId : std::string{}); + plan.waypointFloorIds.push_back(isFinalWaypoint ? finalFloorId : std::string{}); + plan.waypointConnectionIds.push_back(isFinalWaypoint ? finalConnectionId : std::string{}); + plan.waypointVerticalTransitions.push_back(isFinalWaypoint && finalVerticalTransition); + } + }; + + for (std::size_t index = 1; index < zoneRoute.zoneIds.size(); ++index) { + const auto& fromZoneId = zoneRoute.zoneIds[index - 1]; + const auto& toZoneId = zoneRoute.zoneIds[index]; + const auto connectionIndex = zoneRoute.connectionIndices[index - 1]; + if (connectionIndex < layoutCache.layout.connections.size()) { + const auto* connection = &layoutCache.layout.connections[connectionIndex]; + const auto passage = passageWithClearance(*connection, kCandidateClearance); + const auto fromFloorId = cachedFloorIdForZone(layoutCache, fromZoneId); + const auto toFloorId = cachedFloorIdForZone(layoutCache, toZoneId); + const auto& segmentLayout = cachedLayoutForFloor(layoutCache, fromFloorId); + const auto target = closestPointOnSegment(segmentStart, passage.start, passage.end); + const auto segment = buildPath(segmentLayout, segmentStart, target, kCandidateClearance); + appendSegment( + segment, + passage, + fromZoneId, + toZoneId, + toFloorId.empty() ? fromFloorId : toFloorId, + connection->id, + isVerticalConnection(*connection)); + segmentStart = target; + } + } + + if (const auto* exitZone = findCachedZone(layoutCache, zoneRoute.zoneIds.back())) { + const auto exitCenter = polygonCenter(exitZone->area); + if (distanceBetween(segmentStart, exitCenter) > kArrivalEpsilon) { + const auto exitFloorId = exitZone->floorId; + const auto& segmentLayout = cachedLayoutForFloor(layoutCache, exitFloorId); + const auto segment = buildPath(segmentLayout, segmentStart, exitCenter, kCandidateClearance); + appendSegment(segment, pointPassage(exitCenter), std::string{}, exitZone->id, exitFloorId, std::string{}, false); + } + } + + if (!plan.waypoints.empty() && distanceBetween(start, plan.waypoints.front()) <= kArrivalEpsilon) { + plan.waypoints.erase(plan.waypoints.begin()); + plan.waypointPassages.erase(plan.waypointPassages.begin()); + plan.waypointFromZoneIds.erase(plan.waypointFromZoneIds.begin()); + plan.waypointZoneIds.erase(plan.waypointZoneIds.begin()); + plan.waypointFloorIds.erase(plan.waypointFloorIds.begin()); + plan.waypointConnectionIds.erase(plan.waypointConnectionIds.begin()); + plan.waypointVerticalTransitions.erase(plan.waypointVerticalTransitions.begin()); + } + return plan; + } + void replaceRouteWithPlan(EvacuationRoute& route, const RoutePlan& plan, const Point2D& start) const { route.destinationZoneId = plan.destinationZoneId; route.waypoints = plan.waypoints; @@ -630,7 +955,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return false; } - const auto plan = routePlanToNearestExit(layoutCache, landingPoint, startZoneId); + RoutePlan plan; + if (route.followsGuidance && !route.destinationZoneId.empty()) { + plan = routePlanToExit(layoutCache, landingPoint, startZoneId, route.destinationZoneId); + } + if (plan.destinationZoneId.empty()) { + plan = routePlanToNearestExit(layoutCache, landingPoint, startZoneId); + } if (plan.destinationZoneId.empty()) { return false; } @@ -908,7 +1239,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } - const auto plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); + RoutePlan plan; + if (route.followsGuidance && !route.destinationZoneId.empty()) { + plan = routePlanToExit(layoutCache, position.value, startZoneId, route.destinationZoneId); + } + if (plan.destinationZoneId.empty()) { + plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); + } if (plan.destinationZoneId.empty()) { route.noExitAvailable = true; route.destinationZoneId.clear(); @@ -1213,6 +1550,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } std::optional layoutCache_{}; + std::vector routeGuidances_{}; + std::string activeRouteGuidanceId_{}; }; @@ -1227,4 +1566,10 @@ std::unique_ptr makeScenarioSimulationMotionSystem(Facilit return std::make_unique(std::move(layout)); } +std::unique_ptr makeScenarioSimulationMotionSystem( + FacilityLayout2D layout, + std::vector routeGuidances) { + return std::make_unique(std::move(layout), std::move(routeGuidances)); +} + } // namespace safecrowd::domain diff --git a/src/domain/ScenarioSimulationRunner.cpp b/src/domain/ScenarioSimulationRunner.cpp index e1f05fa..fdf1a17 100644 --- a/src/domain/ScenarioSimulationRunner.cpp +++ b/src/domain/ScenarioSimulationRunner.cpp @@ -81,6 +81,40 @@ bool ScenarioSimulationRunner::complete() const noexcept { std::vector ScenarioSimulationRunner::createAgentSeeds() const { std::vector seeds; + const std::uint64_t baseSeed = scenario_.execution.baseSeed != 0 ? scenario_.execution.baseSeed : 1; + auto fnv1a64 = [](const std::string& value) { + std::uint64_t hash = 1469598103934665603ULL; + for (const unsigned char ch : value) { + hash ^= static_cast(ch); + hash *= 1099511628211ULL; + } + return hash; + }; + auto mix64 = [](std::uint64_t v) { + v += 0x9e3779b97f4a7c15ULL; + v = (v ^ (v >> 30U)) * 0xbf58476d1ce4e5b9ULL; + v = (v ^ (v >> 27U)) * 0x94d049bb133111ebULL; + return v ^ (v >> 31U); + }; + auto uniform01 = [&](std::uint64_t v) { + const auto mixed = mix64(v); + const auto mantissa = mixed >> 11U; + return static_cast(mantissa) * (1.0 / 9007199254740992.0); + }; + auto beta22 = [&](std::uint64_t salt) { + const auto u1 = std::max(1e-12, uniform01(baseSeed ^ salt ^ 0xA341316CULL)); + const auto u2 = std::max(1e-12, uniform01(baseSeed ^ salt ^ 0xC8013EA4ULL)); + const auto u3 = std::max(1e-12, uniform01(baseSeed ^ salt ^ 0xAD90777DULL)); + const auto u4 = std::max(1e-12, uniform01(baseSeed ^ salt ^ 0x7E95761EULL)); + const auto x = -std::log(u1 * u2); + const auto y = -std::log(u3 * u4); + if (!(x > 0.0) && !(y > 0.0)) { + return 0.5; + } + return x / (x + y); + }; + + std::uint64_t agentSerial = 0; for (const auto& placement : scenario_.population.initialPlacements) { const auto count = placement.explicitPositions.empty() ? placement.targetAgentCount @@ -122,10 +156,17 @@ std::vector ScenarioSimulationRunner::createAgentSeeds() cons .destinationZoneId = route.destinationZoneId, .currentFloorId = placementFloorId, }; + evacuationRoute.originalDestinationZoneId = evacuationRoute.destinationZoneId; evacuationRoute.displayFloorId = evacuationRoute.currentFloorId; evacuationRoute.previousDistanceToWaypoint = route.waypoints.empty() ? 0.0 : distanceToRouteWaypoint(evacuationRoute, position); + const auto propensitySalt = + mix64(static_cast(++agentSerial)) + ^ mix64(fnv1a64(placement.id)) + ^ mix64(fnv1a64(startZoneId)) + ^ mix64(static_cast(evacuationRoute.destinationZoneId.size())); + const auto guidancePropensity = beta22(propensitySalt); seeds.push_back({ .position = {.value = position}, .agent = { @@ -133,6 +174,7 @@ std::vector ScenarioSimulationRunner::createAgentSeeds() cons .maxSpeed = static_cast(speed), .sourcePlacementId = placement.id, .sourceZoneId = startZoneId, + .guidancePropensity = guidancePropensity, }, .velocity = {.value = {}}, .route = std::move(evacuationRoute), @@ -147,7 +189,7 @@ void ScenarioSimulationRunner::initializeRuntime() { runtime_ = std::make_unique(engine::EngineConfig{ .fixedDeltaTime = 1.0 / 30.0, .maxCatchUpSteps = 1, - .baseSeed = 1, + .baseSeed = scenario_.execution.baseSeed != 0 ? scenario_.execution.baseSeed : 1, }); runtime_->addSystem(std::make_unique(createAgentSeeds(), timeLimitSeconds_)); runtime_->addSystem( @@ -159,7 +201,7 @@ void ScenarioSimulationRunner::initializeRuntime() { {.phase = engine::UpdatePhase::PreSimulation, .triggerPolicy = engine::TriggerPolicy::EveryFrame}); runtime_->addSystem( - makeScenarioSimulationMotionSystem(layout_), + makeScenarioSimulationMotionSystem(layout_, scenario_.control.routeGuidances), {.phase = engine::UpdatePhase::PostSimulation, .triggerPolicy = engine::TriggerPolicy::EveryFrame}); runtime_->addSystem( diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index 19e8ca8..8f26e30 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -92,6 +92,9 @@ std::unique_ptr makeScenarioControlSystem( FacilityLayout2D baseLayout, std::vector blocks); std::unique_ptr makeScenarioSimulationMotionSystem(FacilityLayout2D layout); +std::unique_ptr makeScenarioSimulationMotionSystem( + FacilityLayout2D layout, + std::vector routeGuidances); std::unique_ptr makeScenarioRiskMetricsSystem(FacilityLayout2D layout); class ScenarioAgentSpawnSystem final : public engine::EngineSystem { From 0670ce7162fd51266a08f08f209e8a63601f14ce Mon Sep 17 00:00:00 2001 From: 95x8x9 Date: Wed, 6 May 2026 03:55:20 +0900 Subject: [PATCH 3/4] [Application] Show all guidance periods in tooltip --- src/application/ScenarioCanvasWidget.cpp | 9 +++++---- src/application/SimulationCanvasWidget.cpp | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index 4334630..97ec959 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -188,10 +188,11 @@ QString formatRouteGuidanceTooltip( if (guidance.periods.empty()) { text.append(QStringLiteral("\n Always")); } else { - const auto& period = guidance.periods.front(); - const auto start = std::max(0.0, period.startSeconds); - const auto end = std::max(start, std::max(0.0, period.endSeconds)); - text.append(QString("\n %1s~%2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + for (const auto& period : guidance.periods) { + const auto start = std::max(0.0, period.startSeconds); + const auto end = std::max(start, std::max(0.0, period.endSeconds)); + text.append(QString("\n %1s~%2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } } text.append(QString("\n Base compliance: %1").arg(std::clamp(guidance.baseComplianceRate, 0.0, 1.0), 0, 'f', 2)); text.append(QString("\n Strength: %1").arg(std::clamp(guidance.guidanceStrength, 0.0, 1.0), 0, 'f', 2)); diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index a3cb1ef..a62437d 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -133,10 +133,11 @@ QString formatRouteGuidanceTooltip(const safecrowd::domain::RouteGuidanceDraft& if (guidance.periods.empty()) { text.append(QStringLiteral("\n Always")); } else { - const auto& period = guidance.periods.front(); - const auto start = std::max(0.0, period.startSeconds); - const auto end = std::max(start, std::max(0.0, period.endSeconds)); - text.append(QString("\n %1s~%2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + for (const auto& period : guidance.periods) { + const auto start = std::max(0.0, period.startSeconds); + const auto end = std::max(start, std::max(0.0, period.endSeconds)); + text.append(QString("\n %1s~%2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } } text.append(QString("\n Base compliance: %1").arg(std::clamp(guidance.baseComplianceRate, 0.0, 1.0), 0, 'f', 2)); text.append(QString("\n Strength: %1").arg(std::clamp(guidance.guidanceStrength, 0.0, 1.0), 0, 'f', 2)); From 4bc58902bdb1c8c0f8ad38b680ed19012a9c632a Mon Sep 17 00:00:00 2001 From: 95x8x9 Date: Thu, 7 May 2026 21:42:33 +0900 Subject: [PATCH 4/4] [Domain] Reduce guidance toggle hitch --- src/domain/ScenarioSimulationMotionSystem.cpp | 432 ++++++++++-------- 1 file changed, 235 insertions(+), 197 deletions(-) diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index 48ab24f..56b0c80 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -57,8 +57,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto clampedDelta = std::max(0.0, resources.get().deltaSeconds); if (clampedDelta <= 0.0) { return; - } - + } + auto& query = world.query(); const auto entities = simulationEntities(query); const auto localNeighborIndex = resources.contains() @@ -72,13 +72,13 @@ 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); + + 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; } @@ -87,16 +87,16 @@ 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 = {}; + continue; + } + + if (route.nextWaypointIndex >= route.waypoints.size()) { + velocity.value = {}; + continue; + } + const auto target = routeWaypointTarget(route, position.value); const auto distance = distanceBetween(position.value, target); if (distance <= kArrivalEpsilon) { @@ -109,11 +109,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { velocity.value = {}; continue; } - + 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); @@ -154,16 +154,16 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { .velocity = clampedToLength(finalVelocity, maxSpeed), }); } - - for (const auto& plan : plans) { - auto& position = query.get(plan.entity); - auto& velocity = query.get(plan.entity); - auto& route = query.get(plan.entity); - const auto& agent = query.get(plan.entity); - if (route.nextWaypointIndex >= route.waypoints.size()) { - continue; - } - + + for (const auto& plan : plans) { + auto& position = query.get(plan.entity); + auto& velocity = query.get(plan.entity); + auto& route = query.get(plan.entity); + const auto& agent = query.get(plan.entity); + if (route.nextWaypointIndex >= route.waypoints.size()) { + continue; + } + const auto target = routeWaypointTarget(route, position.value); const auto remainingDistance = distanceBetween(position.value, target); const auto maxSpeed = effectiveMaxSpeed(layoutCache, agent, route, position.value); @@ -181,14 +181,14 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { velocity.value = (nextPosition - previousPosition) * (1.0 / clampedDelta); updateDisplayFloor(route, nextPosition); } - + resolveAgentOverlaps(query, entities, layoutCache); advanceRoutesForCurrentZones(query, entities, layoutCache); advanceRoutesForWaypointProgress(query, clampedDelta, entities, layoutCache); - advanceClock(query, clock, entities, clampedDelta); - resources.set(ScenarioSimulationStepResource{}); - } - + advanceClock(query, clock, entities, clampedDelta); + resources.set(ScenarioSimulationStepResource{}); + } + private: static constexpr double kExitReplanCooldownSeconds = 0.75; static constexpr double kNoExitReplanCooldownSeconds = 7.0; @@ -654,6 +654,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const ScenarioLayoutCacheResource& layoutCache, double elapsedSeconds, std::uint64_t derivedSeed) { + // Keep this small to avoid frame spikes when guidance toggles. + // Higher values converge faster but may cause noticeable hitching with many agents. + constexpr std::size_t kGuidanceReplanBudgetPerFrame = 50; + const auto active = activeRouteGuidance(elapsedSeconds); std::string activeId; if (active.has_value() && active->guidance != nullptr) { @@ -663,42 +667,32 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { activeId.append(std::to_string(active->periodIndex)); } } - if (activeId == activeRouteGuidanceId_) { - return; - } - activeRouteGuidanceId_ = activeId; - - if (!active.has_value() || active->guidance == nullptr) { - for (const auto entity : entities) { - const auto& status = query.get(entity); - if (status.evacuated) { - continue; - } - const auto& position = query.get(entity); - auto& route = query.get(entity); - route.guidanceEventId.clear(); - route.followsGuidance = false; - - if (!route.originalDestinationZoneId.empty()) { - const auto startZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); - if (!startZoneId.empty()) { - RoutePlan plan = routePlanToExit(layoutCache, position.value, startZoneId, route.originalDestinationZoneId); - if (plan.destinationZoneId.empty()) { - plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); - } - if (!plan.destinationZoneId.empty()) { - replaceRouteWithPlan(route, plan, position.value); - } - } - } + if (activeId != activeRouteGuidanceId_) { + activeRouteGuidanceId_ = activeId; + guidanceReplanCursor_ = 0; + guidanceReplanSeed_ = derivedSeed; + if (active.has_value() && active->guidance != nullptr) { + guidanceReplanGuidance_ = *active->guidance; + } else { + guidanceReplanGuidance_.reset(); } + guidanceReplanIdHash_ = fnv1a64(activeId); + guidanceReplanPending_ = true; + } + + if (!guidanceReplanPending_ || guidanceReplanCursor_ >= entities.size()) { return; } - const auto* activeGuidance = active->guidance; - const auto activeIdHash = fnv1a64(activeId); - for (const auto entity : entities) { + const auto endIndex = std::min(entities.size(), guidanceReplanCursor_ + kGuidanceReplanBudgetPerFrame); + + const RouteGuidanceDraft* activeGuidance = guidanceReplanGuidance_.has_value() ? &*guidanceReplanGuidance_ : nullptr; + const auto activeIdHash = guidanceReplanIdHash_; + const auto stableSeed = guidanceReplanSeed_; + + for (std::size_t i = guidanceReplanCursor_; i < endIndex; ++i) { + const auto entity = entities[i]; const auto& status = query.get(entity); if (status.evacuated) { continue; @@ -716,6 +710,31 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } + if (activeGuidance == nullptr) { + route.guidanceEventId.clear(); + route.followsGuidance = false; + + std::string desiredExit = route.originalDestinationZoneId; + if (desiredExit.empty()) { + continue; + } + + if (route.destinationZoneId == desiredExit && !route.waypoints.empty()) { + continue; + } + + RoutePlan plan = routePlanToExit(layoutCache, position.value, startZoneId, desiredExit); + if (plan.destinationZoneId.empty()) { + plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); + } + if (plan.destinationZoneId.empty()) { + continue; + } + replaceRouteWithPlan(route, plan, position.value); + route.nextExitReplanSeconds = elapsedSeconds + 0.25; + continue; + } + bool guidedExitValid = false; if (!activeGuidance->guidedExitZoneId.empty()) { if (const auto* exitZone = findCachedZone(layoutCache, activeGuidance->guidedExitZoneId); @@ -724,20 +743,24 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } + // Detour estimation can be expensive; skip it unless guidance has a detour limit. double detourMeters = 0.0; if (guidedExitValid && !route.originalDestinationZoneId.empty()) { - const auto originalDistance = - zoneDistanceToExit(layoutCache, position.value, startZoneId, route.originalDestinationZoneId); - const auto guidedDistance = - zoneDistanceToExit(layoutCache, position.value, startZoneId, activeGuidance->guidedExitZoneId); - if (originalDistance.has_value() && guidedDistance.has_value()) { - detourMeters = std::max(0.0, *guidedDistance - *originalDistance); + // Use a cheap approximation for detour to avoid expensive graph searches when guidance toggles. + // This detour is only used for compliance probability; the actual route still uses full planning. + const auto* originalExit = findCachedZone(layoutCache, route.originalDestinationZoneId); + const auto* guidedExit = findCachedZone(layoutCache, activeGuidance->guidedExitZoneId); + if (originalExit != nullptr && guidedExit != nullptr && originalExit->kind == ZoneKind::Exit + && guidedExit->kind == ZoneKind::Exit) { + const auto originalDistance = distanceBetween(position.value, polygonCenter(originalExit->area)); + const auto guidedDistance = distanceBetween(position.value, polygonCenter(guidedExit->area)); + detourMeters = std::max(0.0, guidedDistance - originalDistance); } } const auto pFollow = complianceProbability(*activeGuidance, agent, detourMeters); const auto u = uniform01( - derivedSeed + stableSeed ^ activeIdHash ^ (static_cast(entity.index) << 1U) ^ static_cast(entity.generation)); @@ -753,6 +776,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { desiredExit = route.originalDestinationZoneId; } + if (!desiredExit.empty() && route.destinationZoneId == desiredExit && !route.waypoints.empty()) { + continue; + } + RoutePlan plan; if (!desiredExit.empty()) { plan = routePlanToExit(layoutCache, position.value, startZoneId, desiredExit); @@ -766,6 +793,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { replaceRouteWithPlan(route, plan, position.value); route.nextExitReplanSeconds = elapsedSeconds + 0.25; } + + guidanceReplanCursor_ = endIndex; + if (guidanceReplanCursor_ >= entities.size()) { + guidanceReplanPending_ = false; + } } RoutePlan routePlanToNearestExit( @@ -1007,8 +1039,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { if (route.nextWaypointIndex < route.waypoints.size()) { route.previousDistanceToWaypoint = distanceToRouteWaypoint(route, route.currentSegmentStart); - } else { - route.previousDistanceToWaypoint = 0.0; + } else { + route.previousDistanceToWaypoint = 0.0; } route.stalledSeconds = 0.0; route.nextSegmentReplanSeconds = 0.0; @@ -1065,11 +1097,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { double deltaSeconds, const std::vector& entities, const ScenarioLayoutCacheResource& layoutCache) const { - for (const auto entity : entities) { - const auto& status = query.get(entity); - if (status.evacuated) { - continue; - } + for (const auto entity : entities) { + const auto& status = query.get(entity); + if (status.evacuated) { + continue; + } auto& position = query.get(entity); const auto& agent = query.get(entity); @@ -1093,10 +1125,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { position.value = advanceRouteWaypoint(layoutCache, route, agent, position.value); continue; } - - const auto target = routeWaypointTarget(route, position.value); - const auto segment = target - route.currentSegmentStart; - const auto segmentLengthSquared = dot(segment, segment); + + const auto target = routeWaypointTarget(route, position.value); + const auto segment = target - route.currentSegmentStart; + const auto segmentLengthSquared = dot(segment, segment); const auto distance = distanceToRouteWaypoint(route, position.value); if (distance <= kArrivalEpsilon) { @@ -1130,7 +1162,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } } - + if (route.previousDistanceToWaypoint <= 0.0 || distance < route.previousDistanceToWaypoint - kWaypointProgressEpsilon) { route.previousDistanceToWaypoint = distance; @@ -1165,37 +1197,37 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } } - - route.previousDistanceToWaypoint = std::min(route.previousDistanceToWaypoint, distance); - break; - } - } - } - + + route.previousDistanceToWaypoint = std::min(route.previousDistanceToWaypoint, distance); + break; + } + } + } + void advanceRoutesForCurrentZones( engine::WorldQuery& query, const std::vector& entities, const ScenarioLayoutCacheResource& layoutCache) const { - for (const auto entity : entities) { - const auto& status = query.get(entity); - if (status.evacuated) { - continue; - } - + for (const auto entity : entities) { + const auto& status = query.get(entity); + if (status.evacuated) { + continue; + } + auto& position = query.get(entity); auto& route = query.get(entity); const auto& agent = query.get(entity); const auto currentZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); while (!currentZoneId.empty() && route.nextWaypointIndex < route.waypointZoneIds.size()) { - auto matchedIndex = route.waypointZoneIds.size(); - for (auto index = route.nextWaypointIndex; index < route.waypointZoneIds.size(); ++index) { - if (route.waypointZoneIds[index] == currentZoneId) { - matchedIndex = index; - break; - } - } - if (matchedIndex == route.waypointZoneIds.size()) { - break; + auto matchedIndex = route.waypointZoneIds.size(); + for (auto index = route.nextWaypointIndex; index < route.waypointZoneIds.size(); ++index) { + if (route.waypointZoneIds[index] == currentZoneId) { + matchedIndex = index; + break; + } + } + if (matchedIndex == route.waypointZoneIds.size()) { + break; } while (route.nextWaypointIndex <= matchedIndex && route.nextWaypointIndex < route.waypoints.size()) { @@ -1203,8 +1235,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } } - } - + } + void replanBlockedExitRoutes( engine::WorldQuery& query, const std::vector& entities, @@ -1331,13 +1363,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { route.nextSegmentReplanSeconds = elapsedSeconds + kFailedSegmentReplanCooldownSeconds; continue; } - - const auto originalTargetZoneId = route.nextWaypointIndex < route.waypointZoneIds.size() - ? route.waypointZoneIds[route.nextWaypointIndex] - : std::string{}; - const auto originalTargetPassage = route.nextWaypointIndex < route.waypointPassages.size() - ? route.waypointPassages[route.nextWaypointIndex] - : pointPassage(target); + + const auto originalTargetZoneId = route.nextWaypointIndex < route.waypointZoneIds.size() + ? route.waypointZoneIds[route.nextWaypointIndex] + : std::string{}; + const auto originalTargetPassage = route.nextWaypointIndex < route.waypointPassages.size() + ? route.waypointPassages[route.nextWaypointIndex] + : pointPassage(target); const auto originalFromZoneId = route.nextWaypointIndex < route.waypointFromZoneIds.size() ? route.waypointFromZoneIds[route.nextWaypointIndex] : std::string{}; @@ -1365,16 +1397,16 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { if (route.nextWaypointIndex < route.waypointFloorIds.size()) { route.waypointFloorIds.erase(route.waypointFloorIds.begin() + static_cast(route.nextWaypointIndex)); } - - std::vector replacementPassages; - replacementPassages.reserve(replacement.size()); - for (const auto& waypoint : replacement) { - replacementPassages.push_back(pointPassage(waypoint)); - } - replacementPassages.back() = originalTargetPassage; - - std::vector replacementZoneIds(replacement.size(), std::string{}); - replacementZoneIds.back() = originalTargetZoneId; + + std::vector replacementPassages; + replacementPassages.reserve(replacement.size()); + for (const auto& waypoint : replacement) { + replacementPassages.push_back(pointPassage(waypoint)); + } + replacementPassages.back() = originalTargetPassage; + + std::vector replacementZoneIds(replacement.size(), std::string{}); + replacementZoneIds.back() = originalTargetZoneId; std::vector replacementFromZoneIds(replacement.size(), std::string{}); replacementFromZoneIds.back() = originalFromZoneId; std::vector replacementFloorIds(replacement.size(), std::string{}); @@ -1386,15 +1418,15 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { route.waypoints.insert( route.waypoints.begin() + static_cast(route.nextWaypointIndex), replacement.begin(), - replacement.end()); - route.waypointPassages.insert( - route.waypointPassages.begin() + static_cast(route.nextWaypointIndex), - replacementPassages.begin(), - replacementPassages.end()); - route.waypointFromZoneIds.insert( - route.waypointFromZoneIds.begin() + static_cast(route.nextWaypointIndex), - replacementFromZoneIds.begin(), - replacementFromZoneIds.end()); + replacement.end()); + route.waypointPassages.insert( + route.waypointPassages.begin() + static_cast(route.nextWaypointIndex), + replacementPassages.begin(), + replacementPassages.end()); + route.waypointFromZoneIds.insert( + route.waypointFromZoneIds.begin() + static_cast(route.nextWaypointIndex), + replacementFromZoneIds.begin(), + replacementFromZoneIds.end()); route.waypointZoneIds.insert( route.waypointZoneIds.begin() + static_cast(route.nextWaypointIndex), replacementZoneIds.begin(), @@ -1431,16 +1463,16 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { engine::WorldQuery& query, const std::vector& entities, const ScenarioLayoutCacheResource& layoutCache) const { - for (int iteration = 0; iteration < kOverlapRelaxationIterations; ++iteration) { - const auto spatialIndex = buildAgentSpatialIndex(query, entities, 1.0); - std::unordered_set checkedPairs; - checkedPairs.reserve(entities.size() * 4); - for (const auto first : entities) { - auto& firstStatus = query.get(first); - if (firstStatus.evacuated) { - continue; - } - + for (int iteration = 0; iteration < kOverlapRelaxationIterations; ++iteration) { + const auto spatialIndex = buildAgentSpatialIndex(query, entities, 1.0); + std::unordered_set checkedPairs; + checkedPairs.reserve(entities.size() * 4); + for (const auto first : entities) { + auto& firstStatus = query.get(first); + if (firstStatus.evacuated) { + continue; + } + auto& firstPosition = query.get(first); const auto& firstAgent = query.get(first); const auto& firstRoute = query.get(first); @@ -1451,31 +1483,31 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { firstPosition.value, firstCollisionFloorId, static_cast(firstAgent.radius) + kDefaultAgentRadius); - for (const auto second : candidates) { - if (first == second) { - continue; - } - const auto minIndex = std::min(first.index, second.index); - const auto maxIndex = std::max(first.index, second.index); - const auto pairKey = (static_cast(minIndex) << 32) - ^ static_cast(maxIndex); - if (!checkedPairs.insert(pairKey).second) { - continue; - } - - auto& secondStatus = query.get(second); - if (firstStatus.evacuated || secondStatus.evacuated) { - continue; - } - - auto& secondPosition = query.get(second); - const auto& secondAgent = query.get(second); - const auto delta = firstPosition.value - secondPosition.value; - const auto distance = lengthOf(delta); - const auto minimumDistance = static_cast(firstAgent.radius + secondAgent.radius); - if (distance >= minimumDistance) { - continue; - } + for (const auto second : candidates) { + if (first == second) { + continue; + } + const auto minIndex = std::min(first.index, second.index); + const auto maxIndex = std::max(first.index, second.index); + const auto pairKey = (static_cast(minIndex) << 32) + ^ static_cast(maxIndex); + if (!checkedPairs.insert(pairKey).second) { + continue; + } + + auto& secondStatus = query.get(second); + if (firstStatus.evacuated || secondStatus.evacuated) { + continue; + } + + auto& secondPosition = query.get(second); + const auto& secondAgent = query.get(second); + const auto delta = firstPosition.value - secondPosition.value; + const auto distance = lengthOf(delta); + const auto minimumDistance = static_cast(firstAgent.radius + secondAgent.radius); + if (distance >= minimumDistance) { + continue; + } const auto direction = normalizedOr(delta, deterministicFallbackDirection(first)); const auto push = std::min(0.08, (minimumDistance - distance) * 0.35); @@ -1500,31 +1532,31 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } } - } - - void advanceClock( - engine::WorldQuery& query, - ScenarioSimulationClockResource& clock, - const std::vector& entities, - double deltaSeconds) const { - clock.elapsedSeconds += deltaSeconds; - clock.complete = clock.elapsedSeconds >= clock.timeLimitSeconds; - if (clock.complete) { - return; - } - - std::size_t totalAgentCount = 0; - std::size_t evacuatedAgentCount = 0; - for (const auto entity : entities) { - ++totalAgentCount; - const auto& status = query.get(entity); - if (status.evacuated) { - ++evacuatedAgentCount; - } - } - clock.complete = totalAgentCount > 0 && evacuatedAgentCount >= totalAgentCount; - } - + } + + void advanceClock( + engine::WorldQuery& query, + ScenarioSimulationClockResource& clock, + const std::vector& entities, + double deltaSeconds) const { + clock.elapsedSeconds += deltaSeconds; + clock.complete = clock.elapsedSeconds >= clock.timeLimitSeconds; + if (clock.complete) { + return; + } + + std::size_t totalAgentCount = 0; + std::size_t evacuatedAgentCount = 0; + for (const auto entity : entities) { + ++totalAgentCount; + const auto& status = query.get(entity); + if (status.evacuated) { + ++evacuatedAgentCount; + } + } + clock.complete = totalAgentCount > 0 && evacuatedAgentCount >= totalAgentCount; + } + bool currentWaypointIsVertical(const EvacuationRoute& route) const { return route.nextWaypointIndex < route.waypointVerticalTransitions.size() && route.waypointVerticalTransitions[route.nextWaypointIndex]; @@ -1552,8 +1584,14 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::optional layoutCache_{}; std::vector routeGuidances_{}; std::string activeRouteGuidanceId_{}; + bool guidanceReplanPending_{false}; + std::size_t guidanceReplanCursor_{0}; + std::optional guidanceReplanGuidance_{}; + std::uint64_t guidanceReplanSeed_{0U}; + std::uint64_t guidanceReplanIdHash_{0U}; }; - + + } // namespace