@nice2dev/ui-3d 1.2.1 → 1.3.0

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 (58) hide show
  1. package/CHANGELOG.md +415 -0
  2. package/dist/cjs/material/NiceMaterialEditor.js +1 -0
  3. package/dist/cjs/model/ModelEditor.js +7 -1
  4. package/dist/cjs/model/ModelEditorLeftPanel.js +2 -2
  5. package/dist/cjs/model/ModelEditorMenuBar.js +5 -5
  6. package/dist/cjs/model/ModelEditorRightPanel.js +13 -9
  7. package/dist/cjs/model/ModelEditorSubComponents.js +10 -4
  8. package/dist/cjs/model/ModelEditorTimeline.js +4 -2
  9. package/dist/cjs/model/ModelEditorToolbar.js +1 -1
  10. package/dist/cjs/model/ModelEditorViewport.js +6 -3
  11. package/dist/cjs/model/ModelViewer.js +4 -4
  12. package/dist/cjs/model/NiceArmatureEditor.js +4 -4
  13. package/dist/cjs/model/NiceMorphTargetEditor.js +3 -1
  14. package/dist/cjs/model/NiceOctree.js +7 -4
  15. package/dist/cjs/model/NicePhysicsSimulation.js +4 -4
  16. package/dist/cjs/model/NiceProceduralGeometry.js +3 -3
  17. package/dist/cjs/model/NiceTerrainEditor.js +2 -2
  18. package/dist/cjs/model/NiceWeightPainter.js +2 -2
  19. package/dist/cjs/model/NiceXRPreview.js +6 -3
  20. package/dist/cjs/model/useModelEditor.js +131 -79
  21. package/dist/cjs/model/useModelViewer.js +19 -12
  22. package/dist/cjs/nice2dev-ui-3d.css +1 -1
  23. package/dist/cjs/particle/NiceParticleEditor.js +12 -5
  24. package/dist/cjs/rendering/NiceCascadedShadows.js +1 -1
  25. package/dist/cjs/rendering/NiceRenderExport.js +2 -2
  26. package/dist/cjs/rendering/NiceSSAO.js +2 -1
  27. package/dist/cjs/rendering/NiceSSR.js +2 -1
  28. package/dist/cjs/rendering/NiceWebGPURenderer.js +2 -1
  29. package/dist/cjs/uv/NiceUVEditor.js +3 -3
  30. package/dist/esm/material/NiceMaterialEditor.js +1 -0
  31. package/dist/esm/model/ModelEditor.js +7 -1
  32. package/dist/esm/model/ModelEditorLeftPanel.js +3 -3
  33. package/dist/esm/model/ModelEditorMenuBar.js +6 -6
  34. package/dist/esm/model/ModelEditorRightPanel.js +13 -9
  35. package/dist/esm/model/ModelEditorSubComponents.js +11 -5
  36. package/dist/esm/model/ModelEditorTimeline.js +5 -3
  37. package/dist/esm/model/ModelEditorToolbar.js +1 -1
  38. package/dist/esm/model/ModelEditorViewport.js +7 -4
  39. package/dist/esm/model/ModelViewer.js +4 -4
  40. package/dist/esm/model/NiceArmatureEditor.js +4 -4
  41. package/dist/esm/model/NiceMorphTargetEditor.js +3 -1
  42. package/dist/esm/model/NiceOctree.js +7 -4
  43. package/dist/esm/model/NicePhysicsSimulation.js +4 -4
  44. package/dist/esm/model/NiceProceduralGeometry.js +3 -3
  45. package/dist/esm/model/NiceTerrainEditor.js +2 -2
  46. package/dist/esm/model/NiceWeightPainter.js +2 -2
  47. package/dist/esm/model/NiceXRPreview.js +6 -3
  48. package/dist/esm/model/useModelEditor.js +131 -79
  49. package/dist/esm/model/useModelViewer.js +20 -13
  50. package/dist/esm/nice2dev-ui-3d.css +1 -1
  51. package/dist/esm/particle/NiceParticleEditor.js +13 -6
  52. package/dist/esm/rendering/NiceCascadedShadows.js +1 -1
  53. package/dist/esm/rendering/NiceRenderExport.js +2 -2
  54. package/dist/esm/rendering/NiceSSAO.js +2 -1
  55. package/dist/esm/rendering/NiceSSR.js +2 -1
  56. package/dist/esm/rendering/NiceWebGPURenderer.js +2 -1
  57. package/dist/esm/uv/NiceUVEditor.js +3 -3
  58. package/package.json +3 -3
@@ -22,6 +22,7 @@ import { SVGLoader } from 'three/examples/jsm/loaders/SVGLoader.js';
22
22
  import { TDSLoader } from 'three/examples/jsm/loaders/TDSLoader.js';
23
23
  import { VTKLoader } from 'three/examples/jsm/loaders/VTKLoader.js';
24
24
  import { useEditorAsk } from '../core/editorAsk.js';
25
+ import { useNiceTranslation } from '../core/i18n.js';
25
26
  import { createFrameClock, SOFT_SHADOW_MAP } from '../core/threeCompat.js';
