2026: OpenSCAD robot and remote, physical joint limits, model in Unity

hardware/openscad/ rebuilds the broken Fusion models as OpenSCAD:
body (top/bottom plate, standoffs, battery bay, switch), legs (coxa
bracket, femur link, tibia, horn adapters, TPU socks, servo cables),
the whole robot, and the remote (shell, lid, Nextion 3.5", JH-D400X-R4
sticks, 18650 UPS board, IMU, Teensy 4.0, USB-C breakout). Every part
was checked by slicing against the original STEP solids; the original's
bugs are fixed and listed in PLANNING.md.

The robot now uses a hexagon leg layout (corner legs 60 deg round from
the mid legs; the corner coxa brackets need turning on their splines).
Physical limits from a collision sweep of the model are in Config.h:
per-leg coxa limits and LEG_CLEARANCE_MM 56 (a leg is 55.2 mm wide at
the joints); the sim API exports them (version 7). sim_smoke passes.

Unity loads the model (export_unity.py -> Resources/OpenScadModel,
OpenScadModel.cs builds a new rig in place of the old scene's robot).
The PCB model's mounting holes now use the exact EasyEDA grid. README
shows renders of both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-11-14 12:14:32 +01:00
1 parent b1c94e4149
commit 91b5b99fd5
42 files changed
+1560 -20

No files matched your search

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4bfa2732a93b22f48babbbb0cf21cc1b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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 <folder> -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<Transform>())
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<Camera>();
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9aa5d204e0b61604b9d0c92405edb7a8
@@ -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();
@@ -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();
@@ -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<TextAsset>(Folder + "body") != null;
public static void Build(Transform chassis, HxGeometry g, ForwardAxis f, float unitsPerMm)
{
Material template = null;
foreach (var r in chassis.GetComponentsInChildren<Renderer>(true))
{
if (template == null && r.sharedMaterial) template = r.sharedMaterial;
r.enabled = false;
}
foreach (var t in Object.FindObjectsByType<Transform>())
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<Collider>();
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<Renderer>().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<TextAsset>(resource);
if (asset == null) { Debug.LogWarning($"[HexapodSim] missing model mesh {resource}"); return; }
var verts = new List<Vector3>();
var groups = new List<int[]>();
var mats = new List<Material>();
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<MeshFilter>().sharedMesh = mesh;
go.AddComponent<MeshRenderer>().sharedMaterials = mats.ToArray();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ac8b69c6750b194da6fcfc0d9d6cf48
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 027b28f48b4d1d34799b61471976c26b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 86e3f55e00f9d5f4a9ae4f95c2dad14b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 119d6760b10e2d94c89d00627245b5fd
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c8b5e767dc591144899b7e32a99ceabd
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 545cab1c5e6fdb6479a51faf4890b22e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1b15eff82e4f31d4d9ae657c556b7874
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 12b3384c18597f746a655d3d1558942e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6215130e92405f248957c261f62f4ff7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 853358b18f634bb438e5980191cfda74
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: