97mmの乖離は、fk_scan_motion_based.csv のコピーし忘れだった話
LOG170R2R2R2026/4/25

97mmの乖離は、fk_scan_motion_based.csv のコピーし忘れだった話

4つの仮説を順に否定して、5つ目が正解だった。

💡 Key Takeaway

FKスキャンが記録したee座標と、Unity上で実測したBrep位置が97mm離れていた。4つの仮説を立て、順番に潰していった。全部外れた。残った5つ目が正解で、Mac側の fk_scan_motion_based.csv が数週間前の改修前のまま放置されていただけだった。1コマンドで上書きして、lookup最近傍距離は71.10mm → 0.30mmになった。

やったこと

起きたこと

判断

FKScanner_MotionBased.cs を読んだ

コードは構造的に正しかった

仮説A(並列実行誤差)を否定

Brep の Hierarchy パスを突き合わせた

同じ Brep を参照していた

仮説B/C(参照ズレ)を否定

TipBrepReader の Wait を 3秒 → 0.5秒に変えた

結果は 1.4mm しか変わらなかった

仮説D(物理収束時間)を否定

Mac側とUnity側のCSVのタイムスタンプを比較した

1日違いで中身が別物だった

仮説E(CSVが古い)を確定

Unity側CSVをMac側にコピーした

XZ最近傍が 71.10mm → 0.30mm

案A完了

97mm、原因不明から始まった

前日のセッションで fk_scan_motion_based.csv に書かれたee座標と、TipBrepReaderで実測した同anglesのBrep中点位置が97mm離れていることが判明した。

angles = [-50, 100, -55, -2, -35, -20]
CSV ee = (0.183, 0.042, 0.154)
実測 ee = (0.252, 0.018, 0.218)
3D距離 = 約 97mm

「同じアームの、同じ姿勢で、なんでここまで違う」

候補はいくらでも挙げられた。並列実行の誤差、物理シミュのタイミング、Brepの参照ズレ、座標系の根本的な不一致。前日に追加したばかりのP008を思い出した。測れる手段を持つことが、推論より先にくる。手当たり次第に推論するより先に、何が測れるかを整理した。

4つの仮説を立て、順に消した

Claudeと一緒に原因候補を4つに絞った。

仮説

内容

A

20体並列実行時の座標計算誤差

B/C

参照Brepの不一致(リネーム作業の影響)

D

WaitForSeconds(0.5f) が短くて物理収束が間に合わない

E

CSVが生成時のまま古くなっている

Claudeは「Dが一番怪しい」と言った。FKスキャナは0.5秒で読んでいるのに、TipBrepReaderは3秒待つ。並列負荷下でその差が効いている可能性。

仮説Aを消した。 20体のWorld Rotationを全部確認したら全員(0,0,0)。Positionの差は相対座標化で吸収されるので問題ない。

仮説B/Cを消した。 TipBrepReaderのInspectorでleftTipBrep / rightTipBrepをダブルクリックして参照先を辿った。FKScanner_MotionBasedの FindGripperTipBrep が辿るパス(gripper_left1 → ... → Instance-Def-210103_4302_101 → Brep)と完全一致。同じBrepを見ている。

仮説Dを消した。 TipBrepReaderの Wait Seconds After Set を3秒から0.5秒に変えて再実行した。

Wait 3秒  : Midpoint = (0.251820, 0.018492, 0.217731)
Wait 0.5秒: Midpoint = (0.251547, 0.017351, 0.217040)
差        : 約 1.4mm

1.4mm。97mm乖離の説明にならない。3つ消えて、仮説が枯渇した。

過去に起きていたことを遡った

Claudeが過去記事を検索した。数週間前のセッションに、こういう発覚が記録されていた。

「213,312点が、実は手首の位置だった」

それまでのFKスキャナーは endEffector.position - root.position、つまりlink6フランジ(手首)の位置をeeとして記録していた。グリッパ先端ではなかった。気づいたときスキャナー4本を書き換えて、座標系を「グリッパ先端Brep中点」に統一し直した。深夜0時過ぎまでかけて4回スキャンを回し直した日だった。

その改修前後のee差を計算してみた。

CSV 値(旧、手首基準)  : (0.183, 0.042, 0.154)
TipBrepReader 実測(新): (0.252, 0.018, 0.218)
差                      : (+69, -24, +64) mm