26
27
  import { SUPPORTED_EXTENSIONS, pose3dToClip } from './modelEditorTypes.js';
27
28
  import { UndoRedoStack, createMaterialFromPreset, generateLOD, convertToInstanced, applyHDRIPreset, snapMulti, HDRI_PRESETS, PBR_PRESETS } from './modelEditorUtils.js';
@@ -55,6 +56,7 @@ function findNodeById(nodes, id) {
55
56
  ═══════════════════════════════════════════ */
56
57
  function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
57
58
  var _a;
59
+ const { t } = useNiceTranslation();
58
60
  const { pendingAsk, ask } = useEditorAsk();
59
61
  /* ── refs ── */
60
62
  const mountRef = useRef(null);
@@ -394,14 +396,16 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
394
396
  }
395
397
  focusOnObjectRef.current(obj);
396
398
  rebuildTree();
397
- setStatusText(`Loaded: ${filename} (${clips.length} animation(s))`);
398
- }, [rebuildTree]);
399
+ setStatusText(t('useModelEditor.loadedAnimationS', 'Loaded: {filename} ({clips} animation(s))')
400
+ .replace('{filename}', String(filename))
401
+ .replace('{clips}', String(clips.length)));
402
+ }, [rebuildTree, t]);
399
403
  const loadFile = useCallback(async (file) => {
400
404
  var _a, _b, _c, _d, _e, _f;
401
405
  const name = file.name.toLowerCase();
402
406
  const ext = '.' + name.split('.').pop();
403
407
  const url = URL.createObjectURL(file);
404
- setStatusText(`Loading ${file.name}...`);
408
+ setStatusText(t('useModelEditor.loading', 'Loading {name}...').replace('{name}', String(file.name)));
405
409
  try {
406
410
  if (ext === '.fbx') {
407
411
  const loader = new FBXLoader();
@@ -525,32 +529,34 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
525
529
  addObjectToScene(group, [], file.name);
526
530
  }
527
531
  else {
528
- setStatusText(`Unsupported format: ${ext}`);
532
+ setStatusText(t('useModelEditor.unsupportedFormat', 'Unsupported format: {ext}').replace('{ext}', String(ext)));
529
533
  }
530
534
  }
531
535
  catch (err) {
532
536
  log.error('Load error:', err);
533
- setStatusText(`Error loading ${file.name}: ${String(err)}`);
537
+ setStatusText(t('useModelEditor.errorLoading', 'Error loading {name}: {err}')
538
+ .replace('{name}', String(file.name))
539
+ .replace('{err}', String(String(err))));
534
540
  }
535
541
  finally {
536
542
  URL.revokeObjectURL(url);
537
543
  }
538
- }, [addObjectToScene]);
544
+ }, [addObjectToScene, t]);
539
545
  const mergeAnimationFBX = useCallback(async (file) => {
540
546
  var _a;
541
547
  const url = URL.createObjectURL(file);
542
- setStatusText(`Merging animations from ${file.name}...`);
548
+ setStatusText(t('useModelEditor.mergingAnimationsFrom', 'Merging animations from {name}...').replace('{name}', String(file.name)));
543
549
  try {
544
550
  const loader = new FBXLoader();
545
551
  const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
546
552
  const clips = (_a = obj.animations) !== null && _a !== void 0 ? _a : [];
547
553
  if (clips.length === 0) {
548
- setStatusText(`No animations found in ${file.name}`);
554
+ setStatusText(t('useModelEditor.noAnimationsFoundIn', 'No animations found in {name}').replace('{name}', String(file.name)));
549
555
  return;
550
556
  }
551
557
  const root = rootObjectRef.current;
552
558
  if (!root) {
553
- setStatusText('No model loaded — load a base model first');
559
+ setStatusText(t('useModelEditor.noModelLoadedLoadA', 'No model loaded — load a base model first'));
554
560
  return;
555
561
  }
556
562
  if (!mixerRef.current) {
@@ -564,16 +570,20 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
564
570
  }));
565
571
  allClipsRef.current = [...allClipsRef.current, ...newAnims];
566
572
  setAnimations([...allClipsRef.current]);
567
- setStatusText(`Merged ${clips.length} animation(s) from ${file.name}`);
573
+ setStatusText(t('useModelEditor.mergedAnimationSFrom', 'Merged {clips} animation(s) from {name}')
574
+ .replace('{clips}', String(clips.length))
575
+ .replace('{name}', String(file.name)));
568
576
  }
569
577
  catch (err) {
570
578
  log.error('Merge error:', err);
571
- setStatusText(`Error merging ${file.name}: ${String(err)}`);
579
+ setStatusText(t('useModelEditor.errorMerging', 'Error merging {name}: {err}')
580
+ .replace('{name}', String(file.name))
581
+ .replace('{err}', String(String(err))));
572
582
  }
573
583
  finally {
574
584
  URL.revokeObjectURL(url);
575
585
  }
576
- }, []);
586
+ }, [t]);
577
587
  /* ─────────────────────────────────────────
578
588
  Animation controls
579
589
  ───────────────────────────────────────── */
@@ -931,18 +941,40 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
931
941
  clone.position.x += 1;
932
942
  sceneRef.current.add(clone);
