From fe956ed3ce586a20de69b1845218a72813b450c4 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Wed, 29 Apr 2026 19:48:35 +0900 Subject: [PATCH 1/5] [Application] continue scenario workflow --- src/application/MainWindow.cpp | 44 +++- src/application/ProjectPersistence.cpp | 221 ++++++++++++++++++++ src/application/ProjectPersistence.h | 8 + src/application/ScenarioAuthoringWidget.cpp | 219 +++++++++++++++++-- src/application/ScenarioAuthoringWidget.h | 15 ++ src/application/ScenarioResultWidget.cpp | 110 +++------- src/application/ScenarioResultWidget.h | 2 + src/application/ScenarioRunWidget.cpp | 60 +++++- src/application/ScenarioRunWidget.h | 2 + 9 files changed, 564 insertions(+), 117 deletions(-) diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index c7de3f6..af6e8f1 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -127,6 +127,11 @@ void MainWindow::saveCurrentProject() { QMessageBox::warning(this, "Save Project", errorMessage); return; } + } else if (auto* authoringWidget = dynamic_cast(centralWidget())) { + if (!ProjectPersistence::saveScenarioAuthoringState(currentProject_, authoringWidget->currentState(), &errorMessage)) { + QMessageBox::warning(this, "Save Project", errorMessage); + return; + } } currentProject_ = ProjectPersistence::loadProject(currentProject_.folderPath); @@ -164,6 +169,13 @@ void MainWindow::showLayoutReview(const ProjectMetadata& metadata) { showProjectNavigator(); }, [this](const safecrowd::domain::ImportResult& approvedImportResult) { + if (!currentProject_.isBuiltInDemo()) { + QString errorMessage; + if (!ProjectPersistence::saveProjectReview(currentProject_, approvedImportResult, &errorMessage)) { + QMessageBox::warning(this, "Approve Layout", errorMessage); + return; + } + } showScenarioAuthoring(approvedImportResult); }, this)); @@ -175,17 +187,33 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im return; } + ScenarioAuthoringWidget::InitialState initialState; + const bool hasSavedScenarioState = ProjectPersistence::loadScenarioAuthoringState(currentProject_, &initialState); + auto saveHandler = [this]() { + saveCurrentProject(); + }; + auto openProjectHandler = [this]() { + hasCurrentProject_ = false; + currentProject_ = {}; + showProjectNavigator(); + }; + + if (hasSavedScenarioState) { + setCentralWidget(new ScenarioAuthoringWidget( + currentProject_.name, + *importResult.layout, + std::move(initialState), + saveHandler, + openProjectHandler, + this)); + return; + } + setCentralWidget(new ScenarioAuthoringWidget( currentProject_.name, *importResult.layout, - [this]() { - saveCurrentProject(); - }, - [this]() { - hasCurrentProject_ = false; - currentProject_ = {}; - showProjectNavigator(); - }, + saveHandler, + openProjectHandler, this)); } diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 3ebdb2d..1c0ab41 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1,6 +1,7 @@ #include "application/ProjectPersistence.h" #include +#include #include #include @@ -19,6 +20,7 @@ namespace { constexpr auto kProjectFileName = "safecrowd-project.json"; constexpr auto kLayoutFileName = "layout.dxf"; constexpr auto kReviewFileName = "layout-review.json"; +constexpr auto kScenarioAuthoringFileName = "scenario-authoring.json"; QString projectFilePath(const QString& folderPath) { return QDir(folderPath).filePath(kProjectFileName); @@ -28,6 +30,10 @@ QString reviewFilePath(const QString& folderPath) { return QDir(folderPath).filePath(kReviewFileName); } +QString scenarioAuthoringFilePath(const QString& folderPath) { + return QDir(folderPath).filePath(kScenarioAuthoringFileName); +} + QString recentProjectsPath() { const auto appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(appData); @@ -215,6 +221,14 @@ std::vector stringVectorFromJson(const QJsonArray& array) { return values; } +QJsonArray placementAreaToJson(const std::vector& area) { + return ringToJson(area); +} + +std::vector placementAreaFromJson(const QJsonArray& array) { + return ringFromJson(array); +} + QJsonObject provenanceToJson(const safecrowd::domain::ElementProvenance& provenance) { QJsonObject object; object["sourceIds"] = stringArray(provenance.sourceIds); @@ -413,6 +427,155 @@ void updateLiveValidationIssues(safecrowd::domain::ImportResult* importResult) { importResult->issues = std::move(issues); } +QJsonObject executionToJson(const safecrowd::domain::ExecutionConfig& execution) { + QJsonObject object; + object["timeLimitSeconds"] = execution.timeLimitSeconds; + object["sampleIntervalSeconds"] = execution.sampleIntervalSeconds; + object["repeatCount"] = static_cast(execution.repeatCount); + object["baseSeed"] = static_cast(execution.baseSeed); + object["recordOccupantHistory"] = execution.recordOccupantHistory; + return object; +} + +safecrowd::domain::ExecutionConfig executionFromJson(const QJsonObject& object) { + return { + .timeLimitSeconds = object.value("timeLimitSeconds").toDouble(), + .sampleIntervalSeconds = object.value("sampleIntervalSeconds").toDouble(), + .repeatCount = static_cast(object.value("repeatCount").toInt(1)), + .baseSeed = static_cast(object.value("baseSeed").toInt()), + .recordOccupantHistory = object.value("recordOccupantHistory").toBool(false), + }; +} + +QJsonObject eventToJson(const safecrowd::domain::OperationalEventDraft& event) { + QJsonObject object; + object["id"] = QString::fromStdString(event.id); + object["name"] = QString::fromStdString(event.name); + object["triggerSummary"] = QString::fromStdString(event.triggerSummary); + object["targetSummary"] = QString::fromStdString(event.targetSummary); + return object; +} + +safecrowd::domain::OperationalEventDraft eventFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString().toStdString(), + .name = object.value("name").toString().toStdString(), + .triggerSummary = object.value("triggerSummary").toString().toStdString(), + .targetSummary = object.value("targetSummary").toString().toStdString(), + }; +} + +QJsonArray eventsToJson(const std::vector& events) { + QJsonArray array; + for (const auto& event : events) { + array.append(eventToJson(event)); + } + return array; +} + +std::vector eventsFromJson(const QJsonArray& array) { + std::vector events; + events.reserve(array.size()); + for (const auto& value : array) { + events.push_back(eventFromJson(value.toObject())); + } + return events; +} + +QJsonObject placementToJson(const ScenarioCrowdPlacement& placement) { + QJsonObject object; + object["id"] = placement.id; + object["name"] = placement.name; + object["kind"] = static_cast(placement.kind); + object["zoneId"] = placement.zoneId; + object["area"] = placementAreaToJson(placement.area); + object["occupantCount"] = placement.occupantCount; + object["velocity"] = pointArray(placement.velocity); + return object; +} + +ScenarioCrowdPlacement placementFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString(), + .name = object.value("name").toString(), + .kind = static_cast(object.value("kind").toInt()), + .zoneId = object.value("zoneId").toString(), + .area = placementAreaFromJson(object.value("area").toArray()), + .occupantCount = object.value("occupantCount").toInt(1), + .velocity = pointFromJson(object.value("velocity")), + }; +} + +QJsonArray placementsToJson(const std::vector& placements) { + QJsonArray array; + for (const auto& placement : placements) { + array.append(placementToJson(placement)); + } + return array; +} + +std::vector placementsFromJson(const QJsonArray& array) { + std::vector placements; + placements.reserve(array.size()); + for (const auto& value : array) { + placements.push_back(placementFromJson(value.toObject())); + } + return placements; +} + +void syncDraftFromScenarioState(ScenarioAuthoringWidget::ScenarioState* scenario) { + if (scenario == nullptr) { + return; + } + + scenario->draft.control.events = scenario->events; + scenario->draft.population.initialPlacements.clear(); + for (const auto& placement : scenario->crowdPlacements) { + safecrowd::domain::InitialPlacement2D initialPlacement; + initialPlacement.id = placement.id.toStdString(); + initialPlacement.zoneId = placement.zoneId.toStdString(); + initialPlacement.area.outline = placement.area; + initialPlacement.targetAgentCount = static_cast(placement.occupantCount); + initialPlacement.initialVelocity = placement.velocity; + scenario->draft.population.initialPlacements.push_back(std::move(initialPlacement)); + } +} + +QJsonObject scenarioStateToJson(const ScenarioAuthoringWidget::ScenarioState& scenario) { + QJsonObject object; + object["scenarioId"] = QString::fromStdString(scenario.draft.scenarioId); + object["name"] = QString::fromStdString(scenario.draft.name); + object["role"] = static_cast(scenario.draft.role); + object["sourceTemplateId"] = QString::fromStdString(scenario.draft.sourceTemplateId); + object["variationDiffKeys"] = stringArray(scenario.draft.variationDiffKeys); + object["execution"] = executionToJson(scenario.draft.execution); + object["events"] = eventsToJson(scenario.events); + object["placements"] = placementsToJson(scenario.crowdPlacements); + object["startText"] = scenario.startText; + object["destinationText"] = scenario.destinationText; + object["baseScenarioId"] = scenario.baseScenarioId; + object["stagedForRun"] = scenario.stagedForRun; + return object; +} + +ScenarioAuthoringWidget::ScenarioState scenarioStateFromJson(const QJsonObject& object) { + ScenarioAuthoringWidget::ScenarioState scenario; + scenario.draft.scenarioId = object.value("scenarioId").toString().toStdString(); + scenario.draft.name = object.value("name").toString().toStdString(); + scenario.draft.role = static_cast(object.value("role").toInt()); + scenario.draft.sourceTemplateId = object.value("sourceTemplateId").toString().toStdString(); + scenario.draft.variationDiffKeys = stringVectorFromJson(object.value("variationDiffKeys").toArray()); + scenario.draft.execution = executionFromJson(object.value("execution").toObject()); + scenario.events = eventsFromJson(object.value("events").toArray()); + scenario.crowdPlacements = placementsFromJson(object.value("placements").toArray()); + scenario.startText = object.value("startText").toString(); + scenario.destinationText = object.value("destinationText").toString(); + scenario.baseScenarioId = object.value("baseScenarioId").toString(); + scenario.stagedForRun = object.value("stagedForRun").toBool(false); + syncDraftFromScenarioState(&scenario); + return scenario; +} + } // namespace QList ProjectPersistence::loadRecentProjects() { @@ -534,4 +697,62 @@ bool ProjectPersistence::saveProjectReview( return writeJsonDocument(reviewFilePath(metadata.folderPath), QJsonDocument(root), errorMessage); } +bool ProjectPersistence::loadScenarioAuthoringState( + const ProjectMetadata& metadata, + ScenarioAuthoringWidget::InitialState* state) { + if (metadata.isBuiltInDemo() || state == nullptr) { + return false; + } + + const auto document = readJsonDocument(scenarioAuthoringFilePath(metadata.folderPath)); + if (!document.isObject()) { + return false; + } + + const auto root = document.object(); + ScenarioAuthoringWidget::InitialState loaded; + loaded.currentScenarioIndex = root.value("currentScenarioIndex").toInt(-1); + loaded.navigationView = static_cast( + root.value("navigationView").toInt(static_cast(ScenarioAuthoringWidget::NavigationView::Layout))); + loaded.rightPanelMode = static_cast( + root.value("rightPanelMode").toInt(static_cast(ScenarioAuthoringWidget::RightPanelMode::Scenario))); + + for (const auto& value : root.value("scenarios").toArray()) { + loaded.scenarios.push_back(scenarioStateFromJson(value.toObject())); + } + + if (loaded.scenarios.empty()) { + loaded.currentScenarioIndex = -1; + } else if (loaded.currentScenarioIndex < 0 || loaded.currentScenarioIndex >= static_cast(loaded.scenarios.size())) { + loaded.currentScenarioIndex = 0; + } + + *state = std::move(loaded); + return true; +} + +bool ProjectPersistence::saveScenarioAuthoringState( + const ProjectMetadata& metadata, + const ScenarioAuthoringWidget::InitialState& state, + QString* errorMessage) { + if (metadata.isBuiltInDemo()) { + if (errorMessage != nullptr) { + *errorMessage = "Built-in demo projects do not need to be saved."; + } + return false; + } + + QJsonArray scenarios; + for (const auto& scenario : state.scenarios) { + scenarios.append(scenarioStateToJson(scenario)); + } + + QJsonObject root; + root["currentScenarioIndex"] = state.currentScenarioIndex; + root["navigationView"] = static_cast(state.navigationView); + root["rightPanelMode"] = static_cast(state.rightPanelMode); + root["scenarios"] = scenarios; + return writeJsonDocument(scenarioAuthoringFilePath(metadata.folderPath), QJsonDocument(root), errorMessage); +} + } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.h b/src/application/ProjectPersistence.h index a794cee..aff1a93 100644 --- a/src/application/ProjectPersistence.h +++ b/src/application/ProjectPersistence.h @@ -3,6 +3,7 @@ #include #include "application/ProjectMetadata.h" +#include "application/ScenarioAuthoringWidget.h" #include "domain/ImportResult.h" namespace safecrowd::application { @@ -17,6 +18,13 @@ class ProjectPersistence { const ProjectMetadata& metadata, const safecrowd::domain::ImportResult& importResult, QString* errorMessage = nullptr); + static bool loadScenarioAuthoringState( + const ProjectMetadata& metadata, + ScenarioAuthoringWidget::InitialState* state); + static bool saveScenarioAuthoringState( + const ProjectMetadata& metadata, + const ScenarioAuthoringWidget::InitialState& state, + QString* errorMessage = nullptr); }; } // namespace safecrowd::application diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index c9c5f70..98b87a9 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -56,6 +56,36 @@ const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::F return layout.zones.empty() ? nullptr : &layout.zones.back(); } +struct EventPreset { + QString name{}; + QString triggerSummary{}; + QString targetSummary{}; +}; + +const std::vector& sprint1EventPresets() { + static const std::vector presets{ + { + .name = "Exit Closure", + .triggerSummary = "Trigger: operator command during run setup", + .targetSummary = "Target: primary exit route is marked closed for review", + }, + { + .name = "Staged Release", + .triggerSummary = "Trigger: release group after initial evacuation wave", + .targetSummary = "Target: queued occupants enter from the selected start area", + }, + }; + return presets; +} + +QString readinessListText(const QString& readyText, const QStringList& missingItems) { + if (missingItems.isEmpty()) { + return readyText; + } + + return QString("Missing before ready:\n- %1").arg(missingItems.join("\n- ")); +} + QIcon makeCrowdIcon(const QColor& color) { QPixmap pixmap(44, 44); pixmap.fill(Qt::transparent); @@ -182,6 +212,7 @@ QWidget* createCrowdPanel( QWidget* createEventsPanel( const ScenarioAuthoringWidget::ScenarioState* scenario, + std::function addEventHandler, QWidget* parent) { auto* content = new QWidget(parent); auto* layout = new QVBoxLayout(content); @@ -189,6 +220,41 @@ QWidget* createEventsPanel( layout->setSpacing(12); layout->addWidget(createLabel("Events", content, ui::FontRole::Title)); + auto* libraryHeader = createLabel("Event Library", content, ui::FontRole::SectionTitle); + libraryHeader->setStyleSheet(ui::subtleTextStyleSheet()); + layout->addWidget(libraryHeader); + + const auto addPresetButton = [&](const EventPreset& preset) { + const auto name = preset.name; + const auto triggerSummary = preset.triggerSummary; + const auto targetSummary = preset.targetSummary; + auto* button = new QPushButton( + QString("%1\n%2\n%3") + .arg(name, triggerSummary, targetSummary), + content); + button->setFont(ui::font(ui::FontRole::Body)); + button->setMinimumHeight(78); + button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + button->setStyleSheet(ui::secondaryButtonStyleSheet()); + button->setEnabled(scenario != nullptr); + button->setToolTip(QString("Add another %1 event").arg(name)); + auto handler = addEventHandler; + QObject::connect(button, &QPushButton::clicked, content, [=]() { + if (handler) { + handler(name, triggerSummary, targetSummary); + } + }); + layout->addWidget(button); + }; + + for (const auto& preset : sprint1EventPresets()) { + addPresetButton(preset); + } + + auto* configuredHeader = createLabel("Configured Events", content, ui::FontRole::SectionTitle); + configuredHeader->setStyleSheet(ui::subtleTextStyleSheet()); + layout->addWidget(configuredHeader); + if (scenario == nullptr || scenario->events.empty()) { auto* empty = createLabel("No operational events yet", content); empty->setStyleSheet(ui::mutedTextStyleSheet()); @@ -199,10 +265,15 @@ QWidget* createEventsPanel( for (const auto& event : scenario->events) { auto* row = new QPushButton( - QString("%1\n%2") - .arg(QString::fromStdString(event.name), QString::fromStdString(event.targetSummary)), + QString("%1\n%2\n%3") + .arg( + QString::fromStdString(event.name), + QString::fromStdString(event.triggerSummary), + QString::fromStdString(event.targetSummary)), content); row->setFont(ui::font(ui::FontRole::Body)); + row->setMinimumHeight(72); + row->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); row->setStyleSheet(ui::ghostRowStyleSheet()); layout->addWidget(row); } @@ -275,16 +346,16 @@ void ScenarioAuthoringWidget::addEventDraft(const QString& name, const QString& return; } - const auto exists = std::any_of(scenario->events.begin(), scenario->events.end(), [&](const auto& event) { - return QString::fromStdString(event.name) == name; - }); - if (exists) { - return; + auto eventName = name; + for (int suffix = 2; std::any_of(scenario->events.begin(), scenario->events.end(), [&](const auto& event) { + return QString::fromStdString(event.name) == eventName; + }); ++suffix) { + eventName = QString("%1 %2").arg(name).arg(suffix); } scenario->events.push_back({ .id = QString("event-%1").arg(static_cast(scenario->events.size()) + 1).toStdString(), - .name = name.toStdString(), + .name = eventName.toStdString(), .triggerSummary = trigger.toStdString(), .targetSummary = target.toStdString(), }); @@ -379,13 +450,14 @@ void ScenarioAuthoringWidget::refreshInspector() { for (const auto& placement : scenario->crowdPlacements) { people += placement.occupantCount; } - scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6") + scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6\nStaged: %7") .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(static_cast(scenario->events.size())) + .arg(scenario->stagedForRun ? "Yes" : "No")); } } @@ -408,9 +480,23 @@ void ScenarioAuthoringWidget::refreshInspector() { if (newScenarioButton_ != nullptr) { newScenarioButton_->setText(hasScenario ? "New Scenario from Current" : "New Scenario"); } + + const auto readiness = readinessStatus(); + if (readinessLabel_ != nullptr) { + if (rightPanelMode_ == RightPanelMode::Run) { + readinessLabel_->setText(readinessListText("Ready to run the staged baseline scenario.", readiness.missingRunItems)); + } else { + readinessLabel_->setText(readinessListText("Ready to stage this scenario.", readiness.missingStageItems)); + } + } if (stageScenarioButton_ != nullptr) { - stageScenarioButton_->setEnabled(hasScenario); + stageScenarioButton_->setEnabled(readiness.missingStageItems.isEmpty()); stageScenarioButton_->setText(hasScenario && scenario->stagedForRun ? "Staged for Run" : "Stage Scenario"); + stageScenarioButton_->setToolTip(readinessListText("This scenario can be staged for run.", readiness.missingStageItems)); + } + if (executeRunButton_ != nullptr) { + executeRunButton_->setEnabled(readiness.missingRunItems.isEmpty()); + executeRunButton_->setToolTip(readinessListText("Run the staged baseline scenario.", readiness.missingRunItems)); } } @@ -435,13 +521,19 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { shell_->setNavigationPanel(createCrowdPanel(currentScenario(), shell_)); return; } - shell_->setNavigationPanel(createEventsPanel(currentScenario(), shell_)); + shell_->setNavigationPanel(createEventsPanel( + currentScenario(), + [this](const QString& name, const QString& trigger, const QString& target) { + addEventDraft(name, trigger, target); + }, + shell_)); } void ScenarioAuthoringWidget::refreshRightPanel() { scenarioSwitcher_ = nullptr; scenarioSummaryLabel_ = nullptr; changesLabel_ = nullptr; + readinessLabel_ = nullptr; newScenarioButton_ = nullptr; stageScenarioButton_ = nullptr; stagedScenariosLabel_ = nullptr; @@ -479,6 +571,14 @@ void ScenarioAuthoringWidget::refreshScenarioSwitcher() { } void ScenarioAuthoringWidget::runFirstStagedBaselineScenario() { + const auto readiness = readinessStatus(); + if (!readiness.missingRunItems.isEmpty()) { + if (readinessLabel_ != nullptr) { + readinessLabel_->setText(readinessListText("Ready to run the staged baseline scenario.", readiness.missingRunItems)); + } + return; + } + const auto* scenario = firstStagedBaselineScenario(); if (scenario == nullptr) { if (stagedScenariosLabel_ != nullptr) { @@ -499,6 +599,9 @@ void ScenarioAuthoringWidget::runFirstStagedBaselineScenario() { scenario->draft, saveProjectHandler_, openProjectHandler_, + [this](bool showRunPanel) { + returnFromRun(showRunPanel); + }, this); rootLayout->replaceWidget(shell_, runWidget); shell_->hide(); @@ -517,7 +620,42 @@ void ScenarioAuthoringWidget::setRightPanelMode(RightPanelMode mode) { refreshRightPanel(); } +void ScenarioAuthoringWidget::returnFromRun(bool showRunPanel) { + auto* rootLayout = qobject_cast(layout()); + if (rootLayout == nullptr) { + return; + } + + while (auto* item = rootLayout->takeAt(0)) { + if (auto* widget = item->widget()) { + widget->hide(); + widget->deleteLater(); + } + delete item; + } + + rightPanelMode_ = showRunPanel ? RightPanelMode::Run : RightPanelMode::Scenario; + shell_ = new WorkspaceShell(this); + shell_->setTools({"Project"}); + shell_->setSaveProjectHandler(saveProjectHandler_); + shell_->setOpenProjectHandler(openProjectHandler_); + shell_->setTopBarTrailingWidget(createTopBarTogglePanel()); + refreshRightPanel(); + rootLayout->addWidget(shell_); + refreshNavigationPanel(); + refreshCanvas(); + refreshInspector(); +} + void ScenarioAuthoringWidget::stageCurrentScenario() { + const auto readiness = readinessStatus(); + if (!readiness.missingStageItems.isEmpty()) { + if (readinessLabel_ != nullptr) { + readinessLabel_->setText(readinessListText("Ready to stage this scenario.", readiness.missingStageItems)); + } + return; + } + auto* scenario = currentScenario(); if (scenario == nullptr) { return; @@ -628,21 +766,27 @@ QWidget* ScenarioAuthoringWidget::createRunPanel() { continue; } const auto role = scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - lines << QString("- %1 (%2)").arg(QString::fromStdString(scenario.draft.name), role); + lines << QString("- %1 (%2), Events: %3") + .arg(QString::fromStdString(scenario.draft.name), role) + .arg(static_cast(scenario.events.size())); } } stagedScenariosLabel_->setText(lines.join('\n')); layout->addWidget(stagedScenariosLabel_); + + readinessLabel_ = createLabel("", panel); + readinessLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + layout->addWidget(readinessLabel_); layout->addStretch(1); executeRunButton_ = new QPushButton("Run Staged Scenarios", panel); executeRunButton_->setFont(ui::font(ui::FontRole::Body)); executeRunButton_->setStyleSheet(ui::primaryButtonStyleSheet()); - executeRunButton_->setEnabled(stagedCount > 0); layout->addWidget(executeRunButton_); connect(executeRunButton_, &QPushButton::clicked, this, [this]() { runFirstStagedBaselineScenario(); }); + refreshInspector(); return panel; } @@ -672,6 +816,10 @@ QWidget* ScenarioAuthoringWidget::createScenarioPanel() { changesLabel_ = createLabel("", inspector); changesLabel_->setStyleSheet(ui::mutedTextStyleSheet()); inspectorLayout->addWidget(changesLabel_); + + readinessLabel_ = createLabel("", inspector); + readinessLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + inspectorLayout->addWidget(readinessLabel_); inspectorLayout->addStretch(1); stageScenarioButton_ = new QPushButton("Stage Scenario", inspector); @@ -732,6 +880,40 @@ QWidget* ScenarioAuthoringWidget::createTopBarTogglePanel() { return panel; } +ScenarioAuthoringWidget::ReadinessStatus ScenarioAuthoringWidget::readinessStatus() const { + ReadinessStatus status; + const auto* scenario = currentScenario(); + const auto* stagedBaseline = firstStagedBaselineScenario(); + status.hasCurrentScenario = scenario != nullptr; + status.hasCurrentPopulation = scenario != nullptr && !scenario->draft.population.initialPlacements.empty(); + status.hasDestinationZone = firstDestinationZone(layout_) != nullptr; + status.hasStagedBaselineScenario = stagedBaseline != nullptr; + status.hasRunnableBaselinePopulation = stagedBaseline != nullptr + && !stagedBaseline->draft.population.initialPlacements.empty(); + + if (!status.hasCurrentScenario) { + status.missingStageItems << "Create or select a scenario."; + } + if (!status.hasCurrentPopulation) { + status.missingStageItems << "Add at least one population placement on the Crowd canvas."; + } + if (!status.hasDestinationZone) { + status.missingStageItems << "Approve a layout with an Exit zone or another destination zone."; + } + + if (!status.hasStagedBaselineScenario) { + status.missingRunItems << "Stage a baseline scenario for run."; + } + if (status.hasStagedBaselineScenario && !status.hasRunnableBaselinePopulation) { + status.missingRunItems << "Add at least one population placement to the staged baseline scenario."; + } + if (!status.hasDestinationZone) { + status.missingRunItems << "Approve a layout with an Exit zone or another destination zone."; + } + + return status; +} + ScenarioAuthoringWidget::ScenarioState* ScenarioAuthoringWidget::currentScenario() { if (currentScenarioIndex_ < 0 || currentScenarioIndex_ >= static_cast(scenarios_.size())) { return nullptr; @@ -753,4 +935,13 @@ const ScenarioAuthoringWidget::ScenarioState* ScenarioAuthoringWidget::firstStag return it == scenarios_.end() ? nullptr : &(*it); } +ScenarioAuthoringWidget::InitialState ScenarioAuthoringWidget::currentState() const { + return { + .scenarios = scenarios_, + .currentScenarioIndex = currentScenarioIndex_, + .navigationView = navigationView_, + .rightPanelMode = rightPanelMode_, + }; +} + } // namespace safecrowd::application diff --git a/src/application/ScenarioAuthoringWidget.h b/src/application/ScenarioAuthoringWidget.h index 4f8387b..427d422 100644 --- a/src/application/ScenarioAuthoringWidget.h +++ b/src/application/ScenarioAuthoringWidget.h @@ -4,6 +4,7 @@ #include #include +#include #include #include "application/ScenarioCanvasWidget.h" @@ -64,6 +65,8 @@ class ScenarioAuthoringWidget : public QWidget { std::function openProjectHandler, QWidget* parent = nullptr); + [[nodiscard]] InitialState currentState() const; + private: void initializeUi(bool promptForScenario); void addEventDraft(const QString& name, const QString& trigger, const QString& target); @@ -75,6 +78,7 @@ class ScenarioAuthoringWidget : public QWidget { void refreshNavigationPanel(); void refreshRightPanel(); void refreshScenarioSwitcher(); + void returnFromRun(bool showRunPanel); void runFirstStagedBaselineScenario(); void setRightPanelMode(RightPanelMode mode); void stageCurrentScenario(); @@ -84,6 +88,16 @@ class ScenarioAuthoringWidget : public QWidget { QWidget* createRunPanel(); QWidget* createScenarioPanel(); QWidget* createTopBarTogglePanel(); + struct ReadinessStatus { + bool hasCurrentScenario{false}; + bool hasCurrentPopulation{false}; + bool hasDestinationZone{false}; + bool hasStagedBaselineScenario{false}; + bool hasRunnableBaselinePopulation{false}; + QStringList missingStageItems{}; + QStringList missingRunItems{}; + }; + [[nodiscard]] ReadinessStatus readinessStatus() const; ScenarioState* currentScenario(); const ScenarioState* currentScenario() const; const ScenarioState* firstStagedBaselineScenario() const; @@ -103,6 +117,7 @@ class ScenarioAuthoringWidget : public QWidget { QComboBox* scenarioSwitcher_{nullptr}; QLabel* scenarioSummaryLabel_{nullptr}; QLabel* changesLabel_{nullptr}; + QLabel* readinessLabel_{nullptr}; QLabel* stagedScenariosLabel_{nullptr}; QPushButton* newScenarioButton_{nullptr}; QPushButton* stageScenarioButton_{nullptr}; diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index 2921e28..77e6b6e 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -12,7 +12,6 @@ #include #include -#include "application/ScenarioAuthoringWidget.h" #include "application/ScenarioCanvasWidget.h" #include "application/SimulationCanvasWidget.h" #include "application/UiStyle.h" @@ -43,6 +42,17 @@ QFrame* createMetricCard(const QString& title, const QString& value, QWidget* pa return card; } +bool allAgentsEvacuated(const safecrowd::domain::SimulationFrame& frame) { + return frame.totalAgentCount > 0 && frame.evacuatedAgentCount >= frame.totalAgentCount; +} + +QString completionOutcome(const safecrowd::domain::SimulationFrame& frame) { + if (!frame.complete) { + return "Stopped before completion"; + } + return allAgentsEvacuated(frame) ? "Evacuation complete" : "Time limit reached"; +} + QString bottleneckSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { if (risk.bottlenecks.empty()) { return "None"; @@ -54,61 +64,6 @@ QString bottleneckSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { .arg(static_cast(bottleneck.stalledAgentCount)); } -QString zoneLabel(const safecrowd::domain::Zone2D& zone) { - const auto id = QString::fromStdString(zone.id); - const auto label = QString::fromStdString(zone.label); - return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); -} - -const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { - const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { - return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; - }); - return it == layout.zones.end() ? nullptr : &(*it); -} - -const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::FacilityLayout2D& layout) { - const auto exitIt = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { - return zone.kind == safecrowd::domain::ZoneKind::Exit; - }); - if (exitIt != layout.zones.end()) { - return &(*exitIt); - } - return layout.zones.empty() ? nullptr : &layout.zones.back(); -} - -ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( - const safecrowd::domain::ScenarioDraft& scenario, - const safecrowd::domain::FacilityLayout2D& layout) { - ScenarioAuthoringWidget::ScenarioState state; - state.draft = scenario; - state.events = scenario.control.events; - state.stagedForRun = true; - - if (const auto* startZone = firstStartZone(layout); startZone != nullptr) { - state.startText = zoneLabel(*startZone); - } - if (const auto* destinationZone = firstDestinationZone(layout); destinationZone != nullptr) { - state.destinationText = zoneLabel(*destinationZone); - } - - for (const auto& placement : scenario.population.initialPlacements) { - ScenarioCrowdPlacement uiPlacement; - uiPlacement.id = QString::fromStdString(placement.id); - uiPlacement.name = uiPlacement.id; - uiPlacement.kind = (placement.targetAgentCount <= 1 && placement.area.outline.size() <= 1) - ? ScenarioCrowdPlacementKind::Individual - : ScenarioCrowdPlacementKind::Group; - uiPlacement.zoneId = QString::fromStdString(placement.zoneId); - uiPlacement.area = placement.area.outline; - uiPlacement.occupantCount = static_cast(placement.targetAgentCount); - uiPlacement.velocity = placement.initialVelocity; - state.crowdPlacements.push_back(std::move(uiPlacement)); - } - - return state; -} - QWidget* createResultPanel( const safecrowd::domain::ScenarioDraft& scenario, const safecrowd::domain::SimulationFrame& frame, @@ -125,16 +80,22 @@ QWidget* createResultPanel( auto* scenarioLabel = createLabel(QString("Scenario: %1").arg(QString::fromStdString(scenario.name)), panel); scenarioLabel->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(scenarioLabel); + auto* outcomeLabel = createLabel(QString("Outcome: %1").arg(completionOutcome(frame)), panel); + outcomeLabel->setStyleSheet(ui::mutedTextStyleSheet()); + layout->addWidget(outcomeLabel); auto* metricsGrid = new QGridLayout(); metricsGrid->setContentsMargins(0, 0, 0, 0); metricsGrid->setSpacing(8); const auto total = static_cast(frame.totalAgentCount); const auto evacuated = static_cast(frame.evacuatedAgentCount); - metricsGrid->addWidget(createMetricCard("Evacuated", QString("%1 / %2").arg(evacuated).arg(total), panel), 0, 0); - metricsGrid->addWidget(createMetricCard("Time", QString("%1 sec").arg(frame.elapsedSeconds, 0, 'f', 1), panel), 0, 1); - metricsGrid->addWidget(createMetricCard("Risk", safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), panel), 1, 0); - metricsGrid->addWidget(createMetricCard("Stalled", QString::number(static_cast(risk.stalledAgentCount)), panel), 1, 1); + const auto active = static_cast(frame.agents.size()); + metricsGrid->addWidget(createMetricCard("Total", QString::number(total), panel), 0, 0); + metricsGrid->addWidget(createMetricCard("Evacuated", QString("%1 / %2").arg(evacuated).arg(total), panel), 0, 1); + metricsGrid->addWidget(createMetricCard("Elapsed", QString("%1 sec").arg(frame.elapsedSeconds, 0, 'f', 1), panel), 1, 0); + metricsGrid->addWidget(createMetricCard("Active", QString::number(active), panel), 1, 1); + metricsGrid->addWidget(createMetricCard("Completion Risk", safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), panel), 2, 0); + metricsGrid->addWidget(createMetricCard("Stalled", QString::number(static_cast(risk.stalledAgentCount)), panel), 2, 1); layout->addLayout(metricsGrid); auto* detailArea = new QScrollArea(panel); @@ -215,6 +176,7 @@ ScenarioResultWidget::ScenarioResultWidget( safecrowd::domain::ScenarioRiskSnapshot risk, std::function saveProjectHandler, std::function openProjectHandler, + std::function returnToAuthoringHandler, QWidget* parent) : QWidget(parent), projectName_(std::move(projectName)), @@ -223,7 +185,8 @@ ScenarioResultWidget::ScenarioResultWidget( frame_(std::move(frame)), risk_(std::move(risk)), saveProjectHandler_(std::move(saveProjectHandler)), - openProjectHandler_(std::move(openProjectHandler)) { + openProjectHandler_(std::move(openProjectHandler)), + returnToAuthoringHandler_(std::move(returnToAuthoringHandler)) { auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -259,31 +222,10 @@ ScenarioResultWidget::ScenarioResultWidget( } void ScenarioResultWidget::navigateToAuthoring(bool showRunPanel) { - auto* rootLayout = qobject_cast(layout()); - if (rootLayout == nullptr || shell_ == nullptr) { + if (!returnToAuthoringHandler_) { return; } - - ScenarioAuthoringWidget::InitialState initial; - initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); - initial.currentScenarioIndex = 0; - initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; - initial.rightPanelMode = showRunPanel - ? ScenarioAuthoringWidget::RightPanelMode::Run - : ScenarioAuthoringWidget::RightPanelMode::Scenario; - - auto* authoringWidget = new ScenarioAuthoringWidget( - projectName_, - layout_, - std::move(initial), - saveProjectHandler_, - openProjectHandler_, - this); - - rootLayout->replaceWidget(shell_, authoringWidget); - shell_->hide(); - shell_->deleteLater(); - shell_ = nullptr; + returnToAuthoringHandler_(showRunPanel); } } // namespace safecrowd::application diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index b7f0d95..bc11464 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -24,6 +24,7 @@ class ScenarioResultWidget : public QWidget { safecrowd::domain::ScenarioRiskSnapshot risk, std::function saveProjectHandler, std::function openProjectHandler, + std::function returnToAuthoringHandler, QWidget* parent = nullptr); private: @@ -36,6 +37,7 @@ class ScenarioResultWidget : public QWidget { safecrowd::domain::ScenarioRiskSnapshot risk_{}; std::function saveProjectHandler_{}; std::function openProjectHandler_{}; + std::function returnToAuthoringHandler_{}; WorkspaceShell* shell_{nullptr}; }; diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index b7d5aa4..37ff9b6 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -19,6 +19,40 @@ namespace { constexpr double kSimulationDeltaSeconds = 1.0 / 30.0; +bool allAgentsEvacuated(const safecrowd::domain::SimulationFrame& frame) { + return frame.totalAgentCount > 0 && frame.evacuatedAgentCount >= frame.totalAgentCount; +} + +QString completionOutcome(const safecrowd::domain::SimulationFrame& frame) { + if (!frame.complete) { + return "In progress"; + } + return allAgentsEvacuated(frame) ? "Evacuation complete" : "Time limit reached"; +} + +QString runStatusText(const safecrowd::domain::SimulationFrame& frame, bool paused) { + if (frame.complete) { + return completionOutcome(frame); + } + if (paused && frame.elapsedSeconds <= 0.0 && frame.evacuatedAgentCount == 0) { + return "Reset to start"; + } + return paused ? "Paused" : "Running"; +} + +QString hotspotSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { + if (risk.hotspots.empty()) { + return "Hotspots: 0"; + } + + const auto& hotspot = risk.hotspots.front(); + return QString("Hotspots: %1\nWorst: %2 agents at (%3, %4)") + .arg(static_cast(risk.hotspots.size())) + .arg(static_cast(hotspot.agentCount)) + .arg(hotspot.center.x, 0, 'f', 1) + .arg(hotspot.center.y, 0, 'f', 1); +} + QLabel* createLabel(const QString& text, QWidget* parent, ui::FontRole role = ui::FontRole::Body) { auto* label = new QLabel(text, parent); label->setFont(ui::font(role)); @@ -44,6 +78,7 @@ ScenarioRunWidget::ScenarioRunWidget( const safecrowd::domain::ScenarioDraft& scenario, std::function saveProjectHandler, std::function openProjectHandler, + std::function returnToAuthoringHandler, QWidget* parent) : QWidget(parent), projectName_(projectName), @@ -51,7 +86,8 @@ ScenarioRunWidget::ScenarioRunWidget( scenario_(scenario), runner_(layout_, scenario_), saveProjectHandler_(std::move(saveProjectHandler)), - openProjectHandler_(std::move(openProjectHandler)) { + openProjectHandler_(std::move(openProjectHandler)), + returnToAuthoringHandler_(std::move(returnToAuthoringHandler)) { auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -117,7 +153,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { transportLayout->setContentsMargins(0, 0, 0, 0); transportLayout->setSpacing(8); pauseButton_ = createIconButton(QStyle::SP_MediaPause, "Pause simulation", panel); - stopButton_ = createIconButton(QStyle::SP_MediaStop, "Stop and reset run", panel); + stopButton_ = createIconButton(QStyle::SP_MediaStop, "Reset run to start; no result is created", panel); transportLayout->addWidget(pauseButton_); transportLayout->addWidget(stopButton_); transportLayout->addStretch(1); @@ -129,6 +165,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { resultButton_->setFont(ui::font(ui::FontRole::Body)); resultButton_->setStyleSheet(ui::primaryButtonStyleSheet()); resultButton_->setEnabled(false); + resultButton_->setToolTip("Available after evacuation completes or the time limit is reached"); layout->addWidget(resultButton_); connect(pauseButton_, &QPushButton::clicked, this, [this]() { @@ -150,7 +187,7 @@ void ScenarioRunWidget::refreshStatus() { scenarioLabel_->setText(QString("Scenario: %1").arg(QString::fromStdString(scenario_.name))); } if (statusLabel_ != nullptr) { - statusLabel_->setText(QString("Status: %1").arg(frame.complete ? "Complete" : paused_ ? "Paused" : "Running")); + statusLabel_->setText(QString("Status: %1").arg(runStatusText(frame, paused_))); } if (elapsedLabel_ != nullptr) { elapsedLabel_->setText(QString("Elapsed: %1 / %2 sec") @@ -170,10 +207,7 @@ void ScenarioRunWidget::refreshStatus() { .arg(static_cast(risk.stalledAgentCount))); } if (congestionLabel_ != nullptr) { - const auto hotspotCount = risk.hotspots.empty() ? 0 : static_cast(risk.hotspots.front().agentCount); - congestionLabel_->setText(QString("Hotspots: %1%2") - .arg(static_cast(risk.hotspots.size())) - .arg(risk.hotspots.empty() ? QString{} : QString(" (max %1 agents)").arg(hotspotCount))); + congestionLabel_->setText(hotspotSummary(risk)); } if (bottleneckLabel_ != nullptr) { if (risk.bottlenecks.empty()) { @@ -193,12 +227,15 @@ void ScenarioRunWidget::refreshStatus() { pauseButton_->setEnabled(!frame.complete); } if (stopButton_ != nullptr) { + stopButton_->setToolTip("Reset run to start; no result is created"); + stopButton_->setAccessibleName("Reset run to start"); stopButton_->setEnabled(frame.totalAgentCount > 0); } if (resultButton_ != nullptr) { - const bool allAgentsEvacuated = frame.totalAgentCount > 0 - && frame.evacuatedAgentCount >= frame.totalAgentCount; - resultButton_->setEnabled(allAgentsEvacuated); + resultButton_->setEnabled(frame.complete); + resultButton_->setToolTip(frame.complete + ? QString("Open results: %1").arg(completionOutcome(frame)) + : "Available after evacuation completes or the time limit is reached"); } } @@ -212,7 +249,7 @@ void ScenarioRunWidget::stopRun() { void ScenarioRunWidget::showResults() { const auto& frame = runner_.frame(); - if (frame.totalAgentCount == 0 || frame.evacuatedAgentCount < frame.totalAgentCount) { + if (!frame.complete) { return; } if (timer_ != nullptr) { @@ -240,6 +277,7 @@ void ScenarioRunWidget::showResults() { openProjectHandler_(); } }, + returnToAuthoringHandler_, this); rootLayout->replaceWidget(shell_, resultWidget); shell_->hide(); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 5f87dd3..7d2b7ea 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -26,6 +26,7 @@ class ScenarioRunWidget : public QWidget { const safecrowd::domain::ScenarioDraft& scenario, std::function saveProjectHandler, std::function openProjectHandler, + std::function returnToAuthoringHandler, QWidget* parent = nullptr); private: @@ -41,6 +42,7 @@ class ScenarioRunWidget : public QWidget { safecrowd::domain::ScenarioSimulationRunner runner_{}; std::function saveProjectHandler_{}; std::function openProjectHandler_{}; + std::function returnToAuthoringHandler_{}; WorkspaceShell* shell_{nullptr}; SimulationCanvasWidget* canvas_{nullptr}; QTimer* timer_{nullptr}; From 7ba7a2015b57683f7310134e637f7c1819ec272f Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Wed, 29 Apr 2026 20:00:38 +0900 Subject: [PATCH 2/5] Polish Sprint 1 demo flow --- src/application/LayoutReviewWidget.cpp | 26 +++++++++++++++++++-- src/application/LayoutReviewWidget.h | 1 + src/application/ScenarioAuthoringWidget.cpp | 16 +++++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/application/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index 19c315e..5687d9a 100644 --- a/src/application/LayoutReviewWidget.cpp +++ b/src/application/LayoutReviewWidget.cpp @@ -297,6 +297,7 @@ QWidget* createReviewPanel( QLabel** inspectorTitle, QLabel** inspectorDetail, QLabel** approvalStatus, + QPushButton** undoButton, QPushButton** approveButton, QWidget* parent) { auto* panel = new QWidget(parent); @@ -327,9 +328,16 @@ QWidget* createReviewPanel( (*approvalStatus)->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(*approvalStatus); + *undoButton = new QPushButton("Undo Last Edit", panel); + (*undoButton)->setFont(ui::font(ui::FontRole::Body)); + (*undoButton)->setStyleSheet(ui::secondaryButtonStyleSheet()); + (*undoButton)->setToolTip("Undo the last layout correction (Ctrl+Z)"); + layout->addWidget(*undoButton); + *approveButton = new QPushButton("Approve Layout", panel); (*approveButton)->setFont(ui::font(ui::FontRole::Body)); (*approveButton)->setStyleSheet(ui::primaryButtonStyleSheet()); + (*approveButton)->setToolTip("Resolve blocking issues before approval"); layout->addWidget(*approveButton); return panel; @@ -365,6 +373,7 @@ LayoutReviewWidget::LayoutReviewWidget( &inspectorTitleLabel_, &inspectorDetailLabel_, &approvalStatusLabel_, + &undoButton_, &approveButton_, shell_); @@ -384,6 +393,9 @@ LayoutReviewWidget::LayoutReviewWidget( approvalHandler_(importResult_); } }); + connect(undoButton_, &QPushButton::clicked, this, [this]() { + undoLastEdit(); + }); auto* undoShortcut = new QShortcut(QKeySequence::Undo, this); connect(undoShortcut, &QShortcut::activated, this, [this]() { @@ -448,10 +460,20 @@ void LayoutReviewWidget::handlePreviewSelectionChanged(const PreviewSelection& s } void LayoutReviewWidget::refreshApprovalState() { - const auto hasBlocking = safecrowd::domain::hasBlockingImportIssue(importResult_.issues); + const auto blockingCount = std::count_if(importResult_.issues.begin(), importResult_.issues.end(), [](const auto& issue) { + return issue.blocksSimulation(); + }); + const auto hasBlocking = blockingCount > 0; if (approveButton_ != nullptr) { approveButton_->setEnabled(!hasBlocking); + approveButton_->setToolTip(hasBlocking + ? QString("Resolve %1 blocking issue(s) before approval").arg(static_cast(blockingCount)) + : QString("Approve layout and continue to Scenario Authoring")); + } + + if (undoButton_ != nullptr) { + undoButton_->setEnabled(!undoHistory_.empty()); } if (approvalStatusLabel_ == nullptr) { @@ -459,7 +481,7 @@ void LayoutReviewWidget::refreshApprovalState() { } if (hasBlocking) { - approvalStatusLabel_->setText("Resolve blocking issues first"); + approvalStatusLabel_->setText(QString("Resolve %1 blocking issue(s) first").arg(static_cast(blockingCount))); return; } diff --git a/src/application/LayoutReviewWidget.h b/src/application/LayoutReviewWidget.h index 865cc64..7a1e38f 100644 --- a/src/application/LayoutReviewWidget.h +++ b/src/application/LayoutReviewWidget.h @@ -57,6 +57,7 @@ class LayoutReviewWidget : public QWidget { QLabel* inspectorTitleLabel_{nullptr}; QLabel* inspectorDetailLabel_{nullptr}; QLabel* approvalStatusLabel_{nullptr}; + QPushButton* undoButton_{nullptr}; QPushButton* approveButton_{nullptr}; NavigationView navigationView_{NavigationView::Issues}; QString selectedIssueTargetId_{}; diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 98b87a9..b6925a5 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -86,6 +86,18 @@ QString readinessListText(const QString& readyText, const QStringList& missingIt return QString("Missing before ready:\n- %1").arg(missingItems.join("\n- ")); } +QString eventSummary(const std::vector& events) { + if (events.empty()) { + return "none"; + } + + QStringList names; + for (const auto& event : events) { + names << QString::fromStdString(event.name); + } + return names.join(", "); +} + QIcon makeCrowdIcon(const QColor& color) { QPixmap pixmap(44, 44); pixmap.fill(Qt::transparent); @@ -766,9 +778,9 @@ QWidget* ScenarioAuthoringWidget::createRunPanel() { continue; } const auto role = scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - lines << QString("- %1 (%2), Events: %3") + lines << QString("- %1 (%2)\n Events: %3") .arg(QString::fromStdString(scenario.draft.name), role) - .arg(static_cast(scenario.events.size())); + .arg(eventSummary(scenario.events)); } } stagedScenariosLabel_->setText(lines.join('\n')); From 0eb6b230990d49188ca57ecf16e7d48c27f2f469 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Wed, 29 Apr 2026 20:40:07 +0900 Subject: [PATCH 3/5] [Application] stabilize Sprint 1 demo flow --- src/application/MainWindow.cpp | 5 ++- src/application/ProjectPersistence.cpp | 38 +++++++++++++++++++++ src/application/ProjectPersistence.h | 2 ++ src/application/ScenarioAuthoringWidget.cpp | 37 +++++++++++--------- src/application/ScenarioResultWidget.cpp | 35 +++++++++++++++---- src/application/ScenarioRunWidget.cpp | 25 ++++++++++++-- src/application/ScenarioRunWidget.h | 1 + 7 files changed, 118 insertions(+), 25 deletions(-) diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index fc7164a..be38dfd 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -213,7 +213,10 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im } ScenarioAuthoringWidget::InitialState initialState; - const bool hasSavedScenarioState = ProjectPersistence::loadScenarioAuthoringState(currentProject_, &initialState); + const bool hasSavedScenarioState = ProjectPersistence::loadScenarioAuthoringState( + currentProject_, + *importResult.layout, + &initialState); auto saveHandler = [this]() { saveCurrentProject(); }; diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 070cc0a..f1ae4fb 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1,6 +1,7 @@ #include "application/ProjectPersistence.h" #include +#include #include #include @@ -546,6 +547,15 @@ std::vector placementsFromJson(const QJsonArray& array) return placements; } +std::unordered_set layoutZoneIds(const safecrowd::domain::FacilityLayout2D& layout) { + std::unordered_set zoneIds; + zoneIds.reserve(layout.zones.size()); + for (const auto& zone : layout.zones) { + zoneIds.insert(zone.id); + } + return zoneIds; +} + void syncDraftFromScenarioState(ScenarioAuthoringWidget::ScenarioState* scenario) { if (scenario == nullptr) { return; @@ -564,6 +574,30 @@ void syncDraftFromScenarioState(ScenarioAuthoringWidget::ScenarioState* scenario } } +void removePlacementsOutsideLayout( + const safecrowd::domain::FacilityLayout2D& layout, + ScenarioAuthoringWidget::ScenarioState* scenario) { + if (scenario == nullptr || scenario->crowdPlacements.empty()) { + return; + } + + const auto validZoneIds = layoutZoneIds(layout); + const auto oldSize = scenario->crowdPlacements.size(); + scenario->crowdPlacements.erase( + std::remove_if( + scenario->crowdPlacements.begin(), + scenario->crowdPlacements.end(), + [&validZoneIds](const ScenarioCrowdPlacement& placement) { + return !validZoneIds.contains(placement.zoneId.toStdString()); + }), + scenario->crowdPlacements.end()); + + if (scenario->crowdPlacements.size() != oldSize) { + scenario->stagedForRun = false; + syncDraftFromScenarioState(scenario); + } +} + QJsonObject scenarioStateToJson(const ScenarioAuthoringWidget::ScenarioState& scenario) { QJsonObject object; object["scenarioId"] = QString::fromStdString(scenario.draft.scenarioId); @@ -763,6 +797,7 @@ bool ProjectPersistence::saveProjectReview( bool ProjectPersistence::loadScenarioAuthoringState( const ProjectMetadata& metadata, + const safecrowd::domain::FacilityLayout2D& layout, ScenarioAuthoringWidget::InitialState* state) { if (metadata.isBuiltInDemo() || state == nullptr) { return false; @@ -784,6 +819,9 @@ bool ProjectPersistence::loadScenarioAuthoringState( for (const auto& value : root.value("scenarios").toArray()) { loaded.scenarios.push_back(scenarioStateFromJson(value.toObject())); } + for (auto& scenario : loaded.scenarios) { + removePlacementsOutsideLayout(layout, &scenario); + } if (loaded.scenarios.empty()) { loaded.currentScenarioIndex = -1; diff --git a/src/application/ProjectPersistence.h b/src/application/ProjectPersistence.h index 9ec300e..4406fa3 100644 --- a/src/application/ProjectPersistence.h +++ b/src/application/ProjectPersistence.h @@ -4,6 +4,7 @@ #include "application/ProjectMetadata.h" #include "application/ScenarioAuthoringWidget.h" +#include "domain/FacilityLayout2D.h" #include "domain/ImportResult.h" namespace safecrowd::application { @@ -21,6 +22,7 @@ class ProjectPersistence { QString* errorMessage = nullptr); static bool loadScenarioAuthoringState( const ProjectMetadata& metadata, + const safecrowd::domain::FacilityLayout2D& layout, ScenarioAuthoringWidget::InitialState* state); static bool saveScenarioAuthoringState( const ProjectMetadata& metadata, diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index b6925a5..a80a95a 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -66,13 +66,13 @@ const std::vector& sprint1EventPresets() { static const std::vector presets{ { .name = "Exit Closure", - .triggerSummary = "Trigger: operator command during run setup", - .targetSummary = "Target: primary exit route is marked closed for review", + .triggerSummary = "Configured trigger: operator command during run setup", + .targetSummary = "Configured target: primary exit route noted for review", }, { .name = "Staged Release", - .triggerSummary = "Trigger: release group after initial evacuation wave", - .targetSummary = "Target: queued occupants enter from the selected start area", + .triggerSummary = "Configured trigger: release group after initial evacuation wave", + .targetSummary = "Configured target: queued occupants noted for the selected start area", }, }; return presets; @@ -230,9 +230,9 @@ QWidget* createEventsPanel( auto* layout = new QVBoxLayout(content); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(createLabel("Events", content, ui::FontRole::Title)); + layout->addWidget(createLabel("Scenario Events", content, ui::FontRole::Title)); - auto* libraryHeader = createLabel("Event Library", content, ui::FontRole::SectionTitle); + auto* libraryHeader = createLabel("Configured Event Library", content, ui::FontRole::SectionTitle); libraryHeader->setStyleSheet(ui::subtleTextStyleSheet()); layout->addWidget(libraryHeader); @@ -268,7 +268,7 @@ QWidget* createEventsPanel( layout->addWidget(configuredHeader); if (scenario == nullptr || scenario->events.empty()) { - auto* empty = createLabel("No operational events yet", content); + auto* empty = createLabel("No configured scenario events yet", content); empty->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(empty); layout->addStretch(1); @@ -716,7 +716,7 @@ void ScenarioAuthoringWidget::showEmptyCanvas() { auto* title = createLabel("Create a scenario", canvas, ui::FontRole::Title); title->setAlignment(Qt::AlignCenter); layout->addWidget(title); - auto* detail = createLabel("Name the first scenario to start authoring Layout, Crowd, and Events settings.", canvas); + auto* detail = createLabel("Name the first scenario to start authoring Layout, Crowd, and Scenario Events settings.", canvas); detail->setAlignment(Qt::AlignCenter); detail->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(detail); @@ -769,20 +769,25 @@ QWidget* ScenarioAuthoringWidget::createRunPanel() { const auto stagedCount = std::count_if(scenarios_.begin(), scenarios_.end(), [](const auto& scenario) { return scenario.stagedForRun; }); - if (stagedCount == 0) { - lines << "No staged scenarios"; + const auto stagedBaselineCount = std::count_if(scenarios_.begin(), scenarios_.end(), [](const auto& scenario) { + return scenario.stagedForRun && scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline; + }); + if (stagedBaselineCount == 0) { + lines << "No staged baseline scenario"; } else { - lines << "Staged scenarios"; + lines << "Staged baseline for Sprint 1 run"; for (const auto& scenario : scenarios_) { - if (!scenario.stagedForRun) { + if (!scenario.stagedForRun || scenario.draft.role != safecrowd::domain::ScenarioRole::Baseline) { continue; } - const auto role = scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - lines << QString("- %1 (%2)\n Events: %3") - .arg(QString::fromStdString(scenario.draft.name), role) + lines << QString("- %1\n Configured events: %2") + .arg(QString::fromStdString(scenario.draft.name)) .arg(eventSummary(scenario.events)); } } + if (stagedCount > stagedBaselineCount) { + lines << "Staged alternatives remain authored but are not run in Sprint 1."; + } stagedScenariosLabel_->setText(lines.join('\n')); layout->addWidget(stagedScenariosLabel_); @@ -791,7 +796,7 @@ QWidget* ScenarioAuthoringWidget::createRunPanel() { layout->addWidget(readinessLabel_); layout->addStretch(1); - executeRunButton_ = new QPushButton("Run Staged Scenarios", panel); + executeRunButton_ = new QPushButton("Run Staged Baseline", panel); executeRunButton_->setFont(ui::font(ui::FontRole::Body)); executeRunButton_->setStyleSheet(ui::primaryButtonStyleSheet()); layout->addWidget(executeRunButton_); diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index 77e6b6e..e487c00 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "application/ScenarioCanvasWidget.h" @@ -64,6 +65,20 @@ QString bottleneckSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { .arg(static_cast(bottleneck.stalledAgentCount)); } +QString configuredEventSummary(const safecrowd::domain::ScenarioDraft& scenario) { + if (scenario.control.events.empty()) { + return "None"; + } + + QStringList names; + for (const auto& event : scenario.control.events) { + names << QString::fromStdString(event.name); + } + return QString("%1 configured\n%2") + .arg(static_cast(scenario.control.events.size())) + .arg(names.join(", ")); +} + QWidget* createResultPanel( const safecrowd::domain::ScenarioDraft& scenario, const safecrowd::domain::SimulationFrame& frame, @@ -76,8 +91,8 @@ QWidget* createResultPanel( layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(createLabel("Results", panel, ui::FontRole::Title)); - auto* scenarioLabel = createLabel(QString("Scenario: %1").arg(QString::fromStdString(scenario.name)), panel); + layout->addWidget(createLabel("Baseline Result", panel, ui::FontRole::Title)); + auto* scenarioLabel = createLabel(QString("Staged baseline: %1").arg(QString::fromStdString(scenario.name)), panel); scenarioLabel->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(scenarioLabel); auto* outcomeLabel = createLabel(QString("Outcome: %1").arg(completionOutcome(frame)), panel); @@ -89,13 +104,21 @@ QWidget* createResultPanel( metricsGrid->setSpacing(8); const auto total = static_cast(frame.totalAgentCount); const auto evacuated = static_cast(frame.evacuatedAgentCount); + const auto remaining = std::max(0, total - evacuated); const auto active = static_cast(frame.agents.size()); metricsGrid->addWidget(createMetricCard("Total", QString::number(total), panel), 0, 0); metricsGrid->addWidget(createMetricCard("Evacuated", QString("%1 / %2").arg(evacuated).arg(total), panel), 0, 1); - metricsGrid->addWidget(createMetricCard("Elapsed", QString("%1 sec").arg(frame.elapsedSeconds, 0, 'f', 1), panel), 1, 0); - metricsGrid->addWidget(createMetricCard("Active", QString::number(active), panel), 1, 1); - metricsGrid->addWidget(createMetricCard("Completion Risk", safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), panel), 2, 0); - metricsGrid->addWidget(createMetricCard("Stalled", QString::number(static_cast(risk.stalledAgentCount)), panel), 2, 1); + metricsGrid->addWidget(createMetricCard("Remaining", QString("%1 / %2").arg(remaining).arg(total), panel), 1, 0); + metricsGrid->addWidget(createMetricCard( + "Elapsed / Time limit", + QString("%1 / %2 sec") + .arg(frame.elapsedSeconds, 0, 'f', 1) + .arg(scenario.execution.timeLimitSeconds, 0, 'f', 0), + panel), 1, 1); + metricsGrid->addWidget(createMetricCard("Active", QString::number(active), panel), 2, 0); + metricsGrid->addWidget(createMetricCard("Configured Events", configuredEventSummary(scenario), panel), 2, 1); + metricsGrid->addWidget(createMetricCard("Completion Risk", safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), panel), 3, 0); + metricsGrid->addWidget(createMetricCard("Stalled", QString::number(static_cast(risk.stalledAgentCount)), panel), 3, 1); layout->addLayout(metricsGrid); auto* detailArea = new QScrollArea(panel); diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 37ff9b6..402736b 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,20 @@ QString hotspotSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { .arg(hotspot.center.y, 0, 'f', 1); } +QString configuredEventSummary(const safecrowd::domain::ScenarioDraft& scenario) { + if (scenario.control.events.empty()) { + return "Configured Events: none"; + } + + QStringList names; + for (const auto& event : scenario.control.events) { + names << QString::fromStdString(event.name); + } + return QString("Configured Events: %1\n%2") + .arg(static_cast(scenario.control.events.size())) + .arg(names.join(", ")); +} + QLabel* createLabel(const QString& text, QWidget* parent, ui::FontRole role = ui::FontRole::Body) { auto* label = new QLabel(text, parent); label->setFont(ui::font(role)); @@ -125,7 +140,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(createLabel("Run", panel, ui::FontRole::Title)); + layout->addWidget(createLabel("Baseline Run", panel, ui::FontRole::Title)); scenarioLabel_ = createLabel("", panel); scenarioLabel_->setStyleSheet(ui::mutedTextStyleSheet()); statusLabel_ = createLabel("", panel); @@ -134,6 +149,8 @@ QWidget* ScenarioRunWidget::createRunPanel() { elapsedLabel_->setStyleSheet(ui::mutedTextStyleSheet()); agentCountLabel_ = createLabel("", panel); agentCountLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + eventLabel_ = createLabel("", panel); + eventLabel_->setStyleSheet(ui::mutedTextStyleSheet()); riskLabel_ = createLabel("", panel); riskLabel_->setStyleSheet(ui::mutedTextStyleSheet()); congestionLabel_ = createLabel("", panel); @@ -145,6 +162,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->addWidget(statusLabel_); layout->addWidget(elapsedLabel_); layout->addWidget(agentCountLabel_); + layout->addWidget(eventLabel_); layout->addWidget(riskLabel_); layout->addWidget(congestionLabel_); layout->addWidget(bottleneckLabel_); @@ -184,7 +202,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { void ScenarioRunWidget::refreshStatus() { const auto& frame = runner_.frame(); if (scenarioLabel_ != nullptr) { - scenarioLabel_->setText(QString("Scenario: %1").arg(QString::fromStdString(scenario_.name))); + scenarioLabel_->setText(QString("Staged baseline: %1").arg(QString::fromStdString(scenario_.name))); } if (statusLabel_ != nullptr) { statusLabel_->setText(QString("Status: %1").arg(runStatusText(frame, paused_))); @@ -200,6 +218,9 @@ void ScenarioRunWidget::refreshStatus() { .arg(static_cast(frame.totalAgentCount)) .arg(static_cast(frame.agents.size()))); } + if (eventLabel_ != nullptr) { + eventLabel_->setText(configuredEventSummary(scenario_)); + } const auto& risk = runner_.riskSnapshot(); if (riskLabel_ != nullptr) { riskLabel_->setText(QString("Completion Risk: %1\nStalled Agents: %2") diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 7d2b7ea..5525806 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -50,6 +50,7 @@ class ScenarioRunWidget : public QWidget { QLabel* statusLabel_{nullptr}; QLabel* elapsedLabel_{nullptr}; QLabel* agentCountLabel_{nullptr}; + QLabel* eventLabel_{nullptr}; QLabel* riskLabel_{nullptr}; QLabel* congestionLabel_{nullptr}; QLabel* bottleneckLabel_{nullptr}; From 2f310a3709e8dd145f07a93e4cbc9ad4f273d624 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Thu, 30 Apr 2026 16:18:42 +0900 Subject: [PATCH 4/5] [Application] Prepare Sprint 1 demo happy path --- docs/UI.md | 37 +++--- src/application/LayoutCanvasRendering.cpp | 3 +- src/application/LayoutCanvasRendering.h | 5 + src/application/MainWindow.cpp | 82 ++++++++++++- src/application/ProjectPersistence.cpp | 62 ++++++++-- src/application/ScenarioAuthoringWidget.cpp | 68 +++++++---- src/application/ScenarioResultWidget.cpp | 86 +++++++++++++- src/application/ScenarioResultWidget.h | 4 + src/application/ScenarioRunWidget.cpp | 21 +++- src/application/ScenarioRunWidget.h | 4 + src/application/SimulationCanvasWidget.cpp | 123 +++++++++++++++++++- src/application/SimulationCanvasWidget.h | 9 ++ src/application/WorkspaceShell.cpp | 3 +- src/domain/DemoFixtureService.cpp | 22 ++++ src/domain/DemoFixtureService.h | 2 + tests/DemoFixtureServiceTests.cpp | 24 ++++ 16 files changed, 485 insertions(+), 70 deletions(-) diff --git a/docs/UI.md b/docs/UI.md index dae6979..6f5f70f 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -204,12 +204,15 @@ Layout Review의 중앙 작업 영역이다. 현재는 import 결과와 수동 - Scenario 패널에서 현재 시나리오 요약 표시 - Scenario switcher로 baseline/alternative 전환 - `New Scenario from Current`로 현재 시나리오를 복제해 alternative 생성 -- Run 패널에서 실행 대상으로 staged 된 시나리오 목록 표시 +- built-in Demo 프로젝트는 baseline scenario, crowd placement, configured events를 즉시 복원 +- Run 패널에서 실행 대상으로 staged 된 baseline 시나리오 표시 - Layout, Crowd, Events 좌측 탭 제공 - 중앙 canvas에서 보행자 배치 작성 - select, individual occupant placement, rectangular group placement 지원 - 시나리오를 run 대상으로 stage - 우측 Scenario panel에서 baseline 대비 변경 요약과 readiness 표시 +- `Project > Save Project`로 scenario authoring 상태를 프로젝트 폴더에 저장 +- 저장된 프로젝트를 다시 열면 scenario 목록, 배치, 이벤트, 패널 상태를 복원 현재 UI 요소: @@ -220,7 +223,7 @@ Layout Review의 중앙 작업 영역이다. 현재는 import 결과와 수동 - New Scenario from Current - Top-right Scenario / Run panel toggles - Run staged scenario list -- Run Staged Scenarios button +- Run Staged Baseline button - Scenario name 입력 팝업 - 승인 layout canvas - Select tool @@ -239,10 +242,9 @@ Layout Review의 중앙 작업 영역이다. 현재는 import 결과와 수동 현재 약한 부분: -- 운영 이벤트 editor는 최소 구조만 있고 실제 입력 UI가 부족하다. -- readiness panel은 요약 수준이며 실행 불가 사유를 충분히 모아 보여주지 않는다. +- 운영 이벤트 editor는 preset 추가와 목록 표시 수준이며 상세 수정/삭제 UI가 부족하다. - environment와 execution 상세 입력은 아직 제한적이다. -- scenario 저장/로드는 Sprint 1 핵심 시연 범위 밖이지만 후속 보완이 필요하다. +- 저장/로드는 Sprint 1 authoring 상태 중심이며 persisted result artifact 저장은 후속 범위다. ### 3.6 운영 이벤트 @@ -265,7 +267,8 @@ Sprint 1 최소 기준: 현재 상태: - Events 탭과 event draft 구조는 있다. -- 사용자가 직접 이벤트를 추가, 수정, 삭제하는 editor UI는 아직 부족하다. +- Exit Closure, Staged Release preset을 추가할 수 있고 scenario draft의 control plan에 반영된다. +- 사용자가 직접 이벤트를 상세 수정, 삭제하는 editor UI는 아직 부족하다. ### 3.7 Run Workspace @@ -284,6 +287,7 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - elapsed time 기준 시간 진행률 표시 - evacuated/total, active agents 표시 - evacuated/total 기준 대피 진행률 표시 +- configured events 요약 표시 - completion risk, stalled agents, hotspot, bottleneck 요약 표시 - run이 complete되면 Result Summary 진입 버튼 활성화 - risk, stalled, hotspot, bottleneck 기준 툴팁 표시 @@ -298,6 +302,7 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - evacuated / total - evacuation progress - active agents +- configured events summary - completion risk - stalled agents - hotspot count @@ -307,7 +312,6 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - run queue는 staged list 수준이다. - 여러 staged scenario를 순차 실행하는 batch queue는 아직 아니다. -- 실행 전 readiness 조건과 실행 불가 사유 표시가 부족하다. - batch queue와 persisted result artifact가 아직 연결되어 있지 않다. ### 3.8 Result Summary / Analysis Workspace @@ -335,12 +339,16 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 현재 UI 요소: +- Total - Evacuated -- Time +- Remaining +- Elapsed / Time limit +- Active +- Configured Events +- Completion Risk +- Stalled - T90 - T95 -- Risk -- Stalled - Result Reports - Evacuation Progress - Bottlenecks @@ -460,7 +468,8 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 | Layout 보정 | 기본 drawing/editing 가능 | 핵심 흐름 가능 | | Scenario 생성 | baseline 생성과 전환 가능 | 핵심 흐름 가능 | | Crowd 배치 | 개인/그룹 배치 가능 | 핵심 흐름 가능 | -| 운영 이벤트 | 구조는 있으나 editor 부족 | 보완 필요 | +| 운영 이벤트 | preset 추가와 목록 표시 가능 | Sprint 1 최소 흐름 가능 | +| Scenario 저장/로드 | authoring 상태 저장과 복원 가능 | 핵심 흐름 가능 | | Run | 실행, pause/resume, stop 가능 | 핵심 흐름 가능 | | Result Summary | 기본 결과 요약 가능 | 시연 보조 가능 | @@ -478,8 +487,9 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - [ ] 선택 요소의 상세 편집 inspector 보강 - [ ] warning/info 승인 또는 무시 상태 표시 - [ ] issue별 repair suggestion 표시 -- [ ] 운영 이벤트 추가, 수정, 삭제 editor 보강 -- [ ] readiness panel에 실행 불가 사유 목록 표시 +- [x] readiness panel에 실행 불가 사유 목록 표시 +- [x] Scenario authoring 상태 저장/로드 연결 +- [ ] 운영 이벤트 수정, 삭제 editor 보강 ### 6.2 Sprint 2 개선 @@ -500,7 +510,6 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - [ ] Scenario Template Picker 제공 - [ ] Environment 상세 설정 제공 - [ ] Control 이벤트 상세 편집 제공 -- [ ] Scenario 저장/로드 연결 - [ ] baseline 대비 alternative 비교 화면 제공 - [ ] persisted result artifact 기반 analysis 연결 diff --git a/src/application/LayoutCanvasRendering.cpp b/src/application/LayoutCanvasRendering.cpp index b6fb1a9..f4e6fdf 100644 --- a/src/application/LayoutCanvasRendering.cpp +++ b/src/application/LayoutCanvasRendering.cpp @@ -85,7 +85,8 @@ bool LayoutCanvasCamera::handleKeyRelease(QKeyEvent* event) { } bool LayoutCanvasCamera::beginPan(QMouseEvent* event) { - if (event->button() != Qt::MiddleButton && !(event->button() == Qt::LeftButton && spacePressed_)) { + const auto primaryPan = event->button() == Qt::LeftButton && (spacePressed_ || primaryButtonPanEnabled_); + if (event->button() != Qt::MiddleButton && !primaryPan) { return false; } diff --git a/src/application/LayoutCanvasRendering.h b/src/application/LayoutCanvasRendering.h index fb10382..c18c9be 100644 --- a/src/application/LayoutCanvasRendering.h +++ b/src/application/LayoutCanvasRendering.h @@ -76,6 +76,10 @@ class LayoutCanvasCamera { panOffset_ = panOffset; } + void setPrimaryButtonPanEnabled(bool enabled) noexcept { + primaryButtonPanEnabled_ = enabled; + } + bool panning() const noexcept { return panning_; } @@ -87,6 +91,7 @@ class LayoutCanvasCamera { Qt::MouseButton panButton_{Qt::NoButton}; bool panning_{false}; bool spacePressed_{false}; + bool primaryButtonPanEnabled_{false}; }; void includeLayoutCanvasPoint(LayoutCanvasBounds& bounds, const safecrowd::domain::Point2D& point); diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index be593f2..718990f 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -1,6 +1,8 @@ #include "application/MainWindow.h" +#include #include +#include #include @@ -9,6 +11,7 @@ #include "application/ProjectPersistence.h" #include "application/ProjectNavigatorWidget.h" #include "application/ScenarioAuthoringWidget.h" +#include "domain/DemoFixtureService.h" #include "domain/DemoLayouts.h" #include "domain/DxfImportService.h" #include "domain/ImportIssue.h" @@ -28,8 +31,11 @@ void applySavedReviewState(const ProjectMetadata& metadata, safecrowd::domain::I } safecrowd::domain::ImportResult makeDemoImportResult() { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createSprint1DemoFixture(); + safecrowd::domain::ImportResult result; - result.layout = safecrowd::domain::DemoLayouts::demoFacility(); + result.layout = fixture.layout; safecrowd::domain::ImportValidationService validator; result.issues = validator.validate(*result.layout); @@ -39,6 +45,66 @@ safecrowd::domain::ImportResult makeDemoImportResult() { return result; } +QString zoneLabel(const safecrowd::domain::Zone2D& zone) { + const auto id = QString::fromStdString(zone.id); + const auto label = QString::fromStdString(zone.label); + return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); +} + +const safecrowd::domain::Zone2D* findZone( + const safecrowd::domain::FacilityLayout2D& layout, + const std::string& id) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + return zone.id == id; + }); + return it == layout.zones.end() ? nullptr : &(*it); +} + +ScenarioCrowdPlacement makeUiPlacement(const safecrowd::domain::InitialPlacement2D& placement) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(placement.id); + uiPlacement.name = QStringLiteral("Demo crowd group"); + uiPlacement.kind = (placement.targetAgentCount <= 1 && placement.area.outline.size() <= 1) + ? ScenarioCrowdPlacementKind::Individual + : ScenarioCrowdPlacementKind::Group; + uiPlacement.zoneId = QString::fromStdString(placement.zoneId); + uiPlacement.area = placement.area.outline; + uiPlacement.occupantCount = static_cast(placement.targetAgentCount); + uiPlacement.velocity = placement.initialVelocity; + return uiPlacement; +} + +ScenarioAuthoringWidget::InitialState makeDemoScenarioAuthoringState( + const safecrowd::domain::FacilityLayout2D& approvedLayout) { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createSprint1DemoFixture(); + + ScenarioAuthoringWidget::ScenarioState scenario; + scenario.draft = fixture.baselineScenario; + scenario.events = fixture.baselineScenario.control.events; + scenario.stagedForRun = false; + + if (const auto* startZone = findZone(approvedLayout, safecrowd::domain::DemoLayouts::Sprint1FacilityIds::MainRoomZoneId); + startZone != nullptr) { + scenario.startText = zoneLabel(*startZone); + } + if (const auto* destinationZone = findZone(approvedLayout, safecrowd::domain::DemoLayouts::Sprint1FacilityIds::ExitZoneId); + destinationZone != nullptr) { + scenario.destinationText = zoneLabel(*destinationZone); + } + + for (const auto& placement : fixture.baselineScenario.population.initialPlacements) { + scenario.crowdPlacements.push_back(makeUiPlacement(placement)); + } + + ScenarioAuthoringWidget::InitialState state; + state.scenarios.push_back(std::move(scenario)); + state.currentScenarioIndex = 0; + state.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; + state.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; + return state; +} + } // namespace MainWindow::MainWindow(safecrowd::domain::SafeCrowdDomain& domain, QWidget* parent) @@ -226,14 +292,19 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im currentProject_, *importResult.layout, &initialState); + if (currentProject_.isBuiltInDemo()) { + initialState = makeDemoScenarioAuthoringState(*importResult.layout); + } + lastApprovedImportResult_ = importResult; + auto saveHandler = [this]() { saveCurrentProject(); }; auto openProjectHandler = [this]() { hasCurrentProject_ = false; - currentProject_ = {}; - showProjectNavigator(); - }; + currentProject_ = {}; + showProjectNavigator(); + }; auto backToLayoutReviewHandler = [this]() { if (lastApprovedImportResult_.has_value()) { showLayoutReview(currentProject_, *lastApprovedImportResult_); @@ -242,7 +313,7 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im } }; - if (hasSavedScenarioState) { + if (currentProject_.isBuiltInDemo() || hasSavedScenarioState) { setCentralWidget(new ScenarioAuthoringWidget( currentProject_.name, *importResult.layout, @@ -253,7 +324,6 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im this)); return; } - lastApprovedImportResult_ = importResult; setCentralWidget(new ScenarioAuthoringWidget( currentProject_.name, diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 687c4d2..6624671 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -26,7 +26,8 @@ constexpr auto kScenarioAuthoringFileName = "scenario-authoring.json"; bool isProjectManagedEntry(const QString& fileName) { return fileName.compare(kProjectFileName, Qt::CaseInsensitive) == 0 || fileName.compare(kLayoutFileName, Qt::CaseInsensitive) == 0 - || fileName.compare(kReviewFileName, Qt::CaseInsensitive) == 0; + || fileName.compare(kReviewFileName, Qt::CaseInsensitive) == 0 + || fileName.compare(kScenarioAuthoringFileName, Qt::CaseInsensitive) == 0; } QString projectFilePath(const QString& folderPath) { @@ -503,14 +504,26 @@ QJsonObject executionToJson(const safecrowd::domain::ExecutionConfig& execution) safecrowd::domain::ExecutionConfig executionFromJson(const QJsonObject& object) { return { - .timeLimitSeconds = object.value("timeLimitSeconds").toDouble(), - .sampleIntervalSeconds = object.value("sampleIntervalSeconds").toDouble(), - .repeatCount = static_cast(object.value("repeatCount").toInt(1)), + .timeLimitSeconds = std::max(1.0, object.value("timeLimitSeconds").toDouble(600.0)), + .sampleIntervalSeconds = std::max(0.1, object.value("sampleIntervalSeconds").toDouble(1.0)), + .repeatCount = static_cast(std::max(1, object.value("repeatCount").toInt(1))), .baseSeed = static_cast(object.value("baseSeed").toInt()), .recordOccupantHistory = object.value("recordOccupantHistory").toBool(false), }; } +safecrowd::domain::ScenarioRole scenarioRoleFromJson(const QJsonValue& value) { + switch (value.toInt(static_cast(safecrowd::domain::ScenarioRole::Alternative))) { + case static_cast(safecrowd::domain::ScenarioRole::Baseline): + return safecrowd::domain::ScenarioRole::Baseline; + case static_cast(safecrowd::domain::ScenarioRole::Recommended): + return safecrowd::domain::ScenarioRole::Recommended; + case static_cast(safecrowd::domain::ScenarioRole::Alternative): + default: + return safecrowd::domain::ScenarioRole::Alternative; + } +} + QJsonObject eventToJson(const safecrowd::domain::OperationalEventDraft& event) { QJsonObject object; object["id"] = QString::fromStdString(event.id); @@ -559,13 +572,18 @@ QJsonObject placementToJson(const ScenarioCrowdPlacement& placement) { } ScenarioCrowdPlacement placementFromJson(const QJsonObject& object) { + const auto kindValue = object.value("kind").toInt(static_cast(ScenarioCrowdPlacementKind::Individual)); + const auto kind = kindValue == static_cast(ScenarioCrowdPlacementKind::Group) + ? ScenarioCrowdPlacementKind::Group + : ScenarioCrowdPlacementKind::Individual; + return { .id = object.value("id").toString(), .name = object.value("name").toString(), - .kind = static_cast(object.value("kind").toInt()), + .kind = kind, .zoneId = object.value("zoneId").toString(), .area = placementAreaFromJson(object.value("area").toArray()), - .occupantCount = object.value("occupantCount").toInt(1), + .occupantCount = std::max(1, object.value("occupantCount").toInt(1)), .velocity = pointFromJson(object.value("velocity")), }; } @@ -659,7 +677,7 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromJson(const QJsonObject& ScenarioAuthoringWidget::ScenarioState scenario; scenario.draft.scenarioId = object.value("scenarioId").toString().toStdString(); scenario.draft.name = object.value("name").toString().toStdString(); - scenario.draft.role = static_cast(object.value("role").toInt()); + scenario.draft.role = scenarioRoleFromJson(object.value("role")); scenario.draft.sourceTemplateId = object.value("sourceTemplateId").toString().toStdString(); scenario.draft.variationDiffKeys = stringVectorFromJson(object.value("variationDiffKeys").toArray()); scenario.draft.execution = executionFromJson(object.value("execution").toObject()); @@ -673,6 +691,30 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromJson(const QJsonObject& return scenario; } +ScenarioAuthoringWidget::NavigationView navigationViewFromJson(const QJsonValue& value) { + switch (value.toInt(static_cast(ScenarioAuthoringWidget::NavigationView::Layout))) { + case static_cast(ScenarioAuthoringWidget::NavigationView::Crowd): + return ScenarioAuthoringWidget::NavigationView::Crowd; + case static_cast(ScenarioAuthoringWidget::NavigationView::Events): + return ScenarioAuthoringWidget::NavigationView::Events; + case static_cast(ScenarioAuthoringWidget::NavigationView::Layout): + default: + return ScenarioAuthoringWidget::NavigationView::Layout; + } +} + +ScenarioAuthoringWidget::RightPanelMode rightPanelModeFromJson(const QJsonValue& value) { + switch (value.toInt(static_cast(ScenarioAuthoringWidget::RightPanelMode::Scenario))) { + case static_cast(ScenarioAuthoringWidget::RightPanelMode::None): + return ScenarioAuthoringWidget::RightPanelMode::None; + case static_cast(ScenarioAuthoringWidget::RightPanelMode::Run): + return ScenarioAuthoringWidget::RightPanelMode::Run; + case static_cast(ScenarioAuthoringWidget::RightPanelMode::Scenario): + default: + return ScenarioAuthoringWidget::RightPanelMode::Scenario; + } +} + } // namespace QList ProjectPersistence::loadRecentProjects() { @@ -855,10 +897,8 @@ bool ProjectPersistence::loadScenarioAuthoringState( const auto root = document.object(); ScenarioAuthoringWidget::InitialState loaded; loaded.currentScenarioIndex = root.value("currentScenarioIndex").toInt(-1); - loaded.navigationView = static_cast( - root.value("navigationView").toInt(static_cast(ScenarioAuthoringWidget::NavigationView::Layout))); - loaded.rightPanelMode = static_cast( - root.value("rightPanelMode").toInt(static_cast(ScenarioAuthoringWidget::RightPanelMode::Scenario))); + loaded.navigationView = navigationViewFromJson(root.value("navigationView")); + loaded.rightPanelMode = rightPanelModeFromJson(root.value("rightPanelMode")); for (const auto& value : root.value("scenarios").toArray()) { loaded.scenarios.push_back(scenarioStateFromJson(value.toObject())); diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 564e9ec..0720045 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -67,13 +67,13 @@ const std::vector& sprint1EventPresets() { static const std::vector presets{ { .name = "Exit Closure", - .triggerSummary = "Configured trigger: operator command during run setup", - .targetSummary = "Configured target: primary exit route noted for review", + .triggerSummary = "Operator command", + .targetSummary = "Primary exit route", }, { .name = "Staged Release", - .triggerSummary = "Configured trigger: release group after initial evacuation wave", - .targetSummary = "Configured target: queued occupants noted for the selected start area", + .triggerSummary = "After initial evacuation wave", + .targetSummary = "Selected start area occupants", }, }; return presets; @@ -210,14 +210,13 @@ std::vector buildCrowdTree(const ScenarioAuthoringWidget::Sc } placements.push_back({ - .label = QString("%1 - %2 - %3 %4") + .label = QString("%1 (%2)") .arg( - placement.name.isEmpty() ? placement.id : placement.name, - placement.zoneId) - .arg(placement.occupantCount) - .arg(placement.occupantCount == 1 ? "occupant" : "occupants"), + placement.name.isEmpty() ? placement.id : placement.name) + .arg(placement.occupantCount == 1 ? QString("1 occupant") : QString("%1 occupants").arg(placement.occupantCount)), .id = placement.id, - .detail = QString("Velocity: (%1, %2)") + .detail = QString("Zone: %1\nVelocity: (%2, %3)") + .arg(placement.zoneId) .arg(placement.velocity.x, 0, 'f', 2) .arg(placement.velocity.y, 0, 'f', 2), .children = group ? std::move(occupants) : std::vector{}, @@ -256,12 +255,14 @@ std::vector buildEventsTree(const ScenarioAuthoringWidget::S .detail = QString::fromStdString(event.targetSummary), .children = { { - .label = QString("Trigger - %1").arg(QString::fromStdString(event.triggerSummary)), + .label = "Trigger", .id = QString("%1/trigger").arg(eventId), + .detail = QString::fromStdString(event.triggerSummary), }, { - .label = QString("Target - %1").arg(QString::fromStdString(event.targetSummary)), + .label = "Target", .id = QString("%1/target").arg(eventId), + .detail = QString::fromStdString(event.targetSummary), }, }, .expanded = true, @@ -269,7 +270,7 @@ std::vector buildEventsTree(const ScenarioAuthoringWidget::S } return {{ - .label = QString("Events (%1)").arg(static_cast(scenario->events.size())), + .label = QString("Configured (%1)").arg(static_cast(scenario->events.size())), .children = std::move(events), .expanded = true, .selectable = false, @@ -295,23 +296,38 @@ QWidget* createEventsPanel( const auto name = preset.name; const auto triggerSummary = preset.triggerSummary; const auto targetSummary = preset.targetSummary; - auto* button = new QPushButton( - QString("%1\n%2\n%3") - .arg(name, triggerSummary, targetSummary), - content); - button->setFont(ui::font(ui::FontRole::Body)); - button->setMinimumHeight(78); + auto* card = new QFrame(content); + card->setStyleSheet(ui::panelStyleSheet()); + card->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + auto* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(12, 10, 12, 10); + cardLayout->setSpacing(6); + + auto* title = createLabel(name, card, ui::FontRole::SectionTitle); + cardLayout->addWidget(title); + + auto* detail = createLabel( + QString("Trigger: %1\nTarget: %2").arg(triggerSummary, targetSummary), + card, + ui::FontRole::Caption); + detail->setStyleSheet(ui::mutedTextStyleSheet()); + cardLayout->addWidget(detail); + + auto* button = new QPushButton("Add Event", card); + button->setFont(ui::font(ui::FontRole::Caption)); + button->setMinimumHeight(34); button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); button->setStyleSheet(ui::secondaryButtonStyleSheet()); button->setEnabled(scenario != nullptr); button->setToolTip(QString("Add another %1 event").arg(name)); auto handler = addEventHandler; - QObject::connect(button, &QPushButton::clicked, content, [=]() { + QObject::connect(button, &QPushButton::clicked, card, [=]() { if (handler) { handler(name, triggerSummary, targetSummary); } }); - layout->addWidget(button); + cardLayout->addWidget(button); + layout->addWidget(card); }; for (const auto& preset : sprint1EventPresets()) { @@ -324,7 +340,7 @@ QWidget* createEventsPanel( "No configured scenario events yet", {}, content, - nullptr), 1); + createLabel("Configured Events", content, ui::FontRole::SectionTitle)), 1); return content; } @@ -669,6 +685,13 @@ void ScenarioAuthoringWidget::runFirstStagedBaselineScenario() { saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, + [this](bool showRunPanel) { + returnFromRun(showRunPanel); + }, + [this]() { + returnFromRun(true); + runFirstStagedBaselineScenario(); + }, this); rootLayout->replaceWidget(shell_, runWidget); shell_->hide(); @@ -706,6 +729,7 @@ void ScenarioAuthoringWidget::returnFromRun(bool showRunPanel) { shell_->setTools({"Project"}); shell_->setSaveProjectHandler(saveProjectHandler_); shell_->setOpenProjectHandler(openProjectHandler_); + shell_->setBackHandler(backToLayoutReviewHandler_); shell_->setTopBarTrailingWidget(createTopBarTogglePanel()); refreshRightPanel(); rootLayout->addWidget(shell_); diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index a3934b2..a354d13 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -38,6 +38,61 @@ QString formatOptionalSeconds(const std::optional& seconds) { return seconds.has_value() ? QString("%1 sec").arg(*seconds, 0, 'f', 1) : QString("Pending"); } +QString zoneLabel(const safecrowd::domain::Zone2D& zone) { + const auto id = QString::fromStdString(zone.id); + const auto label = QString::fromStdString(zone.label); + return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); +} + +const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; + }); + return it == layout.zones.end() ? nullptr : &(*it); +} + +const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::FacilityLayout2D& layout) { + const auto exitIt = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.kind == safecrowd::domain::ZoneKind::Exit; + }); + if (exitIt != layout.zones.end()) { + return &(*exitIt); + } + return layout.zones.empty() ? nullptr : &layout.zones.back(); +} + +ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( + const safecrowd::domain::ScenarioDraft& scenario, + const safecrowd::domain::FacilityLayout2D& layout) { + ScenarioAuthoringWidget::ScenarioState state; + state.draft = scenario; + state.events = scenario.control.events; + state.stagedForRun = true; + + if (const auto* startZone = firstStartZone(layout); startZone != nullptr) { + state.startText = zoneLabel(*startZone); + } + if (const auto* destinationZone = firstDestinationZone(layout); destinationZone != nullptr) { + state.destinationText = zoneLabel(*destinationZone); + } + + for (const auto& placement : scenario.population.initialPlacements) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(placement.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = (placement.targetAgentCount <= 1 && placement.area.outline.size() <= 1) + ? ScenarioCrowdPlacementKind::Individual + : ScenarioCrowdPlacementKind::Group; + uiPlacement.zoneId = QString::fromStdString(placement.zoneId); + uiPlacement.area = placement.area.outline; + uiPlacement.occupantCount = static_cast(placement.targetAgentCount); + uiPlacement.velocity = placement.initialVelocity; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } + + return state; +} + class EvacuationProgressWidget final : public QWidget { public: explicit EvacuationProgressWidget( @@ -194,18 +249,18 @@ QPushButton* createBottleneckRowButton( QWidget* parent) { const auto label = QString::fromStdString(bottleneck.label); const auto id = QString::fromStdString(bottleneck.connectionId); - const auto idLine = (!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{}; auto* button = new QPushButton( - QString("%1. %2%3\n%4 nearby, %5 stalled") + QString("Bottleneck %1\n%2 nearby, %3 stalled") .arg(static_cast(index + 1)) - .arg(label, idLine) .arg(static_cast(bottleneck.nearbyAgentCount)) .arg(static_cast(bottleneck.stalledAgentCount)), parent); button->setFont(ui::font(ui::FontRole::Body)); button->setCursor(Qt::PointingHandCursor); button->setStyleSheet(ui::ghostRowStyleSheet()); - button->setToolTip(QString("%1\nClick to focus this bottleneck on the canvas.") + button->setToolTip(QString("%1%2\n\n%3\nClick to focus this bottleneck on the canvas.") + .arg(label) + .arg((!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{}) .arg(safecrowd::domain::scenarioBottleneckDefinition())); return button; } @@ -458,6 +513,8 @@ ScenarioResultWidget::ScenarioResultWidget( std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler, + std::function rerunScenarioHandler, QWidget* parent) : QWidget(parent), projectName_(std::move(projectName)), @@ -468,7 +525,9 @@ ScenarioResultWidget::ScenarioResultWidget( artifacts_(std::move(artifacts)), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), - backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)), + returnToAuthoringHandler_(std::move(returnToAuthoringHandler)), + rerunScenarioHandler_(std::move(rerunScenarioHandler)) { auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -525,6 +584,11 @@ ScenarioResultWidget::ScenarioResultWidget( } void ScenarioResultWidget::rerunScenario() { + if (rerunScenarioHandler_) { + rerunScenarioHandler_(); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; @@ -537,6 +601,8 @@ void ScenarioResultWidget::rerunScenario() { saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, + returnToAuthoringHandler_, + rerunScenarioHandler_, this); rootLayout->replaceWidget(shell_, runWidget); @@ -546,6 +612,16 @@ void ScenarioResultWidget::rerunScenario() { } void ScenarioResultWidget::navigateToAuthoring(bool showRunPanel) { + if (returnToAuthoringHandler_) { + returnToAuthoringHandler_(showRunPanel); + return; + } + + auto* rootLayout = qobject_cast(layout()); + if (rootLayout == nullptr || shell_ == nullptr) { + return; + } + ScenarioAuthoringWidget::InitialState initial; initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); initial.currentScenarioIndex = 0; diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index 2871fc7..3e3b2b6 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -27,6 +27,8 @@ class ScenarioResultWidget : public QWidget { std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler = {}, + std::function rerunScenarioHandler = {}, QWidget* parent = nullptr); private: @@ -42,6 +44,8 @@ class ScenarioResultWidget : public QWidget { std::function saveProjectHandler_{}; std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; + std::function returnToAuthoringHandler_{}; + std::function rerunScenarioHandler_{}; WorkspaceShell* shell_{nullptr}; }; diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 496eae4..2d5b6d1 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -218,6 +218,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler, + std::function rerunScenarioHandler, QWidget* parent) : QWidget(parent), projectName_(projectName), @@ -226,7 +228,9 @@ ScenarioRunWidget::ScenarioRunWidget( runner_(layout_, scenario_), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), - backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)), + returnToAuthoringHandler_(std::move(returnToAuthoringHandler)), + rerunScenarioHandler_(std::move(rerunScenarioHandler)) { auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -344,6 +348,11 @@ void ScenarioRunWidget::returnToAuthoring() { timer_->stop(); } + if (returnToAuthoringHandler_) { + returnToAuthoringHandler_(true); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; @@ -413,15 +422,17 @@ void ScenarioRunWidget::refreshStatus() { if (bottleneckLabel_ != nullptr) { if (risk.bottlenecks.empty()) { bottleneckLabel_->setText("Bottlenecks: 0"); + bottleneckLabel_->setToolTip({}); } else { const auto& bottleneck = risk.bottlenecks.front(); const auto label = QString::fromStdString(bottleneck.label); const auto id = QString::fromStdString(bottleneck.connectionId); - const auto idLine = (!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{}; - bottleneckLabel_->setText(QString("Worst Bottleneck: %1%2\nNearby: %3, Stalled: %4") - .arg(label, idLine) + bottleneckLabel_->setText(QString("Worst Bottleneck\nNearby: %1, Stalled: %2") .arg(static_cast(bottleneck.nearbyAgentCount)) .arg(static_cast(bottleneck.stalledAgentCount))); + bottleneckLabel_->setToolTip(QString("%1%2") + .arg(label) + .arg((!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{})); } } if (pauseButton_ != nullptr) { @@ -485,6 +496,8 @@ void ScenarioRunWidget::showResults() { } }, backToLayoutReviewHandler_, + returnToAuthoringHandler_, + rerunScenarioHandler_, this); rootLayout->replaceWidget(shell_, resultWidget); shell_->hide(); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 9b1689d..c662f8e 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -28,6 +28,8 @@ class ScenarioRunWidget : public QWidget { std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler = {}, + std::function rerunScenarioHandler = {}, QWidget* parent = nullptr); private: @@ -45,6 +47,8 @@ class ScenarioRunWidget : public QWidget { std::function saveProjectHandler_{}; std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; + std::function returnToAuthoringHandler_{}; + std::function rerunScenarioHandler_{}; WorkspaceShell* shell_{nullptr}; SimulationCanvasWidget* canvas_{nullptr}; QTimer* timer_{nullptr}; diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index e79851d..3153ff8 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -6,10 +6,14 @@ #include #include +#include +#include #include #include #include +#include #include +#include #include namespace safecrowd::application { @@ -24,6 +28,28 @@ constexpr double kBottleneckFocusZoom = 2.4; constexpr int kHotspotMinCoreAlpha = 72; constexpr int kHotspotMaxCoreAlpha = 190; +QPushButton* createViewControlButton(const QString& text, const QString& tooltip, QWidget* parent) { + auto* button = new QPushButton(text, parent); + button->setFixedSize(text == "Fit" ? QSize(42, 30) : QSize(30, 30)); + button->setCursor(Qt::PointingHandCursor); + button->setToolTip(tooltip); + button->setAccessibleName(tooltip); + button->setStyleSheet( + "QPushButton {" + " background: #ffffff;" + " border: 1px solid #d7e0ea;" + " border-radius: 8px;" + " color: #16202b;" + " font-weight: 700;" + " padding: 0;" + "}" + "QPushButton:hover {" + " background: #eef3f8;" + " border-color: #b8c6d6;" + "}"); + return button; +} + } // namespace SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout2D layout, QWidget* parent) @@ -32,8 +58,11 @@ SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout setMouseTracking(true); setFocusPolicy(Qt::StrongFocus); setMinimumSize(520, 360); + setCursor(Qt::OpenHandCursor); setStyleSheet("QWidget { background: #f4f7fb; }"); + camera_.setPrimaryButtonPanEnabled(true); layoutBounds_ = collectLayoutCanvasBounds(layout_); + createViewControls(); QCoreApplication::instance()->installEventFilter(this); } @@ -109,9 +138,7 @@ void SimulationCanvasWidget::keyReleaseEvent(QKeyEvent* event) { void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { - camera_.reset(); - layoutCacheValid_ = false; - update(); + resetView(); event->accept(); return; } @@ -120,7 +147,7 @@ void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (camera_.updatePan(event)) { - layoutCacheValid_ = false; + invalidateLayoutCache(); update(); return; } @@ -130,6 +157,7 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { void SimulationCanvasWidget::mousePressEvent(QMouseEvent* event) { setFocus(Qt::MouseFocusReason); if (camera_.beginPan(event)) { + setCursor(Qt::ClosedHandCursor); return; } QWidget::mousePressEvent(event); @@ -137,6 +165,7 @@ void SimulationCanvasWidget::mousePressEvent(QMouseEvent* event) { void SimulationCanvasWidget::mouseReleaseEvent(QMouseEvent* event) { if (camera_.finishPan(event)) { + setCursor(Qt::OpenHandCursor); return; } QWidget::mouseReleaseEvent(event); @@ -176,6 +205,11 @@ void SimulationCanvasWidget::paintEvent(QPaintEvent* event) { } } +void SimulationCanvasWidget::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + positionViewControls(); +} + void SimulationCanvasWidget::wheelEvent(QWheelEvent* event) { const auto bounds = collectBounds(); if (!bounds.has_value()) { @@ -183,13 +217,90 @@ void SimulationCanvasWidget::wheelEvent(QWheelEvent* event) { return; } if (camera_.zoomAt(event, *bounds, previewViewport())) { - layoutCacheValid_ = false; + invalidateLayoutCache(); update(); return; } QWidget::wheelEvent(event); } +void SimulationCanvasWidget::createViewControls() { + viewControls_ = new QFrame(this); + viewControls_->setObjectName("simulationViewControls"); + viewControls_->setStyleSheet( + "#simulationViewControls {" + " background: rgba(255, 255, 255, 230);" + " border: 1px solid #d7e0ea;" + " border-radius: 12px;" + "}" + ); + auto* layout = new QHBoxLayout(viewControls_); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(6); + + auto* zoomInButton = createViewControlButton("+", "Zoom in", viewControls_); + auto* zoomOutButton = createViewControlButton("-", "Zoom out", viewControls_); + auto* fitButton = createViewControlButton("Fit", "Fit to view", viewControls_); + layout->addWidget(zoomInButton); + layout->addWidget(zoomOutButton); + layout->addWidget(fitButton); + + connect(zoomInButton, &QPushButton::clicked, this, [this]() { + zoomAtCanvasPoint(rect().center(), 1.2); + }); + connect(zoomOutButton, &QPushButton::clicked, this, [this]() { + zoomAtCanvasPoint(rect().center(), 1.0 / 1.2); + }); + connect(fitButton, &QPushButton::clicked, this, [this]() { + resetView(); + }); + + viewControls_->adjustSize(); + positionViewControls(); +} + +void SimulationCanvasWidget::positionViewControls() { + if (viewControls_ == nullptr) { + return; + } + + viewControls_->adjustSize(); + const int margin = 14; + viewControls_->move(width() - viewControls_->width() - margin, margin); + viewControls_->raise(); +} + +void SimulationCanvasWidget::resetView() { + camera_.reset(); + invalidateLayoutCache(); + update(); +} + +void SimulationCanvasWidget::zoomAtCanvasPoint(const QPointF& anchorPoint, double factor) { + const auto bounds = collectBounds(); + if (!bounds.has_value() || factor <= 0.0) { + return; + } + + const auto viewport = previewViewport(); + if (viewport.width() <= 0.0 || viewport.height() <= 0.0) { + return; + } + + const LayoutCanvasTransform currentTransform(*bounds, viewport, camera_.zoom(), camera_.panOffset()); + const auto anchorWorld = currentTransform.unmap(anchorPoint); + camera_.setZoom(std::clamp(camera_.zoom() * factor, 0.1, 50.0)); + + const LayoutCanvasTransform updatedTransform(*bounds, viewport, camera_.zoom(), camera_.panOffset()); + camera_.setPanOffset(camera_.panOffset() + anchorPoint - updatedTransform.map(anchorWorld)); + invalidateLayoutCache(); + update(); +} + +void SimulationCanvasWidget::invalidateLayoutCache() { + layoutCacheValid_ = false; +} + std::optional SimulationCanvasWidget::collectBounds() const { return layoutBounds_; } @@ -242,7 +353,7 @@ void SimulationCanvasWidget::focusWorldPoint(const safecrowd::domain::Point2D& p const LayoutCanvasTransform transform(*bounds, viewport, camera_.zoom(), {}); camera_.setPanOffset(viewport.center() - transform.map(point)); - layoutCacheValid_ = false; + invalidateLayoutCache(); update(); } diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 527acd3..1f21f18 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -13,10 +13,12 @@ #include "domain/ScenarioSimulationRunner.h" class QEvent; +class QFrame; class QKeyEvent; class QMouseEvent; class QPainter; class QPaintEvent; +class QResizeEvent; class QWheelEvent; namespace safecrowd::application { @@ -41,9 +43,15 @@ class SimulationCanvasWidget : public QWidget { void mousePressEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; void wheelEvent(QWheelEvent* event) override; private: + void createViewControls(); + void positionViewControls(); + void resetView(); + void zoomAtCanvasPoint(const QPointF& anchorPoint, double factor); + void invalidateLayoutCache(); std::optional collectBounds() const; LayoutCanvasTransform currentTransform(const LayoutCanvasBounds& bounds) const; void refreshLayoutCache(const LayoutCanvasBounds& bounds); @@ -59,6 +67,7 @@ class SimulationCanvasWidget : public QWidget { std::optional focusedHotspotIndex_{}; std::optional focusedBottleneckIndex_{}; LayoutCanvasCamera camera_{}; + QFrame* viewControls_{nullptr}; std::optional layoutBounds_{}; QPixmap layoutCache_{}; QSize layoutCacheSize_{}; diff --git a/src/application/WorkspaceShell.cpp b/src/application/WorkspaceShell.cpp index 04ee032..62d9f5a 100644 --- a/src/application/WorkspaceShell.cpp +++ b/src/application/WorkspaceShell.cpp @@ -240,7 +240,8 @@ QWidget* WorkspaceShell::createPanelHeader(const QString& title, QWidget* parent auto* label = new QLabel(title, header); label->setFont(ui::font(ui::FontRole::Title)); - label->setWordWrap(false); + label->setWordWrap(true); + label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); layout->addWidget(label, 1, Qt::AlignVCenter); return header; } diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index 3bade0c..ca4ccdc 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -22,6 +22,28 @@ DemoFixture DemoFixtureService::createSprint1DemoFixture() const { .targetAgentCount = 100, }); + fixture.baselineScenario.scenarioId = "scenario-sprint1-baseline"; + fixture.baselineScenario.name = "Baseline evacuation"; + fixture.baselineScenario.role = ScenarioRole::Baseline; + fixture.baselineScenario.population = fixture.population; + fixture.baselineScenario.execution.timeLimitSeconds = 180.0; + fixture.baselineScenario.execution.sampleIntervalSeconds = 1.0; + fixture.baselineScenario.execution.repeatCount = 1; + fixture.baselineScenario.execution.baseSeed = 1; + fixture.baselineScenario.sourceTemplateId = "sprint1-baseline"; + fixture.baselineScenario.control.events.push_back({ + .id = "event-exit-closure", + .name = "Exit Closure", + .triggerSummary = "Operator command", + .targetSummary = "Primary exit route", + }); + fixture.baselineScenario.control.events.push_back({ + .id = "event-staged-release", + .name = "Staged Release", + .triggerSummary = "After initial evacuation wave", + .targetSummary = "Main Demo Room occupants", + }); + return fixture; } diff --git a/src/domain/DemoFixtureService.h b/src/domain/DemoFixtureService.h index 228da40..14ed6ce 100644 --- a/src/domain/DemoFixtureService.h +++ b/src/domain/DemoFixtureService.h @@ -2,12 +2,14 @@ #include "domain/FacilityLayout2D.h" #include "domain/PopulationSpec.h" +#include "domain/ScenarioAuthoring.h" namespace safecrowd::domain { struct DemoFixture { FacilityLayout2D layout{}; PopulationSpec population{}; + ScenarioDraft baselineScenario{}; }; class DemoFixtureService { diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 771bbb6..d61c90c 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -8,6 +8,7 @@ #include "domain/DemoFixtureService.h" #include "domain/ImportIssue.h" #include "domain/ImportValidationService.h" +#include "domain/ScenarioSimulationRunner.h" namespace { @@ -83,6 +84,13 @@ SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { SC_EXPECT_EQ(population.initialPlacements.front().targetAgentCount, std::size_t{100}); SC_EXPECT_EQ(population.initialPlacements.front().area.outline.size(), std::size_t{4}); + SC_EXPECT_EQ(fixture.baselineScenario.name, std::string("Baseline evacuation")); + SC_EXPECT_EQ(fixture.baselineScenario.role, safecrowd::domain::ScenarioRole::Baseline); + SC_EXPECT_EQ(fixture.baselineScenario.population.initialPlacements.size(), std::size_t{1}); + SC_EXPECT_EQ(fixture.baselineScenario.control.events.size(), std::size_t{2}); + SC_EXPECT_EQ(fixture.baselineScenario.execution.timeLimitSeconds, 180.0); + SC_EXPECT_EQ(fixture.baselineScenario.execution.sampleIntervalSeconds, 1.0); + safecrowd::domain::ImportValidationService validator; const auto issues = validator.validate(layout); SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); @@ -116,3 +124,19 @@ SC_TEST(DemoLayoutsProvidesRuntimeFacilityLayout) { const auto issues = validator.validate(layout); SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); } + +SC_TEST(DemoFixtureBaselineScenarioRunsToResultArtifacts) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createSprint1DemoFixture(); + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.baselineScenario); + for (int i = 0; i < 720 && !runner.complete(); ++i) { + runner.step(0.25); + } + + SC_EXPECT_TRUE(runner.complete()); + SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{100}); + SC_EXPECT_TRUE(!runner.resultArtifacts().evacuationProgress.empty()); + SC_EXPECT_TRUE(runner.resultArtifacts().timingSummary.t90Seconds.has_value() + || runner.frame().elapsedSeconds >= fixture.baselineScenario.execution.timeLimitSeconds); +} From 5d879a159ec5104e59cfc7034f0733212071a84e Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Thu, 30 Apr 2026 16:22:32 +0900 Subject: [PATCH 5/5] [Application] Clarify staged scenario action state --- src/application/ScenarioAuthoringWidget.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 0720045..6983e8d 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -557,9 +557,12 @@ void ScenarioAuthoringWidget::refreshInspector() { } } if (stageScenarioButton_ != nullptr) { - stageScenarioButton_->setEnabled(readiness.missingStageItems.isEmpty()); - stageScenarioButton_->setText(hasScenario && scenario->stagedForRun ? "Staged for Run" : "Stage Scenario"); - stageScenarioButton_->setToolTip(readinessListText("This scenario can be staged for run.", readiness.missingStageItems)); + const bool alreadyStaged = hasScenario && scenario->stagedForRun; + stageScenarioButton_->setEnabled(readiness.missingStageItems.isEmpty() && !alreadyStaged); + stageScenarioButton_->setText(alreadyStaged ? "Already Staged" : "Stage Scenario"); + stageScenarioButton_->setToolTip(alreadyStaged + ? "This scenario is already staged. Open the Run panel to run it." + : readinessListText("This scenario can be staged for run.", readiness.missingStageItems)); } if (executeRunButton_ != nullptr) { executeRunButton_->setEnabled(readiness.missingRunItems.isEmpty()); @@ -751,6 +754,9 @@ void ScenarioAuthoringWidget::stageCurrentScenario() { if (scenario == nullptr) { return; } + if (scenario->stagedForRun) { + return; + } scenario->stagedForRun = true; refreshInspector();