@things-factory/figure-ui 10.1.271 → 10.1.273

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/data-interface.ts +30 -18
  4. package/client/modeller/editor-styles.ts +17 -6
  5. package/client/modeller/editor.ts +2 -0
  6. package/client/modeller/fasten-form.ts +81 -6
  7. package/client/modeller/fasten-geometry.ts +5 -3
  8. package/client/modeller/figure-cavity-drawing.ts +1 -3
  9. package/client/modeller/figure-clips.ts +101 -8
  10. package/client/modeller/figure-data-panel.ts +384 -200
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +24 -14
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +109 -37
  15. package/client/modeller/figure-parts.ts +7 -11
  16. package/client/modeller/figure-preview.ts +299 -79
  17. package/client/modeller/figure-size-editor.ts +7 -2
  18. package/client/modeller/first-save.ts +8 -0
  19. package/client/modeller/focus-on-render.ts +4 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/size-response.ts +14 -0
  22. package/client/modeller/stage-model.ts +50 -6
  23. package/dist-client/modeller/canvas.d.ts +1 -0
  24. package/dist-client/modeller/canvas.js +3 -2
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +17 -3
  27. package/dist-client/modeller/clips.js +69 -4
  28. package/dist-client/modeller/clips.js.map +1 -1
  29. package/dist-client/modeller/data-interface.d.ts +11 -8
  30. package/dist-client/modeller/data-interface.js +27 -17
  31. package/dist-client/modeller/data-interface.js.map +1 -1
  32. package/dist-client/modeller/editor-styles.js +17 -6
  33. package/dist-client/modeller/editor-styles.js.map +1 -1
  34. package/dist-client/modeller/editor.js +2 -0
  35. package/dist-client/modeller/editor.js.map +1 -1
  36. package/dist-client/modeller/fasten-form.d.ts +13 -0
  37. package/dist-client/modeller/fasten-form.js +72 -7
  38. package/dist-client/modeller/fasten-form.js.map +1 -1
  39. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  40. package/dist-client/modeller/fasten-geometry.js +5 -3
  41. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  42. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  43. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  44. package/dist-client/modeller/figure-clips.d.ts +9 -0
  45. package/dist-client/modeller/figure-clips.js +104 -8
  46. package/dist-client/modeller/figure-clips.js.map +1 -1
  47. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  48. package/dist-client/modeller/figure-data-panel.js +382 -190
  49. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  50. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  51. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +24 -14
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-first-save.js +4 -3
  55. package/dist-client/modeller/figure-first-save.js.map +1 -1
  56. package/dist-client/modeller/figure-links.d.ts +9 -0
  57. package/dist-client/modeller/figure-links.js +120 -51
  58. package/dist-client/modeller/figure-links.js.map +1 -1
  59. package/dist-client/modeller/figure-parts.js +7 -11
  60. package/dist-client/modeller/figure-parts.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.d.ts +13 -0
  62. package/dist-client/modeller/figure-preview.js +296 -80
  63. package/dist-client/modeller/figure-preview.js.map +1 -1
  64. package/dist-client/modeller/figure-size-editor.js +7 -3
  65. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  66. package/dist-client/modeller/first-save.d.ts +5 -0
  67. package/dist-client/modeller/first-save.js +7 -0
  68. package/dist-client/modeller/first-save.js.map +1 -1
  69. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  70. package/dist-client/modeller/focus-on-render.js +4 -0
  71. package/dist-client/modeller/focus-on-render.js.map +1 -0
  72. package/dist-client/modeller/pivot.d.ts +12 -0
  73. package/dist-client/modeller/pivot.js +59 -0
  74. package/dist-client/modeller/pivot.js.map +1 -0
  75. package/dist-client/modeller/size-response.d.ts +6 -0
  76. package/dist-client/modeller/size-response.js +14 -0
  77. package/dist-client/modeller/size-response.js.map +1 -1
  78. package/dist-client/modeller/stage-model.d.ts +17 -3
  79. package/dist-client/modeller/stage-model.js +44 -6
  80. package/dist-client/modeller/stage-model.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +8 -7
  83. package/test/browser/editing-page.js +1 -1
  84. package/test/browser/editing.check.mjs +6 -5
  85. package/test/browser/links.check.mjs +30 -0
  86. package/test/browser/modeller.check.mjs +2 -2
  87. package/test/browser/parts-page.js +4 -0
  88. package/test/browser/parts.check.mjs +16 -10
  89. package/test/browser/screen-page.js +12 -1
  90. package/test/browser/screen.check.mjs +142 -11
  91. package/test/browser/section.check.mjs +21 -0
  92. package/test/browser/serve-response.mjs +5 -2
  93. package/test/browser/side-panel-sweep.check.mjs +171 -10
  94. package/test/clips.test.ts +49 -4
  95. package/test/data-motion.test.ts +39 -8
  96. package/test/fasten-form.test.ts +69 -0
  97. package/test/fasten-geometry.test.ts +13 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/size-response.test.ts +10 -2
  101. package/test/stage-model.test.ts +23 -1
  102. package/translations/en.json +31 -11
  103. package/translations/es.json +30 -10
  104. package/translations/ja.json +30 -10
  105. package/translations/ko.json +32 -12
  106. package/translations/ms.json +30 -10
  107. package/translations/zh.json +30 -10
  108. package/tutorial/humanoid/README.md +22 -0
  109. package/tutorial/humanoid/chapters.mjs +247 -0
  110. package/tutorial/humanoid/encode.mjs +26 -0
  111. package/tutorial/humanoid/lib.mjs +197 -0
  112. package/tutorial/humanoid/record.mjs +65 -0
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "menetapkan satah rujukan dalam adegan 3D (lantai: AGV/penghantar, siling: OHT/hois, pusat: sensor/dron).",
486
486
  "figure.text.pose-as-default-explained": "Menulis postur semasa sebagai nilai lalai parameter, supaya figura berdiri begini apabila tiada nilai diberi.",
