diff --git a/.gitattributes b/.gitattributes index 2f33132..afdcc85 100644 --- a/.gitattributes +++ b/.gitattributes @@ -37,3 +37,4 @@ *.ide-wal filter=lfs diff=lfs merge=lfs -text *.ide-shm filter=lfs diff=lfs merge=lfs -text 2018-unity-sim/**/*.unity filter=lfs diff=lfs merge=lfs -text +*.bytes filter=lfs diff=lfs merge=lfs -text diff --git a/2026-current/CLAUDE.md b/2026-current/CLAUDE.md index 1d288d0..61abd47 100644 --- a/2026-current/CLAUDE.md +++ b/2026-current/CLAUDE.md @@ -168,6 +168,11 @@ Remote stick-frame convention: logical X + = right, Y + = forward, twist + - `HexapodSimBootstrap` auto-adds `HexapodSimulation` in any scene and disables the old scripts (`Walk`, `RotationControl`, `Leg`, `HandleHoystick`). Those are kept for reference only. + - `OpenScadModel` replaces the old scene's robot with the OpenSCAD model + (meshes from `hardware/openscad/export_unity.py` in + `Resources/OpenScadModel/`): it builds a new rig with the same names at + the Config.h geometry, whose rest pose (coxa 0, femur 0, tibia -90) is + the zero rotation. `HexapodSimulation.useOpenScadModel` turns it off. - `HexapodSimNative` loads a temp copy of the DLL via LoadLibrary/GetProcAddress, so there's no file lock and each Play is a fresh power-on. @@ -212,14 +217,25 @@ Robot (`robot/README.md` has detail): - `Config.h` geometry now comes from the Unity model (owner's request), 1 unit = 10 mm: - legs at X ±100 / 0, Y ±50 / ±65 mm, - - mount angles ±90° (coxa straight sideways at servo center), + - mount angles ±30° / ±90° / ±150° (hexagon layout, owner's request: mid + legs sideways, corner legs 60° further round; coxa direction at servo + centre — the corner brackets sit 60° round on their splines), - coxa/femur/tibia 55/100/150 mm, - stance radius 75 (so 130 mm out from the coxa pivot), height 150 mm. The old scene's `Leg.translateX` = ±5 was only the old code's rotation pivot; the rig hips are at x ±10. Still to confirm on the real chassis: - joint limits, servo pins, µs/deg calibration, `MAX_JOINT_DEG_S` (in + servo pins, µs/deg calibration, `MAX_JOINT_DEG_S` (in robot.ino), the Limits envelope, InputMapper maxima. +- **Geometry and limits confirmed against the CAD (owner's request):** the + OpenSCAD rebuild of the real chassis (`hardware/openscad/`, see its + `PLANNING.md`) has exactly these mounts and 55/100/150 mm links (tibia to + the centre of a 10 mm foot ball; femur/knee axes in the coxa pivot plane, + 13.5 mm above the bottom plate). Its collision sweep gave the joint + limits now in `Config.h`: coxa limits are **per leg** + (`COXA_MIN_DEG[i]`/`COXA_MAX_DEG[i]`; all ±60 in the hexagon layout, but in + the old sideways layout a corner leg only swung ~36° towards its mid leg), + and `LEG_CLEARANCE_MM` is 56 (a leg is 55.2 mm wide at the joints). The sim API exports the per-leg coxa limits (version 7). - **Fixed (owner-approved):** the `Kinematics.cpp` femur elevation sign (`atan2(local_z, horiz)`; it used to solve for a foot above the hip, so every solution was invalid). Stance pose is now femur −1.2°, tibia diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor.meta new file mode 100644 index 0000000..567c0b1 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4bfa2732a93b22f48babbbb0cf21cc1b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor/OpenScadModelSnapshot.cs b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor/OpenScadModelSnapshot.cs new file mode 100644 index 0000000..0015fc2 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor/OpenScadModelSnapshot.cs @@ -0,0 +1,75 @@ +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace HexapodSim +{ + // Batch-mode check of the OpenSCAD model in Main.unity, without entering + // Play mode: builds the model from the DLL's geometry, poses the legs in + // the firmware's standing pose and renders the main camera to PNGs. + // Unity.exe -batchmode -projectPath . -executeMethod HexapodSim.OpenScadModelSnapshot.Run + // -snapshotDir -quit + public static class OpenScadModelSnapshot + { + public static void Run() + { + string dir = Arg("-snapshotDir") ?? Path.GetFullPath("Temp/OpenScadSnapshot"); + Directory.CreateDirectory(dir); + EditorSceneManager.OpenScene("Assets/Main.unity"); + + Transform chassis = null; + foreach (var t in Object.FindObjectsByType()) + if (t.name == "Leg" && t.Find("hip1") != null) { chassis = t.parent; break; } + if (chassis == null) { Debug.LogError("[Snapshot] no leg rig in Main.unity"); EditorApplication.Exit(1); return; } + + string dll = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..", "sim", "build", "hexapod_sim.dll")); + HxGeometry g; + using (var native = HexapodSimNative.Load(dll)) g = native.Geometry; + OpenScadModel.Build(chassis, g, ForwardAxis.NegativeX, 0.1f); + + // Firmware stance: coxa 0, femur -1.2, tibia -98.3 (HexapodSimulation.ApplyJoints). + foreach (Transform leg in chassis) + { + if (leg.name != "Leg" || !leg.gameObject.activeSelf) continue; + var hip2 = leg.Find("hip1/Hip2"); + var knie = leg.Find("hip1/Hip2/Knie"); + hip2.localRotation = Quaternion.Euler(1.2f, 0, 0); + knie.localRotation = Quaternion.Euler(-(-98.3f + 90f), 0, 0); + } + + var cam = Camera.main; + if (cam == null) cam = new GameObject("SnapshotCamera").AddComponent(); + Vector3 c = chassis.position; + Shoot(cam, c + new Vector3(-30, 22, -38), c, Path.Combine(dir, "front_left.png")); + Shoot(cam, c + new Vector3(42, 30, 20), c, Path.Combine(dir, "rear_right.png")); + Shoot(cam, c + new Vector3(0.01f, 60, 0), c, Path.Combine(dir, "top.png")); + Debug.Log("[Snapshot] wrote " + dir); + EditorApplication.Exit(0); + } + + static void Shoot(Camera cam, Vector3 pos, Vector3 target, string file) + { + cam.transform.position = pos; + cam.transform.LookAt(target); + cam.fieldOfView = 45; + var rt = new RenderTexture(1280, 900, 24); + cam.targetTexture = rt; + cam.Render(); + RenderTexture.active = rt; + var tex = new Texture2D(rt.width, rt.height, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + tex.Apply(); + File.WriteAllBytes(file, tex.EncodeToPNG()); + cam.targetTexture = null; + RenderTexture.active = null; + } + + static string Arg(string name) + { + var a = System.Environment.GetCommandLineArgs(); + for (int i = 0; i < a.Length - 1; i++) if (a[i] == name) return a[i + 1]; + return null; + } + } +} diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor/OpenScadModelSnapshot.cs.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor/OpenScadModelSnapshot.cs.meta new file mode 100644 index 0000000..fbe0fd5 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Editor/OpenScadModelSnapshot.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9aa5d204e0b61604b9d0c92405edb7a8 \ No newline at end of file diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimNative.cs b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimNative.cs index c75cfcb..64519b8 100644 --- a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimNative.cs +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimNative.cs @@ -36,7 +36,9 @@ namespace HexapodSim [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)] public float[] mountAngleDeg; public float coxaMm, femurMm, tibiaMm; public float stanceRadiusMm, stanceHeightMm; - public float coxaMinDeg, coxaMaxDeg, femurMinDeg, femurMaxDeg, tibiaMinDeg, tibiaMaxDeg; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)] public float[] coxaMinDeg; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)] public float[] coxaMaxDeg; + public float femurMinDeg, femurMaxDeg, tibiaMinDeg, tibiaMaxDeg; public float controlHz; public float legClearanceMm; public float stanceHeightRangeMm; @@ -71,7 +73,7 @@ namespace HexapodSim // "Reload firmware" and the new code runs. Each load is a fresh power-on. public sealed class HexapodSimNative : IDisposable { - public const int ExpectedApiVersion = 6; // HXSIM_API_VERSION + public const int ExpectedApiVersion = 7; // HXSIM_API_VERSION [UnmanagedFunctionPointer(CallingConvention.Cdecl)] delegate int IntFn(); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] delegate void VoidFn(); diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimulation.cs b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimulation.cs index f6b9a67..aa5a903 100644 --- a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimulation.cs +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/HexapodSimulation.cs @@ -32,6 +32,9 @@ namespace HexapodSim public float unitsPerMm = 0.1f; [Tooltip("Firmware tibia angle when the rig's Knie rotation is 0 (tibia straight down from a horizontal femur).")] public float kneeRestDeg = -90f; + [Tooltip("Replace the old scene's robot with the OpenSCAD model (hardware/openscad, export_unity.py).")] + public bool useOpenScadModel = true; + bool openScadBuilt_; [Header("World")] [Tooltip("Tilts the ground (deg about robot X = roll, Y = pitch) to exercise the robot gyro.")] @@ -123,6 +126,11 @@ namespace HexapodSim Native.SetRadioPresent(radioPresent); Native.SetImuPresent(imuPresent); Geometry = Native.Geometry; + if (useOpenScadModel && !openScadBuilt_ && OpenScadModel.Available) + { + OpenScadModel.Build(chassis, Geometry, forwardAxis, unitsPerMm); + openScadBuilt_ = true; + } BindRig(); Native.SetGroundTilt(groundTiltDeg.x, groundTiltDeg.y); Native.Start(); diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/OpenScadModel.cs b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/OpenScadModel.cs new file mode 100644 index 0000000..dcb18f4 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/OpenScadModel.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.IO; +using UnityEngine; +using UnityEngine.Rendering; + +namespace HexapodSim +{ + // Swaps the old scene's robot for the OpenSCAD model (hardware/openscad, + // exported by export_unity.py into Resources/OpenScadModel). Builds a new + // rig under the same chassis with the names HexapodSimulation looks for + // (Leg > hip1 > Hip2 > Knie, plus the Point foot marker), with its pivots + // exactly at the Config.h geometry, and hides the old visuals. The meshes + // are in the rest pose (coxa 0, femur 0, tibia -90), which is the rig's + // zero rotation, so the existing joint driving moves them correctly. + public static class OpenScadModel + { + const string Folder = "OpenScadModel/"; + + public static bool Available => Resources.Load(Folder + "body") != null; + + public static void Build(Transform chassis, HxGeometry g, ForwardAxis f, float unitsPerMm) + { + Material template = null; + foreach (var r in chassis.GetComponentsInChildren(true)) + { + if (template == null && r.sharedMaterial) template = r.sharedMaterial; + r.enabled = false; + } + foreach (var t in Object.FindObjectsByType()) + if (t.name == "Leg" && HasChild(t, "hip1")) t.gameObject.SetActive(false); + + var shader = Shader.Find("Standard") ?? (template ? template.shader : null); + AddMesh(chassis, "OpenSCAD body", Folder + "body", Quaternion.identity, f, unitsPerMm, shader); + + for (int i = 0; i < g.numLegs; i++) + { + string side = g.mountAngleDeg[i] > 0 ? "left" : "right"; + float a = g.mountAngleDeg[i] * Mathf.Deg2Rad; + var root = new GameObject("Leg").transform; + root.SetParent(chassis, false); + root.localPosition = RobotFrame.ToUnity(new Vector3(g.mountXmm[i], g.mountYmm[i], 0), f) * unitsPerMm; + root.localRotation = Quaternion.LookRotation( + RobotFrame.ToUnity(new Vector3(Mathf.Cos(a), Mathf.Sin(a), 0), f), Vector3.up); + // The meshes are exported as a leg pointing straight sideways (mount + // +90 left, -90 right) in chassis axes; in the joint's own frame that + // is the same for any mount angle. + float refRad = (side == "left" ? 90f : -90f) * Mathf.Deg2Rad; + Quaternion toJoint = Quaternion.Inverse(Quaternion.LookRotation( + RobotFrame.ToUnity(new Vector3(Mathf.Cos(refRad), Mathf.Sin(refRad), 0), f), Vector3.up)); + + var hip1 = Child(root, "hip1", Vector3.zero); + AddMesh(hip1, "coxa", Folder + $"leg_{side}_coxa", toJoint, f, unitsPerMm, shader); + var hip2 = Child(hip1, "Hip2", new Vector3(0, 0, g.coxaMm * unitsPerMm)); + AddMesh(hip2, "femur", Folder + $"leg_{side}_femur", toJoint, f, unitsPerMm, shader); + var knie = Child(hip2, "Knie", new Vector3(0, 0, g.femurMm * unitsPerMm)); + AddMesh(knie, "tibia", Folder + $"leg_{side}_tibia", toJoint, f, unitsPerMm, shader); + + // Where the firmware's FK puts the foot (centre of the foot ball). + var marker = GameObject.CreatePrimitive(PrimitiveType.Sphere); + var collider = marker.GetComponent(); + if (Application.isPlaying) Object.Destroy(collider); else Object.DestroyImmediate(collider); + marker.name = "Point"; + marker.transform.SetParent(knie, false); + marker.transform.localScale = Vector3.one * 4f * unitsPerMm; + marker.GetComponent().material = MakeMaterial(shader, new Color(0.9f, 0.1f, 0.1f)); + } + } + + static bool HasChild(Transform t, string name) + { + foreach (Transform c in t) + if (string.Equals(c.name, name, System.StringComparison.OrdinalIgnoreCase) || HasChild(c, name)) return true; + return false; + } + + static Transform Child(Transform parent, string name, Vector3 localPos) + { + var t = new GameObject(name).transform; + t.SetParent(parent, false); + t.localPosition = localPos; + return t; + } + + static Material MakeMaterial(Shader shader, Color c) + { + var m = new Material(shader) { color = c }; + if (m.HasProperty("_Glossiness")) m.SetFloat("_Glossiness", 0.25f); + return m; + } + + // Reads one export_unity.py file: robot-frame mm, pivot-relative, + // counter-clockwise outward winding (right-handed). RobotFrame.ToUnity is a + // reflection, so every triangle is flipped to stay front-facing. + static void AddMesh(Transform parent, string name, string resource, Quaternion toLocal, + ForwardAxis f, float unitsPerMm, Shader shader) + { + var asset = Resources.Load(resource); + if (asset == null) { Debug.LogWarning($"[HexapodSim] missing model mesh {resource}"); return; } + + var verts = new List(); + var groups = new List(); + var mats = new List(); + using (var br = new BinaryReader(new MemoryStream(asset.bytes))) + { + if (new string(br.ReadChars(4)) != "HXM1") { Debug.LogWarning($"[HexapodSim] {resource}: not a model mesh"); return; } + int n = br.ReadInt32(); + for (int k = 0; k < n; k++) + { + var col = new Color(br.ReadSingle(), br.ReadSingle(), br.ReadSingle(), br.ReadSingle()); + int tris = br.ReadInt32(); + var idx = new int[tris * 3]; + for (int t = 0; t < tris; t++) + { + int b0 = verts.Count; + for (int v = 0; v < 3; v++) + { + var r = new Vector3(br.ReadSingle(), br.ReadSingle(), br.ReadSingle()); + verts.Add(toLocal * (RobotFrame.ToUnity(r, f) * unitsPerMm)); + } + idx[t * 3] = b0; idx[t * 3 + 1] = b0 + 2; idx[t * 3 + 2] = b0 + 1; + } + groups.Add(idx); + mats.Add(MakeMaterial(shader, col)); + } + } + + var mesh = new Mesh { name = name, indexFormat = IndexFormat.UInt32 }; + mesh.SetVertices(verts); + mesh.subMeshCount = groups.Count; + for (int k = 0; k < groups.Count; k++) mesh.SetTriangles(groups[k], k); + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + + var go = new GameObject(name); + go.transform.SetParent(parent, false); + go.AddComponent().sharedMesh = mesh; + go.AddComponent().sharedMaterials = mats.ToArray(); + } + } +} diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/OpenScadModel.cs.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/OpenScadModel.cs.meta new file mode 100644 index 0000000..3bf790e --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/OpenScadModel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ac8b69c6750b194da6fcfc0d9d6cf48 \ No newline at end of file diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources.meta new file mode 100644 index 0000000..8fd6acc --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 027b28f48b4d1d34799b61471976c26b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel.meta new file mode 100644 index 0000000..8a804e7 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 86e3f55e00f9d5f4a9ae4f95c2dad14b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/body.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/body.bytes new file mode 100644 index 0000000..3bc00ec --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/body.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6647578d1c9ab464f2b219606fa3f95a48f6a82ca1a59adb4039081ba7d374a4 +size 1948048 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/body.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/body.bytes.meta new file mode 100644 index 0000000..7f02e5f --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/body.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 119d6760b10e2d94c89d00627245b5fd +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_coxa.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_coxa.bytes new file mode 100644 index 0000000..deaf514 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_coxa.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e482ab0cbfd148a4e6ef8cf09c603f8beb46ada24ef967327e618deeec5e8c42 +size 368492 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_coxa.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_coxa.bytes.meta new file mode 100644 index 0000000..c8fb9b6 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_coxa.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c8b5e767dc591144899b7e32a99ceabd +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_femur.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_femur.bytes new file mode 100644 index 0000000..17855e1 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_femur.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:254dbebd4623b0d5851981ae0154b078774a91812fc29fe43ab145e17ba1ffc0 +size 235292 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_femur.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_femur.bytes.meta new file mode 100644 index 0000000..7693cf0 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_femur.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 545cab1c5e6fdb6479a51faf4890b22e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_tibia.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_tibia.bytes new file mode 100644 index 0000000..42b402f --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_tibia.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a30906a28f5de7656d28b48ec4de5a1349fd45171781c901f2b6da067591237a +size 625316 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_tibia.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_tibia.bytes.meta new file mode 100644 index 0000000..d03110d --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_left_tibia.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1b15eff82e4f31d4d9ae657c556b7874 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_coxa.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_coxa.bytes new file mode 100644 index 0000000..ea7442f --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_coxa.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fa5288161814eb134fed7e436e3fe0a1989b272260c596dd1a8bfbc9a659180 +size 368492 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_coxa.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_coxa.bytes.meta new file mode 100644 index 0000000..a021f14 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_coxa.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 12b3384c18597f746a655d3d1558942e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_femur.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_femur.bytes new file mode 100644 index 0000000..4ecdc94 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_femur.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b448d2ae407dd90df6184e3ff3c654781c7d23077b8062c4527515d5f014d6a6 +size 235292 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_femur.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_femur.bytes.meta new file mode 100644 index 0000000..e67744a --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_femur.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6215130e92405f248957c261f62f4ff7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_tibia.bytes b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_tibia.bytes new file mode 100644 index 0000000..c696948 --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_tibia.bytes @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c85662e4d1f200247300e251dfb0eefca7be522ce87517b071a7c69f7ef063f5 +size 625316 diff --git a/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_tibia.bytes.meta b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_tibia.bytes.meta new file mode 100644 index 0000000..129678e --- /dev/null +++ b/2026-current/Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/leg_right_tibia.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 853358b18f634bb438e5980191cfda74 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/2026-current/README.md b/2026-current/README.md index c35ed4b..d769ebb 100644 --- a/2026-current/README.md +++ b/2026-current/README.md @@ -3,12 +3,16 @@ A six-legged walking robot (Teensy 3.6) and its handheld remote (Teensy 4.1 + Nextion touchscreen), linked over nRF24L01. +![The hexapod, OpenSCAD model in its standing pose](hardware/openscad/renders/hexapod.png) +![The remote, OpenSCAD model](hardware/openscad/renders/remote.png) + ``` robot/ robot firmware: IK, gait, body pose, IMU stabilization, state machine remote/ remote firmware: 2 x 3-axis sticks, MPU6050 tilt, Nextion UI, RF24 TX shared/ HexapodProtocol: the CmdPacket wire format + radio settings used by both sim/ the unchanged robot firmware compiled into a DLL for the simulation Hexapod_Simulation_Unity/ Unity project that runs that DLL on the 3D model +hardware/openscad/ OpenSCAD model of the robot and remote, rebuilt from the Fusion designs hardware/fusion360/ Fusion 360 designs: body, legs, servo, servo horn, remote case scripts/ PlatformIO helper (per-environment sketch folder) ``` diff --git a/2026-current/hardware/main-pcb-2026-11-13/README.md b/2026-current/hardware/main-pcb-2026-11-13/README.md index 98e6134..0043530 100644 --- a/2026-current/hardware/main-pcb-2026-11-13/README.md +++ b/2026-current/hardware/main-pcb-2026-11-13/README.md @@ -68,8 +68,9 @@ y_mm = (3330.7087 - y_eda) * 0.254 - Outline: 46.0 × 89.0 mm, black soldermask, 1.6 mm FR4 (the OBJ's 2.54 mm "slab" is just the export; the physical board is standard). -- Four M3 clearance holes (⌀ 4 mm), 5 mm in from each edge (hole grid - 35.78 × 78.96 mm). +- Four M3 clearance holes (⌀ 4 mm) at (5.00, 5.04), (40.78, 5.04), + (5.00, 84.00), (40.78, 84.00): hole grid 35.78 × 78.96 mm, centred at + (22.89, 44.52), slightly off the board centre (23, 44.5). - Component placements (pad-group centres from the EasyEDA JSON — these are the exact (x, y) the SCAD uses). All in mm from the board's bottom-left corner. diff --git a/2026-current/hardware/main-pcb-2026-11-13/main-pcb.scad b/2026-current/hardware/main-pcb-2026-11-13/main-pcb.scad index 9339290..14f90e8 100644 --- a/2026-current/hardware/main-pcb-2026-11-13/main-pcb.scad +++ b/2026-current/hardware/main-pcb-2026-11-13/main-pcb.scad @@ -21,7 +21,10 @@ CORNER_R = 1.0; /* [Mounting holes] */ HOLE_DIA = 4.0; // M3 clearance -HOLE_INSET = 5.0; +// Exact EasyEDA HOLE positions; the right/bottom insets are 5.22/5.04 mm, +// not 5, so the grid is 35.78 x 78.96 mm (the body's PCB bosses use it). +HOLES = [[5.00, 5.04], [40.78, 5.04], [5.00, 84.00], [40.78, 84.00]]; +HOLE_GRID_CENTER = [22.89, 44.52]; /* [Rendering] */ SHOW_COMPONENTS = true; @@ -55,9 +58,7 @@ module board_outline_2d() { } module mounting_holes_2d() { - for (x = [HOLE_INSET, BOARD_W - HOLE_INSET], - y = [HOLE_INSET, BOARD_H - HOLE_INSET]) - translate([x, y]) circle(d = HOLE_DIA); + for (h = HOLES) translate(h) circle(d = HOLE_DIA); } module pcb() { diff --git a/2026-current/hardware/openscad/PLANNING.md b/2026-current/hardware/openscad/PLANNING.md new file mode 100644 index 0000000..fa98e19 --- /dev/null +++ b/2026-current/hardware/openscad/PLANNING.md @@ -0,0 +1,156 @@ +# OpenSCAD body redesign: notes + +`body.scad` rebuilds the body of `../fusion360/body-bottom/bottom v59.f3d` +(top + bottom plate, PCB, coxa servos; legs are placeholders). + +## Source of truth + +The `top` and `bottom` **solids** in `bottom v59.stp` (inside +`bottom v59.f3d_files.zip`). The `top.dxf` / `bottom.dxf` sketches in the +same zip are older and differ (front/back notch, side curve, one servo +pocket with an extra notch), so they are not used. + +Coordinates: the plates are written in the original's top-view frame +(u, v) = (Fusion x, -Fusion z); `fusion_to_robot()` rotates that by -90° +into the robot frame of `robot/Config.h` (X = v forward, Y = -u left). +Fusion's up axis is Y; Fusion Y = 0 is the top face of the bottom plate, +which is also Z = 0 here. + +`body.scad` slices were overlaid on the original solid edges at every +feature height (plate faces, standoff split, counterbore floor, nut wells, +PCB bosses, pivot bosses, battery bay): all match, apart from the fixes +below. + +## What the original body is + +- Two 3 mm plates, cavity 27 mm (Z 0..27, top plate Z 27..30). +- Outline: arm ends r 19.5 around each coxa axis, concave fillets r 15.5 + at the mid arms, quadratic curves at the sides and a notch front/back. + Fully symmetric (the "asymmetry" seen earlier came from a stray line + near (128, 8) in `bottom.dxf` and construction points at x = 100). +- Coxa axes (u, v): corners (±50, ±100), mid (±65, 0) = robot + (±100, ±50) and (0, ±65), same as `Config.h`. +- Coxa servos (HS-485HB): ears rest on the top plate (Z 30), case hangs + in a 41 × 21 pocket down to Z 2. Shaft 10 mm from the outer case end. + Ear screw holes ⌀2.5, 48.25 × 10 mm. Corner servos turned 50°. +- Under each coxa axis a ⌀14 × 2 mm pivot boss on the bottom plate; the + coxa bracket (`hinge1` above / `hinge2` below, leg parts) wraps the + plate edge between the servo horn and this boss. +- 4 standoffs ⌀15 at (u, v) (±35, ±30), split at Z 13.5: M3 from the top + in a ⌀7 counterbore (floor Z 17.5), 8 mm grip, M3 nut in a hex well + from below (top of the well Z 9.5). +- PCB: long axis along the body, 4 bosses ⌀5 × 1 mm under the top plate + on the PCB hole grid (35.78 × 78.96), ⌀2.5 holes (self-tapping M3?), + ⌀5 × 1 mm recess on top. PCB top face at Z 26. +- Teensy cut-out 23.78 × 66.96, centred. +- Slide switch at the front: slot 8 × 16 (v 57..73), ⌀2 screw holes 19 mm + apart, "ON"/"OFF" engraved 1 mm, readable from behind the robot. +- Battery bay on the bottom plate: rails 3 × 13.5 mm high at |u| 26.06..29.06 + (52.1 mm between them), from v -74.21 (open, rear) to 31.79, end stop at + v 28.79..31.79. + +## Bugs in the original, fixed here + +- Left-front pivot boss at u = -50.894 instead of -50. +- Right-front corner has no coxa servo (only 5 of 6 placed). +- Only 2 of 6 legs (the mid legs), plus mirror-naming leftovers and the + empty `dontdeleteme` component (nothing to port). +- Hex nut wells rotated randomly; now one orientation. + +## Leg dimensions (for robot/Config.h and the Unity sim) + +Measured on the original solids (left mid leg, `bottom-leg v31`): + +| Item | Value | Config.h today | +|------|-------|----------------| +| Coxa: coxa axis to femur axis, horizontal | 55.0 mm | `COXA_LEN_MM` 55 (matches) | +| Femur: femur axis to knee axis | 100.0 mm | `FEMUR_LEN_MM` 100 (matches) | +| Tibia: knee axis to foot ball centre | 150.0 mm | `TIBIA_LEN_MM` 150 (matches) | +| Foot ball radius | 5.0 mm + 0.5 mm TPU sock wall (same on the real robot) | not modelled | +| Femur/knee axes height | in the coxa's joint plane, 13.5 mm above the bottom plate's top face | coxa pivot at Z 0 (matches if Z 0 = that plane) | +| Leg mounts (coxa axes) | (±100, ±50), (0, ±65) | matches | +| Original pose | femur 0°, tibia -90° (foot straight under the knee) | | + +So the link lengths need no change. What the firmware and Unity don't know +yet: the ground contact is the foot ball's surface (5 mm + sock from the +point the IK places), and the body's underside (coxa bracket lower arm) is +21.5 mm below the joint plane. + +## Physical limits + +Collision sweep of the model's real parts (2D, per joint, in 0.25° steps; +cross-checked with full CGAL intersections of body and legs). Joint +convention as `robot/Config.h`. + +| Joint | Range | Stopped by | +|-------|-------|-----------| +| Coxa, mid legs 1/4 | -66.5° .. 66.5° | coxa bracket shell hits the plate waist | +| Coxa, legs 0 (front-right) / 3 (rear-left) | -95.75° .. 66.75° (sideways mount: -35.75 .. 126.75) | same | +| Coxa, legs 2 (rear-right) / 5 (front-left) | -66.75° .. 95.75° (sideways mount: -126.75 .. 35.75) | same | +| Femur | -101.25° .. 101.25° | femur link hits the coxa bracket | +| Tibia | -177° .. 12.75° | rod folds into the femur link / tibia shell hits it | +| Servo travel | coxa/femur ±90°, tibia -180..0° | from the `SERVOS` pulse ranges | +| Leg width at the joints | 33.7 mm to the horn side + 21.5 mm to the bearing side | neighbours touch at 55.2 mm | + +Hexagon layout (owner's request): corner legs mounted 60° round from the mid +legs (mount angles ±30 / ±90 / ±150); this needs the four corner coxa +brackets turned 60° on their servo splines. In the firmware (`Config.h`): +per-leg `COXA_MIN_DEG`/`COXA_MAX_DEG`, all ±60 now (the sideways layout +needed -32..60 / -60..32 at the corners), +femur ±80 and tibia -150..-20 unchanged (inside the physical range), and +`LEG_CLEARANCE_MM` 56 (was 30). `sim_smoke` passes; turning on the spot +runs at ~77°/s with legs at least 97 mm apart (sideways layout: ~29°/s). + +## Unity simulation + +`export_unity.py` renders the parts into +`Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/*.bytes` +(rerun it after changing the SCAD files). `OpenScadModel.cs` hides the old +scene's robot and builds a new rig with the same names at the exact +Config.h geometry; `HexapodSimulation.useOpenScadModel` switches it off. +`Editor/OpenScadModelSnapshot.cs` renders it in batch mode for checks. + +## Remote (`remote.scad`) + +Rebuilt from `../fusion360/remote/remote v40.f3d` the same way (slices +match the original). Bugs fixed: the lid was off by (0.406, 0.21, -0.365) +mm (sat in the ledge, posts overlapping the bosses); the display bezel's +mesh facets became a clean window; the stray Body12 (an ON-OFF bracket +line never cut in) is left out. The display is a Nextion NX4832T035 +(3.5" Basic); its board's M3 holes are the lid's 94.2 x 48.5 post grid. +Reference parts in `remote.scad`: the joysticks are JH-D400X-R4 (4-axis, +X/Y ±25°, twist ±45°, button; owner), mounted with the dust cover on the +lid's top face; their M2.5 square is 32.5 mm, so the lid's screw holes moved +from the original's 33 mm square to 32.5. The battery is an 18650 UPS/boost +board (89 x 22 mm, SMD-holder version, 22.4 mm tall; owner), on the bottom's +two d5 standoffs at (±27.9, -15) with the floor slots beside them for a +strap; its hole spacing (55.8 mm here) is not confirmed. No collisions. +Also from the owner: the bump on top of the middle post is a lanyard loop +(the original's tunnel through it is modelled); the square hole is the on/off +slide switch, mounted on top (same switch as the robot). Inside: an IMU on the +21 x 9 mm block (assumed GY-521), a USB-C breakout on the side-port block, and +a Teensy **4.0** (confirmed by the owner, although `platformio.ini` builds +the remote for a 4.1); it has no mounting holes and is placed on the +hole-less 8 x 24 mm block as a guess. +Open: what the 6 mm x 10 mm holes in the bottom bosses hold (magnets?), and +whether the upper two bosses are meant to be asymmetric (x -80.387 / +82.73). + +## Notes for future designs + +- Servo wires barely fit on the real robot: the six servo headers hang + above the battery bay, and the front pair sits right over the bay's end + stop (X 28.8..31.8, top at Z 13.5). Kept as-is for now; give the plugs and + wires more room next time (e.g. a lower or shorter end stop, or a battery + that leaves space under the headers). +- The nRF24 under the switch is fine on the real robot. + +## Open questions + +1. Front = switch end (inferred from the ON/OFF text orientation)? +2. PCB direction: `PCB_USB_FRONT` (Teensy USB, 7805 and LED at the front, + nRF24 and battery JST at the rear) or reversed? +3. Battery size. The six servo headers hang above the bay (|Y| 14-15 mm) + and the JP1/JP4 or JP3/JP6 pair touches the end stop; servo plugs and + the battery need to share that space. +4. Leg geometry: in the original the femur servo sits further out than + `COXA_LEN_MM` = 55 suggests. Check when porting the legs. diff --git a/2026-current/hardware/openscad/body.scad b/2026-current/hardware/openscad/body.scad new file mode 100644 index 0000000..432a008 --- /dev/null +++ b/2026-current/hardware/openscad/body.scad @@ -0,0 +1,205 @@ +// 2026 hexapod body: top + bottom plate, rebuilt from the Fusion 360 model +// fusion360/body-bottom/bottom v59.f3d (the `top` and `bottom` solids in +// "bottom v59.stp", which are newer than the top.dxf/bottom.dxf sketches). +// +// Plate geometry is written in the original's top-view coordinates +// (u, v) = (Fusion x, -Fusion z), so every number can be checked against +// the Fusion file. fusion_to_robot() turns that into the robot frame of +// robot/Config.h (+X forward, +Y left, +Z up): X = v, Y = -u. +// Z = 0 is the top face of the bottom plate. +// +// Deviations from the original (its bugs): +// - Left-front pivot boss at u = -50 instead of -50.894. +// - All six coxa servos present (the original has none at right front). +// - Hex nut wells share one orientation (the original's are rotated randomly). + +use <../main-pcb-2026-11-13/main-pcb.scad> +include + +/* [Plates] */ +PLATE_T = 3.0; // original: 3 mm, both plates +CAVITY_H = 27.0; // inner gap; sized so the servo case ends 2 mm above the bottom plate + +/* [Standoffs: split halfway, screw from the top into a captive nut] */ +STANDOFF_D = 15.0; +SCREW_D = 3.1; +SCREW_GRIP = 4.0; // solid length of each half next to the split +COUNTERBORE_D = 7.0; +NUT_AF = 5.5; // M3 nut across flats +STANDOFFS_UV = [[35, 30], [-35, 30], [35, -30], [-35, -30]]; + +/* [Coxa servos] */ +// Shaft axis per leg, in (u, v), with the direction the case extends from the shaft. +// Indices match robot/Config.h legs 0..5. +COXA_UV = [ + [ 50, 100, 230], // 0 right front + [ 65, 0, 180], // 1 right mid + [ 50, -100, 130], // 2 right rear + [-50, -100, 50], // 3 left rear + [-65, 0, 0], // 4 left mid + [-50, 100, -50], // 5 left front (original boss at u = -50.894) +]; +ARM_R = 19.5; // rounded arm end, centred on the coxa axis +PIVOT_BOSS_D = 14.0; // under the bottom plate, carries the coxa bracket +PIVOT_BOSS_H = 2.0; + +/* [Teensy, PCB and switch] */ +TEENSY_CUT = [23.78, 66.96]; // (u, v) size, centred +PCB_BOSSES_UV = [[17.89, 39.48], [-17.89, 39.48], [17.89, -39.48], [-17.89, -39.48]]; +PCB_BOSS_D = 5.0; +PCB_BOSS_H = 1.0; // PCB top face ends up 1 mm under the top plate +PCB_SCREW_D = 2.5; +SWITCH_SLOT = [[-4, 57], [4, 73]]; +SWITCH_HOLES = [[0, 55.5], [0, 74.5]]; +SWITCH_HOLE_D = 2.0; +ENGRAVE_D = 1.0; + +/* [Battery bay on the bottom plate] */ +BAY_RAIL_U = [26.06, 29.06]; // inner/outer face of each rail, mirrored in u +BAY_V = [-74.21, 31.79]; // open end at -v (rear), closed end at +v +BAY_STOP = [[-16, 28.79], [16, 31.79]]; +BAY_H = 13.5; + +/* [Rendering] */ +SHOW_TOP = true; +SHOW_BOTTOM = true; +SHOW_PCB = true; +SHOW_SERVOS = true; +EXPLODE = 0; // mm; lifts the top half +PCB_USB_FRONT = false; // owner: Teensy USB faces away from the switch (rear) + +/* [Hidden] */ +$fa = 2; $fs = 0.3; +STANDOFF_SPLIT = CAVITY_H / 2; +TOP_Z = CAVITY_H + PLATE_T; + +module fusion_to_robot() rotate([0, 0, -90]) children(); + +// linear_extrude with a convexity, so the preview shows holes correctly. +module lx(h) linear_extrude(h, convexity = 10) children(); + +// ---------- outline ------------------------------------------------------- + +function bez2(p, t) = (1-t)*(1-t)*p[0] + 2*(1-t)*t*p[1] + t*t*p[2]; +function rev(l) = [for (i = [len(l)-1:-1:0]) l[i]]; + +NOTCH = [[-34.12, 111.31], [0, 71.93], [34.12, 111.31]]; // front/back notch +WAIST = [[60.03, 83.28], [49.86, 74.48], [50.13, 64.77]]; // corner arm -> side +FILLET_C = [65, 35]; +FILLET_R = 15.5; +N = 24; + +// Right half, front to back; the arm circles fill in the chords at the arm ends. +Q1 = concat( + [for (i = [N/2:N]) bez2(NOTCH, i/N)], + [for (i = [0:N]) bez2(WAIST, i/N)], + [[49.5, 35]], + [for (a = [180:5:270]) FILLET_C + FILLET_R * [cos(a), sin(a)]], + [[65, 0]]); +RIGHT = concat(Q1, [for (p = rev(Q1)) [p[0], -p[1]]]); +OUTLINE = concat(RIGHT, [for (p = rev(RIGHT)) [-p[0], p[1]]]); + +module outline_2d() { + polygon(OUTLINE); + for (c = COXA_UV) translate([c[0], c[1]]) circle(r = ARM_R); +} + +module at_coxa(c) translate([c[0], c[1]]) rotate([0, 0, c[2]]) children(); + +// ---------- top plate ----------------------------------------------------- + +module top_plate_2d() { + difference() { + outline_2d(); + for (c = COXA_UV) at_coxa(c) servo_pocket_2d(); + square(TEENSY_CUT, center = true); + for (s = STANDOFFS_UV) translate(s) circle(d = SCREW_D); + for (b = PCB_BOSSES_UV) translate(b) circle(d = PCB_SCREW_D); + translate(SWITCH_SLOT[0]) square(SWITCH_SLOT[1] - SWITCH_SLOT[0]); + for (h = SWITCH_HOLES) translate(h) circle(d = SWITCH_HOLE_D); + } +} + +module switch_text() { + for (t = [["ON", 49.3], ["OFF", 80.6]]) + translate([0, t[1]]) + text(t[0], size = 5.8, halign = "center", valign = "center", + font = "Liberation Sans"); +} + +module top_half() render(convexity = 10) { + difference() { + union() { + translate([0, 0, CAVITY_H]) lx(PLATE_T) top_plate_2d(); + for (s = STANDOFFS_UV) translate([s[0], s[1], STANDOFF_SPLIT]) + cylinder(d = STANDOFF_D, h = CAVITY_H - STANDOFF_SPLIT); + for (b = PCB_BOSSES_UV) translate([b[0], b[1], CAVITY_H - PCB_BOSS_H]) + cylinder(d = PCB_BOSS_D, h = PCB_BOSS_H); + } + for (s = STANDOFFS_UV) translate([s[0], s[1], 0]) { + translate([0, 0, STANDOFF_SPLIT - 1]) cylinder(d = SCREW_D, h = SCREW_GRIP + 2); + translate([0, 0, STANDOFF_SPLIT + SCREW_GRIP]) cylinder(d = COUNTERBORE_D, h = TOP_Z); + } + for (b = PCB_BOSSES_UV) translate([b[0], b[1], 0]) { + translate([0, 0, CAVITY_H - PCB_BOSS_H - 1]) cylinder(d = PCB_SCREW_D, h = PLATE_T + 2); + translate([0, 0, TOP_Z - 1]) cylinder(d = PCB_BOSS_D, h = 2); + } + translate([0, 0, TOP_Z - ENGRAVE_D]) lx(ENGRAVE_D + 1) switch_text(); + } +} + +// ---------- bottom plate -------------------------------------------------- + +module bay_2d() { + for (m = [1, -1]) scale([m, 1]) + translate([BAY_RAIL_U[0], BAY_V[0]]) + square([BAY_RAIL_U[1] - BAY_RAIL_U[0], BAY_V[1] - BAY_V[0]]); + translate(BAY_STOP[0]) square(BAY_STOP[1] - BAY_STOP[0]); +} + +module bottom_half() render(convexity = 10) { + difference() { + union() { + translate([0, 0, -PLATE_T]) lx(PLATE_T) outline_2d(); + for (s = STANDOFFS_UV) translate([s[0], s[1], 0]) + cylinder(d = STANDOFF_D, h = STANDOFF_SPLIT); + lx(BAY_H) bay_2d(); + for (c = COXA_UV) translate([c[0], c[1], -PLATE_T - PIVOT_BOSS_H]) + cylinder(d = PIVOT_BOSS_D, h = PIVOT_BOSS_H); + } + for (s = STANDOFFS_UV) translate([s[0], s[1], 0]) { + translate([0, 0, STANDOFF_SPLIT - SCREW_GRIP - 1]) cylinder(d = SCREW_D, h = SCREW_GRIP + 2); + translate([0, 0, -PLATE_T - 1]) + cylinder(d = NUT_AF / cos(30), h = STANDOFF_SPLIT - SCREW_GRIP + PLATE_T + 1, $fn = 6); + } + } +} + +// ---------- PCB ----------------------------------------------------------- +// Long axis along the body (robot X), holes on the PCB bosses, top face +// 1 mm under the top plate, Teensy up through the cut-out. + +PCB_T = 1.6; +PCB_HOLE_CENTER = [22.89, 44.52]; // HOLE_GRID_CENTER in main-pcb.scad + +module pcb_in_body() { + translate([0, 0, CAVITY_H - PCB_BOSS_H - PCB_T]) + rotate([0, 0, PCB_USB_FRONT ? -90 : 90]) + translate([-PCB_HOLE_CENTER[0], -PCB_HOLE_CENTER[1], 0]) + main_pcb(); +} + +// ---------- assembly ------------------------------------------------------ + +module body_servos_uv() for (c = COXA_UV) translate([0, 0, TOP_Z]) at_coxa(c) hs485hb(); + +module body_assembly() { + fusion_to_robot() { + if (SHOW_BOTTOM) color("#3a3a3a") bottom_half(); + if (SHOW_TOP) translate([0, 0, EXPLODE]) color("#555555") top_half(); + if (SHOW_SERVOS) translate([0, 0, EXPLODE]) body_servos_uv(); + } + if (SHOW_PCB) translate([0, 0, EXPLODE]) pcb_in_body(); +} + +body_assembly(); diff --git a/2026-current/hardware/openscad/export_unity.py b/2026-current/hardware/openscad/export_unity.py new file mode 100644 index 0000000..697c5eb --- /dev/null +++ b/2026-current/hardware/openscad/export_unity.py @@ -0,0 +1,109 @@ +"""Export the OpenSCAD robot as meshes for the Unity simulation. + + python export_unity.py (from hardware/openscad, needs OpenSCAD) + +Writes Hexapod_Simulation_Unity/Assets/HexapodSim/Resources/OpenScadModel/*.bytes, +loaded by Assets/HexapodSim/OpenScadModel.cs. Every file is one rig segment +in the robot frame (mm, +X forward, +Y left, +Z up), relative to the +segment's pivot, in the rest pose that matches the rig's zero rotations +(coxa 0, femur 0, tibia -90): + body Z = 0 on the joint plane (13.5 mm above the bottom plate) + leg__* left = as leg 4 (mount +90), right = its mirror (mount -90) + +File format (little endian): b"HXM1", int32 groups, then per group +float32 r, g, b, a, int32 triangles, triangles * 9 float32 (x, y, z). +Triangles wind counter-clockwise seen from outside (right-handed). +""" +import os, re, struct, subprocess, sys, tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)).replace("\\", "/") +OUT = os.path.normpath(os.path.join(HERE, "..", "..", "Hexapod_Simulation_Unity", "Assets", + "HexapodSim", "Resources", "OpenScadModel")) +OPENSCAD = os.environ.get("OPENSCAD", r"C:/Program Files/OpenSCAD/openscad.exe") +HEADER = f"use <{HERE}/body.scad>\nuse <{HERE}/leg.scad>\n" +JOINT_PLANE_Z = 13.5 + + +def hexcol(h, a=1.0): + h = h.lstrip("#") + return [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)] + [a] + + +# segment -> list of (colour, OpenSCAD geometry in the segment's frame) +BODY = [ + ("#3a3a3a", f"translate([0, 0, -{JOINT_PLANE_Z}]) fusion_to_robot() bottom_half();"), + ("#555555", f"translate([0, 0, -{JOINT_PLANE_Z}]) fusion_to_robot() top_half();"), + ("#2a2a2a", f"translate([0, 0, -{JOINT_PLANE_Z}]) fusion_to_robot() body_servos_uv();"), + ("#2b6b3a", f"translate([0, 0, -{JOINT_PLANE_Z}]) pcb_in_body();"), +] +LEFT = "rotate([0, 0, 90]) " # leg frame -> robot frame for the left legs (mount +90) +LEG = { + "coxa": [ + ("#d9822b", LEFT + "{ coxa_bracket_upper(); coxa_bracket_lower(); }"), + ("#bdbdbd", LEFT + "translate([55, 0, 0]) along_y(28.7) horn_adapter();"), + ("#5a3a1e", LEFT + "coxa_cables();"), + ], + "femur": [ + ("#4a7fbf", LEFT + "{ link_channel(); link_lid(); }"), + ("#2a2a2a", LEFT + "link_servos();"), + ("#5a3a1e", LEFT + "femur_cables();"), + ], + "tibia": [ + ("#5fa35f", LEFT + "{ tibia_bearing_half(); tibia_horn_half(); }"), + ("#bdbdbd", LEFT + "along_y(28.7) horn_adapter();"), + ("#1c1c1c", LEFT + "foot_sock();"), + ], +} + + +def render(geometry, tmp): + scad = os.path.join(tmp, "part.scad"); stl = os.path.join(tmp, "part.stl") + with open(scad, "w") as f: + f.write(HEADER + geometry + "\n") + r = subprocess.run([OPENSCAD, "-o", stl, scad], capture_output=True, text=True) + if r.returncode != 0 or not os.path.exists(stl): + sys.exit(f"OpenSCAD failed on: {geometry}\n{r.stderr}") + tris = [] + num = r"([-+0-9.eE]+)" + for m in re.finditer(r"outer loop\s+vertex\s+%s\s+%s\s+%s\s+vertex\s+%s\s+%s\s+%s\s+vertex\s+%s\s+%s\s+%s" % ((num,) * 9), + open(stl).read()): + tris.append([float(v) for v in m.groups()]) + os.remove(stl) + return tris + + +def mirror_y(tris): + # (x, y, z) -> (x, -y, z) and swap two vertices to keep outward winding + out = [] + for t in tris: + a, b, c = t[0:3], t[3:6], t[6:9] + a, b, c = [a[0], -a[1], a[2]], [b[0], -b[1], b[2]], [c[0], -c[1], c[2]] + out.append(a + c + b) + return out + + +def write(name, groups): + path = os.path.join(OUT, name + ".bytes") + with open(path, "wb") as f: + f.write(b"HXM1" + struct.pack(" + +/* [Links (match robot/Config.h)] */ +COXA_LEN = 55; +FEMUR_LEN = 100; +TIBIA_LEN = 150; // knee axis to foot ball centre + +/* [Shared joint design] */ +SHELL_R = 25.5; +BRACKET_SHELL_IN = 23.5; +TIBIA_SHELL_IN = 24.0; +LENS_R = 19.963; +LENS_OFFSET = 9.463; +HORN_PLATE = [28.7, 33.7]; +BEARING_PLATE = [-21.5, -18.5]; +RING = [7.2, 10.5, 1.5]; // pocket radius, outer radius, height +BOSS_D = 14; +BOSS_H = 2; +SERVO_EAR_PLANE = 16.5; +HORN_SQUARE = 30; +JOIN = 1; // shells reach this far into their plates, so parts fuse +HORN_HUB_D = 9.2; +HORN_CROSS_DEPTH = 2; + +/* [Fasteners] */ +M3_D = 3.1; +M3_HEAD_D = 5.7; +M3_NUT_AF = 5.7; + +/* [Coxa bracket] */ +BLOCK_Z = [-12, 12.5]; +BLOCK_Y = 15; +BRACKET_BOLTS = [[26.4, 10.92], [26.4, -10.92]]; // (x, y), vertical +CABLE_D = 6; + +/* [Femur link] */ +LINK_X = [-19, 119]; +LINK_Z = 13; // half height +LINK_R = 3; +LINK_WALL = 2; +LINK_TOP = [13.5, 16.5]; // top wall, carries both servos +LINK_LID = [-16.5, -13.5]; +LIP = [0.8, 3, 0.5]; // thickness, height, clearance to the wall +CABLE_SLOT = [-12.5, 3]; // slot for x < -12.5, |y| < 3 + +/* [Tibia] */ +LOBE_X = 25.5; +LOBE_R = 14.985; +LOBE_Y = 12; +TIBIA_BOLTS = [[26.43, 10.585], [26.43, -10.585]]; // (x, z), along y +ROD_R = 5; +ROD_PATH = [[28.5, -0], [47.41, -36.13], [0, -TIBIA_LEN]]; // (x, z), quadratic Bezier + +/* [Foot sock (TPU)] */ +SOCK_T = 0.5; // wall, same as the real printed socks +SOCK_LEN = 12.2; // along the rod from the foot ball centre + +/* [Rendering] */ +SHOW_SERVOS = true; +SHOW_ADAPTERS = true; +SHOW_SOCK = true; +SHOW_CABLES = true; + +/* [Hidden] */ +$fa = 2; $fs = 0.3; +C_BRACKET = "#d9822b"; +C_LINK = "#4a7fbf"; +C_TIBIA = "#5fa35f"; +C_ADAPTER = "#bdbdbd"; +C_SOCK = "#1c1c1c"; +BIG = 200; + +// ---------- helpers ------------------------------------------------------- + +function rot2(p, a) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a)]; +function bez(p, t) = (1-t)*(1-t)*p[0] + 2*(1-t)*t*p[1] + t*t*p[2]; + +// Shell around the local Z axis between r_in and SHELL_R, ±45° about angle a. +module shell(r_in, z0, z1, a = 0) + translate([0, 0, z0]) rotate([0, 0, a - 45]) + lx(z1 - z0) intersection() { + difference() { circle(r = SHELL_R); circle(r = r_in); } + polygon([[0, 0], [2 * SHELL_R, 0], [2 * SHELL_R, 2 * SHELL_R], [0, 2 * SHELL_R]]); + } + +// linear_extrude with a convexity, so the preview shows holes and shells. +module lx(h) linear_extrude(h, convexity = 10) children(); + +// Plate outline around an axis at the origin, biased by `off` along x. +module lens_2d(off) intersection() { + circle(r = SHELL_R); + translate([off, 0]) circle(r = LENS_R); +} + +// Turns a part built along local Z into one along +y (for horizontal axes). +module along_y(y0) translate([0, y0, 0]) rotate([-90, 0, 0]) children(); + +module ring(z0) translate([0, 0, z0]) difference() { + cylinder(r = RING[1], h = RING[2]); + translate([0, 0, -1]) cylinder(r = RING[0], h = RING[2] + 2); +} + +module hex_well(af, h) cylinder(d = af / cos(30), h = h, $fn = 6); + +// The servo cross horn, arms on the diagonals (same in every horn pocket). +CROSS_ARM = concat([[6.106, 0], [10.926, 6.853]], + [for (a = [-34.56 : 10 : 125.44]) [8.874, 8.874] + 2.88 * [cos(a), sin(a)]], + [[6.853, 10.926]]); +module cross_2d() polygon([for (k = [0:3]) for (p = CROSS_ARM) rot2(p, 90 * k)]); + +// Hub hole through, cross pocket in the top HORN_CROSS_DEPTH of a 5 mm plate. +module horn_cut(h = 5) { + translate([0, 0, -1]) cylinder(d = HORN_HUB_D, h = h + 2); + translate([0, 0, h - HORN_CROSS_DEPTH]) lx(HORN_CROSS_DEPTH + 1) cross_2d(); +} + +// Printed horn adapter ("servo horn v6"): sits in a horn plate's square window. +module horn_adapter() color(C_ADAPTER) render(convexity = 10) difference() { + translate([-HORN_SQUARE/2, -HORN_SQUARE/2, 0]) cube([HORN_SQUARE, HORN_SQUARE, 5]); + horn_cut(); +} + +// ---------- coxa bracket -------------------------------------------------- +// Two printed halves split at the joint plane, bolted with 2 x M3. + +// Fills the gap between the two shells; reaches 1 mm into both shell walls so +// each bracket half prints as one solid (in the original they are separate bodies). +module bracket_block(z0, z1) difference() { + translate([18, -BLOCK_Y, z0]) cube([20, 2 * BLOCK_Y, z1 - z0]); + cylinder(r = SHELL_R - 1, h = BIG, center = true); + translate([COXA_LEN, 0, 0]) rotate([90, 0, 0]) cylinder(r = SHELL_R - 1, h = BIG, center = true); +} + +module bracket_common_cuts() { + translate([20, 0, 0]) rotate([0, 90, 0]) cylinder(d = CABLE_D, h = 15); + for (b = BRACKET_BOLTS) translate([b[0], b[1], -5]) cylinder(d = M3_D, h = 10); +} + +module coxa_bracket_upper() color(C_BRACKET) render(convexity = 10) difference() { + union() { + shell(BRACKET_SHELL_IN, 0, HORN_PLATE[0] + JOIN); + translate([0, 0, HORN_PLATE[0]]) difference() { + lx(HORN_PLATE[1] - HORN_PLATE[0]) intersection() { + circle(r = SHELL_R); + union() { + square(HORN_SQUARE, center = true); + translate([LENS_OFFSET, 0]) circle(r = LENS_R); + } + } + horn_cut(); + } + bracket_block(0, BLOCK_Z[1]); + translate([COXA_LEN, 0, 0]) { + along_y(0) shell(BRACKET_SHELL_IN, 0, HORN_PLATE[0] + JOIN, 180); + along_y(HORN_PLATE[0]) lx(HORN_PLATE[1] - HORN_PLATE[0]) difference() { + lens_2d(-LENS_OFFSET); + square(HORN_SQUARE, center = true); + } + } + } + bracket_common_cuts(); + for (b = BRACKET_BOLTS) translate([b[0], b[1], 4]) cylinder(d = M3_HEAD_D, h = 10); +} + +module coxa_bracket_lower() color(C_BRACKET) render(convexity = 10) difference() { + union() { + shell(BRACKET_SHELL_IN, BEARING_PLATE[1] - JOIN, 0); + translate([0, 0, BEARING_PLATE[0]]) + lx(BEARING_PLATE[1] - BEARING_PLATE[0]) lens_2d(LENS_OFFSET); + ring(BEARING_PLATE[1]); + bracket_block(BLOCK_Z[0], 0); + translate([COXA_LEN, 0, 0]) { + along_y(BEARING_PLATE[1] - JOIN) shell(BRACKET_SHELL_IN, 0, -BEARING_PLATE[1] + JOIN, 180); + along_y(BEARING_PLATE[0]) lx(BEARING_PLATE[1] - BEARING_PLATE[0]) + lens_2d(-LENS_OFFSET); + along_y(BEARING_PLATE[1]) ring(0); + } + } + bracket_common_cuts(); + for (b = BRACKET_BOLTS) translate([b[0], b[1], BLOCK_Z[0] - 1]) hex_well(M3_NUT_AF, 9); +} + +// ---------- femur link ---------------------------------------------------- +// Femur frame: origin on the femur axis, knee at x = FEMUR_LEN. + +module link_profile_2d(inset = 0, r = LINK_R) + translate([LINK_X[0] + inset, -LINK_Z + inset]) + offset(r = r) offset(delta = -r) + square([LINK_X[1] - LINK_X[0] - 2*inset, 2*(LINK_Z - inset)]); + +SERVO_SHAFTS = [[0, 0], [FEMUR_LEN, 180]]; // (x, direction the case extends) + +module link_channel() color(C_LINK) render(convexity = 10) difference() { + along_y(LINK_LID[1]) lx(LINK_TOP[1] - LINK_LID[1]) link_profile_2d(); + along_y(LINK_LID[1] - 1) lx(LINK_TOP[0] - LINK_LID[1] + 1) + link_profile_2d(LINK_WALL, 1); + for (s = SERVO_SHAFTS) along_y(LINK_TOP[0] - 1) translate([s[0], 0]) rotate([0, 0, s[1]]) + lx(LINK_TOP[1] - LINK_TOP[0] + 2) servo_pocket_2d(); + translate([LINK_X[0] - 1, -CABLE_SLOT[1], -BIG/2]) + cube([CABLE_SLOT[0] - LINK_X[0] + 1, 2 * CABLE_SLOT[1], BIG]); +} + +module link_lid() color(C_LINK) render(convexity = 10) { + along_y(LINK_LID[0]) lx(LINK_LID[1] - LINK_LID[0]) link_profile_2d(); + for (x = [0, FEMUR_LEN]) translate([x, 0, 0]) + along_y(LINK_LID[0] - BOSS_H) cylinder(d = BOSS_D, h = BOSS_H); + in = LINK_WALL + LIP[2]; + along_y(LINK_LID[1]) lx(LIP[1]) { + for (m = [1, -1]) scale([1, m]) + translate([-10, LINK_Z - in - LIP[0]]) square([120, LIP[0]]); + translate([LINK_X[0] + in, -8]) square([LIP[0], 16]); + translate([LINK_X[1] - in - LIP[0], -8]) square([LIP[0], 16]); + } +} + +module link_servos() if (SHOW_SERVOS) + for (s = SERVO_SHAFTS) translate([s[0], SERVO_EAR_PLANE, 0]) + rotate([-90, 0, 0]) rotate([0, 0, s[1]]) hs485hb(); + +// ---------- tibia --------------------------------------------------------- +// Knee frame: origin on the knee axis, axes as the femur frame in the +// original pose (tibia at -90 degrees, rod hanging down). + +ROD_N = 30; +module rod() for (i = [0:ROD_N-1]) hull() { + a = bez(ROD_PATH, i / ROD_N); b = bez(ROD_PATH, (i + 1) / ROD_N); + translate([a[0], 0, a[1]]) sphere(r = ROD_R, $fn = 36); + translate([b[0], 0, b[1]]) sphere(r = ROD_R, $fn = 36); +} + +module half_space(side) translate([-BIG, side > 0 ? 0 : -BIG, -BIG]) cube([2*BIG, BIG, 2*BIG]); + +// Bolt lobe behind the knee, only outside the shell (r > SHELL_R), so it +// clears the femur link's end at any knee angle. +module tibia_lobe(y0, y1) along_y(y0) lx(y1 - y0) difference() { + translate([LOBE_X, 0]) circle(r = LOBE_R); + circle(r = SHELL_R - JOIN); +} + +// One half of the rod, trimmed to stay outside the shell's inner radius. +module rod_half(side) difference() { + intersection() { rod(); half_space(side); } + along_y(-BIG/2) cylinder(r = TIBIA_SHELL_IN, h = BIG); +} + +module tibia_bolts(y0, h, d) for (b = TIBIA_BOLTS) translate([b[0], 0, b[1]]) along_y(y0) cylinder(d = d, h = h); + +// Bearing side (original "leg left 1"); M3 heads in its 8 mm counterbores. +module tibia_bearing_half() color(C_TIBIA) render(convexity = 10) difference() { + union() { + along_y(BEARING_PLATE[0]) lx(BEARING_PLATE[1] - BEARING_PLATE[0]) lens_2d(LENS_OFFSET); + along_y(BEARING_PLATE[1]) ring(0); + along_y(BEARING_PLATE[1] - JOIN) shell(TIBIA_SHELL_IN, 0, -BEARING_PLATE[1] + JOIN); + tibia_lobe(-LOBE_Y, 0); + rod_half(-1); + } + tibia_bolts(-4, 5, M3_D); + tibia_bolts(-LOBE_Y - 1, LOBE_Y - 4 + 1, M3_HEAD_D); +} + +// Horn side (original "leg left 2"); M3 nuts in its hex wells. +module tibia_horn_half() color(C_TIBIA) render(convexity = 10) difference() { + union() { + along_y(HORN_PLATE[0]) lx(HORN_PLATE[1] - HORN_PLATE[0]) difference() { + lens_2d(LENS_OFFSET); + square(HORN_SQUARE, center = true); + } + along_y(0) shell(TIBIA_SHELL_IN, 0, HORN_PLATE[0] + JOIN); + tibia_lobe(0, LOBE_Y); + rod_half(1); + } + tibia_bolts(-1, 5, M3_D); + for (b = TIBIA_BOLTS) translate([b[0], 0, b[1]]) along_y(4) hex_well(M3_NUT_AF, LOBE_Y); +} + +// TPU sock over the foot ball and the end of the rod. +FOOT = [ROD_PATH[2][0], 0, ROD_PATH[2][1]]; +SOCK_DIR = [ROD_PATH[1][0] - ROD_PATH[2][0], 0, ROD_PATH[1][1] - ROD_PATH[2][1]] + / norm([ROD_PATH[1][0] - ROD_PATH[2][0], ROD_PATH[1][1] - ROD_PATH[2][1]]); +module foot_sock() color(C_SOCK) render(convexity = 10) difference() { + intersection() { + hull() { + translate(FOOT) sphere(r = ROD_R + SOCK_T); + translate(FOOT + SOCK_LEN * SOCK_DIR) sphere(r = ROD_R + SOCK_T); + } + // flat open end, perpendicular to the rod + translate(FOOT) rotate([0, atan2(SOCK_DIR[0], SOCK_DIR[2]), 0]) + translate([-BIG/2, -BIG/2, -BIG]) cube([BIG, BIG, BIG + SOCK_LEN]); + } + rod(); +} + +// ---------- servo cables (reference only) --------------------------------- +// Route of the original: along the lid under the servo cases, up through +// the link's end slot, through the bracket's cable channel, into the body +// cavity at the arm end (the channel sits at the cavity's mid-height). +// The lead leaves the case at the end opposite the shaft, low down. + +CABLE_W = 2.4; +C_CABLE = "#5a3a1e"; + +module cable(pts) color(C_CABLE) + for (i = [0:len(pts)-2]) hull() { + translate(pts[i]) sphere(d = CABLE_W, $fn = 12); + translate(pts[i+1]) sphere(d = CABLE_W, $fn = 12); + } + +// Femur frame part of a lead: case exit -> along the lid -> out of the end slot. +function link_lead(exit_x, toward, z) = [ + [exit_x, -9, z], [exit_x + toward * 2, -12.3, z], [-12, -12.3, z], + [-15.5, -8, z * 0.5], [-17, 0, 0], [LINK_X[0] - 4, 0, 0]]; +// Leg frame part, original pose: bracket channel -> into the body cavity. +LEAD_BODY = [[COXA_LEN - 25.5 - 1, 0, 0], [16, 0, 0], [12, 12, 0], [-35, 12, 0]]; + +// Coxa-frame part: bridge from the link's end slot to the bracket channel, +// then on into the body. +module coxa_cables() { + cable(LEAD_BODY); + cable([[COXA_LEN + LINK_X[0] - 4, 0, 0], [COXA_LEN - 25.5 - 1, 0, 0]]); +} + +// Femur-frame part: both leads inside the femur link. +module femur_cables() { + cable(link_lead(30, 1, -3)); // femur servo + cable(link_lead(FEMUR_LEN - 30, -1, 3)); // knee servo +} + +module leg_cables(pose) rotate([0, 0, pose[0]]) { + coxa_cables(); + translate([COXA_LEN, 0, 0]) rotate([0, -pose[1], 0]) femur_cables(); +} + +// ---------- posed leg ----------------------------------------------------- + +module leg(pose = [0, 0, -90]) { + if (SHOW_CABLES) leg_cables(pose); + leg_parts(pose); +} + +module leg_parts(pose) rotate([0, 0, pose[0]]) { + coxa_bracket_upper(); + coxa_bracket_lower(); + translate([COXA_LEN, 0, 0]) { + if (SHOW_ADAPTERS) along_y(HORN_PLATE[0]) horn_adapter(); + rotate([0, -pose[1], 0]) { + link_channel(); + link_lid(); + link_servos(); + translate([FEMUR_LEN, 0, 0]) rotate([0, -(pose[2] + 90), 0]) { + tibia_bearing_half(); + tibia_horn_half(); + if (SHOW_ADAPTERS) along_y(HORN_PLATE[0]) horn_adapter(); + if (SHOW_SOCK) foot_sock(); + } + } + } +} + +leg(); diff --git a/2026-current/hardware/openscad/old body printed.jpg b/2026-current/hardware/openscad/old body printed.jpg new file mode 100644 index 0000000..76553c7 --- /dev/null +++ b/2026-current/hardware/openscad/old body printed.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19852c13455b5ffbcdc35344ead5b2ec2733278cb81cdd7ba0789224e28899e8 +size 2386844 diff --git a/2026-current/hardware/openscad/remote.scad b/2026-current/hardware/openscad/remote.scad new file mode 100644 index 0000000..174a00d --- /dev/null +++ b/2026-current/hardware/openscad/remote.scad @@ -0,0 +1,245 @@ +// Handheld remote: bottom shell + lid, rebuilt from the Fusion 360 model +// fusion360/remote/remote v40.f3d (the `bottom` and `top` solids in +// "remote v40.stp"; remote v4 is an early flat stick plate). +// +// Written in the original's top-view coordinates (u, v) = (Fusion x, +// -Fusion z), so every number can be checked against the Fusion file. +// Z = 0 on the rim of the bottom shell (the lid's top face sits flush). +// Text on the lid reads upright with +v up. +// +// Deviations from the original (its bugs): +// - Lid moved by (-0.406, -0.21, +0.365) mm: it was off-centre and sat +// 0.365 mm into the bottom's ledge, its posts overlapping the bosses. +// - The display bezel's stepped facets (an imported mesh) are a clean +// rectangular window plus its 0.45 mm lip; small clip notches omitted. +// - Body12 (an "ON-OFF" bracket line that was never cut into the lid) left out. + +/* [Shell] */ +CORNERS = [[-66.5, -58.883], [66.5, -58.883], [66.5, 41.117], [-66.5, 41.117]]; +R_OUT = 37.5; // outside of the bottom shell +R_WALL = 32.5; // inside of the walls (5 mm) +R_LEDGE = 35.5; // rebate the lid drops into +R_LID = 35.0; // lid outline (0.5 mm clearance in the rebate) +FLOOR_Z = [-45, -42]; +LEDGE_Z = -5; +LID_Z = [-5, 0]; + +/* [Fixing] */ +BOSSES = [[-30, 40], [30, 40], [-80.387, -72.711], [82.73, -73]]; +BOSS_D = 10; +BOSS_TOP = -35; +BOSS_HOLE_D = 6; // through the floor, 10 mm deep (magnets?) +POST_HOLE_D = 3; // blind, from the lid post's end + +/* [Sticks, switch, display] */ +STICKS = [[-66.5, 41.117], [66.5, 41.117]]; +STICK_HOLE_D = 40; +STICK_SCREW = 16.25; // 4 x d2 (M2.5 self-tapping) on the joystick's 32.5 mm square (original: 33) +SWITCH_SLOT = [[-4, 33.975], [4, 49.975]]; // on/off slide switch, mounted on top (same as the robot's) +SWITCH_HOLES = [[0, 32.475], [0, 51.475]]; +PILL = [[0, 5], [0, 15]]; // centres of the round ends +WINDOW = [[-37.94, -66.964], [37.96, -16.064]]; +WINDOW_LIP = [0.45, -1.05, -0.45]; // inset, z range +DISPLAY_POCKET = [[-49.0, -73.0], [52.258, -10.0]]; +DISPLAY_POCKET_Z = -2; // pocket from the lid's underside up to here +DISPLAY_POSTS = [[-44.939, -65.772], [49.261, -65.772], [-44.939, -17.272], [49.261, -17.272]]; +TEXT = [["ON", [0.7, 26.29]], ["OFF", [0.15, 57.61]]]; +ENGRAVE_D = 1; + +/* [Rendering] */ +SHOW_BOTTOM = true; +SHOW_LID = true; +SHOW_DISPLAY = true; +SHOW_STICKS = true; +SHOW_BATTERY = true; +EXPLODE = 0; + +/* [Hidden] */ +$fa = 2; $fs = 0.3; +BIG = 300; + +module lx(h) linear_extrude(h, convexity = 10) children(); + +module rounded_2d(r) hull() for (c = CORNERS) translate(c) circle(r = r); + +module stadium_2d(a, b, r) hull() { translate(a) circle(r = r); translate(b) circle(r = r); } + +// ---------- bottom shell -------------------------------------------------- + +module remote_bottom() render(convexity = 10) difference() { + union() { + difference() { + translate([0, 0, FLOOR_Z[0]]) lx(-FLOOR_Z[0]) rounded_2d(R_OUT); + translate([0, 0, FLOOR_Z[1]]) lx(BIG) rounded_2d(R_WALL); + translate([0, 0, LEDGE_Z]) lx(BIG) rounded_2d(R_LEDGE); + } + for (b = BOSSES) translate([b[0], b[1], FLOOR_Z[1] - 1]) cylinder(d = BOSS_D, h = BOSS_TOP - FLOOR_Z[1] + 1); + // blocks rising from the floor + translate([-10.5, 29, FLOOR_Z[1] - 1]) cube([21, 9, -17 - FLOOR_Z[1] + 1]); + translate([-4, 44, FLOOR_Z[1] - 1]) cube([8, 24, -17 - FLOOR_Z[1] + 1]); + for (m = [-1, 1]) translate([m * 27.9, -15, FLOOR_Z[1] - 1]) cylinder(d = 5, h = 4); + translate([88, -10, FLOOR_Z[1] - 1]) cube([12, 14, -28 - FLOOR_Z[1] + 1]); // port board block + // post up through the lid's slot, with a rounded lanyard loop on top + translate([0, 0, FLOOR_Z[1] - 1]) lx(-FLOOR_Z[1] + 1) stadium_2d(PILL[0], PILL[1], 4.75); + difference() { + intersection() { + hull() for (p = PILL) translate([p[0], p[1], 2.75]) sphere(r = 4.75); + translate([-10, 0, 0]) cube([20, 30, 10]); + } + hull() for (p = PILL) translate([-10, p[1], 2.75]) rotate([0, 90, 0]) cylinder(r = 2.75, h = 20); + } + } + for (b = BOSSES) translate([b[0], b[1], FLOOR_Z[0] - 1]) cylinder(d = BOSS_HOLE_D, h = BOSS_TOP - FLOOR_Z[0] + 2); + for (m = [-1, 1]) translate([m * 6.562, 35, FLOOR_Z[1]]) cylinder(d = 3, h = 30); + for (m = [-1, 1]) translate([m * 27.9, -15, FLOOR_Z[1]]) cylinder(d = 3, h = 10); + for (m = [-1, 1]) translate([0, 0, FLOOR_Z[0] - 1]) + lx(5) stadium_2d([m * 23.9, -17], [m * 23.9, -13], 1); + translate([98, -10, -35]) cube([10, 14, 15]); // side port opening + for (v = [-7.5, 1.5]) translate([91, v, -37]) cylinder(d = 3, h = 10); +} + +// ---------- lid ----------------------------------------------------------- + +module lid_plate_2d() difference() { + rounded_2d(R_LID); + for (s = STICKS) translate(s) { + circle(d = STICK_HOLE_D); + for (du = [-1, 1], dv = [-1, 1]) translate([du, dv] * STICK_SCREW) circle(d = 2); + } + stadium_2d(PILL[0], PILL[1], 5); + translate(SWITCH_SLOT[0]) square(SWITCH_SLOT[1] - SWITCH_SLOT[0]); + for (h = SWITCH_HOLES) translate(h) circle(d = 2); + translate(WINDOW[0]) offset(r = 0.5) offset(delta = -0.5) square(WINDOW[1] - WINDOW[0]); +} + +module remote_lid() render(convexity = 10) difference() { + union() { + translate([0, 0, LID_Z[0]]) lx(LID_Z[1] - LID_Z[0]) lid_plate_2d(); + for (b = BOSSES) translate([b[0], b[1], BOSS_TOP]) cylinder(d = BOSS_D, h = LID_Z[0] - BOSS_TOP + 1); + // window lip, left over from the original's display bezel + translate([0, 0, WINDOW_LIP[1]]) lx(WINDOW_LIP[2] - WINDOW_LIP[1]) difference() { + translate(WINDOW[0]) square(WINDOW[1] - WINDOW[0]); + translate(WINDOW[0] + [1, 1] * WINDOW_LIP[0]) square(WINDOW[1] - WINDOW[0] - [2, 2] * WINDOW_LIP[0]); + } + } + translate([0, 0, LID_Z[0] - 1]) lx(DISPLAY_POCKET_Z - LID_Z[0] + 1) difference() { + translate(DISPLAY_POCKET[0]) square(DISPLAY_POCKET[1] - DISPLAY_POCKET[0]); + for (p = DISPLAY_POSTS) translate(p) circle(d = 6); + } + for (p = DISPLAY_POSTS) translate([p[0], p[1], LID_Z[0] - 1]) cylinder(d = 2.5, h = 4); + for (b = BOSSES) translate([b[0], b[1], BOSS_TOP - 1]) cylinder(d = POST_HOLE_D, h = LID_Z[0] - BOSS_TOP + 1); + for (t = TEXT) translate([t[1][0], t[1][1], LID_Z[1] - ENGRAVE_D]) lx(ENGRAVE_D + 1) + text(t[0], size = 5.8, halign = "center", valign = "center", font = "Liberation Sans"); +} + +// ---------- reference parts ----------------------------------------------- + +// Nextion NX4832T035 (3.5" Basic): 100.5 x 54.94 mm board, M3 holes 3.15 / +// 3.22 mm from its edges = the lid's 94.2 x 48.5 post grid; LCD module +// approximate, its visible area (73.44 x 48.96) under the window. +module nextion_3_5() { + centre = (DISPLAY_POSTS[0] + DISPLAY_POSTS[3]) / 2; + translate([centre[0], centre[1], DISPLAY_POCKET_Z - 4.5 - 1.6]) { + color("#1e5a2a") difference() { + translate([-100.5/2, -54.94/2, 0]) cube([100.5, 54.94, 1.6]); + for (p = DISPLAY_POSTS) translate([p[0] - centre[0], p[1] - centre[1], -1]) cylinder(d = 3.2, h = 4); + } + } + win = (WINDOW[0] + WINDOW[1]) / 2; + color("#202020") translate([win[0] - 84/2, win[1] - 54/2, DISPLAY_POCKET_Z - 4.5]) cube([84, 54, 4.5]); + color("#3050a0") translate([win[0] - 73.44/2, win[1] - 48.96/2, DISPLAY_POCKET_Z - 0.2]) cube([73.44, 48.96, 0.2]); +} + +// JH-D400X-R4 4-axis joystick (X/Y +-25 deg, twist +-45 deg, button), +// from its dimension drawing. Mounted with its dust cover on the lid's top +// face; the body hangs through the d40 opening. Local frame: stick axis on +// Z, Z = 0 on the dust cover's underside. Pots on +X and +Y. +JOY_COVER_D = 53.5; +JOY_COVER_T = 3; // not dimensioned; drawing proportions +JOY_BOX = 40.4; // control box, square +JOY_BOX_H = 25.5; +JOY_BELOW = 42; // box bottom to dust cover top +JOY_ABOVE = 52.5; // dust cover top to button top +JOY_POT_D = 16.5; +JOY_POT_L = (49.6 - 40.4); + +module jh_d400x() { + top = JOY_COVER_T; + color("#202020") { + difference() { + cylinder(d = JOY_COVER_D, h = JOY_COVER_T); + for (du = [-1, 1], dv = [-1, 1]) translate([du * 16.25, dv * 16.25, -1]) cylinder(d = 2.7, h = 5); + } + translate([0, 0, top]) cylinder(d1 = 39.5, d2 = 30, h = 3); // dust ring / boot + translate([0, 0, top - JOY_BELOW]) translate([-JOY_BOX/2, -JOY_BOX/2, 0]) cube([JOY_BOX, JOY_BOX, JOY_BOX_H]); + translate([0, 0, top - JOY_BELOW + JOY_BOX_H]) cylinder(d = 34, h = JOY_BELOW - JOY_BOX_H - JOY_COVER_T + 0.01); + for (a = [0, 90]) rotate([0, 0, a]) + translate([JOY_BOX/2, 0, top - JOY_BELOW + JOY_BOX_H/2]) rotate([0, 90, 0]) cylinder(d = JOY_POT_D, h = JOY_POT_L); + // handle: neck, knurled body, top with the button + translate([0, 0, top + 6]) cylinder(d = 6, h = 12); + translate([0, 0, top + 16]) cylinder(d = 22, h = 8); + translate([0, 0, top + 24]) cylinder(d = 35, h = 22); + translate([0, 0, top + 46]) cylinder(d1 = 35, d2 = 26, h = 5); + } + color("#c0c0c0") translate([0, 0, top + 51]) cylinder(d = 8, h = JOY_ABOVE - 51); +} + +// 18650 UPS / boost board (5/9/12 V, Type-C charging), "SMD battery box" +// version: 89 x 22 mm board, holder on top, 22.4 mm overall, d3 holes. +// Placed on the bottom's two d5 standoffs at (+-27.9, -15); the board's real +// hole spacing isn't confirmed. The floor slots beside them take a strap. +UPS_CENTRE = [0, -15]; +UPS_Z = -39; // standoff tops +module ups_18650() translate([UPS_CENTRE[0], UPS_CENTRE[1], UPS_Z]) { + color("#202020") difference() { + translate([-89/2, -22/2, 0]) cube([89, 22, 1.6]); + for (m = [-1, 1]) translate([m * 27.9, 0, -1]) cylinder(d = 3, h = 4); + } + color("#151515") translate([-77/2, -21/2, 1.6]) difference() { + cube([77, 21, 22.4 - 1.6]); + translate([3, 1.5, 3]) cube([71, 18, 30]); + } + color("#4060c0") translate([-65/2, 0, 1.6 + 3 + 18.5/2]) rotate([0, 90, 0]) cylinder(d = 18.5, h = 65); + color("#b0b0b0") translate([89/2 - 7, -4.5, 1.6]) cube([7.5, 9, 3.2]); // Type-C +} + +// Small USB-C breakout (owner: glued in the holder at the side port), on the +// port block (u 88..99, v -10..4, top Z -28) with the receptacle facing the +// opening (v -10..4, Z -35..-20). Board size approximate. +module usb_c_breakout() translate([88, -10, -28]) { + color("#1a3a8a") cube([11, 14, 1.6]); + color("#c0c0c0") translate([11 - 7.3, 7 - 8.9/2, 1.6]) cube([7.5, 8.9, 3.2]); +} + +// IMU (owner: on the big standoff in the middle), assumed a GY-521 / MPU6050 +// like the robot's: 21.2 x 15.6 mm board on the 21 x 9 mm block (top Z -17), +// its hole end over the block's two d3 holes at v 35, board towards the centre. +module imu_gy521() translate([0, 35 + 3.5 - 15.6, -17]) { + color("#1f4fa0") translate([-21.2/2, 0, 0]) cube([21.2, 15.6, 1.6]); + color("#222") translate([-2, 7, 1.6]) cube([4, 4, 1]); +} + +// Teensy 4.0 (owner; no mounting holes), assumed on the hole-less 8 x 24 mm +// block (u -4..4, v 44..68, top Z -17). Across the block by default; lengthwise +// it would end 0.1 mm from the shell wall. +TEENSY_LENGTHWISE = false; +module teensy_4_0() translate([0, 56, -17]) rotate([0, 0, TEENSY_LENGTHWISE ? 90 : 0]) { + color("#1e5a2a") translate([-35.6/2, -17.8/2, 0]) cube([35.6, 17.8, 1.6]); + color("#222") translate([-3, -5, 1.6]) cube([10, 10, 1.2]); // i.MX RT1062 + color("#c0c0c0") translate([-35.6/2 - 1, -7.5/2, 1.6]) cube([5.7, 7.5, 2.9]); // micro-USB +} + +module remote_assembly() { + if (SHOW_BOTTOM) color("#3a3a3a") remote_bottom(); + if (SHOW_LID) translate([0, 0, EXPLODE]) color("#5a5a5a") remote_lid(); + if (SHOW_DISPLAY) translate([0, 0, EXPLODE]) nextion_3_5(); + if (SHOW_BATTERY) ups_18650(); + if (SHOW_BATTERY) usb_c_breakout(); + if (SHOW_BATTERY) imu_gy521(); + if (SHOW_BATTERY) teensy_4_0(); + // pots towards the nearest side wall and the near edge, where there's room + if (SHOW_STICKS) for (s = STICKS) translate([s[0], s[1], LID_Z[1] + EXPLODE]) + mirror([s[0] < 0 ? 1 : 0, 0, 0]) jh_d400x(); +} + +remote_assembly(); diff --git a/2026-current/hardware/openscad/renders/hexapod.png b/2026-current/hardware/openscad/renders/hexapod.png new file mode 100644 index 0000000..09ba840 --- /dev/null +++ b/2026-current/hardware/openscad/renders/hexapod.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed77da80a9c40ccfadde1092aec6a838f8c8df6bba18f442760a08b6d3c16ded +size 171496 diff --git a/2026-current/hardware/openscad/renders/remote.png b/2026-current/hardware/openscad/renders/remote.png new file mode 100644 index 0000000..a7630dd --- /dev/null +++ b/2026-current/hardware/openscad/renders/remote.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2a80a4883502908d08b772cb3265b8660326e49d4fd10099d05503cd5cd6e15 +size 69568 diff --git a/2026-current/hardware/openscad/robot.scad b/2026-current/hardware/openscad/robot.scad new file mode 100644 index 0000000..c2f76fc --- /dev/null +++ b/2026-current/hardware/openscad/robot.scad @@ -0,0 +1,36 @@ +// The whole 2026 hexapod: body (body.scad) plus six legs (leg.scad), +// placed and posed like robot/Config.h. Robot frame: +X forward (switch +// end), +Y left, +Z up, Z = 0 on the bottom plate's top face. + +use +use + +/* [Pose] */ +// "original" = the Fusion model's pose; "stand" = firmware stance +// (Kinematics::neutralStance(): feet 130 mm out, 150 mm down). +POSE_NAME = "stand"; // [original, stand] +// Optional per-leg override, [coxa, femur, tibia] in degrees, legs 0..5. +POSE_OVERRIDE = []; + +// Leg mounts from robot/Config.h: x, y, mount angle (0..2 right, 3..5 left). +// Hexagon layout: corner legs 60 deg round from the mid legs. +LEG_MOUNT = [[100, -50, -30], [0, -65, -90], [-100, -50, -150], + [-100, 50, 150], [0, 65, 90], [100, 50, 30]]; +JOINT_PLANE_Z = 13.5; // femur/knee axes, halfway up the body cavity + +POSES = [["original", [0, 0, -90]], ["stand", [0, -1.2, -98.3]]]; +function base_pose() = [for (p = POSES) if (p[0] == POSE_NAME) p[1]][0]; +function leg_pose(i) = len(POSE_OVERRIDE) == 6 ? POSE_OVERRIDE[i] : base_pose(); + +module robot() { + body_assembly(); + for (i = [0:5]) { + m = LEG_MOUNT[i]; + p = leg_pose(i); + translate([m[0], m[1], JOINT_PLANE_Z]) + if (m[2] > 0) rotate([0, 0, m[2]]) leg(p); + else mirror([0, 1, 0]) rotate([0, 0, -m[2]]) leg([-p[0], p[1], p[2]]); + } +} + +robot(); diff --git a/2026-current/hardware/openscad/servo.scad b/2026-current/hardware/openscad/servo.scad new file mode 100644 index 0000000..f84fe2a --- /dev/null +++ b/2026-current/hardware/openscad/servo.scad @@ -0,0 +1,28 @@ +// Hitec HS-485HB, from the servo's Fusion model (servo-hitec-hs-485hb). +// Local frame: shaft on the Z axis, case extending +X, Z = 0 at the ear +// underside. Include (not use) this file to get the constants. + +SERVO_CASE = [[-10, -10, -28], [30, 10, 9.75]]; +SERVO_EARS = [[-16, -10, 0], [36, 10, 3]]; +SERVO_EAR_HOLES = [[-13.9, 5], [-13.9, -5], [34.35, 5], [34.35, -5]]; +SERVO_EAR_SCREW_D = 2.5; // pilot hole for the ear screws in printed parts +POCKET_CLEAR = 0.5; + +module servo_pocket_2d() { + translate([SERVO_CASE[0][0] - POCKET_CLEAR, SERVO_CASE[0][1] - POCKET_CLEAR]) + square([SERVO_CASE[1][0] - SERVO_CASE[0][0] + 2*POCKET_CLEAR, + SERVO_CASE[1][1] - SERVO_CASE[0][1] + 2*POCKET_CLEAR]); + for (h = SERVO_EAR_HOLES) translate(h) circle(d = SERVO_EAR_SCREW_D); +} + +module hs485hb() { + color("#303030") { + translate(SERVO_CASE[0]) cube(SERVO_CASE[1] - SERVO_CASE[0]); + difference() { + translate(SERVO_EARS[0]) cube(SERVO_EARS[1] - SERVO_EARS[0]); + for (h = SERVO_EAR_HOLES) translate([h[0], h[1], -1]) cylinder(d = 4.42, h = 5); + } + translate([0, 0, 9.75]) cylinder(d = 14, h = 2.25); + } + color("#e0e0e0") translate([0, 0, 12]) cylinder(d = 5, h = 3.25); +} diff --git a/2026-current/robot/Config.h b/2026-current/robot/Config.h index 8cb8331..1d6501b 100644 --- a/2026-current/robot/Config.h +++ b/2026-current/robot/Config.h @@ -9,12 +9,19 @@ // ---- Leg geometry ---- // Taken from the Unity model (Hexapod_Simulation_Unity/Assets/Main.unity, // 1 Unity unit = 10 mm): hip pivots at x = +-10 / 0, z = +-5 / +-6.5; -// coxa 5.5, femur 10, tibia 15. BENCH: confirm against the real chassis. +// coxa 5.5, femur 10, tibia 15. Confirmed exactly against the real +// chassis's CAD (hardware/openscad, from the Fusion model): coxa axes at +// these mounts, coxa 55 / femur 100 / tibia 150 mm to the centre of the +// 10 mm foot ball. The femur and knee axes lie in the coxa pivot plane +// (Z = 0), 13.5 mm above the bottom plate's top face. constexpr int NUM_LEGS = 6; // Leg order: 0=front-right, 1=mid-right, 2=rear-right, 3=rear-left, 4=mid-left, 5=front-left // Angles measured counterclockwise from the body's +X (forward) axis, viewed from above. -// This is the direction the coxa points at servo center: straight sideways on this chassis. -constexpr float LEG_MOUNT_ANGLE_DEG[NUM_LEGS] = { -90, -90, -90, 90, 90, 90 }; +// This is the direction the coxa points at servo center. Hexagon layout (owner's +// request): mid legs straight sideways, front/rear legs 60 deg further round, so +// all six are 60 deg apart. BENCH: the corner coxa brackets sit 60 deg round on +// their servo splines (4 teeth of 24) compared with the old sideways layout. +constexpr float LEG_MOUNT_ANGLE_DEG[NUM_LEGS] = { -30, -90, -150, 150, 90, 30 }; constexpr float LEG_MOUNT_X_MM[NUM_LEGS] = { 100, 0, -100, -100, 0, 100 }; constexpr float LEG_MOUNT_Y_MM[NUM_LEGS] = { -50, -65, -50, 50, 65, 50 }; @@ -23,7 +30,19 @@ constexpr float FEMUR_LEN_MM = 100.0f; constexpr float TIBIA_LEN_MM = 150.0f; // ---- Joint limits, degrees ---- -constexpr float COXA_MIN_DEG = -60, COXA_MAX_DEG = 60; +// Physical limits from the OpenSCAD model (hardware/openscad, collision +// sweep of the real parts, see PLANNING.md "Physical limits"), and servo +// travel (+-90 coxa/femur, -180..0 tibia): +// coxa mid legs -66.5 .. 66.5 coxa bracket shell hits the plate +// coxa 0 front-right, 3 rear-left -95.75 .. 66.75 (servo: -90) +// coxa 2 rear-right, 5 front-left -66.75 .. 95.75 (servo: 90) +// femur -101 .. 101 femur link hits the coxa bracket (servo: 90) +// tibia -177 .. 12.75 rod folds into the femur link / shell hits it (servo: 0) +// The limits below keep ~3 deg from those and the earlier software limits +// where those are tighter. Per leg, because the corner legs' ranges differ (in +// the old sideways layout they could only swing ~36 deg towards the mid leg). +constexpr float COXA_MIN_DEG[NUM_LEGS] = { -60, -60, -60, -60, -60, -60 }; +constexpr float COXA_MAX_DEG[NUM_LEGS] = { 60, 60, 60, 60, 60, 60 }; constexpr float FEMUR_MIN_DEG = -80, FEMUR_MAX_DEG = 80; constexpr float TIBIA_MIN_DEG = -150, TIBIA_MAX_DEG = -20; @@ -41,8 +60,10 @@ constexpr float STANCE_HEIGHT_RATE_MM_S = 60.0f; // ---- Leg collision ---- // Minimum distance between the center lines of neighbouring legs' segments // (coxa, femur, tibia). Gait strides and body poses are scaled down to keep -// it. BENCH: set to roughly the width of a leg (servo body + bracket). -constexpr float LEG_CLEARANCE_MM = 30.0f; +// it. From the OpenSCAD model: at the joints a leg reaches 33.7 mm to its +// horn side (rear) and 21.5 mm to its bearing side (front), so two +// neighbours touch at 55.2 mm between center lines. +constexpr float LEG_CLEARANCE_MM = 56.0f; // ---- Servos (Teensy 3.6, hardware PWM at SERVO_PWM_HZ) ---- // One entry per servo: pulse = us_at_0deg + us_per_deg * joint_deg, clamped diff --git a/2026-current/robot/Kinematics.cpp b/2026-current/robot/Kinematics.cpp index 1055e2f..840b62f 100644 --- a/2026-current/robot/Kinematics.cpp +++ b/2026-current/robot/Kinematics.cpp @@ -51,7 +51,7 @@ JointAngles Kinematics::solve(int i, const FootTarget& target) { out.femur_deg = degrees(femur_rad); out.tibia_deg = degrees(tibia_rad); - if (out.coxa_deg < COXA_MIN_DEG || out.coxa_deg > COXA_MAX_DEG) out.valid = false; + if (out.coxa_deg < COXA_MIN_DEG[i] || out.coxa_deg > COXA_MAX_DEG[i]) out.valid = false; if (out.femur_deg < FEMUR_MIN_DEG || out.femur_deg > FEMUR_MAX_DEG) out.valid = false; if (out.tibia_deg < TIBIA_MIN_DEG || out.tibia_deg > TIBIA_MAX_DEG) out.valid = false; diff --git a/2026-current/sim/HexapodSimApi.cpp b/2026-current/sim/HexapodSimApi.cpp index 6233527..942cdd0 100644 --- a/2026-current/sim/HexapodSimApi.cpp +++ b/2026-current/sim/HexapodSimApi.cpp @@ -40,13 +40,14 @@ HXSIM_API void hxsim_get_geometry(HxGeometry* g) { g->mount_x_mm[i] = LEG_MOUNT_X_MM[i]; g->mount_y_mm[i] = LEG_MOUNT_Y_MM[i]; g->mount_angle_deg[i] = LEG_MOUNT_ANGLE_DEG[i]; + g->coxa_min_deg[i] = COXA_MIN_DEG[i]; + g->coxa_max_deg[i] = COXA_MAX_DEG[i]; } g->coxa_mm = COXA_LEN_MM; g->femur_mm = FEMUR_LEN_MM; g->tibia_mm = TIBIA_LEN_MM; g->stance_radius_mm = STANCE_RADIUS_MM; g->stance_height_mm = STANCE_HEIGHT_MM; - g->coxa_min_deg = COXA_MIN_DEG; g->coxa_max_deg = COXA_MAX_DEG; g->femur_min_deg = FEMUR_MIN_DEG; g->femur_max_deg = FEMUR_MAX_DEG; g->tibia_min_deg = TIBIA_MIN_DEG; g->tibia_max_deg = TIBIA_MAX_DEG; g->control_hz = CONTROL_HZ; diff --git a/2026-current/sim/HexapodSimApi.h b/2026-current/sim/HexapodSimApi.h index 94eb880..711b752 100644 --- a/2026-current/sim/HexapodSimApi.h +++ b/2026-current/sim/HexapodSimApi.h @@ -18,7 +18,7 @@ #endif // Bump whenever a signature or struct below changes; the C# side checks it. -#define HXSIM_API_VERSION 6 +#define HXSIM_API_VERSION 7 #define HXSIM_NUM_LEGS 6 @@ -30,7 +30,8 @@ typedef struct { float mount_angle_deg[HXSIM_NUM_LEGS]; float coxa_mm, femur_mm, tibia_mm; float stance_radius_mm, stance_height_mm; - float coxa_min_deg, coxa_max_deg; + float coxa_min_deg[HXSIM_NUM_LEGS]; // per leg: corner legs can't swing far towards the mid leg + float coxa_max_deg[HXSIM_NUM_LEGS]; float femur_min_deg, femur_max_deg; float tibia_min_deg, tibia_max_deg; float control_hz;