From 0b0d59b6ed2cb154b4c902b05ceba7aaa5d3c82b Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Mon, 11 May 2026 16:13:56 +0900 Subject: [PATCH 1/6] [Domain] Add environment reaction state contract --- src/domain/AgentComponents.h | 4 ++ src/domain/ScenarioSimulationSystems.h | 17 ++++++++ tests/ScenarioSimulationRunnerTests.cpp | 21 ++++++++++ tests/ScenarioSimulationSystemsTests.cpp | 50 ++++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/src/domain/AgentComponents.h b/src/domain/AgentComponents.h index e3c6495..27a2acd 100644 --- a/src/domain/AgentComponents.h +++ b/src/domain/AgentComponents.h @@ -19,6 +19,10 @@ struct Agent { std::string sourcePlacementId{}; std::string sourceZoneId{}; double guidancePropensity{0.5}; + double hazardSensitivity{1.0}; + double smokeSensitivity{1.0}; + double reactionDelaySeconds{0.0}; + double closurePatienceSeconds{0.0}; }; struct Velocity { diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index 355d72e..9d17847 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -79,6 +79,23 @@ struct ScenarioPressureFeedbackResource { std::size_t criticalAgentCount{0}; }; +struct ScenarioEnvironmentReactionAgentState { + bool hazardDetected{false}; + bool hazardAware{false}; + std::string hazardKey{}; + double hazardDetectedAtSeconds{0.0}; + double hazardReactionReadySeconds{0.0}; + bool closureDetected{false}; + bool closureAware{false}; + std::string blockedConnectionId{}; + double closureDetectedAtSeconds{0.0}; + double closureReactionReadySeconds{0.0}; +}; + +struct ScenarioEnvironmentReactionResource { + std::unordered_map agentsById{}; +}; + struct ScenarioResultArtifactsResource { ScenarioResultArtifacts artifacts{}; std::size_t lastRecordedEvacuatedCount{static_cast(-1)}; diff --git a/tests/ScenarioSimulationRunnerTests.cpp b/tests/ScenarioSimulationRunnerTests.cpp index 63b0465..778e3d9 100644 --- a/tests/ScenarioSimulationRunnerTests.cpp +++ b/tests/ScenarioSimulationRunnerTests.cpp @@ -1794,3 +1794,24 @@ SC_TEST(ScenarioSimulationRunnerRoutesAroundClosedObstructions) { SC_EXPECT_EQ(runner.frame().totalAgentCount, static_cast(1)); SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, static_cast(1)); } + +SC_TEST(ScenarioSimulationRunnerNoHazardNoBlockBaselineStillEvacuates) { + safecrowd::domain::InitialPlacement2D placement; + placement.id = "baseline-crowd"; + placement.zoneId = "room"; + placement.targetAgentCount = 4; + placement.initialVelocity = {.x = 1.0, .y = 0.0}; + placement.area.outline = {{.x = 0.8, .y = 1.0}, {.x = 1.6, .y = 1.0}, {.x = 1.6, .y = 3.0}, {.x = 0.8, .y = 3.0}}; + + safecrowd::domain::ScenarioDraft scenario; + scenario.execution.timeLimitSeconds = 12.0; + scenario.population.initialPlacements.push_back(placement); + + safecrowd::domain::ScenarioSimulationRunner runner(wideDoorLayout(), scenario); + for (int i = 0; i < 48 && !runner.complete(); ++i) { + runner.step(0.25); + } + + SC_EXPECT_EQ(runner.frame().totalAgentCount, static_cast(4)); + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, static_cast(4)); +} diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 363a835..f3ba86a 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -520,6 +520,51 @@ std::vector pressureFeedbackMotionSeeds() } // namespace +SC_TEST(Agent_DefaultsIncludeEnvironmentReactionTraits) { + const safecrowd::domain::Agent agent{}; + + SC_EXPECT_NEAR(agent.hazardSensitivity, 1.0, 1e-9); + SC_EXPECT_NEAR(agent.smokeSensitivity, 1.0, 1e-9); + SC_EXPECT_NEAR(agent.reactionDelaySeconds, 0.0, 1e-9); + SC_EXPECT_NEAR(agent.closurePatienceSeconds, 0.0, 1e-9); +} + +SC_TEST(ScenarioEnvironmentReactionResource_DefaultsEmptyAndStoresAgentState) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 2, + }); + + auto& resources = runtime.world().resources(); + resources.set(safecrowd::domain::ScenarioEnvironmentReactionResource{}); + + SC_EXPECT_TRUE(resources.contains()); + auto& reactions = resources.get(); + SC_EXPECT_TRUE(reactions.agentsById.empty()); + + reactions.agentsById.emplace( + 7, + safecrowd::domain::ScenarioEnvironmentReactionAgentState{ + .hazardDetected = true, + .hazardAware = false, + .hazardKey = "fire-a", + .hazardDetectedAtSeconds = 1.25, + .hazardReactionReadySeconds = 2.0, + .closureDetected = true, + .closureAware = false, + .blockedConnectionId = "door-a", + .closureDetectedAtSeconds = 1.5, + .closureReactionReadySeconds = 3.0, + }); + + const auto& state = reactions.agentsById.at(7); + SC_EXPECT_TRUE(state.hazardDetected); + SC_EXPECT_EQ(state.hazardKey, std::string{"fire-a"}); + SC_EXPECT_TRUE(state.closureDetected); + SC_EXPECT_EQ(state.blockedConnectionId, std::string{"door-a"}); +} + SC_TEST(ScenarioAgentSpawnSystem_ConfiguresClockAndSpawnsAgentSeeds) { std::vector seeds; seeds.push_back({ @@ -551,6 +596,11 @@ SC_TEST(ScenarioAgentSpawnSystem_ConfiguresClockAndSpawnsAgentSeeds) { safecrowd::domain::EvacuationRoute, safecrowd::domain::EvacuationStatus>(); SC_EXPECT_EQ(entities.size(), std::size_t{1}); + const auto& agent = runtime.world().query().get(entities.front()); + SC_EXPECT_NEAR(agent.hazardSensitivity, 1.0, 1e-9); + SC_EXPECT_NEAR(agent.smokeSensitivity, 1.0, 1e-9); + SC_EXPECT_NEAR(agent.reactionDelaySeconds, 0.0, 1e-9); + SC_EXPECT_NEAR(agent.closurePatienceSeconds, 0.0, 1e-9); const auto& clock = runtime.world().resources().get(); SC_EXPECT_NEAR(clock.timeLimitSeconds, 15.0, 1e-9); const auto& frame = runtime.world().resources().get().frame; From 0e86b32108b2b9df62526ecc0f0a4026a34bc86e Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Mon, 11 May 2026 17:55:36 +0900 Subject: [PATCH 2/6] [Domain] Add fire and smoke hazard foundation --- src/application/ProjectPersistence.cpp | 100 ++++++- src/application/ScenarioAuthoringWidget.cpp | 303 +++++++++++++++++++- src/application/ScenarioCanvasWidget.cpp | 288 ++++++++++++++++++- src/application/ScenarioCanvasWidget.h | 18 ++ src/domain/ScenarioAuthoring.cpp | 25 ++ src/domain/ScenarioAuthoring.h | 25 ++ tests/ScenarioAuthoringTests.cpp | 44 +++ 7 files changed, 795 insertions(+), 8 deletions(-) diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 76c734f..a631bb5 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -720,20 +721,117 @@ 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 value.toInt() == static_cast(safecrowd::domain::EnvironmentHazardKind::Smoke) + ? safecrowd::domain::EnvironmentHazardKind::Smoke + : safecrowd::domain::EnvironmentHazardKind::Fire; + } + + 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()) { + 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 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 c94b93d..f423434 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -142,6 +143,174 @@ QString blockScheduleSummary(const safecrowd::domain::ConnectionBlockDraft& bloc return intervals.join(", "); } +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"; + } +} + +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"; + } + 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; + }); +} + +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* scopeHint = createLabel( + "Fire/Smoke hazards are v2 simulation inputs. This authoring step stores scenario data only; runtime movement/result effects are handled separately.", + &dialog); + scopeHint->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(scopeHint); + + 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; + } + + 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->kind = static_cast(kindCombo->currentData().toInt()); + hazard->name = name.toStdString(); + 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; +} + int draftOccupantCount(const safecrowd::domain::ScenarioDraft& scenario) { int total = 0; for (const auto& placement : scenario.population.initialPlacements) { @@ -200,6 +369,16 @@ QString buildChangeSummaryLine( .arg(QString::fromStdString(baseline.environment.guidanceProfile), QString::fromStdString(variant.environment.guidanceProfile)); } + if (key == "environment.hazards") { + auto 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,72 @@ 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), + }); + } + + nodes.push_back({ + .label = QString("Hazard - %1: %2").arg(kind, QString::fromStdString(hazard.name)), + .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; @@ -675,12 +924,12 @@ QWidget* createEventsPanel( 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, + shell != nullptr ? shell->createPanelHeader("Events / Hazards", parent, false) : nullptr, {}, {}, std::move(deleteItemHandler), @@ -948,6 +1197,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(); + }); canvas_->setRouteGuidances(scenario->draft.control.routeGuidances); canvas_->setRouteGuidancesChangedHandler([this](const std::vector& guidances) { auto* current = currentScenario(); @@ -994,6 +1254,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_); @@ -1114,7 +1375,7 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { }, { .id = "events", - .label = "Events", + .label = "Events / Hazards", .icon = makeEventsIcon(QColor("#1f5fae")), }, }, @@ -1198,6 +1459,22 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { 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()) { + hazards.erase(hazardIt, hazards.end()); + if (canvas_ != nullptr) { + canvas_->setEnvironmentHazards(hazards); + } + recomputeDiffKeysAfterScenarioChanged(*scenario); + refreshNavigationPanel(); + refreshInspector(); + return; + } + const auto eventId = id.toStdString(); auto& events = scenario->events; const auto it = std::remove_if(events.begin(), events.end(), [&](const auto& event) { @@ -1230,6 +1507,24 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { return; } + 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()) { + if (!editEnvironmentHazard(&(*hazardIt), layout_, this)) { + return; + } + if (canvas_ != nullptr) { + canvas_->setEnvironmentHazards(hazards); + } + recomputeDiffKeysAfterScenarioChanged(*scenario); + refreshNavigationPanel(); + refreshInspector(); + return; + } + const auto eventId = id.toStdString(); auto& events = scenario->events; const auto it = std::find_if(events.begin(), events.end(), [&](auto& event) { diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index eff0349..f5f4cfd 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -182,6 +182,76 @@ std::optional hoveredConnectionBlockIndex( return closestIndex; } +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"; + } +} + +QString formatEnvironmentHazardTooltip(const safecrowd::domain::EnvironmentHazardDraft& hazard) { + QString text = QString("%1 hazard").arg(hazardKindLabel(hazard.kind)); + if (!hazard.name.empty()) { + text.append(QString("\n%1").arg(QString::fromStdString(hazard.name))); + } + text.append(QString("\nActive: %1s ~ %2s").arg(hazard.startSeconds, 0, 'f', 1).arg(hazard.endSeconds, 0, 'f', 1)); + text.append(QString("\nSeverity: %1").arg(severityLabel(hazard.severity))); + text.append("\nv2 input only; runtime effect is handled separately."); + return text; +} + +std::optional hoveredEnvironmentHazardIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& hazards, + const LayoutCanvasTransform& transform, + const QString& currentFloorId, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 15.0; + + std::optional closestIndex; + double closestDistanceSq = kHoverRadiusPixels * kHoverRadiusPixels; + for (std::size_t index = 0; index < hazards.size(); ++index) { + const auto& hazard = hazards[index]; + auto floorId = hazard.floorId; + if (floorId.empty() && !hazard.affectedZoneId.empty()) { + 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()) { + floorId = zoneIt->floorId; + } + } + if (!matchesFloor(floorId, currentFloorId)) { + continue; + } + + const auto center = transform.map(hazard.position); + const auto dx = center.x() - screenPosition.x(); + const auto dy = center.y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= closestDistanceSq) { + closestDistanceSq = distanceSq; + closestIndex = index; + } + } + return closestIndex; +} + QString formatRouteGuidanceTooltip( const safecrowd::domain::RouteGuidanceDraft& guidance) { QString text = QStringLiteral("Route guidance"); @@ -663,6 +733,31 @@ QIcon makeToolIcon(const QString& type, const QColor& color) { return QIcon(pixmap); } + if (type == "fire") { + painter.setBrush(color); + painter.setPen(Qt::NoPen); + QPainterPath flame; + flame.moveTo(22.0, 34.0); + flame.cubicTo(13.0, 28.0, 17.0, 17.0, 22.0, 10.0); + flame.cubicTo(31.0, 17.0, 31.0, 28.0, 22.0, 34.0); + painter.drawPath(flame); + painter.setBrush(Qt::white); + QPainterPath core; + core.moveTo(22.0, 31.0); + core.cubicTo(18.0, 27.0, 20.0, 22.0, 23.0, 18.0); + core.cubicTo(26.0, 22.0, 26.0, 28.0, 22.0, 31.0); + painter.drawPath(core); + return QIcon(pixmap); + } + + if (type == "smoke") { + painter.setPen(QPen(color, 3.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawArc(QRectF(10, 23, 18, 10), 20 * 16, 220 * 16); + painter.drawArc(QRectF(18, 17, 17, 10), 20 * 16, 220 * 16); + painter.drawArc(QRectF(12, 11, 15, 9), 20 * 16, 220 * 16); + return QIcon(pixmap); + } + if (type != "group") { return QIcon(pixmap); } @@ -1245,6 +1340,16 @@ void ScenarioCanvasWidget::setConnectionBlocksChangedHandler(std::function hazards) { + environmentHazards_ = std::move(hazards); + update(); +} + +void ScenarioCanvasWidget::setEnvironmentHazardsChangedHandler( + std::function&)> handler) { + environmentHazardsChangedHandler_ = std::move(handler); +} + void ScenarioCanvasWidget::setRouteGuidances(std::vector guidances) { routeGuidances_ = std::move(guidances); update(); @@ -1298,6 +1403,23 @@ void ScenarioCanvasWidget::activateLayoutElement(const QString& elementId) { 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); + return; + } + if (toolMode_ == ToolMode::RouteGuidance) { const auto targetId = elementId.toStdString(); const auto it = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { @@ -1370,6 +1492,20 @@ bool ScenarioCanvasWidget::editConnectionBlockScheduleById(const QString& blockI return true; } +bool ScenarioCanvasWidget::deleteEnvironmentHazardById(const QString& hazardId) { + auto it = std::find_if(environmentHazards_.begin(), environmentHazards_.end(), [&](const auto& hazard) { + return QString::fromStdString(hazard.id) == hazardId; + }); + if (it == environmentHazards_.end()) { + return false; + } + + environmentHazards_.erase(it); + emitEnvironmentHazardsChanged(); + update(); + return true; +} + bool ScenarioCanvasWidget::deleteRouteGuidanceById(const QString& guidanceId) { auto it = std::find_if(routeGuidances_.begin(), routeGuidances_.end(), [&](const auto& guidance) { return QString::fromStdString(guidance.id) == guidanceId; @@ -1425,6 +1561,7 @@ void ScenarioCanvasWidget::keyReleaseEvent(QKeyEvent* event) { void ScenarioCanvasWidget::leaveEvent(QEvent* event) { hoveredConnectionBlockId_.clear(); + hoveredEnvironmentHazardId_.clear(); hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); QWidget::leaveEvent(event); @@ -1447,8 +1584,9 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { } if (dragging_) { - if (!hoveredConnectionBlockId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { + if (!hoveredConnectionBlockId_.isEmpty() || !hoveredEnvironmentHazardId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { hoveredConnectionBlockId_.clear(); + hoveredEnvironmentHazardId_.clear(); hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); } @@ -1458,8 +1596,9 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { return; } if (selectionDragging_) { - if (!hoveredConnectionBlockId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { + if (!hoveredConnectionBlockId_.isEmpty() || !hoveredEnvironmentHazardId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { hoveredConnectionBlockId_.clear(); + hoveredEnvironmentHazardId_.clear(); hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); } @@ -1478,6 +1617,12 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { transform, currentFloorId_, event->position()); + const auto hoveredHazard = hoveredEnvironmentHazardIndex( + layout_, + environmentHazards_, + transform, + currentFloorId_, + event->position()); const auto hoveredBlock = hoveredConnectionBlockIndex(layout_, connectionBlocks_, transform, currentFloorId_, event->position()); if (hoveredGuidance.has_value()) { @@ -1489,6 +1634,17 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (hoveredId != hoveredRouteGuidanceId_) { hoveredRouteGuidanceId_ = hoveredId; hoveredConnectionBlockId_.clear(); + hoveredEnvironmentHazardId_.clear(); + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); + } + } else if (hoveredHazard.has_value()) { + const auto& hazard = environmentHazards_[*hoveredHazard]; + const auto tooltip = formatEnvironmentHazardTooltip(hazard); + const auto hoveredId = QString::fromStdString(hazard.id); + if (hoveredId != hoveredEnvironmentHazardId_) { + hoveredEnvironmentHazardId_ = hoveredId; + hoveredConnectionBlockId_.clear(); + hoveredRouteGuidanceId_.clear(); QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); } } else if (hoveredBlock.has_value()) { @@ -1498,13 +1654,15 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { const auto hoveredId = QString::fromStdString(block.id.empty() ? block.connectionId : block.id); if (hoveredId != hoveredConnectionBlockId_) { hoveredConnectionBlockId_ = hoveredId; + hoveredEnvironmentHazardId_.clear(); hoveredRouteGuidanceId_.clear(); QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); } } } else { - if (!hoveredConnectionBlockId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { + if (!hoveredConnectionBlockId_.isEmpty() || !hoveredEnvironmentHazardId_.isEmpty() || !hoveredRouteGuidanceId_.isEmpty()) { hoveredConnectionBlockId_.clear(); + hoveredEnvironmentHazardId_.clear(); hoveredRouteGuidanceId_.clear(); QToolTip::hideText(); } @@ -1617,6 +1775,16 @@ 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::RouteGuidance) { addRouteGuidance(event->position()); event->accept(); @@ -1733,6 +1901,7 @@ void ScenarioCanvasWidget::paintEvent(QPaintEvent* event) { } drawFocusedPlacement(painter, transform); drawConnectionBlocks(painter, transform); + drawEnvironmentHazards(painter, transform); drawRouteGuidances(painter, transform); if (dragging_ || selectionDragging_) { @@ -1905,6 +2074,45 @@ void ScenarioCanvasWidget::drawConnectionBlocks(QPainter& painter, const LayoutC } } +void ScenarioCanvasWidget::drawEnvironmentHazards(QPainter& painter, const LayoutCanvasTransform& transform) const { + for (const auto& hazard : environmentHazards_) { + auto floorId = hazard.floorId; + if (floorId.empty() && !hazard.affectedZoneId.empty()) { + 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()) { + floorId = zoneIt->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); + } + } +} + void ScenarioCanvasWidget::drawRouteGuidances(QPainter& painter, const LayoutCanvasTransform& transform) const { painter.setPen(Qt::NoPen); painter.setBrush(QColor("#1f5fae")); @@ -2284,6 +2492,18 @@ QString ScenarioCanvasWidget::nextConnectionBlockId() const { return QString("block-%1").arg(static_cast(connectionBlocks_.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; + } + } +} + QString ScenarioCanvasWidget::nextRouteGuidanceId() const { return QString("guidance-%1").arg(static_cast(routeGuidances_.size()) + 1); } @@ -2449,6 +2669,52 @@ void ScenarioCanvasWidget::addConnectionBlockForConnection(const safecrowd::doma 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::addRouteGuidance(const QPointF& position) { const auto point = unmapPoint(position); const auto zoneId = zoneAt(point); @@ -2807,6 +3073,12 @@ void ScenarioCanvasWidget::emitConnectionBlocksChanged() { } } +void ScenarioCanvasWidget::emitEnvironmentHazardsChanged() { + if (environmentHazardsChangedHandler_) { + environmentHazardsChangedHandler_(environmentHazards_); + } +} + void ScenarioCanvasWidget::emitRouteGuidancesChanged() { if (routeGuidancesChangedHandler_) { routeGuidancesChangedHandler_(routeGuidances_); @@ -2838,6 +3110,12 @@ void ScenarioCanvasWidget::setToolMode(ToolMode mode) { if (blockDoorToolButton_ != nullptr) { blockDoorToolButton_->setChecked(mode == ToolMode::BlockDoor); } + if (fireHazardToolButton_ != nullptr) { + fireHazardToolButton_->setChecked(mode == ToolMode::FireHazard); + } + if (smokeHazardToolButton_ != nullptr) { + smokeHazardToolButton_->setChecked(mode == ToolMode::SmokeHazard); + } if (routeGuidanceToolButton_ != nullptr) { routeGuidanceToolButton_->setChecked(mode == ToolMode::RouteGuidance); } @@ -2892,6 +3170,8 @@ void ScenarioCanvasWidget::setupToolbars() { individualToolButton_ = makeButton(makeToolIcon("individual", QColor("#1f5fae")), "Add Individual Occupant"); groupToolButton_ = makeButton(makeToolIcon("group", QColor("#1f5fae")), "Add Occupant Group"); blockDoorToolButton_ = makeButton(makeToolIcon("block", QColor("#c0392b")), "block door"); + fireHazardToolButton_ = makeButton(makeToolIcon("fire", QColor("#c2410c")), "Add Fire Hazard"); + smokeHazardToolButton_ = makeButton(makeToolIcon("smoke", QColor("#64748b")), "Add Smoke Hazard"); routeGuidanceToolButton_ = makeButton(makeToolIcon("guidance", QColor("#1f5fae")), "Route guidance"); topLayout->addStretch(1); @@ -2916,6 +3196,8 @@ void ScenarioCanvasWidget::setupToolbars() { connect(individualToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::IndividualPlacement); }); connect(groupToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::GroupPlacement); }); connect(blockDoorToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::BlockDoor); }); + connect(fireHazardToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::FireHazard); }); + connect(smokeHazardToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::SmokeHazard); }); connect(routeGuidanceToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::RouteGuidance); }); setToolMode(ToolMode::Select); diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index d5acca4..1def8b3 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -57,6 +57,8 @@ class ScenarioCanvasWidget : public QWidget { void setPlacementsChangedHandler(std::function&)> handler); void setConnectionBlocks(std::vector blocks); void setConnectionBlocksChangedHandler(std::function&)> handler); + void setEnvironmentHazards(std::vector hazards); + void setEnvironmentHazardsChangedHandler(std::function&)> handler); void setRouteGuidances(std::vector guidances); void setRouteGuidancesChangedHandler(std::function&)> handler); void setLayoutElementActivatedHandler(std::function handler); @@ -67,6 +69,7 @@ class ScenarioCanvasWidget : public QWidget { bool deleteCrowdElementById(const QString& crowdElementId); bool deleteConnectionBlockById(const QString& blockId); bool editConnectionBlockScheduleById(const QString& blockId); + bool deleteEnvironmentHazardById(const QString& hazardId); bool deleteRouteGuidanceById(const QString& guidanceId); bool editRouteGuidanceById(const QString& guidanceId); @@ -89,6 +92,8 @@ class ScenarioCanvasWidget : public QWidget { IndividualPlacement, GroupPlacement, BlockDoor, + FireHazard, + SmokeHazard, RouteGuidance, }; @@ -109,11 +114,17 @@ class ScenarioCanvasWidget : public QWidget { safecrowd::domain::Point2D defaultVelocityFrom(const safecrowd::domain::Point2D& point) const; QString nextPlacementId(ScenarioCrowdPlacementKind kind) const; QString nextConnectionBlockId() const; + QString nextEnvironmentHazardId() const; QString nextRouteGuidanceId() const; void addGroupPlacement(const QPointF& start, const QPointF& end); void addIndividualPlacement(const QPointF& position); void addConnectionBlock(const QPointF& position); void addConnectionBlockForConnection(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 addRouteGuidance(const QPointF& position); void addRouteGuidanceForExitZone(const safecrowd::domain::Zone2D& zone); void addRouteGuidanceForConnection(const safecrowd::domain::Connection2D& connection); @@ -127,9 +138,11 @@ class ScenarioCanvasWidget : public QWidget { void drawFocusedLayoutElement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawFocusedPlacement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawConnectionBlocks(QPainter& painter, const LayoutCanvasTransform& transform) const; + void drawEnvironmentHazards(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawRouteGuidances(QPainter& painter, const LayoutCanvasTransform& transform) const; void emitPlacementsChanged(); void emitConnectionBlocksChanged(); + void emitEnvironmentHazardsChanged(); void emitRouteGuidancesChanged(); void repositionToolbars(); void setToolMode(ToolMode mode); @@ -138,6 +151,7 @@ class ScenarioCanvasWidget : public QWidget { safecrowd::domain::FacilityLayout2D layout_{}; std::vector placements_{}; std::vector connectionBlocks_{}; + std::vector environmentHazards_{}; std::vector routeGuidances_{}; QString currentFloorId_{}; QString focusedLayoutElementId_{}; @@ -158,17 +172,21 @@ class ScenarioCanvasWidget : public QWidget { QToolButton* individualToolButton_{nullptr}; QToolButton* groupToolButton_{nullptr}; QToolButton* blockDoorToolButton_{nullptr}; + QToolButton* fireHazardToolButton_{nullptr}; + QToolButton* smokeHazardToolButton_{nullptr}; QToolButton* routeGuidanceToolButton_{nullptr}; QLabel* groupCountLabel_{nullptr}; QSpinBox* groupCountSpinBox_{nullptr}; QLabel* groupDistributionLabel_{nullptr}; QComboBox* groupDistributionComboBox_{nullptr}; QString hoveredConnectionBlockId_{}; + QString hoveredEnvironmentHazardId_{}; QString hoveredRouteGuidanceId_{}; std::function layoutElementActivatedHandler_{}; std::function crowdSelectionChangedHandler_{}; std::function&)> placementsChangedHandler_{}; std::function&)> connectionBlocksChangedHandler_{}; + std::function&)> environmentHazardsChangedHandler_{}; std::function&)> routeGuidancesChangedHandler_{}; }; 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..04039db 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,31 @@ 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; + variant.environment.hazards[0].note = "Edited"; + + 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"); From b6aaf8a543a437c4b529e2265755cada5b6f66b8 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Mon, 11 May 2026 18:23:31 +0900 Subject: [PATCH 3/6] Tighten hazard authoring validation --- src/application/ScenarioAuthoringWidget.cpp | 73 +++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index f423434..7e1a468 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -1,7 +1,9 @@ #include "application/ScenarioAuthoringWidget.h" #include +#include #include +#include #include #include @@ -188,6 +190,33 @@ bool hasSmokeHazard(const safecrowd::domain::EnvironmentState& environment) { }); } +bool pointInRing(const std::vector& ring, const safecrowd::domain::Point2D& point) { + if (ring.size() < 3) { + return false; + } + + bool inside = false; + for (std::size_t i = 0, j = ring.size() - 1; i < ring.size(); j = i++) { + const auto& a = ring[i]; + const auto& b = ring[j]; + const auto intersects = ((a.y > point.y) != (b.y > point.y)) + && (point.x < ((b.x - a.x) * (point.y - a.y) / ((b.y - a.y) == 0.0 ? 1e-9 : (b.y - a.y)) + a.x)); + if (intersects) { + inside = !inside; + } + } + return inside; +} + +bool pointInPolygon(const safecrowd::domain::Polygon2D& polygon, const safecrowd::domain::Point2D& point) { + if (!pointInRing(polygon.outline, point)) { + return false; + } + return std::none_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { + return pointInRing(hole, point); + }); +} + bool editEnvironmentHazard( safecrowd::domain::EnvironmentHazardDraft* hazard, const safecrowd::domain::FacilityLayout2D& layout, @@ -275,7 +304,35 @@ bool editEnvironmentHazard( root->addWidget(scopeHint); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); - QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, [&]() { + if (nameEdit->text().trimmed().isEmpty()) { + QMessageBox::warning(&dialog, "Edit hazard", "Enter a hazard name."); + nameEdit->setFocus(); + return; + } + + 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()) { + QMessageBox::warning(&dialog, "Edit hazard", "Select a valid affected zone."); + zoneCombo->setFocus(); + return; + } + + const safecrowd::domain::Point2D selectedPosition{ + .x = xSpin->value(), + .y = ySpin->value(), + }; + if (!pointInPolygon(selectedZone->area, selectedPosition)) { + QMessageBox::warning(&dialog, "Edit hazard", "The hazard location must stay inside the affected zone."); + xSpin->setFocus(); + return; + } + + dialog.accept(); + }); QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); root->addWidget(buttons); @@ -296,14 +353,20 @@ bool editEnvironmentHazard( return false; } + const safecrowd::domain::Point2D selectedPosition{ + .x = xSpin->value(), + .y = ySpin->value(), + }; + if (!pointInPolygon(selectedZone->area, selectedPosition)) { + QMessageBox::warning(parent, "Edit hazard", "The hazard location must stay inside the affected zone."); + return false; + } + hazard->kind = static_cast(kindCombo->currentData().toInt()); hazard->name = name.toStdString(); hazard->affectedZoneId = selectedZoneId; hazard->floorId = selectedZone->floorId; - hazard->position = { - .x = xSpin->value(), - .y = ySpin->value(), - }; + hazard->position = selectedPosition; hazard->startSeconds = startSpin->value(); hazard->endSeconds = std::max(hazard->startSeconds, endSpin->value()); hazard->severity = static_cast(severityCombo->currentData().toInt()); From eef4f5ee126138323632d7cd33411b6e3942400f Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Mon, 11 May 2026 18:25:18 +0900 Subject: [PATCH 4/6] Refine hazard authoring placement UX --- src/application/ScenarioAuthoringWidget.cpp | 6 ------ src/application/ScenarioCanvasWidget.cpp | 12 ++++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 7e1a468..ad4852b 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -286,11 +286,6 @@ bool editEnvironmentHazard( noteEdit->setPlainText(QString::fromStdString(hazard->note)); noteEdit->setMinimumHeight(72); - auto* scopeHint = createLabel( - "Fire/Smoke hazards are v2 simulation inputs. This authoring step stores scenario data only; runtime movement/result effects are handled separately.", - &dialog); - scopeHint->setStyleSheet(ui::mutedTextStyleSheet()); - form->addRow("Kind", kindCombo); form->addRow("Name", nameEdit); form->addRow("Affected zone", zoneCombo); @@ -301,7 +296,6 @@ bool editEnvironmentHazard( form->addRow("Severity", severityCombo); form->addRow("Note", noteEdit); root->addLayout(form); - root->addWidget(scopeHint); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, [&]() { diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index f5f4cfd..df8fa17 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -211,7 +211,6 @@ QString formatEnvironmentHazardTooltip(const safecrowd::domain::EnvironmentHazar } text.append(QString("\nActive: %1s ~ %2s").arg(hazard.startSeconds, 0, 'f', 1).arg(hazard.endSeconds, 0, 'f', 1)); text.append(QString("\nSeverity: %1").arg(severityLabel(hazard.severity))); - text.append("\nv2 input only; runtime effect is handled separately."); return text; } @@ -410,6 +409,15 @@ bool pointInRing(const std::vector& ring, const safe return inside; } +bool pointInPolygon(const safecrowd::domain::Polygon2D& polygon, const safecrowd::domain::Point2D& point) { + if (!pointInRing(polygon.outline, point)) { + return false; + } + return std::none_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { + return pointInRing(hole, point); + }); +} + double distancePointToSegment( const safecrowd::domain::Point2D& point, const safecrowd::domain::Point2D& start, @@ -2265,7 +2273,7 @@ QString ScenarioCanvasWidget::zoneAt(const safecrowd::domain::Point2D& point) co if (!matchesFloor(zone.floorId, currentFloorId_)) { continue; } - if (pointInRing(zone.area.outline, point)) { + if (pointInPolygon(zone.area, point)) { return QString::fromStdString(zone.id); } } From a9f02a34e419fff7597e9f7203bead5f8c0f2048 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Mon, 11 May 2026 18:35:06 +0900 Subject: [PATCH 5/6] Guard hazard placement inside zones --- src/application/ScenarioCanvasWidget.cpp | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index df8fa17..8c4e801 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -485,6 +485,36 @@ PointBounds boundsOfPoints(const std::vector& points return bounds; } +std::optional representativePointInPolygon(const safecrowd::domain::Polygon2D& polygon) { + const auto center = polygonCenter(polygon); + if (pointInPolygon(polygon, center)) { + return center; + } + + const auto bounds = boundsOfPoints(polygon.outline); + const auto width = bounds.maxX - bounds.minX; + const auto height = bounds.maxY - bounds.minY; + if (width <= kGeometryEpsilon || height <= kGeometryEpsilon) { + return std::nullopt; + } + + constexpr int kSampleCount = 24; + for (int yIndex = 0; yIndex < kSampleCount; ++yIndex) { + const auto y = bounds.minY + (height * (static_cast(yIndex) + 0.5) / kSampleCount); + for (int xIndex = 0; xIndex < kSampleCount; ++xIndex) { + const safecrowd::domain::Point2D candidate{ + .x = bounds.minX + (width * (static_cast(xIndex) + 0.5) / kSampleCount), + .y = y, + }; + if (pointInPolygon(polygon, candidate)) { + return candidate; + } + } + } + + return std::nullopt; +} + bool pointInsidePlacementArea( const std::vector& area, const safecrowd::domain::Point2D& point) { @@ -2705,6 +2735,14 @@ void ScenarioCanvasWidget::addEnvironmentHazardForZone( return; } + if (!pointInPolygon(zone.area, position)) { + const auto fallbackPosition = representativePointInPolygon(zone.area); + if (!fallbackPosition.has_value()) { + return; + } + position = *fallbackPosition; + } + safecrowd::domain::EnvironmentHazardDraft draft; draft.id = nextEnvironmentHazardId().toStdString(); draft.kind = kind; From 73a4382a01b74732bd6be01ed6ec5fbd2876a296 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Mon, 11 May 2026 18:59:40 +0900 Subject: [PATCH 6/6] Share geometry queries for hazard placement --- CMakeLists.txt | 3 + .../LayoutNavigationPanelWidget.cpp | 78 +----- src/application/ScenarioAuthoringWidget.cpp | 31 +-- src/application/ScenarioCanvasWidget.cpp | 82 +----- src/domain/GeometryQueries.cpp | 245 ++++++++++++++++++ src/domain/GeometryQueries.h | 16 ++ tests/GeometryQueriesTests.cpp | 96 +++++++ 7 files changed, 376 insertions(+), 175 deletions(-) create mode 100644 src/domain/GeometryQueries.cpp create mode 100644 src/domain/GeometryQueries.h create mode 100644 tests/GeometryQueriesTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fa5416a..49e1c97 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,8 @@ add_library(safecrowd_domain STATIC src/domain/DemoLayouts.h src/domain/DemoLayouts.cpp src/domain/Geometry2D.h + src/domain/GeometryQueries.h + src/domain/GeometryQueries.cpp src/domain/PopulationSpec.h src/domain/ScenarioAuthoring.h src/domain/ScenarioAuthoring.cpp @@ -159,6 +161,7 @@ if (BUILD_TESTING) tests/ScenarioSimulationRunnerTests.cpp tests/ScenarioAuthoringTests.cpp tests/ScenarioBatchRunnerTests.cpp + tests/GeometryQueriesTests.cpp ) target_include_directories(safecrowd_tests diff --git a/src/application/LayoutNavigationPanelWidget.cpp b/src/application/LayoutNavigationPanelWidget.cpp index 924ad8e..9811ebc 100644 --- a/src/application/LayoutNavigationPanelWidget.cpp +++ b/src/application/LayoutNavigationPanelWidget.cpp @@ -1,5 +1,7 @@ #include "application/LayoutNavigationPanelWidget.h" +#include "domain/GeometryQueries.h" + #include #include #include @@ -19,6 +21,9 @@ constexpr double kGeometryEpsilon = 1e-4; const QColor kExitAccentColor("#2d8f5b"); const QColor kDoorAccentColor("#ff8c00"); +using safecrowd::domain::distanceToPolygonBoundary; +using safecrowd::domain::pointInPolygon; + QString floorActionId(const std::string& floorId) { return QString("floor:%1").arg(QString::fromStdString(floorId)); } @@ -112,79 +117,6 @@ NavigationTreeNode makeSection(const QString& label, std::vector::max(); - const auto checkRing = [&](const std::vector& ring) { - if (ring.size() < 2) { - return; - } - for (std::size_t index = 0; index < ring.size(); ++index) { - best = std::min(best, distancePointToSegment(point, ring[index], ring[(index + 1) % ring.size()])); - } - }; - - checkRing(polygon.outline); - for (const auto& hole : polygon.holes) { - checkRing(hole); - } - return best; -} - -bool pointInRing( - const std::vector& ring, - const safecrowd::domain::Point2D& point) { - if (ring.size() < 3) { - return false; - } - - bool inside = false; - for (std::size_t i = 0, j = ring.size() - 1; i < ring.size(); j = i++) { - const auto& a = ring[i]; - const auto& b = ring[j]; - const auto intersects = ((a.y > point.y) != (b.y > point.y)) - && (point.x < ((b.x - a.x) * (point.y - a.y) / ((b.y - a.y) == 0.0 ? 1e-9 : (b.y - a.y)) + a.x)); - if (intersects) { - inside = !inside; - } - } - return inside; -} - -bool pointInPolygon( - const safecrowd::domain::Polygon2D& polygon, - const safecrowd::domain::Point2D& point) { - if (!pointInRing(polygon.outline, point)) { - return false; - } - - for (const auto& hole : polygon.holes) { - if (pointInRing(hole, point)) { - return false; - } - } - return true; -} - double overlapLength(double firstStart, double firstEnd, double secondStart, double secondEnd) { const auto firstMin = std::min(firstStart, firstEnd); const auto firstMax = std::max(firstStart, firstEnd); diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index ad4852b..4b56798 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -1,5 +1,7 @@ #include "application/ScenarioAuthoringWidget.h" +#include "domain/GeometryQueries.h" + #include #include #include @@ -36,6 +38,8 @@ namespace safecrowd::application { namespace { +using safecrowd::domain::pointInPolygon; + QLabel* createLabel(const QString& text, QWidget* parent, ui::FontRole role = ui::FontRole::Body) { auto* label = new QLabel(text, parent); label->setFont(ui::font(role)); @@ -190,33 +194,6 @@ bool hasSmokeHazard(const safecrowd::domain::EnvironmentState& environment) { }); } -bool pointInRing(const std::vector& ring, const safecrowd::domain::Point2D& point) { - if (ring.size() < 3) { - return false; - } - - bool inside = false; - for (std::size_t i = 0, j = ring.size() - 1; i < ring.size(); j = i++) { - const auto& a = ring[i]; - const auto& b = ring[j]; - const auto intersects = ((a.y > point.y) != (b.y > point.y)) - && (point.x < ((b.x - a.x) * (point.y - a.y) / ((b.y - a.y) == 0.0 ? 1e-9 : (b.y - a.y)) + a.x)); - if (intersects) { - inside = !inside; - } - } - return inside; -} - -bool pointInPolygon(const safecrowd::domain::Polygon2D& polygon, const safecrowd::domain::Point2D& point) { - if (!pointInRing(polygon.outline, point)) { - return false; - } - return std::none_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { - return pointInRing(hole, point); - }); -} - bool editEnvironmentHazard( safecrowd::domain::EnvironmentHazardDraft* hazard, const safecrowd::domain::FacilityLayout2D& layout, diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index 8c4e801..870affa 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -2,6 +2,7 @@ #include "application/ToolIconResources.h" #include "application/UiStyle.h" +#include "domain/GeometryQueries.h" #include #include @@ -53,6 +54,11 @@ const QColor kSelectionHighlightColor("#0b3d78"); [[nodiscard]] safecrowd::domain::Point2D polygonCenter(const safecrowd::domain::Polygon2D& polygon); +using safecrowd::domain::distancePointToSegment; +using safecrowd::domain::pointInPolygon; +using safecrowd::domain::pointInRing; +using safecrowd::domain::representativePointInPolygon; + struct PointBounds { double minX{0.0}; double minY{0.0}; @@ -391,51 +397,6 @@ QString defaultFloorId(const safecrowd::domain::FacilityLayout2D& layout) { return {}; } -bool pointInRing(const std::vector& ring, const safecrowd::domain::Point2D& point) { - if (ring.size() < 3) { - return false; - } - - bool inside = false; - for (std::size_t i = 0, j = ring.size() - 1; i < ring.size(); j = i++) { - const auto& a = ring[i]; - const auto& b = ring[j]; - const auto intersects = ((a.y > point.y) != (b.y > point.y)) - && (point.x < ((b.x - a.x) * (point.y - a.y) / ((b.y - a.y) == 0.0 ? 1e-9 : (b.y - a.y)) + a.x)); - if (intersects) { - inside = !inside; - } - } - return inside; -} - -bool pointInPolygon(const safecrowd::domain::Polygon2D& polygon, const safecrowd::domain::Point2D& point) { - if (!pointInRing(polygon.outline, point)) { - return false; - } - return std::none_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { - return pointInRing(hole, point); - }); -} - -double distancePointToSegment( - const safecrowd::domain::Point2D& point, - const safecrowd::domain::Point2D& start, - const safecrowd::domain::Point2D& end) { - const auto dx = end.x - start.x; - const auto dy = end.y - start.y; - const auto lengthSquared = (dx * dx) + (dy * dy); - if (lengthSquared <= kGeometryEpsilon) { - return std::hypot(point.x - start.x, point.y - start.y); - } - - const auto t = std::clamp( - (((point.x - start.x) * dx) + ((point.y - start.y) * dy)) / lengthSquared, - 0.0, - 1.0); - return std::hypot(point.x - (start.x + (dx * t)), point.y - (start.y + (dy * t))); -} - safecrowd::domain::Point2D polygonCenter(const safecrowd::domain::Polygon2D& polygon) { if (polygon.outline.empty()) { return {}; @@ -485,36 +446,6 @@ PointBounds boundsOfPoints(const std::vector& points return bounds; } -std::optional representativePointInPolygon(const safecrowd::domain::Polygon2D& polygon) { - const auto center = polygonCenter(polygon); - if (pointInPolygon(polygon, center)) { - return center; - } - - const auto bounds = boundsOfPoints(polygon.outline); - const auto width = bounds.maxX - bounds.minX; - const auto height = bounds.maxY - bounds.minY; - if (width <= kGeometryEpsilon || height <= kGeometryEpsilon) { - return std::nullopt; - } - - constexpr int kSampleCount = 24; - for (int yIndex = 0; yIndex < kSampleCount; ++yIndex) { - const auto y = bounds.minY + (height * (static_cast(yIndex) + 0.5) / kSampleCount); - for (int xIndex = 0; xIndex < kSampleCount; ++xIndex) { - const safecrowd::domain::Point2D candidate{ - .x = bounds.minX + (width * (static_cast(xIndex) + 0.5) / kSampleCount), - .y = y, - }; - if (pointInPolygon(polygon, candidate)) { - return candidate; - } - } - } - - return std::nullopt; -} - bool pointInsidePlacementArea( const std::vector& area, const safecrowd::domain::Point2D& point) { @@ -2738,6 +2669,7 @@ void ScenarioCanvasWidget::addEnvironmentHazardForZone( if (!pointInPolygon(zone.area, position)) { const auto fallbackPosition = representativePointInPolygon(zone.area); if (!fallbackPosition.has_value()) { + QMessageBox::information(this, "Hazard", "Could not find a valid point inside this zone."); return; } position = *fallbackPosition; diff --git a/src/domain/GeometryQueries.cpp b/src/domain/GeometryQueries.cpp new file mode 100644 index 0000000..ac1b6e4 --- /dev/null +++ b/src/domain/GeometryQueries.cpp @@ -0,0 +1,245 @@ +#include "domain/GeometryQueries.h" + +#include +#include +#include + +namespace safecrowd::domain { +namespace { + +constexpr double kGeometryEpsilon = 1e-9; + +struct PointBounds { + double minX{0.0}; + double minY{0.0}; + double maxX{0.0}; + double maxY{0.0}; +}; + +PointBounds boundsOfPoints(const std::vector& points) { + PointBounds bounds; + if (points.empty()) { + return bounds; + } + + bounds.minX = points.front().x; + bounds.maxX = points.front().x; + bounds.minY = points.front().y; + bounds.maxY = points.front().y; + for (const auto& point : points) { + bounds.minX = std::min(bounds.minX, point.x); + bounds.maxX = std::max(bounds.maxX, point.x); + bounds.minY = std::min(bounds.minY, point.y); + bounds.maxY = std::max(bounds.maxY, point.y); + } + return bounds; +} + +Point2D polygonCenter(const Polygon2D& polygon) { + if (polygon.outline.empty()) { + return {}; + } + + double x = 0.0; + double y = 0.0; + for (const auto& point : polygon.outline) { + x += point.x; + y += point.y; + } + const auto count = static_cast(polygon.outline.size()); + return {.x = x / count, .y = y / count}; +} + +void appendRingYValues(const std::vector& ring, std::vector& values) { + for (const auto& point : ring) { + values.push_back(point.y); + } +} + +void appendRingIntersections(const std::vector& ring, double y, std::vector& intersections) { + if (ring.size() < 2) { + return; + } + + for (std::size_t i = 0, j = ring.size() - 1; i < ring.size(); j = i++) { + const auto& a = ring[j]; + const auto& b = ring[i]; + if ((a.y > y) == (b.y > y)) { + continue; + } + + const auto denominator = b.y - a.y; + if (std::fabs(denominator) <= kGeometryEpsilon) { + continue; + } + intersections.push_back(a.x + ((y - a.y) * (b.x - a.x) / denominator)); + } +} + +std::optional scanlineRepresentativePoint(const Polygon2D& polygon) { + std::vector yValues; + yValues.reserve(polygon.outline.size()); + appendRingYValues(polygon.outline, yValues); + for (const auto& hole : polygon.holes) { + appendRingYValues(hole, yValues); + } + if (yValues.size() < 2) { + return std::nullopt; + } + + std::sort(yValues.begin(), yValues.end()); + yValues.erase( + std::unique(yValues.begin(), yValues.end(), [](double lhs, double rhs) { + return std::fabs(lhs - rhs) <= kGeometryEpsilon; + }), + yValues.end()); + if (yValues.size() < 2) { + return std::nullopt; + } + + std::optional bestPoint; + double bestWidth = 0.0; + std::vector intersections; + for (std::size_t yIndex = 1; yIndex < yValues.size(); ++yIndex) { + const auto lowerY = yValues[yIndex - 1]; + const auto upperY = yValues[yIndex]; + if (upperY - lowerY <= kGeometryEpsilon) { + continue; + } + + const auto y = (lowerY + upperY) * 0.5; + intersections.clear(); + appendRingIntersections(polygon.outline, y, intersections); + for (const auto& hole : polygon.holes) { + appendRingIntersections(hole, y, intersections); + } + if (intersections.size() < 2) { + continue; + } + + std::sort(intersections.begin(), intersections.end()); + for (std::size_t xIndex = 1; xIndex < intersections.size(); xIndex += 2) { + const auto left = intersections[xIndex - 1]; + const auto right = intersections[xIndex]; + const auto width = right - left; + if (width <= bestWidth + kGeometryEpsilon) { + continue; + } + + const Point2D candidate{.x = (left + right) * 0.5, .y = y}; + if (pointInPolygon(polygon, candidate)) { + bestWidth = width; + bestPoint = candidate; + } + } + } + + return bestPoint; +} + +std::optional gridRepresentativePoint(const Polygon2D& polygon) { + const auto bounds = boundsOfPoints(polygon.outline); + const auto width = bounds.maxX - bounds.minX; + const auto height = bounds.maxY - bounds.minY; + if (width <= kGeometryEpsilon || height <= kGeometryEpsilon) { + return std::nullopt; + } + + constexpr int kSampleCounts[] = {12, 24, 48}; + for (const auto sampleCount : kSampleCounts) { + for (int yIndex = 0; yIndex < sampleCount; ++yIndex) { + const auto y = bounds.minY + (height * (static_cast(yIndex) + 0.5) / sampleCount); + for (int xIndex = 0; xIndex < sampleCount; ++xIndex) { + const Point2D candidate{ + .x = bounds.minX + (width * (static_cast(xIndex) + 0.5) / sampleCount), + .y = y, + }; + if (pointInPolygon(polygon, candidate)) { + return candidate; + } + } + } + } + + return std::nullopt; +} + +} // namespace + +bool pointInRing(const std::vector& ring, const Point2D& point) { + if (ring.size() < 3) { + return false; + } + + bool inside = false; + for (std::size_t i = 0, j = ring.size() - 1; i < ring.size(); j = i++) { + const auto& a = ring[i]; + const auto& b = ring[j]; + const auto intersects = ((a.y > point.y) != (b.y > point.y)) + && (point.x < ((b.x - a.x) * (point.y - a.y) / ((b.y - a.y) == 0.0 ? 1e-9 : (b.y - a.y)) + a.x)); + if (intersects) { + inside = !inside; + } + } + return inside; +} + +bool pointInPolygon(const Polygon2D& polygon, const Point2D& point) { + if (!pointInRing(polygon.outline, point)) { + return false; + } + return std::none_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { + return pointInRing(hole, point); + }); +} + +double distancePointToSegment(const Point2D& point, const Point2D& start, const Point2D& end) { + const auto dx = end.x - start.x; + const auto dy = end.y - start.y; + const auto lengthSquared = (dx * dx) + (dy * dy); + if (lengthSquared <= kGeometryEpsilon) { + return std::hypot(point.x - start.x, point.y - start.y); + } + + const auto t = std::clamp( + (((point.x - start.x) * dx) + ((point.y - start.y) * dy)) / lengthSquared, + 0.0, + 1.0); + return std::hypot(point.x - (start.x + (dx * t)), point.y - (start.y + (dy * t))); +} + +double distanceToPolygonBoundary(const Polygon2D& polygon, const Point2D& point) { + double best = std::numeric_limits::max(); + const auto checkRing = [&](const std::vector& ring) { + if (ring.size() < 2) { + return; + } + for (std::size_t index = 0; index < ring.size(); ++index) { + best = std::min(best, distancePointToSegment(point, ring[index], ring[(index + 1) % ring.size()])); + } + }; + + checkRing(polygon.outline); + for (const auto& hole : polygon.holes) { + checkRing(hole); + } + return best; +} + +std::optional representativePointInPolygon(const Polygon2D& polygon) { + if (polygon.outline.size() < 3) { + return std::nullopt; + } + + const auto center = polygonCenter(polygon); + if (pointInPolygon(polygon, center)) { + return center; + } + + if (auto point = scanlineRepresentativePoint(polygon); point.has_value()) { + return point; + } + return gridRepresentativePoint(polygon); +} + +} // namespace safecrowd::domain diff --git a/src/domain/GeometryQueries.h b/src/domain/GeometryQueries.h new file mode 100644 index 0000000..0a0d4e0 --- /dev/null +++ b/src/domain/GeometryQueries.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +#include "domain/Geometry2D.h" + +namespace safecrowd::domain { + +bool pointInRing(const std::vector& ring, const Point2D& point); +bool pointInPolygon(const Polygon2D& polygon, const Point2D& point); +double distancePointToSegment(const Point2D& point, const Point2D& start, const Point2D& end); +double distanceToPolygonBoundary(const Polygon2D& polygon, const Point2D& point); +std::optional representativePointInPolygon(const Polygon2D& polygon); + +} // namespace safecrowd::domain diff --git a/tests/GeometryQueriesTests.cpp b/tests/GeometryQueriesTests.cpp new file mode 100644 index 0000000..425b301 --- /dev/null +++ b/tests/GeometryQueriesTests.cpp @@ -0,0 +1,96 @@ +#include "TestSupport.h" + +#include "domain/GeometryQueries.h" + +namespace { + +safecrowd::domain::Polygon2D rectangle(double minX, double minY, double maxX, double maxY) { + return { + .outline = { + {.x = minX, .y = minY}, + {.x = maxX, .y = minY}, + {.x = maxX, .y = maxY}, + {.x = minX, .y = maxY}, + }, + }; +} + +} // namespace + +SC_TEST(GeometryQueries_PointInPolygonHandlesRectangleAndHole) { + auto polygon = rectangle(0.0, 0.0, 10.0, 10.0); + polygon.holes.push_back({ + {.x = 4.0, .y = 4.0}, + {.x = 6.0, .y = 4.0}, + {.x = 6.0, .y = 6.0}, + {.x = 4.0, .y = 6.0}, + }); + + SC_EXPECT_TRUE(safecrowd::domain::pointInPolygon(polygon, {.x = 2.0, .y = 2.0})); + SC_EXPECT_TRUE(!safecrowd::domain::pointInPolygon(polygon, {.x = 11.0, .y = 2.0})); + SC_EXPECT_TRUE(!safecrowd::domain::pointInPolygon(polygon, {.x = 5.0, .y = 5.0})); +} + +SC_TEST(GeometryQueries_DistanceToPolygonBoundaryIncludesHoles) { + auto polygon = rectangle(0.0, 0.0, 10.0, 10.0); + polygon.holes.push_back({ + {.x = 4.0, .y = 4.0}, + {.x = 6.0, .y = 4.0}, + {.x = 6.0, .y = 6.0}, + {.x = 4.0, .y = 6.0}, + }); + + const auto distance = safecrowd::domain::distanceToPolygonBoundary(polygon, {.x = 5.0, .y = 3.75}); + + SC_EXPECT_NEAR(distance, 0.25, 1e-9); +} + +SC_TEST(GeometryQueries_RepresentativePointHandlesConcavePolygon) { + const safecrowd::domain::Polygon2D polygon{ + .outline = { + {.x = 0.0, .y = 0.0}, + {.x = 4.0, .y = 0.0}, + {.x = 4.0, .y = 1.0}, + {.x = 1.0, .y = 1.0}, + {.x = 1.0, .y = 4.0}, + {.x = 0.0, .y = 4.0}, + }, + }; + + const auto point = safecrowd::domain::representativePointInPolygon(polygon); + + SC_EXPECT_TRUE(point.has_value()); + SC_EXPECT_TRUE(safecrowd::domain::pointInPolygon(polygon, *point)); +} + +SC_TEST(GeometryQueries_RepresentativePointAvoidsHole) { + auto polygon = rectangle(0.0, 0.0, 10.0, 10.0); + polygon.holes.push_back({ + {.x = 3.0, .y = 3.0}, + {.x = 7.0, .y = 3.0}, + {.x = 7.0, .y = 7.0}, + {.x = 3.0, .y = 7.0}, + }); + + const auto point = safecrowd::domain::representativePointInPolygon(polygon); + + SC_EXPECT_TRUE(point.has_value()); + SC_EXPECT_TRUE(safecrowd::domain::pointInPolygon(polygon, *point)); + SC_EXPECT_TRUE(!(point->x > 3.0 && point->x < 7.0 && point->y > 3.0 && point->y < 7.0)); +} + +SC_TEST(GeometryQueries_RepresentativePointHandlesThinDiagonalPolygon) { + const safecrowd::domain::Polygon2D polygon{ + .outline = { + {.x = 0.0, .y = 0.0}, + {.x = 10.0, .y = 10.0}, + {.x = 10.2, .y = 10.0}, + {.x = 0.2, .y = 0.0}, + }, + }; + + const auto point = safecrowd::domain::representativePointInPolygon(polygon); + + SC_EXPECT_TRUE(point.has_value()); + SC_EXPECT_TRUE(safecrowd::domain::pointInPolygon(polygon, *point)); +}