From 7ccb3b2f29dea475af1fdee7b91fd117c660f18a Mon Sep 17 00:00:00 2001 From: muzygosu Date: Fri, 15 May 2026 16:33:57 +0900 Subject: [PATCH 1/2] Add occupant source authoring and spawning --- src/application/MainWindow.cpp | 16 ++ src/application/ProjectPersistence.cpp | 38 +++ src/application/ScenarioAuthoringWidget.cpp | 81 +++++- src/application/ScenarioBatchResultWidget.cpp | 16 ++ src/application/ScenarioCanvasWidget.cpp | 259 ++++++++++++++++- src/application/ScenarioCanvasWidget.h | 14 + src/application/ScenarioResultWidget.cpp | 16 ++ src/application/ScenarioRunWidget.cpp | 16 ++ src/domain/PopulationSpec.h | 14 + src/domain/ScenarioAuthoring.cpp | 21 +- src/domain/ScenarioSimulationMotionSystem.cpp | 10 +- src/domain/ScenarioSimulationRunner.cpp | 264 ++++++++++++------ src/domain/ScenarioSimulationRunner.h | 8 + src/domain/ScenarioSimulationSystems.cpp | 50 ++++ src/domain/ScenarioSimulationSystems.h | 23 ++ tests/ScenarioSimulationRunnerTests.cpp | 27 ++ 16 files changed, 771 insertions(+), 102 deletions(-) diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index acdc026..9bdea36 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -231,6 +231,22 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromSaved( uiPlacement.generatedPositions = placement.explicitPositions; state.crowdPlacements.push_back(std::move(uiPlacement)); } + for (const auto& source : saved.draft.population.occupantSources) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(source.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = ScenarioCrowdPlacementKind::Source; + uiPlacement.zoneId = QString::fromStdString(source.zoneId); + uiPlacement.floorId = QString::fromStdString(source.floorId); + uiPlacement.area = {source.position}; + uiPlacement.occupantCount = static_cast(source.targetAgentCount); + uiPlacement.velocity = source.initialVelocity; + uiPlacement.sourceAgentsPerSpawn = std::max(1, static_cast(source.agentsPerSpawn)); + uiPlacement.sourceStartSeconds = source.startSeconds; + uiPlacement.sourceEndSeconds = source.endSeconds; + uiPlacement.sourceIntervalSeconds = source.spawnIntervalSeconds; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } return state; } diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index a631bb5..b3d1b2e 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -703,6 +703,36 @@ safecrowd::domain::InitialPlacement2D initialPlacementFromJson(const QJsonObject return placement; } +QJsonObject occupantSourceToJson(const safecrowd::domain::OccupantSource2D& source) { + QJsonObject object; + object["id"] = QString::fromStdString(source.id); + object["zoneId"] = QString::fromStdString(source.zoneId); + object["floorId"] = QString::fromStdString(source.floorId); + object["position"] = pointArray(source.position); + object["targetAgentCount"] = static_cast(source.targetAgentCount); + object["agentsPerSpawn"] = static_cast(source.agentsPerSpawn); + object["startSeconds"] = source.startSeconds; + object["endSeconds"] = source.endSeconds; + object["spawnIntervalSeconds"] = source.spawnIntervalSeconds; + object["initialVelocity"] = pointArray(source.initialVelocity); + return object; +} + +safecrowd::domain::OccupantSource2D occupantSourceFromJson(const QJsonObject& object) { + safecrowd::domain::OccupantSource2D source; + source.id = object.value("id").toString().toStdString(); + source.zoneId = object.value("zoneId").toString().toStdString(); + source.floorId = object.value("floorId").toString().toStdString(); + source.position = pointFromJson(object.value("position")); + source.targetAgentCount = static_cast(object.value("targetAgentCount").toInteger()); + source.agentsPerSpawn = static_cast(std::max(1, object.value("agentsPerSpawn").toInteger(1))); + source.startSeconds = object.value("startSeconds").toDouble(0.0); + source.endSeconds = object.value("endSeconds").toDouble(180.0); + source.spawnIntervalSeconds = object.value("spawnIntervalSeconds").toDouble(5.0); + source.initialVelocity = pointFromJson(object.value("initialVelocity")); + return source; +} + QJsonObject populationToJson(const safecrowd::domain::PopulationSpec& population) { QJsonObject object; QJsonArray placements; @@ -710,6 +740,11 @@ QJsonObject populationToJson(const safecrowd::domain::PopulationSpec& population placements.append(initialPlacementToJson(placement)); } object["initialPlacements"] = placements; + QJsonArray sources; + for (const auto& source : population.occupantSources) { + sources.append(occupantSourceToJson(source)); + } + object["occupantSources"] = sources; return object; } @@ -718,6 +753,9 @@ safecrowd::domain::PopulationSpec populationFromJson(const QJsonObject& object) for (const auto& value : object.value("initialPlacements").toArray()) { population.initialPlacements.push_back(initialPlacementFromJson(value.toObject())); } + for (const auto& value : object.value("occupantSources").toArray()) { + population.occupantSources.push_back(occupantSourceFromJson(value.toObject())); + } return population; } diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 268e71f..c0aee29 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -387,6 +387,9 @@ int draftOccupantCount(const safecrowd::domain::ScenarioDraft& scenario) { for (const auto& placement : scenario.population.initialPlacements) { total += static_cast(placement.targetAgentCount); } + for (const auto& source : scenario.population.occupantSources) { + total += static_cast(source.targetAgentCount); + } return total; } @@ -411,8 +414,10 @@ QString buildChangeSummaryLine( const safecrowd::domain::ScenarioDraft& variant, const std::string& key) { if (key == "population.placements") { - const auto baselinePlacements = static_cast(baseline.population.initialPlacements.size()); - const auto variantPlacements = static_cast(variant.population.initialPlacements.size()); + const auto baselinePlacements = static_cast( + baseline.population.initialPlacements.size() + baseline.population.occupantSources.size()); + const auto variantPlacements = static_cast( + variant.population.initialPlacements.size() + variant.population.occupantSources.size()); QStringList parts; const int occupantDelta = draftOccupantCount(variant) - draftOccupantCount(baseline); if (occupantDelta != 0) { @@ -634,6 +639,9 @@ int totalOccupantCount(const ScenarioAuthoringWidget::ScenarioState& scenario) { for (const auto& placement : scenario.draft.population.initialPlacements) { total += static_cast(placement.targetAgentCount); } + for (const auto& source : scenario.draft.population.occupantSources) { + total += static_cast(source.targetAgentCount); + } return total; } @@ -722,6 +730,36 @@ QIcon groupCrowdTreeIcon() { QColor("#1f5fae")); } +QIcon sourceCrowdTreeIcon() { + QPixmap pixmap(44, 44); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QColor color("#1f5fae"); + painter.setPen(QPen(color, 2.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + QPainterPath platform; + platform.moveTo(9.0, 35.0); + platform.lineTo(30.0, 35.0); + platform.lineTo(35.0, 40.0); + platform.lineTo(4.0, 40.0); + platform.closeSubpath(); + painter.drawPath(platform); + + painter.drawEllipse(QPointF(18, 12), 6.0, 6.0); + QPainterPath body; + body.moveTo(10.0, 33.0); + body.lineTo(10.0, 25.0); + body.cubicTo(10.0, 19.5, 13.5, 17.0, 18.0, 17.0); + body.cubicTo(22.5, 17.0, 26.0, 19.5, 26.0, 25.0); + body.lineTo(26.0, 33.0); + painter.drawPath(body); + + painter.drawLine(QPointF(32.0, 26.0), QPointF(41.0, 26.0)); + painter.drawLine(QPointF(36.5, 21.5), QPointF(36.5, 30.5)); + return QIcon(pixmap); +} + std::vector buildCrowdTree(const ScenarioAuthoringWidget::ScenarioState* scenario) { if (scenario == nullptr || scenario->crowdPlacements.empty()) { return {}; @@ -730,8 +768,9 @@ std::vector buildCrowdTree(const ScenarioAuthoringWidget::Sc std::vector placements; for (const auto& placement : scenario->crowdPlacements) { const bool group = placement.kind == ScenarioCrowdPlacementKind::Group; + const bool source = placement.kind == ScenarioCrowdPlacementKind::Source; std::vector occupants; - for (int index = 1; index <= placement.occupantCount; ++index) { + for (int index = 1; group && index <= placement.occupantCount; ++index) { occupants.push_back({ .label = QString("Occupant %1").arg(index), .id = QString("%1/occupant-%2").arg(placement.id).arg(index), @@ -744,6 +783,16 @@ std::vector buildCrowdTree(const ScenarioAuthoringWidget::Sc }); } + const auto detail = source + ? QString("Source schedule: %1 people every %2s for %3 min\nVelocity: (%4, %5)") + .arg(placement.sourceAgentsPerSpawn) + .arg(placement.sourceIntervalSeconds, 0, 'f', 1) + .arg(std::max(0.0, placement.sourceEndSeconds - placement.sourceStartSeconds) / 60.0, 0, 'f', 1) + .arg(placement.velocity.x, 0, 'f', 2) + .arg(placement.velocity.y, 0, 'f', 2) + : QString("Velocity: (%1, %2)") + .arg(placement.velocity.x, 0, 'f', 2) + .arg(placement.velocity.y, 0, 'f', 2); placements.push_back({ .label = QString("%1 - %2 - %3 %4") .arg( @@ -752,10 +801,8 @@ std::vector buildCrowdTree(const ScenarioAuthoringWidget::Sc .arg(placement.occupantCount) .arg(placement.occupantCount == 1 ? "occupant" : "occupants"), .id = placement.id, - .detail = QString("Velocity: (%1, %2)") - .arg(placement.velocity.x, 0, 'f', 2) - .arg(placement.velocity.y, 0, 'f', 2), - .icon = group ? groupCrowdTreeIcon() : individualCrowdTreeIcon(), + .detail = detail, + .icon = source ? sourceCrowdTreeIcon() : (group ? groupCrowdTreeIcon() : individualCrowdTreeIcon()), .children = group ? std::move(occupants) : std::vector{}, .expanded = false, }); @@ -1715,7 +1762,27 @@ void ScenarioAuthoringWidget::updateCurrentScenarioPlacements(const std::vector< scenario->crowdPlacements = placements; scenario->draft.population.initialPlacements.clear(); + scenario->draft.population.occupantSources.clear(); for (const auto& placement : scenario->crowdPlacements) { + if (placement.kind == ScenarioCrowdPlacementKind::Source) { + if (placement.area.empty()) { + continue; + } + safecrowd::domain::OccupantSource2D source; + source.id = placement.id.toStdString(); + source.zoneId = placement.zoneId.toStdString(); + source.floorId = placement.floorId.toStdString(); + source.position = placement.area.front(); + source.targetAgentCount = static_cast(std::max(0, placement.occupantCount)); + source.agentsPerSpawn = static_cast(std::max(1, placement.sourceAgentsPerSpawn)); + source.startSeconds = std::max(0.0, placement.sourceStartSeconds); + source.endSeconds = std::max(source.startSeconds, placement.sourceEndSeconds); + source.spawnIntervalSeconds = std::max(0.1, placement.sourceIntervalSeconds); + source.initialVelocity = placement.velocity; + scenario->draft.population.occupantSources.push_back(std::move(source)); + continue; + } + safecrowd::domain::InitialPlacement2D initialPlacement; initialPlacement.id = placement.id.toStdString(); initialPlacement.zoneId = placement.zoneId.toStdString(); diff --git a/src/application/ScenarioBatchResultWidget.cpp b/src/application/ScenarioBatchResultWidget.cpp index 758a2bc..5a9f062 100644 --- a/src/application/ScenarioBatchResultWidget.cpp +++ b/src/application/ScenarioBatchResultWidget.cpp @@ -580,6 +580,22 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( uiPlacement.generatedPositions = placement.explicitPositions; state.crowdPlacements.push_back(std::move(uiPlacement)); } + for (const auto& source : scenario.population.occupantSources) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(source.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = ScenarioCrowdPlacementKind::Source; + uiPlacement.zoneId = QString::fromStdString(source.zoneId); + uiPlacement.floorId = QString::fromStdString(source.floorId); + uiPlacement.area = {source.position}; + uiPlacement.occupantCount = static_cast(source.targetAgentCount); + uiPlacement.velocity = source.initialVelocity; + uiPlacement.sourceAgentsPerSpawn = std::max(1, static_cast(source.agentsPerSpawn)); + uiPlacement.sourceStartSeconds = source.startSeconds; + uiPlacement.sourceEndSeconds = source.endSeconds; + uiPlacement.sourceIntervalSeconds = source.spawnIntervalSeconds; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } return state; } diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index 63f3021..961c394 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -48,6 +49,11 @@ constexpr double kDefaultInitialSpeed = 1.3; constexpr double kOccupantMarkerRadius = 5.0; constexpr double kOccupantWorldRadius = 0.25; constexpr double kOccupantMinSpacing = kOccupantWorldRadius * 2.0; +constexpr int kMaxSourceOccupantCount = 5000; +constexpr int kDefaultSourceAgentsPerSpawn = 1; +constexpr double kDefaultSourceStartSeconds = 0.0; +constexpr double kDefaultSourceDurationSeconds = 180.0; +constexpr double kDefaultSourceIntervalSeconds = 5.0; constexpr double kGeometryEpsilon = 1e-9; constexpr double kSelectionDragThresholdPixels = 4.0; const QColor kSelectionHighlightColor("#0b3d78"); @@ -66,10 +72,98 @@ struct PointBounds { double maxY{0.0}; }; +struct OccupantSourceSettings { + int agentsPerSpawn{kDefaultSourceAgentsPerSpawn}; + double startSeconds{kDefaultSourceStartSeconds}; + double durationSeconds{kDefaultSourceDurationSeconds}; + double intervalSeconds{kDefaultSourceIntervalSeconds}; +}; + bool matchesFloor(const std::string& elementFloorId, const QString& floorId) { return floorId.isEmpty() || elementFloorId.empty() || QString::fromStdString(elementFloorId) == floorId; } +int sourceEmissionCount(int agentsPerSpawn, double durationSeconds, double intervalSeconds) { + if (agentsPerSpawn <= 0 || durationSeconds <= 0.0 || intervalSeconds <= 1e-9) { + return 0; + } + const auto ticks = static_cast( + std::floor(std::max(0.0, durationSeconds - 1e-9) / intervalSeconds)) + 1; + const auto count = std::max(0, ticks) * static_cast(agentsPerSpawn); + return static_cast(std::min(kMaxSourceOccupantCount, count)); +} + +bool editOccupantSourceSettings( + QWidget* parent, + OccupantSourceSettings* settings, + const QPoint& screenPosition, + const QString& title) { + if (settings == nullptr) { + return false; + } + + QDialog dialog(parent); + dialog.setWindowTitle(title); + auto* layout = new QGridLayout(&dialog); + layout->setContentsMargins(16, 16, 16, 16); + layout->setHorizontalSpacing(12); + layout->setVerticalSpacing(10); + + auto* peopleSpin = new QSpinBox(&dialog); + peopleSpin->setRange(1, kMaxSourceOccupantCount); + peopleSpin->setSuffix(" people"); + peopleSpin->setValue(std::max(1, settings->agentsPerSpawn)); + + auto* intervalSpin = new QDoubleSpinBox(&dialog); + intervalSpin->setRange(0.1, 3600.0); + intervalSpin->setDecimals(1); + intervalSpin->setSuffix(" sec"); + intervalSpin->setValue(std::max(0.1, settings->intervalSeconds)); + + auto* durationSpin = new QDoubleSpinBox(&dialog); + durationSpin->setRange(0.1, 1440.0); + durationSpin->setDecimals(1); + durationSpin->setSuffix(" min"); + durationSpin->setValue(std::max(0.1, settings->durationSeconds / 60.0)); + + auto* totalLabel = new QLabel(&dialog); + totalLabel->setStyleSheet("QLabel { color: #4f5d6b; }"); + const auto refreshSummary = [=]() { + const auto total = sourceEmissionCount( + peopleSpin->value(), + durationSpin->value() * 60.0, + intervalSpin->value()); + totalLabel->setText(QString("Total emitted: %1 people").arg(total)); + }; + refreshSummary(); + QObject::connect(peopleSpin, qOverload(&QSpinBox::valueChanged), &dialog, refreshSummary); + QObject::connect(intervalSpin, qOverload(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary); + QObject::connect(durationSpin, qOverload(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary); + + layout->addWidget(new QLabel("People each time", &dialog), 0, 0); + layout->addWidget(peopleSpin, 0, 1); + layout->addWidget(new QLabel("Every", &dialog), 1, 0); + layout->addWidget(intervalSpin, 1, 1); + layout->addWidget(new QLabel("Duration", &dialog), 2, 0); + layout->addWidget(durationSpin, 2, 1); + layout->addWidget(totalLabel, 3, 0, 1, 2); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + layout->addWidget(buttons, 4, 0, 1, 2); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + + dialog.move(screenPosition); + if (dialog.exec() != QDialog::Accepted) { + return false; + } + + settings->agentsPerSpawn = peopleSpin->value(); + settings->intervalSeconds = intervalSpin->value(); + settings->durationSeconds = durationSpin->value() * 60.0; + return true; +} + safecrowd::domain::Point2D connectionMarkerCenter(const safecrowd::domain::Connection2D& connection) { return { .x = (connection.centerSpan.start.x + connection.centerSpan.end.x) * 0.5, @@ -610,6 +704,17 @@ QRectF groupMarkerBounds(const ScenarioCrowdPlacement& placement, const LayoutCa return bounds.adjusted(-7.0, -7.0, 7.0, 7.0); } +void drawOccupantSourceMarker(QPainter& painter, const QPointF& center, const QColor& color) { + painter.setPen(QPen(color, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(QColor(color.red(), color.green(), color.blue(), 36)); + painter.drawEllipse(center, 9.0, 9.0); + painter.setBrush(color); + painter.drawEllipse(center, 3.8, 3.8); + painter.drawLine(center + QPointF(11.0, 0.0), center + QPointF(18.0, 0.0)); + painter.drawLine(center + QPointF(18.0, 0.0), center + QPointF(14.0, -4.0)); + painter.drawLine(center + QPointF(18.0, 0.0), center + QPointF(14.0, 4.0)); +} + QString placementIdFromCrowdElementId(const QString& crowdElementId) { return crowdElementId.section('/', 0, 0); } @@ -674,6 +779,31 @@ QIcon makeToolIcon(const QString& type, const QColor& color) { return QIcon(pixmap); } + if (type == "source") { + painter.setPen(QPen(color, 2.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + QPainterPath platform; + platform.moveTo(9.0, 35.0); + platform.lineTo(30.0, 35.0); + platform.lineTo(35.0, 40.0); + platform.lineTo(4.0, 40.0); + platform.closeSubpath(); + painter.drawPath(platform); + + painter.drawEllipse(QPointF(18, 12), 6.0, 6.0); + QPainterPath body; + body.moveTo(10.0, 33.0); + body.lineTo(10.0, 25.0); + body.cubicTo(10.0, 19.5, 13.5, 17.0, 18.0, 17.0); + body.cubicTo(22.5, 17.0, 26.0, 19.5, 26.0, 25.0); + body.lineTo(26.0, 33.0); + painter.drawPath(body); + + painter.drawLine(QPointF(32.0, 26.0), QPointF(41.0, 26.0)); + painter.drawLine(QPointF(36.5, 21.5), QPointF(36.5, 30.5)); + return QIcon(pixmap); + } + if (type == "block") { painter.setPen(QPen(color, 3.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.setBrush(Qt::NoBrush); @@ -1727,6 +1857,12 @@ void ScenarioCanvasWidget::mousePressEvent(QMouseEvent* event) { return; } + if (toolMode_ == ToolMode::SourcePlacement) { + addSourcePlacement(event->position()); + event->accept(); + return; + } + if (toolMode_ == ToolMode::GroupPlacement) { dragging_ = true; dragStart_ = event->position(); @@ -1843,6 +1979,14 @@ void ScenarioCanvasWidget::paintEvent(QPaintEvent* event) { if (!currentFloorId_.isEmpty() && !placement.floorId.isEmpty() && placement.floorId != currentFloorId_) { continue; } + if (placement.kind == ScenarioCrowdPlacementKind::Source) { + if (placement.area.empty()) { + continue; + } + drawOccupantSourceMarker(painter, transform.map(placement.area.front()), QColor("#1f5fae")); + continue; + } + if (placement.kind == ScenarioCrowdPlacementKind::Individual) { if (placement.area.empty()) { continue; @@ -2442,7 +2586,12 @@ safecrowd::domain::Point2D ScenarioCanvasWidget::defaultVelocityFrom(const safec } QString ScenarioCanvasWidget::nextPlacementId(ScenarioCrowdPlacementKind kind) const { - const auto prefix = kind == ScenarioCrowdPlacementKind::Individual ? "individual" : "group"; + const char* prefix = "group"; + if (kind == ScenarioCrowdPlacementKind::Individual) { + prefix = "individual"; + } else if (kind == ScenarioCrowdPlacementKind::Source) { + prefix = "source"; + } return QString("%1-%2").arg(prefix).arg(static_cast(placements_.size()) + 1); } @@ -2570,6 +2719,43 @@ void ScenarioCanvasWidget::addIndividualPlacement(const QPointF& position) { update(); } +void ScenarioCanvasWidget::addSourcePlacement(const QPointF& position) { + const auto point = unmapPoint(position); + const auto zoneId = zoneAt(point); + if (zoneId.isEmpty() || placementPointBlocked(point)) { + return; + } + + const auto id = nextPlacementId(ScenarioCrowdPlacementKind::Source); + const auto sourceCount = sourceEmissionCount( + sourceAgentsPerSpawn_, + sourceDurationSeconds_, + sourceIntervalSeconds_); + placements_.push_back({ + .id = id, + .name = QString("Source %1").arg(id.section('-', -1)), + .kind = ScenarioCrowdPlacementKind::Source, + .zoneId = zoneId, + .floorId = currentFloorId_, + .area = {point}, + .occupantCount = sourceCount, + .velocity = defaultVelocityFrom(point), + .sourceAgentsPerSpawn = sourceAgentsPerSpawn_, + .sourceStartSeconds = sourceStartSeconds_, + .sourceEndSeconds = sourceStartSeconds_ + sourceDurationSeconds_, + .sourceIntervalSeconds = sourceIntervalSeconds_, + }); + focusedCrowdElementId_ = id; + focusedPlacementId_ = id; + selectedPlacementIds_ = QStringList{id}; + focusedLayoutElementId_.clear(); + if (crowdSelectionChangedHandler_) { + crowdSelectionChangedHandler_(id); + } + emitPlacementsChanged(); + update(); +} + void ScenarioCanvasWidget::addConnectionBlock(const QPointF& position) { const auto point = unmapPoint(position); constexpr double kPickRadiusPixels = 18.0; @@ -2947,11 +3133,53 @@ void ScenarioCanvasWidget::openRouteGuidanceEditor(const QString& guidanceId, co update(); } +bool ScenarioCanvasWidget::editOccupantSourceById(const QString& sourceId, const QPoint& screenPosition) { + auto placementIt = std::find_if(placements_.begin(), placements_.end(), [&](const auto& placement) { + return placement.id == sourceId && placement.kind == ScenarioCrowdPlacementKind::Source; + }); + if (placementIt == placements_.end()) { + return false; + } + + OccupantSourceSettings settings{ + .agentsPerSpawn = std::max(1, placementIt->sourceAgentsPerSpawn), + .startSeconds = placementIt->sourceStartSeconds, + .durationSeconds = std::max(0.1, placementIt->sourceEndSeconds - placementIt->sourceStartSeconds), + .intervalSeconds = std::max(0.1, placementIt->sourceIntervalSeconds), + }; + if (!editOccupantSourceSettings(this, &settings, screenPosition, "Edit occupant source")) { + return false; + } + + placementIt->sourceAgentsPerSpawn = settings.agentsPerSpawn; + placementIt->sourceStartSeconds = settings.startSeconds; + placementIt->sourceEndSeconds = settings.startSeconds + settings.durationSeconds; + placementIt->sourceIntervalSeconds = settings.intervalSeconds; + placementIt->occupantCount = sourceEmissionCount( + placementIt->sourceAgentsPerSpawn, + settings.durationSeconds, + placementIt->sourceIntervalSeconds); + emitPlacementsChanged(); + update(); + return true; +} + void ScenarioCanvasWidget::openCrowdPlacementContextMenu(const QString& crowdElementId, const QPoint& screenPosition) { + const auto placementId = placementIdFromCrowdElementId(crowdElementId); + const auto placementIt = std::find_if(placements_.begin(), placements_.end(), [&](const auto& placement) { + return placement.id == placementId; + }); + QMenu menu(this); + QAction* settingsAction = nullptr; + if (placementIt != placements_.end() && placementIt->kind == ScenarioCrowdPlacementKind::Source) { + settingsAction = menu.addAction("Source settings..."); + } auto* deleteAction = menu.addAction("Delete"); const auto* selectedAction = menu.exec(screenPosition); - if (selectedAction == deleteAction) { + if (selectedAction == settingsAction && settingsAction != nullptr) { + editOccupantSourceById(placementId, screenPosition); + } else if (selectedAction == deleteAction) { deleteCrowdElement(crowdElementId); } } @@ -3052,6 +3280,26 @@ void ScenarioCanvasWidget::emitRouteGuidancesChanged() { } } +bool ScenarioCanvasWidget::configureSourcePlacementTool(const QPoint& screenPosition) { + OccupantSourceSettings settings{ + .agentsPerSpawn = sourceAgentsPerSpawn_, + .startSeconds = sourceStartSeconds_, + .durationSeconds = sourceDurationSeconds_, + .intervalSeconds = sourceIntervalSeconds_, + }; + if (!editOccupantSourceSettings(this, &settings, screenPosition, "Add occupant source")) { + setToolMode(ToolMode::Select); + return false; + } + + sourceAgentsPerSpawn_ = settings.agentsPerSpawn; + sourceStartSeconds_ = settings.startSeconds; + sourceDurationSeconds_ = settings.durationSeconds; + sourceIntervalSeconds_ = settings.intervalSeconds; + setToolMode(ToolMode::SourcePlacement); + return true; +} + void ScenarioCanvasWidget::repositionToolbars() { if (topToolbar_ != nullptr) { topToolbar_->setGeometry(0, 0, width(), kTopToolbarHeight); @@ -3074,6 +3322,9 @@ void ScenarioCanvasWidget::setToolMode(ToolMode mode) { if (groupToolButton_ != nullptr) { groupToolButton_->setChecked(mode == ToolMode::GroupPlacement); } + if (sourceToolButton_ != nullptr) { + sourceToolButton_->setChecked(mode == ToolMode::SourcePlacement); + } if (blockDoorToolButton_ != nullptr) { blockDoorToolButton_->setChecked(mode == ToolMode::BlockDoor); } @@ -3136,6 +3387,7 @@ void ScenarioCanvasWidget::setupToolbars() { selectToolButton_ = makeButton(makeToolIcon("select", QColor("#16202b")), "Select"); individualToolButton_ = makeButton(makeToolIcon("individual", QColor("#1f5fae")), "Add Individual Occupant"); groupToolButton_ = makeButton(makeToolIcon("group", QColor("#1f5fae")), "Add Occupant Group"); + sourceToolButton_ = makeButton(makeToolIcon("source", QColor("#1f5fae")), "Add Occupant Source"); blockDoorToolButton_ = makeButton(makeToolIcon("block", QColor("#c0392b")), "block door"); fireHazardToolButton_ = makeButton(makeToolIcon("fire", QColor("#c2410c")), "Add Fire Hazard"); smokeHazardToolButton_ = makeButton(makeToolIcon("smoke", QColor("#64748b")), "Add Smoke Hazard"); @@ -3162,6 +3414,9 @@ void ScenarioCanvasWidget::setupToolbars() { connect(selectToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::Select); }); connect(individualToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::IndividualPlacement); }); connect(groupToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::GroupPlacement); }); + connect(sourceToolButton_, &QToolButton::clicked, this, [this]() { + configureSourcePlacementTool(sourceToolButton_->mapToGlobal(QPoint(0, sourceToolButton_->height()))); + }); connect(blockDoorToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::BlockDoor); }); connect(fireHazardToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::FireHazard); }); connect(smokeHazardToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::SmokeHazard); }); diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index 1def8b3..5bba600 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -30,6 +30,7 @@ namespace safecrowd::application { enum class ScenarioCrowdPlacementKind { Individual, Group, + Source, }; struct ScenarioCrowdPlacement { @@ -44,6 +45,10 @@ struct ScenarioCrowdPlacement { safecrowd::domain::InitialPlacementDistribution distribution{ safecrowd::domain::InitialPlacementDistribution::Uniform}; std::vector generatedPositions{}; + int sourceAgentsPerSpawn{1}; + double sourceStartSeconds{0.0}; + double sourceEndSeconds{180.0}; + double sourceIntervalSeconds{5.0}; }; class ScenarioCanvasWidget : public QWidget { @@ -91,6 +96,7 @@ class ScenarioCanvasWidget : public QWidget { Select, IndividualPlacement, GroupPlacement, + SourcePlacement, BlockDoor, FireHazard, SmokeHazard, @@ -118,6 +124,7 @@ class ScenarioCanvasWidget : public QWidget { QString nextRouteGuidanceId() const; void addGroupPlacement(const QPointF& start, const QPointF& end); void addIndividualPlacement(const QPointF& position); + void addSourcePlacement(const QPointF& position); void addConnectionBlock(const QPointF& position); void addConnectionBlockForConnection(const safecrowd::domain::Connection2D& connection); void addEnvironmentHazard(const QPointF& position, safecrowd::domain::EnvironmentHazardKind kind); @@ -134,6 +141,7 @@ class ScenarioCanvasWidget : public QWidget { void selectLayoutElementAt(const QPointF& position); void openCrowdPlacementContextMenu(const QString& crowdElementId, const QPoint& screenPosition); void openConnectionBlockScheduleEditor(const QString& blockId, const QPoint& screenPosition); + bool editOccupantSourceById(const QString& sourceId, const QPoint& screenPosition); bool deleteCrowdElement(const QString& crowdElementId); void drawFocusedLayoutElement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawFocusedPlacement(QPainter& painter, const LayoutCanvasTransform& transform) const; @@ -144,6 +152,7 @@ class ScenarioCanvasWidget : public QWidget { void emitConnectionBlocksChanged(); void emitEnvironmentHazardsChanged(); void emitRouteGuidancesChanged(); + bool configureSourcePlacementTool(const QPoint& screenPosition); void repositionToolbars(); void setToolMode(ToolMode mode); void setupToolbars(); @@ -171,6 +180,7 @@ class ScenarioCanvasWidget : public QWidget { QToolButton* selectToolButton_{nullptr}; QToolButton* individualToolButton_{nullptr}; QToolButton* groupToolButton_{nullptr}; + QToolButton* sourceToolButton_{nullptr}; QToolButton* blockDoorToolButton_{nullptr}; QToolButton* fireHazardToolButton_{nullptr}; QToolButton* smokeHazardToolButton_{nullptr}; @@ -179,6 +189,10 @@ class ScenarioCanvasWidget : public QWidget { QSpinBox* groupCountSpinBox_{nullptr}; QLabel* groupDistributionLabel_{nullptr}; QComboBox* groupDistributionComboBox_{nullptr}; + int sourceAgentsPerSpawn_{1}; + double sourceStartSeconds_{0.0}; + double sourceDurationSeconds_{180.0}; + double sourceIntervalSeconds_{5.0}; QString hoveredConnectionBlockId_{}; QString hoveredEnvironmentHazardId_{}; QString hoveredRouteGuidanceId_{}; diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index 24dccd9..f2f11b0 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -1060,6 +1060,22 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( uiPlacement.generatedPositions = placement.explicitPositions; state.crowdPlacements.push_back(std::move(uiPlacement)); } + for (const auto& source : scenario.population.occupantSources) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(source.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = ScenarioCrowdPlacementKind::Source; + uiPlacement.zoneId = QString::fromStdString(source.zoneId); + uiPlacement.floorId = QString::fromStdString(source.floorId); + uiPlacement.area = {source.position}; + uiPlacement.occupantCount = static_cast(source.targetAgentCount); + uiPlacement.velocity = source.initialVelocity; + uiPlacement.sourceAgentsPerSpawn = std::max(1, static_cast(source.agentsPerSpawn)); + uiPlacement.sourceStartSeconds = source.startSeconds; + uiPlacement.sourceEndSeconds = source.endSeconds; + uiPlacement.sourceIntervalSeconds = source.spawnIntervalSeconds; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } return state; } diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 31f130a..4b04642 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -209,6 +209,22 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( uiPlacement.generatedPositions = placement.explicitPositions; state.crowdPlacements.push_back(std::move(uiPlacement)); } + for (const auto& source : scenario.population.occupantSources) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(source.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = ScenarioCrowdPlacementKind::Source; + uiPlacement.zoneId = QString::fromStdString(source.zoneId); + uiPlacement.floorId = QString::fromStdString(source.floorId); + uiPlacement.area = {source.position}; + uiPlacement.occupantCount = static_cast(source.targetAgentCount); + uiPlacement.velocity = source.initialVelocity; + uiPlacement.sourceAgentsPerSpawn = std::max(1, static_cast(source.agentsPerSpawn)); + uiPlacement.sourceStartSeconds = source.startSeconds; + uiPlacement.sourceEndSeconds = source.endSeconds; + uiPlacement.sourceIntervalSeconds = source.spawnIntervalSeconds; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } return state; } diff --git a/src/domain/PopulationSpec.h b/src/domain/PopulationSpec.h index bf499ec..59091a8 100644 --- a/src/domain/PopulationSpec.h +++ b/src/domain/PopulationSpec.h @@ -24,8 +24,22 @@ struct InitialPlacement2D { std::vector explicitPositions{}; }; +struct OccupantSource2D { + std::string id{}; + std::string zoneId{}; + std::string floorId{}; + Point2D position{}; + std::size_t targetAgentCount{0}; + std::size_t agentsPerSpawn{1}; + double startSeconds{0.0}; + double endSeconds{180.0}; + double spawnIntervalSeconds{5.0}; + Point2D initialVelocity{}; +}; + struct PopulationSpec { std::vector initialPlacements{}; + std::vector occupantSources{}; }; } // namespace safecrowd::domain diff --git a/src/domain/ScenarioAuthoring.cpp b/src/domain/ScenarioAuthoring.cpp index 7934cfe..4030268 100644 --- a/src/domain/ScenarioAuthoring.cpp +++ b/src/domain/ScenarioAuthoring.cpp @@ -48,8 +48,22 @@ bool placementsEqual(const InitialPlacement2D& lhs, const InitialPlacement2D& rh return true; } +bool occupantSourcesEqual(const OccupantSource2D& lhs, const OccupantSource2D& rhs) { + return lhs.id == rhs.id + && lhs.zoneId == rhs.zoneId + && lhs.floorId == rhs.floorId + && pointsEqual(lhs.position, rhs.position) + && lhs.targetAgentCount == rhs.targetAgentCount + && lhs.agentsPerSpawn == rhs.agentsPerSpawn + && lhs.startSeconds == rhs.startSeconds + && lhs.endSeconds == rhs.endSeconds + && lhs.spawnIntervalSeconds == rhs.spawnIntervalSeconds + && pointsEqual(lhs.initialVelocity, rhs.initialVelocity); +} + bool populationsEqual(const PopulationSpec& lhs, const PopulationSpec& rhs) { - if (lhs.initialPlacements.size() != rhs.initialPlacements.size()) { + if (lhs.initialPlacements.size() != rhs.initialPlacements.size() + || lhs.occupantSources.size() != rhs.occupantSources.size()) { return false; } for (std::size_t i = 0; i < lhs.initialPlacements.size(); ++i) { @@ -57,6 +71,11 @@ bool populationsEqual(const PopulationSpec& lhs, const PopulationSpec& rhs) { return false; } } + for (std::size_t i = 0; i < lhs.occupantSources.size(); ++i) { + if (!occupantSourcesEqual(lhs.occupantSources[i], rhs.occupantSources[i])) { + return false; + } + } return true; } diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index b7568d0..c6809d6 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -340,7 +340,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { advanceRoutesForWaypointProgress(query, clampedDelta, activeEntities_, layoutCache); updateAgentPhysicsFloorIds(query, layoutCache, activeEntities_); resolveAgentOverlaps(query, activeEntities_, layoutCache); - advanceClock(query, clock, entities, clampedDelta); + const auto pendingScheduledSpawns = resources.contains() + ? resources.get().pendingCount + : std::size_t{0}; + advanceClock(query, clock, entities, clampedDelta, pendingScheduledSpawns); resources.set(ScenarioSimulationStepResource{}); } @@ -2276,7 +2279,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { engine::WorldQuery& query, ScenarioSimulationClockResource& clock, const std::vector& entities, - double deltaSeconds) const { + double deltaSeconds, + std::size_t pendingScheduledSpawns) const { clock.elapsedSeconds += deltaSeconds; clock.complete = clock.elapsedSeconds >= clock.timeLimitSeconds; if (clock.complete) { @@ -2292,7 +2296,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { ++evacuatedAgentCount; } } - clock.complete = totalAgentCount > 0 && evacuatedAgentCount >= totalAgentCount; + clock.complete = pendingScheduledSpawns == 0 && totalAgentCount > 0 && evacuatedAgentCount >= totalAgentCount; } bool currentWaypointIsVertical(const EvacuationRoute& route) const { diff --git a/src/domain/ScenarioSimulationRunner.cpp b/src/domain/ScenarioSimulationRunner.cpp index e86fb46..7b64136 100644 --- a/src/domain/ScenarioSimulationRunner.cpp +++ b/src/domain/ScenarioSimulationRunner.cpp @@ -17,6 +17,63 @@ #include "engine/UpdatePhase.h" namespace safecrowd::domain { +namespace { + +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; +} + +std::uint64_t mix64(std::uint64_t value) { + value += 0x9e3779b97f4a7c15ULL; + value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL; + value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL; + return value ^ (value >> 31U); +} + +double uniform01(std::uint64_t value) { + const auto mixed = mix64(value); + const auto mantissa = mixed >> 11U; + return static_cast(mantissa) * (1.0 / 9007199254740992.0); +} + +double beta22(std::uint64_t baseSeed, 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::size_t sourceTickCount(const OccupantSource2D& source) { + if (source.spawnIntervalSeconds <= 1e-9 || source.endSeconds <= source.startSeconds) { + return 0; + } + + const auto duration = source.endSeconds - source.startSeconds; + return static_cast( + std::floor(std::max(0.0, duration - 1e-9) / source.spawnIntervalSeconds)) + 1; +} + +std::size_t sourceScheduleCount(const OccupantSource2D& source) { + if (source.targetAgentCount == 0) { + return 0; + } + const auto scheduled = sourceTickCount(source) * std::max(1, source.agentsPerSpawn); + return std::min(source.targetAgentCount, scheduled); +} + +} // namespace + using namespace simulation_internal; ScenarioSimulationRunner::ScenarioSimulationRunner(FacilityLayout2D layout, ScenarioDraft scenario) { @@ -96,38 +153,6 @@ 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) { @@ -137,69 +162,125 @@ std::vector ScenarioSimulationRunner::createAgentSeeds() cons seeds.reserve(seeds.size() + count); for (std::size_t index = 0; index < count; ++index) { const auto position = placementPoint(placement, index); - auto placementFloorId = placement.floorId; - if (placementFloorId.empty() && !placement.zoneId.empty()) { - placementFloorId = cachedFloorIdForZone(layoutCache_, placement.zoneId); - } - auto startZoneId = placement.zoneId; - if (!startZoneId.empty() && !placementFloorId.empty()) { - const auto zoneFloorId = cachedFloorIdForZone(layoutCache_, startZoneId); - if (!zoneFloorId.empty() && zoneFloorId != placementFloorId) { - startZoneId.clear(); - } - } - if (startZoneId.empty()) { - startZoneId = zoneAt(position, placementFloorId); - } - if (placementFloorId.empty()) { - placementFloorId = cachedFloorIdForZone(layoutCache_, startZoneId); + seeds.push_back(createAgentSeed( + placement.id, + placement.zoneId, + placement.floorId, + position, + placement.initialVelocity, + ++agentSerial)); + } + } + return seeds; +} + +std::vector ScenarioSimulationRunner::createOccupantSourceSeeds() const { + std::vector seeds; + std::size_t totalCount = 0; + for (const auto& source : scenario_.population.occupantSources) { + totalCount += sourceScheduleCount(source); + } + seeds.reserve(totalCount); + + std::uint64_t agentSerial = 0; + for (const auto& placement : scenario_.population.initialPlacements) { + agentSerial += static_cast( + placement.explicitPositions.empty() ? placement.targetAgentCount : placement.explicitPositions.size()); + } + for (const auto& source : scenario_.population.occupantSources) { + const auto targetCount = sourceScheduleCount(source); + const auto tickCount = sourceTickCount(source); + const auto agentsPerSpawn = std::max(1, source.agentsPerSpawn); + std::size_t emittedCount = 0; + for (std::size_t tickIndex = 0; tickIndex < tickCount && emittedCount < targetCount; ++tickIndex) { + const auto spawnSeconds = source.startSeconds + (source.spawnIntervalSeconds * static_cast(tickIndex)); + for (std::size_t agentIndex = 0; agentIndex < agentsPerSpawn && emittedCount < targetCount; ++agentIndex) { + seeds.push_back({ + .spawnSeconds = spawnSeconds, + .seed = createAgentSeed( + source.id, + source.zoneId, + source.floorId, + source.position, + source.initialVelocity, + ++agentSerial), + }); + ++emittedCount; } - const auto route = routePlan(position, startZoneId); - const auto speed = speedOf(placement.initialVelocity); - auto evacuationRoute = EvacuationRoute{ - .waypoints = route.waypoints, - .waypointPassages = route.waypointPassages, - .waypointFromZoneIds = route.waypointFromZoneIds, - .waypointZoneIds = route.waypointZoneIds, - .waypointFloorIds = route.waypointFloorIds, - .waypointConnectionIds = route.waypointConnectionIds, - .waypointVerticalTransitions = route.waypointVerticalTransitions, - .nextWaypointIndex = 0, - .currentSegmentStart = position, - .previousDistanceToWaypoint = 0.0, - .stalledSeconds = 0.0, - .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 = { - .radius = static_cast(kDefaultAgentRadius), - .maxSpeed = static_cast(speed), - .sourcePlacementId = placement.id, - .sourceZoneId = startZoneId, - .guidancePropensity = guidancePropensity, - }, - .velocity = {.value = {}}, - .route = std::move(evacuationRoute), - .status = {}, - }); } } return seeds; } +ScenarioAgentSeed ScenarioSimulationRunner::createAgentSeed( + const std::string& sourcePlacementId, + const std::string& sourceZoneId, + const std::string& sourceFloorId, + Point2D position, + Point2D initialVelocity, + std::uint64_t agentSerial) const { + auto placementFloorId = sourceFloorId; + if (placementFloorId.empty() && !sourceZoneId.empty()) { + placementFloorId = cachedFloorIdForZone(layoutCache_, sourceZoneId); + } + auto startZoneId = sourceZoneId; + if (!startZoneId.empty() && !placementFloorId.empty()) { + const auto zoneFloorId = cachedFloorIdForZone(layoutCache_, startZoneId); + if (!zoneFloorId.empty() && zoneFloorId != placementFloorId) { + startZoneId.clear(); + } + } + if (startZoneId.empty()) { + startZoneId = zoneAt(position, placementFloorId); + } + if (placementFloorId.empty()) { + placementFloorId = cachedFloorIdForZone(layoutCache_, startZoneId); + } + + const auto route = routePlan(position, startZoneId); + const auto speed = speedOf(initialVelocity); + auto evacuationRoute = EvacuationRoute{ + .waypoints = route.waypoints, + .waypointPassages = route.waypointPassages, + .waypointFromZoneIds = route.waypointFromZoneIds, + .waypointZoneIds = route.waypointZoneIds, + .waypointFloorIds = route.waypointFloorIds, + .waypointConnectionIds = route.waypointConnectionIds, + .waypointVerticalTransitions = route.waypointVerticalTransitions, + .nextWaypointIndex = 0, + .currentSegmentStart = position, + .previousDistanceToWaypoint = 0.0, + .stalledSeconds = 0.0, + .destinationZoneId = route.destinationZoneId, + .currentFloorId = placementFloorId, + }; + evacuationRoute.originalDestinationZoneId = evacuationRoute.destinationZoneId; + evacuationRoute.displayFloorId = evacuationRoute.currentFloorId; + evacuationRoute.previousDistanceToWaypoint = route.waypoints.empty() + ? 0.0 + : distanceToRouteWaypoint(evacuationRoute, position); + + const std::uint64_t baseSeed = scenario_.execution.baseSeed != 0 ? scenario_.execution.baseSeed : 1; + const auto propensitySalt = + mix64(agentSerial) + ^ mix64(fnv1a64(sourcePlacementId)) + ^ mix64(fnv1a64(startZoneId)) + ^ mix64(static_cast(evacuationRoute.destinationZoneId.size())); + return { + .position = {.value = position}, + .agent = { + .radius = static_cast(kDefaultAgentRadius), + .maxSpeed = static_cast(speed), + .sourcePlacementId = sourcePlacementId, + .sourceZoneId = startZoneId, + .guidancePropensity = beta22(baseSeed, propensitySalt), + }, + .velocity = {.value = {}}, + .route = std::move(evacuationRoute), + .status = {}, + }; +} + void ScenarioSimulationRunner::initializeRuntime() { runtime_ = std::make_unique(engine::EngineConfig{ .fixedDeltaTime = 0.1, @@ -207,6 +288,11 @@ void ScenarioSimulationRunner::initializeRuntime() { .baseSeed = scenario_.execution.baseSeed != 0 ? scenario_.execution.baseSeed : 1, }); runtime_->addSystem(std::make_unique(createAgentSeeds(), timeLimitSeconds_)); + runtime_->addSystem( + std::make_unique(createOccupantSourceSeeds()), + {.phase = engine::UpdatePhase::FixedSimulation, + .order = -30, + .triggerPolicy = engine::TriggerPolicy::FixedStep}); runtime_->addSystem( makeScenarioControlSystem(layout_, scenario_.control.connectionBlocks), {.phase = engine::UpdatePhase::PreSimulation, diff --git a/src/domain/ScenarioSimulationRunner.h b/src/domain/ScenarioSimulationRunner.h index 0eb8283..6af6fa6 100644 --- a/src/domain/ScenarioSimulationRunner.h +++ b/src/domain/ScenarioSimulationRunner.h @@ -44,6 +44,14 @@ class ScenarioSimulationRunner { }; std::vector createAgentSeeds() const; + std::vector createOccupantSourceSeeds() const; + ScenarioAgentSeed createAgentSeed( + const std::string& sourcePlacementId, + const std::string& sourceZoneId, + const std::string& sourceFloorId, + Point2D position, + Point2D initialVelocity, + std::uint64_t agentSerial) const; void initializeRuntime(); void syncFrameFromRuntime(); RoutePlan routePlan(const Point2D& start, const std::string& startZoneId) const; diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index e9af0b7..ce8697b 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -714,6 +714,56 @@ void ScenarioAgentSpawnSystem::update(engine::EngineWorld& world, const engine:: (void)step; } +ScenarioOccupantSourceSpawnSystem::ScenarioOccupantSourceSpawnSystem(std::vector seeds) + : seeds_(std::move(seeds)) { + std::stable_sort(seeds_.begin(), seeds_.end(), [](const auto& lhs, const auto& rhs) { + return lhs.spawnSeconds < rhs.spawnSeconds; + }); +} + +void ScenarioOccupantSourceSpawnSystem::configure(engine::EngineWorld& world) { + nextSeedIndex_ = 0; + spawnDueSeeds(world, 0.0); +} + +void ScenarioOccupantSourceSpawnSystem::update(engine::EngineWorld& world, const engine::EngineStepContext& step) { + (void)step; + + auto& resources = world.resources(); + if (!resources.contains()) { + resources.set(ScenarioScheduledSpawnResource{.pendingCount = seeds_.size() - nextSeedIndex_}); + return; + } + + const auto& clock = resources.get(); + if (clock.complete) { + resources.set(ScenarioScheduledSpawnResource{.pendingCount = seeds_.size() - nextSeedIndex_}); + return; + } + + const auto deltaSeconds = resources.contains() + ? std::max(0.0, resources.get().deltaSeconds) + : 0.0; + spawnDueSeeds(world, clock.elapsedSeconds + deltaSeconds); +} + +void ScenarioOccupantSourceSpawnSystem::spawnDueSeeds(engine::EngineWorld& world, double elapsedSeconds) { + while (nextSeedIndex_ < seeds_.size() + && seeds_[nextSeedIndex_].spawnSeconds <= elapsedSeconds + 1e-9) { + const auto& seed = seeds_[nextSeedIndex_].seed; + world.commands().spawnEntity( + seed.position, + seed.agent, + seed.velocity, + seed.avoidance, + seed.route, + seed.status); + ++nextSeedIndex_; + } + + world.resources().set(ScenarioScheduledSpawnResource{.pendingCount = seeds_.size() - nextSeedIndex_}); +} + std::vector scenarioNearbyAgents( engine::WorldQuery& query, const ScenarioAgentSpatialIndexResource& index, diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index f51ecdc..969afbd 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -32,6 +32,10 @@ struct ScenarioSimulationStepResource { double deltaSeconds{0.0}; }; +struct ScenarioScheduledSpawnResource { + std::size_t pendingCount{0}; +}; + struct ScenarioAgentSpatialIndexResource { double cellSize{1.0}; std::unordered_map>> cellsByFloor{}; @@ -159,6 +163,11 @@ struct ScenarioAgentSeed { EvacuationStatus status{}; }; +struct ScheduledScenarioAgentSeed { + double spawnSeconds{0.0}; + ScenarioAgentSeed seed{}; +}; + std::vector scenarioNearbyAgents( engine::WorldQuery& query, const ScenarioAgentSpatialIndexResource& index, @@ -198,6 +207,20 @@ class ScenarioAgentSpawnSystem final : public engine::EngineSystem { double timeLimitSeconds_{60.0}; }; +class ScenarioOccupantSourceSpawnSystem final : public engine::EngineSystem { +public: + explicit ScenarioOccupantSourceSpawnSystem(std::vector seeds); + + void configure(engine::EngineWorld& world) override; + void update(engine::EngineWorld& world, const engine::EngineStepContext& step) override; + +private: + void spawnDueSeeds(engine::EngineWorld& world, double elapsedSeconds); + + std::vector seeds_{}; + std::size_t nextSeedIndex_{0}; +}; + class ScenarioSpatialIndexSystem final : public engine::EngineSystem { public: explicit ScenarioSpatialIndexSystem(double cellSize = 1.0); diff --git a/tests/ScenarioSimulationRunnerTests.cpp b/tests/ScenarioSimulationRunnerTests.cpp index d63f030..14caeb4 100644 --- a/tests/ScenarioSimulationRunnerTests.cpp +++ b/tests/ScenarioSimulationRunnerTests.cpp @@ -930,6 +930,33 @@ SC_TEST(ScenarioSimulationRunnerInitializesAndRoutesAgentsThroughLayoutConnectio SC_EXPECT_TRUE(!runner.complete()); } +SC_TEST(ScenarioSimulationRunnerSpawnsOccupantSourcesOnSchedule) { + safecrowd::domain::ScenarioDraft scenario; + scenario.execution.timeLimitSeconds = 10.0; + scenario.population.occupantSources.push_back({ + .id = "source-1", + .zoneId = "room", + .position = {.x = 1.0, .y = 2.0}, + .targetAgentCount = 6, + .agentsPerSpawn = 2, + .startSeconds = 0.0, + .endSeconds = 0.3, + .spawnIntervalSeconds = 0.1, + .initialVelocity = {.x = 1.0, .y = 0.0}, + }); + + safecrowd::domain::ScenarioSimulationRunner runner(wideDoorLayout(), scenario); + SC_EXPECT_EQ(runner.frame().totalAgentCount, static_cast(2)); + + runner.step(0.1); + SC_EXPECT_NEAR(runner.frame().elapsedSeconds, 0.1, 1e-9); + SC_EXPECT_EQ(runner.frame().totalAgentCount, static_cast(4)); + + runner.step(0.1); + SC_EXPECT_NEAR(runner.frame().elapsedSeconds, 0.2, 1e-9); + SC_EXPECT_EQ(runner.frame().totalAgentCount, static_cast(6)); +} + SC_TEST(ScenarioSimulationRunnerSplitsLargeDeltaIntoStableFixedSteps) { safecrowd::domain::ScenarioDraft scenario; scenario.execution.timeLimitSeconds = 10.0; From 1789545f7a31e3742929b7a551fafc95306ec9ec Mon Sep 17 00:00:00 2001 From: learncold Date: Sat, 16 May 2026 23:51:11 +0900 Subject: [PATCH 2/2] Fix occupant source spawn timing --- src/domain/ScenarioSimulationRunner.cpp | 2 +- src/domain/ScenarioSimulationSystems.cpp | 5 +---- tests/ScenarioSimulationRunnerTests.cpp | 8 ++++++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/domain/ScenarioSimulationRunner.cpp b/src/domain/ScenarioSimulationRunner.cpp index 7b64136..08c7eda 100644 --- a/src/domain/ScenarioSimulationRunner.cpp +++ b/src/domain/ScenarioSimulationRunner.cpp @@ -291,7 +291,7 @@ void ScenarioSimulationRunner::initializeRuntime() { runtime_->addSystem( std::make_unique(createOccupantSourceSeeds()), {.phase = engine::UpdatePhase::FixedSimulation, - .order = -30, + .order = 1, .triggerPolicy = engine::TriggerPolicy::FixedStep}); runtime_->addSystem( makeScenarioControlSystem(layout_, scenario_.control.connectionBlocks), diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index ce8697b..986e3ff 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -741,10 +741,7 @@ void ScenarioOccupantSourceSpawnSystem::update(engine::EngineWorld& world, const return; } - const auto deltaSeconds = resources.contains() - ? std::max(0.0, resources.get().deltaSeconds) - : 0.0; - spawnDueSeeds(world, clock.elapsedSeconds + deltaSeconds); + spawnDueSeeds(world, clock.elapsedSeconds); } void ScenarioOccupantSourceSpawnSystem::spawnDueSeeds(engine::EngineWorld& world, double elapsedSeconds) { diff --git a/tests/ScenarioSimulationRunnerTests.cpp b/tests/ScenarioSimulationRunnerTests.cpp index 14caeb4..181166d 100644 --- a/tests/ScenarioSimulationRunnerTests.cpp +++ b/tests/ScenarioSimulationRunnerTests.cpp @@ -951,6 +951,14 @@ SC_TEST(ScenarioSimulationRunnerSpawnsOccupantSourcesOnSchedule) { runner.step(0.1); SC_EXPECT_NEAR(runner.frame().elapsedSeconds, 0.1, 1e-9); SC_EXPECT_EQ(runner.frame().totalAgentCount, static_cast(4)); + const auto agentsAtFirstScheduledSpawn = std::count_if( + runner.frame().agents.begin(), + runner.frame().agents.end(), + [](const auto& agent) { + return std::abs(agent.position.x - 1.0) <= 1e-9 + && std::abs(agent.position.y - 2.0) <= 1e-9; + }); + SC_EXPECT_EQ(agentsAtFirstScheduledSpawn, 2); runner.step(0.1); SC_EXPECT_NEAR(runner.frame().elapsedSeconds, 0.2, 1e-9);