@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
@@ -252,7 +252,7 @@ const NiceWeightPainter = ({ skinnedMesh, activeBoneIndex: initialBoneIndex, sce
252
252
  React.useEffect(() => {
253
253
  setActiveBoneIndex(initialBoneIndex);
254
254
  }, [initialBoneIndex, setActiveBoneIndex]);
255
- return (jsxRuntime.jsxs("div", { className: `nice-weight-painter ${className}`, style: { display: 'grid', gap: 8, fontSize: 13 }, children: [jsxRuntime.jsxs("label", { children: ["Bone:", jsxRuntime.jsx("select", { value: activeBoneIndex, onChange: (e) => setActiveBoneIndex(parseInt(e.target.value, 10)), style: { marginLeft: 8, width: '100%' }, children: boneNames.map((name, i) => (jsxRuntime.jsx("option", { value: i, children: name }, i))) })] }), jsxRuntime.jsxs("fieldset", { style: {
255
+ return (jsxRuntime.jsxs("div", { className: `nice-weight-painter ${className}`, style: { display: 'grid', gap: 8, fontSize: 13 }, children: [jsxRuntime.jsxs("label", { children: [t('weightPainter.bone', 'Bone:'), jsxRuntime.jsx("select", { value: activeBoneIndex, onChange: (e) => setActiveBoneIndex(parseInt(e.target.value, 10)), style: { marginLeft: 8, width: '100%' }, children: boneNames.map((name, i) => (jsxRuntime.jsx("option", { value: i, children: name }, i))) })] }), jsxRuntime.jsxs("fieldset", { style: {
256
256
  display: 'flex',
257
257
  gap: 4,
258
258
  border: '1px solid var(--nice-border, #e2e8f0)',
@@ -267,7 +267,7 @@ const NiceWeightPainter = ({ skinnedMesh, activeBoneIndex: initialBoneIndex, sce
267
267
  borderRadius: 4,
268
268
  cursor: 'pointer',
269
269
  fontSize: 11,
270
- }, children: mode }, mode)))] }), jsxRuntime.jsxs("label", { children: ["Radius: ", brush.radius.toFixed(2), jsxRuntime.jsx("input", { type: "range", min: 0.05, max: 2, step: 0.05, value: brush.radius, onChange: (e) => setBrush({ radius: parseFloat(e.target.value) }), style: { width: '100%' } })] }), jsxRuntime.jsxs("label", { children: ["Strength: ", brush.strength.toFixed(2), jsxRuntime.jsx("input", { type: "range", min: 0.01, max: 1, step: 0.01, value: brush.strength, onChange: (e) => setBrush({ strength: parseFloat(e.target.value) }), style: { width: '100%' } })] }), jsxRuntime.jsxs("div", { style: { display: 'flex', gap: 4 }, children: [jsxRuntime.jsx("button", { type: "button", onClick: isPainting ? stopPainting : startPainting, style: {
270
+ }, children: mode }, mode)))] }), jsxRuntime.jsxs("label", { children: [t('weightPainter.radius', 'Radius: {radius}').replace('{radius}', String(brush.radius.toFixed(2))), jsxRuntime.jsx("input", { type: "range", min: 0.05, max: 2, step: 0.05, value: brush.radius, onChange: (e) => setBrush({ radius: parseFloat(e.target.value) }), style: { width: '100%' } })] }), jsxRuntime.jsxs("label", { children: [t('weightPainter.strength', 'Strength: {strength}').replace('{strength}', String(brush.strength.toFixed(2))), jsxRuntime.jsx("input", { type: "range", min: 0.01, max: 1, step: 0.01, value: brush.strength, onChange: (e) => setBrush({ strength: parseFloat(e.target.value) }), style: { width: '100%' } })] }), jsxRuntime.jsxs("div", { style: { display: 'flex', gap: 4 }, children: [jsxRuntime.jsx("button", { type: "button", onClick: isPainting ? stopPainting : startPainting, style: {
271
271
  flex: 1,
272
272
  background: isPainting
273
273
  ? 'var(--nice-danger, #ef4444)'
@@ -296,7 +296,7 @@ const NiceXRPreview = ({ renderer, scene, camera, mode: initialMode, onSessionSt
296
296
  background: mode === 'vr' ? 'var(--nice-color-accent, #6366f1)' : 'transparent',
297
297
  color: mode === 'vr' ? 'var(--nice-color-white, #fff)' : 'inherit',
298
298
  border: '1px solid var(--nice-border, #e2e8f0)',
299
- }, children: [jsxRuntime.jsx(ui.NiceIcon, { name: "eye", size: 14 }), " VR"] }), jsxRuntime.jsxs("button", { type: "button", onClick: () => setMode('ar'), style: {
299
+ }, children: [jsxRuntime.jsx(ui.NiceIcon, { name: "eye", size: 14 }), " ", t('xrPreview.vr', 'VR')] }), jsxRuntime.jsxs("button", { type: "button", onClick: () => setMode('ar'), style: {
300
300
  flex: 1,
301
301
  padding: '5px 8px',
302
302
  borderRadius: 4,
@@ -304,7 +304,7 @@ const NiceXRPreview = ({ renderer, scene, camera, mode: initialMode, onSessionSt
304
304
  background: mode === 'ar' ? 'var(--nice-color-accent, #6366f1)' : 'transparent',
305
305
  color: mode === 'ar' ? 'var(--nice-color-white, #fff)' : 'inherit',
306
306
  border: '1px solid var(--nice-border, #e2e8f0)',
307
- }, children: [jsxRuntime.jsx(ui.NiceIcon, { name: "window", size: 14 }), " AR"] })] }), jsxRuntime.jsx("button", { type: "button", onClick: isSessionActive ? endSession : startSession, style: {
307
+ }, children: [jsxRuntime.jsx(ui.NiceIcon, { name: "window", size: 14 }), " ", t('xrPreview.ar', 'AR')] })] }), jsxRuntime.jsx("button", { type: "button", onClick: isSessionActive ? endSession : startSession, style: {
308
308
  background: isSessionActive
309
309
  ? 'var(--nice-danger, #ef4444)'
310
310
  : 'var(--nice-success, #22c55e)',
@@ -313,7 +313,10 @@ const NiceXRPreview = ({ renderer, scene, camera, mode: initialMode, onSessionSt
313
313
  borderRadius: 4,
314
314
  padding: '6px 12px',
315
315
  cursor: 'pointer',
316
- }, children: isSessionActive ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(ui.NiceIcon, { name: "stop", size: 14 }), " ", t('xrPreview.exitXr', 'Exit XR')] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(ui.NiceIcon, { name: "play", size: 14 }), " ", t('xrPreview.enterXr', 'Enter XR')] })) }), mode === 'vr' && (jsxRuntime.jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [jsxRuntime.jsx("input", { type: "checkbox", checked: teleportEnabled, onChange: (e) => setTeleportEnabled(e.target.checked) }), t('xrPreview.teleportation', 'Teleportation')] })), isSessionActive && (jsxRuntime.jsxs("div", { style: { fontSize: 11, opacity: 0.7 }, children: ["Controllers: ", controllers.length, jsxRuntime.jsx("br", {}), mode === 'ar' && hitTestResult && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: ["Hit: (", hitTestResult.x.toFixed(2), ", ", hitTestResult.y.toFixed(2), ",", ' ', hitTestResult.z.toFixed(2), ")"] }))] }))] }))] }));
316
+ }, children: isSessionActive ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(ui.NiceIcon, { name: "stop", size: 14 }), " ", t('xrPreview.exitXr', 'Exit XR')] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(ui.NiceIcon, { name: "play", size: 14 }), " ", t('xrPreview.enterXr', 'Enter XR')] })) }), mode === 'vr' && (jsxRuntime.jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [jsxRuntime.jsx("input", { type: "checkbox", checked: teleportEnabled, onChange: (e) => setTeleportEnabled(e.target.checked) }), t('xrPreview.teleportation', 'Teleportation')] })), isSessionActive && (jsxRuntime.jsxs("div", { style: { fontSize: 11, opacity: 0.7 }, children: [t('xrPreview.controllers', 'Controllers: {controllers}').replace('{controllers}', String(controllers.length)), jsxRuntime.jsx("br", {}), mode === 'ar' && hitTestResult && (jsxRuntime.jsx(jsxRuntime.Fragment, { children: t('xrPreview.hit', 'Hit: ({x}, {y}, {z})')
317
+ .replace('{x}', String(hitTestResult.x.toFixed(2)))
318
+ .replace('{y}', String(hitTestResult.y.toFixed(2)))
319
+ .replace('{z}', String(hitTestResult.z.toFixed(2))) }))] }))] }))] }));
317
320
  };