933
943
  rebuildTree();
934
- setStatusText(`Duplicated ${selectedNode.name}`);
935
- }, [selectedNode, rebuildTree]);
944
+ setStatusText(t('useModelEditor.duplicated', 'Duplicated {name}').replace('{name}', String(selectedNode.name)));
945
+ }, [selectedNode, rebuildTree, t]);
936
946
  /* ─────────────────────────────────────────
937
947
  Keyboard shortcuts
938
948
  ───────────────────────────────────────── */
939
949
  useEffect(() => {
950
+ // Escape, then Tab, leaves the viewport, where Tab cycles the mode (WCAG 2.1.2)
951
+ let tabEscape = false;
940
952
  const handleKey = (e) => {
941
- var _a, _b, _c;
942
- const tag = (_a = e.target) === null || _a === void 0 ? void 0 : _a.tagName;
943
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
953
+ var _a, _b, _c, _d;
954
+ // a key sent to the window itself counts as the body's
955
+ const target = e.target instanceof HTMLElement ? e.target : null;
956
+ const tag = target === null || target === void 0 ? void 0 : target.tagName;
957
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (target === null || target === void 0 ? void 0 : target.isContentEditable)) {
958
+ return;
959
+ }
960
+ // The shortcuts are this editor's: keys elsewhere on the page are not, and Tab / Space on
961
+ // one of its controls move focus and press it. They listened on the whole page — Tab moved
962
+ // focus nowhere on a page with the editor and Space pressed no button (TODO_1.3.0 I4).
963
+ const root = (_b = (_a = mountRef.current) === null || _a === void 0 ? void 0 : _a.closest('.nice-app-shell')) !== null && _b !== void 0 ? _b : mountRef.current;
964
+ if (target && target !== document.body && !(root === null || root === void 0 ? void 0 : root.contains(target))) {
965
+ return;
966
+ }
967
+ if ((e.key === 'Tab' || e.key === ' ') &&
968
+ (target === null || target === void 0 ? void 0 : target.closest('button, a[href], [role="button"], [role="combobox"], [role="menuitem"], [role="tab"], [role="option"], [role="checkbox"], [role="switch"], [role="slider"], [role="treeitem"]'))) {
944
969
  return;
945
970
  }
971
+ if (e.key === 'Tab' && tabEscape) {
972
+ tabEscape = false;
973
+ return;
974
+ }
975
+ if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) {
976
+ tabEscape = e.key === 'Escape';
977
+ }
946
978
  switch (e.key.toLowerCase()) {
947
979
  case 'g':
948
980
  setTransformMode('translate');
@@ -969,7 +1001,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
969
1001
  const sel = findNodeById(sceneTree, selectedId);
970
1002
  if (sel) {
971
1003
  sel.object.removeFromParent();
972
- (_b = transformRef.current) === null || _b === void 0 ? void 0 : _b.detach();
1004
+ (_c = transformRef.current) === null || _c === void 0 ? void 0 : _c.detach();
973
1005
  setSelectedId(null);
974
1006
  rebuildTree();
975
1007
  }
@@ -980,7 +1012,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
980
1012
  const sel = findNodeById(sceneTree, selectedId);
981
1013
  if (sel) {
982
1014
  sel.object.removeFromParent();
983
- (_c = transformRef.current) === null || _c === void 0 ? void 0 : _c.detach();
1015
+ (_d = transformRef.current) === null || _d === void 0 ? void 0 : _d.detach();
984
1016
  setSelectedId(null);
985
1017
  rebuildTree();
986
1018
  }
@@ -1076,7 +1108,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1076
1108
  const exportGLTF = useCallback((binary) => {
1077
1109
  const root = rootObjectRef.current;
1078
1110
  if (!root) {
1079
- setStatusText('Nothing to export');
1111
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1080
1112
  return;
1081
1113
  }
1082
1114
  const exporter = new GLTFExporter();
@@ -1097,16 +1129,16 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1097
1129
  a.download = `export.${binary ? 'glb' : 'gltf'}`;
1098
1130
  a.click();
1099
1131
  URL.revokeObjectURL(a.href);
1100
- setStatusText(`Exported ${binary ? 'GLB' : 'GLTF'} successfully`);
1132
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? 'GLB' : 'GLTF'));
1101
1133
  }, (err) => {
1102
1134
  log.error('Export error:', err);
1103
- setStatusText(`Export error: ${String(err)}`);
1135
+ setStatusText(t('useModelEditor.exportError', 'Export error: {err}').replace('{err}', String(String(err))));
1104
1136
  }, options);