487
+ "figure.text.control-played": "Dimainkan",
488
+ "figure.text.control-played-explained": "Klip {clip} sedang menggerakkan kawalan ini. Hentikan klip untuk memberinya nilai.",
487
489
  "figure.text.clear-tried-explained": "mengosongkan nilai kawalan yang dicuba di sini. pose lalai yang disimpan tidak berubah, dan klip menggerakkannya semula.",
488
490
  "figure.text.preview-blocked-by-errors": "pratonton tidak dapat dipaparkan kerana ralat format. lihat tab kematangan untuk sebabnya.",
489
491
  "figure.text.put-on-floor": "Mengalihkan bahagian sekali supaya bahagian bawahnya di lantai (Y=0). Ia tidak kekal di situ apabila saiz berubah; untuk itu, gunakan “Ikut saiz, dilekatkan pada lantai”.",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "kawalan sendiri",
716
718
  "figure.label.motion-multiplier": "pendarab",
717
719
  "figure.label.motion-offset": "ofset",
720
+ "figure.label.pivot-at": "pusing pada",
721
+ "figure.label.pivot-top": "hujung atas",
722
+ "figure.label.pivot-middle": "tengah",
723
+ "figure.label.pivot-bottom": "hujung bawah",
718
724
  "figure.label.motion-through": "paksi melalui",
719
725
  "figure.label.motion-follows": "bergerak bersama {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "bukaan kiri",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} sudah menjadi nama tempat dalam figura ini.",
1021
1027
  "figure.label.placement": "lekatan",
1022
1028
  "figure.label.motion-groups": "kumpulan gerakan",
1029
+ "figure.label.motion-group-named": "gerakan",
1023
1030
  "figure.label.motion-group": "kumpulan gerakan",
1024
1031
  "figure.label.no-motion-group": "tiada kumpulan",
1025
1032
  "figure.label.motion-group-name": "nama",
@@ -1045,12 +1052,12 @@
1045
1052
  "figure.label.data-scale": "skala",
1046
1053
  "figure.label.data-offset": "ofset",
1047
1054
  "figure.label.data-sample": "data sampel",
1048
- "figure.text.data-sample-explained": "Masukkan satu rekod sebagai JSON untuk melihat apa yang digerakkan. Ia dibaca seperti figure di papan membaca datanya, dan tidak disimpan.",
1049
- "figure.text.data-sample-moves-all": "Kedudukan dalam data menggerakkan setiap figure dalam pratonton ke sana; di papan, figure yang diberi nilai sama juga berdiri di tempat yang sama.",
1055
+ "figure.text.data-sample-explained": "Masukkan satu rekod sebagai JSON atau JSON5 untuk melihat apa yang digerakkan. Ia dibaca seperti figure di papan membaca datanya, dan tidak disimpan.",
1050
1056
  "figure.label.data-sample-in-preview": "guna pada pratonton",
1057
+ "figure.text.data-sample-shown-in-preview": "Data sampel dipaparkan dalam pratonton; paparan suntingan kekal seperti dibuat.",
1058
+ "figure.button.data-sample-show-preview": "Lihat pratonton",
1051
1059
  "figure.label.data-schema": "bentuk data",
1052
1060
  "figure.text.data-schema-explained": "Bentuk data yang dihantar tapak kepada figure ini. Namakan setiap medan seperti data tapak menamakannya; kumpulkan medan dalam objek dan senarai.",
1053
- "figure.label.data-bound": "{count} terikat",
1054
1061
  "figure.text.data-no-fields": "Tambah medan pada bentuk data dahulu.",
1055
1062
  "figure.label.data-name": "nama medan",
1056
1063
  "figure.label.data-label": "nama paparan",
@@ -1061,17 +1068,18 @@
1061
1068
  "figure.button.remove-data-field": "buang medan",
1062
1069
  "figure.label.data-from-sample": "buat daripada sampel",
1063
1070
  "figure.text.data-from-sample-explained": "Tampal satu rekod data tapak untuk meneka medannya. Unit, julat dan senarai nilai tidak diteka; tetapkan selepas itu.",
1064
- "figure.text.data-not-json": "Ini bukan JSON.",
1071
+ "figure.text.data-not-json": "Ini bukan JSON atau JSON5.",
1065
1072
  "figure.text.data-guessed": "{count} medan ditemui.",
1066
1073
  "figure.text.data-guessed-replaces": "{count} medan ditemui. Menggunakannya menggantikan bentuk data semasa.",
1067
1074
  "figure.button.data-use-guessed": "guna bentuk ini",
1068
1075
  "figure.text.data-bindings-explained": "Menyambung nilai data kepada figura. Nombor dipaparkan seadanya, berskala atau melalui jadual julat; teks atau benar/palsu melalui jadual kod. Jadual menetapkan, bagi setiap nilai, warna dan kesan bahagian, kawalan dan kumpulan gerakan sekali gus. Pilih baris untuk menyuntingnya.",
1069
- "figure.label.data-field": "nilai data",
1070
1076
  "figure.label.data-behavior": "tingkah laku",
1071
1077
  "figure.label.data-seconds": "masa gerak (s)",
1072
1078
  "figure.label.data-paused": "dijeda",
1073
1079
  "figure.label.data-playing": "dimainkan",
1074
1080
  "figure.label.data-otherwise": "nilai lain",
1081
+ "figure.label.data-keep": "kekalkan",
1082
+ "figure.text.data-keep": "Nilai tanpa baris sendiri mengekalkan rupa yang terakhir ditunjukkan oleh jadual ini.",
1075
1083
  "figure.label.data-range-from": "dari",
1076
1084
  "figure.label.data-range-to": "di bawah",
1077
1085
  "figure.button.add-data-range": "tambah julat",