318
321
 
319
322
  exports.NiceXRPreview = NiceXRPreview;
@@ -24,6 +24,7 @@ var SVGLoader_js = require('three/examples/jsm/loaders/SVGLoader.js');
24
24
  var TDSLoader_js = require('three/examples/jsm/loaders/TDSLoader.js');
25
25
  var VTKLoader_js = require('three/examples/jsm/loaders/VTKLoader.js');
26
26
  var editorAsk = require('../core/editorAsk.js');
27
+ var i18n = require('../core/i18n.js');
27
28
  var threeCompat = require('../core/threeCompat.js');
28
29
  var modelEditorTypes = require('./modelEditorTypes.js');
29
30
  var modelEditorUtils = require('./modelEditorUtils.js');
@@ -76,6 +77,7 @@ function findNodeById(nodes, id) {
76
77
  ═══════════════════════════════════════════ */
77
78
  function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
78
79
  var _a;
80
+ const { t } = i18n.useNiceTranslation();
79
81
  const { pendingAsk, ask } = editorAsk.useEditorAsk();
80
82
  /* ── refs ── */
81
83
  const mountRef = React.useRef(null);
@@ -415,14 +417,16 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
415
417
  }
416
418
  focusOnObjectRef.current(obj);
417
419
  rebuildTree();
