gen_;
diff --git a/src/web/src/search-nav.js b/src/web/src/search-nav.js
new file mode 100644
index 00000000000..6e60614e8f3
--- /dev/null
+++ b/src/web/src/search-nav.js
@@ -0,0 +1,194 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// Copyright (c) 2026, The OpenROAD Authors
+
+// Search & navigation dialogs (issue #10619 table 2.8):
+// - Find: name/glob search by object type (mirrors Qt FindObjectDialog).
+// - Go to position: jump the view to x,y in microns (mirrors Qt GotoDialog).
+// Both reuse the .modal-overlay/.modal-dialog pattern from menu-bar.js.
+
+import { dbuToLatLng, dbuRectToBounds, latLngToDbu } from './coordinates.js';
+import { applySelectionFlags } from './ui-utils.js';
+
+// Build a modal shell and return handles. Closes on backdrop click / Escape.
+function buildModal(title, bodyHtml, okLabel) {
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+
+
+
${title}
+ ${bodyHtml}
+
+
+
+
+
+
`;
+ document.body.appendChild(overlay);
+
+ const close = () => overlay.remove();
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
+ overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
+ overlay.querySelector('.cancel').addEventListener('click', close);
+ overlay.querySelector('.modal-close').addEventListener('click', close);
+
+ const errorDiv = overlay.querySelector('.modal-error');
+ const showError = (msg, isInfo) => {
+ errorDiv.textContent = msg;
+ errorDiv.style.display = msg ? 'block' : 'none';
+ errorDiv.classList.toggle('info', !!isInfo);
+ };
+ return { overlay, close, okBtn: overlay.querySelector('.ok'), showError };
+}
+
+// ── Find ──────────────────────────────────────────────────────────────────
+
+const FIND_TYPES = [
+ { label: 'Instance', value: 'inst' },
+ { label: 'Net', value: 'net' },
+ { label: 'Port', value: 'port' },
+];
+
+export function showFindDialog(app) {
+ if (!app.designScale) {
+ return;
+ }
+ const typeOptions = FIND_TYPES
+ .map((t) => ``)
+ .join('');
+ const { overlay, close, okBtn, showError } = buildModal('Find Object', `
+
+
+
+
+
+
+
+
+ `, 'Find');
+
+ const patternInput = overlay.querySelector('.sn-pattern');
+ const typeSel = overlay.querySelector('.sn-type');
+ const caseChk = overlay.querySelector('.sn-case');
+ patternInput.focus();
+
+ function doFind() {
+ const pattern = patternInput.value.trim();
+ if (!pattern) {
+ showError('Enter a name or glob pattern.');
+ return;
+ }
+ if (!app.websocketManager) {
+ return;
+ }
+ showError('Searching…', true);
+ app.websocketManager
+ .request({
+ type: 'find',
+ obj_type: typeSel.value,
+ pattern,
+ match_case: caseChk.checked,
+ })
+ .then((resp) => {
+ applySelectionFlags(app, resp);
+ const count = resp && resp.count ? resp.count : 0;
+ if (!count) {
+ showError('No objects found.');
+ return;
+ }
+ // Auto-zoom to the matched objects — an improvement over the Qt
+ // Find dialog, which only selects without centering.
+ if (Array.isArray(resp.bbox)) {
+ app.lastSelectionBounds = dbuRectToBounds(
+ resp.bbox[0], resp.bbox[1], resp.bbox[2], resp.bbox[3],
+ app.designScale, app.designMaxDXDY,
+ app.designOriginX, app.designOriginY);
+ app.map.fitBounds(app.lastSelectionBounds);
+ }
+ if (typeof app.updateInspector === 'function') {
+ app.updateInspector(resp);
+ }
+ if (typeof app.redrawAllLayers === 'function') {
+ app.redrawAllLayers();
+ }
+ const shown = resp.selection_count || count;
+ showError(resp.truncated
+ ? `${count} found (showing first ${shown}).`
+ : `${count} found.`, true);
+ })
+ .catch((err) => showError('Find failed: ' + err));
+ }
+
+ okBtn.addEventListener('click', doFind);
+ patternInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); doFind(); }
+ });
+ return { close };
+}
+
+// ── Go to position ──────────────────────────────────────────────────────────
+
+export function showGotoDialog(app) {
+ if (!app.designScale || !app.map) {
+ return;
+ }
+ const dbuPerUm = app.getDbuPerMicron ? app.getDbuPerMicron() : 1000;
+
+ // Prefill X/Y with the current view center (in microns).
+ const c = app.map.getCenter();
+ const centerDbu = latLngToDbu(c.lat, c.lng, app.designScale,
+ app.designMaxDXDY, app.designOriginX, app.designOriginY);
+ const fmt = (dbu) => (dbu / dbuPerUm).toFixed(3);
+
+ const { overlay, close, okBtn, showError } = buildModal('Go to Position', `
+
+
+
+
+
+
`,
+ 'Go to');
+
+ const xIn = overlay.querySelector('.sn-x');
+ const yIn = overlay.querySelector('.sn-y');
+ const sizeIn = overlay.querySelector('.sn-size');
+ xIn.focus();
+ xIn.select();
+
+ function doGoto() {
+ const x = parseFloat(xIn.value);
+ const y = parseFloat(yIn.value);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
+ showError('X and Y must be numbers (microns).');
+ return;
+ }
+ const dbuX = Math.round(x * dbuPerUm);
+ const dbuY = Math.round(y * dbuPerUm);
+ const latlng = dbuToLatLng(dbuX, dbuY, app.designScale,
+ app.designMaxDXDY, app.designOriginX, app.designOriginY);
+
+ const sizeVal = sizeIn.value.trim();
+ const size = sizeVal ? parseFloat(sizeVal) : NaN;
+ if (Number.isFinite(size) && size > 0) {
+ const half = (size * dbuPerUm) / 2;
+ app.map.fitBounds(dbuRectToBounds(
+ dbuX - half, dbuY - half, dbuX + half, dbuY + half,
+ app.designScale, app.designMaxDXDY,
+ app.designOriginX, app.designOriginY));
+ } else {
+ app.map.setView(latlng, app.map.getZoom());
+ }
+ close();
+ }
+
+ okBtn.addEventListener('click', doGoto);
+ for (const el of [xIn, yIn, sizeIn]) {
+ el.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); doGoto(); }
+ });
+ }
+ return { close };
+}
diff --git a/src/web/src/style.css b/src/web/src/style.css
index a0a49f05d1e..a3079febe83 100644
--- a/src/web/src/style.css
+++ b/src/web/src/style.css
@@ -240,6 +240,7 @@ html, body {
justify-content: center;
}
.modal-dialog {
+ position: relative;
background: var(--bg-header);
border: 1px solid var(--border-strong);
border-radius: 6px;
@@ -248,6 +249,24 @@ html, body {
color: var(--fg-primary);
font: 13px monospace;
}
+.modal-dialog .modal-close {
+ position: absolute;
+ top: 8px;
+ right: 10px;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--fg-primary);
+ font-size: 18px;
+ line-height: 20px;
+ cursor: pointer;
+ opacity: 0.7;
+}
+.modal-dialog .modal-close:hover {
+ opacity: 1;
+}
.modal-dialog h3 {
margin: 0 0 12px 0;
font-size: 14px;
@@ -303,6 +322,29 @@ html, body {
cursor: default;
}
+/* Search & navigation dialogs (Find / Go to) */
+.search-nav-dialog .sn-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 6px 0;
+}
+.search-nav-dialog .sn-row label {
+ width: 64px;
+ flex-shrink: 0;
+}
+.search-nav-dialog .sn-row .fb-path-input,
+.search-nav-dialog .sn-type {
+ flex: 1;
+}
+.search-nav-dialog .sn-check {
+ display: block;
+ margin: 8px 0 2px;
+}
+.modal-dialog .modal-error.info {
+ color: var(--fg-primary);
+}
+
/* File browser dialog */
.file-browser-dialog {
min-width: 520px;
@@ -446,6 +488,13 @@ html, body {
.leaflet-container {
cursor: default !important;
}
+/* "Fit" control button — inherits .leaflet-bar sizing from Leaflet's CSS. */
+.leaflet-control-fit a {
+ font-size: 16px;
+ font-weight: bold;
+ line-height: 26px;
+ text-align: center;
+}
.leaflet-dragging .leaflet-container {
cursor: grabbing !important;
}
@@ -727,6 +776,80 @@ html, body {
background: var(--bg-hover);
color: var(--fg-bright);
}
+.cm-item {
+ position: relative;
+ padding: 5px 12px;
+ padding-right: 24px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.cm-item:hover {
+ background: var(--bg-hover);
+ color: var(--fg-bright);
+}
+.cm-item.disabled {
+ opacity: 0.4;
+ cursor: default;
+ pointer-events: none;
+}
+.cm-item.has-submenu::after {
+ content: '▶';
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 10px;
+ opacity: 0.7;
+}
+.cm-submenu {
+ position: absolute;
+ left: 100%;
+ top: -5px;
+ display: none;
+ background: var(--bg-context);
+ border: 1px solid var(--border-strong);
+ border-radius: 4px;
+ min-width: 160px;
+ padding: 4px 0;
+ box-shadow: 0 2px 8px var(--shadow);
+}
+.cm-item.has-submenu:hover > .cm-submenu {
+ display: block;
+}
+.cm-submenu.flip-left {
+ left: auto;
+ right: 100%;
+}
+.cm-swatch {
+ display: inline-block;
+ width: 11px;
+ height: 11px;
+ margin-right: 8px;
+ border: 1px solid var(--border-strong);
+ vertical-align: middle;
+ flex-shrink: 0;
+}
+.cm-toast {
+ position: absolute;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10001;
+ max-width: 80%;
+ padding: 6px 12px;
+ background: var(--bg-context);
+ border: 1px solid var(--border-strong);
+ border-radius: 4px;
+ box-shadow: 0 2px 8px var(--shadow);
+ color: var(--fg-bright);
+ font-size: 12px;
+ pointer-events: none;
+ opacity: 1;
+ transition: opacity 0.3s ease;
+}
+.cm-toast-hide {
+ opacity: 0;
+}
/* Tcl Console */
.tcl-console {
diff --git a/src/web/src/tile_generator.cpp b/src/web/src/tile_generator.cpp
index 93a5aed565f..389f1010759 100644
--- a/src/web/src/tile_generator.cpp
+++ b/src/web/src/tile_generator.cpp
@@ -1394,7 +1394,8 @@ std::vector TileGenerator::generateOverlayTile(
const std::vector& flight_lines,
const std::set* route_guide_net_ids,
const bool has_visible_layers,
- const std::set& visible_layers) const
+ const std::set& visible_layers,
+ const std::vector& colored_polys) const
{
constexpr int kBufferSize = kTileSizeInPixel * kTileSizeInPixel * 4;
std::vector image(kBufferSize, 0); // fully transparent
@@ -1408,7 +1409,7 @@ std::vector TileGenerator::generateOverlayTile(
// Short-circuit: if there's nothing to draw, return a blank tile.
if (highlight_rects.empty() && highlight_polys.empty()
- && colored_rects.empty() && flight_lines.empty()
+ && colored_rects.empty() && colored_polys.empty() && flight_lines.empty()
&& (!route_guide_net_ids || route_guide_net_ids->empty())) {
std::vector png;
lodepng::encode(png, image, kTileSizeInPixel, kTileSizeInPixel);
@@ -1441,6 +1442,9 @@ std::vector TileGenerator::generateOverlayTile(
// their per-layer tag (the overlay sits above all base layers).
drawColoredHighlight(image, colored_rects, "", dbu_tile, scale);
}
+ if (!colored_polys.empty()) {
+ drawColoredPolygons(image, colored_polys, dbu_tile, scale);
+ }
if (!flight_lines.empty()) {
drawFlightLines(image, flight_lines, dbu_tile, scale);
}
@@ -2823,16 +2827,17 @@ static void compositePixel(unsigned char* dst, const unsigned char* src)
dst[3] = out_a;
}
-void TileGenerator::saveImage(const std::string& filename,
- const odb::Rect& region,
- const int width_px,
- const double dbu_per_pixel,
- const TileVisibility& vis) const
+std::vector TileGenerator::renderImagePng(
+ const odb::Rect& region,
+ const int width_px,
+ const double dbu_per_pixel,
+ const TileVisibility& vis,
+ const Color& bg) const
{
odb::dbBlock* block = getBlock();
if (!block) {
logger_->error(utl::WEB, 20, "No design loaded.");
- return;
+ return {};
}
// Determine rendering region (DBU).
@@ -2864,7 +2869,7 @@ void TileGenerator::saveImage(const std::string& filename,
if (img_w <= 0 || img_h <= 0) {
logger_->error(utl::WEB, 21, "Invalid image dimensions.");
- return;
+ return {};
}
// Cap image size at 16k x 16k to prevent excessive memory usage.
@@ -2968,32 +2973,52 @@ void TileGenerator::saveImage(const std::string& filename,
= tile_span_h - crop_y_bottom - static_cast(area.dy() * tile_scale);
// Resample to exact requested dimensions (nearest-neighbor from tile_scale
- // to target scale).
- std::vector final_buf(4UL * final_w * final_h, 0);
+ // to target scale). Start from the requested background color and composite
+ // the (possibly semi-transparent) tiles on top, so the saved image matches
+ // the viewer's background instead of coming out transparent.
+ std::vector final_buf(4UL * final_w * final_h);
for (int fy = 0; fy < final_h; ++fy) {
for (int fx = 0; fx < final_w; ++fx) {
+ const int dst_idx = (fy * final_w + fx) * 4;
+ // Start each pixel at the background, then composite the tile on top.
+ final_buf[dst_idx + 0] = bg.r;
+ final_buf[dst_idx + 1] = bg.g;
+ final_buf[dst_idx + 2] = bg.b;
+ final_buf[dst_idx + 3] = bg.a;
// Map final pixel to tile-span pixel.
const int sx = crop_x + static_cast(fx * tile_scale / scale);
const int sy = crop_y + static_cast(fy * tile_scale / scale);
if (sx >= 0 && sx < tile_span_w && sy >= 0 && sy < tile_span_h) {
const int src_idx = (sy * tile_span_w + sx) * 4;
- const int dst_idx = (fy * final_w + fx) * 4;
- std::memcpy(&final_buf[dst_idx], &output[src_idx], 4);
+ compositePixel(&final_buf[dst_idx], &output[src_idx]);
}
}
}
- // Encode to PNG and save.
+ // Encode to PNG.
std::vector png_data;
const unsigned error = lodepng::encode(png_data, final_buf, final_w, final_h);
if (error) {
logger_->error(
utl::WEB, 23, "PNG encode error: {}", lodepng_error_text(error));
+ return {};
+ }
+ return png_data;
+}
+
+void TileGenerator::saveImage(const std::string& filename,
+ const odb::Rect& region,
+ const int width_px,
+ const double dbu_per_pixel,
+ const TileVisibility& vis) const
+{
+ const std::vector png_data
+ = renderImagePng(region, width_px, dbu_per_pixel, vis);
+ if (png_data.empty()) {
return;
}
lodepng::save_file(png_data, filename);
- logger_->info(
- utl::WEB, 24, "Saved {}x{} image to {}", final_w, final_h, filename);
+ logger_->info(utl::WEB, 24, "Saved image to {}", filename);
}
std::vector TileGenerator::renderOverlayPng(
@@ -3605,6 +3630,42 @@ void TileGenerator::drawHighlight(std::vector& image,
}
}
+void TileGenerator::drawColoredPolygons(
+ std::vector& image,
+ const std::vector& polys,
+ const odb::Rect& dbu_tile,
+ const double scale) const
+{
+ for (const ColoredPolygon& cp : polys) {
+ const odb::Polygon& poly = cp.poly;
+ if (!dbu_tile.overlaps(poly.getEnclosingRect())) {
+ continue;
+ }
+ Color border = cp.color;
+ border.a = 255;
+
+ // Semi-transparent fill in the group's color.
+ fillPolygon(image, poly, dbu_tile, scale, cp.color, /*blend=*/true);
+
+ // Solid outline in the group's color — draw each edge, wrapping the last
+ // point back to the first so the outline is closed even if the point list
+ // is open.
+ const auto& points = poly.getPoints();
+ const int n = static_cast(points.size());
+ for (int i = 0; i < n; ++i) {
+ const odb::Point& p0 = points[i];
+ const odb::Point& p1 = points[(i + 1) % n];
+ const int px0 = static_cast((p0.x() - dbu_tile.xMin()) * scale);
+ const int py0
+ = 255 - static_cast((p0.y() - dbu_tile.yMin()) * scale);
+ const int px1 = static_cast((p1.x() - dbu_tile.xMin()) * scale);
+ const int py1
+ = 255 - static_cast((p1.y() - dbu_tile.yMin()) * scale);
+ drawLine(image, px0, py0, px1, py1, border);
+ }
+ }
+}
+
void TileGenerator::drawColoredHighlight(std::vector& image,
const std::vector& rects,
const std::string& current_layer,
diff --git a/src/web/src/tile_generator.h b/src/web/src/tile_generator.h
index f82869e95b4..1d9da606980 100644
--- a/src/web/src/tile_generator.h
+++ b/src/web/src/tile_generator.h
@@ -58,6 +58,12 @@ struct FlightLine
Color color;
};
+struct ColoredPolygon
+{
+ odb::Polygon poly;
+ Color color;
+};
+
struct SelectionResult
{
std::any object; // dbInst*, dbNet*, etc.
@@ -388,12 +394,22 @@ class TileGenerator
const std::vector& flight_lines = {},
const std::set* route_guide_net_ids = nullptr,
bool has_visible_layers = false,
- const std::set& visible_layers = {}) const;
+ const std::set& visible_layers = {},
+ const std::vector& colored_polys = {}) const;
std::vector generateHeatMapTile(gui::HeatMapDataSource& source,
int z,
int x,
int y) const;
+ // Render full design (or region) to PNG bytes. Works without a running
+ // web server. region in DBU; if zero-area, defaults to die + 5% margin.
+ // Returns an empty vector on error (no design / invalid dimensions).
+ std::vector renderImagePng(const odb::Rect& region,
+ int width_px,
+ double dbu_per_pixel,
+ const TileVisibility& vis,
+ const Color& bg = {}) const;
+
// Render full design (or region) to a PNG file. Works without a running
// web server. region in DBU; if zero-area, defaults to die + 5% margin.
void saveImage(const std::string& filename,
@@ -499,6 +515,11 @@ class TileGenerator
const odb::Rect& dbu_tile,
double scale) const;
+ void drawColoredPolygons(std::vector& image,
+ const std::vector& polys,
+ const odb::Rect& dbu_tile,
+ double scale) const;
+
// Private counterpart of setDebugOverlayCallback: invokes the
// installed callback (if any) for this tile. See the public API
// above for rationale.
diff --git a/src/web/src/ui-utils.js b/src/web/src/ui-utils.js
index 970bdde81e6..7978878aabb 100644
--- a/src/web/src/ui-utils.js
+++ b/src/web/src/ui-utils.js
@@ -9,6 +9,31 @@ export function isStaticMode(app) {
return !!app?.websocketManager?.isStaticMode;
}
+// Serialize the layer/selectability visibility flags the way the server
+// parses them: each visibility key as a boolean, each selectability key with
+// an `s_` prefix. Callers add request-specific fields (visible_layers,
+// selectable_layers, visible_chiplets) on top. Shared by the tile requests,
+// click-select, and the Save export so the columns can't drift.
+export function buildVisibilityFlags(visibility, selectability) {
+ const vf = {};
+ for (const [k, v] of Object.entries(visibility || {})) {
+ vf[k] = !!v;
+ }
+ for (const [k, v] of Object.entries(selectability || {})) {
+ vf['s_' + k] = !!v;
+ }
+ return vf;
+}
+
+// Sync the client-side selection type flags from a server response so the
+// context menu can enable/disable items by selection type.
+export function applySelectionFlags(app, resp) {
+ if (resp && typeof resp.sel_has_inst === 'boolean') {
+ app.selHasInst = resp.sel_has_inst;
+ app.selHasNet = !!resp.sel_has_net;
+ }
+}
+
// Make table column headers resizable by dragging.
// widths is an optional array of CSS width strings (e.g. saved from a
// previous render); when given, it is applied directly instead of
diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp
index 9e64a7e2a4f..b0e1a49e7bf 100644
--- a/src/web/src/web.cpp
+++ b/src/web/src/web.cpp
@@ -5,6 +5,8 @@
#include
+#include
+#include
#include
#include
#include
@@ -19,6 +21,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -31,6 +34,7 @@
#include "boost/beast/websocket.hpp"
#include "boost/json/array.hpp"
#include "boost/json/object.hpp"
+#include "boost/json/parse.hpp"
#include "boost/json/serialize.hpp"
#include "boost/json/value.hpp"
#include "clock_tree_report.h"
@@ -80,11 +84,200 @@ static std::vector serialize_response(
}
//------------------------------------------------------------------------------
-// HTTP request handler (serves embedded static assets)
+// HTTP request handler (serves embedded static assets + image downloads)
//------------------------------------------------------------------------------
+namespace {
+
+// Sanitize a client-supplied download filename before it goes into the
+// Content-Disposition header: drop directory components, keep only
+// [A-Za-z0-9._-] (replacing the rest with '_'), cap the length, and fall back
+// to a default when empty. Prevents header injection via quotes/CRLF.
+std::string sanitizeFilename(std::string name)
+{
+ const auto slash = name.find_last_of("/\\");
+ if (slash != std::string::npos) {
+ name.erase(0, slash + 1);
+ }
+ constexpr std::size_t kMaxLen = 128;
+ if (name.size() > kMaxLen) {
+ name.resize(kMaxLen);
+ }
+ for (char& c : name) {
+ const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
+ || (c >= '0' && c <= '9') || c == '.' || c == '_'
+ || c == '-';
+ if (!ok) {
+ c = '_';
+ }
+ }
+ if (name.empty()) {
+ name = "layout.png";
+ }
+ return name;
+}
+
+// Parse an integer that must span the whole string_view (no trailing garbage),
+// exception-free. Returns false on any malformed/partial input.
+template
+bool parseIntExact(std::string_view s, T& out, int base = 10)
+{
+ const char* const first = s.data();
+ const char* const last = first + s.size();
+ const auto res = std::from_chars(first, last, out, base);
+ return res.ec == std::errc{} && res.ptr == last;
+}
+
+// Percent-decode a URL query value (e.g. the JSON `vis` payload).
+std::string urlDecode(std::string_view s)
+{
+ std::string out;
+ out.reserve(s.size());
+ for (std::size_t i = 0; i < s.size(); ++i) {
+ if (s[i] == '%' && i + 2 < s.size()) {
+ int value = 0;
+ if (parseIntExact(s.substr(i + 1, 2), value, 16)) {
+ out.push_back(static_cast(value));
+ i += 2;
+ continue;
+ }
+ // Not valid hex: keep the literal '%'.
+ }
+ out.push_back(s[i] == '+' ? ' ' : s[i]);
+ }
+ return out;
+}
+
+// Parse the "k=v&k2=v2" query of a request target into a map (values decoded).
+std::map parseQuery(std::string_view target)
+{
+ std::map params;
+ const auto qpos = target.find('?');
+ if (qpos == std::string_view::npos) {
+ return params;
+ }
+ const std::string_view qs = target.substr(qpos + 1);
+ std::size_t start = 0;
+ while (start < qs.size()) {
+ std::size_t amp = qs.find('&', start);
+ if (amp == std::string_view::npos) {
+ amp = qs.size();
+ }
+ const std::string_view kv = qs.substr(start, amp - start);
+ const std::size_t eq = kv.find('=');
+ if (eq != std::string_view::npos) {
+ params.emplace(std::string(kv.substr(0, eq)),
+ urlDecode(kv.substr(eq + 1)));
+ }
+ start = amp + 1;
+ }
+ return params;
+}
+
+// Parse "x0,y0,x1,y1" (DBU) into a Rect. Returns false on malformed input.
+bool parseBbox(const std::string& s, odb::Rect& out)
+{
+ const std::string_view sv(s);
+ int v[4];
+ int idx = 0;
+ std::size_t start = 0;
+ while (idx < 4) {
+ const std::size_t comma = sv.find(',', start);
+ const std::string_view tok
+ = sv.substr(start,
+ comma == std::string_view::npos ? std::string_view::npos
+ : comma - start);
+ if (!parseIntExact(tok, v[idx++])) {
+ return false;
+ }
+ if (comma == std::string_view::npos) {
+ break;
+ }
+ start = comma + 1;
+ }
+ if (idx != 4) {
+ return false;
+ }
+ out = odb::Rect(std::min(v[0], v[2]),
+ std::min(v[1], v[3]),
+ std::max(v[0], v[2]),
+ std::max(v[1], v[3]));
+ return true;
+}
+
+// Render the layout image requested by /download/image into `res`.
+void handleImageDownload(const std::shared_ptr& generator,
+ std::string_view target,
+ http::response& res)
+{
+ if (!generator) {
+ res.result(http::status::not_found);
+ res.body() = "No design loaded.";
+ return;
+ }
+ const auto params = parseQuery(target);
+ const auto type_it = params.find("type");
+ const std::string type = type_it == params.end() ? "entire" : type_it->second;
+
+ odb::Rect region; // zero-area => entire die (renderImagePng default)
+ if (type == "visible") {
+ const auto bbox_it = params.find("bbox");
+ if (bbox_it == params.end() || !parseBbox(bbox_it->second, region)) {
+ res.result(http::status::bad_request);
+ res.body() = "Missing or malformed bbox.";
+ return;
+ }
+ }
+
+ // Respect the session's layer visibility: the frontend sends the same
+ // visibility payload it uses for tiles as a JSON `vis` query param.
+ TileVisibility vis;
+ const auto vis_it = params.find("vis");
+ if (vis_it != params.end()) {
+ // Non-throwing parse: a malformed vis falls back to defaults (all visible).
+ std::error_code ec;
+ const boost::json::value parsed = boost::json::parse(vis_it->second, ec);
+ if (!ec && parsed.is_object()) {
+ vis.parseFromJson(parsed.as_object());
+ }
+ }
+
+ // Background color (RRGGBB hex) so the saved image matches the viewer's
+ // background; absent => transparent.
+ Color bg{}; // {0,0,0,0}
+ const auto bg_it = params.find("bg");
+ unsigned rgb = 0;
+ if (bg_it != params.end() && bg_it->second.size() == 6
+ && parseIntExact(bg_it->second, rgb, 16)) {
+ bg = Color{.r = static_cast((rgb >> 16) & 0xFF),
+ .g = static_cast((rgb >> 8) & 0xFF),
+ .b = static_cast(rgb & 0xFF),
+ .a = 255};
+ }
+ // Malformed/absent bg: keep transparent.
+
+ const std::vector png = generator->renderImagePng(
+ region, /*width_px=*/0, /*dbu_per_pixel=*/0, vis, bg);
+ if (png.empty()) {
+ res.result(http::status::internal_server_error);
+ res.body() = "Image render failed.";
+ return;
+ }
+
+ const auto fname_it = params.find("filename");
+ const std::string filename = sanitizeFilename(
+ fname_it == params.end() ? "layout.png" : fname_it->second);
+ res.set(http::field::content_type, "image/png");
+ res.set(http::field::content_disposition,
+ "attachment; filename=\"" + filename + "\"");
+ res.body().assign(reinterpret_cast(png.data()), png.size());
+}
+
+} // namespace
+
static http::response handle_request(
- http::request&& req)
+ http::request&& req,
+ const std::shared_ptr& generator)
{
http::response res{http::status::ok, req.version()};
res.set(http::field::server, "Boost.Beast Server (C++17)");
@@ -93,17 +286,27 @@ static http::response handle_request(
res.set(http::field::access_control_allow_origin, "*");
if (req.method() == http::verb::get) {
- std::string file_path(req.target());
- if (file_path == "/") {
- file_path = "/index.html";
+ const std::string target(req.target());
+ // Strip any query string for route matching / asset lookup.
+ std::string path = target;
+ if (const auto qpos = path.find('?'); qpos != std::string::npos) {
+ path.resize(qpos);
}
- const auto* asset = findEmbeddedAsset(file_path);
- if (asset) {
- res.set(http::field::content_type, asset->content_type);
- res.body() = std::string(asset->content());
+
+ if (path == "/download/image") {
+ handleImageDownload(generator, target, res);
} else {
- res.result(http::status::not_found);
- res.body() = "Resource not found.";
+ if (path == "/") {
+ path = "/index.html";
+ }
+ const auto* asset = findEmbeddedAsset(path);
+ if (asset) {
+ res.set(http::field::content_type, asset->content_type);
+ res.body() = std::string(asset->content());
+ } else {
+ res.result(http::status::not_found);
+ res.body() = "Resource not found.";
+ }
}
} else {
res.result(http::status::not_found);
@@ -636,10 +839,13 @@ class HttpSession : public std::enable_shared_from_this
beast::flat_buffer buffer_;
std::shared_ptr> res_;
http::request req_;
+ std::shared_ptr generator_;
utl::Logger* logger_;
public:
- HttpSession(Tcp::socket&& socket, utl::Logger* logger);
+ HttpSession(Tcp::socket&& socket,
+ std::shared_ptr generator,
+ utl::Logger* logger);
void run() { do_read(); }
@@ -654,8 +860,12 @@ class HttpSession : public std::enable_shared_from_this
void do_close();
};
-HttpSession::HttpSession(Tcp::socket&& socket, utl::Logger* logger)
- : stream_(std::move(socket)), logger_(logger)
+HttpSession::HttpSession(Tcp::socket&& socket,
+ std::shared_ptr generator,
+ utl::Logger* logger)
+ : stream_(std::move(socket)),
+ generator_(std::move(generator)),
+ logger_(logger)
{
}
@@ -692,7 +902,7 @@ void HttpSession::on_read(beast::error_code ec)
}
res_ = std::make_shared>(
- handle_request(std::move(req_)));
+ handle_request(std::move(req_), generator_));
do_write();
}
@@ -815,7 +1025,8 @@ void DetectSession::on_read(beast::error_code ec)
websocket_session->run(std::move(req_));
} else {
// Regular HTTP - hand off to session with already-read request
- auto s = std::make_shared(stream_.release_socket(), logger_);
+ auto s = std::make_shared(
+ stream_.release_socket(), generator_, logger_);
s->run_with_request(std::move(req_), std::move(buffer_));
}
}
diff --git a/src/web/src/websocket-tile-layer.js b/src/web/src/websocket-tile-layer.js
index a37f7878c9d..da3f4cfadb3 100644
--- a/src/web/src/websocket-tile-layer.js
+++ b/src/web/src/websocket-tile-layer.js
@@ -3,6 +3,8 @@
// Leaflet tile layer that fetches tiles via WebSocket.
+import { buildVisibilityFlags } from './ui-utils.js';
+
// `app` (last arg) is read lazily on every request so that
// app.visibleChiplets, populated by display-controls.js after the tech
// metadata arrives, is reflected in tile requests without rebuilding
@@ -21,15 +23,7 @@ export function createWebSocketTileLayer(visibility, visibleLayers,
// send it on every request so the wire schema stays uniform with
// selectAt requests.
function buildTileRequest(coords, layerName) {
- const vf = {};
- for (const [k, v] of Object.entries(visibility)) {
- vf[k] = !!v;
- }
- if (selectability) {
- for (const [k, v] of Object.entries(selectability)) {
- vf['s_' + k] = !!v;
- }
- }
+ const vf = buildVisibilityFlags(visibility, selectability);
const req = {
type: 'tile',
layer: layerName,
diff --git a/src/web/test/BUILD b/src/web/test/BUILD
index baa738973b9..7404276c2f4 100644
--- a/src/web/test/BUILD
+++ b/src/web/test/BUILD
@@ -72,6 +72,27 @@ js_test(
no_copy_to_bin = JS_FILES,
)
+js_test(
+ name = "context_menu_test",
+ data = DOM_TEST_DATA,
+ entry_point = "js/test-context-menu.js",
+ no_copy_to_bin = JS_FILES,
+)
+
+js_test(
+ name = "search_nav_test",
+ data = DOM_TEST_DATA,
+ entry_point = "js/test-search-nav.js",
+ no_copy_to_bin = JS_FILES,
+)
+
+js_test(
+ name = "capture_test",
+ data = DOM_TEST_DATA,
+ entry_point = "js/test-capture.js",
+ no_copy_to_bin = JS_FILES,
+)
+
js_test(
name = "websocket_manager_test",
data = JS_FILES,
diff --git a/src/web/test/cpp/TestRequestHandler.cpp b/src/web/test/cpp/TestRequestHandler.cpp
index 8002c6df874..068f1b4ab44 100644
--- a/src/web/test/cpp/TestRequestHandler.cpp
+++ b/src/web/test/cpp/TestRequestHandler.cpp
@@ -12,6 +12,7 @@
#include
#include
#include
+#include
#include
#include "boost/json/object.hpp"
@@ -1188,6 +1189,100 @@ TEST_F(SelectHandlerTest, SelectNextRestoresSelectionSetHighlights)
EXPECT_TRUE(found_previous);
}
+//------------------------------------------------------------------------------
+// Find (name/glob search) tests. The gui descriptors are not registered in
+// this unit context, so makeSelected() yields empty Selecteds and inserting
+// more than one into the SelectionSet would dereference a null descriptor in
+// the comparator. We therefore assert the match `count` for patterns matching
+// at most one object — enough to exercise the glob (*, ?), exact-match and
+// case-folding logic plus the error path. Multi-match selection and the
+// sel_has_inst/sel_has_net flags are covered by the WebSocket end-to-end tests.
+//------------------------------------------------------------------------------
+
+class FindHandlerTest : public tst::Nangate45Fixture
+{
+ protected:
+ void SetUp() override
+ {
+ block_->setDieArea(odb::Rect(0, 0, 100000, 100000));
+ gen_ = std::make_shared(
+ getDb(), /*sta=*/nullptr, getLogger());
+ tcl_eval_ = std::make_shared(/*interp=*/nullptr, getLogger());
+ handler_ = std::make_unique(gen_, tcl_eval_);
+ }
+
+ void placeInst(const char* master_name, const char* inst_name, int x, int y)
+ {
+ odb::dbMaster* master = lib_->findMaster(master_name);
+ ASSERT_NE(master, nullptr) << master_name;
+ odb::dbInst* inst = odb::dbInst::create(block_, master, inst_name);
+ inst->setLocation(x, y);
+ inst->setPlacementStatus(odb::dbPlacementStatus::PLACED);
+ }
+
+ void runFind(const std::string& obj_type,
+ const std::string& pattern,
+ bool match_case = false)
+ {
+ WebSocketRequest req;
+ req.id = 1;
+ req.type = WebSocketRequest::kFind;
+ boost::json::object json;
+ json["obj_type"] = obj_type;
+ json["pattern"] = pattern;
+ json["match_case"] = match_case;
+ req.json = std::move(json);
+ last_resp_ = handler_->handleFind(req, state_);
+ }
+
+ int64_t findCount(const std::string& obj_type,
+ const std::string& pattern,
+ bool match_case = false)
+ {
+ runFind(obj_type, pattern, match_case);
+ return parseObj(payloadStr(last_resp_)).at("count").as_int64();
+ }
+
+ std::shared_ptr gen_;
+ std::shared_ptr tcl_eval_;
+ std::unique_ptr handler_;
+ SessionState state_;
+ WebSocketResponse last_resp_;
+};
+
+TEST_F(FindHandlerTest, InstGlobExactAndNoMatch)
+{
+ placeInst("BUF_X16", "u_buf0", 0, 0);
+ placeInst("BUF_X16", "reg_a", 1000, 0);
+
+ EXPECT_EQ(findCount("inst", "u_*"), 1); // glob '*'
+ EXPECT_EQ(findCount("inst", "u_bu?0"), 1); // glob '?'
+ EXPECT_EQ(findCount("inst", "u_buf0"), 1); // exact
+ EXPECT_EQ(findCount("inst", "zzz*"), 0); // no match
+}
+
+TEST_F(FindHandlerTest, RespectsMatchCase)
+{
+ placeInst("BUF_X16", "MixedCase", 0, 0);
+
+ EXPECT_EQ(findCount("inst", "mixedcase", /*match_case=*/false), 1);
+ EXPECT_EQ(findCount("inst", "mixedcase", /*match_case=*/true), 0);
+}
+
+TEST_F(FindHandlerTest, NetByName)
+{
+ odb::dbNet::create(block_, "clk");
+
+ EXPECT_EQ(findCount("net", "cl*"), 1);
+ EXPECT_EQ(findCount("net", "nope"), 0);
+}
+
+TEST_F(FindHandlerTest, UnknownTypeReturnsError)
+{
+ runFind("bogus", "*");
+ EXPECT_EQ(last_resp_.type, WebSocketResponse::kError);
+}
+
//------------------------------------------------------------------------------
// DRCHandler tests
//------------------------------------------------------------------------------
diff --git a/src/web/test/js/test-capture.js b/src/web/test/js/test-capture.js
new file mode 100644
index 00000000000..3bf421f800e
--- /dev/null
+++ b/src/web/test/js/test-capture.js
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// Copyright (c) 2026, The OpenROAD Authors
+
+import './setup-dom.js';
+import { describe, it, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { captureLayout } from '../../src/capture.js';
+
+// capture.js reads `layer instanceof L.GridLayer` / `L.ImageOverlay`; provide a
+// stub L whose classes nothing matches (the mock map has no real layers).
+globalThis.L = { GridLayer: class {}, ImageOverlay: class {} };
+
+// Stub