@fps-games/editor 0.1.1-beta.1 → 0.1.1-beta.2

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 (51) hide show
  1. package/dist/local-editor-harness.d.ts +30 -1
  2. package/dist/local-editor-harness.d.ts.map +1 -1
  3. package/dist/local-editor-harness.js +430 -11
  4. package/dist/local-editor-harness.js.map +1 -1
  5. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts +1 -0
  6. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts.map +1 -1
  7. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js +4 -0
  8. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js.map +1 -1
  9. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.d.ts +22 -2
  10. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.d.ts.map +1 -1
  11. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.js +469 -90
  12. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.js.map +1 -1
  13. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts +4 -0
  14. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  15. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  16. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts +9 -1
  17. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts.map +1 -1
  18. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js +77 -7
  19. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js.map +1 -1
  20. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.d.ts.map +1 -1
  21. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js +4 -1
  22. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js.map +1 -1
  23. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +25 -3
  24. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  25. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +1 -1
  26. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  27. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +227 -27
  28. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  29. package/node_modules/@fps-games/editor-browser/package.json +4 -1
  30. package/node_modules/@fps-games/editor-core/dist/editor-session.d.ts +4 -1
  31. package/node_modules/@fps-games/editor-core/dist/editor-session.d.ts.map +1 -1
  32. package/node_modules/@fps-games/editor-core/dist/editor-session.js +13 -3
  33. package/node_modules/@fps-games/editor-core/dist/editor-session.js.map +1 -1
  34. package/node_modules/@fps-games/editor-core/dist/index.d.ts +1 -0
  35. package/node_modules/@fps-games/editor-core/dist/index.d.ts.map +1 -1
  36. package/node_modules/@fps-games/editor-core/dist/index.js +1 -0
  37. package/node_modules/@fps-games/editor-core/dist/index.js.map +1 -1
  38. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts +1 -1
  39. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts.map +1 -1
  40. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.d.ts +78 -2
  41. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.d.ts.map +1 -1
  42. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.js +81 -1
  43. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.js.map +1 -1
  44. package/node_modules/@fps-games/editor-core/dist/transform-operations.d.ts +32 -0
  45. package/node_modules/@fps-games/editor-core/dist/transform-operations.d.ts.map +1 -0
  46. package/node_modules/@fps-games/editor-core/dist/transform-operations.js +125 -0
  47. package/node_modules/@fps-games/editor-core/dist/transform-operations.js.map +1 -0
  48. package/node_modules/@fps-games/editor-core/package.json +2 -2
  49. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  50. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  51. package/package.json +6 -6
@@ -1,4 +1,4 @@
1
- import { createEditorSession, createInspectorRegistry, createInspectorEditPayload, mergeInspectorSections, serializedMultiObjectToInspectorObject, serializedObjectToInspectorObject, validateSceneGraphDelete, validateSceneGraphDrop, validateSceneGraphGroupSelection, validateSceneGraphMove, validateSceneGraphRename, } from '@fps-games/editor-core';
1
+ import { createEditorSession, createInspectorRegistry, DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS, createInspectorEditPayload, computeEditorTransformActionTargets, mergeInspectorSections, normalizeEditorTransformConstraint, serializedMultiObjectToInspectorObject, serializedObjectToInspectorObject, validateSceneGraphDelete, validateSceneGraphDrop, validateSceneGraphGroupSelection, validateSceneGraphMove, validateSceneGraphRename, } from '@fps-games/editor-core';
2
2
  import { createLocalEditorBrowserUi, } from '@fps-games/editor-browser';
3
3
  import { createBabylonEditorProjection, createBabylonEditorWorld, createBabylonProjectionSelectionController, createBabylonSceneViewCameraController, createBabylonSceneViewInputController, createBabylonTransformGizmoController, focusEditorViewportSelection, } from '@fps-games/editor-babylon';
4
4
  export function createLocalEditorHarness(options) {
@@ -22,6 +22,9 @@ export function createLocalEditorHarness(options) {
22
22
  transformTool: 'select',
23
23
  transformSpace: 'world',
24
24
  transformConstraint: 'axis',
25
+ transformOperationSettings: cloneTransformOperationSettings(DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS),
26
+ duplicateDrag: null,
27
+ armedPlacement: null,
25
28
  resizeHandler: null,
26
29
  status: 'Game running',
27
30
  statusTone: 'default',
@@ -55,7 +58,7 @@ export function createLocalEditorHarness(options) {
55
58
  harness.render();
56
59
  },
57
60
  onCreateFromAsset: (assetId) => {
58
- if (addAssetToDocument(state, options, assetId))
61
+ if (createAssetFromBrowserIntent(state, options, assetId))
59
62
  harness.render();
60
63
  },
61
64
  onSelectHierarchyItem: (input) => {
@@ -102,7 +105,9 @@ export function createLocalEditorHarness(options) {
102
105
  },
103
106
  onTransformToolChange: (tool) => {
104
107
  state.transformTool = tool;
108
+ state.transformConstraint = normalizeTransformConstraint(tool, state.transformConstraint);
105
109
  state.gizmo?.setTool(tool);
110
+ state.gizmo?.setConstraint(state.transformConstraint);
106
111
  harness.render();
107
112
  },
108
113
  onTransformSpaceChange: (space) => {
@@ -111,10 +116,51 @@ export function createLocalEditorHarness(options) {
111
116
  harness.render();
112
117
  },
113
118
  onTransformConstraintChange: (constraint) => {
114
- state.transformConstraint = constraint;
115
- state.gizmo?.setConstraint(constraint);
119
+ state.transformConstraint = normalizeTransformConstraint(state.transformTool, constraint);
120
+ state.gizmo?.setConstraint(state.transformConstraint);
116
121
  harness.render();
117
122
  },
123
+ onTransformSnapEnabledChange: (enabled) => {
124
+ state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, {
125
+ snap: {
126
+ ...state.transformOperationSettings.snap,
127
+ enabled,
128
+ },
129
+ });
130
+ state.gizmo?.setOperationSettings(state.transformOperationSettings);
131
+ state.status = enabled ? 'Transform snap enabled' : 'Transform snap disabled';
132
+ harness.render();
133
+ },
134
+ onTransformSnapStepChange: (input) => {
135
+ const value = normalizePositiveStep(input.value);
136
+ if (value == null)
137
+ return;
138
+ const snap = { ...state.transformOperationSettings.snap };
139
+ if (input.kind === 'move')
140
+ snap.moveStep = value;
141
+ else if (input.kind === 'rotate')
142
+ snap.rotateStepDegrees = value;
143
+ else
144
+ snap.scaleStep = value;
145
+ state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, { snap });
146
+ state.gizmo?.setOperationSettings(state.transformOperationSettings);
147
+ state.status = `Transform snap ${input.kind} step ${value}`;
148
+ harness.render();
149
+ },
150
+ onPlacementModeChange: (mode) => {
151
+ state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, {
152
+ placementMode: normalizePlacementMode(mode),
153
+ });
154
+ state.gizmo?.setOperationSettings(state.transformOperationSettings);
155
+ if (state.transformOperationSettings.placementMode === 'off')
156
+ clearArmedPlacement(state);
157
+ state.status = `Placement mode: ${state.transformOperationSettings.placementMode}`;
158
+ harness.render();
159
+ },
160
+ onTransformAction: (action) => {
161
+ if (executeTransformAction(state, options, action))
162
+ harness.render();
163
+ },
118
164
  onFocusSelection: () => {
119
165
  if (focusSelectedProjection(state))
120
166
  harness.render();
@@ -268,6 +314,49 @@ export function mergeLocalEditorHarnessInspectorComponentSections(input) {
268
314
  }),
269
315
  };
270
316
  }