グリッパの寸法そのものだった。link6フランジからグリッパ先端Brep中点までのオフセット。

仮説Eの輪郭が見えた。改修日にUnity側のスキャナーは更新された。Unity側の Assets/Data/ にある CSV も更新された。でも Mac側の r2r2r-scripts/ にある CSV が、改修前の手首基準のまま残っているのではないか。

ファイルのタイムスタンプが答えを持っていた

ls -la で両方のCSVを比較した。

ls -la ~/r2r2r-scripts/fk_scan_motion_based.csv
ls -la ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv

wc -l ~/r2r2r-scripts/fk_scan_motion_based.csv
wc -l ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv
Mac 側  : 4月18日 10:54、15.7MB、213,313行
Unity 側: 4月19日 06:27、37.5MB、512,088行

1日違い。サイズも行数も別物。同anglesの行をgrepで引いて並べた。

grep "^[^,]*,[^,]*,[^,]*,-50.000,100.000,-55.000,-2.000,-35.000,-20.000" \
  ~/r2r2r-scripts/fk_scan_motion_based.csv

grep "^[^,]*,[^,]*,[^,]*,-50.000,100.000,-55.000,-2.000,-35.000,-20.000" \
  ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv
Mac 側  : ee = (0.182957, 0.041725, 0.153837)
Unity 側: ee = (0.252898, 0.018800, 0.219693)

Unity側の値とTipBrepReader実測値の差は1〜2mm。ほぼ一致。

仮説E確定。 手首位置基準の古いCSVがMac側に残っていた。Piの r2r2r_loop.py がそれを参照していたから、lookupで返される姿勢は「手首がブロック中心に来る」angles、つまりグリッパ先端はそこから97mmズレた場所にあった。

1コマンドで終わった

旧CSVをバックアップしてからコピーした。

cp ~/r2r2r-scripts/fk_scan_motion_based.csv \
   ~/r2r2r-scripts/fk_scan_motion_based.csv.bak_DAY170_pre_overwrite_$(date +%Y%m%d_%H%M%S)

cp ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv \
   ~/r2r2r-scripts/fk_scan_motion_based.csv

5秒で完了した。

71mm が 0.3mm になった

verify_lookup_day169.py を走らせて効果を確認した。

cd ~/r2r2r-scripts && python3 verify_lookup_day169.py

比較

旧CSV

新CSV

改善

総行数

213,312

512,087

2.4倍

3D最近傍

82.98mm

1.02mm

81倍

XZ最近傍

71.10mm

0.30mm

237倍

座標系を改修した日にスキャン直後で出ていた精度(赤ブロック0.30mm)と同じ数値が、ようやくPi側のループからも引けるようになった。

「ロボットが正確な位置に届かない」と感じていた事象の少なくとも一部は、これで説明がつく。CSVの中に最初から正しい姿勢がなかった。引いていたlookup table自体が、別物だった。

見えていなかったのは1点だけだった

過去のセッションで「爪オフセット問題(約90mm)」として記録されていた事象と、今回の97mm。数字がほぼ同じだった。同じ問題を、違う角度から何度も観測していた。

スキャナーの改修はやった。CSVの再生成もやった。座標系の統一もやった。全部「やった」。

Mac側へのコピーだけが「やらなかった」。

数週間、その1コマンドがプロジェクトの中心に居座っていた。スキャナーが正しいことも、Unity側のCSVが更新されていることも、それぞれは正しく把握していた。「Mac側に届いていない」という1点だけが視野から抜けていた。

ミスはいつもこういう形をしている。技術的に難しい問題じゃない。気づくのが難しい問題だった。

前日に「測れる手段」を作っておいたから、4つの仮説を測定で順番に消せた。もし推論で繋いでいたら、たぶん今も的外れな対応を続けていた。

P008が動いた瞬間だった。

残る課題

新CSVでも、前日に手動探索したPICK angles [-49, 98, -32, 0, -65, 0]は引けない。j5が起点姿勢のまま固定されているのでCSVに存在しない。

新CSV top1(3D最近傍 1.02mm):
angles = [-40, 105, -50, 23, -45, -20]
ee     = (0.240339, 0.011132, 0.213523)

このanglesで実機を動かせばグリッパ先端は赤ブロック中心の1mm以内に到達するはず。掴めるかは別で、j5がブロックの向きと合っていないと爪が素通りする可能性がある。

