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/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index fea327b..812b0b9 100644 --- a/src/application/LayoutReviewWidget.cpp +++ b/src/application/LayoutReviewWidget.cpp @@ -337,6 +337,7 @@ QWidget* createReviewPanel( QLabel** inspectorTitle, QLabel** inspectorDetail, QLabel** approvalStatus, + QPushButton** undoButton, QPushButton** approveButton, QWidget* parent) { auto* panel = new QWidget(parent); @@ -367,9 +368,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; @@ -406,6 +414,7 @@ LayoutReviewWidget::LayoutReviewWidget( &inspectorTitleLabel_, &inspectorDetailLabel_, &approvalStatusLabel_, + &undoButton_, &approveButton_, shell_); @@ -426,6 +435,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]() { @@ -490,10 +502,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) { @@ -501,7 +523,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 3533404..06a4019 100644 --- a/src/application/LayoutReviewWidget.h +++ b/src/application/LayoutReviewWidget.h @@ -58,6 +58,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/MainWindow.cpp b/src/application/MainWindow.cpp index 06c615a..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) @@ -152,6 +218,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); @@ -197,6 +268,13 @@ void MainWindow::showLayoutReview(const ProjectMetadata& metadata, safecrowd::do 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; + } + } lastApprovedImportResult_ = approvedImportResult; showScenarioAuthoring(approvedImportResult); }, @@ -209,26 +287,50 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im return; } + ScenarioAuthoringWidget::InitialState initialState; + const bool hasSavedScenarioState = ProjectPersistence::loadScenarioAuthoringState( + currentProject_, + *importResult.layout, + &initialState); + if (currentProject_.isBuiltInDemo()) { + initialState = makeDemoScenarioAuthoringState(*importResult.layout); + } lastApprovedImportResult_ = importResult; + auto saveHandler = [this]() { + saveCurrentProject(); + }; + auto openProjectHandler = [this]() { + hasCurrentProject_ = false; + currentProject_ = {}; + showProjectNavigator(); + }; + auto backToLayoutReviewHandler = [this]() { + if (lastApprovedImportResult_.has_value()) { + showLayoutReview(currentProject_, *lastApprovedImportResult_); + } else { + showLayoutReview(currentProject_); + } + }; + + if (currentProject_.isBuiltInDemo() || hasSavedScenarioState) { + setCentralWidget(new ScenarioAuthoringWidget( + currentProject_.name, + *importResult.layout, + std::move(initialState), + saveHandler, + openProjectHandler, + backToLayoutReviewHandler, + this)); + return; + } + setCentralWidget(new ScenarioAuthoringWidget( currentProject_.name, *importResult.layout, - [this]() { - saveCurrentProject(); - }, - [this]() { - hasCurrentProject_ = false; - currentProject_ = {}; - showProjectNavigator(); - }, - [this]() { - if (lastApprovedImportResult_.has_value()) { - showLayoutReview(currentProject_, *lastApprovedImportResult_); - } else { - showLayoutReview(currentProject_); - } - }, + saveHandler, + openProjectHandler, + backToLayoutReviewHandler, this)); } diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index ca9dbe8..6624671 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1,6 +1,8 @@ #include "application/ProjectPersistence.h" #include +#include +#include #include #include @@ -19,11 +21,13 @@ namespace { constexpr auto kProjectFileName = "safecrowd-project.json"; constexpr auto kLayoutFileName = "layout.dxf"; constexpr auto kReviewFileName = "layout-review.json"; +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) { @@ -34,6 +38,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); @@ -278,6 +286,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); @@ -476,6 +492,229 @@ 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 = 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); + 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) { + 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 = kind, + .zoneId = object.value("zoneId").toString(), + .area = placementAreaFromJson(object.value("area").toArray()), + .occupantCount = std::max(1, 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; +} + +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; + } + + 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)); + } +} + +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); + 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 = 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()); + 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; +} + +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() { @@ -642,4 +881,64 @@ bool ProjectPersistence::saveProjectReview( return writeJsonDocument(reviewFilePath(metadata.folderPath), QJsonDocument(root), errorMessage); } +bool ProjectPersistence::loadScenarioAuthoringState( + const ProjectMetadata& metadata, + const safecrowd::domain::FacilityLayout2D& layout, + 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 = 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())); + } + for (auto& scenario : loaded.scenarios) { + removePlacementsOutsideLayout(layout, &scenario); + } + + 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 de2c7d3..4406fa3 100644 --- a/src/application/ProjectPersistence.h +++ b/src/application/ProjectPersistence.h @@ -3,6 +3,8 @@ #include #include "application/ProjectMetadata.h" +#include "application/ScenarioAuthoringWidget.h" +#include "domain/FacilityLayout2D.h" #include "domain/ImportResult.h" namespace safecrowd::application { @@ -18,6 +20,14 @@ class ProjectPersistence { const ProjectMetadata& metadata, const safecrowd::domain::ImportResult& importResult, QString* errorMessage = nullptr); + static bool loadScenarioAuthoringState( + const ProjectMetadata& metadata, + const safecrowd::domain::FacilityLayout2D& layout, + 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 6cd05cb..6983e8d 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -57,6 +57,48 @@ 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 = "Operator command", + .targetSummary = "Primary exit route", + }, + { + .name = "Staged Release", + .triggerSummary = "After initial evacuation wave", + .targetSummary = "Selected start area occupants", + }, + }; + 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- ")); +} + +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); @@ -168,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{}, @@ -214,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, @@ -227,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, @@ -236,15 +279,69 @@ std::vector buildEventsTree(const ScenarioAuthoringWidget::S QWidget* createEventsPanel( const ScenarioAuthoringWidget::ScenarioState* scenario, + std::function addEventHandler, const WorkspaceShell* shell, QWidget* parent) { - return new NavigationTreeWidget( - "Events", + auto* content = new QWidget(parent); + auto* layout = new QVBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(12); + layout->addWidget(shell != nullptr ? shell->createPanelHeader("Scenario Events", content, false) : createLabel("Scenario Events", content, ui::FontRole::Title)); + + auto* libraryHeader = createLabel("Configured 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* 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, card, [=]() { + if (handler) { + handler(name, triggerSummary, targetSummary); + } + }); + cardLayout->addWidget(button); + layout->addWidget(card); + }; + + for (const auto& preset : sprint1EventPresets()) { + addPresetButton(preset); + } + + layout->addWidget(new NavigationTreeWidget( + "Configured Events", buildEventsTree(scenario), - "No operational events yet", + "No configured scenario events yet", {}, - parent, - shell != nullptr ? shell->createPanelHeader("Events", parent, false) : nullptr); + content, + createLabel("Configured Events", content, ui::FontRole::SectionTitle)), 1); + return content; } } // namespace @@ -316,16 +413,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(), }); @@ -420,13 +517,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")); } } @@ -449,9 +547,26 @@ 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_->setText(hasScenario && scenario->stagedForRun ? "Staged for Run" : "Stage Scenario"); + 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()); + executeRunButton_->setToolTip(readinessListText("Run the staged baseline scenario.", readiness.missingRunItems)); } } @@ -493,13 +608,20 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { shell_)); return; } - shell_->setNavigationPanel(createEventsPanel(currentScenario(), shell_, shell_)); + shell_->setNavigationPanel(createEventsPanel( + currentScenario(), + [this](const QString& name, const QString& trigger, const QString& target) { + addEventDraft(name, trigger, target); + }, + shell_, + shell_)); } void ScenarioAuthoringWidget::refreshRightPanel() { scenarioSwitcher_ = nullptr; scenarioSummaryLabel_ = nullptr; changesLabel_ = nullptr; + readinessLabel_ = nullptr; newScenarioButton_ = nullptr; stageScenarioButton_ = nullptr; stagedScenariosLabel_ = nullptr; @@ -537,6 +659,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) { @@ -558,6 +688,13 @@ void ScenarioAuthoringWidget::runFirstStagedBaselineScenario() { saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, + [this](bool showRunPanel) { + returnFromRun(showRunPanel); + }, + [this]() { + returnFromRun(true); + runFirstStagedBaselineScenario(); + }, this); rootLayout->replaceWidget(shell_, runWidget); shell_->hide(); @@ -576,11 +713,50 @@ 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_->setBackHandler(backToLayoutReviewHandler_); + 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; } + if (scenario->stagedForRun) { + return; + } scenario->stagedForRun = true; refreshInspector(); @@ -625,7 +801,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); @@ -678,30 +854,41 @@ 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)").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_); + + readinessLabel_ = createLabel("", panel); + readinessLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + 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()); - executeRunButton_->setEnabled(stagedCount > 0); layout->addWidget(executeRunButton_); connect(executeRunButton_, &QPushButton::clicked, this, [this]() { runFirstStagedBaselineScenario(); }); + refreshInspector(); return panel; } @@ -731,6 +918,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); @@ -791,6 +982,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; @@ -812,4 +1037,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 f23a473..0a67332 100644 --- a/src/application/ScenarioAuthoringWidget.h +++ b/src/application/ScenarioAuthoringWidget.h @@ -4,6 +4,7 @@ #include #include +#include #include #include "application/ScenarioCanvasWidget.h" @@ -66,6 +67,8 @@ class ScenarioAuthoringWidget : public QWidget { std::function backToLayoutReviewHandler, QWidget* parent = nullptr); + [[nodiscard]] InitialState currentState() const; + private: void initializeUi(bool promptForScenario); void addEventDraft(const QString& name, const QString& trigger, const QString& target); @@ -77,6 +80,7 @@ class ScenarioAuthoringWidget : public QWidget { void refreshNavigationPanel(); void refreshRightPanel(); void refreshScenarioSwitcher(); + void returnFromRun(bool showRunPanel); void runFirstStagedBaselineScenario(); void setRightPanelMode(RightPanelMode mode); void stageCurrentScenario(); @@ -86,6 +90,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; @@ -106,6 +120,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 c1e44c9..a354d13 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "application/ScenarioAuthoringWidget.h" @@ -37,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( @@ -159,6 +215,28 @@ 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"; + } + const auto& bottleneck = risk.bottlenecks.front(); + return QString("%1\n%2 nearby, %3 stalled") + .arg(QString::fromStdString(bottleneck.label)) + .arg(static_cast(bottleneck.nearbyAgentCount)) + .arg(static_cast(bottleneck.stalledAgentCount)); +} + QLabel* createReportSectionHeader(const QString& text, QWidget* parent) { auto* label = createLabel(text, parent, ui::FontRole::SectionTitle); label->setStyleSheet(ui::mutedTextStyleSheet()); @@ -171,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; } @@ -235,59 +313,18 @@ QWidget* createHotspotLegend(QWidget* parent) { return legend; } -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); +QString configuredEventSummary(const safecrowd::domain::ScenarioDraft& scenario) { + if (scenario.control.events.empty()) { + return "None"; } - 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)); + QStringList names; + for (const auto& event : scenario.control.events) { + names << QString::fromStdString(event.name); } - - return state; + return QString("%1 configured\n%2") + .arg(static_cast(scenario.control.events.size())) + .arg(names.join(", ")); } QWidget* createResultPanel( @@ -304,38 +341,44 @@ QWidget* createResultPanel( layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(shell != nullptr ? shell->createPanelHeader("Results", panel) : createLabel("Results", panel, ui::FontRole::Title)); - auto* scenarioLabel = createLabel(QString("Scenario: %1").arg(QString::fromStdString(scenario.name)), panel); + layout->addWidget(shell != nullptr ? shell->createPanelHeader("Baseline Result", panel) : 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); + 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); + 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("Remaining", QString("%1 / %2").arg(remaining).arg(total), panel), 1, 0); metricsGrid->addWidget(createMetricCard( - "Risk", - safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), - panel, - safecrowd::domain::scenarioRiskDefinition()), 1, 0); - metricsGrid->addWidget(createMetricCard( - "Stalled", - QString::number(static_cast(risk.stalledAgentCount)), - panel, - safecrowd::domain::scenarioStalledDefinition()), 1, 1); + "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); metricsGrid->addWidget(createMetricCard( "T90", formatOptionalSeconds(artifacts.timingSummary.t90Seconds), panel, - "Time at which 90% of occupants completed evacuation."), 2, 0); + "Time at which 90% of occupants completed evacuation."), 4, 0); metricsGrid->addWidget(createMetricCard( "T95", formatOptionalSeconds(artifacts.timingSummary.t95Seconds), panel, - "Time at which 95% of occupants completed evacuation."), 2, 1); + "Time at which 95% of occupants completed evacuation."), 4, 1); layout->addLayout(metricsGrid); layout->addStretch(1); @@ -470,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)), @@ -480,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); @@ -537,6 +584,11 @@ ScenarioResultWidget::ScenarioResultWidget( } void ScenarioResultWidget::rerunScenario() { + if (rerunScenarioHandler_) { + rerunScenarioHandler_(); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; @@ -549,6 +601,8 @@ void ScenarioResultWidget::rerunScenario() { saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, + returnToAuthoringHandler_, + rerunScenarioHandler_, this); rootLayout->replaceWidget(shell_, runWidget); @@ -558,6 +612,11 @@ void ScenarioResultWidget::rerunScenario() { } void ScenarioResultWidget::navigateToAuthoring(bool showRunPanel) { + if (returnToAuthoringHandler_) { + returnToAuthoringHandler_(showRunPanel); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; 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 df50b54..2d5b6d1 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,54 @@ 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); +} + +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(", ")); +} + enum class TransportIconKind { Play, Pause, @@ -169,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), @@ -177,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); @@ -223,7 +276,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(shell_ != nullptr ? shell_->createPanelHeader("Run", panel) : createLabel("Run", panel, ui::FontRole::Title)); + layout->addWidget(shell_ != nullptr ? shell_->createPanelHeader("Baseline Run", panel) : createLabel("Baseline Run", panel, ui::FontRole::Title)); scenarioLabel_ = createLabel("", panel); scenarioLabel_->setStyleSheet(ui::mutedTextStyleSheet()); statusLabel_ = createLabel("", panel); @@ -233,6 +286,8 @@ QWidget* ScenarioRunWidget::createRunPanel() { timeProgressBar_ = createProgressBar("Time progress against the scenario time limit.", panel); agentCountLabel_ = createLabel("", panel); agentCountLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + eventLabel_ = createLabel("", panel); + eventLabel_->setStyleSheet(ui::mutedTextStyleSheet()); evacuationProgressBar_ = createProgressBar("Evacuation progress based on evacuated agents divided by total agents.", panel); riskLabel_ = createLabel("", panel); riskLabel_->setStyleSheet(ui::mutedTextStyleSheet()); @@ -250,6 +305,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->addWidget(elapsedLabel_); layout->addWidget(timeProgressBar_); layout->addWidget(agentCountLabel_); + layout->addWidget(eventLabel_); layout->addWidget(evacuationProgressBar_); layout->addWidget(riskLabel_); layout->addWidget(congestionLabel_); @@ -259,7 +315,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { transportLayout->setContentsMargins(0, 0, 0, 0); transportLayout->setSpacing(8); pauseButton_ = createIconButton(TransportIconKind::Pause, "Pause simulation", panel); - stopButton_ = createIconButton(TransportIconKind::Stop, "Stop and reset run", panel); + stopButton_ = createIconButton(TransportIconKind::Stop, "Reset run to start; no result is created", panel); transportLayout->addWidget(pauseButton_); transportLayout->addWidget(stopButton_); transportLayout->addStretch(1); @@ -271,6 +327,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]() { @@ -291,6 +348,11 @@ void ScenarioRunWidget::returnToAuthoring() { timer_->stop(); } + if (returnToAuthoringHandler_) { + returnToAuthoringHandler_(true); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; @@ -321,10 +383,10 @@ void ScenarioRunWidget::returnToAuthoring() { 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(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") @@ -340,6 +402,9 @@ void ScenarioRunWidget::refreshStatus() { .arg(static_cast(frame.totalAgentCount)) .arg(static_cast(frame.agents.size()))); } + if (eventLabel_ != nullptr) { + eventLabel_->setText(configuredEventSummary(scenario_)); + } if (evacuationProgressBar_ != nullptr) { evacuationProgressBar_->setValue(percentValue( static_cast(frame.evacuatedAgentCount), @@ -352,23 +417,22 @@ 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()) { 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) { @@ -380,10 +444,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) { resultButton_->setEnabled(frame.complete && frame.totalAgentCount > 0); + resultButton_->setToolTip(frame.complete + ? QString("Open results: %1").arg(completionOutcome(frame)) + : "Available after evacuation completes or the time limit is reached"); } } @@ -427,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 81663b8..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}; @@ -53,6 +57,7 @@ class ScenarioRunWidget : public QWidget { QLabel* elapsedLabel_{nullptr}; QProgressBar* timeProgressBar_{nullptr}; QLabel* agentCountLabel_{nullptr}; + QLabel* eventLabel_{nullptr}; QProgressBar* evacuationProgressBar_{nullptr}; QLabel* riskLabel_{nullptr}; QLabel* congestionLabel_{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); +}