From 650aaaf037c689532086f035be8fb9c31c783894 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Sat, 22 Aug 2026 10:56:29 +0100 Subject: [PATCH] Port MakeBarBetweenNodesF and Julian cell-split refine. Walk right-hand cells via GetBarBackRight, pick the worst avg-normal/avg-point planar residual, and split with the chord that minimises the worse child residual. Assisted-by: Cursor --- barmesh/README.md | 2 +- barmesh/barmesh.gd | 64 +++++++++++++++ barmesh/draw.gd | 29 +++++++ barmesh/subdiv.gd | 193 ++++++++++++++++++++++++++++++++++----------- 4 files changed, 239 insertions(+), 49 deletions(-) diff --git a/barmesh/README.md b/barmesh/README.md index 5900a0a..a8a65e0 100644 --- a/barmesh/README.md +++ b/barmesh/README.md @@ -9,6 +9,6 @@ Julian’s 3-axis tool-surface mesh. **All geometry here is Z-up** (CAD). | `tool_contact.gd` | Ball-nose drop along **tool axis −Z** from a point above | | `draw.gd` | ImmediateMesh; **only** place that converts CAD Z-up → Godot Y-up `(x, z, y)` | -**Conditions** live in `subdiv.gd` (shared ε / stepover / a). Split a live `Bar` with `InsertNodeIntoBarF` when **XY > epsilon** (default 0.01 mm) **and** (**3D length > stepover** 1 mm **or** contact-normal angle **> a** 15°). Cell splits (`MakeBarBetweenNodesF`): (1) **coplanar_tol** = max ⊥ distance to best-fit plane (3 pts → 0); (2) **max pairwise contact-normal angle** (same `a`); connect opposite-side nodes that are not near-colinear and that avoid slivers. Insertion XY defaults to midpoint bisection; optional plane-intersect guess brackets z/normal discontinuities along the bar. +**Conditions** live in `subdiv.gd` (shared ε / stepover / a). Split a live `Bar` with `InsertNodeIntoBarF` when **XY > epsilon** (default 0.01 mm) **and** (**3D length > stepover** 1 mm **or** contact-normal angle **> a** 15°). Cell splits (`MakeBarBetweenNodesF`): walk right-hand rings via `GetBarBackRight`; pick the cell with worst residual to the plane through avg(contact points) with normal avg(contact normals); split with the chord that minimises the worse child residual. Tol: **coplanar_tol** (⊥) + max pairwise normal angle **a**. Insertion XY defaults to midpoint bisection; optional plane-intersect guess brackets z/normal discontinuities along the bar. Do not assign `Node.p` to a `Node3D.transform` without `draw.cad_to_godot`. diff --git a/barmesh/barmesh.gd b/barmesh/barmesh.gd index 414d5fe..254869c 100644 --- a/barmesh/barmesh.gd +++ b/barmesh/barmesh.gd @@ -201,6 +201,70 @@ func live_bars() -> Array: return out +## Right-hand cell ring of seed (Julian: GetBarBackRight walk). {ok, nodes, bars}. +func cell_ring_right(seed: BMBar) -> Dictionary: + if seed == null or seed.bbardeleted or seed.barforeright == null: + return {"ok": false} + var ring_bars: Array = [] + var ring_nodes: Array = [seed.nodeback] + var at: BMNode = seed.nodeback + var cur: BMBar = seed + for _i in range(1000): + ring_bars.append(cur) + var bfore := cur.nodeback == at + at = cur.get_node_fore(bfore) + if at == seed.nodeback: + if ring_nodes.size() < 3: + return {"ok": false} + return {"ok": true, "nodes": ring_nodes, "bars": ring_bars, "seed": seed} + ring_nodes.append(at) + cur = cur.get_fore_right_bl(bfore) + if cur == null or cur.bbardeleted: + return {"ok": false} + return {"ok": false} + + +func d_test_colinearity_f(node1: BMNode, bar1: BMBar, node2: BMNode, _bar2: BMBar) -> bool: + var bar1a: BMBar = bar1.get_fore_right_bl(bar1.nodefore == node1) + if bar1a == null: + return false + var lbar: BMBar = bar1a + var lnode: BMNode = bar1a.get_node_fore(bar1a.nodeback == node1) + if lnode == node2: + return true + var ref: Vector3 = bar1a.barvecN + for _i in range(1000): + if lnode == node1: + return false + lbar = lbar.get_fore_right_bl(lbar.nodefore == lnode) + if lbar == null: + return false + lnode = lbar.get_node_fore(lbar.nodeback == lnode) + if lbar.barvecN != ref and lbar.barvecN != -ref: + return false + if lnode == node2: + return true + return false + + +## Insert a bar across a cell between node1 and node2 (vendor MakeBarBetweenNodesF). +func make_bar_between_nodes_f(node1: BMNode, bar1: BMBar, node2: BMNode, bar2: BMBar) -> BMBar: + assert(not bar1.bbardeleted and not bar2.bbardeleted) + assert(node1.i < node2.i) + assert(not d_test_colinearity_f(node1, bar1, node2, bar2)) + assert(not d_test_colinearity_f(node2, bar2, node1, bar1)) + var bar1a: BMBar = bar1.get_fore_right_bl(bar1.nodefore == node1) + var bar2a: BMBar = bar2.get_fore_right_bl(bar2.nodefore == node2) + assert(bar1a != null and bar2a != null) + var newbar := BMBar.new(node1, node2) + newbar.set_fore_right_bl(false, bar1a) + newbar.set_fore_right_bl(true, bar2a) + bar1.set_fore_right_bl(bar1.nodefore == node1, newbar) + bar2.set_fore_right_bl(bar2.nodefore == node2, newbar) + bars.append(newbar) + return newbar + + func insert_node_into_bar_f(bar: BMBar, newnode: BMNode) -> BMNode: assert(newnode.p != bar.nodeback.p and newnode.p != bar.nodefore.p) assert(newnode in nodes) diff --git a/barmesh/draw.gd b/barmesh/draw.gd index 60db144..056ff2f 100644 --- a/barmesh/draw.gd +++ b/barmesh/draw.gd @@ -123,6 +123,7 @@ func _refine_barmesh(bm: BarMesh, R: float, tris: Array, z_plane: float, z_above params.epsilon_m = epsilon_mm * 0.001 params.stepover_m = stepover_mm * 0.001 params.angle_deg = angle_deg + params.coplanar_tol_m = epsilon_mm * 0.001 for _pass in range(max_refine_passes): if my_run != _run_id: return @@ -144,6 +145,34 @@ func _refine_barmesh(bm: BarMesh, R: float, tris: Array, z_plane: float, z_above _draw_barmesh(bm) if row_delay_s > 0.0: await get_tree().create_timer(row_delay_s).timeout + # Cells after bars (Julian 144): worst planar residual → MakeBarBetweenNodesF. + await _refine_cells(bm, params, my_run) + + +func _refine_cells(bm: BarMesh, params: Subdiv.Params, my_run: int) -> void: + for _pass in range(max_refine_passes): + if my_run != _run_id: + return + if bm.nodes.size() > 8000: + break + var worst: Dictionary = Subdiv.find_worst_cell_seed(bm, params) + if not bool(worst.get("ok", false)): + break + var pick: Dictionary = Subdiv.pick_cell_split_for_make_bar(worst["nodes"], worst["bars"], params) + if not bool(pick.get("ok", false)): + break + var n1: BarMesh.BMNode = pick["node1"] + var n2: BarMesh.BMNode = pick["node2"] + var b1: BarMesh.BMBar = pick["bar1"] + var b2: BarMesh.BMBar = pick["bar2"] + if b1.bbardeleted or b2.bbardeleted: + break + if bm.d_test_colinearity_f(n1, b1, n2, b2) or bm.d_test_colinearity_f(n2, b2, n1, b1): + break + bm.make_bar_between_nodes_f(n1, b1, n2, b2) + _draw_barmesh(bm) + if row_delay_s > 0.0: + await get_tree().create_timer(row_delay_s).timeout func _grid_parts(lo: float, hi: float) -> int: diff --git a/barmesh/subdiv.gd b/barmesh/subdiv.gd index ac57c5c..c91d509 100644 --- a/barmesh/subdiv.gd +++ b/barmesh/subdiv.gd @@ -42,12 +42,9 @@ static func bar_needs_split(bar: BarMesh.BMBar, params: Params) -> bool: return need_len or need_ang -## Face/cell (MakeBarBetweenNodesF) — Julian CNC 127/132: -## Prefer largest cells whose contact points stay near-coplanar; shrink XY when -## contact normals span a wide range. Connect opposite-side nodes that are not -## near-colinear and that avoid narrow slivers. -## Future (Julian 134): an extra stop-over-subdivide rule will land here as its -## own predicate so it can be tweaked without touching topology. +## Face/cell (MakeBarBetweenNodesF) — Julian CNC 127/132/144: +## Split when avg-normal/avg-point plane residual > coplanar_tol, or contact-normal +## span > a (and XY > ε). Future anti-over-subdivide rule stays separate here. static func cell_needs_split(cell_nodes: Array, params: Params) -> bool: if cell_nodes.size() < 3: return false @@ -55,65 +52,165 @@ static func cell_needs_split(cell_nodes: Array, params: Params) -> bool: var xmax := -INF var ymin := INF var ymax := -INF - var normals: Array[Vector3] = [] - var points: Array[Vector3] = [] for n in cell_nodes: var node: BarMesh.BMNode = n xmin = minf(xmin, node.p.x) xmax = maxf(xmax, node.p.x) ymin = minf(ymin, node.p.y) ymax = maxf(ymax, node.p.y) - if node.contact_normal.length_squared() > 0.25: - normals.append(node.contact_normal.normalized()) - if node.contact_point != Vector3.ZERO or node.contact_kind != BarMesh.BMNode.ContactFeature.NONE: - points.append(node.contact_point) - var dxy := maxf(xmax - xmin, ymax - ymin) - if dxy <= params.epsilon_m: + if maxf(xmax - xmin, ymax - ymin) <= params.epsilon_m: return false - # Wide normal range → keep this cell from staying large in XY. - if _normals_span_exceeds(normals, params.angle_deg): + if cell_planar_residual(cell_nodes) > params.coplanar_tol_m: return true - # Contact points not close to coplanar → subdivide. - if points.size() >= 4 and not _points_near_coplanar(points, params.coplanar_tol_m): - return true - return false + var normals: Array[Vector3] = [] + for n2 in cell_nodes: + var nd: BarMesh.BMNode = n2 + if nd.contact_normal.length_squared() > 0.25: + normals.append(nd.contact_normal.normalized()) + return _normals_span_exceeds(normals, params.angle_deg) -## Among candidate opposite-side node pairs, pick one that is not near-colinear -## with a cell edge and that maximises the smaller of the two resulting face -## areas (sliver avoidance). Returns [node_a, node_b] or empty. -static func pick_cell_split_pair(cell_nodes: Array, params: Params) -> Array: - var n: int = cell_nodes.size() - if n < 4: - return [] - var best: Array = [] - var best_score := -INF - var cos_colin := cos(deg_to_rad(maxf(180.0 - params.angle_deg, 1.0))) - for i in n: - # Opposite-ish: about halfway around the ring. - var j := (i + n / 2) % n - if j == i: +## Julian 144: plane through avg(contact_points) with normal avg(contact_normals); +## residual = max |⊥ distance|. Three points → 0 if normals define a plane. +static func cell_planar_residual(cell_nodes: Array) -> float: + if cell_nodes.size() <= 3: + return 0.0 + var c := Vector3.ZERO + var nsum := Vector3.ZERO + var n_pts := 0 + var n_nrm := 0 + for n in cell_nodes: + var node: BarMesh.BMNode = n + if node.contact_kind != BarMesh.BMNode.ContactFeature.NONE: + c += node.contact_point + n_pts += 1 + if node.contact_normal.length_squared() > 0.25: + nsum += node.contact_normal.normalized() + n_nrm += 1 + if n_pts < 3 or n_nrm < 1 or nsum.length_squared() < 1e-12: + return 0.0 + c /= float(n_pts) + var normal: Vector3 = nsum.normalized() + var worst := 0.0 + for n2 in cell_nodes: + var node2: BarMesh.BMNode = n2 + if node2.contact_kind == BarMesh.BMNode.ContactFeature.NONE: + continue + worst = maxf(worst, absf(normal.dot(node2.contact_point - c))) + return worst + + +## Unique right-hand cells (one seed bar each). Dedupes so bars > cells. +static func unique_cell_seeds(bm: BarMesh) -> Array: + var seen: Dictionary = {} + var seeds: Array = [] + for bar in bm.live_bars(): + var ring: Dictionary = bm.cell_ring_right(bar) + if not bool(ring.get("ok", false)): continue - var a: BarMesh.BMNode = cell_nodes[i] - var b: BarMesh.BMNode = cell_nodes[j] - var ab := Vector2(b.p.x - a.p.x, b.p.y - a.p.y) - if ab.length() <= params.epsilon_m: + var key := _cell_key(ring["nodes"]) + if seen.has(key): continue - var abn := ab.normalized() - # Reject if nearly colinear with either adjacent edge at a or b. - var prev_a: BarMesh.BMNode = cell_nodes[(i - 1 + n) % n] - var next_a: BarMesh.BMNode = cell_nodes[(i + 1) % n] - if _edge_dir_xy(prev_a, a).dot(abn) > cos_colin: + seen[key] = true + seeds.append(bar) + return seeds + + +## Cell farthest from its avg-normal/avg-point plane (among those needing split). +static func find_worst_cell_seed(bm: BarMesh, params: Params) -> Dictionary: + var worst: Dictionary = {"ok": false, "residual": -1.0} + for seed in unique_cell_seeds(bm): + var ring: Dictionary = bm.cell_ring_right(seed) + if not bool(ring.get("ok", false)): + continue + var nodes: Array = ring["nodes"] + if not cell_needs_split(nodes, params): continue - if _edge_dir_xy(a, next_a).dot(abn) > cos_colin: + var r: float = cell_planar_residual(nodes) + if bool(worst.get("ok", false)) and r <= float(worst["residual"]): continue - var area_score := _split_min_poly_area_xy(cell_nodes, i, j) - if area_score > best_score: - best_score = area_score - best = [a, b] + worst = { + "ok": true, + "seed": seed, + "nodes": nodes, + "bars": ring["bars"], + "residual": r, + } + return worst + + +## Pick node/bar pair for MakeBarBetweenNodesF that minimises max child planar residual. +static func pick_cell_split_for_make_bar(ring_nodes: Array, ring_bars: Array, params: Params) -> Dictionary: + var n: int = ring_nodes.size() + if n < 4 or ring_bars.size() != n: + return {"ok": false} + var best: Dictionary = {"ok": false, "score": INF} + for i in n: + for j in range(i + 2, n): + # Skip adjacent wrap (i=0,j=n-1). + if i == 0 and j == n - 1: + continue + if (j - i) < 2 or (n - (j - i)) < 2: + continue + var a: BarMesh.BMNode = ring_nodes[i] + var b: BarMesh.BMNode = ring_nodes[j] + if Vector2(b.p.x - a.p.x, b.p.y - a.p.y).length() <= params.epsilon_m: + continue + var left: Array = _ring_slice(ring_nodes, i, j) + var right: Array = _ring_slice(ring_nodes, j, i) + var score: float = maxf(cell_planar_residual(left), cell_planar_residual(right)) + # Mild sliver guard: reject tiny min face area. + if _split_min_poly_area_xy(ring_nodes, i, j) < params.epsilon_m * params.epsilon_m: + continue + if score < float(best.get("score", INF)): + var bar_a: BarMesh.BMBar = ring_bars[(i - 1 + n) % n] + var bar_b: BarMesh.BMBar = ring_bars[(j - 1 + n) % n] + var n1: BarMesh.BMNode = a + var n2: BarMesh.BMNode = b + var b1: BarMesh.BMBar = bar_a + var b2: BarMesh.BMBar = bar_b + if n2.i < n1.i: + var tn = n1 + n1 = n2 + n2 = tn + var tb = b1 + b1 = b2 + b2 = tb + best = { + "ok": true, + "score": score, + "node1": n1, + "bar1": b1, + "node2": n2, + "bar2": b2, + } return best +static func _cell_key(nodes: Array) -> String: + var ids: Array = [] + for n in nodes: + var node: BarMesh.BMNode = n + ids.append(node.i) + ids.sort() + var parts := PackedStringArray() + for id in ids: + parts.append(str(id)) + return ",".join(parts) + + +static func _ring_slice(nodes: Array, i: int, j: int) -> Array: + var out: Array = [] + var n: int = nodes.size() + var k := i + while true: + out.append(nodes[k]) + if k == j: + break + k = (k + 1) % n + return out + + static func _edge_dir_xy(a: BarMesh.BMNode, b: BarMesh.BMNode) -> Vector2: var d := Vector2(b.p.x - a.p.x, b.p.y - a.p.y) if d.length_squared() < 1e-24: