@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.
- package/CHANGELOG.md +415 -0
- package/dist/cjs/material/NiceMaterialEditor.js +1 -0
- package/dist/cjs/model/ModelEditor.js +7 -1
- package/dist/cjs/model/ModelEditorLeftPanel.js +2 -2
- package/dist/cjs/model/ModelEditorMenuBar.js +5 -5
- package/dist/cjs/model/ModelEditorRightPanel.js +13 -9
- package/dist/cjs/model/ModelEditorSubComponents.js +10 -4
- package/dist/cjs/model/ModelEditorTimeline.js +4 -2
- package/dist/cjs/model/ModelEditorToolbar.js +1 -1
- package/dist/cjs/model/ModelEditorViewport.js +6 -3
- package/dist/cjs/model/ModelViewer.js +4 -4
- package/dist/cjs/model/NiceArmatureEditor.js +4 -4
- package/dist/cjs/model/NiceMorphTargetEditor.js +3 -1
- package/dist/cjs/model/NiceOctree.js +7 -4
- package/dist/cjs/model/NicePhysicsSimulation.js +4 -4
- package/dist/cjs/model/NiceProceduralGeometry.js +3 -3
- package/dist/cjs/model/NiceTerrainEditor.js +2 -2
- package/dist/cjs/model/NiceWeightPainter.js +2 -2
- package/dist/cjs/model/NiceXRPreview.js +6 -3
- package/dist/cjs/model/useModelEditor.js +131 -79
- package/dist/cjs/model/useModelViewer.js +19 -12
- package/dist/cjs/nice2dev-ui-3d.css +1 -1
- package/dist/cjs/particle/NiceParticleEditor.js +12 -5
- package/dist/cjs/rendering/NiceCascadedShadows.js +1 -1
- package/dist/cjs/rendering/NiceRenderExport.js +2 -2
- package/dist/cjs/rendering/NiceSSAO.js +2 -1
- package/dist/cjs/rendering/NiceSSR.js +2 -1
- package/dist/cjs/rendering/NiceWebGPURenderer.js +2 -1
- package/dist/cjs/uv/NiceUVEditor.js +3 -3
- package/dist/esm/material/NiceMaterialEditor.js +1 -0
- package/dist/esm/model/ModelEditor.js +7 -1
- package/dist/esm/model/ModelEditorLeftPanel.js +3 -3
- package/dist/esm/model/ModelEditorMenuBar.js +6 -6
- package/dist/esm/model/ModelEditorRightPanel.js +13 -9
- package/dist/esm/model/ModelEditorSubComponents.js +11 -5
- package/dist/esm/model/ModelEditorTimeline.js +5 -3
- package/dist/esm/model/ModelEditorToolbar.js +1 -1
- package/dist/esm/model/ModelEditorViewport.js +7 -4
- package/dist/esm/model/ModelViewer.js +4 -4
- package/dist/esm/model/NiceArmatureEditor.js +4 -4
- package/dist/esm/model/NiceMorphTargetEditor.js +3 -1
- package/dist/esm/model/NiceOctree.js +7 -4
- package/dist/esm/model/NicePhysicsSimulation.js +4 -4
- package/dist/esm/model/NiceProceduralGeometry.js +3 -3
- package/dist/esm/model/NiceTerrainEditor.js +2 -2
- package/dist/esm/model/NiceWeightPainter.js +2 -2
- package/dist/esm/model/NiceXRPreview.js +6 -3
- package/dist/esm/model/useModelEditor.js +131 -79
- package/dist/esm/model/useModelViewer.js +20 -13
- package/dist/esm/nice2dev-ui-3d.css +1 -1
- package/dist/esm/particle/NiceParticleEditor.js +13 -6
- package/dist/esm/rendering/NiceCascadedShadows.js +1 -1
- package/dist/esm/rendering/NiceRenderExport.js +2 -2
- package/dist/esm/rendering/NiceSSAO.js +2 -1
- package/dist/esm/rendering/NiceSSR.js +2 -1
- package/dist/esm/rendering/NiceWebGPURenderer.js +2 -1
- package/dist/esm/uv/NiceUVEditor.js +3 -3
- 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: [
|
|
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: [
|
|
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
|
|
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
|
|
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: [
|
|
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(
|
|
419
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1655
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
1756
|
-
|
|
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(
|
|
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;
|