diff --git a/src/OpenRoad.cc b/src/OpenRoad.cc index eef476375e0..d6c750925b5 100644 --- a/src/OpenRoad.cc +++ b/src/OpenRoad.cc @@ -139,6 +139,7 @@ OpenRoad::~OpenRoad() delete finale_; delete ram_gen_; delete antenna_checker_; + delete web_server_; odb::dbDatabase::destroy(db_); delete partitionMgr_; delete pdngen_; @@ -147,7 +148,6 @@ OpenRoad::~OpenRoad() delete stt_builder_; delete dft_; delete estimate_parasitics_; - delete web_server_; delete logger_; delete verilog_reader_; delete callback_handler_; diff --git a/src/OpenRoad.tcl b/src/OpenRoad.tcl index 205d07ab125..f46b1913eb8 100644 --- a/src/OpenRoad.tcl +++ b/src/OpenRoad.tcl @@ -211,6 +211,7 @@ proc read_3dbx { args } { utl::error "ORD" 73 "$filename is not readable." } ord::read_3dbx_cmd $filename + check_3dblox } sta::define_cmd_args "read_3dblox_bmap" {filename} diff --git a/src/gui/src/chiplet3DWidget.cpp b/src/gui/src/chiplet3DWidget.cpp index 003f084f9e7..ccecf7ef125 100644 --- a/src/gui/src/chiplet3DWidget.cpp +++ b/src/gui/src/chiplet3DWidget.cpp @@ -3,15 +3,26 @@ #include "chiplet3DWidget.h" +#include #include #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include "gui/gui.h" #include "odb/db.h" #include "odb/dbTransform.h" #include "odb/geom.h" @@ -32,32 +43,69 @@ constexpr float kGridSteps = 5.0f; constexpr float kGridZOffsetFactor = 0.05f; constexpr int kGridLineCount = 5; constexpr float kRotationSensitivity = 2.0f; +static_assert( + kRotationSensitivity > 0.0f, + "kRotationSensitivity must be strictly positive to avoid division by zero"); constexpr float kPanSensitivity = 0.002f; constexpr float kZoomInFactor = 0.9f; constexpr float kZoomOutFactor = 1.1f; +constexpr float kMinDistanceFraction = 0.1f; +constexpr float kMaxDistanceFraction = 20.0f; +constexpr float kZScale = 2.0f; +constexpr float kZoomFactorDefault = 2.2f; +constexpr float kZoomFactorExpanded = 2.5f; +constexpr float kCoordinateTolerance = 1e-3f; +constexpr double kPolygonOffsetChiplet = 1e-5; +constexpr double kPolygonOffsetFace = 1e-6; +constexpr double kDepthTolerance = 1e-5; +constexpr double kSortZTolerance = 1e-6; +constexpr float kMouseClickTolerance = 1e-6f; -static const std::array kColorPalette = {{ +// Rendering constants +const QColor kBackgroundColor(26, 26, 26); +const QColor kGridColor(76, 76, 76); +constexpr int kLine3DWidth = 2; +constexpr float kHighlightZOffset = 2.0f; +constexpr int kTopFaceLightness = 110; +constexpr int kBottomFaceDarkness = 110; +constexpr int kSideFaceDarkness = 105; +static const std::array kColorPalette = {{ {0.0f, 1.0f, 0.0f}, // Green - {1.0f, 1.0f, 0.0f}, // Yellow + {0.0f, 0.5f, 0.5f}, // Teal {0.0f, 1.0f, 1.0f}, // Cyan {1.0f, 0.0f, 1.0f}, // Magenta {1.0f, 0.5f, 0.0f}, // Orange {0.5f, 0.5f, 1.0f}, // Blue-ish - {1.0f, 0.0f, 0.0f} // Red + {1.0f, 0.0f, 0.0f}, // Red + {0.5f, 0.0f, 0.5f}, // Purple + {1.0f, 0.5f, 0.5f} // Pink }}; } // namespace namespace gui { -Chiplet3DWidget::Chiplet3DWidget(QWidget* parent) : QWidget(parent) +Chiplet3DWidget::Chiplet3DWidget(const SelectionSet& selected, + const HighlightSet& highlighted, + QWidget* parent) + : QWidget(parent), selected_(selected), highlighted_(highlighted) { - // Optimize for painting speed setAttribute(Qt::WA_OpaquePaintEvent); } +Chiplet3DWidget::~Chiplet3DWidget() +{ + if (animate_selection_ != nullptr) { + animate_selection_->timer->stop(); + animate_selection_ = nullptr; + } +} + void Chiplet3DWidget::setChip(odb::dbChip* chip) { chip_ = chip; + pan_x_ = 0.0f; + pan_y_ = 0.0f; + rotation_ = QQuaternion(); buildGeometries(); update(); } @@ -67,29 +115,159 @@ void Chiplet3DWidget::setLogger(utl::Logger* logger) logger_ = logger; } -void Chiplet3DWidget::buildGeometries() +void Chiplet3DWidget::zoomTo(const Selected& selection) { - if (!chip_) { + if (!chip_ || !selection) { return; } + + odb::Rect bbox; + if (!selection.getBBox(bbox)) { + return; + } + const odb::Cuboid global_cuboid = chip_->getCuboid(); - const odb::UnfoldedModel* model = chip_->getDb()->getUnfoldedModel(); - if (!model) { + + float target_x = bbox.xCenter() - global_cuboid.xCenter(); + float target_y = bbox.yCenter() - global_cuboid.yCenter(); + float target_z = 0.0f; + + float max_dim = std::max(bbox.dx(), bbox.dy()); + float zoom_factor = kZoomFactorDefault; + + if (selection.getTypeName() == "Marker") { + auto marker_ptr = std::any_cast(&selection.getObject()); + if (marker_ptr && *marker_ptr) { + auto marker = *marker_ptr; + bool has_cuboid = false; + int min_x = std::numeric_limits::max(); + int min_y = std::numeric_limits::max(); + int min_z = std::numeric_limits::max(); + int max_x = std::numeric_limits::lowest(); + int max_y = std::numeric_limits::lowest(); + int max_z = std::numeric_limits::lowest(); + + for (const auto& shape : marker->getShapes()) { + if (std::holds_alternative(shape)) { + const odb::Cuboid cuboid = std::get(shape); + min_x = std::min(min_x, cuboid.xMin()); + min_y = std::min(min_y, cuboid.yMin()); + min_z = std::min(min_z, cuboid.zMin()); + max_x = std::max(max_x, cuboid.xMax()); + max_y = std::max(max_y, cuboid.yMax()); + max_z = std::max(max_z, cuboid.zMax()); + has_cuboid = true; + } + } + + if (has_cuboid) { + float cx = (min_x + max_x) / 2.0f; + float cy = (min_y + max_y) / 2.0f; + float cz = (min_z + max_z) / 2.0f; + + target_x = cx - global_cuboid.xCenter(); + target_y = cy - global_cuboid.yCenter(); + target_z = (cz - global_cuboid.zMin()) * kZScale; + + float dx = max_x - min_x; + float dy = max_y - min_y; + float dz = (max_z - min_z) * kZScale; + + max_dim = std::max({dx, dy, dz}); + zoom_factor = kZoomFactorExpanded; + } + } + } + + QQuaternion pitch = QQuaternion::fromAxisAndAngle(QVector3D(1, 0, 0), -45.0f); + QQuaternion yaw = QQuaternion::fromAxisAndAngle(QVector3D(0, 0, 1), 45.0f); + rotation_ = pitch * yaw; + + QVector3D p_drawn(target_x, target_y, target_z); + QVector3D p_rot = rotation_ * p_drawn; + + pan_x_ = -p_rot.x(); + pan_y_ = -p_rot.y(); + + float min_distance = 100.0f; + if (auto* block = chip_->getBlock()) { + min_distance = block->getDbUnitsPerMicron() * 10.0f; + } + + distance_ = std::max(max_dim * zoom_factor, min_distance); + + const float min_dist_limit + = std::max(bounding_radius_ * kMinDistanceFraction, 1.0f); + const float max_dist_limit + = std::max(bounding_radius_ * kMaxDistanceFraction, min_dist_limit); + distance_ = std::clamp(distance_, min_dist_limit, max_dist_limit); + + update(); +} + +void Chiplet3DWidget::selectionFocus(const Selected& focus) +{ + if (animate_selection_ != nullptr) { + animate_selection_->timer->stop(); + animate_selection_ = nullptr; + } + + if (focus) { + const int update_interval = 100; + const int state_reset_interval = 3; + animate_selection_ = std::make_unique(AnimatedSelected{ + .selection = focus, + .state_count = 0, + .max_state_count = state_reset_interval * kAnimationRepeats, + .state_modulo = state_reset_interval, + .timer = nullptr}); + animate_selection_->timer = std::make_unique(this); + animate_selection_->timer->setInterval(update_interval); + + const qint64 max_animate_time = QDateTime::currentMSecsSinceEpoch() + + (animate_selection_->max_state_count + 2) + * (qint64) update_interval; + connect( + animate_selection_->timer.get(), + &QTimer::timeout, + this, + [this, max_animate_time]() { + if (animate_selection_ == nullptr) { + return; + } + + animate_selection_->state_count++; + if (animate_selection_->max_state_count != 0 + && (animate_selection_->state_count + == animate_selection_->max_state_count + || QDateTime::currentMSecsSinceEpoch() > max_animate_time)) { + animate_selection_->timer->stop(); + animate_selection_ = nullptr; + } + update(); + }); + + animate_selection_->timer->start(); + } +} + +void Chiplet3DWidget::buildGeometries() +{ + if (!chip_) { return; } - const odb::dbTransform center_transform - = odb::dbTransform(odb::Point3D(-global_cuboid.xCenter(), - -global_cuboid.yCenter(), - -global_cuboid.zCenter())); + const odb::Cuboid global_cuboid = chip_->getCuboid(); + const odb::UnfoldedModel model(logger_, chip_); + center_transform_ = odb::dbTransform(odb::Point3D(-global_cuboid.xCenter(), + -global_cuboid.yCenter(), + -global_cuboid.zMin())); vertices_.clear(); - indices_lines_.clear(); - - // Center and Camera calculations - const float cx = (global_cuboid.xMin() + global_cuboid.xMax()) / 2.0f; - const float cy = (global_cuboid.yMin() + global_cuboid.yMax()) / 2.0f; - center_ = QVector3D(cx, cy, 0.0f); + faces_.clear(); + chip_cuboids_.clear(); + chiplet_objects_.clear(); + // Camera calculations const float dx = global_cuboid.dx(); const float dy = global_cuboid.dy(); const float dz = global_cuboid.dz() * kLayerGapFactor; @@ -100,26 +278,96 @@ void Chiplet3DWidget::buildGeometries() distance_ = kDefaultDistance; } - int index = 0; - for (const auto& chip : model->getChips()) { + int color_index = 0; + const auto& chips = model.getChips(); + + for (size_t chip_idx = 0; chip_idx < chips.size(); ++chip_idx) { + const auto& chip = chips[chip_idx]; + chip_cuboids_.push_back(chip.cuboid); odb::Cuboid draw_cuboid = chip.cuboid; - center_transform.apply(draw_cuboid); - // Color by Depth (proportional to Z) - const QVector3D color = kColorPalette[index++ % kColorPalette.size()]; + center_transform_.apply(draw_cuboid); + + const QVector3D color = kColorPalette[color_index++ % kColorPalette.size()]; const uint32_t base = vertices_.size(); for (const auto& p : draw_cuboid.getPoints()) { - vertices_.push_back({QVector3D(p.x(), p.y(), p.z()), color}); + vertices_.push_back({QVector3D(p.x(), p.y(), p.z() * kZScale)}); } - // Add line indices for a cube (12 lines) - const uint32_t lines[24] = {0, 1, 1, 2, 2, 3, 3, 0, // Bottom face - 4, 5, 5, 6, 6, 7, 7, 4, // Top face - 0, 4, 1, 5, 2, 6, 3, 7}; // Connecting pillars + ChipletObject chiplet_obj; + chiplet_obj.world_z_min = draw_cuboid.zMin() * kZScale; + chiplet_obj.world_z_max = draw_cuboid.zMax() * kZScale; + chiplet_obj.world_x_min = draw_cuboid.xMin(); + chiplet_obj.world_x_max = draw_cuboid.xMax(); + chiplet_obj.world_y_min = draw_cuboid.yMin(); + chiplet_obj.world_y_max = draw_cuboid.yMax(); + + QColor baseColor; + baseColor.setRgbF(color.x(), color.y(), color.z()); - for (const uint32_t i : lines) { - indices_lines_.push_back(base + i); + odb::dbBlock* block = nullptr; + if (!chip.chip_inst_path.empty()) { + if (auto* master = chip.chip_inst_path.back()->getMasterChip()) { + block = master->getBlock(); + } + } else if (chip_) { + block = chip_->getBlock(); } + + Selected sel; + if (block) { + sel = Gui::get()->makeSelected(block); + } + + const int chiplet_idx = static_cast(chip_idx); + const int face_base = static_cast(faces_.size()); + + // IMPORTANT: Winding order must produce outward-pointing normals + // via cross(v1-v0, v2-v0) for correct back-face culling. + // + // Assuming getPoints() returns: + // 0=(xmin,ymin,zmin), 1=(xmax,ymin,zmin), + // 2=(xmax,ymax,zmin), 3=(xmin,ymax,zmin), + // 4=(xmin,ymin,zmax), 5=(xmax,ymin,zmax), + // 6=(xmax,ymax,zmax), 7=(xmin,ymax,zmax) + // + // Bottom normal = -Z (outward=down): {0, 3, 2, 1} + // Top normal = +Z (outward=up): {4, 5, 6, 7} <-- NOT {4,7,6,5} + // Front normal = -Y (outward=front): {0, 1, 5, 4} + // Right normal = +X (outward=right): {1, 2, 6, 5} + // Back normal = +Y (outward=back): {2, 3, 7, 6} + // Left normal = -X (outward=left): {0, 4, 7, 3} + + faces_.push_back({{base + 0, base + 3, base + 2, base + 1}, + baseColor.darker(kBottomFaceDarkness), + sel, + chiplet_idx}); // Bottom + faces_.push_back({{base + 4, base + 5, base + 6, base + 7}, + baseColor.lighter(kTopFaceLightness), + sel, + chiplet_idx}); // Top + faces_.push_back({{base + 0, base + 1, base + 5, base + 4}, + baseColor.darker(kSideFaceDarkness), + sel, + chiplet_idx}); // Front + faces_.push_back({{base + 1, base + 2, base + 6, base + 5}, + baseColor, + sel, + chiplet_idx}); // Right + faces_.push_back({{base + 2, base + 3, base + 7, base + 6}, + baseColor.darker(kSideFaceDarkness), + sel, + chiplet_idx}); // Back + faces_.push_back({{base + 0, base + 4, base + 7, base + 3}, + baseColor, + sel, + chiplet_idx}); // Left + + for (int fi = face_base; fi < face_base + 6; ++fi) { + chiplet_obj.face_indices.push_back(fi); + } + + chiplet_objects_.push_back(std::move(chiplet_obj)); } } @@ -129,11 +377,9 @@ void Chiplet3DWidget::paintEvent(QPaintEvent* event) painter.setRenderHint(QPainter::Antialiasing); // 1. Clear Background - painter.fillRect(rect(), QColor(26, 26, 26)); // Approx 0.1f grey + painter.fillRect(rect(), kBackgroundColor); // 2. Setup Camera Matrices - - // Use bounding_radius_ which is rotation-invariant float safe_size = bounding_radius_; if (safe_size < 1.0f) { safe_size = kDefaultSafeSize; @@ -147,102 +393,495 @@ void Chiplet3DWidget::paintEvent(QPaintEvent* event) zFar = zNear + kMaxZFarOffset; } - // Projection Matrix const qreal aspect = qreal(width()) / qreal(height() ? height() : 1); QMatrix4x4 projection; projection.perspective(45.0f, aspect, zNear, zFar); - // ModelView Matrix QMatrix4x4 modelView; modelView.translate(pan_x_, pan_y_, -distance_); modelView.rotate(rotation_); // 3. Draw Grid - painter.setPen(QPen(QColor(76, 76, 76), 1)); // 0.3f grey - const float grid_size - = safe_size * kGridSizeFactor; // Adjust grid to scene size + painter.setPen(QPen(kGridColor, 1)); + const float grid_size = safe_size * kGridSizeFactor; const float step = grid_size / kGridSteps; - const float grid_z = -center_.z() - (distance_ * kGridZOffsetFactor * 0.5f); + const float grid_z = -(bounding_radius_ * kGridZOffsetFactor); for (int i = -kGridLineCount; i <= kGridLineCount; ++i) { const float pos = i * step; - // Lines parallel to Z (or Y in local, depending on orientation) - // The original code drew grid on Z plane. drawLine3D(painter, QVector3D(pos, -grid_size, grid_z), QVector3D(pos, grid_size, grid_z), - QColor(76, 76, 76), + kGridColor, modelView, projection, - rect()); + rect(), + zNear); drawLine3D(painter, QVector3D(-grid_size, pos, grid_z), QVector3D(grid_size, pos, grid_z), - QColor(76, 76, 76), + kGridColor, modelView, projection, - rect()); + rect(), + zNear); } - // 4. Draw Chiplets - if (vertices_.empty()) { + // 4. Draw Chiplets with back-face culling + depth sorting + if (vertices_.empty() || chiplet_objects_.empty()) { return; } - // Process lines - for (size_t i = 0; i < indices_lines_.size(); i += 2) { - const uint32_t idx1 = indices_lines_[i]; - const uint32_t idx2 = indices_lines_[i + 1]; + // Step 4a: Project all faces, cull back-facing ones. + // We determine the outward normal geometrically (from chiplet centroid + // toward face center) so we don't depend on vertex winding order. + sorted_faces_.clear(); + sorted_faces_.reserve(faces_.size()); + + for (const auto& face : faces_) { + QVector3D v0 = modelView * vertices_[face.indices[0]].position; + QVector3D v1 = modelView * vertices_[face.indices[1]].position; + QVector3D v2 = modelView * vertices_[face.indices[2]].position; + QVector3D v3 = modelView * vertices_[face.indices[3]].position; + + QVector3D face_center = (v0 + v1 + v2 + v3) * 0.25f; + + // The winding order is designed to produce an outward-pointing normal. + QVector3D cross_normal = QVector3D::crossProduct(v1 - v0, v2 - v0); + + bool is_back_facing + = QVector3D::dotProduct(cross_normal, face_center) >= 0.0f; + + if (is_back_facing) { + continue; + } + + double avgZ = (v0.z() + v1.z() + v2.z() + v3.z()) * 0.25; + + double face_polygon_offset + = static_cast(face.chiplet_index) * kPolygonOffsetChiplet + + static_cast(&face - faces_.data()) * kPolygonOffsetFace; + + sorted_faces_.push_back({&face, avgZ + face_polygon_offset}); + } + + std::vector chiplet_view_depths(chiplet_objects_.size(), 0.0); + for (size_t i = 0; i < chiplet_objects_.size(); ++i) { + double max_z = -1e9; + for (int face_idx : chiplet_objects_[i].face_indices) { + for (uint32_t v_idx : faces_[face_idx].indices) { + QVector3D view_p = modelView * vertices_[v_idx].position; + max_z = std::max(static_cast(view_p.z()), max_z); + } + } + + // Emulation of glPolygonOffset to break the Z coplanarity tie of chiplets + double polygon_offset = static_cast(i) * kPolygonOffsetChiplet; + chiplet_view_depths[i] = max_z + polygon_offset; + } + + // Step 4b: Sort front-facing faces by view-space depth (farthest first) + // To avoid Painter's algorithm artifacts between different chiplets and + // prevent std::stable_sort Undefined Behavior (Strict Weak Ordering + // violation), we primarily sort by the chiplet's centroid depth. + + // We calculate the viewing direction in world space. Since modelView is + // constructed by translating and then rotating (using the rotation_ + // quaternion), the viewing direction can be robustly computed by rotating the + // default view vector (0, 0, -1) by the inverse (conjugate) of the rotation + // quaternion. + QVector3D view_dir_world + = rotation_.conjugated().rotatedVector(QVector3D(0, 0, -1)); + bool looking_down = view_dir_world.z() <= 0.001f; + bool look_neg_x = view_dir_world.x() <= 0.0f; + bool look_neg_y = view_dir_world.y() <= 0.0f; + + auto overlap1D = [&](double min1, double max1, double min2, double max2) { + return (min1 <= max2 + kCoordinateTolerance + && max1 >= min2 - kCoordinateTolerance); + }; + + auto overlapX = [&](const auto& a, const auto& b) { + return overlap1D( + a.world_x_min, a.world_x_max, b.world_x_min, b.world_x_max); + }; + + auto overlapY = [&](const auto& a, const auto& b) { + return overlap1D( + a.world_y_min, a.world_y_max, b.world_y_min, b.world_y_max); + }; + + auto overlapZ = [&](const auto& a, const auto& b) { + return overlap1D( + a.world_z_min, a.world_z_max, b.world_z_min, b.world_z_max); + }; + + size_t num_chiplets = chiplet_objects_.size(); + std::vector> adj(num_chiplets); + std::vector in_degree(num_chiplets, 0); + + for (size_t i = 0; i < num_chiplets; ++i) { + for (size_t j = i + 1; j < num_chiplets; ++j) { + const auto& chipA = chiplet_objects_[i]; + const auto& chipB = chiplet_objects_[j]; + + bool overX = overlapX(chipA, chipB); + bool overY = overlapY(chipA, chipB); + bool overZ = overlapZ(chipA, chipB); + + if (chipA.world_z_max <= chipB.world_z_min + kCoordinateTolerance && overX + && overY) { + if (looking_down) { + adj[i].push_back(j); + in_degree[j]++; + } else { + adj[j].push_back(i); + in_degree[i]++; + } + } else if (chipB.world_z_max <= chipA.world_z_min + kCoordinateTolerance + && overX && overY) { + if (looking_down) { + adj[j].push_back(i); + in_degree[i]++; + } else { + adj[i].push_back(j); + in_degree[j]++; + } + } else if (chipA.world_x_max <= chipB.world_x_min + kCoordinateTolerance + && overY && overZ) { + if (look_neg_x) { + adj[i].push_back(j); + in_degree[j]++; + } else { + adj[j].push_back(i); + in_degree[i]++; + } + } else if (chipB.world_x_max <= chipA.world_x_min + kCoordinateTolerance + && overY && overZ) { + if (look_neg_x) { + adj[j].push_back(i); + in_degree[i]++; + } else { + adj[i].push_back(j); + in_degree[j]++; + } + } else if (chipA.world_y_max <= chipB.world_y_min + kCoordinateTolerance + && overX && overZ) { + if (look_neg_y) { + adj[i].push_back(j); + in_degree[j]++; + } else { + adj[j].push_back(i); + in_degree[i]++; + } + } else if (chipB.world_y_max <= chipA.world_y_min + kCoordinateTolerance + && overX && overZ) { + if (look_neg_y) { + adj[j].push_back(i); + in_degree[i]++; + } else { + adj[i].push_back(j); + in_degree[j]++; + } + } else if (overX && overY && overZ) { + bool a_contains_b + = (chipA.world_x_min <= chipB.world_x_min + kCoordinateTolerance + && chipA.world_x_max >= chipB.world_x_max - kCoordinateTolerance + && chipA.world_y_min <= chipB.world_y_min + kCoordinateTolerance + && chipA.world_y_max >= chipB.world_y_max - kCoordinateTolerance + && chipA.world_z_min <= chipB.world_z_min + kCoordinateTolerance + && chipA.world_z_max + >= chipB.world_z_max - kCoordinateTolerance); + + bool b_contains_a + = (chipB.world_x_min <= chipA.world_x_min + kCoordinateTolerance + && chipB.world_x_max >= chipA.world_x_max - kCoordinateTolerance + && chipB.world_y_min <= chipA.world_y_min + kCoordinateTolerance + && chipB.world_y_max >= chipA.world_y_max - kCoordinateTolerance + && chipB.world_z_min <= chipA.world_z_min + kCoordinateTolerance + && chipB.world_z_max + >= chipA.world_z_max - kCoordinateTolerance); + + if (a_contains_b && !b_contains_a) { + if (looking_down) { + adj[i].push_back(j); + in_degree[j]++; + } else { + adj[j].push_back(i); + in_degree[i]++; + } + } else if (b_contains_a && !a_contains_b) { + if (looking_down) { + adj[j].push_back(i); + in_degree[i]++; + } else { + adj[i].push_back(j); + in_degree[j]++; + } + } + } + } + } + + // Comparator for priority queue to process furthest chiplets first (Painter's + // algorithm). std::priority_queue puts elements with "lower" priority at the + // bottom. By returning true when 'a' is closer than 'b', 'a' is given lower + // priority. Thus, the furthest chiplets will appear at the top of the queue. + auto is_closer = [&](int a, int b) { + if (std::abs(chiplet_view_depths[a] - chiplet_view_depths[b]) + > kDepthTolerance) { + return chiplet_view_depths[a] > chiplet_view_depths[b]; + } + // Note: This index-based fallback is arbitrary and non-geometric. + // If pathological Z-fighting visual artifacts appear during cycle + // resolution, this fallback is the likely underlying cause. + return a > b; + }; + std::priority_queue, decltype(is_closer)> pq(is_closer); + + for (size_t i = 0; i < num_chiplets; ++i) { + if (in_degree[i] == 0) { + pq.push(i); + } + } + + std::vector chiplet_order(num_chiplets, 0); + int order_idx = 0; + while (static_cast(order_idx) < num_chiplets) { + if (pq.empty()) { + int best_node = -1; + for (size_t i = 0; i < num_chiplets; ++i) { + if (in_degree[i] > 0) { + // Break cycles by forcibly picking the furthest available node. + // If best_node is closer than i, update best_node to i. + if (best_node == -1 || is_closer(best_node, i)) { + best_node = static_cast(i); + } + } + } + if (best_node != -1) { + in_degree[best_node] = 0; + pq.push(best_node); + } else { + break; + } + } + + int u = pq.top(); + pq.pop(); + chiplet_order[u] = order_idx++; + for (int v : adj[u]) { + if (in_degree[v] > 0) { + if (--in_degree[v] == 0) { + pq.push(v); + } + } + } + } + + std::ranges::stable_sort( + sorted_faces_, [&](const ProjectedFace& a, const ProjectedFace& b) { + if (a.face->chiplet_index != b.face->chiplet_index) { + return chiplet_order[a.face->chiplet_index] + < chiplet_order[b.face->chiplet_index]; + } + // Primary: face's view-space depth + // (back-faces are already discarded during sorted_faces_ + // construction) + if (std::abs(a.sortZ - b.sortZ) > kSortZTolerance) { + return a.sortZ < b.sortZ; + } + return a.face < b.face; + }); + + // Step 4c: Draw all surviving front-facing faces back-to-front + for (const auto& pf : sorted_faces_) { + drawFace3D(painter, *(pf.face), modelView, projection, rect(), zNear); + } + + // 5. Draw selection animation highlight + if (animate_selection_ != nullptr) { + bool draw_highlight = true; + if (animate_selection_->state_count % animate_selection_->state_modulo + == 0) { + draw_highlight = false; + } + odb::Rect bbox; + if (draw_highlight && animate_selection_->selection.getBBox(bbox)) { + float target_z_max = 0.0f; + float target_z_min = 0.0f; + if (chip_) { + for (const auto& cuboid : chip_cuboids_) { + odb::Rect c_rect( + cuboid.xMin(), cuboid.yMin(), cuboid.xMax(), cuboid.yMax()); + if (c_rect.intersects(bbox)) { + odb::Cuboid draw_cuboid = cuboid; + center_transform_.apply(draw_cuboid); + target_z_max = draw_cuboid.zMax() * kZScale + kHighlightZOffset; + target_z_min = draw_cuboid.zMin() * kZScale - kHighlightZOffset; + break; + } + } + + odb::Point pt1(bbox.xMin(), bbox.yMin()); + odb::Point pt2(bbox.xMax(), bbox.yMin()); + odb::Point pt3(bbox.xMax(), bbox.yMax()); + odb::Point pt4(bbox.xMin(), bbox.yMax()); - if (idx1 < vertices_.size() && idx2 < vertices_.size()) { - const VertexData& v1 = vertices_[idx1]; - const VertexData& v2 = vertices_[idx2]; + center_transform_.apply(pt1); + center_transform_.apply(pt2); + center_transform_.apply(pt3); + center_transform_.apply(pt4); - QColor c; - c.setRgbF(v1.color.x(), v1.color.y(), v1.color.z()); + QVector3D p1_top(pt1.x(), pt1.y(), target_z_max); + QVector3D p2_top(pt2.x(), pt2.y(), target_z_max); + QVector3D p3_top(pt3.x(), pt3.y(), target_z_max); + QVector3D p4_top(pt4.x(), pt4.y(), target_z_max); - // Draw using helper - drawLine3D( - painter, v1.position, v2.position, c, modelView, projection, rect()); + QVector3D p1_bot(pt1.x(), pt1.y(), target_z_min); + QVector3D p2_bot(pt2.x(), pt2.y(), target_z_min); + QVector3D p3_bot(pt3.x(), pt3.y(), target_z_min); + QVector3D p4_bot(pt4.x(), pt4.y(), target_z_min); + + QColor highlight_color(Painter::kHighlight.r, + Painter::kHighlight.g, + Painter::kHighlight.b, + 255); + + // Top face + drawLine3D(painter, + p1_top, + p2_top, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p2_top, + p3_top, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p3_top, + p4_top, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p4_top, + p1_top, + highlight_color, + modelView, + projection, + rect(), + zNear); + + // Bottom face + drawLine3D(painter, + p1_bot, + p2_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p2_bot, + p3_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p3_bot, + p4_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p4_bot, + p1_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + + // Vertical edges + drawLine3D(painter, + p1_top, + p1_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p2_top, + p2_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p3_top, + p3_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + drawLine3D(painter, + p4_top, + p4_bot, + highlight_color, + modelView, + projection, + rect(), + zNear); + } } } } -// Helper: Projects 3D points to 2D, Handles Z-Clipping void Chiplet3DWidget::drawLine3D(QPainter& painter, const QVector3D& p1_world, const QVector3D& p2_world, const QColor& color, const QMatrix4x4& modelView, const QMatrix4x4& projection, - const QRect& viewport) + const QRect& viewport, + float zNear) { - // 1. Transform to View Space QVector3D p1_view = modelView * p1_world; QVector3D p2_view = modelView * p2_world; - // 2. Clip against Near Plane - // In OpenGL view space, camera looks down -Z. - // Near plane is at z = -nearVal (e.g. -10.0). Points with z > -nearVal are - // behind the near plane. However, QMatrix4x4::perspective sets up a standard - // frustum. We can extract the near plane distance from our setup, but a small - // fixed epsilon usually works for preventing divide-by-zero or "behind head" - // artifacts. - - // Simple clipping: Clip against Z = -0.1 (very close to camera) - const float kClipZ = -0.1f; + const float kClipZ = -zNear; const bool p1_visible = p1_view.z() < kClipZ; const bool p2_visible = p2_view.z() < kClipZ; if (!p1_visible && !p2_visible) { - return; // Both behind camera + return; } if (p1_visible != p2_visible) { - // Line spans the clip plane. Calculate intersection. - // t = (kClipZ - z1) / (z2 - z1) - const float t = (kClipZ - p1_view.z()) / (p2_view.z() - p1_view.z()); + // Prevent division by zero when z values are extremely close to kClipZ + const float denominator = p2_view.z() - p1_view.z(); + float t = 0.5f; + if (std::abs(denominator) > 1e-6f) { + t = (kClipZ - p1_view.z()) / denominator; + } const QVector3D intersection = p1_view + (p2_view - p1_view) * t; if (!p1_visible) { @@ -252,46 +891,173 @@ void Chiplet3DWidget::drawLine3D(QPainter& painter, } } - // NDC or Normalized Device Coordinates const QVector3D p1_ndc = projection.map(p1_view); const QVector3D p2_ndc = projection.map(p2_view); - // Map NDC (-1 to 1) to Viewport (0 to width/height) const float w = viewport.width(); const float h = viewport.height(); - // Note: NDC Y is up, Screen Y is down. const QPointF s1((p1_ndc.x() + 1.0f) * 0.5f * w, (1.0f - p1_ndc.y()) * 0.5f * h); const QPointF s2((p2_ndc.x() + 1.0f) * 0.5f * w, (1.0f - p2_ndc.y()) * 0.5f * h); - // 4. Draw - painter.setPen(QPen(color, 2)); + painter.setPen(QPen(color, kLine3DWidth)); painter.drawLine(s1, s2); } -void Chiplet3DWidget::mousePressEvent(QMouseEvent* e) +std::optional Chiplet3DWidget::projectAndClipFace( + const Face& face, + const QMatrix4x4& modelView, + const QMatrix4x4& projection, + float width, + float height, + float kClipZ) const { - mouse_press_position_ = QVector2D(e->localPos()); + std::vector face_view_points; + face_view_points.reserve(4); + for (int i = 0; i < 4; ++i) { + face_view_points.push_back(modelView * vertices_[face.indices[i]].position); + } + + std::vector clipped_points; + for (size_t i = 0; i < face_view_points.size(); ++i) { + const QVector3D& p1 = face_view_points[i]; + const QVector3D& p2 = face_view_points[(i + 1) % face_view_points.size()]; + + bool p1_visible = p1.z() < kClipZ; + bool p2_visible = p2.z() < kClipZ; + + if (p1_visible) { + clipped_points.push_back(p1); + } + + if (p1_visible != p2_visible) { + // Prevent division by zero when z values are extremely close to kClipZ + const float denominator = p2.z() - p1.z(); + float t = 0.5f; + if (std::abs(denominator) > 1e-6f) { + t = (kClipZ - p1.z()) / denominator; + } + QVector3D intersection = p1 + (p2 - p1) * t; + clipped_points.push_back(intersection); + } + } + + if (clipped_points.empty()) { + return std::nullopt; + } + + QPolygonF poly; + for (const auto& p_view : clipped_points) { + QVector3D p_ndc = projection.map(p_view); + poly << QPointF((p_ndc.x() + 1.0f) * 0.5f * width, + (1.0f - p_ndc.y()) * 0.5f * height); + } + + return poly; } -void Chiplet3DWidget::mouseReleaseEvent(QMouseEvent* e) +void Chiplet3DWidget::drawFace3D(QPainter& painter, + const Face& face, + const QMatrix4x4& modelView, + const QMatrix4x4& projection, + const QRect& viewport, + float zNear) { + const float kClipZ = -zNear; + + auto polygon_opt = projectAndClipFace( + face, modelView, projection, viewport.width(), viewport.height(), kClipZ); + if (!polygon_opt) { + return; + } + const QPolygonF& polygon_buffer = *polygon_opt; + + QColor color = face.color; + int pen_width = 1; + + if (face.selection) { + int highlight_group = 0; + bool is_highlighted = false; + for (const auto& h_set : highlighted_) { + if (h_set.contains(face.selection)) { + is_highlighted = true; + break; + } + highlight_group++; + } + + if (is_highlighted) { + Painter::Color hc + = Painter::kHighlightColors[highlight_group % gui::kNumHighlightSet]; + color = QColor(hc.r, hc.g, hc.b, 255); + pen_width = 3; + } else if (selected_.contains(face.selection)) { + color = QColor(Painter::kHighlight.r, + Painter::kHighlight.g, + Painter::kHighlight.b, + 255); + pen_width = 3; + } + + bool matches_anim = false; + if (animate_selection_ != nullptr) { + if (animate_selection_->selection == face.selection) { + matches_anim = true; + } + } + + if (matches_anim) { + int state + = animate_selection_->state_count % animate_selection_->state_modulo; + pen_width = state + 2; + color = QColor(Painter::kHighlight.r, + Painter::kHighlight.g, + Painter::kHighlight.b, + 255); + } + } + + painter.setBrush(QBrush(color)); + painter.setPen(QPen(color.darker(), pen_width)); + painter.drawPolygon(polygon_buffer); +} + +void Chiplet3DWidget::mousePressEvent(QMouseEvent* e) +{ + mouse_press_position_ = QVector2D(e->localPos()); } void Chiplet3DWidget::mouseMoveEvent(QMouseEvent* e) { const QVector2D diff = QVector2D(e->localPos()) - mouse_press_position_; + if (diff.lengthSquared() < kMouseClickTolerance) { + return; + } mouse_press_position_ = QVector2D(e->localPos()); if (e->buttons() & Qt::LeftButton) { - // Rotation const QVector3D n = QVector3D(diff.y(), diff.x(), 0.0).normalized(); const float angle = diff.length() / kRotationSensitivity; - rotation_ = QQuaternion::fromAxisAndAngle(n, angle) * rotation_; + QQuaternion new_rotation + = QQuaternion::fromAxisAndAngle(n, angle) * rotation_; + + // Constrain rotation to prevent viewing from below (z < 0) + if ((new_rotation * QVector3D(0.0f, 0.0f, 1.0f)).z() >= 0.0f) { + rotation_ = new_rotation; + } else { + // If combined rotation goes below, try applying only horizontal (yaw) + // rotation + QQuaternion yaw_rotation + = QQuaternion::fromAxisAndAngle(QVector3D(0.0f, 1.0f, 0.0f), + diff.x() / kRotationSensitivity) + * rotation_; + if ((yaw_rotation * QVector3D(0.0f, 0.0f, 1.0f)).z() >= 0.0f) { + rotation_ = yaw_rotation; + } + } } else if (e->buttons() & Qt::RightButton) { - // Pan const float scale = distance_ * kPanSensitivity; pan_x_ += diff.x() * scale; pan_y_ -= diff.y() * scale; @@ -299,6 +1065,64 @@ void Chiplet3DWidget::mouseMoveEvent(QMouseEvent* e) update(); } +void Chiplet3DWidget::mouseReleaseEvent(QMouseEvent* e) +{ + const QVector2D diff = QVector2D(e->localPos()) - mouse_press_position_; + if (std::abs(diff.x()) + std::abs(diff.y()) > 5.0f) { + return; + } + + if (e->button() != Qt::LeftButton) { + return; + } + + // Hit-test + float safe_size = bounding_radius_; + if (safe_size < 1.0f) { + safe_size = kDefaultSafeSize; + } + + const float zNear + = std::max(distance_ - safe_size * kNearFarFactor, kMinZNear); + float zFar = distance_ + safe_size * kNearFarFactor; + + if (zFar < zNear + kMinDistanceCheck) { + zFar = zNear + kMaxZFarOffset; + } + + const qreal aspect = qreal(width()) / qreal(height() ? height() : 1); + QMatrix4x4 projection; + projection.perspective(45.0f, aspect, zNear, zFar); + + QMatrix4x4 modelView; + modelView.translate(pan_x_, pan_y_, -distance_); + modelView.rotate(rotation_); + + const float w = width(); + const float h = height(); + const float kClipZ = -zNear; + + const QPointF click_pos = e->localPos(); + + for (const auto& sorted_face : std::ranges::reverse_view(sorted_faces_)) { + const Face* face = sorted_face.face; + if (!face || !face->selection) { + continue; + } + + auto poly_opt + = projectAndClipFace(*face, modelView, projection, w, h, kClipZ); + if (!poly_opt) { + continue; + } + + if (poly_opt->containsPoint(click_pos, Qt::OddEvenFill)) { + emit selected(face->selection); + break; + } + } +} + void Chiplet3DWidget::wheelEvent(QWheelEvent* e) { if (e->angleDelta().y() > 0) { @@ -306,6 +1130,11 @@ void Chiplet3DWidget::wheelEvent(QWheelEvent* e) } else { distance_ *= kZoomOutFactor; } + const float min_dist + = std::max(bounding_radius_ * kMinDistanceFraction, 1.0f); + const float max_dist + = std::max(bounding_radius_ * kMaxDistanceFraction, min_dist); + distance_ = std::clamp(distance_, min_dist, max_dist); update(); } diff --git a/src/gui/src/chiplet3DWidget.h b/src/gui/src/chiplet3DWidget.h index 86e7713bce0..cfc8182ccfb 100644 --- a/src/gui/src/chiplet3DWidget.h +++ b/src/gui/src/chiplet3DWidget.h @@ -1,76 +1,133 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2025, The OpenROAD Authors - #pragma once - #include +#include #include +#include #include #include #include +#include #include +#include +#include #include +#include "gui/gui.h" +#include "odb/dbTransform.h" +#include "odb/geom.h" namespace odb { class dbChip; -} +class dbBlock; +} // namespace odb namespace utl { class Logger; } - namespace gui { - class Chiplet3DWidget : public QWidget { Q_OBJECT - public: - explicit Chiplet3DWidget(QWidget* parent = nullptr); - + Chiplet3DWidget(const SelectionSet& selected, + const HighlightSet& highlighted, + QWidget* parent = nullptr); + ~Chiplet3DWidget() override; void setChip(odb::dbChip* chip); void setLogger(utl::Logger* logger); + public slots: + void zoomTo(const Selected& selection); + void selectionFocus(const Selected& focus); + + signals: + void selected(const Selected& selection); protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; - void mouseReleaseEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; void wheelEvent(QWheelEvent* event) override; private: + struct Face; + struct ChipletObject; void buildGeometries(); - - // Helper to draw a line with 3D projection and clipping void drawLine3D(QPainter& painter, const QVector3D& p1_world, const QVector3D& p2_world, const QColor& color, const QMatrix4x4& modelView, const QMatrix4x4& projection, - const QRect& viewport); - + const QRect& viewport, + float zNear); + void drawFace3D(QPainter& painter, + const Face& face, + const QMatrix4x4& modelView, + const QMatrix4x4& projection, + const QRect& viewport, + float zNear); + std::optional projectAndClipFace(const Face& face, + const QMatrix4x4& modelView, + const QMatrix4x4& projection, + float width, + float height, + float kClipZ) const; odb::dbChip* chip_ = nullptr; utl::Logger* logger_ = nullptr; - QVector2D mouse_press_position_; QQuaternion rotation_; - // Camera State - float distance_ = 10.0f; + float distance_ = 0.0f; float pan_x_ = 0.0f; float pan_y_ = 0.0f; - - float bounding_radius_ = 10.0f; // Rotation-invariant bounding sphere radius - QVector3D center_ = QVector3D(0, 0, 0); - + float bounding_radius_ = 0.0f; + odb::dbTransform center_transform_; struct VertexData { QVector3D position; - QVector3D color; + }; + struct Face + { + std::array indices; + QColor color; + Selected selection; + int chiplet_index; // which chiplet this face belongs to + }; + // Groups all 6 faces of a single chiplet for per-object sorting + struct ChipletObject + { + std::vector face_indices; // indices into faces_ + float world_z_min; // world-space logical Z layer + float world_z_max; + float world_x_min; + float world_x_max; + float world_y_min; + float world_y_max; }; + struct ProjectedFace + { + const Face* face; + double sortZ; // view-space depth for sorting + }; + const SelectionSet& selected_; + const HighlightSet& highlighted_; + struct AnimatedSelected + { + Selected selection; + int state_count; + int max_state_count; + int state_modulo; + std::unique_ptr timer; + }; + std::unique_ptr animate_selection_; + static constexpr int kAnimationRepeats = 6; std::vector vertices_; - std::vector indices_lines_; -}; + std::vector faces_; + std::vector chiplet_objects_; + std::vector chip_cuboids_; + std::vector sorted_faces_; +}; } // namespace gui diff --git a/src/gui/src/mainWindow.cpp b/src/gui/src/mainWindow.cpp index fd93eadb6fc..f778b9f94f4 100644 --- a/src/gui/src/mainWindow.cpp +++ b/src/gui/src/mainWindow.cpp @@ -107,7 +107,7 @@ MainWindow::MainWindow(bool load_settings, QWidget* parent) hierarchy_widget_( new BrowserWidget(viewers_->getModuleSettings(), controls_, this)), charts_widget_(new ChartsWidget(this)), - chiplet_viewer_(new Chiplet3DWidget(this)), + chiplet_viewer_(new Chiplet3DWidget(selected_, highlighted_, this)), chiplet_dock_(new QDockWidget("3D Viewer", this)), help_widget_(new HelpWidget(this)), find_dialog_(new FindObjectDialog(this)), @@ -258,6 +258,10 @@ MainWindow::MainWindow(bool load_settings, QWidget* parent) connect(inspector_, &Inspector::focus, viewers_, &LayoutTabs::selectionFocus); connect( drc_viewer_, &DRCWidget::focus, viewers_, &LayoutTabs::selectionFocus); + connect(drc_viewer_, + &DRCWidget::focus, + chiplet_viewer_, + &Chiplet3DWidget::selectionFocus); connect( this, &MainWindow::highlightChanged, inspector_, &Inspector::loadActions); connect(viewers_, @@ -387,6 +391,7 @@ MainWindow::MainWindow(bool load_settings, QWidget* parent) if (open_inspector) { setSelected(selected, false); } + chiplet_viewer_->zoomTo(selected); odb::Rect bbox; selected.getBBox(bbox); diff --git a/src/odb/include/odb/db.h b/src/odb/include/odb/db.h index a64c59b98e9..d95ba2bb726 100644 --- a/src/odb/include/odb/db.h +++ b/src/odb/include/odb/db.h @@ -7680,6 +7680,7 @@ class dbDatabase : public dbObject void triggerPostReadDef(dbBlock* block, bool floorplan); void triggerPostReadDb(); void triggerPostRead3Dbx(dbChip* chip); + void triggerPostMarkersChanged(); /// /// Create an instance of a database diff --git a/src/odb/include/odb/dbDatabaseObserver.h b/src/odb/include/odb/dbDatabaseObserver.h index 05ff66327ec..27d10a614f8 100644 --- a/src/odb/include/odb/dbDatabaseObserver.h +++ b/src/odb/include/odb/dbDatabaseObserver.h @@ -30,6 +30,7 @@ class dbDatabaseObserver virtual void postReadFloorplanDef(odb::dbBlock*) {} virtual void postReadDb(odb::dbDatabase* db) = 0; virtual void postRead3Dbx(odb::dbChip* chip) = 0; + virtual void postMarkersChanged() {} void setUnregisterObserver(std::function unregister_observer) { diff --git a/src/odb/src/3dblox/checker.cpp b/src/odb/src/3dblox/checker.cpp index 22067fd2633..bf00a246bed 100644 --- a/src/odb/src/3dblox/checker.cpp +++ b/src/odb/src/3dblox/checker.cpp @@ -109,6 +109,7 @@ void Checker::check() checkInternalExtUsage(top_cat, model); checkConnectionRegions(top_cat, model); checkBumpPhysicalAlignment(top_cat, model); + db_->triggerPostMarkersChanged(); } void Checker::checkFloatingChips(dbMarkerCategory* top_cat, diff --git a/src/odb/src/db/dbDatabase.cpp b/src/odb/src/db/dbDatabase.cpp index 83a5fec5d6e..f5e48ccd6c3 100644 --- a/src/odb/src/db/dbDatabase.cpp +++ b/src/odb/src/db/dbDatabase.cpp @@ -1029,6 +1029,14 @@ void dbDatabase::triggerPostReadDb() } } +void dbDatabase::triggerPostMarkersChanged() +{ + _dbDatabase* db = (_dbDatabase*) this; + for (dbDatabaseObserver* observer : db->observers_) { + observer->postMarkersChanged(); + } +} + // User Code End dbDatabasePublicMethods } // namespace odb // Generator Code End Cpp diff --git a/src/web/BUILD b/src/web/BUILD index b01fcc4b733..89e7c6f4d53 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -21,6 +21,8 @@ cc_library( "src/clock_tree_report.h", "src/color.cpp", "src/color.h", + "src/drc_report.cpp", + "src/drc_report.h", "src/hierarchy_report.cpp", "src/hierarchy_report.h", "src/json_builder.h", diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index be5b1009f08..2313c3874bf 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -16,6 +16,7 @@ target_sources(web PRIVATE src/clock_tree_report.cpp src/color.cpp + src/drc_report.cpp src/hierarchy_report.cpp src/request_handler.cpp src/search.cpp diff --git a/src/web/include/web/web.h b/src/web/include/web/web.h index e01897a0ba1..97e576ec9fd 100644 --- a/src/web/include/web/web.h +++ b/src/web/include/web/web.h @@ -12,6 +12,10 @@ namespace sta { class dbSta; } +namespace odb { +class dbDatabaseObserver; +} + namespace web { struct Color; @@ -42,12 +46,15 @@ class WebServer double dbu_per_pixel, const std::string& vis_json); + void reloadDesign(); + private: odb::dbDatabase* db_ = nullptr; sta::dbSta* sta_ = nullptr; utl::Logger* logger_ = nullptr; Tcl_Interp* interp_ = nullptr; std::shared_ptr generator_; + std::unique_ptr observer_; }; } // namespace web diff --git a/src/web/src/3d-viewer-widget.js b/src/web/src/3d-viewer-widget.js new file mode 100644 index 00000000000..71ccdfba933 --- /dev/null +++ b/src/web/src/3d-viewer-widget.js @@ -0,0 +1,587 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +import * as THREE from 'https://esm.sh/three@0.160.0'; + +// Navigation constants matching the Qt GUI (chiplet3DWidget.cpp) +const kRotationSensitivity = 2.0; +const kPanSensitivity = 0.002; +const kZoomInFactor = 0.9; +const kZoomOutFactor = 1.1; +const kMinDistanceFraction = 0.1; +const kMaxDistanceFraction = 20.0; +const kInitialDistanceFactor = 3.0; +const kZScale = 2.0; +const kNearFarFactor = 2.0; +const kMinZNear = 10.0; +const DEG2RAD = Math.PI / 180; + +// Distinct color palette for chiplets (saturated, good contrast in 3D) +const CHIPLET_PALETTE = [ + 0x4CAF50, // green + 0x2196F3, // blue + 0xFF9800, // orange + 0x9C27B0, // purple + 0x00BCD4, // cyan + 0xF44336, // red + 0x26A69A, // teal + 0x795548, // brown + 0xE91E63, // pink + 0x3F51B5, // indigo +]; + +export class ThreeDViewerWidget { + constructor(container, app) { + this._app = app; + this._container = container; + this._element = document.createElement('div'); + this._element.className = '3d-viewer-widget'; + this._element.style.width = '100%'; + this._element.style.height = '100%'; + this._element.style.position = 'relative'; + this._element.style.backgroundColor = 'var(--bg-panel)'; + + container.element.appendChild(this._element); + + this._scene = new THREE.Scene(); + this._scene.background = new THREE.Color(0x000000); + + this._camera = new THREE.PerspectiveCamera(45, 1, 10, 100000); + this._camera.position.set(0, 0, 1000); + + this._renderer = new THREE.WebGLRenderer({ antialias: true }); + this._renderer.setPixelRatio(window.devicePixelRatio); + this._renderer.setSize(container.width, container.height); + this._element.appendChild(this._renderer.domElement); + + // Camera navigation state — matches Qt chiplet3DWidget exactly: + // quaternion-based rotation, distance-based zoom, screen-space pan. + this._rotation = new THREE.Quaternion(); + this._distance = 1000; + this._panX = 0; + this._panY = 0; + this._boundingRadius = 1; + this._sceneCenter = new THREE.Vector3(); + + // Mouse tracking + this._mouseDown = false; + this._mouseButton = -1; + this._lastMousePos = { x: 0, y: 0 }; + + this._setupMouseHandlers(); + + // Lighting: ambient + two directional lights for better depth perception + const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); + this._scene.add(ambientLight); + + const keyLight = new THREE.DirectionalLight(0xffffff, 0.8); + keyLight.position.set(1, 1, 1).normalize(); + this._scene.add(keyLight); + + const fillLight = new THREE.DirectionalLight(0xffffff, 0.3); + fillLight.position.set(-1, -0.5, 0.5).normalize(); + this._scene.add(fillLight); + + this._animationId = null; + this.animate = this.animate.bind(this); + + container.on('resize', this.onResize.bind(this)); + container.on('open', this.onOpen.bind(this)); + container.on('destroy', this.onDestroy.bind(this)); + + // Group for design objects + this._designGroup = new THREE.Group(); + this._scene.add(this._designGroup); + + this._chipletMeshes = []; // tracks chiplet mesh → data for scene rebuild checks + + this.loadData(); + } + + // ─── Mouse Handlers (matching Qt chiplet3DWidget) ────────────────── + + _setupMouseHandlers() { + const canvas = this._renderer.domElement; + + canvas.addEventListener('mousedown', (e) => { + this._mouseDown = true; + this._mouseButton = e.button; + this._lastMousePos.x = e.clientX; + this._lastMousePos.y = e.clientY; + e.preventDefault(); + }); + + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + + this._onMouseMove = (e) => { + if (!this._mouseDown) return; + + const dx = e.clientX - this._lastMousePos.x; + const dy = e.clientY - this._lastMousePos.y; + this._lastMousePos.x = e.clientX; + this._lastMousePos.y = e.clientY; + + if (dx === 0 && dy === 0) return; + + if (this._mouseButton === 0) { + this._handleRotate(dx, dy); + } else if (this._mouseButton === 2) { + this._handlePan(dx, dy); + } + + this._updateCamera(); + }; + + this._onMouseUp = (e) => { + if (this._mouseDown && e.button === this._mouseButton) { + this._mouseDown = false; + this._mouseButton = -1; + } + }; + + window.addEventListener('mousemove', this._onMouseMove); + window.addEventListener('mouseup', this._onMouseUp); + + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + if (e.deltaY < 0) { + this._distance *= kZoomInFactor; + } else { + this._distance *= kZoomOutFactor; + } + const minDist = Math.max(this._boundingRadius * kMinDistanceFraction, 1.0); + const maxDist = Math.max(this._boundingRadius * kMaxDistanceFraction, minDist); + this._distance = Math.max(minDist, Math.min(maxDist, this._distance)); + this._updateCamera(); + }, { passive: false }); + } + + // Quaternion-based rotation matching Qt: axis perpendicular to mouse + // movement in screen space, angle proportional to drag distance. + _handleRotate(dx, dy) { + const length = Math.sqrt(dx * dx + dy * dy); + if (length < 0.001) return; + + // Qt: axis = normalize(diff.y, diff.x, 0) + const axis = new THREE.Vector3(dy / length, dx / length, 0); + const angleDeg = length / kRotationSensitivity; + const deltaQuat = new THREE.Quaternion().setFromAxisAngle(axis, angleDeg * DEG2RAD); + + const newRotation = deltaQuat.clone().multiply(this._rotation); + + // Constraint: prevent viewing from below (Z must stay up) + const zUp = new THREE.Vector3(0, 0, 1).applyQuaternion(newRotation); + if (zUp.z >= 0) { + this._rotation.copy(newRotation); + } else { + // Try yaw-only rotation (horizontal component) + const yawAxis = new THREE.Vector3(0, 1, 0); + const yawAngle = (dx / kRotationSensitivity) * DEG2RAD; + const yawQuat = new THREE.Quaternion().setFromAxisAngle(yawAxis, yawAngle); + const yawRotation = yawQuat.clone().multiply(this._rotation); + const zUpYaw = new THREE.Vector3(0, 0, 1).applyQuaternion(yawRotation); + if (zUpYaw.z >= 0) { + this._rotation.copy(yawRotation); + } + } + } + + // Pan matching Qt: screen-space offset scaled by distance. + _handlePan(dx, dy) { + const scale = this._distance * kPanSensitivity; + this._panX += dx * scale; + this._panY -= dy * scale; // Y inverted, matching Qt + } + + // Reconstruct camera transform from state, matching Qt modelView: + // modelView = translate(panX, panY, -distance) * rotate(rotation) + _updateCamera() { + // Dynamic near/far planes matching Qt (depends on distance and bounds) + const safeSize = Math.max(this._boundingRadius, 1000); + const zNear = Math.max(this._distance - safeSize * kNearFarFactor, kMinZNear); + let zFar = this._distance + safeSize * kNearFarFactor; + if (zFar < zNear + 100) { + zFar = zNear + 10000; + } + this._camera.near = zNear; + this._camera.far = zFar; + this._camera.updateProjectionMatrix(); + + // Inverse of the rotation quaternion maps camera-local → world + const qInv = this._rotation.clone().invert(); + + // Camera position in world: qInv * (panX, panY, distance) + sceneCenter + const pos = new THREE.Vector3(this._panX, this._panY, this._distance); + pos.applyQuaternion(qInv); + this._camera.position.copy(this._sceneCenter).add(pos); + + // Camera up: qInv * (0, 1, 0) + this._camera.up.set(0, 1, 0).applyQuaternion(qInv); + + // Look-at target: sceneCenter + qInv * (panX, panY, 0) + const target = new THREE.Vector3(this._panX, this._panY, 0); + target.applyQuaternion(qInv); + target.add(this._sceneCenter); + this._camera.lookAt(target); + } + + // ─── Lifecycle ───────────────────────────────────────────────────── + + onOpen() { + this.onResize(); + if (!this._animationId) { + this.animate(); + } + if (this._chipletMeshes.length === 0) { + this.loadData(); + } + } + + onDestroy() { + if (this._animationId) { + cancelAnimationFrame(this._animationId); + this._animationId = null; + } + this.clearHighlightDRC(); + if (this._onMouseMove) { + window.removeEventListener('mousemove', this._onMouseMove); + } + if (this._onMouseUp) { + window.removeEventListener('mouseup', this._onMouseUp); + } + if (this._renderer) { + this._renderer.dispose(); + } + } + + onResize() { + const width = this._container.width; + const height = this._container.height; + if (width === 0 || height === 0) return; + + this._camera.aspect = width / height; + this._camera.updateProjectionMatrix(); + this._renderer.setSize(width, height); + this.render(); + } + + animate() { + this._animationId = requestAnimationFrame(this.animate); + this._renderer.render(this._scene, this._camera); + } + + render() { + if (this._renderer && this._scene && this._camera) { + this._renderer.render(this._scene, this._camera); + } + } + + // ─── Data Loading ────────────────────────────────────────────────── + + async loadData() { + for (const sel of ['.three-d-error', '.three-d-info']) { + const prev = this._element.querySelector(sel); + if (prev) prev.remove(); + } + + try { + await this._app.websocketManager.readyPromise; + const data = await this._app.websocketManager.request({ type: 'get_3d_data' }); + if (data && data.error) { + this._showError(data.error); + return; + } + if (data && data.info) { + this._showInfo(data.info); + return; + } + this.buildScene(data); + } catch (err) { + console.error('Failed to load 3D data:', err); + this._showError(err.message || String(err)); + } + } + + _showOverlay(className, text, color) { + const div = document.createElement('div'); + div.className = className; + div.style.cssText = + 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' + + 'font-family:monospace;text-align:center;'; + div.style.color = color; + div.textContent = text; + this._element.appendChild(div); + } + + _showError(message) { + this._showOverlay('three-d-error', + 'Failed to load 3D data: ' + message, 'var(--error)'); + } + + _showInfo(message) { + this._showOverlay('three-d-info', message, 'var(--fg-muted, #888)'); + } + + // ─── Scene Construction ──────────────────────────────────────────── + + buildScene(data) { + // Clear existing objects + this._chipletMeshes = []; + while (this._designGroup.children.length > 0) { + const child = this._designGroup.children[0]; + this._designGroup.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material && !child.material._shared) child.material.dispose(); + } + + if (!data || !data.chiplets || data.chiplets.length === 0) return; + + let bounds = { + minX: Infinity, minY: Infinity, minZ: Infinity, + maxX: -Infinity, maxY: -Infinity, maxZ: -Infinity + }; + + const edgeMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 }); + edgeMaterial._shared = true; + + const dbu = this._app.techData?.dbu_per_micron || 1000; + + // Pre-compute Z offsets to separate chiplets that share the same Z + // AND overlap in XY. Chiplets at the same z that don't overlap + // (e.g. side-by-side) keep their original position. + // + // Algorithm: for each z-group, greedily assign chiplets to the + // lowest slot where they don't overlap with any chiplet already + // in that slot. + const zGroups = new Map(); // z -> [{idx, chiplet}] + data.chiplets.forEach((chiplet, idx) => { + const zKey = chiplet.z; + if (!zGroups.has(zKey)) zGroups.set(zKey, []); + zGroups.get(zKey).push({ idx, chiplet }); + }); + + const zOffsets = new Array(data.chiplets.length).fill(0); + + for (const group of zGroups.values()) { + if (group.length <= 1) continue; + // slots[i] = list of chiplet rects placed in slot i + const slots = []; + for (const { idx, chiplet } of group) { + const ax1 = chiplet.x; + const ay1 = chiplet.y; + const ax2 = chiplet.x + chiplet.width; + const ay2 = chiplet.y + chiplet.height; + + let placed = false; + for (let s = 0; s < slots.length; s++) { + const overlaps = slots[s].some(r => + ax1 < r.x2 && ax2 > r.x1 && + ay1 < r.y2 && ay2 > r.y1 + ); + if (!overlaps) { + slots[s].push({ x1: ax1, y1: ay1, x2: ax2, y2: ay2 }); + zOffsets[idx] = s; + placed = true; + break; + } + } + if (!placed) { + slots.push([{ x1: ax1, y1: ay1, x2: ax2, y2: ay2 }]); + zOffsets[idx] = slots.length - 1; + } + } + } + + // Build chiplets — Z coordinates scaled by kZScale to exaggerate + // vertical stacking (matching Qt kLayerGapFactor = 2.0) + data.chiplets.forEach((chiplet, idx) => { + const width = chiplet.width / dbu; + const height = chiplet.height / dbu; + const depth = ((chiplet.thickness || 10000) / dbu) * kZScale; + + const colorHex = CHIPLET_PALETTE[idx % CHIPLET_PALETTE.length]; + const material = new THREE.MeshPhongMaterial({ + color: colorHex, + transparent: false, + opacity: 1.0, + side: THREE.DoubleSide, + shininess: 40, + }); + + const geometry = new THREE.BoxGeometry(width, height, depth); + const mesh = new THREE.Mesh(geometry, material); + + // Position at chiplet center (Z scaled). + // Apply stacking offset when multiple chiplets share the same z. + const cx = (chiplet.x / dbu) + width / 2; + const cy = (chiplet.y / dbu) + height / 2; + const stackGap = depth * 0.15; + const cz = ((chiplet.z / dbu) * kZScale) + + depth / 2 + + zOffsets[idx] * (depth + stackGap); + + mesh.position.set(cx, cy, cz); + this._designGroup.add(mesh); + + this._chipletMeshes.push({ mesh, data: chiplet }); + + // Edges for depth clarity + const edges = new THREE.EdgesGeometry(geometry); + const line = new THREE.LineSegments(edges, edgeMaterial); + line.position.set(cx, cy, cz); + this._designGroup.add(line); + + // Update bounds (in scaled coordinates) + bounds.minX = Math.min(bounds.minX, chiplet.x / dbu); + bounds.minY = Math.min(bounds.minY, chiplet.y / dbu); + bounds.minZ = Math.min(bounds.minZ, cz - depth / 2); + bounds.maxX = Math.max(bounds.maxX, (chiplet.x / dbu) + width); + bounds.maxY = Math.max(bounds.maxY, (chiplet.y / dbu) + height); + bounds.maxZ = Math.max(bounds.maxZ, cz + depth / 2); + }); + + if (bounds.minX === Infinity) return; + + this._sceneBounds = bounds; + + // Grid on the XY plane at the base + const sizeX = bounds.maxX - bounds.minX; + const sizeY = bounds.maxY - bounds.minY; + const gridSize = Math.max(sizeX, sizeY) * 1.4; + const gridDivisions = 10; + const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x888888, 0x444444); + gridHelper.rotation.x = Math.PI / 2; + gridHelper.position.set( + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + bounds.minZ - 1 + ); + this._designGroup.add(gridHelper); + + // Compute bounding sphere radius (matching Qt) + const dx = bounds.maxX - bounds.minX; + const dy = bounds.maxY - bounds.minY; + const dz = bounds.maxZ - bounds.minZ; + this._boundingRadius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2; + + // Scene center (orbit pivot) + this._sceneCenter.set( + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + (bounds.minZ + bounds.maxZ) / 2 + ); + + // Initial distance (matching Qt kInitialDistanceFactor = 3.0) + this._distance = this._boundingRadius * kInitialDistanceFactor; + + // Initial rotation matching Qt: pitch -45° around X, then yaw +45° around Z + const pitch = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), -45 * DEG2RAD); + const yaw = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(0, 0, 1), 45 * DEG2RAD); + this._rotation = pitch.multiply(yaw); + + // Reset pan + this._panX = 0; + this._panY = 0; + + this._updateCamera(); + + // Start animation if not already running + if (!this._animationId) { + this.animate(); + } + } + + // ─── DRC Highlighting ────────────────────────────────────────────── + + clearHighlightDRC() { + if (this._drcBlinkInterval) { + clearInterval(this._drcBlinkInterval); + this._drcBlinkInterval = null; + } + if (this._drcMeshGroup) { + this._scene.remove(this._drcMeshGroup); + this._drcMeshGroup.children.forEach(child => { + if (child.geometry) child.geometry.dispose(); + }); + if (this._drcMaterial) { + this._drcMaterial.dispose(); + this._drcMaterial = null; + } + this._drcMeshGroup = null; + this.render(); + } + } + + highlightDRC(violations) { + this.clearHighlightDRC(); + if (!violations || violations.length === 0) return; + + const dbu = this._app.techData?.dbu_per_micron || 1000; + + let zMin = 0; + let zMax = (10000 / dbu) * kZScale; + if (this._sceneBounds && this._sceneBounds.minZ !== Infinity) { + zMin = this._sceneBounds.minZ; + zMax = this._sceneBounds.maxZ; + } + zMin -= 1; + zMax += 1; + const depth = Math.max(zMax - zMin, 1); + + this._drcMeshGroup = new THREE.Group(); + + this._drcMaterial = new THREE.LineBasicMaterial({ + color: 0xffff00, + transparent: true, + opacity: 1.0, + depthTest: false, + depthWrite: false + }); + + for (const violation of violations) { + const rects = (violation.rects && violation.rects.length > 0) + ? violation.rects : [violation.bbox]; + + for (const rect of rects) { + const [x1, y1, x2, y2, z1, z2] = rect; + const width = Math.max((x2 - x1) / dbu, 0.001); + const height = Math.max((y2 - y1) / dbu, 0.001); + + let currentZMin = zMin; + let currentDepth = depth; + if (rect.length >= 6 && z1 !== undefined && z2 !== undefined) { + currentZMin = (z1 / dbu) * kZScale; + const z2Scaled = (z2 / dbu) * kZScale; + currentDepth = Math.max(z2Scaled - currentZMin, 0.001); + } + + const cx = (x1 / dbu) + width / 2; + const cy = (y1 / dbu) + height / 2; + const cz = currentZMin + currentDepth / 2; + + const boxGeometry = new THREE.BoxGeometry(width, height, currentDepth); + const edgesGeometry = new THREE.EdgesGeometry(boxGeometry); + boxGeometry.dispose(); + const line = new THREE.LineSegments(edgesGeometry, this._drcMaterial); + line.position.set(cx, cy, cz); + line.renderOrder = 999; + this._drcMeshGroup.add(line); + } + } + + this._drcMeshGroup.renderOrder = 999; + this._scene.add(this._drcMeshGroup); + + let visible = true; + this._drcBlinkInterval = setInterval(() => { + if (this._drcMeshGroup && this._drcMaterial) { + visible = !visible; + this._drcMaterial.opacity = visible ? 1.0 : 0.2; + this.render(); + } + }, 300); + } + +} diff --git a/src/web/src/charts-widget.js b/src/web/src/charts-widget.js index 7773cfea96c..fe058b6681b 100644 --- a/src/web/src/charts-widget.js +++ b/src/web/src/charts-widget.js @@ -213,7 +213,15 @@ export class ChartsWidget { this._pathGroupSelect.addEventListener('change', () => this._fetchHistogram()); this._clockSelect.addEventListener('change', () => this._fetchHistogram()); - this._canvas.addEventListener('mousemove', (e) => this._handleHover(e)); + let mouseMovePending = false; + this._canvas.addEventListener('mousemove', (e) => { + if (mouseMovePending) return; + mouseMovePending = true; + requestAnimationFrame(() => { + this._handleHover(e); + mouseMovePending = false; + }); + }); this._canvas.addEventListener('mouseleave', () => { this._hoveredBar = null; this._tooltip.style.display = 'none'; diff --git a/src/web/src/checkbox-tree-model.js b/src/web/src/checkbox-tree-model.js index f28f181f607..2eceffe1e29 100644 --- a/src/web/src/checkbox-tree-model.js +++ b/src/web/src/checkbox-tree-model.js @@ -17,17 +17,33 @@ export class CheckboxTreeModel { // Build from a declarative spec. // Spec: { id, label?, checked?, hasCheckbox?, data?, children?: [...] } // Returns the root node. - addFromSpec(spec, parent = null) { - const node = this._makeNode(spec.id, parent, spec); + addFromSpec(spec, parent = null, _visited = new Set()) { + if (!spec || typeof spec !== 'object') return null; + + const id = spec.id !== undefined ? spec.id : Symbol('generated-id'); + + if (_visited.has(spec) || _visited.has(id)) { + console.warn(`Circular reference detected in spec for id: ${id}`); + return null; + } + _visited.add(spec); + _visited.add(id); + + const node = this._makeNode(id, parent, spec); node.checked = spec.checked !== false; - if (spec.children) { + + if (Array.isArray(spec.children)) { for (const child of spec.children) { - node.children.push(this.addFromSpec(child, node)); + const childNode = this.addFromSpec(child, node, _visited); + if (childNode) { + node.children.push(childNode); + } } if (node.hasCheckbox) { this._computeParent(node); } } + if (!parent) { this.roots.push(node); } @@ -91,6 +107,8 @@ export class CheckboxTreeModel { // Bulk check/uncheck. Single onChange call at the end. // idToChecked: object { id: boolean, ... } checkSet(idToChecked) { + if (!idToChecked || typeof idToChecked !== 'object') return; + for (const [id, checked] of Object.entries(idToChecked)) { const node = this._nodeMap.get(id); if (!node) continue; diff --git a/src/web/src/clock-tree-widget.js b/src/web/src/clock-tree-widget.js index c458649dbeb..72215ee69c5 100644 --- a/src/web/src/clock-tree-widget.js +++ b/src/web/src/clock-tree-widget.js @@ -164,6 +164,19 @@ export class ClockTreeWidget { this._dragStartTy = 0; this._build(container); + + if (container && container.on) { + container.on('destroy', () => this.destroy()); + } + } + + destroy() { + if (this._abortController) { + this._abortController.abort(); + } + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + } } // ---- DOM construction ---- @@ -250,22 +263,30 @@ export class ClockTreeWidget { this._canvas.style.cursor = 'grabbing'; }); + this._abortController = new AbortController(); + + let mouseMovePending = false; window.addEventListener('mousemove', (e) => { - if (this._dragging) { - this._tx = this._dragStartTx + (e.clientX - this._dragStartX); - this._ty = this._dragStartTy + (e.clientY - this._dragStartY); - this._render(); - } else { - this._handleHover(e); - } - }); + if (mouseMovePending) return; + mouseMovePending = true; + requestAnimationFrame(() => { + if (this._dragging) { + this._tx = this._dragStartTx + (e.clientX - this._dragStartX); + this._ty = this._dragStartTy + (e.clientY - this._dragStartY); + this._render(); + } else { + this._handleHover(e); + } + mouseMovePending = false; + }); + }, { signal: this._abortController.signal }); window.addEventListener('mouseup', () => { if (this._dragging) { this._dragging = false; this._canvas.style.cursor = 'grab'; } - }); + }, { signal: this._abortController.signal }); this._canvas.addEventListener('click', (e) => this._handleClick(e)); @@ -305,7 +326,7 @@ export class ClockTreeWidget { } _buildTabs() { - this._tabBar.innerHTML = ''; + this._tabBar.replaceChildren(); this._clockData.forEach((clk, idx) => { const btn = document.createElement('button'); btn.className = 'timing-tab' + diff --git a/src/web/src/display-controls.js b/src/web/src/display-controls.js index 557d298d9df..555898e5ef4 100644 --- a/src/web/src/display-controls.js +++ b/src/web/src/display-controls.js @@ -33,7 +33,7 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, techData, redrawAllLayers, HeatMapTileLayer) { if (!app.displayControlsEl) return; - app.displayControlsEl.innerHTML = ''; + app.displayControlsEl.replaceChildren(); app.allLayers = []; // Instance borders layer (always below routing layers) @@ -67,7 +67,8 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, const layerSpec = { id: 'layers_parent', - children: techData.layers.map((name, index) => { + children: techData.layers.map((entry, index) => { + const name = typeof entry === 'string' ? entry : entry.name; const layer = new WebSocketTileLayer(app.websocketManager, name, { opacity: 0.7, zIndex: index + 3, @@ -79,7 +80,8 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, const id = `layer_${index}`; layerIds.push(id); - return { id, data: { name, layer, colorIndex: index }, checked: true }; + const color = (typeof entry === 'object' && entry.color) ? entry.color : null; + return { id, data: { name, layer, colorIndex: index, color }, checked: true }; }), }; @@ -129,7 +131,9 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, const layerChildren = document.createElement('div'); layerChildren.className = 'vis-group-children'; - techData.layers.forEach((name, index) => { + techData.layers.forEach((entry, index) => { + const name = typeof entry === 'string' ? entry : entry.name; + const serverColor = (typeof entry === 'object' && entry.color) ? entry.color : null; const id = layerIds[index]; const modelNode = layerModel.get(id); @@ -146,7 +150,7 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, const colorSwatch = document.createElement('span'); colorSwatch.className = 'layer-color'; - const c = layerPalette[index % layerPalette.length]; + const c = serverColor || layerPalette[index % layerPalette.length]; colorSwatch.style.backgroundColor = `rgb(${c[0]},${c[1]},${c[2]})`; label.appendChild(colorSwatch); @@ -186,7 +190,7 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, label.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); - contextMenu.innerHTML = ''; + contextMenu.replaceChildren(); for (const item of menuItems) { const div = document.createElement('div'); div.className = 'context-menu-item'; @@ -267,7 +271,7 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, { key: 'net_scan', label: 'Scan' }, { key: 'net_analog', label: 'Analog' }, ]}); - visTree.add({ label: 'Shapes', children: [ + visTree.add({ label: 'Shape Types', children: [ { key: 'routing', label: 'Routing' }, { key: 'special_nets', label: 'Special Nets' }, { key: 'pins', label: 'Pins' }, @@ -289,9 +293,14 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, { key: 'tracks_pref', label: 'Pref' }, { key: 'tracks_non_pref', label: 'Non Pref' }, ]}); - visTree.add({ key: 'module_view', label: 'Module view' }); - visTree.add({ key: 'debug', label: 'Debug tiles' }); - visTree.render(app.displayControlsEl); + visTree.add({ key: 'rulers', label: 'Rulers' }); + visTree.add({ label: 'Misc', children: [ + { key: 'module_view', label: 'Module view' }, + { key: 'debug', label: 'Debug tiles' }, + ]}); + const visTreeContainer = document.createElement('div'); + app.displayControlsEl.appendChild(visTreeContainer); + visTree.render(visTreeContainer); if (!app.heatMapLayer) { app.heatMapLayer = new HeatMapTileLayer(app.websocketManager, app, { @@ -386,7 +395,7 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, } const legend = app.heatMapLegendEl; - legend.innerHTML = ''; + legend.replaceChildren(); if (!active || !active.show_legend || !active.legend || active.legend.length === 0) { legend.classList.add('hidden'); @@ -427,7 +436,7 @@ export function populateDisplayControls(app, visibility, WebSocketTileLayer, } app.renderHeatMapControls = (data) => { - heatMapContainer.innerHTML = ''; + heatMapContainer.replaceChildren(); const list = document.createElement('div'); list.className = 'heatmap-list'; diff --git a/src/web/src/drc-widget.js b/src/web/src/drc-widget.js new file mode 100644 index 00000000000..57f4674ca7c --- /dev/null +++ b/src/web/src/drc-widget.js @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// DRC Viewer widget — matches the OpenROAD Qt DRC Viewer layout: +// [Category Dropdown] [Load...] +// Type | Violation +// ───────────────────── +// ▶ Category (N markers) +// 1 | RuleName +// 2 | RuleName + +import { dbuRectToBounds } from './coordinates.js'; + +export class DRCWidget { + constructor(container, app, redrawAllLayers) { + this._app = app; + this._redrawAllLayers = redrawAllLayers; + this._categories = []; // [{name, count, violations}] + this._activeCategory = null; // name of selected category + this._selectedIndex = -1; // flat violation index + this._collapsed = new Set(); // collapsed category names + this._visibleIndexes = new Set(); // indexes of currently visible violations + + this._build(container); + } + + _build(container) { + const el = document.createElement('div'); + el.className = 'drc-widget'; + + // --- Top bar: category dropdown + Load button --- + const topBar = document.createElement('div'); + topBar.className = 'drc-topbar'; + + this._categorySelect = document.createElement('select'); + this._categorySelect.className = 'drc-category-select'; + + this._loadBtn = document.createElement('button'); + this._loadBtn.className = 'drc-btn'; + this._loadBtn.textContent = 'Load...'; + + topBar.appendChild(this._categorySelect); + topBar.appendChild(this._loadBtn); + el.appendChild(topBar); + + // --- Tree table: Type | Violation --- + this._tableContainer = document.createElement('div'); + this._tableContainer.className = 'drc-table-container'; + this._tableContainer.setAttribute('tabindex', '0'); + this._tableContainer.style.outline = 'none'; + + this._table = document.createElement('table'); + this._table.className = 'drc-table'; + + const thead = document.createElement('thead'); + const hr = document.createElement('tr'); + for (const label of ['Vis', 'Type', 'Violation']) { + const th = document.createElement('th'); + th.textContent = label; + hr.appendChild(th); + } + thead.appendChild(hr); + this._table.appendChild(thead); + + this._tbody = document.createElement('tbody'); + this._table.appendChild(this._tbody); + this._tableContainer.appendChild(this._table); + el.appendChild(this._tableContainer); + + container.element.appendChild(el); + + this._bindEvents(); + // Populate an empty dropdown, then try to load existing categories + this._refreshDropdown(); + this.update(); + } + + _bindEvents() { + this._categorySelect.addEventListener('change', () => { + this._activeCategory = this._categorySelect.value || null; + this._selectedIndex = -1; + this._clearHighlight(); + this._renderBody(); + }); + + this._loadBtn.addEventListener('click', () => this._showLoadDialog()); + + this._tableContainer.addEventListener('keydown', (e) => { + const visibleViolations = this._getVisibleViolations(); + const pos = visibleViolations.findIndex( + v => v.index === this._selectedIndex); + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (pos < visibleViolations.length - 1) { + this._selectViolation(visibleViolations[pos + 1]); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (pos > 0) { + this._selectViolation(visibleViolations[pos - 1]); + } + } + }); + } + + // Returns violations of the currently active category (or all if none selected) + _getVisibleViolations() { + if (!this._activeCategory) return []; + const cat = this._categories.find(c => c.name === this._activeCategory); + return cat ? cat.violations : []; + } + + setHasChipInsts(hasChipInsts) { + this._hasChipInsts = hasChipInsts; + } + + // ─── Update (refresh from backend) ──────────────────────────────────────── + + async update() { + try { + await this._app.websocketManager.readyPromise; + const data = await this._app.websocketManager.request({ + type: 'drc_report', + }); + this._categories = data.categories || []; + // Keep current selection if still valid, else default to first + const stillValid = this._categories.some( + c => c.name === this._activeCategory); + if (!stillValid) { + this._activeCategory = this._categories.length > 0 + ? this._categories[0].name : null; + } + this._selectedIndex = -1; + this._refreshDropdown(); + this._renderBody(); + this._clearHighlight(); + } catch (e) { + console.error('DRC fetch failed:', e); + } + } + + // ─── Dropdown ───────────────────────────────────────────────────────────── + + _refreshDropdown() { + this._categorySelect.replaceChildren(); + + if (this._categories.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = '(no categories)'; + this._categorySelect.appendChild(opt); + this._categorySelect.disabled = true; + return; + } + + this._categorySelect.disabled = false; + for (const cat of this._categories) { + const opt = document.createElement('option'); + opt.value = cat.name; + opt.textContent = `${cat.name} (${cat.count})`; + if (cat.name === this._activeCategory) opt.selected = true; + this._categorySelect.appendChild(opt); + } + } + + // ─── Tree rendering ─────────────────────────────────────────────────────── + + _renderBody() { + this._tbody.replaceChildren(); + + if (!this._activeCategory) return; + + const cat = this._categories.find(c => c.name === this._activeCategory); + if (!cat) return; + + const collapsed = this._collapsed.has(cat.name); + + // Category header row + const headerRow = document.createElement('tr'); + headerRow.className = 'drc-category-row'; + + const visTd = document.createElement('td'); + const catCheckbox = document.createElement('input'); + catCheckbox.type = 'checkbox'; + catCheckbox.checked = cat.violations.every(v => this._visibleIndexes.has(v.index)); + catCheckbox.indeterminate = !catCheckbox.checked && cat.violations.some(v => this._visibleIndexes.has(v.index)); + catCheckbox.addEventListener('change', (e) => { + const checked = e.target.checked; + cat.violations.forEach(v => { + if (checked) this._visibleIndexes.add(v.index); + else this._visibleIndexes.delete(v.index); + }); + this._sendVisibleRequest(); + this._renderBody(); + }); + visTd.appendChild(catCheckbox); + + const arrowTd = document.createElement('td'); + arrowTd.className = 'drc-category-arrow'; + arrowTd.colSpan = 1; + arrowTd.textContent = collapsed ? '▶' : '▼'; + + const nameTd = document.createElement('td'); + nameTd.textContent = `${cat.name} (${cat.count} marker${cat.count !== 1 ? 's' : ''})`; + + headerRow.appendChild(visTd); + headerRow.appendChild(arrowTd); + headerRow.appendChild(nameTd); + + arrowTd.addEventListener('click', () => { + if (collapsed) { + this._collapsed.delete(cat.name); + } else { + this._collapsed.add(cat.name); + } + this._renderBody(); + }); + nameTd.addEventListener('click', () => { + if (collapsed) { + this._collapsed.delete(cat.name); + } else { + this._collapsed.add(cat.name); + } + this._renderBody(); + }); + + this._tbody.appendChild(headerRow); + + if (!collapsed) { + cat.violations.forEach((v, localIdx) => { + const tr = document.createElement('tr'); + tr.className = 'drc-violation-row'; + if (v.index === this._selectedIndex) tr.classList.add('selected'); + if (!v.is_visited) tr.style.fontWeight = 'bold'; + + const vVisTd = document.createElement('td'); + const vCheckbox = document.createElement('input'); + vCheckbox.type = 'checkbox'; + vCheckbox.checked = this._visibleIndexes.has(v.index); + vCheckbox.addEventListener('change', (e) => { + if (e.target.checked) this._visibleIndexes.add(v.index); + else this._visibleIndexes.delete(v.index); + this._sendVisibleRequest(); + this._renderBody(); + }); + vVisTd.appendChild(vCheckbox); + + const indexTd = document.createElement('td'); + indexTd.className = 'drc-index-cell'; + indexTd.textContent = localIdx + 1; + + const ruleTd = document.createElement('td'); + ruleTd.textContent = v.rule || v.layer || ''; + + tr.appendChild(vVisTd); + tr.appendChild(indexTd); + tr.appendChild(ruleTd); + + tr.style.cursor = 'pointer'; + indexTd.addEventListener('click', () => this._selectViolation(v)); + ruleTd.addEventListener('click', () => this._selectViolation(v)); + + this._tbody.appendChild(tr); + }); + } + } + + _sendVisibleRequest() { + this._app.websocketManager.request({ + type: 'drc_set_visible', + indexes: Array.from(this._visibleIndexes).map(String) + }).then(() => { + this._redrawAllLayers(); + this._update3DHighlights(); + }).catch(err => console.error('drc_set_visible error:', err)); + } + + _update3DHighlights() { + if (!this._app.threeDViewerWidget) return; + const visibleViolations = this._getVisibleViolations().filter( + v => this._visibleIndexes.has(v.index) + ); + if (visibleViolations.length > 0) { + this._app.threeDViewerWidget.highlightDRC(visibleViolations); + } else { + this._app.threeDViewerWidget.clearHighlightDRC(); + } + } + + _selectViolation(v) { + this._selectedIndex = v.index; + + if (!v.is_visited) { + v.is_visited = true; + this._app.websocketManager.request({ + type: 'drc_mark_visited', + index: v.index, + }).catch(err => console.error('drc_mark_visited error:', err)); + } + + // Auto-enable visibility for selected violation + if (!this._visibleIndexes.has(v.index)) { + this._visibleIndexes.add(v.index); + this._sendVisibleRequest(); + } + + this._renderBody(); + + // Scroll selected row into view + const selectedRow = this._tbody.querySelector('tr.selected'); + if (selectedRow) { + selectedRow.scrollIntoView({ block: 'nearest' }); + } + this._tableContainer.focus(); + + this._app.websocketManager.request({ + type: 'drc_highlight', + index: v.index, + }).then(() => { + this._redrawAllLayers(); + this._zoomToBbox(v.bbox); + + // If the 3D Viewer isn't open, open it so we can highlight + if (!this._app.threeDViewerWidget && this._app.focusComponent) { + this._app.focusComponent('3DViewer'); + } + + this._update3DHighlights(); + }).catch(err => console.error('drc_highlight error:', err)); + } + + _clearHighlight() { + this._selectedIndex = -1; + this._app.websocketManager.request({ type: 'drc_highlight', index: -1 }) + .then(() => { + this._redrawAllLayers(); + this._update3DHighlights(); + }) + .catch(err => console.error('drc_highlight clear error:', err)); + } + + _zoomToBbox(bbox) { + if (!this._app.map || !this._app.designScale || !bbox) return; + const [x1, y1, x2, y2] = bbox; + const { designScale: scale, designMaxDXDY: maxDXDY, + designOriginX: ox, designOriginY: oy } = this._app; + const dx = Math.max((x2 - x1) * 0.1, 100); + const dy = Math.max((y2 - y1) * 0.1, 100); + const bounds = dbuRectToBounds( + x1 - dx, y1 - dy, x2 + dx, y2 + dy, + scale, maxDXDY, ox, oy); + this._app.map.fitBounds(bounds); + } + + // ─── Load dialog ────────────────────────────────────────────────────────── + + _showLoadDialog() { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + + overlay.innerHTML = ` + `; + + document.body.appendChild(overlay); + + const breadcrumb = overlay.querySelector('.fb-breadcrumb'); + const fileList = overlay.querySelector('.fb-file-list'); + const pathInput = overlay.querySelector('.fb-path-input'); + const errorDiv = overlay.querySelector('.modal-error'); + const okBtn = overlay.querySelector('.ok'); + const cancelBtn = overlay.querySelector('.cancel'); + let currentPath = ''; + + const close = () => overlay.remove(); + + const updateOkState = () => { + okBtn.disabled = !pathInput.value.trim(); + }; + + const renderBreadcrumb = (dirPath) => { + breadcrumb.replaceChildren(); + const parts = dirPath.split('/').filter(Boolean); + const root = document.createElement('span'); + root.className = 'fb-crumb'; + root.textContent = '/'; + root.addEventListener('click', () => navigate('/')); + breadcrumb.appendChild(root); + let accum = ''; + for (const part of parts) { + accum += '/' + part; + const sep = document.createElement('span'); + sep.className = 'fb-crumb-sep'; + sep.textContent = ' / '; + breadcrumb.appendChild(sep); + const crumb = document.createElement('span'); + crumb.className = 'fb-crumb'; + crumb.textContent = part; + const target = accum; + crumb.addEventListener('click', () => navigate(target)); + breadcrumb.appendChild(crumb); + } + }; + + const renderEntries = (entries) => { + fileList.replaceChildren(); + if (!entries || entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'fb-empty'; + empty.textContent = '(empty directory)'; + fileList.appendChild(empty); + return; + } + for (const entry of entries) { + const row = document.createElement('div'); + row.className = 'fb-entry' + (entry.is_dir ? ' fb-dir' : ''); + const icon = document.createElement('span'); + icon.className = 'fb-icon'; + icon.textContent = entry.is_dir ? '\u{1F4C1}' : '\u{1F4C4}'; + const name = document.createElement('span'); + name.className = 'fb-name'; + name.textContent = entry.name; + row.appendChild(icon); + row.appendChild(name); + + if (entry.is_dir) { + row.addEventListener('click', () => + navigate(currentPath + '/' + entry.name)); + } else { + row.addEventListener('click', () => { + fileList.querySelectorAll('.fb-selected') + .forEach(el => el.classList.remove('fb-selected')); + row.classList.add('fb-selected'); + pathInput.value = currentPath + '/' + entry.name; + updateOkState(); + }); + row.addEventListener('dblclick', () => { + pathInput.value = currentPath + '/' + entry.name; + updateOkState(); + submit(); + }); + } + fileList.appendChild(row); + } + }; + + const navigate = async (dirPath) => { + errorDiv.style.display = 'none'; + fileList.innerHTML = '
Loading...
'; + try { + const resp = await this._app.websocketManager.request({ + type: 'list_dir', + path: dirPath || '', + }); + currentPath = resp.path; + renderBreadcrumb(resp.path); + renderEntries(resp.entries); + pathInput.value = ''; + updateOkState(); + } catch (err) { + fileList.replaceChildren(); + errorDiv.textContent = err.message || String(err); + errorDiv.style.display = ''; + } + }; + + const submit = async () => { + const path = pathInput.value.trim(); + if (!path) return; + okBtn.disabled = true; + okBtn.textContent = 'Loading...'; + errorDiv.style.display = 'none'; + try { + const resp = await this._app.websocketManager.request({ + type: 'drc_load', + path, + }); + if (resp.error || resp.type === 'error') { + throw new Error(resp.result || 'Load failed'); + } + close(); + // Refresh the widget with the newly loaded report + await this.update(); + // Select the newly loaded category + if (resp.category) { + this._activeCategory = resp.category; + this._refreshDropdown(); + this._renderBody(); + } + } catch (err) { + errorDiv.textContent = err.message || 'Load failed'; + errorDiv.style.display = ''; + okBtn.disabled = false; + okBtn.textContent = 'Load'; + } + }; + + cancelBtn.addEventListener('click', close); + okBtn.addEventListener('click', submit); + pathInput.addEventListener('input', updateOkState); + pathInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submit(); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + // Start at current directory + navigate(''); + } +} diff --git a/src/web/src/drc_report.cpp b/src/web/src/drc_report.cpp new file mode 100644 index 00000000000..100cc165845 --- /dev/null +++ b/src/web/src/drc_report.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +#include "drc_report.h" + +#include +#include +#include +#include + +#include "odb/db.h" +#include "odb/geom.h" +#include "tile_generator.h" + +namespace web { + +DRCReport::DRCReport(const TileGenerator* gen) : gen_(gen) +{ +} + +void DRCReport::collectViolations(odb::dbMarkerCategory* category, + std::vector& violations, + int& index) const +{ + for (odb::dbMarker* marker : category->getMarkers()) { + DRCViolation v; + v.index = index++; + v.rule = category->getName(); + + odb::dbTechLayer* layer = marker->getTechLayer(); + if (layer) { + v.layer = layer->getName(); + } + + v.bbox = marker->getBBox(); + v.comment = marker->getComment(); + v.is_visited = marker->isVisited(); + + for (const auto& shape : marker->getShapes()) { + if (std::holds_alternative(shape)) { + v.rects.push_back(std::get(shape)); + } else if (std::holds_alternative(shape)) { + v.polys.push_back(std::get(shape)); + } else if (std::holds_alternative(shape)) { + v.cuboids.push_back(std::get(shape)); + } + // Point and Line shapes: bbox is used as fallback below + } + + // Ensure at least one shape for rendering + if (v.rects.empty() && v.polys.empty() && v.cuboids.empty() + && !v.bbox.isInverted()) { + v.rects.push_back(v.bbox); + } + + violations.push_back(std::move(v)); + } + + // Recurse into sub-categories + for (odb::dbMarkerCategory* sub : category->getMarkerCategories()) { + collectViolations(sub, violations, index); + } +} + +DRCReportResult DRCReport::getReport() const +{ + DRCReportResult result; + + odb::dbChip* chip = gen_->getChip(); + if (!chip) { + return result; + } + + int index = 0; + for (odb::dbMarkerCategory* category : chip->getMarkerCategories()) { + DRCCategoryResult cat_result; + cat_result.name = category->getName(); + collectViolations(category, cat_result.violations, index); + cat_result.count = static_cast(cat_result.violations.size()); + result.total_count += cat_result.count; + result.categories.push_back(std::move(cat_result)); + } + + return result; +} + +} // namespace web diff --git a/src/web/src/drc_report.h b/src/web/src/drc_report.h new file mode 100644 index 00000000000..b82e38232d0 --- /dev/null +++ b/src/web/src/drc_report.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +#pragma once + +#include +#include + +#include "odb/geom.h" + +namespace odb { +class dbMarkerCategory; +} + +namespace web { + +class TileGenerator; + +struct DRCViolation +{ + int index = 0; // flat global index (for highlight lookup) + std::string rule; // sub-category / marker rule name + std::string layer; // tech layer name (empty if none) + odb::Rect bbox; // bounding box in DBU (for zoom) + std::string comment; + bool is_visited = false; + std::vector rects; // shape rects for highlight rendering + std::vector polys; // shape polys for highlight rendering + std::vector cuboids; // shape cuboids for 3D highlight rendering +}; + +struct DRCCategoryResult +{ + std::string name; + int count = 0; + std::vector violations; +}; + +struct DRCReportResult +{ + std::vector categories; + int total_count = 0; +}; + +class DRCReport +{ + public: + explicit DRCReport(const TileGenerator* gen); + + DRCReportResult getReport() const; + + private: + void collectViolations(odb::dbMarkerCategory* category, + std::vector& violations, + int& index) const; + + const TileGenerator* gen_; +}; + +} // namespace web diff --git a/src/web/src/hierarchy-browser.js b/src/web/src/hierarchy-browser.js index 65e9150970f..0331744943f 100644 --- a/src/web/src/hierarchy-browser.js +++ b/src/web/src/hierarchy-browser.js @@ -264,7 +264,7 @@ export class HierarchyBrowser { } _render() { - this._table.innerHTML = ''; + this._table.replaceChildren(); // Header const thead = document.createElement('thead'); diff --git a/src/web/src/inspector.js b/src/web/src/inspector.js index 00c5cd1094f..318e3cd6d1a 100644 --- a/src/web/src/inspector.js +++ b/src/web/src/inspector.js @@ -3,7 +3,7 @@ // Inspector panel — property tree, hover highlights, bbox display. -import { dbuToLatLng, dbuRectToBounds } from './coordinates.js'; +import { dbuRectToBounds } from './coordinates.js'; // SVG icons — distinct shapes so they're easy to tell apart at a glance. // Zoom to: magnifying glass with "+" (Material "zoom_in") @@ -57,7 +57,7 @@ export function createInspectorPanel(app, redrawAllLayers) { function showLoading() { if (!app.inspectorEl) return; - app.inspectorEl.innerHTML = ''; + app.inspectorEl.replaceChildren(); const loading = document.createElement('div'); loading.className = 'stub-panel'; loading.innerHTML = @@ -120,7 +120,7 @@ export function createInspectorPanel(app, redrawAllLayers) { clearClientHoverHighlight(); app.websocketManager.request({ type: 'hover', select_id: -1 }) .then(() => {}) - .catch(() => {}); + .catch(err => console.warn('Failed to clear hover highlight:', err)); } function boundsWithMinimumScreenSize(x1, y1, x2, y2) { @@ -195,7 +195,7 @@ export function createInspectorPanel(app, redrawAllLayers) { renderHoverRects(data.rects || []); redrawAllLayers(); }) - .catch(() => {}); + .catch(err => console.warn('Hover highlight request failed:', err)); }); el.addEventListener('mouseleave', () => { clearHoverHighlight(); @@ -379,7 +379,7 @@ export function createInspectorPanel(app, redrawAllLayers) { function updateInspector(data) { if (!app.inspectorEl) return; lastInspectData = data; - app.inspectorEl.innerHTML = ''; + app.inspectorEl.replaceChildren(); if (!data || !data.properties || data.properties.length === 0) { const placeholder = document.createElement('div'); diff --git a/src/web/src/json_builder.h b/src/web/src/json_builder.h index 565f033ed5b..0b3336a776a 100644 --- a/src/web/src/json_builder.h +++ b/src/web/src/json_builder.h @@ -7,6 +7,7 @@ #include #include #include +#include namespace web { @@ -204,24 +205,28 @@ class JsonBuilder std::string&& take() { return std::move(buf_); } private: - static constexpr int kMaxDepth = 16; - bool need_comma_[kMaxDepth]; + std::vector need_comma_; int depth_ = 0; std::string buf_; void pushContext() { - assert(depth_ < kMaxDepth); - need_comma_[depth_] = false; + if (depth_ >= need_comma_.size()) { + need_comma_.push_back(false); + } else { + need_comma_[depth_] = false; + } depth_++; } void popContext() { - depth_--; if (depth_ > 0) { - need_comma_[depth_ - 1] = true; + depth_--; + if (depth_ > 0) { + need_comma_[depth_ - 1] = true; + } } } diff --git a/src/web/src/main.js b/src/web/src/main.js index f35ab4fbbd3..11ca4ca285e 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -14,9 +14,16 @@ import { populateDisplayControls } from './display-controls.js'; import { createMenuBar } from './menu-bar.js'; import { RulerManager } from './ruler.js'; import { SchematicWidget } from './schematic-widget.js'; +import { DRCWidget } from './drc-widget.js'; +import { ThreeDViewerWidget } from './3d-viewer-widget.js'; import { TclCompleter } from './tcl-completer.js'; import './theme.js'; +// ─── Constants ────────────────────────────────────────────────────────────── + +const TILE_SIZE = 256; +const DEFAULT_WS_HOST = 'localhost:8080'; + // ─── Status Indicator ─────────────────────────────────────────────────────── const statusDiv = document.getElementById('websocket-status'); @@ -68,6 +75,7 @@ const app = { heatMapLegendEl: null, renderHeatMapControls: null, rulerManager: null, + loadError: false, }; const visibility = { @@ -125,6 +133,8 @@ const visibility = { module_view: false, // Debug debug: false, + // Rulers + rulers: true, }; const WebSocketTileLayer = createWebSocketTileLayer(visibility); @@ -229,6 +239,34 @@ function updateHeatMaps(data) { } app.updateHeatMaps = updateHeatMaps; +function rebuildLayersAndControls() { + if (!app.techData || !app.displayControlsEl) return; + if (!app.map) return; // map not ready yet + + // Remove old tile layers from the map before rebuilding. + for (const layer of app.allLayers) { + if (app.map.hasLayer(layer)) { + app.map.removeLayer(layer); + } + } + app.allLayers = []; + app.visibleLayers.clear(); + + try { + populateDisplayControls(app, visibility, WebSocketTileLayer, + app.techData, redrawAllLayers, HeatMapTileLayer); + } catch (err) { + console.error('populateDisplayControls failed:', err); + app.displayControlsEl.innerHTML = + '
Error building display controls: ' + + err.message + '
'; + return; + } + if (app.heatMapData) { + updateHeatMaps(app.heatMapData); + } +} + function redrawAllLayers() { // Show/hide modules layer based on module_view visibility if (app.modulesLayer) { @@ -238,6 +276,14 @@ function redrawAllLayers() { app.map.removeLayer(app.modulesLayer); } } + // Show/hide rulers layer + if (app.rulerManager && app.rulerManager._rulerLayerGroup) { + if (visibility.rulers && !app.map.hasLayer(app.rulerManager._rulerLayerGroup)) { + app.rulerManager._rulerLayerGroup.addTo(app.map); + } else if (!visibility.rulers && app.map.hasLayer(app.rulerManager._rulerLayerGroup)) { + app.map.removeLayer(app.rulerManager._rulerLayerGroup); + } + } // Show/hide pin markers layer if (app.pinsLayer) { if (visibility.pin_markers && !app.map.hasLayer(app.pinsLayer)) { @@ -254,6 +300,60 @@ function redrawAllLayers() { } } +// ─── Shared Design Data Helpers ──────────────────────────────────────────── + +function applyDesignData(techData, boundsData, heatMapData) { + app.loadError = false; + app.hasLiberty = techData.has_liberty; + app.techData = techData; + + const designBounds = boundsData.bounds; + const minY = designBounds[0][0]; + const minX = designBounds[0][1]; + const maxY = designBounds[1][0]; + const maxX = designBounds[1][1]; + + const designWidth = maxX - minX; + const designHeight = maxY - minY; + const hasDesign = designWidth > 0 && designHeight > 0; + + if (hasDesign) { + const maxDXDY = Math.max(designWidth, designHeight); + const scale = TILE_SIZE / maxDXDY; + app.designScale = scale; + app.designMaxDXDY = maxDXDY; + app.designOriginX = minX; + app.designOriginY = minY; + + app.fitBounds = [ + [-maxDXDY * scale, 0], + [(designHeight - maxDXDY) * scale, designWidth * scale] + ]; + app.map.fitBounds(app.fitBounds); + } + + app.heatMapData = heatMapData; + rebuildLayersAndControls(); + + if (hasDesign && !boundsData.shapes_ready) { + document.getElementById('loading-overlay').style.display = 'flex'; + } +} + +function handleDesignLoadError(err, context) { + console.error(context, err); + app.loadError = true; + app.techData = null; + app.allLayers = []; + app.heatMapData = null; + if (app.displayControlsEl) { + const detail = err.message || String(err); + app.displayControlsEl.innerHTML = + '
Failed to load design data. ' + + detail + '
'; + } + document.getElementById('loading-overlay').style.display = 'none'; +} function createLayoutViewer(container) { const mapDiv = document.createElement('div'); @@ -274,7 +374,55 @@ function createLayoutViewer(container) { zoomSnap: 0, fadeAnimation: false, attributionControl: false, + dragging: false, // Disable default left-click pan }); + + // Implement middle-button pan to match Qt GUI + let isMiddleDragging = false; + let middleDragStart = null; + + const abortController = new AbortController(); + if (container && container.on) { + container.on('destroy', () => abortController.abort()); + } + + mapDiv.addEventListener('mousedown', (e) => { + if (e.button === 1) { // Middle button + e.preventDefault(); + isMiddleDragging = true; + middleDragStart = { x: e.clientX, y: e.clientY }; + mapDiv.style.cursor = 'grabbing'; + } + }); + + let mouseMovePending = false; + window.addEventListener('mousemove', (e) => { + if (isMiddleDragging && middleDragStart) { + e.preventDefault(); + if (mouseMovePending) return; + mouseMovePending = true; + requestAnimationFrame(() => { + if (!middleDragStart) { + mouseMovePending = false; + return; + } + const dx = e.clientX - middleDragStart.x; + const dy = e.clientY - middleDragStart.y; + app.map.panBy([-dx, -dy], { animate: false }); + middleDragStart = { x: e.clientX, y: e.clientY }; + mouseMovePending = false; + }); + } + }, { signal: abortController.signal }); + + window.addEventListener('mouseup', (e) => { + if (e.button === 1 && isMiddleDragging) { + isMiddleDragging = false; + middleDragStart = null; + mapDiv.style.cursor = ''; + } + }, { signal: abortController.signal }); + const hoverPane = app.map.createPane(app.hoverHighlightPane); hoverPane.style.zIndex = '650'; hoverPane.style.pointerEvents = 'none'; @@ -308,9 +456,14 @@ function createLayoutViewer(container) { function createDisplayControls(container) { const el = document.createElement('div'); el.className = 'display-controls'; - el.innerHTML = '
Loading layers...
'; + if (app.loadError) { + el.innerHTML = '
No top level block found, probably reading a block-less design (e.g., 3dbx). Please load a design to view layers.
'; + } else { + el.innerHTML = '
Loading layers...
'; + } container.element.appendChild(el); app.displayControlsEl = el; + rebuildLayersAndControls(); } function tclAppend(text, className) { @@ -361,6 +514,8 @@ function createTclConsole(container) { .catch(err => tclAppend(`Error: ${err}\n`, 'tcl-error')); } }); + + container.on('destroy', () => completer.destroy()); } // ─── Inspector Panel ──────────────────────────────────────────────────────── @@ -380,8 +535,7 @@ function createTimingWidget(container) { } function createDRCWidget(container) { - createStubPanel(container, 'DRC', - 'Design rule check violations viewer.'); + app.drcWidget = new DRCWidget(container, app, redrawAllLayers); } function createClockWidget(container) { @@ -392,6 +546,10 @@ function createChartsWidget(container) { app.chartsWidget = new ChartsWidget(container, app, redrawAllLayers); } +function create3DViewerWidget(container) { + app.threeDViewerWidget = new ThreeDViewerWidget(container, app); +} + function createHelpWidget(container) { const el = document.createElement('div'); el.className = 'help-panel'; @@ -409,24 +567,10 @@ function createHelpWidget(container) { container.element.appendChild(el); } -function createSelectHighlight(container) { - createStubPanel(container, 'Selection', - 'Selection and highlight browser.'); -} - function createSchematicWidget(container) { new SchematicWidget(container, app); } -function createStubPanel(container, title, description) { - const el = document.createElement('div'); - el.className = 'stub-panel'; - el.innerHTML = - `
${title}
` + - `
${description}
`; - container.element.appendChild(el); -} - // ─── Layout Configuration ─────────────────────────────────────────────────── const defaultLayoutConfig = { @@ -526,9 +670,9 @@ app.goldenLayout.registerComponentFactoryFunction('TimingWidget', createTimingWi app.goldenLayout.registerComponentFactoryFunction('DRCWidget', createDRCWidget); app.goldenLayout.registerComponentFactoryFunction('ClockWidget', createClockWidget); app.goldenLayout.registerComponentFactoryFunction('ChartsWidget', createChartsWidget); +app.goldenLayout.registerComponentFactoryFunction('3DViewer', create3DViewerWidget); app.goldenLayout.registerComponentFactoryFunction('SchematicWidget', createSchematicWidget); app.goldenLayout.registerComponentFactoryFunction('HelpWidget', createHelpWidget); -app.goldenLayout.registerComponentFactoryFunction('SelectHighlight', createSelectHighlight); // Layout version — bump this to force a layout reset when components change. const LAYOUT_VERSION = 3; @@ -537,7 +681,7 @@ const LAYOUT_VERSION = 3; // Must be created before loadLayout so that components (e.g. SchematicWidget) // constructed during layout initialisation can access app.websocketManager. -const websocketUrl = `ws://${window.location.host || 'localhost:8080'}/ws`; +const websocketUrl = `ws://${window.location.host || DEFAULT_WS_HOST}/ws`; app.websocketManager = new WebSocketManager(websocketUrl, updateStatus); // Restore saved layout or use default @@ -577,9 +721,9 @@ const componentTitles = { DRCWidget: 'DRC', ClockWidget: 'Clock Tree', ChartsWidget: 'Charts', + '3DViewer': '3D Viewer', SchematicWidget: 'Schematic', HelpWidget: 'Help', - SelectHighlight: 'Select Highlight', }; // Focus a Golden Layout component tab, or re-create it if it was closed. @@ -623,6 +767,42 @@ app.websocketManager.onPush = (msg) => { if (msg.type === 'refresh') { document.getElementById('loading-overlay').style.display = 'none'; redrawAllLayers(); + if (app.threeDViewerWidget) { + app.threeDViewerWidget.loadData(); + } + } else if (msg.type === 'drcUpdated') { + if (app.drcWidget) { + app.drcWidget.update(); + } + } else if (msg.type === 'chipLoaded') { + if (app.drcWidget) app.drcWidget.update(); + // Clear selection + app.selectedInstanceName = null; + updateInspector(null); + if (app.highlightRect) { + app.map.removeLayer(app.highlightRect); + app.highlightRect = null; + } + + // Re-fetch bounds, tech, and heatmaps so display controls and tile + // layers are rebuilt (critical for read_3dbx which adds a "Chiplets" + // layer that did not exist at initial page load). + Promise.all([ + app.websocketManager.request({ type: 'tech' }), + app.websocketManager.request({ type: 'bounds' }), + app.websocketManager.request({ type: 'heatmaps' }), + ]).then(([techData, boundsData, heatMapData]) => { + if (app.drcWidget) { + app.drcWidget.setHasChipInsts(!!techData.has_chip_insts); + } + applyDesignData(techData, boundsData, heatMapData); + }).catch(err => { + handleDesignLoadError(err, 'Failed to reload design on chipLoaded:'); + }); + + if (app.threeDViewerWidget) { + app.threeDViewerWidget.loadData(); + } } }; @@ -633,37 +813,7 @@ app.websocketManager.readyPromise.then(async () => { app.websocketManager.request({ type: 'bounds' }), app.websocketManager.request({ type: 'heatmaps' }), ]); - app.hasLiberty = techData.has_liberty; - app.techData = techData; - - // --- Set Bounds --- - const designBounds = boundsData.bounds; - - const minY = designBounds[0][0]; - const minX = designBounds[0][1]; - const maxY = designBounds[1][0]; - const maxX = designBounds[1][1]; - - const designWidth = maxX - minX; - const designHeight = maxY - minY; - - // No design loaded — skip map setup, let user open a DB via menu. - const hasDesign = designWidth > 0 && designHeight > 0; - if (hasDesign) { - const tileSize = 256; - const maxDXDY = Math.max(designWidth, designHeight); - const scale = tileSize / maxDXDY; - app.designScale = scale; - app.designMaxDXDY = maxDXDY; - app.designOriginX = minX; - app.designOriginY = minY; - - app.fitBounds = [ - [-maxDXDY * scale, 0], - [(designHeight - maxDXDY) * scale, designWidth * scale] - ]; - app.map.fitBounds(app.fitBounds); - } + applyDesignData(techData, boundsData, heatMapData); // Click-to-select: convert click position to DBU and query server app.map.on('click', (e) => { @@ -679,7 +829,6 @@ app.websocketManager.readyPromise.then(async () => { } app.websocketManager.request({ type: 'select', dbu_x, dbu_y, zoom: app.map.getZoom(), visible_layers: [...app.visibleLayers], ...vf }) .then(data => { - console.log('Select response:', data, 'at dbu', dbu_x, dbu_y); app.map.closePopup(); if (data.selected && data.selected.length > 0) { const inst = data.selected[0]; @@ -726,6 +875,8 @@ app.websocketManager.readyPromise.then(async () => { app.map.dragging.disable(); }); + const abortController = new AbortController(); + window.addEventListener('mousemove', (e) => { if (!rbStart) return; const dx = e.clientX - rbStart.x; @@ -743,7 +894,7 @@ app.websocketManager.readyPromise.then(async () => { rbDiv.style.width = Math.abs(dx) + 'px'; rbDiv.style.height = Math.abs(dy) + 'px'; } - }); + }, { signal: abortController.signal }); window.addEventListener('mouseup', (e) => { if (!rbStart) return; @@ -768,21 +919,10 @@ app.websocketManager.readyPromise.then(async () => { [Math.min(p1.lat, p2.lat), Math.min(p1.lng, p2.lng)], [Math.max(p1.lat, p2.lat), Math.max(p1.lng, p2.lng)], ]); - }); - } - - populateDisplayControls(app, visibility, WebSocketTileLayer, - techData, redrawAllLayers, HeatMapTileLayer); - updateHeatMaps(heatMapData); - - // Only show the loading overlay if a design is loaded but shapes - // aren't ready yet. On browser reload (without server restart), - // shapes are already built so we skip the overlay. - if (hasDesign && !boundsData.shapes_ready) { - document.getElementById('loading-overlay').style.display = 'flex'; + }, { signal: abortController.signal }); } } catch (err) { - console.error('Failed to load initial data from server:', err); + handleDesignLoadError(err, 'Failed to load initial data from server:'); } }); diff --git a/src/web/src/menu-bar.js b/src/web/src/menu-bar.js index 639b305cffe..61e8e1f589b 100644 --- a/src/web/src/menu-bar.js +++ b/src/web/src/menu-bar.js @@ -28,6 +28,7 @@ export function createMenuBar(app) { ]}, { label: 'Windows', items: [ { label: 'Layout Viewer', action: () => app.focusComponent('LayoutViewer') }, + { label: '3D Viewer', action: () => app.focusComponent('3DViewer') }, { label: 'Display Controls', action: () => app.focusComponent('DisplayControls') }, { label: 'Inspector', action: () => app.focusComponent('Inspector') }, { label: 'Tcl Console', action: () => app.focusComponent('TclConsole') }, @@ -143,28 +144,57 @@ function showPathDialog(app, title, tclCmd) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; - overlay.innerHTML = ` - `; + const dialog = document.createElement('div'); + dialog.className = 'modal-dialog file-browser-dialog'; + + const heading = document.createElement('h3'); + heading.textContent = title; + dialog.appendChild(heading); + + const breadcrumbDiv = document.createElement('div'); + breadcrumbDiv.className = 'fb-breadcrumb'; + dialog.appendChild(breadcrumbDiv); + + const fileListDiv = document.createElement('div'); + fileListDiv.className = 'fb-file-list'; + dialog.appendChild(fileListDiv); + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'fb-path-input'; + input.placeholder = 'Server-side file path (e.g. /path/to/design.odb)'; + dialog.appendChild(input); + + const errorBlock = document.createElement('div'); + errorBlock.className = 'modal-error'; + errorBlock.style.display = 'none'; + dialog.appendChild(errorBlock); + + const buttonsDiv = document.createElement('div'); + buttonsDiv.className = 'modal-buttons'; + + const cancelButton = document.createElement('button'); + cancelButton.className = 'cancel'; + cancelButton.textContent = 'Cancel'; + buttonsDiv.appendChild(cancelButton); + + const okButton = document.createElement('button'); + okButton.className = 'primary ok'; + okButton.disabled = true; + okButton.textContent = isSave ? 'Save' : 'Open'; + buttonsDiv.appendChild(okButton); + + dialog.appendChild(buttonsDiv); + overlay.appendChild(dialog); document.body.appendChild(overlay); - const breadcrumb = overlay.querySelector('.fb-breadcrumb'); - const fileList = overlay.querySelector('.fb-file-list'); - const pathInput = overlay.querySelector('.fb-path-input'); - const errorDiv = overlay.querySelector('.modal-error'); - const okBtn = overlay.querySelector('.ok'); - const cancelBtn = overlay.querySelector('.cancel'); + const breadcrumb = breadcrumbDiv; + const fileList = fileListDiv; + const pathInput = input; + const errorDiv = errorBlock; + const okBtn = okButton; + const cancelBtn = cancelButton; let currentPath = ''; let selectedEntry = null; @@ -178,7 +208,7 @@ function showPathDialog(app, title, tclCmd) { } function renderBreadcrumb(dirPath) { - breadcrumb.innerHTML = ''; + breadcrumb.replaceChildren(); const parts = dirPath.split('/').filter(Boolean); // Root const rootSpan = document.createElement('span'); @@ -205,7 +235,7 @@ function showPathDialog(app, title, tclCmd) { } function renderEntries(entries) { - fileList.innerHTML = ''; + fileList.replaceChildren(); selectedEntry = null; if (!entries || entries.length === 0) { @@ -290,7 +320,7 @@ function showPathDialog(app, title, tclCmd) { } updateOkState(); } catch (err) { - fileList.innerHTML = ''; + fileList.replaceChildren(); errorDiv.textContent = err.message || String(err); errorDiv.style.display = ''; } diff --git a/src/web/src/request_handler.cpp b/src/web/src/request_handler.cpp index 3adb8f1be36..82987f9a0d7 100644 --- a/src/web/src/request_handler.cpp +++ b/src/web/src/request_handler.cpp @@ -5,10 +5,12 @@ #include #include +#include #include #include #include #include +#include #include #include #include @@ -24,11 +26,13 @@ #include "clock_tree_report.h" #include "color.h" +#include "drc_report.h" #include "gui/descriptor_registry.h" #include "gui/gui.h" #include "gui/heatMap.h" #include "hierarchy_report.h" #include "json_builder.h" +#include "odb/3dblox.h" #include "odb/db.h" #include "odb/dbTypes.h" #include "odb/geom.h" @@ -58,6 +62,10 @@ class ShapeCollector : public gui::Painter { polys.push_back(polygon); } + void drawPolygon(const std::vector& points) override + { + polys.emplace_back(points); + } void drawOctagon(const odb::Oct& oct) override { polys.emplace_back(oct); } // No-ops @@ -73,7 +81,6 @@ class ShapeCollector : public gui::Painter void drawLine(const odb::Point&, const odb::Point&) override {} void drawCircle(int, int, int) override {} void drawX(int, int, int) override {} - void drawPolygon(const std::vector&) override {} void drawString(int, int, Anchor, const std::string&, bool) override {} odb::Rect stringBoundaries(int, int, Anchor, const std::string&) override { @@ -184,11 +191,13 @@ int extract_int(const std::string& json, const std::string& key) while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) { pos++; } - try { - return std::stoi(json.substr(pos)); - } catch (...) { - return 0; + int result = 0; + auto [p, ec] + = std::from_chars(json.data() + pos, json.data() + json.size(), result); + if (ec == std::errc()) { + return result; } + return 0; } int extract_int_or(const std::string& json, @@ -219,11 +228,13 @@ float extract_float_or(const std::string& json, while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) { pos++; } - try { - return std::stof(json.substr(pos)); - } catch (...) { - return default_val; + float result = default_val; + auto [p, ec] + = std::from_chars(json.data() + pos, json.data() + json.size(), result); + if (ec == std::errc()) { + return result; } + return default_val; } // Store a Selected in the clickables vector and return its index. @@ -372,7 +383,26 @@ static void serializeTimingNode(JsonBuilder& builder, const TimingNode& n) static double extract_double_value(const std::string& json) { - return extract_float_or(json, "value", 0.0F); + const std::string needle = "\"value\""; + auto pos = json.find(needle); + if (pos == std::string::npos) { + return 0.0; + } + pos = json.find(':', pos + needle.size()); + if (pos == std::string::npos) { + return 0.0; + } + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) { + pos++; + } + double result = 0.0; + auto [p, ec] + = std::from_chars(json.data() + pos, json.data() + json.size(), result); + if (ec == std::errc()) { + return result; + } + return 0.0; } static bool extract_bool_value(const std::string& json) @@ -532,7 +562,10 @@ WebSocketResponse dispatch_request( switch (req.type) { case WebSocketRequest::BOUNDS: { resp.type = 0; - const odb::Rect bounds = gen.getBounds(); + odb::Rect bounds = gen.getBounds(); + if (!gen.getBlock() && bounds.dx() == 0 && bounds.dy() == 0) { + bounds.init(0, 0, 1000, 1000); + } JsonBuilder builder; builder.beginObject(); builder.beginArray("bounds"); @@ -553,12 +586,43 @@ WebSocketResponse dispatch_request( break; } case WebSocketRequest::TECH: { + bool has_block = gen.getBlock() != nullptr; + bool has_chip_insts + = gen.getChip() != nullptr && !gen.getChip()->getChipInsts().empty(); + if (!has_block && !has_chip_insts) { + resp.type = 2; + const std::string err = "No block or chip with instances is loaded."; + resp.payload.assign(err.begin(), err.end()); + break; + } resp.type = 0; JsonBuilder builder; builder.beginObject(); builder.beginArray("layers"); - for (const auto& name : gen.getLayers()) { - builder.value(name); + { + static const int palette[][3] = { + {70, 130, 210}, // moderate blue + {200, 50, 50}, // red + {50, 180, 80}, // green + {200, 160, 40}, // amber + {160, 60, 200}, // purple + {40, 190, 190}, // teal + {220, 120, 50}, // orange + {180, 70, 150}, // magenta + }; + static constexpr int palette_size = 8; + int idx = 0; + for (const auto& name : gen.getLayers()) { + builder.beginObject(); + builder.field("name", name); + builder.beginArray("color"); + builder.value(palette[idx % palette_size][0]); + builder.value(palette[idx % palette_size][1]); + builder.value(palette[idx % palette_size][2]); + builder.endArray(); + builder.endObject(); + ++idx; + } } builder.endArray(); builder.beginArray("sites"); @@ -567,9 +631,8 @@ WebSocketResponse dispatch_request( } builder.endArray(); builder.field("has_liberty", gen.hasSta()); - if (gen.getBlock()) { - builder.field("dbu_per_micron", gen.getBlock()->getDbUnitsPerMicron()); - } + builder.field("has_chip_insts", has_chip_insts); + builder.field("dbu_per_micron", gen.getDbuPerMicron()); builder.endObject(); const std::string& json = builder.str(); resp.payload.assign(json.begin(), json.end()); @@ -1203,6 +1266,100 @@ WebSocketResponse SelectHandler::handleSchematicCone( return resp; } +WebSocketResponse SelectHandler::handleGet3DData(const WebSocketRequest& req) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + + try { + odb::dbChip* chip = gen_->getChip(); + if (!chip) { + JsonBuilder builder; + builder.beginObject(); + builder.field("info", + "No 3D chip data available. Load a multi-die design to " + "use this view."); + builder.endObject(); + const std::string msg = builder.str(); + resp.payload.assign(msg.begin(), msg.end()); + return resp; + } + + JsonBuilder builder; + builder.beginObject(); + builder.beginArray("chiplets"); + + auto processInst = [&](auto& self, + odb::dbChipInst* inst, + int offset_x, + int offset_y, + int offset_z, + const std::string& parent_name) -> void { + odb::dbChip* master_chip = inst->getMasterChip(); + if (master_chip && !master_chip->getChipInsts().empty()) { + for (odb::dbChipInst* child : master_chip->getChipInsts()) { + odb::Point3D loc = inst->getLoc(); + self(self, + child, + offset_x + loc.x(), + offset_y + loc.y(), + offset_z + loc.z(), + parent_name + std::string(inst->getName()) + "/"); + } + } else { + builder.beginObject(); + builder.field("name", parent_name + std::string(inst->getName())); + + odb::Point3D loc = inst->getLoc(); + builder.field("x", offset_x + loc.x()); + builder.field("y", offset_y + loc.y()); + builder.field("z", offset_z + loc.z()); + + int w = 0; + int h = 0; + int thickness = 0; + if (master_chip) { + w = master_chip->getWidth(); + h = master_chip->getHeight(); + thickness = master_chip->getThickness(); + // Fallback: use block bbox if chip has no explicit dimensions + if ((w == 0 || h == 0) && master_chip->getBlock()) { + odb::Rect bbox = master_chip->getBlock()->getBBox()->getBox(); + if (w == 0) { + w = bbox.dx(); + } + if (h == 0) { + h = bbox.dy(); + } + } + } + builder.field("width", w > 0 ? w : 100000); + builder.field("height", h > 0 ? h : 100000); + builder.field("thickness", thickness > 0 ? thickness : 10000); + builder.endObject(); + } + }; + + for (odb::dbChipInst* inst : chip->getChipInsts()) { + processInst(processInst, inst, 0, 0, 0, ""); + } + builder.endArray(); + builder.endObject(); + + const std::string json = builder.str(); + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + JsonBuilder builder; + builder.beginObject(); + builder.field("error", e.what()); + builder.endObject(); + const std::string err = builder.str(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + WebSocketResponse SelectHandler::handleSchematicFull( const WebSocketRequest& req) { @@ -1383,7 +1540,11 @@ WebSocketResponse TclHandler::handleTclEval(const WebSocketRequest& req) resp.id = req.id; resp.type = 0; try { - auto result = tcl_eval_->eval(req.tcl_cmd); + std::string cmd = req.tcl_cmd; + if (cmd.find("read_3dbx") != std::string::npos) { + cmd += "\ncheck_3dbx"; + } + auto result = tcl_eval_->eval(cmd); JsonBuilder builder; builder.beginObject(); builder.field("output", result.output); @@ -1931,6 +2092,13 @@ WebSocketResponse TileHandler::handleTile(const WebSocketRequest& req, lines = state.timing_lines; } + // Merge DRC violation highlights + { + std::lock_guard lock(state.drc_mutex); + colored.insert( + colored.end(), state.drc_rects.begin(), state.drc_rects.end()); + } + // Snapshot module colors for _modules layer std::map mod_colors; { @@ -2034,8 +2202,8 @@ WebSocketResponse TileHandler::handleSetModuleColors( if (colon == std::string::npos) { break; } - const uint32_t mod_id - = static_cast(std::stoul(data.substr(pos, colon - pos))); + uint32_t mod_id = 0; + std::from_chars(data.data() + pos, data.data() + colon, mod_id); pos = colon + 1; auto next_num = [&]() -> int { @@ -2043,7 +2211,8 @@ WebSocketResponse TileHandler::handleSetModuleColors( if (end == std::string::npos) { end = data.size(); } - const int val = std::stoi(data.substr(pos, end - pos)); + int val = 0; + std::from_chars(data.data() + pos, data.data() + end, val); pos = end + 1; return val; }; @@ -2073,6 +2242,13 @@ WebSocketResponse TileHandler::handleHeatMaps(const WebSocketRequest& req, WebSocketResponse resp; resp.id = req.id; resp.type = 0; + + if (gen_ && !gen_->getBlock()) { + const std::string json = R"({"active":"","heatmaps":[]})"; + resp.payload.assign(json.begin(), json.end()); + return resp; + } + try { const std::string json = buildHeatMapsPayloadLocked(state); resp.payload.assign(json.begin(), json.end()); @@ -2194,6 +2370,310 @@ WebSocketResponse TileHandler::handleHeatMapTile(const WebSocketRequest& req, return resp; } +DRCHandler::DRCHandler(std::shared_ptr gen, + std::shared_ptr drc_report) + : gen_(std::move(gen)), drc_report_(std::move(drc_report)) +{ +} + +WebSocketResponse DRCHandler::handleDRCReport(const WebSocketRequest& req) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + try { + const DRCReportResult result = drc_report_->getReport(); + + JsonBuilder builder; + builder.beginObject(); + builder.field("total", result.total_count); + builder.beginArray("categories"); + for (const auto& cat : result.categories) { + builder.beginObject(); + builder.field("name", cat.name); + builder.field("count", cat.count); + builder.beginArray("violations"); + for (const auto& v : cat.violations) { + builder.beginObject(); + builder.field("index", v.index); + builder.field("rule", v.rule); + builder.field("layer", v.layer); + builder.field("comment", v.comment); + builder.field("is_visited", v.is_visited); + builder.beginArray("bbox"); + builder.value(v.bbox.xMin()); + builder.value(v.bbox.yMin()); + builder.value(v.bbox.xMax()); + builder.value(v.bbox.yMax()); + builder.endArray(); + builder.beginArray("rects"); + for (const auto& rect : v.rects) { + builder.beginArray(); + builder.value(rect.xMin()); + builder.value(rect.yMin()); + builder.value(rect.xMax()); + builder.value(rect.yMax()); + builder.endArray(); + } + for (const auto& poly : v.polys) { + const odb::Rect encl = poly.getEnclosingRect(); + builder.beginArray(); + builder.value(encl.xMin()); + builder.value(encl.yMin()); + builder.value(encl.xMax()); + builder.value(encl.yMax()); + builder.endArray(); + } + for (const auto& cuboid : v.cuboids) { + builder.beginArray(); + builder.value(cuboid.xMin()); + builder.value(cuboid.yMin()); + builder.value(cuboid.xMax()); + builder.value(cuboid.yMax()); + builder.value(cuboid.zMin()); + builder.value(cuboid.zMax()); + builder.endArray(); + } + builder.endArray(); + builder.endObject(); + } + builder.endArray(); + builder.endObject(); + } + builder.endArray(); + builder.endObject(); + + const std::string& json = builder.str(); + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + resp.type = 2; + const std::string err = std::string("server error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + +WebSocketResponse DRCHandler::handleDRCHighlight(const WebSocketRequest& req, + SessionState& state) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + + try { + std::lock_guard lock(state.drc_mutex); + state.drc_rects.clear(); + + if (req.drc_violation_index >= 0) { + const DRCReportResult result = drc_report_->getReport(); + // Find violation by flat index across all categories + const DRCViolation* found = nullptr; + for (const auto& cat : result.categories) { + for (const auto& v : cat.violations) { + if (v.index == req.drc_violation_index) { + found = &v; + break; + } + } + if (found) { + break; + } + } + if (found) { + const Color red{.r = 255, .g = 50, .b = 50, .a = 200}; + for (const auto& rect : found->rects) { + state.drc_rects.push_back({rect, red, found->layer}); + } + for (const auto& poly : found->polys) { + state.drc_rects.push_back( + {poly.getEnclosingRect(), red, found->layer}); + } + for (const auto& cuboid : found->cuboids) { + state.drc_rects.push_back( + {cuboid.getEnclosingRect(), red, found->layer}); + } + } + } + + const std::string json = R"({"ok": true})"; + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + resp.type = 2; + const std::string err = std::string("server error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + +WebSocketResponse DRCHandler::handleDRCSetVisible(const WebSocketRequest& req, + SessionState& state) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + + try { + std::lock_guard lock(state.drc_mutex); + state.drc_rects.clear(); + + if (!req.drc_visible_indexes.empty()) { + const DRCReportResult result = drc_report_->getReport(); + const Color red{.r = 255, .g = 50, .b = 50, .a = 200}; + + for (const auto& cat : result.categories) { + for (const auto& v : cat.violations) { + if (req.drc_visible_indexes.contains(v.index)) { + for (const auto& rect : v.rects) { + state.drc_rects.push_back( + {rect, + red, + v.layer, + true}); // true for is_drc if we need to differentiate later + } + for (const auto& poly : v.polys) { + state.drc_rects.push_back( + {poly.getEnclosingRect(), red, v.layer, true}); + } + } + } + } + } + + const std::string json = R"({"ok": true})"; + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + resp.type = 2; + const std::string err = std::string("server error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + +WebSocketResponse DRCHandler::handleDRCMarkVisited(const WebSocketRequest& req) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + + try { + if (req.drc_violation_index >= 0) { + odb::dbChip* chip = gen_->getChip(); + if (chip) { + int index = 0; + odb::dbMarker* found_marker = nullptr; + // Find marker by flat index (this matches drc_report.cpp logic) + std::function search + = [&](odb::dbMarkerCategory* cat) { + for (odb::dbMarker* marker : cat->getMarkers()) { + if (index == req.drc_violation_index) { + found_marker = marker; + return; + } + index++; + } + for (odb::dbMarkerCategory* sub : cat->getMarkerCategories()) { + if (found_marker) { + return; + } + search(sub); + } + }; + for (odb::dbMarkerCategory* cat : chip->getMarkerCategories()) { + if (found_marker) { + break; + } + search(cat); + } + + if (found_marker) { + found_marker->setVisited(true); + } + } + } + const std::string json = R"({"ok": true})"; + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + resp.type = 2; + const std::string err = std::string("server error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + +WebSocketResponse DRCHandler::handleDRCLoad(const WebSocketRequest& req) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + try { + odb::dbChip* chip = gen_->getChip(); + if (!chip) { + throw std::runtime_error("no design loaded"); + } + + const std::string& path = req.drc_file_path; + if (path.empty()) { + throw std::runtime_error("no file path specified"); + } + + odb::dbMarkerCategory* cat = nullptr; + + // Determine format by extension (.json → JSON, else TritonRoute report) + if (path.size() >= 5 && path.compare(path.size() - 5, 5, ".json") == 0) { + auto cats = odb::dbMarkerCategory::fromJSON(chip, path); + if (!cats.empty()) { + cat = *cats.begin(); + } + } else { + cat = odb::dbMarkerCategory::fromTR(chip, "DRC", path); + } + + if (!cat) { + throw std::runtime_error("failed to load DRC report: " + path); + } + + JsonBuilder builder; + builder.beginObject(); + builder.field("ok", true); + builder.field("category", cat->getName()); + builder.endObject(); + const std::string& json = builder.str(); + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + resp.type = 2; + const std::string err = std::string("drc_load error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + +WebSocketResponse DRCHandler::handleDRC3DBloxCheck(const WebSocketRequest& req) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = 0; + try { + odb::dbChip* chip = gen_->getChip(); + if (!chip) { + throw std::runtime_error("no design loaded"); + } + odb::ThreeDBlox checker(gen_->getLogger(), gen_->getDb()); + checker.check(); + + JsonBuilder builder; + builder.beginObject(); + builder.field("ok", true); + builder.endObject(); + const std::string& json = builder.str(); + resp.payload.assign(json.begin(), json.end()); + } catch (const std::exception& e) { + resp.type = 2; + const std::string err = std::string("check_3dblox error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + WebSocketResponse handleListDir(const WebSocketRequest& req) { namespace fs = std::filesystem; diff --git a/src/web/src/request_handler.h b/src/web/src/request_handler.h index 301ac90c21a..a317852be49 100644 --- a/src/web/src/request_handler.h +++ b/src/web/src/request_handler.h @@ -24,6 +24,7 @@ namespace web { class TimingReport; class ClockTreeReport; +class DRCReport; // Thread-safe Tcl command evaluation with output capture. struct TclEvaluator @@ -89,6 +90,13 @@ struct WebSocketRequest SCHEMATIC_CONE, SCHEMATIC_FULL, SCHEMATIC_INSPECT, + GET_3D_DATA, + DRC_REPORT, + DRC_HIGHLIGHT, + DRC_SET_VISIBLE, + DRC_MARK_VISITED, + DRC_LOAD, + DRC_3DBLOX_CHECK, UNKNOWN }; @@ -158,6 +166,11 @@ struct WebSocketRequest bool snap_horizontal = true; bool snap_vertical = true; + // DRC fields + int drc_violation_index = -1; // -1 = clear highlight + std::set drc_visible_indexes; + std::string drc_file_path; // for DRC_LOAD + // Heat map fields std::string heatmap_name; std::string heatmap_option; @@ -202,6 +215,9 @@ struct SessionState std::mutex route_guides_mutex; std::set route_guide_net_ids; // dbNet ODB IDs + std::mutex drc_mutex; + std::vector drc_rects; // highlighted DRC violation shapes + std::mutex heatmap_mutex; std::map> heatmaps; std::string active_heatmap; @@ -255,6 +271,7 @@ class SelectHandler WebSocketResponse handleSchematicFull(const WebSocketRequest& req); WebSocketResponse handleSchematicInspect(const WebSocketRequest& req, SessionState& state); + WebSocketResponse handleGet3DData(const WebSocketRequest& req); private: std::shared_ptr gen_; @@ -337,6 +354,27 @@ class TileHandler std::shared_ptr gen_; }; +// Handles DRC_REPORT and DRC_HIGHLIGHT requests. +class DRCHandler +{ + public: + DRCHandler(std::shared_ptr gen, + std::shared_ptr drc_report); + + WebSocketResponse handleDRCReport(const WebSocketRequest& req); + WebSocketResponse handleDRCHighlight(const WebSocketRequest& req, + SessionState& state); + WebSocketResponse handleDRCSetVisible(const WebSocketRequest& req, + SessionState& state); + WebSocketResponse handleDRCMarkVisited(const WebSocketRequest& req); + WebSocketResponse handleDRCLoad(const WebSocketRequest& req); + WebSocketResponse handleDRC3DBloxCheck(const WebSocketRequest& req); + + private: + std::shared_ptr gen_; + std::shared_ptr drc_report_; +}; + // Handles LIST_DIR requests (server-side file browsing). WebSocketResponse handleListDir(const WebSocketRequest& req); diff --git a/src/web/src/schematic-widget.js b/src/web/src/schematic-widget.js index 8d310bb091a..79298dd1724 100644 --- a/src/web/src/schematic-widget.js +++ b/src/web/src/schematic-widget.js @@ -71,6 +71,16 @@ export class SchematicWidget { this._setupPanZoom(); this._netlistsvgReady = false; this.initNetlistSVG(); + + if (container && container.on) { + container.on('destroy', () => this.destroy()); + } + } + + destroy() { + if (this._abortController) { + this._abortController.abort(); + } } // ── Pan / zoom ─────────────────────────────────────────────────────────── @@ -106,19 +116,27 @@ export class SchematicWidget { e.preventDefault(); }); + this._abortController = new AbortController(); + + let mouseMovePending = false; window.addEventListener('mousemove', (e) => { if (!dragging) return; - this._panX = startPanX + (e.clientX - startX); - this._panY = startPanY + (e.clientY - startY); - this._applyTransform(); - }); + if (mouseMovePending) return; + mouseMovePending = true; + requestAnimationFrame(() => { + this._panX = startPanX + (e.clientX - startX); + this._panY = startPanY + (e.clientY - startY); + this._applyTransform(); + mouseMovePending = false; + }); + }, { signal: this._abortController.signal }); window.addEventListener('mouseup', () => { if (dragging) { dragging = false; c.style.cursor = this._selectMode ? 'default' : 'grab'; } - }); + }, { signal: this._abortController.signal }); } _zoomStep(factor) { diff --git a/src/web/src/search.cpp b/src/web/src/search.cpp index e82ef1a82b8..a719a800b0c 100644 --- a/src/web/src/search.cpp +++ b/src/web/src/search.cpp @@ -167,21 +167,6 @@ void Search::inDbBlockSetDieArea(odb::dbBlock* block) setTopChip(block->getChip()); } -void Search::inDbBlockSetCoreArea(odb::dbBlock* block) -{ - // emit modified(); -} - -void Search::inDbRegionAddBox(odb::dbRegion*, odb::dbBox*) -{ - // emit modified(); -} - -void Search::inDbRegionDestroy(odb::dbRegion* region) -{ - // emit modified(); -} - void Search::inDbRowCreate(odb::dbRow* row) { clearRows(); diff --git a/src/web/src/search.h b/src/web/src/search.h index 72c05ef6bb7..0a351b07bf6 100644 --- a/src/web/src/search.h +++ b/src/web/src/search.h @@ -150,6 +150,16 @@ class Search : public odb::dbBlockCallBackObj // Returns true once shape R-trees are built. bool shapesReady() const { return top_block_data_.shapes_init.load(); } + void setTopBlockReady() + { + top_block_data_.shapes_init = true; + top_block_data_.fills_init = true; + top_block_data_.insts_init = true; + top_block_data_.blockages_init = true; + top_block_data_.obstructions_init = true; + top_block_data_.rows_init = true; + } + // Build the structure for the given chip. void setTopChip(odb::dbChip* chip); @@ -274,21 +284,14 @@ class Search : public odb::dbBlockCallBackObj void inDbSWireAddSBox(odb::dbSBox* box) override; void inDbSWireRemoveSBox(odb::dbSBox* box) override; void inDbBlockSetDieArea(odb::dbBlock* block) override; - void inDbBlockSetCoreArea(odb::dbBlock* block) override; void inDbBlockageCreate(odb::dbBlockage* blockage) override; void inDbBlockageDestroy(odb::dbBlockage* blockage) override; void inDbObstructionCreate(odb::dbObstruction* obs) override; void inDbObstructionDestroy(odb::dbObstruction* obs) override; - void inDbRegionAddBox(odb::dbRegion*, odb::dbBox*) override; - void inDbRegionDestroy(odb::dbRegion* region) override; void inDbRowCreate(odb::dbRow* row) override; void inDbRowDestroy(odb::dbRow* row) override; void inDbWirePostModify(odb::dbWire* wire) override; - // signals: - // void modified(); - // void newChip(odb::dbChip* chip); - private: struct BlockData; diff --git a/src/web/src/style.css b/src/web/src/style.css index 68f71b387af..3aaaf42deb2 100644 --- a/src/web/src/style.css +++ b/src/web/src/style.css @@ -966,6 +966,126 @@ html, body { overflow-wrap: break-word; } +/* ─── DRC Viewer ─────────────────────────────────────────────────────── */ + +.drc-widget { + display: flex; + flex-direction: column; + height: 100%; + font-size: 13px; + font-family: monospace; + overflow: hidden; +} + +/* Top bar: [category dropdown] [Load...] */ +.drc-topbar { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.drc-category-select { + flex: 1; + min-width: 0; + background: var(--bg-input, var(--bg-header)); + border: 1px solid var(--border-strong); + border-radius: 3px; + color: var(--fg-primary); + padding: 3px 6px; + font-size: 12px; + font-family: monospace; +} + +.drc-btn { + background: var(--border-subtle); + border: 1px solid var(--border-strong); + border-radius: 3px; + color: var(--fg-primary); + padding: 4px 12px; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; +} +.drc-btn:hover { background: var(--border); color: var(--fg-bright); } +.drc-btn:disabled { opacity: 0.5; cursor: default; } + +.drc-table-container { + flex: 1; + overflow-y: auto; + min-height: 80px; +} + +.drc-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + table-layout: fixed; +} + +/* "Type" column width */ +.drc-table col:first-child { width: 52px; } + +.drc-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-context); + color: var(--fg-secondary); + padding: 4px 8px; + text-align: left; + border-bottom: 1px solid var(--border); + font-weight: 600; + white-space: nowrap; +} + +/* Category header row */ +.drc-table tbody tr.drc-category-row { + background: var(--bg-context); + cursor: pointer; + user-select: none; +} +.drc-table tbody tr.drc-category-row td { + padding: 4px 8px; + color: var(--fg-secondary); + font-weight: 600; + border-bottom: 1px solid var(--border-subtle); +} +.drc-table tbody tr.drc-category-row:hover { + background: var(--bg-header); +} +.drc-table tbody tr.drc-category-row .drc-category-arrow { + color: var(--fg-muted); + width: 20px; + text-align: center; + padding: 4px 2px; +} + +/* Violation rows */ +.drc-table tbody tr.drc-violation-row td { + padding: 3px 8px; + border-bottom: 1px solid var(--bg-context); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-primary); +} +.drc-table tbody tr.drc-violation-row .drc-index-cell { + color: var(--fg-muted); + text-align: right; + padding-right: 12px; + font-size: 11px; +} +.drc-table tbody tr.drc-violation-row:hover { + background: var(--bg-header); +} +.drc-table tbody tr.drc-violation-row.selected { + background: var(--bg-selected-row); +} + /* ─── Hierarchy Browser ──────────────────────────────────────────────── */ .hierarchy-widget { diff --git a/src/web/src/tcl-completer.js b/src/web/src/tcl-completer.js index 9d18d3d234b..0efe4bee84a 100644 --- a/src/web/src/tcl-completer.js +++ b/src/web/src/tcl-completer.js @@ -31,6 +31,15 @@ export class TclCompleter { this._input.addEventListener('blur', this._onBlurBound); } + destroy() { + this._input.removeEventListener('input', this._onInputBound); + this._input.removeEventListener('blur', this._onBlurBound); + clearTimeout(this._debounceTimer); + if (this._popup && this._popup.parentElement) { + this._popup.remove(); + } + } + _createPopup() { this._popup = document.createElement('div'); this._popup.className = 'tcl-complete-popup'; @@ -192,7 +201,7 @@ export class TclCompleter { if (fullData.mode === 'commands') { this._commandCache = fullData.completions; } - }).catch(() => {}); + }).catch(err => console.warn('Command cache prefetch failed:', err)); } this._showPopup( data.completions, @@ -221,7 +230,7 @@ export class TclCompleter { this._replaceStart = replaceStart; this._replaceEnd = replaceEnd; - this._popup.innerHTML = ''; + this._popup.replaceChildren(); const shown = completions.slice(0, kMaxVisible); shown.forEach((text, i) => { const item = document.createElement('div'); diff --git a/src/web/src/tile_generator.cpp b/src/web/src/tile_generator.cpp index 5eb06556b19..0522636788f 100644 --- a/src/web/src/tile_generator.cpp +++ b/src/web/src/tile_generator.cpp @@ -408,6 +408,8 @@ void TileGenerator::eagerInit() odb::dbBlock* block = getBlock(); if (block) { search_->eagerInit(block); + } else if (chip && !chip->getChipInsts().empty()) { + search_->setTopBlockReady(); } } @@ -416,6 +418,11 @@ bool TileGenerator::shapesReady() const return search_->shapesReady(); } +int TileGenerator::getDbuPerMicron() const +{ + return db_ ? db_->getDbuPerMicron() : 1000; +} + /* static */ odb::Rect TileGenerator::toPixels(const double scale, const odb::Rect& rect, @@ -518,6 +525,35 @@ odb::Rect TileGenerator::getBounds() const bounds.set_xhi(bounds.xMax() + margin); bounds.set_yhi(bounds.yMax() + margin); } + } else if (odb::dbChip* chip = getChip()) { + int w = chip->getWidth(); + int h = chip->getHeight(); + if (w > 0 && h > 0) { + int x0 = chip->getOffset().x(); + int y0 = chip->getOffset().y(); + bounds.init(x0, y0, x0 + w, y0 + h); + } else { + // Fallback: compute bounds from chipInst bboxes when chip has no + // explicit dimensions (e.g. leaf chiplets with only a DEF block). + odb::Rect union_bbox; + union_bbox.mergeInit(); + for (odb::dbChipInst* inst : chip->getChipInsts()) { + odb::Rect ib = inst->getBBox(); + if (ib.dx() == 0 || ib.dy() == 0) { + odb::dbChip* master = inst->getMasterChip(); + if (master && master->getBlock()) { + ib = master->getBlock()->getBBox()->getBox(); + inst->getTransform().apply(ib); + } + } + if (ib.dx() > 0 && ib.dy() > 0) { + union_bbox.merge(ib); + } + } + if (!union_bbox.isInverted()) { + bounds = union_bbox; + } + } } return bounds; } @@ -539,14 +575,22 @@ std::vector TileGenerator::getLayers() const std::vector layers; odb::dbTech* tech = db_->getTech(); if (!tech) { + if (getChip() && !getChip()->getChipInsts().empty()) { + layers.emplace_back("Chiplets"); + } return layers; } for (odb::dbTechLayer* layer : tech->getLayers()) { if (layer->getRoutingLevel() > 0 - || layer->getType() == odb::dbTechLayerType::CUT) { + || layer->getType() == odb::dbTechLayerType::CUT + || layer->getType() == odb::dbTechLayerType::MASTERSLICE + || layer->getType() == odb::dbTechLayerType::OVERLAP) { layers.push_back(layer->getName()); } } + if (getChip() && !getChip()->getChipInsts().empty()) { + layers.emplace_back("Chiplets"); + } return layers; } @@ -648,7 +692,9 @@ std::vector TileGenerator::selectAt( odb::dbTech* tech = db_->getTech(); for (odb::dbTechLayer* layer : tech->getLayers()) { if (layer->getRoutingLevel() <= 0 - && layer->getType() != odb::dbTechLayerType::CUT) { + && layer->getType() != odb::dbTechLayerType::CUT + && layer->getType() != odb::dbTechLayerType::MASTERSLICE + && layer->getType() != odb::dbTechLayerType::OVERLAP) { continue; } if (!visible_layers.empty() && !visible_layers.contains(layer->getName())) { @@ -785,11 +831,77 @@ std::vector TileGenerator::renderTileBuffer( constexpr int buffer_size = kTileSizeInPixel * kTileSizeInPixel * 4; std::vector image_buffer(buffer_size, 0); - // No design loaded — return blank tile. + // Draw chiplets if requested + if (layer == "Chiplets" && getChip()) { + const double num_tiles_at_zoom = static_cast(1LL << z); + if (x >= 0 && y >= 0 && x < num_tiles_at_zoom && y < num_tiles_at_zoom) { + int flipped_y = num_tiles_at_zoom - 1 - y; + const odb::Rect full_bounds = getBounds(); + const double tile_dbu_size = full_bounds.maxDXDY() / num_tiles_at_zoom; + const int dbu_x_min = full_bounds.xMin() + x * tile_dbu_size; + const int dbu_y_min = full_bounds.yMin() + flipped_y * tile_dbu_size; + const int dbu_x_max + = full_bounds.xMin() + std::ceil((x + 1) * tile_dbu_size); + const int dbu_y_max + = full_bounds.yMin() + std::ceil((flipped_y + 1) * tile_dbu_size); + const odb::Rect dbu_tile(dbu_x_min, dbu_y_min, dbu_x_max, dbu_y_max); + const double scale = kTileSizeInPixel / tile_dbu_size; + + for (odb::dbChipInst* inst : getChip()->getChipInsts()) { + odb::Rect inst_bbox = inst->getBBox(); + // Fallback: if master chip has no explicit dimensions but has a block + if (inst_bbox.dx() == 0 || inst_bbox.dy() == 0) { + odb::dbChip* master = inst->getMasterChip(); + if (master && master->getBlock()) { + inst_bbox = master->getBlock()->getBBox()->getBox(); + inst->getTransform().apply(inst_bbox); + } + } + if (inst_bbox.dx() == 0 || inst_bbox.dy() == 0) { + continue; + } + if (inst_bbox.overlaps(dbu_tile)) { + const odb::Rect overlap = inst_bbox.intersect(dbu_tile); + const odb::Rect draw = toPixels(scale, overlap, dbu_tile); + Color chiplet_color{.r = 100, .g = 150, .b = 200, .a = 150}; + drawFilledRect(image_buffer, draw, chiplet_color); + if (draw.xMin() >= 0 && draw.xMin() < kTileSizeInPixel) { + drawFilledRect( + image_buffer, + odb::Rect( + draw.xMin(), draw.yMin(), draw.xMin() + 1, draw.yMax()), + Color{.r = 50, .g = 100, .b = 150, .a = 255}); + } + if (draw.xMax() >= 0 && draw.xMax() < kTileSizeInPixel) { + drawFilledRect( + image_buffer, + odb::Rect( + draw.xMax() - 1, draw.yMin(), draw.xMax(), draw.yMax()), + Color{.r = 50, .g = 100, .b = 150, .a = 255}); + } + if (draw.yMin() >= 0 && draw.yMin() < kTileSizeInPixel) { + drawFilledRect( + image_buffer, + odb::Rect( + draw.xMin(), draw.yMin(), draw.xMax(), draw.yMin() + 1), + Color{.r = 50, .g = 100, .b = 150, .a = 255}); + } + if (draw.yMax() >= 0 && draw.yMax() < kTileSizeInPixel) { + drawFilledRect( + image_buffer, + odb::Rect( + draw.xMin(), draw.yMax() - 1, draw.xMax(), draw.yMax()), + Color{.r = 50, .g = 100, .b = 150, .a = 255}); + } + } + } + } + return image_buffer; + } + + // No block loaded — return blank tile if (!getBlock()) { - std::vector png_data; - lodepng::encode(png_data, image_buffer, kTileSizeInPixel, kTileSizeInPixel); - return png_data; + return image_buffer; } // Per-layer colors: routing level 1=blue, 2=red, then distinct hues @@ -824,7 +936,7 @@ std::vector TileGenerator::renderTileBuffer( const Color obs_color = color.lighter(); // Determine our tile's bounding box in dbu coordinates. - const double num_tiles_at_zoom = pow(2, z); + const double num_tiles_at_zoom = static_cast(1LL << z); if (x >= 0 && y >= 0 && x < num_tiles_at_zoom && y < num_tiles_at_zoom) { y = num_tiles_at_zoom - 1 - y; // flip const odb::Rect full_bounds = getBounds(); @@ -1581,7 +1693,7 @@ std::vector TileGenerator::generateHeatMapTile( constexpr int buffer_size = kTileSizeInPixel * kTileSizeInPixel * 4; std::vector image_buffer(buffer_size, 0); - const double num_tiles_at_zoom = pow(2, z); + const double num_tiles_at_zoom = static_cast(1LL << z); if (x < 0 || y < 0 || x >= num_tiles_at_zoom || y >= num_tiles_at_zoom) { return {}; } @@ -1748,7 +1860,7 @@ void TileGenerator::saveImage(const std::string& filename, const int z = std::max(0, static_cast(std::ceil( std::log2(scale * max_dxdy / kTileSizeInPixel)))); - const int num_tiles = static_cast(std::pow(2, z)); + const int num_tiles = static_cast(1LL << z); const double tile_dbu_size = max_dxdy / num_tiles; const double tile_scale = kTileSizeInPixel / tile_dbu_size; @@ -2125,21 +2237,33 @@ void TileGenerator::drawColoredHighlight(std::vector& image, const odb::Rect overlap = cr.rect.intersect(dbu_tile); const odb::Rect draw = toPixels(scale, overlap, dbu_tile); - // Draw a fixed-width centerline through the shape (cosmetic pen style, - // matching the GUI's 2px cosmetic pen approach from dbDescriptors.cpp). - // This ensures consistent visibility regardless of zoom level. - const int cx = (draw.xMin() + draw.xMax()) / 2; - const int cy = (draw.yMin() + draw.yMax()) / 2; + if (cr.is_drc) { + // Draw a hatch pattern (diagonal) for DRC + for (int x = draw.xMin(); x < draw.xMax(); x++) { + for (int y = draw.yMin(); y < draw.yMax(); y++) { + // Hatch condition: x + y is a multiple of some spacing + if ((x + y) % 6 < 2) { + blendPixel(image, x, 255 - y, cr.color); + } + } + } + } else { + // Draw a fixed-width centerline through the shape + const int cx = (draw.xMin() + draw.xMax()) / 2; + const int cy = (draw.yMin() + draw.yMax()) / 2; - Color line_color = cr.color; - line_color.a = 255; + Color line_color = cr.color; + line_color.a = 255; - if (draw.dx() >= draw.dy()) { - // Horizontal shape: draw horizontal centerline - drawLine(image, draw.xMin(), 255 - cy, draw.xMax(), 255 - cy, line_color); - } else { - // Vertical shape: draw vertical centerline - drawLine(image, cx, 255 - draw.yMin(), cx, 255 - draw.yMax(), line_color); + if (draw.dx() >= draw.dy()) { + // Horizontal shape: draw horizontal centerline + drawLine( + image, draw.xMin(), 255 - cy, draw.xMax(), 255 - cy, line_color); + } else { + // Vertical shape: draw vertical centerline + drawLine( + image, cx, 255 - draw.yMin(), cx, 255 - draw.yMax(), line_color); + } } } } diff --git a/src/web/src/tile_generator.h b/src/web/src/tile_generator.h index 8e8e1003475..10a6df3dc1e 100644 --- a/src/web/src/tile_generator.h +++ b/src/web/src/tile_generator.h @@ -38,6 +38,7 @@ struct ColoredRect odb::Rect rect; Color color; std::string layer; // empty = draw on all layers + bool is_drc = false; }; struct FlightLine @@ -138,6 +139,9 @@ class TileGenerator bool hasSta() const { return sta_ != nullptr; } sta::dbSta* getSta() const { return sta_; } + odb::dbDatabase* getDb() const { return db_; } + utl::Logger* getLogger() const { return logger_; } + int getDbuPerMicron() const; odb::Rect getBounds() const; int getPinMaxSize() const; diff --git a/src/web/src/timing-widget.js b/src/web/src/timing-widget.js index 30205c44eba..ddffe568d0b 100644 --- a/src/web/src/timing-widget.js +++ b/src/web/src/timing-widget.js @@ -86,38 +86,29 @@ export class TimingWidget { return btn; } + _switchTab(tabName) { + this._currentTab = tabName; + this._setupTab.classList.toggle('active', tabName === 'setup'); + this._holdTab.classList.toggle('active', tabName === 'hold'); + this._selectedPathIndex = -1; + this._renderPathTable(); + this._renderDetailTable(); + this._clearTimingHighlight(); + } + + _switchDetailTab(tabName) { + this._detailTab = tabName; + this._dataTab.classList.toggle('active', tabName === 'data'); + this._captureTab.classList.toggle('active', tabName === 'capture'); + this._renderDetailTable(); + } + _bindEvents() { // Tab switching - this._setupTab.addEventListener('click', () => { - this._currentTab = 'setup'; - this._setupTab.classList.add('active'); - this._holdTab.classList.remove('active'); - this._selectedPathIndex = -1; - this._renderPathTable(); - this._renderDetailTable(); - this._clearTimingHighlight(); - }); - this._holdTab.addEventListener('click', () => { - this._currentTab = 'hold'; - this._holdTab.classList.add('active'); - this._setupTab.classList.remove('active'); - this._selectedPathIndex = -1; - this._renderPathTable(); - this._renderDetailTable(); - this._clearTimingHighlight(); - }); - this._dataTab.addEventListener('click', () => { - this._detailTab = 'data'; - this._dataTab.classList.add('active'); - this._captureTab.classList.remove('active'); - this._renderDetailTable(); - }); - this._captureTab.addEventListener('click', () => { - this._detailTab = 'capture'; - this._captureTab.classList.add('active'); - this._dataTab.classList.remove('active'); - this._renderDetailTable(); - }); + this._setupTab.addEventListener('click', () => this._switchTab('setup')); + this._holdTab.addEventListener('click', () => this._switchTab('hold')); + this._dataTab.addEventListener('click', () => this._switchDetailTab('data')); + this._captureTab.addEventListener('click', () => this._switchDetailTab('capture')); // Fetch paths this._updateBtn.addEventListener('click', () => this.update()); @@ -223,7 +214,7 @@ export class TimingWidget { _renderPathTable() { const paths = this._currentTab === 'setup' ? this._setupPaths : this._holdPaths; this._pathCountLabel.textContent = paths.length + ' paths'; - this._pathTable.innerHTML = ''; + this._pathTable.replaceChildren(); const thead = document.createElement('thead'); const hr = document.createElement('tr'); @@ -288,7 +279,7 @@ export class TimingWidget { } _renderDetailTable() { - this._detailTable.innerHTML = ''; + this._detailTable.replaceChildren(); this._selectedDetailIndex = -1; const paths = this._currentTab === 'setup' ? this._setupPaths : this._holdPaths; if (this._selectedPathIndex < 0 || this._selectedPathIndex >= paths.length) return; diff --git a/src/web/src/ui-utils.js b/src/web/src/ui-utils.js index 99f9cffb5fe..d7b1dbdec69 100644 --- a/src/web/src/ui-utils.js +++ b/src/web/src/ui-utils.js @@ -23,19 +23,26 @@ export function makeResizableHeaders(table) { th.appendChild(grip); let startX, startW; + let abortController; const onMouseMove = (e) => { th.style.width = Math.max(30, startW + e.clientX - startX) + 'px'; }; const onMouseUp = () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); + if (abortController) { + abortController.abort(); + abortController = null; + } }; grip.addEventListener('mousedown', (e) => { e.preventDefault(); + if (abortController) { + abortController.abort(); + } + abortController = new AbortController(); startX = e.clientX; startW = th.offsetWidth; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); + document.addEventListener('mousemove', onMouseMove, { signal: abortController.signal }); + document.addEventListener('mouseup', onMouseUp, { signal: abortController.signal }); }); }); } diff --git a/src/web/src/vis-tree.js b/src/web/src/vis-tree.js index 924eb9fd13d..3a50ae92d16 100644 --- a/src/web/src/vis-tree.js +++ b/src/web/src/vis-tree.js @@ -8,7 +8,7 @@ import { CheckboxTreeModel } from './checkbox-tree-model.js'; export class VisTree { constructor(visibility, onChange) { - this.visibility = visibility; + this.visibility = visibility || {}; this.onChange = onChange; this.model = new CheckboxTreeModel(() => { this._syncAll(); @@ -26,6 +26,8 @@ export class VisTree { } render(container) { + if (!container) return; + container.replaceChildren(); for (const r of this.model.roots) container.appendChild(this._dom(r)); this._syncAll(); } @@ -77,7 +79,7 @@ export class VisTree { spacer.textContent = '\u25B6'; label.appendChild(spacer); label.appendChild(cb); - label.appendChild(document.createTextNode(spec.label)); + label.appendChild(document.createTextNode(spec.label || 'Unnamed')); return label; } @@ -91,7 +93,7 @@ export class VisTree { arrow.textContent = '▶'; header.appendChild(arrow); header.appendChild(cb); - header.appendChild(document.createTextNode(spec.label)); + header.appendChild(document.createTextNode(spec.label || 'Unnamed')); div.appendChild(header); const kids = document.createElement('div'); diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp index fa8d423d44e..095e5ea71d9 100644 --- a/src/web/src/web.cpp +++ b/src/web/src/web.cpp @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -32,8 +34,10 @@ #include "boost/beast/http.hpp" #include "boost/beast/websocket.hpp" #include "clock_tree_report.h" +#include "drc_report.h" #include "gui/heatMap.h" #include "odb/db.h" +#include "odb/dbDatabaseObserver.h" #include "request_handler.h" #include "tcl.h" #include "timing_report.h" @@ -131,6 +135,8 @@ static WebSocketRequest parse_web_socket_request(const std::string& msg) } else if (type_str == "schematic_inspect") { req.type = WebSocketRequest::SCHEMATIC_INSPECT; req.schematic_inst_name = extract_string(msg, "inst_name"); + } else if (type_str == "get_3d_data") { + req.type = WebSocketRequest::GET_3D_DATA; } else if (type_str == "select") { req.type = WebSocketRequest::SELECT; req.select_x = extract_int(msg, "dbu_x"); @@ -167,6 +173,29 @@ static WebSocketRequest parse_web_socket_request(const std::string& msg) } else if (type_str == "list_dir") { req.type = WebSocketRequest::LIST_DIR; req.dir_path = extract_string(msg, "path"); + } else if (type_str == "drc_report") { + req.type = WebSocketRequest::DRC_REPORT; + } else if (type_str == "drc_highlight") { + req.type = WebSocketRequest::DRC_HIGHLIGHT; + req.drc_violation_index = extract_int_or(msg, "index", -1); + } else if (type_str == "drc_set_visible") { + req.type = WebSocketRequest::DRC_SET_VISIBLE; + for (const std::string& str_index : extract_string_array(msg, "indexes")) { + try { + req.drc_visible_indexes.insert(std::stoi(str_index)); + } catch (const std::exception&) { + // Skip unparseable indexes; the client may send invalid data. + continue; + } + } + } else if (type_str == "drc_mark_visited") { + req.type = WebSocketRequest::DRC_MARK_VISITED; + req.drc_violation_index = extract_int_or(msg, "index", -1); + } else if (type_str == "drc_load") { + req.type = WebSocketRequest::DRC_LOAD; + req.drc_file_path = extract_string(msg, "path"); + } else if (type_str == "drc_3dblox_check") { + req.type = WebSocketRequest::DRC_3DBLOX_CHECK; } else { req.type = WebSocketRequest::UNKNOWN; } @@ -228,7 +257,8 @@ static http::response handle_request( res.keep_alive(req.keep_alive()); res.set(http::field::access_control_allow_origin, "*"); - std::regex tile_regex(R"(/tile/(\w+)/(\d+)/(-?\d+)/(-?\d+)\.png)"); + static const std::regex tile_regex( + R"(/tile/(\w+)/(\d+)/(-?\d+)/(-?\d+)\.png)"); std::smatch match_pieces; std::string target_path(req.target()); @@ -253,9 +283,14 @@ static http::response handle_request( WebSocketRequest websocket_req; websocket_req.type = WebSocketRequest::TILE; websocket_req.layer = match_pieces[1].str(); - websocket_req.z = std::stoi(match_pieces[2].str()); - websocket_req.x = std::stoi(match_pieces[3].str()); - websocket_req.y = std::stoi(match_pieces[4].str()); + auto parse_int = [](const std::string& s) { + int val = 0; + std::from_chars(s.data(), s.data() + s.size(), val); + return val; + }; + websocket_req.z = parse_int(match_pieces[2].str()); + websocket_req.x = parse_int(match_pieces[3].str()); + websocket_req.y = parse_int(match_pieces[4].str()); WebSocketResponse websocket_resp = dispatch_request(websocket_req, generator); @@ -269,7 +304,7 @@ static http::response handle_request( if (file_path == "/") { file_path = "/index.html"; } - // Reject paths with ".." to preventd irectory traversal + // Reject paths with ".." to prevent directory traversal if (file_path.find("..") == std::string::npos) { auto full_path = std::filesystem::path(doc_root) / file_path.substr(1); std::ifstream file(full_path, std::ios::binary); @@ -277,6 +312,7 @@ static http::response handle_request( std::string content((std::istreambuf_iterator(file)), std::istreambuf_iterator()); res.set(http::field::content_type, content_type_for(file_path)); + res.set(http::field::cache_control, "no-cache"); res.body() = std::move(content); } else { res.result(http::status::not_found); @@ -299,6 +335,10 @@ static http::response handle_request( // WebSocket session - multiplexes many requests over a single connection //------------------------------------------------------------------------------ +class WebSocketSession; +static std::mutex active_sessions_mutex; +static std::set active_sessions; + class WebSocketSession : public std::enable_shared_from_this { websocket::stream websocket_; @@ -312,6 +352,7 @@ class WebSocketSession : public std::enable_shared_from_this TimingHandler timing_handler_; ClockTreeHandler clock_tree_handler_; TileHandler tile_handler_; + DRCHandler drc_handler_; // Write serialization: strand + queue ensures one async_write at a time net::strand strand_; @@ -328,16 +369,18 @@ class WebSocketSession : public std::enable_shared_from_this std::shared_ptr tcl_eval, std::shared_ptr timing_report, std::shared_ptr clock_report, + std::shared_ptr drc_report, utl::Logger* logger); ~WebSocketSession(); void run(http::request&& req); + static void broadcast(const std::string& msg); + void queue_response(const WebSocketResponse& resp); private: void on_accept(beast::error_code ec); void do_read(); void on_read(beast::error_code ec); - void queue_response(const WebSocketResponse& resp); void do_write(); }; @@ -349,6 +392,7 @@ WebSocketSession::WebSocketSession( // NOLINTEND(performance-unnecessary-value-param) std::shared_ptr timing_report, std::shared_ptr clock_report, + std::shared_ptr drc_report, utl::Logger* logger) : websocket_(std::move(socket)), logger_(logger), @@ -357,9 +401,15 @@ WebSocketSession::WebSocketSession( timing_handler_(generator, std::move(timing_report), tcl_eval), clock_tree_handler_(generator, std::move(clock_report), tcl_eval), tile_handler_(generator), + drc_handler_(generator, std::move(drc_report)), strand_(net::make_strand(websocket_.get_executor())), generator_(std::move(generator)) { + { + std::lock_guard lock(active_sessions_mutex); + active_sessions.insert(this); + } + if (generator_->getBlock()) { tile_handler_.initializeHeatMaps(state_); } @@ -367,6 +417,11 @@ WebSocketSession::WebSocketSession( WebSocketSession::~WebSocketSession() { + { + std::lock_guard lock(active_sessions_mutex); + active_sessions.erase(this); + } + if (init_thread_.joinable()) { init_thread_.join(); } @@ -395,9 +450,25 @@ void WebSocketSession::run(http::request&& req) // Only send refresh if there's actually a design to render. // Without this guard, eagerInit returns instantly when no block is // loaded and the push races with async_accept (Beast soft_mutex crash). - if (!self->generator_->getBlock()) { + const bool has_block = self->generator_->getBlock() != nullptr; + const bool has_chip_insts + = self->generator_->getChip() != nullptr + && !self->generator_->getChip()->getChipInsts().empty(); + if (!has_block && !has_chip_insts) { return; } + // For 3D designs loaded before the web server started the observer was + // not yet registered, so chipLoaded was never broadcast. Send it now + // to this session so the frontend rebuilds the Chiplets layer and 3D + // viewer exactly as it would after a live read_3dbx call. + if (!has_block && has_chip_insts) { + WebSocketResponse chip_resp; + chip_resp.id = 0; + chip_resp.type = 0; + const std::string chip_json = R"({"type":"chipLoaded"})"; + chip_resp.payload.assign(chip_json.begin(), chip_json.end()); + self->queue_response(chip_resp); + } // Send server-push refresh notification (id=0) WebSocketResponse resp; resp.id = 0; @@ -507,6 +578,11 @@ void WebSocketSession::on_read(beast::error_code ec) self->select_handler_.handleSchematicInspect(req, self->state_)); }); break; + case WebSocketRequest::GET_3D_DATA: + net::post(websocket_.get_executor(), [self, req]() { + self->queue_response(self->select_handler_.handleGet3DData(req)); + }); + break; case WebSocketRequest::TCL_EVAL: net::post(websocket_.get_executor(), [self = std::move(self), req = std::move(req)]() { @@ -629,6 +705,46 @@ void WebSocketSession::on_read(beast::error_code ec) self->queue_response(handleListDir(req)); }); break; + case WebSocketRequest::DRC_REPORT: + net::post(websocket_.get_executor(), + [self = std::move(self), req = std::move(req)]() { + self->queue_response(self->drc_handler_.handleDRCReport(req)); + }); + break; + case WebSocketRequest::DRC_HIGHLIGHT: + net::post(websocket_.get_executor(), + [self = std::move(self), req = std::move(req)]() { + self->queue_response( + self->drc_handler_.handleDRCHighlight(req, self->state_)); + }); + break; + case WebSocketRequest::DRC_SET_VISIBLE: + net::post(websocket_.get_executor(), + [self = std::move(self), req = std::move(req)]() { + self->queue_response(self->drc_handler_.handleDRCSetVisible( + req, self->state_)); + }); + break; + case WebSocketRequest::DRC_MARK_VISITED: + net::post( + websocket_.get_executor(), + [self = std::move(self), req = std::move(req)]() { + self->queue_response(self->drc_handler_.handleDRCMarkVisited(req)); + }); + break; + case WebSocketRequest::DRC_LOAD: + net::post(websocket_.get_executor(), + [self = std::move(self), req = std::move(req)]() { + self->queue_response(self->drc_handler_.handleDRCLoad(req)); + }); + break; + case WebSocketRequest::DRC_3DBLOX_CHECK: + net::post( + websocket_.get_executor(), + [self = std::move(self), req = std::move(req)]() { + self->queue_response(self->drc_handler_.handleDRC3DBloxCheck(req)); + }); + break; default: net::post(websocket_.get_executor(), [self = std::move(self), req = std::move(req)]() { @@ -682,6 +798,47 @@ void WebSocketSession::do_write() }); } +void WebSocketSession::broadcast(const std::string& msg) +{ + std::lock_guard lock(active_sessions_mutex); + for (auto* session : active_sessions) { + WebSocketResponse resp; + resp.id = 0; + resp.type = 0; // JSON + resp.payload.assign(msg.begin(), msg.end()); + session->queue_response(resp); + } +} + +//------------------------------------------------------------------------------ +// Database Observer - notifies connected clients when the DB reloads +//------------------------------------------------------------------------------ + +class WebDatabaseObserver : public odb::dbDatabaseObserver +{ + WebServer* server_; + + public: + WebDatabaseObserver(WebServer* server) : server_(server) {} + + void postReadLef(odb::dbTech* tech, odb::dbLib* library) override {} + void postReadDef(odb::dbBlock* block) override {} + void postReadDb(odb::dbDatabase* db) override { notify(); } + void postRead3Dbx(odb::dbChip* chip) override { notify(); } + void postMarkersChanged() override + { + WebSocketSession::broadcast(R"({"type":"drcUpdated"})"); + } + + private: + void notify() + { + if (server_) { + server_->reloadDesign(); + } + } +}; + //------------------------------------------------------------------------------ // HTTP session - handles traditional HTTP connections //------------------------------------------------------------------------------ @@ -809,6 +966,7 @@ class DetectSession : public std::enable_shared_from_this std::shared_ptr tcl_eval_; std::shared_ptr timing_report_; std::shared_ptr clock_report_; + std::shared_ptr drc_report_; http::request req_; std::string doc_root_; utl::Logger* logger_; @@ -819,6 +977,7 @@ class DetectSession : public std::enable_shared_from_this std::shared_ptr tcl_eval, std::shared_ptr timing_report, std::shared_ptr clock_report, + std::shared_ptr drc_report, std::string doc_root, utl::Logger* logger); @@ -833,6 +992,7 @@ DetectSession::DetectSession(tcp::socket&& socket, std::shared_ptr tcl_eval, std::shared_ptr timing_report, std::shared_ptr clock_report, + std::shared_ptr drc_report, std::string doc_root, utl::Logger* logger) : stream_(std::move(socket)), @@ -840,6 +1000,7 @@ DetectSession::DetectSession(tcp::socket&& socket, tcl_eval_(std::move(tcl_eval)), timing_report_(std::move(timing_report)), clock_report_(std::move(clock_report)), + drc_report_(std::move(drc_report)), doc_root_(std::move(doc_root)), logger_(logger) { @@ -872,6 +1033,7 @@ void DetectSession::on_read(beast::error_code ec) tcl_eval_, timing_report_, clock_report_, + drc_report_, logger_); websocket_session->run(std::move(req_)); } else { @@ -894,6 +1056,7 @@ class Listener : public std::enable_shared_from_this std::shared_ptr tcl_eval_; std::shared_ptr timing_report_; std::shared_ptr clock_report_; + std::shared_ptr drc_report_; std::string doc_root_; utl::Logger* logger_; @@ -904,6 +1067,7 @@ class Listener : public std::enable_shared_from_this std::shared_ptr tcl_eval, std::shared_ptr timing_report, std::shared_ptr clock_report, + std::shared_ptr drc_report, std::string doc_root, utl::Logger* logger); @@ -920,6 +1084,7 @@ Listener::Listener(net::io_context& ioc, std::shared_ptr tcl_eval, std::shared_ptr timing_report, std::shared_ptr clock_report, + std::shared_ptr drc_report, std::string doc_root, utl::Logger* logger) : ioc_(ioc), @@ -928,6 +1093,7 @@ Listener::Listener(net::io_context& ioc, tcl_eval_(std::move(tcl_eval)), timing_report_(std::move(timing_report)), clock_report_(std::move(clock_report)), + drc_report_(std::move(drc_report)), doc_root_(std::move(doc_root)), logger_(logger) { @@ -979,6 +1145,7 @@ void Listener::on_accept(beast::error_code ec, tcp::socket socket) tcl_eval_, timing_report_, clock_report_, + drc_report_, doc_root_, logger_) ->run(); @@ -996,9 +1163,34 @@ WebServer::WebServer(odb::dbDatabase* db, Tcl_Interp* interp) : db_(db), sta_(sta), logger_(logger), interp_(interp) { + if (db_) { + observer_ = std::make_unique(this); + db_->addObserver(observer_.get()); + } } -WebServer::~WebServer() = default; +WebServer::~WebServer() +{ + if (db_ && observer_) { + db_->removeObserver(observer_.get()); + } +} + +void WebServer::reloadDesign() +{ + if (generator_) { + std::thread([generator = generator_]() { + generator->eagerInit(); + if (!generator->getBlock() + && (!generator->getChip() + || generator->getChip()->getChipInsts().empty())) { + return; + } + WebSocketSession::broadcast(R"({"type":"chipLoaded"})"); + WebSocketSession::broadcast(R"({"type":"refresh"})"); + }).detach(); + } +} void WebServer::saveImage(const std::string& filename, const int x0, @@ -1029,13 +1221,15 @@ void WebServer::serve(int port, const std::string& doc_root) generator_ = std::make_shared(db_, sta_, logger_); auto timing_report = std::make_shared(sta_); auto clock_report = std::make_shared(sta_); + auto drc_report = std::make_shared(generator_.get()); // Create Tcl evaluator with logger sink for output capture auto tcl_eval = std::make_shared(interp_, logger_); auto const address = net::ip::make_address("127.0.0.1"); uint16_t const u_port = port; - int const num_threads = 32; + unsigned int hardware_threads = std::thread::hardware_concurrency(); + int const num_threads = hardware_threads > 0 ? hardware_threads : 1; if (!doc_root.empty()) { logger_->info(utl::WEB, 4, "Serving static files from {}", doc_root); @@ -1066,6 +1260,7 @@ void WebServer::serve(int port, const std::string& doc_root) tcl_eval, timing_report, clock_report, + drc_report, doc_root, logger_) ->run(); diff --git a/src/web/src/websocket-manager.js b/src/web/src/websocket-manager.js index 64ab0f3e826..7706f8f4885 100644 --- a/src/web/src/websocket-manager.js +++ b/src/web/src/websocket-manager.js @@ -3,12 +3,14 @@ // WebSocket manager with request/response tracking and auto-reconnect. +const REQUEST_TIMEOUT_MS = 30000; + export class WebSocketManager { constructor(url, onStatusChange) { this.url = url; this.socket = null; this.nextId = 1; - this.pending = new Map(); // id -> {resolve, reject} + this.pending = new Map(); // id -> {resolve, reject, timer} this.reconnectDelay = 1000; this.readyPromise = null; this.readyResolve = null; @@ -37,7 +39,8 @@ export class WebSocketManager { this.socket.onclose = () => { console.log('WebSocket closed, reconnecting...'); - for (const [id, handler] of this.pending) { + for (const handler of this.pending.values()) { + clearTimeout(handler.timer); handler.reject(new Error('WebSocket closed')); } this.pending.clear(); @@ -70,6 +73,7 @@ export class WebSocketManager { if (!handler) { return; // stale response (e.g. tile scrolled away) } + clearTimeout(handler.timer); this.pending.delete(id); this.onStatusChange(); @@ -96,7 +100,12 @@ export class WebSocketManager { reject(new Error('WebSocket not connected')); return; } - this.pending.set(id, { resolve, reject }); + const timer = setTimeout(() => { + this.pending.delete(id); + this.onStatusChange(); + reject(new Error('Request timed out')); + }, REQUEST_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timer }); this.socket.send(JSON.stringify(msg)); this.onStatusChange(); }); @@ -105,6 +114,8 @@ export class WebSocketManager { } cancel(id) { + const handler = this.pending.get(id); + if (handler) clearTimeout(handler.timer); this.pending.delete(id); this.onStatusChange(); } diff --git a/src/web/src/websocket-tile-layer.js b/src/web/src/websocket-tile-layer.js index daee657a623..d2cdc7ca5f9 100644 --- a/src/web/src/websocket-tile-layer.js +++ b/src/web/src/websocket-tile-layer.js @@ -77,17 +77,17 @@ export function createWebSocketTileLayer(visibility) { this._websocketManager.cancel(tile._websocketRequestId); } - const requestId = this._websocketManager.nextId; - tile._websocketRequestId = requestId; - - this._websocketManager.request({ + const promise = this._websocketManager.request({ type: 'tile', layer: this._layerName, z: coords.z, x: coords.x, y: coords.y, ...vf, - }).then(blob => { + }); + tile._websocketRequestId = promise.requestId; + + promise.then(blob => { if (tile.src && tile.src.startsWith('blob:')) { URL.revokeObjectURL(tile.src); } diff --git a/src/web/test/cpp/CMakeLists.txt b/src/web/test/cpp/CMakeLists.txt index 87f5b0bee1e..cd32ff6b274 100644 --- a/src/web/test/cpp/CMakeLists.txt +++ b/src/web/test/cpp/CMakeLists.txt @@ -36,7 +36,25 @@ gtest_discover_tests(TestRequestHandler WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. ) +add_executable(TestClockTreeReport TestClockTreeReport.cpp) +target_link_libraries(TestClockTreeReport ${TEST_LIBS}) +target_include_directories(TestClockTreeReport PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../src) +gtest_discover_tests(TestClockTreeReport WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + +add_executable(TestSaveImage TestSaveImage.cpp) +target_link_libraries(TestSaveImage ${TEST_LIBS}) +target_include_directories(TestSaveImage PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../src) +gtest_discover_tests(TestSaveImage WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + +add_executable(TestSnap TestSnap.cpp) +target_link_libraries(TestSnap ${TEST_LIBS}) +target_include_directories(TestSnap PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../src) +gtest_discover_tests(TestSnap WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_dependencies(build_and_test TestTileGenerator TestRequestHandler + TestClockTreeReport + TestSaveImage + TestSnap )