From 4b749bbbcbf0ecab8d30ce5a308ad7bca4f04451 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Fri, 15 May 2026 01:56:07 +0900 Subject: [PATCH 1/5] [Domain] Add two-floor evacuation demo map --- ...4 \352\260\200\354\235\264\353\223\234.md" | 11 + src/application/MainWindow.cpp | 57 ++- src/application/ProjectMetadata.h | 19 +- src/application/ProjectPersistence.cpp | 1 + src/domain/DemoFixtureService.cpp | 86 ++++ src/domain/DemoFixtureService.h | 8 + src/domain/DemoLayouts.cpp | 382 +++++++++++++++++- src/domain/DemoLayouts.h | 34 +- tests/DemoFixtureServiceTests.cpp | 105 +++++ 9 files changed, 692 insertions(+), 11 deletions(-) diff --git "a/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" "b/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" index d1e71da..9becdbd 100644 --- "a/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" @@ -2,6 +2,7 @@ Sprint 1 시연에서 사용할 도면 자산과 각 도면이 강조하는 결과 지표를 정리합니다. 도면 파일은 [`assets/demo-layouts/`](../../assets/demo-layouts/) 에 있습니다. +앱 시작 화면의 built-in 프로젝트에는 코드 fixture 기반의 `Two-floor Evacuation Demo`도 포함됩니다. ## 시연 흐름 요약 @@ -47,6 +48,16 @@ Sprint 1 시연에서 사용할 도면 자산과 각 도면이 강조하는 결 - `DensitySummary.peakField` 의 피크 셀이 복도 중앙 또는 로비 출구 직전에 형성 - **보여줄 화면**: 존별 완료 시간 표, 출구 사용 막대그래프, 복도/로비 영역의 밀도 히트맵, 리플레이로 군중이 복도를 통해 모이는 흐름 +### 4. 앱 내장 `Two-floor Evacuation Demo` — 2층 대피 시나리오 시연 + +- **구조**: 2층의 서측 교육실, 중앙 브리핑룸, 동측 교육실에서 복도로 나온 뒤 양쪽 U자형 계단을 통해 1층 로비와 전실을 거쳐 서측/동측 출구로 대피하는 내장 layout. +- **메시지**: "다층 평면에서 층 선택, 계단 전환, 출구 유도 시나리오가 앱에서 바로 실행되는가." +- **강조 지표**: + - 2층 보행자가 계단 연결을 지나 1층 출구로 이동 + - baseline과 우측 출구 유도 alternative를 같은 run 화면에서 실행 가능 + - 결과 화면의 floor selector로 1층/2층 상태를 나누어 확인 가능 +- **보여줄 화면**: Project Navigator에서 `Two-floor Evacuation Demo` 열기, Run Workspace 실행, Result Summary의 층별 canvas와 출구 사용 지표 + ## 시연 셋업 권장값 (참고) | 항목 | 권장 | diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index acdc026..94f5381 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -35,12 +35,9 @@ void applySavedReviewState(const ProjectMetadata& metadata, safecrowd::domain::I ProjectPersistence::loadProjectReview(metadata, importResult); } -safecrowd::domain::ImportResult makeDemoImportResult() { - safecrowd::domain::DemoFixtureService fixtureService; - const auto fixture = fixtureService.createSprint1DemoFixture(); - +safecrowd::domain::ImportResult makeImportResultForDemoLayout(safecrowd::domain::FacilityLayout2D layout) { safecrowd::domain::ImportResult result; - result.layout = fixture.layout; + result.layout = std::move(layout); safecrowd::domain::ImportValidationService validator; result.issues = validator.validate(*result.layout); @@ -50,6 +47,18 @@ safecrowd::domain::ImportResult makeDemoImportResult() { return result; } +safecrowd::domain::ImportResult makeSprint1DemoImportResult() { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createSprint1DemoFixture(); + return makeImportResultForDemoLayout(fixture.layout); +} + +safecrowd::domain::ImportResult makeTwoFloorEvacuationDemoImportResult() { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createTwoFloorEvacuationDemoFixture(); + return makeImportResultForDemoLayout(fixture.layout); +} + ProjectWorkspaceState makeEvacuationScenarioDemoWorkspace() { using namespace safecrowd::domain; @@ -90,6 +99,35 @@ ProjectWorkspaceState makeEvacuationScenarioDemoWorkspace() { return workspace; } +ProjectWorkspaceState makeTwoFloorEvacuationDemoWorkspace() { + using namespace safecrowd::domain; + + safecrowd::domain::DemoFixtureService fixtureService; + auto fixture = fixtureService.createTwoFloorEvacuationDemoFixture(); + + SavedScenarioAuthoringState authoring; + authoring.scenarios.push_back({ + .draft = fixture.baselineScenario, + .baseScenarioId = {}, + .stagedForRun = true, + }); + authoring.scenarios.push_back({ + .draft = fixture.alternativeScenario, + .baseScenarioId = fixture.baselineScenario.scenarioId, + .stagedForRun = true, + }); + authoring.currentScenarioIndex = 1; + authoring.navigationView = SavedNavigationView::Events; + authoring.rightPanelMode = SavedRightPanelMode::Scenario; + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioRun; + workspace.authoring = std::move(authoring); + workspace.runningScenario = fixture.alternativeScenario; + workspace.runningScenarios = {fixture.baselineScenario, fixture.alternativeScenario}; + return workspace; +} + safecrowd::domain::ImportResult makeBlankImportResult(const QString& projectName) { safecrowd::domain::ImportResult result; result.layout = safecrowd::domain::FacilityLayout2D{ @@ -111,8 +149,11 @@ safecrowd::domain::ImportResult makeBlankImportResult(const QString& projectName } safecrowd::domain::ImportResult importProjectLayout(const ProjectMetadata& metadata) { - if (metadata.isBuiltInDemo()) { - return makeDemoImportResult(); + if (metadata.isBuiltInTwoFloorEvacuationDemo()) { + return makeTwoFloorEvacuationDemoImportResult(); + } + if (metadata.layoutPath == builtInDemoLayoutPath() || metadata.isBuiltInEvacuationScenarioDemo()) { + return makeSprint1DemoImportResult(); } if (metadata.isBlankLayoutProject()) { return makeBlankImportResult(metadata.name); @@ -404,6 +445,8 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { ProjectWorkspaceState workspace; if (metadata.isBuiltInEvacuationScenarioDemo()) { workspace = makeEvacuationScenarioDemoWorkspace(); + } else if (metadata.isBuiltInTwoFloorEvacuationDemo()) { + workspace = makeTwoFloorEvacuationDemoWorkspace(); } else if (!ProjectPersistence::loadProjectWorkspace(metadata, &workspace)) { showLayoutReview(metadata, std::move(importResult)); return; diff --git a/src/application/ProjectMetadata.h b/src/application/ProjectMetadata.h index 59becbc..d2f4bd2 100644 --- a/src/application/ProjectMetadata.h +++ b/src/application/ProjectMetadata.h @@ -12,6 +12,10 @@ inline QString builtInEvacuationScenarioDemoLayoutPath() { return QStringLiteral("safecrowd://demo/evacuation-scenario"); } +inline QString builtInTwoFloorEvacuationDemoLayoutPath() { + return QStringLiteral("safecrowd://demo/two-floor-evacuation"); +} + struct ProjectMetadata { QString name{}; QString folderPath{}; @@ -19,13 +23,19 @@ struct ProjectMetadata { QString savedAt{}; bool isBuiltInDemo() const noexcept { - return layoutPath == builtInDemoLayoutPath() || isBuiltInEvacuationScenarioDemo(); + return layoutPath == builtInDemoLayoutPath() + || isBuiltInEvacuationScenarioDemo() + || isBuiltInTwoFloorEvacuationDemo(); } bool isBuiltInEvacuationScenarioDemo() const noexcept { return layoutPath == builtInEvacuationScenarioDemoLayoutPath(); } + bool isBuiltInTwoFloorEvacuationDemo() const noexcept { + return layoutPath == builtInTwoFloorEvacuationDemoLayoutPath(); + } + bool isBlankLayoutProject() const noexcept { return layoutPath.isEmpty(); } @@ -52,4 +62,11 @@ inline ProjectMetadata makeBuiltInEvacuationScenarioDemoProject() { }; } +inline ProjectMetadata makeBuiltInTwoFloorEvacuationDemoProject() { + return { + .name = QStringLiteral("Two-floor Evacuation Demo"), + .layoutPath = builtInTwoFloorEvacuationDemoLayoutPath(), + }; +} + } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index a631bb5..b329741 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1621,6 +1621,7 @@ QList ProjectPersistence::loadRecentProjects() { QList projects; projects.push_back(makeBuiltInDemoProject()); projects.push_back(makeBuiltInEvacuationScenarioDemoProject()); + projects.push_back(makeBuiltInTwoFloorEvacuationDemoProject()); const auto document = readJsonDocument(recentProjectsPath()); if (!document.isObject()) { diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index 2c86ff2..a5189a7 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -22,6 +22,28 @@ ScenarioDraft makeSprint1BlockedDoorAlternative(const ScenarioDraft& baseline) { return alternative; } +ScenarioDraft makeTwoFloorEastExitGuidanceAlternative(const ScenarioDraft& baseline) { + using Ids = DemoLayouts::TwoFloorEvacuationIds; + + auto alternative = duplicateScenarioDraft( + baseline, + "two-floor-guidance", + "East exit guidance alternative"); + alternative.control.routeGuidances.push_back({ + .id = "guidance-east-exit", + .startSeconds = 0.0, + .endSeconds = 180.0, + .periods = {{.startSeconds = 0.0, .endSeconds = 180.0}}, + .guidedExitZoneId = Ids::EastExitZoneId, + .installConnectionId = Ids::UpperCorridorEastStairConnectionId, + .baseComplianceRate = 0.85, + .guidanceStrength = 0.85, + .maxDetourMeters = 30.0, + }); + alternative.variationDiffKeys = computeScenarioDiffKeys(baseline, alternative); + return alternative; +} + DensityCellMetric densityCell( double centerX, double centerY, @@ -779,6 +801,70 @@ DemoFixture DemoFixtureService::createSprint1DemoFixture() const { return fixture; } +DemoAuthoringFixture DemoFixtureService::createTwoFloorEvacuationDemoFixture() const { + using Ids = DemoLayouts::TwoFloorEvacuationIds; + + DemoAuthoringFixture fixture; + fixture.layout = DemoLayouts::twoFloorEvacuationFacility(); + + fixture.population.initialPlacements.push_back({ + .id = "two-floor-west-training-crowd", + .zoneId = Ids::UpperWestTrainingZoneId, + .floorId = Ids::UpperFloorId, + .area = { + .outline = { + {2.0, 2.0}, + {8.5, 2.0}, + {8.5, 5.0}, + {2.0, 5.0}, + }, + }, + .targetAgentCount = 30, + }); + fixture.population.initialPlacements.push_back({ + .id = "two-floor-briefing-crowd", + .zoneId = Ids::UpperBriefingZoneId, + .floorId = Ids::UpperFloorId, + .area = { + .outline = { + {11.0, 2.0}, + {17.0, 2.0}, + {17.0, 5.0}, + {11.0, 5.0}, + }, + }, + .targetAgentCount = 20, + }); + fixture.population.initialPlacements.push_back({ + .id = "two-floor-east-training-crowd", + .zoneId = Ids::UpperEastTrainingZoneId, + .floorId = Ids::UpperFloorId, + .area = { + .outline = { + {19.5, 2.0}, + {26.0, 2.0}, + {26.0, 5.0}, + {19.5, 5.0}, + }, + }, + .targetAgentCount = 30, + }); + + fixture.baselineScenario.scenarioId = "two-floor-baseline"; + fixture.baselineScenario.name = "Two-floor evacuation baseline"; + fixture.baselineScenario.role = ScenarioRole::Baseline; + fixture.baselineScenario.population = fixture.population; + fixture.baselineScenario.execution.timeLimitSeconds = 600.0; + fixture.baselineScenario.execution.sampleIntervalSeconds = 1.0; + fixture.baselineScenario.execution.repeatCount = 1; + fixture.baselineScenario.execution.baseSeed = 42; + fixture.baselineScenario.sourceTemplateId = "two-floor-evacuation-baseline"; + + fixture.alternativeScenario = makeTwoFloorEastExitGuidanceAlternative(fixture.baselineScenario); + + return fixture; +} + DemoScenarioResultFixture DemoFixtureService::createSprint1BlockedDoorResultFixture() const { const auto baselineFixture = createSprint1DemoFixture(); diff --git a/src/domain/DemoFixtureService.h b/src/domain/DemoFixtureService.h index 02a5b8f..320056b 100644 --- a/src/domain/DemoFixtureService.h +++ b/src/domain/DemoFixtureService.h @@ -15,6 +15,13 @@ struct DemoFixture { ScenarioDraft baselineScenario{}; }; +struct DemoAuthoringFixture { + FacilityLayout2D layout{}; + PopulationSpec population{}; + ScenarioDraft baselineScenario{}; + ScenarioDraft alternativeScenario{}; +}; + struct DemoScenarioResultFixture { FacilityLayout2D layout{}; PopulationSpec population{}; @@ -28,6 +35,7 @@ struct DemoScenarioResultFixture { class DemoFixtureService { public: DemoFixture createSprint1DemoFixture() const; + DemoAuthoringFixture createTwoFloorEvacuationDemoFixture() const; DemoScenarioResultFixture createSprint1BlockedDoorResultFixture() const; }; diff --git a/src/domain/DemoLayouts.cpp b/src/domain/DemoLayouts.cpp index ec72e94..2dbf1bd 100644 --- a/src/domain/DemoLayouts.cpp +++ b/src/domain/DemoLayouts.cpp @@ -5,10 +5,10 @@ namespace safecrowd::domain::DemoLayouts { namespace { -Barrier2D makeBarrier(const char* id, std::vector vertices, bool closed = false) { +Barrier2D makeBarrier(const char* id, const char* floorId, std::vector vertices, bool closed = false) { Barrier2D barrier; barrier.id = id; - barrier.floorId = Sprint1FacilityIds::FloorId; + barrier.floorId = floorId; barrier.blocksMovement = true; barrier.geometry = Polyline2D{ .vertices = std::move(vertices), @@ -17,6 +17,60 @@ Barrier2D makeBarrier(const char* id, std::vector vertices, bool closed return barrier; } +Barrier2D makeBarrier(const char* id, std::vector vertices, bool closed = false) { + return makeBarrier(id, Sprint1FacilityIds::FloorId, std::move(vertices), closed); +} + +Zone2D makeRectZone( + const char* id, + const char* floorId, + ZoneKind kind, + const char* label, + double minX, + double minY, + double maxX, + double maxY, + std::size_t capacity, + bool isStair = false) { + Zone2D zone; + zone.id = id; + zone.floorId = floorId; + zone.kind = kind; + zone.label = label; + zone.area = Polygon2D{ + .outline = { + {minX, minY}, + {maxX, minY}, + {maxX, maxY}, + {minX, maxY}, + }, + }; + zone.defaultCapacity = capacity; + zone.isStair = isStair; + return zone; +} + +Connection2D makeConnection( + const char* id, + const char* floorId, + ConnectionKind kind, + const char* fromZoneId, + const char* toZoneId, + double effectiveWidth, + LineSegment2D centerSpan, + bool isStair = false) { + Connection2D connection; + connection.id = id; + connection.floorId = floorId; + connection.kind = kind; + connection.fromZoneId = fromZoneId; + connection.toZoneId = toZoneId; + connection.effectiveWidth = effectiveWidth; + connection.centerSpan = centerSpan; + connection.isStair = isStair; + return connection; +} + } // namespace FacilityLayout2D demoFacility() { @@ -141,4 +195,328 @@ FacilityLayout2D demoFacility() { return layout; } +FacilityLayout2D twoFloorEvacuationFacility() { + using Ids = TwoFloorEvacuationIds; + + FacilityLayout2D layout{}; + layout.id = Ids::LayoutId; + layout.name = "Two-floor Evacuation Demo Layout"; + layout.levelId = Ids::LowerFloorId; + layout.floors.push_back({ + .id = Ids::LowerFloorId, + .label = "Floor 1", + }); + layout.floors.push_back({ + .id = Ids::UpperFloorId, + .label = "Floor 2", + .elevationMeters = 3.5, + }); + + layout.zones.push_back(makeRectZone( + Ids::UpperWestTrainingZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "West Training Room", + 1.0, + 1.0, + 10.0, + 6.0, + 60)); + layout.zones.push_back(makeRectZone( + Ids::UpperBriefingZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "Briefing Room", + 10.0, + 1.0, + 18.0, + 6.0, + 48)); + layout.zones.push_back(makeRectZone( + Ids::UpperEastTrainingZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "East Training Room", + 18.0, + 1.0, + 27.0, + 6.0, + 60)); + layout.zones.push_back(makeRectZone( + Ids::UpperCorridorZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "Upper Corridor", + 1.0, + 6.0, + 27.0, + 9.0, + 90)); + layout.zones.push_back(makeRectZone( + Ids::UpperWestStairZoneId, + Ids::UpperFloorId, + ZoneKind::Stair, + "Upper West Stair", + 3.0, + 9.0, + 5.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::UpperEastStairZoneId, + Ids::UpperFloorId, + ZoneKind::Stair, + "Upper East Stair", + 23.0, + 9.0, + 25.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::LowerWestStairZoneId, + Ids::LowerFloorId, + ZoneKind::Stair, + "Lower West Stair", + 1.0, + 9.0, + 3.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::LowerEastStairZoneId, + Ids::LowerFloorId, + ZoneKind::Stair, + "Lower East Stair", + 25.0, + 9.0, + 27.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::LowerWestVestibuleZoneId, + Ids::LowerFloorId, + ZoneKind::Room, + "West Exit Vestibule", + 1.0, + 4.0, + 6.0, + 9.0, + 36)); + layout.zones.push_back(makeRectZone( + Ids::LowerLobbyZoneId, + Ids::LowerFloorId, + ZoneKind::Room, + "Ground Floor Lobby", + 6.0, + 4.0, + 22.0, + 10.0, + 120)); + layout.zones.push_back(makeRectZone( + Ids::LowerEastVestibuleZoneId, + Ids::LowerFloorId, + ZoneKind::Room, + "East Exit Vestibule", + 22.0, + 4.0, + 27.0, + 9.0, + 36)); + layout.zones.push_back(makeRectZone( + Ids::WestExitZoneId, + Ids::LowerFloorId, + ZoneKind::Exit, + "West Exit", + -2.0, + 5.0, + 1.0, + 8.0, + 40)); + layout.zones.push_back(makeRectZone( + Ids::EastExitZoneId, + Ids::LowerFloorId, + ZoneKind::Exit, + "East Exit", + 27.0, + 5.0, + 30.0, + 8.0, + 40)); + + layout.connections.push_back(makeConnection( + Ids::UpperWestTrainingToCorridorConnectionId, + Ids::UpperFloorId, + ConnectionKind::Doorway, + Ids::UpperWestTrainingZoneId, + Ids::UpperCorridorZoneId, + 3.0, + {{4.0, 6.0}, {7.0, 6.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperBriefingToCorridorConnectionId, + Ids::UpperFloorId, + ConnectionKind::Doorway, + Ids::UpperBriefingZoneId, + Ids::UpperCorridorZoneId, + 3.0, + {{12.5, 6.0}, {15.5, 6.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperEastTrainingToCorridorConnectionId, + Ids::UpperFloorId, + ConnectionKind::Doorway, + Ids::UpperEastTrainingZoneId, + Ids::UpperCorridorZoneId, + 3.0, + {{21.0, 6.0}, {24.0, 6.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperCorridorWestStairConnectionId, + Ids::UpperFloorId, + ConnectionKind::Opening, + Ids::UpperCorridorZoneId, + Ids::UpperWestStairZoneId, + 2.0, + {{3.2, 9.0}, {4.8, 9.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperCorridorEastStairConnectionId, + Ids::UpperFloorId, + ConnectionKind::Opening, + Ids::UpperCorridorZoneId, + Ids::UpperEastStairZoneId, + 2.0, + {{23.2, 9.0}, {24.8, 9.0}})); + auto westVerticalStair = makeConnection( + Ids::WestStairVerticalConnectionId, + Ids::LowerFloorId, + ConnectionKind::Stair, + Ids::UpperWestStairZoneId, + Ids::LowerWestStairZoneId, + 2.0, + {{3.0, 11.6}, {3.0, 12.8}}, + true); + westVerticalStair.lowerEntryDirection = StairEntryDirection::South; + westVerticalStair.upperEntryDirection = StairEntryDirection::South; + layout.connections.push_back(westVerticalStair); + auto eastVerticalStair = makeConnection( + Ids::EastStairVerticalConnectionId, + Ids::LowerFloorId, + ConnectionKind::Stair, + Ids::UpperEastStairZoneId, + Ids::LowerEastStairZoneId, + 2.0, + {{25.0, 11.6}, {25.0, 12.8}}, + true); + eastVerticalStair.lowerEntryDirection = StairEntryDirection::South; + eastVerticalStair.upperEntryDirection = StairEntryDirection::South; + layout.connections.push_back(eastVerticalStair); + layout.connections.push_back(makeConnection( + Ids::LowerWestStairVestibuleConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerWestStairZoneId, + Ids::LowerWestVestibuleZoneId, + 2.0, + {{1.2, 9.0}, {2.8, 9.0}})); + layout.connections.push_back(makeConnection( + Ids::LowerEastStairVestibuleConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerEastStairZoneId, + Ids::LowerEastVestibuleZoneId, + 2.0, + {{25.2, 9.0}, {26.8, 9.0}})); + layout.connections.push_back(makeConnection( + Ids::LowerWestVestibuleLobbyConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerWestVestibuleZoneId, + Ids::LowerLobbyZoneId, + 2.0, + {{6.0, 5.5}, {6.0, 7.5}})); + layout.connections.push_back(makeConnection( + Ids::LowerLobbyEastVestibuleConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerLobbyZoneId, + Ids::LowerEastVestibuleZoneId, + 2.0, + {{22.0, 5.5}, {22.0, 7.5}})); + layout.connections.push_back(makeConnection( + Ids::LowerWestExitConnectionId, + Ids::LowerFloorId, + ConnectionKind::Exit, + Ids::LowerWestVestibuleZoneId, + Ids::WestExitZoneId, + 2.0, + {{1.0, 5.5}, {1.0, 7.5}})); + layout.connections.push_back(makeConnection( + Ids::LowerEastExitConnectionId, + Ids::LowerFloorId, + ConnectionKind::Exit, + Ids::LowerEastVestibuleZoneId, + Ids::EastExitZoneId, + 2.0, + {{27.0, 5.5}, {27.0, 7.5}})); + + layout.barriers.push_back(makeBarrier("two-floor-upper-south-wall", Ids::UpperFloorId, {{1.0, 1.0}, {27.0, 1.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-wall", Ids::UpperFloorId, {{1.0, 1.0}, {1.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-wall", Ids::UpperFloorId, {{27.0, 1.0}, {27.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-room-divider", Ids::UpperFloorId, {{10.0, 1.0}, {10.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-room-divider", Ids::UpperFloorId, {{18.0, 1.0}, {18.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-1", Ids::UpperFloorId, {{1.0, 6.0}, {4.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-2", Ids::UpperFloorId, {{7.0, 6.0}, {12.5, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-3", Ids::UpperFloorId, {{15.5, 6.0}, {21.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-4", Ids::UpperFloorId, {{24.0, 6.0}, {27.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-corridor-north-1", Ids::UpperFloorId, {{1.0, 9.0}, {3.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-corridor-north-2", Ids::UpperFloorId, {{4.8, 9.0}, {23.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-corridor-north-3", Ids::UpperFloorId, {{24.8, 9.0}, {27.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-west-1", Ids::UpperFloorId, {{3.0, 9.0}, {3.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-west-2", Ids::UpperFloorId, {{3.0, 12.8}, {3.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-east", Ids::UpperFloorId, {{5.0, 9.0}, {5.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-north", Ids::UpperFloorId, {{3.0, 13.0}, {5.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-west", Ids::UpperFloorId, {{23.0, 9.0}, {23.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-east-1", Ids::UpperFloorId, {{25.0, 9.0}, {25.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-east-2", Ids::UpperFloorId, {{25.0, 12.8}, {25.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-north", Ids::UpperFloorId, {{23.0, 13.0}, {25.0, 13.0}})); + layout.barriers.push_back(makeBarrier( + "two-floor-upper-corridor-column", + Ids::UpperFloorId, + {{13.0, 7.0}, {15.0, 7.0}, {15.0, 8.0}, {13.0, 8.0}}, + true)); + + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-west", Ids::LowerFloorId, {{1.0, 9.0}, {1.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-east-1", Ids::LowerFloorId, {{3.0, 9.0}, {3.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-east-2", Ids::LowerFloorId, {{3.0, 12.8}, {3.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-north", Ids::LowerFloorId, {{1.0, 13.0}, {3.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-west-1", Ids::LowerFloorId, {{25.0, 9.0}, {25.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-west-2", Ids::LowerFloorId, {{25.0, 12.8}, {25.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-east", Ids::LowerFloorId, {{27.0, 9.0}, {27.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-north", Ids::LowerFloorId, {{25.0, 13.0}, {27.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-south-west", Ids::LowerFloorId, {{1.0, 4.0}, {6.0, 4.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-west-wall-1", Ids::LowerFloorId, {{1.0, 4.0}, {1.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-west-wall-2", Ids::LowerFloorId, {{1.0, 7.5}, {1.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-vestibule-lobby-wall-1", Ids::LowerFloorId, {{6.0, 4.0}, {6.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-vestibule-lobby-wall-2", Ids::LowerFloorId, {{6.0, 7.5}, {6.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-south-1", Ids::LowerFloorId, {{1.0, 9.0}, {1.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-south-2", Ids::LowerFloorId, {{2.8, 9.0}, {6.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-south", Ids::LowerFloorId, {{6.0, 4.0}, {22.0, 4.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-north", Ids::LowerFloorId, {{6.0, 10.0}, {22.0, 10.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-east-wall-1", Ids::LowerFloorId, {{22.0, 4.0}, {22.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-east-wall-2", Ids::LowerFloorId, {{22.0, 7.5}, {22.0, 10.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-south-east", Ids::LowerFloorId, {{22.0, 4.0}, {27.0, 4.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-east-wall-1", Ids::LowerFloorId, {{27.0, 4.0}, {27.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-east-wall-2", Ids::LowerFloorId, {{27.0, 7.5}, {27.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-south-1", Ids::LowerFloorId, {{22.0, 9.0}, {25.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-south-2", Ids::LowerFloorId, {{26.8, 9.0}, {27.0, 9.0}})); + layout.barriers.push_back(makeBarrier( + "two-floor-lower-info-desk", + Ids::LowerFloorId, + {{13.0, 5.8}, {15.0, 5.8}, {15.0, 7.0}, {13.0, 7.0}}, + true)); + + return layout; +} + } // namespace safecrowd::domain::DemoLayouts diff --git a/src/domain/DemoLayouts.h b/src/domain/DemoLayouts.h index 94bf3da..d8b125a 100644 --- a/src/domain/DemoLayouts.h +++ b/src/domain/DemoLayouts.h @@ -32,7 +32,39 @@ struct Sprint1FacilityIds { static constexpr const char* SidePassageWallUpperId = "barrier-wall-side-passage-upper"; }; +struct TwoFloorEvacuationIds { + static constexpr const char* LayoutId = "demo-two-floor-evacuation"; + static constexpr const char* LowerFloorId = "L1"; + static constexpr const char* UpperFloorId = "L2"; + static constexpr const char* UpperWestTrainingZoneId = "two-floor-upper-west-training"; + static constexpr const char* UpperBriefingZoneId = "two-floor-upper-briefing"; + static constexpr const char* UpperEastTrainingZoneId = "two-floor-upper-east-training"; + static constexpr const char* UpperCorridorZoneId = "two-floor-upper-corridor"; + static constexpr const char* UpperWestStairZoneId = "two-floor-upper-west-stair"; + static constexpr const char* UpperEastStairZoneId = "two-floor-upper-east-stair"; + static constexpr const char* LowerWestStairZoneId = "two-floor-lower-west-stair"; + static constexpr const char* LowerEastStairZoneId = "two-floor-lower-east-stair"; + static constexpr const char* LowerWestVestibuleZoneId = "two-floor-lower-west-vestibule"; + static constexpr const char* LowerLobbyZoneId = "two-floor-lower-lobby"; + static constexpr const char* LowerEastVestibuleZoneId = "two-floor-lower-east-vestibule"; + static constexpr const char* WestExitZoneId = "two-floor-west-exit"; + static constexpr const char* EastExitZoneId = "two-floor-east-exit"; + static constexpr const char* UpperWestTrainingToCorridorConnectionId = "two-floor-upper-west-training-corridor"; + static constexpr const char* UpperBriefingToCorridorConnectionId = "two-floor-upper-briefing-corridor"; + static constexpr const char* UpperEastTrainingToCorridorConnectionId = "two-floor-upper-east-training-corridor"; + static constexpr const char* UpperCorridorWestStairConnectionId = "two-floor-upper-corridor-west-stair"; + static constexpr const char* UpperCorridorEastStairConnectionId = "two-floor-upper-corridor-east-stair"; + static constexpr const char* WestStairVerticalConnectionId = "two-floor-west-stair-vertical"; + static constexpr const char* EastStairVerticalConnectionId = "two-floor-east-stair-vertical"; + static constexpr const char* LowerWestStairVestibuleConnectionId = "two-floor-lower-west-stair-vestibule"; + static constexpr const char* LowerEastStairVestibuleConnectionId = "two-floor-lower-east-stair-vestibule"; + static constexpr const char* LowerWestVestibuleLobbyConnectionId = "two-floor-lower-west-vestibule-lobby"; + static constexpr const char* LowerLobbyEastVestibuleConnectionId = "two-floor-lower-lobby-east-vestibule"; + static constexpr const char* LowerWestExitConnectionId = "two-floor-lower-west-exit"; + static constexpr const char* LowerEastExitConnectionId = "two-floor-lower-east-exit"; +}; + FacilityLayout2D demoFacility(); +FacilityLayout2D twoFloorEvacuationFacility(); } // namespace safecrowd::domain::DemoLayouts - diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 9d07e22..02c5841 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -9,6 +9,7 @@ #include "domain/ImportIssue.h" #include "domain/ImportValidationService.h" #include "domain/ScenarioAuthoring.h" +#include "domain/ScenarioSimulationRunner.h" namespace { @@ -36,6 +37,15 @@ bool containsConnectionId( }); } +const safecrowd::domain::Connection2D* findConnectionId( + const std::vector& connections, + const std::string& id) { + const auto it = std::find_if(connections.begin(), connections.end(), [&](const auto& connection) { + return connection.id == id; + }); + return it == connections.end() ? nullptr : &(*it); +} + bool containsBarrierId( const std::vector& barriers, const std::string& id) { @@ -131,6 +141,101 @@ SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); } +SC_TEST(DemoFixtureServiceBuildsTwoFloorEvacuationFixture) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + const auto& layout = fixture.layout; + const auto& population = fixture.population; + using Ids = safecrowd::domain::DemoLayouts::TwoFloorEvacuationIds; + + SC_EXPECT_EQ(layout.id, std::string(Ids::LayoutId)); + SC_EXPECT_EQ(layout.name, std::string("Two-floor Evacuation Demo Layout")); + SC_EXPECT_EQ(layout.levelId, std::string(Ids::LowerFloorId)); + SC_EXPECT_EQ(layout.floors.size(), std::size_t{2}); + SC_EXPECT_EQ(layout.floors.at(0).id, std::string(Ids::LowerFloorId)); + SC_EXPECT_EQ(layout.floors.at(1).id, std::string(Ids::UpperFloorId)); + SC_EXPECT_EQ(layout.zones.size(), std::size_t{13}); + SC_EXPECT_EQ(layout.connections.size(), std::size_t{13}); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperWestTrainingZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperCorridorZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::LowerLobbyZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::WestExitZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::EastExitZoneId)); + SC_EXPECT_TRUE(containsConnectionId(layout.connections, Ids::WestStairVerticalConnectionId)); + SC_EXPECT_TRUE(containsConnectionId(layout.connections, Ids::EastStairVerticalConnectionId)); + SC_EXPECT_TRUE(containsConnectionKind(layout.connections, safecrowd::domain::ConnectionKind::Stair)); + const auto* westStair = findConnectionId(layout.connections, Ids::WestStairVerticalConnectionId); + SC_EXPECT_TRUE(westStair != nullptr); + if (westStair != nullptr) { + SC_EXPECT_EQ(westStair->lowerEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_EQ(westStair->upperEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_NEAR(westStair->centerSpan.start.x, 3.0, 1e-9); + SC_EXPECT_NEAR(westStair->centerSpan.end.x, 3.0, 1e-9); + } + const auto* eastStair = findConnectionId(layout.connections, Ids::EastStairVerticalConnectionId); + SC_EXPECT_TRUE(eastStair != nullptr); + if (eastStair != nullptr) { + SC_EXPECT_EQ(eastStair->lowerEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_EQ(eastStair->upperEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_NEAR(eastStair->centerSpan.start.x, 25.0, 1e-9); + SC_EXPECT_NEAR(eastStair->centerSpan.end.x, 25.0, 1e-9); + } + SC_EXPECT_TRUE(std::all_of(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.floorId == "L1" || zone.floorId == "L2"; + })); + + SC_EXPECT_EQ(population.initialPlacements.size(), std::size_t{3}); + SC_EXPECT_EQ(population.initialPlacements.front().zoneId, std::string(Ids::UpperWestTrainingZoneId)); + SC_EXPECT_EQ(population.initialPlacements.front().floorId, std::string(Ids::UpperFloorId)); + SC_EXPECT_EQ(population.initialPlacements.front().targetAgentCount, std::size_t{30}); + SC_EXPECT_EQ(population.initialPlacements.at(1).targetAgentCount, std::size_t{20}); + SC_EXPECT_EQ(population.initialPlacements.at(2).targetAgentCount, std::size_t{30}); + SC_EXPECT_EQ(fixture.baselineScenario.role, safecrowd::domain::ScenarioRole::Baseline); + SC_EXPECT_EQ(fixture.baselineScenario.population.initialPlacements.size(), std::size_t{3}); + SC_EXPECT_EQ(fixture.alternativeScenario.role, safecrowd::domain::ScenarioRole::Alternative); + SC_EXPECT_EQ(fixture.alternativeScenario.control.routeGuidances.size(), std::size_t{1}); + SC_EXPECT_EQ( + fixture.alternativeScenario.control.routeGuidances.front().guidedExitZoneId, + std::string(Ids::EastExitZoneId)); + SC_EXPECT_TRUE(containsDiffKey(fixture.alternativeScenario, "control.routeGuidances")); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); +} + +SC_TEST(TwoFloorEvacuationDemoFixtureRunsAcrossFloors) { + safecrowd::domain::DemoFixtureService service; + auto fixture = service.createTwoFloorEvacuationDemoFixture(); + auto scenario = fixture.baselineScenario; + + auto placement = scenario.population.initialPlacements.front(); + placement.targetAgentCount = 1; + placement.area.outline = {{4.0, 3.0}}; + scenario.population.initialPlacements = {placement}; + scenario.execution.timeLimitSeconds = 60.0; + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, scenario); + for (int step = 0; step < 600 && !runner.complete(); ++step) { + runner.step(0.1); + } + + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{1}); +} + +SC_TEST(TwoFloorEvacuationDemoCrowdCompletesAfterStairDescent) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.baselineScenario); + for (int step = 0; step < 6000 && !runner.complete(); ++step) { + runner.step(0.1); + } + + SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); +} + SC_TEST(DemoFixtureBlockedDoorResultFixturePreservesScenarioAndResultData) { safecrowd::domain::DemoFixtureService service; const auto fixture = service.createSprint1BlockedDoorResultFixture(); From 4a4768a5fb3853cdc5f77e5810847f160f1cb864 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Fri, 15 May 2026 02:45:56 +0900 Subject: [PATCH 2/5] [Domain] Stabilize two-floor evacuation demo --- docs/UI.md | 6 +- src/domain/DemoLayouts.cpp | 12 ++-- tests/DemoFixtureServiceTests.cpp | 102 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/docs/UI.md b/docs/UI.md index 4b2960c..617506b 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -75,19 +75,19 @@ Sprint 1 시연에서 가장 중요한 기준은 `도면 불러오기 -> 검토/ ### 3.1 Project Navigator -앱을 처음 켰을 때 보이는 시작 화면이다. 예시 프로젝트 목록을 두지 않고, 실제 저장된 프로젝트 목록과 built-in `Demo` 프로젝트를 보여준다. +앱을 처음 켰을 때 보이는 시작 화면이다. 예시 프로젝트 목록을 두지 않고, 실제 저장된 프로젝트 목록과 built-in 데모 프로젝트를 보여준다. 현재 기능: - 최근 프로젝트 목록 표시 -- built-in `Demo` 프로젝트 표시 +- built-in `Demo`, `Evacuation Scenario Demo`, `Two-floor Evacuation Demo` 프로젝트 표시 - 프로젝트 목록이 없을 때 빈 상태 표시 - `+ New Project`로 새 프로젝트 생성 화면 진입 - 최근 프로젝트 row 클릭 시 해당 프로젝트 열기 - 일반 프로젝트 row의 삭제 버튼으로 프로젝트 삭제 - 삭제 전 확인 다이얼로그 표시 - 삭제 확인 시 실제 프로젝트 폴더 삭제 및 최근 목록 갱신 -- built-in `Demo` 프로젝트 삭제 방지 +- built-in 데모 프로젝트 삭제 방지 현재 UI 요소: diff --git a/src/domain/DemoLayouts.cpp b/src/domain/DemoLayouts.cpp index 2dbf1bd..36e83c0 100644 --- a/src/domain/DemoLayouts.cpp +++ b/src/domain/DemoLayouts.cpp @@ -377,7 +377,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::UpperCorridorZoneId, Ids::UpperWestStairZoneId, - 2.0, + 1.6, {{3.2, 9.0}, {4.8, 9.0}})); layout.connections.push_back(makeConnection( Ids::UpperCorridorEastStairConnectionId, @@ -385,7 +385,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::UpperCorridorZoneId, Ids::UpperEastStairZoneId, - 2.0, + 1.6, {{23.2, 9.0}, {24.8, 9.0}})); auto westVerticalStair = makeConnection( Ids::WestStairVerticalConnectionId, @@ -393,7 +393,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Stair, Ids::UpperWestStairZoneId, Ids::LowerWestStairZoneId, - 2.0, + 1.2, {{3.0, 11.6}, {3.0, 12.8}}, true); westVerticalStair.lowerEntryDirection = StairEntryDirection::South; @@ -405,7 +405,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Stair, Ids::UpperEastStairZoneId, Ids::LowerEastStairZoneId, - 2.0, + 1.2, {{25.0, 11.6}, {25.0, 12.8}}, true); eastVerticalStair.lowerEntryDirection = StairEntryDirection::South; @@ -417,7 +417,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::LowerWestStairZoneId, Ids::LowerWestVestibuleZoneId, - 2.0, + 1.6, {{1.2, 9.0}, {2.8, 9.0}})); layout.connections.push_back(makeConnection( Ids::LowerEastStairVestibuleConnectionId, @@ -425,7 +425,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::LowerEastStairZoneId, Ids::LowerEastVestibuleZoneId, - 2.0, + 1.6, {{25.2, 9.0}, {26.8, 9.0}})); layout.connections.push_back(makeConnection( Ids::LowerWestVestibuleLobbyConnectionId, diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 02c5841..cc2821c 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -69,6 +69,38 @@ double spanLength(const safecrowd::domain::LineSegment2D& span) { return std::sqrt(dx * dx + dy * dy); } +double distanceToSegment( + const safecrowd::domain::Point2D& point, + const safecrowd::domain::LineSegment2D& segment) { + const auto dx = segment.end.x - segment.start.x; + const auto dy = segment.end.y - segment.start.y; + const auto lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 1e-12) { + const auto px = point.x - segment.start.x; + const auto py = point.y - segment.start.y; + return std::sqrt(px * px + py * py); + } + + const auto t = std::clamp( + ((point.x - segment.start.x) * dx + (point.y - segment.start.y) * dy) / lengthSquared, + 0.0, + 1.0); + const auto closestX = segment.start.x + dx * t; + const auto closestY = segment.start.y + dy * t; + const auto px = point.x - closestX; + const auto py = point.y - closestY; + return std::sqrt(px * px + py * py); +} + +bool pointInRect( + const safecrowd::domain::Point2D& point, + double minX, + double minY, + double maxX, + double maxY) { + return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY; +} + void translatePolygon(safecrowd::domain::Polygon2D& polygon, double dx, double dy) { auto translateRing = [&](std::vector& ring) { for (auto& point : ring) { @@ -156,6 +188,9 @@ SC_TEST(DemoFixtureServiceBuildsTwoFloorEvacuationFixture) { SC_EXPECT_EQ(layout.floors.at(1).id, std::string(Ids::UpperFloorId)); SC_EXPECT_EQ(layout.zones.size(), std::size_t{13}); SC_EXPECT_EQ(layout.connections.size(), std::size_t{13}); + for (const auto& connection : layout.connections) { + SC_EXPECT_NEAR(connection.effectiveWidth, spanLength(connection.centerSpan), 1e-9); + } SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperWestTrainingZoneId)); SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperCorridorZoneId)); SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::LowerLobbyZoneId)); @@ -236,6 +271,73 @@ SC_TEST(TwoFloorEvacuationDemoCrowdCompletesAfterStairDescent) { SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); } +SC_TEST(TwoFloorEvacuationDemoAlternativeCrowdCompletesAfterGuidedStairDescent) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.alternativeScenario); + for (int step = 0; step < 6000 && !runner.complete(); ++step) { + runner.step(0.1); + } + + SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); + SC_EXPECT_TRUE(runner.frame().agents.empty()); +} + +SC_TEST(TwoFloorEvacuationDemoCrowdMovesOffLowerStairPortalAfterLanding) { + safecrowd::domain::DemoFixtureService service; + auto fixture = service.createTwoFloorEvacuationDemoFixture(); + auto scenario = fixture.baselineScenario; + using Ids = safecrowd::domain::DemoLayouts::TwoFloorEvacuationIds; + + safecrowd::domain::InitialPlacement2D placement; + placement.id = "upper-west-stair-crowd"; + placement.floorId = Ids::UpperFloorId; + placement.zoneId = Ids::UpperWestStairZoneId; + placement.initialVelocity = {.x = 1.2, .y = 0.0}; + placement.explicitPositions = { + {.x = 4.45, .y = 12.45}, + {.x = 4.05, .y = 12.45}, + {.x = 4.45, .y = 12.05}, + {.x = 4.05, .y = 12.05}, + {.x = 4.45, .y = 11.65}, + {.x = 4.05, .y = 11.65}, + }; + scenario.population.initialPlacements = {placement}; + scenario.execution.timeLimitSeconds = 30.0; + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, scenario); + const safecrowd::domain::LineSegment2D lowerWestPortal{{3.0, 11.6}, {3.0, 12.8}}; + bool observedLowerFloor = false; + bool observedMovementOffPortal = false; + for (int step = 0; step < 300 && !runner.complete(); ++step) { + runner.step(0.1); + for (const auto& agent : runner.frame().agents) { + if (agent.floorId != Ids::LowerFloorId) { + continue; + } + observedLowerFloor = true; + if (distanceToSegment(agent.position, lowerWestPortal) > 0.45 || agent.position.y < 11.2) { + observedMovementOffPortal = true; + } + } + } + + std::size_t stalledLowerStairAgents = 0; + for (const auto& agent : runner.frame().agents) { + if (agent.floorId == Ids::LowerFloorId + && pointInRect(agent.position, 1.0, 9.0, 3.0, 13.0) + && agent.stalled) { + ++stalledLowerStairAgents; + } + } + + SC_EXPECT_TRUE(observedLowerFloor); + SC_EXPECT_TRUE(observedMovementOffPortal || runner.frame().agents.empty()); + SC_EXPECT_EQ(stalledLowerStairAgents, std::size_t{0}); +} + SC_TEST(DemoFixtureBlockedDoorResultFixturePreservesScenarioAndResultData) { safecrowd::domain::DemoFixtureService service; const auto fixture = service.createSprint1BlockedDoorResultFixture(); From 5da8bdeda1f5b9ac81f71250d00b40891d2bac60 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Fri, 15 May 2026 03:27:43 +0900 Subject: [PATCH 3/5] [Domain] Scope route guidance to installed signs --- src/application/MainWindow.cpp | 41 +++++++-- src/application/MainWindow.h | 3 +- src/application/ScenarioRunWidget.cpp | 32 +++++-- src/application/ScenarioRunWidget.h | 6 +- src/domain/DemoFixtureService.cpp | 8 +- src/domain/ScenarioSimulationMotionSystem.cpp | 83 ++++++++++++++++++- tests/DemoFixtureServiceTests.cpp | 41 +++++++-- tests/ScenarioSimulationSystemsTests.cpp | 69 +++++++++++++++ 8 files changed, 255 insertions(+), 28 deletions(-) diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index 94f5381..8a5104a 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -311,6 +312,29 @@ SavedScenarioAuthoringState savedStateFromInitial(const ScenarioAuthoringWidget: return saved; } +int selectedRunIndexFor( + const std::vector& scenarios, + const std::optional& selectedScenario) { + if (!selectedScenario.has_value()) { + return 0; + } + + if (!selectedScenario->scenarioId.empty()) { + const auto idIt = std::find_if(scenarios.begin(), scenarios.end(), [&](const auto& scenario) { + return scenario.scenarioId == selectedScenario->scenarioId; + }); + if (idIt != scenarios.end()) { + return static_cast(std::distance(scenarios.begin(), idIt)); + } + } + + const auto nameIt = std::find_if(scenarios.begin(), scenarios.end(), [&](const auto& scenario) { + return scenario.name == selectedScenario->name + && scenario.role == selectedScenario->role; + }); + return nameIt == scenarios.end() ? 0 : static_cast(std::distance(scenarios.begin(), nameIt)); +} + template Widget* visibleChild(QWidget* root) { if (root == nullptr) { @@ -462,12 +486,17 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { break; case ProjectWorkspaceView::ScenarioRun: if (!workspace.runningScenarios.empty()) { + auto returnAuthoringState = workspace.authoring.has_value() + ? std::make_optional(initialStateFromSaved(*workspace.authoring, *importResult.layout)) + : std::optional{}; + const auto initialSelectedRunIndex = selectedRunIndexFor( + workspace.runningScenarios, + workspace.runningScenario); showScenarioRun( *importResult.layout, std::move(workspace.runningScenarios), - workspace.authoring.has_value() - ? std::make_optional(initialStateFromSaved(*workspace.authoring, *importResult.layout)) - : std::nullopt); + std::move(returnAuthoringState), + initialSelectedRunIndex); return; } if (workspace.runningScenario.has_value()) { @@ -758,7 +787,8 @@ void MainWindow::showScenarioRun( void MainWindow::showScenarioRun( const safecrowd::domain::FacilityLayout2D& layout, std::vector scenarios, - std::optional returnAuthoringState) { + std::optional returnAuthoringState, + int initialSelectedRunIndex) { setCentralWidget(new ScenarioRunWidget( currentProject_.name, layout, @@ -779,7 +809,8 @@ void MainWindow::showScenarioRun( } }, std::move(returnAuthoringState), - this)); + this, + initialSelectedRunIndex)); } void MainWindow::showScenarioBatchResult( diff --git a/src/application/MainWindow.h b/src/application/MainWindow.h index 0d6cbbc..b33d4c3 100644 --- a/src/application/MainWindow.h +++ b/src/application/MainWindow.h @@ -46,7 +46,8 @@ class MainWindow : public QMainWindow { void showScenarioRun( const safecrowd::domain::FacilityLayout2D& layout, std::vector scenarios, - std::optional returnAuthoringState = std::nullopt); + std::optional returnAuthoringState = std::nullopt, + int initialSelectedRunIndex = 0); void showScenarioBatchResult( const safecrowd::domain::FacilityLayout2D& layout, std::vector results, diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 31f130a..0c077c5 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -34,6 +34,13 @@ namespace { constexpr double kSimulationDeltaSeconds = 1.0 / 30.0; constexpr int kPlaybackTimerIntervalMs = 33; +int normalizedRunIndex(int index, std::size_t runCount) { + if (runCount == 0) { + return 0; + } + return std::clamp(index, 0, static_cast(runCount) - 1); +} + enum class TransportIconKind { Play, Pause, @@ -232,7 +239,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::move(openProjectHandler), std::move(backToLayoutReviewHandler), std::move(returnAuthoringState), - parent) {} + parent, + 0) {} ScenarioRunWidget::ScenarioRunWidget( const QString& projectName, @@ -260,7 +268,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::move(openProjectHandler), std::move(backToLayoutReviewHandler), std::move(returnAuthoringState), - parent) {} + parent, + 0) {} ScenarioRunWidget::ScenarioRunWidget( const QString& projectName, @@ -270,7 +279,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState, - QWidget* parent) + QWidget* parent, + int initialSelectedRunIndex) : ScenarioRunWidget( projectName, layout, @@ -280,7 +290,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::move(openProjectHandler), std::move(backToLayoutReviewHandler), std::move(returnAuthoringState), - parent) {} + parent, + initialSelectedRunIndex) {} ScenarioRunWidget::ScenarioRunWidget( const QString& projectName, @@ -291,11 +302,12 @@ ScenarioRunWidget::ScenarioRunWidget( std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState, - QWidget* parent) + QWidget* parent, + int initialSelectedRunIndex) : QWidget(parent), projectName_(projectName), layout_(layout), - scenario_(scenarios.empty() ? safecrowd::domain::ScenarioDraft{} : scenarios.front()), + scenario_({}), scenarios_(std::move(scenarios)), cachedResults_(std::move(cachedResults)), batchRunner_(layout_, scenarios_), @@ -303,6 +315,11 @@ ScenarioRunWidget::ScenarioRunWidget( saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + selectedRunIndex_ = normalizedRunIndex(initialSelectedRunIndex, scenarios_.size()); + if (!scenarios_.empty()) { + scenario_ = scenarios_[static_cast(selectedRunIndex_)]; + } + auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -398,7 +415,8 @@ QWidget* ScenarioRunWidget::createRunCanvas() { canvas_ = new SimulationCanvasWidget(layout_, container); canvas_->setMinimumHeight(360); if (!batchRunner_.empty()) { - const auto& run = batchRunner_.run(0); + const auto& run = batchRunner_.run(static_cast( + normalizedRunIndex(selectedRunIndex_, batchRunner_.size()))); canvas_->setConnectionBlocks(run.scenario.control.connectionBlocks); canvas_->setEnvironmentHazards(run.scenario.environment.hazards); canvas_->setRouteGuidances(run.scenario.control.routeGuidances); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 478bfb5..cc8f609 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -56,7 +56,8 @@ class ScenarioRunWidget : public QWidget { std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState = std::nullopt, - QWidget* parent = nullptr); + QWidget* parent = nullptr, + int initialSelectedRunIndex = 0); explicit ScenarioRunWidget( const QString& projectName, @@ -67,7 +68,8 @@ class ScenarioRunWidget : public QWidget { std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState = std::nullopt, - QWidget* parent = nullptr); + QWidget* parent = nullptr, + int initialSelectedRunIndex = 0); const safecrowd::domain::ScenarioDraft& scenario() const noexcept; const std::vector& scenarios() const noexcept; diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index a5189a7..1292090 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -35,10 +35,10 @@ ScenarioDraft makeTwoFloorEastExitGuidanceAlternative(const ScenarioDraft& basel .endSeconds = 180.0, .periods = {{.startSeconds = 0.0, .endSeconds = 180.0}}, .guidedExitZoneId = Ids::EastExitZoneId, - .installConnectionId = Ids::UpperCorridorEastStairConnectionId, - .baseComplianceRate = 0.85, - .guidanceStrength = 0.85, - .maxDetourMeters = 30.0, + .installConnectionId = Ids::UpperWestTrainingToCorridorConnectionId, + .baseComplianceRate = 0.95, + .guidanceStrength = 0.95, + .maxDetourMeters = 60.0, }); alternative.variationDiffKeys = computeScenarioDiffKeys(baseline, alternative); return alternative; diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index 8af041e..26984e3 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -451,6 +451,50 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return it == layoutCache.layout.connections.end() ? nullptr : &(*it); } + bool connectionTouchesFloor( + const ScenarioLayoutCacheResource& layoutCache, + const Connection2D& connection, + const std::string& floorId) const { + if (sameFloor(connection.floorId, floorId)) { + return true; + } + const auto fromFloorId = cachedFloorIdForZone(layoutCache, connection.fromZoneId); + const auto toFloorId = cachedFloorIdForZone(layoutCache, connection.toZoneId); + return sameFloor(fromFloorId, floorId) || sameFloor(toFloorId, floorId); + } + + bool agentCanSeeGuidanceAtInstallConnection( + const ScenarioLayoutCacheResource& layoutCache, + const RouteGuidanceDraft& guidance, + const Position& position, + const Agent& agent, + const EvacuationRoute& route) const { + if (guidance.installConnectionId.empty()) { + return true; + } + + const auto* connection = findConnectionById(layoutCache, guidance.installConnectionId); + if (connection == nullptr || !connectionTouchesFloor(layoutCache, *connection, route.currentFloorId)) { + return false; + } + + const auto currentZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); + if (!currentZoneId.empty() + && currentZoneId != connection->fromZoneId + && currentZoneId != connection->toZoneId) { + return false; + } + + const auto closestOnInstall = closestPointOnSegment( + position.value, + connection->centerSpan.start, + connection->centerSpan.end); + const auto visibilityDistance = std::max( + 2.0, + (connection->effectiveWidth * 0.5) + static_cast(agent.radius) + 0.25); + return distanceBetween(position.value, closestOnInstall) <= visibilityDistance; + } + const Connection2D* nextBlockedConnection( const ScenarioLayoutCacheResource& layoutCache, const EvacuationRoute& route) const { @@ -838,6 +882,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { struct ActiveRouteGuidance { const RouteGuidanceDraft* guidance{nullptr}; + std::size_t guidanceIndex{0}; std::size_t periodIndex{0}; double startSeconds{0.0}; double endSeconds{0.0}; @@ -847,7 +892,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::optional best; double bestStart = -1.0; - for (const auto& guidance : routeGuidances_) { + for (std::size_t guidanceIndex = 0; guidanceIndex < routeGuidances_.size(); ++guidanceIndex) { + const auto& guidance = routeGuidances_[guidanceIndex]; if (guidance.periods.empty()) { // No periods configured => always active (like connection blocks with no intervals). const double start = 0.0; @@ -857,7 +903,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } if (!best.has_value() || start >= bestStart) { bestStart = start; - best = ActiveRouteGuidance{.guidance = &guidance, .periodIndex = 0, .startSeconds = start, .endSeconds = end}; + best = ActiveRouteGuidance{ + .guidance = &guidance, + .guidanceIndex = guidanceIndex, + .periodIndex = 0, + .startSeconds = start, + .endSeconds = end, + }; } continue; } @@ -874,7 +926,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } if (!best.has_value() || start >= bestStart) { bestStart = start; - best = ActiveRouteGuidance{.guidance = &guidance, .periodIndex = index, .startSeconds = start, .endSeconds = end}; + best = ActiveRouteGuidance{ + .guidance = &guidance, + .guidanceIndex = guidanceIndex, + .periodIndex = index, + .startSeconds = start, + .endSeconds = end, + }; } } } @@ -929,6 +987,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::string activeId; if (active.has_value() && active->guidance != nullptr) { activeId = active->guidance->id; + if (activeId.empty()) { + activeId = "route-guidance:"; + activeId.append(std::to_string(active->guidanceIndex)); + } if (!active->guidance->periods.empty()) { activeId.append(":p"); activeId.append(std::to_string(active->periodIndex)); @@ -946,6 +1008,12 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } guidanceReplanIdHash_ = fnv1a64(activeId); guidanceReplanPending_ = true; + } else if (active.has_value() + && active->guidance != nullptr + && !active->guidance->installConnectionId.empty() + && !guidanceReplanPending_) { + guidanceReplanCursor_ = 0; + guidanceReplanPending_ = true; } if (!guidanceReplanPending_ || guidanceReplanCursor_ >= entities.size()) { @@ -1002,6 +1070,15 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } + if (!activeGuidance->installConnectionId.empty()) { + if (route.guidanceEventId == activeId) { + continue; + } + if (!agentCanSeeGuidanceAtInstallConnection(layoutCache, *activeGuidance, position, agent, route)) { + continue; + } + } + bool guidedExitValid = false; if (!activeGuidance->guidedExitZoneId.empty()) { if (const auto* exitZone = findCachedZone(layoutCache, activeGuidance->guidedExitZoneId); diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index cc2821c..76f05c0 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -123,6 +123,15 @@ bool containsDiffKey( }); } +std::size_t evacuatedCountForExit( + const safecrowd::domain::ScenarioResultArtifacts& artifacts, + const std::string& exitZoneId) { + const auto it = std::find_if(artifacts.exitUsage.begin(), artifacts.exitUsage.end(), [&](const auto& exitUsage) { + return exitUsage.exitZoneId == exitZoneId; + }); + return it == artifacts.exitUsage.end() ? std::size_t{0} : it->evacuatedCount; +} + } // namespace SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { @@ -232,6 +241,9 @@ SC_TEST(DemoFixtureServiceBuildsTwoFloorEvacuationFixture) { SC_EXPECT_EQ( fixture.alternativeScenario.control.routeGuidances.front().guidedExitZoneId, std::string(Ids::EastExitZoneId)); + SC_EXPECT_EQ( + fixture.alternativeScenario.control.routeGuidances.front().installConnectionId, + std::string(Ids::UpperWestTrainingToCorridorConnectionId)); SC_EXPECT_TRUE(containsDiffKey(fixture.alternativeScenario, "control.routeGuidances")); safecrowd::domain::ImportValidationService validator; @@ -274,15 +286,32 @@ SC_TEST(TwoFloorEvacuationDemoCrowdCompletesAfterStairDescent) { SC_TEST(TwoFloorEvacuationDemoAlternativeCrowdCompletesAfterGuidedStairDescent) { safecrowd::domain::DemoFixtureService service; const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + using Ids = safecrowd::domain::DemoLayouts::TwoFloorEvacuationIds; - safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.alternativeScenario); - for (int step = 0; step < 6000 && !runner.complete(); ++step) { - runner.step(0.1); + safecrowd::domain::ScenarioSimulationRunner baselineRunner(fixture.layout, fixture.baselineScenario); + for (int step = 0; step < 6000 && !baselineRunner.complete(); ++step) { + baselineRunner.step(0.1); } - SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{80}); - SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); - SC_EXPECT_TRUE(runner.frame().agents.empty()); + safecrowd::domain::ScenarioSimulationRunner alternativeRunner(fixture.layout, fixture.alternativeScenario); + for (int step = 0; step < 6000 && !alternativeRunner.complete(); ++step) { + alternativeRunner.step(0.1); + } + + SC_EXPECT_EQ(baselineRunner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(baselineRunner.frame().evacuatedAgentCount, std::size_t{80}); + SC_EXPECT_EQ(alternativeRunner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(alternativeRunner.frame().evacuatedAgentCount, std::size_t{80}); + SC_EXPECT_TRUE(alternativeRunner.frame().agents.empty()); + + const auto baselineEastExitCount = evacuatedCountForExit( + baselineRunner.resultArtifacts(), + Ids::EastExitZoneId); + const auto alternativeEastExitCount = evacuatedCountForExit( + alternativeRunner.resultArtifacts(), + Ids::EastExitZoneId); + SC_EXPECT_TRUE(alternativeEastExitCount > baselineEastExitCount); + SC_EXPECT_TRUE(alternativeEastExitCount > std::size_t{0}); } SC_TEST(TwoFloorEvacuationDemoCrowdMovesOffLowerStairPortalAfterLanding) { diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 3a8e814..4dad74b 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -1659,6 +1659,75 @@ SC_TEST(ScenarioSimulationMotionSystem_TreatsZeroGuidanceDetourAsStrictTolerance SC_EXPECT_TRUE(!route.followsGuidance); } +SC_TEST(ScenarioSimulationMotionSystem_AppliesInstalledGuidanceOnlyNearInstallConnection) { + auto runFromPosition = [](const safecrowd::domain::Point2D& start) { + std::vector seeds; + seeds.push_back({ + .position = {.value = start}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f, .guidancePropensity = 1.0}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 2.0, .y = 0.5}}, + .waypointPassages = {{{.x = 2.0, .y = 0.3}, {.x = 2.0, .y = 0.7}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"near-exit"}, + .waypointConnectionIds = {"room-near-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = start, + .previousDistanceToWaypoint = 1.5, + .destinationZoneId = "near-exit", + .originalDestinationZoneId = "near-exit", + }, + .status = {}, + }); + + safecrowd::domain::RouteGuidanceDraft guidance; + guidance.id = "installed-guidance"; + guidance.guidedExitZoneId = "far-exit"; + guidance.installConnectionId = "room-near-exit"; + guidance.baseComplianceRate = 1.0; + guidance.guidanceStrength = 1.0; + guidance.maxDetourMeters = 100.0; + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 13, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioSimulationMotionSystem( + twoExitGuidanceDetourLayout(), + std::vector{guidance}), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.world().resources().set(safecrowd::domain::ScenarioSimulationStepResource{.deltaSeconds = 0.1}); + runtime.stepFrame(0.0); + + const auto entities = runtime.world().query().view< + safecrowd::domain::Position, + safecrowd::domain::Agent, + safecrowd::domain::Velocity, + safecrowd::domain::AvoidanceState, + safecrowd::domain::EvacuationRoute, + safecrowd::domain::EvacuationStatus>(); + SC_EXPECT_EQ(entities.size(), std::size_t{1}); + return runtime.world().query().get(entities.front()); + }; + + const auto unseenRoute = runFromPosition({.x = 0.5, .y = 3.5}); + SC_EXPECT_EQ(unseenRoute.destinationZoneId, std::string{"near-exit"}); + SC_EXPECT_TRUE(!unseenRoute.followsGuidance); + SC_EXPECT_TRUE(unseenRoute.guidanceEventId.empty()); + + const auto visibleRoute = runFromPosition({.x = 1.6, .y = 0.5}); + SC_EXPECT_EQ(visibleRoute.destinationZoneId, std::string{"far-exit"}); + SC_EXPECT_TRUE(visibleRoute.followsGuidance); + SC_EXPECT_EQ(visibleRoute.guidanceEventId, std::string{"installed-guidance"}); +} + SC_TEST(ScenarioSimulationMotionSystem_SkipsIntermediateWaypointWhenCrowdPushesAgentPastApproachArea) { std::vector seeds; seeds.push_back({ From 32d3676995e33296bbf4261e12b36d7c904dbdf8 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 13:17:42 +0900 Subject: [PATCH 4/5] test: cover installed guidance recheck --- tests/ScenarioSimulationSystemsTests.cpp | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 4dad74b..645c30b 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -1728,6 +1728,68 @@ SC_TEST(ScenarioSimulationMotionSystem_AppliesInstalledGuidanceOnlyNearInstallCo SC_EXPECT_EQ(visibleRoute.guidanceEventId, std::string{"installed-guidance"}); } +SC_TEST(ScenarioSimulationMotionSystem_RechecksInstalledGuidanceAsAgentApproachesInstallConnection) { + std::vector seeds; + seeds.push_back({ + .position = {.value = {.x = 0.5, .y = 2.15}}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f, .guidancePropensity = 1.0}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 2.0, .y = 0.5}}, + .waypointPassages = {{{.x = 2.0, .y = 0.3}, {.x = 2.0, .y = 0.7}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"near-exit"}, + .waypointConnectionIds = {"room-near-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = {.x = 0.5, .y = 2.15}, + .previousDistanceToWaypoint = 2.27, + .destinationZoneId = "near-exit", + .originalDestinationZoneId = "near-exit", + }, + .status = {}, + }); + + safecrowd::domain::RouteGuidanceDraft guidance; + guidance.id = "installed-guidance"; + guidance.guidedExitZoneId = "far-exit"; + guidance.installConnectionId = "room-near-exit"; + guidance.baseComplianceRate = 1.0; + guidance.guidanceStrength = 1.0; + guidance.maxDetourMeters = 100.0; + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 13, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioSimulationMotionSystem( + twoExitGuidanceDetourLayout(), + std::vector{guidance}), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + for (int step = 0; step < 180; ++step) { + runtime.world().resources().set(safecrowd::domain::ScenarioSimulationStepResource{.deltaSeconds = 0.1}); + runtime.stepFrame(0.0); + } + + const auto entities = runtime.world().query().view< + safecrowd::domain::Position, + safecrowd::domain::Agent, + safecrowd::domain::Velocity, + safecrowd::domain::AvoidanceState, + safecrowd::domain::EvacuationRoute, + safecrowd::domain::EvacuationStatus>(); + SC_EXPECT_EQ(entities.size(), std::size_t{1}); + const auto& route = runtime.world().query().get(entities.front()); + SC_EXPECT_EQ(route.guidanceEventId, std::string{"installed-guidance"}); + SC_EXPECT_TRUE(route.followsGuidance); + SC_EXPECT_EQ(route.destinationZoneId, std::string{"far-exit"}); +} + SC_TEST(ScenarioSimulationMotionSystem_SkipsIntermediateWaypointWhenCrowdPushesAgentPastApproachArea) { std::vector seeds; seeds.push_back({ From 67e8185afb77785e34bfe6f210929785438f388b Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 14:10:02 +0900 Subject: [PATCH 5/5] Open two-floor demo in authoring --- src/application/MainWindow.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index 8a5104a..d7d2aaa 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -122,10 +122,8 @@ ProjectWorkspaceState makeTwoFloorEvacuationDemoWorkspace() { authoring.rightPanelMode = SavedRightPanelMode::Scenario; ProjectWorkspaceState workspace; - workspace.activeView = ProjectWorkspaceView::ScenarioRun; + workspace.activeView = ProjectWorkspaceView::ScenarioAuthoring; workspace.authoring = std::move(authoring); - workspace.runningScenario = fixture.alternativeScenario; - workspace.runningScenarios = {fixture.baselineScenario, fixture.alternativeScenario}; return workspace; }