From 0935c69a099effb6c793a151d7f9d84b5ee16f7b Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Thu, 26 Mar 2026 15:40:46 +0100 Subject: [PATCH 1/8] Added pivot follow geometry toggle Decupled transform space from pivot following geometry or not --- dist/hytale_plugin.js | 164 ++++++++++++++++++++++++++++++- src/pivot_control.ts | 218 ++++++++++++++++++++++++++++++++++++++++++ src/plugin.ts | 2 + 3 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/pivot_control.ts diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 4e51761..535d1eb 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -1067,7 +1067,7 @@ let timeline; let hytale_channel_key = channels[channel]; timeline = timeline = node_data[hytale_channel_key] = []; - let keyframe_list = animator[channel].slice(); + let keyframe_list = animator[channel] && Array.isArray(animator[channel]) ? animator[channel].slice() : []; keyframe_list.sort((a, b) => a.time - b.time); for (let kf of keyframe_list) { let data_point = kf.data_points[0]; @@ -3490,6 +3490,167 @@ 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"] }, + 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; + 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 = () => { + }; + let worldQuat = new THREE.Quaternion(); + Canvas.pivot_marker.getWorldQuaternion(worldQuat); + el.mesh.parent.add(Canvas.pivot_marker); + Canvas.pivot_marker.position.set( + el.origin[0], + el.origin[1], + el.origin[2] + ); + let parentWorldQuat = new THREE.Quaternion(); + el.mesh.parent.getWorldQuaternion(parentWorldQuat); + Canvas.pivot_marker.quaternion.copy(parentWorldQuat.invert().multiply(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 ox = el.origin[0], oy = el.origin[1], oz = el.origin[2]; + if (ox === snap.initialOrigin[0] && oy === snap.initialOrigin[1] && oz === snap.initialOrigin[2]) { + let dx = el.from[0] - snap.initialFrom[0]; + let dy = el.from[1] - snap.initialFrom[1]; + let dz = el.from[2] - snap.initialFrom[2]; + let q = el.mesh.quaternion; + let qx = q.x, qy = q.y, qz = q.z, qw = q.w; + let ix = qw * dx + qy * dz - qz * dy; + let iy = qw * dy + qz * dx - qx * dz; + let iz = qw * dz + qx * dy - qy * dx; + let iw = -qx * dx - qy * dy - qz * dz; + Canvas.pivot_marker.position.set( + snap.initialOrigin[0] + ix * qw + iw * -qx + iy * -qz - iz * -qy, + snap.initialOrigin[1] + iy * qw + iw * -qy + iz * -qx - ix * -qz, + snap.initialOrigin[2] + iz * qw + iw * -qz + ix * -qy - iy * -qx + ); + } else { + Canvas.pivot_marker.position.set(ox, oy, oz); + } + } + function onPointerUp() { + if (!snapshots) return; + let snapshotsCopy = snapshots; + let savedUpdate = savedUpdatePivotMarker; + snapshots = null; + trackedCubeUuid = null; + savedUpdatePivotMarker = null; + setTimeout(() => { + if (savedUpdate) { + Canvas.updatePivotMarker = savedUpdate; + } + 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 } + }); + } + }, 0); + } + document.addEventListener("pointerdown", onPointerDown, true); + document.addEventListener("pointermove", onPointerMove, false); + document.addEventListener("pointerup", onPointerUp, true); + track(toggle, { + delete() { + document.removeEventListener("pointerdown", onPointerDown, true); + document.removeEventListener("pointermove", onPointerMove, false); + document.removeEventListener("pointerup", onPointerUp, true); + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + } + }); + } + // src/plugin.ts BBPlugin.register("hytale_plugin", { title: "Hytale Models", @@ -3513,6 +3674,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..6d32029 --- /dev/null +++ b/src/pivot_control.ts @@ -0,0 +1,218 @@ +//! 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']}, + 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; + + // Capture phase: snapshots state before movement begins. + // Re-parents the pivot marker as a sibling of the cube mesh in the parent's space + // so Blockbench can't interfere. Position is set to el.origin (same world position). + // ON → pointermove will track geometry + // OFF → position stays frozen at initial origin + function onPointerDown() { + if (!isHytaleFormat() || !Modes.edit) return; + if (Toolbox.selected?.id !== 'move_tool') return; + if (!(Transformer as any)?.axis) 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 = () => {}; + + // Preserve the marker's world rotation when moving it to a different parent + let worldQuat = new THREE.Quaternion(); + Canvas.pivot_marker.getWorldQuaternion(worldQuat); + + el.mesh.parent.add(Canvas.pivot_marker); + Canvas.pivot_marker.position.set( + el.origin[0], el.origin[1], el.origin[2] + ); + + // Convert world quaternion to local quaternion in the new parent's space + let parentWorldQuat = new THREE.Quaternion(); + el.mesh.parent.getWorldQuaternion(parentWorldQuat); + Canvas.pivot_marker.quaternion.copy(parentWorldQuat.invert().multiply(worldQuat)); + } + + // Bubble phase (toggle ON only): updates the marker position in parent space + // to track geometry. No jitter since we own the marker (updatePivotMarker is blocked). + 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; + + // If Blockbench moved origin, just track it directly. + // Otherwise compute the visual-space displacement from the from delta. + let ox = el.origin[0], oy = el.origin[1], oz = el.origin[2]; + if (ox === snap.initialOrigin[0] && oy === snap.initialOrigin[1] && oz === snap.initialOrigin[2]) { + let dx = el.from[0] - snap.initialFrom[0]; + let dy = el.from[1] - snap.initialFrom[1]; + let dz = el.from[2] - snap.initialFrom[2]; + // Rotate data-space delta by cube quaternion → visual displacement in parent space + let q = el.mesh.quaternion; + let qx = q.x, qy = q.y, qz = q.z, qw = q.w; + let ix = qw*dx + qy*dz - qz*dy; + let iy = qw*dy + qz*dx - qx*dz; + let iz = qw*dz + qx*dy - qy*dx; + let iw = -qx*dx - qy*dy - qz*dz; + Canvas.pivot_marker.position.set( + snap.initialOrigin[0] + ix*qw + iw*-qx + iy*-qz - iz*-qy, + snap.initialOrigin[1] + iy*qw + iw*-qy + iz*-qx - ix*-qz, + snap.initialOrigin[2] + iz*qw + iw*-qz + ix*-qy - iy*-qx + ); + } else { + Canvas.pivot_marker.position.set(ox, oy, oz); + } + } + + // Deferred adjustment: runs after Transformer finalises positions and Undo captures state. + function onPointerUp() { + if (!snapshots) return; + let snapshotsCopy = snapshots; + let savedUpdate = savedUpdatePivotMarker; + snapshots = null; + trackedCubeUuid = null; + savedUpdatePivotMarker = null; + + // Keep updatePivotMarker blocked until after our deferred adjustment + // to prevent a single-frame flicker where Blockbench re-parents the marker. + setTimeout(() => { + if (savedUpdate) { + Canvas.updatePivotMarker = savedUpdate; + } + 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} + }); + } + }, 0); + } + + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('pointermove', onPointerMove, false); + document.addEventListener('pointerup', onPointerUp, true); + + track(toggle, { + delete() { + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('pointermove', onPointerMove, false); + document.removeEventListener('pointerup', onPointerUp, true); + if (savedUpdatePivotMarker) { + Canvas.updatePivotMarker = savedUpdatePivotMarker; + savedUpdatePivotMarker = null; + } + } + }); +} diff --git a/src/plugin.ts b/src/plugin.ts index d91313a..f3a3154 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -19,6 +19,7 @@ import { setupUVOutline } from "./uv_outline"; import { setupTempFixes } from './temp_fixes' import { setupPreviewScenes } from "./preview_scenes"; import { setupAltDuplicate } from "./alt_duplicate"; +import { setupPivotControl } from "./pivot_control"; BBPlugin.register('hytale_plugin', { title: 'Hytale Models', @@ -43,6 +44,7 @@ BBPlugin.register('hytale_plugin', { setupFormats(); setupElements(); + setupPivotControl(); setupAnimation(); setupAnimationCodec(); setupAttachments(); From e46e4c55513dd8dbb4ee0c2ef8af8d31c2cfca7f Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Thu, 26 Mar 2026 16:18:19 +0100 Subject: [PATCH 2/8] Customizable Double.click tool swap --- dist/hytale_plugin.js | 39 +++++++++++++++++++++++++++++++++++ src/pivot_control.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 535d1eb..05e42f8 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -3638,6 +3638,44 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { 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: "pivot_tool", + options: toolOptions + }); + track(dblClickToolB); + let originalToggleTransforms = Toolbox.toggleTransforms; + Toolbox.toggleTransforms = function() { + if (!isHytaleFormat()) { + return originalToggleTransforms.call(this); + } + 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() { document.removeEventListener("pointerdown", onPointerDown, true); @@ -3647,6 +3685,7 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { Canvas.updatePivotMarker = savedUpdatePivotMarker; savedUpdatePivotMarker = null; } + Toolbox.toggleTransforms = originalToggleTransforms; } }); } diff --git a/src/pivot_control.ts b/src/pivot_control.ts index 6d32029..224409e 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -204,6 +204,52 @@ export function setupPivotControl() { 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: 'pivot_tool', + options: toolOptions + }); + track(dblClickToolB); + + let originalToggleTransforms = Toolbox.toggleTransforms; + Toolbox.toggleTransforms = function() { + if (!isHytaleFormat()) { + return originalToggleTransforms.call(this); + } + 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() { document.removeEventListener('pointerdown', onPointerDown, true); @@ -213,6 +259,7 @@ export function setupPivotControl() { Canvas.updatePivotMarker = savedUpdatePivotMarker; savedUpdatePivotMarker = null; } + Toolbox.toggleTransforms = originalToggleTransforms; } }); } From 798034029c39aa8764b4233e6b3eb503aa0efeb0 Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Thu, 26 Mar 2026 18:04:10 +0100 Subject: [PATCH 3/8] Fix pivot visual issue when moving geometries in deep hierarchies --- dist/hytale_plugin.js | 41 ++++++++++----------------- src/pivot_control.ts | 64 ++++++++++++++++--------------------------- 2 files changed, 39 insertions(+), 66 deletions(-) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 05e42f8..cd36e0d 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -3552,41 +3552,30 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { savedUpdatePivotMarker = Canvas.updatePivotMarker; Canvas.updatePivotMarker = () => { }; - let worldQuat = new THREE.Quaternion(); - Canvas.pivot_marker.getWorldQuaternion(worldQuat); - el.mesh.parent.add(Canvas.pivot_marker); - Canvas.pivot_marker.position.set( - el.origin[0], - el.origin[1], - el.origin[2] - ); - let parentWorldQuat = new THREE.Quaternion(); - el.mesh.parent.getWorldQuaternion(parentWorldQuat); - Canvas.pivot_marker.quaternion.copy(parentWorldQuat.invert().multiply(worldQuat)); + 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 ox = el.origin[0], oy = el.origin[1], oz = el.origin[2]; - if (ox === snap.initialOrigin[0] && oy === snap.initialOrigin[1] && oz === snap.initialOrigin[2]) { - let dx = el.from[0] - snap.initialFrom[0]; - let dy = el.from[1] - snap.initialFrom[1]; - let dz = el.from[2] - snap.initialFrom[2]; - let q = el.mesh.quaternion; - let qx = q.x, qy = q.y, qz = q.z, qw = q.w; - let ix = qw * dx + qy * dz - qz * dy; - let iy = qw * dy + qz * dx - qx * dz; - let iz = qw * dz + qx * dy - qy * dx; - let iw = -qx * dx - qy * dy - qz * dz; + 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( - snap.initialOrigin[0] + ix * qw + iw * -qx + iy * -qz - iz * -qy, - snap.initialOrigin[1] + iy * qw + iw * -qy + iz * -qx - ix * -qz, - snap.initialOrigin[2] + iz * qw + iw * -qz + ix * -qy - iy * -qx + el.from[0] - snap.initialFrom[0], + el.from[1] - snap.initialFrom[1], + el.from[2] - snap.initialFrom[2] ); } else { - Canvas.pivot_marker.position.set(ox, oy, oz); + Canvas.pivot_marker.position.set(0, 0, 0); } } function onPointerUp() { diff --git a/src/pivot_control.ts b/src/pivot_control.ts index 224409e..02a23c0 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -58,11 +58,8 @@ export function setupPivotControl() { let trackedCubeUuid: string | null = null; let savedUpdatePivotMarker: (typeof Canvas.updatePivotMarker) | null = null; - // Capture phase: snapshots state before movement begins. - // Re-parents the pivot marker as a sibling of the cube mesh in the parent's space - // so Blockbench can't interfere. Position is set to el.origin (same world position). - // ON → pointermove will track geometry - // OFF → position stays frozen at initial origin + // 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; @@ -94,23 +91,20 @@ export function setupPivotControl() { savedUpdatePivotMarker = Canvas.updatePivotMarker; Canvas.updatePivotMarker = () => {}; - // Preserve the marker's world rotation when moving it to a different parent - let worldQuat = new THREE.Quaternion(); - Canvas.pivot_marker.getWorldQuaternion(worldQuat); + 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); - el.mesh.parent.add(Canvas.pivot_marker); - Canvas.pivot_marker.position.set( - el.origin[0], el.origin[1], el.origin[2] - ); - - // Convert world quaternion to local quaternion in the new parent's space - let parentWorldQuat = new THREE.Quaternion(); - el.mesh.parent.getWorldQuaternion(parentWorldQuat); - Canvas.pivot_marker.quaternion.copy(parentWorldQuat.invert().multiply(worldQuat)); + Canvas.scene.add(Canvas.pivot_marker); + Canvas.pivot_marker.position.copy(worldPos); + Canvas.pivot_marker.quaternion.copy(worldQuat); + } } - // Bubble phase (toggle ON only): updates the marker position in parent space - // to track geometry. No jitter since we own the marker (updatePivotMarker is blocked). + // Toggle ON: offset marker in mesh-local space to track geometry when origin doesn't move. function onPointerMove() { if (!pivotFollowEnabled || !snapshots || !trackedCubeUuid) return; @@ -118,31 +112,23 @@ export function setupPivotControl() { let el = OutlinerNode.uuids[trackedCubeUuid]; if (!snap || !(el instanceof Cube) || !el.mesh) return; - // If Blockbench moved origin, just track it directly. - // Otherwise compute the visual-space displacement from the from delta. - let ox = el.origin[0], oy = el.origin[1], oz = el.origin[2]; - if (ox === snap.initialOrigin[0] && oy === snap.initialOrigin[1] && oz === snap.initialOrigin[2]) { - let dx = el.from[0] - snap.initialFrom[0]; - let dy = el.from[1] - snap.initialFrom[1]; - let dz = el.from[2] - snap.initialFrom[2]; - // Rotate data-space delta by cube quaternion → visual displacement in parent space - let q = el.mesh.quaternion; - let qx = q.x, qy = q.y, qz = q.z, qw = q.w; - let ix = qw*dx + qy*dz - qz*dy; - let iy = qw*dy + qz*dx - qx*dz; - let iz = qw*dz + qx*dy - qy*dx; - let iw = -qx*dx - qy*dy - qz*dz; + 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( - snap.initialOrigin[0] + ix*qw + iw*-qx + iy*-qz - iz*-qy, - snap.initialOrigin[1] + iy*qw + iw*-qy + iz*-qx - ix*-qz, - snap.initialOrigin[2] + iz*qw + iw*-qz + ix*-qy - iy*-qx + el.from[0] - snap.initialFrom[0], + el.from[1] - snap.initialFrom[1], + el.from[2] - snap.initialFrom[2] ); } else { - Canvas.pivot_marker.position.set(ox, oy, oz); + Canvas.pivot_marker.position.set(0, 0, 0); } } - // Deferred adjustment: runs after Transformer finalises positions and Undo captures state. + // Adjust origin data after Transformer finalises. function onPointerUp() { if (!snapshots) return; let snapshotsCopy = snapshots; @@ -151,8 +137,6 @@ export function setupPivotControl() { trackedCubeUuid = null; savedUpdatePivotMarker = null; - // Keep updatePivotMarker blocked until after our deferred adjustment - // to prevent a single-frame flicker where Blockbench re-parents the marker. setTimeout(() => { if (savedUpdate) { Canvas.updatePivotMarker = savedUpdate; From 3612f76f01dd24c8baa01d45765038dbdc6e822f Mon Sep 17 00:00:00 2001 From: JannisX11 Date: Mon, 11 May 2026 23:21:17 +0200 Subject: [PATCH 4/8] Set Double Click Tool B to resize tool by default and always activate --- dist/hytale_plugin.js | 5 +---- src/pivot_control.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 4f75155..f76c958 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -4144,15 +4144,12 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { 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: "pivot_tool", + value: "resize_tool", options: toolOptions }); track(dblClickToolB); let originalToggleTransforms = Toolbox.toggleTransforms; Toolbox.toggleTransforms = function() { - if (!isHytaleFormat()) { - return originalToggleTransforms.call(this); - } let a = dblClickToolA.value; let b = dblClickToolB.value; if (Toolbox.selected.id === a) { diff --git a/src/pivot_control.ts b/src/pivot_control.ts index 02a23c0..48362dc 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -215,16 +215,13 @@ export function setupPivotControl() { 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: 'pivot_tool', + value: 'resize_tool', options: toolOptions }); track(dblClickToolB); let originalToggleTransforms = Toolbox.toggleTransforms; Toolbox.toggleTransforms = function() { - if (!isHytaleFormat()) { - return originalToggleTransforms.call(this); - } let a = dblClickToolA.value as string; let b = dblClickToolB.value as string; if (Toolbox.selected.id === a) { From 42f2a40af2bcb76fd22e854919696ce569d159ff Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Thu, 14 May 2026 18:15:07 +0200 Subject: [PATCH 5/8] Fixed undo-redo with pivot follow --- dist/hytale_plugin.js | 85 ++++++++++++++++++++-------------- src/pivot_control.ts | 104 +++++++++++++++++++++++++----------------- 2 files changed, 111 insertions(+), 78 deletions(-) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index f76c958..facccc1 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -4074,52 +4074,66 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { Canvas.pivot_marker.position.set(0, 0, 0); } } - function onPointerUp() { + function onFinishEdit() { if (!snapshots) return; let snapshotsCopy = snapshots; - let savedUpdate = savedUpdatePivotMarker; snapshots = null; trackedCubeUuid = null; - savedUpdatePivotMarker = null; - setTimeout(() => { - if (savedUpdate) { - Canvas.updatePivotMarker = savedUpdate; + 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); } - 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(); - if (modified.length > 0) { - Canvas.updateView({ - elements: modified, - element_aspects: { transform: true, geometry: true } - }); - } }, 0); } + Blockbench.on("finish_edit", onFinishEdit); document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("pointermove", onPointerMove, false); document.addEventListener("pointerup", onPointerUp, true); @@ -4160,6 +4174,7 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { }; track(toggle, { delete() { + Blockbench.removeListener("finish_edit", onFinishEdit); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("pointermove", onPointerMove, false); document.removeEventListener("pointerup", onPointerUp, true); diff --git a/src/pivot_control.ts b/src/pivot_control.ts index 48362dc..314ac06 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -128,62 +128,79 @@ export function setupPivotControl() { } } - // Adjust origin data after Transformer finalises. - function onPointerUp() { + // Apply origin adjustment inside finish_edit, before the undo + function onFinishEdit() { if (!snapshots) return; let snapshotsCopy = snapshots; - let savedUpdate = savedUpdatePivotMarker; snapshots = null; trackedCubeUuid = null; - savedUpdatePivotMarker = null; - setTimeout(() => { - if (savedUpdate) { - Canvas.updatePivotMarker = savedUpdate; - } - 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); - } + 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(); - - if (modified.length > 0) { - Canvas.updateView({ - elements: modified, - element_aspects: {transform: true, geometry: true} - }); - } }, 0); } + Blockbench.on('finish_edit', onFinishEdit); document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('pointermove', onPointerMove, false); document.addEventListener('pointerup', onPointerUp, true); @@ -233,6 +250,7 @@ export function setupPivotControl() { track(toggle, { delete() { + Blockbench.removeListener('finish_edit', onFinishEdit); document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('pointermove', onPointerMove, false); document.removeEventListener('pointerup', onPointerUp, true); From e367ecbff6bfb70626c0860549abf7c38f6f715b Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Thu, 28 May 2026 16:53:01 +0200 Subject: [PATCH 6/8] Group pivot follow fix --- dist/hytale_plugin.js | 3 +++ src/pivot_control.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index facccc1..43c0bee 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -4022,6 +4022,9 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { if (!isHytaleFormat() || !Modes.edit) return; if (Toolbox.selected?.id !== "move_tool") return; if (!Transformer?.axis) return; + for (let el2 of Outliner.selected) { + if (el2 instanceof Group) return; + } snapshots = /* @__PURE__ */ new Map(); for (let el2 of Outliner.selected) { if (el2 instanceof Cube) { diff --git a/src/pivot_control.ts b/src/pivot_control.ts index 314ac06..bbf40ab 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -65,6 +65,11 @@ export function setupPivotControl() { if (Toolbox.selected?.id !== 'move_tool') return; if (!(Transformer as any)?.axis) return; + // Skip when any group is selected — group moves handle pivots naturally + for (let el of Outliner.selected) { + if (el instanceof Group) return; + } + snapshots = new Map(); for (let el of Outliner.selected) { if (el instanceof Cube) { From 69bf6316459e723ce7acba4f877a45e4b04c93a8 Mon Sep 17 00:00:00 2001 From: JannisX11 Date: Sat, 30 May 2026 21:59:34 +0200 Subject: [PATCH 7/8] Fix group detection for pivot tool --- dist/hytale_plugin.js | 4 +--- src/pivot_control.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 43c0bee..bd7f7e7 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -4022,9 +4022,7 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { if (!isHytaleFormat() || !Modes.edit) return; if (Toolbox.selected?.id !== "move_tool") return; if (!Transformer?.axis) return; - for (let el2 of Outliner.selected) { - if (el2 instanceof Group) return; - } + if (Group.selected.length) return; snapshots = /* @__PURE__ */ new Map(); for (let el2 of Outliner.selected) { if (el2 instanceof Cube) { diff --git a/src/pivot_control.ts b/src/pivot_control.ts index bbf40ab..ecbd0d1 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -66,9 +66,7 @@ export function setupPivotControl() { if (!(Transformer as any)?.axis) return; // Skip when any group is selected — group moves handle pivots naturally - for (let el of Outliner.selected) { - if (el instanceof Group) return; - } + if (Group.selected.length) return; snapshots = new Map(); for (let el of Outliner.selected) { From 05542f35d2daac02d84e051710fe05474a74c23a Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Fri, 17 Jul 2026 17:47:30 +0200 Subject: [PATCH 8/8] Only show pivot follow toggle on move tool Co-Authored-By: Claude Opus 4.6 --- dist/hytale_plugin.js | 2 +- src/pivot_control.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index bd7f7e7..5cf2507 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -3996,7 +3996,7 @@ body.hytale-uv-outline-only #uv_frame .selection_rectangle { 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"] }, + condition: { formats: FORMAT_IDS, modes: ["edit"], tools: ["move_tool"] }, default: pivotFollowEnabled, onChange(value) { pivotFollowEnabled = value; diff --git a/src/pivot_control.ts b/src/pivot_control.ts index ecbd0d1..14649d6 100644 --- a/src/pivot_control.ts +++ b/src/pivot_control.ts @@ -31,7 +31,7 @@ export function setupPivotControl() { 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']}, + condition: {formats: FORMAT_IDS, modes: ['edit'], tools: ['move_tool']}, default: pivotFollowEnabled, onChange(value: boolean) { pivotFollowEnabled = value;