diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index e285dddb..dd5ac55e 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -417,10 +417,13 @@ def _gmsh_fill_2d(Xall, ring, chain, holes=()): The ring — the cavity boundary, anticlockwise — goes in as a discrete curve carrying its existing segmentation; the surface's chain as a second - discrete curve embedded in the plane surface. A chain end that IS a ring - vertex (a crossing, or an end on the wall) is expressed by having the - chain's elements reference the ring's own node tag — no duplicate node, no - snapping — and a free end is a tip, gmsh's ordinary free-end embed. + discrete curve embedded in the plane surface. ``chain`` is one polyline + of ``Xall`` indices or a sequence of polylines (a trace crossing a + collar piece in several runs), each its own discrete curve. A chain end + that IS a ring vertex (a crossing, or an end on the wall) is expressed + by having the chain's elements reference the ring's own node tag — no + duplicate node, no snapping — and a free end is a tip, gmsh's ordinary + free-end embed. ``holes`` are further closed loops of ``Xall`` indices excluded from the fill — the 2-D thin volume's skin, meshed elsewhere and sewn on — each a @@ -437,9 +440,15 @@ def _gmsh_fill_2d(Xall, ring, chain, holes=()): import gmsh ring = [int(v) for v in ring] - chain = [int(v) for v in (chain if chain is not None else [])] + if chain is None or not len(chain): + chains = [] + elif isinstance(chain[0], (list, tuple, np.ndarray)): + chains = [[int(v) for v in c] for c in chain] + else: + chains = [[int(v) for v in chain]] + flat_chain = [v for c in chains for v in c] holes = [[int(v) for v in loop] for loop in holes] - if len(set(chain)) != len(chain): + if len(set(flat_chain)) != len(flat_chain): raise RuntimeError("the surface's chain repeats a vertex") tag_of = {v: i + 1 for i, v in enumerate(ring)} nxt = len(ring) + 1 @@ -452,7 +461,7 @@ def _gmsh_fill_2d(Xall, ring, chain, holes=()): tag_of[v] = nxt hole_nodes.append(v) nxt += 1 - interior = [v for v in chain if v not in tag_of] + interior = [v for v in flat_chain if v not in tag_of] for v in interior: tag_of[v] = nxt nxt += 1 @@ -480,32 +489,35 @@ def discrete_loop(loop_verts): ring_tag = discrete_loop(ring) hole_tags = [discrete_loop(loop) for loop in holes] - line_tag = None - if chain: + chain_tags = [] + added = set(ring) | set(hole_nodes) + for c in chains: line_tag = gmsh.model.addDiscreteEntity(1) - if interior: + own = [v for v in c if v not in added] + added.update(own) + if own: gmsh.model.mesh.addNodes( - 1, line_tag, [tag_of[v] for v in interior], - np.column_stack([Xall[interior], - np.zeros(len(interior))]) + 1, line_tag, [tag_of[v] for v in own], + np.column_stack([Xall[own], np.zeros(len(own))]) .reshape(-1).tolist()) cseg = np.array([[tag_of[a], tag_of[b]] - for a, b in zip(chain[:-1], chain[1:])], + for a, b in zip(c[:-1], c[1:])], dtype=np.int64) gmsh.model.mesh.addElementsByType(line_tag, 1, [], cseg.reshape(-1).tolist()) + chain_tags.append(line_tag) loops = [gmsh.model.geo.addCurveLoop([ring_tag])] loops += [gmsh.model.geo.addCurveLoop([t]) for t in hole_tags] surf = gmsh.model.geo.addPlaneSurface(loops) gmsh.model.geo.synchronize() - if line_tag is not None: - gmsh.model.mesh.embed(1, [line_tag], 2, surf) + if chain_tags: + gmsh.model.mesh.embed(1, chain_tags, 2, surf) # Sizes bracketing what is already there: fine enough to accept the # chain's own spacing, coarse enough not to refine the cavity beyond # the surviving mesh around it. - constrained = (ring + ring[:1], chain, + constrained = (ring + ring[:1], *chains, *[loop + loop[:1] for loop in holes]) lengths = np.concatenate( [np.linalg.norm(np.diff(Xall[c], axis=0), axis=1) @@ -546,7 +558,8 @@ def discrete_loop(loop_verts): for e in ((int(a), int(b)), (int(b), int(c)), (int(c), int(a))): edges.add((min(e), max(e))) wanted = list(zip(ring, ring[1:] + ring[:1])) - wanted += list(zip(chain, chain[1:])) + for c in chains: + wanted += list(zip(c, c[1:])) for loop in holes: wanted += list(zip(loop, loop[1:] + loop[:1])) missing = [(a, b) for a, b in wanted @@ -1384,6 +1397,38 @@ def _sheet_distance(X, pts, tris): return best +def _nearest_facet(X, pts, tris): + """``(distance, facet index)`` from each point of ``X`` to a + triangulated sheet — :func:`_sheet_distance` keeping the argmin, for + when the answer is WHICH facet a point lies on, not how far it is.""" + best = np.full(len(X), np.inf) + which = np.zeros(len(X), dtype=np.int64) + for k, t in enumerate(tris): + A, B, C = pts[t[0]], pts[t[1]], pts[t[2]] + ab, ac = B - A, C - A + n = np.cross(ab, ac) + nn = float(n @ n) + rel = X - A + d00, d01, d11 = float(ab @ ab), float(ab @ ac), float(ac @ ac) + d20, d21 = rel @ ab, rel @ ac + denom = d00 * d11 - d01 * d01 + v = (d11 * d20 - d01 * d21) / denom + w = (d00 * d21 - d01 * d20) / denom + inside = (v >= 0.0) & (w >= 0.0) & (v + w <= 1.0) + d_plane = np.abs(rel @ n) / np.sqrt(nn) + d_edge = np.full(len(X), np.inf) + for P, Q in ((A, B), (B, C), (C, A)): + e = Q - P + u = np.clip(((X - P) @ e) / float(e @ e), 0.0, 1.0) + d_edge = np.minimum( + d_edge, np.linalg.norm(X - (P + u[:, None] * e), axis=1)) + d = np.where(inside, d_plane, d_edge) + closer = d < best + best[closer] = d[closer] + which[closer] = k + return best, which + + def _propagate_vertex(dm, chart_values, mpi_op, np_combine): """Reconcile a chart-length per-point array over the point star-forest. @@ -1529,7 +1574,7 @@ def star_of_marked(m): def _carve_cavity_3d(dm, X, cells, sheet_pts, sheet_tris, clearance, held_cells, h_vertex, on_wall, shared_chart, - open_wall=None): + open_deletable=None, open_near=None): """Victims, dropped tets and the closed cavity shell around the sheet. The same two-part rule as 2-D — vertices within the clearance go, and any @@ -1541,19 +1586,20 @@ def _carve_cavity_3d(dm, X, cells, sheet_pts, sheet_tris, clearance, guarantees (and this function asserts) that the whole region is interior to this rank — the gather's contract. - ``open_wall = (axis, value)`` is the OUTCROP bowl: wall vertices lying - in that plane may be victims (the cap over the cavity is remeshed), and - the shell may open there — the wall faces of dropped cells come back as - ``cap_faces`` for the caller to pre-mesh. Returns + An OUTCROP passes its frame rule as two vertex masks + (:func:`_outcrop_frame_3d`, the same contract as + :func:`_carve_around_volume_3d`): ``open_deletable`` — wall vertices the + carve may take — and ``open_near`` — wall vertices near the outcrop, + where the cavity may open into cap faces; the wall faces of dropped + cells come back as ``cap_faces`` for the caller to pre-mesh. Returns ``(victims, drop_ids, shell, cap_faces)``. """ from underworld3.utilities.edge_split import cell_diameters h_cell = cell_diameters(dm) d_sheet = _sheet_distance(X, sheet_pts, sheet_tris) - on_open = np.zeros(len(X), dtype=bool) - if open_wall is not None: - on_open = X[:, open_wall[0]] == open_wall[1] + on_open = (open_deletable if open_deletable is not None + else np.zeros(len(X), dtype=bool)) held_vertex = np.zeros(len(X), dtype=bool) if held_cells: @@ -1612,18 +1658,22 @@ def _carve_cavity_3d(dm, X, cells, sheet_pts, sheet_tris, clearance, shell, cap_faces, drop = _closed_shell_3d( dm, X, cells, drop, victim, held_cells, shared_chart, "sheet", - open_wall=open_wall) + open_vertex=open_near) # The straddle rule — or the shell growth — can drop the whole star of # a vertex that is NOT a victim; such a vertex is on no shell face and # would come through the rebuild as an ISOLATED point (global Euler 2, # not 1 — the measured defect class). Every surviving vertex must have - # a surviving cell. + # a surviving cell — except a PROTECTED wall vertex near the outcrop, + # which is a COLLAR node: the cap is re-triangulated through it and the + # fill's tets reference it (the volume carve's measured rule). referenced = np.zeros(len(X), dtype=bool) if (~drop).any(): referenced[cells[~drop].ravel()] = True orphan = ~referenced & ~victim - if orphan[on_wall & ~on_open].any(): + if open_near is not None: + orphan &= ~(orphan & on_wall & ~on_open & open_near) + if (orphan & on_wall & ~on_open).any(): raise RuntimeError( "the sheet's cavity would strand a domain-wall vertex; the sheet " "must be interior, with clearance to spare") @@ -1631,87 +1681,273 @@ def _carve_cavity_3d(dm, X, cells, sheet_pts, sheet_tris, clearance, return np.flatnonzero(victim), np.flatnonzero(drop), shell, cap_faces -def _clip_sheet_to_box(pts, tris, lo, hi): - """Clip a triangulated sheet to the axis-aligned box ``[lo, hi]``. +def _orient_boundary_complex(verts, tris): + """Orient a closed boundary complex so every facet normal points OUT of + the domain. - The specify-long contract (ruling, 2026-08-11): fault surfaces are - defined generously PAST the domain and prep trims them. Each triangle is - Sutherland–Hodgman-clipped against the six half-spaces; cut points snap - exactly onto the plane, and a cut point is computed from its edge's - endpoints in a canonical order so the two triangles sharing the edge - produce the bitwise-identical point (the dedup relies on it). Returns - ``(pts, tris, on_plane)`` with ``on_plane[k] = 2*axis + side`` for a - point lying exactly on a wall plane, else ``-1``. + The gathered complex carries no orientation, so it is propagated by + shared-edge parity within each connected component, and each component's + global sign comes from its enclosed signed volume: the component + enclosing the greatest volume is the outer boundary (normals away from + the domain, signed volume positive), every other component bounds an + interior cavity — a spherical shell's inner surface — whose + domain-outward normals point INTO the cavity, so its signed volume is + made negative. Returns the reoriented ``(nf, 3)`` triangle array. + """ + tris = [list(map(int, t)) for t in np.asarray(tris)] + edge_owner = {} + for k, (a, b, c) in enumerate(tris): + for e in ((a, b), (b, c), (c, a)): + edge_owner.setdefault(frozenset(e), []).append(k) + seen = np.zeros(len(tris), dtype=bool) + comp = np.full(len(tris), -1, dtype=np.int64) + n_comp = 0 + for start in range(len(tris)): + if seen[start]: + continue + stack = [start] + seen[start] = True + comp[start] = n_comp + while stack: + k = stack.pop() + a, b, c = tris[k] + for e in ((a, b), (b, c), (c, a)): + for j in edge_owner[frozenset(e)]: + if j == k or seen[j]: + continue + ja, jb, jc = tris[j] + # Consistent orientation: the shared edge must appear in + # OPPOSITE directions in its two facets. + if e in {(ja, jb), (jb, jc), (jc, ja)}: + tris[j] = [ja, jc, jb] + seen[j] = True + comp[j] = n_comp + stack.append(j) + n_comp += 1 + T = np.asarray(tris, dtype=np.int64) + P = verts[T] + vol6 = np.einsum("ij,ij->i", np.cross(P[:, 0], P[:, 1]), P[:, 2]) + comp_vol = np.array([vol6[comp == c].sum() for c in range(n_comp)]) + outer = int(np.argmax(np.abs(comp_vol))) + for c in range(n_comp): + if (comp_vol[c] > 0.0) != (c == outer): + rows = comp == c + T[rows] = T[rows][:, [0, 2, 1]] + return T + + +def _boundary_signed_distance(X, verts, tris): + """Signed distance to an ORIENTED closed complex: NEGATIVE inside. + + The sign comes from the nearest facet's outward normal; a tie at a + crease is broken by whichever facet is truly nearest, which is + unambiguous for query points off the surface by more than rounding. + """ + d, at = _nearest_facet(X, verts, tris) + T = verts[np.asarray(tris)[at]] + n = np.cross(T[:, 1] - T[:, 0], T[:, 2] - T[:, 0]) + n = n / np.linalg.norm(n, axis=1)[:, None] + side = np.einsum("ij,ij->i", X - T[:, 0], n) + return np.where(side >= 0.0, d, -d) + + +def _concave_crease_facets(verts, tris): + """Mask of facets of an ORIENTED complex incident to a locally CONCAVE + crease. + + A crease between two facets is concave when either facet's far vertex + lies on the OUTWARD side of the other's plane: every crease of an inner + boundary is, no crease of a convex outer boundary is. The sequential + plane clip is exact only across convex creases, so a crossing that can + touch a concave one is refused rather than mis-cut. The threshold sits + at rounding — a genuinely concave crease's offset is O(h^2/R), many + orders above it. """ - lo = np.asarray(lo, dtype=float) - hi = np.asarray(hi, dtype=float) - pts = np.asarray(pts, dtype=float) - key_of = {} - out_pts = [] - corner_ids = {} - - def intern(p): - k = tuple(float(x) for x in p) - if k not in key_of: - key_of[k] = len(out_pts) - out_pts.append(np.asarray(p, dtype=float)) - return key_of[k] - - def cut(a_id, b_id, A, B, axis, value): - # Canonical order so both triangles sharing the edge agree bitwise. - if (a_id, tuple(A)) > (b_id, tuple(B)): - a_id, b_id, A, B = b_id, a_id, B, A - t = (value - A[axis]) / (B[axis] - A[axis]) - p = A + t * (B - A) - p[axis] = value # exactly on the plane - return p + tris = np.asarray(tris, dtype=np.int64) + T = verts[tris] + n = np.cross(T[:, 1] - T[:, 0], T[:, 2] - T[:, 0]) + n = n / np.linalg.norm(n, axis=1)[:, None] + concave = np.zeros(len(tris), dtype=bool) + edge_first = {} + for t, tri in enumerate(tris): + opp = {frozenset((int(tri[0]), int(tri[1]))): int(tri[2]), + frozenset((int(tri[1]), int(tri[2]))): int(tri[0]), + frozenset((int(tri[2]), int(tri[0]))): int(tri[1])} + for e, far in opp.items(): + if e not in edge_first: + edge_first[e] = (t, far) + continue + s, far_s = edge_first[e] + ea, eb = (int(v) for v in e) + scale = float(np.linalg.norm(verts[eb] - verts[ea])) + if (float((verts[far] - T[s, 0]) @ n[s]) > 1e-12 * scale + or float((verts[far_s] - T[t, 0]) @ n[t]) + > 1e-12 * scale): + concave[t] = concave[s] = True + return concave + + +def _clip_sheet_to_boundary(pts, tris, dom_verts, dom_tris, tol=1e-12): + """Clip a triangulated sheet against the mesh's OWN boundary complex. + + The specify-long contract (ruling, 2026-08-11) on a general boundary: + fault surfaces are defined generously PAST the domain and prep trims + them. Sheet vertices classify by signed distance to the oriented + complex; a mixed triangle is cut sequentially by the planes of the + boundary facets it can cross, keeping the inside — exact where the + crossed boundary is locally CONVEX (a box wall, a sphere's outer + surface), which covers the outcrop cases. A crossing that can touch a + locally concave crease (an inner boundary) needs the true polyline cut + and is refused. The cutting planes are the COPLANAR REGIONS' + (:func:`_coplanar_regions`) — a box wall cuts as one plane however it + is faceted — and cut nodes are computed from canonically ordered edge + endpoints and interned by the ``(edge, region)`` identity, so the two + triangles sharing a cut edge produce the SAME node and the clipped + sheet stays conforming; each cut node lands exactly on its region's + plane. Uncut geometry is preserved verbatim. Every kept node is gated + inside the domain on exit — a violation means the crossing was not + locally convex at the sheet's scale and is a refusal, not a tolerance. + Returns ``(pts, tris)``. + """ + pts_in = np.asarray(pts, dtype=float) + tris_in = np.asarray(tris, dtype=np.int64) + oriented = _orient_boundary_complex(dom_verts, dom_tris) + sd = _boundary_signed_distance(pts_in, dom_verts, oriented) + inside = sd < tol + if inside.all(): + return pts_in, tris_in + + DT = dom_verts[oriented] + f_lo = DT.min(axis=1) + f_hi = DT.max(axis=1) + concave = _concave_crease_facets(dom_verts, oriented) + # The cutting planes are the coplanar regions': two coplanar facets of + # one wall must cut with the IDENTICAL plane, or the two triangles + # sharing a cut edge key their cut by different facets and the sheet + # tears along a duplicated node (measured on the box top wall). + region, planes_of = _coplanar_regions(dom_verts, oriented) + + out_pts = [p for p in pts_in] + cut_id = {} + + def cut_on(key, A, B, r): + # Interned by identity, endpoints canonical: the two triangles + # sharing a cut compute the bitwise-identical point, ONE node, and + # the clipped sheet stays conforming. + if key not in cut_id: + anchor, nrm = planes_of[int(r)] + oa = float((A - anchor) @ nrm) + ob = float((B - anchor) @ nrm) + p = A + (oa / (oa - ob)) * (B - A) + p -= float((p - anchor) @ nrm) * nrm # exactly on the plane + cut_id[key] = len(out_pts) + out_pts.append(p) + return cut_id[key] + + def edge_key(a, b): + return (a, b) if a < b else (b, a) out_tris = [] - for tri in tris: - # The polygon starts as the triangle, with vertex identity carried - # so shared cut edges intern to the same point. - poly = [(int(v), pts[int(v)].copy()) for v in tri] - for axis in range(3): - for side, value, keep in ((0, lo[axis], 1.0), (1, hi[axis], -1.0)): - if not poly: - break - nxt = [] - for i in range(len(poly)): - (ai, A), (bi, B) = poly[i], poly[(i + 1) % len(poly)] - a_in = keep * (A[axis] - value) >= 0.0 - b_in = keep * (B[axis] - value) >= 0.0 - if a_in: - nxt.append((ai, A)) - if a_in != b_in: - p = cut(ai, bi, A, B, axis, value) - nxt.append((("cut", min(ai, bi), max(ai, bi), - axis, side), p)) - poly = nxt + for tri in tris_in: + flags = inside[tri] + if flags.all(): + out_tris.append([int(v) for v in tri]) + continue + if not flags.any(): + continue + # The facet planes this triangle can cross: bbox overlap. + P3 = pts_in[tri] + t_lo = P3.min(axis=0) - tol + t_hi = P3.max(axis=0) + tol + near = np.flatnonzero(((f_lo <= t_hi) & (f_hi >= t_lo)).all(axis=1)) + if concave[near].any(): + raise NotImplementedError( + "the sheet crosses the domain boundary at a locally " + "concave crease (an inner boundary); the sequential plane " + "clip is exact only for convex crossings and the polyline " + "cut is not built.") + # Each polygon vertex carries the ORIGINAL sheet edges it lies on: + # a cut on a triangle side is re-derived from that side's original + # endpoints and interned by (edge, region), so the neighbouring + # triangle — whose side may be truncated differently — produces the + # bitwise-identical node (a sub-segment interpolation differs by + # rounding, measured as ~1e-17 duplicate pairs on the sphere). A + # cut on a CHORD (a previous cut's trace across the interior, i.e. + # a crease crossing) is triangle-local and keys by its endpoints. + a0, b0, c0 = (int(v) for v in tri) + sides = {a0: {edge_key(a0, b0), edge_key(a0, c0)}, + b0: {edge_key(a0, b0), edge_key(b0, c0)}, + c0: {edge_key(a0, c0), edge_key(b0, c0)}} + poly = [pts_in[v].copy() for v in (a0, b0, c0)] + rows = [a0, b0, c0] + srcs = [sides[a0], sides[b0], sides[c0]] + for r in sorted({int(region[f]) for f in near}): + anchor, nrm = planes_of[r] + offs = [float((q - anchor) @ nrm) for q in poly] + if all(o > -tol for o in offs): # wholly outside (or on) + poly, rows, srcs = [], [], [] + break + if all(o < tol for o in offs): # wholly inside: no cut + continue + new_poly, new_rows, new_srcs = [], [], [] + m = len(poly) + for i in range(m): + j = (i + 1) % m + oi, oj = offs[i], offs[j] + if oi < tol: + new_poly.append(poly[i]) + new_rows.append(rows[i]) + new_srcs.append(srcs[i]) + if (oi < -tol and oj > tol) or (oi > tol and oj < -tol): + common = srcs[i] & srcs[j] + if common: + e, = common + w = cut_on((e[0], e[1], r), pts_in[e[0]], + pts_in[e[1]], r) + src = {e} + else: + lo_r, hi_r = edge_key(rows[i], rows[j]) + w = cut_on(('x', lo_r, hi_r, r), + np.asarray(out_pts[lo_r]), + np.asarray(out_pts[hi_r]), r) + src = set() + new_poly.append(np.asarray(out_pts[w])) + new_rows.append(w) + new_srcs.append(src) + poly, rows, srcs = new_poly, new_rows, new_srcs if len(poly) < 3: continue - ids = [intern(p) for _tag, p in poly] - for k in range(1, len(ids) - 1): - if len({ids[0], ids[k], ids[k + 1]}) == 3: - out_tris.append((ids[0], ids[k], ids[k + 1])) + for k in range(1, len(rows) - 1): + if len({rows[0], rows[k], rows[k + 1]}) == 3: + out_tris.append([rows[0], rows[k], rows[k + 1]]) if not out_tris: raise ValueError("the sheet lies entirely outside the domain") - out_pts = np.array(out_pts) - on_plane = np.full(len(out_pts), -1, dtype=np.int64) - for axis in range(3): - on_plane[out_pts[:, axis] == lo[axis]] = 2 * axis - on_plane[out_pts[:, axis] == hi[axis]] = 2 * axis + 1 - return out_pts, np.array(out_tris, dtype=np.int64), on_plane - - -def _outcrop_chain(pts, tris, on_plane): - """The sheet's boundary polyline on ONE wall plane, ordered. - - Boundary edges of the clipped sheet whose BOTH ends lie on the same - plane form the outcrop. One open chain on one wall is the supported - case; anything else (two walls, a closed loop, several chains) is - refused with the reason — box-edge outcrops are a later phase. - Returns ``(chain_point_ids, wall_code)`` or ``(None, None)``. + used = sorted({v for t in out_tris for v in t}) + remap = {v: i for i, v in enumerate(used)} + new_pts = np.array([out_pts[v] for v in used]) + new_tris = np.array([[remap[v] for v in t] for t in out_tris], + dtype=np.int64) + sd_out = _boundary_signed_distance(new_pts, dom_verts, oriented) + if (sd_out > 1e-9).any(): + raise RuntimeError( + "the clip kept a sheet node outside the domain; the boundary " + "is not locally convex at the crossing's scale, or the " + "crossing spans facets the clip did not see. A defect, not a " + "tolerance.") + return new_pts, new_tris + + +def _outcrop_chain(pts, tris, dom_verts, dom_tris, tol=1e-9): + """The clipped sheet's boundary polyline ON the domain boundary, ordered. + + Boundary edges of the clipped sheet whose ends AND midpoint lie on the + domain's boundary complex form the outcrop trace — the membership rule + of :func:`_split_skin_trace` one dimension down, metric but unambiguous: + the clip put cut nodes ON the crossed facets, while an interior rim node + is a sheet spacing away. One open chain is the supported case; multiple + chains or a closed loop are refused with the reason. Returns the ordered + chain of point rows, or ``None``. """ from collections import Counter @@ -1719,18 +1955,19 @@ def _outcrop_chain(pts, tris, on_plane): for a, b, c in tris: for e in ((int(a), int(b)), (int(b), int(c)), (int(c), int(a))): edge_count[tuple(sorted(e))] += 1 - walls = set(int(w) for w in on_plane[on_plane >= 0]) - if not walls: - return None, None - if len(walls) > 1: - raise NotImplementedError( - "the sheet meets more than one domain wall; box-edge outcrops " - "are not built. Clip the sheet to a single wall.") - wall = walls.pop() - chain_edges = [e for e, k in edge_count.items() if k == 1 - and on_plane[e[0]] == wall and on_plane[e[1]] == wall] + boundary_edges = [e for e, k in edge_count.items() if k == 1] + if not boundary_edges: + return None + on_v = _sheet_distance(pts, dom_verts, dom_tris) < tol + both_on = [e for e in boundary_edges if on_v[e[0]] and on_v[e[1]]] + if not both_on: + return None + mids = 0.5 * (pts[[e[0] for e in both_on]] + + pts[[e[1] for e in both_on]]) + mid_on = _sheet_distance(mids, dom_verts, dom_tris) < tol + chain_edges = [e for e, ok in zip(both_on, mid_on) if ok] if not chain_edges: - return None, None + return None adj = {} for a, b in chain_edges: adj.setdefault(a, []).append(b) @@ -1746,11 +1983,11 @@ def _outcrop_chain(pts, tris, on_plane): nxt = ns[0] if ns[0] != prev else ns[1] chain.append(nxt) prev, cur = cur, nxt - return chain, wall + return chain def _closed_shell_3d(dm, X, cells, drop, victim, held_cells, shared_chart, - noun, open_wall=None): + noun, open_vertex=None): """The cavity shell over ``drop``, GROWN at pinch edges until manifold. Shared by every 3-D carve. The union of victim stars around any object @@ -1761,16 +1998,18 @@ def _closed_shell_3d(dm, X, cells, drop, victim, held_cells, shared_chart, original mesh did not). Growing the drop at every non-manifold edge merges the wedges; dropping more cells only enlarges the fill. - ``open_wall = (axis, value)`` lets the cavity OPEN onto one flat wall — - the outcrop bowl. A dropped cell's wall face lying in that plane becomes - a CAP face (returned separately; the caller pre-meshes the cap); any - other wall contact still refuses. The manifold check runs on shell - ∪ cap, which together must close. + ``open_vertex`` (a vertex mask) lets the cavity OPEN onto the boundary + near an outcrop — the bowl. A dropped cell's wall face whose vertices + all carry the mask becomes a CAP face (returned separately; the caller + pre-meshes the cap); any other wall contact still refuses. The mask is + the caller's frame rule: the wall plane for a box-framed sheet, the + band-touched coplanar regions for a general zone. The manifold check + runs on shell ∪ cap, which together must close. Refusals stay per-object worded via ``noun``; a wall or seam contact and a growth that would need a held cell are refusals, not growth. Returns ``(shell, cap_faces, drop)`` with ``drop`` possibly grown; ``cap_faces`` - is empty when ``open_wall`` is None. + is empty when ``open_vertex`` is None. """ from collections import Counter @@ -1805,8 +2044,8 @@ def _closed_shell_3d(dm, X, cells, drop, victim, held_cells, shared_chart, "after the gather; the region marking under-reached. " "A defect, not a configuration error.") verts = face_verts[f] - if open_wall is not None and all( - X[v][open_wall[0]] == open_wall[1] for v in verts): + if open_vertex is not None and all( + open_vertex[v] for v in verts): cap_faces.append((f, verts)) continue raise RuntimeError( @@ -2278,9 +2517,15 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, A 3-D simplex mesh, serial or distributed. **Not modified.** points, triangles : array_like The sheet: ``(N, 3)`` vertices and ``(M, 3)`` triangle indices — the - form :class:`~underworld3.meshing.FaultSurface` carries. Interior to - the domain, non-self-intersecting, at least a cell from any embedded - surface. + form :class:`~underworld3.meshing.FaultSurface` carries. + Non-self-intersecting, at least a cell from any embedded surface. + The sheet may run PAST the domain: it is clipped against the mesh's + own boundary, and a trace left ON the boundary becomes the sheet's + OUTCROP — the trace chain's edges carry ``