1105
- }, []);
1137
+ }, [t]);
1106
1138
  const exportOBJ = useCallback(() => {
1107
1139
  const root = rootObjectRef.current;
1108
1140
  if (!root) {
1109
- setStatusText('Nothing to export');
1141
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1110
1142
  return;
1111
1143
  }
1112
1144
  const exporter = new OBJExporter();
@@ -1117,12 +1149,12 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1117
1149
  a.download = 'export.obj';
1118
1150
  a.click();
1119
1151
  URL.revokeObjectURL(a.href);
1120
- setStatusText('Exported OBJ successfully');
1121
- }, []);
1152
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', 'OBJ'));
1153
+ }, [t]);
1122
1154
  const exportSTL = useCallback((binary) => {
1123
1155
  const root = rootObjectRef.current;
1124
1156
  if (!root) {
1125
- setStatusText('Nothing to export');
1157
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1126
1158
  return;
1127
1159
  }
1128
1160
  const exporter = new STLExporter();
@@ -1139,12 +1171,12 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1139
1171
  a.download = 'export.stl';
1140
1172
  a.click();
1141
1173
  URL.revokeObjectURL(a.href);
1142
- setStatusText(`Exported STL${binary ? ' (binary)' : ''} successfully`);
1143
- }, []);
1174
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? t('useModelEditor.stlBinary', 'STL (binary)') : 'STL'));
1175
+ }, [t]);
1144
1176
  const exportPLY = useCallback((binary) => {
1145
1177
  const root = rootObjectRef.current;
1146
1178
  if (!root) {
1147
- setStatusText('Nothing to export');
1179
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1148
1180
  return;
1149
1181
  }
1150
1182
  const exporter = new PLYExporter();
@@ -1162,13 +1194,13 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1162
1194
  a.download = 'export.ply';
1163
1195
  a.click();
1164
1196
  URL.revokeObjectURL(a.href);
1165
- setStatusText(`Exported PLY${binary ? ' (binary)' : ''} successfully`);
1197
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? t('useModelEditor.plyBinary', 'PLY (binary)') : 'PLY'));
1166
1198
  }, { binary });
1167
- }, []);
1199
+ }, [t]);
1168
1200
  const exportUSDZ = useCallback(async () => {
1169
1201
  const root = rootObjectRef.current;
1170
1202
  if (!root) {
1171
- setStatusText('Nothing to export');
1203
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1172
1204
  return;
1173
1205
  }
1174
1206
  try {
@@ -1183,13 +1215,13 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1183
1215
  a.download = 'export.usdz';
1184
1216
  a.click();
1185
1217
  URL.revokeObjectURL(a.href);
1186
- setStatusText('Exported USDZ successfully');
1218
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', 'USDZ'));
1187
1219
  }
1188
1220
  catch (err) {
1189
1221
  log.error('USDZ export error:', err);
1190
- setStatusText(`USDZ export error: ${String(err)}`);
1222
+ setStatusText(t('useModelEditor.usdzExportError', 'USDZ export error: {err}').replace('{err}', String(String(err))));
1191
1223
  }
1192
- }, []);
1224
+ }, [t]);
1193
1225
  /* ─────────────────────────────────────────
1194
1226
  Dispose & clear
1195
1227
  ───────────────────────────────────────── */
@@ -1258,8 +1290,8 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1258
1290
  setAnimDuration(0);
1259
1291
  setSelectedId(null);
1260
1292
  rebuildTree();
1261
- setStatusText('Scene cleared');
1262
- }, [rebuildTree, disposeObject]);
1293
+ setStatusText(t('useModelEditor.sceneCleared', 'Scene cleared'));
1294
+ }, [rebuildTree, disposeObject, t]);
1263
1295
  // asks first: clearing cannot be undone
1264
1296
  const clearScene = useCallback(() => {
1265
1297
  void ask('confirm', 'Clear the entire scene? This cannot be undone.').then((answer) => {
@@ -1277,7 +1309,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1277
1309
  }
1278
1310
  const root = rootObjectRef.current;
1279
1311
  if (!root) {
1280
- setStatusText('Nothing to save');
1312
+ setStatusText(t('useModelEditor.nothingToSave', 'Nothing to save'));
1281
1313
  return;
1282
1314
  }
1283
1315
  const exporter = new GLTFExporter();
@@ -1295,9 +1327,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1295
1327
  reader.readAsDataURL(blob);
1296
1328
  }, (err) => {
1297
1329
  log.error('Save to library error:', err);
1298
- setStatusText(`Save error: ${String(err)}`);
1330
+ setStatusText(t('useModelEditor.saveError', 'Save error: {err}').replace('{err}', String(String(err))));
1299
1331
  }, { binary: true, animations: allClipsRef.current.map((a) => a.clip) });
1300
- }, [onSaveToLibrary, ask]);
1332
+ }, [onSaveToLibrary, ask, t]);
1301
1333
  /* ─────────────────────────────────────────
1302
1334
  Add Mesh / Light / Camera / Empty
1303
1335
  ───────────────────────────────────────── */
@@ -1364,9 +1396,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1364
1396
  }
1365
1397
  sceneRef.current.add(mesh);
1366
1398
  rebuildTree();
1367
- setStatusText(`Added ${mesh.name}`);
1399
+ setStatusText(t('useModelEditor.added', 'Added {name}').replace('{name}', String(mesh.name)));
1368
1400
  setAddMenuOpen(null);
