From 56a0a4a536d031a5380f30882e1528e050a9dcfd Mon Sep 17 00:00:00 2001 From: xtof Date: Mon, 28 Sep 2026 08:43:45 +0200 Subject: [PATCH] Extract schematic layout geometry into SchematicLayout and unit-test it The incremental net placement, column de-overlap pass and hierarchy frame layout lived inside EquipotentialView::renderSchematic(), tied to file-level globals, ImGui and DiagnosisStore, so none of it could be tested. Move them unchanged into src/SchematicLayout.{h,cpp}: - IncrementalLayout: place() (was layoutEquipotential) and resolveColumnOverlaps() (was inline in renderSchematic), replacing the g_placedPositions/g_laidOut/g_layoutNextY globals. - layoutHierarchyGroups(): now operates on a passed shape vector and returns the frames with their pathKey; EquipotentialView sets diagOutline, records occurrence info and inserts the frames as before. Add tests/SchematicLayoutTest.cpp (21 tests): column placement, trace extension left/right, fan-in stacking, no-overlap in a deeper trace, de-overlap persistence, and frame nesting/containment/ordering. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + CMakeLists.txt | 4 +- REUSE.toml | 3 + src/EquipotentialView.cpp | 371 +++--------------------------- src/SchematicLayout.cpp | 285 +++++++++++++++++++++++ src/SchematicLayout.h | 133 +++++++++++ tests/SchematicLayoutTest.cpp | 415 ++++++++++++++++++++++++++++++++++ 7 files changed, 875 insertions(+), 342 deletions(-) create mode 100644 src/SchematicLayout.cpp create mode 100644 src/SchematicLayout.h create mode 100644 tests/SchematicLayoutTest.cpp diff --git a/CLAUDE.md b/CLAUDE.md index f0cf7d9..fee7b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -400,6 +400,12 @@ appear as boxes/pins there). `root_response`/`root_loaded`. - **`PropertiesView`** — renders the current `PropertiesStore` contents as a two-column name/value table into the "Properties" bottom-panel tab. +- **`SchematicLayout`** — the schematic's pure placement geometry, split out + of `EquipotentialView` so it's unit-testable without ImGui frames or a + provider (`tests/SchematicLayoutTest.cpp`): `IncrementalLayout` (per-net + instance placement and column de-overlap) and `layoutHierarchyGroups()` + (module frames). Changes to how the schematic is laid out belong here, + with a test; `EquipotentialView` only turns the result into drawn shapes. ### Diagnosis overlay diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c92edb..d0b1868 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ set(CORE_SOURCES src/GUIData.cpp src/NetlistTree.cpp src/EquipotentialView.cpp + src/SchematicLayout.cpp src/SchematicView.cpp src/DiagnosisStore.cpp src/DiagnosisView.cpp @@ -206,7 +207,7 @@ endif() # Target 3: naja-schematic-tests — GUI-independent unit tests # ============================================================ # Native only: exercises the logic layer (JSON parsing, DiagnosisStore, -# SourceStore, NetlistTree structure) without SDL/OpenGL/naja. Links only +# SourceStore, NetlistTree structure, SchematicLayout geometry) without SDL/OpenGL/naja. Links only # ImGui's core (imgui.cpp/imgui_draw.cpp/imgui_tables.cpp/imgui_widgets.cpp), # not the SDL2/OpenGL3 backends, since none of the tested code needs a real # render backend -- render()/draw calls are never invoked from tests. @@ -233,6 +234,7 @@ if(NOT EMSCRIPTEN_BUILD) src/DiagnosisStore.cpp src/SourceStore.cpp src/PropertiesStore.cpp + src/SchematicLayout.cpp ${IMGUI_DIR}/imgui.cpp ${IMGUI_DIR}/imgui_draw.cpp ${IMGUI_DIR}/imgui_tables.cpp diff --git a/REUSE.toml b/REUSE.toml index a998d08..cbbd01e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -67,6 +67,8 @@ path = [ "src/PropertiesView.cpp", "src/PropertiesView.h", "src/RenderTypes.h", + "src/SchematicLayout.cpp", + "src/SchematicLayout.h", "src/SchematicView.cpp", "src/SchematicView.h", "src/SourceStore.cpp", @@ -85,6 +87,7 @@ path = [ "tests/DiagnosisStoreTest.cpp", "tests/FakeNetlistProvider.h", "tests/NetlistTreeTest.cpp", + "tests/SchematicLayoutTest.cpp", "tests/SourceStoreTest.cpp", "tests/TypesJsonTest.cpp", ] diff --git a/src/EquipotentialView.cpp b/src/EquipotentialView.cpp index 0678177..4a5d770 100644 --- a/src/EquipotentialView.cpp +++ b/src/EquipotentialView.cpp @@ -13,17 +13,16 @@ #include "SchematicView.h" #include "INetlistProvider.h" #include "DiagnosisStore.h" +#include "SchematicLayout.h" + +// Net/instance placement and hierarchy frames live in SchematicLayout +// (pure geometry, unit-tested); this file turns them into drawn shapes. +using namespace SchematicLayout; // --------------------------------------------------------------------------- -// Layout geometry constants +// Layout geometry constants (see SchematicLayout.h for the placement ones) // --------------------------------------------------------------------------- -static constexpr float kInstW = 180.0f; -static constexpr float kInstH = 70.0f; -static constexpr float kColGap = 120.0f; -static constexpr float kRowSpacing = 24.0f; static constexpr float kPortSpacing = 18.0f; -static constexpr float kLeftMargin = 20.0f; -static constexpr float kNetVGap = 80.0f; // Pin click/hit-test radius, in screen pixels (converted to world units by // dividing by the current zoom scale where it's used). A pin now renders as @@ -41,39 +40,6 @@ static constexpr float kHierGap = 14.0f; static constexpr float kHierMargin = 16.0f; static constexpr float kHierHeaderGap = 26.0f; // room below the box's own label/ports -// Hierarchy grouping (module frames around traced leaves) geometry. -static constexpr float kGroupHeader = 30.0f; // room for the frame's label -static constexpr float kGroupPad = 18.0f; // inner padding of a frame -static constexpr float kGroupColGap = 90.0f; // gap between columns inside a frame - -// --------------------------------------------------------------------------- -// Internal item type -// --------------------------------------------------------------------------- -struct Item { - std::string label; // port/term name (for matching, port rendering) - std::string fullName; // slash-path for instances; term name for terms - Direction direction = Direction::Inout; - bool isTerm = false; - DesignRef designRef{}; - unsigned termChildId = 0; - std::optional termBit; - std::vector pathIds; - // Only meaningful for instance occurrences (isTerm == false): whether - // this instance's model has sub-instances worth expanding into a nested - // schematic box. - bool hasInstances = false; - // Total bit-term count of the instance's model, if known. - std::optional bitTermCount; - // RTL source location of the instance itself, if available. - std::optional sourceLoc; - // Instance occurrences only: per-segment instance names / model names of - // the full hierarchical path (the last entry is the instance itself). - std::vector path; - std::vector pathModels; - - const std::string& key() const { return fullName.empty() ? label : fullName; } -}; - // --------------------------------------------------------------------------- // Static view state // --------------------------------------------------------------------------- @@ -126,16 +92,14 @@ static std::map g_instanceInt // Hierarchy grouping: when on, the leaf instances shown are laid out inside // nested frames standing for the hierarchical modules that contain them -// (see layoutHierarchyGroups) instead of the flat column layout. +// (see SchematicLayout::layoutHierarchyGroups) instead of the flat column layout. static bool g_showHierarchy = true; // A "Zoom to Module" request from the canvas context menu: the pathKey of the // frame to fit the view to once this frame's layout is known. static std::string g_pendingZoomGroup; -// Persistent layout state -static std::map g_placedPositions; // key → world top-left -static std::set g_laidOut; -static float g_layoutNextY = 0.f; +// Persistent layout state: where each instance has been placed so far. +static IncrementalLayout g_layout; // --------------------------------------------------------------------------- // Helpers @@ -184,45 +148,6 @@ static std::string modelNameFromLeaf(const std::string& leaf) { return ""; // generic box } -static void buildItems(const Equipotential* eq, - std::vector& drivers, - std::vector& receivers) { - for (const auto& bt : eq->terms) { - Item item; - item.label = bt.getString(); - item.fullName = bt.name; - item.direction = bt.direction; - item.isTerm = true; - item.termChildId = bt.child_id; - item.termBit = bt.bit; - (bt.direction == Direction::Input ? drivers : receivers).push_back(std::move(item)); - } - for (const auto& occ : eq->occurrences) { - Item item; - item.label = occ.term.getString(); - item.isTerm = false; - item.designRef = occ.designRef; - item.termChildId = occ.term.child_id; - item.termBit = occ.term.bit; - item.pathIds = occ.pathIds; - item.hasInstances = occ.has_instances; - item.bitTermCount = occ.bit_term_count; - item.sourceLoc = occ.source_loc; - item.path = occ.path; - item.pathModels = occ.pathModels; - std::string joined; - bool first = true; - for (const auto& seg : occ.path) { - if (!first) joined += '/'; - joined += seg; - first = false; - } - item.fullName = std::move(joined); - item.direction = occ.term.direction; - (occ.term.direction == Direction::Output ? drivers : receivers).push_back(std::move(item)); - } -} - static float portLy(int i, int n) { return n > 1 ? -0.4f + 0.8f * float(i) / float(n - 1) : 0.0f; } @@ -236,77 +161,6 @@ static ImVec2 mouseWorldPos(const SchematicView& sv, const ImVec2& cpos) { (m.y - cpos.y) / s + sv.transform.offset.y - sv.transform.screenOrigin.y / s); } -// --------------------------------------------------------------------------- -// Layout: called once per new equip, stores positions in g_placedPositions -// --------------------------------------------------------------------------- -static void layoutEquipotential(const Equipotential* eq) { - if (g_laidOut.count(eq)) return; - g_laidOut.insert(eq); - - std::vector drivers, receivers; - buildItems(eq, drivers, receivers); - if (drivers.empty() && receivers.empty()) return; - - // Find anchor: first non-term already placed - const Item* anchor = nullptr; - bool anchorDrives = false; - - for (const auto& item : drivers) { - if (!item.isTerm && g_placedPositions.count(item.key())) - { anchor = &item; anchorDrives = true; break; } - } - if (!anchor) { - for (const auto& item : receivers) { - if (!item.isTerm && g_placedPositions.count(item.key())) - { anchor = &item; anchorDrives = false; break; } - } - } - - if (!anchor) { - // First/independent net: two-column layout below existing content - const float lx = kLeftMargin; - const float rx = kLeftMargin + kInstW + kColGap; - float dyl = g_layoutNextY, dyr = g_layoutNextY; - for (const auto& item : drivers) { - if (!item.isTerm) { - g_placedPositions.emplace(item.key(), ImVec2{lx, dyl}); - dyl += kInstH + kRowSpacing; - } - } - for (const auto& item : receivers) { - if (!item.isTerm) { - g_placedPositions.emplace(item.key(), ImVec2{rx, dyr}); - dyr += kInstH + kRowSpacing; - } - } - g_layoutNextY = std::max(dyl, dyr) + kNetVGap; - } else { - // Expansion: extend horizontally from anchor - const ImVec2& ap = g_placedPositions[anchor->key()]; - const auto& items = anchorDrives ? receivers : drivers; - float newX = anchorDrives - ? ap.x + kInstW + kColGap // new receivers go right of driver - : ap.x - kInstW - kColGap; // new drivers go left of receiver - float dy = ap.y; - // Two nets can anchor to the same instance (e.g. each input of a - // gate in a driver trace) and would otherwise stack their new boxes - // at the same spot in the same column: slide down past anything - // already placed there. - auto isFree = [&](float x, float y) { - for (const auto& [key, p] : g_placedPositions) - if (std::abs(p.x - x) < kInstW && std::abs(p.y - y) < kInstH + kRowSpacing / 2) - return false; - return true; - }; - for (const auto& item : items) { - if (item.isTerm || g_placedPositions.count(item.key())) continue; - while (!isFree(newX, dy)) dy += kInstH + kRowSpacing; - g_placedPositions.emplace(item.key(), ImVec2{newX, dy}); - dy += kInstH + kRowSpacing; - } - } -} - // --------------------------------------------------------------------------- // Hierarchy embedding: lay out and wire one expanded instance's internals // (its direct sub-instances plus the nets connecting them) nested inside its @@ -449,164 +303,6 @@ static HierEmitResult emitInstanceInternals(InstanceShape& parent, int& nextInst } -// --------------------------------------------------------------------------- -// Hierarchy grouping: nested frames for the hierarchical modules containing -// the displayed leaf instances. A driver trace (or any equipotential) spans -// leaf cells anywhere in the design; without this, they're shown as one flat -// sea of boxes and the module structure is lost. -// -// Each leaf keeps the logic column the incremental layout gave it -// (layoutEquipotential's g_placedPositions x, i.e. its distance from the -// traced net), and the modules become a tree of frames laid out bottom-up: -// inside a frame, its own leaves and sub-frames are bucketed into columns by -// the (average) logic column of their contents, left to right, and stacked -// by their original vertical order. That keeps the left-to-right signal -// flow while guaranteeing frames nest cleanly and never overlap. -// -// Frames are separate InstanceShapes (isHierGroup) inserted at the front of -// g_schematic.instances; the leaves stay top-level shapes, so wiring, pins -// and hit-testing work exactly as in the flat layout. -// --------------------------------------------------------------------------- -struct LeafHier { - std::vector path; // full instance-name path, leaf last - std::vector pathModels; // matching model names ("" if unknown) -}; - -namespace { -struct GroupNode { - std::string pathKey; - std::string label; - int depth = 0; - std::vector> groups; - std::map groupByName; - std::vector leaves; - // Filled by measureGroup(). - float levelMin = 0.f, levelMax = 0.f, sortY = 0.f; - float w = 0.f, h = 0.f; - ImVec2 rel{}; // top-left, relative to the parent frame - std::vector> leafRel; -}; - -float leafLevel(const InstanceShape& s) { - return (s.x - kLeftMargin) / (kInstW + kColGap); -} - -void measureGroup(GroupNode& node, bool isRoot) { - struct Elem { GroupNode* g; InstanceShape* leaf; float center, sortY, w, h; }; - std::vector elems; - node.levelMin = 1e9f; node.levelMax = -1e9f; node.sortY = 1e9f; - for (auto& g : node.groups) { - measureGroup(*g, false); - elems.push_back({ g.get(), nullptr, 0.5f * (g->levelMin + g->levelMax), g->sortY, g->w, g->h }); - node.levelMin = std::min(node.levelMin, g->levelMin); - node.levelMax = std::max(node.levelMax, g->levelMax); - node.sortY = std::min(node.sortY, g->sortY); - } - for (auto* leaf : node.leaves) { - float lv = leafLevel(*leaf); - elems.push_back({ nullptr, leaf, lv, leaf->y, leaf->w, leaf->h }); - node.levelMin = std::min(node.levelMin, lv); - node.levelMax = std::max(node.levelMax, lv); - node.sortY = std::min(node.sortY, leaf->y); - } - - // Bucket into columns by half-level so a module spanning an odd number - // of logic columns doesn't get forced into the same column as a leaf. - std::map> columns; - for (auto& e : elems) columns[std::lround(e.center * 2.0f)].push_back(&e); - - const float pad = isRoot ? 0.f : kGroupPad; - const float top = isRoot ? 0.f : kGroupHeader; - const float colGap = isRoot ? kColGap : kGroupColGap; - float x = pad, maxBottom = top; - for (auto& [col, members] : columns) { - std::stable_sort(members.begin(), members.end(), - [](const Elem* a, const Elem* b) { return a->sortY < b->sortY; }); - float y = top, colW = 0.f; - for (auto* e : members) { - if (e->g) e->g->rel = ImVec2(x, y); - else node.leafRel.push_back({ e->leaf, ImVec2(x, y) }); - y += e->h + kRowSpacing; - colW = std::max(colW, e->w); - } - maxBottom = std::max(maxBottom, y - kRowSpacing); - x += colW + colGap; - } - node.w = std::max(x - colGap + pad, 2.f * pad + kInstW); - node.h = maxBottom + pad; -} - -void placeGroup(const GroupNode& node, ImVec2 origin, bool isRoot, - int& nextInstId, std::vector& frames) { - if (!isRoot) { - InstanceShape f; - f.id = nextInstId++; - f.name = node.label; - f.x = origin.x; - f.y = origin.y; - f.w = node.w; - f.h = node.h; - f.isHierGroup = true; - f.hierDepth = node.depth; - f.diagOutline = DiagnosisStore::instanceColor(node.pathKey); - g_occInfoByShapeId[f.id] = { node.pathKey, DesignRef{}, std::nullopt }; - frames.push_back(std::move(f)); - } - for (const auto& [leaf, rel] : node.leafRel) { - leaf->x = origin.x + rel.x; - leaf->y = origin.y + rel.y; - if (!isRoot) leaf->label = leafSegment(leaf->name); - } - for (const auto& g : node.groups) - placeGroup(*g, ImVec2(origin.x + g->rel.x, origin.y + g->rel.y), false, nextInstId, frames); -} -} // namespace - -// Returns false (leaving every shape untouched) when no displayed leaf sits -// below the top design, i.e. there's no hierarchy to show. -static bool layoutHierarchyGroups(const std::map& leafHier, - const std::map& keyToInstId, - int& nextInstId) { - bool anyNested = false; - for (const auto& [key, lh] : leafHier) - if (lh.path.size() >= 2 && keyToInstId.count(key)) { anyNested = true; break; } - if (!anyNested) return false; - - GroupNode root; - for (const auto& [key, lh] : leafHier) { - auto kit = keyToInstId.find(key); - if (kit == keyToInstId.end()) continue; - InstanceShape* leaf = g_schematic.findInstanceById(kit->second); - if (!leaf) continue; - GroupNode* node = &root; - for (size_t i = 0; i + 1 < lh.path.size(); ++i) { - const std::string& seg = lh.path[i]; - auto git = node->groupByName.find(seg); - if (git == node->groupByName.end()) { - auto child = std::make_unique(); - child->pathKey = node->pathKey.empty() ? seg : node->pathKey + "/" + seg; - child->depth = node->depth + 1; - const std::string model = i < lh.pathModels.size() ? lh.pathModels[i] : ""; - child->label = model.empty() ? seg : seg + " (" + model + ")"; - git = node->groupByName.emplace(seg, child.get()).first; - node->groups.push_back(std::move(child)); - } - node = git->second; - } - node->leaves.push_back(leaf); - } - - measureGroup(root, true); - std::vector frames; - placeGroup(root, ImVec2(kLeftMargin, 0.f), true, nextInstId, frames); - // Parent-first order at the front: drawn under everything, and reverse - // hit-test scans still find a leaf before the frame around it. - g_schematic.instances.insert(g_schematic.instances.begin(), - std::make_move_iterator(frames.begin()), - std::make_move_iterator(frames.end())); - return true; -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -616,15 +312,13 @@ void EquipotentialView::fitView() { g_pendingFit = true; } void EquipotentialView::clearNets() { g_pendingClear = true; } void EquipotentialView::resetLayout() { - g_placedPositions.clear(); - g_laidOut.clear(); + g_layout.clear(); g_expandedInstances.clear(); g_pendingExpansions.clear(); g_expandedBuses.clear(); g_hierExpanded.clear(); g_hierPending.clear(); g_instanceInternals.clear(); - g_layoutNextY = 0.f; g_skipNextAutoFit = false; g_pendingFit = true; } @@ -902,7 +596,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip // Layout new equips (once each, stores positions) // ----------------------------------------------------------------------- for (Equipotential* eq : equipotentials) - if (eq) layoutEquipotential(eq); + if (eq) g_layout.place(eq); // ----------------------------------------------------------------------- // Rebuild geometry: merged instances + per-equip wires @@ -974,8 +668,8 @@ void EquipotentialView::renderSchematic(const std::vector& equip mi.sourceLoc = item.sourceLoc; mi.path = item.path; mi.pathModels = item.pathModels; - auto pit = g_placedPositions.find(item.key()); - mi.pos = pit != g_placedPositions.end() + auto pit = g_layout.positions().find(item.key()); + mi.pos = pit != g_layout.positions().end() ? pit->second : ImVec2{kLeftMargin, 0.f}; } // Find or create port slot for this port @@ -1196,34 +890,29 @@ void EquipotentialView::renderSchematic(const std::vector& equip g_schematic.instances.push_back(std::move(inst)); } - // Resolve vertical overlaps: layoutEquipotential() reserves a fixed - // kInstH-tall slot per instance, but an expanded instance's actual - // height (computed above from its per-bit port count) can exceed that, - // overlapping whatever was placed below it in the same column. Push - // later instances down within each column and persist the correction - // to g_placedPositions so future layout/anchor placement and Pass 3's - // term-column bandY stay consistent with what's actually drawn. + // Either wrap the leaves in module frames (which re-lays them out), or + // keep the flat incremental layout and just resolve vertical overlaps + // from boxes drawn taller than their reserved slot (persisted, so Pass 3's + // term-column bandY and later anchoring match what's actually drawn). bool grouped = false; if (g_showHierarchy) { std::map leafHier; for (const auto& [key, mi] : minsts) leafHier[key] = { mi.path, mi.pathModels }; - grouped = layoutHierarchyGroups(leafHier, keyToInstId, nextInstId); - } - if (!grouped) { - std::map> byColumn; - for (auto& inst : g_schematic.instances) - if (inst.w > 0.f) byColumn[std::lround(inst.x)].push_back(&inst); - for (auto& [x, col] : byColumn) { - std::sort(col.begin(), col.end(), - [](const InstanceShape* a, const InstanceShape* b) { return a->y < b->y; }); - float minY = -1e9f; - for (auto* inst : col) { - if (inst->y < minY) inst->y = minY; - minY = inst->y + inst->h + kRowSpacing; - g_placedPositions[inst->name] = ImVec2{inst->x, inst->y}; - } + auto frames = layoutHierarchyGroups(leafHier, g_schematic.instances, keyToInstId, nextInstId); + grouped = !frames.empty(); + std::vector frameShapes; + for (auto& f : frames) { + f.shape.diagOutline = DiagnosisStore::instanceColor(f.pathKey); + g_occInfoByShapeId[f.shape.id] = { f.pathKey, DesignRef{}, std::nullopt }; + frameShapes.push_back(std::move(f.shape)); } + // Parent-first order at the front: drawn under everything, and reverse + // hit-test scans still find a leaf before the frame around it. + g_schematic.instances.insert(g_schematic.instances.begin(), + std::make_move_iterator(frameShapes.begin()), + std::make_move_iterator(frameShapes.end())); } + if (!grouped) g_layout.resolveColumnOverlaps(g_schematic.instances); // Hierarchy embedding: now that top-level positions are finalized (the // deoverlap pass above may have shifted a box's y), lay out and append diff --git a/src/SchematicLayout.cpp b/src/SchematicLayout.cpp new file mode 100644 index 0000000..5e296db --- /dev/null +++ b/src/SchematicLayout.cpp @@ -0,0 +1,285 @@ +// SchematicLayout.cpp — pure layout geometry for the equipotential schematic. +#include "SchematicLayout.h" + +#include +#include +#include + +namespace SchematicLayout { + +// Extract the leaf segment from a slash-separated instance path. +// e.g. "top/sub/" → "" +static std::string leafSegment(const std::string& path) { + auto pos = path.rfind('/'); + return (pos == std::string::npos) ? path : path.substr(pos + 1); +} + +void buildItems(const Equipotential* eq, + std::vector& drivers, + std::vector& receivers) { + for (const auto& bt : eq->terms) { + Item item; + item.label = bt.getString(); + item.fullName = bt.name; + item.direction = bt.direction; + item.isTerm = true; + item.termChildId = bt.child_id; + item.termBit = bt.bit; + (bt.direction == Direction::Input ? drivers : receivers).push_back(std::move(item)); + } + for (const auto& occ : eq->occurrences) { + Item item; + item.label = occ.term.getString(); + item.isTerm = false; + item.designRef = occ.designRef; + item.termChildId = occ.term.child_id; + item.termBit = occ.term.bit; + item.pathIds = occ.pathIds; + item.hasInstances = occ.has_instances; + item.bitTermCount = occ.bit_term_count; + item.sourceLoc = occ.source_loc; + item.path = occ.path; + item.pathModels = occ.pathModels; + std::string joined; + bool first = true; + for (const auto& seg : occ.path) { + if (!first) joined += '/'; + joined += seg; + first = false; + } + item.fullName = std::move(joined); + item.direction = occ.term.direction; + (occ.term.direction == Direction::Output ? drivers : receivers).push_back(std::move(item)); + } +} + +// --------------------------------------------------------------------------- +// IncrementalLayout +// --------------------------------------------------------------------------- +void IncrementalLayout::place(const Equipotential* eq) { + if (laidOut_.count(eq)) return; + laidOut_.insert(eq); + + std::vector drivers, receivers; + buildItems(eq, drivers, receivers); + if (drivers.empty() && receivers.empty()) return; + + // Find anchor: first non-term already placed + const Item* anchor = nullptr; + bool anchorDrives = false; + + for (const auto& item : drivers) { + if (!item.isTerm && placed_.count(item.key())) + { anchor = &item; anchorDrives = true; break; } + } + if (!anchor) { + for (const auto& item : receivers) { + if (!item.isTerm && placed_.count(item.key())) + { anchor = &item; anchorDrives = false; break; } + } + } + + if (!anchor) { + // First/independent net: two-column layout below existing content + const float lx = kLeftMargin; + const float rx = kLeftMargin + kInstW + kColGap; + float dyl = nextY_, dyr = nextY_; + for (const auto& item : drivers) { + if (!item.isTerm) { + placed_.emplace(item.key(), ImVec2{lx, dyl}); + dyl += kInstH + kRowSpacing; + } + } + for (const auto& item : receivers) { + if (!item.isTerm) { + placed_.emplace(item.key(), ImVec2{rx, dyr}); + dyr += kInstH + kRowSpacing; + } + } + nextY_ = std::max(dyl, dyr) + kNetVGap; + } else { + // Expansion: extend horizontally from anchor + const ImVec2 ap = placed_[anchor->key()]; + const auto& items = anchorDrives ? receivers : drivers; + float newX = anchorDrives + ? ap.x + kInstW + kColGap // new receivers go right of driver + : ap.x - kInstW - kColGap; // new drivers go left of receiver + float dy = ap.y; + // Two nets can anchor to the same instance (e.g. each input of a + // gate in a driver trace) and would otherwise stack their new boxes + // at the same spot in the same column: slide down past anything + // already placed there. + auto isFree = [&](float x, float y) { + for (const auto& [key, p] : placed_) + if (std::abs(p.x - x) < kInstW && std::abs(p.y - y) < kInstH + kRowSpacing / 2) + return false; + return true; + }; + for (const auto& item : items) { + if (item.isTerm || placed_.count(item.key())) continue; + while (!isFree(newX, dy)) dy += kInstH + kRowSpacing; + placed_.emplace(item.key(), ImVec2{newX, dy}); + dy += kInstH + kRowSpacing; + } + } +} + +void IncrementalLayout::resolveColumnOverlaps(std::vector& instances) { + std::map> byColumn; + for (auto& inst : instances) + if (inst.w > 0.f) byColumn[std::lround(inst.x)].push_back(&inst); + for (auto& [x, col] : byColumn) { + std::sort(col.begin(), col.end(), + [](const InstanceShape* a, const InstanceShape* b) { return a->y < b->y; }); + float minY = -1e9f; + for (auto* inst : col) { + if (inst->y < minY) inst->y = minY; + minY = inst->y + inst->h + kRowSpacing; + placed_[inst->name] = ImVec2{inst->x, inst->y}; + } + } +} + +void IncrementalLayout::clear() { + placed_.clear(); + laidOut_.clear(); + nextY_ = 0.f; +} + +// --------------------------------------------------------------------------- +// Hierarchy grouping +// --------------------------------------------------------------------------- +namespace { +struct GroupNode { + std::string pathKey; + std::string label; + int depth = 0; + std::vector> groups; + std::map groupByName; + std::vector leaves; + // Filled by measureGroup(). + float levelMin = 0.f, levelMax = 0.f, sortY = 0.f; + float w = 0.f, h = 0.f; + ImVec2 rel{}; // top-left, relative to the parent frame + std::vector> leafRel; +}; + +float leafLevel(const InstanceShape& s) { + return (s.x - kLeftMargin) / (kInstW + kColGap); +} + +void measureGroup(GroupNode& node, bool isRoot) { + struct Elem { GroupNode* g; InstanceShape* leaf; float center, sortY, w, h; }; + std::vector elems; + node.levelMin = 1e9f; node.levelMax = -1e9f; node.sortY = 1e9f; + for (auto& g : node.groups) { + measureGroup(*g, false); + elems.push_back({ g.get(), nullptr, 0.5f * (g->levelMin + g->levelMax), g->sortY, g->w, g->h }); + node.levelMin = std::min(node.levelMin, g->levelMin); + node.levelMax = std::max(node.levelMax, g->levelMax); + node.sortY = std::min(node.sortY, g->sortY); + } + for (auto* leaf : node.leaves) { + float lv = leafLevel(*leaf); + elems.push_back({ nullptr, leaf, lv, leaf->y, leaf->w, leaf->h }); + node.levelMin = std::min(node.levelMin, lv); + node.levelMax = std::max(node.levelMax, lv); + node.sortY = std::min(node.sortY, leaf->y); + } + + // Bucket into columns by half-level so a module spanning an odd number + // of logic columns doesn't get forced into the same column as a leaf. + std::map> columns; + for (auto& e : elems) columns[std::lround(e.center * 2.0f)].push_back(&e); + + const float pad = isRoot ? 0.f : kGroupPad; + const float top = isRoot ? 0.f : kGroupHeader; + const float colGap = isRoot ? kColGap : kGroupColGap; + float x = pad, maxBottom = top; + for (auto& [col, members] : columns) { + std::stable_sort(members.begin(), members.end(), + [](const Elem* a, const Elem* b) { return a->sortY < b->sortY; }); + float y = top, colW = 0.f; + for (auto* e : members) { + if (e->g) e->g->rel = ImVec2(x, y); + else node.leafRel.push_back({ e->leaf, ImVec2(x, y) }); + y += e->h + kRowSpacing; + colW = std::max(colW, e->w); + } + maxBottom = std::max(maxBottom, y - kRowSpacing); + x += colW + colGap; + } + node.w = std::max(x - colGap + pad, 2.f * pad + kInstW); + node.h = maxBottom + pad; +} + +void placeGroup(const GroupNode& node, ImVec2 origin, bool isRoot, + int& nextInstId, std::vector& frames) { + if (!isRoot) { + HierFrame f; + f.shape.id = nextInstId++; + f.shape.name = node.label; + f.shape.x = origin.x; + f.shape.y = origin.y; + f.shape.w = node.w; + f.shape.h = node.h; + f.shape.isHierGroup = true; + f.shape.hierDepth = node.depth; + f.pathKey = node.pathKey; + frames.push_back(std::move(f)); + } + for (const auto& [leaf, rel] : node.leafRel) { + leaf->x = origin.x + rel.x; + leaf->y = origin.y + rel.y; + if (!isRoot) leaf->label = leafSegment(leaf->name); + } + for (const auto& g : node.groups) + placeGroup(*g, ImVec2(origin.x + g->rel.x, origin.y + g->rel.y), false, nextInstId, frames); +} +} // namespace + +std::vector layoutHierarchyGroups(const std::map& leafHier, + std::vector& instances, + const std::map& keyToInstId, + int& nextInstId) { + std::vector frames; + bool anyNested = false; + for (const auto& [key, lh] : leafHier) + if (lh.path.size() >= 2 && keyToInstId.count(key)) { anyNested = true; break; } + if (!anyNested) return frames; + + auto findById = [&](int id) -> InstanceShape* { + for (auto& s : instances) if (s.id == id) return &s; + return nullptr; + }; + + GroupNode root; + for (const auto& [key, lh] : leafHier) { + auto kit = keyToInstId.find(key); + if (kit == keyToInstId.end()) continue; + InstanceShape* leaf = findById(kit->second); + if (!leaf) continue; + GroupNode* node = &root; + for (size_t i = 0; i + 1 < lh.path.size(); ++i) { + const std::string& seg = lh.path[i]; + auto git = node->groupByName.find(seg); + if (git == node->groupByName.end()) { + auto child = std::make_unique(); + child->pathKey = node->pathKey.empty() ? seg : node->pathKey + "/" + seg; + child->depth = node->depth + 1; + const std::string model = i < lh.pathModels.size() ? lh.pathModels[i] : ""; + child->label = model.empty() ? seg : seg + " (" + model + ")"; + git = node->groupByName.emplace(seg, child.get()).first; + node->groups.push_back(std::move(child)); + } + node = git->second; + } + node->leaves.push_back(leaf); + } + + measureGroup(root, true); + placeGroup(root, ImVec2(kLeftMargin, 0.f), true, nextInstId, frames); + return frames; +} + +} // namespace SchematicLayout diff --git a/src/SchematicLayout.h b/src/SchematicLayout.h new file mode 100644 index 0000000..586535f --- /dev/null +++ b/src/SchematicLayout.h @@ -0,0 +1,133 @@ +// SchematicLayout.h — pure layout geometry for the equipotential schematic. +// +// Everything here only computes world-space positions/sizes: no ImGui frame, +// no provider requests, no DiagnosisStore lookups. EquipotentialView drives +// it from renderSchematic() and adds the interactive/decorative parts, which +// keeps the geometry testable without a render backend (see +// tests/SchematicLayoutTest.cpp). +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "Types.h" + +namespace SchematicLayout { + +// --------------------------------------------------------------------------- +// Geometry constants (world units) +// --------------------------------------------------------------------------- +inline constexpr float kInstW = 180.0f; +inline constexpr float kInstH = 70.0f; +inline constexpr float kColGap = 120.0f; +inline constexpr float kRowSpacing = 24.0f; +inline constexpr float kLeftMargin = 20.0f; +inline constexpr float kNetVGap = 80.0f; + +// Hierarchy grouping (module frames around traced leaves). +inline constexpr float kGroupHeader = 30.0f; // room for the frame's label +inline constexpr float kGroupPad = 18.0f; // inner padding of a frame +inline constexpr float kGroupColGap = 90.0f; // gap between columns inside a frame + +// --------------------------------------------------------------------------- +// One endpoint of an equipotential: a top-level term, or a pin on an instance +// occurrence. Drivers are top-level inputs and instance outputs; everything +// else is a receiver. +// --------------------------------------------------------------------------- +struct Item { + std::string label; // port/term name (for matching, port rendering) + std::string fullName; // slash-path for instances; term name for terms + Direction direction = Direction::Inout; + bool isTerm = false; + DesignRef designRef{}; + unsigned termChildId = 0; + std::optional termBit; + std::vector pathIds; + // Only meaningful for instance occurrences (isTerm == false): whether + // this instance's model has sub-instances worth expanding into a nested + // schematic box. + bool hasInstances = false; + // Total bit-term count of the instance's model, if known. + std::optional bitTermCount; + // RTL source location of the instance itself, if available. + std::optional sourceLoc; + // Instance occurrences only: per-segment instance names / model names of + // the full hierarchical path (the last entry is the instance itself). + std::vector path; + std::vector pathModels; + + const std::string& key() const { return fullName.empty() ? label : fullName; } +}; + +void buildItems(const Equipotential* eq, std::vector& drivers, std::vector& receivers); + +// --------------------------------------------------------------------------- +// Incremental layout: each equipotential is placed once, the first time it's +// seen, and its instances keep their position from then on (so the view +// doesn't reshuffle as nets are added). A net sharing an already-placed +// instance extends horizontally from it (new receivers to the right of a +// driver, new drivers to the left of a receiver); an unrelated net gets a +// fresh two-column block below everything placed so far. +// --------------------------------------------------------------------------- +class IncrementalLayout { + public: + // No-op if `eq` was already placed. + void place(const Equipotential* eq); + + // Push boxes down within each column (same x) so none overlaps the one + // above it -- place() reserves a fixed kInstH slot per instance, but a box + // with many ports is drawn taller. Zero-width shapes (term stubs) are + // ignored. The corrected positions are persisted so later place() calls + // anchor on what's actually drawn. + void resolveColumnOverlaps(std::vector& instances); + + void clear(); + + // Instance key (slash-joined path) -> world top-left. + const std::map& positions() const { return placed_; } + + private: + std::map placed_; + std::set laidOut_; + float nextY_ = 0.f; +}; + +// --------------------------------------------------------------------------- +// Hierarchy grouping: nested frames for the hierarchical modules containing +// the displayed leaf instances. A driver trace (or any equipotential) spans +// leaf cells anywhere in the design; without this, they're shown as one flat +// sea of boxes and the module structure is lost. +// +// Each leaf keeps the logic column the incremental layout gave it (its x, +// i.e. its distance from the traced net), and the modules become a tree of +// frames laid out bottom-up: inside a frame, its own leaves and sub-frames +// are bucketed into columns by the (average) logic column of their contents, +// left to right, and stacked by their original vertical order. That keeps the +// left-to-right signal flow while guaranteeing frames nest cleanly and never +// overlap. +// --------------------------------------------------------------------------- +struct LeafHier { + std::vector path; // full instance-name path, leaf last + std::vector pathModels; // matching model names ("" if unknown) +}; + +struct HierFrame { + InstanceShape shape; // isHierGroup set, label "inst (Model)" in `name` + std::string pathKey; // slash-joined instance path of the module +}; + +// Moves the leaves of `instances` (looked up through keyToInstId) into their +// module frames and returns the frames, parent first, with ids allocated from +// nextInstId. Leaves inside a frame get their `label` shortened to the leaf +// name. Returns an empty vector, leaving every shape untouched, when no +// displayed leaf sits below the top design (no hierarchy to show). +std::vector layoutHierarchyGroups(const std::map& leafHier, + std::vector& instances, + const std::map& keyToInstId, + int& nextInstId); + +} // namespace SchematicLayout diff --git a/tests/SchematicLayoutTest.cpp b/tests/SchematicLayoutTest.cpp new file mode 100644 index 0000000..5bd340a --- /dev/null +++ b/tests/SchematicLayoutTest.cpp @@ -0,0 +1,415 @@ +#include "SchematicLayout.h" + +#include + +#include + +using namespace SchematicLayout; + +namespace { + +constexpr float kColStep = kInstW + kColGap; // x distance between logic columns +constexpr float kRowStep = kInstH + kRowSpacing; + +InstTermOccurrence occ(std::vector path, const std::string& term, Direction dir, + std::vector models = {}) { + InstTermOccurrence o; + o.path = std::move(path); + o.pathModels = std::move(models); + o.term = BitTerm{term, 0, dir, std::nullopt}; + return o; +} + +BitTerm topTerm(const std::string& name, Direction dir) { return BitTerm{name, 0, dir, std::nullopt}; } + +// driver.Q -> each receiver's A pin, all instances at the top level. +Equipotential net(const std::string& driver, std::vector receivers) { + Equipotential eq{true, {}, {}}; + eq.occurrences.push_back(occ({driver}, "Q", Direction::Output)); + for (auto& r : receivers) eq.occurrences.push_back(occ({r}, "A", Direction::Input)); + return eq; +} + +ImVec2 pos(const IncrementalLayout& l, const std::string& key) { + auto it = l.positions().find(key); + EXPECT_NE(it, l.positions().end()) << key << " was not placed"; + return it == l.positions().end() ? ImVec2(NAN, NAN) : it->second; +} + +InstanceShape box(int id, const std::string& name, float x, float y, + float w = kInstW, float h = kInstH) { + InstanceShape s; + s.id = id; s.name = name; s.x = x; s.y = y; s.w = w; s.h = h; + return s; +} + +bool contains(const InstanceShape& outer, const InstanceShape& inner) { + return inner.x >= outer.x && inner.y >= outer.y && + inner.x + inner.w <= outer.x + outer.w && + inner.y + inner.h <= outer.y + outer.h; +} + +bool overlaps(const InstanceShape& a, const InstanceShape& b) { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; +} + +const InstanceShape& byName(const std::vector& v, const std::string& name) { + for (const auto& s : v) if (s.name == name) return s; + ADD_FAILURE() << "no shape named " << name; + static InstanceShape none; + return none; +} + +} // namespace + +// --------------------------------------------------------------------------- +// buildItems +// --------------------------------------------------------------------------- + +TEST(SchematicLayoutItems, DriversAreTopInputsAndInstanceOutputs) { + Equipotential eq{true, {topTerm("clk", Direction::Input), topTerm("out", Direction::Output)}, {}}; + eq.occurrences.push_back(occ({"u1", "g"}, "Q", Direction::Output)); + eq.occurrences.push_back(occ({"u2"}, "A", Direction::Input)); + + std::vector drivers, receivers; + buildItems(&eq, drivers, receivers); + + ASSERT_EQ(drivers.size(), 2u); + EXPECT_TRUE(drivers[0].isTerm); + EXPECT_EQ(drivers[0].key(), "clk"); + EXPECT_EQ(drivers[1].key(), "u1/g"); + ASSERT_EQ(receivers.size(), 2u); + EXPECT_EQ(receivers[0].key(), "out"); + EXPECT_EQ(receivers[1].key(), "u2"); +} + +// --------------------------------------------------------------------------- +// IncrementalLayout::place +// --------------------------------------------------------------------------- + +TEST(IncrementalLayout, FirstNetPutsDriversLeftAndReceiversRight) { + IncrementalLayout layout; + auto eq = net("u1", {"u2", "u3"}); + layout.place(&eq); + + EXPECT_EQ(pos(layout, "u1").x, kLeftMargin); + EXPECT_EQ(pos(layout, "u1").y, 0.f); + EXPECT_EQ(pos(layout, "u2").x, kLeftMargin + kColStep); + EXPECT_EQ(pos(layout, "u2").y, 0.f); + EXPECT_EQ(pos(layout, "u3").x, kLeftMargin + kColStep); + EXPECT_EQ(pos(layout, "u3").y, kRowStep); +} + +TEST(IncrementalLayout, TopLevelTermsAreNotPlaced) { + IncrementalLayout layout; + Equipotential eq{true, {topTerm("in", Direction::Input)}, {}}; + eq.occurrences.push_back(occ({"u1"}, "A", Direction::Input)); + layout.place(&eq); + + EXPECT_EQ(layout.positions().size(), 1u); + EXPECT_EQ(layout.positions().count("in"), 0u); + // The instance is a receiver: right column, even with no instance driver. + EXPECT_EQ(pos(layout, "u1").x, kLeftMargin + kColStep); +} + +TEST(IncrementalLayout, PlacingTheSameNetTwiceIsANoOp) { + IncrementalLayout layout; + auto eq = net("u1", {"u2"}); + layout.place(&eq); + auto before = layout.positions(); + layout.place(&eq); + EXPECT_EQ(layout.positions().size(), before.size()); + + // ...and it doesn't advance the "below existing content" cursor either. + auto other = net("v1", {"v2"}); + layout.place(&other); + EXPECT_EQ(pos(layout, "v1").y, kRowStep + kNetVGap); +} + +TEST(IncrementalLayout, UnrelatedNetGoesBelowTheTallestColumn) { + IncrementalLayout layout; + auto a = net("u1", {"u2", "u3"}); // right column is 2 rows tall + auto b = net("v1", {"v2"}); + layout.place(&a); + layout.place(&b); + + const float expectedY = 2 * kRowStep + kNetVGap; + EXPECT_EQ(pos(layout, "v1").x, kLeftMargin); + EXPECT_EQ(pos(layout, "v1").y, expectedY); + EXPECT_EQ(pos(layout, "v2").y, expectedY); +} + +TEST(IncrementalLayout, DriverOfAPlacedReceiverGoesOneColumnLeft) { + IncrementalLayout layout; + auto a = net("g", {"sink"}); + auto b = net("src", {"g"}); // trace one step back from g's input + layout.place(&a); + layout.place(&b); + + EXPECT_EQ(pos(layout, "src").x, pos(layout, "g").x - kColStep); + EXPECT_EQ(pos(layout, "src").y, pos(layout, "g").y); + // The anchor itself never moves. + EXPECT_EQ(pos(layout, "g").x, kLeftMargin); +} + +TEST(IncrementalLayout, ReceiversOfAPlacedDriverGoOneColumnRight) { + IncrementalLayout layout; + auto a = net("g", {"sink"}); + auto b = net("sink", {"r1", "r2"}); + layout.place(&a); + layout.place(&b); + + const float x = pos(layout, "sink").x + kColStep; + EXPECT_EQ(pos(layout, "r1").x, x); + EXPECT_EQ(pos(layout, "r2").x, x); + EXPECT_EQ(pos(layout, "r2").y, pos(layout, "r1").y + kRowStep); +} + +// A driver trace through a 2-input gate: each input net anchors on the gate +// and wants the same slot left of it. The second must slide down, not stack. +TEST(IncrementalLayout, FanInNetsAnchoredOnTheSameGateDoNotStack) { + IncrementalLayout layout; + auto out = net("g", {"sink"}); + auto inA = net("a", {"g"}); + auto inB = net("b", {"g"}); + layout.place(&out); + layout.place(&inA); + layout.place(&inB); + + EXPECT_EQ(pos(layout, "a").x, pos(layout, "b").x); + EXPECT_EQ(pos(layout, "a").y, pos(layout, "g").y); + EXPECT_EQ(pos(layout, "b").y, pos(layout, "a").y + kRowStep); +} + +TEST(IncrementalLayout, NoTwoPlacedBoxesOverlapInADeeperTrace) { + // g <- {a, b}, a <- {c, d}, b <- {e, f}: a small binary fan-in tree. + IncrementalLayout layout; + std::vector nets = { + net("g", {"sink"}), net("a", {"g"}), net("b", {"g"}), + net("c", {"a"}), net("d", {"a"}), net("e", {"b"}), net("f", {"b"})}; + for (auto& n : nets) layout.place(&n); + + std::vector boxes; + int id = 1; + for (const auto& [key, p] : layout.positions()) boxes.push_back(box(id++, key, p.x, p.y)); + for (size_t i = 0; i < boxes.size(); ++i) + for (size_t j = i + 1; j < boxes.size(); ++j) + EXPECT_FALSE(overlaps(boxes[i], boxes[j])) << boxes[i].name << " vs " << boxes[j].name; +} + +TEST(IncrementalLayout, ClearForgetsPositionsAndTheVerticalCursor) { + IncrementalLayout layout; + auto a = net("u1", {"u2"}); + layout.place(&a); + layout.clear(); + EXPECT_TRUE(layout.positions().empty()); + + layout.place(&a); // placeable again after clear, back at the top + EXPECT_EQ(pos(layout, "u1").y, 0.f); +} + +// --------------------------------------------------------------------------- +// IncrementalLayout::resolveColumnOverlaps +// --------------------------------------------------------------------------- + +TEST(IncrementalLayout, TallBoxPushesTheNextOneInItsColumnDown) { + IncrementalLayout layout; + std::vector shapes = { + box(1, "tall", 20.f, 0.f, kInstW, 200.f), + box(2, "below", 20.f, kRowStep), + box(3, "otherColumn", 320.f, kRowStep), + }; + layout.resolveColumnOverlaps(shapes); + + EXPECT_EQ(shapes[0].y, 0.f); + EXPECT_EQ(shapes[1].y, 200.f + kRowSpacing); + EXPECT_EQ(shapes[2].y, kRowStep); // different column: untouched + // Persisted, so the next place() anchors on the drawn position. + EXPECT_EQ(pos(layout, "below").y, 200.f + kRowSpacing); +} + +TEST(IncrementalLayout, ColumnOverlapsIgnoreZeroWidthTermStubs) { + IncrementalLayout layout; + std::vector shapes = { + box(1, "u1", 20.f, 0.f), + box(2, "term:in", 20.f, 10.f, 0.f, 0.f), + }; + layout.resolveColumnOverlaps(shapes); + EXPECT_EQ(shapes[1].y, 10.f); + EXPECT_EQ(layout.positions().count("term:in"), 0u); +} + +// --------------------------------------------------------------------------- +// layoutHierarchyGroups +// --------------------------------------------------------------------------- + +namespace { + +// Leaves at logic column `col` (as IncrementalLayout would have placed them). +struct LeafSpec { std::vector path; std::vector models; int col; float y; }; + +struct HierFixture { + std::vector shapes; + std::map leafHier; + std::map keyToInstId; + int nextInstId = 1; + + explicit HierFixture(const std::vector& leaves) { + for (const auto& l : leaves) { + std::string key; + for (const auto& seg : l.path) key += (key.empty() ? "" : "/") + seg; + int id = nextInstId++; + shapes.push_back(box(id, key, kLeftMargin + float(l.col) * kColStep, l.y)); + leafHier[key] = {l.path, l.models}; + keyToInstId[key] = id; + } + } + std::vector run() { + return layoutHierarchyGroups(leafHier, shapes, keyToInstId, nextInstId); + } +}; + +const HierFrame& frame(const std::vector& frames, const std::string& pathKey) { + for (const auto& f : frames) if (f.pathKey == pathKey) return f; + ADD_FAILURE() << "no frame for " << pathKey; + static HierFrame none; + return none; +} + +} // namespace + +TEST(HierarchyGroups, FlatDesignProducesNoFramesAndMovesNothing) { + HierFixture fx({{{"u1"}, {}, 0, 0.f}, {{"u2"}, {}, 1, 0.f}}); + auto before = fx.shapes; + auto frames = fx.run(); + + EXPECT_TRUE(frames.empty()); + for (size_t i = 0; i < before.size(); ++i) { + EXPECT_EQ(fx.shapes[i].x, before[i].x); + EXPECT_EQ(fx.shapes[i].y, before[i].y); + EXPECT_TRUE(fx.shapes[i].label.empty()); + } +} + +TEST(HierarchyGroups, OneModuleFrameWrapsItsLeaves) { + HierFixture fx({{{"core", "g1"}, {"Core", "AND2"}, 0, 0.f}, + {{"core", "g2"}, {"Core", "OR2"}, 1, 0.f}}); + const int firstFreeId = fx.nextInstId; + auto frames = fx.run(); + + ASSERT_EQ(frames.size(), 1u); + const auto& f = frames[0].shape; + EXPECT_EQ(frames[0].pathKey, "core"); + EXPECT_EQ(f.name, "core (Core)"); + EXPECT_TRUE(f.isHierGroup); + EXPECT_EQ(f.hierDepth, 1); + EXPECT_EQ(f.id, firstFreeId); + EXPECT_EQ(fx.nextInstId, firstFreeId + 1); + + const auto& g1 = byName(fx.shapes, "core/g1"); + const auto& g2 = byName(fx.shapes, "core/g2"); + EXPECT_TRUE(contains(f, g1)); + EXPECT_TRUE(contains(f, g2)); + // Room for the frame's label above its contents. + EXPECT_GE(g1.y - f.y, kGroupHeader); + // Inside a frame the box shows just the leaf name. + EXPECT_EQ(g1.label, "g1"); + EXPECT_EQ(g2.label, "g2"); +} + +TEST(HierarchyGroups, FrameLabelFallsBackToInstanceNameWithoutModel) { + HierFixture fx({{{"core", "g1"}, {}, 0, 0.f}}); + auto frames = fx.run(); + ASSERT_EQ(frames.size(), 1u); + EXPECT_EQ(frames[0].shape.name, "core"); +} + +TEST(HierarchyGroups, LogicColumnsStayLeftToRightInsideAFrame) { + HierFixture fx({{{"core", "drv"}, {}, 0, 0.f}, + {{"core", "mid"}, {}, 1, 0.f}, + {{"core", "rcv"}, {}, 2, 0.f}}); + fx.run(); + + const auto& drv = byName(fx.shapes, "core/drv"); + const auto& mid = byName(fx.shapes, "core/mid"); + const auto& rcv = byName(fx.shapes, "core/rcv"); + EXPECT_EQ(mid.x - (drv.x + drv.w), kGroupColGap); + EXPECT_EQ(rcv.x - (mid.x + mid.w), kGroupColGap); + EXPECT_EQ(drv.y, mid.y); + EXPECT_EQ(mid.y, rcv.y); +} + +TEST(HierarchyGroups, SameColumnLeavesKeepTheirVerticalOrder) { + // Given in reverse y order on purpose; map order (by key) is also reversed. + HierFixture fx({{{"core", "b"}, {}, 0, 0.f}, + {{"core", "a"}, {}, 0, 3 * kRowStep}}); + fx.run(); + EXPECT_LT(byName(fx.shapes, "core/b").y, byName(fx.shapes, "core/a").y); + EXPECT_FALSE(overlaps(byName(fx.shapes, "core/a"), byName(fx.shapes, "core/b"))); +} + +TEST(HierarchyGroups, NestedModulesNestTheirFramesParentFirst) { + HierFixture fx({{{"top_a", "sub", "g"}, {"A", "S", "INV"}, 0, 0.f}, + {{"top_a", "h"}, {"A", "BUF"}, 1, 0.f}}); + auto frames = fx.run(); + + ASSERT_EQ(frames.size(), 2u); + EXPECT_EQ(frames[0].pathKey, "top_a"); // parent first: drawn underneath + EXPECT_EQ(frames[1].pathKey, "top_a/sub"); + EXPECT_EQ(frames[0].shape.hierDepth, 1); + EXPECT_EQ(frames[1].shape.hierDepth, 2); + EXPECT_EQ(frames[1].shape.name, "sub (S)"); + + const auto& outer = frames[0].shape; + const auto& inner = frames[1].shape; + EXPECT_TRUE(contains(outer, inner)); + EXPECT_TRUE(contains(inner, byName(fx.shapes, "top_a/sub/g"))); + EXPECT_TRUE(contains(outer, byName(fx.shapes, "top_a/h"))); + EXPECT_FALSE(overlaps(inner, byName(fx.shapes, "top_a/h"))); +} + +TEST(HierarchyGroups, SiblingModulesDoNotOverlap) { + // Two modules whose leaves sit in the same logic columns. + HierFixture fx({{{"m1", "a"}, {}, 0, 0.f}, {{"m1", "b"}, {}, 1, 0.f}, + {{"m2", "c"}, {}, 0, kRowStep}, {{"m2", "d"}, {}, 1, kRowStep}}); + auto frames = fx.run(); + + ASSERT_EQ(frames.size(), 2u); + const auto& m1 = frame(frames, "m1").shape; + const auto& m2 = frame(frames, "m2").shape; + EXPECT_FALSE(overlaps(m1, m2)); + for (auto name : {"m1/a", "m1/b"}) { + EXPECT_TRUE(contains(m1, byName(fx.shapes, name))) << name; + EXPECT_FALSE(overlaps(m2, byName(fx.shapes, name))) << name; + } + for (auto name : {"m2/c", "m2/d"}) { + EXPECT_TRUE(contains(m2, byName(fx.shapes, name))) << name; + EXPECT_FALSE(overlaps(m1, byName(fx.shapes, name))) << name; + } +} + +TEST(HierarchyGroups, TopLevelLeavesStayOutsideFramesAndKeepTheirLabel) { + HierFixture fx({{{"src"}, {}, 0, 0.f}, + {{"core", "g"}, {}, 1, 0.f}, + {{"dst"}, {}, 2, 0.f}}); + auto frames = fx.run(); + + ASSERT_EQ(frames.size(), 1u); + const auto& core = frames[0].shape; + const auto& src = byName(fx.shapes, "src"); + const auto& dst = byName(fx.shapes, "dst"); + EXPECT_FALSE(overlaps(core, src)); + EXPECT_FALSE(overlaps(core, dst)); + EXPECT_TRUE(src.label.empty()); + // Signal flow still reads left to right across the frame. + EXPECT_LT(src.x + src.w, core.x); + EXPECT_LT(core.x + core.w, dst.x); +} + +TEST(HierarchyGroups, LeavesWithoutAShapeAreIgnored) { + HierFixture fx({{{"core", "g"}, {}, 0, 0.f}}); + fx.leafHier["ghost/x"] = {{"ghost", "x"}, {}}; // no keyToInstId entry + auto frames = fx.run(); + ASSERT_EQ(frames.size(), 1u); + EXPECT_EQ(frames[0].pathKey, "core"); +}