r2r2r_loop.py のlookup方式(XZ距離のみ)の問題も見えた。XZで最近傍を取ると、3Dでは32mmズレるケースがある。Y方向の制約が必要だった。

次のセッションで取り組む。

検証で使用・生成したコード

検証プロセス全体を再現できるよう、本セッションで使用・生成したコードを掲載する。Unity Editor 操作のうちコード化されていないもの(Inspector のフィールド設定など)は、各 H2 セクションの記述を参照のこと。

TipBrepReader.cs(前セッションで生成、本セッションで仮説D検証に使用)

任意 angles に対して leftTipBrep / rightTipBrep の World Position を実測する Editor ツール。本セッションでは Wait Seconds After Set を 3 → 0.5 に変えて再実行することで仮説D(物理収束時間)を否定した。

using UnityEngine;
using System.Collections.Generic;

/// <summary>
/// TipBrepReader
/// 任意 angles を入力 → 姿勢を Unity でセット → leftTipBrep/rightTipBrep の World 実位置を読み取り
/// 物理憲法遵守: drive.target のみ書き込み
/// </summary>
public class TipBrepReader : MonoBehaviour
{
    [Header("PICK姿勢角度(度、6要素)")]
    public float[] angles = new float[6] { -50f, 100f, -55f, -2f, -35f, -20f };

    [Header("アーム参照")]
    public ArticulationBody rootArticulationBody;
    public ArticulationBody[] joints = new ArticulationBody[6];

    [Header("爪先Brep参照")]
    public Transform leftTipBrep;
    public Transform rightTipBrep;

    [Header("設定")]
    public float waitSecondsAfterSet = 3f;
    public bool runOnStart = false;

    void Start()
    {
        if (runOnStart)
        {
            StartCoroutine(ReadTipBrepCoroutine());
        }
    }

    [ContextMenu("Read Tip Brep Positions")]
    public void ReadTipBrep()
    {
        if (!Application.isPlaying)
        {
            Debug.LogError("[TipBrepReader] Playモードで実行してください");
            return;
        }
        StartCoroutine(ReadTipBrepCoroutine());
    }

    private System.Collections.IEnumerator ReadTipBrepCoroutine()
    {
        if (rootArticulationBody == null)
        {
            Debug.LogError("[TipBrepReader] rootArticulationBody が未設定");
            yield break;
        }
        if (joints == null || joints.Length < 6)
        {
            Debug.LogError("[TipBrepReader] joints が未設定または6要素未満");
            yield break;
        }
        if (leftTipBrep == null || rightTipBrep == null)
        {
            Debug.LogError("[TipBrepReader] leftTipBrep / rightTipBrep が未設定");
            yield break;
        }
        if (angles == null || angles.Length < 6)
        {
            Debug.LogError("[TipBrepReader] angles は6要素必要");
            yield break;
        }

        // 姿勢セット(SetJointPositions で瞬時、drive.target で物理保持)
        var positions = new List<float>();
        for (int i = 0; i < 6; i++)
            positions.Add(angles[i] * Mathf.Deg2Rad);
        rootArticulationBody.SetJointPositions(positions);
        Physics.SyncTransforms();

        for (int i = 0; i < 6; i++)
        {
            if (joints[i] == null) continue;
            var drive = joints[i].xDrive;
            drive.target = angles[i];
            joints[i].xDrive = drive;
            joints[i].SetDriveTarget(ArticulationDriveAxis.X, drive.target);
        }

        Debug.Log($"[TipBrepReader] 姿勢セット完了。{waitSecondsAfterSet}秒待機中...");
        yield return new WaitForSeconds(waitSecondsAfterSet);

        // 読み取り
        Vector3 leftPos = leftTipBrep.position;
        Vector3 rightPos = rightTipBrep.position;
        Vector3 midpoint = (leftPos + rightPos) * 0.5f;
        Vector3 rootPos = rootArticulationBody.transform.position;
        Vector3 ee = midpoint - rootPos;

        Debug.Log($"[TipBrepReader] === PICK姿勢 Transform 読み取り結果 ===");
        Debug.Log($"[TipBrepReader] angles: [{angles[0]:F2}, {angles[1]:F2}, {angles[2]:F2}, " +
                  $"{angles[3]:F2}, {angles[4]:F2}, {angles[5]:F2}]");
        Debug.Log($"[TipBrepReader] leftTipBrep  World Position: (X, Y, Z) = " +
                  $"({leftPos.x:F6}, {leftPos.y:F6}, {leftPos.z:F6})");
        Debug.Log($"[TipBrepReader] rightTipBrep World Position: (X, Y, Z) = " +
                  $"({rightPos.x:F6}, {rightPos.y:F6}, {rightPos.z:F6})");
        Debug.Log($"[TipBrepReader] Midpoint     World Position: (X, Y, Z) = " +
                  $"({midpoint.x:F6}, {midpoint.y:F6}, {midpoint.z:F6})");
        Debug.Log($"[TipBrepReader] Root         World Position: (X, Y, Z) = " +
                  $"({rootPos.x:F6}, {rootPos.y:F6}, {rootPos.z:F6})");
        Debug.Log($"[TipBrepReader] EE (Midpoint - Root)        : (X, Y, Z) = " +
                  $"({ee.x:F6}, {ee.y:F6}, {ee.z:F6})");
        Debug.Log($"[TipBrepReader] === 読み取り完了 ===");
    }
}

