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:
@@ -8,10 +8,16 @@ import numpy as np
|
||||
from scipy.optimize import LinearConstraint, minimize
|
||||
|
||||
from .dimensions import estimate_node_dimensions, visible_socket
|
||||
from .zones import node_zones
|
||||
from .flow import graph, geometry_paths, origin_socket, peer_links, shared_geometry_outputs, socket_links
|
||||
from .connections import curve_footprint, link_endpoints
|
||||
from .fields import field_layout
|
||||
from .frames import create_field_frames, remove_field_frames
|
||||
from .references import route_references
|
||||
from .passages import route_field_passages
|
||||
from .sections import clear_repeat_regions, enclose_repeat_branches, reserve_field_sections
|
||||
from .sources import place_shared_sources
|
||||
from .zones import node_zones
|
||||
from ..common.nodes import replace_link_source
|
||||
|
||||
|
||||
GEOMETRY_GAP = 100
|
||||
@@ -23,15 +29,6 @@ ROUTE_GAP = 40
|
||||
ROUTE_RUN = 120
|
||||
|
||||
|
||||
def _replace_link(node_group, link, source):
|
||||
"""Replace a source without changing a multi-input socket's branch order."""
|
||||
multi = link.to_socket.is_multi_input
|
||||
replacement = node_group.links.new(source, link.to_socket)
|
||||
if multi:
|
||||
replacement.swap_multi_input_sort_id(link)
|
||||
node_group.links.remove(link)
|
||||
|
||||
|
||||
def _remove_routes(node_group):
|
||||
previous = [node for node in node_group.nodes if node.bl_idname == "NodeReroute"]
|
||||
|
||||
@@ -41,7 +38,7 @@ def _remove_routes(node_group):
|
||||
if not any(link.from_node in previous for link in links):
|
||||
continue
|
||||
if not socket.is_multi_input:
|
||||
_replace_link(node_group, links[0], origin_socket(links[0].from_socket))
|
||||
replace_link_source(node_group, links[0], origin_socket(links[0].from_socket))
|
||||
continue
|
||||
sources = [origin_socket(link.from_socket) for link in links]
|
||||
for link in links:
|
||||
@@ -88,10 +85,10 @@ def _localize_peer_inputs(node_group, ancestors, children):
|
||||
port = node_group.nodes.new("NodeReroute")
|
||||
port["arrangement_input"] = True
|
||||
node_group.links.new(link.from_socket, port.inputs[0])
|
||||
_replace_link(node_group, link, port.outputs[0])
|
||||
replace_link_source(node_group, link, port.outputs[0])
|
||||
|
||||
|
||||
def _columns(nodes, parents, peers, sizes, gap, spacing=None):
|
||||
def _columns(nodes, parents, peers, sizes, gap):
|
||||
"""Allocate columns with equal right edges for independent peer inputs."""
|
||||
roots = {node: node for node in nodes}
|
||||
|
||||
@@ -117,7 +114,6 @@ def _columns(nodes, parents, peers, sizes, gap, spacing=None):
|
||||
columns[levels[root(node)]].append(node)
|
||||
right, positions = 0, {}
|
||||
for level in sorted(columns):
|
||||
right += max(((spacing or {}).get(node, 0) for node in columns[level]), default=0)
|
||||
right += max(sizes[node].width for node in columns[level])
|
||||
for node in columns[level]:
|
||||
positions[node] = right - sizes[node].width
|
||||
@@ -126,13 +122,13 @@ def _columns(nodes, parents, peers, sizes, gap, spacing=None):
|
||||
|
||||
|
||||
def _field_regions(nodes, core, children, order):
|
||||
"""Group calculations by their first geometry consumers and shared scope."""
|
||||
"""Group connected calculations by the geometry inputs that consume them."""
|
||||
targets = {}
|
||||
fields = set(nodes) - core
|
||||
for node in reversed(order):
|
||||
targets[node] = frozenset({node}) if node in core else frozenset().union(
|
||||
*(targets[child] for child in children[node])
|
||||
)
|
||||
targets[node] = frozenset(link.to_socket for socket in node.outputs for link in socket.links
|
||||
if link.to_node in core) | frozenset().union(
|
||||
*(targets[child] for child in children[node] if child not in core))
|
||||
for node in order:
|
||||
if node.get("arrangement_exit"):
|
||||
targets[node] = targets[origin_socket(node.outputs[0]).node]
|
||||
@@ -140,7 +136,26 @@ def _field_regions(nodes, core, children, order):
|
||||
for node in order:
|
||||
if node in fields:
|
||||
regions[targets[node]].append(node)
|
||||
return [(region, owners) for owners, region in regions.items()]
|
||||
result = []
|
||||
for sockets, region in regions.items():
|
||||
remaining = set(region)
|
||||
neighbors = {node: set(children[node]) & remaining for node in region}
|
||||
for node, adjacent in list(neighbors.items()):
|
||||
for other in list(adjacent):
|
||||
neighbors[other].add(node)
|
||||
for node in region:
|
||||
if node not in remaining:
|
||||
continue
|
||||
component, pending = set(), [node]
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current not in remaining:
|
||||
continue
|
||||
remaining.remove(current)
|
||||
component.add(current)
|
||||
pending.extend(neighbors[current])
|
||||
result.append(([member for member in region if member in component], {socket.node for socket in sockets}))
|
||||
return result
|
||||
|
||||
|
||||
def _localize_module_outputs(node_group, core, children):
|
||||
@@ -155,7 +170,7 @@ def _localize_module_outputs(node_group, core, children):
|
||||
port = node_group.nodes.new("NodeReroute")
|
||||
port["arrangement_exit"] = True
|
||||
node_group.links.new(link.from_socket, port.inputs[0])
|
||||
_replace_link(node_group, link, port.outputs[0])
|
||||
replace_link_source(node_group, link, port.outputs[0])
|
||||
|
||||
|
||||
def _localize_inputs(node_group):
|
||||
@@ -169,7 +184,7 @@ def _localize_inputs(node_group):
|
||||
local = node_group.nodes.new("NodeGroupInput")
|
||||
for link, identifier in inputs:
|
||||
source = next(socket for socket in local.outputs if socket.identifier == identifier)
|
||||
_replace_link(node_group, link, source)
|
||||
replace_link_source(node_group, link, source)
|
||||
for node in controls:
|
||||
node_group.nodes.remove(node)
|
||||
|
||||
@@ -384,7 +399,7 @@ class BranchSpace:
|
||||
if link.to_node != other.to_node and link.from_socket != other.from_socket)
|
||||
|
||||
|
||||
def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space, horizontal=None, *, allow_opposite=False):
|
||||
def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, space, *, allow_opposite=False, peripheral=False, repeat_consumer=False):
|
||||
"""Place an upstream module together with its forward-facing exit channels."""
|
||||
right = min(positions[node][0] for node in destinations) - FIELD_GAP
|
||||
baseline = sum(positions[node][1] for node in owners) / len(owners)
|
||||
@@ -398,23 +413,13 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
if allow_opposite:
|
||||
side = 0
|
||||
desired = right - width
|
||||
front_allowed = not horizontal and all(link.from_node in local
|
||||
front_allowed = all(link.from_node in local
|
||||
for node in local for socket in node.inputs for link in socket.links)
|
||||
shared = [] if horizontal else shared_geometry_outputs(local)
|
||||
references = set(shared_geometry_outputs(local))
|
||||
outlets, constraints, entries = [], [], []
|
||||
outlets, constraints = [], []
|
||||
for node, (x, y) in local.items():
|
||||
if horizontal:
|
||||
for socket in node.inputs:
|
||||
for link in socket.links:
|
||||
if link.from_node in positions:
|
||||
source_x, source_y = positions[link.from_node]
|
||||
entries.append((source_x + sizes[link.from_node].width - x,
|
||||
source_y - sizes[link.from_node].sockets[link.from_socket],
|
||||
y - sizes[node].sockets[socket]))
|
||||
for socket in node.outputs:
|
||||
links = list(socket.links)
|
||||
if socket not in references:
|
||||
if socket.type != "GEOMETRY" and not peripheral:
|
||||
returns = [link for link in links if link.to_node in core]
|
||||
if returns:
|
||||
outlets.append(returns)
|
||||
@@ -424,12 +429,15 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
constraints.append((target_x - x - sizes[node].width,
|
||||
y - sizes[node].sockets[socket],
|
||||
target_y - sizes[link.to_node].sockets[link.to_socket]))
|
||||
trunk_end = max(positions[node][0] for node in {*owners, *destinations})
|
||||
constraints.sort()
|
||||
exit_slope = 0.2 if width < 1000 and not peripheral and len(destinations) == 1 and destinations[0] in core and all(
|
||||
socket.type != "GEOMETRY" for node in local for socket in node.outputs
|
||||
if any(link.to_node in positions and link.to_node not in local for link in socket.links)
|
||||
) else 0.8
|
||||
if repeat_consumer and not peripheral:
|
||||
exit_slope = 0.4
|
||||
candidates = {desired, *(left - BRANCH_GAP - width for left, _, _, _ in occupied),
|
||||
*(end + BRANCH_GAP for _, end, _, _ in occupied)}
|
||||
if horizontal:
|
||||
candidates = {left for left in candidates if horizontal[0] <= left and left + width <= horizontal[1]}
|
||||
candidates.add(horizontal[0])
|
||||
best, seen = None, set()
|
||||
for left in sorted(candidates, key=lambda x: (abs(x - desired), x)):
|
||||
tops = {ceiling + BRANCH_GAP + height, floor - BRANCH_GAP,
|
||||
@@ -443,16 +451,11 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)):
|
||||
if top > limits[0] or top - height < limits[1]:
|
||||
continue
|
||||
minimum = max([horizontal[0], *(start + max(GEOMETRY_GAP, abs(top + target_y - source_y) * 0.8)
|
||||
for start, source_y, target_y in entries)]) if horizontal else -float("inf")
|
||||
placed_left = max(left, minimum) if horizontal else min(left, desired)
|
||||
placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(target_y - top - source_y) * 0.8)
|
||||
placed_left = min(left, desired)
|
||||
placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(source_y + top - target_y) * exit_slope)
|
||||
for end, source_y, target_y in constraints)])
|
||||
if not horizontal:
|
||||
# Leave clearance at saturated link constraints for solver roundoff.
|
||||
placed_left -= 0.01
|
||||
if horizontal and not minimum <= placed_left <= horizontal[1] - width:
|
||||
continue
|
||||
# Leave clearance at saturated link constraints for solver roundoff.
|
||||
placed_left -= 0.01
|
||||
in_front = placed_left + width + BRANCH_GAP <= core_left
|
||||
if not (in_front and front_allowed) and ((side == 1 and top - height < ceiling + BRANCH_GAP - 1e-5)
|
||||
or (side == -1 and top > floor - BRANCH_GAP + 1e-5)):
|
||||
@@ -462,26 +465,23 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
continue
|
||||
seen.add(point)
|
||||
distance = abs(placed_left - desired)
|
||||
base_score = distance + abs(top - height / 2 - baseline) * 1.5
|
||||
if best is not None and base_score > best[0]:
|
||||
base_score = (sum(end - placed_left + abs(source_y + top - target_y) * 1.5
|
||||
for end, source_y, target_y in constraints) / len(constraints)
|
||||
if constraints else distance + abs(top - height / 2 - baseline) * 1.5)
|
||||
if best is not None and round(base_score, 6) > best[0]:
|
||||
continue
|
||||
overlapping = [box for box in occupied
|
||||
if placed_left < box[1] + BRANCH_GAP and placed_left + width > box[0] - BRANCH_GAP]
|
||||
if any(top > bottom - BRANCH_GAP + 1e-5 and top - height < upper + BRANCH_GAP - 1e-5
|
||||
for _, _, upper, bottom in overlapping):
|
||||
continue
|
||||
channels = [(placed_left + local[socket.node][0] + sizes[socket.node].width,
|
||||
top + local[socket.node][1] - sizes[socket.node].sockets[socket]) for socket in shared]
|
||||
if any(a < trunk_end and b > start and bottom - ROUTE_GAP < level < upper + ROUTE_GAP
|
||||
for start, level in channels for a, b, upper, bottom in occupied):
|
||||
continue
|
||||
placed = positions | {node: (x + placed_left, y + top) for node, (x, y) in local.items()}
|
||||
exits, routes = space.connections(outlets, placed, sizes)
|
||||
body = (placed_left, placed_left + width, top, top - height)
|
||||
if not space.fits(body, exits):
|
||||
continue
|
||||
score = base_score + space.crossings(exits) * BRANCH_GAP * 4
|
||||
candidate = (score, distance, -top, placed_left, top)
|
||||
candidate = (round(score, 6), round(distance, 6), -top, placed_left, top)
|
||||
if best is None or candidate < best:
|
||||
best = candidate
|
||||
best_exits, best_routes = exits, routes
|
||||
@@ -489,7 +489,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
if not allow_opposite:
|
||||
return _place_field_region(
|
||||
local, width, height, destinations, owners, positions, sizes,
|
||||
occupied, limits, core, trunks, space, horizontal, allow_opposite=True,
|
||||
occupied, limits, core, space, allow_opposite=True, peripheral=peripheral,
|
||||
repeat_consumer=repeat_consumer,
|
||||
)
|
||||
raise ValueError("The field region does not fit its reserved scope")
|
||||
_, _, _, left, top = best
|
||||
@@ -500,38 +501,29 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
space.bodies.append((set(local), body))
|
||||
space.exits.extend(best_exits)
|
||||
space.routes.extend(best_routes)
|
||||
for socket in shared:
|
||||
start = positions[socket.node][0] + sizes[socket.node].width
|
||||
level = positions[socket.node][1] - sizes[socket.node].sockets[socket]
|
||||
trunks[socket] = level
|
||||
occupied.append((start, trunk_end, level + ROUTE_GAP, level - ROUTE_GAP))
|
||||
|
||||
|
||||
def _route_field_links(node_group, layouts, positions):
|
||||
"""Materialize the passages reserved between expression columns."""
|
||||
protected = set()
|
||||
for layout in layouts:
|
||||
anchor = next(iter(layout.positions))
|
||||
dx = positions[anchor][0] - layout.positions[anchor][0]
|
||||
dy = positions[anchor][1] - layout.positions[anchor][1]
|
||||
for link, points in layout.routes:
|
||||
source = link.from_socket
|
||||
protected.add((source, link.to_socket))
|
||||
for x, y in points:
|
||||
node = node_group.nodes.new("NodeReroute")
|
||||
node["arrangement_branch"] = True
|
||||
node.location = (x + dx - 8, y + dy + 8)
|
||||
node_group.links.new(source, node.inputs[0])
|
||||
source = node.outputs[0]
|
||||
_replace_link(node_group, link, source)
|
||||
return protected
|
||||
replace_link_source(node_group, link, source)
|
||||
|
||||
|
||||
def _route_branch_exits(node_group, routes):
|
||||
"""Materialize the channels reserved during placement, sharing trunk corners."""
|
||||
deliveries, protected = defaultdict(list), set()
|
||||
deliveries = defaultdict(list)
|
||||
for link, points in routes:
|
||||
protected.add((link.from_socket, link.to_socket))
|
||||
deliveries[link.from_socket].append((link, points))
|
||||
for source, branches in deliveries.items():
|
||||
corners = {}
|
||||
@@ -543,8 +535,7 @@ def _route_branch_exits(node_group, routes):
|
||||
source = corners[point] = node.outputs[0]
|
||||
for link, points in branches:
|
||||
if points:
|
||||
_replace_link(node_group, link, corners[points[-1]])
|
||||
return protected
|
||||
replace_link_source(node_group, link, corners[points[-1]])
|
||||
|
||||
|
||||
def _route_peer_bypasses(node_group, sizes, positions, ancestors):
|
||||
@@ -571,116 +562,14 @@ def _route_peer_bypasses(node_group, sizes, positions, ancestors):
|
||||
entry = node_group.nodes.new("NodeReroute")
|
||||
entry["arrangement_bypass"] = True
|
||||
node_group.links.new(source, entry.inputs[0])
|
||||
_replace_link(node_group, port.inputs[0].links[0], entry.outputs[0])
|
||||
replace_link_source(node_group, port.inputs[0].links[0], entry.outputs[0])
|
||||
positions[entry] = (x - 8, level + 8)
|
||||
sizes[entry] = estimate_node_dimensions(entry)
|
||||
|
||||
|
||||
def _route_references(node_group, sizes, positions, regions, paths, trunks, protected):
|
||||
"""Share forward trunks and branch next to each consumer for every socket type."""
|
||||
references = defaultdict(list)
|
||||
path_of = {node: index for index, path in enumerate(paths) for node in path}
|
||||
peer_edges = {link for node in node_group.nodes if len(links := peer_links(node)) > 1 for link in links}
|
||||
for link in list(node_group.links):
|
||||
if (link.from_socket, link.to_socket) in protected or link.from_node.get("arrangement_branch") or link.to_node.get("arrangement_branch"):
|
||||
continue
|
||||
if link.from_node.get("arrangement_bypass") or link.to_node.get("arrangement_bypass"):
|
||||
continue
|
||||
if (link.from_node in path_of and link.to_node in path_of
|
||||
and path_of[link.from_node] == path_of[link.to_node] and len(link.from_socket.links) == 1):
|
||||
continue
|
||||
if link in peer_edges:
|
||||
continue
|
||||
if positions[link.to_node][0] - positions[link.from_node][0] - sizes[link.from_node].width > ROUTE_RUN * 4:
|
||||
references[link.from_socket].append(link)
|
||||
boxes = {node: _node_box(node, positions, sizes) for node in positions}
|
||||
tracks = []
|
||||
|
||||
def route(source, x, y):
|
||||
node = node_group.nodes.new("NodeReroute")
|
||||
node.location = (x - 8, y + 8)
|
||||
node_group.links.new(source, node.inputs[0])
|
||||
return node.outputs[0]
|
||||
|
||||
for source, links in references.items():
|
||||
if len(links) < 2 and source.type != "GEOMETRY":
|
||||
continue
|
||||
left = positions[source.node][0] + sizes[source.node].width
|
||||
right = max(positions[link.to_node][0] for link in links)
|
||||
start_y = positions[source.node][1] - sizes[source.node].sockets[source]
|
||||
destinations = {link.to_node for link in links}
|
||||
obstacles = [(bottom - ROUTE_GAP, top + ROUTE_GAP) for node, (a, b, top, bottom) in boxes.items()
|
||||
if node not in destinations and a < right and b > left]
|
||||
for a, b, top, bottom in regions:
|
||||
if a >= right or b <= left:
|
||||
continue
|
||||
endpoints = destinations | {source.node}
|
||||
if any(a <= positions[node][0] <= b and bottom <= positions[node][1] <= top for node in endpoints):
|
||||
continue
|
||||
obstacles.append((bottom - ROUTE_GAP, top + ROUTE_GAP))
|
||||
obstacles += [(y - ROUTE_GAP, y + ROUTE_GAP) for a, b, y in tracks if a < right and b > left]
|
||||
if len(links) == 1:
|
||||
target = links[0]
|
||||
target_y = positions[target.to_node][1] - sizes[target.to_node].sockets[target.to_socket]
|
||||
if abs(target_y - start_y) < ROUTE_GAP and all(not lower < start_y < upper for lower, upper in obstacles):
|
||||
continue
|
||||
first_target = min(positions[link.to_node][0] for link in links)
|
||||
reach = (first_target - left) / 2.4
|
||||
lower, upper = start_y - reach, start_y + reach
|
||||
for link in links:
|
||||
y = positions[link.to_node][1] - sizes[link.to_node].sockets[link.to_socket]
|
||||
reach = (positions[link.to_node][0] - left) / 2.4
|
||||
lower, upper = max(lower, y - reach), min(upper, y + reach)
|
||||
candidates = [start_y, lower, upper, *(edge for interval in obstacles for edge in interval)]
|
||||
available = [y for y in candidates if lower <= y <= upper and all(not a < y < b for a, b in obstacles)]
|
||||
if source in trunks:
|
||||
level = trunks[source]
|
||||
elif not available:
|
||||
continue
|
||||
else:
|
||||
level = min(available, key=lambda y: (abs(y - start_y) + sum(
|
||||
abs(y - positions[link.to_node][1] + sizes[link.to_node].sockets[link.to_socket])
|
||||
for link in links) / len(links), -y))
|
||||
entry_run = min(max(ROUTE_RUN, abs(level - start_y) * 0.8), (first_target - left) / 3)
|
||||
trunk = route(source, left + entry_run, level)
|
||||
tracks.append((left + entry_run, right, level))
|
||||
previous_x = left + entry_run
|
||||
branches = []
|
||||
for link in links:
|
||||
target_x = positions[link.to_node][0]
|
||||
target_y = positions[link.to_node][1] - sizes[link.to_node].sockets[link.to_socket]
|
||||
exit_run = min(max(ROUTE_RUN, abs(target_y - level) * 0.8), (target_x - left) / 3)
|
||||
branches.append((target_x - exit_run, link))
|
||||
for x, link in sorted(branches, key=lambda item: item[0]):
|
||||
if x != previous_x:
|
||||
trunk = route(trunk, x, level)
|
||||
previous_x = x
|
||||
_replace_link(node_group, link, trunk)
|
||||
|
||||
|
||||
def _zone_width(interior, layout, sizes):
|
||||
"""Reserve the body and the actual socket runs at each end of a paired scope."""
|
||||
body_height = max(sizes[node].height for node in interior)
|
||||
spans = []
|
||||
for top in (layout.height + BRANCH_GAP, -body_height - BRANCH_GAP):
|
||||
left, right = FIELD_GAP, layout.width + FIELD_GAP
|
||||
for node, (x, y) in layout.positions.items():
|
||||
for socket in node.inputs:
|
||||
for link in socket.links:
|
||||
if link.from_node not in layout.positions and link.from_node not in interior:
|
||||
delta = top + y - sizes[node].sockets[socket] + sizes[link.from_node].sockets[link.from_socket]
|
||||
left = max(left, max(GEOMETRY_GAP, abs(delta) * 0.8) - x)
|
||||
for socket in node.outputs:
|
||||
for link in socket.links:
|
||||
if link.to_node in interior:
|
||||
delta = top + y - sizes[node].sockets[socket] + sizes[link.to_node].sockets[link.to_socket]
|
||||
right = max(right, x + sizes[node].width + max(GEOMETRY_GAP, abs(delta) * 0.8))
|
||||
spans.append(left + right)
|
||||
return max(spans) + BRANCH_GAP * 2
|
||||
|
||||
|
||||
def arrange_nodes(node_group):
|
||||
"""Fix geometry lanes, pack shallow field regions, and route shared references."""
|
||||
remove_field_frames(node_group)
|
||||
_remove_routes(node_group)
|
||||
parents, children, order, ancestors = graph(node_group)
|
||||
_localize_peer_inputs(node_group, ancestors, children)
|
||||
@@ -700,68 +589,64 @@ def arrange_nodes(node_group):
|
||||
outlets = [link.from_node for node in order if node in core for socket in node.inputs
|
||||
for link in socket_links(socket) if link.from_node in region]
|
||||
peers.append(list(dict.fromkeys(outlets)))
|
||||
spacing, zones, field_layouts = {}, [], []
|
||||
scoped = set()
|
||||
for entry, end, members in reversed(node_zones(node_group)):
|
||||
if entry not in core or end not in core:
|
||||
continue
|
||||
interior = (members & core) - {entry}
|
||||
fields = [node for node in order if node in members - core - scoped]
|
||||
if fields:
|
||||
scoped.update(fields)
|
||||
layout = field_layout(fields, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
|
||||
field_layouts.append(layout)
|
||||
local, width, height = layout.positions, layout.width, layout.height
|
||||
span = _zone_width(interior, layout, sizes)
|
||||
for node in core:
|
||||
if entry in geometry_parents[node]:
|
||||
spacing[node] = max(spacing.get(node, 0), span)
|
||||
zones.append((entry, end, interior, fields, local, width, height))
|
||||
column_parents = {node: list(sources) for node, sources in geometry_parents.items()}
|
||||
for node in core:
|
||||
if node.get("arrangement_input"):
|
||||
source = origin_socket(node.outputs[0]).node
|
||||
if source in core:
|
||||
column_parents[node] = [source]
|
||||
geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP, spacing)
|
||||
# Preserve execution order through shared geometry and field calculations.
|
||||
preceding = {}
|
||||
column_parents = {}
|
||||
for node in order:
|
||||
sources = set().union(*(preceding[parent] for parent in parents[node]))
|
||||
if node in core:
|
||||
column_parents[node] = sources
|
||||
preceding[node] = {node} if node in core else sources
|
||||
geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP)
|
||||
positions = _place_geometry_paths(paths, geometry_x, sizes, order)
|
||||
occupied = _geometry_corridors(paths, positions, sizes)
|
||||
trunks = {}
|
||||
space = BranchSpace()
|
||||
layouts = []
|
||||
layouts, field_layouts = [], []
|
||||
for region, targets in regions:
|
||||
remaining = [node for node in region if node not in scoped]
|
||||
if remaining:
|
||||
layout = field_layout(remaining, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
|
||||
field_layouts.append(layout)
|
||||
layouts.append((remaining, targets, layout.positions, layout.width, layout.height))
|
||||
reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts),
|
||||
*(height for _, _, _, _, _, _, height in zones)]) * 2 + BRANCH_GAP * 2
|
||||
reserve += sum(height + BRANCH_GAP * 2 for _, _, _, _, _, _, height in zones)
|
||||
layout = field_layout(region, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
|
||||
field_layouts.append(layout)
|
||||
layouts.append((region, targets, layout.positions, layout.width, layout.height))
|
||||
reserve_field_sections(node_group, layouts, positions, sizes, gap=BRANCH_GAP)
|
||||
occupied = _geometry_corridors(paths, positions, sizes)
|
||||
reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts)]) * 2 + BRANCH_GAP * 2
|
||||
limits = (max(y for _, y in positions.values()) + reserve,
|
||||
min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve)
|
||||
for entry, end, interior, fields, local, width, height in zones:
|
||||
destinations = [min((node for node in interior if node.bl_idname != "NodeReroute"),
|
||||
key=lambda node: (positions[node][0], node.name))]
|
||||
horizontal = (positions[entry][0] + sizes[entry].width + FIELD_GAP, positions[end][0] - FIELD_GAP)
|
||||
_place_field_region(local, width, height, destinations, destinations, positions, sizes, occupied, limits, core, trunks, space, horizontal)
|
||||
members = interior | {entry} | set(fields)
|
||||
boxes = [_node_box(node, positions, sizes) for node in members]
|
||||
occupied.append((min(box[0] for box in boxes), max(box[1] for box in boxes),
|
||||
max(box[2] for box in boxes), min(box[3] for box in boxes)))
|
||||
pending = list(layouts)
|
||||
shared_nodes = {socket.node for socket in shared_geometry_outputs(node_group.nodes)}
|
||||
peripheral_regions = set()
|
||||
for region, _, _, _, _ in layouts:
|
||||
uses = defaultdict(set)
|
||||
for node in region:
|
||||
for socket in node.outputs:
|
||||
for link in socket.links:
|
||||
if link.to_node not in region:
|
||||
uses[origin_socket(socket)].add(link.to_node)
|
||||
if any(len(consumers) > 1 and (socket.type != "GEOMETRY" or not consumers & core)
|
||||
for socket, consumers in uses.items()):
|
||||
peripheral_regions.add(region[0])
|
||||
while True:
|
||||
peripheral_nodes = {node for region, _, _, _, _ in layouts if region[0] in peripheral_regions for node in region}
|
||||
upstream = {region[0] for region, _, _, _, _ in layouts
|
||||
if region[0] not in peripheral_regions
|
||||
and (consumers := {link.to_node for node in region for socket in node.outputs
|
||||
for link in socket.links if link.to_node not in region})
|
||||
and consumers <= peripheral_nodes}
|
||||
if not upstream:
|
||||
break
|
||||
peripheral_regions.update(upstream)
|
||||
def delivery_order(layout):
|
||||
ports = [link.to_socket for node in layout[0] for socket in node.outputs
|
||||
for link in socket.links if link.to_node in core]
|
||||
return min(((positions[socket.node][0], list(socket.node.inputs).index(socket))
|
||||
for socket in ports), default=(float("inf"), 0))
|
||||
|
||||
repeat_consumers = {node for entry, end, members in node_zones(node_group)
|
||||
if entry.bl_idname == "GeometryNodeRepeatInput"
|
||||
for node in members & core if node not in {entry, end}}
|
||||
while pending:
|
||||
ready = [layout for layout in pending if all(
|
||||
link.to_node in positions or link.to_node in layout[0]
|
||||
for node in layout[0] for socket in node.outputs for link in socket.links)]
|
||||
layout = min(ready, key=lambda item: (any(node in shared_nodes for node in item[0]),
|
||||
layout = min(ready, key=lambda item: (item[0][0] in peripheral_regions or any(node in shared_nodes for node in item[0]),
|
||||
delivery_order(item), order.index(item[0][0])))
|
||||
pending.remove(layout)
|
||||
region, targets, local, width, height = layout
|
||||
@@ -769,15 +654,28 @@ def arrange_nodes(node_group):
|
||||
for link in socket.links if link.to_node not in region and link.to_node in positions))
|
||||
destinations = consumers or [node for node in order if node in targets] or [paths[0][-1]]
|
||||
owners = [node for node in order if node in targets] or [paths[0][-1]]
|
||||
_place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space)
|
||||
protected = _route_field_links(node_group, field_layouts, positions)
|
||||
protected.update(_route_branch_exits(node_group, space.routes))
|
||||
_place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, space,
|
||||
peripheral=region[0] in peripheral_regions,
|
||||
repeat_consumer=any(node in repeat_consumers for node in destinations))
|
||||
enclose_repeat_branches(node_group, positions, sizes, space.bodies, gap=BRANCH_GAP)
|
||||
clear_repeat_regions(node_group, positions, sizes, space.bodies, gap=BRANCH_GAP)
|
||||
repeat_members = set().union(*(members for entry, _, members in node_zones(node_group)
|
||||
if entry.bl_idname == "GeometryNodeRepeatInput"))
|
||||
peripheral_nodes.difference_update(repeat_members)
|
||||
place_shared_sources(space.bodies, peripheral_nodes, positions, sizes, gap=BRANCH_GAP)
|
||||
_, space.routes = space.connections([[link] for link, _ in space.routes], positions, sizes)
|
||||
_route_field_links(node_group, field_layouts, positions)
|
||||
_route_branch_exits(node_group, space.routes)
|
||||
_route_peer_bypasses(node_group, sizes, positions, ancestors)
|
||||
for node, position in positions.items():
|
||||
node.location = position
|
||||
_route_references(node_group, sizes, positions, occupied, paths, trunks, protected)
|
||||
route_references(node_group, space.bodies, peripheral_nodes=peripheral_nodes)
|
||||
while unused := [node for node in node_group.nodes
|
||||
if node.bl_idname == "NodeReroute" and not node.outputs[0].is_linked]:
|
||||
for members, _ in space.bodies:
|
||||
members.difference_update(unused)
|
||||
for node in unused:
|
||||
node_group.nodes.remove(node)
|
||||
route_field_passages(node_group, space.bodies)
|
||||
create_field_frames(node_group, space.bodies, core)
|
||||
_presentation(node_group)
|
||||
|
||||
@@ -22,6 +22,8 @@ def visible_socket(socket):
|
||||
|
||||
def estimate_node_dimensions(node):
|
||||
"""Reserve socket rows, expanded vector values, and node-specific controls."""
|
||||
if node.bl_idname == "NodeFrame":
|
||||
return NodeDimensions(node.width, node.height, {})
|
||||
if node.bl_idname == "NodeReroute":
|
||||
return NodeDimensions(16, 16, {socket: 8 for socket in (*node.inputs, *node.outputs)})
|
||||
inputs = [s for s in node.inputs if visible_socket(s)]
|
||||
|
||||
@@ -33,6 +33,22 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
|
||||
continue
|
||||
for node in members[1:]:
|
||||
roots[root(node)] = root(members[0])
|
||||
# Stack exclusive calculation chains without disturbing ordered operands.
|
||||
operands = {node for sources in peers if len(sources) > 1 for node in sources}
|
||||
chain_heights = {node: sizes[node].height for node in nodes}
|
||||
simple = {"ShaderNodeMath", "ShaderNodeVectorMath", "ShaderNodeClamp"}
|
||||
for node in nodes:
|
||||
sources = [parent for parent in parents[node] if parent in roots]
|
||||
if node.bl_idname not in simple or node in operands or len(sources) != 1:
|
||||
continue
|
||||
source = sources[0]
|
||||
if source in operands or {link.to_node for socket in source.outputs for link in socket.links} != {node}:
|
||||
continue
|
||||
first, last = root(source), root(node)
|
||||
height = chain_heights[first] + gap + chain_heights[last]
|
||||
if first != last and height <= target_height:
|
||||
roots[last] = first
|
||||
chain_heights[first] = height
|
||||
groups = defaultdict(list)
|
||||
for node in nodes:
|
||||
groups[root(node)].append(node)
|
||||
@@ -80,11 +96,12 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
|
||||
layers = columns
|
||||
depth = column - 1
|
||||
links = [link for node in nodes for socket in node.inputs for link in socket.links if link.from_node in block_of]
|
||||
edges, passages = [], []
|
||||
edges, passages, vertical = [], [], []
|
||||
for link in links:
|
||||
first, last = block_of[link.from_node], block_of[link.to_node]
|
||||
if first == last:
|
||||
raise ValueError("Equal operands must not contain an internal dependency")
|
||||
vertical.append(link)
|
||||
continue
|
||||
start_offset = offsets[link.from_node] + sizes[link.from_node].sockets[link.from_socket]
|
||||
end_offset = offsets[link.to_node] + sizes[link.to_node].sockets[link.to_socket]
|
||||
previous, previous_offset, path = first, start_offset, []
|
||||
@@ -173,18 +190,18 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
|
||||
raise ValueError(f"Expression layers could not be placed: {result.message}")
|
||||
centers = result.x[:count]
|
||||
positions, point_positions, right = {}, {}, 0
|
||||
vertical_blocks = {block_of[link.from_node] for link in vertical}
|
||||
for rank in range(depth + 1):
|
||||
layer = layers[rank]
|
||||
width = max(max(sizes[node].width for node in blocks[item]) if item < len(blocks) else 16 for item in layer)
|
||||
padding = gap / 2 if any(item in vertical_blocks for item in layer) else 0
|
||||
if rank:
|
||||
run = max([gap, *(abs(centers[first] + a - centers[last] - b) * 0.8
|
||||
for first, last, a, b in edges if ranks[last] == rank)])
|
||||
right += run
|
||||
right += width
|
||||
right += gap
|
||||
right += width + padding * 2
|
||||
for item in layer:
|
||||
if item < len(blocks):
|
||||
for node in blocks[item]:
|
||||
positions[node] = (right - sizes[node].width, -centers[item] - offsets[node])
|
||||
positions[node] = (right - padding - sizes[node].width, -centers[item] - offsets[node])
|
||||
else:
|
||||
point_positions[item] = (right, -centers[item])
|
||||
routes = []
|
||||
@@ -193,4 +210,12 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
|
||||
points = [point for index, point in enumerate(points) if index == 0 or index == len(points)-1
|
||||
or not abs(points[index-1][1] - point[1]) < 1e-6 or not abs(points[index+1][1] - point[1]) < 1e-6]
|
||||
routes.append((link, points))
|
||||
for link in vertical:
|
||||
source, target = link.from_node, link.to_node
|
||||
x1, y1 = positions[source]
|
||||
x2, y2 = positions[target]
|
||||
middle = (y1 - sizes[source].height + y2) / 2
|
||||
routes.append((link, [(x1 + sizes[source].width + gap / 2, y1 - sizes[source].sockets[link.from_socket]),
|
||||
(x1 + sizes[source].width + gap / 2, middle),
|
||||
(x2 - gap / 2, middle), (x2 - gap / 2, y2 - sizes[target].sockets[link.to_socket])]))
|
||||
return FieldLayout(positions, right, result.x[count], routes)
|
||||
|
||||
58
tools/nodes/arrangement/frames.py
Normal file
58
tools/nodes/arrangement/frames.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Frame complex field calculations by their consuming inputs."""
|
||||
|
||||
from .flow import origin_socket
|
||||
|
||||
FRAME_PADDING = 32
|
||||
FRAME_HEADING = 48
|
||||
FRAME_MIN_NODES = 12
|
||||
|
||||
|
||||
def remove_field_frames(node_group):
|
||||
"""Detach generated containers before calculating absolute positions again."""
|
||||
frames = {node for node in node_group.nodes if node.get("arrangement_frame")}
|
||||
for node in node_group.nodes:
|
||||
if node.parent in frames:
|
||||
position = tuple(node.location_absolute)
|
||||
node.parent = None
|
||||
node.location = position
|
||||
for frame in frames:
|
||||
node_group.nodes.remove(frame)
|
||||
|
||||
|
||||
def create_field_frames(node_group, bodies, core):
|
||||
"""Keep a complex calculation and its internal wire passages in one frame."""
|
||||
for members, (left, right, top, bottom) in bodies:
|
||||
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) < FRAME_MIN_NODES:
|
||||
continue
|
||||
members = set(members)
|
||||
outlets = [link for node in members for socket in node.outputs for link in socket.links
|
||||
if link.to_node not in members]
|
||||
targets = []
|
||||
pending = list(outlets)
|
||||
visited = set()
|
||||
while pending:
|
||||
link = pending.pop()
|
||||
if link.to_node in core:
|
||||
targets.append(link)
|
||||
elif link.to_node not in visited:
|
||||
visited.add(link.to_node)
|
||||
pending.extend(edge for socket in link.to_node.outputs for edge in socket.links)
|
||||
target = min(targets, key=lambda link: (link.to_node.location_absolute.x,
|
||||
link.to_node.name, link.to_socket.name), default=None)
|
||||
pending = {node for node in node_group.nodes if node.bl_idname == "NodeReroute"
|
||||
and node.get("arrangement_branch") and origin_socket(node.outputs[0]).node in members}
|
||||
while ready := {node for node in pending if all(link.to_node in members for link in node.outputs[0].links)}:
|
||||
members.update(ready)
|
||||
pending.difference_update(ready)
|
||||
frame = node_group.nodes.new("NodeFrame")
|
||||
frame["arrangement_frame"] = True
|
||||
frame.name = f"{target.to_node.name} · {target.to_socket.name}" if target else "Field Calculation"
|
||||
frame.label = frame.name
|
||||
frame.shrink = False
|
||||
frame.location = (left - FRAME_PADDING, top + FRAME_HEADING)
|
||||
frame.width = right - left + FRAME_PADDING * 2
|
||||
frame.height = top - bottom + FRAME_HEADING + FRAME_PADDING
|
||||
frame.use_custom_color = True
|
||||
frame.color = (0.12, 0.16, 0.18)
|
||||
for node in members:
|
||||
node.parent = frame
|
||||
275
tools/nodes/arrangement/passages.py
Normal file
275
tools/nodes/arrangement/passages.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Find curved passages around unrelated calculation frames."""
|
||||
|
||||
from heapq import heappop, heappush
|
||||
from math import hypot
|
||||
|
||||
from ..common.nodes import replace_link_source
|
||||
from .connections import curve_footprint, link_endpoints
|
||||
from .dimensions import estimate_node_dimensions
|
||||
from .flow import origin_socket, peer_links
|
||||
from .frames import FRAME_HEADING, FRAME_MIN_NODES, FRAME_PADDING
|
||||
from .zones import node_zones, repeat_regions
|
||||
|
||||
PASSAGE_GAP = 96
|
||||
PASSAGE_SLOPE = 0.2
|
||||
|
||||
|
||||
def _intersects(start, end, boxes):
|
||||
bend = max(40, abs(end[0] - start[0]) * 0.42)
|
||||
left, right = min(start[0], end[0] - bend), max(start[0] + bend, end[0])
|
||||
bottom, top = sorted((start[1], end[1]))
|
||||
boxes = [box for box in boxes if box[0] < right and box[1] > left and box[3] < top and box[2] > bottom]
|
||||
if not boxes:
|
||||
return False
|
||||
return any(a < right and b > left and bottom < top and upper > lower
|
||||
for left, right, top, lower in curve_footprint(start, end)
|
||||
for a, b, upper, bottom in boxes)
|
||||
|
||||
|
||||
def _passage(start, end, boxes, tracks, *, monotonic=False, forward_start=False, bounds=None):
|
||||
"""Find a short sequence of unobstructed, slanted Bezier segments."""
|
||||
points = [start, end]
|
||||
for gap in (PASSAGE_GAP, PASSAGE_GAP * 2, PASSAGE_GAP * 4):
|
||||
points.extend((x, y) for left, right, top, bottom in boxes
|
||||
for x in (left - gap, right + gap) for y in (top + gap, bottom - gap))
|
||||
points.extend((x, y) for left, right, top, bottom in boxes
|
||||
for x in (left - (top - bottom) - PASSAGE_GAP, right + (top - bottom) + PASSAGE_GAP)
|
||||
for y in (top + PASSAGE_GAP, bottom - PASSAGE_GAP))
|
||||
points = list(dict.fromkeys(points))
|
||||
if bounds is not None:
|
||||
points = points[:2] + [point for point in points[2:] if bounds[0] <= point[0] <= bounds[1]]
|
||||
direction = 1 if end[0] >= start[0] else -1
|
||||
result = _search_passage(points, boxes, tracks, direction, forward_start)
|
||||
if result is not None or monotonic:
|
||||
return result
|
||||
result = _search_passage(points, boxes, tracks, 0, forward_start)
|
||||
if result is not None:
|
||||
return result
|
||||
raise ValueError("No unobstructed passage between calculation regions")
|
||||
|
||||
|
||||
def _search_passage(points, boxes, tracks, direction, forward_start):
|
||||
"""Prefer a continuous horizontal direction whenever obstacles permit it."""
|
||||
costs, previous, pending = {0: 0}, {}, [(0, 0)]
|
||||
while pending:
|
||||
cost, index = heappop(pending)
|
||||
if cost != costs[index]:
|
||||
continue
|
||||
if index == 1:
|
||||
path = []
|
||||
while index:
|
||||
path.append(points[index])
|
||||
index = previous[index]
|
||||
return list(reversed(path))
|
||||
x, y = points[index]
|
||||
for target, (u, v) in enumerate(points):
|
||||
if target == index or abs(u - x) < max(40, abs(v - y) * PASSAGE_SLOPE):
|
||||
continue
|
||||
if direction and (u - x) * direction <= 0:
|
||||
continue
|
||||
if forward_start and index == 0 and u <= x:
|
||||
continue
|
||||
if abs(v - y) < 0.01:
|
||||
if u - x < PASSAGE_GAP:
|
||||
continue
|
||||
if any(a < u and b > x and abs(y - level) < PASSAGE_GAP
|
||||
for a, b, level in tracks):
|
||||
continue
|
||||
distance = cost + hypot(u - x, v - y) + max(0, x - u) + PASSAGE_GAP
|
||||
if distance >= costs.get(target, float("inf")) or _intersects((x, y), (u, v), boxes):
|
||||
continue
|
||||
costs[target], previous[target] = distance, index
|
||||
heappush(pending, (distance, target))
|
||||
return None
|
||||
|
||||
|
||||
def route_field_passages(node_group, bodies):
|
||||
"""Allow wires into their own calculations and route transit wires around frames."""
|
||||
frames = [(members, (left - FRAME_PADDING, right + FRAME_PADDING,
|
||||
top + FRAME_HEADING, bottom - FRAME_PADDING))
|
||||
for members, (left, right, top, bottom) in bodies
|
||||
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) >= FRAME_MIN_NODES]
|
||||
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
|
||||
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
|
||||
for entry, end, members in node_zones(node_group):
|
||||
if entry.bl_idname != "GeometryNodeRepeatInput":
|
||||
continue
|
||||
for node in members:
|
||||
if node.bl_idname == "NodeReroute":
|
||||
x, y = positions[node]
|
||||
x = max(positions[entry][0] + sizes[entry].width + 40,
|
||||
min(x, positions[end][0] - 40 - sizes[node].width))
|
||||
node.location = positions[node] = (x, y)
|
||||
repeats = repeat_regions(node_group, positions, sizes)
|
||||
frames.extend(repeats)
|
||||
for node in node_group.nodes:
|
||||
if node.bl_idname != "NodeReroute":
|
||||
continue
|
||||
x, y = positions[node]
|
||||
if not any(node not in members and left < x + 8 < right and bottom < y - 8 < top
|
||||
for members, (left, right, top, bottom) in repeats):
|
||||
continue
|
||||
blocked = [box for members, box in frames if node not in members and box[0] < x + 8 < box[1]]
|
||||
levels = {edge for _, _, top, bottom in blocked
|
||||
for edge in (top + PASSAGE_GAP, bottom - PASSAGE_GAP)}
|
||||
level = min((level for level in levels if all(not bottom < level < top
|
||||
for _, _, top, bottom in blocked)), key=lambda level: abs(level - y))
|
||||
node.location = positions[node] = (x, level + 8)
|
||||
for link in list(node_group.links):
|
||||
if link.from_socket.type != "GEOMETRY" or link.from_node.bl_idname == "NodeReroute":
|
||||
continue
|
||||
start, end = link_endpoints(link, positions, sizes)
|
||||
distance = start[0] - end[0]
|
||||
if any(link.from_node in members and link.to_node in members for members, _ in repeats):
|
||||
continue
|
||||
if distance <= 2000:
|
||||
continue
|
||||
# Bound the right-facing handle before a long return towards the left.
|
||||
run = distance * 0.08 + PASSAGE_GAP
|
||||
offset = max(PASSAGE_GAP * 2, run * PASSAGE_SLOPE)
|
||||
point = (start[0] - run, start[1] + (offset if end[1] > start[1] else -offset))
|
||||
levels = {start[1] - offset, start[1] + offset,
|
||||
*(edge for _, (_, _, top, bottom) in frames
|
||||
for edge in (top + PASSAGE_GAP, bottom - PASSAGE_GAP))}
|
||||
clear_levels = [level for level in levels
|
||||
if abs(level - start[1]) >= PASSAGE_GAP and abs(level - end[1]) >= PASSAGE_GAP
|
||||
and all(not (left < point[0] < right and bottom < level < top)
|
||||
for _, (left, right, top, bottom) in frames)]
|
||||
point = (point[0], min(clear_levels, key=lambda level: (abs(level - point[1]), -level)))
|
||||
corner = node_group.nodes.new("NodeReroute")
|
||||
corner["arrangement_reference"] = True
|
||||
corner["arrangement_return"] = True
|
||||
corner.location = (point[0] - 8, point[1] + 8)
|
||||
positions[corner] = tuple(corner.location_absolute)
|
||||
sizes[corner] = estimate_node_dimensions(corner)
|
||||
node_group.links.new(link.from_socket, corner.inputs[0])
|
||||
replace_link_source(node_group, link, corner.outputs[0])
|
||||
tracks = []
|
||||
for link in node_group.links:
|
||||
start, end = link_endpoints(link, positions, sizes)
|
||||
if abs(start[1] - end[1]) < 0.01:
|
||||
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1]))
|
||||
for link in list(node_group.links):
|
||||
consumers, pending = set(), [link.to_node]
|
||||
while pending:
|
||||
node = pending.pop()
|
||||
if node in consumers:
|
||||
continue
|
||||
consumers.add(node)
|
||||
if node.bl_idname == "NodeReroute":
|
||||
pending.extend(edge.to_node for edge in node.outputs[0].links)
|
||||
source = origin_socket(link.from_socket).node
|
||||
bounds = next(((box[0] + 60, box[1] - 60) for members, box in repeats
|
||||
if source in members and consumers & members), None)
|
||||
obstacles = [box for members, box in frames if source not in members and not consumers & members]
|
||||
if (link.from_socket.type != "GEOMETRY" and not link.from_node.get("arrangement_reference")
|
||||
and not link.to_node.get("arrangement_reference")):
|
||||
obstacles = [box for members, box in repeats if source not in members and not consumers & members]
|
||||
start, end = link_endpoints(link, positions, sizes)
|
||||
if not _intersects(start, end, obstacles):
|
||||
continue
|
||||
try:
|
||||
path = _passage(start, end, obstacles, tracks,
|
||||
bounds=bounds,
|
||||
forward_start=link.from_socket.type != "GEOMETRY"
|
||||
and link.from_node.bl_idname != "NodeReroute")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"Cannot route {source.name} to {link.to_node.name}: {start} -> {end}") from error
|
||||
output, previous = link.from_socket, start
|
||||
for point in path[:-1]:
|
||||
corner = node_group.nodes.new("NodeReroute")
|
||||
corner["arrangement_reference"] = True
|
||||
corner.location = (point[0] - 8, point[1] + 8)
|
||||
node_group.links.new(output, corner.inputs[0])
|
||||
output = corner.outputs[0]
|
||||
if abs(previous[1] - point[1]) < 0.01:
|
||||
tracks.append((previous[0], point[0], point[1]))
|
||||
previous = point
|
||||
replace_link_source(node_group, link, output)
|
||||
_simplify_passages(node_group, frames, bodies)
|
||||
|
||||
|
||||
def _simplify_passages(node_group, frames, bodies):
|
||||
"""Shortcut redundant turns without removing shared junctions or crossing frames."""
|
||||
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
|
||||
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
|
||||
internal = set().union(*(members for _, _, members in node_zones(node_group)))
|
||||
while True:
|
||||
removed = False
|
||||
for corner in list(node_group.nodes):
|
||||
if corner.get("arrangement_return") or corner in internal:
|
||||
continue
|
||||
if not corner.get("arrangement_reference") or len(corner.inputs[0].links) != 1:
|
||||
continue
|
||||
if len(corner.outputs[0].links) != 1:
|
||||
continue
|
||||
incoming = corner.inputs[0].links[0]
|
||||
start, _ = link_endpoints(incoming, positions, sizes)
|
||||
points = [start]
|
||||
chain, current = [], corner
|
||||
while current.get("arrangement_reference") and len(current.outputs[0].links) == 1:
|
||||
chain.append(current)
|
||||
outgoing = current.outputs[0].links[0]
|
||||
if outgoing in peer_links(outgoing.to_node):
|
||||
break
|
||||
middle, end = link_endpoints(outgoing, positions, sizes)
|
||||
points.append(middle)
|
||||
# A shortcut must remove an actual reversal, not a useful pipe approach.
|
||||
forward_entry = (incoming.from_socket.type != "GEOMETRY"
|
||||
and incoming.from_node.bl_idname != "NodeReroute")
|
||||
if (not forward_entry or end[0] > start[0]) and any(
|
||||
(b[0] - a[0]) * (end[0] - start[0]) < 0
|
||||
for a, b in zip(points, points[1:] + [end])):
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
consumers, pending = set(), [outgoing.to_node]
|
||||
while pending:
|
||||
node = pending.pop()
|
||||
if node in consumers:
|
||||
continue
|
||||
consumers.add(node)
|
||||
if node.bl_idname == "NodeReroute":
|
||||
pending.extend(edge.to_node for edge in node.outputs[0].links)
|
||||
source = origin_socket(incoming.from_socket).node
|
||||
obstacles = [box for members, box in frames
|
||||
if source not in members and not consumers & members]
|
||||
horizontal = abs(dy) < 0.01
|
||||
blocked = abs(dx) < max(40, abs(dy) * PASSAGE_SLOPE) or (horizontal and dx < PASSAGE_GAP)
|
||||
if horizontal and not blocked:
|
||||
for edge in node_group.links:
|
||||
if edge.from_node in chain or edge.to_node in chain:
|
||||
continue
|
||||
a, b = link_endpoints(edge, positions, sizes)
|
||||
if abs(a[1] - b[1]) < 0.01 and abs(a[1] - start[1]) < PASSAGE_GAP:
|
||||
blocked |= min(a[0], b[0]) < end[0] and max(a[0], b[0]) > start[0]
|
||||
path = [end] if not blocked and not _intersects(start, end, obstacles) else None
|
||||
if path is None and abs(dx) >= max(40, abs(dy) * PASSAGE_SLOPE):
|
||||
tracks = []
|
||||
for edge in node_group.links:
|
||||
if edge.from_node in chain or edge.to_node in chain:
|
||||
continue
|
||||
a, b = link_endpoints(edge, positions, sizes)
|
||||
if abs(a[1] - b[1]) < 0.01:
|
||||
tracks.append((min(a[0], b[0]), max(a[0], b[0]), a[1]))
|
||||
path = _passage(start, end, obstacles, tracks, monotonic=True)
|
||||
if path is not None:
|
||||
output = incoming.from_socket
|
||||
for point in path[:-1]:
|
||||
node = node_group.nodes.new("NodeReroute")
|
||||
node["arrangement_reference"] = True
|
||||
node.location = (point[0] - 8, point[1] + 8)
|
||||
positions[node] = tuple(node.location_absolute)
|
||||
sizes[node] = estimate_node_dimensions(node)
|
||||
node_group.links.new(output, node.inputs[0])
|
||||
output = node.outputs[0]
|
||||
replace_link_source(node_group, outgoing, output)
|
||||
for members, _ in bodies:
|
||||
members.difference_update(chain)
|
||||
for node in reversed(chain):
|
||||
node_group.nodes.remove(node)
|
||||
removed = True
|
||||
break
|
||||
current = outgoing.to_node
|
||||
if removed:
|
||||
break
|
||||
if not removed:
|
||||
return
|
||||
185
tools/nodes/arrangement/references.py
Normal file
185
tools/nodes/arrangement/references.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Route shared results on peripheral tracks with local consumer branches."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from ..common.nodes import replace_link_source
|
||||
from .connections import curve_footprint, socket_position, link_endpoints
|
||||
from .dimensions import estimate_node_dimensions
|
||||
from .flow import geometry_paths, origin_socket, peer_links
|
||||
from .frames import FRAME_PADDING, FRAME_HEADING, FRAME_MIN_NODES
|
||||
from .zones import node_zones
|
||||
|
||||
TRACK_GAP = 96
|
||||
PORT_RUN = 40
|
||||
FRAME_CLEARANCE = 160
|
||||
|
||||
|
||||
def route_references(node_group, bodies, *, peripheral_nodes=frozenset()):
|
||||
"""Keep execution links direct and distribute shared results outside field regions."""
|
||||
nodes = list(node_group.nodes)
|
||||
repeats = [members for entry, _, members in node_zones(node_group)
|
||||
if entry.bl_idname == "GeometryNodeRepeatInput"]
|
||||
sizes = {node: estimate_node_dimensions(node) for node in nodes}
|
||||
positions = {node: tuple(node.location_absolute) for node in nodes}
|
||||
region_of = {node: index for index, (members, _) in enumerate(bodies) for node in members}
|
||||
producer_regions = {index for index, (members, _) in enumerate(bodies) if members & peripheral_nodes}
|
||||
boxes = [(node, (positions[node][0], positions[node][0] + sizes[node].width,
|
||||
positions[node][1], positions[node][1] - sizes[node].height))
|
||||
for node in nodes if node.bl_idname not in {"NodeReroute", "NodeFrame"}]
|
||||
padding = FRAME_CLEARANCE - TRACK_GAP
|
||||
regions = [(left - FRAME_PADDING - padding, right + FRAME_PADDING + padding,
|
||||
top + FRAME_HEADING + padding, bottom - FRAME_PADDING - padding)
|
||||
for _, (left, right, top, bottom) in bodies]
|
||||
frames = {index for index, (members, _) in enumerate(bodies)
|
||||
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) >= FRAME_MIN_NODES}
|
||||
_, paths = geometry_paths(node_group)
|
||||
execution = {(first, last) for path in paths for first, last in zip(path, path[1:])}
|
||||
references = defaultdict(list)
|
||||
for node in nodes:
|
||||
if node.bl_idname == "NodeReroute" and not node.get("arrangement_input"):
|
||||
continue
|
||||
for socket in node.inputs:
|
||||
for link in list(socket.links):
|
||||
if link.from_node.get("arrangement_input"):
|
||||
continue
|
||||
source = origin_socket(link.from_socket)
|
||||
if any(source.node in members and node in members for members in repeats):
|
||||
continue
|
||||
if source.node.bl_idname == "NodeGroupInput":
|
||||
continue
|
||||
if (source.node, node) in execution:
|
||||
continue
|
||||
if source.node in region_of and region_of[source.node] == region_of.get(node):
|
||||
continue
|
||||
start = socket_position(source, positions, sizes)
|
||||
end = link_endpoints(link, positions, sizes)[1]
|
||||
if link in peer_links(node):
|
||||
end = (end[0], start[1])
|
||||
distance = end[0] - start[0]
|
||||
if socket.type != "GEOMETRY":
|
||||
references[source, False].append((link, end))
|
||||
continue
|
||||
if 0 <= distance <= 480:
|
||||
continue
|
||||
if distance > 0 and (len(source.links) == 1 or distance <= 800):
|
||||
footprint = curve_footprint(start, end)
|
||||
endpoint_regions = {region_of[member] for member in (source.node, node) if member in region_of}
|
||||
obstacles = [box for member, box in boxes if member not in {source.node, node}]
|
||||
obstacles += [(left + padding, right - padding, top - padding, bottom + padding)
|
||||
for index, (left, right, top, bottom) in enumerate(regions)
|
||||
if index in frames and index not in endpoint_regions]
|
||||
if all(not any(a < right and b > left and bottom < top and upper > lower
|
||||
for left, right, top, lower in footprint)
|
||||
for a, b, upper, bottom in obstacles):
|
||||
if link.from_socket != source:
|
||||
replace_link_source(node_group, link, source)
|
||||
continue
|
||||
references[source, distance < 0].append((link, end))
|
||||
references = {key: deliveries for key, deliveries in references.items()
|
||||
if key[0].type == "GEOMETRY" or len({link.to_node for link, _ in deliveries}) > 1}
|
||||
tracks = []
|
||||
replaced = {link for deliveries in references.values() for link, _ in deliveries}
|
||||
wire_corridors = []
|
||||
for link in node_group.links:
|
||||
start, end = link_endpoints(link, positions, sizes)
|
||||
if link.from_socket.type == "GEOMETRY" and link not in replaced:
|
||||
wire_corridors.extend(curve_footprint(start, end))
|
||||
if link.from_socket.type == "GEOMETRY" and abs(start[1] - end[1]) < 1 and abs(start[0] - end[0]) > PORT_RUN:
|
||||
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1]))
|
||||
|
||||
def route(source, point):
|
||||
node = node_group.nodes.new("NodeReroute")
|
||||
node["arrangement_reference"] = True
|
||||
node.location = (point[0] - 8, point[1] + 8)
|
||||
node_group.links.new(source, node.inputs[0])
|
||||
return node.outputs[0]
|
||||
|
||||
ordered = sorted(references, key=lambda item: (item[0].type != "GEOMETRY", item[0].node.name, item[0].identifier, item[1]))
|
||||
for source, backward in ordered:
|
||||
deliveries = references[source, backward]
|
||||
start = socket_position(source, positions, sizes)
|
||||
peripheral = len(deliveries) > 1
|
||||
forward_pipe = peripheral and (source.type != "GEOMETRY" or source.node in peripheral_nodes)
|
||||
level_entry = forward_pipe and source.type != "GEOMETRY"
|
||||
source_region = region_of.get(source.node)
|
||||
source_members = bodies[source_region][0] if source_region is not None else {source.node}
|
||||
|
||||
def path(level):
|
||||
arrivals = [(end[0] - max(PORT_RUN, abs(level - end[1]) * 0.8), link, end)
|
||||
for link, end in deliveries]
|
||||
launch = min(x for x, _, _ in arrivals)
|
||||
run = max(PORT_RUN, abs(level - start[1]) * 0.8)
|
||||
launch = start[0] + run if forward_pipe or launch >= start[0] + run else min(launch, start[0] - run)
|
||||
return launch, arrivals
|
||||
|
||||
endpoints = {source.node, *(link.to_node for link, _ in deliveries)}
|
||||
obstacles = [box for node, box in boxes if node not in endpoints]
|
||||
obstacles += [box for index, box in enumerate(regions) if not forward_pipe or index != source_region]
|
||||
boundaries = [box for _, box in boxes] + regions if peripheral else obstacles
|
||||
candidates = {edge for y in [start[1], *(end[1] for _, end in deliveries)]
|
||||
for edge in (y - TRACK_GAP, y + TRACK_GAP)}
|
||||
if level_entry:
|
||||
candidates.add(start[1])
|
||||
candidates.update({
|
||||
*(edge for _, _, top, bottom in boundaries for edge in (top + TRACK_GAP, bottom - TRACK_GAP)),
|
||||
*(edge for _, _, y in tracks for edge in (y + TRACK_GAP, y - TRACK_GAP))})
|
||||
|
||||
def clear(level):
|
||||
launch, arrivals = path(level)
|
||||
if forward_pipe and launch > min(x for x, _, _ in arrivals):
|
||||
return False
|
||||
if any(abs(level - y) < TRACK_GAP for y in [*([start[1]] if not level_entry else []), *(end[1] for _, end in deliveries)]):
|
||||
return False
|
||||
left = min(launch, *(x for x, _, _ in arrivals))
|
||||
right = max(launch, *(x for x, _, _ in arrivals))
|
||||
if peripheral:
|
||||
scope_left = min(left, start[0], *(end[0] for _, end in deliveries))
|
||||
scope_right = max(right, start[0], *(end[0] for _, end in deliveries))
|
||||
scope = [box for box in [*(box for node, box in boxes if node not in source_members and node not in peripheral_nodes),
|
||||
*(box for index, box in enumerate(regions) if index != source_region and index not in producer_regions)]
|
||||
if box[0] < scope_right and box[1] > scope_left]
|
||||
if scope and min(box[3] for box in scope) - TRACK_GAP < level < max(box[2] for box in scope) + TRACK_GAP:
|
||||
return False
|
||||
return (all(not bottom - TRACK_GAP < level < top + TRACK_GAP
|
||||
for a, b, top, bottom in obstacles if a < right and b > left)
|
||||
and all(abs(level - previous) >= TRACK_GAP - 0.01
|
||||
for a, b, previous in tracks if a < right and b > left))
|
||||
|
||||
available = [y for y in candidates if clear(y)]
|
||||
if not available:
|
||||
raise ValueError(f"No forward reference channel for {source.node.name}")
|
||||
def cost(level):
|
||||
length = abs(level - start[1]) + sum(abs(level - end[1]) for _, end in deliveries)
|
||||
launch, arrivals = path(level)
|
||||
length += max(0, start[0] - launch) * 2
|
||||
ramps = [(start, (launch, level))]
|
||||
ramps += [((x, level), end) for x, _, end in arrivals]
|
||||
footprints = [curve_footprint(a, b) for a, b in ramps]
|
||||
crossings = sum(any(a < right and b > left and bottom < upper and top > lower
|
||||
for left, right, upper, lower in footprint)
|
||||
for node, (a, b, top, bottom) in boxes if node not in endpoints
|
||||
for footprint in footprints)
|
||||
overlaps = sum(min(right, b) - max(left, a) > TRACK_GAP
|
||||
and max(lower, bottom) - min(upper, top) < TRACK_GAP
|
||||
for footprint in footprints for left, right, upper, lower in footprint
|
||||
if upper - lower < (right - left) * 0.25
|
||||
for a, b, top, bottom in wire_corridors
|
||||
if top - bottom < (b - a) * 0.25)
|
||||
return overlaps * 4000 + crossings * 400 + length, abs(level - start[1]), -level
|
||||
|
||||
level = min(available, key=cost)
|
||||
launch, arrivals = path(level)
|
||||
root = route(source, (launch, level))
|
||||
trunk, previous_x = root, launch
|
||||
for x, link, end in sorted(arrivals, key=lambda item: item[0]):
|
||||
if x - previous_x >= PORT_RUN * 2:
|
||||
trunk = route(trunk, (x, level))
|
||||
previous_x = x
|
||||
actual_end = link_endpoints(link, positions, sizes)[1]
|
||||
branch = route(trunk, (end[0] - PORT_RUN, end[1])) if end != actual_end else trunk
|
||||
replace_link_source(node_group, link, branch)
|
||||
tracks.append((min(launch, *(x for x, _, _ in arrivals)),
|
||||
max(launch, *(x for x, _, _ in arrivals)), level))
|
||||
wire_corridors.extend(curve_footprint(start, (launch, level)))
|
||||
for x, _, end in arrivals:
|
||||
wire_corridors.extend(curve_footprint((x, level), end))
|
||||
116
tools/nodes/arrangement/sections.py
Normal file
116
tools/nodes/arrangement/sections.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Reserve horizontal space for field calculations beside their geometry consumers."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from .zones import node_zones, repeat_regions
|
||||
|
||||
|
||||
def clear_repeat_regions(node_group, positions, sizes, bodies, *, gap):
|
||||
"""Move unrelated calculation bodies and nodes outside reserved repeat areas."""
|
||||
regions = repeat_regions(node_group, positions, sizes)
|
||||
grouped = set().union(*(members for members, _ in bodies)) if bodies else set()
|
||||
groups = [members for members, _ in bodies] + [{node} for node in positions if node not in grouped]
|
||||
for members in groups:
|
||||
obstacles = [box for owned, box in regions if not members & owned]
|
||||
left = min(positions[node][0] for node in members)
|
||||
right = max(positions[node][0] + sizes[node].width for node in members)
|
||||
top = max(positions[node][1] for node in members)
|
||||
bottom = min(positions[node][1] - sizes[node].height for node in members)
|
||||
if not any(left < b and right > a and top > d and bottom < c for a, b, c, d in obstacles):
|
||||
continue
|
||||
occupied = obstacles + [(x, x + sizes[node].width, y, y - sizes[node].height)
|
||||
for node, (x, y) in positions.items() if node not in members]
|
||||
occupied = [box for box in occupied if left < box[1] + gap and right > box[0] - gap]
|
||||
offsets = {edge for _, _, upper, lower in occupied
|
||||
for edge in (upper + gap - bottom, lower - gap - top)}
|
||||
targets = {link.to_node for node in members for socket in node.outputs for link in socket.links
|
||||
if link.to_node not in members and link.to_node in positions}
|
||||
above = (right - left < 1000 and len(targets) == 1
|
||||
and bottom > positions[next(iter(targets))][1])
|
||||
offset = min((offset for offset in offsets
|
||||
if all(top + offset <= lower - gap + 1e-5 or bottom + offset >= upper + gap - 1e-5
|
||||
for _, _, upper, lower in occupied)),
|
||||
key=lambda offset: (above and bottom + offset <= positions[next(iter(targets))][1], abs(offset)))
|
||||
for node in members:
|
||||
x, y = positions[node]
|
||||
positions[node] = (x, y + offset)
|
||||
for index, (members, _) in enumerate(bodies):
|
||||
bodies[index] = (members, (min(positions[node][0] for node in members),
|
||||
max(positions[node][0] + sizes[node].width for node in members),
|
||||
max(positions[node][1] for node in members),
|
||||
min(positions[node][1] - sizes[node].height for node in members)))
|
||||
|
||||
|
||||
def enclose_repeat_branches(node_group, positions, sizes, bodies, *, gap):
|
||||
"""Leave real horizontal space between repeat boundaries and their calculations."""
|
||||
for entry, end, members in node_zones(node_group):
|
||||
if entry.bl_idname != "GeometryNodeRepeatInput":
|
||||
continue
|
||||
interior = (members - {entry, end}) & positions.keys()
|
||||
if not interior:
|
||||
continue
|
||||
for boundary, direction in ((entry, -1), (end, 1)):
|
||||
start = positions[boundary][0]
|
||||
target = (min(positions[node][0] for node in interior) - gap - sizes[entry].width
|
||||
if direction < 0 else
|
||||
max(positions[node][0] + sizes[node].width for node in interior) + gap)
|
||||
distance = direction * (target - start)
|
||||
if distance <= 0:
|
||||
continue
|
||||
moving = {node for node, (x, _) in positions.items()
|
||||
if node not in members and direction * (x - start) >= 0} | {boundary}
|
||||
for body, _ in bodies:
|
||||
if body & moving and not body & members:
|
||||
moving.update(body)
|
||||
for node in moving:
|
||||
x, y = positions[node]
|
||||
positions[node] = (x + direction * distance, y)
|
||||
for index, (members, _) in enumerate(bodies):
|
||||
bodies[index] = (members, (min(positions[node][0] for node in members),
|
||||
max(positions[node][0] + sizes[node].width for node in members),
|
||||
max(positions[node][1] for node in members),
|
||||
min(positions[node][1] - sizes[node].height for node in members)))
|
||||
|
||||
|
||||
def reserve_field_sections(node_group, layouts, positions, sizes, *, gap):
|
||||
"""Extend execution stages to make room for substantial incoming calculations."""
|
||||
stages = {node: node for node in positions}
|
||||
for entry, _, members in node_zones(node_group):
|
||||
for node in members & positions.keys():
|
||||
stages[node] = entry
|
||||
sections = defaultdict(list)
|
||||
for region, owners, local, width, height in layouts:
|
||||
if width < 1000 or not owners:
|
||||
continue
|
||||
owner = min(owners, key=lambda node: (positions[node][0], node.name))
|
||||
sections[stages[owner]].append((region, owner, local, width, height))
|
||||
front = max((width for _, _, _, width, _ in layouts), default=0)
|
||||
bands = [min(x for x, _ in positions.values()) - front - gap] * 2
|
||||
for stage in sorted(sections, key=lambda node: (positions[node][0], node.name)):
|
||||
start = positions[stage][0]
|
||||
pending = []
|
||||
for region, owner, local, width, height in sorted(sections[stage], key=lambda item: -item[3]):
|
||||
band = min(range(2), key=lambda index: bands[index])
|
||||
left = bands[band] + gap
|
||||
for node in region:
|
||||
for socket in node.inputs:
|
||||
for link in socket.links:
|
||||
source = link.from_node
|
||||
if source in positions and stages[source] != stage:
|
||||
left = max(left, positions[source][0] + sizes[source].width + gap - local[node][0])
|
||||
offsets = [local[node][1] - sizes[node].sockets[socket]
|
||||
+ sizes[link.to_node].sockets[link.to_socket]
|
||||
+ positions[owner][1] - positions[link.to_node][1]
|
||||
for node in region for socket in node.outputs for link in socket.links
|
||||
if link.to_node in positions]
|
||||
above = max((abs(height + gap + offset) for offset in offsets), default=gap)
|
||||
below = max((abs(-sizes[owner].height - gap + offset) for offset in offsets), default=gap)
|
||||
exit_run = max(gap, min(above, below) * 0.8)
|
||||
finish = max(positions[owner][0] - exit_run, left + width)
|
||||
pending.append((owner, finish + exit_run))
|
||||
bands[band] = finish
|
||||
extra = max(finish - positions[owner][0] for owner, finish in pending)
|
||||
if extra > 0:
|
||||
for node, (x, y) in positions.items():
|
||||
if x >= start:
|
||||
positions[node] = (x + extra, y)
|
||||
38
tools/nodes/arrangement/sources.py
Normal file
38
tools/nodes/arrangement/sources.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Place shared calculation sources beside their peripheral distribution channels."""
|
||||
|
||||
|
||||
def place_shared_sources(bodies, peripheral_nodes, positions, sizes, *, gap):
|
||||
"""Place consumer regions first, then keep their producers outside and to the left."""
|
||||
ordinary = [box for members, box in bodies if not members & peripheral_nodes]
|
||||
if not ordinary:
|
||||
ordinary = [(x, x + sizes[node].width, y, y - sizes[node].height)
|
||||
for node, (x, y) in positions.items() if node not in peripheral_nodes]
|
||||
ceiling = max(box[2] for box in ordinary)
|
||||
floor = min(box[3] for box in ordinary)
|
||||
for index, (members, (left, right, top, bottom)) in enumerate(bodies):
|
||||
if not members & peripheral_nodes:
|
||||
continue
|
||||
links = [link for node in members for socket in node.outputs for link in socket.links
|
||||
if link.to_node not in members and link.to_node in positions]
|
||||
if not links:
|
||||
continue
|
||||
height = top - bottom
|
||||
candidates = (ceiling + gap * 2 + height, floor - gap * 2)
|
||||
new_top = min(candidates, key=lambda level: (sum(
|
||||
abs(positions[link.to_node][1] - level + height / 2) for link in links), -level))
|
||||
dy = new_top - top
|
||||
new_left = float("inf")
|
||||
for link in links:
|
||||
source, target = link.from_node, link.to_node
|
||||
source_y = positions[source][1] + dy - sizes[source].sockets[link.from_socket]
|
||||
target_y = positions[target][1] - sizes[target].sockets[link.to_socket]
|
||||
runway = (abs(source_y - target_y) + height * 2) * 0.8 + gap * 3
|
||||
new_left = min(new_left, positions[target][0] - runway
|
||||
- (positions[source][0] - left) - sizes[source].width)
|
||||
dx = new_left - left
|
||||
for node in members:
|
||||
x, y = positions[node]
|
||||
positions[node] = (x + dx, y + dy)
|
||||
bodies[index] = (members, (left + dx, right + dx, top + dy, bottom + dy))
|
||||
ceiling = max(ceiling, top + dy)
|
||||
floor = min(floor, bottom + dy)
|
||||
@@ -3,6 +3,20 @@
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
|
||||
def repeat_regions(node_group, positions, sizes):
|
||||
"""Bound repeat execution and calculations without peripheral routing corners."""
|
||||
regions = []
|
||||
for entry, end, members in node_zones(node_group):
|
||||
if entry.bl_idname != "GeometryNodeRepeatInput":
|
||||
continue
|
||||
nodes = {node for node in members if node.bl_idname not in {"NodeReroute", "NodeFrame"}}
|
||||
regions.append((members, (positions[entry][0] - 60,
|
||||
positions[end][0] + sizes[end].width + 60,
|
||||
max(positions[node][1] for node in nodes) + 60,
|
||||
min(positions[node][1] - sizes[node].height for node in nodes) - 40)))
|
||||
return regions
|
||||
|
||||
|
||||
def node_zones(node_group):
|
||||
"""Return each paired entry, exit, and its data-dependent node membership."""
|
||||
parents = {node: set() for node in node_group.nodes}
|
||||
|
||||
@@ -58,7 +58,8 @@ def check_asset_library(asset_path):
|
||||
modifier = obj.modifiers.new("Asset Check", "NODES")
|
||||
modifier.node_group = node_group
|
||||
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
|
||||
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0}
|
||||
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
|
||||
"Split Threshold": 0.0}
|
||||
for item in node_group.interface.items_tree:
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
|
||||
modifier[item.identifier] = values[item.name]
|
||||
|
||||
@@ -55,6 +55,12 @@ def smooth_boundary_camera(group, camera, protected=False):
|
||||
links.new(camera, axes.inputs[0])
|
||||
depth = axes.outputs["Z"]
|
||||
smoothed = smooth_boundary_depth(group, depth, protected)
|
||||
return reconstruct_camera_depth(group, camera, depth, smoothed)
|
||||
|
||||
|
||||
def reconstruct_camera_depth(group, camera, depth, smoothed):
|
||||
"""Move each valid camera point along its original projection ray."""
|
||||
nodes, links = group.nodes, group.links
|
||||
changed = _math(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0)
|
||||
enabled = _math(group, "MULTIPLY", changed, _math(group, "GREATER_THAN", depth, 1e-8))
|
||||
projected = nodes.new("ShaderNodeVectorMath")
|
||||
|
||||
@@ -36,10 +36,8 @@ def taper_split_profile(group, original, geometry):
|
||||
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
|
||||
protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
|
||||
influence = boundary_influence(group, protected)
|
||||
controls = group_input(nodes, {"Edge Round", "Split Threshold"})
|
||||
strength = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Edge Round"], 4))
|
||||
band = _math(group, "MINIMUM", _math(group, "MULTIPLY", influence, strength), 1)
|
||||
band = _math(group, "MULTIPLY", band, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0))
|
||||
controls = group_input(nodes, {"Split Threshold"})
|
||||
band = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0))
|
||||
retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0))
|
||||
profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained)
|
||||
return store_float_attribute(group, geometry, "o_balloon", profile)
|
||||
|
||||
@@ -155,15 +155,23 @@ def sample_face_camera(group, image):
|
||||
return texture.outputs["Color"]
|
||||
|
||||
|
||||
def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
|
||||
face_center=None, triangles=False):
|
||||
"""Split adjacent face samples using their Z depth and base center spacing."""
|
||||
def ray_distance(group, camera):
|
||||
"""Measure the distance from the camera origin to a camera-space point."""
|
||||
length = group.nodes.new("ShaderNodeVectorMath")
|
||||
length.operation = "LENGTH"
|
||||
group.links.new(camera, length.inputs[0])
|
||||
return length.outputs["Value"]
|
||||
|
||||
|
||||
def split_depth_surface(group, geometry, face_camera, strength, reference_depth, *,
|
||||
face_distance=None, face_center=None, triangles=False):
|
||||
"""Split relative ray-distance jumps over the base face-center spacing."""
|
||||
nodes, links = group.nodes, group.links
|
||||
if face_center is None:
|
||||
face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
|
||||
face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE")
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(face_camera, axes.inputs[0])
|
||||
if face_distance is None:
|
||||
face_distance = ray_distance(group, face_camera)
|
||||
centers, depths = [], []
|
||||
for side in (0, 1):
|
||||
corner = nodes.new("GeometryNodeCornersOfEdge")
|
||||
@@ -172,7 +180,7 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
|
||||
links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"])
|
||||
for data_type, field, results in (
|
||||
("FLOAT_VECTOR", face_center, centers),
|
||||
("FLOAT", axes.outputs["Z"], depths),
|
||||
("FLOAT", face_distance, depths),
|
||||
):
|
||||
sample = nodes.new("GeometryNodeFieldAtIndex")
|
||||
sample.data_type = data_type
|
||||
@@ -185,13 +193,15 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
|
||||
for index, center in enumerate(centers):
|
||||
links.new(center, distance.inputs[index])
|
||||
delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths))
|
||||
scaled = _math(group, "MULTIPLY", delta, uniform)
|
||||
# Cross multiplication avoids division and stays invariant under uniform scaling.
|
||||
local_distance = _math(group, "MAXIMUM", _math(group, "MULTIPLY", _math(group, "ADD", *depths), 0.5), 1e-8)
|
||||
reference = _math(group, "MAXIMUM", reference_depth, 1e-8)
|
||||
# Uniform Scale cancels between the distance jump and local spacing correction.
|
||||
# Cross multiplication also avoids dividing by the local distance.
|
||||
selected = _math(
|
||||
group,
|
||||
"GREATER_THAN",
|
||||
_math(group, "MULTIPLY", scaled, strength),
|
||||
_math(group, "MULTIPLY", distance.outputs["Value"], 2.5),
|
||||
_math(group, "MULTIPLY", _math(group, "MULTIPLY", delta, reference), strength),
|
||||
_math(group, "MULTIPLY", _math(group, "MULTIPLY", distance.outputs["Value"], local_distance), 2.5),
|
||||
)
|
||||
selected = _math(
|
||||
group,
|
||||
@@ -352,7 +362,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
|
||||
normal = nodes.new("GeometryNodeInputNormal")
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
links.new(normal_smooth, blur.inputs["Iterations"])
|
||||
blur.inputs["Iterations"].default_value = normal_smooth
|
||||
links.new(normal.outputs[0], blur.inputs["Value"])
|
||||
normalize = nodes.new("ShaderNodeVectorMath")
|
||||
normalize.operation = "NORMALIZE"
|
||||
|
||||
@@ -11,6 +11,15 @@ def prepare_node_group(node_group):
|
||||
node_group.nodes.remove(node)
|
||||
|
||||
|
||||
def replace_link_source(node_group, link, source):
|
||||
"""Replace a source while preserving multi-input socket order."""
|
||||
multi = link.to_socket.is_multi_input
|
||||
replacement = node_group.links.new(source, link.to_socket)
|
||||
if multi:
|
||||
replacement.swap_multi_input_sort_id(link)
|
||||
node_group.links.remove(link)
|
||||
|
||||
|
||||
def interface_socket(group, name, in_out, socket_type, parent=None):
|
||||
socket = group.interface.new_socket(
|
||||
name=name,
|
||||
|
||||
98
tools/nodes/common/outline_smoothing.py
Normal file
98
tools/nodes/common/outline_smoothing.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Adapt camera depth along the original outline with batched weight updates."""
|
||||
|
||||
from .boundary_smoothing import boundary_influence, reconstruct_camera_depth
|
||||
from .depth_surface import CUT_ATTRIBUTE, _math, ray_distance
|
||||
from .nodes import evaluate_field, group_input, read_float_attribute, sample_field
|
||||
from .smoothing import edge_boundary_field
|
||||
|
||||
|
||||
def _outline_influence(group, original, geometry):
|
||||
"""Identify original outline points and protect split boundary intersections."""
|
||||
nodes, links = group.nodes, group.links
|
||||
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
|
||||
nearest = nodes.new("GeometryNodeSampleNearest")
|
||||
nearest.domain = "POINT"
|
||||
links.new(original, nearest.inputs["Geometry"])
|
||||
position = nodes.new("GeometryNodeInputPosition").outputs["Position"]
|
||||
links.new(position, nearest.inputs["Sample Position"])
|
||||
original_rim = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
|
||||
original_rim = _math(group, "GREATER_THAN", original_rim, 0)
|
||||
cut = _math(group, "GREATER_THAN", read_float_attribute(group, CUT_ATTRIBUTE), 0)
|
||||
protected = _math(group, "MULTIPLY", _math(group, "GREATER_THAN", rim, 0),
|
||||
_math(group, "MAXIMUM", cut, _math(group, "SUBTRACT", 1, original_rim)))
|
||||
# Resolve planar correspondence before replacing positions with camera depth.
|
||||
return sample_field(group, geometry, boundary_influence(group, protected), "FLOAT")
|
||||
|
||||
|
||||
def smooth_outline_camera(group, original, geometry, camera, uniform_scale):
|
||||
"""Update outline weights and blur ray distance in fixed-weight batches."""
|
||||
nodes, links = group.nodes, group.links
|
||||
controls = group_input(nodes, {"Outline Depth Fix", "Boundary Smooth"})
|
||||
influence = _outline_influence(group, original, geometry)
|
||||
depth = ray_distance(group, camera)
|
||||
|
||||
endpoints = nodes.new("GeometryNodeInputMeshEdgeVertices")
|
||||
length = nodes.new("ShaderNodeVectorMath")
|
||||
length.operation = "DISTANCE"
|
||||
links.new(endpoints.outputs["Position 1"], length.inputs[0])
|
||||
links.new(endpoints.outputs["Position 2"], length.inputs[1])
|
||||
statistic = nodes.new("GeometryNodeAttributeStatistic")
|
||||
statistic.data_type, statistic.domain = "FLOAT", "EDGE"
|
||||
links.new(geometry, statistic.inputs["Geometry"])
|
||||
links.new(length.outputs["Value"], statistic.inputs["Attribute"])
|
||||
scale = _math(group, "DIVIDE", uniform_scale,
|
||||
_math(group, "MAXIMUM", statistic.outputs["Median"], 1e-8))
|
||||
|
||||
seed = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(depth, seed.inputs["Z"])
|
||||
carrier = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(geometry, carrier.inputs["Geometry"])
|
||||
links.new(seed.outputs[0], carrier.inputs["Position"])
|
||||
repeat_output = nodes.new("GeometryNodeRepeatOutput")
|
||||
repeat_input = nodes.new("GeometryNodeRepeatInput")
|
||||
repeat_input.pair_with_output(repeat_output)
|
||||
links.new(carrier.outputs[0], repeat_input.inputs["Geometry"])
|
||||
links.new(controls.outputs["Outline Depth Fix"], repeat_input.inputs["Iterations"])
|
||||
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
current = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs[0], current.inputs[0])
|
||||
reference = nodes.new("GeometryNodeBlurAttribute")
|
||||
reference.data_type = "FLOAT"
|
||||
reference.inputs["Iterations"].default_value = 1
|
||||
reference.inputs["Weight"].default_value = 0.5
|
||||
links.new(current.outputs["Z"], reference.inputs["Value"])
|
||||
residual = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", current.outputs["Z"], reference.outputs[0]))
|
||||
ratio = _math(group, "MULTIPLY", residual, scale)
|
||||
weight = nodes.new("ShaderNodeMapRange")
|
||||
weight.interpolation_type, weight.clamp = "SMOOTHSTEP", True
|
||||
weight.inputs["From Min"].default_value = 0.25
|
||||
weight.inputs["From Max"].default_value = 0.5
|
||||
links.new(ratio, weight.inputs["Value"])
|
||||
active = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", depth, 1e-8))
|
||||
active = sample_field(group, geometry, active, "FLOAT")
|
||||
weighted = _math(group, "MULTIPLY", weight.outputs[0], active)
|
||||
# Sampling the round's input prevents the inner Blur from updating its weight.
|
||||
frozen = sample_field(group, repeat_input.outputs["Geometry"], weighted, "FLOAT")
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT"
|
||||
links.new(current.outputs["Z"], blur.inputs["Value"])
|
||||
links.new(frozen, blur.inputs["Weight"])
|
||||
links.new(controls.outputs["Boundary Smooth"], blur.inputs["Iterations"])
|
||||
updated = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(blur.outputs[0], updated.inputs["Z"])
|
||||
move = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(repeat_input.outputs["Geometry"], move.inputs["Geometry"])
|
||||
links.new(updated.outputs[0], move.inputs["Position"])
|
||||
links.new(move.outputs[0], repeat_output.inputs["Geometry"])
|
||||
|
||||
result = sample_field(group, repeat_output.outputs["Geometry"], current.outputs["Z"], "FLOAT")
|
||||
enabled = _math(group, "MULTIPLY", active,
|
||||
_math(group, "MULTIPLY", controls.outputs["Outline Depth Fix"],
|
||||
controls.outputs["Boundary Smooth"]))
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "FLOAT"
|
||||
links.new(enabled, choose.inputs["Switch"])
|
||||
links.new(depth, choose.inputs["False"])
|
||||
links.new(result, choose.inputs["True"])
|
||||
return reconstruct_camera_depth(group, camera, depth, choose.outputs[0])
|
||||
@@ -51,7 +51,6 @@ def expand_smooth(
|
||||
repeat_output = nodes.new("GeometryNodeRepeatOutput")
|
||||
repeat_input = nodes.new("GeometryNodeRepeatInput")
|
||||
repeat_input.pair_with_output(repeat_output)
|
||||
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
|
||||
links.new(
|
||||
iteration_controls.outputs[iterations_name],
|
||||
repeat_input.inputs["Iterations"],
|
||||
@@ -109,11 +108,6 @@ def expand_smooth(
|
||||
links.new(boundary_points.outputs["Value"], choose_boundary.inputs["Switch"])
|
||||
links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"])
|
||||
links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"])
|
||||
boundary_mix = nodes.new("ShaderNodeMix")
|
||||
boundary_mix.data_type = "VECTOR"
|
||||
boundary_mix.inputs[0].default_value = 1.0
|
||||
links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4])
|
||||
links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5])
|
||||
|
||||
weight = weight_controls.outputs[weight_name]
|
||||
if pin_sharp:
|
||||
@@ -126,15 +120,10 @@ def expand_smooth(
|
||||
normal_coherence = nodes.new("ShaderNodeVectorMath")
|
||||
normal_coherence.operation = "LENGTH"
|
||||
links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0])
|
||||
sharp_switch = nodes.new("GeometryNodeSwitch")
|
||||
sharp_switch.input_type = "FLOAT"
|
||||
sharp_switch.inputs["Switch"].default_value = pin_sharp
|
||||
sharp_switch.inputs["False"].default_value = 1.0
|
||||
links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"])
|
||||
smooth_weight = nodes.new("ShaderNodeMath")
|
||||
smooth_weight.operation = "MULTIPLY"
|
||||
links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0])
|
||||
links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1])
|
||||
links.new(normal_coherence.outputs["Value"], smooth_weight.inputs[1])
|
||||
weight = smooth_weight.outputs[0]
|
||||
if influence is not None:
|
||||
masked_weight = nodes.new("ShaderNodeMath")
|
||||
@@ -143,17 +132,12 @@ def expand_smooth(
|
||||
links.new(influence, masked_weight.inputs[1])
|
||||
weight = masked_weight.outputs[0]
|
||||
|
||||
target_position = nodes.new("GeometryNodeSwitch")
|
||||
target_position.input_type = "VECTOR"
|
||||
target_position.inputs["Switch"].default_value = True
|
||||
links.new(smooth_all.outputs["Value"], target_position.inputs["False"])
|
||||
links.new(boundary_mix.outputs["Result"], target_position.inputs["True"])
|
||||
current_position = nodes.new("GeometryNodeInputPosition")
|
||||
mix_position = nodes.new("ShaderNodeMix")
|
||||
mix_position.data_type = "VECTOR"
|
||||
links.new(weight, mix_position.inputs[0])
|
||||
links.new(current_position.outputs["Position"], mix_position.inputs[4])
|
||||
links.new(target_position.outputs["Output"], mix_position.inputs[5])
|
||||
links.new(choose_boundary.outputs["Output"], mix_position.inputs[5])
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"])
|
||||
links.new(mix_position.outputs["Result"], set_position.inputs["Position"])
|
||||
|
||||
@@ -110,7 +110,7 @@ def build_image_depth_balloon_group():
|
||||
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
|
||||
group.is_modifier, group.use_fake_user = (True, True)
|
||||
group.color_tag = 'GEOMETRY'
|
||||
group['anyimage_asset_revision'] = 11
|
||||
group['anyimage_asset_revision'] = 12
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
|
||||
float_input(group, 'Depth Scale', 1.0)
|
||||
@@ -120,8 +120,8 @@ def build_image_depth_balloon_group():
|
||||
iterations.min_value, iterations.max_value = (0, 20)
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options)
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True
|
||||
|
||||
@@ -4,7 +4,8 @@ from mathutils import Matrix
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
|
||||
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field
|
||||
from ..common.smoothing import expand_smooth
|
||||
from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary
|
||||
from ..common.boundary_smoothing import smooth_cut_boundary
|
||||
from ..common.outline_smoothing import smooth_outline_camera
|
||||
from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output
|
||||
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface
|
||||
from ..common.smoothing import edge_boundary_field
|
||||
@@ -72,83 +73,33 @@ def build_volume_fields(group):
|
||||
return {"rim": rim, "center": center, "thickness": thickness}
|
||||
|
||||
|
||||
def build_volume_rounding(group, geometry, fields, uniform, normal_weight=1):
|
||||
links = group.links
|
||||
|
||||
control = group.nodes.new("NodeGroupInput")
|
||||
profile = group.nodes.new("GeometryNodeInputNamedAttribute")
|
||||
profile.data_type = "FLOAT"
|
||||
profile.inputs["Name"].default_value = "o_balloon"
|
||||
balloon = profile.outputs["Attribute"]
|
||||
maximum = group.nodes.new("GeometryNodeAttributeStatistic")
|
||||
maximum.data_type, maximum.domain = "FLOAT", "POINT"
|
||||
links.new(geometry, maximum.inputs["Geometry"])
|
||||
links.new(balloon, maximum.inputs["Attribute"])
|
||||
limit = _math(group, "MAXIMUM", maximum.outputs["Max"], 1e-8)
|
||||
falloff = group.nodes.new("ShaderNodeMapRange")
|
||||
falloff.interpolation_type = "SMOOTHERSTEP"
|
||||
falloff.inputs["To Min"].default_value = 1
|
||||
falloff.inputs["To Max"].default_value = 0
|
||||
links.new(balloon, falloff.inputs["Value"])
|
||||
links.new(limit, falloff.inputs["From Max"])
|
||||
weight = _math(group, "MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"])
|
||||
center = fields["center"]
|
||||
position = group.nodes.new("GeometryNodeInputPosition")
|
||||
axes = group.nodes.new("ShaderNodeSeparateXYZ")
|
||||
def build_shell_fields(group, geometry, fields, uniform, normal_weight=1):
|
||||
"""Evaluate rear directions on the center surface and select shell thickness."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs[0], axes.inputs[0])
|
||||
target = _math(group, "ADD", center, balloon)
|
||||
delta = _math(group, "SUBTRACT", target, axes.outputs["Z"])
|
||||
delta = _math(group, "MULTIPLY", delta, weight)
|
||||
enabled = _math(group, "MINIMUM", control.outputs["Thickness"], 1)
|
||||
enabled = _math(group, "MAXIMUM", enabled, 0)
|
||||
delta = _math(group, "MULTIPLY", delta, enabled)
|
||||
rear = _math(group, "MULTIPLY", balloon, weight)
|
||||
rear = _math(group, "MULTIPLY", rear, control.outputs["Thickness"])
|
||||
thickness = fields["thickness"]
|
||||
total = _math(group, "ADD", thickness, delta)
|
||||
total = _math(group, "ADD", total, rear)
|
||||
|
||||
# Evaluate normals on a separate center surface with identical point indices.
|
||||
center_position = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
center_position = nodes.new("ShaderNodeCombineXYZ")
|
||||
for axis in ("X", "Y"):
|
||||
links.new(axes.outputs[axis], center_position.inputs[axis])
|
||||
links.new(center, center_position.inputs["Z"])
|
||||
surface = group.nodes.new("GeometryNodeSetPosition")
|
||||
links.new(fields["center"], center_position.inputs["Z"])
|
||||
surface = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(geometry, surface.inputs["Geometry"])
|
||||
links.new(center_position.outputs[0], surface.inputs["Position"])
|
||||
normal = group.nodes.new("GeometryNodeInputNormal")
|
||||
blur = group.nodes.new("GeometryNodeBlurAttribute")
|
||||
normal = nodes.new("GeometryNodeInputNormal")
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
blur.inputs["Iterations"].default_value = 100
|
||||
links.new(normal.outputs[0], blur.inputs["Value"])
|
||||
local = group.nodes.new("NodeGroupInput")
|
||||
links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"])
|
||||
normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
|
||||
index = group.nodes.new("GeometryNodeInputIndex")
|
||||
sample = group.nodes.new("GeometryNodeSampleIndex")
|
||||
sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT"
|
||||
links.new(surface.outputs[0], sample.inputs["Geometry"])
|
||||
links.new(normalized, sample.inputs["Value"])
|
||||
links.new(index.outputs[0], sample.inputs["Index"])
|
||||
thickness = group.nodes.new("GeometryNodeSwitch")
|
||||
rear_normal = sample_field(group, surface.outputs[0], normalized, "FLOAT_VECTOR")
|
||||
control = group_input(nodes, {"Uniform Thickness"})
|
||||
thickness = nodes.new("GeometryNodeSwitch")
|
||||
thickness.input_type = "FLOAT"
|
||||
links.new(uniform, thickness.inputs["Switch"])
|
||||
links.new(total, thickness.inputs["False"])
|
||||
links.new(fields["thickness"], thickness.inputs["False"])
|
||||
links.new(control.outputs["Uniform Thickness"], thickness.inputs["True"])
|
||||
rounding = {
|
||||
"edge_weight": weight, "front_delta": delta,
|
||||
"thickness": thickness.outputs[0], "normal": sample.outputs[0],
|
||||
}
|
||||
displacement = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(delta, displacement.inputs["Z"])
|
||||
front = group.nodes.new("GeometryNodeSetPosition")
|
||||
links.new(geometry, front.inputs["Geometry"])
|
||||
links.new(displacement.outputs[0], front.inputs["Offset"])
|
||||
choose = group.nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(uniform, choose.inputs["Switch"])
|
||||
links.new(front.outputs[0], choose.inputs["False"])
|
||||
links.new(geometry, choose.inputs["True"])
|
||||
return choose.outputs[0], rounding
|
||||
return {"thickness": thickness.outputs[0], "normal": rear_normal}
|
||||
|
||||
|
||||
def build_front_fields(group, rim, front_normal, normal_weight=1):
|
||||
@@ -186,14 +137,10 @@ def build_front_fields(group, rim, front_normal, normal_weight=1):
|
||||
structure = _math(group, "MAXIMUM", structure, 0)
|
||||
|
||||
normal = front_normal
|
||||
controls = group.nodes.new("NodeGroupInput")
|
||||
# Keep the accepted broad front-direction filter at the default of 50;
|
||||
# zero disables both this filter and the initial front/back normal filters.
|
||||
spread = _math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50)
|
||||
smooth = group.nodes.new("GeometryNodeBlurAttribute")
|
||||
smooth.data_type = "FLOAT_VECTOR"
|
||||
links.new(normal, smooth.inputs["Value"])
|
||||
links.new(spread, smooth.inputs["Iterations"])
|
||||
smooth.inputs["Iterations"].default_value = 512
|
||||
normalized = blend_normal(group, normal, smooth.outputs[0], normal_weight)
|
||||
return {"structure": structure, "inflation_normal": normalized}
|
||||
|
||||
@@ -247,17 +194,21 @@ def build_inflation_position(group, position, distance, uniform, fields):
|
||||
result.operation = "ADD"
|
||||
links.new(position, result.inputs[0])
|
||||
links.new(offset.outputs[0], result.inputs[1])
|
||||
return result.outputs[0]
|
||||
enabled = group.nodes.new("GeometryNodeSwitch")
|
||||
enabled.input_type = "VECTOR"
|
||||
links.new(control.outputs["Front Inflation"], enabled.inputs["Switch"])
|
||||
links.new(position, enabled.inputs["False"])
|
||||
links.new(result.outputs[0], enabled.inputs["True"])
|
||||
return enabled.outputs[0]
|
||||
|
||||
|
||||
def build_front_normal(group, normal_weight):
|
||||
links = group.links
|
||||
normal = group.nodes.new("GeometryNodeInputNormal")
|
||||
control = group.nodes.new("NodeGroupInput")
|
||||
blur = group.nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
links.new(normal.outputs[0], blur.inputs["Value"])
|
||||
links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"])
|
||||
blur.inputs["Iterations"].default_value = 100
|
||||
normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
|
||||
return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT")
|
||||
|
||||
@@ -291,35 +242,14 @@ def build_shell_position(group, position, front, uniform, fields):
|
||||
thin_offset = vector("SCALE", normal, distance)
|
||||
uniform_position = vector("ADD", front, thin_offset)
|
||||
|
||||
# R = t * (front_z - center) + (1 - t) * front_delta, with t clamped.
|
||||
# This avoids division by Thickness and removes the old zero-thickness drift.
|
||||
control = group.nodes.new("NodeGroupInput")
|
||||
control = group_input(group.nodes, {"Thickness"})
|
||||
clamp = group.nodes.new("ShaderNodeClamp")
|
||||
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
|
||||
t = clamp.outputs[0]
|
||||
center = fields["center"]
|
||||
delta = fields["front_delta"]
|
||||
radius = _math(group, "SUBTRACT", front_z, center)
|
||||
radius = _math(group, "MULTIPLY", radius, t)
|
||||
remaining = _math(group, "SUBTRACT", 1, t)
|
||||
remainder = _math(group, "MULTIPLY", remaining, delta)
|
||||
radius = _math(group, "ADD", radius, remainder)
|
||||
|
||||
# Front and rear use the same edge field, evaluated on their own caps.
|
||||
edge = fields["edge_weight"]
|
||||
mask = read_float_attribute(group, "o_normal_scale")
|
||||
remaining = _math(group, "SUBTRACT", 1, edge)
|
||||
remaining = _math(group, "MULTIPLY", remaining, mask)
|
||||
weight = _math(group, "SUBTRACT", 1, remaining)
|
||||
difference = _math(group, "SUBTRACT", weight, edge)
|
||||
base = _math(group, "MULTIPLY", difference, radius)
|
||||
layer = _math(group, "MULTIPLY", weight, distance)
|
||||
rear_radius = _math(group, "ADD", base, layer)
|
||||
front_radius = _math(group, "MULTIPLY", edge, radius)
|
||||
normal = fields["normal"]
|
||||
direction = vector("SUBTRACT", normal, (0, 0, 1))
|
||||
front_shift = vector("SCALE", direction, front_radius)
|
||||
front_position = vector("ADD", front, front_shift)
|
||||
radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_z, fields["center"]), t)
|
||||
rear = _math(group, "SUBTRACT", 1, read_float_attribute(group, "o_normal_scale"))
|
||||
rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance))
|
||||
direction = vector("SUBTRACT", fields["normal"], (0, 0, 1))
|
||||
rear_shift = vector("SCALE", direction, rear_radius)
|
||||
axis_offset = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(distance, axis_offset.inputs["Z"])
|
||||
@@ -334,7 +264,7 @@ def build_shell_position(group, position, front, uniform, fields):
|
||||
links.new(transition.outputs[0], mix.inputs[0])
|
||||
links.new(thin_offset, mix.inputs[4])
|
||||
links.new(full_offset, mix.inputs[5])
|
||||
balloon_position = vector("ADD", front_position, mix.outputs["Result"])
|
||||
balloon_position = vector("ADD", front, mix.outputs["Result"])
|
||||
choose = group.nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "VECTOR"
|
||||
links.new(uniform, choose.inputs["Switch"])
|
||||
@@ -351,11 +281,11 @@ def _build_depth_surface(group, geometry, controls):
|
||||
scale = controls.outputs["Uniform Scale"]
|
||||
original = geometry
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, geometry, sample_face_camera(group, image), split, scale, triangles=True,
|
||||
group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"], triangles=True,
|
||||
)
|
||||
geometry = remove_boundary_triangles(group, geometry)
|
||||
camera = build_surface_camera(group, _position_field(group, image), corner_camera, cut_vertex, split)
|
||||
camera = smooth_boundary_camera(group, camera)
|
||||
camera = smooth_outline_camera(group, original, geometry, camera, scale)
|
||||
projection = project_depth_surface(
|
||||
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split,
|
||||
)
|
||||
@@ -370,10 +300,10 @@ def _build_depth_surface(group, geometry, controls):
|
||||
normal_weight = build_normal_weight(group, uniform)
|
||||
normal = build_front_normal(group, normal_weight)
|
||||
front = build_front_fields(group, volume["rim"], normal, normal_weight)
|
||||
rounded, rounding = build_volume_rounding(group, projection, volume, uniform, normal_weight)
|
||||
thickness = sample_field(group, projection, rounding["thickness"], "FLOAT")
|
||||
shell_fields = build_shell_fields(group, projection, volume, uniform, normal_weight)
|
||||
thickness = sample_field(group, projection, shell_fields["thickness"], "FLOAT")
|
||||
shell = thicken_depth_surface(
|
||||
group, rounded, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
|
||||
group, projection, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
|
||||
)
|
||||
fields = {
|
||||
name: sample_field(group, projection, value, data_type, index=shell["source_index"])
|
||||
@@ -385,7 +315,7 @@ def _build_depth_surface(group, geometry, controls):
|
||||
fields.update({
|
||||
name: sample_field(group, projection, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT",
|
||||
index=shell["source_index"])
|
||||
for name, value in rounding.items()
|
||||
for name, value in shell_fields.items()
|
||||
})
|
||||
oriented = build_shell_position(group, shell["position"], shell["front"], uniform, fields)
|
||||
links.new(oriented, shell["position_input"])
|
||||
@@ -405,7 +335,7 @@ def build_image_depth_cutout_group():
|
||||
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
|
||||
group.is_modifier, group.use_fake_user = (True, True)
|
||||
group.color_tag = 'GEOMETRY'
|
||||
group['anyimage_asset_revision'] = 18
|
||||
group['anyimage_asset_revision'] = 23
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
|
||||
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
|
||||
@@ -416,17 +346,16 @@ def build_image_depth_cutout_group():
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = (0, 20)
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 5, 0, 20
|
||||
boundary_smooth.description = 'Smooth boundary depth and geometry along the original outline and split edges'
|
||||
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options)
|
||||
normal_iterations = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
normal_iterations.default_value = 50
|
||||
normal_iterations.min_value, normal_iterations.max_value = (0, 50)
|
||||
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 1, 20
|
||||
boundary_smooth.description = 'Boundary geometry iterations and depth blur steps per outline smoothing round'
|
||||
outline_smooth = interface_socket(group, 'Outline Depth Fix', 'INPUT', 'NodeSocketInt', options)
|
||||
outline_smooth.default_value, outline_smooth.min_value, outline_smooth.max_value = 16, 0, 200
|
||||
outline_smooth.description = 'Adaptive ray-distance smoothing rounds around the original outline; preserves split boundary distance'
|
||||
float_input(group, 'Front Inflation', 0.0, maximum=1, subtype='FACTOR', parent=options)
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True
|
||||
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
|
||||
|
||||
@@ -39,7 +39,7 @@ def _quad_sphere(group, segments):
|
||||
def build_image_depth_panorama_group():
|
||||
group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
|
||||
group.is_modifier = group.use_fake_user = True
|
||||
group["anyimage_asset_revision"] = 14
|
||||
group["anyimage_asset_revision"] = 15
|
||||
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
|
||||
subdivide.default_value, subdivide.min_value, subdivide.max_value = 7, 0, 8
|
||||
@@ -57,7 +57,6 @@ def build_image_depth_panorama_group():
|
||||
float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options)
|
||||
data = group.interface.new_panel("Data", default_closed=True)
|
||||
interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
|
||||
float_input(group, "Reference Distance", 1, minimum=1e-6, subtype="DISTANCE", parent=data).hide_in_modifier = True
|
||||
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
@@ -151,11 +150,15 @@ def build_image_depth_panorama_group():
|
||||
links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"])
|
||||
links.new(delete.outputs[0], selection.inputs["False"])
|
||||
links.new(culled, selection.inputs["True"])
|
||||
controls = group_input(nodes, {"Split Threshold", "Reference Distance"})
|
||||
controls = group_input(nodes, {"Split Threshold"})
|
||||
strength = controls.outputs["Split Threshold"]
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(group, selection.outputs[0], depth(combine(face_u, face_v))[0], strength,
|
||||
_math(group, "DIVIDE", 1, controls.outputs["Reference Distance"]),
|
||||
face_center=face.outputs["Value"])
|
||||
face_camera = depth(combine(face_u, face_v))[0]
|
||||
radial = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(face_camera, radial.inputs[0])
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, selection.outputs[0], face_camera, strength, 1.0,
|
||||
face_distance=radial.outputs["X"], face_center=face.outputs["Value"],
|
||||
)
|
||||
distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength)
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(distance, axes.inputs[0])
|
||||
|
||||
@@ -25,7 +25,7 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
"GeometryNodeTree",
|
||||
)
|
||||
group.is_modifier = True
|
||||
group["anyimage_asset_revision"] = 14
|
||||
group["anyimage_asset_revision"] = 16
|
||||
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
geometry.structure_type = "SINGLE"
|
||||
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
|
||||
@@ -40,35 +40,18 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
|
||||
depth_scale.default_value = 1.0
|
||||
depth_scale.min_value = 0.0
|
||||
float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR")
|
||||
float_input(group, "Split Threshold", 0.1, maximum=1, subtype="FACTOR")
|
||||
iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt")
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = 0, 20
|
||||
valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool")
|
||||
valid_only.default_value = True
|
||||
options = group.interface.new_panel(name="Options", default_closed=True)
|
||||
float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options)
|
||||
float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
|
||||
boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 5, 0, 20
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 20
|
||||
boundary_smooth.description = "Smooth boundary depth and cut-edge geometry while preserving the original image border"
|
||||
base_plane_depth = _interface_socket(
|
||||
group,
|
||||
"Reference Depth",
|
||||
"INPUT",
|
||||
"NodeSocketFloat",
|
||||
parent=options,
|
||||
)
|
||||
base_plane_depth.default_value = 1.0
|
||||
base_plane_depth.min_value = 0.0
|
||||
base_plane_depth.subtype = "DISTANCE"
|
||||
normal_smooth = _interface_socket(
|
||||
group, "Normal Smooth", "INPUT", "NodeSocketInt", parent=options,
|
||||
)
|
||||
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 50, 0, 50
|
||||
normal_smooth.description = "Smooth the shell thickness direction"
|
||||
smooth_weight = _interface_socket(group, "Smooth Weight", "INPUT", "NodeSocketFloat", parent=options)
|
||||
smooth_weight.default_value = 1.0
|
||||
smooth_weight.min_value, smooth_weight.max_value = 0.0, 1.0
|
||||
smooth_weight.subtype = "FACTOR"
|
||||
validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options)
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
|
||||
@@ -134,9 +117,10 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
links.new(culled, cull.inputs["True"])
|
||||
links.new(protected, cull.inputs["False"])
|
||||
split_input = _group_input(nodes, {"Split Threshold"})
|
||||
reference_input = _group_input(nodes, {"Reference Depth"})
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]),
|
||||
split_input.outputs["Split Threshold"], scale_input.outputs["Uniform Scale"],
|
||||
split_input.outputs["Split Threshold"], reference_input.outputs["Reference Depth"],
|
||||
)
|
||||
camera = build_surface_camera(
|
||||
group, texture.outputs["Color"], corner_camera, cut_vertex,
|
||||
@@ -150,10 +134,10 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
depth_input.outputs["Depth Scale"], split_input.outputs["Split Threshold"],
|
||||
)
|
||||
surface = smooth_cut_boundary(group, surface, original_boundary)
|
||||
shell_input = _group_input(nodes, {"Thickness", "Normal Smooth"})
|
||||
shell_input = _group_input(nodes, {"Thickness"})
|
||||
shell = thicken_depth_surface(
|
||||
group, surface, shell_input.outputs["Thickness"],
|
||||
normal_smooth=shell_input.outputs["Normal Smooth"],
|
||||
normal_smooth=100,
|
||||
)
|
||||
positive = nodes.new("ShaderNodeMath")
|
||||
positive.operation = "GREATER_THAN"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user