diff --git a/plugins.json b/plugins.json index fc512b28..3d547e07 100644 --- a/plugins.json +++ b/plugins.json @@ -1,4 +1,16 @@ { + "hytale_uv_preserving_scale": { + "title": "Hytale UV-Preserving Scale", + "author": "CODEFELICE", + "description": "Uniformly scales Hytale models without changing their UV size or texture layout.", + "icon": "photo_size_select_large", + "variant": "both", + "tags": ["Hytale", "Scale", "UV"], + "version": "1.0.0", + "min_version": "5.0.5", + "repository": "https://github.com/CODEFELICE/hytale-uv-preserving-scale", + "bug_tracker": "https://github.com/CODEFELICE/hytale-uv-preserving-scale/issues" + }, "sam3dj": { "title": "sam3DJ", "author": "1Turtle", diff --git a/plugins/hytale_uv_preserving_scale/LICENSE.MD b/plugins/hytale_uv_preserving_scale/LICENSE.MD new file mode 100644 index 00000000..44ebcc4f --- /dev/null +++ b/plugins/hytale_uv_preserving_scale/LICENSE.MD @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CODEFELICE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hytale_uv_preserving_scale/about.md b/plugins/hytale_uv_preserving_scale/about.md new file mode 100644 index 00000000..6bfe490f --- /dev/null +++ b/plugins/hytale_uv_preserving_scale/about.md @@ -0,0 +1,20 @@ +# Hytale UV-Preserving Scale + +Adds **Tools → Scale Model — Preserve UV**. + +Resizing a cube in the Hytale format also changes its UV size, which usually means redoing texture work. This plugin scales the model a different way: it keeps every cube's base size and just multiplies its `stretch` (and moves the pivots and origins). The model gets bigger or smaller, but the UVs stay exactly where they were. + +Works with **Hytale Character** and **Hytale Prop**. + +What you can set: + +* Scale factor, with presets (×0.25, ×0.5, ×2, ×4). +* Scale the whole model or just the selected hierarchy. +* Pivot: model origin, the selected root, or a custom point. Picking "Selected Hierarchy" defaults to the selected root, so the part scales in place. +* Optionally scale the position keyframes of loaded animations. Rotation, stretch, visibility and UV channels are left alone. + +Everything happens in one undo step. If something goes wrong it restores the model instead of leaving it half-scaled. + +**Bake pose, keep animations.** It also wraps the native *Bake Animation Pose into Model*. Baking a pose changes the model's rest pose, which normally breaks your other animations (they're stored relative to the old rest). With the re-base option (ticked by default in the popup), every loaded animation is shifted onto the new rest pose in the same undo step, so they keep playing. Rotation and position are re-based; scale is left to the native action, which doesn't bake it. + +Standalone add-on. It doesn't modify the official Hytale plugin. diff --git a/plugins/hytale_uv_preserving_scale/hytale_uv_preserving_scale.js b/plugins/hytale_uv_preserving_scale/hytale_uv_preserving_scale.js new file mode 100644 index 00000000..9e76b877 --- /dev/null +++ b/plugins/hytale_uv_preserving_scale/hytale_uv_preserving_scale.js @@ -0,0 +1,1485 @@ +/*! Hytale UV-Preserving Scale v1.0.0 | MIT */ +/* + * Hytale UV-Preserving Scale + * + * Scales a Hytale model up or down without changing its UVs. In this format the + * UV size comes from a cube's base size, so a normal resize would change the UVs. + * Instead we keep the base size, multiply each cube's `stretch` (that's what + * scales the visible box), and move pivots/origins. Texture layout stays put. + * + * Standalone add-on for the official Hytale plugin. It doesn't import or modify + * it; it just checks the active format id, so load order doesn't matter. + * + * Author: CODEFELICE + */ + +(function () { + 'use strict'; + + var PLUGIN_ID = 'hytale_uv_preserving_scale'; + var ACTION_ID = 'hytale_uv_preserving_scale_action'; + var DIALOG_ID = 'hytale_uv_preserving_scale_dialog'; + var TITLE = 'Hytale UV-Preserving Scale'; + var UNDO_LABEL = 'Scale Hytale model while preserving UVs'; + + // We only check the active format id, so the official plugin doesn't need to + // be loaded first. + var HYTALE_FORMAT_IDS = ['hytale_character', 'hytale_prop']; + + var FACTOR_MIN = 0.1; + var FACTOR_MAX = 10; + var FACTOR_DEFAULT = 0.5; + + // Only for comparisons, never for rounding values. + var EPSILON = 1e-6; + + // --- Math -------------------------------------------------------------- + + // Turn -0 into 0. (-0 === 0 is true, so other values pass through.) + function normalizeZero(x) { + return x === 0 ? 0 : x; + } + + // Scale a point around a pivot: V' = P + s(V - P). + function scalePointAroundPivot(point, pivot, s) { + return [ + normalizeZero(pivot[0] + s * (point[0] - pivot[0])), + normalizeZero(pivot[1] + s * (point[1] - pivot[1])), + normalizeZero(pivot[2] + s * (point[2] - pivot[2])) + ]; + } + + // Scale a displacement/offset. No anchor, so the pivot doesn't matter. + function scaleDisplacement(vec, s) { + return [ + normalizeZero(vec[0] * s), + normalizeZero(vec[1] * s), + normalizeZero(vec[2] * s) + ]; + } + + function centerOf(from, to) { + return [ + (from[0] + to[0]) / 2, + (from[1] + to[1]) / 2, + (from[2] + to[2]) / 2 + ]; + } + + // Offset to add to from and to so the center lands at its scaled position. + function computeCenterDelta(from, to, pivot, s) { + var c = centerOf(from, to); + var c2 = scalePointAroundPivot(c, pivot, s); + return [c2[0] - c[0], c2[1] - c[1], c2[2] - c[2]]; + } + + // Multiply stretch, don't replace it. Negative and zero axes are kept. + function scaleStretch(stretch, s) { + return [ + normalizeZero(stretch[0] * s), + normalizeZero(stretch[1] * s), + normalizeZero(stretch[2] * s) + ]; + } + + // The actual cube scale. Same delta on from and to keeps the base size, so + // the visible scaling comes from stretch alone (which keeps the UVs intact). + function transformCubeData(data, pivot, s) { + var delta = computeCenterDelta(data.from, data.to, pivot, s); + return { + from: [ + normalizeZero(data.from[0] + delta[0]), + normalizeZero(data.from[1] + delta[1]), + normalizeZero(data.from[2] + delta[2]) + ], + to: [ + normalizeZero(data.to[0] + delta[0]), + normalizeZero(data.to[1] + delta[1]), + normalizeZero(data.to[2] + delta[2]) + ], + origin: scalePointAroundPivot(data.origin, pivot, s), + stretch: scaleStretch(data.stretch, s) + }; + } + + function transformGroupData(data, pivot, s) { + var out = { origin: scalePointAroundPivot(data.origin, pivot, s) }; + // original_offset/original_position are local offsets, not world points, + // so just multiply them by s. + if (Array.isArray(data.original_offset)) { + out.original_offset = scaleDisplacement(data.original_offset, s); + } + if (Array.isArray(data.original_position)) { + out.original_position = scaleDisplacement(data.original_position, s); + } + return out; + } + + function isPlainNumberString(str) { + if (typeof str !== 'string') return false; + var t = str.trim(); + if (t === '') return false; + if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(t)) return false; + return Number.isFinite(parseFloat(t)); + } + + // Position keyframes are deltas, so scale by s. Plain numbers stay numbers; + // Molang expressions get wrapped so they still scale. + function scaleKeyframeValue(raw, s) { + if (typeof raw === 'number') { + return Number.isFinite(raw) ? normalizeZero(raw * s) : raw; + } + if (typeof raw === 'string') { + if (isPlainNumberString(raw)) { + return normalizeZero(parseFloat(raw) * s); + } + var t = raw.trim(); + if (t === '') return raw; + return '(' + raw + ') * ' + s; + } + return raw; + } + + // Re-base a keyframe value by subtracting a constant offset. Used after a pose + // is baked into the model: every keyframe of the channel is shifted by the + // baked offset, so the animation keeps playing relative to the new rest pose. + function rebaseKeyframeValue(raw, offset) { + if (!offset) return raw; + if (typeof raw === 'number') { + return Number.isFinite(raw) ? normalizeZero(raw - offset) : raw; + } + if (typeof raw === 'string') { + if (isPlainNumberString(raw)) { + return normalizeZero(parseFloat(raw) - offset); + } + var t = raw.trim(); + if (t === '') return raw; + return '(' + raw + ') - (' + offset + ')'; + } + return raw; + } + + // Reject NaN, Infinity, zero, negatives and out-of-range values. + function validateScaleFactor(raw, opts) { + var min = opts && typeof opts.min === 'number' ? opts.min : FACTOR_MIN; + var max = opts && typeof opts.max === 'number' ? opts.max : FACTOR_MAX; + + if (raw === null || raw === undefined || raw === '') { + return { ok: false, error: 'Enter a scale factor.' }; + } + var n = typeof raw === 'number' ? raw : parseFloat(raw); + if (typeof n !== 'number' || Number.isNaN(n)) { + return { ok: false, error: 'Scale factor must be a valid number.' }; + } + if (!Number.isFinite(n)) { + return { ok: false, error: 'Scale factor must be finite (not Infinity).' }; + } + if (n === 0) { + return { ok: false, error: 'Scale factor cannot be zero.' }; + } + if (n < 0) { + return { ok: false, error: 'Scale factor must be positive.' }; + } + if (n < min || n > max) { + return { ok: false, error: 'Scale factor must be between ' + min + ' and ' + max + '.' }; + } + return { ok: true, value: n }; + } + + function nearlyEqual(a, b, eps) { + eps = typeof eps === 'number' ? eps : EPSILON; + if (a === b) return true; + if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b; + return Math.abs(a - b) <= eps * Math.max(1, Math.abs(a), Math.abs(b)); + } + + function vecNearlyEqual(a, b, eps) { + if (!a || !b || a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!nearlyEqual(a[i], b[i], eps)) return false; + } + return true; + } + + // Exact match, for values we expect to be left untouched (UVs, rotations). + function vecExactEqual(a, b) { + if (!a || !b || a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] !== b[i] && !(Number.isNaN(a[i]) && Number.isNaN(b[i]))) return false; + } + return true; + } + + // Write into a vector array in place so existing references stay valid. + function writeVec(target, src) { + for (var i = 0; i < src.length; i++) { + target[i] = normalizeZero(src[i]); + } + } + + // --- Element operations (work on the Cube/Group/Keyframe interface) ----- + + function snapshotCube(cube) { + var faces = {}; + for (var fk in cube.faces) { + var f = cube.faces[fk]; + faces[fk] = { + uv: Array.isArray(f.uv) ? f.uv.slice() : null, + rotation: f.rotation, + texture: f.texture, + enabled: f.enabled + }; + } + var size = cube.size(); + return { + cube: cube, + from: cube.from.slice(), + to: cube.to.slice(), + origin: cube.origin.slice(), + rotation: cube.rotation.slice(), + stretch: (cube.stretch ? cube.stretch.slice() : [1, 1, 1]), + size: Array.isArray(size) ? size.slice() : [size, size, size], + visibility: cube.visibility, + shading_mode: cube.shading_mode, + double_sided: cube.double_sided, + faces: faces + }; + } + + function snapshotGroup(group) { + return { + group: group, + origin: group.origin.slice(), + rotation: group.rotation.slice(), + original_offset: Array.isArray(group.original_offset) ? group.original_offset.slice() : undefined, + original_position: Array.isArray(group.original_position) ? group.original_position.slice() : undefined, + visibility: group.visibility, + name: group.name, + uuid: group.uuid, + parent: group.parent + }; + } + + function snapshotKeyframes(keyframes) { + return keyframes.map(function (kf) { + var dps = []; + for (var i = 0; i < kf.data_points.length; i++) { + dps.push({ x: kf.get('x', i), y: kf.get('y', i), z: kf.get('z', i) }); + } + return { kf: kf, dps: dps }; + }); + } + + // Snapshot everything we might touch, so we can roll back on failure. + function snapshotModelState(cubes, groups, keyframes) { + return { + cubes: cubes.map(snapshotCube), + groups: groups.map(snapshotGroup), + keyframes: snapshotKeyframes(keyframes) + }; + } + + function applyCubeTransform(cube, pivot, s) { + var stretch = cube.stretch ? cube.stretch : [1, 1, 1]; + var res = transformCubeData( + { from: cube.from, to: cube.to, origin: cube.origin, stretch: stretch }, + pivot, s + ); + writeVec(cube.from, res.from); + writeVec(cube.to, res.to); + writeVec(cube.origin, res.origin); + if (!cube.stretch) cube.stretch = [1, 1, 1]; + writeVec(cube.stretch, res.stretch); + } + + function applyGroupTransform(group, pivot, s) { + var res = transformGroupData({ + origin: group.origin, + original_offset: Array.isArray(group.original_offset) ? group.original_offset : undefined, + original_position: Array.isArray(group.original_position) ? group.original_position : undefined + }, pivot, s); + writeVec(group.origin, res.origin); + if (res.original_offset && Array.isArray(group.original_offset)) { + writeVec(group.original_offset, res.original_offset); + } + if (res.original_position && Array.isArray(group.original_position)) { + writeVec(group.original_position, res.original_position); + } + } + + function scaleLoadedPositionAnimations(keyframes, s) { + for (var i = 0; i < keyframes.length; i++) { + var kf = keyframes[i]; + for (var dp = 0; dp < kf.data_points.length; dp++) { + kf.set('x', scaleKeyframeValue(kf.get('x', dp), s), dp); + kf.set('y', scaleKeyframeValue(kf.get('y', dp), s), dp); + kf.set('z', scaleKeyframeValue(kf.get('z', dp), s), dp); + } + } + } + + function restoreSnapshot(snapshot) { + snapshot.cubes.forEach(function (snap) { + var cube = snap.cube; + writeVec(cube.from, snap.from); + writeVec(cube.to, snap.to); + writeVec(cube.origin, snap.origin); + writeVec(cube.rotation, snap.rotation); + if (cube.stretch) writeVec(cube.stretch, snap.stretch); + for (var fk in snap.faces) { + if (!cube.faces[fk]) continue; + var fs = snap.faces[fk]; + if (fs.uv && cube.faces[fk].uv) writeVec(cube.faces[fk].uv, fs.uv); + cube.faces[fk].rotation = fs.rotation; + cube.faces[fk].texture = fs.texture; + cube.faces[fk].enabled = fs.enabled; + } + cube.visibility = snap.visibility; + if (snap.shading_mode !== undefined) cube.shading_mode = snap.shading_mode; + if (snap.double_sided !== undefined) cube.double_sided = snap.double_sided; + }); + snapshot.groups.forEach(function (snap) { + var group = snap.group; + writeVec(group.origin, snap.origin); + writeVec(group.rotation, snap.rotation); + if (snap.original_offset && Array.isArray(group.original_offset)) { + writeVec(group.original_offset, snap.original_offset); + } + if (snap.original_position && Array.isArray(group.original_position)) { + writeVec(group.original_position, snap.original_position); + } + group.visibility = snap.visibility; + }); + snapshot.keyframes.forEach(function (snap) { + snap.dps.forEach(function (vals, i) { + snap.kf.set('x', vals.x, i); + snap.kf.set('y', vals.y, i); + snap.kf.set('z', vals.z, i); + }); + }); + } + + // Sanity-check the result before committing. Returns the first problem found. + function validateTransformedState(snapshot, s, pivot, animationsScaled) { + var i; + for (i = 0; i < snapshot.cubes.length; i++) { + var snap = snapshot.cubes[i]; + var cube = snap.cube; + var label = 'cube "' + (cube.name || cube.uuid) + '"'; + + var exp = transformCubeData( + { from: snap.from, to: snap.to, origin: snap.origin, stretch: snap.stretch }, + pivot, s + ); + + // base size must not change + var sizeNow = cube.size(); + if (!Array.isArray(sizeNow)) sizeNow = [sizeNow, sizeNow, sizeNow]; + if (!vecNearlyEqual(sizeNow, snap.size)) { + return { ok: false, error: label + ': base size changed (' + snap.size + ' -> ' + sizeNow + ').' }; + } + // stretch must be old * s + if (!vecNearlyEqual(cube.stretch || [1, 1, 1], scaleStretch(snap.stretch, s))) { + return { ok: false, error: label + ': stretch not multiplied by factor.' }; + } + // center, from/to and origin at the expected positions + var expCenter = scalePointAroundPivot(centerOf(snap.from, snap.to), pivot, s); + if (!vecNearlyEqual(centerOf(cube.from, cube.to), expCenter)) { + return { ok: false, error: label + ': center is not at the expected position.' }; + } + if (!vecNearlyEqual(cube.from, exp.from) || !vecNearlyEqual(cube.to, exp.to)) { + return { ok: false, error: label + ': from/to not at expected position.' }; + } + if (!vecNearlyEqual(cube.origin, exp.origin)) { + return { ok: false, error: label + ': origin not at expected position.' }; + } + // rotation untouched + if (!vecExactEqual(cube.rotation, snap.rotation)) { + return { ok: false, error: label + ': rotation changed.' }; + } + // UVs, face rotations, textures untouched + for (var fk in snap.faces) { + var fs = snap.faces[fk]; + var fn = cube.faces[fk]; + if (!fn) return { ok: false, error: label + ': face "' + fk + '" disappeared.' }; + if (fs.uv && !vecExactEqual(fn.uv, fs.uv)) { + return { ok: false, error: label + ': UV of face "' + fk + '" changed.' }; + } + if (fn.rotation !== fs.rotation) { + return { ok: false, error: label + ': UV rotation of face "' + fk + '" changed.' }; + } + if (fn.texture !== fs.texture) { + return { ok: false, error: label + ': texture of face "' + fk + '" changed.' }; + } + } + if (cube.visibility !== snap.visibility) { + return { ok: false, error: label + ': visibility changed.' }; + } + if (snap.shading_mode !== undefined && cube.shading_mode !== snap.shading_mode) { + return { ok: false, error: label + ': shading mode changed.' }; + } + if (snap.double_sided !== undefined && cube.double_sided !== snap.double_sided) { + return { ok: false, error: label + ': double-sided state changed.' }; + } + } + + for (i = 0; i < snapshot.groups.length; i++) { + var gsnap = snapshot.groups[i]; + var group = gsnap.group; + var glabel = 'group "' + (group.name || group.uuid) + '"'; + var gexp = transformGroupData({ + origin: gsnap.origin, + original_offset: gsnap.original_offset, + original_position: gsnap.original_position + }, pivot, s); + + if (!vecNearlyEqual(group.origin, gexp.origin)) { + return { ok: false, error: glabel + ': origin not at expected position.' }; + } + if (gsnap.original_offset && !vecNearlyEqual(group.original_offset, scaleDisplacement(gsnap.original_offset, s))) { + return { ok: false, error: glabel + ': original_offset not multiplied by factor.' }; + } + if (gsnap.original_position && !vecNearlyEqual(group.original_position, scaleDisplacement(gsnap.original_position, s))) { + return { ok: false, error: glabel + ': original_position not multiplied by factor.' }; + } + if (!vecExactEqual(group.rotation, gsnap.rotation)) { + return { ok: false, error: glabel + ': rotation changed.' }; + } + if (group.parent !== gsnap.parent) { + return { ok: false, error: glabel + ': parent changed.' }; + } + if (group.name !== gsnap.name) { + return { ok: false, error: glabel + ': name changed.' }; + } + if (group.uuid !== gsnap.uuid) { + return { ok: false, error: glabel + ': uuid changed.' }; + } + } + + if (animationsScaled) { + for (i = 0; i < snapshot.keyframes.length; i++) { + var ksnap = snapshot.keyframes[i]; + var kf = ksnap.kf; + for (var dp = 0; dp < ksnap.dps.length; dp++) { + var before = ksnap.dps[dp]; + var axes = ['x', 'y', 'z']; + for (var ax = 0; ax < axes.length; ax++) { + var axis = axes[ax]; + var expected = scaleKeyframeValue(before[axis], s); + var now = kf.get(axis, dp); + var same; + if (typeof expected === 'number' && typeof now === 'number') { + same = nearlyEqual(now, expected); + } else { + same = now === expected; + } + if (!same) { + return { ok: false, error: 'a position keyframe value was not multiplied by the factor exactly once.' }; + } + } + } + } + } + + return { ok: true }; + } + + // Pure helpers, shared with the test runner and reused by the live code below. + var PURE = { + PLUGIN_ID: PLUGIN_ID, + HYTALE_FORMAT_IDS: HYTALE_FORMAT_IDS, + FACTOR_MIN: FACTOR_MIN, + FACTOR_MAX: FACTOR_MAX, + FACTOR_DEFAULT: FACTOR_DEFAULT, + EPSILON: EPSILON, + normalizeZero: normalizeZero, + scalePointAroundPivot: scalePointAroundPivot, + scaleDisplacement: scaleDisplacement, + centerOf: centerOf, + computeCenterDelta: computeCenterDelta, + scaleStretch: scaleStretch, + transformCubeData: transformCubeData, + transformGroupData: transformGroupData, + isPlainNumberString: isPlainNumberString, + scaleKeyframeValue: scaleKeyframeValue, + rebaseKeyframeValue: rebaseKeyframeValue, + validateScaleFactor: validateScaleFactor, + nearlyEqual: nearlyEqual, + vecNearlyEqual: vecNearlyEqual, + vecExactEqual: vecExactEqual, + writeVec: writeVec, + snapshotModelState: snapshotModelState, + applyCubeTransform: applyCubeTransform, + applyGroupTransform: applyGroupTransform, + scaleLoadedPositionAnimations: scaleLoadedPositionAnimations, + restoreSnapshot: restoreSnapshot, + validateTransformedState: validateTransformedState + }; + + // Outside Blockbench (the test runner) just export the helpers and stop. + var inBlockbench = (typeof Plugin !== 'undefined') && Plugin && typeof Plugin.register === 'function'; + if (!inBlockbench) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = PURE; + } + return; + } + + // --- Blockbench integration -------------------------------------------- + + var activeDialog = null; + var pluginAction = null; + var lastFormValues = null; // keep inputs around when reopening after an error + var animLoadAttempted = null; // the Project we already tried to lazy-load animations for + + function isHytaleActiveFormat() { + return (typeof Format !== 'undefined') && !!Format && HYTALE_FORMAT_IDS.indexOf(Format.id) !== -1; + } + + function getVec3(v, fallback) { + if (Array.isArray(v) && v.length >= 3 && + Number.isFinite(v[0]) && Number.isFinite(v[1]) && Number.isFinite(v[2])) { + return [v[0], v[1], v[2]]; + } + return fallback ? fallback.slice() : null; + } + + function fmtVec(v) { + return v.map(function (n) { + return String(Math.round(n * 10000) / 10000); + }).join(', '); + } + + // Escape any dynamic text before it goes into the summary's innerHTML. + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // Walk the chosen scope and split it into cubes, groups and anything we + // can't handle. A node is only visited once, even if it and a parent are + // both selected. + function collectTargetElements(scope) { + var cubes = []; + var groups = []; + var unsupported = {}; + var seen = Object.create(null); + + function visit(node) { + if (!node || seen[node.uuid]) return; + seen[node.uuid] = true; + if (typeof Group !== 'undefined' && node instanceof Group) { + groups.push(node); + } else if (typeof Cube !== 'undefined' && node instanceof Cube) { + cubes.push(node); + } else { + var t = (node.constructor && node.constructor.name) || node.type || 'Unknown'; + unsupported[t] = (unsupported[t] || 0) + 1; + } + if (node.children && node.children.length) { + for (var i = 0; i < node.children.length; i++) visit(node.children[i]); + } + } + + var roots = []; + if (scope === 'selection') { + roots = getSelectionRoots(); + for (var i = 0; i < roots.length; i++) visit(roots[i]); + } else { + var top = (typeof Outliner !== 'undefined' && Outliner.root) ? Outliner.root : []; + for (var j = 0; j < top.length; j++) { + var n = top[j]; + if ((typeof Group !== 'undefined' && n instanceof Group) || + (typeof Cube !== 'undefined' && n instanceof Cube)) { + roots.push(n); + } + visit(n); + } + } + + var unsupportedList = Object.keys(unsupported).map(function (k) { + return { type: k, count: unsupported[k] }; + }); + return { cubes: cubes, groups: groups, unsupported: unsupportedList, roots: roots }; + } + + // Top-level selected nodes whose parents aren't also selected. + function getSelectionRoots() { + var selGroups = (typeof Group !== 'undefined' && Group.all) + ? Group.all.filter(function (g) { return g.selected; }) + : []; + var selCubes = (typeof selected !== 'undefined' && Array.isArray(selected)) + ? selected.filter(function (e) { return typeof Cube !== 'undefined' && e instanceof Cube; }) + : []; + var nodes = selGroups.concat(selCubes); + var selectedSet = Object.create(null); + nodes.forEach(function (n) { selectedSet[n.uuid] = true; }); + + return nodes.filter(function (node) { + var p = node.parent; + while (p && typeof p === 'object') { + if (selectedSet[p.uuid]) return false; + p = p.parent; + } + return true; + }); + } + + function determinePivot(result, scope) { + var mode = result.pivot || 'origin'; + if (mode === 'origin') { + return { ok: true, pivot: [0, 0, 0], label: 'Model Origin [0, 0, 0]' }; + } + if (mode === 'custom') { + var cp = result.custom_pivot || [0, 0, 0]; + var v = [Number(cp[0]), Number(cp[1]), Number(cp[2])]; + if (!Number.isFinite(v[0]) || !Number.isFinite(v[1]) || !Number.isFinite(v[2])) { + return { ok: false, error: 'Custom pivot must contain three finite numbers.' }; + } + return { ok: true, pivot: v, label: 'Custom Pivot [' + fmtVec(v) + ']' }; + } + if (mode === 'root') { + var roots; + if (scope === 'selection') { + roots = getSelectionRoots(); + } else { + var top = (typeof Outliner !== 'undefined' && Outliner.root) ? Outliner.root : []; + roots = top.filter(function (n) { + return (typeof Group !== 'undefined' && n instanceof Group) || + (typeof Cube !== 'undefined' && n instanceof Cube); + }); + } + if (roots.length !== 1) { + return { + ok: false, + error: 'Selected Root Pivot needs exactly one root element in the chosen scope (found ' + roots.length + ').' + }; + } + var origin = getVec3(roots[0].origin, [0, 0, 0]); + return { ok: true, pivot: origin.slice(), label: 'Selected Root Pivot [' + fmtVec(origin) + ']' }; + } + return { ok: false, error: 'Unknown pivot mode.' }; + } + + // Find the position keyframes in scope. Only loaded animations are touched. + function collectPositionAnimators(scope, targetGroupUuids) { + var entries = []; + var animationsSet = Object.create(null); + var animations = []; + var keyframes = []; + var warnings = []; + + var anims = (typeof Animation !== 'undefined' && Animation.all) ? Animation.all : []; + for (var a = 0; a < anims.length; a++) { + var anim = anims[a]; + if (!anim || !anim.animators) continue; + for (var uuid in anim.animators) { + var animator = anim.animators[uuid]; + if (!animator) continue; + // Bone animators only; skip the effects (sound/particle) animator. + if (typeof BoneAnimator !== 'undefined' && !(animator instanceof BoneAnimator)) continue; + + var posKfs = (animator.position && Array.isArray(animator.position)) + ? animator.position.filter(function (kf) { return kf && kf.channel === 'position'; }) + : []; + if (!posKfs.length) continue; + + // Match the animator to its group; don't guess if we can't. + var group = null; + try { + group = (typeof animator.getGroup === 'function') ? animator.getGroup() : null; + } catch (e) { group = null; } + if (!group) group = animator.group || null; + if (!group && typeof Group !== 'undefined' && Group.all) { + group = Group.all.find(function (g) { return g.uuid === animator.uuid; }) || null; + } + if (!group) { + warnings.push('Animation "' + (anim.name || '?') + '": position animator "' + + (animator.name || uuid) + '" could not be matched to a group and was left unchanged.'); + continue; + } + if (scope === 'selection' && !targetGroupUuids[group.uuid]) { + continue; + } + + entries.push({ animation: anim, animator: animator, group: group, keyframes: posKfs }); + if (!animationsSet[anim.uuid]) { + animationsSet[anim.uuid] = true; + animations.push(anim); + } + for (var k = 0; k < posKfs.length; k++) keyframes.push(posKfs[k]); + } + } + return { entries: entries, animations: animations, keyframes: keyframes, warnings: warnings }; + } + + function refreshBlockbenchView(cubes, groups) { + try { + if (typeof Canvas === 'undefined') return; + Canvas.updateView({ + elements: cubes, + element_aspects: { transform: true, geometry: true }, + groups: groups, + selection: true + }); + if (typeof Canvas.updateAllBones === 'function') Canvas.updateAllBones(); + if (typeof Canvas.updateOrigin === 'function') Canvas.updateOrigin(); + } catch (e) { + console.error('[' + TITLE + '] canvas refresh failed:', e); + } + } + + // The whole thing as one undo step. Rolls back on any failure. + function applyModelScale(opts) { + var s = opts.factor; + var pivot = opts.pivot; + var cubes = opts.cubes; + var groups = opts.groups; + var animKeyframes = opts.animKeyframes || []; + var animAnimations = opts.animAnimations || []; + var animationsScaled = animKeyframes.length > 0; + + var snapshot = snapshotModelState(cubes, groups, animKeyframes); + + // One undo step covering the cubes, groups, the outliner, and (when + // scaling animations) the affected animations and their keyframes. + var aspects = { + elements: cubes.slice(), + groups: groups.slice(), + outliner: true, + selection: true + }; + if (animationsScaled) { + aspects.animations = animAnimations.slice(); + aspects.keyframes = animKeyframes.slice(); + } + + var editStarted = false; + try { + Undo.initEdit(aspects); + editStarted = true; + + for (var g = 0; g < groups.length; g++) applyGroupTransform(groups[g], pivot, s); + for (var c = 0; c < cubes.length; c++) applyCubeTransform(cubes[c], pivot, s); + if (animationsScaled) scaleLoadedPositionAnimations(animKeyframes, s); + + var v = validateTransformedState(snapshot, s, pivot, animationsScaled); + if (!v.ok) throw new Error('Post-operation validation failed: ' + v.error); + + refreshBlockbenchView(cubes, groups); + + for (var a = 0; a < animAnimations.length; a++) animAnimations[a].saved = false; + if (typeof Project !== 'undefined' && Project) Project.saved = false; + + Undo.finishEdit(UNDO_LABEL, aspects); + return { ok: true }; + } catch (err) { + console.error('[' + TITLE + '] operation failed, rolling back:', err); + // Restore our snapshot, cancel the open undo, refresh the view. + try { restoreSnapshot(snapshot); } catch (e) { console.error('snapshot restore failed:', e); } + try { if (editStarted) Undo.cancelEdit(); } catch (e) { console.error('Undo.cancelEdit failed:', e); } + try { refreshBlockbenchView(cubes, groups); } catch (e) { /* already logged */ } + return { ok: false, error: (err && err.message) ? err.message : String(err) }; + } + } + + // --- Dialog ------------------------------------------------------------ + + function countLoadedPositionAnimations() { + return collectPositionAnimators('all', Object.create(null)).animations.length; + } + + // Live summary shown under the form. + function buildSummaryHtml(result) { + var fv = validateScaleFactor(result.factor, { min: FACTOR_MIN, max: FACTOR_MAX }); + var scope = result.scope || 'all'; + var targets = collectTargetElements(scope); + var pv = determinePivot(result, scope); + + var targetGroupUuids = Object.create(null); + targets.groups.forEach(function (g) { targetGroupUuids[g.uuid] = true; }); + var anim = result.scale_animations + ? collectPositionAnimators(scope, targetGroupUuids) + : { animations: [], keyframes: [], warnings: [] }; + + function row(k, val) { + return '
' + notes.join('
') + '