Add tests and implement boundary smoothing and cutout functionality
- Introduced tests for boundary smoothing in `test_boundary_smoothing.py`, verifying that smoothing reduces stair-step artifacts while preserving boundary integrity. - Added tests for cutout boundary behavior in `test_cutout_boundary.py`, ensuring proper handling of boundary triangles and normal blending. - Created `test_temporary_attributes.py` to check the cleanup of temporary attributes across depth modifier branches, ensuring no warnings are raised. - Implemented point culling functionality in `test_validity.py`, confirming that invalid points are removed without affecting adjacent geometry. - Developed boundary smoothing logic in `boundary_smoothing.py`, allowing for depth smoothing while maintaining edge integrity. - Added cutout boundary management in `cutout_boundary.py`, including functions for removing boundary triangles and tapering thickness profiles. - Implemented validity checks in `validity.py` to ensure proper deletion of invalid vertices and associated wire edges.
This commit is contained in:
@@ -82,7 +82,8 @@ def _localize_peer_inputs(node_group, ancestors, children):
|
||||
if source.bl_idname == "NodeReroute":
|
||||
source["arrangement_input"] = True
|
||||
continue
|
||||
if source.bl_idname != "NodeGroupInput" and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream:
|
||||
# Zone exits keep their own lane when a later peer merge is placed.
|
||||
if source.bl_idname not in {"NodeGroupInput", "GeometryNodeRepeatOutput"} and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream:
|
||||
continue
|
||||
port = node_group.nodes.new("NodeReroute")
|
||||
port["arrangement_input"] = True
|
||||
@@ -262,9 +263,9 @@ def _place_geometry_paths(paths, geometry_x, sizes, order):
|
||||
weights /= weights.max()
|
||||
constraints = []
|
||||
if equalities:
|
||||
constraints.append(LinearConstraint(np.array(equalities), centers, centers))
|
||||
constraints.append(LinearConstraint(np.array(equalities), np.array(centers) / pitch, np.array(centers) / pitch))
|
||||
if inequalities:
|
||||
constraints.append(LinearConstraint(np.array(inequalities), separations, np.inf))
|
||||
constraints.append(LinearConstraint(np.array(inequalities), np.array(separations) / pitch, np.inf))
|
||||
compact = []
|
||||
for node in order:
|
||||
if node not in path_of:
|
||||
@@ -279,14 +280,14 @@ def _place_geometry_paths(paths, geometry_x, sizes, order):
|
||||
matrix = np.array(compact)
|
||||
metric += matrix.T @ matrix * 4
|
||||
solution = minimize(lambda values: values @ metric @ values,
|
||||
np.array([-rows[index] * pitch for index in range(len(paths))], dtype=float),
|
||||
np.array([-rows[index] for index in range(len(paths))], dtype=float),
|
||||
jac=lambda values: 2 * metric @ values, constraints=constraints,
|
||||
method="SLSQP", options={"ftol": 1e-5, "maxiter": 300})
|
||||
method="SLSQP", options={"ftol": 1e-12, "maxiter": 300})
|
||||
if not solution.success:
|
||||
raise ValueError(f"Geometry lane constraints could not be placed: {solution.message}")
|
||||
for index, path in enumerate(paths):
|
||||
for node in path:
|
||||
positions[node] = (positions[node][0], float(solution.x[index]))
|
||||
positions[node] = (positions[node][0], float(solution.x[index] * pitch))
|
||||
for node in order:
|
||||
if node not in positions:
|
||||
continue
|
||||
@@ -383,7 +384,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):
|
||||
def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space, horizontal=None, *, allow_opposite=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)
|
||||
@@ -394,6 +395,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
axis_bottom = axis_top - sizes[output].height
|
||||
core_left = min(positions[node][0] for node in core)
|
||||
side = 1 if floor > axis_top else -1 if ceiling < axis_bottom else 0
|
||||
if allow_opposite:
|
||||
side = 0
|
||||
desired = right - width
|
||||
front_allowed = not horizontal and all(link.from_node in local
|
||||
for node in local for socket in node.inputs for link in socket.links)
|
||||
@@ -433,6 +436,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
limits[0], limits[1] + height,
|
||||
*(top + BRANCH_GAP + height for _, _, top, _ in occupied),
|
||||
*(bottom - BRANCH_GAP for _, _, _, bottom in occupied)}
|
||||
tops.update(top + BRANCH_GAP + height for _, boxes in space.exits for _, _, top, _ in boxes)
|
||||
tops.update(bottom - BRANCH_GAP for _, boxes in space.exits for _, _, _, bottom in boxes)
|
||||
if front_allowed:
|
||||
tops.add(baseline + height / 2)
|
||||
for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)):
|
||||
@@ -443,6 +448,9 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
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)
|
||||
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
|
||||
in_front = placed_left + width + BRANCH_GAP <= core_left
|
||||
@@ -478,6 +486,11 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
|
||||
best = candidate
|
||||
best_exits, best_routes = exits, routes
|
||||
if best is None:
|
||||
if not allow_opposite:
|
||||
return _place_field_region(
|
||||
local, width, height, destinations, owners, positions, sizes,
|
||||
occupied, limits, core, trunks, space, horizontal, allow_opposite=True,
|
||||
)
|
||||
raise ValueError("The field region does not fit its reserved scope")
|
||||
_, _, _, left, top = best
|
||||
for node, (x, y) in local.items():
|
||||
@@ -645,7 +658,7 @@ def _route_references(node_group, sizes, positions, regions, paths, trunks, prot
|
||||
_replace_link(node_group, link, trunk)
|
||||
|
||||
|
||||
def _zone_width(entry, interior, layout, sizes):
|
||||
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 = []
|
||||
@@ -654,8 +667,8 @@ def _zone_width(entry, interior, layout, sizes):
|
||||
for node, (x, y) in layout.positions.items():
|
||||
for socket in node.inputs:
|
||||
for link in socket.links:
|
||||
if link.from_node == entry:
|
||||
delta = top + y - sizes[node].sockets[socket] + sizes[entry].sockets[link.from_socket]
|
||||
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:
|
||||
@@ -663,7 +676,7 @@ def _zone_width(entry, interior, layout, sizes):
|
||||
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)
|
||||
return max(spans) + BRANCH_GAP * 2
|
||||
|
||||
|
||||
def arrange_nodes(node_group):
|
||||
@@ -699,7 +712,7 @@ def arrange_nodes(node_group):
|
||||
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(entry, interior, layout, sizes)
|
||||
span = _zone_width(interior, layout, sizes)
|
||||
for node in core:
|
||||
if entry in geometry_parents[node]:
|
||||
spacing[node] = max(spacing.get(node, 0), span)
|
||||
@@ -724,6 +737,7 @@ def arrange_nodes(node_group):
|
||||
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)
|
||||
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:
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"FunctionNodeSeparateColor": 1,
|
||||
"GeometryNodeAccumulateField": 2,
|
||||
"GeometryNodeAttributeStatistic": 2,
|
||||
"GeometryNodeAttributeDomainSize": 1,
|
||||
"GeometryNodeBlurAttribute": 1,
|
||||
"GeometryNodeBoundBox": 0,
|
||||
"GeometryNodeCaptureAttribute": 3,
|
||||
"GeometryNodeCornersOfEdge": 0,
|
||||
"GeometryNodeCornersOfFace": 0,
|
||||
"GeometryNodeDeleteGeometry": 2,
|
||||
|
||||
@@ -40,7 +40,7 @@ def estimate_node_dimensions(node):
|
||||
|
||||
# Conservative reserves include spacing between UI sections.
|
||||
controls = RULES["controls"][node.bl_idname]
|
||||
for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "capture_items", "enum_items"):
|
||||
for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "enum_items"):
|
||||
controls += len(getattr(node, collection, ()))
|
||||
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
|
||||
controls += sum(item.item_type == "PANEL" for item in node.node_tree.interface.items_tree)
|
||||
|
||||
Reference in New Issue
Block a user