diff --git "a/docs/\354\240\234\354\266\234\354\232\251/\354\242\205\355\225\251\354\204\244\352\263\204/\354\204\270\353\266\200_\353\202\264\354\232\251.md" "b/docs/\354\240\234\354\266\234\354\232\251/\354\242\205\355\225\251\354\204\244\352\263\204/\354\204\270\353\266\200_\353\202\264\354\232\251.md"
new file mode 100644
index 0000000..b9582be
--- /dev/null
+++ "b/docs/\354\240\234\354\266\234\354\232\251/\354\242\205\355\225\251\354\204\244\352\263\204/\354\204\270\353\266\200_\353\202\264\354\232\251.md"
@@ -0,0 +1,284 @@
+# 세부 내용
+
+## 1. 프로젝트 구조 설계
+
+본 프로젝트는 유지보수성과 확장성을 높이기 위해 `Application -> Domain -> Engine`의 3계층 구조로 설계하였다. 각 계층은 명확한 역할을 가지며, 상위 계층은 하위 계층을 사용할 수 있지만 하위 계층은 상위 계층을 알지 못하도록 단방향 의존성 구조를 적용하였다.
+
+계층 간 의존성은 다음과 같다.
+
+```text
+Application -> Domain -> Engine
+```
+
+Application 계층은 Qt 기반 데스크톱 UI를 담당한다. 사용자의 프로젝트 생성, 도면 검토, 시나리오 작성, 시뮬레이션 실행, 결과 시각화와 같은 화면 흐름을 구성하며, 사용자의 입력을 Domain 계층의 기능으로 연결하는 역할을 한다.
+
+Domain 계층은 SafeCrowd의 핵심 비즈니스 로직을 담당한다. DXF 도면 변환, 공간 레이아웃 구성, 시나리오 작성, 군중 시뮬레이션 실행, 위험 지표 계산, 시나리오 대안 추천 기능이 이 계층에 포함된다.
+
+Engine 계층은 범용 ECS(Entity Component System) 런타임을 담당한다. Entity, Component, System, Resource, Scheduler를 관리하며, Domain 계층의 군중 시뮬레이션 시스템이 안정적으로 실행될 수 있는 기반을 제공한다.
+
+### 1.1 구현 방식
+
+프로젝트는 CMake를 이용해 계층별 빌드 타깃을 분리하였다.
+
+| 빌드 타깃 | 담당 계층 | 역할 |
+| --- | --- | --- |
+| `ecs_engine` | Engine | ECS 런타임, 엔티티/컴포넌트 저장소, 시스템 스케줄러 |
+| `safecrowd_domain` | Domain | 도면 변환, 시나리오, 시뮬레이션, 위험 지표, 추천 로직 |
+| `safecrowd_app` | Application | Qt 기반 데스크톱 UI와 사용자 워크플로우 |
+
+이 구조를 적용함으로써 UI 코드가 시뮬레이션 로직과 직접 섞이지 않도록 하였다. 또한 추후 웹 UI, 다른 파일 입력 방식, 별도 분석 도구, 새로운 결과 시각화 방식이 추가되더라도 Domain과 Engine 계층을 재사용할 수 있도록 설계하였다.
+
+## 2. Engine 계층 구현
+
+Engine 계층은 군중 시뮬레이션의 기반이 되는 ECS 런타임을 담당한다. 군중 시뮬레이션은 많은 수의 에이전트를 동시에 갱신해야 하므로, 각 객체가 모든 데이터를 개별적으로 보유하는 방식보다 데이터를 기능별로 분리하여 관리하는 구조가 적합하다. 따라서 본 프로젝트는 C++20 기반으로 ECS 엔진을 구현하여 메모리 접근 효율, 실행 순서 관리, 재현 가능한 시뮬레이션을 지원하도록 하였다.
+
+ECS 구조에서는 시뮬레이션에 등장하는 개체를 Entity로 식별하고, 실제 데이터는 Component에 저장한다. 예를 들어 에이전트의 위치는 `Position`, 속도는 `Velocity`, 이동 특성은 `Agent` 컴포넌트로 분리된다. 이후 System은 필요한 Component 조합을 가진 Entity만 조회하여 매 프레임 상태를 갱신한다.
+
+### 2.1 ECS 실행 흐름
+
+ECS 구조의 기본 실행 흐름은 다음과 같다.
+
+1. Entity 생성
+2. Component 등록
+3. System이 필요한 Component 조합 조회
+4. 시뮬레이션 상태 갱신
+5. Resource 및 결과 데이터 갱신
+6. 다음 프레임 진행
+
+### 2.2 주요 구성 요소
+
+| 구성 요소 | 역할 |
+| --- | --- |
+| Entity | 시뮬레이션 개체를 식별하는 ID |
+| Component | 위치, 속도, 상태, 경로 등 데이터 저장 |
+| System | Component를 조회하여 매 프레임 로직 수행 |
+| ComponentRegistry | Component 타입 등록과 메타데이터 관리 |
+| PackedComponentStorage | Component 데이터를 연속 저장하여 조회 효율 향상 |
+| WorldQuery | System이 월드 상태와 Component를 조회하는 기능 |
+| WorldCommands | Entity 생성/삭제, Component 추가/삭제 명령 예약 |
+| WorldResources | 전역 시뮬레이션 자원 관리 |
+| SystemScheduler | System 실행 순서와 실행 조건 제어 |
+| FrameClock | 고정 시간 간격 기반의 프레임 진행 관리 |
+| DeterministicRng | 동일 조건에서 재현 가능한 난수 제공 |
+
+엔진 내부에서는 `WorldQuery`, `WorldCommands`, `WorldResources`를 통해 System이 월드 상태를 안전하게 조회하고 필요한 작업을 예약할 수 있도록 하였다. 이를 통해 각 System이 전체 데이터를 직접 무분별하게 수정하지 않고, 프레임 단위의 갱신 흐름을 안정적으로 유지할 수 있게 하였다.
+
+또한 Entity는 세대 정보(generation)를 포함하는 식별 구조로 관리된다. 이를 통해 삭제된 Entity의 ID가 잘못 재사용되어 이전 객체를 참조하는 문제를 방지하였다.
+
+### 2.3 시스템 스케줄링과 재현성
+
+군중 시뮬레이션에서는 이동 계산, 충돌 회피, 위험 지표 계산, 결과 저장처럼 실행 순서가 중요한 작업이 존재한다. 본 프로젝트는 `SystemScheduler`를 통해 System의 실행 순서와 실행 조건을 관리하였다.
+
+예를 들어 이동 시스템이 에이전트 위치를 갱신한 뒤, 위험 지표 시스템이 갱신된 위치를 기준으로 밀집도와 압박도를 계산하고, 결과 아티팩트 시스템이 해당 프레임의 결과를 저장하는 방식으로 실행 흐름을 구성하였다.
+
+또한 `FrameClock`과 `DeterministicRng`를 사용하여 동일한 입력 조건에서는 동일한 시뮬레이션 결과가 재현되도록 하였다. 이는 결과 비교, 디버깅, 반복 실험에서 중요한 역할을 한다.
+
+### 2.4 독립 빌드와 테스트
+
+Engine 계층은 CMake의 `ecs_engine` 타깃으로 분리하였다. 이를 통해 Domain이나 Application 계층 없이도 엔진 기능을 독립적으로 빌드하고 테스트할 수 있다.
+
+CTest 기반 테스트를 통해 Entity 생성, Component 저장, Component 조회, Resource 관리, System 실행, Scheduler 동작 등 ECS 핵심 기능이 정상적으로 동작하는지 검증하였다.
+
+## 3. Layout 변환 구현
+
+Layout 변환 기능은 사용자가 입력한 DXF 도면을 군중 시뮬레이션에 사용할 수 있는 2D 공간 데이터로 바꾸는 단계이다. 일반적인 도면 파일은 사람이 보기 위한 선, 도형, 레이어 정보로 구성되어 있기 때문에 이를 바로 시뮬레이션에 사용할 수 없다. 따라서 본 프로젝트는 DXF 도면을 원본에 가까운 형태로 읽은 뒤, 시뮬레이션에 필요한 의미 단위로 정규화하고 검증하는 변환 파이프라인을 구현하였다.
+
+### 3.1 DXF 입력과 RawImportModel 생성
+
+먼저 `DxfImportService`가 DXF 파일을 읽어 도면 내부의 선, 폴리라인, 원, 호, 해치, 블록 참조, 주석 등의 기본 요소를 분석한다. 이 단계에서는 도면 정보를 바로 시뮬레이션 구조로 바꾸지 않고, 원본 도면에 가까운 `RawImportModel` 형태로 저장한다.
+
+`RawImportModel`을 별도로 두는 이유는 원본 도면 정보와 시뮬레이션용 공간 모델 사이의 차이를 명확히 분리하기 위해서이다. 이를 통해 도면 파싱 단계에서 발생한 정보와 이후 변환 단계에서 발생한 문제를 구분하여 추적할 수 있다.
+
+### 3.2 Geometry 정규화와 의미 분류
+
+추출된 도면 요소는 벽, 문, 출구, 장애물, 보행 가능 영역, 층간 연결 등 시뮬레이션에 필요한 의미 단위로 분류된다. 단순한 선분은 벽이나 경계로 해석될 수 있고, 특정 레이어명이나 도형 정보는 출구 또는 연결 통로로 변환될 수 있다.
+
+이 과정에서는 `GeometryNormalizer`와 import semantic rule을 사용하여 도면 표현 방식의 차이를 줄이고, 시뮬레이션에서 일관되게 사용할 수 있는 `CanonicalGeometry` 형태로 정규화한다. 예를 들어 연결되지 않은 선분, 중복된 점, 해석이 애매한 도형은 정규화 과정에서 정리되거나 검토 이슈로 남겨진다.
+
+### 3.3 FacilityLayout2D 생성
+
+정규화된 도면 정보는 `FacilityLayoutBuilder`를 통해 최종적인 `FacilityLayout2D` 구조로 변환된다. `FacilityLayout2D`는 군중 시뮬레이션이 사용하는 핵심 공간 모델이며, 층, 구역, 연결 통로, 벽, 장애물, 출구, 계단과 같은 요소를 포함한다.
+
+이 구조를 통해 시뮬레이션은 단순한 도면 선이 아니라, 에이전트가 이동할 수 있는 공간, 통과할 수 있는 연결 지점, 막힌 경계, 대피 가능한 출구를 기준으로 동작할 수 있다.
+
+### 3.4 Layout 검증과 사용자 수정
+
+마지막으로 `ImportValidationService`를 통해 변환된 Layout이 시뮬레이션 가능한 상태인지 검사한다. 주요 검증 항목은 다음과 같다.
+
+1. 출구가 존재하는지 여부
+2. 각 보행 가능 구역에서 출구까지 이동 가능한지 여부
+3. 통로 또는 연결부의 폭이 너무 좁지 않은지 여부
+4. 문과 연결 통로가 실제 구역 경계와 맞는지 여부
+5. 층, 계단, 연결 정보가 유효한지 여부
+6. 장애물이나 벽이 공간 구조와 충돌하지 않는지 여부
+
+검증 결과는 실행을 막는 `Error`, 실행은 가능하지만 개선이 필요한 `Warning`, 참고용 정보인 `Info`로 분류된다. Application 계층에서는 이러한 이슈를 Layout 검토 화면에 표시하고, 사용자가 필요한 경우 직접 방, 출구, 벽, 문, 계단, U자형 계단, 층 정보를 수정할 수 있도록 하였다.
+
+이를 통해 자동 변환의 한계를 보완하고, 비전문가도 화면에서 도면 변환 결과를 확인하며 시뮬레이션 가능한 공간으로 다듬을 수 있도록 하였다.
+
+## 4. Domain 계층 구현
+
+Domain 계층은 SafeCrowd의 핵심 기능을 담당한다. 단순히 화면에 사람의 이동을 보여주는 것이 아니라, 입력 공간을 시뮬레이션 가능한 모델로 바꾸고, 군중 이동을 계산하며, 위험 지표를 산출하고, 대안 시나리오를 비교할 수 있도록 구성하였다.
+
+### 4.1 선행자료 기반 위험 지표 정의
+
+군중 시뮬레이션 결과를 단순한 이동 애니메이션으로만 보여주면 안전관리자가 위험 상황을 판단하기 어렵다. 따라서 본 프로젝트에서는 행정안전부의 인파사고 대응 국민행동요령과 군중 관리 관련 선행 연구를 참고하여, 군중 밀집, 병목, 압박 전조, 대피 지연과 관련된 위험 요소를 정리하였다.
+
+본 프로젝트에서 중점적으로 다루는 위험 요소는 다음과 같다.
+
+| 위험 요소 | 주요 지표 |
+| --- | --- |
+| 대피 지연 | 전체 대피 시간, T50, T90, T95, 미대피 인원 |
+| 밀집 위험 | 단위면적당 인원 수, 최대 밀집 구역 |
+| 압박 전조 | 사람 간 근접도, 벽/장애물 근접도, 노출 시간, 압박 점수 |
+| 병목 현상 | 출구/복도/연결부 주변 정체, 평균 속도 저하 |
+| 운영 갈등 | 양방향 흐름 충돌, 특정 연결부 집중 |
+| 위험 노출 | 화재/연기 등 위험 요소 주변 체류 시간 |
+
+이러한 기준을 바탕으로 Domain 계층은 시뮬레이션 결과를 정량 지표로 변환한다. 예를 들어 특정 구역에 사람이 몰리면 밀집도 지표가 상승하고, 사람 간 거리가 지나치게 가까워지거나 벽과의 거리가 줄어들면 압박도 지표가 증가한다. 이를 통해 단순히 "사람이 많이 모였다"가 아니라, "어느 위치에서 어떤 유형의 위험이 발생했는지"를 판단할 수 있도록 하였다.
+
+### 4.2 위험 지표 계산 구현
+
+위험 지표 계산은 시뮬레이션 중 매 프레임 갱신되는 에이전트 상태를 기반으로 수행된다. Domain 계층의 위험 지표 시스템은 에이전트의 위치, 속도, 대피 상태, 주변 공간 정보를 수집하고, 이를 셀 단위로 집계하여 밀집도, 압박도, 병목, hotspot을 계산한다.
+
+위험 지표 계산 흐름은 다음과 같다.
+
+1. 에이전트 위치와 속도 수집
+2. 대피 완료 또는 비활성 상태를 제외한 활성 에이전트 선별
+3. 공간을 일정 크기의 셀 단위로 분할
+4. 셀별 인원 수와 평균 속도 계산
+5. 밀집도와 압박도 계산
+6. 병목, 정체, 위험 구역 탐지
+7. 시간별 결과와 최대 위험 지점 저장
+
+밀집도는 일정 크기의 셀 안에 존재하는 인원 수를 셀 면적으로 나누어 계산한다. 현재 프로젝트에서는 약 1.5m 크기의 셀을 기준으로 밀집도를 계산하며, 단위면적당 인원 수가 높을수록 위험도가 높게 판단된다.
+
+```text
+밀집도 = 셀 안의 인원 수 / 셀 면적
+```
+
+현재 고밀도 기준값은 약 3.55명/m²로 설정되어 있으며, 이 값에 가까워지거나 초과하는 셀은 고밀도 위험 구역으로 판단한다.
+
+압박도는 단순한 인원 수뿐 아니라 사람 간 거리가 지나치게 가까운지, 벽이나 장애물과의 거리가 가까운지, 해당 상태가 얼마나 오래 지속되었는지를 함께 반영한다. 주변 에이전트와의 겹침 또는 근접 정도가 커질수록 압박 점수가 증가하며, 특정 기준을 넘으면 압박 hotspot이나 critical pressure event로 기록된다.
+
+```text
+압박도 = 사람 간 근접도 + 벽/장애물 근접도 + 노출 시간
+```
+
+병목 지표는 출구, 문, 계단, 연결 통로 주변에서 에이전트가 많이 모이고 평균 이동 속도가 낮아지는 상황을 탐지하여 계산한다. 단순히 사람이 많은 곳만 병목으로 보지 않고, 통과 지점 주변의 정체와 이동 속도 저하를 함께 고려한다.
+
+계산된 지표는 `ScenarioResultArtifacts`에 저장되어 Application 계층에서 결과 화면, 히트맵, 비교 그래프, 요약 카드로 시각화할 수 있도록 전달된다.
+
+### 4.3 에이전트 움직임 구현
+
+에이전트 움직임은 Domain 계층의 시뮬레이션 러너와 ECS 시스템을 통해 구현하였다. 사용자가 시나리오를 실행하면 `ScenarioSimulationRunner`가 ECS Runtime을 구성하고, 에이전트 생성, 이동, 위험 계산, 결과 저장 시스템을 순서대로 등록한다.
+
+시뮬레이션 실행 흐름은 다음과 같다.
+
+1. 시나리오 설정과 Layout 정보를 ECS Runtime에 등록
+2. 초기 배치 또는 시간 기반 source spawn에 따라 에이전트 생성
+3. 각 에이전트의 목적지와 대피 경로 계산
+4. 매 프레임 에이전트 이동 속도와 방향 계산
+5. 벽, 장애물, 다른 에이전트와의 충돌 및 겹침 보정
+6. 화재/연기, 폐쇄된 통로, 안내 유도 조건 반영
+7. 대피 완료 여부와 결과 지표 갱신
+
+에이전트는 현재 위치에서 가장 적절한 출구 또는 목표 구역을 향해 이동한다. 이동 경로는 공간의 구역과 연결 통로를 기준으로 구성되며, 여러 층이 있는 경우 계단 또는 U자형 계단과 같은 층간 연결 정보도 함께 고려한다.
+
+이동 중에는 벽이나 장애물을 통과하지 않도록 보정하고, 다른 에이전트와 지나치게 겹치지 않도록 회피 및 분리 처리를 수행한다. 또한 출구나 통로 주변에 사람이 몰려 속도가 낮아지는 상황도 프레임 단위로 반영된다.
+
+시나리오에서 화재나 연기와 같은 위험 요소가 설정된 경우, 에이전트는 위험 구역을 회피하거나 이동 속도가 감소할 수 있다. 일정 시간 이후 활성화되는 위험 요소, 감지 지연, 반응 지연을 반영하여 실제 상황에 가까운 흐름을 표현할 수 있도록 하였다.
+
+또한 사용자는 특정 위치에 안내 유도 정보를 배치할 수 있다. 안내 유도는 영향 반경, 준수율, 우회 허용 범위 등을 통해 일부 에이전트가 기존 경로 대신 안내된 출구나 연결부를 선택하도록 만든다. 이를 통해 단순 대피뿐 아니라 운영자가 개입했을 때의 대안 시나리오를 실험할 수 있다.
+
+### 4.4 시나리오 대안 추천 구현
+
+본 프로젝트는 시뮬레이션 결과를 바탕으로 대안 시나리오를 추천하는 기능도 구현하였다. `AlternativeRecommendationService`는 기존 시나리오와 결과 지표를 분석하여 위험을 줄일 수 있는 운영 대안을 생성한다.
+
+추천에 활용되는 대표적인 근거는 다음과 같다.
+
+1. 특정 출구 또는 연결부에 병목이 집중되는지 여부
+2. 고밀도 hotspot이 반복적으로 발생하는지 여부
+3. 압박 위험이 특정 구역에서 지속되는지 여부
+4. 특정 출구 사용률이 과도하게 높은지 여부
+5. 양방향 흐름 충돌이나 운영 갈등이 발생하는지 여부
+
+추천 결과는 통로 개방/차단 조정, 우회 안내 배치, 출구 사용 분산, 위험 구역 회피 유도, 단계적 대피와 같은 형태로 구성된다. 이 기능은 사용자가 직접 모든 대안을 설계하지 않아도, 시뮬레이션 결과를 근거로 개선 방향을 빠르게 검토할 수 있게 한다.
+
+## 5. Application 계층 및 결과 시각화 구현
+
+Application 계층은 비전문가도 시뮬레이션을 사용할 수 있도록 전체 작업 흐름을 화면으로 구성한다. 프로젝트 생성, 도면 검토, 시나리오 작성, 실행, 결과 분석이 하나의 데스크톱 애플리케이션 안에서 이어지도록 구현하였다.
+
+### 5.1 프로젝트와 작업 흐름
+
+사용자는 새 프로젝트를 생성하거나 기존 프로젝트를 열 수 있다. 프로젝트에는 메타데이터, Layout 검토 상태, 시나리오 작성 상태, 시뮬레이션 결과 상태가 함께 저장된다.
+
+주요 작업 흐름은 다음과 같다.
+
+1. 프로젝트 생성 또는 열기
+2. DXF 도면 가져오기 또는 데모 Layout 선택
+3. Layout 검토 및 오류 수정
+4. 시나리오 작성
+5. 시뮬레이션 실행
+6. 결과 재생 및 위험 지표 확인
+7. 대안 시나리오 비교
+
+### 5.2 시나리오 작성 화면
+
+시나리오 작성 화면에서는 사용자가 군중 배치, 출발 지점, 위험 요소, 차단 구역, 안내 유도 정보를 배치할 수 있다. 사용자는 개별 에이전트, 그룹 배치, source spawn 방식으로 인원을 설정할 수 있으며, 도어 차단, 화재/연기 위험 요소, 안내 경로 등을 추가할 수 있다.
+
+작성된 시나리오는 baseline, alternative, recommended 유형으로 관리된다. 이를 통해 기본 운영안과 개선 운영안을 구분하고, 시뮬레이션 결과를 서로 비교할 수 있다.
+
+### 5.3 시뮬레이션 실행 화면
+
+시뮬레이션 실행 화면에서는 작성된 시나리오를 선택하고, 실행 시간, 시간 간격, 반복 횟수, 난수 seed 등을 설정할 수 있다. 실행 중에는 진행률, 현재 시간, 대피 완료 인원, 주요 위험 지표를 확인할 수 있으며, 실행 결과는 replay frame과 요약 지표로 저장된다.
+
+또한 여러 시나리오를 batch로 실행하여 결과를 비교할 수 있도록 하였다. 이를 통해 운영 대안별 대피 시간, 남은 인원, 밀집도, 압박 위험 등을 한 번에 비교할 수 있다.
+
+### 5.4 결과 분석 히트맵
+
+결과 화면에서는 사용자가 표시할 오버레이를 선택할 수 있도록 구성하였다. 기본 화면에서는 Peak Density를 표시하여 시뮬레이션 중 가장 높은 밀집도가 발생한 구역을 보여준다. 사용자는 필요에 따라 Pressure, Bottlenecks, Hotspots, None 오버레이를 선택하여 다른 기준의 위험 정보를 확인할 수 있다.
+
+밀집도 히트맵은 밀집도 값이 낮을수록 파란색 계열로 표시되고, 기준 밀도에 가까워질수록 초록색, 노란색, 주황색을 거쳐 빨간색 계열로 표시된다. 현재 고밀도 기준값은 약 3.55명/m²이며, 이 값에 가까워지거나 초과하는 구역은 위험도가 높은 영역으로 판단하여 빨간색에 가깝게 표현한다.
+
+압박도 히트맵은 단순히 사람이 많은 구역만 표시하는 것이 아니라, 사람 간 거리가 지나치게 가까운 정도와 벽/장애물과의 근접 정도를 함께 반영한다. 따라서 밀집도는 높지 않더라도 특정 지점에서 사람이 서로 밀착되거나 벽 근처에 몰리는 경우 압박도 값이 높게 나타날 수 있다.
+
+병목 오버레이는 출구, 복도, 계단, 연결부 주변에서 속도가 낮아지고 사람이 정체되는 구간을 표시한다. Hotspot 오버레이는 밀집도 또는 압박도가 높은 위험 구역을 강조하여, 사용자가 결과 화면에서 문제 지점을 빠르게 찾을 수 있도록 한다.
+
+### 5.5 결과 요약과 비교 분석
+
+결과 화면은 히트맵뿐 아니라 시나리오의 핵심 지표를 카드와 그래프로 제공한다. 주요 지표는 다음과 같다.
+
+1. 대피 완료율
+2. 전체 대피 시간
+3. T50, T90, T95
+4. 최대 밀집도
+5. 병목 발생 구역
+6. 압박 hotspot 수
+7. critical pressure event
+8. 출구별 사용률
+9. 구역별/그룹별 대피 완료 상태
+
+이를 통해 사용자는 단순히 애니메이션을 보는 데서 끝나지 않고, 어떤 시나리오가 더 안전한지 정량적으로 비교할 수 있다.
+
+## 6. 저장 및 재사용 기능
+
+본 프로젝트는 한 번 작성한 프로젝트를 다시 열어 이어서 작업할 수 있도록 저장 기능을 구현하였다. 저장 대상에는 프로젝트 메타데이터, Layout 검토 결과, import artifact, 시나리오 draft, 실행 결과, workspace 상태가 포함된다.
+
+저장 기능을 통해 사용자는 도면 변환 결과를 다시 수정하거나, 기존 시나리오를 복제하여 새로운 대안을 만들거나, 이전 실행 결과와 새 실행 결과를 비교할 수 있다. 이는 반복 실험이 필요한 군중 안전 검토 업무에서 중요한 기능이다.
+
+## 7. 현재 구현 범위와 향후 보완
+
+현재 프로젝트는 DXF 기반 Layout 변환, Qt 기반 시나리오 작성, ECS 기반 군중 시뮬레이션, 위험 지표 계산, 결과 히트맵, batch 비교, 대안 추천의 기본 흐름을 구현하였다.
+
+다만 실제 현장 적용 수준으로 확장하기 위해서는 다음과 같은 보완이 필요하다.
+
+1. 다양한 DXF 작성 관례에 대한 import rule 확장
+2. 시뮬레이션 결과 export 기능 강화
+3. 화재/연기 확산 모델과의 연동
+4. 인체 압박, 낙상, 부상 가능성에 대한 별도 모델 고도화
+5. 대규모 인원 시뮬레이션 성능 최적화
+6. 사용자 입력 실수를 줄이기 위한 시나리오 작성 보조 기능 강화
+7. 실제 시설 데이터와 비교한 검증 사례 축적
+
+따라서 본 프로젝트는 비전문가도 사용할 수 있는 군중 안전 시뮬레이터의 핵심 구조와 기능을 구현한 단계이며, 향후 실제 운영 환경의 데이터와 결합하면 군중 안전 의사결정 지원 도구로 확장할 수 있다.
diff --git a/presentation_assets/agent_movement_flow.png b/presentation_assets/agent_movement_flow.png
new file mode 100644
index 0000000..3cbf7a7
Binary files /dev/null and b/presentation_assets/agent_movement_flow.png differ
diff --git a/presentation_assets/agent_movement_flow.svg b/presentation_assets/agent_movement_flow.svg
new file mode 100644
index 0000000..6544d9f
--- /dev/null
+++ b/presentation_assets/agent_movement_flow.svg
@@ -0,0 +1,122 @@
+
diff --git a/src/application/ProjectWorkspaceState.h b/src/application/ProjectWorkspaceState.h
index dea70ff..7efc839 100644
--- a/src/application/ProjectWorkspaceState.h
+++ b/src/application/ProjectWorkspaceState.h
@@ -31,11 +31,12 @@ enum class SavedRightPanelMode {
};
enum class SavedResultNavigationView {
- Bottleneck,
- Hotspot,
- Zone,
- Groups,
- Recommendations,
+ Bottleneck = 0,
+ Hotspot = 1,
+ Zone = 2,
+ Groups = 3,
+ Recommendations = 4,
+ HazardExposure = 5,
};
struct SavedScenarioState {
diff --git a/src/application/ResultArtifactsCodec.cpp b/src/application/ResultArtifactsCodec.cpp
index 6b89b96..cff00ff 100644
--- a/src/application/ResultArtifactsCodec.cpp
+++ b/src/application/ResultArtifactsCodec.cpp
@@ -300,6 +300,124 @@ safecrowd::domain::PlacementCompletionMetric placementCompletionMetricFromJson(c
return placement;
}
+QString hazardExposureKindToJson(safecrowd::domain::EnvironmentHazardKind kind) {
+ switch (kind) {
+ case safecrowd::domain::EnvironmentHazardKind::Smoke:
+ return "smoke";
+ case safecrowd::domain::EnvironmentHazardKind::Fire:
+ default:
+ return "fire";
+ }
+}
+
+safecrowd::domain::EnvironmentHazardKind hazardExposureKindFromJson(const QJsonValue& value) {
+ if (value.isDouble()) {
+ return value.toInt() == static_cast(safecrowd::domain::EnvironmentHazardKind::Smoke)
+ ? safecrowd::domain::EnvironmentHazardKind::Smoke
+ : safecrowd::domain::EnvironmentHazardKind::Fire;
+ }
+
+ const auto text = value.toString().trimmed().toLower();
+ if (text == "smoke") {
+ return safecrowd::domain::EnvironmentHazardKind::Smoke;
+ }
+ if (text == "fire") {
+ return safecrowd::domain::EnvironmentHazardKind::Fire;
+ }
+ return safecrowd::domain::EnvironmentHazardKind::Fire;
+}
+
+QString hazardExposureSeverityToJson(safecrowd::domain::ScenarioElementSeverity severity) {
+ switch (severity) {
+ case safecrowd::domain::ScenarioElementSeverity::Low:
+ return "low";
+ case safecrowd::domain::ScenarioElementSeverity::High:
+ return "high";
+ case safecrowd::domain::ScenarioElementSeverity::Medium:
+ default:
+ return "medium";
+ }
+}
+
+safecrowd::domain::ScenarioElementSeverity hazardExposureSeverityFromJson(const QJsonValue& value) {
+ if (value.isDouble()) {
+ const auto raw = value.toInt();
+ if (raw == static_cast(safecrowd::domain::ScenarioElementSeverity::Low)) {
+ return safecrowd::domain::ScenarioElementSeverity::Low;
+ }
+ if (raw == static_cast(safecrowd::domain::ScenarioElementSeverity::High)) {
+ return safecrowd::domain::ScenarioElementSeverity::High;
+ }
+ return safecrowd::domain::ScenarioElementSeverity::Medium;
+ }
+
+ const auto text = value.toString().trimmed().toLower();
+ if (text == "low") {
+ return safecrowd::domain::ScenarioElementSeverity::Low;
+ }
+ if (text == "high") {
+ return safecrowd::domain::ScenarioElementSeverity::High;
+ }
+ if (text == "medium") {
+ return safecrowd::domain::ScenarioElementSeverity::Medium;
+ }
+ return safecrowd::domain::ScenarioElementSeverity::Medium;
+}
+
+QJsonObject hazardExposureMetricToJson(const safecrowd::domain::HazardExposureMetric& metric) {
+ QJsonObject object;
+ object["hazardId"] = QString::fromStdString(metric.hazardId);
+ object["hazardName"] = QString::fromStdString(metric.hazardName);
+ object["kind"] = hazardExposureKindToJson(metric.kind);
+ object["severity"] = hazardExposureSeverityToJson(metric.severity);
+ object["affectedZoneId"] = QString::fromStdString(metric.affectedZoneId);
+ object["floorId"] = QString::fromStdString(metric.floorId);
+ object["position"] = pointArray(metric.position);
+ object["exposedAgentSeconds"] = metric.exposedAgentSeconds;
+ object["peakExposedAgentCount"] = static_cast(metric.peakExposedAgentCount);
+ object["firstExposureSeconds"] = optionalDoubleToJson(metric.firstExposureSeconds);
+ object["peakAtSeconds"] = optionalDoubleToJson(metric.peakAtSeconds);
+ object["exposureScore"] = metric.exposureScore;
+ return object;
+}
+
+safecrowd::domain::HazardExposureMetric hazardExposureMetricFromJson(const QJsonObject& object) {
+ safecrowd::domain::HazardExposureMetric metric;
+ metric.hazardId = object.value("hazardId").toString().toStdString();
+ metric.hazardName = object.value("hazardName").toString().toStdString();
+ metric.kind = hazardExposureKindFromJson(object.value("kind"));
+ metric.severity = hazardExposureSeverityFromJson(object.value("severity"));
+ metric.affectedZoneId = object.value("affectedZoneId").toString().toStdString();
+ metric.floorId = object.value("floorId").toString().toStdString();
+ metric.position = pointFromJson(object.value("position"));
+ metric.exposedAgentSeconds = object.value("exposedAgentSeconds").toDouble();
+ metric.peakExposedAgentCount = static_cast(object.value("peakExposedAgentCount").toInteger());
+ metric.firstExposureSeconds = optionalDoubleFromJson(object.value("firstExposureSeconds"));
+ metric.peakAtSeconds = optionalDoubleFromJson(object.value("peakAtSeconds"));
+ metric.exposureScore = object.value("exposureScore").toDouble();
+ return metric;
+}
+
+QJsonObject hazardExposureSummaryToJson(const safecrowd::domain::HazardExposureSummary& summary) {
+ QJsonObject object;
+ object["totalExposureScore"] = summary.totalExposureScore;
+ QJsonArray hazards;
+ for (const auto& metric : summary.hazards) {
+ hazards.append(hazardExposureMetricToJson(metric));
+ }
+ object["hazards"] = hazards;
+ return object;
+}
+
+safecrowd::domain::HazardExposureSummary hazardExposureSummaryFromJson(const QJsonObject& object) {
+ safecrowd::domain::HazardExposureSummary summary;
+ summary.totalExposureScore = object.value("totalExposureScore").toDouble();
+ for (const auto& value : object.value("hazards").toArray()) {
+ summary.hazards.push_back(hazardExposureMetricFromJson(value.toObject()));
+ }
+ return summary;
+}
+
QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifacts& artifacts) {
QJsonObject object;
QJsonArray progress;
@@ -335,6 +453,7 @@ QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifac
object["timingSummary"] = timing;
object["densitySummary"] = densitySummaryToJson(artifacts.densitySummary);
+ object["hazardExposureSummary"] = hazardExposureSummaryToJson(artifacts.hazardExposureSummary);
QJsonArray exitUsage;
for (const auto& exit : artifacts.exitUsage) {
@@ -389,6 +508,10 @@ safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonOb
if (object.value("densitySummary").isObject()) {
artifacts.densitySummary = densitySummaryFromJson(object.value("densitySummary").toObject());
}
+ if (object.value("hazardExposureSummary").isObject()) {
+ artifacts.hazardExposureSummary =
+ hazardExposureSummaryFromJson(object.value("hazardExposureSummary").toObject());
+ }
for (const auto& value : object.value("exitUsage").toArray()) {
artifacts.exitUsage.push_back(exitUsageMetricFromJson(value.toObject()));
}
diff --git a/src/application/ScenarioBatchResultWidget.cpp b/src/application/ScenarioBatchResultWidget.cpp
index 2cb9d05..f3046be 100644
--- a/src/application/ScenarioBatchResultWidget.cpp
+++ b/src/application/ScenarioBatchResultWidget.cpp
@@ -90,6 +90,48 @@ QString formatPressureScore(double score) {
return QString::number(score, 'f', 1);
}
+QString formatExposureScore(double score) {
+ return QString::number(score, 'f', 1);
+}
+
+QString formatExposureSeconds(double seconds) {
+ return QString("%1 agent-sec").arg(seconds, 0, 'f', 1);
+}
+
+double hazardExposureSecondsForKind(
+ const safecrowd::domain::HazardExposureSummary& summary,
+ safecrowd::domain::EnvironmentHazardKind kind) {
+ double total = 0.0;
+ for (const auto& metric : summary.hazards) {
+ if (metric.kind == kind) {
+ total += metric.exposedAgentSeconds;
+ }
+ }
+ return total;
+}
+
+double totalHazardExposureSeconds(const safecrowd::domain::HazardExposureSummary& summary) {
+ double total = 0.0;
+ for (const auto& metric : summary.hazards) {
+ total += metric.exposedAgentSeconds;
+ }
+ return total;
+}
+
+const safecrowd::domain::HazardExposureMetric* peakHazardExposureMetric(
+ const safecrowd::domain::HazardExposureSummary& summary) {
+ const safecrowd::domain::HazardExposureMetric* best = nullptr;
+ for (const auto& metric : summary.hazards) {
+ if (best == nullptr
+ || metric.peakExposedAgentCount > best->peakExposedAgentCount
+ || (metric.peakExposedAgentCount == best->peakExposedAgentCount
+ && metric.exposedAgentSeconds > best->exposedAgentSeconds)) {
+ best = &metric;
+ }
+ }
+ return best;
+}
+
QTableWidget* createComparisonTable(const QStringList& headers, QWidget* parent) {
auto* table = new QTableWidget(0, headers.size(), parent);
table->setHorizontalHeaderLabels(headers);
@@ -833,6 +875,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation
switch (view) {
case SavedResultNavigationView::Hotspot:
return ScenarioResultNavigationView::Hotspot;
+ case SavedResultNavigationView::HazardExposure:
+ return ScenarioResultNavigationView::HazardExposure;
case SavedResultNavigationView::Zone:
return ScenarioResultNavigationView::Zone;
case SavedResultNavigationView::Groups:
@@ -849,6 +893,8 @@ SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView
switch (view) {
case ScenarioResultNavigationView::Hotspot:
return SavedResultNavigationView::Hotspot;
+ case ScenarioResultNavigationView::HazardExposure:
+ return SavedResultNavigationView::HazardExposure;
case ScenarioResultNavigationView::Zone:
return SavedResultNavigationView::Zone;
case ScenarioResultNavigationView::Groups:
@@ -1012,6 +1058,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() {
auto* tabs = new QTabWidget(graphPanel);
remainingChart_ = new ComparisonGraphWidget(ComparisonGraphMode::Remaining, tabs);
exitsChart_ = new ComparisonGraphWidget(ComparisonGraphMode::Exits, tabs);
+ exposureTable_ = createComparisonTable({"Scenario", "Total", "Fire", "Smoke", "Peak", "Peak at"}, tabs);
pressureTable_ = createComparisonTable({"Scenario", "Peak score", "Exposed / Critical", "Hotspots", "Events", "Peak at"}, tabs);
static_cast(remainingChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
@@ -1020,6 +1067,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() {
});
tabs->addTab(remainingChart_, "Remaining");
tabs->addTab(exitsChart_, "Exits");
+ tabs->addTab(exposureTable_, "Exposure");
tabs->addTab(pressureTable_, "Pressure");
graphLayout->addWidget(tabs, 1);
layout->addWidget(graphPanel, 1);
@@ -1087,7 +1135,7 @@ QWidget* ScenarioBatchResultWidget::createSummaryPanel() {
layout->setContentsMargins(0, 0, 10, 0);
layout->setSpacing(12);
- auto* intro = createLabel("Choose which completed scenarios appear together in the comparison graphs and pressure summary table.", content, ui::FontRole::Caption);
+ auto* intro = createLabel("Choose which completed scenarios appear together in the comparison graphs and risk summary tables.", content, ui::FontRole::Caption);
intro->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
intro->setStyleSheet(ui::mutedTextStyleSheet());
layout->addWidget(intro);
@@ -1517,6 +1565,7 @@ void ScenarioBatchResultWidget::refreshComparisonSelection() {
static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
}
refreshComparisonCountLabel();
+ refreshExposureComparisonTable();
refreshPressureComparisonTable();
}
@@ -1529,6 +1578,53 @@ void ScenarioBatchResultWidget::refreshComparisonCountLabel() {
.arg(static_cast(results_.size())));
}
+void ScenarioBatchResultWidget::refreshExposureComparisonTable() {
+ if (exposureTable_ == nullptr) {
+ return;
+ }
+
+ const std::vector visibleIndices = selectedCompareIndices_;
+ exposureTable_->setRowCount(static_cast(visibleIndices.size()));
+ for (int row = 0; row < static_cast(visibleIndices.size()); ++row) {
+ const auto index = visibleIndices[static_cast(row)];
+ if (index < 0 || index >= static_cast(results_.size())) {
+ continue;
+ }
+
+ const auto& result = results_[static_cast(index)];
+ const auto& summary = result.artifacts.hazardExposureSummary;
+ const bool emphasized = index == currentResultIndex_;
+ const auto* peakHazard = peakHazardExposureMetric(summary);
+ exposureTable_->setItem(row, 0, tableItem(QString::fromStdString(result.scenario.name), emphasized));
+ exposureTable_->setItem(row, 1, tableItem(formatExposureSeconds(totalHazardExposureSeconds(summary)), emphasized));
+ exposureTable_->setItem(
+ row,
+ 2,
+ tableItem(formatExposureSeconds(hazardExposureSecondsForKind(
+ summary,
+ safecrowd::domain::EnvironmentHazardKind::Fire)), emphasized));
+ exposureTable_->setItem(
+ row,
+ 3,
+ tableItem(formatExposureSeconds(hazardExposureSecondsForKind(
+ summary,
+ safecrowd::domain::EnvironmentHazardKind::Smoke)), emphasized));
+ exposureTable_->setItem(
+ row,
+ 4,
+ tableItem(
+ peakHazard == nullptr
+ ? QString("0 people")
+ : QString("%1 people").arg(static_cast(peakHazard->peakExposedAgentCount)),
+ emphasized));
+ exposureTable_->setItem(
+ row,
+ 5,
+ tableItem(peakHazard == nullptr ? QString("Pending") : formatSeconds(peakHazard->peakAtSeconds), emphasized));
+ }
+ exposureTable_->resizeRowsToContents();
+}
+
void ScenarioBatchResultWidget::refreshPressureComparisonTable() {
if (pressureTable_ == nullptr) {
return;
@@ -1610,6 +1706,7 @@ void ScenarioBatchResultWidget::setComparisonSelection(std::vector indices)
static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
}
refreshComparisonCountLabel();
+ refreshExposureComparisonTable();
refreshPressureComparisonTable();
}
@@ -1892,6 +1989,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() {
if (exitsChart_ != nullptr) {
static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
}
+ refreshExposureComparisonTable();
refreshPressureComparisonTable();
refreshResultNavigationPanel();
if (detailLabel_ != nullptr) {
@@ -1901,7 +1999,9 @@ void ScenarioBatchResultWidget::refreshSelectedResult() {
? QString("Baseline")
: formatDeltaSeconds(selectedFinalSeconds - finalSeconds(results_[static_cast(baselineIndex)])))
: QString("No baseline");
- detailLabel_->setText(QString("%1 (%2)\nFinal: %3\nDelta vs baseline: %4\nEvacuated: %5 / %6 (%7)\nRisk: %8\nHotspots: %9\nBottlenecks: %10\nPressure hotspots: %11\nCritical pressure: %12 agents / %13 events\nPeak pressure: %14 at %15\nT90 / T95: %16 / %17")
+ const auto& exposureSummary = result.artifacts.hazardExposureSummary;
+ const auto* peakHazard = peakHazardExposureMetric(exposureSummary);
+ detailLabel_->setText(QString("%1 (%2)\nFinal: %3\nDelta vs baseline: %4\nEvacuated: %5 / %6 (%7)\nRisk: %8\nHotspots: %9\nBottlenecks: %10\nHazard exposure: %11 (fire %12 / smoke %13)\nHazard peak: %14 at %15; score %16\nPressure hotspots: %17\nCritical pressure: %18 agents / %19 events\nPeak pressure: %20 at %21\nT90 / T95: %22 / %23")
.arg(QString::fromStdString(result.scenario.name))
.arg(scenarioRoleLabel(result.scenario.role))
.arg(formatSeconds(selectedFinalSeconds))
@@ -1912,6 +2012,18 @@ void ScenarioBatchResultWidget::refreshSelectedResult() {
.arg(safecrowd::domain::scenarioRiskLevelLabel(result.risk.completionRisk))
.arg(static_cast(result.risk.hotspots.size()))
.arg(static_cast(result.risk.bottlenecks.size()))
+ .arg(formatExposureSeconds(totalHazardExposureSeconds(exposureSummary)))
+ .arg(formatExposureSeconds(hazardExposureSecondsForKind(
+ exposureSummary,
+ safecrowd::domain::EnvironmentHazardKind::Fire)))
+ .arg(formatExposureSeconds(hazardExposureSecondsForKind(
+ exposureSummary,
+ safecrowd::domain::EnvironmentHazardKind::Smoke)))
+ .arg(peakHazard == nullptr
+ ? QString("0 people")
+ : QString("%1 people").arg(static_cast(peakHazard->peakExposedAgentCount)))
+ .arg(peakHazard == nullptr ? QString("Pending") : formatSeconds(peakHazard->peakAtSeconds))
+ .arg(formatExposureScore(exposureSummary.totalExposureScore))
.arg(static_cast(result.artifacts.pressureSummary.peakHotspots.size()))
.arg(static_cast(result.artifacts.pressureSummary.peakCriticalAgentCount))
.arg(static_cast(result.artifacts.pressureSummary.criticalEvents.size()))
diff --git a/src/application/ScenarioBatchResultWidget.h b/src/application/ScenarioBatchResultWidget.h
index 56808da..67b68e3 100644
--- a/src/application/ScenarioBatchResultWidget.h
+++ b/src/application/ScenarioBatchResultWidget.h
@@ -67,6 +67,7 @@ class ScenarioBatchResultWidget : public QWidget {
void pauseReplay();
void refreshComparisonSelection();
void refreshComparisonCountLabel();
+ void refreshExposureComparisonTable();
void refreshPressureComparisonTable();
void refreshResultNavigationPanel();
void refreshSelectedResult();
@@ -107,6 +108,7 @@ class ScenarioBatchResultWidget : public QWidget {
QLabel* replayTimeLabel_{nullptr};
QLabel* detailLabel_{nullptr};
QLabel* comparisonCountLabel_{nullptr};
+ QTableWidget* exposureTable_{nullptr};
QTableWidget* pressureTable_{nullptr};
std::vector compareCheckBoxes_{};
QWidget* remainingChart_{nullptr};
diff --git a/src/application/ScenarioResultNavigation.cpp b/src/application/ScenarioResultNavigation.cpp
index 91e1be1..3a83119 100644
--- a/src/application/ScenarioResultNavigation.cpp
+++ b/src/application/ScenarioResultNavigation.cpp
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -34,6 +35,72 @@ QString formatOptionalSeconds(const std::optional& seconds) {
return seconds.has_value() ? QString("%1 sec").arg(*seconds, 0, 'f', 1) : QString("Pending");
}
+QString formatExposureSeconds(double seconds) {
+ return QString("%1 agent-sec").arg(seconds, 0, 'f', 1);
+}
+
+QString hazardKindLabel(safecrowd::domain::EnvironmentHazardKind kind) {
+ switch (kind) {
+ case safecrowd::domain::EnvironmentHazardKind::Smoke:
+ return "Smoke";
+ case safecrowd::domain::EnvironmentHazardKind::Fire:
+ default:
+ return "Fire";
+ }
+}
+
+QString severityLabel(safecrowd::domain::ScenarioElementSeverity severity) {
+ switch (severity) {
+ case safecrowd::domain::ScenarioElementSeverity::Low:
+ return "Low";
+ case safecrowd::domain::ScenarioElementSeverity::High:
+ return "High";
+ case safecrowd::domain::ScenarioElementSeverity::Medium:
+ default:
+ return "Medium";
+ }
+}
+
+struct HazardKindExposureSummary {
+ int hazardCount{0};
+ double exposedAgentSeconds{0.0};
+ double exposureScore{0.0};
+ std::size_t peakExposedAgentCount{0};
+ std::optional peakAtSeconds{};
+};
+
+HazardKindExposureSummary summarizeHazardKind(
+ const safecrowd::domain::HazardExposureSummary& summary,
+ safecrowd::domain::EnvironmentHazardKind kind) {
+ HazardKindExposureSummary result;
+ for (const auto& metric : summary.hazards) {
+ if (metric.kind != kind) {
+ continue;
+ }
+ ++result.hazardCount;
+ result.exposedAgentSeconds += metric.exposedAgentSeconds;
+ result.exposureScore += metric.exposureScore;
+ if (metric.peakExposedAgentCount > result.peakExposedAgentCount
+ || (metric.peakExposedAgentCount == result.peakExposedAgentCount
+ && !result.peakAtSeconds.has_value()
+ && metric.peakAtSeconds.has_value())) {
+ result.peakExposedAgentCount = metric.peakExposedAgentCount;
+ result.peakAtSeconds = metric.peakAtSeconds;
+ }
+ }
+ return result;
+}
+
+QString hazardDisplayName(const safecrowd::domain::HazardExposureMetric& metric) {
+ if (!metric.hazardName.empty()) {
+ return QString::fromStdString(metric.hazardName);
+ }
+ if (!metric.hazardId.empty()) {
+ return QString::fromStdString(metric.hazardId);
+ }
+ return "Unnamed hazard";
+}
+
QLabel* createReportSectionHeader(const QString& text, QWidget* parent) {
auto* label = createLabel(text, parent, ui::FontRole::SectionTitle);
label->setStyleSheet(ui::mutedTextStyleSheet());
@@ -177,6 +244,14 @@ QIcon makeResultNavigationIcon(const QString& tabId, const QColor& color) {
painter.drawEllipse(QPointF(22, 22), 7, 7);
painter.setBrush(color);
painter.drawEllipse(QPointF(22, 22), 3, 3);
+ } else if (tabId == "exposure") {
+ QPainterPath flame;
+ flame.moveTo(QPointF(17, 31));
+ flame.cubicTo(QPointF(10, 24), QPointF(15, 15), QPointF(20, 10));
+ flame.cubicTo(QPointF(23, 15), QPointF(30, 20), QPointF(25, 31));
+ painter.drawPath(flame);
+ painter.drawArc(QRectF(23, 14, 12, 10), 20 * 16, 220 * 16);
+ painter.drawArc(QRectF(29, 18, 10, 8), 20 * 16, 220 * 16);
} else if (tabId == "zone") {
painter.drawRoundedRect(QRectF(11, 11, 22, 22), 4, 4);
painter.drawLine(QPointF(22, 11), QPointF(22, 33));
@@ -300,6 +375,84 @@ QWidget* createHotspotReportPanel(
return parts.panel;
}
+QFrame* createHazardKindRow(
+ const QString& label,
+ const HazardKindExposureSummary& summary,
+ QWidget* parent) {
+ return createReportInfoRow({
+ QString("%1 exposure").arg(label),
+ QString("Exposure time: %1 Hazards: %2")
+ .arg(formatExposureSeconds(summary.exposedAgentSeconds))
+ .arg(summary.hazardCount),
+ QString("Peak exposed: %1 people Peak: %2")
+ .arg(static_cast(summary.peakExposedAgentCount))
+ .arg(formatOptionalSeconds(summary.peakAtSeconds)),
+ QString("Severity-weighted score: %1").arg(summary.exposureScore, 0, 'f', 1),
+ }, parent);
+}
+
+QWidget* createHazardExposureReportPanel(
+ const safecrowd::domain::ScenarioResultArtifacts& artifacts,
+ QWidget* parent) {
+ auto parts = createResultReportPanel("Exposure", "Fire and smoke dwell-time impact", parent);
+ const auto& summary = artifacts.hazardExposureSummary;
+ if (summary.hazards.empty()) {
+ auto* empty = createLabel("No hazard exposure data", parts.content);
+ empty->setStyleSheet(ui::mutedTextStyleSheet());
+ parts.contentLayout->addWidget(empty);
+ parts.contentLayout->addStretch(1);
+ return parts.panel;
+ }
+
+ auto* byTypeHeader = createReportSectionHeader("By Type", parts.content);
+ byTypeHeader->setToolTip("Exposure time is accumulated agent-seconds inside fire or smoke influence areas.");
+ parts.contentLayout->addWidget(byTypeHeader);
+ parts.contentLayout->addWidget(createHazardKindRow(
+ "Fire",
+ summarizeHazardKind(summary, safecrowd::domain::EnvironmentHazardKind::Fire),
+ parts.content));
+ parts.contentLayout->addWidget(createHazardKindRow(
+ "Smoke",
+ summarizeHazardKind(summary, safecrowd::domain::EnvironmentHazardKind::Smoke),
+ parts.content));
+
+ parts.contentLayout->addWidget(createReportSectionHeader("Hazards", parts.content));
+ for (std::size_t index = 0; index < summary.hazards.size(); ++index) {
+ const auto& hazard = summary.hazards[index];
+ QStringList lines{
+ QString("%1. %2 (%3)")
+ .arg(static_cast(index + 1))
+ .arg(hazardDisplayName(hazard))
+ .arg(hazardKindLabel(hazard.kind)),
+ QString("Exposure: %1 Peak exposed: %2 people")
+ .arg(formatExposureSeconds(hazard.exposedAgentSeconds))
+ .arg(static_cast(hazard.peakExposedAgentCount)),
+ QString("First: %1 Peak: %2")
+ .arg(formatOptionalSeconds(hazard.firstExposureSeconds))
+ .arg(formatOptionalSeconds(hazard.peakAtSeconds)),
+ QString("Severity: %1 Score: %2")
+ .arg(severityLabel(hazard.severity))
+ .arg(hazard.exposureScore, 0, 'f', 1),
+ };
+
+ QStringList locationParts;
+ if (!hazard.affectedZoneId.empty()) {
+ locationParts.push_back(QString("Zone: %1").arg(QString::fromStdString(hazard.affectedZoneId)));
+ }
+ if (!hazard.floorId.empty()) {
+ locationParts.push_back(QString("Floor: %1").arg(QString::fromStdString(hazard.floorId)));
+ }
+ locationParts.push_back(QString("Position: %1, %2")
+ .arg(hazard.position.x, 0, 'f', 1)
+ .arg(hazard.position.y, 0, 'f', 1));
+ lines.push_back(locationParts.join(" "));
+ parts.contentLayout->addWidget(createReportInfoRow(lines, parts.content));
+ }
+
+ parts.contentLayout->addStretch(1);
+ return parts.panel;
+}
+
QWidget* createZoneReportPanel(const safecrowd::domain::ScenarioResultArtifacts& artifacts, QWidget* parent) {
auto parts = createResultReportPanel("Zone", "Completion by source zone", parent);
if (artifacts.zoneCompletion.empty()) {
@@ -361,6 +514,11 @@ std::vector scenarioResultNavigationTabs() {
.label = "Hotspot",
.icon = makeResultNavigationIcon("hotspot", QColor("#1f5fae")),
},
+ {
+ .id = "exposure",
+ .label = "Exposure",
+ .icon = makeResultNavigationIcon("exposure", QColor("#1f5fae")),
+ },
{
.id = "zone",
.label = "Zone",
@@ -383,6 +541,8 @@ QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) {
switch (view) {
case ScenarioResultNavigationView::Hotspot:
return "hotspot";
+ case ScenarioResultNavigationView::HazardExposure:
+ return "exposure";
case ScenarioResultNavigationView::Zone:
return "zone";
case ScenarioResultNavigationView::Groups:
@@ -399,6 +559,9 @@ ScenarioResultNavigationView scenarioResultNavigationViewFromTabId(const QString
if (tabId == "hotspot") {
return ScenarioResultNavigationView::Hotspot;
}
+ if (tabId == "exposure") {
+ return ScenarioResultNavigationView::HazardExposure;
+ }
if (tabId == "zone") {
return ScenarioResultNavigationView::Zone;
}
@@ -421,6 +584,8 @@ QWidget* createScenarioResultNavigationPanel(
switch (view) {
case ScenarioResultNavigationView::Hotspot:
return createHotspotReportPanel(risk, std::move(hotspotFocusHandler), parent);
+ case ScenarioResultNavigationView::HazardExposure:
+ return createHazardExposureReportPanel(artifacts, parent);
case ScenarioResultNavigationView::Zone:
return createZoneReportPanel(artifacts, parent);
case ScenarioResultNavigationView::Groups:
diff --git a/src/application/ScenarioResultNavigation.h b/src/application/ScenarioResultNavigation.h
index b7b7322..dcbb906 100644
--- a/src/application/ScenarioResultNavigation.h
+++ b/src/application/ScenarioResultNavigation.h
@@ -17,6 +17,7 @@ namespace safecrowd::application {
enum class ScenarioResultNavigationView {
Bottleneck,
Hotspot,
+ HazardExposure,
Zone,
Groups,
Recommendations,
diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp
index c6c4aa1..249a60c 100644
--- a/src/application/ScenarioResultWidget.cpp
+++ b/src/application/ScenarioResultWidget.cpp
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -78,6 +79,80 @@ QString formatPressureScore(double score) {
return QString::number(score, 'f', 1);
}
+QString formatExposureScore(double score) {
+ return QString::number(score, 'f', 1);
+}
+
+QString formatExposureSeconds(double seconds) {
+ return QString("%1 agent-sec").arg(seconds, 0, 'f', 1);
+}
+
+QString hazardKindLabel(safecrowd::domain::EnvironmentHazardKind kind) {
+ switch (kind) {
+ case safecrowd::domain::EnvironmentHazardKind::Smoke:
+ return "Smoke";
+ case safecrowd::domain::EnvironmentHazardKind::Fire:
+ default:
+ return "Fire";
+ }
+}
+
+QString hazardSeverityLabel(safecrowd::domain::ScenarioElementSeverity severity) {
+ switch (severity) {
+ case safecrowd::domain::ScenarioElementSeverity::Low:
+ return "Low";
+ case safecrowd::domain::ScenarioElementSeverity::High:
+ return "High";
+ case safecrowd::domain::ScenarioElementSeverity::Medium:
+ default:
+ return "Medium";
+ }
+}
+
+QString hazardExposureLabel(const safecrowd::domain::HazardExposureMetric& metric) {
+ if (!metric.hazardName.empty()) {
+ return QString::fromStdString(metric.hazardName);
+ }
+ if (!metric.hazardId.empty()) {
+ return QString::fromStdString(metric.hazardId);
+ }
+ return hazardKindLabel(metric.kind);
+}
+
+double hazardExposureSecondsForKind(
+ const safecrowd::domain::HazardExposureSummary& summary,
+ safecrowd::domain::EnvironmentHazardKind kind) {
+ double total = 0.0;
+ for (const auto& metric : summary.hazards) {
+ if (metric.kind == kind) {
+ total += metric.exposedAgentSeconds;
+ }
+ }
+ return total;
+}
+
+double totalHazardExposureSeconds(const safecrowd::domain::HazardExposureSummary& summary) {
+ double total = 0.0;
+ for (const auto& metric : summary.hazards) {
+ total += metric.exposedAgentSeconds;
+ }
+ return total;
+}
+
+const safecrowd::domain::HazardExposureMetric* peakHazardExposureMetric(
+ const safecrowd::domain::HazardExposureSummary& summary) {
+ const safecrowd::domain::HazardExposureMetric* best = nullptr;
+ for (const auto& metric : summary.hazards) {
+ if (best == nullptr
+ || metric.peakExposedAgentCount > best->peakExposedAgentCount
+ || (metric.peakExposedAgentCount == best->peakExposedAgentCount
+ && metric.exposedAgentSeconds > best->exposedAgentSeconds)) {
+ best = &metric;
+ }
+ }
+ return best;
+}
+
QString simplifyLocationLabel(QString text) {
text = text.simplified();
if (text.isEmpty()) {
@@ -565,6 +640,7 @@ QString resultCriteriaTooltip(const safecrowd::domain::ScenarioResultArtifacts&
.arg(artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter, 0, 'f', 1),
QString("Pressure hotspot: score %1 or higher in a crowded cell.")
.arg(artifacts.pressureSummary.hotspotScoreThreshold, 0, 'f', 1),
+ "Hazard exposure: accumulated agent-seconds inside fire and smoke influence areas.",
safecrowd::domain::scenarioStalledDefinition(),
safecrowd::domain::scenarioBottleneckDefinition(),
}.join("\n\n");
@@ -653,6 +729,57 @@ QWidget* createExitUsageTable(const safecrowd::domain::ScenarioResultArtifacts&
return container;
}
+QWidget* createHazardExposureTable(const safecrowd::domain::ScenarioResultArtifacts& artifacts, QWidget* parent) {
+ auto* container = new QWidget(parent);
+ auto* layout = new QVBoxLayout(container);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->setSpacing(8);
+
+ const auto& summary = artifacts.hazardExposureSummary;
+ auto* total = createLabel(
+ QString("Exposure: %1 Fire: %2 Smoke: %3")
+ .arg(formatExposureSeconds(totalHazardExposureSeconds(summary)))
+ .arg(formatExposureSeconds(hazardExposureSecondsForKind(
+ summary,
+ safecrowd::domain::EnvironmentHazardKind::Fire)))
+ .arg(formatExposureSeconds(hazardExposureSecondsForKind(
+ summary,
+ safecrowd::domain::EnvironmentHazardKind::Smoke))),
+ container,
+ ui::FontRole::Caption);
+ total->setStyleSheet(ui::mutedTextStyleSheet());
+ total->setToolTip(QString("Accumulated exposed agent-seconds around fire and smoke hazards.\nSeverity-weighted score: %1")
+ .arg(formatExposureScore(summary.totalExposureScore)));
+ layout->addWidget(total);
+
+ if (summary.hazards.empty()) {
+ auto* empty = createLabel("No hazard exposure data", container);
+ empty->setStyleSheet(ui::mutedTextStyleSheet());
+ layout->addWidget(empty);
+ layout->addStretch(1);
+ return container;
+ }
+
+ auto* table = createResultTable(
+ {"Hazard", "Kind", "Exposure", "Peak", "First", "Peak at"},
+ static_cast(summary.hazards.size()),
+ container);
+ for (int row = 0; row < static_cast(summary.hazards.size()); ++row) {
+ const auto& hazard = summary.hazards[static_cast(row)];
+ table->setItem(row, 0, tableItem(hazardExposureLabel(hazard)));
+ table->setItem(row, 1, tableItem(QString("%1 / %2")
+ .arg(hazardKindLabel(hazard.kind), hazardSeverityLabel(hazard.severity))));
+ table->setItem(row, 2, tableItem(QString("%1 agent-sec")
+ .arg(hazard.exposedAgentSeconds, 0, 'f', 1)));
+ table->setItem(row, 3, tableItem(QString::number(static_cast(hazard.peakExposedAgentCount))));
+ table->setItem(row, 4, tableItem(formatOptionalSeconds(hazard.firstExposureSeconds)));
+ table->setItem(row, 5, tableItem(formatOptionalSeconds(hazard.peakAtSeconds)));
+ }
+ table->resizeRowsToContents();
+ layout->addWidget(table);
+ return container;
+}
+
class ResultReplayControls final : public QWidget {
public:
ResultReplayControls(
@@ -874,6 +1001,7 @@ QWidget* createResultGraphPanel(
remainingLayout->addWidget(timing);
tabs->addTab(remainingTab, "Remaining");
tabs->addTab(createExitUsageTable(artifacts, tabs), "Exits");
+ tabs->addTab(createHazardExposureTable(artifacts, tabs), "Exposure");
layout->addWidget(tabs, 1);
QObject::connect(toggleButton, &QPushButton::clicked, panel, [panel, tabs, toggleButton]() {
@@ -1175,6 +1303,23 @@ QWidget* createResultPanel(
const auto slowestGroup = artifacts.placementCompletion.empty()
? QString("Pending")
: QString::fromStdString(artifacts.placementCompletion.front().placementId);
+ const auto totalHazardExposure = totalHazardExposureSeconds(artifacts.hazardExposureSummary);
+ const auto fireHazardExposure = hazardExposureSecondsForKind(
+ artifacts.hazardExposureSummary,
+ safecrowd::domain::EnvironmentHazardKind::Fire);
+ const auto smokeHazardExposure = hazardExposureSecondsForKind(
+ artifacts.hazardExposureSummary,
+ safecrowd::domain::EnvironmentHazardKind::Smoke);
+ const auto* peakHazardExposure = peakHazardExposureMetric(artifacts.hazardExposureSummary);
+ const auto topHazard = peakHazardExposure == nullptr
+ ? QString("No hazard exposure recorded.")
+ : QString("Peak hazard: %1 (%2)\nExposure: %3\nPeak exposed: %4 people\nPeak at: %5\nFirst exposure: %6")
+ .arg(hazardExposureLabel(*peakHazardExposure))
+ .arg(hazardKindLabel(peakHazardExposure->kind))
+ .arg(formatExposureSeconds(peakHazardExposure->exposedAgentSeconds))
+ .arg(static_cast(peakHazardExposure->peakExposedAgentCount))
+ .arg(formatOptionalSeconds(peakHazardExposure->peakAtSeconds))
+ .arg(formatOptionalSeconds(peakHazardExposure->firstExposureSeconds));
const auto peakPressureTooltip = QString(
"Highest pressure hotspot score observed during the run.%1%2")
.arg(artifacts.pressureSummary.peakAtSeconds.has_value()
@@ -1251,6 +1396,30 @@ QWidget* createResultPanel(
panel,
QString("Peak simultaneously critical agents during the run.\nExposed peak: %1 agents.")
.arg(static_cast(artifacts.pressureSummary.peakExposedAgentCount))), 6, 0);
+ metricsGrid->addWidget(createMetricCard(
+ "Hazard Exposure",
+ formatExposureSeconds(totalHazardExposure),
+ panel,
+ QString("Total occupant dwell time inside fire and smoke influence areas.\nSeverity-weighted score: %1\n\n%2")
+ .arg(formatExposureScore(artifacts.hazardExposureSummary.totalExposureScore))
+ .arg(topHazard)), 6, 1);
+ metricsGrid->addWidget(createMetricCard(
+ "Fire Exposure",
+ formatExposureSeconds(fireHazardExposure),
+ panel,
+ "Accumulated occupant dwell time inside fire influence areas."), 7, 0);
+ metricsGrid->addWidget(createMetricCard(
+ "Smoke Exposure",
+ formatExposureSeconds(smokeHazardExposure),
+ panel,
+ "Accumulated occupant dwell time inside smoke influence areas."), 7, 1);
+ metricsGrid->addWidget(createMetricCard(
+ "Hazard Peak",
+ peakHazardExposure == nullptr
+ ? QString("None")
+ : QString("%1 people").arg(static_cast(peakHazardExposure->peakExposedAgentCount)),
+ panel,
+ topHazard), 8, 0);
layout->addLayout(metricsGrid);
layout->addStretch(1);
@@ -1287,6 +1456,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation
switch (view) {
case SavedResultNavigationView::Hotspot:
return ScenarioResultNavigationView::Hotspot;
+ case SavedResultNavigationView::HazardExposure:
+ return ScenarioResultNavigationView::HazardExposure;
case SavedResultNavigationView::Zone:
return ScenarioResultNavigationView::Zone;
case SavedResultNavigationView::Groups:
@@ -1303,6 +1474,8 @@ SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView
switch (view) {
case ScenarioResultNavigationView::Hotspot:
return SavedResultNavigationView::Hotspot;
+ case ScenarioResultNavigationView::HazardExposure:
+ return SavedResultNavigationView::HazardExposure;
case ScenarioResultNavigationView::Zone:
return SavedResultNavigationView::Zone;
case ScenarioResultNavigationView::Groups:
diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp
index a79ccf9..9a0960b 100644
--- a/src/application/ScenarioRunWidget.cpp
+++ b/src/application/ScenarioRunWidget.cpp
@@ -839,16 +839,8 @@ void ScenarioRunWidget::showResults() {
result.frame,
result.risk,
result.artifacts,
- [this]() {
- if (saveProjectHandler_) {
- saveProjectHandler_();
- }
- },
- [this]() {
- if (openProjectHandler_) {
- openProjectHandler_();
- }
- },
+ saveProjectHandler_,
+ openProjectHandler_,
backToLayoutReviewHandler_,
SavedResultNavigationView::Bottleneck,
returnAuthoringState_,
@@ -858,16 +850,8 @@ void ScenarioRunWidget::showResults() {
projectName_,
layout_,
std::move(results),
- [this]() {
- if (saveProjectHandler_) {
- saveProjectHandler_();
- }
- },
- [this]() {
- if (openProjectHandler_) {
- openProjectHandler_();
- }
- },
+ saveProjectHandler_,
+ openProjectHandler_,
backToLayoutReviewHandler_,
returnAuthoringState_,
selectedRunIndex_,
diff --git a/src/application/WorkspaceShell.cpp b/src/application/WorkspaceShell.cpp
index 875822f..7688955 100644
--- a/src/application/WorkspaceShell.cpp
+++ b/src/application/WorkspaceShell.cpp
@@ -13,6 +13,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -355,7 +356,7 @@ void WorkspaceShell::setBackHandler(std::function handler) {
QPushButton* WorkspaceShell::createBackButton(QWidget* parent) const {
auto* button = createPanelBackButton(parent);
- connect(button, &QPushButton::clicked, button, [this]() {
+ connect(button, &QPushButton::clicked, this, [this]() {
if (backHandler_) {
backHandler_();
}
@@ -399,14 +400,20 @@ void WorkspaceShell::rebuildTopBar() {
auto* menu = new QMenu(button);
openProjectAction_ = menu->addAction("Open Project");
connect(openProjectAction_, &QAction::triggered, this, [this]() {
- if (openProjectHandler_) {
- openProjectHandler_();
+ const auto handler = openProjectHandler_;
+ if (handler) {
+ QTimer::singleShot(0, this, [handler]() {
+ handler();
+ });
}
});
saveProjectAction_ = menu->addAction("Save Project");
connect(saveProjectAction_, &QAction::triggered, this, [this]() {
- if (saveProjectHandler_) {
- saveProjectHandler_();
+ const auto handler = saveProjectHandler_;
+ if (handler) {
+ QTimer::singleShot(0, this, [handler]() {
+ handler();
+ });
}
});
button->setMenu(menu);
diff --git a/tests/ProjectPersistenceTests.cpp b/tests/ProjectPersistenceTests.cpp
index cc631b4..2f0e8dc 100644
--- a/tests/ProjectPersistenceTests.cpp
+++ b/tests/ProjectPersistenceTests.cpp
@@ -1,7 +1,10 @@
#include "TestSupport.h"
#include "application/ProjectPersistence.h"
+#include "application/ResultArtifactsCodec.h"
#include
+#include
+#include
#include
using namespace safecrowd::application;
@@ -96,6 +99,191 @@ SC_TEST(ProjectPersistence_preservesRunningScenarioIndex) {
SC_EXPECT_EQ(loaded.runningScenarios.front().execution.repeatCount, std::uint32_t{3});
}
+SC_TEST(ProjectPersistence_preservesHazardExposureResultArtifacts) {
+ QTemporaryDir projectDir;
+ SC_EXPECT_TRUE(projectDir.isValid());
+
+ ScenarioDraft scenario;
+ scenario.scenarioId = "hazard-result";
+ scenario.name = "Hazard Exposure Result";
+
+ HazardExposureMetric exposure;
+ exposure.hazardId = "fire-a";
+ exposure.hazardName = "Lobby fire";
+ exposure.kind = EnvironmentHazardKind::Fire;
+ exposure.severity = ScenarioElementSeverity::High;
+ exposure.affectedZoneId = "lobby";
+ exposure.floorId = "L1";
+ exposure.position = {.x = 2.0, .y = 3.0};
+ exposure.exposedAgentSeconds = 12.5;
+ exposure.peakExposedAgentCount = 4;
+ exposure.firstExposureSeconds = 1.5;
+ exposure.peakAtSeconds = 3.0;
+ exposure.exposureScore = 25.0;
+
+ ProjectWorkspaceState workspace;
+ workspace.activeView = ProjectWorkspaceView::ScenarioResult;
+ workspace.result = SavedScenarioResultState{
+ .scenario = scenario,
+ .artifacts = {
+ .hazardExposureSummary = {
+ .totalExposureScore = 25.0,
+ .hazards = {exposure},
+ },
+ },
+ .navigationView = SavedResultNavigationView::HazardExposure,
+ };
+
+ const ProjectMetadata metadata{
+ .name = "Hazard Exposure Persistence Test",
+ .folderPath = projectDir.path(),
+ };
+
+ QString errorMessage;
+ SC_EXPECT_TRUE(ProjectPersistence::saveProjectWorkspace(metadata, workspace, &errorMessage));
+
+ ProjectWorkspaceState loaded;
+ SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded));
+ SC_EXPECT_TRUE(loaded.result.has_value());
+ SC_EXPECT_TRUE(loaded.result->navigationView == SavedResultNavigationView::HazardExposure);
+ const auto& loadedSummary = loaded.result->artifacts.hazardExposureSummary;
+ SC_EXPECT_NEAR(loadedSummary.totalExposureScore, 25.0, 1e-9);
+ SC_EXPECT_EQ(loadedSummary.hazards.size(), std::size_t{1});
+ const auto& loadedExposure = loadedSummary.hazards.front();
+ SC_EXPECT_EQ(loadedExposure.hazardId, std::string{"fire-a"});
+ SC_EXPECT_EQ(loadedExposure.hazardName, std::string{"Lobby fire"});
+ SC_EXPECT_TRUE(loadedExposure.kind == EnvironmentHazardKind::Fire);
+ SC_EXPECT_TRUE(loadedExposure.severity == ScenarioElementSeverity::High);
+ SC_EXPECT_EQ(loadedExposure.affectedZoneId, std::string{"lobby"});
+ SC_EXPECT_EQ(loadedExposure.floorId, std::string{"L1"});
+ SC_EXPECT_NEAR(loadedExposure.position.x, 2.0, 1e-9);
+ SC_EXPECT_NEAR(loadedExposure.position.y, 3.0, 1e-9);
+ SC_EXPECT_NEAR(loadedExposure.exposedAgentSeconds, 12.5, 1e-9);
+ SC_EXPECT_EQ(loadedExposure.peakExposedAgentCount, std::size_t{4});
+ SC_EXPECT_TRUE(loadedExposure.firstExposureSeconds.has_value());
+ SC_EXPECT_NEAR(*loadedExposure.firstExposureSeconds, 1.5, 1e-9);
+ SC_EXPECT_TRUE(loadedExposure.peakAtSeconds.has_value());
+ SC_EXPECT_NEAR(*loadedExposure.peakAtSeconds, 3.0, 1e-9);
+ SC_EXPECT_NEAR(loadedExposure.exposureScore, 25.0, 1e-9);
+}
+
+SC_TEST(ProjectPersistence_preservesBatchHazardExposureResultArtifacts) {
+ QTemporaryDir projectDir;
+ SC_EXPECT_TRUE(projectDir.isValid());
+
+ HazardExposureMetric fireExposure;
+ fireExposure.hazardId = "fire-b";
+ fireExposure.hazardName = "Atrium fire";
+ fireExposure.kind = EnvironmentHazardKind::Fire;
+ fireExposure.severity = ScenarioElementSeverity::High;
+ fireExposure.exposedAgentSeconds = 18.0;
+ fireExposure.peakExposedAgentCount = 6;
+ fireExposure.peakAtSeconds = 4.5;
+ fireExposure.exposureScore = 36.0;
+
+ HazardExposureMetric smokeExposure;
+ smokeExposure.hazardId = "smoke-b";
+ smokeExposure.hazardName = "Upper smoke";
+ smokeExposure.kind = EnvironmentHazardKind::Smoke;
+ smokeExposure.severity = ScenarioElementSeverity::Medium;
+ smokeExposure.exposedAgentSeconds = 9.0;
+ smokeExposure.peakExposedAgentCount = 3;
+ smokeExposure.peakAtSeconds = 6.0;
+ smokeExposure.exposureScore = 13.5;
+
+ SavedScenarioResultState baseline;
+ baseline.scenario.scenarioId = "hazard-batch-baseline";
+ baseline.scenario.name = "Hazard Batch Baseline";
+ baseline.artifacts.hazardExposureSummary = {
+ .totalExposureScore = 36.0,
+ .hazards = {fireExposure},
+ };
+
+ SavedScenarioResultState alternative;
+ alternative.scenario.scenarioId = "hazard-batch-alternative";
+ alternative.scenario.name = "Hazard Batch Alternative";
+ alternative.navigationView = SavedResultNavigationView::HazardExposure;
+ alternative.artifacts.hazardExposureSummary = {
+ .totalExposureScore = 49.5,
+ .hazards = {fireExposure, smokeExposure},
+ };
+
+ ProjectWorkspaceState workspace;
+ workspace.activeView = ProjectWorkspaceView::ScenarioResult;
+ workspace.batchResult = SavedScenarioBatchResultState{
+ .results = {baseline, alternative},
+ .currentResultIndex = 1,
+ };
+
+ const ProjectMetadata metadata{
+ .name = "Hazard Batch Persistence Test",
+ .folderPath = projectDir.path(),
+ };
+
+ QString errorMessage;
+ SC_EXPECT_TRUE(ProjectPersistence::saveProjectWorkspace(metadata, workspace, &errorMessage));
+
+ ProjectWorkspaceState loaded;
+ SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded));
+ SC_EXPECT_TRUE(loaded.batchResult.has_value());
+ SC_EXPECT_EQ(loaded.batchResult->results.size(), std::size_t{2});
+ SC_EXPECT_EQ(loaded.batchResult->currentResultIndex, 1);
+
+ const auto& loadedAlternative = loaded.batchResult->results.back();
+ SC_EXPECT_EQ(loadedAlternative.scenario.scenarioId, std::string{"hazard-batch-alternative"});
+ SC_EXPECT_TRUE(loadedAlternative.navigationView == SavedResultNavigationView::HazardExposure);
+ const auto& loadedSummary = loadedAlternative.artifacts.hazardExposureSummary;
+ SC_EXPECT_NEAR(loadedSummary.totalExposureScore, 49.5, 1e-9);
+ SC_EXPECT_EQ(loadedSummary.hazards.size(), std::size_t{2});
+ SC_EXPECT_TRUE(loadedSummary.hazards.back().kind == EnvironmentHazardKind::Smoke);
+ SC_EXPECT_TRUE(loadedSummary.hazards.back().severity == ScenarioElementSeverity::Medium);
+ SC_EXPECT_NEAR(loadedSummary.hazards.back().exposedAgentSeconds, 9.0, 1e-9);
+}
+
+SC_TEST(ResultArtifactsCodec_readsHazardExposureEnumsDefensively) {
+ QJsonArray position;
+ position.append(1.0);
+ position.append(2.0);
+
+ QJsonObject hazard;
+ hazard["hazardId"] = "legacy-smoke";
+ hazard["kind"] = "Smoke";
+ hazard["severity"] = "High";
+ hazard["position"] = position;
+
+ QJsonArray hazards;
+ hazards.append(hazard);
+
+ QJsonObject numericHazard;
+ numericHazard["hazardId"] = "legacy-numeric";
+ numericHazard["kind"] = static_cast(EnvironmentHazardKind::Smoke);
+ numericHazard["severity"] = static_cast(ScenarioElementSeverity::Low);
+ numericHazard["position"] = position;
+ hazards.append(numericHazard);
+
+ QJsonObject invalidHazard;
+ invalidHazard["hazardId"] = "legacy-invalid";
+ invalidHazard["kind"] = 99;
+ invalidHazard["severity"] = 99;
+ invalidHazard["position"] = position;
+ hazards.append(invalidHazard);
+
+ QJsonObject summary;
+ summary["hazards"] = hazards;
+
+ QJsonObject artifactsObject;
+ artifactsObject["hazardExposureSummary"] = summary;
+
+ const auto artifacts = resultArtifactsFromJson(artifactsObject);
+ SC_EXPECT_EQ(artifacts.hazardExposureSummary.hazards.size(), std::size_t{3});
+ SC_EXPECT_TRUE(artifacts.hazardExposureSummary.hazards[0].kind == EnvironmentHazardKind::Smoke);
+ SC_EXPECT_TRUE(artifacts.hazardExposureSummary.hazards[0].severity == ScenarioElementSeverity::High);
+ SC_EXPECT_TRUE(artifacts.hazardExposureSummary.hazards[1].kind == EnvironmentHazardKind::Smoke);
+ SC_EXPECT_TRUE(artifacts.hazardExposureSummary.hazards[1].severity == ScenarioElementSeverity::Low);
+ SC_EXPECT_TRUE(artifacts.hazardExposureSummary.hazards[2].kind == EnvironmentHazardKind::Fire);
+ SC_EXPECT_TRUE(artifacts.hazardExposureSummary.hazards[2].severity == ScenarioElementSeverity::Medium);
+}
+
SC_TEST(ProjectPersistence_preservesImportArtifactsBesideLayoutReview) {
QTemporaryDir projectDir;
SC_EXPECT_TRUE(projectDir.isValid());