418
- setStatusText(`Loaded: ${filename} (${clips.length} animation(s))`);
419
- }, [rebuildTree]);
420
+ setStatusText(t('useModelEditor.loadedAnimationS', 'Loaded: {filename} ({clips} animation(s))')
421
+ .replace('{filename}', String(filename))
422
+ .replace('{clips}', String(clips.length)));
423
+ }, [rebuildTree, t]);
420
424
  const loadFile = React.useCallback(async (file) => {
421
425
  var _a, _b, _c, _d, _e, _f;
422
426
  const name = file.name.toLowerCase();
423
427
  const ext = '.' + name.split('.').pop();
424
428
  const url = URL.createObjectURL(file);
425
- setStatusText(`Loading ${file.name}...`);
429
+ setStatusText(t('useModelEditor.loading', 'Loading {name}...').replace('{name}', String(file.name)));
426
430
  try {
427
431
  if (ext === '.fbx') {
428
432
  const loader = new FBXLoader_js.FBXLoader();
@@ -546,32 +550,34 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
546
550
  addObjectToScene(group, [], file.name);
547
551
  }
548
552
  else {
549
- setStatusText(`Unsupported format: ${ext}`);
553
+ setStatusText(t('useModelEditor.unsupportedFormat', 'Unsupported format: {ext}').replace('{ext}', String(ext)));
550
554
  }
551
555
  }
552
556
  catch (err) {
553
557
  log.error('Load error:', err);
554
- setStatusText(`Error loading ${file.name}: ${String(err)}`);
558
+ setStatusText(t('useModelEditor.errorLoading', 'Error loading {name}: {err}')
559
+ .replace('{name}', String(file.name))
560
+ .replace('{err}', String(String(err))));
555
561
  }
556
562
  finally {
557
563
  URL.revokeObjectURL(url);
558
564
  }
559
- }, [addObjectToScene]);
565
+ }, [addObjectToScene, t]);
560
566
  const mergeAnimationFBX = React.useCallback(async (file) => {
561
567
  var _a;
562
568
  const url = URL.createObjectURL(file);
563
- setStatusText(`Merging animations from ${file.name}...`);
569
+ setStatusText(t('useModelEditor.mergingAnimationsFrom', 'Merging animations from {name}...').replace('{name}', String(file.name)));
564
570
  try {
565
571
  const loader = new FBXLoader_js.FBXLoader();
566
572
  const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
567
573
  const clips = (_a = obj.animations) !== null && _a !== void 0 ? _a : [];
568
574
  if (clips.length === 0) {
569
- setStatusText(`No animations found in ${file.name}`);
575
+ setStatusText(t('useModelEditor.noAnimationsFoundIn', 'No animations found in {name}').replace('{name}', String(file.name)));
570
576
  return;
571
577
  }
572
578
  const root = rootObjectRef.current;
573
579
  if (!root) {
574
- setStatusText('No model loaded — load a base model first');
580
+ setStatusText(t('useModelEditor.noModelLoadedLoadA', 'No model loaded — load a base model first'));
575
581
  return;
576
582
  }
577
583
  if (!mixerRef.current) {
@@ -585,16 +591,20 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
585
591
  }));
586
592
  allClipsRef.current = [...allClipsRef.current, ...newAnims];
587
593
  setAnimations([...allClipsRef.current]);
588
- setStatusText(`Merged ${clips.length} animation(s) from ${file.name}`);
594
+ setStatusText(t('useModelEditor.mergedAnimationSFrom', 'Merged {clips} animation(s) from {name}')
595
+ .replace('{clips}', String(clips.length))
596
+ .replace('{name}', String(file.name)));
589
597
  }
590
598
  catch (err) {
591
599
  log.error('Merge error:', err);
592
- setStatusText(`Error merging ${file.name}: ${String(err)}`);
600
+ setStatusText(t('useModelEditor.errorMerging', 'Error merging {name}: {err}')
601
+ .replace('{name}', String(file.name))
602
+ .replace('{err}', String(String(err))));
593
603
  }
594
604
  finally {
595
605
  URL.revokeObjectURL(url);
596
606
  }
597
- }, []);
607
+ }, [t]);
598
608
  /* ─────────────────────────────────────────
599
609
  Animation controls
600
610
  ───────────────────────────────────────── */
@@ -952,18 +962,40 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
952
962
  clone.position.x += 1;
953
963
  sceneRef.current.add(clone);
954
964
  rebuildTree();
955
- setStatusText(`Duplicated ${selectedNode.name}`);
956
- }, [selectedNode, rebuildTree]);
965
+ setStatusText(t('useModelEditor.duplicated', 'Duplicated {name}').replace('{name}', String(selectedNode.name)));
966
+ }, [selectedNode, rebuildTree, t]);
957
967
  /* ─────────────────────────────────────────
958
968
  Keyboard shortcuts
959
969
  ───────────────────────────────────────── */
960
970
  React.useEffect(() => {
971
+ // Escape, then Tab, leaves the viewport, where Tab cycles the mode (WCAG 2.1.2)
972
+ let tabEscape = false;
961
973
  const handleKey = (e) => {
962
- var _a, _b, _c;
963
- const tag = (_a = e.target) === null || _a === void 0 ? void 0 : _a.tagName;
964
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
974
+ var _a, _b, _c, _d;
975
+ // a key sent to the window itself counts as the body's
976
+ const target = e.target instanceof HTMLElement ? e.target : null;
977
+ const tag = target === null || target === void 0 ? void 0 : target.tagName;
978
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (target === null || target === void 0 ? void 0 : target.isContentEditable)) {
979
+ return;
980
+ }
981
+ // The shortcuts are this editor's: keys elsewhere on the page are not, and Tab / Space on
982
+ // one of its controls move focus and press it. They listened on the whole page — Tab moved
983
+ // focus nowhere on a page with the editor and Space pressed no button (TODO_1.3.0 I4).
984
+ const root = (_b = (_a = mountRef.current) === null || _a === void 0 ? void 0 : _a.closest('.nice-app-shell')) !== null && _b !== void 0 ? _b : mountRef.current;
985
+ if (target && target !== document.body && !(root === null || root === void 0 ? void 0 : root.contains(target))) {
986
+ return;
987
+ }
988
+ if ((e.key === 'Tab' || e.key === ' ') &&
989
+ (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"]'))) {
965
990
  return;
966
991
  }
992
+ if (e.key === 'Tab' && tabEscape) {
993
+ tabEscape = false;
994
+ return;
995
+ }
996
+ if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) {
997
+ tabEscape = e.key === 'Escape';
998
+ }
967
999
  switch (e.key.toLowerCase()) {
968
1000
  case 'g':
969
1001
  setTransformMode('translate');
@@ -990,7 +1022,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
990
1022
  const sel = findNodeById(sceneTree, selectedId);
991
1023
  if (sel) {
992
1024
  sel.object.removeFromParent();
993
- (_b = transformRef.current) === null || _b === void 0 ? void 0 : _b.detach();
1025
+ (_c = transformRef.current) === null || _c === void 0 ? void 0 : _c.detach();
994
1026
  setSelectedId(null);
995
1027
  rebuildTree();
996
1028
  }
@@ -1001,7 +1033,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1001
1033
  const sel = findNodeById(sceneTree, selectedId);
1002
1034
  if (sel) {
1003
1035
  sel.object.removeFromParent();
1004
- (_c = transformRef.current) === null || _c === void 0 ? void 0 : _c.detach();
1036
+ (_d = transformRef.current) === null || _d === void 0 ? void 0 : _d.detach();
1005
1037
  setSelectedId(null);
1006
1038
  rebuildTree();
1007
1039
  }
@@ -1097,7 +1129,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1097
1129
  const exportGLTF = React.useCallback((binary) => {
1098
1130
  const root = rootObjectRef.current;
1099
1131
  if (!root) {
1100
- setStatusText('Nothing to export');
1132
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1101
1133
  return;
1102
1134
  }
1103
1135
  const exporter = new GLTFExporter_js.GLTFExporter();
@@ -1118,16 +1150,16 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1118
1150
  a.download = `export.${binary ? 'glb' : 'gltf'}`;
1119
1151
  a.click();
1120
1152
  URL.revokeObjectURL(a.href);
1121
- setStatusText(`Exported ${binary ? 'GLB' : 'GLTF'} successfully`);
1153
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? 'GLB' : 'GLTF'));
1122
1154
  }, (err) => {
1123
1155
  log.error('Export error:', err);
1124
- setStatusText(`Export error: ${String(err)}`);
1156
+ setStatusText(t('useModelEditor.exportError', 'Export error: {err}').replace('{err}', String(String(err))));
1125
1157
  }, options);
1126
- }, []);
1158
+ }, [t]);
1127
1159
  const exportOBJ = React.useCallback(() => {
1128
1160
  const root = rootObjectRef.current;
1129
1161
  if (!root) {
1130
- setStatusText('Nothing to export');
1162
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1131
1163
  return;
1132
1164
  }
1133
1165
  const exporter = new OBJExporter_js.OBJExporter();
@@ -1138,12 +1170,12 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1138
1170
  a.download = 'export.obj';
1139
1171
  a.click();
1140
1172
  URL.revokeObjectURL(a.href);
1141
- setStatusText('Exported OBJ successfully');
1142
- }, []);
1173
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', 'OBJ'));
1174
+ }, [t]);
1143
1175
  const exportSTL = React.useCallback((binary) => {
1144
1176
  const root = rootObjectRef.current;
1145
1177
  if (!root) {
1146
- setStatusText('Nothing to export');
1178
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1147
1179
  return;
1148
1180
  }
1149
1181
  const exporter = new STLExporter_js.STLExporter();
@@ -1160,12 +1192,12 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1160
1192
  a.download = 'export.stl';
1161
1193
  a.click();
1162
1194
  URL.revokeObjectURL(a.href);
1163
- setStatusText(`Exported STL${binary ? ' (binary)' : ''} successfully`);
1164
- }, []);
1195
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? t('useModelEditor.stlBinary', 'STL (binary)') : 'STL'));
1196
+ }, [t]);
1165
1197
  const exportPLY = React.useCallback((binary) => {
1166
1198
  const root = rootObjectRef.current;
1167
1199
  if (!root) {
1168
- setStatusText('Nothing to export');
1200
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1169
1201
  return;
1170
1202
  }
1171
1203
  const exporter = new PLYExporter_js.PLYExporter();
@@ -1183,13 +1215,13 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1183
1215
  a.download = 'export.ply';
1184
1216
  a.click();
1185
1217
  URL.revokeObjectURL(a.href);
1186
- setStatusText(`Exported PLY${binary ? ' (binary)' : ''} successfully`);
1218
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? t('useModelEditor.plyBinary', 'PLY (binary)') : 'PLY'));
1187
1219
  }, { binary });