1369
- }, [rebuildTree]);
1401
+ }, [rebuildTree, t]);
1370
1402
  const addSceneLight = useCallback((type) => {
1371
1403
  let light;
1372
1404
  switch (type) {
@@ -1418,9 +1450,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1418
1450
  sceneRef.current.add(helper);
1419
1451
  }
1420
1452
  rebuildTree();
1421
- setStatusText(`Added ${light.name}`);
1453
+ setStatusText(t('useModelEditor.added', 'Added {name}').replace('{name}', String(light.name)));
1422
1454
  setAddMenuOpen(null);
1423
- }, [rebuildTree]);
1455
+ }, [rebuildTree, t]);
1424
1456
  const addCameraObject = useCallback(() => {
1425
1457
  const cam = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
1426
1458
  cam.name = 'Camera';
@@ -1431,9 +1463,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1431
1463
  helper.name = '__helper_Camera';
1432
1464
  sceneRef.current.add(helper);
1433
1465
  rebuildTree();
1434
- setStatusText('Added Camera');
1466
+ setStatusText(t('useModelEditor.addedCamera', 'Added Camera'));
1435
1467
  setAddMenuOpen(null);
1436
- }, [rebuildTree]);
1468
+ }, [rebuildTree, t]);
1437
1469
  const addEmpty = useCallback((type) => {
1438
1470
  const obj = new THREE.Object3D();
1439
1471
  obj.name =
@@ -1445,9 +1477,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1445
1477
  obj.add(h);
1446
1478
  }
1447
1479
  rebuildTree();
1448
- setStatusText(`Added ${obj.name}`);
1480
+ setStatusText(t('useModelEditor.added', 'Added {name}').replace('{name}', String(obj.name)));
1449
1481
  setAddMenuOpen(null);
1450
- }, [rebuildTree]);
1482
+ }, [rebuildTree, t]);
1451
1483
  /* ─────────────────────────────────────────
1452
1484
  Raycaster click-to-select
1453
1485
  ───────────────────────────────────────── */
@@ -1515,14 +1547,16 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1515
1547
  }
1516
1548
  e.target.value = '';
1517
1549
  if (!onAIPoseVideo) {
1518
- setAiStatus('AI pose callback not configured.');
1550
+ setAiStatus(t('useModelEditor.aiPoseCallbackNotConfigured', 'AI pose callback not configured.'));
1519
1551
  return;
1520
1552
  }
1521
1553
  setAiBusy(true);
1522
- setAiStatus('Uploading video for AI pose estimation...');
1554
+ setAiStatus(t('useModelEditor.uploadingVideoForAiPose', 'Uploading video for AI pose estimation...'));
1523
1555
  try {
1524
1556
  const result = await onAIPoseVideo(file);
1525
- setAiStatus(`Got ${result.frame_count} frames at ${result.fps} fps. Creating animation clip...`);
1557
+ setAiStatus(t('useModelEditor.gotFramesAtFpsCreating', 'Got {frame_count} frames at {fps} fps. Creating animation clip...')
1558
+ .replace('{frame_count}', String(result.frame_count))
1559
+ .replace('{fps}', String(result.fps)));
1526
1560
  const clip = pose3dToClip(result);
1527
1561
  if (clip && rootObjectRef.current) {
1528
1562
  if (!mixerRef.current) {
@@ -1537,20 +1571,20 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1537
1571
  allClipsRef.current = [...allClipsRef.current, newAnim];
1538
1572
  setAnimations([...allClipsRef.current]);
1539
1573
  playClip(allClipsRef.current.length - 1);
1540
- setAiStatus('Animation created successfully!');
1574
+ setAiStatus(t('useModelEditor.animationCreatedSuccessfully', 'Animation created successfully!'));
1541
1575
  }
1542
1576
  else {
1543
- setAiStatus('Could not create animation — load a rigged model first.');
1577
+ setAiStatus(t('useModelEditor.couldNotCreateAnimationLoad', 'Could not create animation — load a rigged model first.'));
1544
1578
  }
1545
1579
  }
1546
1580
  catch (err) {
1547
1581
  log.error('AI pose error:', err);
1548
- setAiStatus(`Error: ${String(err)}`);
1582
+ setAiStatus(t('useModelEditor.error', 'Error: {err}').replace('{err}', String(String(err))));
1549
1583
  }
1550
1584
  finally {
1551
1585
  setAiBusy(false);
1552
1586
  }
1553
- }, [playClip, onAIPoseVideo]);
1587
+ }, [playClip, onAIPoseVideo, t]);
1554
1588
  /* ─────────────────────────────────────────
1555
1589
  Node helpers
1556
1590
  ───────────────────────────────────────── */
@@ -1574,26 +1608,37 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1574
1608
  const undo = useCallback(() => {
1575
1609
  const label = undoRedoRef.current.undo();
1576
1610
  if (label) {
1577
- setStatusText(`Undo: ${label}`);
1611
+ setStatusText(t('useModelEditor.undo', 'Undo: {label}').replace('{label}', String(label)));
1578
1612
  rebuildTree();
1579
1613
  }
1580
1614
  setUndoRedoVer((v) => v + 1);
1581
- }, [rebuildTree]);
1615
+ }, [rebuildTree, t]);
1582
1616
  const redo = useCallback(() => {
1583
1617
  const label = undoRedoRef.current.redo();
1584
1618
  if (label) {
1585
- setStatusText(`Redo: ${label}`);
1619
+ setStatusText(t('useModelEditor.redo', 'Redo: {label}').replace('{label}', String(label)));
1586
1620
  rebuildTree();
1587
1621
  }
1588
1622
  setUndoRedoVer((v) => v + 1);
1589
- }, [rebuildTree]);
1623
+ }, [rebuildTree, t]);
1590
1624
  const canUndo = undoRedoRef.current.canUndo();
