diff --git a/.agent-logs/developer-connector-router-quality-retry-20260704-2205.md b/.agent-logs/developer-connector-router-quality-retry-20260704-2205.md new file mode 100644 index 0000000..9e550ae --- /dev/null +++ b/.agent-logs/developer-connector-router-quality-retry-20260704-2205.md @@ -0,0 +1,51 @@ +# Developer Agent Report + +**Result**: SUCCEEDED +**Report**: `.agent-logs/developer-connector-router-quality-retry-20260704-2205.md` + +- **Files Modified**: + - `src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs` + - `test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs` + - `docs/reqstream/rendering-layout/connector-router.yaml` + - `docs/design/rendering-layout/connector-router.md` + - `docs/verification/rendering-layout/connector-router.md` + - `docs/user_guide/introduction.md` + - `.agent-logs/developer-connector-router-quality-retry-20260704-2205.md` +- **Languages Detected**: C#, Markdown, YAML +- **Standards Applied**: + - `docs/design/introduction.md` + - `.github/standards/coding-principles.md` + - `.github/standards/csharp-language.md` + - `.github/standards/testing-principles.md` + - `.github/standards/csharp-testing.md` + - `.github/standards/requirements-principles.md` + - `.github/standards/reqstream-usage.md` + - `.github/standards/software-items.md` + - `.github/standards/design-documentation.md` + - `.github/standards/verification-documentation.md` + - `.github/standards/technical-documentation.md` + +## Tooling Executed + +- **Language Tools**: + - `dotnet test test/DemaConsulting.Rendering.Layout.Tests/` + `DemaConsulting.Rendering.Layout.Tests.csproj --no-restore --nologo` + - `pwsh ./lint.ps1` + - `pwsh ./build.ps1` +- **Compliance Tools**: + - `lint.ps1` checks: `markdownlint-cli2`, `cspell`, `yamllint`, `dotnet format`, + `reqstream`, `reviewmark`, `versionmark` +- **Validation Results**: + - Targeted layout tests passed for `net8.0`, `net9.0`, and `net10.0` + - `pwsh ./lint.ps1` passed + - `pwsh ./build.ps1` passed with zero warnings/errors and successful full test execution + +## Compliance Status + +- **Quality Checks**: Required targeted test, lint, and build validations all passed; + `fix.ps1` was not needed because lint passed without formatting changes. +- **Issues Resolved**: `ConnectorRouter` now resolves folder tab width and height hints + independently so partial folder hints stay shape-aware, regression coverage documents + width-only and height-only cases, and the required reqstream/design/verification/user-guide + artifacts were updated. Unrelated gallery worktree changes remained untouched and were + excluded from the commit. diff --git a/.reviewmark.yaml b/.reviewmark.yaml index ef23f2f..59087f8 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -365,6 +365,7 @@ reviews: - "docs/design/rendering-layout/engine/engine.md" - "docs/reqstream/rendering-layout.yaml" - "docs/reqstream/rendering-layout/engine/engine.yaml" + - "docs/design/rendering-layout/connector-router.md" # Rendering.Layout system-level units - id: Rendering-Layout-LayeredLayoutAlgorithm diff --git a/docs/design/rendering-layout/connector-router.md b/docs/design/rendering-layout/connector-router.md index a8059a9..3e9f50c 100644 --- a/docs/design/rendering-layout/connector-router.md +++ b/docs/design/rendering-layout/connector-router.md @@ -30,15 +30,38 @@ It mirrors ELK's `elk.edgeRouting` and today carries the single value `Orthogona exposed on the open property system as `CoreOptions.EdgeRouting` (id `rendering.edgerouting`, default `Orthogonal`), so routing can be selected per scope alongside `CoreOptions.Algorithm`. +Internally, `ConnectorRouter` also resolves one shape-geometry object per endpoint box. That internal +abstraction exposes two face-level concerns per `PortSide`: the connectable extents (one or more +usable sub-ranges along the face, possibly empty) and the surface projection (the inward offset from +the bounding-box face to the real outline at a chosen along-face coordinate). The shipped +implementations are: + +- **Rectangle** — one full-length extent per face; zero projection on every face. +- **RoundedRectangle** — one extent per face inset by the resolved corner radius at both ends; zero + projection because the flat portion of the face still lies on the bounding box. +- **Note** — one full-length extent per face for now; zero projection. +- **Folder** — full-length left, right, and bottom extents; top extent only to the right of the + raised tab, and a positive top-face projection equal to the resolved tab height so the anchor + touches the recessed body top. `FolderTabWidth` and `FolderTabHeight` are resolved + independently, so when only one hint is supplied the router computes the missing companion + dimension from its generic fallback before extents and projection are evaluated. + ### ConnectorRouter Methods `Route(boxes, connection, options)` rejects null arguments (including a null `From` or `To`) with `ArgumentNullException`, then: -1. **Anchor selection.** Computes each box centre and, for each endpoint, chooses the midpoint of the - box side whose outward normal best points at the opposing box centre (right/left when the - horizontal separation dominates, otherwise bottom/top). The chosen `PortSide` is retained so the - route exits and enters perpendicular to the face. +1. **Anchor selection.** Computes the naturally-facing source and target faces from box separation as + before (right/left when the horizontal separation dominates, otherwise bottom/top). For each + endpoint, if that natural face reports a non-empty connectable extent it is used; otherwise the + router falls back, in order, to the adjacent face that still points most toward the other box on + the minor axis, then the other adjacent face, and finally the opposite face as a last resort. + The along-face coordinate is chosen from the overlap-centre rule used previously, then clamped + inward by `ConnectorRouteOptions.Clearance` whenever the face is long enough to keep that margin + from both ends. Faces too short for that inset fall back to their own center instead of violating + the margin. The result is then clamped into the chosen face's usable connectable extents (again + applying the same clearance inset when an extent is long enough) and projected inward to the real + outline. The chosen `PortSide` is retained so the route exits and enters perpendicular to the face. 2. **Obstacle set.** Builds a `Rect` per box, excluding the connection's two endpoint boxes matched by reference identity, so the connector is free to leave and enter the boxes it joins. 3. **Dispatch.** Routes through the router realizing `options.EdgeRouting`. Today `Orthogonal` maps to @@ -48,8 +71,17 @@ exposed on the open property system as `CoreOptions.EdgeRouting` (id `rendering. 4. **Assembly.** Wraps the returned waypoints in a `LayoutLine` carrying the connection's `TargetEnd`, `LineStyle`, and `Label`, with `SourceEnd` left `None`. -The batch overload applies the single-connection routine to each connection and returns one line per -connection in input order. +The batch overload first computes those naive per-connection face coordinates, then groups any shared +box face claims and redistributes them across the **union of that face's connectable extents** rather +than across the full bounding-box span. It still orders the claims by counterpart-box centre so the +visual left-to-right or top-to-bottom order of the connectors tracks the order of their counterparts. +Finally it routes the connectors sequentially and turns only each prior route's **interior** segments +into soft obstacles; the short endpoint-adjacent approach legs are intentionally omitted so several +connectors may still share a legitimate final corridor into the same box face without being lured into +redundant leave-and-return detours. The underlying router now discourages an interior overlap in +proportion to how far it extends, rather than treating every overlap as an equally cheap flat cost, so +parallel fan-out or fan-in connectors separate into visually distinct corridors instead of merging +along a shared trunk for an extended span. ### ConnectorRouter Error Handling @@ -81,6 +113,14 @@ them: - **External application code** — any caller that supplies its own placed `LayoutBox` list (for example from a containment or free-form placement produced outside the layered pipeline) and needs routed `LayoutLine` connectors to drop into a `LayoutTree`. +- **LayeredPipeline** — the `PortDistributor` and `LongEdgeJoiner` stages internally consume + `ConnectorRouter`'s (internal, widened-accessibility) shape-geometry resolution + (`ResolveShapeGeometry` and its supporting extent-math helpers) for same-scope (leaf) edges routed + through `LayeredLayoutAlgorithm`'s own pipeline, so a shaped node gets the same connectable-extent + restriction and inward surface projection whether it is routed by `ConnectorRouter` (cross-container + edges) or by the layered pipeline (same-scope edges). See _Layered Pipeline Unit Design_'s + "Layered Pipeline Dependencies" section for the reverse-direction documentation of this cross-unit + dependency. ### ConnectorRouter Interactions @@ -95,6 +135,8 @@ any set of placed boxes. | Requirement ID | Satisfied by | | --- | --- | | Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-ShapeAwareAnchors | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-SharedFaceDistribution | ConnectorRouter behavior described above | | Rendering-Layout-ConnectorRouter-AvoidsObstacles | ConnectorRouter behavior described above | | Rendering-Layout-ConnectorRouter-ExcludesEndpoints | ConnectorRouter behavior described above | | Rendering-Layout-ConnectorRouter-CarriesStyling | ConnectorRouter behavior described above | diff --git a/docs/design/rendering-layout/default-layout.md b/docs/design/rendering-layout/default-layout.md index c294c8f..9911e32 100644 --- a/docs/design/rendering-layout/default-layout.md +++ b/docs/design/rendering-layout/default-layout.md @@ -24,18 +24,20 @@ registry is safe to read (resolve) concurrently. ### DefaultLayout Methods -`LayoutEngine.Layout(graph, options)` resolves against the shared default registry; -`LayoutEngine.Layout(graph, options, registry)` resolves against a caller-supplied registry. Both reject +`LayoutEngine.Layout(graph)` resolves against the shared default registry; +`LayoutEngine.Layout(graph, registry)` resolves against a caller-supplied registry. Both reject null arguments with `ArgumentNullException`, then: 1. **Resolve the algorithm identifier.** The identifier is read from an explicit `CoreOptions.Algorithm` - on the graph, else from an explicit `CoreOptions.Algorithm` on the options, else `DefaultAlgorithmId` - (`"hierarchical"`). Resolution consults *explicit* settings only (via `TryGet`), so an unset graph and - options fall through to the hierarchical default rather than the `CoreOptions.Algorithm` property - default of `"layered"`. The graph takes precedence over the options because, in the ELK-style model, - layout options are naturally attached to the graph being laid out. + set directly on the graph, else `DefaultAlgorithmId` (`"hierarchical"`). Resolution consults an + *explicit* graph setting only (via `TryGet`), so an unset graph falls through to the hierarchical + default rather than the `CoreOptions.Algorithm` property default of `"layered"`. The graph is the + single place to configure a layout — since `LayoutGraph` is itself an `IPropertyHolder` — so there is + no second, free-standing options object at this entry point that could disagree with it. 2. **Resolve and apply.** The identifier is resolved from the registry and the resolved algorithm's - `Apply(graph, options)` produces the placed `LayoutTree`. + `Apply(graph, options)` produces the placed `LayoutTree`, where `options` is an empty `LayoutOptions` + used only to seed the algorithm's internal option-cascading contract (see the respective algorithm's + Unit Design document); the graph itself already carries every explicit setting. Defaulting to the hierarchical engine is what lets the single facade serve both flat and nested graphs. It is safe because of the hierarchical engine's flat-graph equivalence guarantee: for a graph with no @@ -51,12 +53,12 @@ would, while a nested graph is composed correctly — with no decision required - The facade shall default to the hierarchical engine, not the layered algorithm, so one entry point handles both flat and nested graphs; the flat-graph equivalence guarantee makes this behavior- preserving. -- The facade shall consult only explicit algorithm declarations when resolving, so an unset graph and - options reach the hierarchical default rather than the layered property default. +- The facade shall consult only an explicit algorithm declaration on the graph when resolving, so an + unset graph reaches the hierarchical default rather than the layered property default. ### DefaultLayout Error Handling -Null `graph`, `options`, or (three-argument overload) `registry` throw `ArgumentNullException`. A +Null `graph` or (two-argument overload) `registry` throw `ArgumentNullException`. A declared algorithm identifier absent from the resolving registry surfaces the registry's `KeyNotFoundException`. @@ -67,7 +69,7 @@ declared algorithm identifier absent from the resolving registry surfaces the re - **Rendering.Abstractions** (`LayoutAlgorithmRegistry`, `ILayoutAlgorithm`) — the registry type populated by `CreateDefaultRegistry` and the algorithm contract resolved and invoked by `LayoutEngine.Layout`. -- **Rendering model** (`DemaConsulting.Rendering`) — the `LayoutGraph`, `LayoutOptions`, and +- **Rendering model** (`DemaConsulting.Rendering`) — the `LayoutGraph` and `LayoutTree` types on the public `Layout` signature, plus `CoreOptions.Algorithm` used for algorithm-identifier resolution. - **Layout units** (`LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorithm`, @@ -80,8 +82,8 @@ No OTS runtime component or shared package is consumed. `LayoutAlgorithms` and `LayoutEngine` are consumed by: -- **External application code** — the primary caller. Applications invoke `LayoutEngine.Layout(graph, - options)` (or the three-argument overload with a custom registry) as the batteries-included happy +- **External application code** — the primary caller. Applications invoke `LayoutEngine.Layout(graph)` + (or the two-argument overload with a custom registry) as the batteries-included happy path for going from `LayoutGraph` to placed `LayoutTree` with a single call. - **Renderer host code** (for example downstream of `SvgRenderer` / `PngRenderer`) — callers that pair `LayoutEngine.Layout(...)` with an `IRenderer` to go from graph to rendered output in two diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index fc7ca33..f7f5186 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -23,7 +23,17 @@ per-sub-edge port positions, the per-sub-edge bend points, and finally the assem waypoints. The `BackEdgeEntryApproach` parameter (default `ConnectorClearance`) lets a decoration-aware caller lengthen a reversed edge's final approach without disturbing default geometry. The `SwapNodeAxes` seam swaps each node's width and height for the down/up directions so -the direction-agnostic stages space layers by the correct extent. +the direction-agnostic stages space layers by the correct extent; it preserves every other +`LayerNode` field (`Shape`, `RoundedCornerRadius`, `FolderTabWidth`, `FolderTabHeight`, `Label`, +`RealWidth`, `RealHeight`) unchanged, since only the abstract along/cross axes need to reorient. + +`LayerNode` carries shape metadata alongside its abstract `Width`/`Height`: `Shape` (defaulting to +`BoxShape.Rectangle`, which keeps every 2-arg call site's byte-identical full-face behavior), +`RoundedCornerRadius`, `FolderTabWidth`, `FolderTabHeight`, and `Label` mirror the corresponding +`LayoutGraphNode`/`LayoutBox` properties, and `RealWidth`/`RealHeight` carry the node's true, +never-swapped bounding-box dimensions (needed because `Width`/`Height` may have been swapped by +`SwapNodeAxes`, but shape geometry such as a note's fold size combines both real dimensions +independently of the abstract axes). Each stage implements `ILayoutStage` (`void Apply(LayeredGraph graph)`) and mutates the graph in place. Stages are stateless and may be shared across pipelines. `LayeredLayoutMetrics` holds the @@ -61,13 +71,22 @@ The default stage sequence added by `AddDefaultStages` runs in this order: source-side and target-side port that lies within the corresponding node face. The clearance inset is capped at half the face extent so a box too small to hold the full clearance on both edges degrades gracefully (ports collapse toward the centre) rather than inverting the clamp range; a - box at least twice the clearance tall is unaffected, keeping its geometry byte-identical. + box at least twice the clearance tall is unaffected, keeping its geometry byte-identical. For a + non-`BoxShape.Rectangle` real node, the port band is further restricted to the shape's usable + connectable extents on the resolved real face (proportionally distributed across multiple + disjoint extents when the shape excludes a middle portion of the face), reusing `ConnectorRouter`'s + shape-geometry resolution via `ShapeAnchorSupport`; a plain-`Rectangle` node keeps the original + full-span formula untouched. 7. **LayeredCorridorRouter.** Assigns routing slots per corridor and emits orthogonal bend points, adding no bend points for a straight sub-edge. It reads `BackEdgeEntryApproach` to reserve a minimum final approach for a reversed edge; at the default this clamp is a no-op, keeping forward geometry byte-identical. 8. **LongEdgeJoiner.** Concatenates the bend points of a split edge's sub-edges into one polyline per - original edge. + original edge. For a non-`BoxShape.Rectangle` real endpoint, the assembled perpendicular endpoint + coordinate is additionally projected inward by the shape's surface-projection offset at that + endpoint's local face coordinate (for example a folder's tab height), so the connector touches the + shape's real outline rather than the plain bounding-box edge; a plain-`Rectangle` endpoint skips + geometry resolution entirely and keeps the original formula byte-identical. 9. **AxisTransform.** Maps the abstract left-to-right along/cross coordinates onto screen coordinates for the requested direction. The Right direction is the identity; Down, Left, and Up are rotations or flips. It also normalizes the input node axes at the start of `Run`. @@ -85,7 +104,14 @@ or several. All pipeline types are internal and consume only the geometric value types of the Layout system (`Point2D`, `Rect`) plus the internal `LayerNode`, `LayerEdge`, `AugNode`, and `AugEdge` records. No stage depends on the semantic `LayoutGraph` model, any OTS runtime component, or any Shared -Package. +Package. The one exception is the `PortDistributor` and `LongEdgeJoiner` stages' dependency, via the +small internal `ShapeAnchorSupport` helper, on `ConnectorRouter`'s internal shape-geometry types +(`IBoxShapeGeometry`, `ResolveShapeGeometry`, `BuildUsableExtents`, `TotalExtentLength`, +`CoordinateAtDistance`) for non-`BoxShape.Rectangle` nodes — a documented cross-unit dependency +(`LayeredPipeline` unit → `ConnectorRouter` unit) that lets a shaped node's ports and endpoints reuse +`ConnectorRouter`'s already-tested extent-restriction and surface-projection rules instead of +duplicating them. See _ConnectorRouter Unit Design_'s "Callers" section for the reverse-direction +documentation of this dependency. #### Layered Pipeline Callers @@ -121,3 +147,4 @@ public layout result contract. | Rendering-Layout-LayeredPipeline-PackedComponentsBackEdgeApproach | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-SharedState | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-InputValidation | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-ShapeAwareAnchors | Layered pipeline behavior described above | diff --git a/docs/design/rendering-layout/engine/orthogonal-edge-router.md b/docs/design/rendering-layout/engine/orthogonal-edge-router.md index 53fa34c..7f3971e 100644 --- a/docs/design/rendering-layout/engine/orthogonal-edge-router.md +++ b/docs/design/rendering-layout/engine/orthogonal-edge-router.md @@ -10,14 +10,16 @@ which all single-connector routing quality flows. #### OrthogonalEdgeRouter Data Model -`OrthogonalEdgeRouter` is a static class with no instance state. Inputs are the source and target `Point2D` -anchors, a list of obstacle `Rect`, a clearance distance, optional source and target `PortSide` -values, and an optional list of `CostBand` records. The result is a `RouteResult` record carrying -the ordered `Waypoints` and a `Crossed` flag. +`OrthogonalEdgeRouter` is a static class with no instance state. Inputs are the source and target +`Point2D` anchors, a list of obstacle `Rect`, a clearance distance, optional source and target +`PortSide` values, an optional list of `CostBand` records, and an optional list of soft-obstacle +`Rect` values (typically already-routed connector segments). The result is a `RouteResult` record +carrying the ordered `Waypoints` and a `Crossed` flag. #### OrthogonalEdgeRouter Methods -`RouteWithStatus(source, target, obstacles, clearance, sourceSide?, targetSide?, costBands?)` +`RouteWithStatus(source, target, obstacles, clearance, sourceSide?, targetSide?, costBands?, +softObstacles?)` computes the route and reports whether it had to cross an obstacle. The algorithm is: 1. **Perpendicular stubs.** When a side is supplied, the anchor is stepped off its edge by a short @@ -25,7 +27,8 @@ computes the route and reports whether it had to cross an obstacle. The algorith the gap to the opposing anchor so two facing stubs across a narrow gap meet at the midline instead of overshooting. 2. **Grid construction.** Candidate grid lines are built from the two endpoint coordinates plus each - obstacle's near and far edges offset outward by the current clearance. + obstacle's near and far edges offset outward by the current clearance; optional soft-obstacle + edges are also added so the search has candidate lanes on either side of an already-routed line. 3. **Clearance-retry ladder.** An A\*-style search runs over the grid at successively smaller clearances — full, half, quarter, then zero. Segments passing within the current clearance of an obstacle are rejected; the largest clearance yielding an obstacle-free path is used. @@ -33,12 +36,29 @@ computes the route and reports whether it had to cross an obstacle. The algorith enclosed target) does the router fall back to a best-effort L-shape and set `Crossed = true`. 5. **Finalize.** The original anchors are re-attached outside their stubs and the path is simplified — collinear interior points are removed while U-turns are preserved so a perpendicular - stub is never collapsed. + stub is never collapsed. A final defensive cleanup removes any exact leave-and-return excursion + that revisits one waypoint before continuing, so callers never receive a visibly redundant loop + even if a future soft-obstacle pattern reintroduces one. A turn penalty biases the search toward routes with fewer bends. When cost bands are supplied, each segment's length is scaled by the cheapest band covering its midpoint, so a discounted highway band attracts wires into shared corridors while a null band list leaves cost neutral. The thin `Route` -wrapper returns only the `Waypoints` for callers that do not need the crossing status. +wrapper returns only the `Waypoints` for callers that do not need the crossing status. Soft obstacles +add a penalty rather than a hard block, which is what keeps a shared box face reachable even when one +connector's interior corridor has already been claimed by an earlier route. That penalty is +proportional to the length of the overlap between the candidate move and the soft obstacle, not a flat +per-move cost: a flat penalty was tried first and found insufficient, because on a sparse narrow-gap +grid a connector's entire multi-hundred-pixel corridor can collapse into a single grid move, so a flat +cost priced a long visual overlap identically to a trivial one and was always cheaper than the +roughly fixed cost of a lane-change detour. Scaling the penalty by overlap length keeps a short, +incidental overlap cheap while making an extended overlap cost substantially more than detouring to a +free lane, so parallel connectors separate into distinct corridors instead of merging along a shared +trunk. The redundant leave-and- +return regression arose when endpoint-adjacent approach legs were also contributed as soft obstacles: +those are exactly the segments that several connectors may legitimately share when converging on one +face, so penalizing them lured the search into a pointless excursion away from a usable approach point +and back again. `ConnectorRouter` now omits those endpoint-adjacent segments from the soft-obstacle +set, while the final revisit cleanup remains as a defensive last line of defense. #### OrthogonalEdgeRouter Error Handling @@ -86,3 +106,5 @@ route individual connectors; the `Crossed` flag feeds their layout-warning handl | Rendering-Layout-OrthogonalEdgeRouter-PerpendicularEnds | OrthogonalEdgeRouter behavior described above | | Rendering-Layout-OrthogonalEdgeRouter-CrossingStatus | OrthogonalEdgeRouter behavior described above | | Rendering-Layout-OrthogonalEdgeRouter-CostBands | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-NoWaypointRevisit | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap | OrthogonalEdgeRouter behavior described above | diff --git a/docs/design/rendering-layout/hierarchical-layout-algorithm.md b/docs/design/rendering-layout/hierarchical-layout-algorithm.md index 9200398..8ba38e8 100644 --- a/docs/design/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/design/rendering-layout/hierarchical-layout-algorithm.md @@ -16,16 +16,20 @@ when a caller selects it by name. ### HierarchicalLayoutAlgorithm Data Model The class is sealed and stateless with respect to any single layout. It exposes the `AlgorithmId` -constant (`"hierarchical"`) and returns it from `Id`. Two private constants govern container framing: -`ContainerPadding` (`12.0` logical pixels) is the inset kept on every side between a container border -and its children's sub-layout, and `ContainerTitleHeight` (`24.0` logical pixels) is the title band -reserved above the children of a container that carries a `Label` (a container with no label reserves -no band). The engine holds a single field, a `LayoutAlgorithmRegistry` used to resolve the per-scope -leaf algorithm by identifier. A default constructor builds a default registry containing the -bundled `LayeredLayoutAlgorithm` and `ContainmentLayoutAlgorithm`; an injecting constructor accepts a -caller-supplied registry (rejecting null). The engine treats a scope that explicitly selects its own -`"hierarchical"` identifier as selecting the default leaf algorithm, so recursion always terminates in a -bundled leaf algorithm. +constant (`"hierarchical"`) and returns it from `Id`. A private constant and a per-node override +together govern container framing: `ContainerPadding` (`12.0` logical pixels) is the inset kept on +every side between a container border and its children's sub-layout, and `DefaultContainerTitleHeight` +(`24.0` logical pixels) is the *default* title band reserved above the children of a container that +carries a `Label` (a container with no label reserves no band regardless of any override). A container +node's own `LayoutGraphNode.TitleHeight` — when set — replaces `DefaultContainerTitleHeight` for that +node, resolved by the private `ResolveTitleHeight` helper; this lets a caller reserve a title band that +matches a specific theme's actual title-area height (including a keyword line) instead of being +limited to the generic default. The engine holds a single field, a `LayoutAlgorithmRegistry` used to +resolve the per-scope leaf algorithm by identifier. A default constructor builds a default registry +containing the bundled `LayeredLayoutAlgorithm` and `ContainmentLayoutAlgorithm`; an injecting +constructor accepts a caller-supplied registry (rejecting null). The engine treats a scope that +explicitly selects its own `"hierarchical"` identifier as selecting the default leaf algorithm, so +recursion always terminates in a bundled leaf algorithm. ### HierarchicalLayoutAlgorithm Methods diff --git a/docs/design/rendering-layout/layered-layout-algorithm.md b/docs/design/rendering-layout/layered-layout-algorithm.md index 87d1583..2d32c6f 100644 --- a/docs/design/rendering-layout/layered-layout-algorithm.md +++ b/docs/design/rendering-layout/layered-layout-algorithm.md @@ -22,7 +22,14 @@ per node followed by `LayoutLine` per edge). `Apply(graph, options)` rejects null `graph` or `options` with `ArgumentNullException`, then: 1. **Index mapping.** Assigns each `LayoutGraphNode` a positional index and builds the - `LayerNode` array from node sizes. + `LayerNode` array from node sizes and shape metadata (`Shape`, `RoundedCornerRadius`, + `FolderTabWidth`, `FolderTabHeight`, `Label`, and the real, never-swapped `RealWidth`/`RealHeight`). + The pipeline's `PortDistributor` and `LongEdgeJoiner` stages read this metadata for a + non-`BoxShape.Rectangle` node, so `Shape` now influences same-scope connector port placement and + endpoint surface projection, not only the placed box's rendered outline: a `BoxShape.Folder` + package's tab is excluded from its connectable extent and the routed connector's endpoint is + projected inward to the recessed body outline, and a `BoxShape.Note`'s folded corner is similarly + excluded, matching `ConnectorRouter`'s existing shape-geometry rules for cross-container edges. 2. **Edge mapping.** Maps each edge to an index pair, dropping any edge that references a node outside this graph. 3. **Direction resolution.** Resolves the requested flow direction from `CoreOptions.Direction`, @@ -59,3 +66,4 @@ the Engine subsystem. It is the entry point resolved by renderers through the la | Rendering-Layout-LayeredAlgorithm-EmptyGraph | LayeredLayoutAlgorithm behavior described above | | Rendering-Layout-LayeredAlgorithm-Direction | LayeredLayoutAlgorithm behavior described above | | Rendering-Layout-LayeredAlgorithm-Validation | LayeredLayoutAlgorithm behavior described above | +| Rendering-Layout-LayeredAlgorithm-ShapeAwareRouting | LayeredLayoutAlgorithm behavior described above | diff --git a/docs/design/rendering/layout-graph.md b/docs/design/rendering/layout-graph.md index 867f1cd..4a2437d 100644 --- a/docs/design/rendering/layout-graph.md +++ b/docs/design/rendering/layout-graph.md @@ -37,8 +37,14 @@ the responsibility of a hierarchical layout engine (a later delivery). - `LayoutGraph` (sealed class extending `PropertyHolder`) — `Nodes`, `Edges`, `AddNode`, `AddEdge`; each instance is a container scope (the root graph, or a node's `Children`). - `LayoutGraphNode` (sealed class extending `PropertyHolder`) — `Id`, `Width`, `Height`, `Label`, - `Children` (the lazily-created nested child subgraph), and `HasChildren` (whether the node holds at - least one child). + `Shape` (the `BoxShape` outline, defaulting to `Rectangle`), `Keyword` (an optional italicized + keyword line shown above the title), `Compartments` (an ordered, read-only list of + `LayoutCompartment` feature sections, defaulting to empty), `TitleHeight` (an optional override, in + logical pixels, of the title band a hierarchical layout engine reserves above this node's children + when it is a labelled container), `RoundedCornerRadius`, `FolderTabWidth`, and `FolderTabHeight` + (optional resolved shape-geometry hints copied onto placed boxes so routing and rendering can agree + on the real outline of rounded rectangles and folders), `Children` (the lazily-created nested child + subgraph), and `HasChildren` (whether the node holds at least one child). - `LayoutGraphEdge` (sealed class extending `PropertyHolder`) — `Id`, `Source`, `Target`, `TargetEnd`, `LineStyle`, `Label`. @@ -63,6 +69,35 @@ guarantees. child without forcing the lazy allocation, so consumers can distinguish a container from a leaf and skip empty containers. +`BoxShape LayoutGraphNode.Shape { get; set; }`, `string? LayoutGraphNode.Keyword { get; set; }`, and +`IReadOnlyList LayoutGraphNode.Compartments { get; set; }` — the node's box +outline, optional keyword line, and ordered feature-section list. Each defaults to a plain rectangle +with no keyword and no compartments, so setting none of them reproduces the pre-existing flat-graph +behavior exactly. Every bundled leaf algorithm (`LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorithm`) +and `HierarchicalLayoutAlgorithm` copy these three properties, unchanged, onto the placed `LayoutBox` +(or view node), so a caller selects the full appearance of a box once, on the input graph, rather +than after layout. + +`double? LayoutGraphNode.TitleHeight { get; set; }` — an optional override, in logical pixels, of the +title band `HierarchicalLayoutAlgorithm` reserves above this node's children when the node is a +labelled container. `null` (the default) selects the engine's own generic default band height; +setting it — typically to a theme's own computed title-area height when the container also carries a +`Keyword` — lets the reserved band match what the renderer will actually draw instead of being limited +to the engine's generic default. Ignored for a leaf node (one with no children) and for a container +with no `Label`. + +`double? LayoutGraphNode.RoundedCornerRadius { get; set; }`, `double? LayoutGraphNode.FolderTabWidth +{ get; set; }`, and `double? LayoutGraphNode.FolderTabHeight { get; set; }` — optional resolved +shape-geometry hints, in logical pixels, for the two shipped non-rectangular box families whose real +outline differs materially from the bounding rectangle used during placement. A caller sets +`RoundedCornerRadius` to the radius the renderer will actually draw for a rounded rectangle and sets +the two folder-tab values to the exact top-left folder tab geometry the renderer will draw. All three +default to `null`, preserving the existing fallback behavior for callers that do not need exact +shape-aware routing. The bundled leaf algorithms and the hierarchical engine's sized view propagate +the hints unchanged onto the placed `LayoutBox`, where `ConnectorRouter`, `SvgRenderer`, and +`SkiaRasterRenderer` can all consume the same resolved values without the layout APIs taking a +`Theme` dependency. + ### Layout Graph Error Handling Invalid inputs are rejected at their entry point rather than deferred to layout time. The @@ -90,6 +125,18 @@ the appropriate ancestor container. reused across different scopes but not twice within one scope. - A leaf node shall allocate no child subgraph, so that adding the hierarchy capability leaves a flat graph's structure and layout unchanged; `HasChildren` shall not force that allocation. +- `Shape`, `Keyword`, and `Compartments` shall default to a plain rectangle, no keyword, and no + compartments respectively, so that adding box-appearance selection to the input graph leaves a + node's placed appearance unchanged when none of the three are set; a layout algorithm shall copy + each of the three properties unchanged onto the placed box (or, for `HierarchicalLayoutAlgorithm`, + onto the corresponding view node) rather than substituting a default. +- `TitleHeight` shall default to `null`, so that a container node reserves `HierarchicalLayoutAlgorithm`'s + generic default title-band height unless a caller explicitly overrides it; the override shall apply + only while the container also carries a `Label`, matching the existing label-gated title-band + behavior. +- `RoundedCornerRadius`, `FolderTabWidth`, and `FolderTabHeight` shall default to `null`, so callers + that do not need exact shape-aware routing or rendering retain the pre-existing generic fallback + behavior; when set, a layout algorithm shall propagate each hint unchanged onto the placed box. - An edge shall reside in the container at or above the *lowest common ancestor* (LCA) of its two endpoints. An edge whose endpoints live in different descendant containers (a *cross-container* edge) shall therefore be added to an ancestor container while its `Source` and `Target` reference @@ -102,9 +149,10 @@ the appropriate ancestor container. The unit depends on the Options unit within the Rendering model (`LayoutGraph`, `LayoutGraphNode`, and `LayoutGraphEdge` all derive from `PropertyHolder`, and each element carries `LayoutProperty` overrides). It also consumes the shared notation enumerations `EndMarkerStyle` and `LineStyle` -declared by the Layout Tree unit, on `LayoutGraphEdge`. Outside the Rendering model, the unit has no -project references, no OTS runtime component, and no Shared Package dependency; it uses only the -.NET base class library. +declared by the Layout Tree unit, on `LayoutGraphEdge`, and `BoxShape` and `LayoutCompartment`, +also declared by the Layout Tree unit, on `LayoutGraphNode`. Outside the Rendering model, the unit +has no project references, no OTS runtime component, and no Shared Package dependency; it uses only +the .NET base class library. ### Layout Graph Callers @@ -134,3 +182,6 @@ carries an `EndMarkerStyle` and `LineStyle` from the Layout Tree unit's enumerat | Rendering-Model-LayoutGraph-ContainerNodes | `LayoutGraphNode.Children` / `HasChildren` | | Rendering-Model-LayoutGraph-ScopedIdentifiers | Per-`LayoutGraph` id-uniqueness reused by `Children` | | Rendering-Model-LayoutGraph-CrossContainerEdge | `LayoutGraphEdge` endpoints referencing descendant nodes | +| Rendering-Model-LayoutGraph-BoxAppearance | `LayoutGraphNode.Shape` / `.Keyword` / `.Compartments` | +| Rendering-Model-LayoutGraph-ContainerTitleHeight | `LayoutGraphNode.TitleHeight` | +| Rendering-Model-LayoutGraph-ShapeGeometryHints | `LayoutGraphNode` shape-geometry hint properties | diff --git a/docs/design/rendering/layout-tree.md b/docs/design/rendering/layout-tree.md index 6703ba6..4ca4979 100644 --- a/docs/design/rendering/layout-tree.md +++ b/docs/design/rendering/layout-tree.md @@ -26,7 +26,8 @@ the top-left, so a renderer can draw each element directly without resolving nes `Warnings`. - `LayoutNode` (abstract record) — the discriminated-union root; carries no members. - `LayoutBox` (node record) — `X`, `Y`, `Width`, `Height`, `Label`, integer `Depth`, `Shape`, - `Compartments`, `Children`, and optional `Keyword`. + `Compartments`, `Children`, optional `Keyword`, and optional resolved shape-geometry hints + `RoundedCornerRadius`, `FolderTabWidth`, and `FolderTabHeight`. - `LayoutCompartment` (sealed record) — `Title` and text `Rows`. - `LayoutPort` (node record) — `CentreX`, `CentreY`, attached `Side`, and `Label`. - `LayoutLine` (node record) — `Waypoints`, `SourceEnd`, `TargetEnd`, `LineStyle`, and @@ -71,6 +72,9 @@ Layout Graph unit, not this unit. shall not apply any coordinate transform. - `LayoutBox` shall express nesting as an integer `Depth`, not a resolved color, so that the theme in effect at render time selects the fill color. +- `LayoutBox` may carry optional resolved shape-geometry hints when routing and rendering need to + agree on the real outline of a non-rectangular shape; the hints remain geometric values rather than + resolved drawing commands so the model stays renderer-agnostic. - `LayoutNode` shall remain an open discriminated union; adding a new node type shall be an additive change, and consumers shall skip node types they do not recognize. - All node records shall be immutable, so a placed tree can be shared and rendered repeatedly without @@ -117,3 +121,4 @@ it obtains one from a layout algorithm and passes it to a renderer. | Rendering-Model-LayoutTree-Activation | `LayoutActivation` record | | Rendering-Model-LayoutTree-Grid | `LayoutGrid` / `LayoutGridRow` / `LayoutGridCell` | | Rendering-Model-LayoutTree-Geometry | `Point2D` and `Rect` value types | +| Rendering-Model-LayoutTree-ShapeGeometryHints | `LayoutBox` shape-geometry hint properties | diff --git a/docs/gallery/box-appearance.svg b/docs/gallery/box-appearance.svg new file mode 100644 index 0000000..59113d9 --- /dev/null +++ b/docs/gallery/box-appearance.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «package» + Powertrain + + «part def» + Engine + + ports + intake : FluidPort + exhaust : FluidPort + + «part def» + ElectricMotor + + diff --git a/docs/gallery/folder-top-face-anchor.svg b/docs/gallery/folder-top-face-anchor.svg new file mode 100644 index 0000000..6614248 --- /dev/null +++ b/docs/gallery/folder-top-face-anchor.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «package» + Utilities + + GlobMatcher + + PathHelpers + + + Client + + diff --git a/docs/gallery/gallery.md b/docs/gallery/gallery.md index 33d224f..ec4243f 100644 --- a/docs/gallery/gallery.md +++ b/docs/gallery/gallery.md @@ -80,3 +80,31 @@ The layered pipeline rendered to a raster PNG image. ![Hierarchical nested diagram as PNG](hierarchical-nested.png) The hierarchical nested diagram rendered to a raster PNG image. + +## Box appearance + +A node's Shape, Keyword, and Compartments properties select the box outline, an italicized keyword line, and labelled +feature sections, all through the plain input graph model — no downstream renderer-specific code required. This is +generic block-diagram notation; SysML is just one modeling language that uses it. + +![A folder container with two boxes carrying a keyword, one also compartmented, joined by an edge](box-appearance.svg) + +A folder container holding two boxes with a keyword line — one also with a labelled compartment — joined by a decorated +edge. + +## Shape-aware connectors + +A box's Shape can make its true outline diverge from its plain bounding rectangle — a folder's tab, a note's folded +corner, a rounded rectangle's corners. The router keeps connectors off those non-connectable regions and projects each +anchor down to the shape's actual drawn outline, so every connector visibly touches the shape it targets. + +![An external node connected into a folder top face, clear of the tab](folder-top-face-anchor.svg) + +An edge approaching a folder container from above: the connector avoids the tab and anchors on the folder's +recessed top edge instead of floating above it. + +![One of each container shape side by side, each holding content](shape-gallery.svg) + +Every Shape value side by side, each with content appropriate to it: rectangle and rounded-rectangle boxes with a +keyword and a compartment, a folder holding a nested child, and a note holding free-form text — every shape reserves +enough space so its content never overlaps the tab or the folded corner. diff --git a/docs/gallery/shape-gallery.svg b/docs/gallery/shape-gallery.svg new file mode 100644 index 0000000..675b8f2 --- /dev/null +++ b/docs/gallery/shape-gallery.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + Sensor + + attributes + value : Real + unit : String + + «part def» + Controller + + ports + cmd : Signal + + «package» + Utilities + + PathHelpers + + + Note + + Values expressed in SI units + unless stated otherwise. + diff --git a/docs/reqstream/rendering-layout/connector-router.yaml b/docs/reqstream/rendering-layout/connector-router.yaml index 42073da..39bf8db 100644 --- a/docs/reqstream/rendering-layout/connector-router.yaml +++ b/docs/reqstream/rendering-layout/connector-router.yaml @@ -21,6 +21,36 @@ sections: - Route_TargetToTheRight_AnchorsFaceEachOther - Route_TargetBelow_AnchorsFaceEachOther + - id: Rendering-Layout-ConnectorRouter-ShapeAwareAnchors + title: >- + ConnectorRouter shall respect each chosen face's connectable extents, shall fall back to the + next-best usable face when the naturally-facing face is unusable, and shall project the final + anchor inward from the bounding box to the box shape's real outline. + justification: | + Some shapes do not use the entire bounding-box face as a valid connection surface: a folder's + raised tab occupies part of the top edge, a rounded rectangle's corner arcs remove the face + ends, and future shapes may recess or notch a face. Connector anchors must therefore be + selected from the usable sub-ranges of a face, must pick a different face when the natural + one has no usable range, and must touch the drawn outline rather than an enclosing rectangle. + tests: + - Route_FolderTopFace_TabExcludedFromConnectableExtent + - Route_FolderTopFace_WidthOnlyHint_UsesFallbackTabHeight + - Route_FolderTopFace_HeightOnlyHint_UsesFallbackTabWidth + - Route_FolderTopFace_ProjectsAnchorToRecessedBodyTop + - Route_FaceSelectionFallback_EmptyNaturalFaceUsesAdjacentFace + + - id: Rendering-Layout-ConnectorRouter-SharedFaceDistribution + title: >- + When several connectors share one box face, ConnectorRouter shall distribute their anchors + across the union of that face's connectable extents rather than across the full bounding-box + span. + justification: | + A shape may reserve parts of a face for its own outline geometry, such as a folder tab on the + top edge. Shared-face anchor spreading must use only the usable connection ranges so multiple + connectors remain both distinct and valid for the shape they terminate on. + tests: + - Route_SharedFolderTopFace_DistributesAcrossReducedExtent + - id: Rendering-Layout-ConnectorRouter-AvoidsObstacles title: >- ConnectorRouter shall route a connector around an intervening box without passing through diff --git a/docs/reqstream/rendering-layout/default-layout.yaml b/docs/reqstream/rendering-layout/default-layout.yaml index b5fa43e..da0cbed 100644 --- a/docs/reqstream/rendering-layout/default-layout.yaml +++ b/docs/reqstream/rendering-layout/default-layout.yaml @@ -38,16 +38,16 @@ sections: - id: Rendering-Layout-LayoutEngine-Resolution title: >- - The layout facade shall resolve the algorithm from an explicit declaration on the graph in - preference to one on the options, applying the resolved algorithm to the graph. + The layout facade shall resolve the algorithm from an explicit declaration set directly on the + graph, applying the resolved algorithm to the graph. justification: | - Layout options are naturally attached to the graph in the ELK-style model, so a graph-level - declaration must take precedence over a free-standing options declaration, giving callers a - predictable single source of truth for algorithm selection. + The graph is the single place to configure a layout — since `LayoutGraph` is itself an + `IPropertyHolder` — so resolving only against an explicit graph-level declaration gives callers + a predictable single source of truth for algorithm selection, with no second, free-standing + options object at this entry point that could disagree with it. tests: - - Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly - - Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly - - Layout_GraphDeclarationOverridesOptions + - Layout_GraphDeclaresLayered_MatchesLayeredAlgorithmExactly + - Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly - id: Rendering-Layout-LayoutEngine-FlatEquivalence title: >- @@ -58,7 +58,7 @@ sections: algorithm directly, so routing every call through the facade can never change existing output. tests: - Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly - - Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly + - Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly - id: Rendering-Layout-LayoutEngine-NestedComposition title: >- @@ -85,12 +85,11 @@ sections: - id: Rendering-Layout-LayoutEngine-Validation title: >- - The layout facade shall reject a null graph, null options, or null registry argument with an - argument-null error. + The layout facade shall reject a null graph or null registry argument with an argument-null + error. justification: | Failing fast on null arguments prevents undefined behavior and surfaces caller errors at the public facade boundary. tests: - Layout_NullGraph_Throws - - Layout_NullOptions_Throws - Layout_NullRegistry_Throws diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index 66fb0db..3653e71 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -228,3 +228,31 @@ sections: tests: - LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException - LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException + + - id: Rendering-Layout-LayeredPipeline-ShapeAwareAnchors + title: >- + The port-distribution and long-edge-joining stages shall restrict a shaped node's connector + ports to its usable connectable face extents and shall project the resulting endpoint inward + to the shape's real outline, matching ConnectorRouter's shape-geometry rules. + justification: | + LayeredLayoutAlgorithm's own routing pipeline previously anchored every connector to the + plain bounding-box face, ignoring BoxShape.Folder/Note/RoundedRectangle geometry, so a + same-scope (leaf) edge routed through the layered pipeline looked inconsistent with an + equivalent cross-container edge routed through ConnectorRouter. Reusing ConnectorRouter's + already-tested extent-restriction and surface-projection rules gives both routing paths the + same anchor-projection parity for every BoxShape. + tests: + - PortDistributor_Apply_FolderTargetTopFace_Down_PortExcludesTabRegion + - PortDistributor_Apply_NoteSourceRightFace_Right_PortExcludesFoldRegion + - PortDistributor_Apply_RectangleNode_MatchesPlainFullSpanFormula + - LongEdgeJoiner_Apply_FolderTargetTopFace_Down_ProjectsInwardByTabHeight + - LongEdgeJoiner_Apply_FolderSourceTopFace_Up_ProjectsInwardByTabHeight + - LongEdgeJoiner_Apply_FolderNode_Right_NonTopFaceUnaffected + - LongEdgeJoiner_Apply_FolderNode_Left_NonTopFaceUnaffected + - LongEdgeJoiner_Apply_RectangleNode_MatchesPlainFormula + - Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop + - Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop + - Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab + - Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab + - Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone + - Apply_RectangleChain_MatchesPreShapeAwarenessOutput diff --git a/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml b/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml index 31e70e5..521a446 100644 --- a/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml +++ b/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml @@ -74,3 +74,30 @@ sections: must bias toward discounted bands while keeping null bands neutral. tests: - RouteWithStatus_HighwayBand_PrefersBandedDetour + + - id: Rendering-Layout-OrthogonalEdgeRouter-NoWaypointRevisit + title: >- + OrthogonalEdgeRouter shall not publish a waypoint sequence that visits one point, leaves it, + and later revisits that exact point before continuing. + justification: | + A leave-and-return loop is a visibly redundant detour that adds no routing value and makes a + connector appear to hesitate or double back. The engine must therefore either avoid or + defensively remove such revisit loops before returning the route. + tests: + - RouteWithStatus_SoftObstacleDetour_DoesNotRevisitWaypoint + + - id: Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap + title: >- + When an alternate lane is reachable at bounded extra cost, OrthogonalEdgeRouter shall not + route a segment along an extended overlap with an existing soft obstacle. + justification: | + Several parallel connectors that each collapse their long corridor onto the same grid line + visually merge into one thick trunk for the length of the overlap, which reads as a single + connector rather than several distinct ones. A short, incidental overlap must remain + tolerated (steering every trivial touch away would force pointless detours and would + conflict with the legitimate shared endpoint-adjacent approach corridor), but an overlap + that extends for a substantial span must be priced high enough that a bounded-cost lane + change is preferred instead. + tests: + - RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane + - RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute diff --git a/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml b/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml index fe77950..1dbe8d6 100644 --- a/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml @@ -63,3 +63,23 @@ sections: tests: - Apply_NullGraph_Throws - Apply_NullOptions_Throws + + - id: Rendering-Layout-LayeredAlgorithm-ShapeAwareRouting + title: >- + LayeredLayoutAlgorithm shall project a same-scope connector's endpoint onto a shaped node's + real outline and shall exclude the shape's non-connectable face regions from candidate + endpoints, matching ConnectorRouter's shape-geometry rules for the node's BoxShape. + justification: | + A node's Shape previously only affected the rendered outline of the placed box; the routed + connector endpoint still landed on the plain bounding-box face regardless of BoxShape, which + looked visually inconsistent with an equivalent cross-container edge routed by + ConnectorRouter (for example a BoxShape.Folder package's connector floating above the + recessed body instead of touching it). This is genuinely new, observable behavior of the + public algorithm output, not merely an internal pipeline-stage detail. + tests: + - Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop + - Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop + - Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab + - Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab + - Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone + - Apply_RectangleChain_MatchesPreShapeAwarenessOutput diff --git a/docs/reqstream/rendering/layout-graph.yaml b/docs/reqstream/rendering/layout-graph.yaml index 7381961..f629e32 100644 --- a/docs/reqstream/rendering/layout-graph.yaml +++ b/docs/reqstream/rendering/layout-graph.yaml @@ -79,3 +79,51 @@ sections: tests: - LayoutGraphEdge_CrossContainer_ReferencingDescendant_ConstructibleAtRoot - LayoutGraphEdge_CrossContainer_BetweenSiblingContainers_ConstructibleAtRoot + + - id: Rendering-Model-LayoutGraph-BoxAppearance + title: >- + A graph node shall carry a box shape, an optional keyword, and an ordered list of + compartments, and a layout algorithm shall propagate them unchanged onto the placed box. + justification: | + Selecting a node's outline, italicized keyword line, and labelled feature sections through + the input graph model — rather than only on the output box — lets a caller describe a + node's full appearance once, before layout, so every bundled leaf algorithm and the + hierarchical layout engine place a box with the requested appearance instead of defaulting + every node to a plain rectangle with no compartments. + tests: + - Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPlacedBox + - Apply_NodeWithNoShapeKeywordOrCompartments_DefaultsToPlainRectangle + - Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPackedBox + - Apply_NestedGraph_PropagatesContainerAndLeafShapeKeywordCompartments + + - id: Rendering-Model-LayoutGraph-ContainerTitleHeight + title: >- + A container node shall be able to override the title-band height a hierarchical layout + engine reserves above its children, and the override shall apply only while the container + also carries a label. + justification: | + The hierarchical engine's generic default title-band height does not know about a specific + theme's font metrics or whether the container also carries a keyword line; letting a caller + override the reserved height per container lets the band match what the renderer will + actually draw (for example a folder container whose theme uses larger fonts, or which also + shows a keyword above its label) instead of being limited to one generic default for every + theme and every container. + tests: + - Apply_ContainerWithTitleHeightOverride_ReplacesDefaultTitleBand + - Apply_UnlabelledContainerWithTitleHeightOverride_ReservesNoTitleBand + + - id: Rendering-Model-LayoutGraph-ShapeGeometryHints + title: >- + A graph node shall be able to carry optional resolved shape-geometry hints for a rounded + corner radius and a folder tab's width and height, and layout algorithms shall propagate + those hints unchanged onto the placed box. + justification: | + The layout pipeline routes connectors after placement but before rendering, while renderers + draw the final outline using theme-resolved shape geometry. Carrying optional resolved shape + hints on the input node lets routing and rendering agree on the same real outline without + forcing the layout APIs to take a theme dependency. + tests: + - LayoutGraphNode_ShapeGeometryHints_DefaultNullAndSettable + - Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPlacedBox + - Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPackedBox + - Apply_NestedGraph_PropagatesContainerAndLeafShapeKeywordCompartments diff --git a/docs/reqstream/rendering/layout-tree.yaml b/docs/reqstream/rendering/layout-tree.yaml index 1cb0118..0945c23 100644 --- a/docs/reqstream/rendering/layout-tree.yaml +++ b/docs/reqstream/rendering/layout-tree.yaml @@ -144,3 +144,14 @@ sections: tests: - Point2D_Construction_StoresXY - Rect_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-ShapeGeometryHints + title: >- + A placed box shall be able to carry optional resolved shape-geometry hints for a rounded + corner radius and a folder tab's width and height. + justification: | + Once layout has produced placed boxes, both connector routing and rendering operate from the + layout tree rather than the original input graph. Carrying any optional resolved shape + geometry on the placed box lets both later stages agree on the same physical outline. + tests: + - LayoutBox_Construction_StoresAllFields diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 7ae2f11..0d157b3 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -57,7 +57,7 @@ Installing a renderer or the layout package transitively brings in the model and `ContainmentLayoutAlgorithm` (id `"containment"`), and the recursive `HierarchicalLayoutAlgorithm` (id `"hierarchical"`). - **`LayoutEngine`** — the batteries-included facade (in `DemaConsulting.Rendering.Layout`). One call, - `LayoutEngine.Layout(graph, options)`, lays out a graph with the algorithm it declares, resolved from + `LayoutEngine.Layout(graph)`, lays out a graph with the algorithm it declares, resolved from the bundled algorithms. It handles both flat and nested graphs (see *Quickstart* below). - **`LayoutTree`** — the *placed* result: boxes with absolute coordinates and orthogonally routed connectors. @@ -89,7 +89,7 @@ graph.AddEdge("a-b", a, b); graph.AddEdge("b-c", b, c); // 2. Lay it out with whatever algorithm the graph declares (default: hierarchical). -var tree = LayoutEngine.Layout(graph, new LayoutOptions()); +var tree = LayoutEngine.Layout(graph); // 3. Render the placed tree to SVG. var renderer = new SvgRenderer(); @@ -106,7 +106,7 @@ the quickstart above lays the chain out exactly as the layered algorithm would ## Choosing a layout algorithm -Select an algorithm by setting `CoreOptions.Algorithm` on the options (or the graph); `LayoutEngine` +Select an algorithm by setting `CoreOptions.Algorithm` directly on the graph; `LayoutEngine` resolves it from the bundled algorithms. The two bundled *leaf* algorithms arrange a flat scope differently: @@ -117,17 +117,19 @@ differently: ```csharp // Pick the layered algorithm explicitly. -var tree = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("layered")); +graph.Set(CoreOptions.Algorithm, "layered"); +var tree = LayoutEngine.Layout(graph); // Or pack the same graph with the containment algorithm instead. -var packed = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("containment")); +graph.Set(CoreOptions.Algorithm, "containment"); +var packed = LayoutEngine.Layout(graph); ``` -`LayoutOptions.ForAlgorithm("layered")` is shorthand for `new LayoutOptions()` with -`CoreOptions.Algorithm` set. A declaration on the graph itself takes precedence over one on the options, -so `graph.Set(CoreOptions.Algorithm, "containment")` selects containment even if the options say -otherwise. If you prefer to skip the facade you can still instantiate an algorithm directly and call -`Apply` — `new LayeredLayoutAlgorithm().Apply(graph, options)` — but `LayoutEngine` is the recommended +The graph is the single place to configure a layout — `LayoutGraph` is itself an `IPropertyHolder`, so +`graph.Set(CoreOptions.Algorithm, "containment")` is all it takes to select an algorithm; there is no +separate options object to keep in sync with it. If you prefer to skip the facade you can still +instantiate an algorithm directly and call `Apply` — `new LayeredLayoutAlgorithm().Apply(graph, new +LayoutOptions())` — but `LayoutEngine` is the recommended entry point because it also handles nesting. ## Describing nested (hierarchical) graphs @@ -171,6 +173,50 @@ The flat *leaf* algorithms (`LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorith top-level nodes and edges, so a flat graph lays out exactly as before and any nesting is carried harmlessly until a hierarchical engine consumes it. +## Selecting box appearance + +A `LayoutGraphNode` also carries the appearance of the box it will be placed as: `Shape` (the +`BoxShape` outline — `Rectangle`, `RoundedRectangle`, `Folder`, or `Note`), `Keyword` (an optional +italicized keyword line shown above the title, e.g. `«part def»`), and `Compartments` (an ordered +list of `LayoutCompartment` feature sections, each with its own title and rows, rendered below the +title with a divider line). This is generic block-diagram notation — useful for any node-and-edge +diagram, not just SysML — and every bundled leaf algorithm plus `HierarchicalLayoutAlgorithm` copy +all three properties, unchanged, onto the placed box, so you select a node's full appearance once +on the input graph: + +```csharp +var graph = new LayoutGraph(); + +// A folder-shaped container node. +var group = graph.AddNode("powertrain", width: 240, height: 220); +group.Label = "Powertrain"; +group.Shape = BoxShape.Folder; + +// A nested leaf node with a keyword and a compartment of feature rows. +var engine = group.Children.AddNode("engine", width: 160, height: 110); +engine.Label = "Engine"; +engine.Keyword = "part def"; +engine.Compartments = [new LayoutCompartment("ports", ["intake", "exhaust"])]; +``` + +Only the node's `Width`/`Height` affect layout placement — a caller is responsible for sizing a node +large enough to fit its keyword line and compartment rows (see `BoxMetrics.TitleAreaHeight` and the +per-compartment row heights in `DemaConsulting.Rendering.Abstractions`); no leaf algorithm grows a box +to fit its own appearance. The [gallery's "Box appearance" diagram](../gallery/gallery.md) shows a +complete, rendered example. + +When a container node (one with `Children`) also carries a `Label`, `HierarchicalLayoutAlgorithm` +reserves a title band above its children so the label never overlaps the nested content. By default +that band is a generic fixed height, sized for a plain label with no keyword; if the container also +sets `Keyword`, or is rendered with a theme whose fonts are larger than the default assumes, set +`TitleHeight` explicitly — typically to `BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: +group.Keyword is not null)` — so the reserved band matches what the renderer will actually draw: + +```csharp +group.Keyword = "package"; +group.TitleHeight = BoxMetrics.TitleAreaHeight(Themes.Light, hasLabel: true, hasKeyword: true); +``` + ## Configuring layout with options Options are set with typed keys from `CoreOptions`. Unknown or not-yet-honored options default @@ -258,6 +304,11 @@ nodes.AddRange(lines); var tree = new LayoutTree(360, 80, nodes); ``` +For folder-shaped boxes, `FolderTabWidth` and `FolderTabHeight` may be supplied together or +individually on each `LayoutBox`. If one value is omitted, `ConnectorRouter` falls back the +missing dimension before choosing anchors so routes still avoid the tab strip and land on the +recessed body top that the renderers draw. + ## Packing boxes into a container When you want to arrange peer boxes inside a container — the members of a package, the contents of a @@ -307,7 +358,8 @@ ab.TargetEnd = EndMarkerStyle.FilledArrow; graph.AddEdge("a-c", a, c); // Pack the nodes into rows and route the edges around them. -var tree = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("containment")); +graph.Set(CoreOptions.Algorithm, "containment"); +var tree = LayoutEngine.Layout(graph); // Render the placed tree with any renderer. var renderer = new SvgRenderer(); @@ -322,9 +374,9 @@ parts — select the bundled `HierarchicalLayoutAlgorithm` (id `"hierarchical"`) `LayoutEngine` uses by default. It is the recursive layout engine: it lays out each container's `Children` in their own coordinate space, sizes the container to enclose them, and composes every sub-layout into one placed tree. It does not place boxes itself; it delegates each scope to a *leaf* -algorithm chosen with `CoreOptions.Algorithm` — the root inherits the option's algorithm (default -`"layered"`), and any container can override it. A graph with no containers is laid out exactly as the -selected leaf algorithm would lay it out, so the engine is a safe drop-in. +algorithm chosen with `CoreOptions.Algorithm` — the root inherits the graph's own declared algorithm +(default `"layered"`), and any container can override it. A graph with no containers is laid out exactly +as the selected leaf algorithm would lay it out, so the engine is a safe drop-in. ```csharp // A container laid out "layered", packed by a "containment" root, plus a sibling leaf. @@ -336,9 +388,10 @@ var c1 = group.Children.AddNode("c1", width: 80, height: 40); var c2 = group.Children.AddNode("c2", width: 80, height: 40); group.Children.AddEdge("c1-c2", c1, c2); // intra-container edge graph.AddNode("outside", width: 80, height: 40); // a sibling leaf at the root +graph.Set(CoreOptions.Algorithm, "containment"); // the root packs with the containment algorithm // Pack the root with the containment algorithm; the container recurses with its own algorithm. -var tree = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("containment")); +var tree = LayoutEngine.Layout(graph); // Render the composed tree with any renderer. var renderer = new SvgRenderer(); @@ -376,7 +429,7 @@ using var output = File.Create(outputPath); renderer.Render(tree, new RenderOptions(Themes.Light), output); ``` -`LayoutEngine.Layout(graph, options, registry)` accepts a custom registry directly, so you can keep the +`LayoutEngine.Layout(graph, registry)` accepts a custom registry directly, so you can keep the one-call convenience while resolving against your own set of algorithms. # Gallery diff --git a/docs/verification/rendering-layout/connector-router.md b/docs/verification/rendering-layout/connector-router.md index a9f1862..de71cd7 100644 --- a/docs/verification/rendering-layout/connector-router.md +++ b/docs/verification/rendering-layout/connector-router.md @@ -34,6 +34,20 @@ out-of-order batch result, or non-argument-null exception for invalid input cons - **Anchors face each other** (`Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther`): `Route_TargetToTheRight_AnchorsFaceEachOther` and `Route_TargetBelow_AnchorsFaceEachOther` confirm the route starts and ends on the box faces that point at the opposing box. +- **Shape-aware anchors** (`Rendering-Layout-ConnectorRouter-ShapeAwareAnchors`): + `Route_FolderTopFace_TabExcludedFromConnectableExtent` confirms a folder top-face anchor never lands + on the tab strip, `Route_FolderTopFace_WidthOnlyHint_UsesFallbackTabHeight` confirms a width-only + folder hint still uses the generic tab-height fallback, + `Route_FolderTopFace_HeightOnlyHint_UsesFallbackTabWidth` confirms a height-only folder hint still + uses the generic tab-width fallback, + `Route_FolderTopFace_ProjectsAnchorToRecessedBodyTop` confirms the final anchor is projected down + to the folder body's recessed top edge, and + `Route_FaceSelectionFallback_EmptyNaturalFaceUsesAdjacentFace` confirms anchor selection falls back + to the next-best usable face when the natural face has no usable extent. +- **Shared-face distribution across usable extents** + (`Rendering-Layout-ConnectorRouter-SharedFaceDistribution`): + `Route_SharedFolderTopFace_DistributesAcrossReducedExtent` confirms a batch of connectors sharing a + folder's top face is spread only across the usable extent to the right of the tab. - **Obstacle avoidance** (`Rendering-Layout-ConnectorRouter-AvoidsObstacles`): `Route_ObstacleBetweenEndpoints_RoutesAroundInterior` confirms the route is orthogonal and never enters an intervening box's interior. @@ -50,10 +64,33 @@ out-of-order batch result, or non-argument-null exception for invalid input cons `Route_NullConnections_Throws`, `Route_NullOptions_Throws`, and `Route_NullConnection_Throws` confirm null arguments are rejected with an argument-null error. +### Additional Regression Coverage + +The following tests guard against a reported defect but are not linked to a requirement here, because +the actual behavior change they exercise (the length-proportional soft-obstacle cost) is owned by, and +already covered under, `OrthogonalEdgeRouter`'s own +`Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap` requirement (see +_OrthogonalEdgeRouter Unit Verification_); these end-to-end tests add confidence at the batch-API +level rather than introducing a new `ConnectorRouter`-level responsibility: + +- `Route_ThreeConnectorsNarrowGap_InteriorCorridorsDoNotOverlapAlongLength`, + `Route_FiveConnectorsNarrowGap_InteriorCorridorsDoNotOverlapAlongLength`, and + `Route_ThreeSourceBoxesNarrowGap_InteriorCorridorsDoNotOverlapAlongLength` reproduce the reported + DictionaryMark defect (a narrow vertical gap between a small box and a much wider one) and confirm + that no two distinct connectors' interior segments ride the same grid line for an extended span. + ### Requirements Coverage - **`Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther`**: Route_TargetToTheRight_AnchorsFaceEachOther, Route_TargetBelow_AnchorsFaceEachOther +- **`Rendering-Layout-ConnectorRouter-ShapeAwareAnchors`**: + Route_FolderTopFace_TabExcludedFromConnectableExtent, + Route_FolderTopFace_WidthOnlyHint_UsesFallbackTabHeight, + Route_FolderTopFace_HeightOnlyHint_UsesFallbackTabWidth, + Route_FolderTopFace_ProjectsAnchorToRecessedBodyTop, + Route_FaceSelectionFallback_EmptyNaturalFaceUsesAdjacentFace +- **`Rendering-Layout-ConnectorRouter-SharedFaceDistribution`**: + Route_SharedFolderTopFace_DistributesAcrossReducedExtent - **`Rendering-Layout-ConnectorRouter-AvoidsObstacles`**: Route_ObstacleBetweenEndpoints_RoutesAroundInterior - **`Rendering-Layout-ConnectorRouter-ExcludesEndpoints`**: diff --git a/docs/verification/rendering-layout/default-layout.md b/docs/verification/rendering-layout/default-layout.md index 094be99..dc70976 100644 --- a/docs/verification/rendering-layout/default-layout.md +++ b/docs/verification/rendering-layout/default-layout.md @@ -29,8 +29,8 @@ leaf algorithm applied directly to guarantee byte-identical behavior for existin A verification run passes when every named scenario below asserts without unexpected exception, and the referenced tests cover each `Rendering-Layout-DefaultRegistry-*` and `Rendering-Layout-LayoutEngine-*` requirement. Any drift in the bundled algorithm set, in the -default algorithm identifier (`"hierarchical"`), in the resolution precedence (graph over options -over default), in the flat-graph equivalence guarantee, in nested composition, or in the argument- +default algorithm identifier (`"hierarchical"`), in the resolution against an explicit graph-level +declaration, in the flat-graph equivalence guarantee, in nested composition, or in the argument- null validation behavior constitutes a failure. ### Test Scenarios @@ -46,13 +46,12 @@ null validation behavior constitutes a failure. `Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly` confirms an undeclared flat graph is laid out through the hierarchical engine to a result identical to the layered leaf algorithm. - **Resolution** (`Rendering-Layout-LayoutEngine-Resolution`): - `Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly` and - `Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly` confirm an explicit options - declaration selects that algorithm; `Layout_GraphDeclarationOverridesOptions` confirms a graph-level - declaration takes precedence over the options. + `Layout_GraphDeclaresLayered_MatchesLayeredAlgorithmExactly` and + `Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly` confirm an explicit graph-level + declaration selects that algorithm, applied via the resolved algorithm's `Apply`. - **Flat equivalence** (`Rendering-Layout-LayoutEngine-FlatEquivalence`): `Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly` and - `Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly` deep-compare the facade's output + `Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly` deep-compare the facade's output bit-for-bit with the leaf algorithm applied directly, proving the facade changes no existing output. - **Nested composition** (`Rendering-Layout-LayoutEngine-NestedComposition`): `Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree` confirms an undeclared nested graph is @@ -61,9 +60,8 @@ null validation behavior constitutes a failure. `Layout_CustomRegistry_ResolvesRegisteredAlgorithm` confirms a caller-supplied registry's algorithm is resolved and applied; `Layout_UnregisteredAlgorithm_Throws` confirms an unknown identifier surfaces a key-not-found error. -- **Validation** (`Rendering-Layout-LayoutEngine-Validation`): `Layout_NullGraph_Throws`, - `Layout_NullOptions_Throws`, and `Layout_NullRegistry_Throws` confirm null arguments are rejected with - an argument-null error. +- **Validation** (`Rendering-Layout-LayoutEngine-Validation`): `Layout_NullGraph_Throws` and + `Layout_NullRegistry_Throws` confirm null arguments are rejected with an argument-null error. ### Requirements Coverage @@ -74,14 +72,14 @@ null validation behavior constitutes a failure. - **`Rendering-Layout-LayoutEngine-DefaultAlgorithm`**: DefaultAlgorithmId_IsHierarchical, Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly - **`Rendering-Layout-LayoutEngine-Resolution`**: - Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly, - Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly, Layout_GraphDeclarationOverridesOptions + Layout_GraphDeclaresLayered_MatchesLayeredAlgorithmExactly, + Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly - **`Rendering-Layout-LayoutEngine-FlatEquivalence`**: Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly, - Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly + Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly - **`Rendering-Layout-LayoutEngine-NestedComposition`**: Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree - **`Rendering-Layout-LayoutEngine-CustomRegistry`**: Layout_CustomRegistry_ResolvesRegisteredAlgorithm, Layout_UnregisteredAlgorithm_Throws - **`Rendering-Layout-LayoutEngine-Validation`**: - Layout_NullGraph_Throws, Layout_NullOptions_Throws, Layout_NullRegistry_Throws + Layout_NullGraph_Throws, Layout_NullRegistry_Throws diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index 44211bf..04346f1 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -120,6 +120,22 @@ failure. - **Input validation** (`Rendering-Layout-LayeredPipeline-InputValidation`): `LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException` and `LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException`. +- **Shape-aware anchors** (`Rendering-Layout-LayeredPipeline-ShapeAwareAnchors`): + `PortDistributor_Apply_FolderTargetTopFace_Down_PortExcludesTabRegion`, + `PortDistributor_Apply_NoteSourceRightFace_Right_PortExcludesFoldRegion`, and + `PortDistributor_Apply_RectangleNode_MatchesPlainFullSpanFormula` (port-distribution stage); + `LongEdgeJoiner_Apply_FolderTargetTopFace_Down_ProjectsInwardByTabHeight`, + `LongEdgeJoiner_Apply_FolderSourceTopFace_Up_ProjectsInwardByTabHeight`, + `LongEdgeJoiner_Apply_FolderNode_Right_NonTopFaceUnaffected`, + `LongEdgeJoiner_Apply_FolderNode_Left_NonTopFaceUnaffected`, and + `LongEdgeJoiner_Apply_RectangleNode_MatchesPlainFormula` (long-edge-joining stage); and the + full-pipeline (`LayeredLayoutAlgorithm`) confirmations + `Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop`, + `Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop`, + `Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab`, + `Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab`, + `Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone`, and + `Apply_RectangleChain_MatchesPreShapeAwarenessOutput`. #### Requirements Coverage @@ -182,3 +198,18 @@ failure. - **`Rendering-Layout-LayeredPipeline-InputValidation`**: LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException, LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException +- **`Rendering-Layout-LayeredPipeline-ShapeAwareAnchors`**: + PortDistributor_Apply_FolderTargetTopFace_Down_PortExcludesTabRegion, + PortDistributor_Apply_NoteSourceRightFace_Right_PortExcludesFoldRegion, + PortDistributor_Apply_RectangleNode_MatchesPlainFullSpanFormula, + LongEdgeJoiner_Apply_FolderTargetTopFace_Down_ProjectsInwardByTabHeight, + LongEdgeJoiner_Apply_FolderSourceTopFace_Up_ProjectsInwardByTabHeight, + LongEdgeJoiner_Apply_FolderNode_Right_NonTopFaceUnaffected, + LongEdgeJoiner_Apply_FolderNode_Left_NonTopFaceUnaffected, + LongEdgeJoiner_Apply_RectangleNode_MatchesPlainFormula, + Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop, + Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop, + Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab, + Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab, + Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone, + Apply_RectangleChain_MatchesPreShapeAwarenessOutput diff --git a/docs/verification/rendering-layout/engine/orthogonal-edge-router.md b/docs/verification/rendering-layout/engine/orthogonal-edge-router.md index 8c8c726..89c1032 100644 --- a/docs/verification/rendering-layout/engine/orthogonal-edge-router.md +++ b/docs/verification/rendering-layout/engine/orthogonal-edge-router.md @@ -52,6 +52,14 @@ supplied side, incorrect `Crossed` flag, or lost cost-band bias constitutes a fa - **Cost bands** (`Rendering-Layout-OrthogonalEdgeRouter-CostBands`): `RouteWithStatus_HighwayBand_PrefersBandedDetour` confirms the router prefers a discounted band over an equal-length alternative. +- **No redundant waypoint revisit** (`Rendering-Layout-OrthogonalEdgeRouter-NoWaypointRevisit`): + `RouteWithStatus_SoftObstacleDetour_DoesNotRevisitWaypoint` confirms a soft-obstacle-driven detour + does not publish a leave-and-return loop that revisits one waypoint before continuing. +- **No extended soft-obstacle overlap** (`Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap`): + `RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane` confirms the router detours to an + alternate lane instead of riding a soft obstacle that occupies the natural corridor for an extended + span; `RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute` confirms a short, incidental + overlap still stays cheaper than the detour and is tolerated as before. #### Requirements Coverage @@ -68,3 +76,7 @@ supplied side, incorrect `Crossed` flag, or lost cost-band bias constitutes a fa RouteWithStatus_NoBlockingObstacle_ReportsNotCrossed, RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed - **`Rendering-Layout-OrthogonalEdgeRouter-CostBands`**: RouteWithStatus_HighwayBand_PrefersBandedDetour +- **`Rendering-Layout-OrthogonalEdgeRouter-NoWaypointRevisit`**: + RouteWithStatus_SoftObstacleDetour_DoesNotRevisitWaypoint +- **`Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap`**: + RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane, RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute diff --git a/docs/verification/rendering-layout/layered-layout-algorithm.md b/docs/verification/rendering-layout/layered-layout-algorithm.md index e2be349..cf01cd1 100644 --- a/docs/verification/rendering-layout/layered-layout-algorithm.md +++ b/docs/verification/rendering-layout/layered-layout-algorithm.md @@ -25,8 +25,8 @@ production code paths. A verification run passes when every named scenario below asserts without unexpected exception, and the referenced tests cover each `Rendering-Layout-LayeredAlgorithm-*` requirement. Any drift in the stable identifier (`"layered"`), in the one-box-per-node / one-line-per-edge placement contract, in -empty-graph handling, in flow-direction honoring, or in the argument-null validation behavior -constitutes a failure. +empty-graph handling, in flow-direction honoring, in shape-aware endpoint projection/exclusion, or in +the argument-null validation behavior constitutes a failure. ### Test Scenarios @@ -47,6 +47,16 @@ constitutes a failure. - **Validation** (`Rendering-Layout-LayeredAlgorithm-Validation`): `Apply_NullGraph_Throws` confirms a null graph argument is rejected with an argument-null error, and `Apply_NullOptions_Throws` confirms a null options argument is likewise rejected with an argument-null error. +- **Shape-aware routing** (`Rendering-Layout-LayeredAlgorithm-ShapeAwareRouting`): + `Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop` and + `Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop` confirm a `BoxShape.Folder` node's + routed endpoint on its real Top face is projected inward to the recessed body outline; + `Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab` and + `Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab` confirm the folder tab never spuriously + affects a face other than the real Top face; `Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone` + confirms a `BoxShape.Note`'s folded-corner strip is excluded from the connector's landing zone; and + `Apply_RectangleChain_MatchesPreShapeAwarenessOutput` pins the exact placement and routing of a + plain-`Rectangle` chain to prove the new shape-aware code never engages for the default shape. ### Requirements Coverage @@ -61,3 +71,10 @@ constitutes a failure. Apply_DownDirectionOnGraphScope_IsHonored, Apply_DefaultDirection_FlowsLeftToRight - **`Rendering-Layout-LayeredAlgorithm-Validation`**: Apply_NullGraph_Throws, Apply_NullOptions_Throws +- **`Rendering-Layout-LayeredAlgorithm-ShapeAwareRouting`**: + Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop, + Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop, + Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab, + Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab, + Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone, + Apply_RectangleChain_MatchesPreShapeAwarenessOutput diff --git a/docs/verification/rendering/layout-graph.md b/docs/verification/rendering/layout-graph.md index 46fa848..cb00789 100644 --- a/docs/verification/rendering/layout-graph.md +++ b/docs/verification/rendering/layout-graph.md @@ -89,6 +89,17 @@ edge is expressible at the lowest common ancestor, including the sibling-contain **Covers**: `Rendering-Model-LayoutGraph-CrossContainerEdge`. +#### Shape-geometry hints are stored and propagated + +Test `LayoutGraphNode_ShapeGeometryHints_DefaultNullAndSettable` confirms that the optional rounded +corner and folder-tab geometry hints default to null and preserve explicit values on the input node. +The propagation tests `Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPlacedBox`, +`Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPackedBox`, and +`Apply_NestedGraph_PropagatesContainerAndLeafShapeKeywordCompartments` then confirm the layered, +containment, and hierarchical layout paths all copy those hints onto their placed boxes unchanged. + +**Covers**: `Rendering-Model-LayoutGraph-ShapeGeometryHints`. + ### Requirements Coverage - **`Rendering-Model-LayoutGraph-AddNode`**: AddNode_AppendsNodeAndReturnsIt @@ -104,3 +115,8 @@ edge is expressible at the lowest common ancestor, including the sibling-contain - **`Rendering-Model-LayoutGraph-CrossContainerEdge`**: LayoutGraphEdge_CrossContainer_ReferencingDescendant_ConstructibleAtRoot, LayoutGraphEdge_CrossContainer_BetweenSiblingContainers_ConstructibleAtRoot +- **`Rendering-Model-LayoutGraph-ShapeGeometryHints`**: + LayoutGraphNode_ShapeGeometryHints_DefaultNullAndSettable, + Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPlacedBox, + Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPackedBox, + Apply_NestedGraph_PropagatesContainerAndLeafShapeKeywordCompartments diff --git a/docs/verification/rendering/layout-tree.md b/docs/verification/rendering/layout-tree.md index 5fa4d09..5c93324 100644 --- a/docs/verification/rendering/layout-tree.md +++ b/docs/verification/rendering/layout-tree.md @@ -138,6 +138,14 @@ and returns its supplied coordinates and bounds unchanged. **Covers**: `Rendering-Model-LayoutTree-Geometry`. +#### Box carries optional shape-geometry hints + +Test `LayoutBox_Construction_StoresAllFields` confirms that a placed box preserves the optional +rounded-corner and folder-tab geometry hints alongside its existing position, shape, compartments, and +children, so downstream routing and rendering stages can observe the resolved shape outline geometry. + +**Covers**: `Rendering-Model-LayoutTree-ShapeGeometryHints`. + ### Requirements Coverage - **`Rendering-Model-LayoutTree-Canvas`**: LayoutTree_Construction_StoresWidthHeightNodes @@ -156,3 +164,5 @@ and returns its supplied coordinates and bounds unchanged. - **`Rendering-Model-LayoutTree-Grid`**: LayoutGrid_Construction_StoresAllFields - **`Rendering-Model-LayoutTree-Geometry`**: Point2D_Construction_StoresXY, Rect_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-ShapeGeometryHints`**: + LayoutBox_Construction_StoresAllFields diff --git a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs index 995ef94..761825e 100644 --- a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs +++ b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs @@ -2,6 +2,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using DemaConsulting.Rendering.Abstractions; using DemaConsulting.Rendering.Layout.Engine; namespace DemaConsulting.Rendering.Layout; @@ -71,9 +72,16 @@ public sealed record ConnectorRouteOptions( /// /// Source and target anchors are chosen on the box faces that front each other, based on the boxes' /// relative placement rather than the direction to a possibly far-off centre, and are aligned to the -/// overlap of the boxes on the shared edge. Connectors therefore leave and arrive on the sides the two -/// boxes actually present to each other, without wrapping back across a wide endpoint box. The chosen -/// side is passed to the underlying router so the connector exits and enters perpendicular to the edge. +/// overlap of the boxes on the shared edge. When a face is long enough, that overlap-based coordinate +/// is kept at least away from both ends of the face so +/// a slight shared span near one corner does not pin the connector visually against that corner. Each +/// face is then interpreted through the box shape's own routing geometry: only its connectable +/// sub-ranges may be used, an unusable natural face falls back to the next-best adjacent face, and the +/// final anchor is projected inward from the bounding box to the real outline when the shape is +/// recessed there (for example the body top of a folder below its tab). Connectors therefore leave and +/// arrive on the sides the two boxes actually present to each other, without wrapping back across a +/// wide endpoint box or anchoring on a shape's non-connectable outline detail. The chosen side is +/// passed to the underlying router so the connector exits and enters perpendicular to the edge. /// /// /// Today the only supported style is , realized by the @@ -128,6 +136,19 @@ public static class ConnectorRouter /// Thrown when names a style that has no shipped /// router. /// + /// + /// Prefer this overload over calling the single-connection + /// once per connector whenever several connectors may land on the same box face (for example many + /// cross-package edges converging on one shared box). picks each + /// connector's anchor independently from its own pair of boxes; when two boxes do not overlap on the + /// face's axis, that per-pair calculation can clamp to the same boundary point for every connector + /// sharing the face, making them visually collapse into one another. Routing as a batch lets this + /// method detect a face shared by more than one connector and spread their anchors evenly across it + /// (see ) before any obstacle-avoiding path is computed. It + /// also routes connectors one at a time, adding each already-routed line's own path as a soft + /// obstacle for the ones that follow, so parallel connectors bound for nearby anchors fan out into + /// separate corridors instead of overlapping along a shared trunk. + /// public static IReadOnlyList Route( IReadOnlyList boxes, IReadOnlyList connections, @@ -137,10 +158,34 @@ public static IReadOnlyList Route( ArgumentNullException.ThrowIfNull(connections); ArgumentNullException.ThrowIfNull(options); + var anchors = new FaceAnchors[connections.Count]; + for (var i = 0; i < connections.Count; i++) + { + var connection = connections[i]; + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(connection.From); + ArgumentNullException.ThrowIfNull(connection.To); + + var (sourceAlong, sourceSide, targetAlong, targetSide) = FacingAnchors(connection.From, connection.To, options.Clearance); + anchors[i] = new FaceAnchors(sourceAlong, sourceSide, targetAlong, targetSide); + } + + DistributeSharedFaceAnchors(connections, anchors, options.Clearance); + + // Route one connector at a time, growing a soft-obstacle set with each already-routed line (as + // a thin rectangle per segment) so later connectors prefer a free lane over one already used by + // an earlier connector. These are soft (cost-penalized), not hard, obstacles: several connectors + // converging on the same box face legitimately share their final short approach corridor, and + // hard-blocking it would make that shared face unreachable for every connector after the first + // (see AddLineObstacles remarks). var lines = new List(connections.Count); - foreach (var connection in connections) + var routedLineObstacles = new List(); + foreach (var t in anchors) { - lines.Add(Route(boxes, connection, options)); + var index = lines.Count; + var line = RouteWithAnchors(boxes, connections[index], options, t, routedLineObstacles); + lines.Add(line); + AddLineObstacles(line.Waypoints, routedLineObstacles); } return lines; @@ -165,6 +210,14 @@ public static IReadOnlyList Route( /// Thrown when names a style that has no shipped /// router. /// + /// + /// This overload considers only 's own two boxes when picking anchors, + /// so it cannot spread anchors across a box face that also receives other connectors routed by + /// separate calls, nor steer around any other connector's path. When routing several connectors that + /// may share a target (or source) box, or may run parallel to one another, prefer the batch + /// + /// overload instead. + /// public static LayoutLine Route( IReadOnlyList boxes, Connection connection, @@ -176,17 +229,39 @@ public static LayoutLine Route( ArgumentNullException.ThrowIfNull(connection.From); ArgumentNullException.ThrowIfNull(connection.To); - var from = connection.From; - var to = connection.To; - // Pick anchors on the faces the two boxes actually present to each other, based on their // relative placement. Using the direction to the other box's centre misfires for wide boxes // (whose centre can sit far past the near endpoint), forcing the connector to wrap back across a // box; deriving the facing sides from the box rectangles avoids that. - var (source, sourceSide, target, targetSide) = FacingAnchors(from, to); + var (sourceAlong, sourceSide, targetAlong, targetSide) = FacingAnchors(connection.From, connection.To, options.Clearance); + + return RouteWithAnchors( + boxes, + connection, + options, + new FaceAnchors(sourceAlong, sourceSide, targetAlong, targetSide), + []); + } + + /// + /// Builds the obstacle set for — every other box, plus any already + /// routed treated as soft (cost-penalized, never hard-blocking) + /// obstacles — and routes it between the already chosen . + /// + private static LayoutLine RouteWithAnchors( + IReadOnlyList boxes, + Connection connection, + ConnectorRouteOptions options, + FaceAnchors anchors, + IReadOnlyList extraSoftObstacles) + { + var from = connection.From; + var to = connection.To; + var source = ResolveAnchorPoint(from, anchors.SourceSide, anchors.SourceAlong, options.Clearance); + var target = ResolveAnchorPoint(to, anchors.TargetSide, anchors.TargetAlong, options.Clearance); - // The obstacle set is every box except this connection's own endpoints, matched by instance - // identity. The connector must be free to leave and enter the boxes it joins. + // The hard obstacle set is every box except this connection's own endpoints, matched by + // instance identity. The connector must be free to leave and enter the boxes it joins. var obstacles = new List(boxes.Count); foreach (var box in boxes) { @@ -198,7 +273,8 @@ public static LayoutLine Route( obstacles.Add(new Rect(box.X, box.Y, box.Width, box.Height)); } - var waypoints = RouteWaypoints(options.EdgeRouting, source, target, obstacles, options.Clearance, sourceSide, targetSide); + var waypoints = RouteWaypoints( + options.EdgeRouting, source, target, obstacles, options.Clearance, anchors.SourceSide, anchors.TargetSide, extraSoftObstacles); return new LayoutLine( Waypoints: waypoints, @@ -208,6 +284,649 @@ public static LayoutLine Route( MidpointLabel: connection.Label); } + /// + /// Appends a thin rectangle obstacle for every interior orthogonal segment of + /// to , so a subsequently routed + /// connector treats this already-routed line as something to steer clear of (by the router's usual + /// obstacle clearance) rather than freely overlapping it. + /// + /// + /// The first and last segments are intentionally excluded. Those short endpoint-adjacent approach + /// legs are the one place where several connectors may legitimately share the same corridor while + /// still reaching a common face cleanly; penalizing them as soft obstacles is what causes the + /// redundant leave-and-return detours this method now avoids. + /// + private static void AddLineObstacles(IReadOnlyList waypoints, List obstacles) + { + // A hairline thickness is enough: the router already keeps a clearance gap between paths and + // any obstacle, so a wide slab here would only double up on that margin unnecessarily. + const double halfThickness = 1.0; + + for (var i = 0; i < waypoints.Count - 1; i++) + { + if (i == 0 || i == waypoints.Count - 2) + { + continue; + } + + var a = waypoints[i]; + var b = waypoints[i + 1]; + + if (Math.Abs(a.Y - b.Y) < 1e-6) + { + // Horizontal segment. + var x = Math.Min(a.X, b.X); + var width = Math.Abs(b.X - a.X); + if (width < 1e-6) + { + continue; + } + + obstacles.Add(new Rect(x, a.Y - halfThickness, width, 2.0 * halfThickness)); + } + else if (Math.Abs(a.X - b.X) < 1e-6) + { + // Vertical segment. + var y = Math.Min(a.Y, b.Y); + var height = Math.Abs(b.Y - a.Y); + if (height < 1e-6) + { + continue; + } + + obstacles.Add(new Rect(a.X - halfThickness, y, 2.0 * halfThickness, height)); + } + } + } + + /// + /// The chosen source and target face coordinates and box sides for one connection, prior to + /// obstacle-avoiding path computation. The along-axis coordinates are stored in the owning box's + /// local face coordinate system (0 at the face start, increasing toward the face end) so shared-face + /// redistribution can work directly against shape-aware connectable extents. + /// + private readonly record struct FaceAnchors(double SourceAlong, PortSide SourceSide, double TargetAlong, PortSide TargetSide); + + /// + /// One connector's claim on a shared box face: which connection it belongs to, whether it is the + /// connection's source or target end, and the position (its counterpart box's centre on the face's + /// axis) used to order claims before spreading them evenly across the face. + /// + private readonly record struct FaceSlot(int ConnectionIndex, bool IsSource, double Counterpart); + + /// + /// Redistributes anchors for any box face claimed by more than one connector in this batch, so + /// co-terminating connectors land at evenly spaced points along the face instead of each + /// independently clamping to the same boundary point (see ). + /// + /// The connections being routed, in the same order as . + /// + /// The naive per-connection anchors computed by ; updated in place for any + /// connector whose anchor is moved to share its face fairly with others. The redistribution spans + /// the union of the face's connectable extents rather than the full bounding-box edge. + /// + /// Minimum inset kept between a redistributed anchor and the ends of the face. + private static void DistributeSharedFaceAnchors( + IReadOnlyList connections, + FaceAnchors[] anchors, + double clearance) + { + var groups = new Dictionary<(LayoutBox Box, PortSide Side), List>(FaceKeyComparer.Instance); + + for (var i = 0; i < connections.Count; i++) + { + var connection = connections[i]; + var faceAnchors = anchors[i]; + + AddSlot(groups, connection.From, faceAnchors.SourceSide, i, isSource: true, CounterpartCentre(connection.To, faceAnchors.SourceSide)); + AddSlot(groups, connection.To, faceAnchors.TargetSide, i, isSource: false, CounterpartCentre(connection.From, faceAnchors.TargetSide)); + } + + foreach (var (key, slots) in groups) + { + if (slots.Count < 2) + { + // A face claimed by a single connector already has the best anchor FacingAnchors could + // compute for that pair of boxes; leave it untouched. + continue; + } + + var (box, side) = key; + var geometry = ResolveShapeGeometry(box); + var usableExtents = BuildUsableExtents(geometry.GetConnectableExtents(side), clearance); + if (usableExtents.Count == 0) + { + continue; + } + + var usableLength = TotalExtentLength(usableExtents); + var ordered = slots.OrderBy(s => s.Counterpart).ThenBy(s => s.ConnectionIndex).ToList(); + var count = ordered.Count; + + for (var k = 0; k < count; k++) + { + var distance = count == 1 ? usableLength / 2.0 : k * usableLength / (count - 1); + var along = CoordinateAtDistance(usableExtents, distance); + var slot = ordered[k]; + var current = anchors[slot.ConnectionIndex]; + anchors[slot.ConnectionIndex] = slot.IsSource + ? current with { SourceAlong = along } + : current with { TargetAlong = along }; + } + } + } + + /// Records one connector's claim on a shared box face for later distribution. + private static void AddSlot( + Dictionary<(LayoutBox Box, PortSide Side), List> groups, + LayoutBox box, + PortSide side, + int connectionIndex, + bool isSource, + double counterpartCentre) + { + var key = (box, side); + if (!groups.TryGetValue(key, out var slots)) + { + slots = []; + groups[key] = slots; + } + + slots.Add(new FaceSlot(connectionIndex, isSource, counterpartCentre)); + } + + /// + /// The centre of on the axis perpendicular to , + /// used to order connectors sharing a face by where their counterpart box sits. + /// + private static double CounterpartCentre(LayoutBox box, PortSide faceSide) => + faceSide is PortSide.Left or PortSide.Right + ? box.Y + (box.Height / 2.0) + : box.X + (box.Width / 2.0); + + /// + /// Insets each connectable face segment by up to at both ends so + /// redistributed anchors keep the same breathing room from the face edges that plain rectangular + /// distribution already used. When that inset would collapse every segment to a point, the original + /// extents are used instead so a reduced-but-still-usable face does not become artificially empty. + /// + /// + /// Also consumed internally by the LayeredPipeline unit's PortDistributor stage so + /// shaped nodes routed through LayeredLayoutAlgorithm get the same connectable-extent + /// restriction as -routed edges. + /// + internal static IReadOnlyList<(double Lo, double Hi)> BuildUsableExtents( + IReadOnlyList<(double Lo, double Hi)> extents, + double clearance) + { + var insetExtents = new List<(double Lo, double Hi)>(extents.Count); + foreach (var (lo, hi) in extents) + { + if (hi < lo) + { + continue; + } + + var inset = Math.Min(clearance, (hi - lo) / 2.0); + insetExtents.Add((lo + inset, hi - inset)); + } + + if (TotalExtentLength(insetExtents) > 1e-9) + { + return insetExtents; + } + + var fallback = new List<(double Lo, double Hi)>(extents.Count); + foreach (var (lo, hi) in extents) + { + if (hi >= lo) + { + fallback.Add((lo, hi)); + } + } + + return fallback; + } + + /// + /// Returns the total length covered by a set of local face extents. + /// + /// + /// Also consumed internally by the LayeredPipeline unit's PortDistributor stage. + /// + internal static double TotalExtentLength(IReadOnlyList<(double Lo, double Hi)> extents) + { + var total = 0.0; + foreach (var (lo, hi) in extents) + { + total += Math.Max(0.0, hi - lo); + } + + return total; + } + + /// + /// Returns the local face coordinate lying units along the concatenated + /// union of . + /// + /// + /// Also consumed internally by the LayeredPipeline unit's PortDistributor stage. + /// + internal static double CoordinateAtDistance(IReadOnlyList<(double Lo, double Hi)> extents, double distance) + { + ArgumentOutOfRangeException.ThrowIfNegative(distance); + + var remaining = distance; + for (var i = 0; i < extents.Count; i++) + { + var (lo, hi) = extents[i]; + var length = Math.Max(0.0, hi - lo); + if (remaining <= length || i == extents.Count - 1) + { + return lo + Math.Min(remaining, length); + } + + remaining -= length; + } + + return 0.0; + } + + /// + /// Matches box faces by the source box's instance identity rather than 's + /// record value-equality, so two distinct boxes that happen to share the same geometry are not + /// merged into a single face group. + /// + private sealed class FaceKeyComparer : IEqualityComparer<(LayoutBox Box, PortSide Side)> + { + public static readonly FaceKeyComparer Instance = new(); + + public bool Equals((LayoutBox Box, PortSide Side) x, (LayoutBox Box, PortSide Side) y) => + ReferenceEquals(x.Box, y.Box) && x.Side == y.Side; + + public int GetHashCode((LayoutBox Box, PortSide Side) obj) => + HashCode.Combine(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj.Box), obj.Side); + } + + /// + /// Small inset applied immediately to the right of a folder tab so a top-face anchor never lands + /// exactly on the tab's vertical shoulder. + /// + private const double FolderTopFaceMargin = 1.0; + + /// + /// Describes the parts of a box face that connectors may use and how a point chosen on the bounding + /// box projects inward to the shape's real outline. + /// + /// + /// Current shipped shapes use constant per-face projections, but + /// intentionally receives the along-face coordinate so a future shape can vary its projection across + /// the face (for example a sloped or curved shoulder) without changing the router's calling + /// contract. + /// + /// + /// Also consumed internally by the LayeredPipeline unit's PortDistributor and + /// LongEdgeJoiner stages so shaped nodes routed through LayeredLayoutAlgorithm get the + /// same connectable-extent restriction and inward surface projection as + /// -routed edges. + /// + internal interface IBoxShapeGeometry + { + /// + /// Returns the local along-face sub-ranges, in logical pixels, where connectors may anchor on + /// . Coordinates are measured from the face start (top-to-bottom on left + /// and right faces; left-to-right on top and bottom faces). An empty list means the face is + /// unusable for anchoring. + /// + IReadOnlyList<(double Lo, double Hi)> GetConnectableExtents(PortSide side); + + /// + /// Returns the inward perpendicular offset, in logical pixels, from the bounding-box face on + /// to the real shape outline at the given local along-face coordinate. + /// A value of zero means the bounding box already lies on the drawn outline. + /// + double ProjectToSurface(PortSide side, double alongAxisCoordinate); + } + + /// + /// Shared base for the shipped box-shape geometries. + /// + internal abstract class BoxShapeGeometryBase : IBoxShapeGeometry + { + /// + /// Gets the width of the owning box, used when resolving top and bottom face extents. + /// + protected double Width { get; } + + /// + /// Gets the height of the owning box, used when resolving left and right face extents. + /// + protected double Height { get; } + + /// + /// Initializes a new instance of the class. + /// + protected BoxShapeGeometryBase(LayoutBox box) + { + Width = box.Width; + Height = box.Height; + } + + /// + public abstract IReadOnlyList<(double Lo, double Hi)> GetConnectableExtents(PortSide side); + + /// + public abstract double ProjectToSurface(PortSide side, double alongAxisCoordinate); + + /// + /// Returns a single full-length extent for . + /// + protected IReadOnlyList<(double Lo, double Hi)> FullExtent(PortSide side) => + [(0.0, BoxFaceLength(side))]; + + /// + /// Returns the local length of . + /// + protected double BoxFaceLength(PortSide side) => + side is PortSide.Left or PortSide.Right ? Height : Width; + + /// + /// Builds a single connectable segment inset equally from both ends of the given face length. + /// If the inset consumes the whole face, the remaining usable anchor point collapses to the face + /// midpoint instead of becoming invalid. + /// + protected static IReadOnlyList<(double Lo, double Hi)> InsetExtent(double faceLength, double inset) + { + var boundedInset = Math.Min(Math.Max(0.0, inset), faceLength / 2.0); + return [(boundedInset, faceLength - boundedInset)]; + } + } + + /// + /// Rectangle geometry: every face is fully usable and the bounding box already lies on the drawn + /// outline. + /// + internal sealed class RectangleGeometry : BoxShapeGeometryBase + { + public RectangleGeometry(LayoutBox box) + : base(box) + { + } + + public override IReadOnlyList<(double Lo, double Hi)> GetConnectableExtents(PortSide side) => FullExtent(side); + + public override double ProjectToSurface(PortSide side, double alongAxisCoordinate) => 0.0; + } + + /// + /// Rounded-rectangle geometry: each face is usable only between the two corner arcs. + /// + internal sealed class RoundedRectangleGeometry : BoxShapeGeometryBase + { + private readonly double _cornerRadius; + + public RoundedRectangleGeometry(LayoutBox box) + : base(box) + { + _cornerRadius = Math.Max(0.0, box.RoundedCornerRadius ?? 0.0); + } + + public override IReadOnlyList<(double Lo, double Hi)> GetConnectableExtents(PortSide side) => + InsetExtent(BoxFaceLength(side), _cornerRadius); + + public override double ProjectToSurface(PortSide side, double alongAxisCoordinate) => 0.0; + } + + /// + /// Small inset applied immediately beside a note's folded corner so a face anchor never lands + /// exactly on the fold's edge. + /// + private const double NoteFoldMargin = 1.0; + + /// + /// Note geometry: the top-right corner is cut by a diagonal fold, so the affected portions of the + /// top face (near the right edge) and the right face (near the top edge) are excluded from the + /// connectable extent. The remaining extents lie exactly on the bounding box, matching the + /// rounded-rectangle pattern, so no surface projection offset is needed. + /// + internal sealed class NoteGeometry : BoxShapeGeometryBase + { + private readonly double _fold; + + public NoteGeometry(LayoutBox box) + : base(box) + { + _fold = Math.Min(Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction, NotationMetrics.NoteFoldMaxSize); + } + + public override IReadOnlyList<(double Lo, double Hi)> GetConnectableExtents(PortSide side) + { + if (_fold <= 0.0) + { + return FullExtent(side); + } + + switch (side) + { + case PortSide.Top: + // The fold cuts diagonally from (Width - fold, 0) to (Width, fold); only the + // portion left of the fold's near edge still touches the real top outline. + var topEnd = Math.Max(0.0, Width - _fold - NoteFoldMargin); + return topEnd <= 0.0 ? [] : [(0.0, topEnd)]; + + case PortSide.Right: + // The same diagonal cut removes the topmost portion of the right face. + var rightStart = Math.Min(Height, _fold + NoteFoldMargin); + return rightStart >= Height ? [] : [(rightStart, Height)]; + + default: + return FullExtent(side); + } + } + + public override double ProjectToSurface(PortSide side, double alongAxisCoordinate) => 0.0; + } + + /// + /// Folder geometry: the top-left tab occupies part of the top bounding edge, so that portion is + /// excluded from the connectable extent and the remaining top-face anchors project down to the + /// body's recessed top edge. + /// + internal sealed class FolderGeometry : BoxShapeGeometryBase + { + private readonly double _tabHeight; + private readonly double _tabWidth; + + public FolderGeometry(LayoutBox box) + : base(box) + { + _tabHeight = ResolveFolderTabHeight(box); + _tabWidth = ResolveFolderTabWidth(box); + } + + /// + /// Resolves the folder-tab width used by routing, honoring a caller-supplied hint and + /// otherwise falling back to the router's generic folder-tab width formula. + /// + /// Box whose folder-tab width is being resolved. + /// The non-negative folder-tab width, in logical pixels. + private static double ResolveFolderTabWidth(LayoutBox box) + { + if (box.FolderTabWidth.HasValue) + { + return Math.Max(0.0, box.FolderTabWidth.Value); + } + + return Math.Min( + box.Width * NotationMetrics.FolderTabMaxWidthFraction, + Math.Max( + NotationMetrics.FolderTabMinWidth, + (box.Label?.Length ?? 4) * Themes.Light.FontSizeBody * + NotationMetrics.FolderLabelCharWidthFactor + + (2.0 * Themes.Light.LabelPadding))); + } + + /// + /// Resolves the folder-tab height used by routing, honoring a caller-supplied hint and + /// otherwise falling back to the router's generic folder-tab height formula. + /// + /// Box whose folder-tab height is being resolved. + /// The non-negative folder-tab height, in logical pixels. + private static double ResolveFolderTabHeight(LayoutBox box) => + box.FolderTabHeight.HasValue + ? Math.Max(0.0, box.FolderTabHeight.Value) + : BoxMetrics.FolderTabHeight(Themes.Light); + + public override IReadOnlyList<(double Lo, double Hi)> GetConnectableExtents(PortSide side) + { + if (side is not PortSide.Top) + { + return FullExtent(side); + } + + if (_tabWidth <= 0.0 || _tabHeight <= 0.0) + { + return FullExtent(side); + } + + var topStart = Math.Min(Width, _tabWidth + FolderTopFaceMargin); + return topStart >= Width + ? [] + : [(topStart, Width)]; + } + + public override double ProjectToSurface(PortSide side, double alongAxisCoordinate) => + side is PortSide.Top ? _tabHeight : 0.0; + } + + /// + /// Resolves the shape geometry object used to interpret a box's connectable face extents and real + /// outline projection. + /// + /// + /// Also consumed internally by the LayeredPipeline unit's PortDistributor and + /// LongEdgeJoiner stages via . + /// + internal static IBoxShapeGeometry ResolveShapeGeometry(LayoutBox box) => box.Shape switch + { + BoxShape.Folder => new FolderGeometry(box), + BoxShape.RoundedRectangle => new RoundedRectangleGeometry(box), + BoxShape.Note => new NoteGeometry(box), + _ => new RectangleGeometry(box), + }; + + /// + /// Returns the local face coordinate to use on after clamping the naive + /// along-face position to the nearest connectable shape extent and, when possible, insetting that + /// extent by at both ends. + /// + private static double ClampToConnectableExtent(LayoutBox box, PortSide side, double alongAxisCoordinate, double clearance) + { + var geometry = ResolveShapeGeometry(box); + var extents = BuildUsableExtents(geometry.GetConnectableExtents(side), clearance); + if (extents.Count == 0) + { + return FaceLength(box, side) / 2.0; + } + + var best = alongAxisCoordinate; + var bestDistance = double.PositiveInfinity; + foreach (var (lo, hi) in extents) + { + var clamped = Math.Clamp(alongAxisCoordinate, lo, hi); + var distance = Math.Abs(clamped - alongAxisCoordinate); + if (distance < bestDistance) + { + bestDistance = distance; + best = clamped; + } + } + + return best; + } + + /// + /// Resolves the final anchor point on a box: clamp the local face coordinate into the nearest + /// connectable extent (respecting the requested end clearance whenever that extent is long enough), + /// then project it inward from the bounding box to the real drawn outline. + /// + private static Point2D ResolveAnchorPoint(LayoutBox box, PortSide side, double alongAxisCoordinate, double clearance) + { + var geometry = ResolveShapeGeometry(box); + var clamped = ClampToConnectableExtent(box, side, alongAxisCoordinate, clearance); + var offset = Math.Max(0.0, geometry.ProjectToSurface(side, clamped)); + + return side switch + { + PortSide.Left => new Point2D(box.X + offset, box.Y + clamped), + PortSide.Right => new Point2D(box.X + box.Width - offset, box.Y + clamped), + PortSide.Top => new Point2D(box.X + clamped, box.Y + offset), + _ => new Point2D(box.X + clamped, box.Y + box.Height - offset), + }; + } + + /// + /// Returns the local length of a box face. + /// + private static double FaceLength(LayoutBox box, PortSide side) => + side is PortSide.Left or PortSide.Right ? box.Height : box.Width; + + /// + /// Returns the faces to try for one endpoint, ordered from the natural face chosen by box + /// separation, then the adjacent face that still points most toward the other box on the minor + /// axis, then the other adjacent face, and finally the opposite face as a last resort. + /// + private static IReadOnlyList PreferredFaceOrder(LayoutBox box, LayoutBox other, PortSide naturalSide) + { + var boxCentreX = box.X + (box.Width / 2.0); + var boxCentreY = box.Y + (box.Height / 2.0); + var otherCentreX = other.X + (other.Width / 2.0); + var otherCentreY = other.Y + (other.Height / 2.0); + + return naturalSide switch + { + PortSide.Left => otherCentreY <= boxCentreY + ? [PortSide.Left, PortSide.Top, PortSide.Bottom, PortSide.Right] + : [PortSide.Left, PortSide.Bottom, PortSide.Top, PortSide.Right], + PortSide.Right => otherCentreY <= boxCentreY + ? [PortSide.Right, PortSide.Top, PortSide.Bottom, PortSide.Left] + : [PortSide.Right, PortSide.Bottom, PortSide.Top, PortSide.Left], + PortSide.Top => otherCentreX <= boxCentreX + ? [PortSide.Top, PortSide.Left, PortSide.Right, PortSide.Bottom] + : [PortSide.Top, PortSide.Right, PortSide.Left, PortSide.Bottom], + _ => otherCentreX <= boxCentreX + ? [PortSide.Bottom, PortSide.Left, PortSide.Right, PortSide.Top] + : [PortSide.Bottom, PortSide.Right, PortSide.Left, PortSide.Top], + }; + } + + /// + /// Chooses the first usable face from ; when every face reports an + /// empty extent, the natural face is returned so routing still has a deterministic fallback. + /// + private static PortSide ChooseUsableFace(LayoutBox box, LayoutBox other, PortSide naturalSide) + { + var geometry = ResolveShapeGeometry(box); + foreach (var side in PreferredFaceOrder(box, other, naturalSide)) + { + if (geometry.GetConnectableExtents(side).Count > 0) + { + return side; + } + } + + return naturalSide; + } + + /// + /// Converts an absolute axis coordinate on one of 's faces into the owning + /// face's local coordinate system. + /// + private static double ToLocalAlong(LayoutBox box, PortSide side, double absoluteCoordinate) => + side is PortSide.Left or PortSide.Right + ? absoluteCoordinate - box.Y + : absoluteCoordinate - box.X; + /// /// Dispatches to the router realizing the requested style. /// @@ -218,24 +937,36 @@ private static IReadOnlyList RouteWaypoints( IReadOnlyList obstacles, double clearance, PortSide sourceSide, - PortSide targetSide) => edgeRouting switch + PortSide targetSide, + IReadOnlyList? softObstacles = null) => edgeRouting switch { EdgeRouting.Orthogonal => - OrthogonalEdgeRouter.RouteWithStatus(source, target, obstacles, clearance, sourceSide, targetSide).Waypoints, + OrthogonalEdgeRouter.RouteWithStatus( + source, target, obstacles, clearance, sourceSide, targetSide, costBands: null, softObstacles).Waypoints, _ => throw new NotSupportedException($"Edge routing style '{edgeRouting}' has no shipped router."), }; /// /// Chooses boundary anchors on the two box faces that front each other, based on the boxes' - /// relative placement. The connector leaves and enters on the sides actually facing the other box — - /// the axis along which the boxes are more separated — and each anchor is aligned to the overlap of - /// the boxes on the shared edge, so the route stays short and never wraps back across an endpoint. + /// relative placement. The natural side is the face on the axis along which the boxes are more + /// separated; when that face has no connectable extent, the router falls back to the adjacent face + /// that still points most toward the other box on the minor axis, then the other adjacent face, and + /// finally the opposite face. The along-face coordinate is still aligned to the overlap of the two + /// boxes on that face axis when they overlap, or to this box's own face centre when they do not, + /// but is also kept at least away from the ends of any face long + /// enough to allow that margin before any shape-specific clamping is applied. /// /// The source box. /// The target box. - /// The source anchor and side, and the target anchor and side. - private static (Point2D Source, PortSide SourceSide, Point2D Target, PortSide TargetSide) FacingAnchors( - LayoutBox from, LayoutBox to) + /// + /// Minimum inset to keep from the ends of a sufficiently long face before the coordinate is + /// clamped to any shape-specific connectable extent. + /// + /// + /// The source and target local face coordinates, together with the chosen source and target sides. + /// + private static (double SourceAlong, PortSide SourceSide, double TargetAlong, PortSide TargetSide) FacingAnchors( + LayoutBox from, LayoutBox to, double clearance) { // Signed separation on each axis: positive when the boxes clear each other on that axis, // negative when they overlap. Anchor on the axis with the greater separation so the connector @@ -247,34 +978,59 @@ private static (Point2D Source, PortSide SourceSide, Point2D Target, PortSide Ta { // Left/right relationship: the left box anchors its right face, the right box its left face. var fromIsLeft = from.X + (from.Width / 2.0) <= to.X + (to.Width / 2.0); - var y = AlignedCoordinate(from.Y, from.Y + from.Height, to.Y, to.Y + to.Height); - return fromIsLeft - ? (new Point2D(from.X + from.Width, Clamp(y, from.Y, from.Y + from.Height)), PortSide.Right, - new Point2D(to.X, Clamp(y, to.Y, to.Y + to.Height)), PortSide.Left) - : (new Point2D(from.X, Clamp(y, from.Y, from.Y + from.Height)), PortSide.Left, - new Point2D(to.X + to.Width, Clamp(y, to.Y, to.Y + to.Height)), PortSide.Right); + var naturalFromSide = fromIsLeft ? PortSide.Right : PortSide.Left; + var naturalToSide = fromIsLeft ? PortSide.Left : PortSide.Right; + var sourceSide = ChooseUsableFace(from, to, naturalFromSide); + var targetSide = ChooseUsableFace(to, from, naturalToSide); + var sourceCoordinate = NaiveAnchorCoordinate(from, to, sourceSide, clearance); + var targetCoordinate = NaiveAnchorCoordinate(to, from, targetSide, clearance); + return (ToLocalAlong(from, sourceSide, sourceCoordinate), sourceSide, ToLocalAlong(to, targetSide, targetCoordinate), targetSide); } // Top/bottom relationship: the upper box anchors its bottom face, the lower box its top face. var fromIsAbove = from.Y + (from.Height / 2.0) <= to.Y + (to.Height / 2.0); - var x = AlignedCoordinate(from.X, from.X + from.Width, to.X, to.X + to.Width); - return fromIsAbove - ? (new Point2D(Clamp(x, from.X, from.X + from.Width), from.Y + from.Height), PortSide.Bottom, - new Point2D(Clamp(x, to.X, to.X + to.Width), to.Y), PortSide.Top) - : (new Point2D(Clamp(x, from.X, from.X + from.Width), from.Y), PortSide.Top, - new Point2D(Clamp(x, to.X, to.X + to.Width), to.Y + to.Height), PortSide.Bottom); + var naturalSourceSide = fromIsAbove ? PortSide.Bottom : PortSide.Top; + var naturalTargetSide = fromIsAbove ? PortSide.Top : PortSide.Bottom; + var sourceTopBottomSide = ChooseUsableFace(from, to, naturalSourceSide); + var targetTopBottomSide = ChooseUsableFace(to, from, naturalTargetSide); + var sourceCoordinateOnFallback = NaiveAnchorCoordinate(from, to, sourceTopBottomSide, clearance); + var targetCoordinateOnFallback = NaiveAnchorCoordinate(to, from, targetTopBottomSide, clearance); + return (ToLocalAlong(from, sourceTopBottomSide, sourceCoordinateOnFallback), sourceTopBottomSide, ToLocalAlong(to, targetTopBottomSide, targetCoordinateOnFallback), targetTopBottomSide); } /// - /// Returns a coordinate shared by the spans [, ] and - /// [, ]: the centre of their overlap when they overlap, - /// otherwise the midpoint of the gap between them. Anchors are clamped into each box afterwards, so - /// a shared coordinate keeps both ends aligned when the boxes overlap on the perpendicular axis. + /// Returns the naive absolute coordinate on 's chosen face before shape + /// extents are applied: the overlap centre on the face axis when the two boxes overlap there, or + /// this box's own face centre when they do not, with an added inward clamp that keeps sufficiently + /// long faces away from their corners by . + /// + private static double NaiveAnchorCoordinate(LayoutBox box, LayoutBox other, PortSide side, double clearance) => + side is PortSide.Left or PortSide.Right + ? AnchorCoordinate(box.Y, box.Y + box.Height, other.Y, other.Y + other.Height, clearance) + : AnchorCoordinate(box.X, box.X + box.Width, other.X, other.X + other.Width, clearance); + + /// + /// Returns the anchor coordinate for a box face spanning [, ] + /// against the facing box's span [, ] on the + /// same axis. The starting point is the centre of the two spans' overlap when they overlap + /// (keeping both ends aligned on a single coordinate for a short, straight hop across the shared + /// span), or this box's own centre when they do not. When the face is longer than twice + /// , that coordinate is then clamped inward so it stays at least that + /// far from either end of the face; otherwise the face centre is used directly instead of + /// violating the requested margin. /// - private static double AlignedCoordinate(double aLo, double aHi, double bLo, double bHi) => - (Math.Max(aLo, bLo) + Math.Min(aHi, bHi)) / 2.0; + private static double AnchorCoordinate(double lo, double hi, double otherLo, double otherHi, double clearance) + { + var faceCentre = (lo + hi) / 2.0; + var inset = Math.Max(0.0, clearance); + if ((hi - lo) <= 2.0 * inset) + { + return faceCentre; + } - /// Clamps into the inclusive range [, ]. - private static double Clamp(double value, double min, double max) => - Math.Clamp(value, min, max); + var overlapLo = Math.Max(lo, otherLo); + var overlapHi = Math.Min(hi, otherHi); + var coordinate = overlapLo <= overlapHi ? (overlapLo + overlapHi) / 2.0 : faceCentre; + return Math.Clamp(coordinate, lo + inset, hi - inset); + } } diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs index 0f7f496..e498d04 100644 --- a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs @@ -116,9 +116,13 @@ public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) node.Height, node.Label, Depth: 0, - BoxShape.Rectangle, - Compartments: [], - Children: []); + node.Shape, + node.Compartments, + Children: [], + Keyword: node.Keyword, + RoundedCornerRadius: node.RoundedCornerRadius, + FolderTabWidth: node.FolderTabWidth, + FolderTabHeight: node.FolderTabHeight); } // Pack the leaf boxes into rows within a width budget derived from a roughly 4:3 canvas, keeping diff --git a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs index 5cbc92c..e772220 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs @@ -11,9 +11,60 @@ namespace DemaConsulting.Rendering.Layout.Engine; /// /// A node to be placed by the , identified by its size. /// -/// Width of the node's bounding box in logical pixels. -/// Height of the node's bounding box in logical pixels. -internal readonly record struct LayerNode(double Width, double Height); +/// +/// Width of the node's bounding box in logical pixels. For / +/// flow, swaps this +/// with so the abstract along/cross axes line up with layer progression; see +/// for the caller's true, never-swapped width. +/// +/// +/// Height of the node's bounding box in logical pixels. See the remarks for the +/// Down/Up axis-swap caveat; see for the caller's true, never-swapped height. +/// +/// +/// The box shape used for shape-aware port distribution and connector-endpoint projection. Defaults to +/// , which keeps every existing 2-arg call site's full-face, zero-offset +/// behavior unchanged. +/// +/// +/// Corner radius hint for nodes, or to +/// use the shape's default. Ignored for other shapes. +/// +/// +/// Folder-tab width hint for nodes, or to fall back +/// to the router's generic folder-tab width formula (which also consults ). Ignored +/// for other shapes. +/// +/// +/// Folder-tab height hint for nodes, or to use the +/// shape's default tab height. Ignored for other shapes. +/// +/// +/// The node's label, threaded through so 's label-length tab-width fallback +/// computes the same tab width the router and renderer use when no explicit +/// hint is set. +/// +/// +/// The caller's true, never-swapped bounding-box width in logical pixels, needed because +/// may have been swapped with by +/// for Down/Up flow. Shape geometry (for example +/// 's fold size, which combines both real dimensions) requires the real, +/// never-swapped values. +/// +/// +/// The caller's true, never-swapped bounding-box height in logical pixels. See the +/// remarks. +/// +internal readonly record struct LayerNode( + double Width, + double Height, + BoxShape Shape = BoxShape.Rectangle, + double? RoundedCornerRadius = null, + double? FolderTabWidth = null, + double? FolderTabHeight = null, + string? Label = null, + double RealWidth = 0.0, + double RealHeight = 0.0); /// /// A directed edge (from a source node to a target node, by index) used for layering. diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs index 3aa0fba..cfa3f0d 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -141,7 +141,11 @@ public LayeredGraph( /// the along-axis must instead be the node height, so /// calls this seam before the stages run. It is never invoked for the /// / paths, which keeps those - /// outputs byte-identical. + /// outputs byte-identical. Every other field (, + /// , , + /// , , + /// , ) is preserved unchanged by + /// the swap, since only the abstract along/cross axes need to reorient for Down/Up flow. /// public void SwapNodeAxes() { @@ -150,7 +154,7 @@ public void SwapNodeAxes() { // S2234: width and height are deliberately swapped so the stages space layers by height. #pragma warning disable S2234 - swapped[i] = new LayerNode(Nodes[i].Height, Nodes[i].Width); + swapped[i] = Nodes[i] with { Width = Nodes[i].Height, Height = Nodes[i].Width }; #pragma warning restore S2234 } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs index d35e4ab..8311820 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs @@ -16,6 +16,9 @@ public void Apply(LayeredGraph graph) var augNodes = graph.AugNodes; var augEdges = graph.AugEdges; var augX = graph.AugX; + var augY = graph.AugY; + var nodes = graph.Nodes; + var direction = graph.Direction; var augPortYSrc = graph.AugPortYSrc; var augPortYTgt = graph.AugPortYTgt; var augBendPoints = graph.AugBendPoints; @@ -62,6 +65,29 @@ public void Apply(LayeredGraph graph) var srcPortY = augPortYSrc[subEdges[0]]; var tgtPortY = augPortYTgt[subEdges[^1]]; + // Project a shaped real endpoint inward to the shape's real outline, matching + // ConnectorRouter's own shape-geometry rules. Dummy nodes never reach here (long-edge + // splitting only inserts dummies at intermediate layers, so the first sub-edge's source + // and the last sub-edge's target are always real), and the plain-Rectangle fast path skips + // geometry resolution entirely so its arithmetic stays byte-identical. + if (!ShapeAnchorSupport.IsPlainRectangle(nodes[srcNodeIdx])) + { + var srcSide = ShapeAnchorSupport.ResolveRealFace(direction, isSource: true); + var srcGeometry = ConnectorRouter.ResolveShapeGeometry(ShapeAnchorSupport.BuildAdapterBox(nodes[srcNodeIdx])); + var srcLocal = srcPortY - augY[srcNodeIdx]; + var srcOffset = Math.Max(0.0, srcGeometry.ProjectToSurface(srcSide, srcLocal)); + srcRight -= srcOffset; + } + + if (!ShapeAnchorSupport.IsPlainRectangle(nodes[tgtNodeIdx])) + { + var tgtSide = ShapeAnchorSupport.ResolveRealFace(direction, isSource: false); + var tgtGeometry = ConnectorRouter.ResolveShapeGeometry(ShapeAnchorSupport.BuildAdapterBox(nodes[tgtNodeIdx])); + var tgtLocal = tgtPortY - augY[tgtNodeIdx]; + var tgtOffset = Math.Max(0.0, tgtGeometry.ProjectToSurface(tgtSide, tgtLocal)); + tgtLeft += tgtOffset; + } + var wps = new List { new(srcRight, srcPortY), diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs index 0675bc9..4851d32 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs @@ -15,7 +15,6 @@ internal sealed class PortDistributor : ILayoutStage public void Apply(LayeredGraph graph) { ArgumentNullException.ThrowIfNull(graph); - var n = graph.N; var nodes = graph.Nodes; var augNodes = graph.AugNodes; var augEdges = graph.AugEdges; @@ -57,7 +56,14 @@ public void Apply(LayeredGraph graph) .OrderBy(ei => augY[augEdges[ei].Target] + (augNodes[augEdges[ei].Target].Height / 2.0)) .ThenBy(ei => ei) .ToList(); - DistributePorts(sorted, augY[ni], nodes[ni].Height, augPortYSrc); + if (ShapeAnchorSupport.IsPlainRectangle(nodes[ni])) + { + DistributePorts(sorted, augY[ni], nodes[ni].Height, augPortYSrc); + } + else + { + DistributeShapedPorts(sorted, graph.Direction, isSource: true, augY[ni], nodes[ni], augPortYSrc); + } } } @@ -90,7 +96,15 @@ public void Apply(LayeredGraph graph) .OrderBy(ei => augY[augEdges[ei].Source] + (augNodes[augEdges[ei].Source].Height / 2.0)) .ThenBy(ei => ei) .ToList(); - DistributePorts(sorted, augY[ni], nodes[ni < n ? ni : 0].Height, augPortYTgt); + var node = nodes[ni]; + if (ShapeAnchorSupport.IsPlainRectangle(node)) + { + DistributePorts(sorted, augY[ni], node.Height, augPortYTgt); + } + else + { + DistributeShapedPorts(sorted, graph.Direction, isSource: false, augY[ni], node, augPortYTgt); + } } } @@ -138,4 +152,53 @@ private static void DistributePorts( nodeTop + nodeHeight - inset); } } + + /// + /// Distributes port Y positions along a non- real node's face, + /// restricting the band to the shape's usable connectable extents on the resolved real face + /// (proportionally across multiple disjoint extents, when the shape excludes a middle portion of + /// the face) instead of the plain full-span band uses. + /// + /// + /// Reuses 's (internal) shape-geometry resolution so a shaped node + /// routed through the layered pipeline gets the same connectable-extent restriction that + /// -routed edges already apply; see + /// docs/design/rendering-layout/engine/layered-pipeline.md. Per the layered pipeline's + /// abstract axis convention (verified in the shape-aware-anchors planning report), the abstract + /// cross-axis coordinate is never reflected, so the local face coordinate returned by + /// maps directly onto the abstract Y axis by + /// adding , with no sign flip in any of the four flow directions. + /// + private static void DistributeShapedPorts( + IReadOnlyList sortedEdgeIndices, + LayoutDirection direction, + bool isSource, + double nodeTop, + LayerNode node, + double[] portY) + { + var side = ShapeAnchorSupport.ResolveRealFace(direction, isSource); + var geometry = ConnectorRouter.ResolveShapeGeometry(ShapeAnchorSupport.BuildAdapterBox(node)); + var extents = geometry.GetConnectableExtents(side); + var usable = ConnectorRouter.BuildUsableExtents(extents, ConnectorClearance); + var total = ConnectorRouter.TotalExtentLength(usable); + + if (total <= 1e-9) + { + // The shape excludes the entire face; fall back to the plain full-span band rather than + // producing an invalid (zero-length) port distribution. The abstract Height already equals + // the tangential face length for whichever real face was resolved (see the verified + // abstract-axis invariant on ShapeAnchorSupport.ResolveRealFace). + DistributePorts(sortedEdgeIndices, nodeTop, node.Height, portY); + return; + } + + var count = sortedEdgeIndices.Count; + for (var k = 0; k < count; k++) + { + var distance = count == 1 ? total / 2.0 : k * total / (count - 1); + var local = ConnectorRouter.CoordinateAtDistance(usable, distance); + portY[sortedEdgeIndices[k]] = nodeTop + local; + } + } } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/ShapeAnchorSupport.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ShapeAnchorSupport.cs new file mode 100644 index 0000000..d98d1a2 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ShapeAnchorSupport.cs @@ -0,0 +1,85 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Small helper bridging the layered pipeline's abstract, direction-agnostic axes to +/// 's (internal) shape-geometry types, so +/// and can restrict a shaped node's connector ports to its usable +/// connectable face extents and project the resulting endpoint inward to the shape's real outline, +/// matching 's own shape-geometry rules for cross-container edges. +/// +/// +/// This is the one place the LayeredPipeline unit depends on the ConnectorRouter unit; +/// see docs/design/rendering-layout/engine/layered-pipeline.md's "Layered Pipeline +/// Dependencies" section for the documented exception. +/// +internal static class ShapeAnchorSupport +{ + /// + /// Resolves the real (screen-space) box face that a source or target port on an abstract, + /// direction-agnostic node maps to for the given flow direction. + /// + /// The layout's requested flow direction. + /// + /// to resolve the source-side (abstract right/along-far) face; + /// to resolve the target-side (abstract left/along-near) face. + /// + /// The real the abstract face maps to. + /// + /// Verified by algebraic trace of 's per-direction point mapping (see + /// the shape-aware-anchors planning report): the abstract cross-axis coordinate is never + /// reflected, so a local face coordinate carries over unchanged in all four cases, and the + /// direction-to-face table is: + /// + /// : source → , target → + /// . + /// : source → , target → + /// . + /// : source → , target → + /// . + /// : source → , target → + /// . + /// + /// + public static PortSide ResolveRealFace(LayoutDirection direction, bool isSource) => direction switch + { + LayoutDirection.Right => isSource ? PortSide.Right : PortSide.Left, + LayoutDirection.Left => isSource ? PortSide.Left : PortSide.Right, + LayoutDirection.Down => isSource ? PortSide.Bottom : PortSide.Top, + LayoutDirection.Up => isSource ? PortSide.Top : PortSide.Bottom, + _ => isSource ? PortSide.Right : PortSide.Left, + }; + + /// + /// Builds a throwaway carrying just enough of 's + /// shape metadata to pass into 's (internal) shape-geometry + /// resolution. The box's position is irrelevant (always 0, 0) because only face-local + /// extents and projections are read from the resolved geometry. + /// + /// The real (non-dummy) node whose shape metadata should be adapted. + /// A minimal suitable for ConnectorRouter.ResolveShapeGeometry. + public static LayoutBox BuildAdapterBox(LayerNode node) => new( + X: 0.0, + Y: 0.0, + Width: node.RealWidth, + Height: node.RealHeight, + Label: node.Label, + Depth: 0, + Shape: node.Shape, + Compartments: [], + Children: [], + RoundedCornerRadius: node.RoundedCornerRadius, + FolderTabWidth: node.FolderTabWidth, + FolderTabHeight: node.FolderTabHeight); + + /// + /// Fast-path guard used by both and to + /// skip all shape-aware logic and fall through to the original, byte-identical arithmetic for the + /// common (and default) case of a plain rectangle. + /// + /// The node to test. + /// if is a plain rectangle. + public static bool IsPlainRectangle(LayerNode node) => node.Shape == BoxShape.Rectangle; +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs b/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs index 8bc3c2c..fb9db39 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs @@ -76,6 +76,10 @@ private enum Dir /// /// Optional box side the target anchor sits on; see . /// Optional cost bands biasing the route toward highway corridors; null leaves cost neutral. + /// + /// Optional rectangles (typically already-routed connector segments) that the route should prefer + /// to avoid but is never hard-blocked by. + /// /// /// An ordered list of waypoints beginning with and ending with /// . Consecutive waypoints always share an X or a Y coordinate. @@ -87,8 +91,9 @@ public static IReadOnlyList Route( double clearance, PortSide? sourceSide = null, PortSide? targetSide = null, - IReadOnlyList? costBands = null) => - RouteWithStatus(source, target, obstacles, clearance, sourceSide, targetSide, costBands).Waypoints; + IReadOnlyList? costBands = null, + IReadOnlyList? softObstacles = null) => + RouteWithStatus(source, target, obstacles, clearance, sourceSide, targetSide, costBands, softObstacles).Waypoints; /// /// Computes an orthogonal route and reports whether it had to cross an obstacle. The route is @@ -103,6 +108,13 @@ public static IReadOnlyList Route( /// Optional box side the source anchor sits on (adds a perpendicular stub). /// Optional box side the target anchor sits on (adds a perpendicular stub). /// Optional cost bands biasing the route toward highway corridors; null leaves cost neutral. + /// + /// Optional rectangles (typically already-routed connector segments) that the route should prefer + /// to avoid but is never hard-blocked by. Unlike , a segment may still + /// cross a soft obstacle when doing so is the only (or cheapest) way to reach the target; this keeps + /// a shared target/source face reachable even when every other connector converging on it already + /// occupies the same short approach corridor. + /// /// The waypoints and a flag indicating whether the route crosses an obstacle. public static RouteResult RouteWithStatus( Point2D source, @@ -111,7 +123,8 @@ public static RouteResult RouteWithStatus( double clearance, PortSide? sourceSide = null, PortSide? targetSide = null, - IReadOnlyList? costBands = null) + IReadOnlyList? costBands = null, + IReadOnlyList? softObstacles = null) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(target); @@ -129,14 +142,14 @@ public static RouteResult RouteWithStatus( // Try to find an obstacle-free orthogonal path, preferring the largest clearance that works. foreach (var c in ClearanceLevels(clearance)) { - var xs = BuildAxis(routeSource.X, routeTarget.X, obstacles, c, horizontal: true); - var ys = BuildAxis(routeSource.Y, routeTarget.Y, obstacles, c, horizontal: false); + var xs = BuildAxis(routeSource.X, routeTarget.X, obstacles, c, horizontal: true, softObstacles); + var ys = BuildAxis(routeSource.Y, routeTarget.Y, obstacles, c, horizontal: false, softObstacles); var path = AStar( xs, ys, IndexOf(xs, routeSource.X), IndexOf(ys, routeSource.Y), IndexOf(xs, routeTarget.X), IndexOf(ys, routeTarget.Y), - obstacles, c, costBands); + obstacles, c, costBands, softObstacles); if (path is not null) { @@ -189,7 +202,7 @@ private static IReadOnlyList Finalize( full.Add(target); } - return Simplify(full); + return RemovePointRevisits(Simplify(full)); } /// @@ -230,14 +243,17 @@ private static double StubLength(Point2D anchor, PortSide? side, Point2D other, /// /// Builds the sorted, de-duplicated set of grid coordinates for one axis: the two endpoint - /// coordinates plus each obstacle's near/far edge offset outward by the clearance. + /// coordinates plus each obstacle's near/far edge offset outward by the clearance, plus the same + /// offset for any so the search has a candidate lane on either + /// side of an already-routed line to prefer when one is available. /// private static double[] BuildAxis( double a, double b, IReadOnlyList obstacles, double clearance, - bool horizontal) + bool horizontal, + IReadOnlyList? softObstacles = null) { var set = new SortedSet { a, b }; foreach (var r in obstacles) @@ -254,6 +270,23 @@ private static double[] BuildAxis( } } + if (softObstacles is not null) + { + foreach (var r in softObstacles) + { + if (horizontal) + { + set.Add(r.X - clearance); + set.Add(r.X + r.Width + clearance); + } + else + { + set.Add(r.Y - clearance); + set.Add(r.Y + r.Height + clearance); + } + } + } + return [.. set]; } @@ -288,7 +321,8 @@ private static int IndexOf(double[] axis, double value) int goalJ, IReadOnlyList obstacles, double clearance, - IReadOnlyList? costBands) + IReadOnlyList? costBands, + IReadOnlyList? softObstacles = null) { var nx = xs.Length; var ny = ys.Length; @@ -306,6 +340,26 @@ private static int IndexOf(double[] axis, double value) // remove a bend are preferred only when not much longer. const double TurnPenalty = 20.0; + // Soft-obstacle cost is split into a small flat base (so a trivial, few-pixel overlap stays + // cheap enough that a shared approach corridor is never worth a pointless detour) plus a + // per-unit-length term proportional to how much of the candidate move actually overlaps the + // soft obstacle. A flat, length-independent penalty was tried first and found insufficient: on + // a sparse narrow-gap grid a connector's entire multi-hundred-pixel corridor can collapse into + // a single grid move, so a flat cost priced a 500px visual overlap identically to a trivial + // one — cheaper than the roughly fixed cost of detouring to a nearby alternate lane, so A* + // always kept the long overlap no matter how visually merged the result looked. Scaling the + // cost with the overlap length keeps short, incidental overlaps (and the legitimate + // endpoint-adjacent approach corridors that never become soft obstacles in the first place — + // see AddLineObstacles in ConnectorRouter) cheap, while making an extended + // overlap cost substantially more than a bounded-cost lane change, so the search prefers the + // detour once the overlap grows long enough to matter. + const double SoftObstacleBaseCost = 10.0; + const double SoftObstaclePerUnitLengthCost = 1.0; + + // Resolved once outside the neighbor loop so a null softObstacles argument does not allocate a + // fresh empty array on every A* iteration. + var resolvedSoftObstacles = softObstacles ?? []; + while (open.Count > 0) { var (ci, cj, cd) = open.Dequeue(); @@ -330,7 +384,11 @@ private static int IndexOf(double[] axis, double value) : Math.Abs(ys[nj] - ys[cj]); var bandMultiplier = SegmentCostMultiplier(xs, ys, ci, cj, ni, nj, costBands); var turnCost = cd != Dir.None && cd != nd ? TurnPenalty : 0.0; - var tentative = g + (stepLength * bandMultiplier) + turnCost; + var softOverlap = SoftObstacleOverlapLength(xs, ys, ci, cj, ni, nj, resolvedSoftObstacles); + var softCost = softOverlap > 0.0 + ? SoftObstacleBaseCost + (softOverlap * SoftObstaclePerUnitLengthCost) + : 0.0; + var tentative = g + (stepLength * bandMultiplier) + turnCost + softCost; var neighborState = (ni, nj, nd); if (best.TryGetValue(neighborState, out var existing) && tentative >= existing) @@ -461,6 +519,66 @@ private static bool SegmentBlocked( return false; } + /// + /// Returns the total length by which the straight grid segment between two adjacent nodes overlaps + /// the interiors of (summed across every soft obstacle it + /// intersects), or 0 when it does not overlap any. Mirrors 's + /// horizontal/vertical branch logic and its clearance-0.0 "just touching interior" semantics + /// (soft obstacles are never inflated by clearance, unlike hard obstacles), but reports the + /// overlapping span instead of a boolean so the caller can price the move proportionally to how + /// much of it is actually shared with an already-routed line. + /// + private static double SoftObstacleOverlapLength( + double[] xs, + double[] ys, + int i1, + int j1, + int i2, + int j2, + IReadOnlyList softObstacles) + { + var total = 0.0; + + if (j1 == j2) + { + // Horizontal segment at y = ys[j1] spanning the two x grid lines. + var y = ys[j1]; + var xa = Math.Min(xs[i1], xs[i2]); + var xb = Math.Max(xs[i1], xs[i2]); + foreach (var r in softObstacles) + { + if (r.Y < y && y < r.Y + r.Height) + { + var overlap = Math.Min(xb, r.X + r.Width) - Math.Max(xa, r.X); + if (overlap > 0.0) + { + total += overlap; + } + } + } + } + else + { + // Vertical segment at x = xs[i1] spanning the two y grid lines. + var x = xs[i1]; + var ya = Math.Min(ys[j1], ys[j2]); + var yb = Math.Max(ys[j1], ys[j2]); + foreach (var r in softObstacles) + { + if (r.X < x && x < r.X + r.Width) + { + var overlap = Math.Min(yb, r.Y + r.Height) - Math.Max(ya, r.Y); + if (overlap > 0.0) + { + total += overlap; + } + } + } + } + + return total; + } + /// Reconstructs the grid-point path by walking the came-from chain back to the start. private static List Reconstruct( double[] xs, @@ -529,6 +647,57 @@ private static IReadOnlyList Simplify(List points) return result; } + /// + /// Removes any exact leave-and-return excursion that departs a waypoint and later comes back to the + /// same point before continuing onward. This is a defensive cleanup only: the primary fix for the + /// shared-face detour regression is to stop contributing endpoint-adjacent soft obstacles that lure + /// the search into such loops. The cleanup remains valuable as a last line of defense so a future + /// caller cannot reintroduce a visibly redundant revisit sequence into the published route. + /// + private static IReadOnlyList RemovePointRevisits(IReadOnlyList points) + { + if (points.Count <= 3) + { + return points; + } + + var result = new List(points); + var changed = true; + while (changed) + { + changed = false; + for (var i = 0; i < result.Count - 2; i++) + { + var removedCycle = false; + for (var j = i + 2; j < result.Count; j++) + { + if (!SamePoint(result[i], result[j])) + { + continue; + } + + result.RemoveRange(i + 1, j - i); + changed = true; + removedCycle = true; + break; + } + + if (removedCycle) + { + break; + } + } + } + + return result; + } + + /// + /// Returns whether two waypoints represent the same geometric point. + /// + private static bool SamePoint(Point2D left, Point2D right) => + Math.Abs(left.X - right.X) < 1e-9 && Math.Abs(left.Y - right.Y) < 1e-9; + /// /// Builds the least-bad L-shaped fallback route used when A* cannot find an obstacle-free path: /// it tries the horizontal-first and vertical-first elbows and returns whichever crosses fewer diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs index a3681bc..8916479 100644 --- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -115,11 +115,43 @@ public sealed class HierarchicalLayoutAlgorithm : ILayoutAlgorithm private const double ContainerPadding = 12.0; /// - /// Height, in logical pixels, of the title band reserved at the top of a container that carries a - /// . The children are offset below this band so the label and the - /// nested content never overlap; a container with no label reserves no band. + /// Default height, in logical pixels, of the title band reserved at the top of a container that + /// carries a and does not set its own + /// . The children are offset below this band so the label + /// and the nested content never overlap; a container with no label reserves no band. /// - private const double ContainerTitleHeight = 24.0; + private const double DefaultContainerTitleHeight = 24.0; + + /// + /// Resolves the title-band height reserved above a container's children: the node's own + /// override when set (so a caller can match a specific + /// theme's actual title-area height, including a keyword line), otherwise + /// ; zero for a node with no label. + /// + /// The container node whose title-band height is resolved. + /// The title-band height in logical pixels. + private static double ResolveTitleHeight(LayoutGraphNode node) => + node.Label is null ? 0.0 : node.TitleHeight ?? DefaultContainerTitleHeight; + + /// + /// Resolves the total vertical band reserved above a container's children: the label/keyword + /// title band from , plus — for a + /// container — the folder tab height, since the renderer recesses the title text below the tab + /// and the container must reserve enough space so nested children never overlap it. + /// + /// The container node whose reserved content offset is resolved. + /// The total reserved band height in logical pixels. + private static double ResolveContentOffsetHeight(LayoutGraphNode node) + { + var titleHeight = ResolveTitleHeight(node); + if (node.Shape != BoxShape.Folder) + { + return titleHeight; + } + + var tabHeight = node.FolderTabHeight ?? BoxMetrics.FolderTabHeight(Themes.Light); + return titleHeight + tabHeight; + } /// /// The leaf-algorithm provider used to resolve the per-scope layout algorithm by identifier. It is @@ -212,7 +244,18 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) var effectiveSize = new Dictionary(); LayoutContainerChildren(graph, effective, subLayouts, effectiveSize); - var (view, _) = BuildSizedView(graph, effectiveSize); + // Map every descendant node to the direct member of this scope that contains it (needed before + // placement so edge classification below can decide, purely from graph structure, which edges + // the leaf algorithm should route locally versus which the router must handle for this scope). + var descendantToDirect = new Dictionary(); + foreach (var direct in graph.Nodes) + { + MapDescendants(direct, direct, descendantToDirect); + } + + var (leafEdges, routedEdges) = ClassifyEdges(graph, descendantToDirect); + + var (view, _) = BuildSizedView(graph, effectiveSize, leafEdges); // Place this level with the resolved leaf algorithm over the sized view. The leaf algorithm // emits one box per node in Nodes order, so placed boxes align with graph.Nodes by index. @@ -222,7 +265,7 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) var (composed, indexOf) = ComposeBoxes(graph, placedBoxes, subLayouts); - var crossLines = RouteCrossContainerEdges(graph, composed, indexOf, effective); + var crossLines = RouteCrossContainerEdges(routedEdges, composed, indexOf, descendantToDirect, effective); // Assemble: composed boxes, then the leaf algorithm's routed lines, then the cross-container // lines. The canvas dimensions are the leaf algorithm's for this (sized) level. @@ -268,7 +311,7 @@ private void LayoutContainerChildren( // Size the container to enclose its sub-layout plus a padding inset on every side and, when // the container is labelled, a title band above the children. - var titleHeight = node.Label is null ? 0.0 : ContainerTitleHeight; + var titleHeight = ResolveContentOffsetHeight(node); effectiveSize[node] = ( sub.Width + (2 * ContainerPadding), sub.Height + (2 * ContainerPadding) + titleHeight); @@ -277,18 +320,24 @@ private void LayoutContainerChildren( /// /// Builds an internal, side-effect-free sized view of : the same nodes in - /// the same order (container nodes carrying their effective size, leaves their own size), only - /// the edges whose endpoints are both direct members of this scope. The scope's own cascaded + /// the same order (container nodes carrying their effective size, leaves their own size), only the + /// edges in — those between two distinct direct members that neither + /// touch a box also involved in a cross-container edge, per . Every + /// other edge (genuine cross-container edges, and any direct-member edge that shares a box with + /// one) is routed by this scope's router instead, so a box that receives both kinds of connector + /// has every one of its anchors allocated by a single coordinated pass. The scope's own cascaded /// options are carried separately as the caller's effective snapshot rather than propagated /// onto this view, since every leaf algorithm resolves such properties from the options it is /// invoked with. /// /// The caller's scope, which is never mutated. /// Effective sizes for the container nodes of this scope. + /// The edges this scope's leaf algorithm should route locally. /// The sized view graph and a map from each original node to its view counterpart. private static (LayoutGraph View, Dictionary ViewOf) BuildSizedView( LayoutGraph graph, - Dictionary effectiveSize) + Dictionary effectiveSize, + HashSet leafEdges) { var view = new LayoutGraph(); @@ -300,13 +349,25 @@ private static (LayoutGraph View, Dictionary V : (node.Width, node.Height); var viewNode = view.AddNode(node.Id, width, height); viewNode.Label = node.Label; + viewNode.Shape = node.Shape; + viewNode.Keyword = node.Keyword; + viewNode.Compartments = node.Compartments; + viewNode.RoundedCornerRadius = node.RoundedCornerRadius; + viewNode.FolderTabWidth = node.FolderTabWidth; + viewNode.FolderTabHeight = node.FolderTabHeight; viewOf[node] = viewNode; } foreach (var edge in graph.Edges) { - // Only edges whose both endpoints are direct members of this scope are placed by the leaf - // algorithm here; cross-container edges are routed separately after composition. + // Only edges classified for local routing are placed by the leaf algorithm here; every + // other edge (cross-container, or a direct-member edge promoted alongside one) is routed + // separately after composition, by this scope's router. + if (!leafEdges.Contains(edge)) + { + continue; + } + if (viewOf.TryGetValue(edge.Source, out var viewSource) && viewOf.TryGetValue(edge.Target, out var viewTarget)) { @@ -346,7 +407,7 @@ private static (LayoutBox[] Composed, Dictionary IndexOf) if (subLayouts.TryGetValue(node, out var sub)) { // Offset the nested content to the container's padded interior, below any title band. - var titleHeight = node.Label is null ? 0.0 : ContainerTitleHeight; + var titleHeight = ResolveContentOffsetHeight(node); var offsetX = box.X + ContainerPadding; var offsetY = box.Y + ContainerPadding + titleHeight; var children = new List(sub.Nodes.Count); @@ -367,31 +428,69 @@ private static (LayoutBox[] Composed, Dictionary IndexOf) } /// - /// Routes the cross-container edges owned by this scope — edges whose endpoints resolve to different - /// direct-member containers of this level — around the sibling boxes between them. + /// Routes every edge this scope's router is responsible for — genuine cross-container edges, plus + /// any direct-member edge promoted alongside one by because it shares a + /// box with a cross-container edge — around the sibling boxes between them, all in one batch call. /// - /// The scope whose edges are examined for cross-container routing. + /// The edges this scope must route, per . /// The composed top-level boxes of this scope, aligned with its nodes by index. /// Map from each direct-member node to its positional index in . + /// Map from every descendant node to the direct member that owns it. /// This scope's cascaded effective options, supplying . - /// One routed line per cross-container edge owned by this scope. + /// One routed line per edge in . private static List RouteCrossContainerEdges( - LayoutGraph graph, + List routedEdges, LayoutBox[] composed, Dictionary indexOf, + Dictionary descendantToDirect, LayoutOptions effective) { - // Map every descendant node to the direct member of this scope that contains it, so an edge that - // references a deeply nested endpoint can be anchored to the top-level box that owns it. - var descendantToDirect = new Dictionary(); - foreach (var direct in graph.Nodes) + var routeOptions = new ConnectorRouteOptions(effective.Get(CoreOptions.EdgeRouting)); + var boxesForRouting = (IReadOnlyList)composed; + + // Collect every edge this scope must route into one list of Connections, then route them all in + // a single batch call. Routing them independently (one ConnectorRouter.Route call per edge) + // would let separate edges that converge on the same box face pick colliding anchors, and + // separate edges on similar paths collapse onto the same corridor — the batch overload spreads + // shared-face anchors and steers later connectors around earlier ones. + var connections = new List(routedEdges.Count); + foreach (var edge in routedEdges) { - MapDescendants(direct, direct, descendantToDirect); + var from = composed[indexOf[descendantToDirect[edge.Source]]]; + var to = composed[indexOf[descendantToDirect[edge.Target]]]; + connections.Add(new Connection(from, to, edge.TargetEnd, edge.LineStyle, edge.Label)); } - var routeOptions = new ConnectorRouteOptions(effective.Get(CoreOptions.EdgeRouting)); - var boxesForRouting = (IReadOnlyList)composed; - var crossLines = new List(); + return new List(ConnectorRouter.Route(boxesForRouting, connections, routeOptions)); + } + + /// + /// Classifies every edge of , purely from graph structure, into the edges + /// this scope's leaf algorithm should route locally and the edges this scope's router must handle + /// instead. + /// + /// + /// An edge whose direct-member endpoints (per ) resolve to the + /// same node belongs to a lower scope entirely and is skipped here. An edge between two distinct + /// direct members, with at least one endpoint actually nested inside a container, is a genuine + /// cross-container edge routed by this scope's router. An edge between two distinct direct members + /// that are both literally direct (neither endpoint nested) would normally be routed + /// locally by the leaf algorithm — but when either of those direct members is also an endpoint of a + /// genuine cross-container edge, the edge is promoted to this scope's router too, so every connector + /// converging on that box's face is anchored by one coordinated pass instead of two independent, + /// mutually unaware ones (the leaf algorithm's own internal routing and this scope's router). + /// + /// The scope whose edges are classified. + /// Map from every descendant node to the direct member that owns it. + /// The edges to route locally, and the edges this scope's router must handle. + private static (HashSet LeafEdges, List RoutedEdges) ClassifyEdges( + LayoutGraph graph, + Dictionary descendantToDirect) + { + var directDirect = new List(); + var routedEdges = new List(); + var conflicted = new HashSet(); + foreach (var edge in graph.Edges) { // Skip edges whose endpoints are not both under this scope. @@ -401,29 +500,41 @@ private static List RouteCrossContainerEdges( continue; } - // An edge whose endpoints are both direct members is already routed by the leaf algorithm. - var bothDirect = ReferenceEquals(sourceDirect, edge.Source) && - ReferenceEquals(targetDirect, edge.Target); - if (bothDirect) + // An edge whose endpoints share one container belongs to that lower scope, not this one. + if (ReferenceEquals(sourceDirect, targetDirect)) { continue; } - // An edge whose endpoints share one container belongs to that lower scope, not this one. - if (ReferenceEquals(sourceDirect, targetDirect)) + var bothDirect = ReferenceEquals(sourceDirect, edge.Source) && ReferenceEquals(targetDirect, edge.Target); + if (bothDirect) { - continue; + // Provisionally a leaf-routed edge; may still be promoted below if either endpoint is + // also touched by a genuine cross-container edge. + directDirect.Add(edge); + } + else + { + routedEdges.Add(edge); + conflicted.Add(sourceDirect); + conflicted.Add(targetDirect); } + } - var from = composed[indexOf[sourceDirect]]; - var to = composed[indexOf[targetDirect]]; - crossLines.Add(ConnectorRouter.Route( - boxesForRouting, - new Connection(from, to, edge.TargetEnd, edge.LineStyle, edge.Label), - routeOptions)); + var leafEdges = new HashSet(); + foreach (var edge in directDirect) + { + if (conflicted.Contains(edge.Source) || conflicted.Contains(edge.Target)) + { + routedEdges.Add(edge); + } + else + { + leafEdges.Add(edge); + } } - return crossLines; + return (leafEdges, routedEdges); } /// diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index 7772579..54def30 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -44,7 +44,16 @@ public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) { var node = graphNodes[i]; indexOf[node] = i; - engineNodes[i] = new LayerNode(node.Width, node.Height); + engineNodes[i] = new LayerNode( + node.Width, + node.Height, + node.Shape, + node.RoundedCornerRadius, + node.FolderTabWidth, + node.FolderTabHeight, + node.Label, + RealWidth: node.Width, + RealHeight: node.Height); } // Map edges to index pairs, dropping any that reference nodes outside this graph. @@ -74,9 +83,13 @@ public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) rect.Height, graphNodes[i].Label, Depth: 0, - BoxShape.Rectangle, - Compartments: [], - Children: [])); + graphNodes[i].Shape, + graphNodes[i].Compartments, + Children: [], + Keyword: graphNodes[i].Keyword, + RoundedCornerRadius: graphNodes[i].RoundedCornerRadius, + FolderTabWidth: graphNodes[i].FolderTabWidth, + FolderTabHeight: graphNodes[i].FolderTabHeight)); } // Build a (source, target) -> polyline lookup from the acyclic edge set the engine routed. diff --git a/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs index b50eee6..7a0a622 100644 --- a/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs +++ b/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs @@ -15,14 +15,17 @@ namespace DemaConsulting.Rendering.Layout; /// /// /// -/// Algorithm resolution. The algorithm identifier is taken, in order of -/// precedence, from an explicit set on the -/// graph itself, then from an explicit set on the -/// supplied options, and finally — when neither carries an explicit value — from -/// . The graph takes precedence because, in the ELK-style model, -/// layout options are naturally attached to the graph being laid out; the free-standing -/// acts as a fallback. The resolved identifier is looked up in the -/// registry and applied. +/// Algorithm resolution. The algorithm identifier is taken from an explicit +/// set directly on the graph itself — for example +/// graph.Set(CoreOptions.Algorithm, "layered") — falling back to +/// only when the graph carries no explicit value. There is a single +/// place to configure layout: the graph itself, since is an +/// . This deliberately avoids a second, free-standing options object at +/// this entry point that could disagree with the graph's own settings; callers configure every +/// property (algorithm, direction, edge routing, spacing, and so on) the +/// same way, by calling on +/// the graph (or, for a nested container's own scope, on its +/// graph). The resolved algorithm identifier is looked up in the registry and applied. /// /// /// Why the default is hierarchical, not layered. When no algorithm is @@ -34,17 +37,16 @@ namespace DemaConsulting.Rendering.Layout; /// A flat graph therefore lays out exactly as the layered algorithm would, while a nested graph is /// composed correctly — with no decision required from the caller. Note that /// has its own property default of layered; the facade -/// intentionally consults explicit settings only (via +/// intentionally consults an explicit graph setting only (via /// ) so that an -/// unset graph and options fall through to instead of the -/// property default. +/// unset graph falls through to instead of the property default. /// /// -/// The parameterless overload resolves against a shared default registry of the bundled algorithms +/// The single-argument overload resolves against a shared default registry of the bundled algorithms /// (see ). Because the bundled algorithms are /// stateless, that shared registry is safe to read concurrently. Callers that want to add or replace /// algorithms — for example to register a custom — pass their own -/// registry to the three-argument overload. +/// registry to the two-argument overload. /// /// /// @@ -64,8 +66,9 @@ namespace DemaConsulting.Rendering.Layout; /// graph.AddEdge("b-c", b, c); /// /// // 2. Lay it out with whatever algorithm the graph declares (default: hierarchical, -/// // which is byte-identical to layered for this flat graph). -/// var tree = LayoutEngine.Layout(graph, new LayoutOptions()); +/// // which is byte-identical to layered for this flat graph). Configure the graph itself +/// // (e.g. graph.Set(CoreOptions.Algorithm, "layered")) to change that. +/// var tree = LayoutEngine.Layout(graph); /// /// // 3. Render the placed tree to SVG. /// using var output = File.Create("diagram.svg"); @@ -75,14 +78,14 @@ namespace DemaConsulting.Rendering.Layout; public static class LayoutEngine { /// - /// Identifier of the algorithm used when neither the graph nor the options declares one: - /// hierarchical. It lays out flat and nested graphs uniformly, matching the layered algorithm - /// byte-for-byte on flat graphs while composing nested graphs correctly. + /// Identifier of the algorithm used when the graph declares none: hierarchical. It lays out + /// flat and nested graphs uniformly, matching the layered algorithm byte-for-byte on flat graphs + /// while composing nested graphs correctly. /// public const string DefaultAlgorithmId = HierarchicalLayoutAlgorithm.AlgorithmId; /// - /// Shared registry of the bundled algorithms used by the parameterless overload. The bundled + /// Shared registry of the bundled algorithms used by the single-argument overload. The bundled /// algorithms are stateless, so a single shared instance is safe to read (resolve) concurrently. /// private static readonly LayoutAlgorithmRegistry DefaultRegistry = @@ -92,31 +95,31 @@ public static class LayoutEngine /// Lays out with the algorithm it declares (see the resolution rules on /// ), resolving it from the bundled algorithms. /// - /// The unplaced input graph, flat or nested. - /// - /// Layout options carried into the resolved algorithm. May declare - /// to select the algorithm explicitly; otherwise the graph's - /// declaration, then , applies. + /// + /// The unplaced input graph, flat or nested. Configure it directly (via + /// ) with any + /// the layout should honor, including + /// to select the algorithm explicitly; otherwise + /// applies. /// /// A fully-placed with absolute coordinates and routed edges. - /// - /// Thrown when or is . - /// + /// Thrown when is . /// /// Thrown when the declared algorithm identifier is not one of the bundled algorithms. /// - public static LayoutTree Layout(LayoutGraph graph, LayoutOptions options) => - Layout(graph, options, DefaultRegistry); + public static LayoutTree Layout(LayoutGraph graph) => + Layout(graph, DefaultRegistry); /// /// Lays out with the algorithm it declares (see the resolution rules on /// ), resolving it from a caller-supplied . /// - /// The unplaced input graph, flat or nested. - /// - /// Layout options carried into the resolved algorithm. May declare - /// to select the algorithm explicitly; otherwise the graph's - /// declaration, then , applies. + /// + /// The unplaced input graph, flat or nested. Configure it directly (via + /// ) with any + /// the layout should honor, including + /// to select the algorithm explicitly; otherwise + /// applies. /// /// /// Provider of the algorithms to resolve against. Use @@ -125,38 +128,30 @@ public static LayoutTree Layout(LayoutGraph graph, LayoutOptions options) => /// /// A fully-placed with absolute coordinates and routed edges. /// - /// Thrown when , , or - /// is . + /// Thrown when or is . /// /// /// Thrown when the declared algorithm identifier is not registered in . /// - public static LayoutTree Layout(LayoutGraph graph, LayoutOptions options, LayoutAlgorithmRegistry registry) + public static LayoutTree Layout(LayoutGraph graph, LayoutAlgorithmRegistry registry) { ArgumentNullException.ThrowIfNull(graph); - ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(registry); - var algorithmId = ResolveAlgorithm(graph, options); - return registry.Resolve(algorithmId).Apply(graph, options); + var algorithmId = ResolveAlgorithm(graph); + + // The graph carries every explicit setting a leaf algorithm needs; seed the cascade with an + // empty LayoutOptions rather than accepting a caller-supplied one, so the graph itself is the + // single place options are configured (see the "Algorithm resolution" remarks above). + return registry.Resolve(algorithmId).Apply(graph, new LayoutOptions()); } /// - /// Resolves the algorithm identifier from an explicit on the - /// graph, then on the options, falling back to when neither is set. + /// Resolves the algorithm identifier from an explicit set on the + /// graph, falling back to when the graph declares none. /// - /// The graph whose explicit algorithm declaration takes precedence. - /// The options consulted when the graph declares no algorithm. + /// The graph whose explicit algorithm declaration is consulted. /// The algorithm identifier to resolve and apply. - private static string ResolveAlgorithm(LayoutGraph graph, LayoutOptions options) - { - if (graph.TryGet(CoreOptions.Algorithm, out var fromGraph)) - { - return fromGraph; - } - - return options.TryGet(CoreOptions.Algorithm, out var fromOptions) - ? fromOptions - : DefaultAlgorithmId; - } + private static string ResolveAlgorithm(LayoutGraph graph) => + graph.TryGet(CoreOptions.Algorithm, out var fromGraph) ? fromGraph : DefaultAlgorithmId; } diff --git a/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs b/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs index 7ada3bd..82082ca 100644 --- a/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs +++ b/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs @@ -16,8 +16,9 @@ namespace DemaConsulting.Rendering.Layout; /// /// /// Begin with the one-call facade : -/// LayoutEngine.Layout(graph, options) resolves whichever algorithm the graph (or options) -/// declares and lays out flat and nested graphs uniformly. The bundled algorithms are +/// LayoutEngine.Layout(graph) resolves whichever algorithm the graph declares (configure it +/// directly, e.g. graph.Set(CoreOptions.Algorithm, "layered")) and lays out flat and nested +/// graphs uniformly. The bundled algorithms are /// (layered — ELK-style Sugiyama layering), /// (containment — grouped/packed placement), and /// (hierarchical — the recursive engine that diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs index 512c6eb..53adfd7 100644 --- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -359,8 +359,8 @@ private static void RenderBoxOutline( RenderNotePng(canvas, box, scale, fillPaint, strokePaint); break; - case BoxShape.RoundedRectangle when theme.LineCornerRadius > 0: - var cornerR = (float)(NotationMetrics.RoundedRectRadius(theme) * scale); + case BoxShape.RoundedRectangle when ResolveRoundedCornerRadius(box, theme) > 0: + var cornerR = (float)(ResolveRoundedCornerRadius(box, theme) * scale); canvas.DrawRoundRect(rect, cornerR, cornerR, fillPaint); canvas.DrawRoundRect(rect, cornerR, cornerR, strokePaint); break; @@ -377,12 +377,8 @@ private static void RenderBoxOutline( /// private static SKPath BuildFolderPath(LayoutBox box, Theme theme, float scale) { - var tabHeight = BoxMetrics.FolderTabHeight(theme); - var tabWidth = Math.Min( - box.Width * NotationMetrics.FolderTabMaxWidthFraction, - Math.Max( - NotationMetrics.FolderTabMinWidth, - (box.Label?.Length ?? 4) * theme.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + 2.0 * theme.LabelPadding)); + var tabHeight = ResolveFolderTabHeight(box, theme); + var tabWidth = ResolveFolderTabWidth(box, theme); var x = (float)(box.X * scale); var yTab = (float)(box.Y * scale); @@ -403,6 +399,51 @@ private static SKPath BuildFolderPath(LayoutBox box, Theme theme, float scale) return path; } + /// + /// Resolves the rounded-corner radius for a box, preferring a caller-supplied placed-box value so + /// routing and rendering can agree on the exact outline geometry. A negative caller-supplied value + /// is clamped to zero. + /// + private static double ResolveRoundedCornerRadius(LayoutBox box, Theme theme) => + box.RoundedCornerRadius.HasValue + ? Math.Max(0.0, box.RoundedCornerRadius.Value) + : NotationMetrics.RoundedRectRadius(theme); + + /// + /// Resolves the folder tab width for a box, preferring a caller-supplied placed-box value so + /// routing and rendering can agree on the exact top-face geometry. A negative caller-supplied value + /// is clamped to zero. + /// + private static double ResolveFolderTabWidth(LayoutBox box, Theme theme) => + box.FolderTabWidth.HasValue + ? Math.Max(0.0, box.FolderTabWidth.Value) + : Math.Min( + box.Width * NotationMetrics.FolderTabMaxWidthFraction, + Math.Max( + NotationMetrics.FolderTabMinWidth, + (box.Label?.Length ?? 4) * theme.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + + (2.0 * theme.LabelPadding))); + + /// + /// Resolves the folder tab height for a box, preferring a caller-supplied placed-box value so + /// routing and rendering can agree on the exact top-face projection offset. A negative + /// caller-supplied value is clamped to zero. + /// + private static double ResolveFolderTabHeight(LayoutBox box, Theme theme) => + box.FolderTabHeight.HasValue + ? Math.Max(0.0, box.FolderTabHeight.Value) + : BoxMetrics.FolderTabHeight(theme); + + /// + /// Resolves the top Y coordinate (unscaled) of the title/label area for a box. For a + /// outline, the title area is recessed below the tab so that + /// keyword/label text and compartments never overlap the (otherwise empty) tab notch. + /// + private static double ResolveTitleAreaTop(LayoutBox box, Theme theme) => + box.Shape == BoxShape.Folder + ? box.Y + ResolveFolderTabHeight(box, theme) + : box.Y; + /// /// Draws a note-shaped box (a rectangle with a folded-down top-right corner). /// @@ -451,7 +492,7 @@ private static void RenderBoxTitle(SKCanvas canvas, LayoutBox box, RenderOptions var theme = options.Theme; var scale = (float)options.Scale; var centerX = (float)((box.X + box.Width / 2.0) * scale); - var cursorY = box.Y + theme.LabelPadding; + var cursorY = ResolveTitleAreaTop(box, theme) + theme.LabelPadding; // Keyword line (smaller, italic, guillemet-wrapped) above the name if (box.Keyword != null) @@ -496,7 +537,7 @@ private static void RenderBoxCompartments( // Compartments start below the title area (keyword + label), computed via shared metrics var labelAreaHeight = BoxMetrics.TitleAreaHeight(theme, box.Label != null, box.Keyword != null); - var compartmentY = box.Y + labelAreaHeight; + var compartmentY = ResolveTitleAreaTop(box, theme) + labelAreaHeight; foreach (var compartment in box.Compartments) { @@ -1595,4 +1636,3 @@ private static void RenderGrid(SKCanvas canvas, LayoutGrid grid, RenderOptions o } } } - diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index e69910d..09683d0 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -384,8 +384,9 @@ private static void RenderBoxOutline(StringBuilder sb, LayoutBox box, Theme them break; case BoxShape.RoundedRectangle: - var cornerStr = theme.LineCornerRadius > 0 - ? $" rx=\"{F(NotationMetrics.RoundedRectRadius(theme) * scale)}\" ry=\"{F(NotationMetrics.RoundedRectRadius(theme) * scale)}\"" + var cornerRadius = ResolveRoundedCornerRadius(box, theme); + var cornerStr = cornerRadius > 0 + ? $" rx=\"{F(cornerRadius * scale)}\" ry=\"{F(cornerRadius * scale)}\"" : string.Empty; sb.Append(CultureInfo.InvariantCulture, $""" """); @@ -406,12 +407,8 @@ private static void RenderBoxOutline(StringBuilder sb, LayoutBox box, Theme them /// private static void RenderFolderOutline(StringBuilder sb, LayoutBox box, Theme theme, string fillColor, double scale) { - var tabHeight = BoxMetrics.FolderTabHeight(theme); - var tabWidth = Math.Min( - box.Width * NotationMetrics.FolderTabMaxWidthFraction, - Math.Max( - NotationMetrics.FolderTabMinWidth, - (box.Label?.Length ?? 4) * theme.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + 2.0 * theme.LabelPadding)); + var tabHeight = ResolveFolderTabHeight(box, theme); + var tabWidth = ResolveFolderTabWidth(box, theme); var x = box.X * scale; var yTab = box.Y * scale; @@ -425,6 +422,51 @@ private static void RenderFolderOutline(StringBuilder sb, LayoutBox box, Theme t sb.AppendLine(); } + /// + /// Resolves the rounded-corner radius for a box, preferring a caller-supplied placed-box value so + /// routing and rendering can agree on the exact outline geometry. A negative caller-supplied value + /// is clamped to zero. + /// + private static double ResolveRoundedCornerRadius(LayoutBox box, Theme theme) => + box.RoundedCornerRadius.HasValue + ? Math.Max(0.0, box.RoundedCornerRadius.Value) + : NotationMetrics.RoundedRectRadius(theme); + + /// + /// Resolves the folder tab width for a box, preferring a caller-supplied placed-box value so + /// routing and rendering can agree on the exact top-face geometry. A negative caller-supplied value + /// is clamped to zero. + /// + private static double ResolveFolderTabWidth(LayoutBox box, Theme theme) => + box.FolderTabWidth.HasValue + ? Math.Max(0.0, box.FolderTabWidth.Value) + : Math.Min( + box.Width * NotationMetrics.FolderTabMaxWidthFraction, + Math.Max( + NotationMetrics.FolderTabMinWidth, + (box.Label?.Length ?? 4) * theme.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + + (2.0 * theme.LabelPadding))); + + /// + /// Resolves the folder tab height for a box, preferring a caller-supplied placed-box value so + /// routing and rendering can agree on the exact top-face projection offset. A negative + /// caller-supplied value is clamped to zero. + /// + private static double ResolveFolderTabHeight(LayoutBox box, Theme theme) => + box.FolderTabHeight.HasValue + ? Math.Max(0.0, box.FolderTabHeight.Value) + : BoxMetrics.FolderTabHeight(theme); + + /// + /// Resolves the top Y coordinate (unscaled) of the title/label area for a box. For a + /// outline, the title area is recessed below the tab so that + /// keyword/label text and compartments never overlap the (otherwise empty) tab notch. + /// + private static double ResolveTitleAreaTop(LayoutBox box, Theme theme) => + box.Shape == BoxShape.Folder + ? box.Y + ResolveFolderTabHeight(box, theme) + : box.Y; + /// /// Renders a note-shaped outline (a rectangle with a folded-down top-right corner), /// used for documentation and comment nodes. @@ -462,7 +504,7 @@ private static void RenderNoteOutline(StringBuilder sb, LayoutBox box, Theme the private static void RenderBoxTitle(StringBuilder sb, LayoutBox box, Theme theme, double scale) { var centerX = (box.X + box.Width / 2.0) * scale; - var cursorY = box.Y + theme.LabelPadding; + var cursorY = ResolveTitleAreaTop(box, theme) + theme.LabelPadding; // Keyword line (smaller, italic, guillemet-wrapped) above the name if (box.Keyword != null) @@ -522,7 +564,7 @@ private static void RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme { // Compartments start below the title area (keyword + label), computed via shared metrics var labelAreaHeight = BoxMetrics.TitleAreaHeight(theme, box.Label != null, box.Keyword != null); - var compartmentY = box.Y + labelAreaHeight; + var compartmentY = ResolveTitleAreaTop(box, theme) + labelAreaHeight; foreach (var compartment in box.Compartments) { @@ -1177,4 +1219,3 @@ private static string EscapeXml(string text) => .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); } - diff --git a/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs b/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs index c05400a..83d0c40 100644 --- a/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs +++ b/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs @@ -71,6 +71,9 @@ public sealed class LayoutGraphNode : PropertyHolder /// private LayoutGraph? _children; + /// Backing store for ; never . + private IReadOnlyList _compartments = []; + /// /// Initializes a new instance of the class. /// @@ -99,6 +102,71 @@ public LayoutGraphNode(string id, double width, double height) /// Gets or sets an optional text label rendered inside the node. public string? Label { get; set; } + /// + /// Gets or sets the visual shape of the box a leaf algorithm places for this node. Defaults to + /// ; set to for a package-style + /// container, for example. + /// + public BoxShape Shape { get; set; } = BoxShape.Rectangle; + + /// + /// Gets or sets an optional keyword (for example "part def") rendered on a smaller line + /// above the node's , following the SysML v2 graphical convention. + /// when no keyword line should be shown. + /// + public string? Keyword { get; set; } + + /// + /// Gets or sets the ordered list of compartments (for example an attributes or ports section) + /// displayed below the node's label when it is placed as a . Empty by + /// default; assigning is coerced to an empty list. + /// + public IReadOnlyList Compartments + { + get => _compartments; + set => _compartments = value ?? []; + } + + /// + /// Gets or sets the height, in logical pixels, of the title band a hierarchical layout engine + /// reserves above this node's children when it acts as a labelled container. + /// (the default) selects the engine's own generic default band height; set this explicitly — for + /// example to a theme's computed title-area height — when the container also carries a + /// or a larger title font than the generic default assumes, so the reserved + /// band matches what the renderer will actually draw. Ignored for a leaf node (one with no + /// children). + /// + public double? TitleHeight { get; set; } + + /// + /// Gets or sets the rounded-corner radius, in logical pixels, that a caller has already resolved + /// for this node's outline. means + /// downstream components should use their own generic fallback. Set this when the layout and + /// rendering pipelines must agree on the exact rounded-rectangle face extents — for example so a + /// connector router keeps anchors off the corner arcs while the renderer draws the matching + /// outline. + /// + public double? RoundedCornerRadius { get; set; } + + /// + /// Gets or sets the folder-tab width, in logical pixels, that a caller has already resolved for + /// this node's outline. means downstream + /// components should use their own generic fallback. Set this when routing and rendering must agree + /// on the exact width of the raised tab that occupies the folder's top-left edge, so connectors can + /// avoid anchoring on that tab strip. + /// + public double? FolderTabWidth { get; set; } + + /// + /// Gets or sets the folder-tab height, in logical pixels, that a caller has already resolved for + /// this node's outline. means downstream + /// components should use their own generic fallback. Set this when routing and rendering must agree + /// on how far the folder body's real top edge sits below the bounding box's top edge, so connector + /// anchors projected onto the folder's top face touch the drawn outline instead of floating above + /// it. + /// + public double? FolderTabHeight { get; set; } + /// /// Gets the child subgraph nested inside this node, turning the node into a container of nodes and /// contained edges. diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs index 0533bf4..2cabf43 100644 --- a/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs @@ -48,6 +48,21 @@ public sealed record LayoutCompartment( /// above the bold label, following the SysML v2 graphical convention. when no /// keyword should be shown. /// +/// +/// Optional rounded-corner radius, in logical pixels, already resolved for a +/// outline. means a downstream +/// renderer or router should use its own generic fallback. +/// +/// +/// Optional folder-tab width, in logical pixels, already resolved for a +/// outline. means a downstream renderer or router should use its own generic +/// fallback. +/// +/// +/// Optional folder-tab height, in logical pixels, already resolved for a +/// outline. means a downstream renderer or router should use its own generic +/// fallback. +/// public sealed record LayoutBox( double X, double Y, @@ -58,4 +73,7 @@ public sealed record LayoutBox( BoxShape Shape, IReadOnlyList Compartments, IReadOnlyList Children, - string? Keyword = null) : LayoutNode; + string? Keyword = null, + double? RoundedCornerRadius = null, + double? FolderTabWidth = null, + double? FolderTabHeight = null) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/NamespaceDoc.cs b/src/DemaConsulting.Rendering/NamespaceDoc.cs index 33d90d3..01258d0 100644 --- a/src/DemaConsulting.Rendering/NamespaceDoc.cs +++ b/src/DemaConsulting.Rendering/NamespaceDoc.cs @@ -38,7 +38,7 @@ namespace DemaConsulting.Rendering; /// /// /// Lay it out with the DemaConsulting.Rendering.Layout package: the one-call -/// LayoutEngine.Layout(graph, options) facade resolves whatever algorithm the graph +/// LayoutEngine.Layout(graph) facade resolves whatever algorithm the graph /// declares, or select a specific ILayoutAlgorithm (layered, containment, /// or the recursive hierarchical engine) yourself. This yields a placed /// . @@ -86,8 +86,9 @@ namespace DemaConsulting.Rendering; /// graph.AddEdge("b-c", b, c); /// /// // 2. Lay it out with the one-call facade (default: hierarchical, byte-identical to -/// // layered for this flat graph). -/// LayoutTree tree = LayoutEngine.Layout(graph, new LayoutOptions()); +/// // layered for this flat graph). Configure the graph itself (e.g. +/// // graph.Set(CoreOptions.Algorithm, "layered")) to change that. +/// LayoutTree tree = LayoutEngine.Layout(graph); /// /// // 3. Render the placed tree to SVG. /// using var output = File.Create("diagram.svg"); diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index 48d66d5..51262f3 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -43,6 +43,9 @@ internal static class GalleryCatalog public const string ThemePrintPng = "theme-print.png"; public const string LayeredPipelinePng = "layered-pipeline.png"; public const string HierarchicalNestedPng = "hierarchical-nested.png"; + public const string BoxAppearanceSvg = "box-appearance.svg"; + public const string FolderTopFaceAnchorSvg = "folder-top-face-anchor.svg"; + public const string ShapeGallerySvg = "shape-gallery.svg"; /// Gets the browsable sections of the gallery, in display order. public static IReadOnlyList Sections { get; } = @@ -132,5 +135,38 @@ internal static class GalleryCatalog "Hierarchical nested diagram as PNG", "The hierarchical nested diagram rendered to a raster PNG image."), ]), + new GallerySection( + "Box appearance", + "A node's Shape, Keyword, and Compartments properties select the box outline, an " + + "italicized keyword line, and labelled feature sections, all through the plain input " + + "graph model — no downstream renderer-specific code required. This is generic block-" + + "diagram notation; SysML is just one modeling language that uses it.", + [ + new GalleryImage( + BoxAppearanceSvg, + "A folder container with two boxes carrying a keyword, one also compartmented, joined by an edge", + "A folder container holding two boxes with a keyword line — one also with a labelled " + + "compartment — joined by a decorated edge."), + ]), + new GallerySection( + "Shape-aware connectors", + "A box's Shape can make its true outline diverge from its plain bounding rectangle — a " + + "folder's tab, a note's folded corner, a rounded rectangle's corners. The router keeps " + + "connectors off those non-connectable regions and projects each anchor down to the shape's " + + "actual drawn outline, so every connector visibly touches the shape it targets.", + [ + new GalleryImage( + FolderTopFaceAnchorSvg, + "An external node connected into a folder top face, clear of the tab", + "An edge approaching a folder container from above: the connector avoids the tab and " + + "anchors on the folder's recessed top edge instead of floating above it."), + new GalleryImage( + ShapeGallerySvg, + "One of each container shape side by side, each holding content", + "Every Shape value side by side, each with content appropriate to it: rectangle and " + + "rounded-rectangle boxes with a keyword and a compartment, a folder holding a " + + "nested child, and a note holding free-form text — every shape reserves enough " + + "space so its content never overlaps the tab or the folded corner."), + ]), ]; } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index 236dc8e..ded9707 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -2,6 +2,8 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using DemaConsulting.Rendering.Abstractions; + namespace DemaConsulting.Rendering.Gallery; /// @@ -205,6 +207,164 @@ public static LayoutGraph MixedDirectionNested() return graph; } + /// + /// A folder container holding two boxes with a keyword line, one also compartmented, joined by a decorated edge — + /// a block-diagram notation used by SysML and similar modeling languages, but expressed purely + /// through the generic , , + /// and properties on the input graph model, with no + /// SysML-specific code anywhere in the rendering pipeline. + /// + /// + /// A leaf algorithm places a box at exactly the width and height its node declares — it never + /// grows a box to fit a keyword line or compartment rows — so the caller must size each node + /// tall enough to hold its title area () plus every + /// compartment's own rows, exactly as a caller such as a SysML general-view layout strategy does. + /// + /// A two-level compound graph with a folder container and boxes carrying a keyword and compartments. + public static LayoutGraph BoxAppearance() + { + var theme = Themes.Dark; + var titleHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); + + var graph = new LayoutGraph(); + + var pkg = graph.AddNode("pkg", 10, 10); + pkg.Label = "Powertrain"; + pkg.Shape = BoxShape.Folder; + pkg.Keyword = "package"; + + // The "ports" compartment adds a title row plus one row per port, sized from the same theme + // metrics the renderer uses, so the compartment text never overflows the box. + var portsCompartment = new LayoutCompartment("ports", ["intake : FluidPort", "exhaust : FluidPort"]); + var compartmentHeight = theme.LabelPadding + theme.FontSizeBody + theme.LabelPadding // title row + + (portsCompartment.Rows.Count * (theme.LabelPadding + theme.FontSizeBody)) // data rows + + theme.LabelPadding; // bottom gap + + var engine = pkg.Children.AddNode("engine", 160, titleHeight + compartmentHeight); + engine.Label = "Engine"; + engine.Keyword = "part def"; + engine.Compartments = [portsCompartment]; + + var motor = pkg.Children.AddNode("motor", 160, titleHeight); + motor.Label = "ElectricMotor"; + motor.Keyword = "part def"; + + var edge = pkg.Children.AddEdge("motor-engine", motor, engine); + edge.TargetEnd = EndMarkerStyle.HollowTriangle; + + return graph; + } + + /// + /// An edge landing on a container's top face, demonstrating + /// shape-aware connector anchoring: the router keeps the connector off the folder's tab (the + /// small raised label strip at the top-left) and projects the anchor down to the folder's + /// actual recessed outline instead of the plain bounding rectangle, so the line visibly touches + /// the drawn shape rather than floating above it. + /// + /// + /// The edge connects directly to the folder node itself (a direct root member, not a + /// descendant), so it belongs to the root's own leaf view and its ranking honors the downward + /// flow direction below, reliably placing Client above the folder — the one relationship + /// where the tab's presence actually matters. A cross-container edge into a descendant would be + /// excluded from that view and ranked only by insertion order, not by flow direction. + /// + /// A compound graph with an external node connected into a folder container from above. + public static LayoutGraph FolderTopFaceAnchor() + { + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var pkg = graph.AddNode("utilities", 10, 10); + pkg.Label = "Utilities"; + pkg.Shape = BoxShape.Folder; + pkg.Keyword = "package"; + + var globMatcher = AddLabelled(pkg.Children, "glob-matcher", "GlobMatcher"); + var pathHelpers = AddLabelled(pkg.Children, "path-helpers", "PathHelpers"); + Connect(pkg.Children, "glob-matcher-path-helpers", globMatcher, pathHelpers); + + var client = AddLabelled(graph, "client", "Client"); + + // Connect directly to the folder node itself (a direct root member, not a descendant), so this + // edge belongs to the root's own leaf view and its ranking honors the Direction override above, + // reliably placing Client above the folder. A cross-container edge into a descendant would be + // excluded from that view and ranked only by insertion order, not by Direction. + Connect(graph, "client-utilities", client, pkg); + + return graph; + } + + /// + /// One sibling of each value, side by side, each carrying content + /// appropriate to that shape: a plain rectangle and a rounded rectangle each with a keyword line + /// and a labelled compartment, a folder holding a nested child box, and a note holding an + /// untitled compartment of free-form text. Demonstrates that every shape reserves enough space + /// for its own content (title area, compartments, or nested children) without the content + /// overlapping the shape's non-rectangular features (the folder's tab, the note's folded + /// corner). + /// + /// A flat graph of four sibling containers, one per value. + public static LayoutGraph ShapeGallery() + { + var theme = Themes.Dark; + var graph = new LayoutGraph(); + + var titledKeywordHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); + var titledOnlyHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: false); + + // A plain rectangle with a keyword line and a labelled compartment. + var attributesCompartment = new LayoutCompartment("attributes", ["value : Real", "unit : String"]); + var sensor = graph.AddNode("sensor", 160, titledKeywordHeight + TitledCompartmentHeight(theme, attributesCompartment)); + sensor.Label = "Sensor"; + sensor.Keyword = "part def"; + sensor.Compartments = [attributesCompartment]; + + // A rounded rectangle with a keyword line and a labelled compartment. + var portsCompartment = new LayoutCompartment("ports", ["cmd : Signal"]); + var controller = graph.AddNode("controller", 160, titledKeywordHeight + TitledCompartmentHeight(theme, portsCompartment)); + controller.Label = "Controller"; + controller.Keyword = "part def"; + controller.Shape = BoxShape.RoundedRectangle; + controller.RoundedCornerRadius = 14.0; + controller.Compartments = [portsCompartment]; + + // A folder holding a single nested child box. + var utilities = graph.AddNode("utilities", 160, titledKeywordHeight + 50 + (2 * 12)); + utilities.Label = "Utilities"; + utilities.Keyword = "package"; + utilities.Shape = BoxShape.Folder; + AddLabelled(utilities.Children, "path-helpers", "PathHelpers"); + + // A note holding an untitled compartment of free-form text, exercising the folded-corner + // routing fix: the compartment divider and text sit clear of the diagonal fold. + var noteBody = new LayoutCompartment(null, ["Values expressed in SI units", "unless stated otherwise."]); + var note = graph.AddNode("note", 200, titledOnlyHeight + UntitledCompartmentHeight(theme, noteBody)); + note.Label = "Note"; + note.Shape = BoxShape.Note; + note.Compartments = [noteBody]; + + return graph; + } + + /// + /// Computes the height of a titled compartment (a title row plus one row per line, plus the + /// leading title-area gap and the trailing bottom gap), matching the renderer's own layout + /// formula so the compartment content never overflows the box. + /// + private static double TitledCompartmentHeight(Theme theme, LayoutCompartment compartment) => + theme.LabelPadding + theme.FontSizeBody + theme.LabelPadding // title row + + (compartment.Rows.Count * (theme.LabelPadding + theme.FontSizeBody)) // data rows + + theme.LabelPadding; // bottom gap + + /// + /// Computes the height of an untitled compartment (one row per line, no title row), matching the + /// renderer's own layout formula so the compartment content never overflows the box. + /// + private static double UntitledCompartmentHeight(Theme theme, LayoutCompartment compartment) => + (compartment.Rows.Count * (theme.LabelPadding + theme.FontSizeBody)) // data rows + + theme.LabelPadding; // bottom gap + /// Adds a labelled leaf node of the standard showcase size to the given graph. private static LayoutGraphNode AddLabelled(LayoutGraph graph, string id, string label) { diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index 23b8237..e06f05b 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -22,10 +22,12 @@ public sealed class GalleryShowcaseTests [Fact] public void Gallery_LayeredPipeline_RendersSvg() { + var graph = GalleryDiagrams.LayeredPipeline(); + graph.Set(CoreOptions.Algorithm, "layered"); + GalleryWriter.Svg( GalleryCatalog.LayeredPipelineSvg, - GalleryDiagrams.LayeredPipeline(), - LayoutOptions.ForAlgorithm("layered"), + graph, Themes.Dark); } @@ -36,10 +38,12 @@ public void Gallery_LayeredPipeline_RendersSvg() [Fact] public void Gallery_ContainmentPacked_RendersSvg() { + var graph = GalleryDiagrams.ContainmentPacked(); + graph.Set(CoreOptions.Algorithm, "containment"); + GalleryWriter.Svg( GalleryCatalog.ContainmentPackedSvg, - GalleryDiagrams.ContainmentPacked(), - LayoutOptions.ForAlgorithm("containment"), + graph, Themes.Dark); } @@ -53,7 +57,6 @@ public void Gallery_HierarchicalNested_RendersSvg() GalleryWriter.Svg( GalleryCatalog.HierarchicalNestedSvg, GalleryDiagrams.HierarchicalNested(), - new LayoutOptions(), Themes.Dark); } @@ -67,7 +70,6 @@ public void Gallery_OrthogonalObstacle_RendersSvg() GalleryWriter.Svg( GalleryCatalog.OrthogonalObstacleSvg, GalleryDiagrams.OrthogonalObstacle(), - new LayoutOptions(), Themes.Dark); } @@ -78,13 +80,13 @@ public void Gallery_OrthogonalObstacle_RendersSvg() [Fact] public void Gallery_DirectionRight_RendersSvg() { - var options = LayoutOptions.ForAlgorithm("layered"); - options.Set(CoreOptions.Direction, LayoutFlowDirection.Right); + var graph = GalleryDiagrams.DirectionShowcase(); + graph.Set(CoreOptions.Algorithm, "layered"); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Right); GalleryWriter.Svg( GalleryCatalog.DirectionRightSvg, - GalleryDiagrams.DirectionShowcase(), - options, + graph, Themes.Dark); } @@ -95,13 +97,13 @@ public void Gallery_DirectionRight_RendersSvg() [Fact] public void Gallery_DirectionDown_RendersSvg() { - var options = LayoutOptions.ForAlgorithm("layered"); - options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + var graph = GalleryDiagrams.DirectionShowcase(); + graph.Set(CoreOptions.Algorithm, "layered"); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); GalleryWriter.Svg( GalleryCatalog.DirectionDownSvg, - GalleryDiagrams.DirectionShowcase(), - options, + graph, Themes.Dark); } @@ -116,7 +118,6 @@ public void Gallery_MixedDirectionNested_RendersSvg() GalleryWriter.Svg( GalleryCatalog.MixedDirectionNestedSvg, GalleryDiagrams.MixedDirectionNested(), - new LayoutOptions(), Themes.Dark); } @@ -130,7 +131,6 @@ public void Gallery_ThemeLight_RendersPng() GalleryWriter.Png( GalleryCatalog.ThemeLightPng, GalleryDiagrams.ThemeShowcase(), - new LayoutOptions(), Themes.Light); } @@ -144,7 +144,6 @@ public void Gallery_ThemeDark_RendersPng() GalleryWriter.Png( GalleryCatalog.ThemeDarkPng, GalleryDiagrams.ThemeShowcase(), - new LayoutOptions(), Themes.Dark); } @@ -158,7 +157,6 @@ public void Gallery_ThemePrint_RendersPng() GalleryWriter.Png( GalleryCatalog.ThemePrintPng, GalleryDiagrams.ThemeShowcase(), - new LayoutOptions(), Themes.Print); } @@ -169,10 +167,12 @@ public void Gallery_ThemePrint_RendersPng() [Fact] public void Gallery_LayeredPipeline_RendersPng() { + var graph = GalleryDiagrams.LayeredPipeline(); + graph.Set(CoreOptions.Algorithm, "layered"); + GalleryWriter.Png( GalleryCatalog.LayeredPipelinePng, - GalleryDiagrams.LayeredPipeline(), - LayoutOptions.ForAlgorithm("layered"), + graph, Themes.Dark); } @@ -186,7 +186,48 @@ public void Gallery_HierarchicalNested_RendersPng() GalleryWriter.Png( GalleryCatalog.HierarchicalNestedPng, GalleryDiagrams.HierarchicalNested(), - new LayoutOptions(), + Themes.Dark); + } + + /// + /// Renders the box-appearance showcase to SVG, proving a folder container's + /// and , and a nested + /// box's and , + /// all render correctly when selected purely through the input graph model. + /// + [Fact] + public void Gallery_BoxAppearance_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.BoxAppearanceSvg, + GalleryDiagrams.BoxAppearance(), + Themes.Dark); + } + + /// + /// Renders the folder-top-face-anchor diagram to SVG, proving an edge approaching a folder + /// container from above anchors clear of the tab, on the folder's actual outline. + /// + [Fact] + public void Gallery_FolderTopFaceAnchor_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.FolderTopFaceAnchorSvg, + GalleryDiagrams.FolderTopFaceAnchor(), + Themes.Dark); + } + + /// + /// Renders the shape-gallery diagram to SVG, proving every value reserves + /// enough space for its own content (title area, compartments, or nested children) without that + /// content overlapping the shape's non-rectangular features. + /// + [Fact] + public void Gallery_ShapeGallery_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.ShapeGallerySvg, + GalleryDiagrams.ShapeGallery(), Themes.Dark); } } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs b/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs index 296c529..c25228f 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs @@ -27,16 +27,19 @@ internal static class GalleryWriter private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; /// - /// Lays out with , renders it to - /// as SVG, and asserts the result is a well-formed SVG document. + /// Lays out with whatever algorithm and options it declares (see + /// ), renders it to as SVG, and asserts the + /// result is a well-formed SVG document. /// /// Stable output filename (for example layered-pipeline.svg). - /// The graph to lay out. - /// The layout options selecting the algorithm. + /// + /// The graph to lay out. Configure it directly (for example + /// graph.Set(CoreOptions.Algorithm, "layered")) before calling this method. + /// /// The theme to render with. - public static void Svg(string fileName, LayoutGraph graph, LayoutOptions options, Theme theme) + public static void Svg(string fileName, LayoutGraph graph, Theme theme) { - var tree = LayoutEngine.Layout(graph, options); + var tree = LayoutEngine.Layout(graph); var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); using (var stream = File.Create(path)) @@ -48,16 +51,19 @@ public static void Svg(string fileName, LayoutGraph graph, LayoutOptions options } /// - /// Lays out with , renders it to - /// as PNG, and asserts the result decodes as a valid raster image. + /// Lays out with whatever algorithm and options it declares (see + /// ), renders it to as PNG, and asserts the + /// result decodes as a valid raster image. /// /// Stable output filename (for example layered-pipeline.png). - /// The graph to lay out. - /// The layout options selecting the algorithm. + /// + /// The graph to lay out. Configure it directly (for example + /// graph.Set(CoreOptions.Algorithm, "layered")) before calling this method. + /// /// The theme to render with. - public static void Png(string fileName, LayoutGraph graph, LayoutOptions options, Theme theme) + public static void Png(string fileName, LayoutGraph graph, Theme theme) { - var tree = LayoutEngine.Layout(graph, options); + var tree = LayoutEngine.Layout(graph); var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); using (var stream = File.Create(path)) diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs index cc1e1f4..455bbe3 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs @@ -3,6 +3,7 @@ // using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; using DemaConsulting.Rendering.Layout; namespace DemaConsulting.Rendering.Layout.Tests; @@ -16,8 +17,29 @@ public sealed class ConnectorRouterTests /// Creates a plain rectangular at the given placement with no /// compartments or children. /// - private static LayoutBox Box(double x, double y, double width, double height, string? label = null) => - new(x, y, width, height, label, 0, BoxShape.Rectangle, [], []); + private static LayoutBox Box( + double x, + double y, + double width, + double height, + string? label = null, + BoxShape shape = BoxShape.Rectangle, + double? roundedCornerRadius = null, + double? folderTabWidth = null, + double? folderTabHeight = null) => + new( + x, + y, + width, + height, + label, + 0, + shape, + [], + [], + RoundedCornerRadius: roundedCornerRadius, + FolderTabWidth: folderTabWidth, + FolderTabHeight: folderTabHeight); /// /// When the target box lies to the right, the source anchor sits on the source box's right @@ -68,6 +90,273 @@ public void Route_TargetBelow_AnchorsFaceEachOther() Assert.Equal(to.Y, end.Y, 6); // top face of target } + /// + /// When only a tiny shared span sits at one end of a much longer face, anchor selection clamps + /// the long face inward by the configured clearance instead of hugging that corner. + /// + [Fact] + public void ConnectorRouter_Route_SmallSharedSpanOnLongFace_UsesClearanceInset() + { + // Arrange: a tall source box overlaps a short target box only at the very top of the source face. + var from = Box(0, 0, 60, 100); + var to = Box(200, 0, 60, 10); + var boxes = new[] { from, to }; + var options = new ConnectorRouteOptions(Clearance: 12.0); + + // Act: route from the tall box into the short box across their facing left/right faces. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), options); + + // Assert: the long source face stays at least the configured clearance away from both ends. + var start = line.Waypoints[0]; + Assert.Equal(from.X + from.Width, start.X, 6); + Assert.Equal(from.Y + options.Clearance, start.Y, 6); + } + + /// + /// When a face is too short to keep the configured clearance from both ends, anchor selection + /// falls back to that face's own center instead of violating the margin. + /// + [Fact] + public void ConnectorRouter_Route_ShortFaceForMargin_UsesFaceCenter() + { + // Arrange: the source face is shorter than twice the requested clearance. + var from = Box(0, 0, 60, 10); + var to = Box(200, 0, 60, 100); + var boxes = new[] { from, to }; + var options = new ConnectorRouteOptions(Clearance: 12.0); + + // Act: route from the short box into the tall box. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), options); + + // Assert: the short source face anchors at its own midpoint. + var start = line.Waypoints[0]; + Assert.Equal(from.X + from.Width, start.X, 6); + Assert.Equal(from.Y + (from.Height / 2.0), start.Y, 6); + } + + /// + /// A folder's top-face connectable extent excludes the raised tab strip, so a connector + /// approaching from above clamps to the first usable point to the right of the tab instead of + /// anchoring on the tab itself. + /// + [Fact] + public void Route_FolderTopFace_TabExcludedFromConnectableExtent() + { + // Arrange: the source sits above the folder and overlaps only the tab region horizontally. + var from = Box(40, 0, 40, 40); + var to = Box(0, 120, 140, 90, "Utilities", BoxShape.Folder, folderTabWidth: 60.0, folderTabHeight: 24.0); + var boxes = new[] { from, to }; + + // Act: route directly into the folder from above. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the target anchor lands to the right of the tab strip and on the recessed body top. + var end = line.Waypoints[^1]; + Assert.True(end.X > to.X + 60.0, "Target anchor should clamp to the usable top-face extent right of the tab."); + Assert.Equal(to.Y + 24.0, end.Y, 6); + } + + /// + /// The generic face-end clearance composes with a shape's own narrower connectable extent, so a + /// folder top-face anchor stays inside the usable body-top span rather than overriding it. + /// + [Fact] + public void ConnectorRouter_Route_FolderTopFace_ComposesClearanceWithReducedExtent() + { + // Arrange: the source overlaps a point already right of the tab, but still inside the reduced + // top-face span that should receive the extra clearance inset. + var from = Box(50, 0, 30, 30); + var to = Box(0, 120, 140, 90, "Utilities", BoxShape.Folder, folderTabWidth: 60.0, folderTabHeight: 24.0); + var boxes = new[] { from, to }; + var options = new ConnectorRouteOptions(Clearance: 12.0); + + // Act: route directly into the folder from above. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), options); + + // Assert: the anchor respects both the folder extent ([61, 140]) and the added clearance, + // producing the first usable point at X = 61 + 12 = 73 on the recessed body top. + var end = line.Waypoints[^1]; + Assert.Equal(to.X + 73.0, end.X, 6); + Assert.Equal(to.Y + 24.0, end.Y, 6); + } + + /// + /// When only the folder-tab width is supplied, the router still applies its generic tab-height + /// fallback so the anchor remains off the tab and lands on the recessed body top. + /// + [Fact] + public void Route_FolderTopFace_WidthOnlyHint_UsesFallbackTabHeight() + { + // Arrange: the source overlaps only the explicit tab width while the height must come from the fallback. + var from = Box(40, 0, 40, 40); + var to = Box(0, 120, 140, 90, "Utilities", BoxShape.Folder, folderTabWidth: 60.0); + var boxes = new[] { from, to }; + var expectedTabHeight = BoxMetrics.FolderTabHeight(Themes.Light); + + // Act: route directly into the folder from above. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the target anchor still avoids the tab and projects onto the recessed body top. + var end = line.Waypoints[^1]; + Assert.True(end.X > to.X + 60.0, "Target anchor should remain to the right of the explicit tab width."); + Assert.Equal(to.Y + expectedTabHeight, end.Y, 6); + } + + /// + /// When only the folder-tab height is supplied, the router still applies its generic tab-width + /// fallback so the top-face anchor avoids the drawn tab and projects onto the recessed body top. + /// + [Fact] + public void Route_FolderTopFace_HeightOnlyHint_UsesFallbackTabWidth() + { + // Arrange: the source overlaps the fallback tab region while the width must come from the fallback formula. + var from = Box(40, 0, 40, 40); + var to = Box(0, 120, 140, 90, "Utilities", BoxShape.Folder, folderTabHeight: 24.0); + var boxes = new[] { from, to }; + var expectedTabWidth = Math.Min( + to.Width * NotationMetrics.FolderTabMaxWidthFraction, + Math.Max( + NotationMetrics.FolderTabMinWidth, + (to.Label?.Length ?? 4) * Themes.Light.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + + (2.0 * Themes.Light.LabelPadding))); + + // Act: route directly into the folder from above. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the target anchor stays to the right of the fallback tab and reaches the recessed body top. + var end = line.Waypoints[^1]; + Assert.True(end.X > to.X + expectedTabWidth, "Target anchor should remain to the right of the fallback tab width."); + Assert.Equal(to.Y + 24.0, end.Y, 6); + } + + /// + /// When several connectors share a folder's top face, their anchors are distributed across the + /// usable top-face extent to the right of the tab, not across the full box width. + /// + [Fact] + public void Route_SharedFolderTopFace_DistributesAcrossReducedExtent() + { + // Arrange: three source boxes above a folder, all converging on its top face. + var s1 = Box(0, 0, 30, 30); + var s2 = Box(55, 0, 30, 30); + var s3 = Box(110, 0, 30, 30); + var folder = Box(0, 120, 140, 90, "Utilities", BoxShape.Folder, folderTabWidth: 60.0, folderTabHeight: 24.0); + var boxes = new[] { s1, s2, s3, folder }; + var connections = new[] + { + new Connection(s1, folder), + new Connection(s2, folder), + new Connection(s3, folder), + }; + + // Act: route them as a batch so the shared-face distribution logic engages. + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + var targets = lines.Select(line => line.Waypoints[^1]).ToArray(); + + // Assert: every target anchor stays on the recessed body top and to the right of the tab. + Assert.All(targets, target => + { + Assert.True(target.X > folder.X + 60.0, "Target anchor should remain off the folder tab."); + Assert.Equal(folder.Y + 24.0, target.Y, 6); + }); + + // Assert: the anchors are distinct and ordered across the reduced usable span. + Assert.True(targets[0].X < targets[1].X, "First target anchor should sit left of the second."); + Assert.True(targets[1].X < targets[2].X, "Second target anchor should sit left of the third."); + } + + /// + /// Surface projection applies the folder tab height as an inward offset on the top face, so the + /// final anchor touches the folder body's recessed outline rather than the bounding-box edge. + /// + [Fact] + public void Route_FolderTopFace_ProjectsAnchorToRecessedBodyTop() + { + // Arrange: the source overlaps a clearly usable part of the folder's top face. + var from = Box(95, 0, 20, 20); + var to = Box(0, 120, 140, 90, "Utilities", BoxShape.Folder, folderTabWidth: 60.0, folderTabHeight: 24.0); + var boxes = new[] { from, to }; + + // Act + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the route still enters from above, but the final touch point is recessed by the tab height. + Assert.Equal(line.Waypoints[^1].X, line.Waypoints[^2].X, 6); + Assert.True(line.Waypoints[^2].Y < line.Waypoints[^1].Y, "Last segment should enter the top face from above."); + Assert.Equal(to.Y + 24.0, line.Waypoints[^1].Y, 6); + } + + /// + /// When the naturally-facing face has an empty connectable extent, anchor selection falls back + /// to the first usable adjacent face in the documented preference order. + /// + [Fact] + public void Route_FaceSelectionFallback_EmptyNaturalFaceUsesAdjacentFace() + { + // Arrange: the source sits above-left of a folder whose top face is entirely consumed by the tab. + var from = Box(90, 0, 20, 20); + var to = Box(100, 100, 80, 60, "Package", BoxShape.Folder, folderTabWidth: 80.0, folderTabHeight: 24.0); + var boxes = new[] { from, to }; + + // Act + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the target cannot use its top face, so it falls back to its left face. + var end = line.Waypoints[^1]; + Assert.Equal(to.X, end.X, 6); + Assert.Equal(to.Y + (to.Height / 2.0), end.Y, 6); + } + + /// + /// A note's top face excludes the portion consumed by the folded-corner cut, so a connector + /// approaching from above clamps to the first usable point left of the fold instead of anchoring + /// in the cut-off triangle. + /// + [Fact] + public void Route_NoteTopFace_FoldExcludedFromConnectableExtent() + { + // Arrange: the source sits above the note and overlaps only the folded-corner region horizontally. + // A 140x90 note folds min(140, 90) * 0.25 = 22.5, capped at NoteFoldMaxSize (16). + var from = Box(130, 0, 40, 40); + var to = Box(0, 120, 140, 90, "Design Note", BoxShape.Note); + var boxes = new[] { from, to }; + + // Act: route directly into the note from above. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the target anchor lands left of the fold and directly on the bounding-box top edge + // (no projection offset — the safe zone already lies on the real outline). + var end = line.Waypoints[^1]; + Assert.True( + end.X < to.X + to.Width - NotationMetrics.NoteFoldMaxSize, + "Target anchor should clamp to the usable top-face extent left of the fold."); + Assert.Equal(to.Y, end.Y, 6); + } + + /// + /// A note's right face excludes the portion consumed by the folded-corner cut (the topmost + /// strip), so a connector approaching from the right clamps below the fold instead of anchoring + /// in the cut-off triangle. + /// + [Fact] + public void Route_NoteRightFace_FoldExcludedFromConnectableExtent() + { + // Arrange: the source sits to the right of the note, level with the folded-corner region. + var from = Box(200, 120, 40, 20); + var to = Box(0, 120, 140, 90, "Design Note", BoxShape.Note); + var boxes = new[] { from, to }; + + // Act: route directly into the note from the right. + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the target anchor lands below the fold and directly on the bounding-box right edge. + var end = line.Waypoints[^1]; + Assert.True( + end.Y > to.Y + NotationMetrics.NoteFoldMaxSize, + "Target anchor should clamp to the usable right-face extent below the fold."); + Assert.Equal(to.X + to.Width, end.X, 6); + } + /// /// A connector between two boxes routes around an intervening obstacle box without passing /// through the obstacle's interior, while still producing an axis-aligned path. @@ -167,6 +456,341 @@ public void Route_MultipleConnections_ReturnsOneLinePerConnectionInOrder() Assert.Equal(EndMarkerStyle.HollowDiamond, lines[1].TargetEnd); } + /// + /// Reproduces the DictionaryMark rendering defect: three small source boxes stacked far below a + /// much taller target box, each connected to the same target. Routed independently, every + /// connector's naive anchor clamps to the exact same target-box corner because none of the + /// source boxes overlap the target vertically. Routed as a batch, the three target anchors must + /// be spread apart instead. + /// + [Fact] + public void Route_ThreeConnectorsShareTargetFace_BatchSpreadsTargetAnchors() + { + // Arrange: geometry taken directly from DictionaryMark's generated SVG. + var yamlDotNet = Box(24, 1265, 130, 50, "YamlDotNet"); + var fileSystemGlobbing = Box(24, 1345, 168.24, 50, "FileSystemGlobbing"); + var testResults = Box(24, 1425, 130, 50, "TestResults"); + var dictionaryMarkSystem = Box(1037.52, 421, 308.16, 224, "DictionaryMarkSystem"); + var boxes = new[] { yamlDotNet, fileSystemGlobbing, testResults, dictionaryMarkSystem }; + + var connections = new[] + { + new Connection(yamlDotNet, dictionaryMarkSystem, EndMarkerStyle.FilledDiamond), + new Connection(fileSystemGlobbing, dictionaryMarkSystem, EndMarkerStyle.FilledDiamond), + new Connection(testResults, dictionaryMarkSystem, EndMarkerStyle.FilledDiamond), + }; + + // Act: route the single-connection overload to confirm the defect this test guards against, + // then route the same connections as a batch. + var independentTargets = connections + .Select(c => ConnectorRouter.Route(boxes, c, new ConnectorRouteOptions()).Waypoints[^1]) + .ToArray(); + var batchLines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + var batchTargets = batchLines.Select(l => l.Waypoints[^1]).ToArray(); + + // Assert: routed independently, all three connectors collapse onto the identical target point + // (the defect reported against DictionaryMark's diagram). + Assert.Equal(independentTargets[0].Y, independentTargets[1].Y, 6); + Assert.Equal(independentTargets[1].Y, independentTargets[2].Y, 6); + + // Assert: routed as a batch, the three target anchors are distinct and ordered to match the + // source boxes' own stacking order (YamlDotNet above FileSystemGlobbing above TestResults). + Assert.True(batchTargets[0].Y < batchTargets[1].Y, "First target anchor should sit above the second."); + Assert.True(batchTargets[1].Y < batchTargets[2].Y, "Second target anchor should sit above the third."); + + // Assert: every batch target anchor still lands on the target box's left face. + Assert.All(batchTargets, p => Assert.Equal(dictionaryMarkSystem.X, p.X, 6)); + + // Assert: every batch target anchor stays within the target box's own vertical extent. + Assert.All(batchTargets, p => + { + Assert.True(p.Y >= dictionaryMarkSystem.Y, "Target anchor should not sit above the target box."); + Assert.True(p.Y <= dictionaryMarkSystem.Y + dictionaryMarkSystem.Height, "Target anchor should not sit below the target box."); + }); + } + + /// + /// When a box face is shared by only one connector in the batch, that connector's anchor is + /// left exactly as independent single-connection routing would produce — the redistribution + /// logic only engages for faces with two or more connectors. + /// + [Fact] + public void Route_BatchWithoutSharedFaces_MatchesIndependentRouting() + { + // Arrange: two unrelated connections that do not share any box + var a = Box(0, 0, 60, 60); + var b = Box(200, 0, 60, 60); + var c = Box(0, 300, 60, 60); + var d = Box(200, 300, 60, 60); + var boxes = new[] { a, b, c, d }; + var connections = new[] { new Connection(a, b), new Connection(c, d) }; + + // Act + var batchLines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + var independentLine0 = ConnectorRouter.Route(boxes, connections[0], new ConnectorRouteOptions()); + var independentLine1 = ConnectorRouter.Route(boxes, connections[1], new ConnectorRouteOptions()); + + // Assert: batch routing reproduces the same anchors as routing each connection independently + Assert.Equal(independentLine0.Waypoints[0], batchLines[0].Waypoints[0]); + Assert.Equal(independentLine0.Waypoints[^1], batchLines[0].Waypoints[^1]); + Assert.Equal(independentLine1.Waypoints[0], batchLines[1].Waypoints[0]); + Assert.Equal(independentLine1.Waypoints[^1], batchLines[1].Waypoints[^1]); + } + + /// + /// Reproduces the second half of the DictionaryMark rendering defect: once the batch overload + /// spreads three connectors' target anchors across a shared face (see + /// ), each connector + /// still travels from its own widely separated source box toward that face, and every one of + /// them naturally wants its long axis-changing run in the same column (the target's own + /// stepped-off approach column), because that column is the only fixed X common to all three + /// paths. Routing the connectors sequentially and steering each later one away from earlier + /// ones' already-claimed corridors must produce visually distinct vertical runs instead of one + /// another's corridor, while still reaching its own spread-out target anchor. + /// + [Fact] + public void Route_ThreeConnectorsShareTargetFace_TrunksDoNotOverlap() + { + // Arrange: geometry taken directly from DictionaryMark's generated SVG (same as the anchor + // spread test above) — three source boxes stacked far below a much taller target box. + var yamlDotNet = Box(24, 1265, 130, 50, "YamlDotNet"); + var fileSystemGlobbing = Box(24, 1345, 168.24, 50, "FileSystemGlobbing"); + var testResults = Box(24, 1425, 130, 50, "TestResults"); + var dictionaryMarkSystem = Box(1037.52, 421, 308.16, 224, "DictionaryMarkSystem"); + var boxes = new[] { yamlDotNet, fileSystemGlobbing, testResults, dictionaryMarkSystem }; + + var connections = new[] + { + new Connection(yamlDotNet, dictionaryMarkSystem, EndMarkerStyle.FilledDiamond), + new Connection(fileSystemGlobbing, dictionaryMarkSystem, EndMarkerStyle.FilledDiamond), + new Connection(testResults, dictionaryMarkSystem, EndMarkerStyle.FilledDiamond), + }; + + // Act + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions(EdgeRouting.Orthogonal, 12.0)); + + // Assert: every connector still reaches its own (now distinct) anchor on the target's face. + Assert.Equal(dictionaryMarkSystem.X, lines[0].Waypoints[^1].X, 6); + Assert.Equal(dictionaryMarkSystem.X, lines[1].Waypoints[^1].X, 6); + Assert.Equal(dictionaryMarkSystem.X, lines[2].Waypoints[^1].X, 6); + + // Assert: the long vertical run each connector uses to change its Y position (its longest + // vertical segment) sits at a distinct X for each connector — the defect this test guards + // against had all three collapse onto the exact same column, drawing what looked like one + // thick merged trunk instead of three separate connectors. + var trunkX = lines.Select(LongestVerticalSegmentX).ToArray(); + Assert.NotEqual(trunkX[0], trunkX[1], 3); + Assert.NotEqual(trunkX[0], trunkX[2], 3); + Assert.NotEqual(trunkX[1], trunkX[2], 3); + + // Assert: none of the three connectors crossed a box (the soft-obstacle steering must never + // force a fallback to an obstacle-crossing route just to avoid another connector's corridor). + Assert.All(lines, line => AssertAllSegmentsOrthogonal(line.Waypoints)); + } + + /// + /// Returns the X coordinate of 's longest vertical (axis-changing) + /// segment — the run a connector uses to move from its source's Y level toward its target's Y + /// level, as opposed to the short perpendicular entry/exit stubs at either end. + /// + private static double LongestVerticalSegmentX(LayoutLine line) + { + var waypoints = line.Waypoints; + var bestLength = -1.0; + var bestX = double.NaN; + for (var i = 0; i < waypoints.Count - 1; i++) + { + var a = waypoints[i]; + var b = waypoints[i + 1]; + if (Math.Abs(a.X - b.X) > 1e-6) + { + continue; + } + + var length = Math.Abs(a.Y - b.Y); + if (length > bestLength) + { + bestLength = length; + bestX = a.X; + } + } + + return bestX; + } + + /// + /// Reproduces the true DictionaryMark rendering defect (a flat, length-independent soft-obstacle + /// penalty in the underlying orthogonal router's A* search): a small source box sits just below + /// a much wider target box across a narrow vertical gap, so every connector leaving the source's + /// top face is stepped off to the identical Y line and the sparse narrow-gap grid offers only one + /// natural corridor row. Three connections fan out from that shared source face to three widely + /// spaced anchors spread across the target's shared face. Before the length-proportional fix, the + /// interior horizontal corridor segments of connectors 1 and 2 collapsed onto the exact same grid + /// row and overlapped for hundreds of pixels because the flat penalty underpriced a long overlap + /// relative to a lane-change detour; after the fix each interior segment must resolve to a + /// distinct row (or overlap for only a trivial span) instead. + /// + [Fact] + public void Route_ThreeConnectorsNarrowGap_InteriorCorridorsDoNotOverlapAlongLength() + { + // Arrange: geometry taken directly from the reported DictionaryMark defect repro — a small + // source box directly below (with a 48px gap) a much wider target box. + var source = Box(12, 1291, 232, 318, "OtsDependencies"); + var target = Box(166, 36, 1208, 1207, "DictionaryMark"); + var boxes = new[] { source, target }; + + // Three connections between the same pair of boxes fan out across both shared faces. + var connections = new[] + { + new Connection(source, target, EndMarkerStyle.FilledDiamond), + new Connection(source, target, EndMarkerStyle.FilledDiamond), + new Connection(source, target, EndMarkerStyle.FilledDiamond), + }; + + // Act + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + + // Assert: every connector still forms a valid orthogonal path. + Assert.All(lines, line => AssertAllSegmentsOrthogonal(line.Waypoints)); + + // Assert: no two distinct connectors' interior segments ride the same grid line for an + // extended span (the visually merged trunk this test guards against). + AssertNoCollinearOverlapAcrossLines(lines); + } + + /// + /// Variation of + /// with more fan-out edges (five instead of three) from the same narrow-gap source face, to + /// confirm the fix generalizes beyond the exact reported connector count. + /// + [Fact] + public void Route_FiveConnectorsNarrowGap_InteriorCorridorsDoNotOverlapAlongLength() + { + // Arrange: same narrow-gap geometry as the primary repro, but with five fan-out connections. + var source = Box(12, 1291, 232, 318, "OtsDependencies"); + var target = Box(166, 36, 1208, 1207, "DictionaryMark"); + var boxes = new[] { source, target }; + + var connections = Enumerable.Range(0, 5) + .Select(_ => new Connection(source, target, EndMarkerStyle.FilledDiamond)) + .ToArray(); + + // Act + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + + // Assert + Assert.All(lines, line => AssertAllSegmentsOrthogonal(line.Waypoints)); + AssertNoCollinearOverlapAcrossLines(lines); + } + + /// + /// Fan-in variation of : + /// three distinct small source boxes, each across the same narrow gap from the shared wide target, + /// converge on the same target face. This mirrors the reported defect direction (many-to-one) + /// with genuinely separate source boxes rather than repeated connections between the same pair. + /// + [Fact] + public void Route_ThreeSourceBoxesNarrowGap_InteriorCorridorsDoNotOverlapAlongLength() + { + // Arrange: three narrow source boxes side by side below the same narrow gap, all facing the + // same much wider target box above them. + var sourceA = Box(12, 1291, 60, 318, "A"); + var sourceB = Box(92, 1291, 60, 318, "B"); + var sourceC = Box(172, 1291, 60, 318, "C"); + var target = Box(166, 36, 1208, 1207, "DictionaryMark"); + var boxes = new[] { sourceA, sourceB, sourceC, target }; + + var connections = new[] + { + new Connection(sourceA, target, EndMarkerStyle.FilledDiamond), + new Connection(sourceB, target, EndMarkerStyle.FilledDiamond), + new Connection(sourceC, target, EndMarkerStyle.FilledDiamond), + }; + + // Act + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + + // Assert + Assert.All(lines, line => AssertAllSegmentsOrthogonal(line.Waypoints)); + AssertNoCollinearOverlapAcrossLines(lines); + } + + /// + /// Asserts that no two of the given lines' interior segments — those excluding each + /// line's first and last segment, which are the endpoint-adjacent approach legs the router's own + /// AddLineObstacles design intentionally lets several connectors share (see its remarks in + /// ConnectorRouter.cs) — are collinear and overlap for more than a trivial span. Two + /// horizontal segments are collinear when they share the same Y (within a tight tolerance); two + /// vertical segments are collinear when they share the same X. The overlap span is the length of + /// their shared range along the collinear axis; only spans exceeding + /// are flagged, so incidental few-pixel touches (e.g. two corridors merely crossing the same grid + /// line for an instant) are not falsely reported. + /// + private static void AssertNoCollinearOverlapAcrossLines(IReadOnlyList lines, double maxOverlap = 5.0) + { + // Collect each line's interior segments (excluding the first and last, matching + // AddLineObstacles' own endpoint-adjacent exclusion). + var interiorSegments = new List<(int LineIndex, Point2D A, Point2D B)>(); + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var waypoints = lines[lineIndex].Waypoints; + for (var i = 1; i < waypoints.Count - 2; i++) + { + interiorSegments.Add((lineIndex, waypoints[i], waypoints[i + 1])); + } + } + + for (var x = 0; x < interiorSegments.Count; x++) + { + for (var y = x + 1; y < interiorSegments.Count; y++) + { + var (lineX, ax, bx) = interiorSegments[x]; + var (lineY, ay, by) = interiorSegments[y]; + if (lineX == lineY) + { + // Only cross-connector overlaps are of interest here. + continue; + } + + var overlap = CollinearOverlapLength(ax, bx, ay, by); + Assert.True( + overlap <= maxOverlap, + $"Interior segments of connectors {lineX} and {lineY} are collinear and overlap for " + + $"{overlap:F1}px (({ax.X},{ax.Y})-({bx.X},{bx.Y}) vs ({ay.X},{ay.Y})-({by.X},{by.Y})), " + + "exceeding the trivial-overlap threshold."); + } + } + } + + /// + /// Returns the length of the collinear overlap between two axis-aligned segments, or 0 when they + /// are not both horizontal at the same Y, not both vertical at the same X, or do not overlap. + /// + private static double CollinearOverlapLength(Point2D a1, Point2D b1, Point2D a2, Point2D b2) + { + const double tolerance = 1e-6; + + var horizontal1 = Math.Abs(a1.Y - b1.Y) < tolerance; + var horizontal2 = Math.Abs(a2.Y - b2.Y) < tolerance; + if (horizontal1 && horizontal2 && Math.Abs(a1.Y - a2.Y) < tolerance) + { + var lo = Math.Max(Math.Min(a1.X, b1.X), Math.Min(a2.X, b2.X)); + var hi = Math.Min(Math.Max(a1.X, b1.X), Math.Max(a2.X, b2.X)); + return Math.Max(0.0, hi - lo); + } + + var vertical1 = Math.Abs(a1.X - b1.X) < tolerance; + var vertical2 = Math.Abs(a2.X - b2.X) < tolerance; + if (vertical1 && vertical2 && Math.Abs(a1.X - a2.X) < tolerance) + { + var lo = Math.Max(Math.Min(a1.Y, b1.Y), Math.Min(a2.Y, b2.Y)); + var hi = Math.Min(Math.Max(a1.Y, b1.Y), Math.Max(a2.Y, b2.Y)); + return Math.Max(0.0, hi - lo); + } + + return 0.0; + } + /// /// A null box list is rejected by the batch overload. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs index 2cef841..f49d62f 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs @@ -22,6 +22,39 @@ public void Id_IsContainment() Assert.Equal("containment", new ContainmentLayoutAlgorithm().Id); } + /// + /// Proves that a node's , , + /// , and optional shape-geometry hints flow through to + /// the packed unchanged, so routing and rendering can agree on the box's + /// real outline after packing. + /// + [Fact] + public void Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPackedBox() + { + // Arrange + var graph = new LayoutGraph(); + var node = graph.AddNode("pkg", 120, 80); + node.Label = "Powertrain"; + node.Shape = BoxShape.Folder; + node.Keyword = "package"; + node.Compartments = [new LayoutCompartment(null, ["Engine", "Gearbox"])]; + node.RoundedCornerRadius = 10.0; + node.FolderTabWidth = 76.0; + node.FolderTabHeight = 24.0; + + // Act + var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert + var box = Assert.Single(tree.Nodes.OfType()); + Assert.Equal(BoxShape.Folder, box.Shape); + Assert.Equal("package", box.Keyword); + Assert.Equal(node.Compartments, box.Compartments); + Assert.Equal(10.0, box.RoundedCornerRadius); + Assert.Equal(76.0, box.FolderTabWidth); + Assert.Equal(24.0, box.FolderTabHeight); + } + /// /// Proves that the nodes are packed as non-overlapping boxes, in input order, entirely within /// the returned canvas. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs index 51e2fdd..bb4b197 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs @@ -65,9 +65,17 @@ public void LongEdgeJoiner_Apply_LongEdge_ConcatenatesSubEdgeBendPoints() /// Input nodes. /// Input edges. /// The graph after the long-edge-joining stage. - private static LayeredGraph BuildJoinedGraph(List nodes, List edges) + private static LayeredGraph BuildJoinedGraph(List nodes, List edges) => + BuildJoinedGraph(nodes, edges, LayoutDirection.Right); + + /// Runs the stages up to and including long-edge joining and returns the graph. + /// Input nodes. + /// Input edges. + /// The flow direction under test. + /// The graph after the long-edge-joining stage. + private static LayeredGraph BuildJoinedGraph(List nodes, List edges, LayoutDirection direction) { - var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + var graph = new LayeredGraph(nodes, edges, direction); new CycleBreaker().Apply(graph); new LayerAssigner().Apply(graph); new LongEdgeSplitter().Apply(graph); @@ -78,4 +86,120 @@ private static LayeredGraph BuildJoinedGraph(List nodes, List + /// Under a flow the target's resolved real face is + /// , so a target's assembled endpoint is + /// projected inward by the folder-tab height, touching the recessed body top rather than the + /// bounding-box edge. + /// + [Fact] + public void LongEdgeJoiner_Apply_FolderTargetTopFace_Down_ProjectsInwardByTabHeight() + { + // Arrange: a source node feeding a Folder-shaped target under a Down flow. + var nodes = new List + { + new(60, 40), + new(140, 90, BoxShape.Folder, FolderTabWidth: 60.0, FolderTabHeight: 24.0, Label: "Utilities", RealWidth: 140, RealHeight: 90), + }; + var graph = BuildJoinedGraph(nodes, [new(0, 1)], LayoutDirection.Down); + + var tgt = graph.AugEdges[0].Target; + var polyline = graph.Waypoints[0]; + + // Assert: the target endpoint's X (the perpendicular axis for a Down flow) is recessed by the + // 24-unit tab height rather than sitting on the plain bounding-box edge. + Assert.Equal(graph.AugX[tgt] + 24.0, polyline[^1].X, 6); + } + + /// + /// Under an flow the source's resolved real face is + /// , so a source's assembled endpoint is + /// projected inward by the folder-tab height. + /// + [Fact] + public void LongEdgeJoiner_Apply_FolderSourceTopFace_Up_ProjectsInwardByTabHeight() + { + // Arrange: a Folder-shaped source node under an Up flow. + var nodes = new List + { + new(140, 90, BoxShape.Folder, FolderTabWidth: 60.0, FolderTabHeight: 24.0, Label: "Utilities", RealWidth: 140, RealHeight: 90), + new(60, 40), + }; + var graph = BuildJoinedGraph(nodes, [new(0, 1)], LayoutDirection.Up); + + var src = graph.AugEdges[0].Source; + var polyline = graph.Waypoints[0]; + + // Assert: the source endpoint's X is recessed inward (toward the node) by the tab height. + Assert.Equal(graph.AugX[src] + nodes[src].Width - 24.0, polyline[0].X, 6); + } + + /// + /// Under a flow the source's resolved real face is + /// , which never restricts or + /// projects (only the Top face carries a tab), so the source endpoint is unaffected — matching + /// the plain-rectangle formula exactly. + /// + [Fact] + public void LongEdgeJoiner_Apply_FolderNode_Right_NonTopFaceUnaffected() + { + // Arrange: a Folder-shaped source node under the default Right flow (source face = Right). + var nodes = new List + { + new(140, 90, BoxShape.Folder, FolderTabWidth: 60.0, FolderTabHeight: 24.0, Label: "Utilities", RealWidth: 140, RealHeight: 90), + new(60, 40), + }; + var graph = BuildJoinedGraph(nodes, [new(0, 1)]); + + var src = graph.AugEdges[0].Source; + var polyline = graph.Waypoints[0]; + + // Assert: the source endpoint's X exactly matches the non-projected right-face formula. + Assert.Equal(graph.AugX[src] + nodes[src].Width, polyline[0].X, 6); + } + + /// + /// Under a flow the target's resolved real face is + /// , which never restricts or + /// projects, so the target endpoint is unaffected — matching the plain-rectangle formula + /// exactly. + /// + [Fact] + public void LongEdgeJoiner_Apply_FolderNode_Left_NonTopFaceUnaffected() + { + // Arrange: a Folder-shaped target node under a Left flow (target face = Right). + var nodes = new List + { + new(60, 40), + new(140, 90, BoxShape.Folder, FolderTabWidth: 60.0, FolderTabHeight: 24.0, Label: "Utilities", RealWidth: 140, RealHeight: 90), + }; + var graph = BuildJoinedGraph(nodes, [new(0, 1)], LayoutDirection.Left); + + var tgt = graph.AugEdges[0].Target; + var polyline = graph.Waypoints[0]; + + // Assert: the target endpoint's X exactly matches the non-projected right-face formula. + Assert.Equal(graph.AugX[tgt], polyline[^1].X, 6); + } + + /// + /// A plain node's assembled endpoint is byte-identical to the + /// pre-shape-awareness formula: regression guard proving the + /// ShapeAnchorSupport.IsPlainRectangle fast path skips geometry resolution entirely (not + /// just producing a zero offset) for the default (and by far most common) shape. + /// + [Fact] + public void LongEdgeJoiner_Apply_RectangleNode_MatchesPlainFormula() + { + var nodes = new List { new(60, 40), new(60, 40) }; + var graph = BuildJoinedGraph(nodes, [new(0, 1)]); + + var src = graph.AugEdges[0].Source; + var tgt = graph.AugEdges[0].Target; + var polyline = graph.Waypoints[0]; + + Assert.Equal(graph.AugX[src] + graph.AugNodes[src].Width, polyline[0].X, 9); + Assert.Equal(graph.AugX[tgt], polyline[^1].X, 9); + } } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs index 8d8b8da..e81cd8a 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs @@ -2,6 +2,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using DemaConsulting.Rendering.Abstractions; using DemaConsulting.Rendering.Layout.Engine; using DemaConsulting.Rendering.Layout.Engine.Layered; @@ -79,9 +80,17 @@ public void PortDistributor_Apply_SmallFace_PortsLieWithinNodeFacesWithoutThrowi /// Input nodes. /// Input edges. /// The graph after the port-distribution stage. - private static LayeredGraph BuildPortedGraph(List nodes, List edges) + private static LayeredGraph BuildPortedGraph(List nodes, List edges) => + BuildPortedGraph(nodes, edges, LayoutDirection.Right); + + /// Runs the stages up to and including port distribution and returns the graph. + /// Input nodes. + /// Input edges. + /// The flow direction under test. + /// The graph after the port-distribution stage. + private static LayeredGraph BuildPortedGraph(List nodes, List edges, LayoutDirection direction) { - var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + var graph = new LayeredGraph(nodes, edges, direction); new CycleBreaker().Apply(graph); new LayerAssigner().Apply(graph); new LongEdgeSplitter().Apply(graph); @@ -90,4 +99,99 @@ private static LayeredGraph BuildPortedGraph(List nodes, List + /// A target node whose tab occupies part of the direction's + /// resolved target face (the real face for + /// ) has its port band restricted to the tab-excluded portion + /// of that face, instead of the plain full-span band a rectangle would use. + /// + [Fact] + public void PortDistributor_Apply_FolderTargetTopFace_Down_PortExcludesTabRegion() + { + // Arrange: a single edge into a Folder-shaped target under a Down (top-to-bottom) flow, whose + // resolved target face is the real Top face. + var nodes = new List + { + new(60, 40), + new(140, 90, BoxShape.Folder, FolderTabWidth: 60.0, FolderTabHeight: 24.0, Label: "Utilities", RealWidth: 140, RealHeight: 90), + }; + var graph = BuildPortedGraph(nodes, [new(0, 1)], LayoutDirection.Down); + + var tgt = graph.AugEdges[0].Target; + + // Note: Down flow swaps internal axes so layers stack along the internal Y axis; the resulting + // offset therefore corresponds to the real-world horizontal (X) position along the folder's top + // face, even though it is read from the AugPortYTgt/AugY fields. + var portOffsetAlongTopFace = graph.AugPortYTgt[0] - graph.AugY[tgt]; + + // Assert: the port lands to the right of the tab (plus its small anti-shoulder margin), never + // inside the tab-excluded strip a plain rectangle's full-span band would have allowed. + Assert.True( + portOffsetAlongTopFace > 60.0, + $"Expected port beyond the 60-wide tab, got offset {portOffsetAlongTopFace}."); + } + + /// + /// A node's fold-excluded strip on the real + /// face (resolved for a source) + /// is excluded from the port band, matching 's own exclusion rule. + /// + [Fact] + public void PortDistributor_Apply_NoteSourceRightFace_Right_PortExcludesFoldRegion() + { + // Arrange: a single edge out of a Note-shaped source under a Right flow, whose resolved source + // face is the real Right face. A 140x90 note folds min(140, 90) * 0.25 = 22.5, capped at + // NotationMetrics.NoteFoldMaxSize (16). + var nodes = new List + { + new(140, 90, BoxShape.Note, RealWidth: 140, RealHeight: 90), + new(60, 40), + }; + var graph = BuildPortedGraph(nodes, [new(0, 1)]); + + var src = graph.AugEdges[0].Source; + + // Note: Right flow uses the internal axes without swapping, so this offset corresponds + // directly to the real-world vertical (Y) position along the note's right face. + var portOffsetAlongRightFace = graph.AugPortYSrc[0] - graph.AugY[src]; + + // Assert: the port lands below the folded-corner strip, never within it. + Assert.True( + portOffsetAlongRightFace > NotationMetrics.NoteFoldMaxSize, + $"Expected port below the fold ({NotationMetrics.NoteFoldMaxSize}), got offset {portOffsetAlongRightFace}."); + } + + /// + /// A plain node's port distribution is byte-identical to the + /// pre-shape-awareness full-span formula: regression guard proving the + /// ShapeAnchorSupport.IsPlainRectangle fast path never engages the new shape-aware code + /// for the default (and by far most common) shape. + /// + [Fact] + public void PortDistributor_Apply_RectangleNode_MatchesPlainFullSpanFormula() + { + // Arrange: a fan-in of three edges onto a plain-rectangle target. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 90) }; + var graph = BuildPortedGraph(nodes, [new(0, 3), new(1, 3), new(2, 3)]); + + var tgt = graph.AugEdges[0].Target; + const double clearance = LayeredLayoutMetrics.ConnectorClearance; + var inset = Math.Min(clearance, nodes[tgt].Height / 2.0); + var usable = nodes[tgt].Height - (2.0 * inset); + + // Assert: each recorded port Y matches the original even-spacing formula exactly. + var sorted = graph.AugEdges + .Select((e, i) => (Edge: e, Index: i)) + .Where(x => x.Edge.Target == tgt) + .OrderBy(x => graph.AugY[x.Edge.Source] + (nodes[x.Edge.Source].Height / 2.0)) + .ThenBy(x => x.Index) + .Select(x => x.Index) + .ToList(); + for (var k = 0; k < sorted.Count; k++) + { + var expected = graph.AugY[tgt] + inset + (k * usable / (sorted.Count - 1)); + Assert.Equal(expected, graph.AugPortYTgt[sorted[k]], 9); + } + } } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs index abdf7ed..e31cc8e 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs @@ -228,6 +228,101 @@ public void RouteWithStatus_CleanRoute_KeepsClearanceFromObstacles() } } + /// + /// A route steered by soft-obstacle penalties does not publish a redundant leave-and-return + /// waypoint loop that revisits the same point before continuing. + /// + [Fact] + public void RouteWithStatus_SoftObstacleDetour_DoesNotRevisitWaypoint() + { + // Arrange: a vertical route whose preferred corridor is marked as a soft obstacle, representing + // an already-routed connector that a later connector should prefer to steer around without + // publishing a redundant excursion that leaves and returns to the same point. + var source = new Point2D(100, 0); + var target = new Point2D(100, 200); + var softObstacles = new[] { new Rect(99, 20, 2, 160) }; + + // Act + var result = OrthogonalEdgeRouter.RouteWithStatus( + source, + target, + obstacles: [], + clearance: 10, + sourceSide: PortSide.Bottom, + targetSide: PortSide.Top, + softObstacles: softObstacles); + + // Assert: the path is valid and never visits, leaves, and later revisits the same point. + Assert.False(result.Crossed); + AssertAllSegmentsOrthogonal(result.Waypoints); + AssertNoWaypointRevisit(result.Waypoints); + } + + /// + /// Pins the length-proportional soft-obstacle cost fix directly: when a soft obstacle occupies + /// the entire natural straight corridor for an extended span (hundreds of pixels), the router + /// must prefer a bounded-cost detour to an alternate lane rather than riding the long overlap. + /// Before the fix, the flat per-move SoftObstaclePenalty underpriced a long overlap + /// relative to the (roughly fixed) cost of a one-lane detour, so the search always kept the + /// overlap regardless of its length; this reproduces that exact mechanism with a synthetic soft + /// obstacle standing in for an already-routed connector's corridor. + /// + [Fact] + public void RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane() + { + // Arrange: a straight horizontal route whose entire natural corridor (y = 0) is claimed by a + // long soft obstacle for most of its length; an alternate lane is reachable at y = +/-11 (the + // soft obstacle's own clearance-offset edge, added to the grid by BuildAxis) for a bounded + // detour cost (two turns plus a short perpendicular jog). + var source = new Point2D(0, 0); + var target = new Point2D(500, 0); + var softObstacles = new[] { new Rect(50, -1, 400, 2) }; + + // Act + var result = OrthogonalEdgeRouter.RouteWithStatus( + source, target, obstacles: [], clearance: 10, softObstacles: softObstacles); + + // Assert: a valid orthogonal route was found (soft obstacles never hard-block). + Assert.False(result.Crossed); + AssertAllSegmentsOrthogonal(result.Waypoints); + + // Assert: no segment of the route rides the long soft obstacle for more than a trivial span — + // the router must have detoured to the alternate lane instead of eating the full 400px overlap. + var longestOverlap = LongestOverlapWithRect(result.Waypoints, softObstacles[0]); + Assert.True( + longestOverlap < 20.0, + $"Route rides the soft obstacle for {longestOverlap:F1}px; expected a detour to an alternate lane."); + } + + /// + /// Companion to : + /// confirms the fix's proportional cost does not over-correct into penalizing short, incidental + /// soft-obstacle overlaps into pointless detours — a handful of overlapping pixels must still be + /// cheaper than the bounded detour cost, preserving the router's tolerance for legitimate shared + /// corridors near a common face. + /// + [Fact] + public void RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute() + { + // Arrange: the same straight corridor, but the soft obstacle now spans only a few pixels near + // the midpoint — an incidental overlap rather than a long shared trunk. + var source = new Point2D(0, 0); + var target = new Point2D(500, 0); + var softObstacles = new[] { new Rect(248, -1, 4, 2) }; + + // Act + var result = OrthogonalEdgeRouter.RouteWithStatus( + source, target, obstacles: [], clearance: 10, softObstacles: softObstacles); + + // Assert: still a valid, non-crossing route. + Assert.False(result.Crossed); + AssertAllSegmentsOrthogonal(result.Waypoints); + + // Assert: the route stays on the direct y = 0 corridor rather than detouring for a trivial + // overlap — every waypoint remains on the straight line. + Assert.All(result.Waypoints, p => Assert.Equal(0.0, p.Y, 6)); + } + /// /// A null source anchor is rejected. /// @@ -306,6 +401,23 @@ private static void AssertNoSegmentCrossesObstacle(IReadOnlyList path, } } + /// + /// Asserts that the path never visits one point, leaves it, and later returns to the exact same + /// point. + /// + private static void AssertNoWaypointRevisit(IReadOnlyList path) + { + for (var i = 0; i < path.Count - 2; i++) + { + for (var j = i + 2; j < path.Count; j++) + { + Assert.False( + SamePoint(path[i], path[j]), + $"Waypoint ({path[i].X},{path[i].Y}) is revisited at positions {i} and {j}."); + } + } + } + /// /// Returns true when the axis-aligned segment passes through the strict interior of the rect. /// @@ -344,4 +456,55 @@ private static double SegmentDistanceToRect(Point2D a, Point2D b, Rect r) var dy = Math.Max(0.0, Math.Max(r.Y - yhi, ylo - (r.Y + r.Height))); return Math.Sqrt((dx * dx) + (dy * dy)); } + + /// + /// Returns whether two waypoints identify the same geometric point. + /// + private static bool SamePoint(Point2D left, Point2D right) => + Math.Abs(left.X - right.X) < 1e-9 && Math.Abs(left.Y - right.Y) < 1e-9; + + /// + /// Returns the longest overlap, in pixels, between any segment of and + /// the interior of along the segment's own axis (horizontal segments + /// are compared against the rect's X range when the segment's Y falls inside the rect's Y range, + /// and vice versa for vertical segments). + /// + private static double LongestOverlapWithRect(IReadOnlyList path, Rect rect) + { + var longest = 0.0; + for (var i = 0; i < path.Count - 1; i++) + { + var a = path[i]; + var b = path[i + 1]; + + if (Math.Abs(a.Y - b.Y) < 1e-6) + { + // Horizontal segment. + if (a.Y <= rect.Y || a.Y >= rect.Y + rect.Height) + { + continue; + } + + var xa = Math.Min(a.X, b.X); + var xb = Math.Max(a.X, b.X); + var overlap = Math.Min(xb, rect.X + rect.Width) - Math.Max(xa, rect.X); + longest = Math.Max(longest, Math.Max(0.0, overlap)); + } + else + { + // Vertical segment. + if (a.X <= rect.X || a.X >= rect.X + rect.Width) + { + continue; + } + + var ya = Math.Min(a.Y, b.Y); + var yb = Math.Max(a.Y, b.Y); + var overlap = Math.Min(yb, rect.Y + rect.Height) - Math.Max(ya, rect.Y); + longest = Math.Max(longest, Math.Max(0.0, overlap)); + } + } + + return longest; + } } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index 99a1734..e88592e 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -117,6 +117,113 @@ public void Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely() } } + /// + /// Proves that a container's own override replaces the + /// engine's generic default title-band height, both in the container's effective size and in + /// the vertical offset applied to its nested children — so a caller can match a specific theme's + /// actual title-area height (for example when the container also carries a keyword line) instead + /// of being limited to the engine's generic default band. + /// + [Fact] + public void Apply_ContainerWithTitleHeightOverride_ReplacesDefaultTitleBand() + { + // Arrange: two otherwise-identical single-child containers, one with a TitleHeight override. + var defaultGraph = new LayoutGraph(); + var defaultGroup = defaultGraph.AddNode("group", 10, 10); + defaultGroup.Label = "Group"; + defaultGroup.Children.AddNode("child", 80, 40); + + var overrideGraph = new LayoutGraph(); + var overrideGroup = overrideGraph.AddNode("group", 10, 10); + overrideGroup.Label = "Group"; + overrideGroup.TitleHeight = 100.0; + overrideGroup.Children.AddNode("child", 80, 40); + + // Act + var defaultTree = new HierarchicalLayoutAlgorithm().Apply(defaultGraph, LayoutOptions.ForAlgorithm("layered")); + var overrideTree = new HierarchicalLayoutAlgorithm().Apply(overrideGraph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the override container is exactly (100 - 24) taller than the default container, and + // its nested child is offset down by that same difference. + var defaultContainer = Assert.Single(defaultTree.Nodes.OfType()); + var overrideContainer = Assert.Single(overrideTree.Nodes.OfType()); + Assert.Equal(defaultContainer.Height + 76.0, overrideContainer.Height, precision: 6); + + var defaultChild = Assert.Single(defaultContainer.Children.OfType()); + var overrideChild = Assert.Single(overrideContainer.Children.OfType()); + var defaultOffsetFromTop = defaultChild.Y - defaultContainer.Y; + var overrideOffsetFromTop = overrideChild.Y - overrideContainer.Y; + Assert.Equal(defaultOffsetFromTop + 76.0, overrideOffsetFromTop, precision: 6); + } + + /// + /// Proves that a container's own override applies only + /// while it carries a : an unlabelled container reserves no + /// title band regardless of the override, matching the engine's existing label-gated behavior. + /// + [Fact] + public void Apply_UnlabelledContainerWithTitleHeightOverride_ReservesNoTitleBand() + { + // Arrange: two otherwise-identical unlabelled single-child containers; only one sets an + // (ignored) TitleHeight override. + var plainGraph = new LayoutGraph(); + var plainGroup = plainGraph.AddNode("group", 10, 10); + plainGroup.Children.AddNode("child", 80, 40); + + var overrideGraph = new LayoutGraph(); + var overrideGroup = overrideGraph.AddNode("group", 10, 10); + overrideGroup.TitleHeight = 100.0; + overrideGroup.Children.AddNode("child", 80, 40); + + // Act + var plainTree = new HierarchicalLayoutAlgorithm().Apply(plainGraph, LayoutOptions.ForAlgorithm("layered")); + var overrideTree = new HierarchicalLayoutAlgorithm().Apply(overrideGraph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: both containers are sized identically, since neither reserves a title band. + var plainContainer = Assert.Single(plainTree.Nodes.OfType()); + var overrideContainer = Assert.Single(overrideTree.Nodes.OfType()); + Assert.Equal(plainContainer.Height, overrideContainer.Height, precision: 6); + } + + /// + /// Proves that a container reserves additional space above its + /// nested children for the folder tab, beyond the ordinary title band, so children are never + /// placed underneath the recessed keyword/label text (which the renderer draws below the tab). + /// + [Fact] + public void Apply_FolderContainer_ReservesTabHeightAboveChildren() + { + // Arrange: two otherwise-identical labelled single-child containers, one shaped as a folder + // with an explicit tab height. + var rectangleGraph = new LayoutGraph(); + var rectangleGroup = rectangleGraph.AddNode("group", 10, 10); + rectangleGroup.Label = "Group"; + rectangleGroup.Children.AddNode("child", 80, 40); + + var folderGraph = new LayoutGraph(); + var folderGroup = folderGraph.AddNode("group", 10, 10); + folderGroup.Label = "Group"; + folderGroup.Shape = BoxShape.Folder; + folderGroup.FolderTabHeight = 24.0; + folderGroup.Children.AddNode("child", 80, 40); + + // Act + var rectangleTree = new HierarchicalLayoutAlgorithm().Apply(rectangleGraph, LayoutOptions.ForAlgorithm("layered")); + var folderTree = new HierarchicalLayoutAlgorithm().Apply(folderGraph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the folder container is exactly tab-height taller, and its child is offset down by + // that same additional amount, so the child never overlaps the recessed title area. + var rectangleContainer = Assert.Single(rectangleTree.Nodes.OfType()); + var folderContainer = Assert.Single(folderTree.Nodes.OfType()); + Assert.Equal(rectangleContainer.Height + 24.0, folderContainer.Height, precision: 6); + + var rectangleChild = Assert.Single(rectangleContainer.Children.OfType()); + var folderChild = Assert.Single(folderContainer.Children.OfType()); + var rectangleOffsetFromTop = rectangleChild.Y - rectangleContainer.Y; + var folderOffsetFromTop = folderChild.Y - folderContainer.Y; + Assert.Equal(rectangleOffsetFromTop + 24.0, folderOffsetFromTop, precision: 6); + } + /// /// Proves that a containment-packed root can hold a container whose children are laid out with a /// per-node layered override, composing without error. @@ -429,6 +536,96 @@ public void Apply_CrossContainerEdge_ToWideContainer_DoesNotCrossEndpointBoxes() } } + /// + /// Proves that a box which is both a plain root-level sibling (connected to several other + /// root-level boxes by ordinary edges the leaf algorithm would normally route locally) and the + /// endpoint of a genuine cross-container edge from inside a separate container has every one of + /// its anchors allocated by a single coordinated pass, not two independent ones. Regression: the + /// leaf algorithm's own locally-routed anchors and this scope's cross-container router's anchor + /// were previously computed by two separate batches, each + /// unaware of the other, so an anchor from one batch could land at the exact same point as an + /// anchor from the other — exactly what happened in a real SysML general-view diagram where a + /// package-external specialization edge collided with several sibling boxes' membership edges on + /// the same shared target face. + /// + [Fact] + public void Apply_BoxWithBothLeafAndCrossContainerEdges_SpreadsSharedFaceAnchorsTogether() + { + // Arrange: "target" is a small root-level box; "a" is a much wider box packed directly below + // it, and "pkg" (a container whose child "typed" is the real edge endpoint, after promotion to + // the cross-container batch) is packed in a further row, also directly below "target" and also + // wide enough to span its full width. Both "a" and "pkg" fully contain target's horizontal + // extent, so ConnectorRouter's face-anchor calculation (which centres on the *overlap* of the + // two boxes, or the target's own centre when they don't overlap at all) resolves to target's + // own bottom-face centre for BOTH edges independently. Before the fix, "a-target" was routed + // alone in the leaf algorithm's own batch and "typed-target" was routed alone in this scope's + // separate cross-container batch, so each computed that identical centred point with no + // awareness of the other, landing both anchors on the exact same pixel. This mirrors the real + // DictionaryMark diagram where a package-external specialization edge collided with a sibling + // membership edge on the same shared target face. + var graph = new LayoutGraph(); + var target = graph.AddNode("target", 40, 40); + var a = graph.AddNode("a", 300, 40); + + var pkg = graph.AddNode("pkg", 10, 10); + var typed = pkg.Children.AddNode("typed", 60, 40); + + graph.AddEdge("a-target", a, target); + graph.AddEdge("typed-target", typed, target); + + // Act: the "containment" leaf algorithm packs boxes deterministically and routes its own edges + // with ConnectorRouter, so the root scope's leaf-routed batch and its cross-container batch are + // both driven by the same underlying router — exactly like the real diagram's regression. + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment")); + + // Assert: the two connectors landing on "target" reach distinct anchor points. + var lines = tree.Nodes.OfType().ToList(); + Assert.Equal(2, lines.Count); + Assert.NotEqual(lines[0].Waypoints[^1], lines[1].Waypoints[^1]); + } + + /// + /// Proves that a container node's , + /// , and folder-geometry hints, and a nested leaf's + /// and rounded-corner hint, all survive the + /// hierarchical engine's sized-view and composition round-trip unchanged. + /// + [Fact] + public void Apply_NestedGraph_PropagatesContainerAndLeafShapeKeywordCompartments() + { + // Arrange: a "package" folder container holding a "part def" leaf with a ports compartment. + var graph = new LayoutGraph(); + var pkg = graph.AddNode("pkg", 10, 10); + pkg.Label = "Powertrain"; + pkg.Shape = BoxShape.Folder; + pkg.Keyword = "package"; + pkg.FolderTabWidth = 82.0; + pkg.FolderTabHeight = 24.0; + + var engine = pkg.Children.AddNode("engine", 120, 80); + engine.Label = "Engine"; + engine.Shape = BoxShape.RoundedRectangle; + engine.Keyword = "part def"; + engine.Compartments = [new LayoutCompartment("ports", ["intake : FluidPort"])]; + engine.RoundedCornerRadius = 14.0; + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert + var folder = Assert.Single(tree.Nodes.OfType()); + Assert.Equal(BoxShape.Folder, folder.Shape); + Assert.Equal("package", folder.Keyword); + Assert.Equal(82.0, folder.FolderTabWidth); + Assert.Equal(24.0, folder.FolderTabHeight); + + var part = Assert.Single(folder.Children.OfType()); + Assert.Equal(BoxShape.RoundedRectangle, part.Shape); + Assert.Equal("part def", part.Keyword); + Assert.Equal(engine.Compartments, part.Compartments); + Assert.Equal(14.0, part.RoundedCornerRadius); + } + /// /// Proves that three levels of nesting compose so a box contains a box that contains a box. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index da14ddc..777b99c 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -3,6 +3,7 @@ // using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; namespace DemaConsulting.Rendering.Layout.Tests; @@ -53,6 +54,55 @@ public void Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges() Assert.All(lines, line => Assert.True(line.Waypoints.Count >= 2)); } + /// + /// Proves that a node's , , + /// , and optional shape-geometry hints flow through to + /// the placed unchanged, so a caller can select both the visible outline + /// and any resolved routing/rendering geometry purely through the input graph model. + /// + [Fact] + public void Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPlacedBox() + { + var graph = new LayoutGraph(); + var node = graph.AddNode("part", 120, 80); + node.Label = "Engine"; + node.Shape = BoxShape.RoundedRectangle; + node.Keyword = "part def"; + node.Compartments = [new LayoutCompartment("ports", ["intake : FluidPort", "exhaust : FluidPort"])]; + node.RoundedCornerRadius = 14.0; + node.FolderTabWidth = 70.0; + node.FolderTabHeight = 22.0; + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var box = Assert.Single(tree.Nodes.OfType()); + Assert.Equal(BoxShape.RoundedRectangle, box.Shape); + Assert.Equal("part def", box.Keyword); + Assert.Equal(node.Compartments, box.Compartments); + Assert.Equal(14.0, box.RoundedCornerRadius); + Assert.Equal(70.0, box.FolderTabWidth); + Assert.Equal(22.0, box.FolderTabHeight); + } + + /// + /// Proves that an unset , , + /// and default to a plain rectangle with no keyword or + /// compartments, preserving the algorithm's prior behavior for callers that never set them. + /// + [Fact] + public void Apply_NodeWithNoShapeKeywordOrCompartments_DefaultsToPlainRectangle() + { + var graph = new LayoutGraph(); + graph.AddNode("plain", 80, 40); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var box = Assert.Single(tree.Nodes.OfType()); + Assert.Equal(BoxShape.Rectangle, box.Shape); + Assert.Null(box.Keyword); + Assert.Empty(box.Compartments); + } + /// /// Proves that an empty graph produces an empty, positively-sized canvas. /// @@ -254,4 +304,186 @@ public void Apply_SmallNodeChain_PlacesWithoutThrowingInEveryDirection(LayoutFlo Assert.True(tree.Height > 0); Assert.All(lines, line => Assert.True(line.Waypoints.Count >= 2)); } + + /// + /// Under a flow the target's resolved real face is + /// , so a -shaped target's routed + /// connector lands on the recessed body top (box.Y + folder-tab height) rather than the plain + /// bounding-box top edge — proving 's own routing pipeline + /// now gets the same anchor projection already applies. + /// + [Fact] + public void Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop() + { + var graph = new LayoutGraph(); + var source = graph.AddNode("client", 80, 40); + var target = graph.AddNode("pkg", 140, 90); + target.Label = "Utilities"; + target.Shape = BoxShape.Folder; + target.FolderTabWidth = 60.0; + target.FolderTabHeight = 24.0; + graph.AddEdge("uses", source, target); + + var options = new LayoutOptions(); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, options); + + var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities"); + var line = Assert.Single(tree.Nodes.OfType()); + + // Assert: the connector's final waypoint touches the recessed body top, not the bounding-box edge. + Assert.Equal(targetBox.Y + 24.0, line.Waypoints[^1].Y, 6); + } + + /// + /// Under an flow the source's resolved real face is + /// , so a -shaped source's routed + /// connector departs from the recessed body top rather than the plain bounding-box top edge. + /// + [Fact] + public void Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop() + { + var graph = new LayoutGraph(); + var source = graph.AddNode("pkg", 140, 90); + source.Label = "Utilities"; + source.Shape = BoxShape.Folder; + source.FolderTabWidth = 60.0; + source.FolderTabHeight = 24.0; + var target = graph.AddNode("client", 80, 40); + graph.AddEdge("uses", source, target); + + var options = new LayoutOptions(); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Up); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, options); + + var sourceBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities"); + var line = Assert.Single(tree.Nodes.OfType()); + + // Assert: the connector's first waypoint touches the recessed body top, not the bounding-box edge. + Assert.Equal(sourceBox.Y + 24.0, line.Waypoints[0].Y, 6); + } + + /// + /// Under a flow the target's resolved real face is + /// , which never restricts or projects + /// (only the real Top face carries a tab), so the connector lands on the plain bounding-box left + /// edge exactly as it would for a rectangle — the folder tab must never spuriously affect a face + /// other than the real Top face. + /// + [Fact] + public void Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab() + { + var graph = new LayoutGraph(); + var source = graph.AddNode("client", 40, 20); + var target = graph.AddNode("pkg", 140, 90); + target.Label = "Utilities"; + target.Shape = BoxShape.Folder; + target.FolderTabWidth = 60.0; + target.FolderTabHeight = 24.0; + graph.AddEdge("uses", source, target); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities"); + var line = Assert.Single(tree.Nodes.OfType()); + + // Assert: the connector's final waypoint sits exactly on the plain bounding-box left edge. + Assert.Equal(targetBox.X, line.Waypoints[^1].X, 6); + } + + /// + /// Under a flow the target's resolved real face is + /// , which never restricts or + /// projects, so the connector lands on the plain bounding-box right edge exactly as it would for + /// a rectangle. + /// + [Fact] + public void Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab() + { + var graph = new LayoutGraph(); + var source = graph.AddNode("client", 40, 20); + var target = graph.AddNode("pkg", 140, 90); + target.Label = "Utilities"; + target.Shape = BoxShape.Folder; + target.FolderTabWidth = 60.0; + target.FolderTabHeight = 24.0; + graph.AddEdge("uses", source, target); + + var options = new LayoutOptions(); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Left); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, options); + + var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities"); + var line = Assert.Single(tree.Nodes.OfType()); + + // Assert: the connector's final waypoint sits exactly on the plain bounding-box right edge. + Assert.Equal(targetBox.X + targetBox.Width, line.Waypoints[^1].X, 6); + } + + /// + /// A -shaped target's fold-excluded strip on its real Top face + /// (resolved for a flow) is excluded from the connector's + /// landing zone, matching 's own exclusion rule; no surface + /// projection applies (a note's fold sits exactly on the bounding-box edge). + /// + [Fact] + public void Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone() + { + var graph = new LayoutGraph(); + var source = graph.AddNode("author", 80, 40); + var target = graph.AddNode("note", 140, 90); + target.Label = "Design Note"; + target.Shape = BoxShape.Note; + graph.AddEdge("writes", source, target); + + var options = new LayoutOptions(); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, options); + + var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Design Note"); + var line = Assert.Single(tree.Nodes.OfType()); + var end = line.Waypoints[^1]; + + // Assert: the connector lands on the plain top edge (no projection offset for Note) but strictly + // left of the fold-excluded strip near the right edge. A 140x90 note folds + // min(140, 90) * 0.25 = 22.5, capped at NotationMetrics.NoteFoldMaxSize (16). + Assert.Equal(targetBox.Y, end.Y, 6); + Assert.True( + end.X < targetBox.X + targetBox.Width - NotationMetrics.NoteFoldMaxSize, + "Target anchor should land left of the fold-excluded strip."); + } + + /// + /// A plain chain's placement and routing are byte-for-byte + /// unchanged from the pre-shape-awareness behavior: regression guard proving the new shape-aware + /// code paths never engage for the default (and by far most common) shape. Uses the same chain + /// graph as . + /// + [Fact] + public void Apply_RectangleChain_MatchesPreShapeAwarenessOutput() + { + var tree = new LayeredLayoutAlgorithm().Apply(BuildChain(), new LayoutOptions()); + + var boxes = tree.Nodes.OfType().ToList(); + var lines = tree.Nodes.OfType().ToList(); + + Assert.Equal(3, boxes.Count); + Assert.Equal(2, lines.Count); + + // Pinned expected values for the 80x40 three-node chain (a->b->c) under the default Right flow. + Assert.Equal(20.0, boxes[0].X, 6); + Assert.Equal(20.0, boxes[0].Y, 6); + Assert.Equal(170.0, boxes[1].X, 6); + Assert.Equal(20.0, boxes[1].Y, 6); + Assert.Equal(320.0, boxes[2].X, 6); + Assert.Equal(20.0, boxes[2].Y, 6); + Assert.Equal(100.0, lines[0].Waypoints[0].X, 6); + Assert.Equal(40.0, lines[0].Waypoints[0].Y, 6); + Assert.Equal(170.0, lines[0].Waypoints[^1].X, 6); + Assert.Equal(40.0, lines[0].Waypoints[^1].Y, 6); + } } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs index 8eb7285..76e298e 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs @@ -9,8 +9,8 @@ namespace DemaConsulting.Rendering.Layout.Tests; /// /// Tests for the batteries-included facade, covering algorithm -/// resolution (graph precedence over options, default to hierarchical), the flat-graph equivalence -/// to the corresponding leaf algorithm, nested-graph composition, custom-registry support, and +/// resolution (explicit graph declaration, default to hierarchical), the flat-graph equivalence to +/// the corresponding leaf algorithm, nested-graph composition, custom-registry support, and /// null-argument validation. /// public sealed class LayoutEngineTests @@ -35,71 +35,50 @@ public void Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly() { // Arrange var graph = BuildFlatGraph(); - var options = new LayoutOptions(); // Act - var expected = new LayeredLayoutAlgorithm().Apply(graph, options); - var actual = LayoutEngine.Layout(graph, options); + var expected = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + var actual = LayoutEngine.Layout(graph); // Assert AssertTreesIdentical(expected, actual); } /// - /// Proves that an explicit "layered" declaration on the options resolves the layered algorithm, - /// producing output identical to invoking that algorithm directly. - /// - [Fact] - public void Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly() - { - // Arrange - var graph = BuildFlatGraph(); - var options = LayoutOptions.ForAlgorithm("layered"); - - // Act - var expected = new LayeredLayoutAlgorithm().Apply(graph, options); - var actual = LayoutEngine.Layout(graph, options); - - // Assert - AssertTreesIdentical(expected, actual); - } - - /// - /// Proves that an explicit "containment" declaration on the options resolves the containment + /// Proves that an explicit "layered" declaration set directly on the graph resolves the layered /// algorithm, producing output identical to invoking that algorithm directly. /// [Fact] - public void Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly() + public void Layout_GraphDeclaresLayered_MatchesLayeredAlgorithmExactly() { // Arrange var graph = BuildFlatGraph(); - var options = LayoutOptions.ForAlgorithm("containment"); + graph.Set(CoreOptions.Algorithm, "layered"); // Act - var expected = new ContainmentLayoutAlgorithm().Apply(graph, options); - var actual = LayoutEngine.Layout(graph, options); + var expected = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + var actual = LayoutEngine.Layout(graph); // Assert AssertTreesIdentical(expected, actual); } /// - /// Proves the graph's explicit algorithm declaration takes precedence over the options' - /// declaration: a graph declaring "containment" is packed even when the options declare "layered". + /// Proves that an explicit "containment" declaration set directly on the graph resolves the + /// containment algorithm, producing output identical to invoking that algorithm directly. /// [Fact] - public void Layout_GraphDeclarationOverridesOptions() + public void Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly() { - // Arrange: the graph declares containment, the options declare layered + // Arrange var graph = BuildFlatGraph(); graph.Set(CoreOptions.Algorithm, "containment"); - var options = LayoutOptions.ForAlgorithm("layered"); // Act - var expected = new ContainmentLayoutAlgorithm().Apply(graph, options); - var actual = LayoutEngine.Layout(graph, options); + var expected = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); + var actual = LayoutEngine.Layout(graph); - // Assert: the containment result wins + // Assert AssertTreesIdentical(expected, actual); } @@ -120,7 +99,7 @@ public void Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree() graph.AddNode("outside", 80, 40); // Act - var tree = LayoutEngine.Layout(graph, new LayoutOptions()); + var tree = LayoutEngine.Layout(graph); // Assert: the container box was composed with its nested children var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0); @@ -140,7 +119,7 @@ public void Layout_CustomRegistry_ResolvesRegisteredAlgorithm() graph.Set(CoreOptions.Algorithm, "stub"); // Act - var tree = LayoutEngine.Layout(graph, new LayoutOptions(), registry); + var tree = LayoutEngine.Layout(graph, registry); // Assert: the stub's sentinel canvas proves it was the algorithm applied Assert.Equal(StubAlgorithm.SentinelWidth, tree.Width); @@ -158,7 +137,7 @@ public void Layout_UnregisteredAlgorithm_Throws() graph.Set(CoreOptions.Algorithm, "nope"); // Act / Assert - Assert.Throws(() => LayoutEngine.Layout(graph, new LayoutOptions())); + Assert.Throws(() => LayoutEngine.Layout(graph)); } /// @@ -168,17 +147,7 @@ public void Layout_UnregisteredAlgorithm_Throws() public void Layout_NullGraph_Throws() { // Act / Assert - Assert.Throws(() => LayoutEngine.Layout(null!, new LayoutOptions())); - } - - /// - /// Proves a null options argument is rejected. - /// - [Fact] - public void Layout_NullOptions_Throws() - { - // Act / Assert - Assert.Throws(() => LayoutEngine.Layout(new LayoutGraph(), null!)); + Assert.Throws(() => LayoutEngine.Layout(null!)); } /// @@ -189,7 +158,7 @@ public void Layout_NullRegistry_Throws() { // Act / Assert Assert.Throws( - () => LayoutEngine.Layout(new LayoutGraph(), new LayoutOptions(), null!)); + () => LayoutEngine.Layout(new LayoutGraph(), null!)); } /// Builds a small deterministic flat graph of three labelled leaves and two edges. diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs index 96b574b..88dde02 100644 --- a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs +++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs @@ -37,4 +37,58 @@ public void SvgRenderer_Render_SingleBox_ProducesSvgDocument() Assert.Equal("image/svg+xml", renderer.MediaType); Assert.Equal(".svg", renderer.DefaultExtension); } + + /// + /// Proves that a box renders its keyword/label title text + /// recessed below the tab (never floating in the empty tab notch above the box outline), + /// matching standard SysML/UML folder notation where the tab itself carries no text. + /// + [Fact] + public void SvgRenderer_Render_FolderBox_TitleRecessedBelowTab() + { + // Arrange: an equivalent rectangle and folder box, both wide enough that the folder's + // tab does not span the full width, so any title text still positioned at the tab's + // vertical band would be visibly outside the tab's horizontal extent. + var theme = Themes.Light; + var rectangleBox = new LayoutBox(0, 0, 200, 80, "Widget", 0, BoxShape.Rectangle, [], [], Keyword: "part def"); + var folderBox = new LayoutBox(0, 0, 200, 80, "Widget", 0, BoxShape.Folder, [], [], Keyword: "part def"); + var renderer = new SvgRenderer(); + + // Act: render both boxes and extract the keyword line's y-coordinate from each + var rectangleSvg = RenderToString(renderer, rectangleBox, theme); + var folderSvg = RenderToString(renderer, folderBox, theme); + var rectangleKeywordY = ExtractFirstTextY(rectangleSvg); + var folderKeywordY = ExtractFirstTextY(folderSvg); + var tabHeight = BoxMetrics.FolderTabHeight(theme); + + // Assert: the folder's keyword text sits below the rectangle's equivalent position by + // (at least) the tab height, i.e. it is recessed below the tab rather than drawn at the + // very top of the bounding box. + Assert.True( + folderKeywordY >= rectangleKeywordY + tabHeight - 0.5, + $"Expected folder keyword y ({folderKeywordY}) to be at least tab height ({tabHeight}) " + + $"below the rectangle keyword y ({rectangleKeywordY})."); + } + + /// + /// Renders a single box within a minimal layout tree and returns the resulting SVG markup. + /// + private static string RenderToString(SvgRenderer renderer, LayoutBox box, Theme theme) + { + var tree = new LayoutTree(200, 100, [box]); + using var stream = new MemoryStream(); + renderer.Render(tree, new RenderOptions(theme), stream); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + /// + /// Extracts the y-coordinate attribute value of the first <text> element in + /// the given SVG markup. + /// + private static double ExtractFirstTextY(string svg) + { + var match = System.Text.RegularExpressions.Regex.Match(svg, """ element in: {svg}"); + return double.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + } } diff --git a/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs b/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs index edb1e74..e7a5180 100644 --- a/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs +++ b/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs @@ -65,6 +65,53 @@ public void Node_CarriesPerElementProperties() Assert.Equal(LayoutFlowDirection.Down, node.Get(CoreOptions.Direction)); } + /// + /// Proves that the optional shape-geometry hints used to align routing and rendering are stored + /// on the input node unchanged and default to when not supplied. + /// + [Fact] + public void LayoutGraphNode_ShapeGeometryHints_DefaultNullAndSettable() + { + // Arrange: create one node that leaves the hints unset and one that sets all three. + var graph = new LayoutGraph(); + var plain = graph.AddNode("plain", 80, 40); + var hinted = graph.AddNode("hinted", 120, 80); + + // Act: supply explicit rounded-corner and folder-tab geometry hints. + hinted.RoundedCornerRadius = 12.0; + hinted.FolderTabWidth = 72.0; + hinted.FolderTabHeight = 24.0; + + // Assert: unset hints stay null, while explicit values are preserved exactly. + Assert.Null(plain.RoundedCornerRadius); + Assert.Null(plain.FolderTabWidth); + Assert.Null(plain.FolderTabHeight); + Assert.Equal(12.0, hinted.RoundedCornerRadius); + Assert.Equal(72.0, hinted.FolderTabWidth); + Assert.Equal(24.0, hinted.FolderTabHeight); + } + + /// + /// Proves that assigning to is + /// coerced to an empty list rather than leaving the non-nullable property null, so downstream + /// algorithms and renderers can always safely iterate it. + /// + [Fact] + public void LayoutGraphNode_Compartments_NullAssignmentCoercedToEmpty() + { + // Arrange + var graph = new LayoutGraph(); + var node = graph.AddNode("a", 80, 40); + node.Compartments = [new LayoutCompartment("Attributes", ["value : int"])]; + + // Act + node.Compartments = null!; + + // Assert + Assert.NotNull(node.Compartments); + Assert.Empty(node.Compartments); + } + /// /// Proves that AddNode rejects a null identifier. /// diff --git a/test/DemaConsulting.Rendering.Tests/LayoutTests.cs b/test/DemaConsulting.Rendering.Tests/LayoutTests.cs index b213b97..2664f26 100644 --- a/test/DemaConsulting.Rendering.Tests/LayoutTests.cs +++ b/test/DemaConsulting.Rendering.Tests/LayoutTests.cs @@ -32,7 +32,7 @@ public void LayoutTree_Construction_StoresWidthHeightNodes() } /// - /// A LayoutBox constructed with all nine parameters set to non-default values stores + /// A LayoutBox constructed with all geometry-related parameters set to non-default values stores /// each property exactly as supplied. /// [Fact] @@ -42,7 +42,7 @@ public void LayoutBox_Construction_StoresAllFields() var compartment = new LayoutCompartment("Attributes", ["radius : Real"]); var child = new LayoutPort(15, 25, PortSide.Top, "p"); - // Act: construct a LayoutBox with all nine parameters non-default + // Act: construct a LayoutBox with all fields non-default, including shape-geometry hints. var box = new LayoutBox( X: 10.0, Y: 20.0, @@ -52,9 +52,13 @@ public void LayoutBox_Construction_StoresAllFields() Depth: 2, Shape: BoxShape.RoundedRectangle, Compartments: [compartment], - Children: [child]); + Children: [child], + Keyword: "part def", + RoundedCornerRadius: 12.0, + FolderTabWidth: 72.0, + FolderTabHeight: 24.0); - // Assert: all nine properties equal the supplied values + // Assert: all fields equal the supplied values. Assert.Equal(10.0, box.X); Assert.Equal(20.0, box.Y); Assert.Equal(200.0, box.Width); @@ -66,6 +70,10 @@ public void LayoutBox_Construction_StoresAllFields() Assert.Same(compartment, box.Compartments[0]); Assert.Single(box.Children); Assert.Same(child, box.Children[0]); + Assert.Equal("part def", box.Keyword); + Assert.Equal(12.0, box.RoundedCornerRadius); + Assert.Equal(72.0, box.FolderTabWidth); + Assert.Equal(24.0, box.FolderTabHeight); } ///