From de3a28117527614706b0761ba599093a0f7fab66 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 13 Oct 2026 13:55:12 -0300 Subject: [PATCH] With ankles, the shin stops at the ankle (#174); Aether 0.810.0 With `ankles` set, each calf's capsule was built as specified: its cap reached a radius past the ankle, through the foot's place, and rested on the ground beside the foot. A standing figure with soft ankles put 42% of its weight on its shins' end caps. The shin is now built with its ankle end drawn in by its radius, so its cap ends at the ankle. Its knee end, radius and spec are unchanged, and the foot's joint is still made at the ankle. That figure now stands entirely on its feet. Without ankles the calf is the reference's, unchanged. human_bone_far_end gives a built bone's far end in its body's frame: the ankle for a calf with ankles (the foot's joint), else its capsule's end farther from its joint. Consumers that took a calf's ankle from its shin's capsule should read it here. test_human checks the shin's shape against the ankle and the reference, the far ends, and the issue's measure (shins against feet, as shares of the figure's weight). The old capsule fails it. AE_PIN moves to 0.810.0, the latest release (the gate passes on it unchanged), and the README's install line, left at 0.806.0, follows. --- .github/workflows/test.yaml | 6 +- README.md | 6 +- aephysics/human/module.ae | 59 +++++++++++-- tests/test_human.ae | 160 ++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 650ca46..551c95b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,13 +15,13 @@ jobs: - name: Checkout aephysics uses: actions/checkout@v4 - # Pin: 0.809.0, the latest release (f32x8 arrived in 0.788.0, aether#2428, + # Pin: 0.810.0, the latest release (f32x8 arrived in 0.788.0, aether#2428, # which the wide contact solver is written in). A reproducible bootstrap # version: the floor this code needs, verified on Linux by this job. - name: Install the released Aether toolchain and aeb run: | curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.809.0 AEB_REF=v0.326 sh + | AE_PIN=0.810.0 AEB_REF=v0.326 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run the tests with ae @@ -54,7 +54,7 @@ jobs: - name: Install the released Aether toolchain and aeb run: | curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.809.0 AEB_REF=v0.326 sh + | AE_PIN=0.810.0 AEB_REF=v0.326 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run the tests with ae diff --git a/README.md b/README.md index a37e11d..7d613f4 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ so a test written against the reference reads the same here. | `aephysics.debug_draw` | what a host draws a world with (the reference's b3DebugDraw): callbacks for a user shape, segments, transforms, points, spheres, capsules, bounds, boxes and strings, the options, the reference's 145 named colours, material presets and graph colours; `physics_world.world_draw` walks the shapes in the drawing bounds (each coloured by its body's state as the reference picks it, or by its material's own colour) and draws what the options ask of their bodies (names, mass, sleep, each joint kind once, contacts by state or graph colour with normals, forces or features, islands); the world def's debug shape callbacks make the host's drawable for a shape when it is first drawn and give it back when the shape goes, its geometry changes (the reference keeps the stale one) or the world goes (the reference leaves those to the host), and an in-place restore keeps the drawables of the shapes still there; `recording.player_draw_frame_queries` draws a replayed frame's queries, the selected one labelled with its caller | done, `test_debug_draw.ae` (41 checks: every shape once with its colour, a drawable each made once and kept, every option counted against the world (a point per manifold point, a weld's two boxes, a label per joint), the drawing bounds and the mask culling, the drawables' lives through destroy, geometry change, restore and the world's end, a player's world and its queries drawn, the labels' number formats) | | `aephysics.snapshot` | a world as bytes and back (the reference's world_snapshot.c): `save_world` and `load_world` keep everything a step reads (the settings, the id pools, the solver sets, bodies, shapes and their geometry once each, contacts with their manifolds and mesh caches, joints, sensors, islands, the trees and pair set, the graph's colours), so a loaded world steps on as the one it was taken from, bit for bit; `restore_world` loads one into an existing world in place, its id, workers and callbacks kept (what a replay rewinds with); a snapshot of another layout or any damage to its bytes loads no world | done, `test_snapshot.ae` (63 checks: a world of every kind of thing loaded at four moments and stepped 150 steps beside its original, the same hash and events every step; a loaded world saves to the same bytes; one worker's world loaded with four steps alike; a restore in place stepping alike and saving to the same bytes, a refused restore leaving the world empty and whole; every truncation and corruption refused, nothing leaked) | | `aephysics.recording` | recording and replay (the reference's recording.c and recording_replay.c): a world records every call that changes it, 160 kinds, field by field, a create's record with the id it made, a state hash after every step and the geometry the calls name once each, and every world query with each visit and the caller's answer (a caller named by the query filter's id and name gets a key a viewer follows it by); a player replays it into a world of its own, restored in place so its id stays, checking every step's hash and every created id, asking every recorded query again with the recorded answers and comparing each visit, with keyframes under a memory budget for seeking, bodies in the order they were made, the queries of the frame last played with their hits, a pause before a step that makes bodies, and the recording's info; records it does not know are skipped, as a newer minor version may add some | `test_recording.ae` (152 checks: the snapshot test's scene recorded from an empty world and from mid stream with the host changing it between steps, played on one worker and several, every frame's hash as recorded; every one of the 160 calls recorded and replayed (the reference's AllOps); geometry kept once from two copies; seeking back and forth with and without keyframes and under a tight budget; the reference's QueryReplay and TaggedQuery, every query kind with answers that stop and clip them, a changed hit caught at its frame; the reference's NameRoundTrip, RollbackNames, GeometryHashCollision and ReservedHeaderBytes; a file round trip; every truncation, a skipped call, a changed id, a short record and a changed hash each caught, nothing leaked); `test_threads.ae` (four threads querying a recording world, every record whole and replayed alike) | -| `aephysics.human` | the ragdoll: twelve capsule bones on spherical joints with cone and twist limits and revolute joints with angle limits -- a person's ranges by default (shoulders that reach overhead on a cone centred off the rest, hips on an elliptical cone through 125 degrees ahead, 25 back, 45 out and 30 across, knees and elbows that never pass straight), the reference's tight ones on request -- soles that grip, and on request (`Human.ankles`, past the reference) the feet as bones of their own on ankles, an elliptical cone through 50 degrees down, 35 up and 15 aside with 15 of roll, so a crouched figure's soles lie flat, a spring on every joint toward the reference pose, a motor whose torque limit is joint friction, filter joints for the limbs that clash; the align spring, kinematic anchors through motor or parallel joints and their targets driven over a step (the pose drive of an active ragdoll), velocity, kicks, bullets | `test_human.ae` (115 checks: the figure's shape, a fall to rest in one piece, the setters, a kick, standing under the align spring, the pose held on anchors, the anchors driven two metres and the figure walked behind them, the same drop twice bit for bit, a fitted shape, each arm reaching flexion 170, abduction 160 and extension 45 degrees standing and prone, the hips' cone through a person's four ranges and short of a split, the hinges stopping at straight and folding to their range, the soles' grip, the ankles' four ranges measured through the joint and the soles flat under shins tipped 25 degrees over them) | [the reference's rain benchmark, 300 ragdolls over 400 steps, at 0.9x](bench/RESULTS.md#human) | +| `aephysics.human` | the ragdoll: twelve capsule bones on spherical joints with cone and twist limits and revolute joints with angle limits -- a person's ranges by default (shoulders that reach overhead on a cone centred off the rest, hips on an elliptical cone through 125 degrees ahead, 25 back, 45 out and 30 across, knees and elbows that never pass straight), the reference's tight ones on request -- soles that grip, and on request (`Human.ankles`, past the reference) the feet as bones of their own on ankles, an elliptical cone through 50 degrees down, 35 up and 15 aside with 15 of roll, so a crouched figure's soles lie flat, and the shins stopping at the ankle so the feet bear the figure (`human_bone_far_end` still gives the ankle), a spring on every joint toward the reference pose, a motor whose torque limit is joint friction, filter joints for the limbs that clash; the align spring, kinematic anchors through motor or parallel joints and their targets driven over a step (the pose drive of an active ragdoll), velocity, kicks, bullets | `test_human.ae` (134 checks: the figure's shape, a fall to rest in one piece, the setters, a kick, standing under the align spring, the pose held on anchors, the anchors driven two metres and the figure walked behind them, the same drop twice bit for bit, a fitted shape, each arm reaching flexion 170, abduction 160 and extension 45 degrees standing and prone, the hips' cone through a person's four ranges and short of a split, the hinges stopping at straight and folding to their range, the soles' grip, the ankles' four ranges measured through the joint and the soles flat under shins tipped 25 degrees over them, a standing figure with ankles carried by its feet and none of it by its shins, where the shins as specified took 42%) | [the reference's rain benchmark, 300 ragdolls over 400 steps, at 0.9x](bench/RESULTS.md#human) | | `aephysics` | the public API | | Deliberate choices: @@ -81,11 +81,11 @@ Install the released toolchain with the shared, version-pinned installer ```sh curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.806.0 AEB_REF=v0.326 sh + | AE_PIN=0.810.0 AEB_REF=v0.326 sh export PATH="$HOME/.local/bin:$PATH" ``` -This installs `ae` 0.806.0, the release CI pins, and `aeb` v0.326 under +This installs `ae` 0.810.0, the release CI pins, and `aeb` v0.326 under `~/.local` (`PREFIX` overrides it), using release binaries where available. CI verifies only the pinned release; older ones are not tested (the eight-wide lanes, `f32x8`, arrived in 0.788.0). diff --git a/aephysics/human/module.ae b/aephysics/human/module.ae index 1a9b381..03fbed4 100644 --- a/aephysics/human/module.ae +++ b/aephysics/human/module.ae @@ -37,6 +37,7 @@ exports ( BONE_FOOT_L, BONE_FOOT_R, MAX_BONE_COUNT, ANKLE_PLANTAR_DEGREES, ANKLE_DORSAL_DEGREES, ANKLE_SIDE_DEGREES, ANKLE_ROLL_DEGREES, HumanBone, Human, BoneSpec, bone_at, filter_joint_at, spec_at, human_reference_shape, human_anatomical_limits, human_tight_limits, human_chain_child, human_shape_head, human_shape_tail, human_shape_toe, human_fit_shape, destroy_shape, create_human_shaped, + human_bone_far_end, create_human, destroy_human, human_set_velocity, human_apply_angular_impulse, human_set_joint_friction_torque, human_set_joint_spring_hertz, human_set_joint_damping_ratio, human_align_spring, human_release_align_spring, human_create_motor_anchors, human_create_parallel_anchors, human_destroy_anchors, human_has_anchors, human_set_anchor_target, human_set_bullet @@ -79,7 +80,8 @@ const ANKLE_FRICTION = 0.6 // frame in the reference pose, and the joint's limits and friction. // Its own capsule (the shin's on a calf, which carries the foot too: // a body lists its shapes newest first, so the first it lists is the -// foot) and the foot's, a null id on every other bone. +// foot) and the foot's, a null id on every other bone. With ankles a +// calf carries only its shin, and the shin stops at the ankle (#174). struct HumanBone { body_id: BodyId joint_id: JointId @@ -117,6 +119,25 @@ struct Human { bone_at(h: *Human, index: int) -> *HumanBone { return (h.bones + index * sizeof(HumanBone)) as *HumanBone } filter_joint_at(h: *Human, index: int) -> JointId { return (h.filter_joints as JointId[])[index] } +// A built bone's far end in its body's frame: the end of its own capsule +// farther from its joint (a forearm's past the wrist, a foot's at the +// toe), but a calf with ankles ends at its ankle, the foot's joint, which +// its shin stops short of by a radius (#174). A figure not spawned, or an +// index past its bones, has none: zero. +human_bone_far_end(h: *Human, index: int) -> Vec3 { + if h.is_spawned == false || index < 0 || index >= h.bone_count { return math.vec3_zero() } + if h.ankles && (index == BONE_CALF_L || index == BONE_CALF_R) { + foot = BONE_FOOT_L + if index == BONE_CALF_R { foot = BONE_FOOT_R } + return bone_at(h, foot).local_frame_a.p + } + bone = bone_at(h, index) + c = dynamics.shape_get_capsule(bone.shape_id) + joint = bone.local_frame_b.p + if math.length(math.sub(c.center2, joint)) > math.length(math.sub(c.center1, joint)) { return c.center2 } + return c.center1 +} + transform_of(px: float, py: float, pz: float, qx: float, qy: float, qz: float, qs: float) -> Transform { return Transform { p: math.vec3(px, py, pz), q: math.quat_vs(math.vec3(qx, qy, qz), qs) } } @@ -644,7 +665,14 @@ place_bone(h: *Human, world_id: WorldId, index: int, placement: Transform, spec: sdef.filter.group_index = spec.group * group_index sdef.base_material.friction = spec.friction sdef.base_material.rolling_resistance = 0.1 - bone.shape_id = dynamics.create_capsule_shape(bone.body_id, &sdef, spec.capsule) + // A calf whose foot is a bone of its own stops at the ankle, its cap + // within the joint: as specified its cap reaches a radius past the + // ankle, through the foot's place, and stands on the ground beside the + // foot, so the shins would carry the figure (#174). Its spec keeps the + // ankle, where the foot's joint is made. + capsule = spec.capsule + if spec.has_foot && foot_on_calf == false { capsule = short_of_ankle(spec.capsule) } + bone.shape_id = dynamics.create_capsule_shape(bone.body_id, &sdef, capsule) bone.foot_shape_id = dynamics.null_shape_id() if spec.has_foot && foot_on_calf { fdef = dynamics.default_shape_def() @@ -663,11 +691,32 @@ place_bone(h: *Human, world_id: WorldId, index: int, placement: Transform, spec: bone.joint_friction = spec.joint_friction } +// A calf's capsule with its ankle end, the one farther from the knee, +// drawn in by its radius, so its cap ends at the ankle; one no longer than +// its radius collapses to its knee end (made a sphere, as any capsule that +// short is). +short_of_ankle(c: Capsule) -> Capsule { + far_is_second = math.length(c.center2) > math.length(c.center1) + knee = c.center2 + ankle = c.center1 + if far_is_second { + knee = c.center1 + ankle = c.center2 + } + along = math.sub(ankle, knee) + span = math.length(along) + if span <= 0.0 { return c } + end = math.sub(ankle, math.mul_sv(math.min_float(c.radius, span) / span, along)) + if far_is_second { return manifold.capsule(knee, end, c.radius) } + return manifold.capsule(end, knee, c.radius) +} + // A calf's foot as a bone of its own: a body in the calf's frame carrying // the foot's capsule, on a spherical joint at the ankle (the far end of the -// calf's capsule). The joint's frame looks down the foot (z from the ankle -// to the toe) with x the way the toes go down (the shin's line across the -// foot), so its elliptical cone reaches ANKLE_PLANTAR_DEGREES toward x and +// calf's capsule as specified, where the built shin's cap ends, #174). The +// joint's frame looks down the foot (z from the ankle to the toe) with x +// the way the toes go down (the shin's line across the foot), so its +// elliptical cone reaches ANKLE_PLANTAR_DEGREES toward x and // ANKLE_DORSAL_DEGREES back (a centre tipped toward x by half their // difference), ANKLE_SIDE_DEGREES across, and its twist is the sole's roll. // The foot rests in the calf's frame, so both frames are one at rest. diff --git a/tests/test_human.ae b/tests/test_human.ae index f4ef68a..9a13c8f 100644 --- a/tests/test_human.ae +++ b/tests/test_human.ae @@ -1026,6 +1026,165 @@ test_ankle_crouch() { math.abs_float(deep.x - over) < 4.0 && math.abs_float(deep.y - over) < 4.0) } +// #174: with ankles each calf's capsule stops at its ankle, its cap within +// the joint (the reference's reaches a radius past the ankle into the +// foot's place); its knee end and radius are the reference's, and its far +// end (human_bone_far_end) is still the ankle. Without ankles the calf's +// capsule is the reference's whole. The issue's own measure: a figure +// standing with ankles stands on its feet, its shins' end caps off the +// ground, where the capsule as specified rests on it beside the foot. +capsule_far(c: Capsule, from: Vec3) -> Vec3 { + if math.length(math.sub(c.center2, from)) > math.length(math.sub(c.center1, from)) { return c.center2 } + return c.center1 +} + +capsule_near(c: Capsule, from: Vec3) -> Vec3 { + if math.length(math.sub(c.center2, from)) > math.length(math.sub(c.center1, from)) { return c.center1 } + return c.center2 +} + +// The ground's normal load on one shape, in newtons, over this step's substeps. +ground_load(world_id: WorldId, shape_id: ShapeId, data: ContactData[]) -> float { + ground = ground_of(world_id) + load = 0.0 + count = dynamics.shape_get_contact_data(shape_id, data, 8) + c = 0 + while c < count { + on_ground = dynamics.shape_get_body(data[c].shape_id_a).index1 == ground.index1 || dynamics.shape_get_body(data[c].shape_id_b).index1 == ground.index1 + if on_ground { + m = 0 + while m < data[c].manifold_count { + mf = ((data[c].manifolds + m * sizeof(Manifold)) as ptr) as *Manifold + p = 0 + while p < mf.point_count { + load = load + (dynamics.manifold_point_at(mf, p).total_normal_impulse as float) + p = p + 1 + } + m = m + 1 + } + } + c = c + 1 + } + return load * 60.0 +} + +// The figure's load on the ground, (shins, feet) as shares of its weight +// averaged over its last second: the reference's sample, standing on its +// tight limits under the align spring for four seconds, its joints' springs +// and friction stiff enough to hold it upright but its ankles soft, free to +// let the feet lie as the load puts them (as an active ragdoll's are). The +// capsule as specified puts 42% of the weight on the shins' end caps here. +standing_legs(ankles: bool) -> Vec2 { + world_id = world_with_ground() + h = Human { bones: null, filter_joints: null, filter_joint_count: 0, friction_torque: 0.0, is_spawned: false } + h.ankles = ankles + shape = human.human_reference_shape() + human.human_tight_limits(shape) + human.create_human_shaped(&h, world_id, Transform { p: math.vec3(0.0, 0.05, 0.0), q: math.quat_identity() }, shape, 50.0, 20.0, 1.0, 1, null) + human.destroy_shape(shape) + human.human_align_spring(&h, world_id, ground_of(world_id), 25.0, 1.0) + if ankles { + joint_solver.spherical_joint_set_spring_hertz(human.bone_at(&h, human.BONE_FOOT_L).joint_id, 1.0) + joint_solver.spherical_joint_set_spring_hertz(human.bone_at(&h, human.BONE_FOOT_R).joint_id, 1.0) + joint_solver.spherical_joint_set_max_motor_torque(human.bone_at(&h, human.BONE_FOOT_L).joint_id, 3.0) + joint_solver.spherical_joint_set_max_motor_torque(human.bone_at(&h, human.BONE_FOOT_R).joint_id, 3.0) + } + weight = 0.0 + i = 0 + while i < h.bone_count { + weight = weight + dynamics.body_get_mass(human.bone_at(&h, i).body_id) + i = i + 1 + } + weight = weight * math.length(dynamics.default_world_def().gravity) + data_block = calloc(8, sizeof(ContactData)) + data = data_block as ContactData[] + shins = 0.0 + feet = 0.0 + s = 0 + while s < 240 { + physics_world.step(world_id, 1.0 / 60.0, 4) + if s >= 180 { + side = 0 + while side < 2 { + calf = human.bone_at(&h, human.BONE_CALF_L) + if side == 1 { calf = human.bone_at(&h, human.BONE_CALF_R) } + foot_shape = calf.foot_shape_id + if ankles { + foot_shape = human.bone_at(&h, human.BONE_FOOT_L).shape_id + if side == 1 { foot_shape = human.bone_at(&h, human.BONE_FOOT_R).shape_id } + } + shins = shins + ground_load(world_id, calf.shape_id, data) + feet = feet + ground_load(world_id, foot_shape, data) + side = side + 1 + } + } + s = s + 1 + } + pelvis_y = dynamics.body_get_position(human.bone_at(&h, human.BONE_PELVIS).body_id).y + ensure("shins: standing (ankles ${ankles}), the pelvis up (${pelvis_y} m)", pelvis_y > 0.8) + free(data_block) + human.destroy_human(&h) + dynamics.destroy_world(world_id) + return Vec2 { x: shins / 60.0 / weight, y: feet / 60.0 / weight } +} + +test_ankle_shins() { + reference = human.human_reference_shape() + world_id = world_with_ground() + h = Human { bones: null, filter_joints: null, filter_joint_count: 0, friction_torque: 0.0, is_spawned: false } + ensure("shins: a figure not spawned has no far end", math.length(human.human_bone_far_end(&h, human.BONE_CALF_L)) == 0.0) + h.ankles = true + human.create_human(&h, world_id, math.vec3(0.0, 1.0, 0.0), 5.0, 1.0, 0.7, 1, null) + origin = math.vec3_zero() + side = 0 + while side < 2 { + calf_index = human.BONE_CALF_L + foot_index = human.BONE_FOOT_L + if side == 1 { + calf_index = human.BONE_CALF_R + foot_index = human.BONE_FOOT_R + } + spec = human.spec_at(reference, calf_index) + calf = human.bone_at(&h, calf_index) + foot = human.bone_at(&h, foot_index) + c = dynamics.shape_get_capsule(calf.shape_id) + ankle = foot.local_frame_a.p + knee = capsule_near(c, origin) + far = capsule_far(c, origin) + tip = math.add(far, math.mul_sv(c.radius, math.normalize(math.sub(far, knee)))) + ensure("shins (side ${side}): with ankles the shin's cap ends at the ankle (${math.length(math.sub(tip, ankle))} m)", math.length(math.sub(tip, ankle)) < 0.00001) + ensure("shins (side ${side}): the ankle is where the reference's capsule ends", math.length(math.sub(ankle, capsule_far(spec.capsule, origin))) < 0.000001) + ensure("shins (side ${side}): its knee end and radius the reference's", + math.length(math.sub(knee, capsule_near(spec.capsule, origin))) < 0.000001 && math.abs_float(c.radius - spec.capsule.radius) < 0.000001) + ensure("shins (side ${side}): its far end is the ankle", math.length(math.sub(human.human_bone_far_end(&h, calf_index), ankle)) < 0.000001) + fc = dynamics.shape_get_capsule(foot.shape_id) + ensure("shins (side ${side}): the foot's far end is its toe, away from the ankle", + math.length(math.sub(human.human_bone_far_end(&h, foot_index), capsule_far(fc, foot.local_frame_b.p))) < 0.000001) + side = side + 1 + } + ensure("shins: past the bones, no far end", math.length(human.human_bone_far_end(&h, human.MAX_BONE_COUNT)) == 0.0) + human.destroy_human(&h) + h = Human { bones: null, filter_joints: null, filter_joint_count: 0, friction_torque: 0.0, is_spawned: false } + human.create_human(&h, world_id, math.vec3(0.0, 1.0, 0.0), 5.0, 1.0, 0.7, 1, null) + calf = human.bone_at(&h, human.BONE_CALF_L) + c = dynamics.shape_get_capsule(calf.shape_id) + spec = human.spec_at(reference, human.BONE_CALF_L) + ensure("shins: without ankles the calf's capsule is the reference's whole", + math.length(math.sub(c.center1, spec.capsule.center1)) < 0.000001 && math.length(math.sub(c.center2, spec.capsule.center2)) < 0.000001) + ensure("shins: and its far end the capsule's", math.length(math.sub(human.human_bone_far_end(&h, human.BONE_CALF_L), capsule_far(spec.capsule, origin))) < 0.000001) + elbow = human.bone_at(&h, human.BONE_LOWER_ARM_L) + ec = dynamics.shape_get_capsule(elbow.shape_id) + ensure("shins: a forearm's far end is past the wrist, its capsule's", math.length(math.sub(human.human_bone_far_end(&h, human.BONE_LOWER_ARM_L), capsule_far(ec, origin))) < 0.000001) + human.destroy_human(&h) + dynamics.destroy_world(world_id) + human.destroy_shape(reference) + ankled = standing_legs(true) + whole = standing_legs(false) + println("human: standing, the shares of the weight on shins and feet: with ankles ${ankled.x} and ${ankled.y}, without ${whole.x} and ${whole.y}") + ensure("shins: standing with ankles, the feet bear the figure (${ankled.y} of its weight)", ankled.y > 0.97 && ankled.y < 1.03) + ensure("shins: and the shins nothing (${ankled.x})", ankled.x < 0.001) +} + main() { g_slab = hull.make_box_hull(20.0, 1.0, 20.0) as ptr test_build_and_fall() @@ -1041,6 +1200,7 @@ main() { test_grip() test_ankles() test_ankle_crouch() + test_ankle_shins() hull.destroy_hull(g_slab as *HullData) println("human: ${checks} checks") if failures == 0 {