diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index f5c10f2..5cf2507 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -3986,6 +3986,208 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { track({ delete: () => events.forEach(([type, handler]) => document.removeEventListener(type, handler, true)) }); } + // src/pivot_control.ts + var pivotFollowEnabled = true; + function setupPivotControl() { + StateMemory.init("hytale_pivot_follow", "boolean"); + pivotFollowEnabled = StateMemory.get("hytale_pivot_follow") ?? true; + let toggle = new Toggle("hytale_pivot_follow", { + name: "Pivot Follow", + description: "When enabled, the pivot point moves along with the element when using the move tool", + icon: pivotFollowEnabled ? "location_searching" : "location_disabled", + category: "edit", + condition: { formats: FORMAT_IDS, modes: ["edit"], tools: ["move_tool"] }, + default: pivotFollowEnabled, + onChange(value) { + pivotFollowEnabled = value; + StateMemory.set("hytale_pivot_follow", value); + toggle.setIcon(value ? "location_searching" : "location_disabled"); + } + }); + let tsItem = BarItems.transform_space; + if (tsItem) { + for (let toolbar of Object.values(Toolbars)) { + let children = toolbar.children; + if (Array.isArray(children) && children.includes(tsItem)) { + let index = children.indexOf(tsItem); + toolbar.add(toggle, index + 1); + break; + } + } + } + let snapshots = null; + let trackedCubeUuid = null; + let savedUpdatePivotMarker = null; + function onPointerDown() { + if (!isHytaleFormat() || !Modes.edit) return; + if (Toolbox.selected?.id !== "move_tool") return; + if (!Transformer?.axis) return; + if (Group.selected.length) return; + snapshots = /* @__PURE__ */ new Map(); + for (let el2 of Outliner.selected) { + if (el2 instanceof Cube) { + snapshots.set(el2.uuid, { + initialOrigin: [...el2.origin], + initialFrom: [...el2.from] + }); + } + } + if (snapshots.size === 0) { + snapshots = null; + return; + } + trackedCubeUuid = null; + for (let [uuid] of snapshots) { + let el2 = OutlinerNode.uuids[uuid]; + if (el2 instanceof Cube && el2.mesh?.parent) { + trackedCubeUuid = uuid; + break; + } + } + if (!trackedCubeUuid) return; + let el = OutlinerNode.uuids[trackedCubeUuid]; + savedUpdatePivotMarker = Canvas.updatePivotMarker; + Canvas.updatePivotMarker = () => { + }; + if (!pivotFollowEnabled) { + let worldPos = new THREE.Vector3(); + let worldQuat = new THREE.Quaternion(); + el.mesh.getWorldPosition(worldPos); + Canvas.pivot_marker.getWorldQuaternion(worldQuat); + Canvas.scene.add(Canvas.pivot_marker); + Canvas.pivot_marker.position.copy(worldPos); + Canvas.pivot_marker.quaternion.copy(worldQuat); + } + } + function onPointerMove() { + if (!pivotFollowEnabled || !snapshots || !trackedCubeUuid) return; + let snap = snapshots.get(trackedCubeUuid); + let el = OutlinerNode.uuids[trackedCubeUuid]; + if (!snap || !(el instanceof Cube) || !el.mesh) return; + let originMoved = el.origin[0] !== snap.initialOrigin[0] || el.origin[1] !== snap.initialOrigin[1] || el.origin[2] !== snap.initialOrigin[2]; + if (!originMoved) { + Canvas.pivot_marker.position.set( + el.from[0] - snap.initialFrom[0], + el.from[1] - snap.initialFrom[1], + el.from[2] - snap.initialFrom[2] + ); + } else { + Canvas.pivot_marker.position.set(0, 0, 0); + } + } + function onFinishEdit() { + if (!snapshots) return; + let snapshotsCopy = snapshots; + snapshots = null; + trackedCubeUuid = null; + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + let modified = []; + for (let [uuid, snap] of snapshotsCopy) { + let el = OutlinerNode.uuids[uuid]; + if (!(el instanceof Cube) || !el.mesh) continue; + let originMoved = el.origin[0] !== snap.initialOrigin[0] || el.origin[1] !== snap.initialOrigin[1] || el.origin[2] !== snap.initialOrigin[2]; + if (pivotFollowEnabled && !originMoved) { + let delta = new THREE.Vector3( + el.from[0] - snap.initialFrom[0], + el.from[1] - snap.initialFrom[1], + el.from[2] - snap.initialFrom[2] + ); + delta.applyQuaternion(el.mesh.quaternion); + let desired = [ + snap.initialOrigin[0] + delta.x, + snap.initialOrigin[1] + delta.y, + snap.initialOrigin[2] + delta.z + ]; + el.transferOrigin(desired, false); + modified.push(el); + } else if (!pivotFollowEnabled && originMoved) { + el.transferOrigin(snap.initialOrigin, false); + modified.push(el); + } + } + Canvas.pivot_marker.position.set(0, 0, 0); + Canvas.pivot_marker.quaternion.identity(); + Canvas.updatePivotMarker(); + if (modified.length > 0) { + Canvas.updateView({ + elements: modified, + element_aspects: { transform: true, geometry: true } + }); + } + } + function onPointerUp() { + if (!snapshots) return; + let pendingSnapshots = snapshots; + setTimeout(() => { + if (snapshots !== pendingSnapshots) return; + snapshots = null; + trackedCubeUuid = null; + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + Canvas.pivot_marker.position.set(0, 0, 0); + Canvas.pivot_marker.quaternion.identity(); + Canvas.updatePivotMarker(); + }, 0); + } + Blockbench.on("finish_edit", onFinishEdit); + document.addEventListener("pointerdown", onPointerDown, true); + document.addEventListener("pointermove", onPointerMove, false); + document.addEventListener("pointerup", onPointerUp, true); + let toolOptions = { + move_tool: "Move", + resize_tool: "Resize", + rotate_tool: "Rotate", + pivot_tool: "Pivot", + vertex_snap_tool: "Vertex Snap" + }; + let dblClickToolA = new Setting("hytale_dblclick_tool_a", { + name: "Double Click Tool A", + description: 'First tool in the double-click toggle pair. Requires "Double Click Switch Tools" to be enabled in Blockbench controls settings.', + category: "controls", + type: "select", + value: "move_tool", + options: toolOptions + }); + track(dblClickToolA); + let dblClickToolB = new Setting("hytale_dblclick_tool_b", { + name: "Double Click Tool B", + description: 'Second tool in the double-click toggle pair. Requires "Double Click Switch Tools" to be enabled in Blockbench controls settings.', + category: "controls", + type: "select", + value: "resize_tool", + options: toolOptions + }); + track(dblClickToolB); + let originalToggleTransforms = Toolbox.toggleTransforms; + Toolbox.toggleTransforms = function() { + let a = dblClickToolA.value; + let b = dblClickToolB.value; + if (Toolbox.selected.id === a) { + BarItems[b]?.select(); + } else if (Toolbox.selected.id === b) { + BarItems[a]?.select(); + } + }; + track(toggle, { + delete() { + Blockbench.removeListener("finish_edit", onFinishEdit); + document.removeEventListener("pointerdown", onPointerDown, true); + document.removeEventListener("pointermove", onPointerMove, false); + document.removeEventListener("pointerup", onPointerUp, true); + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + Toolbox.toggleTransforms = originalToggleTransforms; + } + }); + } + // src/change_orientation.ts function canChangeParentGroup(cube) { let parent = cube.parent; @@ -4151,6 +4353,7 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { onload() { setupFormats(); setupElements(); + setupPivotControl(); setupAnimation(); setupAnimationCodec(); setupAttachments(); diff --git a/src/pivot_control.ts b/src/pivot_control.ts new file mode 100644 index 0000000..14649d6 --- /dev/null +++ b/src/pivot_control.ts @@ -0,0 +1,267 @@ +//! Copyright (C) 2025 Hypixel Studios Canada inc. +//! Licensed under the GNU General Public License, see LICENSE.MD + +import { track } from "./cleanup"; +import { FORMAT_IDS, isHytaleFormat } from "./formats"; + +declare global { + const StateMemory: any +} + +let pivotFollowEnabled = true; + +/** + * Pivot control features for Hytale formats. + * Provides a "Pivot Follow" toggle that controls whether cube origins (pivot points) + * follow geometry when translating, regardless of the active transform space. + * + * Pivot marker visual during drag: + * ON → marker stays on the mesh (default parenting), follows geometry naturally + * OFF → marker is re-parented as a sibling at its initial position, stays frozen + * + * On pointerup, adjusts the actual origin data and restores the marker. + */ +export function setupPivotControl() { + + StateMemory.init('hytale_pivot_follow', 'boolean'); + pivotFollowEnabled = StateMemory.get('hytale_pivot_follow') ?? true; + + let toggle = new Toggle('hytale_pivot_follow', { + name: 'Pivot Follow', + description: 'When enabled, the pivot point moves along with the element when using the move tool', + icon: pivotFollowEnabled ? 'location_searching' : 'location_disabled', + category: 'edit', + condition: {formats: FORMAT_IDS, modes: ['edit'], tools: ['move_tool']}, + default: pivotFollowEnabled, + onChange(value: boolean) { + pivotFollowEnabled = value; + StateMemory.set('hytale_pivot_follow', value); + toggle.setIcon(value ? 'location_searching' : 'location_disabled'); + } + }); + + // Place toggle near the transform space selector + let tsItem = BarItems.transform_space; + if (tsItem) { + for (let toolbar of Object.values(Toolbars)) { + let children = (toolbar as any).children; + if (Array.isArray(children) && children.includes(tsItem)) { + let index = children.indexOf(tsItem); + (toolbar as any).add(toggle, index + 1); + break; + } + } + } + + type CubeSnapshot = { initialOrigin: number[], initialFrom: number[] }; + let snapshots: Map | null = null; + let trackedCubeUuid: string | null = null; + let savedUpdatePivotMarker: (typeof Canvas.updatePivotMarker) | null = null; + + // Snapshot state and prepare pivot marker for drag. + // ON: marker stays on mesh. OFF: re-parent to scene root (frozen). + function onPointerDown() { + if (!isHytaleFormat() || !Modes.edit) return; + if (Toolbox.selected?.id !== 'move_tool') return; + if (!(Transformer as any)?.axis) return; + + // Skip when any group is selected — group moves handle pivots naturally + if (Group.selected.length) return; + + snapshots = new Map(); + for (let el of Outliner.selected) { + if (el instanceof Cube) { + snapshots.set(el.uuid, { + initialOrigin: [...el.origin], + initialFrom: [...el.from] + }); + } + } + if (snapshots.size === 0) { snapshots = null; return; } + + // Find the first cube to track for the pivot marker + trackedCubeUuid = null; + for (let [uuid] of snapshots) { + let el = OutlinerNode.uuids[uuid]; + if (el instanceof Cube && el.mesh?.parent) { + trackedCubeUuid = uuid; + break; + } + } + if (!trackedCubeUuid) return; + + let el = OutlinerNode.uuids[trackedCubeUuid] as Cube; + savedUpdatePivotMarker = Canvas.updatePivotMarker; + Canvas.updatePivotMarker = () => {}; + + if (!pivotFollowEnabled) { + // Freeze marker at current world position + let worldPos = new THREE.Vector3(); + let worldQuat = new THREE.Quaternion(); + el.mesh.getWorldPosition(worldPos); + Canvas.pivot_marker.getWorldQuaternion(worldQuat); + + Canvas.scene.add(Canvas.pivot_marker); + Canvas.pivot_marker.position.copy(worldPos); + Canvas.pivot_marker.quaternion.copy(worldQuat); + } + } + + // Toggle ON: offset marker in mesh-local space to track geometry when origin doesn't move. + function onPointerMove() { + if (!pivotFollowEnabled || !snapshots || !trackedCubeUuid) return; + + let snap = snapshots.get(trackedCubeUuid); + let el = OutlinerNode.uuids[trackedCubeUuid]; + if (!snap || !(el instanceof Cube) || !el.mesh) return; + + let originMoved = + el.origin[0] !== snap.initialOrigin[0] || + el.origin[1] !== snap.initialOrigin[1] || + el.origin[2] !== snap.initialOrigin[2]; + + if (!originMoved) { + Canvas.pivot_marker.position.set( + el.from[0] - snap.initialFrom[0], + el.from[1] - snap.initialFrom[1], + el.from[2] - snap.initialFrom[2] + ); + } else { + Canvas.pivot_marker.position.set(0, 0, 0); + } + } + + // Apply origin adjustment inside finish_edit, before the undo + function onFinishEdit() { + if (!snapshots) return; + let snapshotsCopy = snapshots; + snapshots = null; + trackedCubeUuid = null; + + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + + let modified: OutlinerElement[] = []; + for (let [uuid, snap] of snapshotsCopy) { + let el = OutlinerNode.uuids[uuid]; + if (!(el instanceof Cube) || !el.mesh) continue; + + let originMoved = + el.origin[0] !== snap.initialOrigin[0] || + el.origin[1] !== snap.initialOrigin[1] || + el.origin[2] !== snap.initialOrigin[2]; + + if (pivotFollowEnabled && !originMoved) { + let delta = new THREE.Vector3( + el.from[0] - snap.initialFrom[0], + el.from[1] - snap.initialFrom[1], + el.from[2] - snap.initialFrom[2] + ); + delta.applyQuaternion(el.mesh.quaternion); + let desired: ArrayVector3 = [ + snap.initialOrigin[0] + delta.x, + snap.initialOrigin[1] + delta.y, + snap.initialOrigin[2] + delta.z + ]; + el.transferOrigin(desired, false); + modified.push(el); + } else if (!pivotFollowEnabled && originMoved) { + el.transferOrigin(snap.initialOrigin as ArrayVector3, false); + modified.push(el); + } + } + + Canvas.pivot_marker.position.set(0, 0, 0); + Canvas.pivot_marker.quaternion.identity(); + Canvas.updatePivotMarker(); + + if (modified.length > 0) { + Canvas.updateView({ + elements: modified, + element_aspects: {transform: true, geometry: true} + }); + } + } + + // Clean up if the drag was cancelled + function onPointerUp() { + if (!snapshots) return; + let pendingSnapshots = snapshots; + setTimeout(() => { + if (snapshots !== pendingSnapshots) return; + snapshots = null; + trackedCubeUuid = null; + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + Canvas.pivot_marker.position.set(0, 0, 0); + Canvas.pivot_marker.quaternion.identity(); + Canvas.updatePivotMarker(); + }, 0); + } + + Blockbench.on('finish_edit', onFinishEdit); + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('pointermove', onPointerMove, false); + document.addEventListener('pointerup', onPointerUp, true); + + // TODO: Should this be a vanilla Blockbench feature? Replace hardcoded move to resize toggle with two configurable tool selects. + // toggle could be made configurable upstream instead of overriding it here. + // Double-click tool switch override. + // Replaces Blockbench's hardcoded move↔resize toggle with two configurable tool selects. + let toolOptions: Record = { + move_tool: 'Move', + resize_tool: 'Resize', + rotate_tool: 'Rotate', + pivot_tool: 'Pivot', + vertex_snap_tool: 'Vertex Snap', + }; + + let dblClickToolA = new Setting('hytale_dblclick_tool_a', { + name: 'Double Click Tool A', + description: 'First tool in the double-click toggle pair. Requires "Double Click Switch Tools" to be enabled in Blockbench controls settings.', + category: 'controls', + type: 'select', + value: 'move_tool', + options: toolOptions + }); + track(dblClickToolA); + + let dblClickToolB = new Setting('hytale_dblclick_tool_b', { + name: 'Double Click Tool B', + description: 'Second tool in the double-click toggle pair. Requires "Double Click Switch Tools" to be enabled in Blockbench controls settings.', + category: 'controls', + type: 'select', + value: 'resize_tool', + options: toolOptions + }); + track(dblClickToolB); + + let originalToggleTransforms = Toolbox.toggleTransforms; + Toolbox.toggleTransforms = function() { + let a = dblClickToolA.value as string; + let b = dblClickToolB.value as string; + if (Toolbox.selected.id === a) { + BarItems[b]?.select(); + } else if (Toolbox.selected.id === b) { + BarItems[a]?.select(); + } + }; + + track(toggle, { + delete() { + Blockbench.removeListener('finish_edit', onFinishEdit); + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('pointermove', onPointerMove, false); + document.removeEventListener('pointerup', onPointerUp, true); + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + Toolbox.toggleTransforms = originalToggleTransforms; + } + }); +} diff --git a/src/plugin.ts b/src/plugin.ts index d184a43..5371d04 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -20,6 +20,7 @@ import { setupTempFixes } from './temp_fixes' import { setupPreviewScenes } from "./preview_scenes"; import { setupUVCanvasResize } from "./uv_canvas_resize"; import { setupAltDuplicate } from "./alt_duplicate"; +import { setupPivotControl } from "./pivot_control"; import { setupChangeOrientation } from "./change_orientation"; import { setupShortcuts } from "./shortcuts"; @@ -46,6 +47,7 @@ BBPlugin.register('hytale_plugin', { setupFormats(); setupElements(); + setupPivotControl(); setupAnimation(); setupAnimationCodec(); setupAttachments();