317
+ function normalizeTransformConstraint(tool, constraint) {
318
+ return normalizeEditorTransformConstraint(tool, constraint) ?? 'axis';
319
+ }
320
+ function cloneTransformOperationSettings(settings) {
321
+ return {
322
+ snap: {
323
+ enabled: settings.snap.enabled,
324
+ moveStep: settings.snap.moveStep,
325
+ rotateStepDegrees: settings.snap.rotateStepDegrees,
326
+ scaleStep: settings.snap.scaleStep,
327
+ },
328
+ placementMode: settings.placementMode,
329
+ };
330
+ }
331
+ function updateTransformOperationSettings(current, patch) {
332
+ return {
333
+ ...current,
334
+ ...patch,
335
+ snap: patch.snap
336
+ ? { ...current.snap, ...patch.snap }
337
+ : { ...current.snap },
338
+ };
339
+ }
340
+ function normalizePositiveStep(value) {
341
+ if (!Number.isFinite(value) || value <= 0)
342
+ return null;
343
+ return Number(value.toFixed(4));
344
+ }
345
+ function normalizePlacementMode(mode) {
346
+ return mode === 'ground' || mode === 'surface' ? mode : 'off';
347
+ }
348
+ function validateTransformActionSelection(state, action) {
349
+ const selection = state.session?.getState().selection;
350
+ const selectedCount = selection?.selectedIds.length ?? 0;
351
+ if (action.startsWith('align-')) {
352
+ return selectedCount >= 2 && selection?.activeId
353
+ ? { ok: true }
354
+ : { ok: false, message: 'Align needs at least 2 selected objects and an active object' };
355
+ }
356
+ return selectedCount >= 3
357
+ ? { ok: true }
358
+ : { ok: false, message: 'Distribute needs at least 3 selected objects' };
359
+ }
271
360
  async function createEditorWorld(state, options, render) {
272
361
  disposeEditorWorld(state);
273
362
  const canvas = options.worldAdapter.getCanvas();
@@ -301,8 +390,8 @@ async function createEditorWorld(state, options, render) {
301
390
  logger: console,
302
391
  onDragStart(event) {
303
392
  state.status = event.targetIds.length > 1
304
- ? `Dragging ${event.tool} ${event.targetIds.length} objects`
305
- : `Dragging ${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
393
+ ? `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.targetIds.length} objects`
394
+ : `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
306
395
  render();
307
396
  },
308
397
  onDragUpdate() {
@@ -313,12 +402,20 @@ async function createEditorWorld(state, options, render) {
313
402
  render();
314
403
  },
315
404
  onDragCancel(event) {
405
+ if (event.duplicate && cancelDuplicateDrag(state, options)) {
406
+ render();
407
+ return;
408
+ }
316
409
  state.status = event.targetIds.length > 1
317
410
  ? `Canceled ${event.tool} ${event.targetIds.length} objects`
318
411
  : `Canceled ${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
319
412
  render();
320
413
  },
414
+ onDuplicateDragStart(input) {
415
+ return beginDuplicateDrag(state, options, input);
416
+ },
321
417
  });
418
+ gizmo.setOperationSettings(state.transformOperationSettings);
322
419
  gizmo.setConstraint(state.transformConstraint);
323
420
  const selectionController = createBabylonProjectionSelectionController({
324
421
  scene: world.scene,
@@ -348,7 +445,17 @@ async function createEditorWorld(state, options, render) {
348
445
  isGizmoDragCandidate: (event) => gizmo.isGizmoDragCandidate(event),
349
446
  isBoxSelectCandidate: (event) => selectionController.isBoxSelectionCandidate(event),
350
447
  isViewPlaneMoveCandidate: (event) => gizmo.isViewPlaneMoveCandidate(event),
448
+ isPlacementCandidate: () => isPlacementArmed(state),
351
449
  onPointerIntentStart(event) {
450
+ if (event.state.intent === 'gizmo-drag') {
451
+ gizmo.preparePointerDrag(event.originalEvent);
452
+ return;
453
+ }
454
+ if (event.state.intent === 'placement') {
455
+ if (previewArmedPlacement(state, event.originalEvent))
456
+ render();
457
+ return;
458
+ }
352
459
  if (event.state.intent === 'view-plane-move') {
353
460
  if (gizmo.beginViewPlaneMove(event.originalEvent))
354
461
  render();
@@ -359,6 +466,11 @@ async function createEditorWorld(state, options, render) {
359
466
  }
360
467
  },
361
468
  onPointerIntentMove(event) {
469
+ if (event.state.intent === 'placement') {
470
+ if (previewArmedPlacement(state, event.originalEvent))
471
+ render();
472
+ return;
473
+ }
362
474
  if (event.state.intent === 'view-plane-move') {
363
475
  if (gizmo.updateViewPlaneMove(event.originalEvent))
364
476
  render();
@@ -373,6 +485,11 @@ async function createEditorWorld(state, options, render) {
373
485
  }
374
486
  },
375
487
  onPointerIntentEnd(event) {
488
+ if (event.state.intent === 'placement') {
489
+ if (commitArmedPlacement(state, options, event.originalEvent))
490
+ render();
491
+ return;
492
+ }
376
493
  if (event.state.intent === 'view-plane-move') {
377
494
  if (gizmo.endViewPlaneMove(event.originalEvent))
378
495
  render();
@@ -383,6 +500,11 @@ async function createEditorWorld(state, options, render) {
383
500
  }
384
501
  },
385
502
  onPointerIntentCancel(event) {
503
+ if (event.state.intent === 'placement') {
504
+ state.gizmo?.setPlacementMarker(null);
505
+ render();
506
+ return;
507
+ }
386
508
  if (event.state.intent === 'view-plane-move') {
387
509
  gizmo.cancelDrag();
388
510
  render();
@@ -803,6 +925,193 @@ function syncSelectionToProjection(state, selection) {
803
925
  state.gizmo?.setSelection(selection);
804
926
  state.gizmo?.refreshSelection();
805
927
  }
928
+ function executeTransformAction(state, options, action) {
929
+ if (state.mode !== 'editor' || !state.session)
930
+ return false;
931
+ const document = state.session.getState().workingDocument;
932
+ const selection = state.session.getState().selection;
933
+ cancelActiveOperation(state);
934
+ const validation = validateTransformActionSelection(state, action);
935
+ if (!validation.ok) {
936
+ state.status = validation.message;
937
+ return true;
938
+ }
939
+ const beforeTransforms = state.projection?.readNodeTransforms(selection.selectedIds) ?? {};
940
+ const transformTargets = selection.selectedIds
941
+ .map((id) => {
942
+ const transform = beforeTransforms[id];
943
+ return transform ? { id, transform } : null;
944
+ })
945
+ .filter((target) => !!target);
946
+ const targets = computeEditorTransformActionTargets({
947
+ action,
948
+ activeId: selection.activeId,
949
+ targets: transformTargets,
950
+ });
951
+ if (targets.length === 0) {
952
+ state.status = `Transform action rejected: ${action}`;
953
+ return true;
954
+ }
955
+ const changedTargets = targets.filter(target => !editorTransformSnapshotsEqual(target.before, target.after));
956
+ if (changedTargets.length === 0) {
957
+ state.status = `Transform action unchanged: ${action}`;
958
+ return true;
959
+ }
960
+ const patch = options.documentAdapter.createTransformBatchPatch?.({
961
+ document,
962
+ targetIds: changedTargets.map(target => target.id),
963
+ activeId: selection.activeId,
964
+ tool: 'move',
965
+ space: 'world',
966
+ constraint: action === 'align-all' ? 'free' : 'axis',
967
+ pivot: createTransformActionPivot(targets),
968
+ targets: changedTargets,
969
+ });
970
+ if (!patch) {
971
+ state.status = `Transform action ignored: ${action}`;
972
+ return true;
973
+ }
974
+ const result = state.session.dispatch({
975
+ type: 'document.patch',
976
+ label: patch.label ?? formatTransformActionStatus(action, changedTargets.length),
977
+ patch: patch.patch,
978
+ targetId: selection.activeId ?? changedTargets[0]?.id,
979
+ });
980
+ if (!result.documentChanged) {
981
+ state.status = `Transform action unchanged: ${action}`;
982
+ return true;
983
+ }
984
+ const changedIds = patch.changedIds ?? changedTargets.map(target => target.id);
985
+ syncProjectionForChangedIds(state, options, result.workingDocument, changedIds);
986
+ state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
987
+ state.status = patch.label ?? formatTransformActionStatus(action, changedTargets.length);
988
+ return true;
989
+ }
990
+ function createTransformActionPivot(targets) {
991
+ const center = averageTransformTargetPositions(targets);
992
+ return {
993
+ mode: 'selection-center',
994
+ position: center,
995
+ };
996
+ }
997
+ function averageTransformTargetPositions(targets) {
998
+ if (targets.length === 0)
999
+ return { x: 0, y: 0, z: 0 };
1000
+ return {
1001
+ x: targets.reduce((sum, target) => sum + target.before.position.x, 0) / targets.length,
1002
+ y: targets.reduce((sum, target) => sum + target.before.position.y, 0) / targets.length,
1003
+ z: targets.reduce((sum, target) => sum + target.before.position.z, 0) / targets.length,
1004
+ };
1005
+ }
1006
+ function editorTransformSnapshotsEqual(left, right) {
1007
+ return editorVec3Equal(left.position, right.position)
1008
+ && editorVec3Equal(left.rotation, right.rotation)
1009
+ && editorVec3Equal(left.scale, right.scale);
1010
+ }
1011
+ function editorVec3Equal(left, right) {
1012
+ return Math.abs(left.x - right.x) < 0.000000001
1013
+ && Math.abs(left.y - right.y) < 0.000000001
1014
+ && Math.abs(left.z - right.z) < 0.000000001;
1015
+ }
1016
+ function formatTransformActionStatus(action, count) {
1017
+ return `${action} ${count} object${count === 1 ? '' : 's'}`;
1018
+ }
1019
+ function createAssetFromBrowserIntent(state, options, assetId) {
1020
+ if (state.transformOperationSettings.placementMode === 'off') {
1021
+ return addAssetToDocument(state, options, assetId);
1022
+ }
1023
+ return armAssetPlacement(state, options, assetId);
1024
+ }
1025
+ function armAssetPlacement(state, options, assetId) {
1026
+ if (state.mode !== 'editor')
1027
+ return false;
1028
+ cancelActiveOperation(state);
1029
+ const asset = state.assets.find(candidate => resolveAssetId(options, candidate) === assetId);
1030
+ if (!asset)
1031
+ return false;
1032
+ state.armedPlacement = { assetId, asset };
1033
+ state.gizmo?.setPlacementMarker(null);
1034
+ state.status = `Placement armed: ${formatAssetLabel(asset, assetId)} (${state.transformOperationSettings.placementMode})`;
1035
+ return true;
1036
+ }
1037
+ function isPlacementArmed(state) {
1038
+ return state.mode === 'editor'
1039
+ && !!state.armedPlacement
1040
+ && state.transformOperationSettings.placementMode !== 'off';
1041
+ }
1042
+ function previewArmedPlacement(state, event) {
1043
+ if (!isPlacementArmed(state))
1044
+ return false;
1045
+ const hit = pickArmedPlacementHit(state, event);
1046
+ state.gizmo?.setPlacementMarker(hit);
1047
+ const mode = state.transformOperationSettings.placementMode;
1048
+ state.status = hit
1049
+ ? `Placement ${mode}: ${formatVec3(hit.position)}`
1050
+ : `Placement ${mode}: no hit`;
1051
+ return true;
1052
+ }
1053
+ function commitArmedPlacement(state, options, event) {
1054
+ if (!isPlacementArmed(state) || !state.session)
1055
+ return false;
1056
+ const armed = state.armedPlacement;
1057
+ const session = state.session;
1058
+ const beforeDocument = session.getState().workingDocument;
1059
+ const hit = pickArmedPlacementHit(state, event);
1060
+ if (!armed || !hit) {
1061
+ state.gizmo?.setPlacementMarker(null);
1062
+ state.status = 'Placement rejected: no hit';
1063
+ return true;
1064
+ }
1065
+ const patch = options.documentAdapter.createPlacedAssetPatch?.({
1066
+ document: beforeDocument,
1067
+ asset: armed.asset,
1068
+ hit,
1069
+ });
1070
+ if (!patch) {
1071
+ state.status = 'Placement rejected: document adapter does not support placed assets';
1072
+ return true;
1073
+ }
1074
+ const result = session.dispatch({
1075
+ type: 'document.patch',
1076
+ label: patch.label ?? `Place ${armed.assetId}`,
1077
+ patch: patch.patch,
1078
+ targetId: patch.createdId ?? undefined,
1079
+ });
1080
+ if (!result.documentChanged) {
1081
+ state.status = 'Placement unchanged';
1082
+ return true;
1083
+ }
1084
+ const createdId = patch.createdId
1085
+ ?? options.documentAdapter.findCreatedId?.(beforeDocument, result.workingDocument)
1086
+ ?? null;
1087
+ let selection = result.selection;
1088
+ if (createdId && isNodeSelectableInDocument(options, result.workingDocument, createdId)) {
1089
+ selection = session.dispatch({
1090
+ type: 'selection.replace',
1091
+ selectedIds: [createdId],
1092
+ activeId: createdId,
1093
+ label: 'Select Placed Item',
1094
+ }).selection;
1095
+ }
1096
+ else {
1097
+ selection = sanitizeSelection(state, options, result.workingDocument, selection) ?? selection;
1098
+ }
1099
+ clearArmedPlacement(state);
1100
+ rebuildProjectionFromDocument(state, options, result.workingDocument, selection);
1101
+ state.summary = summarizeDocument(options, result.workingDocument, session.getSource());
1102
+ state.status = patch.label ?? `Placed ${formatAssetLabel(armed.asset, armed.assetId)} at ${formatVec3(hit.position)}`;
1103
+ return true;
1104
+ }
1105
+ function pickArmedPlacementHit(state, event) {
1106
+ const mode = state.transformOperationSettings.placementMode;
1107
+ return mode === 'off'
1108
+ ? null
1109
+ : state.gizmo?.pickPlacementHit(event.clientX, event.clientY, mode) ?? null;
1110
+ }
1111
+ function clearArmedPlacement(state) {
1112
+ state.armedPlacement = null;
1113
+ state.gizmo?.setPlacementMarker(null);
1114
+ }
806
1115
  function addAssetToDocument(state, options, assetId) {
807
1116
  if (state.mode !== 'editor')
808
1117
  return false;
@@ -971,6 +1280,86 @@ function findInspectorPropertyByPath(inspector, path) {
971
1280
  }
972
1281
  return null;
973
1282
  }
1283
+ function beginDuplicateDrag(state, options, input) {
1284
+ if (state.mode !== 'editor' || !state.session)
1285
+ return null;
1286
+ if (state.duplicateDrag)
1287
+ return null;
1288
+ const document = state.session.getState().workingDocument;
1289
+ const patch = options.documentAdapter.createDuplicateSelectionPatch?.({
1290
+ document,
1291
+ targetIds: input.targetIds,
1292
+ activeId: input.activeId,
1293
+ transforms: input.beforeTransforms,
1294
+ });
1295
+ if (!patch || patch.createdIds.length === 0) {
1296
+ state.status = 'Duplicate drag rejected';
1297
+ return null;
1298
+ }
1299
+ const originalSelection = state.session.getSelection();
1300
+ const result = state.session.dispatch({
1301
+ type: 'document.patch',
1302
+ label: patch.label ?? `Duplicate ${input.targetIds.length} object(s)`,
1303
+ patch: patch.patch,
1304
+ targetId: patch.activeId ?? patch.createdIds[patch.createdIds.length - 1] ?? undefined,
1305
+ });
1306
+ if (!result.documentChanged) {
1307
+ state.status = 'Duplicate drag unchanged';
1308
+ return null;
1309
+ }
1310
+ const createdIds = patch.createdIds.filter(id => isNodeSelectableInDocument(options, result.workingDocument, id));
1311
+ if (createdIds.length === 0) {
1312
+ const undone = state.session.undo();
1313
+ if (undone)
1314
+ rebuildProjectionFromDocument(state, options, undone.workingDocument, originalSelection);
1315
+ state.status = 'Duplicate drag rejected: duplicated selection is not selectable';
1316
+ return null;
1317
+ }
1318
+ const activeId = patch.activeId && createdIds.includes(patch.activeId)
1319
+ ? patch.activeId
1320
+ : createdIds[createdIds.length - 1] ?? null;
1321
+ const selectionResult = state.session.dispatch({
1322
+ type: 'selection.replace',
1323
+ selectedIds: createdIds,
1324
+ activeId,
1325
+ label: 'Select Duplicate Drag Targets',
1326
+ });
1327
+ state.duplicateDrag = {
1328
+ originalSelection,
1329
+ createdIds,
1330
+ activeId,
1331
+ };
1332
+ rebuildProjectionFromDocument(state, options, result.workingDocument, selectionResult.selection);
1333
+ if (patch.reprojectIds?.length)
1334
+ reprojectProjectionForChangedIds(state, options, result.workingDocument, patch.reprojectIds);
1335
+ else
1336
+ syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds ?? createdIds);
1337
+ state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1338
+ state.status = patch.label ?? `Duplicated ${createdIds.length} object(s)`;
1339
+ return {
1340
+ targetIds: createdIds,
1341
+ activeId,
1342
+ };
1343
+ }
1344
+ function cancelDuplicateDrag(state, options) {
1345
+ const duplicate = state.duplicateDrag;
1346
+ if (!duplicate || !state.session)
1347
+ return false;
1348
+ state.duplicateDrag = null;
1349
+ const undone = state.session.undo();
1350
+ if (!undone)
1351
+ return false;
1352
+ const selectionResult = state.session.dispatch({
1353
+ type: 'selection.replace',
1354
+ selectedIds: duplicate.originalSelection.selectedIds,
1355
+ activeId: duplicate.originalSelection.activeId,
1356
+ label: 'Restore Duplicate Drag Selection',
1357
+ });
1358
+ rebuildProjectionFromDocument(state, options, undone.workingDocument, selectionResult.selection);
1359
+ state.summary = summarizeDocument(options, undone.workingDocument, state.session.getSource());
1360
+ state.status = `Canceled duplicate drag ${duplicate.createdIds.length} object(s)`;
1361
+ return true;
1362
+ }
974
1363
  function commitGizmoTransform(state, options, event) {
975
1364
  if (state.mode !== 'editor' || !state.session)
976
1365
  return false;
@@ -981,8 +1370,10 @@ function commitGizmoTransform(state, options, event) {
981
1370
  document,
982
1371
  });
983
1372
  if (!patch) {
984
- restoreBatchTransformPreview(state, event.targets);
985
- state.status = `Ignored ${event.tool} ${event.targetIds.length} objects`;
1373
+ if (!cancelDuplicateDrag(state, options)) {
1374
+ restoreBatchTransformPreview(state, event.targets);
1375
+ state.status = `Ignored ${event.tool} ${event.targetIds.length} objects`;
1376
+ }
986
1377
  return false;
987
1378
  }
988
1379
  const result = state.session.dispatch({
@@ -990,11 +1381,14 @@ function commitGizmoTransform(state, options, event) {
990
1381
  label: patch.label ?? `${event.tool} ${event.targetIds.length} objects`,
991
1382
  patch: patch.patch,
992
1383
  targetId: event.activeId ?? undefined,
1384
+ }, {
1385
+ mergeWithPrevious: event.targetIds.some(id => state.duplicateDrag?.createdIds.includes(id)) === true,
993
1386
  });
994
1387
  syncProjectionForDispatchResult(state, options, result);
995
1388
  syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds ?? event.targetIds);
996
1389
  state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
997
1390
  state.status = patch.label ?? `${event.tool} ${event.targetIds.length} objects`;
1391
+ state.duplicateDrag = null;
998
1392
  return result.documentChanged;
999
1393
  }
1000
1394
  const patch = options.documentAdapter.createTransformPatch?.({
@@ -1002,12 +1396,15 @@ function commitGizmoTransform(state, options, event) {
1002
1396
  targetId: event.nodeId,
1003
1397
  tool: event.tool,
1004
1398
  space: event.space,
1399
+ constraint: event.constraint,
1005
1400
  before: event.before,
1006
1401
  after: event.after,
1007
1402
  });
1008
1403
  if (!patch) {
1009
- state.projection?.setNodeTransformPreview(event.nodeId, event.before);
1010
- state.status = `Ignored ${event.tool} ${event.nodeId}`;
1404
+ if (!cancelDuplicateDrag(state, options)) {
1405
+ state.projection?.setNodeTransformPreview(event.nodeId, event.before);
1406
+ state.status = `Ignored ${event.tool} ${event.nodeId}`;
1407
+ }
1011
1408
  return false;
1012
1409
  }
1013
1410
  const result = state.session.dispatch({
@@ -1015,6 +1412,8 @@ function commitGizmoTransform(state, options, event) {
1015
1412
  label: patch.label ?? `${event.tool} ${event.nodeId}`,
1016
1413
  patch: patch.patch,
1017
1414
  targetId: event.nodeId,
1415
+ }, {
1416
+ mergeWithPrevious: state.duplicateDrag?.createdIds.includes(event.nodeId) === true,
1018
1417
  });
1019
1418
  if (patch.changedIds)
1020
1419
  syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds);
@@ -1022,6 +1421,7 @@ function commitGizmoTransform(state, options, event) {
1022
1421
  syncProjectionForDispatchResult(state, options, result, patch.changedId ?? event.nodeId);
1023
1422
  state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1024
1423
  state.status = patch.label ?? `${event.tool} ${event.nodeId}`;
1424
+ state.duplicateDrag = null;
1025
1425
  return result.documentChanged;
1026
1426
  }
1027
1427
  function isTransformBatchCommit(event) {
@@ -1040,6 +1440,7 @@ function cancelActiveOperation(state) {
1040
1440
  state.sceneViewInput?.cancelActiveIntent();
1041
1441
  state.selectionController?.cancelBoxSelection();
1042
1442
  cancelActiveGizmoDrag(state);
1443
+ clearArmedPlacement(state);
1043
1444
  }
1044
1445
  function focusSelectedProjection(state) {
1045
1446
  if (state.mode !== 'editor')
@@ -1072,6 +1473,17 @@ function formatEditorStatusTime(timestamp) {
1072
1473
  const pad = (value) => String(value).padStart(2, '0');
1073
1474
  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1074
1475
  }
1476
+ function formatVec3(value) {
1477
+ return `${formatPlacementNumber(value.x)}, ${formatPlacementNumber(value.y)}, ${formatPlacementNumber(value.z)}`;
1478
+ }
1479
+ function formatPlacementNumber(value) {
1480
+ return Number.isFinite(value) ? Number(value.toFixed(3)).toString() : String(value);
1481
+ }
1482
+ function formatAssetLabel(asset, fallback) {
1483
+ const record = isObjectRecord(asset) ? asset : null;
1484
+ const label = record?.label ?? record?.displayName ?? record?.name;
1485
+ return typeof label === 'string' && label.trim() ? label : fallback;
1486
+ }
1075
1487
  function undoSessionChange(state, options) {
1076
1488
  cancelActiveOperation(state);
1077
1489
  const result = state.session?.undo();
@@ -1083,7 +1495,7 @@ function undoSessionChange(state, options) {
1083
1495
  return true;
1084
1496
  }
1085
1497
  function redoSessionChange(state, options) {
1086
- cancelActiveGizmoDrag(state);
1498
+ cancelActiveOperation(state);
1087
1499
  const result = state.session?.redo();
1088
1500
  if (!result)
1089
1501
  return false;
@@ -1188,6 +1600,13 @@ function createUiState(state, options) {
1188
1600
  dragPhase: state.gizmo?.getState().dragPhase ?? 'idle',
1189
1601
  draggingNodeId: state.gizmo?.getState().draggingNodeId ?? null,
1190
1602
  },
1603
+ transformOperations: {
1604
+ settings: cloneTransformOperationSettings(state.transformOperationSettings),
1605
+ selectedCount: selectedIds.length,
1606
+ activeId,
1607
+ canAlign: selectedIds.length >= 2 && activeId != null,
1608
+ canDistribute: selectedIds.length >= 3,
1609
+ },
1191
1610
  session: sessionState
1192
1611
  ? {
1193
1612
  source: sessionState.source,