FKScanner_MotionBased.cs(既存、本セッションで仮説A・B/C・Dの検証で読み込み・分析)

20 体並列で姿勢-EE 対応をスキャンし、fk_scan_motion_based.csv に書き出すスキャナー。本セッションでは ee 計算ロジック(midpoint - root.position)、SetJointPositions と Physics.SyncTransforms の順序、WaitForSeconds(0.5f) のタイミング、FindGripperTipBrep の探索パスを分析した。

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.IO;

/// <summary>
/// FKScanner_MotionBased
/// 確認済みPICK角度を起点にj0とj1をグリッドサーチし、
/// 各点のEndEffector位置をFKで記録するスキャナー。IKは一切使わない。
/// 20体のロボットを並列稼働させ、点を分担処理する。
/// 物理憲法遵守: drive.target のみ更新。stiffness/damping/anchorRotation には触れない。
/// </summary>
public class FKScanner_MotionBased : MonoBehaviour
{
    private static readonly float[] BaseAngles = { -46.93f, 102.74f, -43.33f, 3.6f, -60.11f, -30f };

    private const float J0Min  = -90f;
    private const float J0Max  =  90f;
    private const float J0Step =   5f;

    private const float J1RelMin  = -30f;
    private const float J1RelMax  =  30f;
    private const float J1RelStep =   5f;

    private const int RobotCount = 20;

    [Header("Prefab")]
    public GameObject mecharmPrefab;

    [Header("MotionLog Mode")]
    public bool useMotionLogMode = false;
    public string motionLogCsvPath = "Assets/Data/motion_log_block.csv";
    public string outputMotionBasedCsvPath = "Assets/Data/fk_scan_motion_based.csv";

    [System.Serializable]
    public class RobotConfig
    {
        public ArticulationBody rootArticulationBody;
        public ArticulationBody[] joints;
        public Transform endEffector;
        public Transform leftTipBrep;
        public Transform rightTipBrep;
    }

    [Header("Robot Configs (15体分)")]
    public RobotConfig[] robots = new RobotConfig[RobotCount];

    private List<string[]> _allRows = new List<string[]>();
    private int _doneCount = 0;
    private bool _isMotionLogMode = false;

    void Start()
    {
        robots = new RobotConfig[RobotCount];
        var foundRobots = new List<GameObject>();
        for (int r = 1; r <= RobotCount; r++)
        {
            var go = GameObject.Find($"mecharm270mpi_{r}_0");
            if (go != null) foundRobots.Add(go);
            else Debug.LogWarning($"[FKScanner_MotionBased] mecharm270mpi_{r}_0 が見つかりません");
        }

        for (int r = 0; r < foundRobots.Count; r++)
        {
            var instance = foundRobots[r];
            var cfg = new RobotConfig();

            foreach (var ab in instance.GetComponentsInChildren<ArticulationBody>())
            {
                if (ab.isRoot) { cfg.rootArticulationBody = ab; break; }
            }

            var jointList = new List<ArticulationBody>();
            foreach (var ab in instance.GetComponentsInChildren<ArticulationBody>())
                if (!ab.isRoot) jointList.Add(ab);
            cfg.joints = jointList.GetRange(0, Mathf.Min(6, jointList.Count)).ToArray();

            var eeTransform = instance.transform.Find("base/link1/link2/link3/link4/link5/link6/link6_flange");
            cfg.endEffector = eeTransform != null
                ? eeTransform
                : cfg.joints[cfg.joints.Length - 1].transform;

            cfg.leftTipBrep = FindGripperTipBrep(instance.transform, "left");
            cfg.rightTipBrep = FindGripperTipBrep(instance.transform, "right");
            if (cfg.leftTipBrep == null || cfg.rightTipBrep == null)
                Debug.LogWarning($"[FKScanner_MotionBased] Robot[{r}] Gripper tip Brep not found");

            robots[r] = cfg;
        }

        if (useMotionLogMode)
        {
            _isMotionLogMode = true;
            StartCoroutine(ScanMotionLogMode());
        }
        else
        {
            _isMotionLogMode = false;
            var grid = BuildGrid();
            int chunkSize = Mathf.CeilToInt((float)grid.Count / RobotCount);
            for (int r = 0; r < RobotCount; r++)
            {
                int start = r * chunkSize;
                if (start >= grid.Count) break;
                int end = Mathf.Min(start + chunkSize, grid.Count);
                var chunk = grid.GetRange(start, end - start);
                StartCoroutine(ScanRoutine(r, chunk));
            }
        }
    }

