Implement arrangement framework for node groups

- Introduced `frames.py` to manage field complex calculations and their arrangement within node groups.
- Added functionality to create and remove field frames based on node arrangements.
- Developed `passages.py` to find unobstructed paths for connections around calculation frames.
- Implemented `references.py` to route shared results on peripheral tracks, ensuring efficient distribution of outputs.
- Created `sections.py` to reserve space for field calculations adjacent to geometry consumers.
- Added `sources.py` to position shared calculation sources effectively beside their distribution channels.
- Enhanced outline smoothing with `outline_smoothing.py` to adapt camera depth along original outlines.
This commit is contained in:
2026-09-12 12:19:07 +08:00
parent dd32ccdc44
commit 5b2dc59f79
57 changed files with 2253 additions and 700 deletions

View File

@@ -9,7 +9,7 @@ import bpy
from ..arrangement.dimensions import estimate_node_dimensions
from ..arrangement.flow import node_roles
from ..arrangement.connections import link_endpoints
from ..arrangement.zones import node_zones
from ..arrangement.zones import node_zones, repeat_regions
def _display_value(value):
@@ -30,7 +30,7 @@ def _display_value(value):
def _node_group_data(node_group):
nodes = list(node_group.nodes)
nodes = [node for node in node_group.nodes if node.bl_idname != "NodeFrame"]
ids = {node: index for index, node in enumerate(nodes)}
dimensions = {node: estimate_node_dimensions(node) for node in nodes}
roles = node_roles(node_group)
@@ -56,28 +56,39 @@ def _node_group_data(node_group):
sockets.append([socket.name, round(offset, 2), int(socket.is_output), socket.type, default])
records.append({"id": ids[node], "name": node.name, "title": title,
"type": node.bl_idname, "role": roles[node],
"x": round(node.location.x, 2), "y": round(-node.location.y, 2),
"x": round(node.location_absolute.x, 2), "y": round(-node.location_absolute.y, 2),
"w": size.width, "h": size.height, "folded": bool(node.hide),
"settings": settings, "sockets": sockets})
links = []
positions = {node: tuple(node.location) for node in nodes}
positions = {node: tuple(node.location_absolute) for node in nodes}
for link in node_group.links:
source, target = link.from_node, link.to_node
(x1, y1), (x2, y2) = link_endpoints(link, positions, dimensions)
links.append([ids[source], ids[target], *[round(v, 2) for v in (x1, -y1, x2, -y2)], link.from_socket.type])
zones = []
repeats = {frozenset(members): box for members, box in repeat_regions(node_group, positions, dimensions)}
for entry, end, members in node_zones(node_group):
if entry.bl_idname != "GeometryNodeRepeatInput":
members = {node for node in members if roles[node] == "geometry"} | {entry, end}
boxes = [records[ids[node]] for node in members]
left = min(node["x"] for node in boxes) - 40
padding = 60 if entry.bl_idname == "GeometryNodeRepeatInput" else 40
left = min(node["x"] for node in boxes) - padding
top = min(node["y"] for node in boxes) - 60
right = max(node["x"] + node["w"] for node in boxes) + 40
right = max(node["x"] + node["w"] for node in boxes) + padding
bottom = max(node["y"] + node["h"] for node in boxes) + 40
if entry.bl_idname == "GeometryNodeRepeatInput":
left, right, upper, lower = repeats[frozenset(members)]
top, bottom = -upper, -lower
zones.append({"title": entry.bl_label.removesuffix(" Input"),
"type": entry.bl_idname, "entry": ids[entry], "exit": ids[end],
"nodes": sorted(ids[node] for node in members),
"x": left, "y": top, "w": right - left, "h": bottom - top})
zones.sort(key=lambda zone: zone["w"] * zone["h"], reverse=True)
return {"name": node_group.name, "nodes": records, "links": links, "zones": zones}
frames = [{"title": node.label or node.name, "x": node.location_absolute.x,
"y": -node.location_absolute.y, "w": node.width, "h": node.height,
"nodes": [ids[child] for child in nodes if child.parent == node]}
for node in node_group.nodes if node.bl_idname == "NodeFrame"]
return {"name": node_group.name, "nodes": records, "links": links, "zones": zones, "frames": frames}
def write_node_preview(node_groups, output_path, *, fragment=False):

View File

@@ -76,22 +76,33 @@
dy = (height - (bottom + top) * scale) / 2;
draw();
}
function updateZoneViews() {
view.querySelectorAll('[data-zone]').forEach(option => option.remove());
function updateRegionViews() {
view.querySelectorAll('[data-zone], [data-frame]').forEach(option => option.remove());
current.zones.forEach(zone => {
const option = document.createElement('option');
option.value = `zone:${zone.entry}`; option.dataset.zone = 'true';
option.textContent = `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`;
view.appendChild(option);
});
current.frames.forEach((frame, index) => {
const option = document.createElement('option');
option.value = `frame:${index}`; option.dataset.frame = 'true';
option.textContent = `Frame · ${frame.title}`;
view.appendChild(option);
});
}
function changeView() {
selected = null;
if (view.value === 'all') {
fit(bounds([...current.nodes, ...current.zones]));
fit(bounds([...current.nodes, ...current.zones, ...current.frames]));
detail.textContent = '完整排列';
return;
}
if (view.value.startsWith('frame:')) {
const frame = current.frames[Number(view.value.split(':')[1])];
fit(bounds([frame])); detail.textContent = frame.title;
return;
}
if (view.value === 'zone' || view.value.startsWith('zone:')) {
if (current.zones.length) {
const zones = view.value === 'zone' ? current.zones : current.zones.filter(zone => `zone:${zone.entry}` === view.value);
@@ -152,6 +163,17 @@
context.strokeStyle = border; context.lineWidth = 1;
context.strokeRect(0.5, 0.5, width - 1, height - 1);
context.save(); context.beginPath(); context.rect(1, 1, width - 2, height - 2); context.clip();
for (const frame of current.frames) {
const x = frame.x * scale + dx, y = frame.y * scale + dy;
const w = frame.w * scale, h = frame.h * scale;
context.fillStyle = '#78909c'; context.globalAlpha = 0.06;
context.fillRect(x, y, w, h);
context.globalAlpha = 0.5; context.strokeStyle = '#78909c'; context.lineWidth = 1;
context.strokeRect(x, y, w, h); context.globalAlpha = 1;
if (w > 90 && scale >= 0.15) {
context.fillStyle = foreground; label(frame.title, x + 8, y + Math.max(size, 24 * scale), w - 16);
}
}
for (const zone of current.zones) {
const x = zone.x * scale + dx, y = zone.y * scale + dy;
const w = zone.w * scale, h = zone.h * scale;
@@ -236,7 +258,9 @@
}
picker.addEventListener('change', () => {
if (view.value.startsWith('zone:')) view.value = 'zone';
current = data[Number(picker.value)]; updateZoneViews(); changeView();
current = data[Number(picker.value)]; updateRegionViews();
if (!view.value) view.value = 'all';
changeView();
});
view.addEventListener('change', changeView);
root.querySelector('#layout-in').addEventListener('click', () => zoom(1.5));
@@ -274,6 +298,6 @@
}).observe(canvas);
new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
updateZoneViews();
updateRegionViews();
})();
</script>