1188
- }, []);
1220
+ }, [t]);
1189
1221
  const exportUSDZ = React.useCallback(async () => {
1190
1222
  const root = rootObjectRef.current;
1191
1223
  if (!root) {
1192
- setStatusText('Nothing to export');
1224
+ setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
1193
1225
  return;
1194
1226
  }
1195
1227
  try {
@@ -1204,13 +1236,13 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1204
1236
  a.download = 'export.usdz';
1205
1237
  a.click();
1206
1238
  URL.revokeObjectURL(a.href);
1207
- setStatusText('Exported USDZ successfully');
1239
+ setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', 'USDZ'));
1208
1240
  }
1209
1241
  catch (err) {
1210
1242
  log.error('USDZ export error:', err);
1211
- setStatusText(`USDZ export error: ${String(err)}`);
1243
+ setStatusText(t('useModelEditor.usdzExportError', 'USDZ export error: {err}').replace('{err}', String(String(err))));
1212
1244
  }
1213
- }, []);
1245
+ }, [t]);
1214
1246
  /* ─────────────────────────────────────────
1215
1247
  Dispose & clear
1216
1248
  ───────────────────────────────────────── */
@@ -1279,8 +1311,8 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1279
1311
  setAnimDuration(0);
1280
1312
  setSelectedId(null);
1281
1313
  rebuildTree();
1282
- setStatusText('Scene cleared');
1283
- }, [rebuildTree, disposeObject]);
1314
+ setStatusText(t('useModelEditor.sceneCleared', 'Scene cleared'));
1315
+ }, [rebuildTree, disposeObject, t]);
1284
1316
  // asks first: clearing cannot be undone
1285
1317
  const clearScene = React.useCallback(() => {
1286
1318
  void ask('confirm', 'Clear the entire scene? This cannot be undone.').then((answer) => {
@@ -1298,7 +1330,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1298
1330
  }
1299
1331
  const root = rootObjectRef.current;
1300
1332
  if (!root) {
1301
- setStatusText('Nothing to save');
1333
+ setStatusText(t('useModelEditor.nothingToSave', 'Nothing to save'));
1302
1334
  return;
1303
1335
  }
1304
1336
  const exporter = new GLTFExporter_js.GLTFExporter();
@@ -1316,9 +1348,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1316
1348
  reader.readAsDataURL(blob);
1317
1349
  }, (err) => {
1318
1350
  log.error('Save to library error:', err);
1319
- setStatusText(`Save error: ${String(err)}`);
1351
+ setStatusText(t('useModelEditor.saveError', 'Save error: {err}').replace('{err}', String(String(err))));
1320
1352
  }, { binary: true, animations: allClipsRef.current.map((a) => a.clip) });
1321
- }, [onSaveToLibrary, ask]);
1353
+ }, [onSaveToLibrary, ask, t]);
1322
1354
  /* ─────────────────────────────────────────
1323
1355
  Add Mesh / Light / Camera / Empty
1324
1356
  ───────────────────────────────────────── */
@@ -1385,9 +1417,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1385
1417
  }
1386
1418
  sceneRef.current.add(mesh);