    private IEnumerator ScanMotionLogMode()
    {
        var motionPoses = LoadMotionLogCSV();
        if (motionPoses.Count == 0) yield break;

        var allGrids = new List<float[]>();
        foreach (var pose in motionPoses)
        {
            var grid = BuildMotionBasedGrid(pose);
            allGrids.AddRange(grid);
        }

        int chunkSize = Mathf.CeilToInt((float)allGrids.Count / RobotCount);
        for (int r = 0; r < RobotCount; r++)
        {
            int start = r * chunkSize;
            if (start >= allGrids.Count) break;
            int end = Mathf.Min(start + chunkSize, allGrids.Count);
            var chunk = allGrids.GetRange(start, end - start);
            StartCoroutine(ScanMotionRoutine(r, chunk));
        }
    }

    private List<float[]> LoadMotionLogCSV()
    {
        var poses = new List<float[]>();
        string csvPath = Path.Combine(Application.dataPath, "..", motionLogCsvPath);
        if (!File.Exists(csvPath)) return poses;

        try
        {
            var lines = File.ReadAllLines(csvPath);
            int lineCount = 0;
            foreach (var line in lines)
            {
                if (lineCount == 0) { lineCount++; continue; }
                if (string.IsNullOrWhiteSpace(line)) continue;
                var cols = line.Split(',');
                if (cols.Length < 8) continue;
                float[] angles = new float[6];
                for (int i = 0; i < 6; i++)
                {
                    if (!float.TryParse(cols[i + 2], out angles[i])) angles[i] = 0f;
                }
                poses.Add(angles);
                lineCount++;
            }
        }
        catch { }
        return poses;
    }

    private List<float[]> BuildMotionBasedGrid(float[] baseAngles)
    {
        var grid = new List<float[]>();
        float[] offsets = { -15f, -10f, -5f, 0f, 5f, 10f, 15f };

        for (int i0 = 0; i0 < offsets.Length; i0++)
        for (int i1 = 0; i1 < offsets.Length; i1++)
        for (int i2 = 0; i2 < offsets.Length; i2++)
        for (int i3 = 0; i3 < offsets.Length; i3++)
        for (int i4 = 0; i4 < offsets.Length; i4++)
        {
            float[] angles = new float[6];
            angles[0] = baseAngles[0] + offsets[i0];
            angles[1] = baseAngles[1] + offsets[i1];
            angles[2] = baseAngles[2] + offsets[i2];
            angles[3] = baseAngles[3] + offsets[i3];
            angles[4] = baseAngles[4] + offsets[i4];
            angles[5] = baseAngles[5];
            grid.Add(angles);
        }
        return grid;
    }

    private List<(float j0, float j1)> BuildGrid()
    {
        var list = new List<(float, float)>();
        for (float j0 = J0Min; j0 <= J0Max + 0.001f; j0 += J0Step)
            for (float j1rel = J1RelMin; j1rel <= J1RelMax + 0.001f; j1rel += J1RelStep)
                list.Add((j0, BaseAngles[1] + j1rel));
        return list;
    }