1591
1625
  const canRedo = undoRedoRef.current.canRedo();
1592
1626
  const undoLabel = undoRedoRef.current.undoLabel;
1593
1627
  const redoLabel = undoRedoRef.current.redoLabel;
1594
- // Keyboard shortcuts for undo/redo
1628
+ // Keyboard shortcuts for undo/redo — in this editor only, and not in a text field, whose own
1629
+ // undo they took (on the whole page before)
1595
1630
  useEffect(() => {
1596
1631
  const handleKeyDown = (e) => {
1632
+ var _a, _b;
1633
+ const target = e.target instanceof HTMLElement ? e.target : null;
1634
+ const tag = target === null || target === void 0 ? void 0 : target.tagName;
1635
+ const root = (_b = (_a = mountRef.current) === null || _a === void 0 ? void 0 : _a.closest('.nice-app-shell')) !== null && _b !== void 0 ? _b : mountRef.current;
1636
+ if (tag === 'INPUT' ||
1637
+ tag === 'TEXTAREA' ||
1638
+ (target === null || target === void 0 ? void 0 : target.isContentEditable) ||
1639
+ (target && target !== document.body && !(root === null || root === void 0 ? void 0 : root.contains(target)))) {
1640
+ return;
1641
+ }
1597
1642
  if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
1598
1643
  e.preventDefault();
1599
1644
  undo();
@@ -1615,30 +1660,35 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1615
1660
  return SUPPORTED_EXTENSIONS.includes(ext);
1616
1661
  });
1617
1662
  if (fileArray.length === 0) {
1618
- setStatusText('No supported files found');
1663
+ setStatusText(t('useModelEditor.noSupportedFilesFound', 'No supported files found'));
1619
1664
  return;
1620
1665
  }
1621
- setStatusText(`Importing ${fileArray.length} file(s)...`);
1666
+ setStatusText(t('useModelEditor.importingFileS', 'Importing {fileArray} file(s)...').replace('{fileArray}', String(fileArray.length)));
1622
1667
  let loaded = 0;
1623
1668
  for (const file of fileArray) {
1624
1669
  try {
1625
1670
  await loadFile(file);
1626
1671
  loaded++;
1627
- setStatusText(`Imported ${loaded}/${fileArray.length}: ${file.name}`);
1672
+ setStatusText(t('useModelEditor.imported', 'Imported {loaded}/{fileArray}: {name}')
1673
+ .replace('{loaded}', String(loaded))
1674
+ .replace('{fileArray}', String(fileArray.length))
1675
+ .replace('{name}', String(file.name)));
1628
1676
  }
1629
1677
  catch (err) {
1630
1678
  log.error(`Failed to import ${file.name}:`, err);
1631
1679
  }
1632
1680
  }
1633
- setStatusText(`Batch import complete: ${loaded}/${fileArray.length} files loaded`);
1634
- }, [loadFile]);
1681
+ setStatusText(t('useModelEditor.batchImportCompleteFilesLoaded', 'Batch import complete: {loaded}/{fileArray} files loaded')
1682
+ .replace('{loaded}', String(loaded))
1683
+ .replace('{fileArray}', String(fileArray.length)));
1684
+ }, [loadFile, t]);
1635
1685
  /* ─────────────────────────────────────────
1636
1686
  PBR Presets
1637
1687
  ───────────────────────────────────────── */
1638
1688
  const applyPreset = useCallback((preset) => {
1639
1689
  const node = selectedNode;
1640
1690
  if (!node || !(node.object instanceof THREE.Mesh)) {
1641
- setStatusText('Select a mesh to apply material preset');
1691
+ setStatusText(t('useModelEditor.selectAMeshToApply', 'Select a mesh to apply material preset'));
1642
1692
  return;
1643
1693
  }
1644
1694
  const mesh = node.object;
@@ -1671,7 +1721,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1671
1721
  mesh.material = newMaterial;
1672
1722
  }
1673
1723
  pushUndo({
1674
- label: `Apply ${preset.name} preset`,
1724
+ label: t('useModelEditor.applyPreset', 'Apply {name} preset').replace('{name}', String(preset.name)),
1675
1725
  undo: () => {
1676
1726
  if (Array.isArray(mesh.material)) {
1677
1727
  mesh.material[materialIdx] = oldClone;
@@ -1690,15 +1740,15 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1690
1740
  },
1691
1741
  });
1692
1742
  setMatRefresh((v) => v + 1);
1693
- setStatusText(`Applied ${preset.name} preset`);
1694
- }, [selectedNode, selMaterialIdx, pushUndo]);
1743
+ setStatusText(t('useModelEditor.appliedPreset', 'Applied {name} preset').replace('{name}', String(preset.name)));
1744
+ }, [selectedNode, selMaterialIdx, pushUndo, t]);
1695
1745
  /* ─────────────────────────────────────────
1696
1746
  LOD Generation
1697
1747
  ───────────────────────────────────────── */
