Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/godot-headless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 10 additions & 0 deletions barmesh/draw.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions barmesh/tool_contact.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
142 changes: 142 additions & 0 deletions tools/test_eartip_quality.gd
Original file line number Diff line number Diff line change
@@ -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)