1387
1419
  rebuildTree();
1388
- setStatusText(`Added ${mesh.name}`);
1420
+ setStatusText(t('useModelEditor.added', 'Added {name}').replace('{name}', String(mesh.name)));
1389
1421
  setAddMenuOpen(null);
1390
- }, [rebuildTree]);
1422
+ }, [rebuildTree, t]);
1391
1423
  const addSceneLight = React.useCallback((type) => {
1392
1424
  let light;
1393
1425
  switch (type) {
@@ -1439,9 +1471,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1439
1471
  sceneRef.current.add(helper);
1440
1472
  }
1441
1473
  rebuildTree();
1442
- setStatusText(`Added ${light.name}`);
1474
+ setStatusText(t('useModelEditor.added', 'Added {name}').replace('{name}', String(light.name)));
1443
1475
  setAddMenuOpen(null);
1444
- }, [rebuildTree]);
1476
+ }, [rebuildTree, t]);
1445
1477
  const addCameraObject = React.useCallback(() => {
1446
1478
  const cam = new THREE__namespace.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
1447
1479
  cam.name = 'Camera';
@@ -1452,9 +1484,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1452
1484
  helper.name = '__helper_Camera';
1453
1485
  sceneRef.current.add(helper);
1454
1486
  rebuildTree();
1455
- setStatusText('Added Camera');
1487
+ setStatusText(t('useModelEditor.addedCamera', 'Added Camera'));
1456
1488
  setAddMenuOpen(null);
1457
- }, [rebuildTree]);
1489
+ }, [rebuildTree, t]);
1458
1490
  const addEmpty = React.useCallback((type) => {
1459
1491
  const obj = new THREE__namespace.Object3D();
1460
1492
  obj.name =
@@ -1466,9 +1498,9 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1466
1498
  obj.add(h);
1467
1499
  }
1468
1500
  rebuildTree();
1469
- setStatusText(`Added ${obj.name}`);
1501
+ setStatusText(t('useModelEditor.added', 'Added {name}').replace('{name}', String(obj.name)));
1470
1502
  setAddMenuOpen(null);
1471
- }, [rebuildTree]);
1503
+ }, [rebuildTree, t]);
1472
1504
  /* ─────────────────────────────────────────
1473
1505
  Raycaster click-to-select
1474
1506
  ───────────────────────────────────────── */
@@ -1536,14 +1568,16 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1536
1568
  }
1537
1569
  e.target.value = '';
1538
1570
  if (!onAIPoseVideo) {
1539
- setAiStatus('AI pose callback not configured.');
1571
+ setAiStatus(t('useModelEditor.aiPoseCallbackNotConfigured', 'AI pose callback not configured.'));
1540
1572
  return;
1541
1573
  }
1542
1574
  setAiBusy(true);
1543
- setAiStatus('Uploading video for AI pose estimation...');
1575
+ setAiStatus(t('useModelEditor.uploadingVideoForAiPose', 'Uploading video for AI pose estimation...'));
1544
1576
  try {
1545
1577
  const result = await onAIPoseVideo(file);
1546
- setAiStatus(`Got ${result.frame_count} frames at ${result.fps} fps. Creating animation clip...`);
1578
+ setAiStatus(t('useModelEditor.gotFramesAtFpsCreating', 'Got {frame_count} frames at {fps} fps. Creating animation clip...')
1579
+ .replace('{frame_count}', String(result.frame_count))
1580
+ .replace('{fps}', String(result.fps)));
1547
1581
  const clip = modelEditorTypes.pose3dToClip(result);
1548
1582
  if (clip && rootObjectRef.current) {
1549
1583
  if (!mixerRef.current) {
@@ -1558,20 +1592,20 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1558
1592
  allClipsRef.current = [...allClipsRef.current, newAnim];
1559
1593
  setAnimations([...allClipsRef.current]);
1560
1594
  playClip(allClipsRef.current.length - 1);
1561
- setAiStatus('Animation created successfully!');
1595
+ setAiStatus(t('useModelEditor.animationCreatedSuccessfully', 'Animation created successfully!'));
1562
1596
  }
1563
1597
  else {
1564
- setAiStatus('Could not create animation — load a rigged model first.');
1598
+ setAiStatus(t('useModelEditor.couldNotCreateAnimationLoad', 'Could not create animation — load a rigged model first.'));
1565
1599
  }
1566
1600
  }
1567
1601
  catch (err) {
1568
1602
  log.error('AI pose error:', err);
1569
- setAiStatus(`Error: ${String(err)}`);
1603
+ setAiStatus(t('useModelEditor.error', 'Error: {err}').replace('{err}', String(String(err))));
1570
1604
  }
1571
1605
  finally {
1572
1606
  setAiBusy(false);
1573
1607
  }
1574
- }, [playClip, onAIPoseVideo]);
1608
+ }, [playClip, onAIPoseVideo, t]);
1575
1609
  /* ─────────────────────────────────────────
1576
1610
  Node helpers
1577
1611
  ───────────────────────────────────────── */
