diff --git "a/docs/product/\354\234\204\355\227\230 \354\240\225\354\235\230.md" "b/docs/product/\354\234\204\355\227\230 \354\240\225\354\235\230.md" index 588b3ea..7b5d834 100644 --- "a/docs/product/\354\234\204\355\227\230 \354\240\225\354\235\230.md" +++ "b/docs/product/\354\234\204\355\227\230 \354\240\225\354\235\230.md" @@ -116,6 +116,8 @@ Pathfinder 2026.1 공식 문서를 다시 확인한 결과, 현재 제품 기능 기본 `시야/친숙도/유도 신호` 입력과 결과 비교는 `1차 확장` 범위로 보고, `FED/FDS` 같은 환경 연동은 `중기 확장`으로 둔다. +시나리오 작성 단계의 화재/연기 hazard는 v1에서 위치, 구역, 시간대, 심각도를 기록하는 authoring 입력이다. 이는 비상 조건을 비교하기 위한 시나리오 요소이며, 연기 농도, 화재 확산, FED, FDS 연동을 계산하는 물리 모델은 아니다. + ### 1.3. 근접도 및 압박 전조 ### 정의 diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 84a306b..7ea2fc6 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -720,20 +720,108 @@ safecrowd::domain::PopulationSpec populationFromJson(const QJsonObject& object) return population; } +QString hazardKindToJson(safecrowd::domain::EnvironmentHazardKind kind) { + switch (kind) { + case safecrowd::domain::EnvironmentHazardKind::Smoke: + return "Smoke"; + case safecrowd::domain::EnvironmentHazardKind::Fire: + default: + return "Fire"; + } +} + +safecrowd::domain::EnvironmentHazardKind hazardKindFromJson(const QJsonValue& value) { + if (value.isDouble()) { + return static_cast(value.toInt()); + } + + const auto raw = value.toString().toLower(); + if (raw == "smoke") { + return safecrowd::domain::EnvironmentHazardKind::Smoke; + } + return safecrowd::domain::EnvironmentHazardKind::Fire; +} + +QString severityToJson(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 severityFromJson(const QJsonValue& value) { + if (value.isDouble()) { + return static_cast(value.toInt()); + } + + const auto raw = value.toString().toLower(); + if (raw == "low") { + return safecrowd::domain::ScenarioElementSeverity::Low; + } + if (raw == "high") { + return safecrowd::domain::ScenarioElementSeverity::High; + } + return safecrowd::domain::ScenarioElementSeverity::Medium; +} + +QJsonObject hazardToJson(const safecrowd::domain::EnvironmentHazardDraft& hazard) { + QJsonObject object; + object["id"] = QString::fromStdString(hazard.id); + object["kind"] = hazardKindToJson(hazard.kind); + object["name"] = QString::fromStdString(hazard.name); + object["affectedZoneId"] = QString::fromStdString(hazard.affectedZoneId); + object["floorId"] = QString::fromStdString(hazard.floorId); + object["position"] = pointArray(hazard.position); + object["startSeconds"] = hazard.startSeconds; + object["endSeconds"] = hazard.endSeconds; + object["severity"] = severityToJson(hazard.severity); + object["note"] = QString::fromStdString(hazard.note); + return object; +} + +safecrowd::domain::EnvironmentHazardDraft hazardFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString().toStdString(), + .kind = hazardKindFromJson(object.value("kind")), + .name = object.value("name").toString().toStdString(), + .affectedZoneId = object.value("affectedZoneId").toString().toStdString(), + .floorId = object.value("floorId").toString().toStdString(), + .position = pointFromJson(object.value("position")), + .startSeconds = object.value("startSeconds").toDouble(0.0), + .endSeconds = object.value("endSeconds").toDouble(0.0), + .severity = severityFromJson(object.value("severity")), + .note = object.value("note").toString().toStdString(), + }; +} + QJsonObject environmentToJson(const safecrowd::domain::EnvironmentState& environment) { QJsonObject object; object["reducedVisibility"] = environment.reducedVisibility; object["familiarityProfile"] = QString::fromStdString(environment.familiarityProfile); object["guidanceProfile"] = QString::fromStdString(environment.guidanceProfile); + QJsonArray hazards; + for (const auto& hazard : environment.hazards) { + hazards.append(hazardToJson(hazard)); + } + object["hazards"] = hazards; return object; } safecrowd::domain::EnvironmentState environmentFromJson(const QJsonObject& object) { - return { + safecrowd::domain::EnvironmentState environment{ .reducedVisibility = object.value("reducedVisibility").toBool(false), .familiarityProfile = object.value("familiarityProfile").toString().toStdString(), .guidanceProfile = object.value("guidanceProfile").toString().toStdString(), }; + for (const auto& value : object.value("hazards").toArray()) { + environment.hazards.push_back(hazardFromJson(value.toObject())); + } + return environment; } QJsonObject eventToJson(const safecrowd::domain::OperationalEventDraft& event) { diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index e421d42..5a9cc8c 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,8 @@ QLabel* createLabel(const QString& text, QWidget* parent, ui::FontRole role = ui return label; } +QString zoneLabel(const safecrowd::domain::Zone2D& zone); + bool editOperationalEvent( safecrowd::domain::OperationalEventDraft* event, QWidget* parent) { @@ -92,6 +95,150 @@ bool editOperationalEvent( return true; } +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"; + } +} + +bool editEnvironmentHazard( + safecrowd::domain::EnvironmentHazardDraft* hazard, + const safecrowd::domain::FacilityLayout2D& layout, + QWidget* parent) { + if (hazard == nullptr) { + return false; + } + if (layout.zones.empty()) { + QMessageBox::warning(parent, "Edit hazard", "A hazard must be assigned to a zone."); + return false; + } + + QDialog dialog(parent); + dialog.setWindowTitle("Edit hazard"); + + auto* root = new QVBoxLayout(&dialog); + root->setContentsMargins(16, 16, 16, 16); + root->setSpacing(12); + + auto* form = new QFormLayout(); + form->setContentsMargins(0, 0, 0, 0); + form->setSpacing(8); + + auto* kindCombo = new QComboBox(&dialog); + kindCombo->addItem("Fire", static_cast(safecrowd::domain::EnvironmentHazardKind::Fire)); + kindCombo->addItem("Smoke", static_cast(safecrowd::domain::EnvironmentHazardKind::Smoke)); + kindCombo->setCurrentIndex(std::max(0, kindCombo->findData(static_cast(hazard->kind)))); + + auto* nameEdit = new QLineEdit(&dialog); + nameEdit->setText(QString::fromStdString(hazard->name)); + + auto* zoneCombo = new QComboBox(&dialog); + for (const auto& zone : layout.zones) { + zoneCombo->addItem(zoneLabel(zone), QString::fromStdString(zone.id)); + } + zoneCombo->setCurrentIndex(std::max(0, zoneCombo->findData(QString::fromStdString(hazard->affectedZoneId)))); + + auto* xSpin = new QDoubleSpinBox(&dialog); + xSpin->setRange(-100000.0, 100000.0); + xSpin->setDecimals(2); + xSpin->setValue(hazard->position.x); + + auto* ySpin = new QDoubleSpinBox(&dialog); + ySpin->setRange(-100000.0, 100000.0); + ySpin->setDecimals(2); + ySpin->setValue(hazard->position.y); + + auto* startSpin = new QDoubleSpinBox(&dialog); + startSpin->setRange(0.0, 86400.0); + startSpin->setDecimals(1); + startSpin->setSuffix(" s"); + startSpin->setValue(std::max(0.0, hazard->startSeconds)); + + auto* endSpin = new QDoubleSpinBox(&dialog); + endSpin->setRange(0.0, 86400.0); + endSpin->setDecimals(1); + endSpin->setSuffix(" s"); + endSpin->setValue(std::max(0.0, hazard->endSeconds)); + + auto* severityCombo = new QComboBox(&dialog); + severityCombo->addItem("Low", static_cast(safecrowd::domain::ScenarioElementSeverity::Low)); + severityCombo->addItem("Medium", static_cast(safecrowd::domain::ScenarioElementSeverity::Medium)); + severityCombo->addItem("High", static_cast(safecrowd::domain::ScenarioElementSeverity::High)); + severityCombo->setCurrentIndex(std::max(0, severityCombo->findData(static_cast(hazard->severity)))); + + auto* noteEdit = new QPlainTextEdit(&dialog); + noteEdit->setPlainText(QString::fromStdString(hazard->note)); + noteEdit->setMinimumHeight(72); + + auto* visibilityHint = createLabel( + "Smoke hazards are scenario inputs linked to reduced visibility concepts. No fire spread, smoke concentration, FED, or FDS runtime effect is calculated in v1.", + &dialog); + visibilityHint->setStyleSheet(ui::mutedTextStyleSheet()); + + form->addRow("Kind", kindCombo); + form->addRow("Name", nameEdit); + form->addRow("Affected zone", zoneCombo); + form->addRow("X", xSpin); + form->addRow("Y", ySpin); + form->addRow("Start", startSpin); + form->addRow("End", endSpin); + form->addRow("Severity", severityCombo); + form->addRow("Note", noteEdit); + root->addLayout(form); + root->addWidget(visibilityHint); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + root->addWidget(buttons); + + if (dialog.exec() != QDialog::Accepted) { + return false; + } + + const auto name = nameEdit->text().trimmed(); + if (name.isEmpty()) { + return false; + } + + hazard->kind = static_cast(kindCombo->currentData().toInt()); + hazard->name = name.toStdString(); + const auto selectedZoneId = zoneCombo->currentData().toString().toStdString(); + const auto selectedZone = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + return zone.id == selectedZoneId; + }); + if (selectedZone == layout.zones.end()) { + return false; + } + hazard->affectedZoneId = selectedZoneId; + hazard->floorId = selectedZone->floorId; + hazard->position = { + .x = xSpin->value(), + .y = ySpin->value(), + }; + hazard->startSeconds = startSpin->value(); + hazard->endSeconds = std::max(hazard->startSeconds, endSpin->value()); + hazard->severity = static_cast(severityCombo->currentData().toInt()); + hazard->note = noteEdit->toPlainText().trimmed().toStdString(); + return true; +} + QString zoneLabel(const safecrowd::domain::Zone2D& zone) { const auto id = QString::fromStdString(zone.id); const auto label = QString::fromStdString(zone.label); @@ -142,6 +289,29 @@ QString blockScheduleSummary(const safecrowd::domain::ConnectionBlockDraft& bloc return intervals.join(", "); } +QString hazardScheduleSummary(const safecrowd::domain::EnvironmentHazardDraft& hazard) { + return QString("%1s - %2s").arg(hazard.startSeconds, 0, 'f', 1).arg(hazard.endSeconds, 0, 'f', 1); +} + +QString hazardZoneSummary( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::EnvironmentHazardDraft& hazard) { + if (hazard.affectedZoneId.empty()) { + return "Unassigned zone"; + } + return zoneName(layout, hazard.affectedZoneId); +} + +QString hazardPositionSummary(const safecrowd::domain::EnvironmentHazardDraft& hazard) { + return QString("(%1, %2)").arg(hazard.position.x, 0, 'f', 1).arg(hazard.position.y, 0, 'f', 1); +} + +bool hasSmokeHazard(const safecrowd::domain::EnvironmentState& environment) { + return std::any_of(environment.hazards.begin(), environment.hazards.end(), [](const auto& hazard) { + return hazard.kind == safecrowd::domain::EnvironmentHazardKind::Smoke; + }); +} + int draftOccupantCount(const safecrowd::domain::ScenarioDraft& scenario) { int total = 0; for (const auto& placement : scenario.population.initialPlacements) { @@ -200,6 +370,15 @@ QString buildChangeSummaryLine( .arg(QString::fromStdString(baseline.environment.guidanceProfile), QString::fromStdString(variant.environment.guidanceProfile)); } + if (key == "environment.hazards") { + QString summary = countChangeSummary("hazards", + static_cast(baseline.environment.hazards.size()), + static_cast(variant.environment.hazards.size())); + if (hasSmokeHazard(variant.environment)) { + summary += ", smoke linked to reduced visibility concept"; + } + return QString("environment.hazards (%1)").arg(summary); + } if (key == "control.events") { return QString("control.events (%1)") .arg(countChangeSummary("events", static_cast(baseline.control.events.size()), @@ -247,6 +426,9 @@ QString changeCategoryLabel(const std::string& key) { if (key.rfind("population.", 0) == 0) { return "Crowd"; } + if (key == "environment.hazards") { + return "Hazards"; + } if (key.rfind("environment.", 0) == 0) { return "Layout"; } @@ -265,6 +447,7 @@ QString compactChangeSummary(const QString& summary) { compact.replace("environment.reducedVisibility", "layout visibility"); compact.replace("environment.familiarityProfile", "layout familiarity"); compact.replace("environment.guidanceProfile", "layout guidance"); + compact.replace("environment.hazards", "hazards"); compact.replace("control.events", "events"); compact.replace("control.connectionBlocks", "blocked events"); compact.replace("control.routeGuidances", "route guidance"); @@ -568,6 +751,73 @@ std::vector buildEventsTree( }); } + const auto& hazards = scenario->draft.environment.hazards; + if (!hazards.empty()) { + std::vector nodes; + nodes.reserve(hazards.size()); + for (const auto& hazard : hazards) { + const auto hazardId = QString::fromStdString(hazard.id); + const auto kind = hazardKindLabel(hazard.kind); + const auto zone = hazardZoneSummary(layout, hazard); + const auto position = hazardPositionSummary(hazard); + const auto schedule = hazardScheduleSummary(hazard); + const auto severity = severityLabel(hazard.severity); + QStringList details; + details << QString("Zone: %1").arg(zone) + << QString("Location: %1").arg(position) + << QString("Period: %1").arg(schedule) + << QString("Severity: %1").arg(severity); + if (hazard.kind == safecrowd::domain::EnvironmentHazardKind::Smoke) { + details << "Visibility: reduced visibility concept"; + } + + std::vector children{ + { + .label = QString("Kind - %1").arg(kind), + .id = QString("%1/kind").arg(hazardId), + }, + { + .label = QString("Zone - %1").arg(zone), + .id = QString("%1/zone").arg(hazardId), + }, + { + .label = QString("Location - %1").arg(position), + .id = QString("%1/location").arg(hazardId), + }, + { + .label = QString("Period - %1").arg(schedule), + .id = QString("%1/period").arg(hazardId), + }, + { + .label = QString("Severity - %1").arg(severity), + .id = QString("%1/severity").arg(hazardId), + }, + }; + if (!hazard.note.empty()) { + children.push_back({ + .label = QString("Note - %1").arg(QString::fromStdString(hazard.note)), + .id = QString("%1/note").arg(hazardId), + }); + } + + const auto hazardName = QString::fromStdString(hazard.name); + nodes.push_back({ + .label = QString("Hazard - %1: %2").arg(kind, hazardName), + .id = hazardId, + .detail = details.join(" / "), + .children = std::move(children), + .expanded = true, + }); + } + + sections.push_back({ + .label = QString("Hazards (%1)").arg(static_cast(hazards.size())), + .children = std::move(nodes), + .expanded = true, + .selectable = false, + }); + } + const auto& routeGuidances = scenario->draft.control.routeGuidances; if (!routeGuidances.empty()) { std::vector nodes; @@ -670,17 +920,16 @@ std::vector buildEventsTree( QWidget* createEventsPanel( const safecrowd::domain::FacilityLayout2D& layout, const ScenarioAuthoringWidget::ScenarioState* scenario, - const WorkspaceShell* shell, QWidget* parent, std::function deleteItemHandler, std::function settingsItemHandler) { return new NavigationTreeWidget( - "Events", + "Events / Hazards", buildEventsTree(layout, scenario), - "No operational events or blocked exits yet", + "No operational events, hazards, or blocked exits yet", {}, parent, - shell != nullptr ? shell->createPanelHeader("Events", parent, false) : nullptr, + createLabel("Events / Hazards", parent, ui::FontRole::Title), {}, {}, std::move(deleteItemHandler), @@ -949,6 +1198,17 @@ void ScenarioAuthoringWidget::refreshCanvas() { refreshNavigationPanel(); refreshInspector(); }); + canvas_->setEnvironmentHazards(scenario->draft.environment.hazards); + canvas_->setEnvironmentHazardsChangedHandler([this](const std::vector& hazards) { + auto* current = currentScenario(); + if (current == nullptr) { + return; + } + current->draft.environment.hazards = hazards; + recomputeDiffKeysAfterScenarioChanged(*current); + refreshNavigationPanel(); + refreshInspector(); + }); if (!selectedLayoutElementId_.isEmpty()) { canvas_->focusLayoutElement(selectedLayoutElementId_); } else if (!selectedCrowdElementId_.isEmpty()) { @@ -984,6 +1244,7 @@ void ScenarioAuthoringWidget::refreshInspector() { addMetaRow(panelLayout, "Population", QString::number(totalOccupantCount(*scenario)), scenarioOverviewPanel_); addMetaRow(panelLayout, "Events", QString::number(static_cast(scenario->events.size())), scenarioOverviewPanel_); + addMetaRow(panelLayout, "Hazards", QString::number(static_cast(scenario->draft.environment.hazards.size())), scenarioOverviewPanel_); addMetaRow(panelLayout, "Guidance", QString::number(static_cast(scenario->draft.control.routeGuidances.size())), scenarioOverviewPanel_); addMetaRow(panelLayout, "Blocked", QString::number(static_cast(scenario->draft.control.connectionBlocks.size())), scenarioOverviewPanel_); addMetaRow(panelLayout, "Start", scenario->startText, scenarioOverviewPanel_); @@ -1104,7 +1365,7 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { }, { .id = "events", - .label = "Events", + .label = "Events / Hazards", .icon = makeEventsIcon(QColor("#1f5fae")), }, }, @@ -1173,7 +1434,6 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { layout_, currentScenario(), shell_, - shell_, [this](const QString& rawId) { auto* scenario = currentScenario(); if (scenario == nullptr || rawId.isEmpty()) { @@ -1193,25 +1453,41 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { const auto it = std::remove_if(events.begin(), events.end(), [&](const auto& event) { return event.id == eventId; }); - if (it == events.end()) { + if (it != events.end()) { + events.erase(it, events.end()); + scenario->draft.control.events = scenario->events; + recomputeDiffKeysAfterScenarioChanged(*scenario); + refreshNavigationPanel(); + refreshInspector(); + return; + } + + auto& hazards = scenario->draft.environment.hazards; + const auto hazardId = id.toStdString(); + const auto hazardIt = std::remove_if(hazards.begin(), hazards.end(), [&](const auto& hazard) { + return hazard.id == hazardId; + }); + if (hazardIt == hazards.end()) { return; } - events.erase(it, events.end()); - scenario->draft.control.events = scenario->events; + hazards.erase(hazardIt, hazards.end()); + if (canvas_ != nullptr) { + canvas_->setEnvironmentHazards(hazards); + } recomputeDiffKeysAfterScenarioChanged(*scenario); refreshNavigationPanel(); refreshInspector(); }, [this](const QString& rawId) { - if (canvas_ == nullptr || rawId.isEmpty()) { + if (rawId.isEmpty()) { return; } const auto id = rawId.section('/', 0, 0); - if (canvas_->editConnectionBlockScheduleById(id)) { + if (canvas_ != nullptr && canvas_->editConnectionBlockScheduleById(id)) { return; } - if (canvas_->editRouteGuidanceById(id)) { + if (canvas_ != nullptr && canvas_->editRouteGuidanceById(id)) { return; } @@ -1225,13 +1501,28 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { const auto it = std::find_if(events.begin(), events.end(), [&](auto& event) { return event.id == eventId; }); - if (it == events.end()) { + if (it != events.end()) { + if (!editOperationalEvent(&(*it), this)) { + return; + } + scenario->draft.control.events = scenario->events; + recomputeDiffKeysAfterScenarioChanged(*scenario); + refreshNavigationPanel(); + refreshInspector(); return; } - if (!editOperationalEvent(&(*it), this)) { + + auto& hazards = scenario->draft.environment.hazards; + const auto hazardId = id.toStdString(); + const auto hazardIt = std::find_if(hazards.begin(), hazards.end(), [&](auto& hazard) { + return hazard.id == hazardId; + }); + if (hazardIt == hazards.end() || !editEnvironmentHazard(&(*hazardIt), layout_, this)) { return; } - scenario->draft.control.events = scenario->events; + if (canvas_ != nullptr) { + canvas_->setEnvironmentHazards(hazards); + } recomputeDiffKeysAfterScenarioChanged(*scenario); refreshNavigationPanel(); refreshInspector(); diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index eff0349..19e5e77 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -663,6 +663,35 @@ QIcon makeToolIcon(const QString& type, const QColor& color) { return QIcon(pixmap); } + if (type == "fire") { + QPainterPath flame; + flame.moveTo(QPointF(22, 35)); + flame.cubicTo(QPointF(11, 29), QPointF(14, 18), QPointF(20, 13)); + flame.cubicTo(QPointF(22, 10), QPointF(23, 7), QPointF(22, 4)); + flame.cubicTo(QPointF(31, 10), QPointF(36, 19), QPointF(31, 28)); + flame.cubicTo(QPointF(29, 32), QPointF(26, 34), QPointF(22, 35)); + painter.setPen(Qt::NoPen); + painter.setBrush(color); + painter.drawPath(flame); + painter.setBrush(QColor("#fff4d6")); + QPainterPath inner; + inner.moveTo(QPointF(22, 31)); + inner.cubicTo(QPointF(17, 27), QPointF(19, 20), QPointF(23, 16)); + inner.cubicTo(QPointF(28, 22), QPointF(28, 28), QPointF(22, 31)); + painter.drawPath(inner); + return QIcon(pixmap); + } + + if (type == "smoke") { + painter.setPen(QPen(color, 3.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawArc(QRectF(9, 24, 14, 10), 20 * 16, 220 * 16); + painter.drawArc(QRectF(19, 22, 16, 12), 20 * 16, 220 * 16); + painter.drawArc(QRectF(12, 14, 13, 10), 20 * 16, 220 * 16); + painter.drawArc(QRectF(24, 11, 12, 10), 20 * 16, 220 * 16); + painter.drawLine(QPointF(13, 36), QPointF(34, 36)); + return QIcon(pixmap); + } + if (type != "group") { return QIcon(pixmap); } @@ -1255,6 +1284,16 @@ void ScenarioCanvasWidget::setRouteGuidancesChangedHandler( routeGuidancesChangedHandler_ = std::move(handler); } +void ScenarioCanvasWidget::setEnvironmentHazards(std::vector hazards) { + environmentHazards_ = std::move(hazards); + update(); +} + +void ScenarioCanvasWidget::setEnvironmentHazardsChangedHandler( + std::function&)> handler) { + environmentHazardsChangedHandler_ = std::move(handler); +} + void ScenarioCanvasWidget::setLayoutElementActivatedHandler(std::function handler) { layoutElementActivatedHandler_ = std::move(handler); } @@ -1315,6 +1354,20 @@ void ScenarioCanvasWidget::activateLayoutElement(const QString& elementId) { return; } addRouteGuidanceForConnection(*connectionIt); + return; + } + + if (toolMode_ == ToolMode::FireHazard || toolMode_ == ToolMode::SmokeHazard) { + const auto targetId = elementId.toStdString(); + const auto it = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { + return zone.id == targetId; + }); + if (it == layout_.zones.end()) { + return; + } + addEnvironmentHazardForZone(*it, polygonCenter(it->area), toolMode_ == ToolMode::FireHazard + ? safecrowd::domain::EnvironmentHazardKind::Fire + : safecrowd::domain::EnvironmentHazardKind::Smoke); } } @@ -1623,6 +1676,14 @@ void ScenarioCanvasWidget::mousePressEvent(QMouseEvent* event) { return; } + if (toolMode_ == ToolMode::FireHazard || toolMode_ == ToolMode::SmokeHazard) { + addEnvironmentHazard(event->position(), toolMode_ == ToolMode::FireHazard + ? safecrowd::domain::EnvironmentHazardKind::Fire + : safecrowd::domain::EnvironmentHazardKind::Smoke); + event->accept(); + return; + } + if (toolMode_ == ToolMode::Select) { selectionDragging_ = true; selectionDragStart_ = event->position(); @@ -1734,6 +1795,7 @@ void ScenarioCanvasWidget::paintEvent(QPaintEvent* event) { drawFocusedPlacement(painter, transform); drawConnectionBlocks(painter, transform); drawRouteGuidances(painter, transform); + drawEnvironmentHazards(painter, transform); if (dragging_ || selectionDragging_) { const auto start = dragging_ ? dragStart_ : selectionDragStart_; @@ -1943,6 +2005,46 @@ void ScenarioCanvasWidget::drawRouteGuidances(QPainter& painter, const LayoutCan } } +void ScenarioCanvasWidget::drawEnvironmentHazards(QPainter& painter, const LayoutCanvasTransform& transform) const { + for (const auto& hazard : environmentHazards_) { + if (hazard.affectedZoneId.empty()) { + continue; + } + const auto zoneIt = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { + return zone.id == hazard.affectedZoneId; + }); + if (zoneIt == layout_.zones.end()) { + continue; + } + const auto floorId = hazard.floorId.empty() ? zoneIt->floorId : hazard.floorId; + if (!matchesFloor(floorId, currentFloorId_)) { + continue; + } + + const auto center = transform.map(hazard.position); + const QColor fill = hazard.kind == safecrowd::domain::EnvironmentHazardKind::Fire + ? QColor("#c2410c") + : QColor("#64748b"); + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + painter.drawEllipse(center, 11.0, 11.0); + + painter.setPen(QPen(Qt::white, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + if (hazard.kind == safecrowd::domain::EnvironmentHazardKind::Fire) { + QPainterPath flame; + flame.moveTo(center + QPointF(0.0, 6.0)); + flame.cubicTo(center + QPointF(-5.0, 2.0), center + QPointF(-3.5, -4.0), center + QPointF(-0.5, -7.0)); + flame.cubicTo(center + QPointF(4.0, -3.0), center + QPointF(4.0, 3.0), center + QPointF(0.0, 6.0)); + painter.drawPath(flame); + } else { + painter.drawArc(QRectF(center.x() - 7.0, center.y() - 1.0, 9.0, 7.0), 20 * 16, 220 * 16); + painter.drawArc(QRectF(center.x() - 1.0, center.y() - 3.0, 10.0, 7.0), 20 * 16, 220 * 16); + painter.drawArc(QRectF(center.x() - 5.0, center.y() - 8.0, 8.0, 6.0), 20 * 16, 220 * 16); + } + } +} + std::optional ScenarioCanvasWidget::collectBounds() const { return collectLayoutCanvasBounds(layout_, currentFloorId_.toStdString()); } @@ -2288,6 +2390,18 @@ QString ScenarioCanvasWidget::nextRouteGuidanceId() const { return QString("guidance-%1").arg(static_cast(routeGuidances_.size()) + 1); } +QString ScenarioCanvasWidget::nextEnvironmentHazardId() const { + for (int index = static_cast(environmentHazards_.size()) + 1;; ++index) { + const auto candidate = QString("hazard-%1").arg(index); + const auto exists = std::any_of(environmentHazards_.begin(), environmentHazards_.end(), [&](const auto& hazard) { + return QString::fromStdString(hazard.id) == candidate; + }); + if (!exists) { + return candidate; + } + } +} + void ScenarioCanvasWidget::addGroupPlacement(const QPointF& start, const QPointF& end) { if ((QLineF(start, end).length()) < 8.0) { return; @@ -2557,6 +2671,52 @@ void ScenarioCanvasWidget::addRouteGuidanceForConnection(const safecrowd::domain update(); } +void ScenarioCanvasWidget::addEnvironmentHazard( + const QPointF& position, + safecrowd::domain::EnvironmentHazardKind kind) { + const auto point = unmapPoint(position); + const auto zoneId = zoneAt(point); + if (zoneId.isEmpty()) { + QMessageBox::information(this, "Hazard", "Click inside a zone to place a fire or smoke hazard."); + return; + } + + const auto zoneIdStd = zoneId.toStdString(); + const auto it = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { + return zone.id == zoneIdStd; + }); + if (it == layout_.zones.end()) { + return; + } + addEnvironmentHazardForZone(*it, point, kind); +} + +void ScenarioCanvasWidget::addEnvironmentHazardForZone( + const safecrowd::domain::Zone2D& zone, + safecrowd::domain::Point2D position, + safecrowd::domain::EnvironmentHazardKind kind) { + if (!matchesFloor(zone.floorId, currentFloorId_)) { + return; + } + + safecrowd::domain::EnvironmentHazardDraft draft; + draft.id = nextEnvironmentHazardId().toStdString(); + draft.kind = kind; + draft.name = QString("%1 hazard %2") + .arg(kind == safecrowd::domain::EnvironmentHazardKind::Fire ? "Fire" : "Smoke") + .arg(static_cast(environmentHazards_.size()) + 1) + .toStdString(); + draft.affectedZoneId = zone.id; + draft.floorId = zone.floorId.empty() ? currentFloorId_.toStdString() : zone.floorId; + draft.position = position; + draft.startSeconds = 0.0; + draft.endSeconds = 60.0; + draft.severity = safecrowd::domain::ScenarioElementSeverity::Medium; + environmentHazards_.push_back(std::move(draft)); + emitEnvironmentHazardsChanged(); + update(); +} + void ScenarioCanvasWidget::selectSingleAt(const QPointF& position, const LayoutCanvasTransform& transform) { const auto crowdElementId = placementAt(position, transform); if (!crowdElementId.isEmpty()) { @@ -2813,6 +2973,12 @@ void ScenarioCanvasWidget::emitRouteGuidancesChanged() { } } +void ScenarioCanvasWidget::emitEnvironmentHazardsChanged() { + if (environmentHazardsChangedHandler_) { + environmentHazardsChangedHandler_(environmentHazards_); + } +} + void ScenarioCanvasWidget::repositionToolbars() { if (topToolbar_ != nullptr) { topToolbar_->setGeometry(0, 0, width(), kTopToolbarHeight); @@ -2841,6 +3007,12 @@ void ScenarioCanvasWidget::setToolMode(ToolMode mode) { if (routeGuidanceToolButton_ != nullptr) { routeGuidanceToolButton_->setChecked(mode == ToolMode::RouteGuidance); } + if (fireHazardToolButton_ != nullptr) { + fireHazardToolButton_->setChecked(mode == ToolMode::FireHazard); + } + if (smokeHazardToolButton_ != nullptr) { + smokeHazardToolButton_->setChecked(mode == ToolMode::SmokeHazard); + } if (groupCountLabel_ != nullptr) { groupCountLabel_->setVisible(mode == ToolMode::GroupPlacement); } @@ -2893,6 +3065,8 @@ void ScenarioCanvasWidget::setupToolbars() { groupToolButton_ = makeButton(makeToolIcon("group", QColor("#1f5fae")), "Add Occupant Group"); blockDoorToolButton_ = makeButton(makeToolIcon("block", QColor("#c0392b")), "block door"); routeGuidanceToolButton_ = makeButton(makeToolIcon("guidance", QColor("#1f5fae")), "Route guidance"); + fireHazardToolButton_ = makeButton(makeToolIcon("fire", QColor("#c2410c")), "Add Fire Hazard"); + smokeHazardToolButton_ = makeButton(makeToolIcon("smoke", QColor("#64748b")), "Add Smoke Hazard"); topLayout->addStretch(1); groupCountLabel_ = new QLabel("Group count", propertyPanel_); @@ -2917,6 +3091,8 @@ void ScenarioCanvasWidget::setupToolbars() { connect(groupToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::GroupPlacement); }); connect(blockDoorToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::BlockDoor); }); connect(routeGuidanceToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::RouteGuidance); }); + connect(fireHazardToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::FireHazard); }); + connect(smokeHazardToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::SmokeHazard); }); setToolMode(ToolMode::Select); repositionToolbars(); diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index d5acca4..24e7a40 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -59,6 +59,8 @@ class ScenarioCanvasWidget : public QWidget { void setConnectionBlocksChangedHandler(std::function&)> handler); void setRouteGuidances(std::vector guidances); void setRouteGuidancesChangedHandler(std::function&)> handler); + void setEnvironmentHazards(std::vector hazards); + void setEnvironmentHazardsChangedHandler(std::function&)> handler); void setLayoutElementActivatedHandler(std::function handler); void setCrowdSelectionChangedHandler(std::function handler); void focusLayoutElement(const QString& elementId); @@ -90,6 +92,8 @@ class ScenarioCanvasWidget : public QWidget { GroupPlacement, BlockDoor, RouteGuidance, + FireHazard, + SmokeHazard, }; std::optional collectBounds() const; @@ -110,6 +114,7 @@ class ScenarioCanvasWidget : public QWidget { QString nextPlacementId(ScenarioCrowdPlacementKind kind) const; QString nextConnectionBlockId() const; QString nextRouteGuidanceId() const; + QString nextEnvironmentHazardId() const; void addGroupPlacement(const QPointF& start, const QPointF& end); void addIndividualPlacement(const QPointF& position); void addConnectionBlock(const QPointF& position); @@ -117,6 +122,11 @@ class ScenarioCanvasWidget : public QWidget { void addRouteGuidance(const QPointF& position); void addRouteGuidanceForExitZone(const safecrowd::domain::Zone2D& zone); void addRouteGuidanceForConnection(const safecrowd::domain::Connection2D& connection); + void addEnvironmentHazard(const QPointF& position, safecrowd::domain::EnvironmentHazardKind kind); + void addEnvironmentHazardForZone( + const safecrowd::domain::Zone2D& zone, + safecrowd::domain::Point2D position, + safecrowd::domain::EnvironmentHazardKind kind); void openRouteGuidanceEditor(const QString& guidanceId, const QPoint& screenPosition); void selectSingleAt(const QPointF& position, const LayoutCanvasTransform& transform); void selectPlacementsInRect(const QRectF& screenRect, const LayoutCanvasTransform& transform); @@ -128,9 +138,11 @@ class ScenarioCanvasWidget : public QWidget { void drawFocusedPlacement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawConnectionBlocks(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawRouteGuidances(QPainter& painter, const LayoutCanvasTransform& transform) const; + void drawEnvironmentHazards(QPainter& painter, const LayoutCanvasTransform& transform) const; void emitPlacementsChanged(); void emitConnectionBlocksChanged(); void emitRouteGuidancesChanged(); + void emitEnvironmentHazardsChanged(); void repositionToolbars(); void setToolMode(ToolMode mode); void setupToolbars(); @@ -139,6 +151,7 @@ class ScenarioCanvasWidget : public QWidget { std::vector placements_{}; std::vector connectionBlocks_{}; std::vector routeGuidances_{}; + std::vector environmentHazards_{}; QString currentFloorId_{}; QString focusedLayoutElementId_{}; QString focusedCrowdElementId_{}; @@ -159,6 +172,8 @@ class ScenarioCanvasWidget : public QWidget { QToolButton* groupToolButton_{nullptr}; QToolButton* blockDoorToolButton_{nullptr}; QToolButton* routeGuidanceToolButton_{nullptr}; + QToolButton* fireHazardToolButton_{nullptr}; + QToolButton* smokeHazardToolButton_{nullptr}; QLabel* groupCountLabel_{nullptr}; QSpinBox* groupCountSpinBox_{nullptr}; QLabel* groupDistributionLabel_{nullptr}; @@ -170,6 +185,7 @@ class ScenarioCanvasWidget : public QWidget { std::function&)> placementsChangedHandler_{}; std::function&)> connectionBlocksChangedHandler_{}; std::function&)> routeGuidancesChangedHandler_{}; + std::function&)> environmentHazardsChangedHandler_{}; }; } // namespace safecrowd::application diff --git a/src/domain/ScenarioAuthoring.cpp b/src/domain/ScenarioAuthoring.cpp index 6bd1da4..35b3b20 100644 --- a/src/domain/ScenarioAuthoring.cpp +++ b/src/domain/ScenarioAuthoring.cpp @@ -58,6 +58,28 @@ bool populationsEqual(const PopulationSpec& lhs, const PopulationSpec& rhs) { return true; } +bool hazardsEqual(const std::vector& lhs, + const std::vector& rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + for (std::size_t i = 0; i < lhs.size(); ++i) { + if (lhs[i].id != rhs[i].id + || lhs[i].kind != rhs[i].kind + || lhs[i].name != rhs[i].name + || lhs[i].affectedZoneId != rhs[i].affectedZoneId + || lhs[i].floorId != rhs[i].floorId + || !pointsEqual(lhs[i].position, rhs[i].position) + || lhs[i].startSeconds != rhs[i].startSeconds + || lhs[i].endSeconds != rhs[i].endSeconds + || lhs[i].severity != rhs[i].severity + || lhs[i].note != rhs[i].note) { + return false; + } + } + return true; +} + bool eventsEqual(const std::vector& lhs, const std::vector& rhs) { if (lhs.size() != rhs.size()) { @@ -160,6 +182,9 @@ std::vector computeScenarioDiffKeys(const ScenarioDraft& baseline, if (baseline.environment.guidanceProfile != variant.environment.guidanceProfile) { keys.emplace_back("environment.guidanceProfile"); } + if (!hazardsEqual(baseline.environment.hazards, variant.environment.hazards)) { + keys.emplace_back("environment.hazards"); + } if (!eventsEqual(baseline.control.events, variant.control.events)) { keys.emplace_back("control.events"); } diff --git a/src/domain/ScenarioAuthoring.h b/src/domain/ScenarioAuthoring.h index 23012fe..ec83716 100644 --- a/src/domain/ScenarioAuthoring.h +++ b/src/domain/ScenarioAuthoring.h @@ -15,10 +15,35 @@ enum class ScenarioRole { Recommended, }; +enum class EnvironmentHazardKind { + Fire, + Smoke, +}; + +enum class ScenarioElementSeverity { + Low, + Medium, + High, +}; + +struct EnvironmentHazardDraft { + std::string id{}; + EnvironmentHazardKind kind{EnvironmentHazardKind::Fire}; + std::string name{}; + std::string affectedZoneId{}; + std::string floorId{}; + Point2D position{}; + double startSeconds{0.0}; + double endSeconds{0.0}; + ScenarioElementSeverity severity{ScenarioElementSeverity::Medium}; + std::string note{}; +}; + struct EnvironmentState { bool reducedVisibility{false}; std::string familiarityProfile{}; std::string guidanceProfile{}; + std::vector hazards{}; }; struct OperationalEventDraft { diff --git a/tests/ScenarioAuthoringTests.cpp b/tests/ScenarioAuthoringTests.cpp index f9339fa..57d8a05 100644 --- a/tests/ScenarioAuthoringTests.cpp +++ b/tests/ScenarioAuthoringTests.cpp @@ -35,6 +35,21 @@ bool containsKey(const std::vector& keys, const std::string& key) { return std::find(keys.begin(), keys.end(), key) != keys.end(); } +EnvironmentHazardDraft makeSmokeHazard() { + EnvironmentHazardDraft hazard; + hazard.id = "hazard-1"; + hazard.kind = EnvironmentHazardKind::Smoke; + hazard.name = "Smoke near lobby"; + hazard.affectedZoneId = "zone-a"; + hazard.floorId = "L1"; + hazard.position = {.x = 1.0, .y = 2.0}; + hazard.startSeconds = 5.0; + hazard.endSeconds = 60.0; + hazard.severity = ScenarioElementSeverity::High; + hazard.note = "Visibility concept only"; + return hazard; +} + } // namespace SC_TEST(duplicateScenarioDraft_setsAlternativeRoleAndIdentity) { @@ -54,18 +69,22 @@ SC_TEST(duplicateScenarioDraft_setsAlternativeRoleAndIdentity) { SC_TEST(duplicateScenarioDraft_doesNotMutateSource) { auto baseline = makeBaselineDraft(); + baseline.environment.hazards.push_back(makeSmokeHazard()); const auto originalEventCount = baseline.control.events.size(); const auto originalPlacementCount = baseline.population.initialPlacements.size(); + const auto originalHazardCount = baseline.environment.hazards.size(); const auto originalRole = baseline.role; const auto originalId = baseline.scenarioId; auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); variant.control.events.clear(); variant.population.initialPlacements.clear(); + variant.environment.hazards.clear(); variant.execution.timeLimitSeconds = 1.0; SC_EXPECT_EQ(baseline.control.events.size(), originalEventCount); SC_EXPECT_EQ(baseline.population.initialPlacements.size(), originalPlacementCount); + SC_EXPECT_EQ(baseline.environment.hazards.size(), originalHazardCount); SC_EXPECT_TRUE(baseline.role == originalRole); SC_EXPECT_EQ(baseline.scenarioId, originalId); SC_EXPECT_NEAR(baseline.execution.timeLimitSeconds, 600.0, 1e-9); @@ -146,6 +165,30 @@ SC_TEST(computeScenarioDiffKeys_detectsGuidanceProfileChange) { SC_EXPECT_TRUE(containsKey(keys, "environment.guidanceProfile")); } +SC_TEST(computeScenarioDiffKeys_detectsEnvironmentHazardsChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.environment.hazards.push_back(makeSmokeHazard()); + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "environment.hazards")); +} + +SC_TEST(computeScenarioDiffKeys_detectsEnvironmentHazardDetailChange) { + auto baseline = makeBaselineDraft(); + baseline.environment.hazards.push_back(makeSmokeHazard()); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.environment.hazards[0].position = {.x = 3.0, .y = 4.0}; + variant.environment.hazards[0].severity = ScenarioElementSeverity::Medium; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "environment.hazards")); +} + SC_TEST(computeScenarioDiffKeys_detectsControlEventsChange) { const auto baseline = makeBaselineDraft(); auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant");