diff --git a/.github/workflows/godot-headless.yml b/.github/workflows/godot-headless.yml index ccbece5..d523f25 100644 --- a/.github/workflows/godot-headless.yml +++ b/.github/workflows/godot-headless.yml @@ -67,11 +67,22 @@ jobs: fi grep -q "DROP_QUALITY_OK" "$QOUT" [ "$QRC" -eq 0 ] + EOUT=$(mktemp) + set +e + godot --headless --path /src -s res://tools/test_eartip_quality.gd 2>&1 | tee "$EOUT" + ERC=${PIPESTATUS[0]} + set -e + if grep -E "Parse Error|Failed to load script|EARTIP_QUALITY_FAIL" "$EOUT"; then + echo "::error::Godot eartip quality (fall-through) failure" + exit 1 + fi + grep -q "EARTIP_QUALITY_OK" "$EOUT" + [ "$ERC" -eq 0 ] ' - name: Summarize if: always() run: | echo "### Godot CI" >> "$GITHUB_STEP_SUMMARY" - echo "Self-hosted Docker \`ghcr.io/sharkyrawr/godot-docker:4.6\` — bake + \`test_contact\` + \`test_drop_quality\` (Julian flat/roof model)" >> "$GITHUB_STEP_SUMMARY" + echo "bake + contact + drop_quality (flat/roof) + **eartip penetration** (\`test_eartip_quality.gd\`)" >> "$GITHUB_STEP_SUMMARY" echo "Status: ${{ job.status }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/barmesh/draw.gd b/barmesh/draw.gd index 4a6e20d..f4f8fc3 100644 --- a/barmesh/draw.gd +++ b/barmesh/draw.gd @@ -20,6 +20,7 @@ const Contact = preload("res://barmesh/tool_contact.gd") var _playing: bool = false var _run_id: int = 0 var _last_bm: BarMesh +var _last_tris: Array = [] var _normals_mi: MeshInstance3D @@ -28,6 +29,14 @@ static func cad_to_godot(p: Vector3) -> Vector3: return Vector3(p.x, p.z, p.y) +func get_last_barmesh() -> BarMesh: + return _last_bm + + +func get_last_tris_cad() -> Array: + return _last_tris + + func _ready() -> void: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED @@ -71,6 +80,7 @@ func play_over_part(p_radius: float = -1.0) -> void: var ypart := BarMeshGD.Partition1.new(cad["ymin"], cad["ymax"], ny) var z_above: float = cad["zmax"] + R + pad_m var z_plane: float = cad["zmin"] + _last_tris = tris var bm := BarMeshGD.new() bm.start_rect_bar_mesh(xpart, ypart, z_above) var more := true diff --git a/barmesh/tool_contact.gd b/barmesh/tool_contact.gd index 6147558..8c32cc8 100644 --- a/barmesh/tool_contact.gd +++ b/barmesh/tool_contact.gd @@ -280,3 +280,74 @@ static func _point_in_triangle(p: Vector3, a: Vector3, b: Vector3, c: Vector3) - var u := 1.0 - v - w var eps := -1e-6 return u >= eps and v >= eps and w >= eps + + +static func highest_surface_z_at_xy(x: float, y: float, tris_cad: Array) -> float: + ## Highest mesh z along vertical line (x,y,*) — for fall-through vs top skin. + var z_best: float = -1e30 + var found := false + for tri in tris_cad: + var a: Vector3 = tri[0] + var b: Vector3 = tri[1] + var c: Vector3 = tri[2] + var n := (b - a).cross(c - a) + if absf(n.z) < 1e-12: + continue + # Plane: n·(p-a)=0 → z = a.z + (-n.x*(x-a.x)-n.y*(y-a.y))/n.z + var z: float = a.z - (n.x * (x - a.x) + n.y * (y - a.y)) / n.z + var p := Vector3(x, y, z) + if not _point_in_triangle(p, a, b, c): + continue + if z > z_best: + z_best = z + found = true + if not found: + return NAN + return z_best + + +static func min_distance_point_to_tris(p: Vector3, tris_cad: Array) -> float: + ## Closest distance from point to any triangle (for sphere penetration checks). + var best: float = 1e30 + for tri in tris_cad: + var d: float = _point_triangle_distance(p, tri[0], tri[1], tri[2]) + if d < best: + best = d + return best + + +static func _point_triangle_distance(p: Vector3, a: Vector3, b: Vector3, c: Vector3) -> float: + var ab := b - a + var ac := c - a + var ap := p - a + var d1 := ab.dot(ap) + var d2 := ac.dot(ap) + if d1 <= 0.0 and d2 <= 0.0: + return ap.length() + var bp := p - b + var d3 := ab.dot(bp) + var d4 := ac.dot(bp) + if d3 >= 0.0 and d4 <= d3: + return bp.length() + var vc := d1 * d4 - d3 * d2 + if vc <= 0.0 and d1 >= 0.0 and d3 <= 0.0: + var v: float = d1 / (d1 - d3) + return (a + ab * v - p).length() + var cp := p - c + var d5 := ab.dot(cp) + var d6 := ac.dot(cp) + if d6 >= 0.0 and d5 <= d6: + return cp.length() + var vb := d5 * d2 - d1 * d6 + if vb <= 0.0 and d2 >= 0.0 and d6 <= 0.0: + var w: float = d2 / (d2 - d6) + return (a + ac * w - p).length() + var va := d3 * d6 - d5 * d4 + if va <= 0.0 and (d4 - d3) >= 0.0 and (d5 - d6) >= 0.0: + var w2: float = (d4 - d3) / ((d4 - d3) + (d5 - d6)) + return (b + (c - b) * w2 - p).length() + var n := ab.cross(ac) + var nlen2 := n.length_squared() + if nlen2 < 1e-24: + return ap.length() + return absf(n.dot(ap)) / sqrt(nlen2) diff --git a/tools/test_eartip_quality.gd b/tools/test_eartip_quality.gd new file mode 100644 index 0000000..3ac19e1 --- /dev/null +++ b/tools/test_eartip_quality.gd @@ -0,0 +1,142 @@ +extends SceneTree +## CI: bake BarMesh on the real eartip and fail on fall-through. +## Checks: |CL−contact|≈R; sphere not penetrating drop tris; CL not below top skin at XY. +## Headless: godot --headless --path . -s res://tools/test_eartip_quality.gd + +const Contact := preload("res://barmesh/tool_contact.gd") + + +func _init() -> void: + call_deferred("_run") + + +func _fail(msg: String) -> void: + push_error("EARTIP_QUALITY_FAIL " + msg) + quit(1) + + +func _run() -> void: + var packed: PackedScene = load("res://scenes/main.tscn") + if packed == null: + _fail("load main") + return + var root: Node = packed.instantiate() + root.name = "Main" + get_root().add_child(root) + for i in 8: + await process_frame + + var R_mm := 1.5 + var R := R_mm * 0.001 + var stats: Dictionary = await root.bake_strategy("barmesh", R_mm, 1.0, 0.01, 15.0) + if stats.is_empty(): + _fail("bake empty") + return + + var preview := root.get_node_or_null("BarMeshPreview") + if preview == null or not preview.has_method("get_last_barmesh"): + _fail("no preview accessors") + return + var bm = preview.get_last_barmesh() + var tris: Array = preview.get_last_tris_cad() + if bm == null or bm.nodes.is_empty(): + _fail("empty barmesh") + return + if tris.is_empty(): + _fail("empty tris") + return + + # Part-only tris (exclude synthetic base plane at zmin) for top-skin checks. + var zmin := 1e30 + for tri in tris: + for v in tri: + zmin = minf(zmin, v.z) + var part_tris: Array = [] + for tri in tris: + var on_base := true + for v in tri: + if absf(v.z - zmin) > 1e-6: + on_base = false + break + if not on_base: + part_tris.append(tri) + + var tol := 2e-4 + var checked := 0 + var below_skin := 0 + var low_flat := 0 + var penetrations := 0 + var worst_below := 0.0 + var worst_flat := 0.0 + var worst_at := Vector3.ZERO + for node in bm.nodes: + var nd = node + if nd.contact_kind == 0: + continue + var cl: Vector3 = nd.p + var d_contact: float = cl.distance_to(nd.contact_point) + if absf(d_contact - R) > tol: + _fail("|CL-contact|!=R d=%s R=%s at %s" % [d_contact, R, cl]) + return + var d_mesh: float = Contact.min_distance_point_to_tris(cl, tris) + if d_mesh < R - tol: + penetrations += 1 + var z_skin: float = Contact.highest_surface_z_at_xy(cl.x, cl.y, part_tris) + if is_finite(z_skin) and cl.z < z_skin - tol: + below_skin += 1 + var depth: float = z_skin - cl.z + if depth > worst_below: + worst_below = depth + worst_at = cl + if below_skin <= 8: + push_warning( + "EARTIP_BELOW_SKIN cl.z=%s skin=%s depth_mm=%s at (%s,%s)" + % [cl.z, z_skin, depth * 1000.0, cl.x, cl.y] + ) + # Near-horizontal skin: ball CL must be ~ skin + R (Julian fall-through class). + if is_finite(z_skin) and nd.contact_normal.z > 0.92: + var expect_z: float = z_skin + R * nd.contact_normal.z + if cl.z < expect_z - tol: + low_flat += 1 + var gap: float = expect_z - cl.z + if gap > worst_flat: + worst_flat = gap + worst_at = cl + if low_flat <= 8: + push_warning( + "EARTIP_LOW_FLAT cl.z=%s expect=%s gap_mm=%s at (%s,%s)" + % [cl.z, expect_z, gap * 1000.0, cl.x, cl.y] + ) + checked += 1 + + if checked < 10: + _fail("too few contact nodes " + str(checked)) + return + + if penetrations > 0: + _fail("sphere penetration at %s of %s nodes" % [penetrations, checked]) + return + + if below_skin > 0: + _fail( + "CL below top skin at %s of %s nodes; worst depth_mm=%s at %s" + % [below_skin, checked, worst_below * 1000.0, worst_at] + ) + return + + if low_flat > 0: + _fail( + "CL too low on flat skin at %s of %s nodes; worst gap_mm=%s at %s" + % [low_flat, checked, worst_flat * 1000.0, worst_at] + ) + return + + print( + "EARTIP_QUALITY_OK nodes=", + checked, + " part_tris=", + part_tris.size(), + " R_mm=", + R_mm + ) + quit(0)