1698
1748
  const generateSelectedLOD = useCallback(() => {
1699
1749
  const node = selectedNode;
1700
1750
  if (!node || !(node.object instanceof THREE.Mesh)) {
1701
- setStatusText('Select a mesh to generate LOD');
1751
+ setStatusText(t('useModelEditor.selectAMeshToGenerate', 'Select a mesh to generate LOD'));
1702
1752
  return;
1703
1753
  }
1704
1754
  const mesh = node.object;
@@ -1707,7 +1757,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1707
1757
  parent.remove(mesh);
1708
1758
  parent.add(lod);
1709
1759
  pushUndo({
1710
- label: `Generate LOD for ${mesh.name}`,
1760
+ label: t('useModelEditor.generateLodFor', 'Generate LOD for {name}').replace('{name}', String(mesh.name)),
1711
1761
  undo: () => {
1712
1762
  parent.remove(lod);
1713
1763
  parent.add(mesh);
@@ -1718,8 +1768,8 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1718
1768
  },
1719
1769
  });
1720
1770
  rebuildTree();
1721
- setStatusText(`Generated LOD for ${mesh.name} (4 levels)`);
1722
- }, [selectedNode, pushUndo, rebuildTree]);
1771
+ setStatusText(t('useModelEditor.generatedLodFor4Levels', 'Generated LOD for {name} (4 levels)').replace('{name}', String(mesh.name)));
1772
+ }, [selectedNode, pushUndo, rebuildTree, t]);
1723
1773
  /* ─────────────────────────────────────────
1724
1774
  Instancing
1725
1775
  ───────────────────────────────────────── */
@@ -1727,12 +1777,14 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1727
1777
  const root = rootObjectRef.current || sceneRef.current;
1728
1778
  const result = convertToInstanced(root);
1729
1779
  if (result.created === 0) {
1730
- setStatusText('No duplicate meshes found to instance');
1780
+ setStatusText(t('useModelEditor.noDuplicateMeshesFoundTo', 'No duplicate meshes found to instance'));
1731
1781
  return;
1732
1782
  }
1733
1783
  rebuildTree();
1734
- setStatusText(`Created ${result.created} instanced meshes, removed ${result.removed} duplicates`);
1735
- }, [rebuildTree]);
1784
+ setStatusText(t('useModelEditor.createdInstancedMeshesRemovedDuplicates', 'Created {created} instanced meshes, removed {removed} duplicates')
1785
+ .replace('{created}', String(result.created))
1786
+ .replace('{removed}', String(result.removed)));
1787
+ }, [rebuildTree, t]);
1736
1788
  /* ── HDRI Environment ── */
1737
1789
  const applyHDRI = useCallback((preset) => {
1738
1790
  const renderer = rendererRef.current;
@@ -1750,8 +1802,8 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1750
1802
  else {
1751
1803
  setFogEnabled(false);
1752
1804
  }
1753
- setStatusText(`Environment: ${preset.name}`);
1754
- }, []);
1805
+ setStatusText(t('useModelEditor.environment', 'Environment: {name}').replace('{name}', String(preset.name)));
1806
+ }, [t]);
1755
1807
  /* ── Snap ── */