@@ -1094,7 +1102,6 @@
1094
1102
  "figure.button.data-add-rotation": "buat putaran",
1095
1103
  "figure.label.data-none": "tiada",
1096
1104
  "figure.button.remove-data-binding": "buang ikatan",
1097
- "figure.label.data-items-count": "{count} item",
1098
1105
  "figure.text.data-value-not-a-list": "{path}: bukan senarai.",
1099
1106
  "figure.text.data-value-value-unknown": "{path}: bukan salah satu nilainya.",
1100
1107
  "figure.text.data-value-json-unreadable": "{path}: teks JSON tidak boleh dibaca.",
@@ -1232,10 +1239,7 @@
1232
1239
  "figure.label.data-legend-role": "Peranan petunjuk",
1233
1240
  "figure.label.data-legend-swappable": "Pengguna boleh menggantinya dengan petunjuk",
1234
1241
  "figure.label.data-method": "Kaedah",
1235
- "figure.label.data-new-field": "Medan baharu",
1236
1242
  "figure.label.data-sample-legend": "Petunjuk sampel",
1237
- "figure.label.data-table-legend": "petunjuk {name}",
1238
- "figure.label.data-table-own": "jadual figura",
1239
1243
  "figure.label.data-target-group": "Kumpulan gerakan",
1240
1244
  "figure.label.data-target-inputs": "Kawalan",
1241
1245
  "figure.label.data-target-parts": "Bahagian",
@@ -1322,6 +1326,7 @@
1322
1326
  "figure.button.shift-track": "anjak",
1323
1327
  "figure.button.copy-track-apply": "salin",
1324
1328
  "figure.text.copy-clip-needs-group": "Salinan menggerakkan kawalan yang sama, jadi hanya klip dalam kumpulan gerakan boleh disalin.",
1329
+ "figure.text.copy-clip-makes-group": "Menyalin semua trek ke klip bernama baharu. Salinan menggerakkan kawalan yang sama, jadi kedua-duanya dimasukkan ke kumpulan gerakan baharu bernama klip ini (satu dimainkan pada satu masa).",
1325
1330
  "figure.label.primitive-pipe": "paip",
1326
1331
  "figure.label.primitive-duct": "salur",
1327
1332
  "figure.label.primitive-tapered-box": "kotak tirus",
@@ -1402,5 +1407,20 @@
1402
1407
  "figure.label.clip-interpolation-smooth": "Mula dan henti lancar",
1403
1408
  "figure.label.clip-interpolation-step": "Lompat",
1404
1409
  "figure.text.copy-track-off-key": "Trek lancar hanya boleh dianjak supaya bermula pada salah satu kuncinya. Nilai yang boleh: {seconds} s",
1405
- "figure.text.mirror-copies-clips": "Gerakan dalam {clips} turut disalin ke salinan."
1410
+ "figure.text.mirror-copies-clips": "Gerakan dalam {clips} turut disalin ke salinan.",
1411
+ "figure.label.data-bindings-title": "Pengikatan data",
1412
+ "figure.label.data-column-field": "Medan",
1413
+ "figure.label.data-column-kind": "Jenis",
1414
+ "figure.label.data-column-moves": "Menggerakkan",
1415
+ "figure.text.data-method-does-value": "Menggunakan nilai data tanpa penukaran.",
1416
+ "figure.text.data-method-does-scale": "Menggunakan nilai data yang didarab dengan skala.",
1417
+ "figure.text.data-method-does-code": "Menggunakan tetapan jadual bagi setiap nilai data.",
1418
+ "figure.text.data-method-does-ranges": "Menggunakan tetapan jadual bagi julat yang merangkumi nilai data.",
1419
+ "figure.label.data-bindings-without-field": "Pengikatan tanpa medan",
1420
+ "figure.button.add": "Tambah",
1421
+ "figure.label.data-targets": "Sasaran",
1422
+ "figure.label.data-target-add": "Tambah sasaran",
1423
+ "figure.label.data-target-choose": "Pilih bahagian atau kawalan",
1424
+ "figure.text.data-no-targets": "Tiada sasaran dipilih.",
1425
+ "figure.button.data-remove-target": "Buang sasaran"
1406
1426
  }
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "设置 3D 场景中的基准面 (地面: AGV/输送机,天花板: OHT/吊车,中心: 传感器/无人机)。",
486
486
  "figure.text.pose-as-default-explained": "把当前姿态写为参数默认值。没有给值时,图形以此姿态站立。",
487
+ "figure.text.control-played": "播放中",
488
+ "figure.text.control-played-explained": "{clip} 片段正在驱动此控制值。停止片段后即可赋值。",
487
489
  "figure.text.clear-tried-explained": "清除在预览中修改的控制值。已保存的默认姿态不变,剪辑会再次驱动它。",
488
490
  "figure.text.preview-blocked-by-errors": "由于格式错误无法显示预览。请在成熟度标签页查看原因。",
489
491
  "figure.text.put-on-floor": "只把当前位置的底面对齐到地面(Y=0)。尺寸变化时不会跟随。若要保持附着,请使用“附着地面并跟随尺寸”。",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "自身控制值",
716
718
  "figure.label.motion-multiplier": "倍率",
717
719
  "figure.label.motion-offset": "偏移",
720
+ "figure.label.pivot-at": "转动中心",
721
+ "figure.label.pivot-top": "上端",
722
+ "figure.label.pivot-middle": "中间",
723
+ "figure.label.pivot-bottom": "下端",
718
724
  "figure.label.motion-through": "轴经过的点",
719
725
  "figure.label.motion-follows": "跟随 {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "开口 左侧",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} 已是此图形中某个位置的名称。",
1021
1027
  "figure.label.placement": "附着",
1022
1028
  "figure.label.motion-groups": "动作组",
1029
+ "figure.label.motion-group-named": "动作",
1023
1030
  "figure.label.motion-group": "动作组",