@@ -1595,26 +1629,37 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1595
1629
  const undo = React.useCallback(() => {
1596
1630
  const label = undoRedoRef.current.undo();
1597
1631
  if (label) {
1598
- setStatusText(`Undo: ${label}`);
1632
+ setStatusText(t('useModelEditor.undo', 'Undo: {label}').replace('{label}', String(label)));
1599
1633
  rebuildTree();
1600
1634
  }
1601
1635
  setUndoRedoVer((v) => v + 1);
1602
- }, [rebuildTree]);
1636
+ }, [rebuildTree, t]);
1603
1637
  const redo = React.useCallback(() => {
1604
1638
  const label = undoRedoRef.current.redo();
1605
1639
  if (label) {
1606
- setStatusText(`Redo: ${label}`);
1640
+ setStatusText(t('useModelEditor.redo', 'Redo: {label}').replace('{label}', String(label)));
1607
1641
  rebuildTree();
1608
1642
  }
1609
1643
  setUndoRedoVer((v) => v + 1);
1610
- }, [rebuildTree]);
1644
+ }, [rebuildTree, t]);
1611
1645
  const canUndo = undoRedoRef.current.canUndo();
1612
1646
  const canRedo = undoRedoRef.current.canRedo();
1613
1647
  const undoLabel = undoRedoRef.current.undoLabel;
1614
1648
  const redoLabel = undoRedoRef.current.redoLabel;
1615
- // Keyboard shortcuts for undo/redo
1649
+ // Keyboard shortcuts for undo/redo — in this editor only, and not in a text field, whose own
1650
+ // undo they took (on the whole page before)
1616
1651
  React.useEffect(() => {
1617
1652
  const handleKeyDown = (e) => {
1653
+ var _a, _b;
1654
+ const target = e.target instanceof HTMLElement ? e.target : null;
1655
+ const tag = target === null || target === void 0 ? void 0 : target.tagName;
1656
+ const root = (_b = (_a = mountRef.current) === null || _a === void 0 ? void 0 : _a.closest('.nice-app-shell')) !== null && _b !== void 0 ? _b : mountRef.current;
1657
+ if (tag === 'INPUT' ||
1658
+ tag === 'TEXTAREA' ||
1659
+ (target === null || target === void 0 ? void 0 : target.isContentEditable) ||
1660
+ (target && target !== document.body && !(root === null || root === void 0 ? void 0 : root.contains(target)))) {
1661
+ return;
1662
+ }
1618
1663
  if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
1619
1664
  e.preventDefault();
1620
1665
  undo();
@@ -1636,30 +1681,35 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1636
1681
  return modelEditorTypes.SUPPORTED_EXTENSIONS.includes(ext);
1637
1682
  });
1638
1683
  if (fileArray.length === 0) {
1639
- setStatusText('No supported files found');
1684
+ setStatusText(t('useModelEditor.noSupportedFilesFound', 'No supported files found'));
1640
1685
  return;
1641
1686
  }
1642
- setStatusText(`Importing ${fileArray.length} file(s)...`);
1687
+ setStatusText(t('useModelEditor.importingFileS', 'Importing {fileArray} file(s)...').replace('{fileArray}', String(fileArray.length)));
1643
1688
  let loaded = 0;
1644
1689
  for (const file of fileArray) {
1645
1690
  try {
1646
1691
  await loadFile(file);
1647
1692
  loaded++;
1648
- setStatusText(`Imported ${loaded}/${fileArray.length}: ${file.name}`);
1693
+ setStatusText(t('useModelEditor.imported', 'Imported {loaded}/{fileArray}: {name}')
1694
+ .replace('{loaded}', String(loaded))
1695
+ .replace('{fileArray}', String(fileArray.length))
1696
+ .replace('{name}', String(file.name)));
1649
1697
  }
1650
1698
  catch (err) {
1651
1699
  log.error(`Failed to import ${file.name}:`, err);
1652
1700
  }
1653
1701
  }
1654
- setStatusText(`Batch import complete: ${loaded}/${fileArray.length} files loaded`);
1655
- }, [loadFile]);
1702
+ setStatusText(t('useModelEditor.batchImportCompleteFilesLoaded', 'Batch import complete: {loaded}/{fileArray} files loaded')
1703
+ .replace('{loaded}', String(loaded))
1704
+ .replace('{fileArray}', String(fileArray.length)));
1705
+ }, [loadFile, t]);
1656
1706
  /* ─────────────────────────────────────────
1657
1707
  PBR Presets
1658
1708
  ───────────────────────────────────────── */