    private IEnumerator ScanRoutine(int robotIndex, List<(float j0, float j1)> chunk)
    {
        var cfg = robots[robotIndex];
        if (cfg == null || cfg.rootArticulationBody == null || cfg.joints == null
            || cfg.leftTipBrep == null || cfg.rightTipBrep == null)
        {
            OnRobotDone();
            yield break;
        }

        var localRows = new List<string[]>();

        foreach (var (j0, j1) in chunk)
        {
            float[] angles = new float[6] { j0, j1, BaseAngles[2], BaseAngles[3], BaseAngles[4], BaseAngles[5] };

            var positions = new List<float>();
            for (int i = 0; i < 6; i++)
                positions.Add(angles[i] * Mathf.Deg2Rad);
            cfg.rootArticulationBody.SetJointPositions(positions);
            Physics.SyncTransforms();

            for (int i = 0; i < 6; i++)
            {
                if (cfg.joints[i] == null) continue;
                var drive = cfg.joints[i].xDrive;
                drive.target = angles[i];
                cfg.joints[i].xDrive = drive;
                cfg.joints[i].SetDriveTarget(ArticulationDriveAxis.X, drive.target);
            }

            yield return new WaitForSeconds(0.5f);

            Vector3 midpoint = (cfg.leftTipBrep.position + cfg.rightTipBrep.position) * 0.5f;
            Vector3 ee = midpoint - cfg.rootArticulationBody.transform.position;
            localRows.Add(new string[]
            {
                ee.x.ToString("F6"), ee.y.ToString("F6"), ee.z.ToString("F6"),
                angles[0].ToString("F3"), angles[1].ToString("F3"), angles[2].ToString("F3"),
                angles[3].ToString("F3"), angles[4].ToString("F3"), angles[5].ToString("F3")
            });
        }

        lock (_allRows) { _allRows.AddRange(localRows); }
        OnRobotDone();
    }

    private IEnumerator ScanMotionRoutine(int robotIndex, List<float[]> chunk)
    {
        var cfg = robots[robotIndex];
        if (cfg == null || cfg.rootArticulationBody == null || cfg.joints == null
            || cfg.leftTipBrep == null || cfg.rightTipBrep == null)
        {
            OnRobotDone();
            yield break;
        }

        var localRows = new List<string[]>();

        foreach (var angles in chunk)
        {
            var positions = new List<float>();
            for (int i = 0; i < 6; i++)
                positions.Add(angles[i] * Mathf.Deg2Rad);
            cfg.rootArticulationBody.SetJointPositions(positions);
            Physics.SyncTransforms();

            for (int i = 0; i < 6; i++)
            {
                if (cfg.joints[i] == null) continue;
                var drive = cfg.joints[i].xDrive;
                drive.target = angles[i];
                cfg.joints[i].xDrive = drive;
                cfg.joints[i].SetDriveTarget(ArticulationDriveAxis.X, drive.target);
            }

            yield return new WaitForSeconds(0.5f);

            Vector3 midpoint = (cfg.leftTipBrep.position + cfg.rightTipBrep.position) * 0.5f;
            Vector3 ee = midpoint - cfg.rootArticulationBody.transform.position;
            localRows.Add(new string[]
            {
                ee.x.ToString("F6"), ee.y.ToString("F6"), ee.z.ToString("F6"),
                angles[0].ToString("F3"), angles[1].ToString("F3"), angles[2].ToString("F3"),
                angles[3].ToString("F3"), angles[4].ToString("F3"), angles[5].ToString("F3")
            });
        }

        lock (_allRows) { _allRows.AddRange(localRows); }
        OnRobotDone();
    }

    private void OnRobotDone()
    {
        _doneCount++;
        if (_doneCount >= RobotCount)
            StartCoroutine(WriteCSVAndQuit());
    }

    private IEnumerator WriteCSVAndQuit()
    {
        yield return null;

        string logsDir = Path.Combine(Application.dataPath, "..", "Logs");
        Directory.CreateDirectory(logsDir);

        string csvPath;
        if (_isMotionLogMode)
        {
            csvPath = Path.Combine(Application.dataPath, "..", outputMotionBasedCsvPath);
            Directory.CreateDirectory(Path.GetDirectoryName(csvPath));
        }
        else
        {
            csvPath = Path.Combine(logsDir, "fk_scan_table.csv");
        }

        using (var writer = new StreamWriter(csvPath, false) { AutoFlush = true })
        {
            writer.WriteLine("ee_x,ee_y,ee_z,j0,j1,j2,j3,j4,j5");
            lock (_allRows)
            {
                foreach (var row in _allRows)
                    writer.WriteLine(string.Join(",", row));
            }
        }

#if UNITY_EDITOR
        UnityEditor.EditorApplication.isPlaying = false;
#endif
    }