1024
1031
  "figure.label.no-motion-group": "不分组",
1025
1032
  "figure.label.motion-group-name": "名称",
@@ -1045,12 +1052,12 @@
1045
1052
  "figure.label.data-scale": "倍率",
1046
1053
  "figure.label.data-offset": "偏移",
1047
1054
  "figure.label.data-sample": "样本数据",
1048
- "figure.text.data-sample-explained": "以 JSON 输入一条数据,查看它驱动什么。读取方式与看板上的 Figure 相同,不会保存。",
1049
- "figure.text.data-sample-moves-all": "决定位置的值会把预览中的所有 Figure 移到该位置;在看板上,收到相同值的 Figure 也会站在同一位置。",
1055
+ "figure.text.data-sample-explained": "以 JSON 或 JSON5 输入一条数据,查看它驱动什么。读取方式与看板上的 Figure 相同,不会保存。",
1050
1056
  "figure.label.data-sample-in-preview": "应用到预览",
1057
+ "figure.text.data-sample-shown-in-preview": "示例数据显示在预览中,编辑视图保持原样。",
1058
+ "figure.button.data-sample-show-preview": "在预览中查看",
1051
1059
  "figure.label.data-schema": "数据结构",
1052
1060
  "figure.text.data-schema-explained": "现场发给此 Figure 的数据结构。字段名按现场数据的名称填写,可用对象和列表组织。",
1053
- "figure.label.data-bound": "{count} 个动作",
1054
1061
  "figure.text.data-no-fields": "请先在数据结构中添加字段。",
1055
1062
  "figure.label.data-name": "字段名",
1056
1063
  "figure.label.data-label": "显示名称",
@@ -1061,17 +1068,18 @@
1061
1068
  "figure.button.remove-data-field": "删除字段",
1062
1069
  "figure.label.data-from-sample": "从样本生成",
1063
1070
  "figure.text.data-from-sample-explained": "粘贴一条现场数据即可推测字段。单位、范围和取值列表不会推测,替换后再设置。",
1064
- "figure.text.data-not-json": "不是 JSON 格式。",
1071
+ "figure.text.data-not-json": "不是 JSON 或 JSON5 格式。",
1065
1072
  "figure.text.data-guessed": "找到 {count} 个字段。",
1066
1073
  "figure.text.data-guessed-replaces": "找到 {count} 个字段。替换后将取代当前数据结构。",
1067
1074
  "figure.button.data-use-guessed": "替换为此结构",
1068
1075
  "figure.text.data-bindings-explained": "将数据值连接到人偶。数字按原样、倍率或区间表显示;文字和真/假按代码表显示。表按值同时决定部件的颜色与效果、控制值和动作组。选择一行即可编辑。",
1069
- "figure.label.data-field": "数据值",
1070
1076
  "figure.label.data-behavior": "动作",
1071
1077
  "figure.label.data-seconds": "移动时间 (秒)",
1072
1078
  "figure.label.data-paused": "暂停",
1073
1079
  "figure.label.data-playing": "播放",
1074
1080
  "figure.label.data-otherwise": "其他值",
1081
+ "figure.label.data-keep": "保持不变",
1082
+ "figure.text.data-keep": "表中没有的值到来时,保持此表最后显示的样子。",
1075
1083
  "figure.label.data-range-from": "起 (含)",
1076
1084
  "figure.label.data-range-to": "止 (不含)",
1077
1085
  "figure.button.add-data-range": "添加区间",
@@ -1094,7 +1102,6 @@
1094
1102
  "figure.button.data-add-rotation": "生成旋转",
1095
1103
  "figure.label.data-none": "无",
1096
1104
  "figure.button.remove-data-binding": "删除绑定",
1097
- "figure.label.data-items-count": "{count} 个物品",
1098
1105
  "figure.text.data-value-not-a-list": "{path}:不是列表。",
1099
1106
  "figure.text.data-value-value-unknown": "{path}:不在取值列表中。",
1100
1107
  "figure.text.data-value-json-unreadable": "{path}:无法读取 JSON 文本。",
@@ -1232,10 +1239,7 @@
1232
1239
  "figure.label.data-legend-role": "图例角色",
1233
1240
  "figure.label.data-legend-swappable": "使用方可换成图例",
1234
1241
  "figure.label.data-method": "方式",
1235
- "figure.label.data-new-field": "新字段",
1236
1242
  "figure.label.data-sample-legend": "示例图例",
1237
- "figure.label.data-table-legend": "图例 {name}",
1238
- "figure.label.data-table-own": "人偶自身的表",
1239
1243
  "figure.label.data-target-group": "动作组",
1240
1244
  "figure.label.data-target-inputs": "控制值",
1241
1245
  "figure.label.data-target-parts": "部件",
@@ -1322,6 +1326,7 @@
1322
1326
  "figure.button.shift-track": "平移",
1323
1327
  "figure.button.copy-track-apply": "复制",
1324
1328
  "figure.text.copy-clip-needs-group": "副本驱动相同的控制值,因此只能复制已放入动作组的片段。",
1329
+ "figure.text.copy-clip-makes-group": "将所有轨道复制为新名称的片段。副本驱动相同的控制值,因此以此片段的名称创建动作组并放入两者(同一时间只播放一个)。",
1325
1330
  "figure.label.primitive-pipe": "管",
1326
1331
  "figure.label.primitive-duct": "风管",
1327
1332
  "figure.label.primitive-tapered-box": "渐变箱",
@@ -1402,5 +1407,20 @@
1402
1407
  "figure.label.clip-interpolation-smooth": "平滑启停",
1403
1408
  "figure.label.clip-interpolation-step": "立即切换",
1404
1409
  "figure.text.copy-track-off-key": "平滑运动的轨道只能平移到其关键帧所在的时刻。可用值:{seconds} 秒",
