mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 10:53:21 +08:00
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth, per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta. - Mount the official MCP Streamable HTTP app at /mcp (stateless); AuthAuditMiddleware is pure ASGI and skips /mcp. - Let uvicorn chunk MCP responses; some Windows LSPs rewrite Content-Length replies on */mcp* paths into broken chunked framing. - NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default so LAN and reverse-proxy hosts work; Bearer is the gate). - Remove the three stdio Python MCP packages; tests moved to /mcp, including real-TCP framing regressions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
796 lines
27 KiB
Python
796 lines
27 KiB
Python
"""Compose multiple view layouts via portal-aware packing + shared-node merge.
|
|
|
|
Dual-unit staging views intentionally overlap on portals. Pack order grows by
|
|
shared membership (Prim-style), then block origins come from a spring layout on
|
|
the portal-weighted block graph so units that share hubs sit near each other.
|
|
Orphan/misc blocks strip-pack below the glued component — cutting long
|
|
cross-unit edges that area-first square packing leaves behind.
|
|
|
|
When ``merge_shared`` (default), later blocks that share fabric_node_ids are
|
|
rigidly translated/rotated onto the first owner's world coordinates.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
|
|
|
DEFAULT_SLOT_PAD = 600.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComposeBlock:
|
|
"""One staging canvas / dual-unit view to pack."""
|
|
|
|
key: str
|
|
positions: dict[str, tuple[float, float]] # fabric_node_id -> local xy
|
|
|
|
|
|
def _membership_counts(blocks: list[ComposeBlock]) -> dict[str, int]:
|
|
counts: dict[str, int] = {}
|
|
for b in blocks:
|
|
for nid in b.positions:
|
|
counts[nid] = counts.get(nid, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _shared_weight(
|
|
ids_a: set[str],
|
|
ids_b: set[str],
|
|
membership: dict[str, int],
|
|
) -> float:
|
|
"""Higher when blocks share portal-like hubs (multi-membership nodes)."""
|
|
shared = ids_a & ids_b
|
|
if not shared:
|
|
return 0.0
|
|
# membership>=2 are glue points; boost hubs that appear in many units.
|
|
return float(sum(max(1, membership.get(n, 1)) ** 2 for n in shared))
|
|
|
|
|
|
def _fabric_bridge_hits(
|
|
ids_a: set[str],
|
|
ids_b: set[str],
|
|
links: list[tuple[str, str]],
|
|
) -> int:
|
|
"""Count fabric edges with one end in A and the other in B."""
|
|
if not links or not ids_a or not ids_b:
|
|
return 0
|
|
n = 0
|
|
for a, b in links:
|
|
if (a in ids_a and b in ids_b) or (a in ids_b and b in ids_a):
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def _portal_grow_order(
|
|
blocks: list[ComposeBlock],
|
|
slot_meta: dict[str, dict[str, float]],
|
|
membership: dict[str, int],
|
|
*,
|
|
links: list[tuple[str, str]] | None = None,
|
|
fabric_bridges: bool = True,
|
|
) -> list[str]:
|
|
"""Order blocks by shared-portal growth (Prim), not raw area.
|
|
|
|
Seed = block with strongest multi-membership glue (ignore orphan-only
|
|
misc), then attach max shared-weight neighbor. Fabric bridges (optional)
|
|
also count as glue so long-edge peers attach before pure orphans.
|
|
"""
|
|
if not blocks:
|
|
return []
|
|
id_sets = {b.key: set(b.positions) for b in blocks}
|
|
remaining = {b.key for b in blocks}
|
|
link_list = list(links or []) if fabric_bridges else []
|
|
|
|
def glue_mass(key: str) -> float:
|
|
return float(
|
|
sum(
|
|
membership.get(n, 1) ** 2
|
|
for n in id_sets[key]
|
|
if membership.get(n, 1) >= 2
|
|
)
|
|
)
|
|
|
|
def fabric_to(ids: set[str], other: set[str]) -> float:
|
|
if not link_list:
|
|
return 0.0
|
|
return float(_fabric_bridge_hits(ids, other, link_list))
|
|
|
|
glued = [k for k in remaining if glue_mass(k) > 0]
|
|
if not glued and link_list:
|
|
# Prefer a block that participates in any inter-block fabric edge.
|
|
seeded = [
|
|
k
|
|
for k in remaining
|
|
if any(
|
|
_fabric_bridge_hits(id_sets[k], id_sets[o], link_list) > 0
|
|
for o in remaining
|
|
if o != k
|
|
)
|
|
]
|
|
glued = seeded
|
|
if glued:
|
|
seed = max(glued, key=lambda k: (glue_mass(k), slot_meta[k]["area"], k))
|
|
else:
|
|
seed = max(remaining, key=lambda k: (slot_meta[k]["area"], k))
|
|
order = [seed]
|
|
remaining.remove(seed)
|
|
placed_nodes = set(id_sets[seed])
|
|
|
|
while remaining:
|
|
best_key = None
|
|
best_score: tuple[float, float, float, float, str] | None = None
|
|
for k in remaining:
|
|
w = _shared_weight(id_sets[k], placed_nodes, membership)
|
|
fb = fabric_to(id_sets[k], placed_nodes)
|
|
rest_nodes: set[str] = set()
|
|
for o in remaining:
|
|
if o != k:
|
|
rest_nodes |= id_sets[o]
|
|
w_rest = (
|
|
_shared_weight(id_sets[k], rest_nodes, membership)
|
|
if w <= 0 and fb <= 0
|
|
else 0.0
|
|
)
|
|
fb_rest = (
|
|
fabric_to(id_sets[k], rest_nodes) if w <= 0 and fb <= 0 else 0.0
|
|
)
|
|
# Orphans (no portal/fabric glue): larger → later.
|
|
if w <= 0 and fb <= 0 and w_rest <= 0 and fb_rest <= 0:
|
|
area_term = -slot_meta[k]["area"]
|
|
else:
|
|
area_term = slot_meta[k]["area"]
|
|
score = (w + fb * 4.0, w_rest + fb_rest * 2.0, area_term, fb, k)
|
|
if best_score is None or score > best_score:
|
|
best_score = score
|
|
best_key = k
|
|
assert best_key is not None
|
|
order.append(best_key)
|
|
remaining.remove(best_key)
|
|
placed_nodes |= id_sets[best_key]
|
|
return order
|
|
|
|
|
|
def _rank_shared_pivots(
|
|
shared: list[str], membership: dict[str, int]
|
|
) -> list[str]:
|
|
"""Prefer multi-unit hubs as rigid-align pivots (not corridor alpha order)."""
|
|
return sorted(shared, key=lambda n: (-membership.get(n, 1), n))
|
|
|
|
|
|
def _block_graph(
|
|
blocks: list[ComposeBlock],
|
|
membership: dict[str, int],
|
|
*,
|
|
links: list[tuple[str, str]] | None = None,
|
|
fabric_bridges: bool = True,
|
|
bridge_boost: float = 8.0,
|
|
) -> dict[str, dict[str, float]]:
|
|
"""Undirected weighted graph: portal glue + optional fabric-bridge boost.
|
|
|
|
``bridge_boost`` is MCP-tunable: each inter-block fabric edge adds this
|
|
much weight so long-spoke peers sit near each other in the spring pack.
|
|
"""
|
|
id_sets = {b.key: set(b.positions) for b in blocks}
|
|
keys = [b.key for b in blocks]
|
|
adj: dict[str, dict[str, float]] = {k: {} for k in keys}
|
|
boost = max(0.0, float(bridge_boost))
|
|
link_list = list(links or []) if fabric_bridges and boost > 0 else []
|
|
for i, ka in enumerate(keys):
|
|
for kb in keys[i + 1 :]:
|
|
w = _shared_weight(id_sets[ka], id_sets[kb], membership)
|
|
if link_list:
|
|
w += boost * float(
|
|
_fabric_bridge_hits(id_sets[ka], id_sets[kb], link_list)
|
|
)
|
|
if w <= 0:
|
|
continue
|
|
adj[ka][kb] = w
|
|
adj[kb][ka] = w
|
|
return adj
|
|
|
|
|
|
def _reflect_about_axis(
|
|
pos: dict[str, tuple[float, float]],
|
|
members: list[str],
|
|
p0: tuple[float, float],
|
|
p1: tuple[float, float],
|
|
) -> dict[str, tuple[float, float]]:
|
|
ax, ay = p0
|
|
bx, by = p1
|
|
dx, dy = bx - ax, by - ay
|
|
L2 = dx * dx + dy * dy
|
|
if L2 < 1e-12:
|
|
return dict(pos)
|
|
out = dict(pos)
|
|
for n in members:
|
|
if n not in out:
|
|
continue
|
|
x, y = out[n]
|
|
t = ((x - ax) * dx + (y - ay) * dy) / L2
|
|
px, py = ax + t * dx, ay + t * dy
|
|
out[n] = (2 * px - x, 2 * py - y)
|
|
return out
|
|
|
|
|
|
def _external_bridge_cost(
|
|
placed: dict[str, tuple[float, float]],
|
|
world: dict[str, tuple[float, float]],
|
|
links: list[tuple[str, str]],
|
|
new_ids: set[str],
|
|
) -> float:
|
|
"""Sum of squared lengths for edges that leave this block into ``world``."""
|
|
if not links or not new_ids:
|
|
return 0.0
|
|
cost = 0.0
|
|
for a, b in links:
|
|
a_new = a in new_ids
|
|
b_new = b in new_ids
|
|
if a_new == b_new:
|
|
continue
|
|
pa = placed.get(a) if a_new else world.get(a)
|
|
pb = placed.get(b) if b_new else world.get(b)
|
|
if pa is None or pb is None:
|
|
continue
|
|
dx = pa[0] - pb[0]
|
|
dy = pa[1] - pb[1]
|
|
cost += dx * dx + dy * dy
|
|
return cost
|
|
|
|
|
|
def _portal_centroid_origins(
|
|
key_order: list[str],
|
|
slot_meta: dict[str, dict[str, float]],
|
|
graph: dict[str, dict[str, float]],
|
|
*,
|
|
pad: float,
|
|
ideal_scale: float = 0.55,
|
|
spring_iters: int = 80,
|
|
) -> dict[str, tuple[float, float]]:
|
|
"""Spring-embed block centroids on the portal/fabric graph; orphans strip below.
|
|
|
|
Origins are used for strip_pack seeds (and as a soft hint before merge).
|
|
``ideal_scale`` / ``spring_iters`` are MCP-tunable (smaller scale → denser).
|
|
"""
|
|
if not key_order:
|
|
return {}
|
|
glued = [k for k in key_order if graph.get(k)]
|
|
orphans = [k for k in key_order if k not in set(glued)]
|
|
|
|
origins: dict[str, tuple[float, float]] = {}
|
|
if not glued:
|
|
# Fall back to square strip for everything.
|
|
n = len(key_order)
|
|
cols = max(1, int(math.ceil(math.sqrt(n))))
|
|
col_w = [0.0] * cols
|
|
row_h: dict[int, float] = defaultdict(float)
|
|
for i, key in enumerate(key_order):
|
|
c, r = i % cols, i // cols
|
|
col_w[c] = max(col_w[c], slot_meta[key]["w"])
|
|
row_h[r] = max(row_h[r], slot_meta[key]["h"])
|
|
cox = [0.0]
|
|
for c in range(cols - 1):
|
|
cox.append(cox[-1] + col_w[c])
|
|
roy = {0: 0.0}
|
|
for r in range(1, (n // cols) + 2):
|
|
roy[r] = roy.get(r - 1, 0.0) + row_h.get(r - 1, 0.0)
|
|
for i, key in enumerate(key_order):
|
|
c, r = i % cols, i // cols
|
|
origins[key] = (cox[c], roy[r])
|
|
return origins
|
|
|
|
scale = max(0.25, min(float(ideal_scale), 1.2))
|
|
iters = max(20, min(int(spring_iters), 240))
|
|
|
|
# Ideal edge length ~ mean diagonal of the two slots.
|
|
def ideal(a: str, b: str) -> float:
|
|
da = math.hypot(slot_meta[a]["w"], slot_meta[a]["h"])
|
|
db = math.hypot(slot_meta[b]["w"], slot_meta[b]["h"])
|
|
return scale * (da + db) + float(pad)
|
|
|
|
# Init on a circle scaled by total size.
|
|
n_g = len(glued)
|
|
span = sum(math.hypot(slot_meta[k]["w"], slot_meta[k]["h"]) for k in glued) / max(
|
|
n_g, 1
|
|
)
|
|
radius = max(span * 0.7 * math.sqrt(n_g), 400.0)
|
|
pos: dict[str, list[float]] = {}
|
|
for i, key in enumerate(glued):
|
|
ang = 2.0 * math.pi * i / n_g
|
|
pos[key] = [radius * math.cos(ang), radius * math.sin(ang)]
|
|
|
|
# Lightweight spring / Fruchterman-style iterations (no numpy).
|
|
max_w = max((w for nbrs in graph.values() for w in nbrs.values()), default=1.0)
|
|
for _ in range(iters):
|
|
disp = {k: [0.0, 0.0] for k in glued}
|
|
# Attractive along portal / fabric edges.
|
|
for a in glued:
|
|
for b, w in graph.get(a, {}).items():
|
|
if b not in pos or a >= b:
|
|
continue
|
|
dx = pos[b][0] - pos[a][0]
|
|
dy = pos[b][1] - pos[a][1]
|
|
dist = math.hypot(dx, dy) or 1e-6
|
|
L = ideal(a, b)
|
|
# Stronger weight → stronger pull toward ideal length.
|
|
force = (dist - L) * (0.15 + 0.35 * (w / max_w))
|
|
ux, uy = dx / dist, dy / dist
|
|
disp[a][0] += ux * force
|
|
disp[a][1] += uy * force
|
|
disp[b][0] -= ux * force
|
|
disp[b][1] -= uy * force
|
|
# Mild repulsion so slots do not stack.
|
|
for i, a in enumerate(glued):
|
|
for b in glued[i + 1 :]:
|
|
dx = pos[b][0] - pos[a][0]
|
|
dy = pos[b][1] - pos[a][1]
|
|
dist = math.hypot(dx, dy) or 1e-6
|
|
min_d = 0.45 * (
|
|
math.hypot(slot_meta[a]["w"], slot_meta[a]["h"])
|
|
+ math.hypot(slot_meta[b]["w"], slot_meta[b]["h"])
|
|
)
|
|
if dist >= min_d:
|
|
continue
|
|
push = (min_d - dist) * 0.35
|
|
ux, uy = dx / dist, dy / dist
|
|
disp[a][0] -= ux * push
|
|
disp[a][1] -= uy * push
|
|
disp[b][0] += ux * push
|
|
disp[b][1] += uy * push
|
|
for k in glued:
|
|
pos[k][0] += max(-800.0, min(800.0, disp[k][0]))
|
|
pos[k][1] += max(-800.0, min(800.0, disp[k][1]))
|
|
|
|
# Convert centers → top-left origins (slot min corner).
|
|
for key in glued:
|
|
cx, cy = pos[key]
|
|
origins[key] = (
|
|
cx - 0.5 * slot_meta[key]["w"],
|
|
cy - 0.5 * slot_meta[key]["h"],
|
|
)
|
|
|
|
# Orphans: horizontal strip under the glued bbox.
|
|
if orphans:
|
|
xs = [origins[k][0] for k in glued]
|
|
ys = [origins[k][1] for k in glued]
|
|
ys2 = [origins[k][1] + slot_meta[k]["h"] for k in glued]
|
|
base_y = max(ys2) + float(pad)
|
|
base_x = min(xs)
|
|
x_cursor = base_x
|
|
for key in orphans:
|
|
origins[key] = (x_cursor, base_y)
|
|
x_cursor += slot_meta[key]["w"] + 0.25 * float(pad)
|
|
|
|
# Normalize so min corner is near origin.
|
|
min_x = min(o[0] for o in origins.values())
|
|
min_y = min(o[1] for o in origins.values())
|
|
return {k: (x - min_x + 40.0, y - min_y + 40.0) for k, (x, y) in origins.items()}
|
|
|
|
|
|
def _rigid_align_to_world(
|
|
local: dict[str, tuple[float, float]],
|
|
world: dict[str, tuple[float, float]],
|
|
shared: list[str],
|
|
*,
|
|
prefer_center: tuple[float, float] | None = None,
|
|
links: list[tuple[str, str]] | None = None,
|
|
) -> dict[str, tuple[float, float]]:
|
|
"""Translate (+ rotate about first shared) so shared nodes match world.
|
|
|
|
With two+ shared pivots, orientation follows the portal chord; also try the
|
|
flip across that chord and keep the side with fewer crossings on the
|
|
partial world (then shorter external bridges / closer to ``prefer_center``).
|
|
With a single pivot, rotate so the block centroid aims at ``prefer_center``.
|
|
"""
|
|
if not shared:
|
|
return dict(local)
|
|
p0 = shared[0]
|
|
lx0, ly0 = local[p0]
|
|
wx0, wy0 = world[p0]
|
|
angle = 0.0
|
|
if len(shared) >= 2:
|
|
p1 = shared[1]
|
|
ldx, ldy = local[p1][0] - lx0, local[p1][1] - ly0
|
|
wdx, wdy = world[p1][0] - wx0, world[p1][1] - wy0
|
|
if (ldx * ldx + ldy * ldy) > 1e-12 and (wdx * wdx + wdy * wdy) > 1e-12:
|
|
angle = math.atan2(wdy, wdx) - math.atan2(ldy, ldx)
|
|
elif prefer_center is not None and len(local) >= 2:
|
|
# Local centroid relative to portal.
|
|
cx = sum(p[0] for p in local.values()) / len(local)
|
|
cy = sum(p[1] for p in local.values()) / len(local)
|
|
ldx, ldy = cx - lx0, cy - ly0
|
|
tdx = prefer_center[0] - wx0
|
|
tdy = prefer_center[1] - wy0
|
|
if (ldx * ldx + ldy * ldy) > 1e-8 and (tdx * tdx + tdy * tdy) > 1e-8:
|
|
angle = math.atan2(tdy, tdx) - math.atan2(ldy, ldx)
|
|
cos_a, sin_a = math.cos(angle), math.sin(angle)
|
|
out: dict[str, tuple[float, float]] = {}
|
|
for nid, (lx, ly) in local.items():
|
|
dx, dy = lx - lx0, ly - ly0
|
|
rx = dx * cos_a - dy * sin_a
|
|
ry = dx * sin_a + dy * cos_a
|
|
out[nid] = (wx0 + rx, wy0 + ry)
|
|
|
|
if len(shared) < 2:
|
|
return out
|
|
|
|
# Dual-portal flip: same chord, opposite bank.
|
|
shared_set = set(shared)
|
|
new_ids = {n for n in local if n not in shared_set}
|
|
link_list = list(links or [])
|
|
|
|
def _score(cand: dict[str, tuple[float, float]]) -> tuple[int, float, float]:
|
|
trial = dict(world)
|
|
for nid, xy in cand.items():
|
|
if nid in shared_set:
|
|
continue
|
|
trial[nid] = xy
|
|
xcount = (
|
|
count_edge_crossings(trial, link_list) if link_list else 0
|
|
)
|
|
bridge = _external_bridge_cost(cand, world, link_list, new_ids)
|
|
if prefer_center is None:
|
|
aim = 0.0
|
|
else:
|
|
excl = [cand[n] for n in new_ids if n in cand]
|
|
if not excl:
|
|
aim = 0.0
|
|
else:
|
|
cx = sum(p[0] for p in excl) / len(excl)
|
|
cy = sum(p[1] for p in excl) / len(excl)
|
|
aim = (cx - prefer_center[0]) ** 2 + (cy - prefer_center[1]) ** 2
|
|
return (int(xcount), bridge, aim)
|
|
|
|
flipped = _reflect_about_axis(
|
|
out, list(local.keys()), world[shared[0]], world[shared[1]]
|
|
)
|
|
for p in shared:
|
|
if p in world:
|
|
flipped[p] = world[p]
|
|
if _score(flipped) < _score(out):
|
|
return flipped
|
|
return out
|
|
|
|
|
|
def strip_pack_blocks(
|
|
blocks: list[ComposeBlock],
|
|
*,
|
|
pad: float = DEFAULT_SLOT_PAD,
|
|
merge_shared: bool = True,
|
|
links: list[tuple[str, str]] | None = None,
|
|
fabric_bridges: bool = True,
|
|
bridge_boost: float = 8.0,
|
|
ideal_scale: float = 0.55,
|
|
spring_iters: int = 80,
|
|
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
|
|
"""Pack blocks; return merged positions + meta.
|
|
|
|
With ``merge_shared`` (default): later blocks that share node ids with
|
|
already-placed nodes are rigidly aligned (portal merge). First owner wins
|
|
for shared coordinates. Blocks without shared anchors use strip-pack slots.
|
|
|
|
Pack order prefers portal connectivity growth so dual units that share
|
|
hubs stay glued; large orphan/misc blocks are placed last. Optional
|
|
``links`` + ``fabric_bridges`` add fabric-bridge weight to the spring and
|
|
guide dual-portal flip by partial-graph crossings (then bridge length).
|
|
|
|
MCP knobs: ``pad``, ``bridge_boost``, ``ideal_scale``, ``spring_iters``,
|
|
``fabric_bridges``.
|
|
"""
|
|
if not blocks:
|
|
return {}, {"slots": 0, "nodes": 0, "pad": pad, "merge_shared": merge_shared}
|
|
|
|
slot_meta: dict[str, dict[str, float]] = {}
|
|
valid: list[ComposeBlock] = []
|
|
for b in blocks:
|
|
if not b.positions:
|
|
continue
|
|
xs = [p[0] for p in b.positions.values()]
|
|
ys = [p[1] for p in b.positions.values()]
|
|
min_x, max_x = min(xs), max(xs)
|
|
min_y, max_y = min(ys), max(ys)
|
|
w = max(max_x - min_x, 1.0) + float(pad)
|
|
h = max(max_y - min_y, 1.0) + float(pad)
|
|
slot_meta[b.key] = {
|
|
"min_x": min_x,
|
|
"min_y": min_y,
|
|
"w": w,
|
|
"h": h,
|
|
"area": w * h,
|
|
"n": float(len(b.positions)),
|
|
}
|
|
valid.append(b)
|
|
|
|
if not valid:
|
|
return {}, {"slots": 0, "nodes": 0, "pad": pad, "merge_shared": merge_shared}
|
|
|
|
membership = _membership_counts(valid)
|
|
link_list = list(links or [])
|
|
do_fabric = bool(fabric_bridges)
|
|
boost = max(0.0, float(bridge_boost))
|
|
if merge_shared:
|
|
key_order = _portal_grow_order(
|
|
valid,
|
|
slot_meta,
|
|
membership,
|
|
links=link_list,
|
|
fabric_bridges=do_fabric,
|
|
)
|
|
order_mode = "portal_grow"
|
|
graph = _block_graph(
|
|
valid,
|
|
membership,
|
|
links=link_list,
|
|
fabric_bridges=do_fabric,
|
|
bridge_boost=boost,
|
|
)
|
|
origins = _portal_centroid_origins(
|
|
key_order,
|
|
slot_meta,
|
|
graph,
|
|
pad=float(pad),
|
|
ideal_scale=float(ideal_scale),
|
|
spring_iters=int(spring_iters),
|
|
)
|
|
pack_mode = "portal_centroid"
|
|
cols = 0
|
|
else:
|
|
key_order = [
|
|
b.key
|
|
for b in sorted(valid, key=lambda b: (-slot_meta[b.key]["area"], b.key))
|
|
]
|
|
order_mode = "area"
|
|
pack_mode = "strip"
|
|
n = max(1, len(key_order))
|
|
cols = max(1, int(math.ceil(math.sqrt(n))))
|
|
col_widths: list[float] = [0.0] * cols
|
|
row_heights: dict[int, float] = defaultdict(float)
|
|
for i, key in enumerate(key_order):
|
|
c, r = i % cols, i // cols
|
|
meta = slot_meta[key]
|
|
col_widths[c] = max(col_widths[c], meta["w"])
|
|
row_heights[r] = max(row_heights[r], meta["h"])
|
|
|
|
col_origin = [0.0]
|
|
for c in range(cols - 1):
|
|
col_origin.append(col_origin[-1] + col_widths[c])
|
|
row_origin: dict[int, float] = {0: 0.0}
|
|
max_row = max((i // cols for i in range(n)), default=0)
|
|
for r in range(1, max_row + 2):
|
|
row_origin[r] = row_origin.get(r - 1, 0.0) + row_heights.get(r - 1, 0.0)
|
|
|
|
origins = {}
|
|
for i, key in enumerate(key_order):
|
|
c, r = i % cols, i // cols
|
|
origins[key] = (col_origin[c], row_origin[r])
|
|
|
|
by_key = {b.key: b for b in valid}
|
|
out: dict[str, tuple[float, float]] = {}
|
|
owner: dict[str, str] = {}
|
|
merged_via: dict[str, str] = {}
|
|
align_meta: list[dict[str, Any]] = []
|
|
# Per-block members as placed (whole sub-region = rigid body).
|
|
group_nodes: dict[str, list[str]] = {k: [] for k in key_order}
|
|
group_pivots: dict[str, list[str]] = {k: [] for k in key_order}
|
|
|
|
for key in key_order:
|
|
block = by_key[key]
|
|
meta = slot_meta[key]
|
|
shared_raw = [nid for nid in block.positions if nid in out]
|
|
shared = _rank_shared_pivots(shared_raw, membership)
|
|
if merge_shared and shared:
|
|
ox, oy = origins[key]
|
|
prefer = (
|
|
ox + 0.5 * meta["w"],
|
|
oy + 0.5 * meta["h"],
|
|
)
|
|
aligned = _rigid_align_to_world(
|
|
block.positions,
|
|
out,
|
|
shared,
|
|
prefer_center=prefer,
|
|
links=link_list,
|
|
)
|
|
placed_new = 0
|
|
for nid, xy in aligned.items():
|
|
group_nodes[key].append(nid)
|
|
if nid in out:
|
|
continue
|
|
out[nid] = xy
|
|
owner[nid] = key
|
|
placed_new += 1
|
|
# Freeze pivots = high-membership shared (true portals), not every
|
|
# overlapping corridor id — soft polish needs corridors movable.
|
|
portal_pivots = [n for n in shared if membership.get(n, 1) >= 2][:8]
|
|
group_pivots[key] = portal_pivots or list(shared[:2])
|
|
merged_via[key] = "rigid_shared"
|
|
align_meta.append(
|
|
{
|
|
"key": key,
|
|
"shared": shared[:6],
|
|
"shared_n": len(shared),
|
|
"placed_new": placed_new,
|
|
"pivot_n": len(group_pivots[key]),
|
|
}
|
|
)
|
|
continue
|
|
|
|
ox, oy = origins[key]
|
|
for nid, (lx, ly) in block.positions.items():
|
|
group_nodes[key].append(nid)
|
|
if nid in out:
|
|
group_pivots[key].append(nid)
|
|
continue
|
|
out[nid] = (ox + (lx - meta["min_x"]), oy + (ly - meta["min_y"]))
|
|
owner[nid] = key
|
|
merged_via[key] = "strip_pack"
|
|
|
|
rigid_groups = [
|
|
{
|
|
"key": k,
|
|
"node_ids": sorted(set(group_nodes[k])),
|
|
"pivots": sorted(set(group_pivots.get(k) or [])),
|
|
"via": merged_via.get(k),
|
|
"soft": True,
|
|
}
|
|
for k in key_order
|
|
if group_nodes.get(k)
|
|
]
|
|
mass_groups = [
|
|
{**g, "cores": list(g.get("pivots") or []), "soft": True}
|
|
for g in rigid_groups
|
|
]
|
|
|
|
return out, {
|
|
"slots": len(key_order),
|
|
"nodes": len(out),
|
|
"pad": float(pad),
|
|
"merge_shared": bool(merge_shared),
|
|
"fabric_bridges": do_fabric if merge_shared else False,
|
|
"bridge_boost": boost if merge_shared else 0.0,
|
|
"ideal_scale": float(ideal_scale) if merge_shared else None,
|
|
"spring_iters": int(spring_iters) if merge_shared else None,
|
|
"order_mode": order_mode,
|
|
"pack_mode": pack_mode,
|
|
"cols": cols,
|
|
"order": key_order,
|
|
"origins": {k: [round(v[0], 2), round(v[1], 2)] for k, v in origins.items()},
|
|
"owner": {k: owner[k] for k in list(owner)[:40]},
|
|
"merged_via": merged_via,
|
|
"alignments": align_meta[:20],
|
|
"rigid_groups": rigid_groups,
|
|
"mass_groups": mass_groups,
|
|
"soft": True,
|
|
"tip": (
|
|
"Compose: portal+fabric spring seed; prefer mass_merge "
|
|
"(core/ring/chain attract) then polish_crossings."
|
|
),
|
|
"slot_meta": {
|
|
k: {
|
|
"w": round(v["w"], 1),
|
|
"h": round(v["h"], 1),
|
|
"area": round(v["area"], 1),
|
|
"n": int(v["n"]),
|
|
}
|
|
for k, v in slot_meta.items()
|
|
},
|
|
}
|
|
|
|
|
|
def blocks_from_position_maps(
|
|
maps: list[tuple[str, dict[str, tuple[float, float]]]],
|
|
) -> list[ComposeBlock]:
|
|
return [ComposeBlock(key=k, positions=dict(pos)) for k, pos in maps if pos]
|
|
|
|
|
|
def compose_into_state(
|
|
state: LayoutState,
|
|
blocks: list[ComposeBlock],
|
|
params: LayoutParams | None = None,
|
|
*,
|
|
pad: float = DEFAULT_SLOT_PAD,
|
|
merge_shared: bool = True,
|
|
fabric_bridges: bool = True,
|
|
bridge_boost: float = 8.0,
|
|
ideal_scale: float = 0.55,
|
|
spring_iters: int = 80,
|
|
) -> OpResult:
|
|
"""Write strip-packed / merge-aligned coords onto ``state``."""
|
|
del params
|
|
merged, meta = strip_pack_blocks(
|
|
blocks,
|
|
pad=pad,
|
|
merge_shared=merge_shared,
|
|
links=list(state.links or []),
|
|
fabric_bridges=fabric_bridges,
|
|
bridge_boost=bridge_boost,
|
|
ideal_scale=ideal_scale,
|
|
spring_iters=spring_iters,
|
|
)
|
|
if not merged:
|
|
return OpResult(
|
|
state=state,
|
|
moved=set(),
|
|
op="compose_views",
|
|
params=meta,
|
|
note="no_blocks",
|
|
)
|
|
out = state.copy()
|
|
moved: set[str] = set()
|
|
for nid, xy in merged.items():
|
|
if nid not in out.positions:
|
|
out.positions[nid] = xy
|
|
moved.add(nid)
|
|
continue
|
|
if out.positions[nid] != xy:
|
|
out.positions[nid] = xy
|
|
moved.add(nid)
|
|
out.meta = dict(out.meta or {})
|
|
out.meta["compose_views"] = meta
|
|
try:
|
|
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
|
|
attach_mass_to_compose_meta,
|
|
build_mass_field,
|
|
)
|
|
|
|
mass = build_mass_field(out, groups=meta.get("mass_groups") or [])
|
|
out.meta["mass_field"] = mass
|
|
out.meta["compose_views"] = attach_mass_to_compose_meta(meta, mass)
|
|
except Exception:
|
|
pass
|
|
out.last_moved = moved
|
|
return OpResult(
|
|
state=out,
|
|
moved=moved,
|
|
op="compose_views",
|
|
params={**meta, "moved_n": len(moved)},
|
|
note=(
|
|
f"compose_views:slots={meta.get('slots')} nodes={meta.get('nodes')} "
|
|
f"merge_shared={meta.get('merge_shared')} order={meta.get('order_mode')}"
|
|
),
|
|
)
|
|
|
|
|
|
def compose_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
|
|
out: dict[str, Any] = {}
|
|
if not overrides:
|
|
return out
|
|
if overrides.get("pad") is not None:
|
|
try:
|
|
out["pad"] = float(overrides["pad"])
|
|
except (TypeError, ValueError):
|
|
pass
|
|
for flag in ("merge_shared", "fabric_bridges"):
|
|
if flag not in overrides:
|
|
continue
|
|
v = overrides[flag]
|
|
if isinstance(v, bool):
|
|
out[flag] = v
|
|
else:
|
|
out[flag] = str(v).strip().lower() in {"1", "true", "yes", "on"}
|
|
if overrides.get("bridge_boost") is not None:
|
|
try:
|
|
out["bridge_boost"] = max(0.0, float(overrides["bridge_boost"]))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if overrides.get("ideal_scale") is not None:
|
|
try:
|
|
out["ideal_scale"] = max(0.25, min(1.2, float(overrides["ideal_scale"])))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if overrides.get("spring_iters") is not None:
|
|
try:
|
|
out["spring_iters"] = max(20, min(240, int(overrides["spring_iters"])))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
src = overrides.get("source_view_ids")
|
|
if isinstance(src, list):
|
|
out["source_view_ids"] = [str(x).strip() for x in src if str(x).strip()]
|
|
elif isinstance(src, str) and src.strip():
|
|
out["source_view_ids"] = [s.strip() for s in src.split(",") if s.strip()]
|
|
return out
|