    Transform FindGripperTipBrep(Transform root, string side)
    {
        Transform g = FindDeepChild(root, $"gripper_{side}1");
        if (g == null) return null;
        Transform i = FindDeepChild(g, "Instance-Def-210103_4302_101");
        if (i == null) return null;
        return i.Find("Brep");
    }

    Transform FindDeepChild(Transform parent, string name)
    {
        foreach (Transform child in parent)
        {
            if (child.name == name) return child;
            var result = FindDeepChild(child, name);
            if (result != null) return result;
        }
        return null;
    }
}

仮説E検証コマンド(本セッションで生成・実行)

Mac側CSVとUnity側CSVのファイル更新日時、行数、同angles行の値を比較する一連のbashコマンド。

# 1. 両 CSV のファイル情報
ls -la ~/r2r2r-scripts/fk_scan_motion_based.csv
ls -la ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv

# 2. 行数とヘッダ
echo "=== Mac 側 ==="
head -5 ~/r2r2r-scripts/fk_scan_motion_based.csv
wc -l ~/r2r2r-scripts/fk_scan_motion_based.csv

echo "=== Unity 側 ==="
head -5 ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv
wc -l ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv

# 3. 同 angles の行を両 CSV から抽出
grep "^[^,]*,[^,]*,[^,]*,-50.000,100.000,-55.000,-2.000,-35.000,-20.000" \
  ~/r2r2r-scripts/fk_scan_motion_based.csv

grep "^[^,]*,[^,]*,[^,]*,-50.000,100.000,-55.000,-2.000,-35.000,-20.000" \
  ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv

Unity側 Assets/Data/ 一覧確認コマンド

LOG158 統合テーブルの構成(4ファイル+motion_log_block.csv)を確認するため、ディレクトリ全件のヘッダと行数を出力する。

ls -la ~/Robotics_UX/Robotics_UX_New/Assets/Data/

# 各 CSV の行数とヘッダ
for f in ~/Robotics_UX/Robotics_UX_New/Assets/Data/*.csv; do
  echo "=== $f ==="
  wc -l "$f"
  head -1 "$f"
  echo ""
done

案A実行コマンド(バックアップ + コピー + 検証)

# 1. 旧 Mac 側 CSV をタイムスタンプ付きでバックアップ
cp ~/r2r2r-scripts/fk_scan_motion_based.csv \
   ~/r2r2r-scripts/fk_scan_motion_based.csv.bak_DAY170_pre_overwrite_$(date +%Y%m%d_%H%M%S)

# 2. バックアップファイル確認
ls -la ~/r2r2r-scripts/fk_scan_motion_based.csv*

# 3. Unity 側 CSV を Mac 側にコピー(上書き)
cp ~/Robotics_UX/Robotics_UX_New/Assets/Data/fk_scan_motion_based.csv \
   ~/r2r2r-scripts/fk_scan_motion_based.csv

# 4. コピー後の Mac 側 CSV を確認
ls -la ~/r2r2r-scripts/fk_scan_motion_based.csv
wc -l ~/r2r2r-scripts/fk_scan_motion_based.csv
head -2 ~/r2r2r-scripts/fk_scan_motion_based.csv

# 5. 期待値との一致確認(angles=[-50,100,-55,-2,-35,-20] の行)
grep "^[^,]*,[^,]*,[^,]*,-50.000,100.000,-55.000,-2.000,-35.000,-20.000" \
  ~/r2r2r-scripts/fk_scan_motion_based.csv
# 期待: ee = (0.252898, 0.018800, 0.219693)

効果検証スクリプト(前セッションで生成、本セッションで再実行)

verify_lookup_day169.py は前セッションで作成済み。本セッションでは案A 実行後の Mac 側 CSV に対して再実行することで効果を測定した(XZ最近傍 71.10mm → 0.30mm の確認)。

cd ~/r2r2r-scripts && python3 verify_lookup_day169.py

スクリプト本体は ~/r2r2r-scripts/verify_lookup_day169.py を参照。赤ブロック中心 (0.2401, 0.012, 0.214) を目標座標として、CSV 全件から XZ 距離・3D 距離それぞれの最近傍 top5 を出力する。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。