1405
- "figure.text.mirror-copies-clips": "剪辑“{clips}”中的动作也会一并复制到副本。"
1410
+ "figure.text.mirror-copies-clips": "剪辑“{clips}”中的动作也会一并复制到副本。",
1411
+ "figure.label.data-bindings-title": "数据绑定",
1412
+ "figure.label.data-column-field": "字段",
1413
+ "figure.label.data-column-kind": "类型",
1414
+ "figure.label.data-column-moves": "连接对象",
1415
+ "figure.text.data-method-does-value": "直接应用数据值,不做转换。",
1416
+ "figure.text.data-method-does-scale": "将数据值乘以倍率后应用。",
1417
+ "figure.text.data-method-does-code": "按每个数据值应用表中设定的内容。",
1418
+ "figure.text.data-method-does-ranges": "按数据值所在区间应用表中设定的内容。",
1419
+ "figure.label.data-bindings-without-field": "没有字段的绑定",
1420
+ "figure.button.add": "添加",
1421
+ "figure.label.data-targets": "对象",
1422
+ "figure.label.data-target-add": "添加对象",
1423
+ "figure.label.data-target-choose": "选择部件或控制值",
1424
+ "figure.text.data-no-targets": "尚未选择对象。",
1425
+ "figure.button.data-remove-target": "删除对象"
1406
1426
  }
