commit 736b3d3bb8e62ad214895b0664483913c11f41b1
Author: Shimazue Eiichi <shimazueiichi@local>
Date:   Sat Nov 7 14:25:45 2026 +0900

    [依頼111] TwinCheck のシーンの写しを記録に残さず、動かすたびに元のシーンから写して開く形にする
    
    - TwinCheckA.OpenWorkScene: 元のシーン Assets/Scenes/R2R2R-20260224.unity を
      Assets/TwinCheck/_work/TwinCheck_Base.unity に写して開く（保存しない）。
      元のパス・MD5・changeset（Unity Version Control の版の中身と MD5 が一致したときだけ）を
      出力フォルダの scene_source.json に書く
    - TwinScene.Open・TwinCheckA.Run・TwinCheckDump.RunScene をこの関数に揃えた
    - .gitignore に _work/・_work.meta・Python~/__pycache__/ を足した
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/.gitignore b/.gitignore
index 09a16a6..6805b63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ Packages/packages-lock.json
 Assets/GaussianAssets/*.bytes
 
 .tmp.driveupload/
+Assets/TwinCheck/_work/
+Assets/TwinCheck/_work.meta
+Assets/TwinCheck/Python~/__pycache__/
diff --git a/Assets/TwinCheck/Editor/TwinCheckA.cs b/Assets/TwinCheck/Editor/TwinCheckA.cs
index 0b4d62d..ad87476 100644
--- a/Assets/TwinCheck/Editor/TwinCheckA.cs
+++ b/Assets/TwinCheck/Editor/TwinCheckA.cs
@@ -10,12 +10,90 @@ using System.Globalization;
 /// TwinCheckA
 /// 依頼107 の A。FK テーブルから選んだ行の関節角を、シーンの写しの20体それぞれに置き、
 /// 爪先の位置（根元からの差）を書き出す。表の ee との比較は Python 側で行う。
-/// シーンは開くだけで保存しない。
+/// シーンは開くだけで保存しない。シーンの写しは OpenWorkScene で毎回作る（依頼111）。
 /// 実行: Unity -batchmode -quit -executeMethod TwinCheckA.Run -twinIn {行の CSV} -twinOut {出力 CSV} [-twinSign 1|-1]
 /// </summary>
 public static class TwinCheckA
 {
-    public const string ScenePath = "Assets/TwinCheck/TwinCheck_Base.unity";
+    // 元のシーン（読むだけ）と、動かすたびに作る写し（_work/ は記録に入れない。Unity は Assets の外のシーンを開けない）
+    public const string SourceScenePath = "Assets/Scenes/R2R2R-20260224.unity";
+    public const string ScenePath = "Assets/TwinCheck/_work/TwinCheck_Base.unity";
+
+    /// <summary>
+    /// 元のシーンを _work/ に写して開く（保存しない）。元のパス・MD5・changeset を
+    /// 出力フォルダ（-twinOut、なければ -twinShots のあるフォルダ）の scene_source.json に書く。
+    /// changeset は Unity Version Control で読み込んでいる版の中身と MD5 が一致したときだけ書く。
+    /// </summary>
+    public static void OpenWorkScene()
+    {
+        Directory.CreateDirectory(Path.GetDirectoryName(ScenePath));
+        File.Copy(SourceScenePath, ScenePath, true);
+        AssetDatabase.ImportAsset(ScenePath, ImportAssetOptions.ForceUpdate);
+        string md5 = Md5(SourceScenePath);
+        string cs = Changeset(SourceScenePath, md5, out string note);
+
+        string outArg = TwinCheckArgs.Get("-twinOut", "");
+        if (outArg == "") outArg = TwinCheckArgs.Get("-twinShots", "");
+        if (outArg != "")
+        {
+            string dir = Path.GetDirectoryName(Path.GetFullPath(outArg));
+            Directory.CreateDirectory(dir);
+            var sb = new StringBuilder("{\n");
+            sb.Append($"  \"source\": \"{SourceScenePath}\",\n");
+            sb.Append($"  \"md5\": \"{md5}\",\n");
+            sb.Append($"  \"changeset\": {(cs == null ? "null" : cs)},\n");
+            sb.Append($"  \"changeset_note\": \"{note}\",\n");
+            sb.Append($"  \"copied_to\": \"{ScenePath}\",\n");
+            sb.Append($"  \"copied_at\": \"{System.DateTime.Now:yyyy-MM-ddTHH:mm:ss}\"\n");
+            sb.Append("}\n");
+            File.WriteAllText(Path.Combine(dir, "scene_source.json"), sb.ToString());
+        }
+        EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
+        Debug.Log($"[TwinCheck] scene {SourceScenePath} md5={md5} cs={cs ?? "-"} ({note})");
+    }
+
+    static string Md5(string path)
+    {
+        using (var m = System.Security.Cryptography.MD5.Create())
+        using (var f = File.OpenRead(path))
+            return System.BitConverter.ToString(m.ComputeHash(f)).Replace("-", "").ToLowerInvariant();
+    }
+
+    /// <summary>cm で読み込んでいる版の番号を調べ、その版の中身の MD5 が今のファイルと同じなら番号を返す。</summary>
+    static string Changeset(string path, string md5, out string note)
+    {
+        string full = Path.GetFullPath(path);
+        string cs = Cm($"ls \"{full}\" --format={{changeset}}").Trim();
+        if (!int.TryParse(cs, out _)) { note = "cm で版を調べられなかった"; return null; }
+        string tmp = Path.Combine(Path.GetTempPath(), $"twincheck_scene_cs{cs}.unity");
+        Cm($"cat \"{full}#cs:{cs}\" --file=\"{tmp}\"");
+        if (!File.Exists(tmp)) { note = $"cs:{cs} の中身を取り出せなかった"; return null; }
+        bool same = Md5(tmp) == md5;
+        File.Delete(tmp);
+        if (!same) { note = $"読み込んでいる版 cs:{cs} から変更あり（チェックインされていない）"; return null; }
+        note = $"cs:{cs} の中身と MD5 が一致";
+        return cs;
+    }
+
+    static string Cm(string args)
+    {
+        foreach (var exe in new[] { "/usr/local/bin/cm", "cm" })
+        {
+            try
+            {
+                var psi = new System.Diagnostics.ProcessStartInfo(exe, args)
+                    { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+                using (var p = System.Diagnostics.Process.Start(psi))
+                {
+                    string o = p.StandardOutput.ReadToEnd();
+                    p.WaitForExit(60000);
+                    return o;
+                }
+            }
+            catch (System.Exception) { }
+        }
+        return "";
+    }
 
     public static void Run()
     {
@@ -23,7 +101,7 @@ public static class TwinCheckA
         string outPath = TwinCheckArgs.Get("-twinOut", "");
         float sign = float.Parse(TwinCheckArgs.Get("-twinSign", "1"), CultureInfo.InvariantCulture);
 
-        EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
+        OpenWorkScene();
         var arms = FindArms();
 
         var lines = File.ReadAllLines(inPath);
diff --git a/Assets/TwinCheck/Editor/TwinCheckDump.cs b/Assets/TwinCheck/Editor/TwinCheckDump.cs
index 67b540f..4144ecb 100644
--- a/Assets/TwinCheck/Editor/TwinCheckDump.cs
+++ b/Assets/TwinCheck/Editor/TwinCheckDump.cs
@@ -48,7 +48,7 @@ public static class TwinCheckDump
     {
         string outPath = TwinCheckArgs.Get("-twinOut", "/tmp/twincheck/scene.txt");
         var sb = new StringBuilder();
-        EditorSceneManager.OpenScene(TwinCheckA.ScenePath, OpenSceneMode.Single);
+        TwinCheckA.OpenWorkScene();
         foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects())
         {
             var t = go.transform;
diff --git a/Assets/TwinCheck/Editor/TwinScene.cs b/Assets/TwinCheck/Editor/TwinScene.cs
index 564a578..ee2a600 100644
--- a/Assets/TwinCheck/Editor/TwinScene.cs
+++ b/Assets/TwinCheck/Editor/TwinScene.cs
@@ -1,11 +1,10 @@
 using UnityEngine;
-using UnityEditor.SceneManagement;
 using System.Globalization;
 using System.IO;
 
 /// <summary>
 /// TwinScene
-/// 依頼107。シーンの写し（Assets/TwinCheck/TwinCheck_Base.unity）を開き、
+/// 依頼107。元のシーンの写し（TwinCheckA.OpenWorkScene が毎回 Assets/TwinCheck/_work/ に作る）を開き、
 /// 1体目のアーム（mecharm270mpi_1_0、根元が原点）・赤ブロック・白箱・床・光だけを残して見せる。
 /// 他の物は SetActive(false) にするだけで、シーンは保存しない。
 /// </summary>
@@ -16,7 +15,7 @@ public class TwinScene
 
     public static TwinScene Open()
     {
-        EditorSceneManager.OpenScene(TwinCheckA.ScenePath, OpenSceneMode.Single);
+        TwinCheckA.OpenWorkScene();
         var s = new TwinScene();
         var keep = new[] { "mecharm270mpi_1_0", "RedBlock_arm1", "WhiteBox_arm1", "Plane1", "Directional Light", "Main Camera" };
         foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects())
