diff --git a/src/application/LayoutCanvasSnapping.cpp b/src/application/LayoutCanvasSnapping.cpp index 9c4927b..2ea867e 100644 --- a/src/application/LayoutCanvasSnapping.cpp +++ b/src/application/LayoutCanvasSnapping.cpp @@ -9,6 +9,11 @@ namespace safecrowd::application { namespace { +struct SnapGeometry { + std::vector vertices{}; + std::vector edges{}; +}; + bool matchesFloor(const std::string& elementFloorId, const std::string& floorId) { return floorId.empty() || elementFloorId.empty() || elementFloorId == floorId; } @@ -202,54 +207,55 @@ std::optional stairEntrySpanForFloor( return std::nullopt; } -} // namespace - -LayoutSnapResult snapLayoutPoint( - const safecrowd::domain::FacilityLayout2D& layout, - const std::string& floorId, - const safecrowd::domain::Point2D& point, - const LayoutCanvasTransform& transform, - const LayoutSnapOptions& options) { - std::vector vertices; - std::vector edges; - +SnapGeometry collectSnapGeometry(const safecrowd::domain::FacilityLayout2D& layout, const std::string& floorId) { + SnapGeometry geometry; for (const auto& zone : layout.zones) { if (matchesFloor(zone.floorId, floorId)) { - appendPolygonSnapGeometry(zone.area, vertices, edges); + appendPolygonSnapGeometry(zone.area, geometry.vertices, geometry.edges); } } for (const auto& barrier : layout.barriers) { if (matchesFloor(barrier.floorId, floorId)) { - appendPolylineSnapGeometry(barrier.geometry, vertices, edges); + appendPolylineSnapGeometry(barrier.geometry, geometry.vertices, geometry.edges); } } for (const auto& connection : layout.connections) { if (!matchesFloor(connection.floorId, floorId)) { continue; } - vertices.push_back(connection.centerSpan.start); - vertices.push_back(connection.centerSpan.end); - edges.push_back(connection.centerSpan); + geometry.vertices.push_back(connection.centerSpan.start); + geometry.vertices.push_back(connection.centerSpan.end); + geometry.edges.push_back(connection.centerSpan); } for (const auto& connection : layout.connections) { const auto entrySpan = stairEntrySpanForFloor(layout, connection, floorId); if (!entrySpan.has_value()) { continue; } - vertices.push_back(entrySpan->start); - vertices.push_back(entrySpan->end); - vertices.push_back({ + geometry.vertices.push_back(entrySpan->start); + geometry.vertices.push_back(entrySpan->end); + geometry.vertices.push_back({ .x = (entrySpan->start.x + entrySpan->end.x) * 0.5, .y = (entrySpan->start.y + entrySpan->end.y) * 0.5, }); - edges.push_back(*entrySpan); + geometry.edges.push_back(*entrySpan); } + return geometry; +} + +LayoutSnapResult snapPointToGeometry( + const SnapGeometry& geometry, + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::Point2D& point, + const LayoutCanvasTransform& transform, + const LayoutSnapOptions& options) { + (void)layout; LayoutSnapResult result{.point = point}; double bestDistance = options.tolerancePixels; if (options.snapVertices) { - for (const auto& vertex : vertices) { + for (const auto& vertex : geometry.vertices) { const auto distance = screenDistance(transform, point, vertex); if (distance <= bestDistance) { bestDistance = distance; @@ -259,7 +265,7 @@ LayoutSnapResult snapLayoutPoint( } if (options.snapEdges) { - for (const auto& edge : edges) { + for (const auto& edge : geometry.edges) { const auto candidate = closestPointOnSegment(point, edge.start, edge.end); const auto distance = screenDistance(transform, point, candidate); if (distance <= bestDistance) { @@ -272,4 +278,93 @@ LayoutSnapResult snapLayoutPoint( return result; } +double horizontalScreenDistance( + const LayoutCanvasTransform& transform, + const safecrowd::domain::Point2D& point, + double guideX) { + const auto current = transform.map(point); + const auto aligned = transform.map({.x = guideX, .y = point.y}); + return std::abs(current.x() - aligned.x()); +} + +double verticalScreenDistance( + const LayoutCanvasTransform& transform, + const safecrowd::domain::Point2D& point, + double guideY) { + const auto current = transform.map(point); + const auto aligned = transform.map({.x = point.x, .y = guideY}); + return std::abs(current.y() - aligned.y()); +} + +} // namespace + +LayoutSnapResult snapLayoutPoint( + const safecrowd::domain::FacilityLayout2D& layout, + const std::string& floorId, + const safecrowd::domain::Point2D& point, + const LayoutCanvasTransform& transform, + const LayoutSnapOptions& options) { + return snapPointToGeometry(collectSnapGeometry(layout, floorId), layout, point, transform, options); +} + +LayoutSnapResult snapLayoutDragPoint( + const safecrowd::domain::FacilityLayout2D& layout, + const std::string& floorId, + const safecrowd::domain::Point2D& anchor, + const safecrowd::domain::Point2D& point, + const LayoutCanvasTransform& transform, + const LayoutSnapOptions& options) { + const auto geometry = collectSnapGeometry(layout, floorId); + auto result = snapPointToGeometry(geometry, layout, point, transform, options); + + double bestXDistance = options.tolerancePixels; + std::optional snappedX; + double bestYDistance = options.tolerancePixels; + std::optional snappedY; + + auto considerX = [&](double x) { + if (std::abs(x - anchor.x) <= 1e-9) { + return; + } + const auto distance = horizontalScreenDistance(transform, point, x); + if (distance <= bestXDistance) { + bestXDistance = distance; + snappedX = x; + } + }; + auto considerY = [&](double y) { + if (std::abs(y - anchor.y) <= 1e-9) { + return; + } + const auto distance = verticalScreenDistance(transform, point, y); + if (distance <= bestYDistance) { + bestYDistance = distance; + snappedY = y; + } + }; + + for (const auto& vertex : geometry.vertices) { + considerX(vertex.x); + considerY(vertex.y); + } + for (const auto& edge : geometry.edges) { + if (std::abs(edge.start.x - edge.end.x) <= 1e-9) { + considerX(edge.start.x); + } + if (std::abs(edge.start.y - edge.end.y) <= 1e-9) { + considerY(edge.start.y); + } + } + + if (snappedX.has_value()) { + result.point.x = *snappedX; + result.snapped = true; + } + if (snappedY.has_value()) { + result.point.y = *snappedY; + result.snapped = true; + } + return result; +} + } // namespace safecrowd::application diff --git a/src/application/LayoutCanvasSnapping.h b/src/application/LayoutCanvasSnapping.h index 12f21f7..2e80611 100644 --- a/src/application/LayoutCanvasSnapping.h +++ b/src/application/LayoutCanvasSnapping.h @@ -25,4 +25,12 @@ LayoutSnapResult snapLayoutPoint( const LayoutCanvasTransform& transform, const LayoutSnapOptions& options = {}); +LayoutSnapResult snapLayoutDragPoint( + const safecrowd::domain::FacilityLayout2D& layout, + const std::string& floorId, + const safecrowd::domain::Point2D& anchor, + const safecrowd::domain::Point2D& point, + const LayoutCanvasTransform& transform, + const LayoutSnapOptions& options = {}); + } // namespace safecrowd::application diff --git a/src/application/LayoutNavigationPanelWidget.cpp b/src/application/LayoutNavigationPanelWidget.cpp index 1a14e77..ab49220 100644 --- a/src/application/LayoutNavigationPanelWidget.cpp +++ b/src/application/LayoutNavigationPanelWidget.cpp @@ -34,6 +34,7 @@ NavigationTreeNode makeZoneNode(const safecrowd::domain::Zone2D& zone) { .label = zoneLabel(zone), .id = QString::fromStdString(zone.id), .detail = QString("Zone: %1").arg(QString::fromStdString(zone.id)), + .expanded = false, }; } @@ -59,7 +60,7 @@ NavigationTreeNode makeSection(const QString& label, std::vector buildLayoutTree(const safecrowd::domain::Facilit if (!rooms.empty()) { nodes.push_back({ .label = "Layout", + .id = "layout", .children = std::move(rooms), - .expanded = true, + .expanded = false, .selectable = false, }); } @@ -208,7 +210,9 @@ LayoutNavigationPanelWidget::LayoutNavigationPanelWidget( const safecrowd::domain::FacilityLayout2D* facilityLayout, std::function selectElementHandler, QWidget* parent, - QWidget* headerWidget) + QWidget* headerWidget, + NavigationTreeState navigationState, + std::function&)> expandedStateChangedHandler) : QWidget(parent) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -219,7 +223,9 @@ LayoutNavigationPanelWidget::LayoutNavigationPanelWidget( "No recognized layout elements", std::move(selectElementHandler), this, - headerWidget)); + headerWidget, + std::move(navigationState), + std::move(expandedStateChangedHandler))); } } // namespace safecrowd::application diff --git a/src/application/LayoutNavigationPanelWidget.h b/src/application/LayoutNavigationPanelWidget.h index 98376b1..21c82c6 100644 --- a/src/application/LayoutNavigationPanelWidget.h +++ b/src/application/LayoutNavigationPanelWidget.h @@ -2,9 +2,11 @@ #include +#include #include #include +#include "application/NavigationTreeWidget.h" #include "domain/FacilityLayout2D.h" namespace safecrowd::application { @@ -15,7 +17,9 @@ class LayoutNavigationPanelWidget : public QWidget { const safecrowd::domain::FacilityLayout2D* layout, std::function selectElementHandler = {}, QWidget* parent = nullptr, - QWidget* headerWidget = nullptr); + QWidget* headerWidget = nullptr, + NavigationTreeState navigationState = {}, + std::function&)> expandedStateChangedHandler = {}); }; } // namespace safecrowd::application diff --git a/src/application/LayoutPreviewWidget.cpp b/src/application/LayoutPreviewWidget.cpp index e3fa503..940402d 100644 --- a/src/application/LayoutPreviewWidget.cpp +++ b/src/application/LayoutPreviewWidget.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,8 @@ constexpr double kConnectionHitTolerance = 10.0; constexpr double kDraftMinimumSize = 0.2; constexpr double kGeometryEpsilon = 1e-4; constexpr double kPolygonCloseTolerancePixels = 12.0; +constexpr double kSelectionDragThresholdPixels = 4.0; +constexpr double kSelectionStrokeWidthPixels = 8.0; constexpr double kMinimumDoorWidth = 0.9; constexpr int kTopToolbarHeight = 44; constexpr int kPropertyPanelHeight = 42; @@ -168,6 +171,39 @@ void drawPolyline(QPainter& painter, const safecrowd::domain::Polyline2D& polyli drawLayoutCanvasPolyline(painter, polyline, transform); } +QPainterPath linePath(const safecrowd::domain::LineSegment2D& line, const LayoutTransform& transform) { + QPainterPath path; + path.moveTo(transform.map(line.start)); + path.lineTo(transform.map(line.end)); + return path; +} + +QPainterPath polylinePainterPath(const safecrowd::domain::Polyline2D& polyline, const LayoutTransform& transform) { + QPainterPath path; + if (polyline.vertices.empty()) { + return path; + } + + path.moveTo(transform.map(polyline.vertices.front())); + for (std::size_t index = 1; index < polyline.vertices.size(); ++index) { + path.lineTo(transform.map(polyline.vertices[index])); + } + if (polyline.closed && polyline.vertices.size() > 2) { + path.closeSubpath(); + } + return path; +} + +bool strokedPathIntersectsRect(const QPainterPath& path, const QRectF& rect, double strokeWidth) { + if (path.isEmpty() || rect.isEmpty()) { + return false; + } + + QPainterPathStroker stroker; + stroker.setWidth(strokeWidth); + return stroker.createStroke(path).intersects(rect) || rect.contains(path.boundingRect()); +} + bool stringListContains(const std::vector& values, const QString& target) { return std::any_of(values.begin(), values.end(), [&](const std::string& value) { return QString::fromStdString(value) == target; @@ -1420,10 +1456,6 @@ QIcon makeToolIcon(const QString& glyph, const QColor& color, bool filled = fals path.lineTo(5.5, 16.3); path.closeSubpath(); painter.drawPath(path); - } else if (glyph == "delete") { - painter.drawRect(QRectF(7, 8, 10, 11)); - painter.drawLine(QPointF(5, 8), QPointF(19, 8)); - painter.drawLine(QPointF(9, 5), QPointF(15, 5)); } else if (glyph == "reset") { painter.drawArc(QRectF(5, 5, 14, 14), 40 * 16, 280 * 16); painter.drawLine(QPointF(15, 4), QPointF(19, 5)); @@ -1566,9 +1598,16 @@ void LayoutPreviewWidget::focusElement(const QString& elementId) { } void LayoutPreviewWidget::focusIssueTarget(const QString& targetId) { + if (importResult_.layout.has_value()) { + selectFloorForElement(targetId); + } + selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = targetId; Bounds2D targetBounds; @@ -1630,15 +1669,7 @@ void LayoutPreviewWidget::setImportResult(safecrowd::domain::ImportResult import currentFloorId_ = defaultFloorId(*importResult_.layout); } - if (!selectedZoneId_.isEmpty() && !containsZone(*importResult_.layout, selectedZoneId_)) { - selectedZoneId_.clear(); - } - if (!selectedConnectionId_.isEmpty() && !containsConnection(*importResult_.layout, selectedConnectionId_)) { - selectedConnectionId_.clear(); - } - if (!selectedBarrierId_.isEmpty() && !containsBarrier(*importResult_.layout, selectedBarrierId_)) { - selectedBarrierId_.clear(); - } + pruneSelection(); if (toolbarCorner_ != nullptr) { toolbarCorner_->setVisible(true); @@ -1717,17 +1748,27 @@ void LayoutPreviewWidget::mouseDoubleClickEvent(QMouseEvent* event) { } void LayoutPreviewWidget::mouseMoveEvent(QMouseEvent* event) { - if (!camera_.panning() && !drafting_) { + if (!camera_.panning() && !drafting_ && !selectionDragging_) { QWidget::mouseMoveEvent(event); return; } + if (selectionDragging_) { + selectionDragCurrent_ = event->position(); + update(); + event->accept(); + return; + } + if (drafting_) { const auto bounds = collectBounds(importResult_, currentFloorId()); if (bounds.has_value()) { const LayoutTransform transform(*bounds, previewViewport(rect()), camera_.zoom(), camera_.panOffset()); const auto world = transform.unmap(event->position()); - draftCurrentWorld_ = snapWorldPoint(QPointF(world.x, world.y), transform); + const QPointF worldPoint(world.x, world.y); + draftCurrentWorld_ = toolMode_ == ToolMode::DrawRoom && roomDrawMode_ == RoomDrawMode::Polygon + ? snapWorldPoint(worldPoint, transform) + : snapDragWorldPoint(draftStartWorld_, worldPoint, transform); update(); event->accept(); return; @@ -1745,6 +1786,27 @@ void LayoutPreviewWidget::mouseMoveEvent(QMouseEvent* event) { void LayoutPreviewWidget::mousePressEvent(QMouseEvent* event) { setFocus(Qt::MouseFocusReason); + if (event->button() == Qt::RightButton && toolMode_ == ToolMode::Select) { + const auto bounds = collectBounds(importResult_, currentFloorId()); + if (bounds.has_value() && importResult_.layout.has_value()) { + const LayoutTransform transform(*bounds, previewViewport(rect()), camera_.zoom(), camera_.panOffset()); + const auto floorId = currentFloorId(); + const auto zoneId = hitTestZone(*importResult_.layout, event->position(), transform, floorId); + const auto connectionId = hitTestConnection(*importResult_.layout, event->position(), transform, floorId); + const auto barrierId = hitTestBarrier(*importResult_.layout, event->position(), transform, floorId); + if (connectionId.has_value() && !isSelected(PreviewSelectionKind::Connection, *connectionId)) { + selectConnection(*connectionId); + } else if (barrierId.has_value() && !isSelected(PreviewSelectionKind::Barrier, *barrierId)) { + selectBarrier(*barrierId); + } else if (zoneId.has_value() && !isSelected(PreviewSelectionKind::Zone, *zoneId)) { + selectZone(*zoneId); + } + } + showSelectionContextMenu(event->globalPosition().toPoint()); + event->accept(); + return; + } + if (camera_.beginPan(event)) { return; } @@ -1792,7 +1854,7 @@ void LayoutPreviewWidget::mousePressEvent(QMouseEvent* event) { return; } - if (toolMode_ != ToolMode::Select && toolMode_ != ToolMode::Delete) { + if (toolMode_ != ToolMode::Select) { const LayoutTransform transform(*bounds, previewViewport(rect()), camera_.zoom(), camera_.panOffset()); const auto world = transform.unmap(event->position()); drafting_ = true; @@ -1802,7 +1864,10 @@ void LayoutPreviewWidget::mousePressEvent(QMouseEvent* event) { return; } - applyToolAt(event->position()); + selectionDragging_ = true; + selectionDragStart_ = event->position(); + selectionDragCurrent_ = selectionDragStart_; + update(); event->accept(); return; } @@ -1815,6 +1880,24 @@ void LayoutPreviewWidget::mouseReleaseEvent(QMouseEvent* event) { return; } + if (selectionDragging_ && event->button() == Qt::LeftButton) { + selectionDragging_ = false; + selectionDragCurrent_ = event->position(); + const auto dragDistance = distanceBetweenScreenPoints(selectionDragStart_, selectionDragCurrent_); + const auto bounds = collectBounds(importResult_, currentFloorId()); + if (bounds.has_value()) { + const LayoutTransform transform(*bounds, previewViewport(rect()), camera_.zoom(), camera_.panOffset()); + if (dragDistance <= kSelectionDragThresholdPixels) { + selectSingleAt(event->position(), transform); + } else { + selectElementsInRect(QRectF(selectionDragStart_, selectionDragCurrent_).normalized(), transform); + } + } + update(); + event->accept(); + return; + } + if (drafting_ && event->button() == Qt::LeftButton && !(toolMode_ == ToolMode::DrawRoom && roomDrawMode_ == RoomDrawMode::Polygon)) { drafting_ = false; @@ -1822,7 +1905,7 @@ void LayoutPreviewWidget::mouseReleaseEvent(QMouseEvent* event) { if (bounds.has_value()) { const LayoutTransform transform(*bounds, previewViewport(rect()), camera_.zoom(), camera_.panOffset()); const auto world = transform.unmap(event->position()); - draftCurrentWorld_ = snapWorldPoint(QPointF(world.x, world.y), transform); + draftCurrentWorld_ = snapDragWorldPoint(draftStartWorld_, QPointF(world.x, world.y), transform); } switch (toolMode_) { @@ -1841,7 +1924,6 @@ void LayoutPreviewWidget::mouseReleaseEvent(QMouseEvent* event) { case ToolMode::DrawDoor: break; case ToolMode::Select: - case ToolMode::Delete: break; } @@ -1901,25 +1983,22 @@ void LayoutPreviewWidget::paintEvent(QPaintEvent* event) { } } - QString highlightTargetId = focusedTargetId_; - if (!selectedConnectionId_.isEmpty()) { - highlightTargetId = selectedConnectionId_; - } else if (!selectedBarrierId_.isEmpty()) { - highlightTargetId = selectedBarrierId_; - } else if (!selectedZoneId_.isEmpty()) { - highlightTargetId = selectedZoneId_; - } - - if (!highlightTargetId.isEmpty()) { - painter.setBrush(QColor(255, 219, 102, 96)); - painter.setPen(QPen(QColor(194, 74, 44), 3.5)); + const bool hasExplicitSelection = hasSelection(); + if (hasExplicitSelection || !focusedTargetId_.isEmpty()) { + painter.setBrush(QColor(31, 95, 174, 44)); + painter.setPen(QPen(QColor(31, 95, 174), 2.25, Qt::DashLine)); if (importResult_.layout.has_value()) { for (const auto& zone : importResult_.layout->zones) { if (!matchesFloor(zone.floorId, currentFloorId())) { continue; } - if (QString::fromStdString(zone.id) == highlightTargetId || traceMatches(zone.provenance, highlightTargetId)) { + const auto id = QString::fromStdString(zone.id); + const bool selected = selectedZoneIds_.contains(id); + const bool focused = !hasExplicitSelection + && !focusedTargetId_.isEmpty() + && (id == focusedTargetId_ || traceMatches(zone.provenance, focusedTargetId_)); + if (selected || focused) { painter.drawPath(polygonPath(zone.area, transform)); } } @@ -1927,7 +2006,12 @@ void LayoutPreviewWidget::paintEvent(QPaintEvent* event) { if (!matchesFloor(connection.floorId, currentFloorId())) { continue; } - if (QString::fromStdString(connection.id) == highlightTargetId || traceMatches(connection.provenance, highlightTargetId)) { + const auto id = QString::fromStdString(connection.id); + const bool selected = selectedConnectionIds_.contains(id); + const bool focused = !hasExplicitSelection + && !focusedTargetId_.isEmpty() + && (id == focusedTargetId_ || traceMatches(connection.provenance, focusedTargetId_)); + if (selected || focused) { drawLine(painter, connection.centerSpan, transform); } } @@ -1935,40 +2019,51 @@ void LayoutPreviewWidget::paintEvent(QPaintEvent* event) { if (!matchesFloor(barrier.floorId, currentFloorId())) { continue; } - if (QString::fromStdString(barrier.id) == highlightTargetId || traceMatches(barrier.provenance, highlightTargetId)) { + const auto id = QString::fromStdString(barrier.id); + const bool selected = selectedBarrierIds_.contains(id); + const bool focused = !hasExplicitSelection + && !focusedTargetId_.isEmpty() + && (id == focusedTargetId_ || traceMatches(barrier.provenance, focusedTargetId_)); + if (selected || focused) { drawPolyline(painter, barrier.geometry, transform); } } } - if (importResult_.canonicalGeometry.has_value()) { + if (!hasExplicitSelection && importResult_.canonicalGeometry.has_value()) { for (const auto& walkable : importResult_.canonicalGeometry->walkableAreas) { const auto id = QString::fromStdString(walkable.id); - if (traceRefMatches(importResult_, id, highlightTargetId)) { + if (traceRefMatches(importResult_, id, focusedTargetId_)) { painter.drawPath(polygonPath(walkable.polygon, transform)); } } for (const auto& obstacle : importResult_.canonicalGeometry->obstacles) { const auto id = QString::fromStdString(obstacle.id); - if (traceRefMatches(importResult_, id, highlightTargetId)) { + if (traceRefMatches(importResult_, id, focusedTargetId_)) { painter.drawPath(polygonPath(obstacle.footprint, transform)); } } for (const auto& wall : importResult_.canonicalGeometry->walls) { const auto id = QString::fromStdString(wall.id); - if (traceRefMatches(importResult_, id, highlightTargetId)) { + if (traceRefMatches(importResult_, id, focusedTargetId_)) { drawLine(painter, wall.segment, transform); } } for (const auto& opening : importResult_.canonicalGeometry->openings) { const auto id = QString::fromStdString(opening.id); - if (traceRefMatches(importResult_, id, highlightTargetId)) { + if (traceRefMatches(importResult_, id, focusedTargetId_)) { drawLine(painter, opening.span, transform); } } } } + if (selectionDragging_) { + painter.setBrush(QColor(31, 95, 174, 28)); + painter.setPen(QPen(QColor(31, 95, 174), 1.6, Qt::DashLine)); + painter.drawRect(QRectF(selectionDragStart_, selectionDragCurrent_).normalized()); + } + if (drafting_) { painter.setBrush(QColor(31, 95, 174, 60)); painter.setPen(QPen(QColor(31, 95, 174), 2.0, Qt::DashLine)); @@ -2028,6 +2123,10 @@ void LayoutPreviewWidget::resizeEvent(QResizeEvent* event) { } void LayoutPreviewWidget::wheelEvent(QWheelEvent* event) { + if (switchFloorByWheel(event)) { + return; + } + const auto bounds = collectBounds(importResult_, currentFloorId()); if (!bounds.has_value()) { QWidget::wheelEvent(event); @@ -2066,22 +2165,7 @@ void LayoutPreviewWidget::applyToolAt(const QPointF& position) { switch (toolMode_) { case ToolMode::Select: - if (zoneId.has_value()) { - selectZone(*zoneId); - } else if (connectionId.has_value()) { - selectConnection(*connectionId); - } else if (barrierId.has_value()) { - selectBarrier(*barrierId); - } else { - clearSelection(); - } - return; - case ToolMode::Delete: - if (connectionId.has_value()) { - deleteConnection(*connectionId); - } else if (barrierId.has_value()) { - deleteBarrier(*barrierId); - } + selectSingleAt(testPosition, transform); return; case ToolMode::DrawRoom: case ToolMode::DrawExit: @@ -2101,9 +2185,13 @@ void LayoutPreviewWidget::applyToolAt(const QPointF& position) { void LayoutPreviewWidget::clearSelection() { selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_.clear(); + selectionDragging_ = false; emitCurrentSelection(); update(); } @@ -2169,8 +2257,11 @@ void LayoutPreviewWidget::createRoomPolygon(const std::vector& points) } selectedZoneId_ = lastZoneId; + selectedZoneIds_ = QStringList{lastZoneId}; selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = lastZoneId; notifyLayoutEdited(); emitCurrentSelection(); @@ -2250,8 +2341,11 @@ void LayoutPreviewWidget::createZone(const QPointF& startWorld, const QPointF& e } selectedZoneId_ = lastZoneId; + selectedZoneIds_ = QStringList{lastZoneId}; selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = lastZoneId; notifyLayoutEdited(); emitCurrentSelection(); @@ -2284,8 +2378,11 @@ void LayoutPreviewWidget::createBarrier(const QPointF& startWorld, const QPointF }); selectedBarrierId_ = barrierId; + selectedBarrierIds_ = QStringList{barrierId}; selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); focusedTargetId_ = barrierId; notifyLayoutEdited(); emitCurrentSelection(); @@ -2343,8 +2440,11 @@ void LayoutPreviewWidget::createConnection(const QPointF& startWorld, const QPoi }); selectedConnectionId_ = connectionId; + selectedConnectionIds_ = QStringList{connectionId}; selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = connectionId; notifyLayoutEdited(); emitCurrentSelection(); @@ -2477,8 +2577,11 @@ void LayoutPreviewWidget::createVerticalLink(const QPointF& startWorld, const QP } selectedConnectionId_ = verticalConnectionId; + selectedConnectionIds_ = QStringList{verticalConnectionId}; selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = verticalConnectionId; notifyLayoutEdited(); emitCurrentSelection(); @@ -2626,8 +2729,11 @@ void LayoutPreviewWidget::createDoorAt(const QString& barrierId, const QPointF& }); selectedConnectionId_ = connectionId; + selectedConnectionIds_ = QStringList{connectionId}; selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = connectionId; notifyLayoutEdited(); emitCurrentSelection(); @@ -2649,6 +2755,8 @@ void LayoutPreviewWidget::deleteConnection(const QString& connectionId) { connections.erase(it, connections.end()); selectedConnectionId_.clear(); + selectedConnectionIds_.removeAll(connectionId); + selectPrimaryFromLists(); focusedTargetId_.clear(); notifyLayoutEdited(); emitCurrentSelection(); @@ -2670,12 +2778,64 @@ void LayoutPreviewWidget::deleteBarrier(const QString& barrierId) { barriers.erase(it, barriers.end()); selectedBarrierId_.clear(); + selectedBarrierIds_.removeAll(barrierId); + selectPrimaryFromLists(); focusedTargetId_.clear(); notifyLayoutEdited(); emitCurrentSelection(); update(); } +void LayoutPreviewWidget::deleteSelectedElements() { + if (!importResult_.layout.has_value() || !hasSelection()) { + return; + } + + auto& layout = *importResult_.layout; + bool changed = false; + + const auto selectedZoneId = [&](const std::string& id) { + return selectedZoneIds_.contains(QString::fromStdString(id)); + }; + + auto& connections = layout.connections; + const auto connectionIt = std::remove_if(connections.begin(), connections.end(), [&](const auto& connection) { + return selectedConnectionIds_.contains(QString::fromStdString(connection.id)) + || selectedZoneId(connection.fromZoneId) + || selectedZoneId(connection.toZoneId); + }); + if (connectionIt != connections.end()) { + connections.erase(connectionIt, connections.end()); + changed = true; + } + + auto& barriers = layout.barriers; + const auto barrierIt = std::remove_if(barriers.begin(), barriers.end(), [&](const auto& barrier) { + return selectedBarrierIds_.contains(QString::fromStdString(barrier.id)); + }); + if (barrierIt != barriers.end()) { + barriers.erase(barrierIt, barriers.end()); + changed = true; + } + + auto& zones = layout.zones; + const auto zoneIt = std::remove_if(zones.begin(), zones.end(), [&](const auto& zone) { + return selectedZoneIds_.contains(QString::fromStdString(zone.id)); + }); + if (zoneIt != zones.end()) { + zones.erase(zoneIt, zones.end()); + changed = true; + } + + if (!changed) { + clearSelection(); + return; + } + + clearSelection(); + notifyLayoutEdited(); +} + void LayoutPreviewWidget::emitCurrentSelection() { if (selectionChangedHandler_) { selectionChangedHandler_(currentSelection()); @@ -2696,6 +2856,23 @@ void LayoutPreviewWidget::finishRoomPolygonDraft() { createRoomPolygon(points); } +QPointF LayoutPreviewWidget::snapDragWorldPoint( + const QPointF& anchorWorldPoint, + const QPointF& worldPoint, + const LayoutCanvasTransform& transform) const { + if (!importResult_.layout.has_value()) { + return worldPoint; + } + + const auto snapped = snapLayoutDragPoint( + *importResult_.layout, + currentFloorId().toStdString(), + {.x = anchorWorldPoint.x(), .y = anchorWorldPoint.y()}, + {.x = worldPoint.x(), .y = worldPoint.y()}, + transform); + return QPointF(snapped.point.x, snapped.point.y); +} + QPointF LayoutPreviewWidget::snapWorldPoint(const QPointF& worldPoint, const LayoutCanvasTransform& transform) const { if (!importResult_.layout.has_value()) { return worldPoint; @@ -2709,6 +2886,44 @@ QPointF LayoutPreviewWidget::snapWorldPoint(const QPointF& worldPoint, const Lay return QPointF(snapped.point.x, snapped.point.y); } +bool LayoutPreviewWidget::hasSelection() const { + return !selectedZoneIds_.isEmpty() || !selectedConnectionIds_.isEmpty() || !selectedBarrierIds_.isEmpty(); +} + +bool LayoutPreviewWidget::isSelected(PreviewSelectionKind kind, const QString& id) const { + switch (kind) { + case PreviewSelectionKind::Zone: + return selectedZoneIds_.contains(id); + case PreviewSelectionKind::Connection: + return selectedConnectionIds_.contains(id); + case PreviewSelectionKind::Barrier: + return selectedBarrierIds_.contains(id); + case PreviewSelectionKind::None: + case PreviewSelectionKind::Multiple: + return false; + } + return false; +} + +void LayoutPreviewWidget::pruneSelection() { + if (!importResult_.layout.has_value()) { + clearSelection(); + return; + } + + const auto& layout = *importResult_.layout; + selectedZoneIds_.erase(std::remove_if(selectedZoneIds_.begin(), selectedZoneIds_.end(), [&](const auto& id) { + return !containsZone(layout, id); + }), selectedZoneIds_.end()); + selectedConnectionIds_.erase(std::remove_if(selectedConnectionIds_.begin(), selectedConnectionIds_.end(), [&](const auto& id) { + return !containsConnection(layout, id); + }), selectedConnectionIds_.end()); + selectedBarrierIds_.erase(std::remove_if(selectedBarrierIds_.begin(), selectedBarrierIds_.end(), [&](const auto& id) { + return !containsBarrier(layout, id); + }), selectedBarrierIds_.end()); + selectPrimaryFromLists(); +} + void LayoutPreviewWidget::notifyLayoutEdited() { if (layoutEditedHandler_ && importResult_.layout.has_value()) { layoutEditedHandler_(*importResult_.layout); @@ -2740,8 +2955,11 @@ void LayoutPreviewWidget::repositionToolbars() { void LayoutPreviewWidget::selectBarrier(const QString& barrierId) { selectedBarrierId_ = barrierId; + selectedBarrierIds_ = QStringList{barrierId}; selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); focusedTargetId_ = barrierId; emitCurrentSelection(); update(); @@ -2749,13 +2967,60 @@ void LayoutPreviewWidget::selectBarrier(const QString& barrierId) { void LayoutPreviewWidget::selectConnection(const QString& connectionId) { selectedConnectionId_ = connectionId; + selectedConnectionIds_ = QStringList{connectionId}; selectedZoneId_.clear(); + selectedZoneIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = connectionId; emitCurrentSelection(); update(); } +void LayoutPreviewWidget::selectElementsInRect(const QRectF& screenRect, const LayoutCanvasTransform& transform) { + if (!importResult_.layout.has_value() || screenRect.isEmpty()) { + clearSelection(); + return; + } + + selectedZoneIds_.clear(); + selectedConnectionIds_.clear(); + selectedBarrierIds_.clear(); + + const auto& layout = *importResult_.layout; + const auto floorId = currentFloorId(); + for (const auto& zone : layout.zones) { + if (!matchesFloor(zone.floorId, floorId)) { + continue; + } + const auto path = polygonPath(zone.area, transform); + if (path.intersects(screenRect) || screenRect.contains(path.boundingRect())) { + selectedZoneIds_.append(QString::fromStdString(zone.id)); + } + } + for (const auto& connection : layout.connections) { + if (!matchesFloor(connection.floorId, floorId)) { + continue; + } + if (strokedPathIntersectsRect(linePath(connection.centerSpan, transform), screenRect, kSelectionStrokeWidthPixels)) { + selectedConnectionIds_.append(QString::fromStdString(connection.id)); + } + } + for (const auto& barrier : layout.barriers) { + if (!matchesFloor(barrier.floorId, floorId)) { + continue; + } + if (strokedPathIntersectsRect(polylinePainterPath(barrier.geometry, transform), screenRect, kSelectionStrokeWidthPixels)) { + selectedBarrierIds_.append(QString::fromStdString(barrier.id)); + } + } + + focusedTargetId_.clear(); + selectPrimaryFromLists(); + emitCurrentSelection(); + update(); +} + void LayoutPreviewWidget::selectFloorForElement(const QString& elementId) { if (!importResult_.layout.has_value() || elementId.isEmpty()) { return; @@ -2794,10 +3059,50 @@ void LayoutPreviewWidget::selectFloorForElement(const QString& elementId) { } } +void LayoutPreviewWidget::selectPrimaryFromLists() { + selectedZoneId_ = selectedZoneIds_.isEmpty() ? QString{} : selectedZoneIds_.front(); + selectedConnectionId_ = selectedConnectionIds_.isEmpty() ? QString{} : selectedConnectionIds_.front(); + selectedBarrierId_ = selectedBarrierIds_.isEmpty() ? QString{} : selectedBarrierIds_.front(); + + if (!selectedZoneId_.isEmpty()) { + focusedTargetId_ = selectedZoneId_; + } else if (!selectedConnectionId_.isEmpty()) { + focusedTargetId_ = selectedConnectionId_; + } else if (!selectedBarrierId_.isEmpty()) { + focusedTargetId_ = selectedBarrierId_; + } else { + focusedTargetId_.clear(); + } +} + +void LayoutPreviewWidget::selectSingleAt(const QPointF& position, const LayoutCanvasTransform& transform) { + if (!importResult_.layout.has_value()) { + clearSelection(); + return; + } + + const auto floorId = currentFloorId(); + const auto zoneId = hitTestZone(*importResult_.layout, position, transform, floorId); + const auto connectionId = hitTestConnection(*importResult_.layout, position, transform, floorId); + const auto barrierId = hitTestBarrier(*importResult_.layout, position, transform, floorId); + if (connectionId.has_value()) { + selectConnection(*connectionId); + } else if (barrierId.has_value()) { + selectBarrier(*barrierId); + } else if (zoneId.has_value()) { + selectZone(*zoneId); + } else { + clearSelection(); + } +} + void LayoutPreviewWidget::selectZone(const QString& zoneId) { selectedZoneId_ = zoneId; + selectedZoneIds_ = QStringList{zoneId}; selectedConnectionId_.clear(); + selectedConnectionIds_.clear(); selectedBarrierId_.clear(); + selectedBarrierIds_.clear(); focusedTargetId_ = zoneId; emitCurrentSelection(); update(); @@ -2832,6 +3137,46 @@ QString LayoutPreviewWidget::currentFloorId() const { return {}; } +bool LayoutPreviewWidget::switchFloorByWheel(QWheelEvent* event) { + if (event == nullptr + || !(event->modifiers() & Qt::ControlModifier) + || !importResult_.layout.has_value() + || importResult_.layout->floors.size() <= 1) { + return false; + } + + const auto delta = event->angleDelta().y() != 0 ? event->angleDelta().y() : event->pixelDelta().y(); + if (delta == 0) { + return false; + } + + auto& layout = *importResult_.layout; + int currentIndex = 0; + const auto activeFloorId = currentFloorId(); + for (std::size_t index = 0; index < layout.floors.size(); ++index) { + if (QString::fromStdString(layout.floors[index].id) == activeFloorId) { + currentIndex = static_cast(index); + break; + } + } + + const auto nextIndex = std::clamp( + currentIndex + (delta > 0 ? 1 : -1), + 0, + static_cast(layout.floors.size() - 1)); + const auto nextFloorId = QString::fromStdString(layout.floors[static_cast(nextIndex)].id); + if (!nextFloorId.isEmpty() && nextFloorId != currentFloorId_) { + currentFloorId_ = nextFloorId; + clearSelection(); + refreshFloorSelector(); + camera_.reset(); + update(); + } + + event->accept(); + return true; +} + QString LayoutPreviewWidget::verticalTargetFloorId() const { if (verticalTargetFloorComboBox_ == nullptr || verticalTargetFloorComboBox_->currentIndex() < 0) { return {}; @@ -2917,14 +3262,21 @@ void LayoutPreviewWidget::setToolMode(ToolMode mode) { if (stairToolButton_ != nullptr) { stairToolButton_->setChecked(toolMode_ == ToolMode::DrawStair); } - if (deleteToolButton_ != nullptr) { - deleteToolButton_->setChecked(toolMode_ == ToolMode::Delete); - } refreshPropertyPanel(); update(); } +void LayoutPreviewWidget::showSelectionContextMenu(const QPoint& globalPosition) { + QMenu menu(this); + auto* deleteAction = menu.addAction("Delete"); + deleteAction->setEnabled(hasSelection()); + const auto* selectedAction = menu.exec(globalPosition); + if (selectedAction == deleteAction) { + deleteSelectedElements(); + } +} + void LayoutPreviewWidget::setupToolbars() { const QString frameStyle = "QFrame { background: rgba(255, 255, 255, 245); border: 1px solid #d7e0ea; border-radius: 0px; }" @@ -3023,7 +3375,6 @@ void LayoutPreviewWidget::setupToolbars() { }; selectToolButton_ = makeButton(topToolbar_, topLayout, makeToolIcon("select", QColor("#16202b")), "Select"); - deleteToolButton_ = makeButton(topToolbar_, topLayout, makeToolIcon("delete", QColor("#8f2d20")), "Delete"); resetViewButton_ = makeButton(topToolbar_, topLayout, makeToolIcon("reset", QColor("#1f5fae")), "Reset View"); resetViewButton_->setCheckable(false); auto* floorLabel = new QLabel("Floor", topToolbar_); @@ -3046,7 +3397,6 @@ void LayoutPreviewWidget::setupToolbars() { sideLayout->addStretch(1); connect(selectToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::Select); }); - connect(deleteToolButton_, &QToolButton::clicked, this, [this]() { setToolMode(ToolMode::Delete); }); connect(resetViewButton_, &QToolButton::clicked, this, [this]() { resetView(); }); connect(floorComboBox_, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { if (index < 0 || floorComboBox_ == nullptr) { @@ -3145,6 +3495,18 @@ void LayoutPreviewWidget::refreshPropertyPanel() { PreviewSelection LayoutPreviewWidget::currentSelection() const { PreviewSelection selection; + const int selectedCount = selectedZoneIds_.size() + selectedConnectionIds_.size() + selectedBarrierIds_.size(); + if (selectedCount > 1) { + selection.kind = PreviewSelectionKind::Multiple; + selection.id = "multiple"; + selection.title = QString("%1 elements selected").arg(selectedCount); + selection.detail = QString("%1 rooms/exits/stairs, %2 openings/doors, %3 walls selected. Right-click the selection to delete.") + .arg(selectedZoneIds_.size()) + .arg(selectedConnectionIds_.size()) + .arg(selectedBarrierIds_.size()); + return selection; + } + if (importResult_.layout.has_value() && !selectedZoneId_.isEmpty()) { const auto& layout = *importResult_.layout; const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { diff --git a/src/application/LayoutPreviewWidget.h b/src/application/LayoutPreviewWidget.h index 75a89ef..168fc47 100644 --- a/src/application/LayoutPreviewWidget.h +++ b/src/application/LayoutPreviewWidget.h @@ -4,6 +4,10 @@ #include #include +#include +#include +#include +#include #include #include "domain/FacilityLayout2D.h" @@ -24,6 +28,7 @@ namespace safecrowd::application { enum class PreviewSelectionKind { None, + Multiple, Zone, Connection, Barrier, @@ -72,7 +77,6 @@ class LayoutPreviewWidget : public QWidget { DrawWall, DrawDoor, DrawStair, - Delete, }; enum class RoomDrawMode { @@ -90,8 +94,16 @@ class LayoutPreviewWidget : public QWidget { void createZone(const QPointF& startWorld, const QPointF& endWorld, safecrowd::domain::ZoneKind kind); void deleteConnection(const QString& connectionId); void deleteBarrier(const QString& barrierId); + void deleteSelectedElements(); void emitCurrentSelection(); void finishRoomPolygonDraft(); + bool hasSelection() const; + bool isSelected(PreviewSelectionKind kind, const QString& id) const; + void pruneSelection(); + QPointF snapDragWorldPoint( + const QPointF& anchorWorldPoint, + const QPointF& worldPoint, + const LayoutCanvasTransform& transform) const; QPointF snapWorldPoint(const QPointF& worldPoint, const LayoutCanvasTransform& transform) const; void notifyLayoutEdited(); void repositionToolbars(); @@ -99,25 +111,36 @@ class LayoutPreviewWidget : public QWidget { void refreshPropertyPanel(); void selectBarrier(const QString& barrierId); void selectConnection(const QString& connectionId); + void selectElementsInRect(const QRectF& screenRect, const LayoutCanvasTransform& transform); void selectFloorForElement(const QString& elementId); + void selectPrimaryFromLists(); + void selectSingleAt(const QPointF& position, const LayoutCanvasTransform& transform); void selectZone(const QString& zoneId); void addFloor(); QString currentFloorId() const; + bool switchFloorByWheel(QWheelEvent* event); QString verticalTargetFloorId() const; void setToolMode(ToolMode mode); + void showSelectionContextMenu(const QPoint& globalPosition); void setupToolbars(); PreviewSelection currentSelection() const; safecrowd::domain::ImportResult importResult_{}; QString selectedBarrierId_{}; + QStringList selectedBarrierIds_{}; QString focusedTargetId_{}; QString selectedConnectionId_{}; + QStringList selectedConnectionIds_{}; QString selectedZoneId_{}; + QStringList selectedZoneIds_{}; QPointF draftStartWorld_{}; QPointF draftCurrentWorld_{}; + QPointF selectionDragStart_{}; + QPointF selectionDragCurrent_{}; std::vector roomPolygonDraftPoints_{}; LayoutCanvasCamera camera_{}; bool drafting_{false}; + bool selectionDragging_{false}; ToolMode toolMode_{ToolMode::Select}; RoomDrawMode roomDrawMode_{RoomDrawMode::Rectangle}; bool roomAutoWallsEnabled_{true}; @@ -148,7 +171,6 @@ class LayoutPreviewWidget : public QWidget { QToolButton* wallToolButton_{nullptr}; QToolButton* doorToolButton_{nullptr}; QToolButton* stairToolButton_{nullptr}; - QToolButton* deleteToolButton_{nullptr}; QToolButton* addFloorButton_{nullptr}; QToolButton* resetViewButton_{nullptr}; std::function selectionChangedHandler_{}; diff --git a/src/application/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index d360ce5..6ea6619 100644 --- a/src/application/LayoutReviewWidget.cpp +++ b/src/application/LayoutReviewWidget.cpp @@ -58,6 +58,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { switch (code) { case ImportIssueCode::MissingExit: + case ImportIssueCode::MissingRoom: case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: return true; @@ -226,6 +227,8 @@ QWidget* createNavigationPanel( bool showIssues, std::function selectIssueHandler, std::function selectLayoutElementHandler, + NavigationTreeState layoutNavigationState, + std::function&)> layoutExpandedStateChangedHandler, const WorkspaceShell* shell, QWidget* parent) { auto* content = new QWidget(parent); @@ -238,7 +241,9 @@ QWidget* createNavigationPanel( importResult.layout.has_value() ? &(*importResult.layout) : nullptr, std::move(selectLayoutElementHandler), content, - shell != nullptr ? shell->createPanelHeader("Layout", content, false) : nullptr)); + shell != nullptr ? shell->createPanelHeader("Layout", content, false) : nullptr, + std::move(layoutNavigationState), + std::move(layoutExpandedStateChangedHandler))); return content; } @@ -465,6 +470,7 @@ void LayoutReviewWidget::handleIssueSelected(const safecrowd::domain::ImportIssu } void LayoutReviewWidget::handleLayoutElementSelected(const QString& elementId) { + selectedLayoutElementId_ = elementId; selectedIssueTargetId_.clear(); selectedIssueCode_.clear(); @@ -484,16 +490,26 @@ void LayoutReviewWidget::handleLayoutEdited(const safecrowd::domain::FacilityLay void LayoutReviewWidget::handlePreviewSelectionChanged(const PreviewSelection& selection) { lastSelection_ = selection; + selectedLayoutElementId_ = selection.empty() || selection.kind == PreviewSelectionKind::Multiple ? QString{} : selection.id; selectedIssueTargetId_.clear(); selectedIssueCode_.clear(); showSelectionInspector(selection); + if (navigationView_ == NavigationView::Layout) { + refreshNavigationPanel(); + } } void LayoutReviewWidget::refreshApprovalState() { - const auto hasBlocking = safecrowd::domain::hasBlockingImportIssue(importResult_.issues); + const auto blockingCount = std::count_if(importResult_.issues.begin(), importResult_.issues.end(), [](const auto& issue) { + return issue.blocksSimulation(); + }); + const auto hasBlocking = blockingCount > 0; if (approveButton_ != nullptr) { approveButton_->setEnabled(!hasBlocking); + approveButton_->setToolTip(hasBlocking + ? QString("Resolve %1 blocking issue(s) before approval").arg(static_cast(blockingCount)) + : QString("Approve layout and continue to Scenario Authoring")); } if (approvalStatusLabel_ == nullptr) { @@ -501,7 +517,7 @@ void LayoutReviewWidget::refreshApprovalState() { } if (hasBlocking) { - approvalStatusLabel_->setText("Resolve blocking issues first"); + approvalStatusLabel_->setText(QString("Resolve %1 blocking issue(s) first").arg(static_cast(blockingCount))); return; } @@ -535,6 +551,14 @@ void LayoutReviewWidget::refreshNavigationPanel() { [this](const QString& elementId) { handleLayoutElementSelected(elementId); }, + NavigationTreeState{ + .expandedNodeIds = layoutExpandedNodeIds_, + .selectedId = selectedLayoutElementId_, + .restoreExpandedState = true, + }, + [this](const QSet& expandedNodeIds) { + layoutExpandedNodeIds_ = expandedNodeIds; + }, shell_, shell_)); } diff --git a/src/application/LayoutReviewWidget.h b/src/application/LayoutReviewWidget.h index 3533404..438b858 100644 --- a/src/application/LayoutReviewWidget.h +++ b/src/application/LayoutReviewWidget.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -60,6 +61,8 @@ class LayoutReviewWidget : public QWidget { QLabel* approvalStatusLabel_{nullptr}; QPushButton* approveButton_{nullptr}; NavigationView navigationView_{NavigationView::Issues}; + QSet layoutExpandedNodeIds_{}; + QString selectedLayoutElementId_{}; QString selectedIssueTargetId_{}; QString selectedIssueCode_{}; PreviewSelection lastSelection_{}; diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index 06c615a..ba31372 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -1,6 +1,8 @@ #include "application/MainWindow.h" +#include #include +#include #include @@ -9,6 +11,8 @@ #include "application/ProjectPersistence.h" #include "application/ProjectNavigatorWidget.h" #include "application/ScenarioAuthoringWidget.h" +#include "application/ScenarioResultWidget.h" +#include "application/ScenarioRunWidget.h" #include "domain/DemoLayouts.h" #include "domain/DxfImportService.h" #include "domain/ImportIssue.h" @@ -27,9 +31,15 @@ void applySavedReviewState(const ProjectMetadata& metadata, safecrowd::domain::I ProjectPersistence::loadProjectReview(metadata, importResult); } -safecrowd::domain::ImportResult makeDemoImportResult() { +safecrowd::domain::ImportResult makeDemoImportResult(const ProjectMetadata& metadata) { safecrowd::domain::ImportResult result; - result.layout = safecrowd::domain::DemoLayouts::demoFacility(); + if (metadata.layoutPath == sprint1DemoLayoutPath()) { + result.layout = safecrowd::domain::DemoLayouts::demoFacility(); + } else if (metadata.layoutPath == twoFloorDemoLayoutPath()) { + result.layout = safecrowd::domain::DemoLayouts::demoTwoFloorFacility(); + } else { + result.layout = safecrowd::domain::DemoLayouts::demoFacility(); + } safecrowd::domain::ImportValidationService validator; result.issues = validator.validate(*result.layout); @@ -39,6 +49,138 @@ safecrowd::domain::ImportResult makeDemoImportResult() { return result; } +safecrowd::domain::ImportResult importProjectLayout(const ProjectMetadata& metadata) { + if (metadata.isBuiltInDemo()) { + return makeDemoImportResult(metadata); + } + + safecrowd::domain::DxfImportService importer; + const safecrowd::domain::ImportRequest importRequest{ + .sourcePath = std::filesystem::path(metadata.layoutPath.toStdWString()), + .requestedFormat = safecrowd::domain::ImportedFileFormat::Dxf, + .preserveRawModel = true, + .runValidation = true, + }; + return importer.importFile(importRequest); +} + +QString zoneLabel(const safecrowd::domain::Zone2D& zone) { + const auto id = QString::fromStdString(zone.id); + const auto label = QString::fromStdString(zone.label); + return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); +} + +const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; + }); + return it == layout.zones.end() ? nullptr : &(*it); +} + +const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::FacilityLayout2D& layout) { + const auto exitIt = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.kind == safecrowd::domain::ZoneKind::Exit; + }); + if (exitIt != layout.zones.end()) { + return &(*exitIt); + } + return layout.zones.empty() ? nullptr : &layout.zones.back(); +} + +ScenarioAuthoringWidget::NavigationView navigationViewFromSaved(SavedNavigationView view) { + switch (view) { + case SavedNavigationView::Crowd: + return ScenarioAuthoringWidget::NavigationView::Crowd; + case SavedNavigationView::Events: + return ScenarioAuthoringWidget::NavigationView::Events; + case SavedNavigationView::Layout: + default: + return ScenarioAuthoringWidget::NavigationView::Layout; + } +} + +ScenarioAuthoringWidget::RightPanelMode rightPanelModeFromSaved(SavedRightPanelMode mode) { + switch (mode) { + case SavedRightPanelMode::None: + return ScenarioAuthoringWidget::RightPanelMode::None; + case SavedRightPanelMode::Run: + return ScenarioAuthoringWidget::RightPanelMode::Run; + case SavedRightPanelMode::Scenario: + default: + return ScenarioAuthoringWidget::RightPanelMode::Scenario; + } +} + +ScenarioAuthoringWidget::ScenarioState scenarioStateFromSaved( + const SavedScenarioState& saved, + const safecrowd::domain::FacilityLayout2D& layout) { + ScenarioAuthoringWidget::ScenarioState state; + state.draft = saved.draft; + state.events = saved.draft.control.events; + state.baseScenarioId = QString::fromStdString(saved.baseScenarioId); + state.stagedForRun = saved.stagedForRun; + + if (const auto* startZone = firstStartZone(layout); startZone != nullptr) { + state.startText = zoneLabel(*startZone); + } + if (const auto* destinationZone = firstDestinationZone(layout); destinationZone != nullptr) { + state.destinationText = zoneLabel(*destinationZone); + } + + for (const auto& placement : saved.draft.population.initialPlacements) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(placement.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = (placement.targetAgentCount <= 1 && placement.area.outline.size() <= 1) + ? ScenarioCrowdPlacementKind::Individual + : ScenarioCrowdPlacementKind::Group; + uiPlacement.zoneId = QString::fromStdString(placement.zoneId); + uiPlacement.floorId = QString::fromStdString(placement.floorId); + uiPlacement.area = placement.area.outline; + uiPlacement.occupantCount = static_cast(placement.targetAgentCount); + uiPlacement.velocity = placement.initialVelocity; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } + + return state; +} + +ScenarioAuthoringWidget::InitialState initialStateFromSaved( + const SavedScenarioAuthoringState& saved, + const safecrowd::domain::FacilityLayout2D& layout) { + ScenarioAuthoringWidget::InitialState initial; + initial.currentScenarioIndex = saved.currentScenarioIndex; + initial.navigationView = navigationViewFromSaved(saved.navigationView); + initial.rightPanelMode = rightPanelModeFromSaved(saved.rightPanelMode); + initial.scenarios.reserve(saved.scenarios.size()); + for (const auto& scenario : saved.scenarios) { + initial.scenarios.push_back(scenarioStateFromSaved(scenario, layout)); + } + if (initial.currentScenarioIndex < 0 || initial.currentScenarioIndex >= static_cast(initial.scenarios.size())) { + initial.currentScenarioIndex = initial.scenarios.empty() ? -1 : 0; + } + return initial; +} + +template +Widget* visibleChild(QWidget* root) { + if (root == nullptr) { + return nullptr; + } + + Widget* match = nullptr; + if (auto* widget = dynamic_cast(root); widget != nullptr && widget->isVisible()) { + match = widget; + } + const auto children = root->findChildren(); + for (auto* child : children) { + if (auto* widget = dynamic_cast(child); widget != nullptr && widget->isVisible()) { + match = widget; + } + } + return match; +} + } // namespace MainWindow::MainWindow(safecrowd::domain::SafeCrowdDomain& domain, QWidget* parent) @@ -127,7 +269,53 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { return; } - showLayoutReview(metadata); + currentProject_ = metadata; + hasCurrentProject_ = true; + + auto importResult = importProjectLayout(metadata); + applySavedReviewState(metadata, &importResult); + if (!importResult.layout.has_value()) { + showLayoutReview(metadata, std::move(importResult)); + return; + } + + ProjectWorkspaceState workspace; + if (!ProjectPersistence::loadProjectWorkspace(metadata, &workspace)) { + showLayoutReview(metadata, std::move(importResult)); + return; + } + + lastApprovedImportResult_ = importResult; + switch (workspace.activeView) { + case ProjectWorkspaceView::ScenarioAuthoring: + if (workspace.authoring.has_value()) { + showScenarioAuthoring(importResult, initialStateFromSaved(*workspace.authoring, *importResult.layout)); + return; + } + break; + case ProjectWorkspaceView::ScenarioRun: + if (workspace.runningScenario.has_value()) { + showScenarioRun(*importResult.layout, *workspace.runningScenario); + return; + } + break; + case ProjectWorkspaceView::ScenarioResult: + if (workspace.result.has_value()) { + showScenarioResult( + *importResult.layout, + workspace.result->scenario, + workspace.result->frame, + workspace.result->risk, + workspace.result->artifacts); + return; + } + break; + case ProjectWorkspaceView::LayoutReview: + default: + break; + } + + showLayoutReview(metadata, std::move(importResult)); } void MainWindow::saveCurrentProject() { @@ -147,11 +335,40 @@ void MainWindow::saveCurrentProject() { return; } - if (auto* reviewWidget = dynamic_cast(centralWidget())) { + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::LayoutReview; + + if (auto* reviewWidget = visibleChild(centralWidget())) { if (!ProjectPersistence::saveProjectReview(currentProject_, reviewWidget->currentImportResult(), &errorMessage)) { QMessageBox::warning(this, "Save Project", errorMessage); return; } + } else if (lastApprovedImportResult_.has_value()) { + if (!ProjectPersistence::saveProjectReview(currentProject_, *lastApprovedImportResult_, &errorMessage)) { + QMessageBox::warning(this, "Save Project", errorMessage); + return; + } + } + + if (auto* authoringWidget = visibleChild(centralWidget())) { + workspace.activeView = ProjectWorkspaceView::ScenarioAuthoring; + workspace.authoring = authoringWidget->currentSavedState(); + } else if (auto* resultWidget = visibleChild(centralWidget())) { + workspace.activeView = ProjectWorkspaceView::ScenarioResult; + workspace.result = SavedScenarioResultState{ + .scenario = resultWidget->scenario(), + .frame = resultWidget->frame(), + .risk = resultWidget->risk(), + .artifacts = resultWidget->artifacts(), + }; + } else if (auto* runWidget = visibleChild(centralWidget())) { + workspace.activeView = ProjectWorkspaceView::ScenarioRun; + workspace.runningScenario = runWidget->scenario(); + } + + if (!ProjectPersistence::saveProjectWorkspace(currentProject_, workspace, &errorMessage)) { + QMessageBox::warning(this, "Save Project", errorMessage); + return; } currentProject_ = ProjectPersistence::loadProject(currentProject_.folderPath); @@ -163,19 +380,7 @@ void MainWindow::showLayoutReview(const ProjectMetadata& metadata) { hasCurrentProject_ = true; lastApprovedImportResult_.reset(); - auto importResult = metadata.isBuiltInDemo() - ? makeDemoImportResult() - : [&metadata]() { - safecrowd::domain::DxfImportService importer; - const safecrowd::domain::ImportRequest importRequest{ - .sourcePath = std::filesystem::path(metadata.layoutPath.toStdWString()), - .requestedFormat = safecrowd::domain::ImportedFileFormat::Dxf, - .preserveRawModel = true, - .runValidation = true, - }; - return importer.importFile(importRequest); - }(); - + auto importResult = importProjectLayout(metadata); applySavedReviewState(metadata, &importResult); showLayoutReview(metadata, std::move(importResult)); @@ -232,4 +437,92 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im this)); } +void MainWindow::showScenarioAuthoring( + const safecrowd::domain::ImportResult& importResult, + ScenarioAuthoringWidget::InitialState initialState) { + if (!importResult.layout.has_value()) { + QMessageBox::warning(this, "Scenario Authoring", "An approved layout is required before creating a scenario."); + return; + } + + lastApprovedImportResult_ = importResult; + + setCentralWidget(new ScenarioAuthoringWidget( + currentProject_.name, + *importResult.layout, + std::move(initialState), + [this]() { + saveCurrentProject(); + }, + [this]() { + hasCurrentProject_ = false; + currentProject_ = {}; + showProjectNavigator(); + }, + [this]() { + if (lastApprovedImportResult_.has_value()) { + showLayoutReview(currentProject_, *lastApprovedImportResult_); + } else { + showLayoutReview(currentProject_); + } + }, + this)); +} + +void MainWindow::showScenarioRun( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::ScenarioDraft& scenario) { + setCentralWidget(new ScenarioRunWidget( + currentProject_.name, + layout, + scenario, + [this]() { + saveCurrentProject(); + }, + [this]() { + hasCurrentProject_ = false; + currentProject_ = {}; + showProjectNavigator(); + }, + [this]() { + if (lastApprovedImportResult_.has_value()) { + showLayoutReview(currentProject_, *lastApprovedImportResult_); + } else { + showLayoutReview(currentProject_); + } + }, + this)); +} + +void MainWindow::showScenarioResult( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::ScenarioDraft& scenario, + const safecrowd::domain::SimulationFrame& frame, + const safecrowd::domain::ScenarioRiskSnapshot& risk, + const safecrowd::domain::ScenarioResultArtifacts& artifacts) { + setCentralWidget(new ScenarioResultWidget( + currentProject_.name, + layout, + scenario, + frame, + risk, + artifacts, + [this]() { + saveCurrentProject(); + }, + [this]() { + hasCurrentProject_ = false; + currentProject_ = {}; + showProjectNavigator(); + }, + [this]() { + if (lastApprovedImportResult_.has_value()) { + showLayoutReview(currentProject_, *lastApprovedImportResult_); + } else { + showLayoutReview(currentProject_); + } + }, + this)); +} + } // namespace safecrowd::application diff --git a/src/application/MainWindow.h b/src/application/MainWindow.h index f35d16b..68b8dd5 100644 --- a/src/application/MainWindow.h +++ b/src/application/MainWindow.h @@ -5,7 +5,11 @@ #include #include "application/ProjectMetadata.h" +#include "application/ScenarioAuthoringWidget.h" #include "domain/ImportResult.h" +#include "domain/ScenarioResultArtifacts.h" +#include "domain/ScenarioRiskMetrics.h" +#include "domain/ScenarioSimulationFrame.h" namespace safecrowd::domain { class SafeCrowdDomain; @@ -30,6 +34,18 @@ class MainWindow : public QMainWindow { void showLayoutReview(const ProjectMetadata& metadata); void showLayoutReview(const ProjectMetadata& metadata, safecrowd::domain::ImportResult importResult); void showScenarioAuthoring(const safecrowd::domain::ImportResult& importResult); + void showScenarioAuthoring( + const safecrowd::domain::ImportResult& importResult, + ScenarioAuthoringWidget::InitialState initialState); + void showScenarioRun( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::ScenarioDraft& scenario); + void showScenarioResult( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::ScenarioDraft& scenario, + const safecrowd::domain::SimulationFrame& frame, + const safecrowd::domain::ScenarioRiskSnapshot& risk, + const safecrowd::domain::ScenarioResultArtifacts& artifacts); safecrowd::domain::SafeCrowdDomain& domain_; ProjectMetadata currentProject_{}; diff --git a/src/application/NavigationTreeWidget.cpp b/src/application/NavigationTreeWidget.cpp index 191f219..8b63569 100644 --- a/src/application/NavigationTreeWidget.cpp +++ b/src/application/NavigationTreeWidget.cpp @@ -4,9 +4,12 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -31,6 +34,32 @@ class NavigationTreeView final : public QTreeWidget { using QTreeWidget::QTreeWidget; protected: + void mousePressEvent(QMouseEvent* event) override { + if (event != nullptr && event->button() == Qt::LeftButton) { + if (auto* item = toggleAreaItem(event->position()); item != nullptr) { + suppressToggleRelease_ = true; + item->setExpanded(!item->isExpanded()); + event->accept(); + return; + } + } + + suppressToggleRelease_ = false; + QTreeWidget::mousePressEvent(event); + } + + void mouseReleaseEvent(QMouseEvent* event) override { + if (suppressToggleRelease_) { + suppressToggleRelease_ = false; + if (event != nullptr) { + event->accept(); + } + return; + } + + QTreeWidget::mouseReleaseEvent(event); + } + void drawBranches(QPainter* painter, const QRect& rect, const QModelIndex& index) const override { if (!model()->hasChildren(index)) { return; @@ -55,6 +84,25 @@ class NavigationTreeView final : public QTreeWidget { painter->drawPolygon(arrow); painter->restore(); } + +private: + QTreeWidgetItem* toggleAreaItem(const QPointF& position) const { + auto* item = itemAt(position.toPoint()); + if (item == nullptr || item->childCount() <= 0) { + return nullptr; + } + + int depth = 0; + for (auto* parentItem = item->parent(); parentItem != nullptr; parentItem = parentItem->parent()) { + ++depth; + } + + constexpr int kExtraToggleWidth = 24; + const int toggleWidth = ((depth + 1) * indentation()) + kExtraToggleWidth; + return position.x() <= toggleWidth ? item : nullptr; + } + + bool suppressToggleRelease_{false}; }; class NavigationTreeDelegate final : public QStyledItemDelegate { @@ -157,7 +205,38 @@ QString navigationTreeStyleSheet(bool interactive) { ).arg(itemHover, itemSelected); } -QTreeWidgetItem* addTreeNode(QTreeWidgetItem* parentItem, const NavigationTreeNode& node) { +void collectExpandedIds(const QTreeWidgetItem* item, QSet& expandedIds) { + if (item == nullptr) { + return; + } + + const auto id = item->data(0, kIdRole).toString(); + if (item->isExpanded() && !id.isEmpty()) { + expandedIds.insert(id); + } + for (int index = 0; index < item->childCount(); ++index) { + collectExpandedIds(item->child(index), expandedIds); + } +} + +QSet collectExpandedIds(const QTreeWidget* tree) { + QSet expandedIds; + if (tree == nullptr) { + return expandedIds; + } + + const auto* root = tree->invisibleRootItem(); + for (int index = 0; index < root->childCount(); ++index) { + collectExpandedIds(root->child(index), expandedIds); + } + return expandedIds; +} + +QTreeWidgetItem* addTreeNode( + QTreeWidgetItem* parentItem, + const NavigationTreeNode& node, + const NavigationTreeState& state, + QTreeWidgetItem** selectedItem) { auto* item = new QTreeWidgetItem(parentItem); item->setText(0, node.label); item->setToolTip(0, node.detail.isEmpty() ? node.label : node.detail); @@ -169,9 +248,15 @@ QTreeWidgetItem* addTreeNode(QTreeWidgetItem* parentItem, const NavigationTreeNo } for (const auto& child : node.children) { - addTreeNode(item, child); + addTreeNode(item, child, state, selectedItem); + } + const bool expanded = state.restoreExpandedState && !node.id.isEmpty() + ? state.expandedNodeIds.contains(node.id) + : node.expanded; + item->setExpanded(expanded); + if (selectedItem != nullptr && *selectedItem == nullptr && !state.selectedId.isEmpty() && node.id == state.selectedId) { + *selectedItem = item; } - item->setExpanded(node.expanded); return item; } @@ -184,7 +269,9 @@ NavigationTreeWidget::NavigationTreeWidget( const QString& emptyText, std::function activateItemHandler, QWidget* parent, - QWidget* headerWidget) + QWidget* headerWidget, + NavigationTreeState state, + std::function&)> expandedStateChangedHandler) : QWidget(parent) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -217,12 +304,22 @@ NavigationTreeWidget::NavigationTreeWidget( tree->setItemDelegate(new NavigationTreeDelegate(tree)); tree->setStyleSheet(navigationTreeStyleSheet(interactive)); + QTreeWidgetItem* selectedItem = nullptr; for (const auto& node : nodes) { - addTreeNode(tree->invisibleRootItem(), node); + addTreeNode(tree->invisibleRootItem(), node, state, &selectedItem); + } + if (selectedItem != nullptr) { + auto* ancestor = selectedItem->parent(); + while (ancestor != nullptr) { + ancestor->setExpanded(true); + ancestor = ancestor->parent(); + } + tree->setCurrentItem(selectedItem); + selectedItem->setSelected(true); } if (activateItemHandler) { - QObject::connect(tree, &QTreeWidget::itemClicked, tree, [activateItemHandler](QTreeWidgetItem* item, int) { + QObject::connect(tree, &QTreeWidget::itemClicked, tree, [activateItemHandler, tree](QTreeWidgetItem* item, int) { if (item == nullptr) { return; } @@ -230,7 +327,9 @@ NavigationTreeWidget::NavigationTreeWidget( const auto selectable = item->data(0, kSelectableRole).toBool(); const auto id = item->data(0, kIdRole).toString(); if (selectable && !id.isEmpty()) { - activateItemHandler(id); + QTimer::singleShot(0, tree, [activateItemHandler, id]() { + activateItemHandler(id); + }); } }); } else { @@ -240,6 +339,18 @@ NavigationTreeWidget::NavigationTreeWidget( }); } + if (expandedStateChangedHandler) { + const auto notifyExpandedStateChanged = [tree, expandedStateChangedHandler]() { + expandedStateChangedHandler(collectExpandedIds(tree)); + }; + QObject::connect(tree, &QTreeWidget::itemExpanded, tree, [notifyExpandedStateChanged](QTreeWidgetItem*) { + notifyExpandedStateChanged(); + }); + QObject::connect(tree, &QTreeWidget::itemCollapsed, tree, [notifyExpandedStateChanged](QTreeWidgetItem*) { + notifyExpandedStateChanged(); + }); + } + layout->addWidget(tree, 1); } diff --git a/src/application/NavigationTreeWidget.h b/src/application/NavigationTreeWidget.h index 93c5e6f..624214a 100644 --- a/src/application/NavigationTreeWidget.h +++ b/src/application/NavigationTreeWidget.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -17,6 +18,12 @@ struct NavigationTreeNode { bool selectable{true}; }; +struct NavigationTreeState { + QSet expandedNodeIds{}; + QString selectedId{}; + bool restoreExpandedState{false}; +}; + class NavigationTreeWidget : public QWidget { public: explicit NavigationTreeWidget( @@ -25,7 +32,9 @@ class NavigationTreeWidget : public QWidget { const QString& emptyText, std::function activateItemHandler = {}, QWidget* parent = nullptr, - QWidget* headerWidget = nullptr); + QWidget* headerWidget = nullptr, + NavigationTreeState state = {}, + std::function&)> expandedStateChangedHandler = {}); }; } // namespace safecrowd::application diff --git a/src/application/ProjectMetadata.h b/src/application/ProjectMetadata.h index 7a4e4fb..ed1218b 100644 --- a/src/application/ProjectMetadata.h +++ b/src/application/ProjectMetadata.h @@ -4,10 +4,18 @@ namespace safecrowd::application { -inline QString builtInDemoLayoutPath() { +inline QString builtInDemoLayoutPrefix() { + return QStringLiteral("safecrowd://demo/"); +} + +inline QString sprint1DemoLayoutPath() { return QStringLiteral("safecrowd://demo/sprint1-facility"); } +inline QString twoFloorDemoLayoutPath() { + return QStringLiteral("safecrowd://demo/2f-demo"); +} + struct ProjectMetadata { QString name{}; QString folderPath{}; @@ -15,7 +23,7 @@ struct ProjectMetadata { QString savedAt{}; bool isBuiltInDemo() const noexcept { - return layoutPath == builtInDemoLayoutPath(); + return layoutPath.startsWith(builtInDemoLayoutPrefix()); } bool isValid() const noexcept { @@ -26,11 +34,4 @@ struct ProjectMetadata { } }; -inline ProjectMetadata makeBuiltInDemoProject() { - return { - .name = QStringLiteral("Demo"), - .layoutPath = builtInDemoLayoutPath(), - }; -} - } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 5f6cfcb..414ede2 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -19,11 +19,13 @@ namespace { constexpr auto kProjectFileName = "safecrowd-project.json"; constexpr auto kLayoutFileName = "layout.dxf"; constexpr auto kReviewFileName = "layout-review.json"; +constexpr auto kWorkspaceFileName = "workspace-state.json"; bool isProjectManagedEntry(const QString& fileName) { return fileName.compare(kProjectFileName, Qt::CaseInsensitive) == 0 || fileName.compare(kLayoutFileName, Qt::CaseInsensitive) == 0 - || fileName.compare(kReviewFileName, Qt::CaseInsensitive) == 0; + || fileName.compare(kReviewFileName, Qt::CaseInsensitive) == 0 + || fileName.compare(kWorkspaceFileName, Qt::CaseInsensitive) == 0; } QString projectFilePath(const QString& folderPath) { @@ -34,6 +36,10 @@ QString reviewFilePath(const QString& folderPath) { return QDir(folderPath).filePath(kReviewFileName); } +QString workspaceFilePath(const QString& folderPath) { + return QDir(folderPath).filePath(kWorkspaceFileName); +} + QString recentProjectsPath() { const auto appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(appData); @@ -549,11 +555,457 @@ safecrowd::domain::FacilityLayout2D layoutFromJson(const QJsonObject& object) { return layout; } +QJsonObject initialPlacementToJson(const safecrowd::domain::InitialPlacement2D& placement) { + QJsonObject object; + object["id"] = QString::fromStdString(placement.id); + object["zoneId"] = QString::fromStdString(placement.zoneId); + object["floorId"] = QString::fromStdString(placement.floorId); + object["area"] = polygonToJson(placement.area); + object["targetAgentCount"] = static_cast(placement.targetAgentCount); + object["initialVelocity"] = pointArray(placement.initialVelocity); + return object; +} + +safecrowd::domain::InitialPlacement2D initialPlacementFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString().toStdString(), + .zoneId = object.value("zoneId").toString().toStdString(), + .floorId = object.value("floorId").toString().toStdString(), + .area = polygonFromJson(object.value("area").toObject()), + .targetAgentCount = static_cast(object.value("targetAgentCount").toInteger()), + .initialVelocity = pointFromJson(object.value("initialVelocity")), + }; +} + +QJsonObject populationToJson(const safecrowd::domain::PopulationSpec& population) { + QJsonObject object; + QJsonArray placements; + for (const auto& placement : population.initialPlacements) { + placements.append(initialPlacementToJson(placement)); + } + object["initialPlacements"] = placements; + return object; +} + +safecrowd::domain::PopulationSpec populationFromJson(const QJsonObject& object) { + safecrowd::domain::PopulationSpec population; + for (const auto& value : object.value("initialPlacements").toArray()) { + population.initialPlacements.push_back(initialPlacementFromJson(value.toObject())); + } + return population; +} + +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); + return object; +} + +safecrowd::domain::EnvironmentState environmentFromJson(const QJsonObject& object) { + return { + .reducedVisibility = object.value("reducedVisibility").toBool(false), + .familiarityProfile = object.value("familiarityProfile").toString().toStdString(), + .guidanceProfile = object.value("guidanceProfile").toString().toStdString(), + }; +} + +QJsonObject eventToJson(const safecrowd::domain::OperationalEventDraft& event) { + QJsonObject object; + object["id"] = QString::fromStdString(event.id); + object["name"] = QString::fromStdString(event.name); + object["triggerSummary"] = QString::fromStdString(event.triggerSummary); + object["targetSummary"] = QString::fromStdString(event.targetSummary); + return object; +} + +safecrowd::domain::OperationalEventDraft eventFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString().toStdString(), + .name = object.value("name").toString().toStdString(), + .triggerSummary = object.value("triggerSummary").toString().toStdString(), + .targetSummary = object.value("targetSummary").toString().toStdString(), + }; +} + +QJsonObject connectionBlockIntervalToJson(const safecrowd::domain::ConnectionBlockIntervalDraft& interval) { + QJsonObject object; + object["startSeconds"] = interval.startSeconds; + object["endSeconds"] = interval.endSeconds; + return object; +} + +safecrowd::domain::ConnectionBlockIntervalDraft connectionBlockIntervalFromJson(const QJsonObject& object) { + return { + .startSeconds = object.value("startSeconds").toDouble(), + .endSeconds = object.value("endSeconds").toDouble(), + }; +} + +QJsonObject connectionBlockToJson(const safecrowd::domain::ConnectionBlockDraft& block) { + QJsonObject object; + object["id"] = QString::fromStdString(block.id); + object["connectionId"] = QString::fromStdString(block.connectionId); + QJsonArray intervals; + for (const auto& interval : block.intervals) { + intervals.append(connectionBlockIntervalToJson(interval)); + } + object["intervals"] = intervals; + return object; +} + +safecrowd::domain::ConnectionBlockDraft connectionBlockFromJson(const QJsonObject& object) { + safecrowd::domain::ConnectionBlockDraft block; + block.id = object.value("id").toString().toStdString(); + block.connectionId = object.value("connectionId").toString().toStdString(); + for (const auto& value : object.value("intervals").toArray()) { + block.intervals.push_back(connectionBlockIntervalFromJson(value.toObject())); + } + return block; +} + +QJsonObject controlPlanToJson(const safecrowd::domain::ControlPlan& control) { + QJsonObject object; + QJsonArray events; + for (const auto& event : control.events) { + events.append(eventToJson(event)); + } + object["events"] = events; + + QJsonArray connectionBlocks; + for (const auto& block : control.connectionBlocks) { + connectionBlocks.append(connectionBlockToJson(block)); + } + object["connectionBlocks"] = connectionBlocks; + return object; +} + +safecrowd::domain::ControlPlan controlPlanFromJson(const QJsonObject& object) { + safecrowd::domain::ControlPlan control; + for (const auto& value : object.value("events").toArray()) { + control.events.push_back(eventFromJson(value.toObject())); + } + for (const auto& value : object.value("connectionBlocks").toArray()) { + control.connectionBlocks.push_back(connectionBlockFromJson(value.toObject())); + } + return control; +} + +QJsonObject executionToJson(const safecrowd::domain::ExecutionConfig& execution) { + QJsonObject object; + object["timeLimitSeconds"] = execution.timeLimitSeconds; + object["sampleIntervalSeconds"] = execution.sampleIntervalSeconds; + object["repeatCount"] = static_cast(execution.repeatCount); + object["baseSeed"] = static_cast(execution.baseSeed); + object["recordOccupantHistory"] = execution.recordOccupantHistory; + return object; +} + +safecrowd::domain::ExecutionConfig executionFromJson(const QJsonObject& object) { + return { + .timeLimitSeconds = object.value("timeLimitSeconds").toDouble(), + .sampleIntervalSeconds = object.value("sampleIntervalSeconds").toDouble(), + .repeatCount = static_cast(object.value("repeatCount").toInt(1)), + .baseSeed = static_cast(object.value("baseSeed").toInt()), + .recordOccupantHistory = object.value("recordOccupantHistory").toBool(false), + }; +} + +QJsonObject scenarioDraftToJson(const safecrowd::domain::ScenarioDraft& scenario) { + QJsonObject object; + object["scenarioId"] = QString::fromStdString(scenario.scenarioId); + object["name"] = QString::fromStdString(scenario.name); + object["role"] = static_cast(scenario.role); + object["population"] = populationToJson(scenario.population); + object["environment"] = environmentToJson(scenario.environment); + object["control"] = controlPlanToJson(scenario.control); + object["execution"] = executionToJson(scenario.execution); + object["sourceTemplateId"] = QString::fromStdString(scenario.sourceTemplateId); + object["variationDiffKeys"] = stringArray(scenario.variationDiffKeys); + object["blockingIssues"] = stringArray(scenario.blockingIssues); + return object; +} + +safecrowd::domain::ScenarioDraft scenarioDraftFromJson(const QJsonObject& object) { + return { + .scenarioId = object.value("scenarioId").toString().toStdString(), + .name = object.value("name").toString().toStdString(), + .role = static_cast(object.value("role").toInt()), + .population = populationFromJson(object.value("population").toObject()), + .environment = environmentFromJson(object.value("environment").toObject()), + .control = controlPlanFromJson(object.value("control").toObject()), + .execution = executionFromJson(object.value("execution").toObject()), + .sourceTemplateId = object.value("sourceTemplateId").toString().toStdString(), + .variationDiffKeys = stringVectorFromJson(object.value("variationDiffKeys").toArray()), + .blockingIssues = stringVectorFromJson(object.value("blockingIssues").toArray()), + }; +} + +QJsonValue optionalDoubleToJson(const std::optional& value) { + return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null); +} + +std::optional optionalDoubleFromJson(const QJsonValue& value) { + if (value.isNull() || value.isUndefined()) { + return std::nullopt; + } + return value.toDouble(); +} + +QJsonObject simulationAgentFrameToJson(const safecrowd::domain::SimulationAgentFrame& agent) { + QJsonObject object; + object["id"] = QString::number(static_cast(agent.id)); + object["position"] = pointArray(agent.position); + object["velocity"] = pointArray(agent.velocity); + object["radius"] = agent.radius; + object["floorId"] = QString::fromStdString(agent.floorId); + return object; +} + +safecrowd::domain::SimulationAgentFrame simulationAgentFrameFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString().toULongLong(), + .position = pointFromJson(object.value("position")), + .velocity = pointFromJson(object.value("velocity")), + .radius = object.value("radius").toDouble(0.25), + .floorId = object.value("floorId").toString().toStdString(), + }; +} + +QJsonObject simulationFrameToJson(const safecrowd::domain::SimulationFrame& frame) { + QJsonObject object; + object["elapsedSeconds"] = frame.elapsedSeconds; + object["complete"] = frame.complete; + object["totalAgentCount"] = static_cast(frame.totalAgentCount); + object["evacuatedAgentCount"] = static_cast(frame.evacuatedAgentCount); + QJsonArray agents; + for (const auto& agent : frame.agents) { + agents.append(simulationAgentFrameToJson(agent)); + } + object["agents"] = agents; + return object; +} + +safecrowd::domain::SimulationFrame simulationFrameFromJson(const QJsonObject& object) { + safecrowd::domain::SimulationFrame frame; + frame.elapsedSeconds = object.value("elapsedSeconds").toDouble(); + frame.complete = object.value("complete").toBool(false); + frame.totalAgentCount = static_cast(object.value("totalAgentCount").toInteger()); + frame.evacuatedAgentCount = static_cast(object.value("evacuatedAgentCount").toInteger()); + for (const auto& value : object.value("agents").toArray()) { + frame.agents.push_back(simulationAgentFrameFromJson(value.toObject())); + } + return frame; +} + +QJsonObject hotspotToJson(const safecrowd::domain::ScenarioCongestionHotspot& hotspot) { + QJsonObject object; + object["center"] = pointArray(hotspot.center); + object["cellMin"] = pointArray(hotspot.cellMin); + object["cellMax"] = pointArray(hotspot.cellMax); + object["agentCount"] = static_cast(hotspot.agentCount); + return object; +} + +safecrowd::domain::ScenarioCongestionHotspot hotspotFromJson(const QJsonObject& object) { + return { + .center = pointFromJson(object.value("center")), + .cellMin = pointFromJson(object.value("cellMin")), + .cellMax = pointFromJson(object.value("cellMax")), + .agentCount = static_cast(object.value("agentCount").toInteger()), + }; +} + +QJsonObject bottleneckToJson(const safecrowd::domain::ScenarioBottleneckMetric& bottleneck) { + QJsonObject object; + object["connectionId"] = QString::fromStdString(bottleneck.connectionId); + object["label"] = QString::fromStdString(bottleneck.label); + object["passage"] = lineToJson(bottleneck.passage); + object["nearbyAgentCount"] = static_cast(bottleneck.nearbyAgentCount); + object["stalledAgentCount"] = static_cast(bottleneck.stalledAgentCount); + object["averageSpeed"] = bottleneck.averageSpeed; + return object; +} + +safecrowd::domain::ScenarioBottleneckMetric bottleneckFromJson(const QJsonObject& object) { + return { + .connectionId = object.value("connectionId").toString().toStdString(), + .label = object.value("label").toString().toStdString(), + .passage = lineFromJson(object.value("passage").toObject()), + .nearbyAgentCount = static_cast(object.value("nearbyAgentCount").toInteger()), + .stalledAgentCount = static_cast(object.value("stalledAgentCount").toInteger()), + .averageSpeed = object.value("averageSpeed").toDouble(), + }; +} + +QJsonObject riskSnapshotToJson(const safecrowd::domain::ScenarioRiskSnapshot& risk) { + QJsonObject object; + object["completionRisk"] = static_cast(risk.completionRisk); + object["stalledAgentCount"] = static_cast(risk.stalledAgentCount); + QJsonArray hotspots; + for (const auto& hotspot : risk.hotspots) { + hotspots.append(hotspotToJson(hotspot)); + } + object["hotspots"] = hotspots; + QJsonArray bottlenecks; + for (const auto& bottleneck : risk.bottlenecks) { + bottlenecks.append(bottleneckToJson(bottleneck)); + } + object["bottlenecks"] = bottlenecks; + return object; +} + +safecrowd::domain::ScenarioRiskSnapshot riskSnapshotFromJson(const QJsonObject& object) { + safecrowd::domain::ScenarioRiskSnapshot risk; + risk.completionRisk = static_cast(object.value("completionRisk").toInt()); + risk.stalledAgentCount = static_cast(object.value("stalledAgentCount").toInteger()); + for (const auto& value : object.value("hotspots").toArray()) { + risk.hotspots.push_back(hotspotFromJson(value.toObject())); + } + for (const auto& value : object.value("bottlenecks").toArray()) { + risk.bottlenecks.push_back(bottleneckFromJson(value.toObject())); + } + return risk; +} + +QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifacts& artifacts) { + QJsonObject object; + QJsonArray progress; + for (const auto& sample : artifacts.evacuationProgress) { + QJsonObject sampleObject; + sampleObject["timeSeconds"] = sample.timeSeconds; + sampleObject["evacuatedCount"] = static_cast(sample.evacuatedCount); + sampleObject["totalCount"] = static_cast(sample.totalCount); + sampleObject["evacuatedRatio"] = sample.evacuatedRatio; + progress.append(sampleObject); + } + object["evacuationProgress"] = progress; + + QJsonObject timing; + timing["t50Seconds"] = optionalDoubleToJson(artifacts.timingSummary.t50Seconds); + timing["t90Seconds"] = optionalDoubleToJson(artifacts.timingSummary.t90Seconds); + timing["t95Seconds"] = optionalDoubleToJson(artifacts.timingSummary.t95Seconds); + timing["finalEvacuationTimeSeconds"] = optionalDoubleToJson(artifacts.timingSummary.finalEvacuationTimeSeconds); + object["timingSummary"] = timing; + return object; +} + +safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonObject& object) { + safecrowd::domain::ScenarioResultArtifacts artifacts; + for (const auto& value : object.value("evacuationProgress").toArray()) { + const auto sampleObject = value.toObject(); + artifacts.evacuationProgress.push_back({ + .timeSeconds = sampleObject.value("timeSeconds").toDouble(), + .evacuatedCount = static_cast(sampleObject.value("evacuatedCount").toInteger()), + .totalCount = static_cast(sampleObject.value("totalCount").toInteger()), + .evacuatedRatio = sampleObject.value("evacuatedRatio").toDouble(), + }); + } + + const auto timing = object.value("timingSummary").toObject(); + artifacts.timingSummary.t50Seconds = optionalDoubleFromJson(timing.value("t50Seconds")); + artifacts.timingSummary.t90Seconds = optionalDoubleFromJson(timing.value("t90Seconds")); + artifacts.timingSummary.t95Seconds = optionalDoubleFromJson(timing.value("t95Seconds")); + artifacts.timingSummary.finalEvacuationTimeSeconds = optionalDoubleFromJson(timing.value("finalEvacuationTimeSeconds")); + return artifacts; +} + +QJsonObject savedScenarioStateToJson(const SavedScenarioState& scenario) { + QJsonObject object; + object["draft"] = scenarioDraftToJson(scenario.draft); + object["baseScenarioId"] = QString::fromStdString(scenario.baseScenarioId); + object["stagedForRun"] = scenario.stagedForRun; + return object; +} + +SavedScenarioState savedScenarioStateFromJson(const QJsonObject& object) { + return { + .draft = scenarioDraftFromJson(object.value("draft").toObject()), + .baseScenarioId = object.value("baseScenarioId").toString().toStdString(), + .stagedForRun = object.value("stagedForRun").toBool(false), + }; +} + +QJsonObject authoringStateToJson(const SavedScenarioAuthoringState& authoring) { + QJsonObject object; + QJsonArray scenarios; + for (const auto& scenario : authoring.scenarios) { + scenarios.append(savedScenarioStateToJson(scenario)); + } + object["scenarios"] = scenarios; + object["currentScenarioIndex"] = authoring.currentScenarioIndex; + object["navigationView"] = static_cast(authoring.navigationView); + object["rightPanelMode"] = static_cast(authoring.rightPanelMode); + return object; +} + +SavedScenarioAuthoringState authoringStateFromJson(const QJsonObject& object) { + SavedScenarioAuthoringState authoring; + for (const auto& value : object.value("scenarios").toArray()) { + authoring.scenarios.push_back(savedScenarioStateFromJson(value.toObject())); + } + authoring.currentScenarioIndex = object.value("currentScenarioIndex").toInt(-1); + authoring.navigationView = static_cast(object.value("navigationView").toInt()); + authoring.rightPanelMode = static_cast(object.value("rightPanelMode").toInt(1)); + return authoring; +} + +QJsonObject resultStateToJson(const SavedScenarioResultState& result) { + QJsonObject object; + object["scenario"] = scenarioDraftToJson(result.scenario); + object["frame"] = simulationFrameToJson(result.frame); + object["risk"] = riskSnapshotToJson(result.risk); + object["artifacts"] = resultArtifactsToJson(result.artifacts); + return object; +} + +SavedScenarioResultState resultStateFromJson(const QJsonObject& object) { + return { + .scenario = scenarioDraftFromJson(object.value("scenario").toObject()), + .frame = simulationFrameFromJson(object.value("frame").toObject()), + .risk = riskSnapshotFromJson(object.value("risk").toObject()), + .artifacts = resultArtifactsFromJson(object.value("artifacts").toObject()), + }; +} + +QJsonObject workspaceStateToJson(const ProjectWorkspaceState& state) { + QJsonObject object; + object["version"] = 1; + object["activeView"] = static_cast(state.activeView); + if (state.authoring.has_value()) { + object["authoring"] = authoringStateToJson(*state.authoring); + } + if (state.runningScenario.has_value()) { + object["runningScenario"] = scenarioDraftToJson(*state.runningScenario); + } + if (state.result.has_value()) { + object["result"] = resultStateToJson(*state.result); + } + return object; +} + +ProjectWorkspaceState workspaceStateFromJson(const QJsonObject& object) { + ProjectWorkspaceState state; + state.activeView = static_cast(object.value("activeView").toInt()); + if (object.value("authoring").isObject()) { + state.authoring = authoringStateFromJson(object.value("authoring").toObject()); + } + if (object.value("runningScenario").isObject()) { + state.runningScenario = scenarioDraftFromJson(object.value("runningScenario").toObject()); + } + if (object.value("result").isObject()) { + state.result = resultStateFromJson(object.value("result").toObject()); + } + return state; +} + bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { using safecrowd::domain::ImportIssueCode; switch (code) { case ImportIssueCode::MissingExit: + case ImportIssueCode::MissingRoom: case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: case ImportIssueCode::InvalidFloorReference: @@ -589,7 +1041,14 @@ void updateLiveValidationIssues(safecrowd::domain::ImportResult* importResult) { QList ProjectPersistence::loadRecentProjects() { QList projects; - projects.push_back(makeBuiltInDemoProject()); + projects.push_back(ProjectMetadata{ + .name = QStringLiteral("Demo"), + .layoutPath = sprint1DemoLayoutPath(), + }); + projects.push_back(ProjectMetadata{ + .name = QStringLiteral("2F demo"), + .layoutPath = twoFloorDemoLayoutPath(), + }); const auto document = readJsonDocument(recentProjectsPath()); if (!document.isObject()) { @@ -691,6 +1150,20 @@ bool ProjectPersistence::loadProjectReview(const ProjectMetadata& metadata, safe return true; } +bool ProjectPersistence::loadProjectWorkspace(const ProjectMetadata& metadata, ProjectWorkspaceState* state) { + if (metadata.isBuiltInDemo() || state == nullptr) { + return false; + } + + const auto document = readJsonDocument(workspaceFilePath(metadata.folderPath)); + if (!document.isObject()) { + return false; + } + + *state = workspaceStateFromJson(document.object()); + return true; +} + bool ProjectPersistence::saveProject(ProjectMetadata metadata, QString* errorMessage) { if (metadata.isBuiltInDemo()) { if (errorMessage != nullptr) { @@ -751,4 +1224,18 @@ bool ProjectPersistence::saveProjectReview( return writeJsonDocument(reviewFilePath(metadata.folderPath), QJsonDocument(root), errorMessage); } +bool ProjectPersistence::saveProjectWorkspace( + const ProjectMetadata& metadata, + const ProjectWorkspaceState& state, + QString* errorMessage) { + if (metadata.isBuiltInDemo()) { + if (errorMessage != nullptr) { + *errorMessage = "Built-in demo projects do not need to be saved."; + } + return false; + } + + return writeJsonDocument(workspaceFilePath(metadata.folderPath), QJsonDocument(workspaceStateToJson(state)), errorMessage); +} + } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.h b/src/application/ProjectPersistence.h index de2c7d3..8518afa 100644 --- a/src/application/ProjectPersistence.h +++ b/src/application/ProjectPersistence.h @@ -3,6 +3,7 @@ #include #include "application/ProjectMetadata.h" +#include "application/ProjectWorkspaceState.h" #include "domain/ImportResult.h" namespace safecrowd::application { @@ -13,11 +14,16 @@ class ProjectPersistence { static ProjectMetadata loadProject(const QString& folderPath); static bool deleteProject(const ProjectMetadata& metadata, QString* errorMessage = nullptr); static bool loadProjectReview(const ProjectMetadata& metadata, safecrowd::domain::ImportResult* importResult); + static bool loadProjectWorkspace(const ProjectMetadata& metadata, ProjectWorkspaceState* state); static bool saveProject(ProjectMetadata metadata, QString* errorMessage = nullptr); static bool saveProjectReview( const ProjectMetadata& metadata, const safecrowd::domain::ImportResult& importResult, QString* errorMessage = nullptr); + static bool saveProjectWorkspace( + const ProjectMetadata& metadata, + const ProjectWorkspaceState& state, + QString* errorMessage = nullptr); }; } // namespace safecrowd::application diff --git a/src/application/ProjectWorkspaceState.h b/src/application/ProjectWorkspaceState.h new file mode 100644 index 0000000..7aee6d9 --- /dev/null +++ b/src/application/ProjectWorkspaceState.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "domain/ScenarioAuthoring.h" +#include "domain/ScenarioResultArtifacts.h" +#include "domain/ScenarioRiskMetrics.h" +#include "domain/ScenarioSimulationFrame.h" + +namespace safecrowd::application { + +enum class ProjectWorkspaceView { + LayoutReview, + ScenarioAuthoring, + ScenarioRun, + ScenarioResult, +}; + +enum class SavedNavigationView { + Layout, + Crowd, + Events, +}; + +enum class SavedRightPanelMode { + None, + Scenario, + Run, +}; + +struct SavedScenarioState { + safecrowd::domain::ScenarioDraft draft{}; + std::string baseScenarioId{}; + bool stagedForRun{false}; +}; + +struct SavedScenarioAuthoringState { + std::vector scenarios{}; + int currentScenarioIndex{-1}; + SavedNavigationView navigationView{SavedNavigationView::Layout}; + SavedRightPanelMode rightPanelMode{SavedRightPanelMode::Scenario}; +}; + +struct SavedScenarioResultState { + safecrowd::domain::ScenarioDraft scenario{}; + safecrowd::domain::SimulationFrame frame{}; + safecrowd::domain::ScenarioRiskSnapshot risk{}; + safecrowd::domain::ScenarioResultArtifacts artifacts{}; +}; + +struct ProjectWorkspaceState { + ProjectWorkspaceView activeView{ProjectWorkspaceView::LayoutReview}; + std::optional authoring{}; + std::optional runningScenario{}; + std::optional result{}; +}; + +} // namespace safecrowd::application diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index c4d0a8c..9473265 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -40,6 +40,50 @@ QString zoneLabel(const safecrowd::domain::Zone2D& zone) { return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); } +QString zoneName(const safecrowd::domain::FacilityLayout2D& layout, const std::string& zoneId) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + return zone.id == zoneId; + }); + if (it == layout.zones.end()) { + return QString::fromStdString(zoneId); + } + const auto label = QString::fromStdString(it->label); + return label.isEmpty() ? QString::fromStdString(it->id) : label; +} + +QString connectionLabel( + const safecrowd::domain::FacilityLayout2D& layout, + const safecrowd::domain::Connection2D& connection) { + const auto from = zoneName(layout, connection.fromZoneId); + const auto to = zoneName(layout, connection.toZoneId); + if (!from.isEmpty() && !to.isEmpty()) { + return QString("%1 -> %2").arg(from, to); + } + return QString::fromStdString(connection.id); +} + +QString connectionLabelForId(const safecrowd::domain::FacilityLayout2D& layout, const std::string& connectionId) { + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == connectionId; + }); + if (it == layout.connections.end()) { + return QString::fromStdString(connectionId); + } + return connectionLabel(layout, *it); +} + +QString blockScheduleSummary(const safecrowd::domain::ConnectionBlockDraft& block) { + if (block.intervals.empty()) { + return "Always blocked"; + } + + QStringList intervals; + for (const auto& interval : block.intervals) { + intervals << QString("%1s - %2s").arg(interval.startSeconds, 0, 'f', 1).arg(interval.endSeconds, 0, 'f', 1); + } + return intervals.join(", "); +} + const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; @@ -201,53 +245,118 @@ QWidget* createCrowdPanel( shell != nullptr ? shell->createPanelHeader("Crowd", parent, false) : nullptr); } -std::vector buildEventsTree(const ScenarioAuthoringWidget::ScenarioState* scenario) { - if (scenario == nullptr || scenario->events.empty()) { +std::vector buildEventsTree( + const safecrowd::domain::FacilityLayout2D& layout, + const ScenarioAuthoringWidget::ScenarioState* scenario) { + if (scenario == nullptr) { return {}; } - std::vector events; - for (const auto& event : scenario->events) { - const auto eventId = QString::fromStdString(event.id); - events.push_back({ - .label = QString::fromStdString(event.name), - .id = eventId, - .detail = QString::fromStdString(event.targetSummary), - .children = { - { - .label = QString("Trigger - %1").arg(QString::fromStdString(event.triggerSummary)), - .id = QString("%1/trigger").arg(eventId), + std::vector sections; + if (!scenario->events.empty()) { + std::vector events; + for (const auto& event : scenario->events) { + const auto eventId = QString::fromStdString(event.id); + events.push_back({ + .label = QString::fromStdString(event.name), + .id = eventId, + .detail = QString::fromStdString(event.targetSummary), + .children = { + { + .label = QString("Trigger - %1").arg(QString::fromStdString(event.triggerSummary)), + .id = QString("%1/trigger").arg(eventId), + }, + { + .label = QString("Target - %1").arg(QString::fromStdString(event.targetSummary)), + .id = QString("%1/target").arg(eventId), + }, }, - { - .label = QString("Target - %1").arg(QString::fromStdString(event.targetSummary)), - .id = QString("%1/target").arg(eventId), + .expanded = true, + }); + } + + sections.push_back({ + .label = QString("Operational Events (%1)").arg(static_cast(scenario->events.size())), + .children = std::move(events), + .expanded = true, + .selectable = false, + }); + } + + const auto& connectionBlocks = scenario->draft.control.connectionBlocks; + if (!connectionBlocks.empty()) { + std::vector blocks; + for (const auto& block : connectionBlocks) { + const auto blockId = QString::fromStdString(block.id); + const auto targetLabel = connectionLabelForId(layout, block.connectionId); + const auto schedule = blockScheduleSummary(block); + blocks.push_back({ + .label = QString("Blocked - %1").arg(targetLabel), + .id = blockId, + .detail = schedule, + .children = { + { + .label = QString("Target - %1").arg(targetLabel), + .id = QString("%1/target").arg(blockId), + }, + { + .label = QString("Schedule - %1").arg(schedule), + .id = QString("%1/schedule").arg(blockId), + }, }, - }, + .expanded = true, + }); + } + + sections.push_back({ + .label = QString("Blocked Doors / Exits (%1)").arg(static_cast(connectionBlocks.size())), + .children = std::move(blocks), .expanded = true, + .selectable = false, }); } - return {{ - .label = QString("Events (%1)").arg(static_cast(scenario->events.size())), - .children = std::move(events), - .expanded = true, - .selectable = false, - }}; + return sections; } QWidget* createEventsPanel( + const safecrowd::domain::FacilityLayout2D& layout, const ScenarioAuthoringWidget::ScenarioState* scenario, const WorkspaceShell* shell, QWidget* parent) { return new NavigationTreeWidget( "Events", - buildEventsTree(scenario), - "No operational events yet", + buildEventsTree(layout, scenario), + "No operational events or blocked exits yet", {}, parent, shell != nullptr ? shell->createPanelHeader("Events", parent, false) : nullptr); } +SavedNavigationView savedNavigationView(ScenarioAuthoringWidget::NavigationView view) { + switch (view) { + case ScenarioAuthoringWidget::NavigationView::Crowd: + return SavedNavigationView::Crowd; + case ScenarioAuthoringWidget::NavigationView::Events: + return SavedNavigationView::Events; + case ScenarioAuthoringWidget::NavigationView::Layout: + default: + return SavedNavigationView::Layout; + } +} + +SavedRightPanelMode savedRightPanelMode(ScenarioAuthoringWidget::RightPanelMode mode) { + switch (mode) { + case ScenarioAuthoringWidget::RightPanelMode::None: + return SavedRightPanelMode::None; + case ScenarioAuthoringWidget::RightPanelMode::Run: + return SavedRightPanelMode::Run; + case ScenarioAuthoringWidget::RightPanelMode::Scenario: + default: + return SavedRightPanelMode::Scenario; + } +} + } // namespace ScenarioAuthoringWidget::ScenarioAuthoringWidget( @@ -311,6 +420,24 @@ void ScenarioAuthoringWidget::initializeUi(bool promptForScenario) { } } +SavedScenarioAuthoringState ScenarioAuthoringWidget::currentSavedState() const { + SavedScenarioAuthoringState state; + state.currentScenarioIndex = currentScenarioIndex_; + state.navigationView = savedNavigationView(navigationView_); + state.rightPanelMode = savedRightPanelMode(rightPanelMode_); + state.scenarios.reserve(scenarios_.size()); + for (const auto& scenario : scenarios_) { + auto draft = scenario.draft; + draft.control.events = scenario.events; + state.scenarios.push_back({ + .draft = std::move(draft), + .baseScenarioId = scenario.baseScenarioId.toStdString(), + .stagedForRun = scenario.stagedForRun, + }); + } + return state; +} + void ScenarioAuthoringWidget::addEventDraft(const QString& name, const QString& trigger, const QString& target) { auto* scenario = currentScenario(); if (scenario == nullptr) { @@ -406,6 +533,12 @@ void ScenarioAuthoringWidget::refreshCanvas() { canvas_->setPlacementsChangedHandler([this](const std::vector& placements) { updateCurrentScenarioPlacements(placements); }); + canvas_->setLayoutElementActivatedHandler([this](const QString& elementId) { + selectedLayoutElementId_ = elementId; + if (navigationView_ == NavigationView::Layout) { + refreshNavigationPanel(); + } + }); canvas_->setConnectionBlocks(scenario->draft.control.connectionBlocks); canvas_->setConnectionBlocksChangedHandler([this](const std::vector& blocks) { auto* current = currentScenario(); @@ -413,11 +546,15 @@ void ScenarioAuthoringWidget::refreshCanvas() { return; } current->draft.control.connectionBlocks = blocks; + refreshNavigationPanel(); refreshInspector(); if (rightPanelMode_ == RightPanelMode::Run) { refreshRightPanel(); } }); + if (!selectedLayoutElementId_.isEmpty()) { + canvas_->focusLayoutElement(selectedLayoutElementId_); + } shell_->setCanvas(canvas_); } @@ -433,13 +570,15 @@ void ScenarioAuthoringWidget::refreshInspector() { for (const auto& placement : scenario->crowdPlacements) { people += placement.occupantCount; } - scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6") + const auto blockCount = static_cast(scenario->draft.control.connectionBlocks.size()); + scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6\nBlocked exits: %7") .arg( QString::fromStdString(scenario->draft.name), scenario->draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative") .arg(people) .arg(scenario->startText, scenario->destinationText) - .arg(static_cast(scenario->events.size()))); + .arg(static_cast(scenario->events.size())) + .arg(blockCount)); } } @@ -451,6 +590,10 @@ void ScenarioAuthoringWidget::refreshInspector() { if (!scenario->events.empty()) { changes << QString("Events: %1 configured").arg(static_cast(scenario->events.size())); } + if (!scenario->draft.control.connectionBlocks.empty()) { + changes << QString("Blocked exits: %1 configured") + .arg(static_cast(scenario->draft.control.connectionBlocks.size())); + } if (changes.isEmpty()) { changes << "No changed fields yet"; } @@ -486,12 +629,21 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { shell_->setNavigationPanel(new LayoutNavigationPanelWidget( &layout_, [this](const QString& elementId) { + selectedLayoutElementId_ = elementId; if (canvas_ != nullptr) { canvas_->activateLayoutElement(elementId); } }, shell_, - shell_->createPanelHeader("Layout", shell_, false))); + shell_->createPanelHeader("Layout", shell_, false), + NavigationTreeState{ + .expandedNodeIds = layoutExpandedNodeIds_, + .selectedId = selectedLayoutElementId_, + .restoreExpandedState = true, + }, + [this](const QSet& expandedNodeIds) { + layoutExpandedNodeIds_ = expandedNodeIds; + })); return; } if (navigationView_ == NavigationView::Crowd) { @@ -506,7 +658,7 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { shell_)); return; } - shell_->setNavigationPanel(createEventsPanel(currentScenario(), shell_, shell_)); + shell_->setNavigationPanel(createEventsPanel(layout_, currentScenario(), shell_, shell_)); } void ScenarioAuthoringWidget::refreshRightPanel() { diff --git a/src/application/ScenarioAuthoringWidget.h b/src/application/ScenarioAuthoringWidget.h index f23a473..042f1b9 100644 --- a/src/application/ScenarioAuthoringWidget.h +++ b/src/application/ScenarioAuthoringWidget.h @@ -3,10 +3,12 @@ #include #include +#include #include #include #include "application/ScenarioCanvasWidget.h" +#include "application/ProjectWorkspaceState.h" #include "domain/FacilityLayout2D.h" #include "domain/ScenarioAuthoring.h" @@ -66,6 +68,8 @@ class ScenarioAuthoringWidget : public QWidget { std::function backToLayoutReviewHandler, QWidget* parent = nullptr); + SavedScenarioAuthoringState currentSavedState() const; + private: void initializeUi(bool promptForScenario); void addEventDraft(const QString& name, const QString& trigger, const QString& target); @@ -99,6 +103,8 @@ class ScenarioAuthoringWidget : public QWidget { int currentScenarioIndex_{-1}; NavigationView navigationView_{NavigationView::Layout}; RightPanelMode rightPanelMode_{RightPanelMode::Scenario}; + QSet layoutExpandedNodeIds_{}; + QString selectedLayoutElementId_{}; WorkspaceShell* shell_{nullptr}; ScenarioCanvasWidget* canvas_{nullptr}; QPushButton* scenarioPanelButton_{nullptr}; diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index d0a39f9..af1bf0f 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -416,6 +416,10 @@ void ScenarioCanvasWidget::setConnectionBlocksChangedHandler(std::function handler) { + layoutElementActivatedHandler_ = std::move(handler); +} + void ScenarioCanvasWidget::focusLayoutElement(const QString& elementId) { if (elementId.startsWith("floor:")) { currentFloorId_ = elementId.mid(QString("floor:").size()); @@ -532,6 +536,9 @@ void ScenarioCanvasWidget::mousePressEvent(QMouseEvent* event) { if (it == layout_.connections.end()) { continue; } + if (!matchesFloor(it->floorId, currentFloorId_)) { + continue; + } const auto halfWidth = std::max(0.0, it->effectiveWidth * 0.5); const auto distance = std::max( 0.0, @@ -573,6 +580,12 @@ void ScenarioCanvasWidget::mousePressEvent(QMouseEvent* event) { return; } + if (toolMode_ == ToolMode::Select) { + selectLayoutElementAt(event->position()); + event->accept(); + return; + } + QWidget::mousePressEvent(event); } @@ -793,6 +806,9 @@ void ScenarioCanvasWidget::drawConnectionBlocks(QPainter& painter, const LayoutC if (it == layout_.connections.end()) { continue; } + if (!matchesFloor(it->floorId, currentFloorId_)) { + continue; + } const auto center = transform.map(connectionCenter(*it)); const double r = 10.0; @@ -876,6 +892,9 @@ const safecrowd::domain::Connection2D* ScenarioCanvasWidget::connectionAt( const safecrowd::domain::Connection2D* best = nullptr; double bestDistance = std::max(0.0, toleranceWorldUnits); for (const auto& connection : layout_.connections) { + if (!matchesFloor(connection.floorId, currentFloorId_)) { + continue; + } const auto distance = distancePointToSegment(point, connection.centerSpan.start, connection.centerSpan.end); if (distance <= bestDistance) { bestDistance = distance; @@ -885,6 +904,37 @@ const safecrowd::domain::Connection2D* ScenarioCanvasWidget::connectionAt( return best; } +const safecrowd::domain::Barrier2D* ScenarioCanvasWidget::barrierAt( + const safecrowd::domain::Point2D& point, + double toleranceWorldUnits) const { + const safecrowd::domain::Barrier2D* best = nullptr; + double bestDistance = std::max(0.0, toleranceWorldUnits); + for (const auto& barrier : layout_.barriers) { + if (!matchesFloor(barrier.floorId, currentFloorId_)) { + continue; + } + const auto& vertices = barrier.geometry.vertices; + if (vertices.size() < 2) { + continue; + } + for (std::size_t index = 1; index < vertices.size(); ++index) { + const auto distance = distancePointToSegment(point, vertices[index - 1], vertices[index]); + if (distance <= bestDistance) { + bestDistance = distance; + best = &barrier; + } + } + if (barrier.geometry.closed) { + const auto distance = distancePointToSegment(point, vertices.back(), vertices.front()); + if (distance <= bestDistance) { + bestDistance = distance; + best = &barrier; + } + } + } + return best; +} + safecrowd::domain::Point2D ScenarioCanvasWidget::connectionCenter(const safecrowd::domain::Connection2D& connection) const { return { .x = (connection.centerSpan.start.x + connection.centerSpan.end.x) * 0.5, @@ -1095,6 +1145,9 @@ void ScenarioCanvasWidget::addConnectionBlock(const QPointF& position) { const safecrowd::domain::Connection2D* connection = nullptr; double bestDistance = toleranceWorldUnits; for (const auto& candidate : layout_.connections) { + if (!matchesFloor(candidate.floorId, currentFloorId_)) { + continue; + } if (candidate.kind != safecrowd::domain::ConnectionKind::Doorway && candidate.kind != safecrowd::domain::ConnectionKind::Exit) { continue; @@ -1137,6 +1190,31 @@ void ScenarioCanvasWidget::addConnectionBlockForConnection(const safecrowd::doma update(); } +void ScenarioCanvasWidget::selectLayoutElementAt(const QPointF& position) { + const auto point = unmapPoint(position); + constexpr double kPickRadiusPixels = 14.0; + const auto offsetPoint = unmapPoint(position + QPointF(kPickRadiusPixels, 0.0)); + const auto dx = offsetPoint.x - point.x; + const auto dy = offsetPoint.y - point.y; + const auto toleranceWorldUnits = std::max(0.35, std::hypot(dx, dy)); + + QString selectedId; + if (const auto* connection = connectionAt(point, toleranceWorldUnits); connection != nullptr) { + selectedId = QString::fromStdString(connection->id); + } else if (const auto* barrier = barrierAt(point, toleranceWorldUnits); barrier != nullptr) { + selectedId = QString::fromStdString(barrier->id); + } else { + selectedId = zoneAt(point); + } + + focusedLayoutElementId_ = selectedId; + focusedPlacementId_.clear(); + if (layoutElementActivatedHandler_) { + layoutElementActivatedHandler_(selectedId); + } + update(); +} + void ScenarioCanvasWidget::openConnectionBlockScheduleEditor(const QString& blockId, const QPoint& screenPosition) { QMenu menu(this); auto* editAction = menu.addAction("Set schedule..."); diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index 6a93ff9..429e30c 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -52,6 +52,7 @@ class ScenarioCanvasWidget : public QWidget { void setPlacementsChangedHandler(std::function&)> handler); void setConnectionBlocks(std::vector blocks); void setConnectionBlocksChangedHandler(std::function&)> handler); + void setLayoutElementActivatedHandler(std::function handler); void focusLayoutElement(const QString& elementId); void activateLayoutElement(const QString& elementId); void focusPlacement(const QString& placementId); @@ -83,6 +84,7 @@ class ScenarioCanvasWidget : public QWidget { safecrowd::domain::Point2D unmapPoint(const QPointF& point) const; QString zoneAt(const safecrowd::domain::Point2D& point) const; const safecrowd::domain::Connection2D* connectionAt(const safecrowd::domain::Point2D& point, double toleranceWorldUnits) const; + const safecrowd::domain::Barrier2D* barrierAt(const safecrowd::domain::Point2D& point, double toleranceWorldUnits) const; safecrowd::domain::Point2D connectionCenter(const safecrowd::domain::Connection2D& connection) const; bool placementAreaBlocked(const std::vector& area, int occupantCount) const; bool placementPointBlocked(const safecrowd::domain::Point2D& point) const; @@ -93,6 +95,7 @@ class ScenarioCanvasWidget : public QWidget { void addIndividualPlacement(const QPointF& position); void addConnectionBlock(const QPointF& position); void addConnectionBlockForConnection(const safecrowd::domain::Connection2D& connection); + void selectLayoutElementAt(const QPointF& position); void openConnectionBlockScheduleEditor(const QString& blockId, const QPoint& screenPosition); void drawFocusedLayoutElement(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawFocusedPlacement(QPainter& painter, const LayoutCanvasTransform& transform) const; @@ -122,6 +125,7 @@ class ScenarioCanvasWidget : public QWidget { QToolButton* blockDoorToolButton_{nullptr}; QLabel* groupCountLabel_{nullptr}; QSpinBox* groupCountSpinBox_{nullptr}; + std::function layoutElementActivatedHandler_{}; std::function&)> placementsChangedHandler_{}; std::function&)> connectionBlocksChangedHandler_{}; }; diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index 7d3e56a..c8bf108 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -537,6 +537,22 @@ ScenarioResultWidget::ScenarioResultWidget( rootLayout->addWidget(shell_); } +const safecrowd::domain::ScenarioDraft& ScenarioResultWidget::scenario() const noexcept { + return scenario_; +} + +const safecrowd::domain::SimulationFrame& ScenarioResultWidget::frame() const noexcept { + return frame_; +} + +const safecrowd::domain::ScenarioRiskSnapshot& ScenarioResultWidget::risk() const noexcept { + return risk_; +} + +const safecrowd::domain::ScenarioResultArtifacts& ScenarioResultWidget::artifacts() const noexcept { + return artifacts_; +} + void ScenarioResultWidget::rerunScenario() { auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index 2871fc7..1077e05 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -29,6 +29,11 @@ class ScenarioResultWidget : public QWidget { std::function backToLayoutReviewHandler, QWidget* parent = nullptr); + const safecrowd::domain::ScenarioDraft& scenario() const noexcept; + const safecrowd::domain::SimulationFrame& frame() const noexcept; + const safecrowd::domain::ScenarioRiskSnapshot& risk() const noexcept; + const safecrowd::domain::ScenarioResultArtifacts& artifacts() const noexcept; + private: void rerunScenario(); void navigateToAuthoring(bool showRunPanel); diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 65decb8..b565860 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -219,6 +219,10 @@ ScenarioRunWidget::ScenarioRunWidget( timer_->start(); } +const safecrowd::domain::ScenarioDraft& ScenarioRunWidget::scenario() const noexcept { + return scenario_; +} + QWidget* ScenarioRunWidget::createRunPanel() { auto* panel = new QWidget(shell_); auto* layout = new QVBoxLayout(panel); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 81663b8..2218ba7 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -30,6 +30,8 @@ class ScenarioRunWidget : public QWidget { std::function backToLayoutReviewHandler, QWidget* parent = nullptr); + const safecrowd::domain::ScenarioDraft& scenario() const noexcept; + private: QWidget* createRunPanel(); void returnToAuthoring(); diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index 97c89ca..a0ba0aa 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -251,6 +251,10 @@ void SimulationCanvasWidget::resizeEvent(QResizeEvent* event) { } void SimulationCanvasWidget::wheelEvent(QWheelEvent* event) { + if (switchFloorByWheel(event)) { + return; + } + const auto bounds = collectBounds(); if (!bounds.has_value()) { QWidget::wheelEvent(event); @@ -342,6 +346,9 @@ void SimulationCanvasWidget::drawConnectionBlockOverlay(QPainter& painter, const if (it == layout_.connections.end()) { continue; } + if (!matchesFloor(it->floorId, currentFloorId_)) { + continue; + } const auto center = transform.map(connectionCenter(*it)); const double r = 10.0; @@ -439,6 +446,40 @@ void SimulationCanvasWidget::drawBottleneckOverlay(QPainter& painter, const Layo painter.restore(); } +bool SimulationCanvasWidget::switchFloorByWheel(QWheelEvent* event) { + if (event == nullptr + || !(event->modifiers() & Qt::ControlModifier) + || layout_.floors.size() <= 1) { + return false; + } + + const auto delta = event->angleDelta().y() != 0 ? event->angleDelta().y() : event->pixelDelta().y(); + if (delta == 0) { + return false; + } + + auto currentIndex = 0; + for (std::size_t index = 0; index < layout_.floors.size(); ++index) { + if (layout_.floors[index].id == currentFloorId_) { + currentIndex = static_cast(index); + break; + } + } + + const auto direction = delta > 0 ? 1 : -1; + const auto nextIndex = std::clamp( + currentIndex + direction, + 0, + static_cast(layout_.floors.size() - 1)); + const auto& nextFloorId = layout_.floors[static_cast(nextIndex)].id; + if (nextIndex != currentIndex && !nextFloorId.empty()) { + setCurrentFloorId(nextFloorId, true); + } + + event->accept(); + return true; +} + void SimulationCanvasWidget::setCurrentFloorId(std::string floorId, bool manualSelection) { if (floorId == currentFloorId_ && manualSelection == manualFloorSelection_) { return; diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index b1be2c4..d66aa6f 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -58,6 +58,7 @@ class SimulationCanvasWidget : public QWidget { void drawConnectionBlockOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawHotspotOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawBottleneckOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; + bool switchFloorByWheel(QWheelEvent* event); void setCurrentFloorId(std::string floorId, bool manualSelection); void setupFloorSelector(); void repositionFloorSelector(); diff --git a/src/domain/AgentComponents.h b/src/domain/AgentComponents.h index d6f6711..152aa57 100644 --- a/src/domain/AgentComponents.h +++ b/src/domain/AgentComponents.h @@ -34,14 +34,14 @@ struct EvacuationRoute { Point2D currentSegmentStart{}; double previousDistanceToWaypoint{0.0}; double stalledSeconds{0.0}; - std::string destinationZoneId{}; - std::string currentFloorId{}; - std::string displayFloorId{}; - + double replanCooldownSeconds{0.0}; double nextExitReplanSeconds{0.0}; double nextSegmentReplanSeconds{0.0}; std::uint64_t observedLayoutRevision{0}; bool noExitAvailable{false}; + std::string destinationZoneId{}; + std::string currentFloorId{}; + std::string displayFloorId{}; }; struct EvacuationStatus { diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index 3bade0c..661dac6 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -25,4 +25,25 @@ DemoFixture DemoFixtureService::createSprint1DemoFixture() const { return fixture; } +DemoFixture DemoFixtureService::create2FDemoFixture() const { + DemoFixture fixture; + fixture.layout = DemoLayouts::demoTwoFloorFacility(); + + fixture.population.initialPlacements.push_back({ + .id = "placement-1", + .zoneId = DemoLayouts::TwoFloorFacilityIds::HallZoneL2Id, + .area = { + .outline = { + {6.0, 10.0}, + {10.0, 10.0}, + {10.0, 14.0}, + {6.0, 14.0}, + }, + }, + .targetAgentCount = 80, + }); + + return fixture; +} + } // namespace safecrowd::domain diff --git a/src/domain/DemoFixtureService.h b/src/domain/DemoFixtureService.h index 228da40..547d27e 100644 --- a/src/domain/DemoFixtureService.h +++ b/src/domain/DemoFixtureService.h @@ -13,6 +13,7 @@ struct DemoFixture { class DemoFixtureService { public: DemoFixture createSprint1DemoFixture() const; + DemoFixture create2FDemoFixture() const; }; } // namespace safecrowd::domain diff --git a/src/domain/DemoLayouts.cpp b/src/domain/DemoLayouts.cpp index ec72e94..dc3e498 100644 --- a/src/domain/DemoLayouts.cpp +++ b/src/domain/DemoLayouts.cpp @@ -1,5 +1,6 @@ #include "domain/DemoLayouts.h" +#include #include namespace safecrowd::domain::DemoLayouts { @@ -141,4 +142,487 @@ FacilityLayout2D demoFacility() { return layout; } +namespace { + +Barrier2D makeFloorOutlineBarrier(const char* id, const char* floorId, double width, double height) { + Barrier2D barrier; + barrier.id = id; + barrier.floorId = floorId; + barrier.blocksMovement = true; + barrier.geometry = Polyline2D{ + .vertices = { + {0.0, 0.0}, + {width, 0.0}, + {width, height}, + {0.0, height}, + {0.0, 0.0}, + }, + .closed = false, + }; + return barrier; +} + +} // namespace + +FacilityLayout2D demoTwoFloorFacility() { + constexpr double kWidth = 30.0; + constexpr double kHeight = 20.0; + + FacilityLayout2D layout{}; + layout.id = TwoFloorFacilityIds::LayoutId; + layout.name = "2F demo"; + layout.levelId = TwoFloorFacilityIds::Floor1Id; + layout.floors.push_back({ + .id = TwoFloorFacilityIds::Floor1Id, + .label = "1F", + .elevationMeters = 0.0, + }); + layout.floors.push_back({ + .id = TwoFloorFacilityIds::Floor2Id, + .label = "2F", + .elevationMeters = 3.5, + }); + + const auto makeRoom = [](const std::string& id, + const std::string& floorId, + const std::string& label, + ZoneKind kind, + const std::vector& outline, + std::size_t capacity) { + Zone2D zone; + zone.id = id; + zone.floorId = floorId; + zone.kind = kind; + zone.isStair = kind == ZoneKind::Stair; + zone.label = label; + zone.area = Polygon2D{.outline = outline}; + zone.defaultCapacity = capacity; + return zone; + }; + + const auto addDoor = [&](const std::string& id, + const std::string& floorId, + ConnectionKind kind, + const std::string& fromZoneId, + const std::string& toZoneId, + Point2D start, + Point2D end) { + Connection2D door; + door.id = id; + door.floorId = floorId; + door.kind = kind; + door.fromZoneId = fromZoneId; + door.toZoneId = toZoneId; + door.centerSpan = LineSegment2D{start, end}; + door.effectiveWidth = std::sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y)); + layout.connections.push_back(std::move(door)); + }; + + const auto addStairLink = [&](const std::string& id, + const std::string& floorId, + const std::string& fromZoneId, + const std::string& toZoneId, + Point2D start, + Point2D end) { + Connection2D link; + link.id = id; + link.floorId = floorId; + link.kind = ConnectionKind::Stair; + link.isStair = true; + link.fromZoneId = fromZoneId; + link.toZoneId = toZoneId; + link.centerSpan = LineSegment2D{start, end}; + link.effectiveWidth = std::sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y)); + link.lowerEntryDirection = StairEntryDirection::North; + link.upperEntryDirection = StairEntryDirection::South; + layout.connections.push_back(std::move(link)); + }; + + const auto addWall = [&](const std::string& id, + const std::string& floorId, + Point2D start, + Point2D end) { + Barrier2D barrier; + barrier.id = id; + barrier.floorId = floorId; + barrier.geometry = Polyline2D{ + .vertices = {start, end}, + .closed = false, + }; + barrier.blocksMovement = true; + layout.barriers.push_back(std::move(barrier)); + }; + + const double hallMinX = 2.0; + const double hallMaxX = 28.0; + const double hallMinY = 7.0; + const double hallMaxY = 14.0; + const double roomBandMinX = 4.5; + const double roomBandMaxX = 25.5; + const double bottomRoomMinY = 0.0; + const double bottomRoomMaxY = 7.0; + const double topRoomMinY = 14.0; + const double topRoomMaxY = 20.0; + constexpr int kBottomRoomCount = 3; + constexpr int kTopRoomCountL2 = 3; + const double bottomRoomWidth = (roomBandMaxX - roomBandMinX) / static_cast(kBottomRoomCount); + const double topRoomWidth = (roomBandMaxX - roomBandMinX) / static_cast(kTopRoomCountL2); + + const auto addHall = [&](const std::string& id, + const std::string& floorId, + const std::string& label, + const std::vector& outline) { + layout.zones.push_back(makeRoom( + id, + floorId, + label, + ZoneKind::Room, + outline, + 500)); + }; + + const auto addBottomRooms = [&](const std::string& floorId, + const std::string& hallId, + const std::string& roomPrefix, + const std::string& doorPrefix) { + for (int i = 0; i < kBottomRoomCount; ++i) { + const double x0 = roomBandMinX + bottomRoomWidth * static_cast(i); + const double x1 = roomBandMinX + bottomRoomWidth * static_cast(i + 1); + const auto roomId = roomPrefix + std::to_string(i + 1); + layout.zones.push_back(makeRoom( + roomId, + floorId, + "Bottom Room " + std::to_string(i + 1), + ZoneKind::Room, + {{x0, bottomRoomMinY}, {x1, bottomRoomMinY}, {x1, bottomRoomMaxY}, {x0, bottomRoomMaxY}}, + 50)); + + const double doorCenterX = (x0 + x1) * 0.5; + addDoor( + doorPrefix + std::to_string(i + 1), + floorId, + ConnectionKind::Doorway, + hallId, + roomId, + {.x = doorCenterX - 0.9, .y = hallMinY}, + {.x = doorCenterX + 0.9, .y = hallMinY}); + } + }; + + const auto addTopRooms2F = [&](const std::string& floorId, const std::string& hallId) { + for (int i = 0; i < kTopRoomCountL2; ++i) { + const double x0 = roomBandMinX + topRoomWidth * static_cast(i); + const double x1 = roomBandMinX + topRoomWidth * static_cast(i + 1); + const auto roomId = std::string(TwoFloorFacilityIds::TopRoomL2Prefix) + std::to_string(i + 1); + layout.zones.push_back(makeRoom( + roomId, + floorId, + "Top Room " + std::to_string(i + 1), + ZoneKind::Room, + {{x0, topRoomMinY}, {x1, topRoomMinY}, {x1, topRoomMaxY}, {x0, topRoomMaxY}}, + 44)); + + const double doorCenterX = (x0 + x1) * 0.5; + addDoor( + std::string(TwoFloorFacilityIds::TopDoorL2Prefix) + std::to_string(i + 1), + floorId, + ConnectionKind::Doorway, + hallId, + roomId, + {.x = doorCenterX - 0.9, .y = hallMaxY}, + {.x = doorCenterX + 0.9, .y = hallMaxY}); + } + + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::CornerRoomL2Id, + floorId, + "Top Left Corner", + ZoneKind::Room, + {{0.0, 14.0}, {2.2, 14.0}, {2.2, 20.0}, {0.0, 20.0}}, + 18)); + addDoor( + TwoFloorFacilityIds::CornerDoorL2Id, + floorId, + ConnectionKind::Doorway, + hallId, + TwoFloorFacilityIds::CornerRoomL2Id, + {.x = 2.2, .y = 15.2}, + {.x = 2.2, .y = 17.0}); + + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::CornerRoomRightL2Id, + floorId, + "Top Right Corner", + ZoneKind::Room, + {{25.5, 14.0}, {28.0, 14.0}, {28.0, 20.0}, {25.5, 20.0}}, + 18)); + addDoor( + TwoFloorFacilityIds::CornerRightDoorL2Id, + floorId, + ConnectionKind::Doorway, + hallId, + TwoFloorFacilityIds::CornerRoomRightL2Id, + {.x = 28.0, .y = 15.2}, + {.x = 28.0, .y = 17.0}); + }; + + const auto addTopRooms1F = [&](const std::string& floorId, const std::string& hallId) { + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::CornerRoomL1Id, + floorId, + "Top Left Corner", + ZoneKind::Room, + {{0.0, 14.0}, {2.2, 14.0}, {2.2, 20.0}, {0.0, 20.0}}, + 18)); + addDoor( + TwoFloorFacilityIds::CornerDoorL1Id, + floorId, + ConnectionKind::Doorway, + hallId, + TwoFloorFacilityIds::CornerRoomL1Id, + {.x = 2.2, .y = 15.2}, + {.x = 2.2, .y = 17.0}); + + const std::vector> roomRanges{ + {4.0, 12.0}, + {18.0, 28.0}, + }; + for (std::size_t index = 0; index < roomRanges.size(); ++index) { + const auto& [x0, x1] = roomRanges[index]; + const auto roomId = std::string(TwoFloorFacilityIds::TopRoomL1Prefix) + std::to_string(index + 1); + layout.zones.push_back(makeRoom( + roomId, + floorId, + "Top Room " + std::to_string(index + 1), + ZoneKind::Room, + {{x0, topRoomMinY}, {x1, topRoomMinY}, {x1, topRoomMaxY}, {x0, topRoomMaxY}}, + 48)); + + const double doorCenterX = (x0 + x1) * 0.5; + addDoor( + std::string(TwoFloorFacilityIds::TopDoorL1Prefix) + std::to_string(index + 1), + floorId, + ConnectionKind::Doorway, + hallId, + roomId, + {.x = doorCenterX - 0.9, .y = hallMaxY}, + {.x = doorCenterX + 0.9, .y = hallMaxY}); + } + }; + + addHall( + TwoFloorFacilityIds::HallZoneL1Id, + TwoFloorFacilityIds::Floor1Id, + "1F Hall", + { + {0.0, 8.0}, + {2.2, 8.0}, + {2.2, hallMinY}, + {hallMaxX, hallMinY}, + {hallMaxX, 8.0}, + {30.0, 8.0}, + {30.0, 20.0}, + {28.0, 20.0}, + {28.0, hallMaxY}, + {25.5, hallMaxY}, + {4.0, hallMaxY}, + {4.0, 20.0}, + {2.2, 20.0}, + {2.2, hallMaxY}, + {0.0, hallMaxY}, + }); + addBottomRooms( + TwoFloorFacilityIds::Floor1Id, + TwoFloorFacilityIds::HallZoneL1Id, + TwoFloorFacilityIds::BottomRoomL1Prefix, + TwoFloorFacilityIds::BottomDoorL1Prefix); + addTopRooms1F(TwoFloorFacilityIds::Floor1Id, TwoFloorFacilityIds::HallZoneL1Id); + + addHall( + TwoFloorFacilityIds::HallZoneL2Id, + TwoFloorFacilityIds::Floor2Id, + "2F Hall", + { + {2.2, hallMinY}, + {hallMaxX, hallMinY}, + {hallMaxX, hallMaxY}, + {30.0, hallMaxY}, + {30.0, 20.0}, + {28.0, 20.0}, + {28.0, hallMaxY}, + {25.5, hallMaxY}, + {4.5, hallMaxY}, + {4.5, 20.0}, + {2.2, 20.0}, + }); + addBottomRooms( + TwoFloorFacilityIds::Floor2Id, + TwoFloorFacilityIds::HallZoneL2Id, + TwoFloorFacilityIds::BottomRoomL2Prefix, + TwoFloorFacilityIds::BottomDoorL2Prefix); + addTopRooms2F(TwoFloorFacilityIds::Floor2Id, TwoFloorFacilityIds::HallZoneL2Id); + + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::LeftStairZoneL1Id, + TwoFloorFacilityIds::Floor1Id, + "Left Stairs", + ZoneKind::Stair, + {{0.0, 0.0}, {2.2, 0.0}, {2.2, 8.0}, {0.0, 8.0}}, + 20)); + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::RightStairZoneL1Id, + TwoFloorFacilityIds::Floor1Id, + "Right Stairs", + ZoneKind::Stair, + {{27.6, 0.0}, {30.0, 0.0}, {30.0, 8.0}, {27.6, 8.0}}, + 20)); + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::LeftStairZoneL2Id, + TwoFloorFacilityIds::Floor2Id, + "Left Stairs", + ZoneKind::Stair, + {{0.0, 2.0}, {2.4, 2.0}, {2.4, 8.5}, {0.0, 8.5}}, + 20)); + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::RightStairZoneL2Id, + TwoFloorFacilityIds::Floor2Id, + "Right Stairs", + ZoneKind::Stair, + {{27.6, 2.0}, {30.0, 2.0}, {30.0, 8.5}, {27.6, 8.5}}, + 20)); + + addDoor( + TwoFloorFacilityIds::LeftStairDoorL1Id, + TwoFloorFacilityIds::Floor1Id, + ConnectionKind::Opening, + TwoFloorFacilityIds::HallZoneL1Id, + TwoFloorFacilityIds::LeftStairZoneL1Id, + {.x = 2.2, .y = 7.0}, + {.x = 2.2, .y = 8.0}); + addDoor( + TwoFloorFacilityIds::RightStairDoorL1Id, + TwoFloorFacilityIds::Floor1Id, + ConnectionKind::Opening, + TwoFloorFacilityIds::HallZoneL1Id, + TwoFloorFacilityIds::RightStairZoneL1Id, + {.x = hallMaxX, .y = 7.0}, + {.x = hallMaxX, .y = 8.0}); + addDoor( + TwoFloorFacilityIds::LeftStairDoorL2Id, + TwoFloorFacilityIds::Floor2Id, + ConnectionKind::Opening, + TwoFloorFacilityIds::HallZoneL2Id, + TwoFloorFacilityIds::LeftStairZoneL2Id, + {.x = hallMinX, .y = 7.2}, + {.x = hallMinX, .y = 8.6}); + addDoor( + TwoFloorFacilityIds::RightStairDoorL2Id, + TwoFloorFacilityIds::Floor2Id, + ConnectionKind::Opening, + TwoFloorFacilityIds::HallZoneL2Id, + TwoFloorFacilityIds::RightStairZoneL2Id, + {.x = hallMaxX, .y = 7.2}, + {.x = hallMaxX, .y = 8.6}); + + layout.zones.push_back(makeRoom( + TwoFloorFacilityIds::ExitZoneL1Id, + TwoFloorFacilityIds::Floor1Id, + "Exit", + ZoneKind::Exit, + {{12.0, 14.0}, {18.0, 14.0}, {18.0, 20.0}, {12.0, 20.0}}, + 60)); + addDoor( + TwoFloorFacilityIds::ExitDoorL1Id, + TwoFloorFacilityIds::Floor1Id, + ConnectionKind::Exit, + TwoFloorFacilityIds::HallZoneL1Id, + TwoFloorFacilityIds::ExitZoneL1Id, + {.x = 14.0, .y = hallMaxY}, + {.x = 16.0, .y = hallMaxY}); + + const double roomDivider1 = roomBandMinX + bottomRoomWidth; + const double roomDivider2 = roomBandMinX + bottomRoomWidth * 2.0; + + // 1F top walls + addWall("barrier-l1-top-left-door-upper", TwoFloorFacilityIds::Floor1Id, {2.2, 17.0}, {2.2, 20.0}); + addWall("barrier-l1-top-left-door-lower", TwoFloorFacilityIds::Floor1Id, {2.2, 14.0}, {2.2, 15.2}); + addWall("barrier-l1-top-left-bottom", TwoFloorFacilityIds::Floor1Id, {0.0, 14.0}, {2.2, 14.0}); + addWall("barrier-l1-top-room-left-side", TwoFloorFacilityIds::Floor1Id, {4.0, 14.0}, {4.0, 20.0}); + addWall("barrier-l1-top-room-mid-left", TwoFloorFacilityIds::Floor1Id, {12.0, 14.0}, {12.0, 20.0}); + addWall("barrier-l1-top-room-mid-right", TwoFloorFacilityIds::Floor1Id, {18.0, 14.0}, {18.0, 20.0}); + addWall("barrier-l1-top-right-door-upper", TwoFloorFacilityIds::Floor1Id, {28.0, 17.0}, {28.0, 20.0}); + addWall("barrier-l1-top-right-door-lower", TwoFloorFacilityIds::Floor1Id, {28.0, 14.0}, {28.0, 15.2}); + addWall("barrier-l1-top-right-bottom", TwoFloorFacilityIds::Floor1Id, {28.0, 14.0}, {30.0, 14.0}); + addWall("barrier-l1-top-main-left-a", TwoFloorFacilityIds::Floor1Id, {4.0, 14.0}, {7.1, 14.0}); + addWall("barrier-l1-top-main-left-b", TwoFloorFacilityIds::Floor1Id, {8.9, 14.0}, {12.0, 14.0}); + addWall("barrier-l1-top-exit-left", TwoFloorFacilityIds::Floor1Id, {12.0, 14.0}, {14.0, 14.0}); + addWall("barrier-l1-top-exit-right", TwoFloorFacilityIds::Floor1Id, {16.0, 14.0}, {18.0, 14.0}); + addWall("barrier-l1-top-main-right-a", TwoFloorFacilityIds::Floor1Id, {18.0, 14.0}, {22.1, 14.0}); + addWall("barrier-l1-top-main-right-b", TwoFloorFacilityIds::Floor1Id, {23.9, 14.0}, {25.5, 14.0}); + addWall("barrier-l1-top-right-inner-divider", TwoFloorFacilityIds::Floor1Id, {25.5, 14.0}, {25.5, 20.0}); + + // 1F bottom walls + addWall("barrier-l1-bottom-left-side", TwoFloorFacilityIds::Floor1Id, {4.5, 0.0}, {4.5, 7.0}); + addWall("barrier-l1-bottom-divider-1", TwoFloorFacilityIds::Floor1Id, {roomDivider1, 0.0}, {roomDivider1, 7.0}); + addWall("barrier-l1-bottom-divider-2", TwoFloorFacilityIds::Floor1Id, {roomDivider2, 0.0}, {roomDivider2, 7.0}); + addWall("barrier-l1-bottom-right-side", TwoFloorFacilityIds::Floor1Id, {25.5, 0.0}, {25.5, 7.0}); + addWall("barrier-l1-bottom-top-a", TwoFloorFacilityIds::Floor1Id, {4.5, 7.0}, {7.1, 7.0}); + addWall("barrier-l1-bottom-top-b", TwoFloorFacilityIds::Floor1Id, {8.9, 7.0}, {14.1, 7.0}); + addWall("barrier-l1-bottom-top-c", TwoFloorFacilityIds::Floor1Id, {15.9, 7.0}, {21.1, 7.0}); + addWall("barrier-l1-bottom-top-d", TwoFloorFacilityIds::Floor1Id, {22.9, 7.0}, {25.5, 7.0}); + + // 2F top walls + addWall("barrier-l2-top-left-door-upper", TwoFloorFacilityIds::Floor2Id, {2.2, 17.0}, {2.2, 20.0}); + addWall("barrier-l2-top-left-door-lower", TwoFloorFacilityIds::Floor2Id, {2.2, 14.0}, {2.2, 15.2}); + addWall("barrier-l2-top-left-side", TwoFloorFacilityIds::Floor2Id, {4.5, 14.0}, {4.5, 20.0}); + addWall("barrier-l2-top-divider-1", TwoFloorFacilityIds::Floor2Id, {roomDivider1, 14.0}, {roomDivider1, 20.0}); + addWall("barrier-l2-top-divider-2", TwoFloorFacilityIds::Floor2Id, {roomDivider2, 14.0}, {roomDivider2, 20.0}); + addWall("barrier-l2-top-right-side", TwoFloorFacilityIds::Floor2Id, {25.5, 14.0}, {25.5, 20.0}); + addWall("barrier-l2-top-right-door-upper", TwoFloorFacilityIds::Floor2Id, {28.0, 17.0}, {28.0, 20.0}); + addWall("barrier-l2-top-right-door-lower", TwoFloorFacilityIds::Floor2Id, {28.0, 14.0}, {28.0, 15.2}); + addWall("barrier-l2-top-main-a", TwoFloorFacilityIds::Floor2Id, {4.5, 14.0}, {7.1, 14.0}); + addWall("barrier-l2-top-main-b", TwoFloorFacilityIds::Floor2Id, {8.9, 14.0}, {14.1, 14.0}); + addWall("barrier-l2-top-main-c", TwoFloorFacilityIds::Floor2Id, {15.9, 14.0}, {21.1, 14.0}); + addWall("barrier-l2-top-main-d", TwoFloorFacilityIds::Floor2Id, {22.9, 14.0}, {25.5, 14.0}); + addDoor( + "conn-l1-top-room-2-side-door", + TwoFloorFacilityIds::Floor1Id, + ConnectionKind::Doorway, + TwoFloorFacilityIds::HallZoneL1Id, + std::string(TwoFloorFacilityIds::TopRoomL1Prefix) + "2", + {.x = 28.0, .y = 15.2}, + {.x = 28.0, .y = 17.0}); + + // 2F bottom walls + addWall("barrier-l2-bottom-left-side", TwoFloorFacilityIds::Floor2Id, {4.5, 0.0}, {4.5, 7.0}); + addWall("barrier-l2-bottom-divider-1", TwoFloorFacilityIds::Floor2Id, {roomDivider1, 0.0}, {roomDivider1, 7.0}); + addWall("barrier-l2-bottom-divider-2", TwoFloorFacilityIds::Floor2Id, {roomDivider2, 0.0}, {roomDivider2, 7.0}); + addWall("barrier-l2-bottom-right-side", TwoFloorFacilityIds::Floor2Id, {25.5, 0.0}, {25.5, 7.0}); + addWall("barrier-l2-bottom-top-a", TwoFloorFacilityIds::Floor2Id, {4.5, 7.0}, {7.1, 7.0}); + addWall("barrier-l2-bottom-top-b", TwoFloorFacilityIds::Floor2Id, {8.9, 7.0}, {14.1, 7.0}); + addWall("barrier-l2-bottom-top-c", TwoFloorFacilityIds::Floor2Id, {15.9, 7.0}, {21.1, 7.0}); + addWall("barrier-l2-bottom-top-d", TwoFloorFacilityIds::Floor2Id, {22.9, 7.0}, {25.5, 7.0}); + + // Stair links between floors (left and right). + addStairLink( + TwoFloorFacilityIds::LeftStairLinkId, + TwoFloorFacilityIds::Floor1Id, + TwoFloorFacilityIds::LeftStairZoneL1Id, + TwoFloorFacilityIds::LeftStairZoneL2Id, + {.x = 1.0, .y = 6.0 - 0.9}, + {.x = 1.0, .y = 6.0 + 0.9}); + addStairLink( + TwoFloorFacilityIds::RightStairLinkId, + TwoFloorFacilityIds::Floor1Id, + TwoFloorFacilityIds::RightStairZoneL1Id, + TwoFloorFacilityIds::RightStairZoneL2Id, + {.x = 29.0, .y = 6.0 - 0.9}, + {.x = 29.0, .y = 6.0 + 0.9}); + + layout.barriers.push_back(makeFloorOutlineBarrier(TwoFloorFacilityIds::OuterWallL1Id, TwoFloorFacilityIds::Floor1Id, kWidth, kHeight)); + layout.barriers.push_back(makeFloorOutlineBarrier(TwoFloorFacilityIds::OuterWallL2Id, TwoFloorFacilityIds::Floor2Id, kWidth, kHeight)); + + return layout; +} + } // namespace safecrowd::domain::DemoLayouts diff --git a/src/domain/DemoLayouts.h b/src/domain/DemoLayouts.h index 94bf3da..e8e9db1 100644 --- a/src/domain/DemoLayouts.h +++ b/src/domain/DemoLayouts.h @@ -34,5 +34,52 @@ struct Sprint1FacilityIds { FacilityLayout2D demoFacility(); +struct TwoFloorFacilityIds { + static constexpr const char* LayoutId = "demo-fixture-02"; + static constexpr const char* Floor1Id = "L1"; + static constexpr const char* Floor2Id = "L2"; + + static constexpr const char* HallZoneL1Id = "zone-l1-hall"; + static constexpr const char* HallZoneL2Id = "zone-l2-hall"; + static constexpr const char* CornerRoomL1Id = "zone-l1-corner-room"; + static constexpr const char* CornerRoomL2Id = "zone-l2-corner-room"; + static constexpr const char* CornerRoomRightL1Id = "zone-l1-corner-room-right"; + static constexpr const char* CornerRoomRightL2Id = "zone-l2-corner-room-right"; + + static constexpr const char* TopRoomL1Prefix = "zone-l1-top-room-"; + static constexpr const char* BottomRoomL1Prefix = "zone-l1-bottom-room-"; + static constexpr const char* TopRoomL2Prefix = "zone-l2-top-room-"; + static constexpr const char* BottomRoomL2Prefix = "zone-l2-bottom-room-"; + + static constexpr const char* LeftStairZoneL1Id = "zone-l1-stairs-left"; + static constexpr const char* RightStairZoneL1Id = "zone-l1-stairs-right"; + static constexpr const char* LeftStairZoneL2Id = "zone-l2-stairs-left"; + static constexpr const char* RightStairZoneL2Id = "zone-l2-stairs-right"; + static constexpr const char* ExitZoneL1Id = "zone-l1-exit"; + + static constexpr const char* CornerDoorL1Id = "conn-l1-corner-door"; + static constexpr const char* CornerDoorL2Id = "conn-l2-corner-door"; + static constexpr const char* CornerRightDoorL1Id = "conn-l1-corner-right-door"; + static constexpr const char* CornerRightDoorL2Id = "conn-l2-corner-right-door"; + + static constexpr const char* TopDoorL1Prefix = "conn-l1-top-door-"; + static constexpr const char* BottomDoorL1Prefix = "conn-l1-bottom-door-"; + static constexpr const char* TopDoorL2Prefix = "conn-l2-top-door-"; + static constexpr const char* BottomDoorL2Prefix = "conn-l2-bottom-door-"; + + static constexpr const char* LeftStairDoorL1Id = "conn-l1-stairs-left-door"; + static constexpr const char* RightStairDoorL1Id = "conn-l1-stairs-right-door"; + static constexpr const char* LeftStairDoorL2Id = "conn-l2-stairs-left-door"; + static constexpr const char* RightStairDoorL2Id = "conn-l2-stairs-right-door"; + static constexpr const char* ExitDoorL1Id = "conn-l1-exit"; + static constexpr const char* LeftStairLinkId = "conn-stairs-left-l1-l2"; + static constexpr const char* RightStairLinkId = "conn-stairs-right-l1-l2"; + + static constexpr const char* OuterWallL1Id = "barrier-l1-outline"; + static constexpr const char* OuterWallL2Id = "barrier-l2-outline"; +}; + +FacilityLayout2D demoTwoFloorFacility(); + } // namespace safecrowd::domain::DemoLayouts diff --git a/src/domain/ImportIssue.cpp b/src/domain/ImportIssue.cpp index 43e344d..e17ac7a 100644 --- a/src/domain/ImportIssue.cpp +++ b/src/domain/ImportIssue.cpp @@ -31,6 +31,8 @@ const char* toString(ImportIssueCode code) noexcept { return "UnsupportedEntity"; case ImportIssueCode::MissingSourceGeometry: return "MissingSourceGeometry"; + case ImportIssueCode::MissingRoom: + return "MissingRoom"; case ImportIssueCode::MissingBlockDefinition: return "MissingBlockDefinition"; case ImportIssueCode::InvalidGeometry: diff --git a/src/domain/ImportIssue.h b/src/domain/ImportIssue.h index 243a0b7..b21d845 100644 --- a/src/domain/ImportIssue.h +++ b/src/domain/ImportIssue.h @@ -17,6 +17,7 @@ enum class ImportIssueCode { FileReadFailed, UnsupportedEntity, MissingSourceGeometry, + MissingRoom, MissingBlockDefinition, InvalidGeometry, DisconnectedWalkableArea, diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp index 33e78dc..bd820f7 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -151,10 +151,14 @@ std::vector ImportValidationService::validate(const FacilityLayout2 } std::unordered_set exitZoneIds; + std::size_t roomZoneCount = 0; for (const auto& zone : layout.zones) { if (zone.kind == ZoneKind::Exit) { exitZoneIds.insert(zone.id); } + if (zone.kind == ZoneKind::Room) { + ++roomZoneCount; + } } if (exitZoneIds.empty()) { @@ -167,6 +171,15 @@ std::vector ImportValidationService::validate(const FacilityLayout2 }); } + if (roomZoneCount == 0) { + issues.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::MissingRoom, + .message = "Agents can only be placed inside Room or Exit zones.", + .targetId = layout.id, + }); + } + for (const auto& connection : layout.connections) { if (connection.effectiveWidth > 0.0 && connection.effectiveWidth < kMinimumConnectionWidth) { issues.push_back({ diff --git a/src/domain/ScenarioRiskMetricsSystem.cpp b/src/domain/ScenarioRiskMetricsSystem.cpp index 89e2ef5..1254e5f 100644 --- a/src/domain/ScenarioRiskMetricsSystem.cpp +++ b/src/domain/ScenarioRiskMetricsSystem.cpp @@ -150,9 +150,9 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { auto& query = world.query(); auto& resources = world.resources(); - activeLayout_ = resources.contains() - ? &resources.get().layout - : &layout_; + const auto& activeLayout = resources.contains() + ? resources.get().layout + : layout_; ScenarioRiskSnapshot snapshot; const auto entities = query.view(); @@ -187,7 +187,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } collectHotspots(snapshot, cells); - collectBottlenecks(snapshot, query, entities); + collectBottlenecks(snapshot, query, entities, activeLayout); ScenarioSimulationClockResource clock; if (resources.contains()) { @@ -255,17 +255,17 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } } - std::string zoneDisplayName(const std::string& zoneId) const { - const auto* zone = findZone(layout(), zoneId); + std::string zoneDisplayName(const FacilityLayout2D& layout, const std::string& zoneId) const { + const auto* zone = findZone(layout, zoneId); if (zone == nullptr) { return zoneId; } return zone->label.empty() ? zone->id : zone->label; } - std::string connectionLabel(const Connection2D& connection) const { - const auto from = zoneDisplayName(connection.fromZoneId); - const auto to = zoneDisplayName(connection.toZoneId); + std::string connectionLabel(const FacilityLayout2D& layout, const Connection2D& connection) const { + const auto from = zoneDisplayName(layout, connection.fromZoneId); + const auto to = zoneDisplayName(layout, connection.toZoneId); if (!from.empty() && !to.empty()) { return from + " -> " + to; } @@ -275,15 +275,16 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { void collectBottlenecks( ScenarioRiskSnapshot& snapshot, engine::WorldQuery& query, - const std::vector& entities) const { - for (const auto& connection : layout().connections) { + const std::vector& entities, + const FacilityLayout2D& layout) const { + for (const auto& connection : layout.connections) { if (connection.directionality == TravelDirection::Closed) { continue; } ScenarioBottleneckMetric metric; metric.connectionId = connection.id; - metric.label = connectionLabel(connection); + metric.label = connectionLabel(layout, connection); metric.passage = connection.centerSpan; double speedSum = 0.0; diff --git a/src/domain/ScenarioSimulationInternal.cpp b/src/domain/ScenarioSimulationInternal.cpp index ca5e4df..bd4fa8a 100644 --- a/src/domain/ScenarioSimulationInternal.cpp +++ b/src/domain/ScenarioSimulationInternal.cpp @@ -1,5 +1,7 @@ #include "domain/ScenarioSimulationInternal.h" +#include "domain/ScenarioSimulationSystems.h" + #include #include #include @@ -437,6 +439,120 @@ FacilityLayout2D layoutForFloor(const FacilityLayout2D& layout, const std::strin return filtered; } +ScenarioLayoutCacheResource buildScenarioLayoutCache(FacilityLayout2D layout) { + ScenarioLayoutCacheResource cache; + cache.layout = std::move(layout); + + std::vector floorIds; + auto addFloorId = [&](const std::string& floorId) { + if (floorId.empty()) { + return; + } + if (std::find(floorIds.begin(), floorIds.end(), floorId) == floorIds.end()) { + floorIds.push_back(floorId); + } + }; + + for (const auto& floor : cache.layout.floors) { + addFloorId(floor.id); + } + for (std::size_t index = 0; index < cache.layout.zones.size(); ++index) { + const auto& zone = cache.layout.zones[index]; + cache.zoneIndices[zone.id] = index; + cache.zoneFloorIds[zone.id] = zone.floorId; + addFloorId(zone.floorId); + } + for (const auto& connection : cache.layout.connections) { + addFloorId(connection.floorId); + } + for (const auto& barrier : cache.layout.barriers) { + addFloorId(barrier.floorId); + } + for (const auto& control : cache.layout.controls) { + addFloorId(control.floorId); + } + + for (const auto& floorId : floorIds) { + cache.floorLayouts.emplace(floorId, layoutForFloor(cache.layout, floorId)); + } + + for (std::size_t index = 0; index < cache.layout.connections.size(); ++index) { + const auto& connection = cache.layout.connections[index]; + if (connection.directionality == TravelDirection::Closed || !canTraverseConnection(cache.layout, connection)) { + continue; + } + if (connection.directionality != TravelDirection::ReverseOnly) { + cache.traversableConnectionsByZone[connection.fromZoneId].push_back({ + .nextZoneId = connection.toZoneId, + .connectionIndex = index, + }); + } + if (connection.directionality != TravelDirection::ForwardOnly) { + cache.traversableConnectionsByZone[connection.toZoneId].push_back({ + .nextZoneId = connection.fromZoneId, + .connectionIndex = index, + }); + } + } + + return cache; +} + +const FacilityLayout2D& cachedLayoutForFloor(const ScenarioLayoutCacheResource& cache, const std::string& floorId) { + if (floorId.empty()) { + return cache.layout; + } + const auto it = cache.floorLayouts.find(floorId); + return it == cache.floorLayouts.end() ? cache.layout : it->second; +} + +const Zone2D* findCachedZone(const ScenarioLayoutCacheResource& cache, const std::string& zoneId) { + const auto it = cache.zoneIndices.find(zoneId); + if (it == cache.zoneIndices.end() || it->second >= cache.layout.zones.size()) { + return nullptr; + } + return &cache.layout.zones[it->second]; +} + +const Connection2D* findCachedConnectionBetween( + const ScenarioLayoutCacheResource& cache, + const std::string& from, + const std::string& to) { + const auto it = cache.traversableConnectionsByZone.find(from); + if (it == cache.traversableConnectionsByZone.end()) { + return nullptr; + } + for (const auto& traversal : it->second) { + if (traversal.nextZoneId == to && traversal.connectionIndex < cache.layout.connections.size()) { + return &cache.layout.connections[traversal.connectionIndex]; + } + } + return nullptr; +} + +std::string cachedFloorIdForZone(const ScenarioLayoutCacheResource& cache, const std::string& zoneId) { + const auto it = cache.zoneFloorIds.find(zoneId); + return it == cache.zoneFloorIds.end() ? std::string{} : it->second; +} + +const std::vector& cachedTraversalsForZone( + const ScenarioLayoutCacheResource& cache, + const std::string& zoneId) { + static const std::vector empty; + const auto it = cache.traversableConnectionsByZone.find(zoneId); + return it == cache.traversableConnectionsByZone.end() ? empty : it->second; +} + +std::string zoneAt(const ScenarioLayoutCacheResource& cache, const Point2D& point, const std::string& floorId) { + const auto& floorLayout = cachedLayoutForFloor(cache, floorId); + for (const auto& zone : floorLayout.zones) { + if (pointInRing(zone.area.outline, point)) { + return zone.id; + } + } + return {}; +} + Point2D passageNormalToward(const LineSegment2D& passage, const Zone2D& fromZone, const Zone2D& toZone) { const auto passageDirection = passage.end - passage.start; const auto firstNormal = normalizedOr(perpendicularLeft(passageDirection), {}); @@ -508,18 +624,23 @@ const Connection2D* findConnectionBetween(const FacilityLayout2D& layout, const return it == layout.connections.end() ? nullptr : &(*it); } -std::optional> zoneRouteToNearestExit(const FacilityLayout2D& layout, const std::string& startZoneId) { +std::optional> zoneRouteToNearestExit( + const ScenarioLayoutCacheResource& cache, + const std::string& startZoneId) { if (startZoneId.empty()) { return std::nullopt; } - if (const auto* startZone = findZone(layout, startZoneId); startZone != nullptr && startZone->kind == ZoneKind::Exit) { + if (const auto* startZone = findCachedZone(cache, startZoneId); startZone != nullptr && startZone->kind == ZoneKind::Exit) { return std::vector{startZoneId}; } std::unordered_set exitZoneIds; - exitZoneIds.reserve(layout.zones.size()); - for (const auto& zone : layout.zones) { + exitZoneIds.reserve(cache.layout.zones.size()); + std::unordered_map centers; + centers.reserve(cache.layout.zones.size()); + for (const auto& zone : cache.layout.zones) { + centers.emplace(zone.id, polygonCenter(zone.area)); if (zone.kind == ZoneKind::Exit) { exitZoneIds.insert(zone.id); } @@ -528,42 +649,11 @@ std::optional> zoneRouteToNearestExit(const FacilityLay return std::nullopt; } - std::unordered_map centers; - centers.reserve(layout.zones.size()); - for (const auto& zone : layout.zones) { - centers.emplace(zone.id, polygonCenter(zone.area)); - } - const auto zoneCenter = [&](const std::string& zoneId) -> Point2D { + auto zoneCenter = [&](const std::string& zoneId) -> Point2D { const auto it = centers.find(zoneId); return it == centers.end() ? Point2D{} : it->second; }; - std::unordered_map>> adjacency; - adjacency.reserve(layout.zones.size() * 2); - for (const auto& connection : layout.connections) { - if (connection.directionality == TravelDirection::Closed) { - continue; - } - if (!canTraverseConnection(layout, connection)) { - continue; - } - - const auto portal = midpoint(connection.centerSpan); - const auto fromCenter = zoneCenter(connection.fromZoneId); - const auto toCenter = zoneCenter(connection.toZoneId); - const auto forwardWeight = - distanceBetween(fromCenter, portal) + distanceBetween(portal, toCenter); - const auto reverseWeight = - distanceBetween(toCenter, portal) + distanceBetween(portal, fromCenter); - - if (connection.directionality != TravelDirection::ReverseOnly) { - adjacency[connection.fromZoneId].push_back({connection.toZoneId, forwardWeight}); - } - if (connection.directionality != TravelDirection::ForwardOnly) { - adjacency[connection.toZoneId].push_back({connection.fromZoneId, reverseWeight}); - } - } - struct QueueItem { double distance{0.0}; std::string zoneId{}; @@ -573,21 +663,21 @@ std::optional> zoneRouteToNearestExit(const FacilityLay } }; - std::unordered_map dist; - dist.reserve(layout.zones.size()); - std::unordered_map prev; - prev.reserve(layout.zones.size()); - std::priority_queue, std::greater> pq; + std::unordered_map distances; + distances.reserve(cache.layout.zones.size()); + std::unordered_map previous; + previous.reserve(cache.layout.zones.size()); + std::priority_queue, std::greater> queue; - dist[startZoneId] = 0.0; - pq.push({.distance = 0.0, .zoneId = startZoneId}); + distances[startZoneId] = 0.0; + queue.push({.distance = 0.0, .zoneId = startZoneId}); - while (!pq.empty()) { - const auto current = pq.top(); - pq.pop(); + while (!queue.empty()) { + const auto current = queue.top(); + queue.pop(); - const auto bestIt = dist.find(current.zoneId); - if (bestIt == dist.end() || current.distance > bestIt->second + 1e-12) { + const auto bestIt = distances.find(current.zoneId); + if (bestIt == distances.end() || current.distance > bestIt->second + 1e-12) { continue; } @@ -595,28 +685,28 @@ std::optional> zoneRouteToNearestExit(const FacilityLay std::vector route; for (auto zoneId = current.zoneId; !zoneId.empty();) { route.push_back(zoneId); - const auto it = prev.find(zoneId); - zoneId = it == prev.end() ? std::string{} : it->second; + const auto it = previous.find(zoneId); + zoneId = it == previous.end() ? std::string{} : it->second; } std::reverse(route.begin(), route.end()); return route; } - const auto adjIt = adjacency.find(current.zoneId); - if (adjIt == adjacency.end()) { - continue; - } - - for (const auto& [next, cost] : adjIt->second) { - if (next.empty()) { + for (const auto& traversal : cachedTraversalsForZone(cache, current.zoneId)) { + if (traversal.nextZoneId.empty() || traversal.connectionIndex >= cache.layout.connections.size()) { continue; } - const auto nextDistance = current.distance + std::max(0.0, cost); - const auto distIt = dist.find(next); - if (distIt == dist.end() || nextDistance + 1e-12 < distIt->second) { - dist[next] = nextDistance; - prev[next] = current.zoneId; - pq.push({.distance = nextDistance, .zoneId = next}); + + const auto& connection = cache.layout.connections[traversal.connectionIndex]; + const auto portal = midpoint(connection.centerSpan); + const auto nextDistance = current.distance + + distanceBetween(zoneCenter(current.zoneId), portal) + + distanceBetween(portal, zoneCenter(traversal.nextZoneId)); + const auto distanceIt = distances.find(traversal.nextZoneId); + if (distanceIt == distances.end() || nextDistance + 1e-12 < distanceIt->second) { + distances[traversal.nextZoneId] = nextDistance; + previous[traversal.nextZoneId] = current.zoneId; + queue.push({.distance = nextDistance, .zoneId = traversal.nextZoneId}); } } } diff --git a/src/domain/ScenarioSimulationInternal.h b/src/domain/ScenarioSimulationInternal.h index b2c231d..2d85bb5 100644 --- a/src/domain/ScenarioSimulationInternal.h +++ b/src/domain/ScenarioSimulationInternal.h @@ -12,6 +12,11 @@ #include "engine/Entity.h" #include "engine/WorldQuery.h" +namespace safecrowd::domain { +struct ScenarioConnectionTraversal; +struct ScenarioLayoutCacheResource; +} + namespace safecrowd::domain::simulation_internal { inline constexpr double kDefaultTimeLimitSeconds = 60.0; @@ -32,6 +37,7 @@ inline constexpr double kWaypointCrossingEpsilon = 0.08; inline constexpr double kWaypointProgressEpsilon = 0.02; inline constexpr double kWaypointStallSeconds = 0.75; inline constexpr double kPortalCrossingEpsilon = 0.02; +inline constexpr double kRouteReplanCooldownSeconds = 0.35; struct Bounds { double minX{0.0}; @@ -103,7 +109,9 @@ bool pointInRing(const std::vector& ring, const Point2D& point); Point2D polygonCenter(const Polygon2D& polygon); const Zone2D* findZone(const FacilityLayout2D& layout, const std::string& zoneId); const Connection2D* findConnectionBetween(const FacilityLayout2D& layout, const std::string& from, const std::string& to); -std::optional> zoneRouteToNearestExit(const FacilityLayout2D& layout, const std::string& startZoneId); +std::optional> zoneRouteToNearestExit( + const ScenarioLayoutCacheResource& cache, + const std::string& startZoneId); std::string floorIdForZone(const FacilityLayout2D& layout, const std::string& zoneId); bool isVerticalConnection(const Connection2D& connection); bool canTraverseConnection(const FacilityLayout2D& layout, const Connection2D& connection); @@ -112,6 +120,18 @@ StairEntryDirection stairEntryDirectionForFloor( const Connection2D& connection, const std::string& floorId); FacilityLayout2D layoutForFloor(const FacilityLayout2D& layout, const std::string& floorId); +ScenarioLayoutCacheResource buildScenarioLayoutCache(FacilityLayout2D layout); +const FacilityLayout2D& cachedLayoutForFloor(const ScenarioLayoutCacheResource& cache, const std::string& floorId); +const Zone2D* findCachedZone(const ScenarioLayoutCacheResource& cache, const std::string& zoneId); +const Connection2D* findCachedConnectionBetween( + const ScenarioLayoutCacheResource& cache, + const std::string& from, + const std::string& to); +std::string cachedFloorIdForZone(const ScenarioLayoutCacheResource& cache, const std::string& zoneId); +const std::vector& cachedTraversalsForZone( + const ScenarioLayoutCacheResource& cache, + const std::string& zoneId); +std::string zoneAt(const ScenarioLayoutCacheResource& cache, const Point2D& point, const std::string& floorId); bool routePassageCrossed(const FacilityLayout2D& layout, const EvacuationRoute& route, const Point2D& position, double agentRadius); double speedOf(const Point2D& velocity); std::vector simulationEntities(engine::WorldQuery& query); diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index e1644e8..1b26e9c 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -15,35 +16,40 @@ using namespace simulation_internal; class ScenarioSimulationMotionSystem final : public engine::EngineSystem { public: + ScenarioSimulationMotionSystem() = default; + explicit ScenarioSimulationMotionSystem(FacilityLayout2D layout) - : layout_(std::move(layout)) { + : layoutCache_(buildScenarioLayoutCache(std::move(layout))) { + } + + void configure(engine::EngineWorld& world) override { + if (layoutCache_.has_value() && !world.resources().contains()) { + world.resources().set(*layoutCache_); + } } void update(engine::EngineWorld& world, const engine::EngineStepContext& step) override { (void)step; auto& resources = world.resources(); - activeLayout_ = resources.contains() - ? &resources.get().layout - : nullptr; if (!resources.contains()) { - activeLayout_ = nullptr; return; } + if (!resources.contains()) { + return; + } + const auto& layoutCache = resources.get(); auto& clock = resources.get(); if (clock.complete) { - activeLayout_ = nullptr; return; } const std::uint64_t layoutRevision = resources.contains() ? resources.get().revision : 0U; - const auto clampedDelta = std::max(0.0, resources.get().deltaSeconds); if (clampedDelta <= 0.0) { - activeLayout_ = nullptr; return; } @@ -55,10 +61,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::vector plans; plans.reserve(entities.size()); - advanceRoutesForCurrentZones(query, entities); - advanceRoutesForWaypointProgress(query, 0.0, entities); - replanBlockedExitRoutes(query, entities, clock.elapsedSeconds, layoutRevision); - replanBlockedRouteSegments(query, entities, clock.elapsedSeconds, layoutRevision); + advanceRoutesForCurrentZones(query, entities, layoutCache); + advanceRoutesForWaypointProgress(query, 0.0, entities, layoutCache); + replanBlockedExitRoutes(query, entities, layoutCache, clock.elapsedSeconds, layoutRevision); + replanBlockedRouteSegments(query, entities, layoutCache, clock.elapsedSeconds, layoutRevision); for (const auto entity : entities) { auto& position = query.get(entity); @@ -70,7 +76,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } - const auto floorLayout = layoutForFloor(layout(), route.currentFloorId); + const auto& floorLayout = cachedLayoutForFloor(layoutCache, route.currentFloorId); const auto* destinationZone = findZone(floorLayout, route.destinationZoneId); if (destinationZone != nullptr && pointInRing(destinationZone->area.outline, position.value)) { status.evacuated = true; @@ -94,7 +100,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } const auto routeDirection = (target - position.value) * (1.0 / distance); - const auto maxSpeed = effectiveMaxSpeed(agent, route, position.value); + const auto maxSpeed = effectiveMaxSpeed(layoutCache, agent, route, position.value); const auto desiredVelocity = routeDirection * maxSpeed; double speedScale = 1.0; const auto neighborRadius = static_cast(agent.radius) + kDefaultAgentRadius + kPersonalSpaceBuffer; @@ -127,12 +133,12 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto target = routeWaypointTarget(route, position.value); const auto remainingDistance = distanceBetween(position.value, target); - const auto maxSpeed = effectiveMaxSpeed(agent, route, position.value); + const auto maxSpeed = effectiveMaxSpeed(layoutCache, agent, route, position.value); const auto stepVelocity = clampedToLength(plan.velocity, std::min(maxSpeed, remainingDistance / clampedDelta)); const auto previousPosition = position.value; const auto nextPosition = constrainedMove( - layoutForFloor(layout(), route.currentFloorId), + cachedLayoutForFloor(layoutCache, route.currentFloorId), previousPosition, previousPosition + (stepVelocity * clampedDelta)); position.value = nextPosition; @@ -140,12 +146,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { updateDisplayFloor(route, nextPosition); } - resolveAgentOverlaps(query, entities); - advanceRoutesForCurrentZones(query, entities); - advanceRoutesForWaypointProgress(query, clampedDelta, entities); + resolveAgentOverlaps(query, entities, layoutCache); + advanceRoutesForCurrentZones(query, entities, layoutCache); + advanceRoutesForWaypointProgress(query, clampedDelta, entities, layoutCache); advanceClock(query, clock, entities, clampedDelta); resources.set(ScenarioSimulationStepResource{}); - activeLayout_ = nullptr; } private: @@ -165,22 +170,24 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::string destinationZoneId{}; }; - const FacilityLayout2D& layout() const { - return activeLayout_ == nullptr ? layout_ : *activeLayout_; - } - - const Connection2D* findConnectionById(const std::string& connectionId) const { + const Connection2D* findConnectionById( + const ScenarioLayoutCacheResource& layoutCache, + const std::string& connectionId) const { if (connectionId.empty()) { return nullptr; } - const auto it = std::find_if(layout().connections.begin(), layout().connections.end(), [&](const auto& connection) { - return connection.id == connectionId; - }); - return it == layout().connections.end() ? nullptr : &(*it); + const auto it = std::find_if( + layoutCache.layout.connections.begin(), + layoutCache.layout.connections.end(), + [&](const auto& connection) { + return connection.id == connectionId; + }); + return it == layoutCache.layout.connections.end() ? nullptr : &(*it); } - bool nextConnectionBlocked(const EvacuationRoute& route) const { - if (route.nextWaypointIndex >= route.waypoints.size() || route.nextWaypointIndex >= route.waypointConnectionIds.size()) { + bool nextConnectionBlocked(const ScenarioLayoutCacheResource& layoutCache, const EvacuationRoute& route) const { + if (route.nextWaypointIndex >= route.waypoints.size() + || route.nextWaypointIndex >= route.waypointConnectionIds.size()) { return false; } for (std::size_t index = route.nextWaypointIndex; index < route.waypointConnectionIds.size(); ++index) { @@ -188,15 +195,18 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { if (connectionId.empty()) { continue; } - const auto* connection = findConnectionById(connectionId); + const auto* connection = findConnectionById(layoutCache, connectionId); return connection != nullptr && connection->directionality == TravelDirection::Closed; } return false; } - RoutePlan routePlanToNearestExit(const Point2D& start, const std::string& startZoneId) const { + RoutePlan routePlanToNearestExit( + const ScenarioLayoutCacheResource& layoutCache, + const Point2D& start, + const std::string& startZoneId) const { RoutePlan plan; - auto zoneRoute = zoneRouteToNearestExit(layout(), startZoneId); + auto zoneRoute = zoneRouteToNearestExit(layoutCache, startZoneId); if (!zoneRoute.has_value() || zoneRoute->empty()) { return plan; } @@ -226,11 +236,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { for (std::size_t index = 1; index < zoneRoute->size(); ++index) { const auto& fromZoneId = (*zoneRoute)[index - 1]; const auto& toZoneId = (*zoneRoute)[index]; - if (const auto* connection = findConnectionBetween(layout(), fromZoneId, toZoneId)) { + if (const auto* connection = findCachedConnectionBetween(layoutCache, fromZoneId, toZoneId)) { const auto passage = passageWithClearance(*connection, kCandidateClearance); - const auto fromFloorId = floorIdForZone(layout(), fromZoneId); - const auto toFloorId = floorIdForZone(layout(), toZoneId); - const auto segmentLayout = layoutForFloor(layout(), fromFloorId); + const auto fromFloorId = cachedFloorIdForZone(layoutCache, fromZoneId); + const auto toFloorId = cachedFloorIdForZone(layoutCache, toZoneId); + const auto& segmentLayout = cachedLayoutForFloor(layoutCache, fromFloorId); const auto target = closestPointOnSegment(segmentStart, passage.start, passage.end); const auto segment = buildPath(segmentLayout, segmentStart, target, kCandidateClearance); appendSegment( @@ -245,11 +255,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } - if (const auto* exitZone = findZone(layout(), zoneRoute->back())) { + if (const auto* exitZone = findCachedZone(layoutCache, zoneRoute->back())) { const auto exitCenter = polygonCenter(exitZone->area); if (distanceBetween(segmentStart, exitCenter) > kArrivalEpsilon) { const auto exitFloorId = exitZone->floorId; - const auto segmentLayout = layoutForFloor(layout(), exitFloorId); + const auto& segmentLayout = cachedLayoutForFloor(layoutCache, exitFloorId); const auto segment = buildPath(segmentLayout, segmentStart, exitCenter, kCandidateClearance); appendSegment(segment, pointPassage(exitCenter), std::string{}, exitZone->id, exitFloorId, std::string{}, false); } @@ -292,7 +302,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { void advanceRoutesForWaypointProgress( engine::WorldQuery& query, double deltaSeconds, - const std::vector& entities) const { + const std::vector& entities, + const ScenarioLayoutCacheResource& layoutCache) const { for (const auto entity : entities) { const auto& status = query.get(entity); if (status.evacuated) { @@ -303,7 +314,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto& agent = query.get(entity); auto& route = query.get(entity); while (route.nextWaypointIndex < route.waypoints.size()) { - const auto floorLayout = layoutForFloor(layout(), route.currentFloorId); + const auto& floorLayout = cachedLayoutForFloor(layoutCache, route.currentFloorId); if (routePassageCrossed(floorLayout, route, position.value, agent.radius)) { advanceRouteWaypoint(route, position.value); continue; @@ -354,7 +365,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } - void advanceRoutesForCurrentZones(engine::WorldQuery& query, const std::vector& entities) const { + void advanceRoutesForCurrentZones( + engine::WorldQuery& query, + const std::vector& entities, + const ScenarioLayoutCacheResource& layoutCache) const { for (const auto entity : entities) { const auto& status = query.get(entity); if (status.evacuated) { @@ -363,7 +377,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto& position = query.get(entity); auto& route = query.get(entity); - const auto currentZoneId = zoneAt(position.value, route.currentFloorId); + const auto currentZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); while (!currentZoneId.empty() && route.nextWaypointIndex < route.waypointZoneIds.size()) { auto matchedIndex = route.waypointZoneIds.size(); for (auto index = route.nextWaypointIndex; index < route.waypointZoneIds.size(); ++index) { @@ -386,6 +400,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { void replanBlockedExitRoutes( engine::WorldQuery& query, const std::vector& entities, + const ScenarioLayoutCacheResource& layoutCache, double elapsedSeconds, std::uint64_t layoutRevision) const { for (const auto entity : entities) { @@ -401,23 +416,22 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { route.nextSegmentReplanSeconds = 0.0; } - const bool blockedAhead = nextConnectionBlocked(route); + const bool blockedAhead = nextConnectionBlocked(layoutCache, route); if (!blockedAhead && !route.noExitAvailable) { continue; } - if (elapsedSeconds + 1e-9 < route.nextExitReplanSeconds) { continue; } const auto& position = query.get(entity); - const auto startZoneId = zoneAt(position.value, route.currentFloorId); + const auto startZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); if (startZoneId.empty()) { route.nextExitReplanSeconds = elapsedSeconds + kExitReplanCooldownSeconds; continue; } - const auto plan = routePlanToNearestExit(position.value, startZoneId); + const auto plan = routePlanToNearestExit(layoutCache, position.value, startZoneId); if (plan.destinationZoneId.empty()) { route.noExitAvailable = true; route.destinationZoneId.clear(); @@ -461,6 +475,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { void replanBlockedRouteSegments( engine::WorldQuery& query, const std::vector& entities, + const ScenarioLayoutCacheResource& layoutCache, double elapsedSeconds, std::uint64_t layoutRevision) const { for (const auto entity : entities) { @@ -483,7 +498,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { if (route.nextWaypointIndex >= route.waypoints.size()) { continue; } - if (nextConnectionBlocked(route)) { + if (nextConnectionBlocked(layoutCache, route)) { continue; } if (elapsedSeconds + 1e-9 < route.nextSegmentReplanSeconds) { @@ -492,7 +507,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto target = routeWaypointTarget(route, position.value); const auto clearance = static_cast(agent.radius) + kPathClearance; - const auto floorLayout = layoutForFloor(layout(), route.currentFloorId); + const auto& floorLayout = cachedLayoutForFloor(layoutCache, route.currentFloorId); if (lineOfSightClear(floorLayout, position.value, target, clearance)) { continue; } @@ -518,17 +533,22 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto originalConnectionId = route.nextWaypointIndex < route.waypointConnectionIds.size() ? route.waypointConnectionIds[route.nextWaypointIndex] : std::string{}; - route.waypoints.erase(route.waypoints.begin() + static_cast(route.nextWaypointIndex)); - route.waypointPassages.erase(route.waypointPassages.begin() + static_cast(route.nextWaypointIndex)); - route.waypointFromZoneIds.erase(route.waypointFromZoneIds.begin() + static_cast(route.nextWaypointIndex)); - route.waypointZoneIds.erase(route.waypointZoneIds.begin() + static_cast(route.nextWaypointIndex)); - route.waypointFloorIds.erase(route.waypointFloorIds.begin() + static_cast(route.nextWaypointIndex)); - route.waypointConnectionIds.erase( - route.waypointConnectionIds.begin() + static_cast(route.nextWaypointIndex)); - if (route.nextWaypointIndex < route.waypointVerticalTransitions.size()) { - route.waypointVerticalTransitions.erase( - route.waypointVerticalTransitions.begin() + static_cast(route.nextWaypointIndex)); - } + const bool originalVerticalTransition = route.nextWaypointIndex < route.waypointVerticalTransitions.size() + ? route.waypointVerticalTransitions[route.nextWaypointIndex] + : false; + + const auto eraseWaypointEntry = [&](auto& values) { + if (route.nextWaypointIndex < values.size()) { + values.erase(values.begin() + static_cast(route.nextWaypointIndex)); + } + }; + eraseWaypointEntry(route.waypoints); + eraseWaypointEntry(route.waypointPassages); + eraseWaypointEntry(route.waypointFromZoneIds); + eraseWaypointEntry(route.waypointZoneIds); + eraseWaypointEntry(route.waypointFloorIds); + eraseWaypointEntry(route.waypointConnectionIds); + eraseWaypointEntry(route.waypointVerticalTransitions); std::vector replacementPassages; replacementPassages.reserve(replacement.size()); @@ -546,38 +566,22 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::vector replacementConnectionIds(replacement.size(), std::string{}); replacementConnectionIds.back() = originalConnectionId; std::vector replacementVerticalTransitions(replacement.size(), false); - if (route.nextWaypointIndex < route.waypointVerticalTransitions.size()) { - replacementVerticalTransitions.back() = route.waypointVerticalTransitions[route.nextWaypointIndex]; - } - - route.waypoints.insert( - route.waypoints.begin() + static_cast(route.nextWaypointIndex), - replacement.begin(), - replacement.end()); - route.waypointPassages.insert( - route.waypointPassages.begin() + static_cast(route.nextWaypointIndex), - replacementPassages.begin(), - replacementPassages.end()); - route.waypointFromZoneIds.insert( - route.waypointFromZoneIds.begin() + static_cast(route.nextWaypointIndex), - replacementFromZoneIds.begin(), - replacementFromZoneIds.end()); - route.waypointZoneIds.insert( - route.waypointZoneIds.begin() + static_cast(route.nextWaypointIndex), - replacementZoneIds.begin(), - replacementZoneIds.end()); - route.waypointFloorIds.insert( - route.waypointFloorIds.begin() + static_cast(route.nextWaypointIndex), - replacementFloorIds.begin(), - replacementFloorIds.end()); - route.waypointConnectionIds.insert( - route.waypointConnectionIds.begin() + static_cast(route.nextWaypointIndex), - replacementConnectionIds.begin(), - replacementConnectionIds.end()); - route.waypointVerticalTransitions.insert( - route.waypointVerticalTransitions.begin() + static_cast(route.nextWaypointIndex), - replacementVerticalTransitions.begin(), - replacementVerticalTransitions.end()); + replacementVerticalTransitions.back() = originalVerticalTransition; + + const auto insertWaypointEntries = [&](auto& values, const auto& inserts) { + values.insert( + values.begin() + static_cast(std::min(route.nextWaypointIndex, values.size())), + inserts.begin(), + inserts.end()); + }; + + insertWaypointEntries(route.waypoints, replacement); + insertWaypointEntries(route.waypointPassages, replacementPassages); + insertWaypointEntries(route.waypointFromZoneIds, replacementFromZoneIds); + insertWaypointEntries(route.waypointZoneIds, replacementZoneIds); + insertWaypointEntries(route.waypointFloorIds, replacementFloorIds); + insertWaypointEntries(route.waypointConnectionIds, replacementConnectionIds); + insertWaypointEntries(route.waypointVerticalTransitions, replacementVerticalTransitions); route.currentSegmentStart = position.value; route.previousDistanceToWaypoint = distanceToRouteWaypoint(route, position.value); route.stalledSeconds = 0.0; @@ -585,7 +589,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } - void resolveAgentOverlaps(engine::WorldQuery& query, const std::vector& entities) const { + void resolveAgentOverlaps( + engine::WorldQuery& query, + const std::vector& entities, + const ScenarioLayoutCacheResource& layoutCache) const { for (int iteration = 0; iteration < kOverlapRelaxationIterations; ++iteration) { const auto spatialIndex = buildAgentSpatialIndex(query, entities, 1.0); std::unordered_set checkedPairs; @@ -634,11 +641,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto& firstRoute = query.get(first); const auto& secondRoute = query.get(second); firstPosition.value = constrainedMove( - layoutForFloor(layout(), firstRoute.currentFloorId), + cachedLayoutForFloor(layoutCache, firstRoute.currentFloorId), firstPosition.value, firstPosition.value + (direction * push)); secondPosition.value = constrainedMove( - layoutForFloor(layout(), secondRoute.currentFloorId), + cachedLayoutForFloor(layoutCache, secondRoute.currentFloorId), secondPosition.value, secondPosition.value - (direction * push)); } @@ -669,26 +676,18 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { clock.complete = totalAgentCount > 0 && evacuatedAgentCount >= totalAgentCount; } - std::string zoneAt(const Point2D& point, const std::string& floorId) const { - for (const auto& zone : layout().zones) { - if (!floorId.empty() && !zone.floorId.empty() && zone.floorId != floorId) { - continue; - } - if (pointInRing(zone.area.outline, point)) { - return zone.id; - } - } - return {}; - } - bool currentWaypointIsVertical(const EvacuationRoute& route) const { return route.nextWaypointIndex < route.waypointVerticalTransitions.size() && route.waypointVerticalTransitions[route.nextWaypointIndex]; } - double effectiveMaxSpeed(const Agent& agent, const EvacuationRoute& route, const Point2D& position) const { - const auto currentZoneId = zoneAt(position, route.currentFloorId); - const auto* zone = findZone(layout(), currentZoneId); + double effectiveMaxSpeed( + const ScenarioLayoutCacheResource& layoutCache, + const Agent& agent, + const EvacuationRoute& route, + const Point2D& position) const { + const auto currentZoneId = zoneAt(layoutCache, position, route.currentFloorId); + const auto* zone = findCachedZone(layoutCache, currentZoneId); const bool inStairZone = zone != nullptr && (zone->kind == ZoneKind::Stair || zone->isStair || zone->isRamp); const bool onVerticalTransition = currentWaypointIsVertical(route); @@ -719,12 +718,15 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } } - FacilityLayout2D layout_{}; - const FacilityLayout2D* activeLayout_{nullptr}; + std::optional layoutCache_{}; }; } // namespace +std::unique_ptr makeScenarioSimulationMotionSystem() { + return std::make_unique(); +} + std::unique_ptr makeScenarioSimulationMotionSystem(FacilityLayout2D layout) { return std::make_unique(std::move(layout)); } diff --git a/src/domain/ScenarioSimulationRunner.cpp b/src/domain/ScenarioSimulationRunner.cpp index 4ae9623..29e2916 100644 --- a/src/domain/ScenarioSimulationRunner.cpp +++ b/src/domain/ScenarioSimulationRunner.cpp @@ -25,6 +25,7 @@ ScenarioSimulationRunner::ScenarioSimulationRunner(FacilityLayout2D layout, Scen void ScenarioSimulationRunner::reset(FacilityLayout2D layout, ScenarioDraft scenario) { layout_ = std::move(layout); + layoutCache_ = buildScenarioLayoutCache(layout_); scenario_ = std::move(scenario); frame_ = {}; riskSnapshot_ = {}; @@ -87,11 +88,11 @@ std::vector ScenarioSimulationRunner::createAgentSeeds() cons const auto position = placementPoint(placement, index); auto placementFloorId = placement.floorId; if (placementFloorId.empty() && !placement.zoneId.empty()) { - placementFloorId = floorIdForZone(layout_, placement.zoneId); + placementFloorId = cachedFloorIdForZone(layoutCache_, placement.zoneId); } auto startZoneId = placement.zoneId; if (!startZoneId.empty() && !placementFloorId.empty()) { - const auto zoneFloorId = floorIdForZone(layout_, startZoneId); + const auto zoneFloorId = cachedFloorIdForZone(layoutCache_, startZoneId); if (!zoneFloorId.empty() && zoneFloorId != placementFloorId) { startZoneId.clear(); } @@ -100,7 +101,7 @@ std::vector ScenarioSimulationRunner::createAgentSeeds() cons startZoneId = zoneAt(position, placementFloorId); } if (placementFloorId.empty()) { - placementFloorId = floorIdForZone(layout_, startZoneId); + placementFloorId = cachedFloorIdForZone(layoutCache_, startZoneId); } const auto route = routePlan(position, startZoneId); const auto speed = speedOf(placement.initialVelocity); @@ -223,11 +224,11 @@ ScenarioSimulationRunner::RoutePlan ScenarioSimulationRunner::routePlan(const Po for (std::size_t index = 1; index < zoneRoute->size(); ++index) { const auto& fromZoneId = (*zoneRoute)[index - 1]; const auto& toZoneId = (*zoneRoute)[index]; - if (const auto* connection = findConnectionBetween(layout_, fromZoneId, toZoneId)) { + if (const auto* connection = findCachedConnectionBetween(layoutCache_, fromZoneId, toZoneId)) { const auto passage = passageWithClearance(*connection, kCandidateClearance); - const auto fromFloorId = floorIdForZone(layout_, fromZoneId); - const auto toFloorId = floorIdForZone(layout_, toZoneId); - const auto segmentLayout = layoutForFloor(layout_, fromFloorId); + const auto fromFloorId = cachedFloorIdForZone(layoutCache_, fromZoneId); + const auto toFloorId = cachedFloorIdForZone(layoutCache_, toZoneId); + const auto& segmentLayout = cachedLayoutForFloor(layoutCache_, fromFloorId); const auto target = closestPointOnSegment(segmentStart, passage.start, passage.end); const auto segment = buildPath(segmentLayout, segmentStart, target, kCandidateClearance); appendSegment( @@ -241,11 +242,11 @@ ScenarioSimulationRunner::RoutePlan ScenarioSimulationRunner::routePlan(const Po segmentStart = target; } } - if (const auto* exitZone = findZone(layout_, zoneRoute->back())) { + if (const auto* exitZone = findCachedZone(layoutCache_, zoneRoute->back())) { const auto exitCenter = polygonCenter(exitZone->area); if (distanceBetween(segmentStart, exitCenter) > kArrivalEpsilon) { const auto exitFloorId = exitZone->floorId; - const auto segmentLayout = layoutForFloor(layout_, exitFloorId); + const auto& segmentLayout = cachedLayoutForFloor(layoutCache_, exitFloorId); const auto segment = buildPath(segmentLayout, segmentStart, exitCenter, kCandidateClearance); appendSegment(segment, pointPassage(exitCenter), std::string{}, exitZone->id, exitFloorId, std::string{}, false); } @@ -264,19 +265,11 @@ ScenarioSimulationRunner::RoutePlan ScenarioSimulationRunner::routePlan(const Po } std::optional> ScenarioSimulationRunner::zoneRouteToExit(const std::string& startZoneId) const { - return zoneRouteToNearestExit(layout_, startZoneId); + return zoneRouteToNearestExit(layoutCache_, startZoneId); } std::string ScenarioSimulationRunner::zoneAt(const Point2D& point, const std::string& floorId) const { - for (const auto& zone : layout_.zones) { - if (!floorId.empty() && !zone.floorId.empty() && zone.floorId != floorId) { - continue; - } - if (pointInRing(zone.area.outline, point)) { - return zone.id; - } - } - return {}; + return simulation_internal::zoneAt(layoutCache_, point, floorId); } Point2D ScenarioSimulationRunner::placementPoint(const InitialPlacement2D& placement, std::size_t index) const { diff --git a/src/domain/ScenarioSimulationRunner.h b/src/domain/ScenarioSimulationRunner.h index 1b088b8..28433cb 100644 --- a/src/domain/ScenarioSimulationRunner.h +++ b/src/domain/ScenarioSimulationRunner.h @@ -52,6 +52,7 @@ class ScenarioSimulationRunner { Point2D placementPoint(const InitialPlacement2D& placement, std::size_t index) const; FacilityLayout2D layout_{}; + ScenarioLayoutCacheResource layoutCache_{}; ScenarioDraft scenario_{}; std::unique_ptr runtime_{}; SimulationFrame frame_{}; diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index dd52a8b..5959650 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -1,10 +1,11 @@ #include "domain/ScenarioSimulationSystems.h" +#include "domain/ScenarioSimulationInternal.h" + #include #include #include #include -#include #include #include @@ -59,23 +60,15 @@ bool intervalContains(const ConnectionBlockIntervalDraft& interval, double timeS } bool connectionShouldBeBlocked(const ConnectionBlockDraft& block, double timeSeconds) { + if (block.connectionId.empty()) { + return false; + } if (block.intervals.empty()) { return true; } - - for (const auto& interval : block.intervals) { - if (intervalContains(interval, timeSeconds)) { - return true; - } - } - return false; -} - -const Connection2D* findConnectionById(const FacilityLayout2D& layout, const std::string& connectionId) { - const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { - return connection.id == connectionId; + return std::any_of(block.intervals.begin(), block.intervals.end(), [&](const auto& interval) { + return intervalContains(interval, timeSeconds); }); - return it == layout.connections.end() ? nullptr : &(*it); } Connection2D* findConnectionById(FacilityLayout2D& layout, const std::string& connectionId) { @@ -85,6 +78,44 @@ Connection2D* findConnectionById(FacilityLayout2D& layout, const std::string& co return it == layout.connections.end() ? nullptr : &(*it); } +std::unordered_set activeBlockedConnectionIds( + const FacilityLayout2D& layout, + const std::vector& blocks, + double elapsedSeconds) { + std::unordered_set ids; + ids.reserve(blocks.size()); + for (const auto& block : blocks) { + if (!connectionShouldBeBlocked(block, elapsedSeconds)) { + continue; + } + if (std::any_of(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + })) { + ids.insert(block.connectionId); + } + } + return ids; +} + +FacilityLayout2D layoutWithConnectionBlocks( + FacilityLayout2D layout, + const std::unordered_set& blockedConnectionIds) { + for (const auto& connectionId : blockedConnectionIds) { + auto* connection = findConnectionById(layout, connectionId); + if (connection == nullptr) { + continue; + } + connection->directionality = TravelDirection::Closed; + layout.barriers.push_back(Barrier2D{ + .id = "control-block-" + connectionId, + .floorId = connection->floorId, + .geometry = Polyline2D{.vertices = {connection->centerSpan.start, connection->centerSpan.end}, .closed = false}, + .blocksMovement = true, + }); + } + return layout; +} + class ScenarioControlSystem final : public engine::EngineSystem { public: ScenarioControlSystem(FacilityLayout2D baseLayout, std::vector blocks) @@ -93,7 +124,7 @@ class ScenarioControlSystem final : public engine::EngineSystem { } void configure(engine::EngineWorld& world) override { - world.resources().set(ScenarioLayoutResource{.layout = baseLayout_}); + world.resources().set(simulation_internal::buildScenarioLayoutCache(baseLayout_)); world.resources().set(ScenarioLayoutRevisionResource{.revision = revision_}); } @@ -101,52 +132,30 @@ class ScenarioControlSystem final : public engine::EngineSystem { (void)step; auto& resources = world.resources(); - if (!resources.contains()) { - resources.set(ScenarioLayoutResource{.layout = baseLayout_}); - } - double elapsedSeconds = 0.0; if (resources.contains()) { elapsedSeconds = std::max(0.0, resources.get().elapsedSeconds); } - auto& layout = resources.get().layout; - layout = baseLayout_; - - std::unordered_set blockedConnectionIds; - blockedConnectionIds.reserve(blocks_.size()); - for (const auto& block : blocks_) { - if (block.connectionId.empty()) { - continue; - } - - auto* connection = findConnectionById(layout, block.connectionId); - if (connection == nullptr) { - continue; - } - - if (connectionShouldBeBlocked(block, elapsedSeconds)) { - connection->directionality = TravelDirection::Closed; - blockedConnectionIds.insert(block.connectionId); - layout.barriers.push_back(Barrier2D{ - .id = "control-block-" + block.connectionId, - .floorId = connection->floorId, - .geometry = Polyline2D{.vertices = {connection->centerSpan.start, connection->centerSpan.end}, .closed = false}, - .blocksMovement = true, - }); - } else { - // Restored by layout reset from baseLayout_. + auto blockedConnectionIds = activeBlockedConnectionIds(baseLayout_, blocks_, elapsedSeconds); + const bool changed = blockedConnectionIds != previousBlockedConnectionIds_; + const bool cacheMissing = !resources.contains(); + if (!changed && !cacheMissing) { + if (!resources.contains() + || resources.get().revision != revision_) { + resources.set(ScenarioLayoutRevisionResource{.revision = revision_}); } + return; } - if (blockedConnectionIds != previousBlockedConnectionIds_) { + if (changed) { ++revision_; - previousBlockedConnectionIds_ = std::move(blockedConnectionIds); - resources.set(ScenarioLayoutRevisionResource{.revision = revision_}); - } else if (!resources.contains() - || resources.get().revision != revision_) { - resources.set(ScenarioLayoutRevisionResource{.revision = revision_}); + previousBlockedConnectionIds_ = blockedConnectionIds; } + + auto controlledLayout = layoutWithConnectionBlocks(baseLayout_, blockedConnectionIds); + resources.set(simulation_internal::buildScenarioLayoutCache(std::move(controlledLayout))); + resources.set(ScenarioLayoutRevisionResource{.revision = revision_}); } private: diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index 077feb1..e3c6565 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -43,6 +43,19 @@ struct ScenarioAgentSpatialIndexResource { std::unordered_map> cells{}; }; +struct ScenarioConnectionTraversal { + std::string nextZoneId{}; + std::size_t connectionIndex{0}; +}; + +struct ScenarioLayoutCacheResource { + FacilityLayout2D layout{}; + std::unordered_map floorLayouts{}; + std::unordered_map zoneIndices{}; + std::unordered_map zoneFloorIds{}; + std::unordered_map> traversableConnectionsByZone{}; +}; + struct ScenarioRiskMetricsResource { ScenarioRiskSnapshot snapshot{}; ScenarioRiskSnapshot peakSnapshot{}; @@ -69,6 +82,7 @@ std::vector scenarioNearbyAgents( const Point2D& point, double radius); +std::unique_ptr makeScenarioSimulationMotionSystem(); std::unique_ptr makeScenarioControlSystem( FacilityLayout2D baseLayout, std::vector blocks); diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index f1551b0..0b31eb5 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -121,3 +121,43 @@ SC_TEST(DemoLayoutsProvidesRuntimeFacilityLayout) { const auto issues = validator.validate(layout); SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); } + +SC_TEST(DemoFixtureServiceBuildsTwoFloorFixture) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.create2FDemoFixture(); + const auto& layout = fixture.layout; + const auto& population = fixture.population; + + SC_EXPECT_EQ(layout.id, std::string(safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::LayoutId)); + SC_EXPECT_EQ(layout.name, std::string("2F demo")); + SC_EXPECT_EQ(layout.levelId, std::string(safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::Floor1Id)); + SC_EXPECT_EQ(layout.floors.size(), std::size_t{2}); + SC_EXPECT_TRUE(containsZoneId(layout.zones, safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::HallZoneL1Id)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::HallZoneL2Id)); + SC_EXPECT_TRUE(containsConnectionKind(layout.connections, safecrowd::domain::ConnectionKind::Stair)); + + SC_EXPECT_EQ(population.initialPlacements.size(), std::size_t{1}); + SC_EXPECT_EQ(population.initialPlacements.front().zoneId, std::string(safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::HallZoneL2Id)); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); +} + +SC_TEST(DemoLayoutsProvidesTwoFloorDemoLayout) { + const auto layout = safecrowd::domain::DemoLayouts::demoTwoFloorFacility(); + + SC_EXPECT_EQ(layout.id, std::string(safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::LayoutId)); + SC_EXPECT_EQ(layout.name, std::string("2F demo")); + SC_EXPECT_EQ(layout.floors.size(), std::size_t{2}); + SC_EXPECT_TRUE(containsZoneId(layout.zones, safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::ExitZoneL1Id)); + SC_EXPECT_TRUE(containsConnectionId(layout.connections, safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::LeftStairLinkId)); + SC_EXPECT_TRUE(containsConnectionId(layout.connections, safecrowd::domain::DemoLayouts::TwoFloorFacilityIds::RightStairLinkId)); + for (const auto& connection : layout.connections) { + SC_EXPECT_NEAR(connection.effectiveWidth, spanLength(connection.centerSpan), 1e-9); + } + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); +} diff --git a/tests/FacilityLayoutBuilderTests.cpp b/tests/FacilityLayoutBuilderTests.cpp index c978f9f..77eb25e 100644 --- a/tests/FacilityLayoutBuilderTests.cpp +++ b/tests/FacilityLayoutBuilderTests.cpp @@ -130,6 +130,39 @@ SC_TEST(ImportValidationServiceReportsMissingExitDisconnectedAreaAndNarrowConnec SC_EXPECT_TRUE(safecrowd::domain::hasBlockingImportIssue(issues)); } +SC_TEST(ImportValidationServiceReportsMissingRoomAsNonBlockingWarning) { + safecrowd::domain::FacilityLayout2D layout; + layout.id = "layout-L1"; + layout.levelId = "L1"; + layout.floors.push_back({ + .id = "L1", + .label = "Floor 1", + }); + layout.zones.push_back({ + .id = "zone-exit-1", + .floorId = "L1", + .kind = safecrowd::domain::ZoneKind::Exit, + .label = "Exit 1", + .area = { + .outline = { + {0.0, 0.0}, + {4.0, 0.0}, + {4.0, 4.0}, + {0.0, 4.0}, + }, + }, + }); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(containsIssueCode(issues, safecrowd::domain::ImportIssueCode::MissingRoom)); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); + SC_EXPECT_EQ( + std::string(safecrowd::domain::toString(safecrowd::domain::ImportIssueCode::MissingRoom)), + std::string("MissingRoom")); +} + SC_TEST(ImportValidationServiceReportsInvalidFloorReferences) { safecrowd::domain::FacilityLayout2D layout; layout.id = "layout-L1"; diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 4f69d3b..75564b3 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -260,10 +260,13 @@ SC_TEST(ScenarioControlSystem_BlocksConnectionsUsingScenarioClock) { runtime.stepFrame(0.0); { - const auto& scenarioLayout = runtime.world().resources().get().layout; - SC_EXPECT_EQ(scenarioLayout.connections.size(), std::size_t{1}); - SC_EXPECT_EQ(scenarioLayout.connections.front().directionality, safecrowd::domain::TravelDirection::Closed); - SC_EXPECT_EQ(scenarioLayout.barriers.size(), std::size_t{1}); + const auto& layoutCache = + runtime.world().resources().get(); + SC_EXPECT_EQ(layoutCache.layout.connections.size(), std::size_t{1}); + SC_EXPECT_EQ( + layoutCache.layout.connections.front().directionality, + safecrowd::domain::TravelDirection::Closed); + SC_EXPECT_EQ(layoutCache.layout.barriers.size(), std::size_t{1}); } auto& clock = runtime.world().resources().get(); @@ -271,10 +274,13 @@ SC_TEST(ScenarioControlSystem_BlocksConnectionsUsingScenarioClock) { runtime.stepFrame(0.0); { - const auto& scenarioLayout = runtime.world().resources().get().layout; - SC_EXPECT_EQ(scenarioLayout.connections.size(), std::size_t{1}); - SC_EXPECT_EQ(scenarioLayout.connections.front().directionality, safecrowd::domain::TravelDirection::Bidirectional); - SC_EXPECT_EQ(scenarioLayout.barriers.size(), std::size_t{0}); + const auto& layoutCache = + runtime.world().resources().get(); + SC_EXPECT_EQ(layoutCache.layout.connections.size(), std::size_t{1}); + SC_EXPECT_EQ( + layoutCache.layout.connections.front().directionality, + safecrowd::domain::TravelDirection::Bidirectional); + SC_EXPECT_EQ(layoutCache.layout.barriers.size(), std::size_t{0}); } }