1659
1709
  const applyPreset = React.useCallback((preset) => {
1660
1710
  const node = selectedNode;
1661
1711
  if (!node || !(node.object instanceof THREE__namespace.Mesh)) {
1662
- setStatusText('Select a mesh to apply material preset');
1712
+ setStatusText(t('useModelEditor.selectAMeshToApply', 'Select a mesh to apply material preset'));
1663
1713
  return;
1664
1714
  }
1665
1715
  const mesh = node.object;
@@ -1692,7 +1742,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1692
1742
  mesh.material = newMaterial;
1693
1743
  }
1694
1744
  pushUndo({
1695
- label: `Apply ${preset.name} preset`,
1745
+ label: t('useModelEditor.applyPreset', 'Apply {name} preset').replace('{name}', String(preset.name)),
1696
1746
  undo: () => {
1697
1747
  if (Array.isArray(mesh.material)) {
1698
1748
  mesh.material[materialIdx] = oldClone;
@@ -1711,15 +1761,15 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1711
1761
  },
1712
1762
  });
1713
1763
  setMatRefresh((v) => v + 1);
1714
- setStatusText(`Applied ${preset.name} preset`);
1715
- }, [selectedNode, selMaterialIdx, pushUndo]);
1764
+ setStatusText(t('useModelEditor.appliedPreset', 'Applied {name} preset').replace('{name}', String(preset.name)));
1765
+ }, [selectedNode, selMaterialIdx, pushUndo, t]);
1716
1766
  /* ─────────────────────────────────────────
1717
1767
  LOD Generation
1718
1768
  ───────────────────────────────────────── */
1719
1769
  const generateSelectedLOD = React.useCallback(() => {
1720
1770
  const node = selectedNode;
1721
1771
  if (!node || !(node.object instanceof THREE__namespace.Mesh)) {
1722
- setStatusText('Select a mesh to generate LOD');
1772
+ setStatusText(t('useModelEditor.selectAMeshToGenerate', 'Select a mesh to generate LOD'));
1723
1773
  return;
1724
1774
  }
1725
1775
  const mesh = node.object;
@@ -1728,7 +1778,7 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1728
1778
  parent.remove(mesh);
1729
1779
  parent.add(lod);
1730
1780
  pushUndo({
1731
- label: `Generate LOD for ${mesh.name}`,
1781
+ label: t('useModelEditor.generateLodFor', 'Generate LOD for {name}').replace('{name}', String(mesh.name)),
1732
1782
  undo: () => {
1733
1783
  parent.remove(lod);
1734
1784
  parent.add(mesh);
@@ -1739,8 +1789,8 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1739
1789
  },
1740
1790
  });
1741
1791
  rebuildTree();
1742
- setStatusText(`Generated LOD for ${mesh.name} (4 levels)`);
1743
- }, [selectedNode, pushUndo, rebuildTree]);
1792
+ setStatusText(t('useModelEditor.generatedLodFor4Levels', 'Generated LOD for {name} (4 levels)').replace('{name}', String(mesh.name)));
1793
+ }, [selectedNode, pushUndo, rebuildTree, t]);
1744
1794
  /* ─────────────────────────────────────────
1745
1795
  Instancing
1746
1796
  ───────────────────────────────────────── */
@@ -1748,12 +1798,14 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1748
1798
  const root = rootObjectRef.current || sceneRef.current;
1749
1799
  const result = modelEditorUtils.convertToInstanced(root);
1750
1800
  if (result.created === 0) {
1751
- setStatusText('No duplicate meshes found to instance');
1801
+ setStatusText(t('useModelEditor.noDuplicateMeshesFoundTo', 'No duplicate meshes found to instance'));
1752
1802
  return;
1753
1803
  }
1754
1804
  rebuildTree();
1755
- setStatusText(`Created ${result.created} instanced meshes, removed ${result.removed} duplicates`);
1756
- }, [rebuildTree]);
1805
+ setStatusText(t('useModelEditor.createdInstancedMeshesRemovedDuplicates', 'Created {created} instanced meshes, removed {removed} duplicates')
1806
+ .replace('{created}', String(result.created))
1807
+ .replace('{removed}', String(result.removed)));
1808
+ }, [rebuildTree, t]);
1757
1809
  /* ── HDRI Environment ── */
1758
1810
  const applyHDRI = React.useCallback((preset) => {
1759
1811
  const renderer = rendererRef.current;
@@ -1771,8 +1823,8 @@ function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
1771
1823
  else {
1772
1824
  setFogEnabled(false);
1773
1825
  }
1774
- setStatusText(`Environment: ${preset.name}`);
1775
- }, []);
1826
+ setStatusText(t('useModelEditor.environment', 'Environment: {name}').replace('{name}', String(preset.name)));
1827
+ }, [t]);
1776
1828
  /* ── Snap ── */
1777
1829
  const snapPoint = React.useCallback((point) => {
1778
1830
  const root = rootObjectRef.current || sceneRef.current;