@@ -0,0 +1,22 @@
1
+ # Humanoid walk/run tutorial — recorder
2
+
3
+ The lesson itself lives in operato-figure: `helps/figure/tutorial-humanoid(.ko).html`, with its videos in
4
+ `helps/figure/media/humanoid/`. This folder re-records those videos whenever the modeller's screens change.
5
+
6
+ - `chapters.mjs` — the eight chapters, one per section of the lesson, in the order a person works. Values here and in
7
+ the lesson's tables must stay the same.
8
+ - `lib.mjs` — the "hands": finds a button or field by the words on the screen, moves a drawn pointer there and clicks
9
+ or types with the real mouse and keyboard. Nothing reaches into the editor's state.
10
+ - `record.mjs` — opens the modeller and plays the chapters, one WebM per chapter plus its last screen as PNG.
11
+ - `encode.mjs` — WebM → MP4 (H.264) and PNG → poster JPG for the lesson.
12
+
13
+ ```bash
14
+ # 1. a modeller page that opens an empty figure in Korean (the recordings show the Korean interface)
15
+ # 2. a dry run without video: every chapter must pass
16
+ node tutorial/humanoid/record.mjs --url http://localhost:5179/?fresh --out ./recordings --no-video
17
+ # 3. record at lesson pace, then encode into the lesson's media folder
18
+ node tutorial/humanoid/record.mjs --url http://localhost:5179/?fresh --out ./recordings --pace 1.2
19
+ node tutorial/humanoid/encode.mjs ./recordings ../../../operato-application/packages/operato-figure/helps/figure/media/humanoid
20
+ ```
21
+
22
+ A chapter that fails leaves `<id>-failed.png` showing where the hands stopped. ffmpeg must be on the PATH.
@@ -0,0 +1,247 @@
1
+ /*
2
+ * 휴머노이드 걷기·뛰기 튜토리얼의 장(章)들 -- 교재(operato-figure helps의 tutorial-humanoid)의 절과 하나씩 맞물린다.
3
+ * 한 장은 사람이 하는 그대로의 순서다: 부품을 고르고, 이름과 크기를 넣고, 붙이고, 관절을 넣고, 자세를 기록한다.
4
+ * 장은 앞 장이 끝난 모습에서 시작하므로 처음부터 차례로 실행한다.
5
+ */
6
+
7
+ /* Where a part's fastening dialog is: the attach link of a part not yet fastened, or the change link of one that is. */
8
+ const openAttach = h => h.click('「다른 부품에 붙이기」 or 「바꾸기」', t => t.byWords(['button', 'a'], '다른 부품에 붙이기')[0] || t.byWords(['button', 'a'], '바꾸기')[0])
9
+ const inDialog = (h, words) => h.click(`dialog 「${words}」`, (t, w) => t.byWords(['button', 'span', 'label', 'div', 'text'], w).filter(e => t.inDialog(e) && t.words(e) === w).pop(), words)
10
+ const apply = h => h.click('the dialog\'s 「붙이기」/「적용」', t => t.query('md-filled-button').filter(b => ['붙이기', '적용'].includes(t.words(b))).pop())
11
+
12
+ async function addPart(h, shape, name, size) {
13
+ await h.buttonTitled(shape)
14
+ await h.tab('속성')
15
+ await h.field('이름', name)
16
+ await h.tab('크기')
17
+ if (shape === '구') {
18
+ await h.field('X·Z · 지름', size[0])
19
+ await h.field('Y · 지름', size[1])
20
+ } else {
21
+ await h.field('X', size[0])
22
+ await h.field('Y', size[1])
23
+ await h.field('Z', size[2])
24
+ }
25
+ await h.tab('속성')
26
+ }
27
+
28
+ async function attach(h, target, face) {
29
+ await openAttach(h)
30
+ await inDialog(h, target)
31
+ if (face) await inDialog(h, face)
32
+ }
33
+
34
+ async function joint(h, name, min, max, pivot) {
35
+ await h.tab('움직임')
36
+ await h.field('조작 이름', name)
37
+ await h.typeInto('range min', t => t.fieldAfter('범위 (deg)', 0) || t.fieldAfter('범위 (도)', 0), min)
38
+ await h.typeInto('range max', t => t.fieldAfter('범위 (deg)', 1) || t.fieldAfter('범위 (도)', 1), max)
39
+ /* 회전 중심을 이름으로: 위 끝 · 가운데 · 아래 끝 -- 숫자는 버튼이 채운다. */
40
+ if (pivot) await h.click(`pivot ${pivot}`, (t, p) => t.query(`md-filter-chip[data-value="${p}"]`)[0], pivot)
41
+ await h.button('움직임 추가')
42
+ }
43
+
44
+ /*
45
+ * A pose, on the pose bar right under the controls: the clip chosen there, its moment typed, the eight controls typed,
46
+ * then 「현재 자세 기록」 -- no scrolling down to the clip.
47
+ */
48
+ async function pose(h, clip, seconds, values) {
49
+ const bar = 'div[pose-bar]'
50
+ const chosen = await h.page.evaluate((b, c) => {
51
+ const t = window.__tut
52
+ const choice = t.all().find(e => e.matches && e.matches(`${b} span[clip-choices] button`) && t.words(e) === c)
53
+ return !choice || choice.getAttribute('aria-pressed') === 'true'
54
+ }, bar, clip)
55
+ if (!chosen) await h.click(`pose bar ${clip}`, (t, b, c) => t.all().find(e => e.matches && e.matches(`${b} span[clip-choices] button`) && t.words(e) === c && t.visible(e)), bar, clip)
56
+ await h.typeInto(`${clip} time`, (t, b) => t.bring(`${b} input[type=number]`), seconds, bar)
57
+ for (const [control, value] of Object.entries(values))
58
+ await h.typeInto(`control ${control}`, (t, c) => t.bring(`input[type=number][aria-label="${c}"]`), value, control)
59
+ await h.click(`${clip} 「현재 자세 기록」`, (t, b) => t.bring(`${b} button[remember-pose]`), bar)
60
+ }
61
+
62
+ export const chapters = [
63
+ {
64
+ id: '01-pelvis',
65
+ title: '1. 시작 — 골반',
66
+ note: '바닥에 서는 피규어를 만들고, 몸의 중심인 골반을 880 mm 띄웁니다',
67
+ async run(h) {
68
+ await h.say('어디에 두는 설비인지 고릅니다', '사람은 바닥에 섭니다')
69
+ await h.click('start 바닥', t => t.byWords(['button'], '바닥')[0])
70
+ await h.say('첫 부품으로 상자를 놓습니다')
71
+ await h.click('start box', t => t.query('button[start-shape="box"]')[0])
72
+ await h.say('이름을 pelvis로 바꿉니다', '이름은 나중에 관절·데이터 연결에 그대로 쓰입니다')
73
+ await h.field('이름', 'pelvis')
74
+ await h.say('크기 탭에서 300 × 180 × 200 mm', 'Y가 위쪽입니다')
75
+ await h.tab('크기')
76
+ await h.field('X', 300)
77
+ await h.field('Y', 180)
78
+ await h.field('Z', 200)
79
+ await h.tab('속성')
80
+ await h.say('「바꾸기」 → 세부 → 간격 880 mm', '다리가 들어갈 자리만큼 바닥에서 띄웁니다')
81
+ await h.button('바꾸기')
82
+ await h.click('세부', t => t.byWords(['summary'], '세부')[0] || t.byWords(['summary'], '세부 · ').pop())
83
+ await h.typeInDialog('gap', t => t.query('input[gap]')[0], 880)
84
+ await apply(h)
85
+ await h.say('')
86
+ }
87
+ },
88
+ {
89
+ id: '02-body',
90
+ title: '2. 몸통과 머리',
91
+ note: '「다른 부품에 붙이기」로 부품을 쌓습니다',
92
+ async run(h) {
93
+ await h.say('상자를 하나 더 놓고 torso라고 이름 붙입니다', '340 × 500 × 200 mm')
94
+ await addPart(h, '상자', 'torso', [340, 500, 200])
95
+ await h.say('pelvis에 붙입니다', '비어 있는 윗면 가운데가 기본입니다')
96
+ await attach(h, 'pelvis')
97
+ await apply(h)
98
+ await h.say('구를 놓고 head — 지름 200 × 240', '구는 가로(X·Z) 지름과 세로(Y) 지름 두 가지로 정합니다')
99
+ await addPart(h, '구', 'head', [200, 240])
100
+ await attach(h, 'torso')
101
+ await apply(h)
102
+ await h.say('')
103
+ }
104
+ },
105
+ {
106
+ id: '03-left-leg',
107
+ title: '3. 왼쪽 다리와 관절',
108
+ note: '허벅지 → 정강이 → 발, 고관절과 무릎',
109
+ async run(h) {
110
+ await h.say('l-thigh: 130 × 420 × 140', '왼쪽(−X) 부품은 l- 로 시작합니다 — 대칭 복사가 r- 로 바꿔 줍니다')
111
+ await addPart(h, '상자', 'l-thigh', [130, 420, 140])
112
+ await h.say('pelvis에 붙이면 비어 있는 아랫면이 기본입니다')
113
+ await attach(h, 'pelvis')
114
+ await h.say('세부 → 좌우 위치 「왼쪽 끝 정렬」', '골반의 왼쪽 가장자리에 맞춥니다')
115
+ await h.click('세부', t => t.byWords(['summary'], '세부')[0] || t.query('details[fasten-detail] summary')[0])
116
+ await h.click('왼쪽 끝 정렬', t => t.byWords(['button'], '왼쪽 끝 정렬')[0])
117
+ await apply(h)
118
+ await h.say('움직임 탭: 회전 · 축 X, 이름 l-hip, −60 ~ 60°', '다른 부품에 붙은 부품은 「회전」이 기본입니다')
119
+ await joint(h, 'l-hip', -60, 60)
120
+ await h.say('l-shin: 110 × 420 × 120 → l-thigh 아랫면')
121
+ await addPart(h, '상자', 'l-shin', [110, 420, 120])
122
+ await attach(h, 'l-thigh')
123
+ await apply(h)
124
+ await h.say('무릎 l-knee: 0 ~ 130°', '+ 가 뒤로 굽는 방향입니다 (반투명 미리보기로 확인)')
125
+ await joint(h, 'l-knee', 0, 130)
126
+ await h.say('l-foot: 110 × 70 × 240 → l-shin 아랫면')
127
+ await addPart(h, '상자', 'l-foot', [110, 70, 240])
128
+ await attach(h, 'l-shin')
129
+ await apply(h)
130
+ await h.say('')
131
+ }
132
+ },
133
+ {
134
+ id: '04-left-arm',
135
+ title: '4. 왼팔과 관절',
136
+ note: '옆면에 붙이고 위 끝에 맞추기, 회전 중심 옮기기',
137
+ async run(h) {
138
+ await h.say('l-upperarm: 90 × 300 × 90')
139
+ await addPart(h, '상자', 'l-upperarm', [90, 300, 90])
140
+ await h.say('torso에 붙이면 비어 있는 「왼쪽 면」이 기본입니다')
141
+ await attach(h, 'torso')
142
+ await h.say('세부 → 위아래 위치 「위 끝 정렬」', '어깨 높이에 맞춥니다')
143
+ await h.click('세부', t => t.byWords(['summary'], '세부')[0] || t.query('details[fasten-detail] summary')[0])
144
+ await h.click('위 끝 정렬', t => t.byWords(['button'], '위 끝 정렬')[0])
145
+ await apply(h)
146
+ await h.say('어깨 l-shoulder: −60 ~ 60°, 회전 중심 「위 끝」', '위 끝 정렬로 붙였으니 이미 위 끝이 골라져 있습니다 — 숫자는 버튼이 채웁니다')
147
+ await joint(h, 'l-shoulder', -60, 60, 'top')
148
+ await h.say('l-forearm: 80 × 280 × 80 → l-upperarm', '세로로 긴 팔에 붙이면 아랫면이 기본입니다')
149
+ await addPart(h, '상자', 'l-forearm', [80, 280, 80])
150
+ await attach(h, 'l-upperarm')
151
+ await apply(h)
152
+ await h.say('팔꿈치 l-elbow: −130 ~ 0°', '− 가 앞으로 굽는 방향입니다')
153
+ await joint(h, 'l-elbow', -130, 0)
154
+ await h.say('')
155
+ }
156
+ },
157
+ {
158
+ id: '05-mirror',
159
+ title: '5. 대칭 복사로 오른쪽',
160
+ note: '아래 붙은 부품과 관절까지 한 번에',
161
+ async run(h) {
162
+ for (const part of ['l-thigh', 'l-upperarm']) {
163
+ await h.say(`${part} 선택 → 속성 → 더 보기 → 「X 반대편에」`, '이름 앞머리 l- → r- 가 자동으로 채워집니다')
164
+ await h.click(`list ${part}`, (t, p) => t.all().filter(e => t.visible(e) && e.getBoundingClientRect().left < 220 && [...e.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim() === p)[0], part)
165
+ await h.tab('속성')
166
+ await h.click('더 보기', t => t.byWords(['summary', 'button', 'span'], '더 보기')[0])
167
+ await h.click('X 반대편에', t => t.byWords(['button'], 'X 반대편에')[0])
168
+ await h.say('「움직임 방향 유지」를 켜면 좌우 관절의 + 방향이 같아집니다', '걷기 자세를 좌우 같은 숫자 규칙으로 넣을 수 있습니다')
169
+ await h.click('움직임 방향 유지', t => t.byWords(['label'], '움직임 방향 유지')[0])
170
+ await h.click('X 반대편에 (confirm)', t => t.byWords(['button'], 'X 반대편에').pop())
171
+ }
172
+ await h.page.keyboard.press('Escape')
173
+ await h.say('정면 뷰로 확인합니다')
174
+ await h.click('Front view', t => t.all().find(e => e.getAttribute && e.getAttribute('title') === 'Front' && t.visible(e)))
175
+ await h.wait(1500)
176
+ await h.click('Perspective view', t => t.all().find(e => e.getAttribute && e.getAttribute('title') === 'Perspective' && t.visible(e)))
177
+ await h.say('')
178
+ }
179
+ },
180
+ {
181
+ id: '06-walk',
182
+ title: '6. 걷기 — 자세 4개 기록',
183
+ note: '시간을 옮기고, 조작값을 넣고, 「현재 자세 기록」',
184
+ async run(h) {
185
+ await h.page.keyboard.press('Escape')
186
+ await h.tab('움직임')
187
+ await h.say('클립 이름 walk, 길이 1.1초 → 「재생 추가」', '첫 트랙 하나만 만들면 나머지는 자세 기록이 만들어 줍니다')
188
+ await h.field('clip 이름', 'walk')
189
+ await h.field('길이 (초)', 1.1)
190
+ await h.button('재생 추가')
191
+ const walk = [
192
+ [0, { 'l-hip': -25, 'r-hip': 15, 'l-knee': 5, 'r-knee': 20, 'l-shoulder': 20, 'r-shoulder': -20, 'l-elbow': -15, 'r-elbow': -30 }, '0초 — 왼발 내딛기'],
193
+ [0.275, { 'l-hip': -5, 'r-hip': -10, 'l-knee': 10, 'r-knee': 55, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -20, 'r-elbow': -20 }, '0.275초 — 오른발 지나가기'],
194
+ [0.55, { 'l-hip': 15, 'r-hip': -25, 'l-knee': 20, 'r-knee': 5, 'l-shoulder': -20, 'r-shoulder': 20, 'l-elbow': -30, 'r-elbow': -15 }, '0.55초 — 오른발 내딛기'],
195
+ [0.825, { 'l-hip': -10, 'r-hip': -5, 'l-knee': 55, 'r-knee': 10, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -20, 'r-elbow': -20 }, '0.825초 — 왼발 지나가기']
196
+ ]
197
+ for (const [at, values, words] of walk) {
198
+ await h.say(words, '고관절 − 는 앞으로, 무릎 + 는 뒤로 굽힘. 1.1초(끝)는 0초와 같게 자동으로 맞춰집니다')
199
+ await pose(h, 'walk', at, values)
200
+ }
201
+ await h.say('')
202
+ }
203
+ },
204
+ {
205
+ id: '07-run',
206
+ title: '7. 뛰기 — 클립 복사 후 자세 바꾸기',
207
+ note: '복사하면 걷기·뛰기가 한 동작 그룹이 됩니다',
208
+ async run(h) {
209
+ await h.say('walk의 「클립 복사」', '복사본과 함께 동작 그룹이 생깁니다 — 한 번에 하나만 재생')
210
+ await h.click('copy walk', t => { const top = t.clipTop('walk'); return t.query('button[copy-clip]').find(b => b.getBoundingClientRect().top > top) })
211
+ await h.say('이름 run, 길이 0.7초', '키 시각은 길이에 맞춰 함께 줄어듭니다')
212
+ await h.typeInto('walk-copy name', t => { t.clipTop('walk-copy'); return t.all().find(e => e.tagName === 'INPUT' && e.value === 'walk-copy') }, 'run')
213
+ await h.typeInto('run length', t => { const top = t.clipTop('run'); return t.fieldAfter('길이 (초)', 0, { after: top - 5 }) }, 0.7)
214
+ const run = [
215
+ [0, { 'l-hip': -40, 'r-hip': 25, 'l-knee': 20, 'r-knee': 75, 'l-shoulder': 40, 'r-shoulder': -45, 'l-elbow': -90, 'r-elbow': -95 }, '0초 — 왼발 착지, 팔은 90° 굽혀 크게'],
216
+ [0.175, { 'l-hip': -5, 'r-hip': -45, 'l-knee': 35, 'r-knee': 110, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -90, 'r-elbow': -90 }, '0.175초 — 오른 무릎 높이 들기'],
217
+ [0.35, { 'l-hip': 25, 'r-hip': -40, 'l-knee': 75, 'r-knee': 20, 'l-shoulder': -45, 'r-shoulder': 40, 'l-elbow': -95, 'r-elbow': -90 }, '0.35초 — 오른발 착지'],
218
+ [0.525, { 'l-hip': -45, 'r-hip': -5, 'l-knee': 110, 'r-knee': 35, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -90, 'r-elbow': -90 }, '0.525초 — 왼 무릎 높이 들기']
219
+ ]
220
+ for (const [at, values, words] of run) {
221
+ await h.say(words)
222
+ await pose(h, 'run', at, values)
223
+ }
224
+ await h.say('')
225
+ }
226
+ },
227
+ {
228
+ id: '08-preview',
229
+ title: '8. 미리보기에서 재생',
230
+ note: '동작 그룹에서 클립을 고르고 배속을 올립니다',
231
+ async run(h) {
232
+ await h.button('미리보기')
233
+ await h.wait(1500)
234
+ await h.say('「동작」 그룹: 클립 walk, 배속 1', '복사로 만든 그룹은 「동작」이라는 이름으로 생깁니다')
235
+ await h.click('walk clip', t => t.query('span[clip-choices] button').find(b => t.words(b) === 'walk'))
236
+ const speed = await h.locate('speed', t => t.query('input[type=range][max="3"]').pop())
237
+ await h.drag({ x: speed.x - speed.w / 2 + 2, y: speed.y }, { x: speed.x - speed.w / 2 + speed.w / 3, y: speed.y })
238
+ await h.wait(4000)
239
+ await h.say('클립 「run」 — 같은 배속으로 바뀌어 뜁니다', '조작값 줄은 「재생 중」으로 잠깁니다')
240
+ await h.click('run clip', t => t.query('span[clip-choices] button').find(b => t.words(b) === 'run'))
241
+ await h.wait(4000)
242
+ await h.say('')
243
+ }
244
+ }
245
+ ]
246
+
247
+ export { addPart, attach, apply, joint, pose, openAttach, inDialog }
@@ -0,0 +1,26 @@
1
+ /*
2
+ * 녹화한 장(章) 영상을 교재에 싣는 형태로 바꾼다: WebM → MP4(H.264, 웹 재생용 faststart), 장의 마지막 화면 → 포스터 JPG.
3
+ *
4
+ * node tutorial/humanoid/encode.mjs <녹화 폴더> <교재 media 폴더>
5
+ *
6
+ * 교재 media 폴더는 operato-figure 의 helps/figure/media/humanoid 이다. ffmpeg 이 PATH 에 있어야 한다.
7
+ */
8
+ import { execFileSync } from 'node:child_process'
9
+ import { mkdirSync, readdirSync, statSync } from 'node:fs'
10
+ import { join, resolve } from 'node:path'
11
+
12
+ const [from, to] = process.argv.slice(2).map(p => p && resolve(p))
13
+ if (!from || !to) {
14
+ console.error('usage: node tutorial/humanoid/encode.mjs <recordings> <media folder>')
15
+ process.exit(1)
16
+ }
17
+ mkdirSync(to, { recursive: true })
18
+
19
+ for (const name of readdirSync(from).filter(n => n.endsWith('.webm')).sort()) {
20
+ const id = name.slice(0, -5)
21
+ const mp4 = join(to, `${id}.mp4`)
22
+ /* 1280 wide keeps the panels' words legible; crf 28 and no sound keep a chapter to a few MB. */
23
+ execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', join(from, name), '-vf', 'scale=1280:-2,fps=30', '-c:v', 'libx264', '-preset', 'slow', '-crf', '28', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', mp4])
24
+ execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', join(from, `${id}.png`), '-vf', 'scale=1280:-2', '-q:v', '4', join(to, `${id}.jpg`)])
25
+ console.log(`${id}.mp4 ${(statSync(mp4).size / 1e6).toFixed(1)} MB`)
26
+ }