1756
1808
  const snapPoint = useCallback((point) => {
1757
1809
  const root = rootObjectRef.current || sceneRef.current;
@@ -1,4 +1,4 @@
1
- import { resolveCssColor } from '@nice2dev/ui';
1
+ import { resolveCssColor, getInputEngine } from '@nice2dev/ui';
2
2
  import { useRef, useState, useEffect, useCallback } from 'react';
3
3
  import * as THREE from 'three';
4
4
  import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
@@ -8,6 +8,7 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
8
8
  import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
9
9
  import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
10
10
  import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
11
+ import { useNiceTranslation } from '../core/i18n.js';
11
12
  import { createFrameClock, SOFT_SHADOW_MAP } from '../core/threeCompat.js';
12
13
 
13
14
  /**
@@ -41,6 +42,7 @@ const TONE_MAP = {
41
42
  ═══════════════════════════════════════════ */
42
43
  function useModelViewer(props = {}) {
43
44
  var _a;
45
+ const { t } = useNiceTranslation();
44
46
  const { src, format, navigationMode: initNavMode = 'orbit', quality = 'high', autoRotateSpeed: initAutoRotate = 0, backgroundColor = 'var(--nice-bg, #1a1a2e)', showGrid: initShowGrid = true, shadows = true, gamepad = false, cameraPresets: _cameraPresets = [], annotations: _annotations = [], hotspots: _hotspots = [], floorPlans: _floorPlans = [], onHotspotClick: _onHotspotClick, onAnnotationClick: _onAnnotationClick, onLoadProgress, onLoad, onError, clippingPlanes = [], measureMode: initMeasureMode = false, measurements: _measurements = [], onMeasure: _onMeasure, minDistance = 0.1, maxDistance = 1000, fov = 50, vr: _vr = false, walkSpeed = 5, ambientIntensity = 0.5, directionalIntensity = 0.8, antiAlias = true, toneMapping = 'aces', exposure = 1, } = props;
45
47
  /* ── refs ── */
46
48
  const mountRef = useRef(null);
@@ -160,7 +162,7 @@ function useModelViewer(props = {}) {
160
162
  ro.observe(mount);
161
163
  // Animation loop
162
164
  const animate = () => {
163
- var _a, _b, _c, _d, _e, _f, _g;
165
+ var _a, _b, _c, _d, _e, _f;
164
166
  if (disposeRef.current) {
165
167
  return;
166
168
  }
@@ -168,12 +170,12 @@ function useModelViewer(props = {}) {
168
170
  const dt = (_b = (_a = clockRef.current) === null || _a === void 0 ? void 0 : _a.getDelta()) !== null && _b !== void 0 ? _b : 0;
169
171
  // Gamepad FPS-style nav
170
172
  if (gamepad && navMode === 'fps') {
171
- const pads = (_c = navigator.getGamepads) === null || _c === void 0 ? void 0 : _c.call(navigator);
172
- const gp = (_d = pads === null || pads === void 0 ? void 0 : pads[0]) !== null && _d !== void 0 ? _d : null;
173
+ // The pads the input engine read this frame (1.3.0: the engine is the only pad reader).
174
+ const gp = (_c = getInputEngine().gamepadSnapshot[0]) !== null && _c !== void 0 ? _c : null;
173
175
  if (gp) {
174
- const lx = (_e = gp.axes[0]) !== null && _e !== void 0 ? _e : 0;
175
- const ly = (_f = gp.axes[1]) !== null && _f !== void 0 ? _f : 0;
176
- const rx = (_g = gp.axes[2]) !== null && _g !== void 0 ? _g : 0;
176
+ const lx = (_d = gp.axes[0]) !== null && _d !== void 0 ? _d : 0;
177
+ const ly = (_e = gp.axes[1]) !== null && _e !== void 0 ? _e : 0;
178
+ const rx = (_f = gp.axes[2]) !== null && _f !== void 0 ? _f : 0;
177
179
  const fwd = new THREE.Vector3();
178
180
  camera.getWorldDirection(fwd);
179
181
  fwd.y = 0;
@@ -328,7 +330,7 @@ function useModelViewer(props = {}) {
328
330
  setLoading(true);
329
331
  setLoadProgress(0);
330
332
  setError(null);
331
- setStatusText('Loading…');
333
+ setStatusText(t('useModelViewer.loading', 'Loading…'));
332
334
  const detectedFmt = detectFormat(url, fmt);
333
335
  const onProgress = (e) => {
334
336
  if (e.lengthComputable) {
@@ -436,21 +438,23 @@ function useModelViewer(props = {}) {
436
438
  mixerRef.current = mixer;
437
439
  }
438
440
  countScene();
439
- setStatusText(`Loaded – ${polyCount} tris, ${meshCount} meshes`);
441
+ setStatusText(t('useModelViewer.loadedTrisMeshes', 'Loaded – {polyCount} tris, {meshCount} meshes')
442
+ .replace('{polyCount}', String(polyCount))
443
+ .replace('{meshCount}', String(meshCount)));
440
444
  onLoad === null || onLoad === void 0 ? void 0 : onLoad();
441
445
  }
442
446
  }
443
447
  catch (err) {
444
448
  const msg = err instanceof Error ? err.message : String(err);
445
449
  setError(msg);
446
- setStatusText(`Error: ${msg}`);
450
+ setStatusText(t('useModelViewer.error', 'Error: {msg}').replace('{msg}', String(msg)));
447
451
  onError === null || onError === void 0 ? void 0 : onError(err instanceof Error ? err : new Error(msg));
448
452
  }
449
453
  finally {
450
454
  setLoading(false);
451
455
  setLoadProgress(1);
452
456
  }
453
- }, [countScene, detectFormat, onLoad, onError, onLoadProgress, polyCount, meshCount]);
457
+ }, [countScene, detectFormat, onLoad, onError, onLoadProgress, polyCount, meshCount, t]);
454
458
  /* ── auto-load from src ── */
455
459
  useEffect(() => {
456
460
  if (src) {
@@ -591,9 +595,12 @@ function useModelViewer(props = {}) {
591
595
  /* ── finalise status text ── */
592
596
  useEffect(() => {
593
597
  if (!loading && !error && polyCount > 0) {
594
- setStatusText(`${polyCount.toLocaleString()} triangles · ${meshCount} meshes · ${animNames.length} animation(s)`);
598
+ setStatusText(t('useModelViewer.trianglesMeshesAnimationS', '{polyCount} triangles · {meshCount} meshes · {animNames} animation(s)')
599
+ .replace('{polyCount}', String(polyCount.toLocaleString()))
600
+ .replace('{meshCount}', String(meshCount))
601
+ .replace('{animNames}', String(animNames.length)));
595
602
  }
596
- }, [loading, error, polyCount, meshCount, animNames.length]);
603
+ }, [loading, error, polyCount, meshCount, animNames.length, t]);
597
604
  return {
598
605
  mountRef,
599
606
  loading,