diff --git a/assets/crosshair.png b/assets/crosshair.png new file mode 100644 index 000000000..aca65552a Binary files /dev/null and b/assets/crosshair.png differ diff --git a/assets/fox.png b/assets/fox.png new file mode 100644 index 000000000..ab1f3dd6c Binary files /dev/null and b/assets/fox.png differ diff --git a/assets/splash_art/1.webp b/assets/splash_art/1.webp index 0bdb202dd..503964aaf 100644 Binary files a/assets/splash_art/1.webp and b/assets/splash_art/1.webp differ diff --git a/assets/splash_art/2.webp b/assets/splash_art/2.webp index 92ba27395..44de36e00 100644 Binary files a/assets/splash_art/2.webp and b/assets/splash_art/2.webp differ diff --git a/assets/splash_art/3.webp b/assets/splash_art/3.webp index 6d9a356a2..a68305b0d 100644 Binary files a/assets/splash_art/3.webp and b/assets/splash_art/3.webp differ diff --git a/assets/splash_art/4.webp b/assets/splash_art/4.webp deleted file mode 100644 index 7f5d1ba32..000000000 Binary files a/assets/splash_art/4.webp and /dev/null differ diff --git a/assets/splash_art/5.webp b/assets/splash_art/5.webp deleted file mode 100644 index 32e644116..000000000 Binary files a/assets/splash_art/5.webp and /dev/null differ diff --git a/build/icon.ico b/build/icon.ico index 134514ec2..03b3d9135 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 000000000..5a50d5ad0 --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1 @@ +ManifestDPIAware true \ No newline at end of file diff --git a/build/json.icns b/build/json.icns new file mode 100644 index 000000000..1e2dd49ec Binary files /dev/null and b/build/json.icns differ diff --git a/build/json.ico b/build/json.ico new file mode 100644 index 000000000..137d03189 Binary files /dev/null and b/build/json.ico differ diff --git a/content/cube_knife.png b/content/cube_knife.png new file mode 100644 index 000000000..4bd23ae49 Binary files /dev/null and b/content/cube_knife.png differ diff --git a/content/flipbook_editor.png b/content/flipbook_editor.png deleted file mode 100644 index ff48553ff..000000000 Binary files a/content/flipbook_editor.png and /dev/null differ diff --git a/content/knife_tool.png b/content/knife_tool.png deleted file mode 100644 index c897c0c4a..000000000 Binary files a/content/knife_tool.png and /dev/null differ diff --git a/content/news.json b/content/news.json index cc157731c..e265ecc21 100644 --- a/content/news.json +++ b/content/news.json @@ -5,20 +5,25 @@ "layout": "vertical", "insert_after": "splash_screen", "text": [ - {"text": "Welcome to Blockbench 4.10", "type": "h3"}, - {"text": "The Knife Tool Update!", "type": "h1"}, - {"text": "Check out the [full changelog](https://github.com/JannisX11/blockbench/releases/tag/v4.10.0)!"} + {"text": "Welcome to Blockbench 4.11", "type": "h3"}, + {"text": "The Texture Group Update!", "type": "h1"}, + {"text": "Check out the [full changelog](https://github.com/JannisX11/blockbench/releases/tag/v4.11.0)!"} ], "features": [ { - "image": "https://web.blockbench.net/content/flipbook_editor.png", - "title": "Flipbook Animation Editor", - "text": "Setup animated textures, preview them, add, remove, rearrange, or resize frames in this new convenient editor!" + "image": "https://web.blockbench.net/content/texture_groups.png", + "title": "Texture Groups", + "text": "Organize your textures with groups!" }, { - "image": "https://web.blockbench.net/content/knife_tool.png", - "title": "Knife Tool", - "text": "Cut your mesh to create new details, edges, and faces and shape your mesh in new ways!" + "image": "https://web.blockbench.net/content/tiled_view.png", + "title": "Onion Skin and Tiled View", + "text": "Use onion skinning on animated textures or create tiled textures." + }, + { + "image": "https://web.blockbench.net/content/cube_knife.png", + "title": "Knife Tool for Cubes", + "text": "Use the knife tool to cut cubes and speed up your workflow!" } ] } diff --git a/content/texture_groups.png b/content/texture_groups.png new file mode 100644 index 000000000..99c306bd1 Binary files /dev/null and b/content/texture_groups.png differ diff --git a/content/tiled_view.png b/content/tiled_view.png new file mode 100644 index 000000000..cdb051fef Binary files /dev/null and b/content/tiled_view.png differ diff --git a/css/dialogs.css b/css/dialogs.css index 934601ef2..96e8aa016 100644 --- a/css/dialogs.css +++ b/css/dialogs.css @@ -170,6 +170,9 @@ .dialog_bar > .molang_input { width: calc(100% - var(--max_label_width)); } + .dialog_bar.form_bar.small_text { + word-break: break-word; + } /*.dialog_bar::after { content: ""; clear: both; @@ -673,6 +676,7 @@ .keybind_line > div:first-child { flex-grow: 1; flex-shrink: 1; + flex-basis: 0; padding: 4px; padding-left: 8px; display: flex; @@ -719,6 +723,29 @@ width: 25px; float: right; } + .keybind_item_variations > li { + display: flex; + margin-bottom: 2px; + } + .keybind_item_variations > li > label { + color: var(--color-subtle_text); + padding: 3px; + padding-left: 15px; + width: 30px; + } + .keybind_item_variations > li > * { + flex-grow: 1; + flex-shrink: 0; + width: 0; + } + .keybind_item_variations > li > bb-select { + margin-right: 54px; + } + .keybind_variation_conflict { + color: var(--color-warning); + margin-left: -22px; + width: 22px; + } /*Colors*/ dialog#theme .dialog_wrapper { @@ -787,7 +814,7 @@ float: left; padding: 6px; border: 2px solid transparent; - background-color: var(--color-ui); + background-color: var(--color-back); color: var(--color-text); cursor: pointer; } @@ -799,16 +826,23 @@ #theme_list .theme.selected { border-color: var(--color-accent); } + #theme_list .theme > .theme_preview { + margin-bottom: 4px; + } #theme_list .theme * { cursor: inherit; } - .theme_name, .theme_author { - float: left; - margin-top: 4px; - margin-bottom: -4px; + .theme_name { + display: inline-block; } .theme_author { + color: var(--color-subtle_text); + } + .theme_type_icon { float: right; + margin-right: 5px; + } + .theme_type_icon > i { color: var(--color-subtle_text); } @@ -1242,6 +1276,7 @@ } #plugin_list > li { overflow-y: hidden; + position: relative; margin: 12px; padding: 8px 12px; padding-bottom: 12px; @@ -1282,6 +1317,10 @@ margin-top: 8px; display: inline-block; } + .plugin_icon_area img.icon { + height: auto; + margin-top: 0; + } .plugin_icon_area img { border-radius: 8px; pointer-events: none; @@ -1350,6 +1389,10 @@ .plugin_installed_tag { color: var(--color-confirm); } + #plugin_list .plugin_installed_tag { + position: absolute; + right: 4px; + } dialog#plugins .version { display: inline-block; color: var(--color-subtle_text); @@ -1602,6 +1645,7 @@ width: 320px; margin-bottom: -26px; margin-top: -20px; + image-rendering: auto; } .plugins_suggested_row { width: 100%; @@ -2043,6 +2087,82 @@ right: 0; background: var(--color-back); } +/* Animation import */ + dialog#animation_import .form_bar__path { + padding: 2px; + color: var(--color-subtle_text); + overflow-x: auto; + white-space: nowrap; + text-align: right; + direction: rtl; + } +/* Animation Controller curves */ + + dialog#blend_transition_edit .blend_transition_graph_wrapper { + margin-top: 6px; + margin-bottom: 10px; + display: flex; + } + #blend_transition_graph { + background-color: var(--color-back); + border: 1px solid var(--color-border); + position: relative; + overflow: hidden; + cursor: crosshair; + } + #blend_transition_graph svg { + height: 100%; + width: 100%; + pointer-events: none; + } + #blend_transition_graph svg path { + fill: none; + stroke-width: 2px; + stroke: var(--color-accent); + } + #blend_transition_graph svg path.zero_lines { + fill: none; + stroke-width: 1px; + stroke: var(--color-grid); + } + .blend_transition_graph_point { + position: absolute; + width: 11px; + height: 11px; + background-color: var(--color-accent); + margin: -1px; + transform: rotate(45deg); + transform-origin: center; + } + .blend_transition_graph_point:hover { + background-color: var(--color-light); + } + .blend_transition_graph_point::before { + content: ""; + position: absolute; + width: 24px; + height: 24px; + left: -6px; + top: -6px; + cursor: move; + } + .blend_transition_preview { + width: 12px; + height: auto; + position: relative; + background-color: var(--color-back); + margin-left: 8px; + overflow: hidden; + } + .blend_transition_preview > div { + background-color: var(--color-accent); + position: absolute; + height: calc(var(--progress) * 100%); + width: 100%; + bottom: 0; + left: 0; + right: 0; + } /* Texture Edit */ div.texture_adjust_previews { @@ -2225,6 +2345,10 @@ background-color: var(--color-accent); color: var(--color-accent_text); } + .flipbook_frame.selected { + background-color: var(--color-accent); + color: var(--color-accent_text); + } .flipbook_frame > img { cursor: inherit; pointer-events: none; diff --git a/css/general.css b/css/general.css index fb649121a..2d6dfa34a 100644 --- a/css/general.css +++ b/css/general.css @@ -28,13 +28,13 @@ } .progress_bar { background-color: var(--color-back); - height: 20px; + height: 18px; margin-top: 12px; } .progress_bar_inner { background-color: var(--color-accent); height: 100%; - width: 0px; + width: calc(100% * var(--progress)); } .accent_color { color: var(--color-accent); @@ -47,8 +47,10 @@ font-weight: normal; display: inline-block; } - .code { + code, .code { font-family: var(--font-code); + } + .code { font-size: 16px; } .small_text { @@ -709,6 +711,9 @@ pointer-events: none; flex: 1 0 auto; } + .contextMenu li.marked > span { + text-decoration: underline; + } .contextMenu li.parent.focused > .contextMenu.sub { display: block; } @@ -767,6 +772,10 @@ .contextMenu .menu_search_bar { padding: 0; display: flex; + position: sticky; + top: 0; + background-color: inherit; + border-bottom: 2px solid var(--color-menu_separator); } .menu_search_bar > input { color: inherit; diff --git a/css/panels.css b/css/panels.css index 9b87a8916..5c3c1bf4f 100644 --- a/css/panels.css +++ b/css/panels.css @@ -111,7 +111,7 @@ .panel.fixed_height { flex-grow: 0; } - .panel.bottommost_panel { + .panel.bottommost_panel:not(.topmost_panel) { margin-top: auto; } @@ -363,6 +363,10 @@ flex-grow: 1; margin-top: 6px; } + .bar.display_inline_inputs { + display: flex; + gap: 1px; + } input#preset_name { background-color: var(--color-back); @@ -402,6 +406,7 @@ #cubes_list { padding-top: 1px; overflow-y: scroll; + --indentation-offset: 16px; } #cubes_list > li:last-child { margin-bottom: 180px; @@ -437,6 +442,7 @@ width: 100%; padding: 2px; box-sizing: border-box; + padding-left: calc(var(--indentation) * var(--indentation-offset)); } .outliner_object:active { background-color: var(--color-ui); @@ -470,8 +476,7 @@ bottom: 4px; width: 4px; margin-left: 10px; - border-left: 2px solid var(--color-text); - opacity: 0.2; + border-left: 2px solid var(--color-guidelines); pointer-events: none; } .drag_hover[order]::before { @@ -621,6 +626,7 @@ flex-shrink: 0; overflow: hidden; position: relative; + pointer-events: none; } .texture.selected img.texture_icon { margin-top: 0; @@ -638,6 +644,21 @@ z-index: 100; border: 2px solid var(--color-accent); box-shadow: 0 0 16px black; + height: 48px; + width: 48px; + position: absolute; + pointer-events: none; + } + .texture_group_drag_helper { + position: absolute; + z-index: 100; + min-height: 24px; + min-width: 120px; + padding: 4px; + border: 2px solid var(--color-accent); + background-color: var(--color-ui); + box-shadow: 0 0 16px black; + pointer-events: none; } .icon_placeholder { width: 48px; @@ -670,13 +691,10 @@ } .texture_error { position: absolute; - color: red; + color: var(--color-error); margin-left: 21px; margin-top: 21px; text-shadow: 0 0 5px #000; - font-size: 18pt; - left: 0; - max-width: 28px; } .texture_movie { position: absolute; @@ -710,6 +728,64 @@ border-top-right-radius: 4px; border-bottom-right-radius: 4px; } + + .texture_group { + padding-bottom: 4px; + } + .texture_group_head { + height: 32px; + padding: 4px; + padding-right: 8px; + display: flex; + gap: 5px; + color: var(--color-subtle_text); + } + .texture_group_head:hover { + color: var(--color-text); + } + .texture_group_head > .icon-open-state { + text-align: center; + width: 21px; + margin-top: 4px; + flex-shrink: 0; + } + .texture_group_head > label { + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; + } + .texture_group_head.folded > label { + max-width: calc(60% - 50px); + min-width: 30px; + } + .texture_group_head > .in_list_button { + margin-left: auto; + } + .texture_group_mini_icon_list { + display: flex; + gap: 2px; + margin-right: auto; + margin-left: 4px; + max-width: 36%; + overflow: hidden; + padding-right: 7px; + } + .texture_group_mini_icon_list > .texture_mini_icon { + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; + overflow: hidden; + background-color: var(--color-ui); + flex-shrink: 0; + margin-right: -8px; + border: 1px solid var(--color-border); + } + .texture_group_list { + margin-left: 14px; + padding-left: 6px; + border-left: 2px solid var(--color-guidelines); + } #texture_animation_playback { display: flex; @@ -1185,6 +1261,18 @@ display: block; margin-left: -2px; } + #timeline_custom_range_indicator { + position: absolute; + z-index: 0; + pointer-events: none; + height: 100%; + top: 0; + opacity: 0.86; + border-radius: 3px; + background-color: var(--color-button); + border-right: 1px solid var(--color-border); + border-left: 1px solid var(--color-border); + } #panel_timeline .keyframe { position: absolute; @@ -1416,7 +1504,7 @@ #timeline_body li > .animator_head_bar .channel_head:hover { color: var(--color-light); } - #timeline_body li > .animator_channel_bar .channel_head { + body:not(.is_mobile) #timeline_body li > .animator_channel_bar .channel_head { padding-left: 16px; } .animator.selected .channel_head { @@ -1734,6 +1822,17 @@ .controller_transition .controller_item_drag_handle { background-color: var(--color-marker); } +.blend_transition_curve_button { + margin-left: 4px; + cursor: pointer; + display: flex; + width: 42px; + justify-content: start; + align-items: center; +} +.blend_transition_curve_button > span { + color: var(--color-subtle_text); +} span.controller_state_section_info { margin: 0 8px; color: var(--color-subtle_text); @@ -1974,7 +2073,8 @@ span.controller_state_section_info { pointer-events: none; } - body[mode=paint] #uv_frame { + body[mode=paint] #uv_frame, + body[mode=paint] #uv_viewport.tiled_mode { cursor: crosshair; } #uv_frame > #texture_canvas_wrapper > canvas, @@ -1988,6 +2088,19 @@ span.controller_state_section_info { object-fit: cover; object-position: 0 0; } + #uv_frame > #texture_canvas_wrapper > canvas.overlay_canvas[overlay_mode=tiled] { + width: 300%; + height: 300%; + margin-top: calc(-1 * var(--inner-height)); + margin-left: calc(-1 * var(--inner-width)); + } + #uv_frame > #texture_canvas_wrapper > canvas.overlay_canvas[overlay_mode=onion_skin] { + width: 100%; + height: 100%; + } + #uv_frame > #texture_canvas_wrapper > canvas.overlay_canvas.above { + z-index: 1; + } /* Fix in Firefox + iPadOS */ #uv_frame_spacer { width: 1px; @@ -2004,6 +2117,8 @@ span.controller_state_section_info { left: 0; object-fit: cover; object-position: 0 0; + margin: -1px; + border: 1px solid var(--color-grid); } #uv_texture_grid path { fill: none; @@ -2177,6 +2292,7 @@ span.controller_state_section_info { flex-grow: 1; flex-shrink: 1; width: 50px; + overflow: hidden; } .uv_face_properties_labels label > i { vertical-align: top; diff --git a/css/setup.css b/css/setup.css index 7b2eb97fa..f92f0e24e 100644 --- a/css/setup.css +++ b/css/setup.css @@ -344,6 +344,7 @@ --color-checkerboard: #1c2026; --color-menu_separator: #b0afba; + --color-guidelines: rgba(136, 150, 157, 0.35); --color-close: #d62e3f; --color-confirm: #90ee90; @@ -749,6 +750,19 @@ div.nslide.editing { cursor: text; } + .numeric_input.is_colored::before { + content: ""; + position: absolute; + pointer-events: none; + top: 0; + right: 0; + z-index: 1; + border-width: 4px; + border-style: solid; + border-color: var(--corner-color); + border-bottom-color: transparent !important; + border-left-color: transparent !important; + } input.toggle_panel { display: none; diff --git a/icons/favicon_beta.png b/icons/favicon_beta.png new file mode 100644 index 000000000..dcbded9c3 Binary files /dev/null and b/icons/favicon_beta.png differ diff --git a/icons/icon_maskable_beta.png b/icons/icon_maskable_beta.png new file mode 100644 index 000000000..5e96dc761 Binary files /dev/null and b/icons/icon_maskable_beta.png differ diff --git a/index.html b/index.html index 85c135e1c..8319d65f4 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,7 @@ + @@ -146,12 +147,13 @@ + - + diff --git a/js/animations/animation.js b/js/animations/animation.js index 537478c3f..16b248899 100644 --- a/js/animations/animation.js +++ b/js/animations/animation.js @@ -467,6 +467,7 @@ class Animation extends AnimationItem { Animator.preview(); updateInterface(); } + Blockbench.dispatchEvent('select_animation', {animation: this}) return this; } setLength(len = this.length) { @@ -505,7 +506,7 @@ class Animation extends AnimationItem { if (check(this.name)) { return this.name; } - for (var num = 2; num < 8e3; num++) { + for (var num = 2; num < 8e2; num++) { if (check(name+num)) { scope.name = name+num; return scope.name; @@ -724,7 +725,7 @@ class Animation extends AnimationItem { }, methods: { autocomplete(text, position) { - let test = Animator.autocompleteMolang(text, position, 'animation'); + let test = MolangAutocomplete.AnimationContext.autocomplete(text, position); return test; } }, @@ -832,6 +833,23 @@ class Animation extends AnimationItem { } }, 'rename', + { + id: 'reload', + name: 'menu.animation.reload', + icon: 'refresh', + condition: (animation) => Format.animation_files && isApp && animation.saved, + click(animation) { + Blockbench.read([animation.path], {}, ([file]) => { + Undo.initEdit({animations: [animation]}) + let anim_index = Animation.all.indexOf(animation); + animation.remove(false, false); + let [new_animation] = Animator.loadFile(file, [animation.name]); + Animation.all.remove(new_animation); + Animation.all.splice(anim_index, 0, new_animation); + Undo.finishEdit('Reload animation', {animations: [new_animation]}) + }) + } + }, { id: 'unload', name: 'menu.animation.unload', @@ -851,18 +869,10 @@ class Animation extends AnimationItem { ]) Animation.prototype.file_menu = new Menu([ {name: 'menu.animation_file.unload', icon: 'remove', click(id) { - let animations_to_remove = []; - let controllers_to_remove = []; - AnimationItem.all.forEach(animation => { - if (animation.path == id && animation.saved) { - if (animation instanceof AnimationController) { - controllers_to_remove.push(animation); - } else { - animations_to_remove.push(animation); - } - } - }) + let animations_to_remove = Animation.all.filter(anim => anim.path == id && anim.saved); + let controllers_to_remove = AnimationController.all.filter(anim => anim.path == id && anim.saved); if (!animations_to_remove.length && !controllers_to_remove.length) return; + Undo.initEdit({animations: animations_to_remove, animation_controllers: controllers_to_remove}); animations_to_remove.forEach(animation => { animation.remove(false, false); @@ -872,6 +882,34 @@ class Animation extends AnimationItem { }) Undo.finishEdit('Unload animation file', {animations: [], animation_controllers: []}); }}, + {name: 'menu.animation.reload', icon: 'refresh', click(id) { + let animations_to_remove = Animation.all.filter(anim => anim.path == id && anim.saved); + let controllers_to_remove = AnimationController.all.filter(anim => anim.path == id && anim.saved); + if (!animations_to_remove.length && !controllers_to_remove.length) return; + + Undo.initEdit({animations: animations_to_remove, animation_controllers: controllers_to_remove}); + let names = []; + let selected_name = AnimationItem.selected?.name; + animations_to_remove.forEach(animation => { + names.push(animation.name); + animation.remove(false, false); + }) + controllers_to_remove.forEach(animation => { + names.push(animation.name); + animation.remove(false, false); + }) + + Blockbench.read([id], {}, ([file]) => { + let new_animations = Animator.loadFile(file, names); + let selected = new_animations.find(item => item.name == selected_name); + if (selected) selected.select(); + if (new_animations[0] instanceof AnimationController) { + Undo.finishEdit('Reload animation controller file', {animation_controllers: new_animations, animations: []}); + } else { + Undo.finishEdit('Reload animation file', {animations: new_animations, animation_controllers: []}); + } + }) + }}, {name: 'menu.animation_file.import_remaining', icon: 'playlist_add', click(id) { Blockbench.read([id], {}, files => { Animator.importFile(files[0]); @@ -975,6 +1013,49 @@ Blockbench.addDragHandler('animation', { } }) +new ValidatorCheck('unused_animators', { + condition: { features: ['animation_mode'], selected: {animation: true} }, + update_triggers: ['select_animation'], + run() { + let animation = Animation.selected; + if (!animation) return; + let animators = []; + for (let id in animation.animators) { + let animator = animation.animators[id]; + if (animator instanceof BoneAnimator && animator.keyframes.length) { + if (!animator.getGroup()) { + animators.push(animator); + } + } + } + if (animators.length) { + let buttons = [ + { + name: 'Retarget Animators', + icon: 'rebase', + click() { + Validator.dialog.close() + BarItems.retarget_animators.click(); + }, + }, + { + name: 'Reveal in Timeline', + icon: 'fa-sort-amount-up', + click() { + for (let animator of animators) { + animator.addToTimeline(); + } + Validator.dialog.close(); + }, + } + ]; + this.warn({ + message: `The animation "${animation.name}" contains ${animators.length} animated nodes that do not exist in the current model.`, + buttons, + }) + } + } +}) BARS.defineActions(function() { new NumSlider('slider_animation_length', { @@ -1265,6 +1346,160 @@ BARS.defineActions(function() { }).show(); } }) + new Action('merge_animation', { + icon: 'merge_type', + category: 'animation', + condition: () => Modes.animate && Animation.all.length > 1, + click: async function() { + let source_animation = Animation.selected; + + let options = await new Promise(resolve => { + let animation_options = {}; + for (let animation of Animation.all) { + if (animation == source_animation) continue; + animation_options[animation.uuid] = animation.name; + } + new Dialog('merge_animation', { + name: 'action.merge_animation', + form: { + animation: {label: 'dialog.merge_animation.merge_target', type: 'select', options: animation_options}, + }, + onConfirm(result) { + resolve(result); + }, + onCancel() { + resolve(false); + } + }).show(); + }) + if (!options) return; + + let target_animation = Animation.all.find(anim => anim.uuid == options.animation); + let animations = [source_animation, target_animation]; + Undo.initEdit({animations}); + + + for (let uuid in source_animation.animators) { + let source_animator = source_animation.animators[uuid]; + // Get target animator + let target_animator; + if (source_animator instanceof BoneAnimator) { + let node = source_animator.getElement ? source_animator.getElement() : source_animator.getGroup(); + target_animator = target_animation.getBoneAnimator(node); + } else if (source_animator instanceof EffectAnimator) { + if (!target_animation.animators.effects) { + target_animation.animators.effects = new EffectAnimator(target_animation); + } + target_animator = target_animation.animators.effects; + } + for (let channel in source_animator.channels) { + let channel_config = source_animator.channels[channel]; + let source_kfs = source_animator[channel]; + let target_kfs = target_animator[channel]; + + if (source_kfs.length == 0) { + continue; + } else if (target_kfs.length == 0) { + for (let src_kf of source_kfs) { + target_animator.createKeyframe(src_kf, src_kf.time, channel, false, false); + } + continue; + } + + let timecodes = {}; + // Save base values + for (let kf of source_kfs) { + let key = Math.roundTo(kf.time, 2); + if (!timecodes[key]) timecodes[key] = {}; + timecodes[key].source_kf = kf; + timecodes[key].time = kf.time; + } + for (let kf of target_kfs) { + let key = Math.roundTo(kf.time, 2); + if (!timecodes[key]) timecodes[key] = {}; + timecodes[key].target_kf = kf; + timecodes[key].time = kf.time; + } + if (source_animator.interpolate) { + // Interpolate in between values before they become affected by changes + for (let key in timecodes) { + let data = timecodes[key]; + Timeline.time = data.time; + if (!data.target_kf) { + data.target_values = target_animator.interpolate(channel, true); + } + if (!data.source_kf) { + data.source_values = source_animator.interpolate(channel, true); + } + } + } + function mergeValues(a, b) { + if (!a) return b; + if (!b) return a; + if (typeof a == 'number' && typeof b == 'number') { + return a + b; + } + return a.toString() + ' + ' + b.toString(); + } + let keys = Object.keys(timecodes).sort((a, b) => a.time - b.time); + for (let key of keys) { + let {source_kf, target_kf, target_values, source_values, time} = timecodes[key]; + Timeline.time = time; + if ((source_kf || target_kf).transform) { + if (source_kf && target_kf) { + for (let axis of 'xyz') { + let source_val = source_kf.get(axis); + let target_val = target_kf.get(axis); + target_kf.set(axis, mergeValues(target_val, source_val)); + } + } else if (source_kf) { + let target_kf = target_animator.createKeyframe(null, time, channel, false, false); + let i = 0; + for (let axis of 'xyz') { + let source_val = source_kf.get(axis); + let target_val = target_values[i] ?? 0; + target_kf.set(axis, mergeValues(target_val, source_val)); + i++; + } + + } else if (target_kf) { + let i = 0; + for (let axis of 'xyz') { + let source_val = source_values[i] ?? 0; + let target_val = target_kf.get(axis); + target_kf.set(axis, mergeValues(target_val, source_val)); + i++; + } + } + } else if (source_animator instanceof EffectAnimator) { + if (source_kf && target_kf) { + if (channel == 'timeline' ) { + let source = source_kf.data_points[0].script; + let target = target_kf.data_points[0].script; + target_kf.data_points[0].script = (source && target) ? (target + '\n' + source) : (source || target); + } else if (channel_config?.max_data_points > 1) { + for (let src_kfdp of source_kf.data_points) { + let new_dp = new KeyframeDataPoint(target_kf); + new_dp.extend(src_kfdp); + target_kf.data_points.push(new_dp); + } + } + + } else if (source_kf) { + let new_kf = target_animator.createKeyframe(source_kf, source_kf.time, source_kf.channel, false, false); + Property.resetUniqueValues(Keyframe, new_kf); + } + } + } + } + } + animations.remove(source_animation); + source_animation.remove(false); + target_animation.select(); + + Undo.finishEdit('Merge animations'); + } + }) let optimize_animation_mode = 'selected_animation'; new Action('optimize_animation', { icon: 'settings_slow_motion', @@ -1412,6 +1647,116 @@ BARS.defineActions(function() { } } }) + new Action('retarget_animators', { + icon: 'rebase', + category: 'animation', + condition: () => Animation.selected, + click: async function() { + let animation = Animation.selected; + let form = {}; + let unassigned_animators = []; + let assigned_animators = []; + + for (let id in animation.animators) { + let animator = animation.animators[id]; + if (animator instanceof BoneAnimator && animator.keyframes.length) { + if (!animator.getGroup()) { + unassigned_animators.push(animator); + } else { + assigned_animators.push(animator); + } + } + } + let all_animators = unassigned_animators.slice(); + if (unassigned_animators.length && assigned_animators.length) { + all_animators.push('_'); + } + all_animators.push(...assigned_animators); + + for (let animator of all_animators) { + if (animator == '_') { + form._ = '_'; + continue; + } + let is_assigned = assigned_animators.includes(animator); + let options = {}; + let nodes; + if (animator.type == 'bone') { + nodes = Group.all; + } else { + nodes = Outliner.all.filter(element => element.type == animator.type); + } + if (!is_assigned) options[animator.uuid] = '-'; + for (let node of nodes) { + options[node.uuid] = node.name; + } + form[animator.uuid] = { + label: animator.name, + type: 'select', + value: animator.uuid, + options + } + } + + let form_result = await new Promise(resolve => { + + new Dialog('retarget_animators', { + name: 'action.retarget_animators', + form, + onConfirm(result) { + resolve(result); + }, + onCancel() { + resolve(false); + } + }).show(); + }) + if (!form_result) return; + Undo.initEdit({animations: [animation]}); + + let temp_animators = {}; + + function copyAnimator(target, source) { + for (let channel in target.channels) { + target[channel].splice(0, Infinity, ...source[channel]); + for (let kf of target[channel]) { + kf.animator = target; + } + } + target.rotation_global = source.rotation_global; + } + function resetAnimator(animator) { + for (let channel in animator.channels) { + animator[channel].empty(); + } + animator.rotation_global = false; + } + + for (let animator of all_animators) { + if (animator == '_') continue; + + let target_uuid = form_result[animator.uuid]; + if (target_uuid == animator.uuid) continue; + let target_animator = animation.animators[target_uuid]; + + if (!temp_animators[target_uuid]) { + temp_animators[target_uuid] = new animator.constructor(target_uuid, animation); + copyAnimator(temp_animators[target_uuid], target_animator); + } + copyAnimator(target_animator, temp_animators[animator.uuid] ?? animator); + + // Reset animator + if (!temp_animators[animator.uuid]) { + temp_animators[animator.uuid] = new animator.constructor(animator.uuid, animation); + copyAnimator(temp_animators[animator.uuid], animator); + resetAnimator(animator) + } + } + + Undo.finishEdit('Retarget animations'); + Animator.preview(); + } + }) }) diff --git a/js/animations/animation_controllers.js b/js/animations/animation_controllers.js index ae72272df..3ae6fb575 100644 --- a/js/animations/animation_controllers.js +++ b/js/animations/animation_controllers.js @@ -32,6 +32,12 @@ class AnimationControllerState { if (AnimationControllerState.properties[key].type == 'array') continue; AnimationControllerState.properties[key].merge(this, data) } + if (typeof data.blend_transition_curve == 'object') { + this.blend_transition_curve = {}; + for (let key in data.blend_transition_curve) { + this.blend_transition_curve[key] = data.blend_transition_curve[key]; + } + } if (data.animations instanceof Array) { this.animations.empty(); data.animations.forEach(a => { @@ -127,6 +133,14 @@ class AnimationControllerState { this.sounds.push(sound); }) } + if (typeof data.blend_transition == 'object') { + this.blend_transition_curve = {}; + this.blend_transition = Math.max(...Object.keys(data.blend_transition).map(time => parseFloat(time))); + for (let key in data.blend_transition) { + let timecode = parseFloat(key) / this.blend_transition; + this.blend_transition_curve[timecode] = data.blend_transition[key]; + } + } } getUndoCopy() { var copy = { @@ -182,16 +196,30 @@ class AnimationControllerState { return new oneLiner({[state ? state.name : 'missing_state']: condition}) }) } - if (this.blend_transition) object.blend_transition = this.blend_transition; - if (this.blend_via_shortest_path) object.blend_via_shortest_path = this.blend_via_shortest_path; + if (this.blend_transition) { + object.blend_transition = this.blend_transition; + let curve_keys = this.blend_transition_curve && Object.keys(this.blend_transition_curve); + if (curve_keys?.length) { + let curve_output = {}; + let points = curve_keys.map(key => ({time: parseFloat(key), value: this.blend_transition_curve[key]})); + points.sort((a, b) => a.time - b.time); + for (let point of points) { + let timecode = trimFloatNumber(point.time * this.blend_transition, 4).toString(); + if (!timecode.includes('.')) timecode += '.0'; + curve_output[timecode] = Math.roundTo(point.value, 6); + } + object.blend_transition = curve_output; + } + if (this.blend_via_shortest_path) object.blend_via_shortest_path = this.blend_via_shortest_path; + } Blockbench.dispatchEvent('compile_bedrock_animation_controller_state', {state: this, json: object}); return object; } select(force) { if (this.controller.selected_state !== this || force) { this.controller.last_state = this.controller.selected_state; - this.controller.transition_timestamp = Date.now(); - this.start_timestamp = Date.now(); + this.controller.transition_timestamp = performance.now(); + this.start_timestamp = performance.now(); this.controller.selected_state = this; @@ -386,12 +414,376 @@ class AnimationControllerState { } }) } + calculateBlendValue(blend_progress) { + if (!this.blend_transition_curve || Object.keys(this.blend_transition_curve).length < 2) { + return blend_progress; + } + let time = blend_progress; + let keys = Object.keys(this.blend_transition_curve); + let values = keys.map(key => this.blend_transition_curve[key]); + let times = keys.map(v => parseFloat(v)); + let prev_time = -Infinity, prev = null; + let next_time = Infinity, next = null; + let i = 0; + for (let t of times) { + if (t <= time && t > prev_time) { + prev = i; prev_time = t; + } + if (t >= time && t < next_time) { + next = i; next_time = t; + } + i++; + } + if (prev === null) return 1 - values[next]; + if (next === null || prev == next) return 1 - values[prev]; + let two_point_blend = Math.getLerp(prev_time, next_time, time) || 0; + return 1 - Math.lerp(values[prev], values[next], two_point_blend); + } + editTransitionCurve() { + let state = this; + let duration = this.blend_transition; + let points = []; + for (let key in this.blend_transition_curve) { + key = parseFloat(key); + points.push({ + uuid: guid(), + time: key, + value: this.blend_transition_curve[key] + }) + } + if (!points.length) { + points.push({time: 0, value: 1, uuid: guid()}); + points.push({time: 1, value: 0, uuid: guid()}); + } + + let preview_loop = setInterval(() => { + dialog.content_vue.preview(); + }, 1000 / 60); + let preview_loop_start_time = performance.now(); + + let dialog = new Dialog('blend_transition_edit', { + title: 'animation_controllers.state.blend_transition_curve', + width: 418, + keyboard_actions: { + copy: { + keybind: new Keybind({key: 'c', ctrl: true}), + run() { + this.content_vue.copy(); + } + }, + paste: { + keybind: new Keybind({key: 'v', ctrl: true}), + run() { + this.content_vue.paste(); + } + } + }, + form: { + duration: { + label: 'animation_controllers.state.blend_transition', + value: duration, + min: 0.05, + step: 0.05, + type: 'number', + }, + extended_graph: { + label: 'dialog.blend_transition_edit.extended', + value: false, + type: 'checkbox', + }, + buttons: { + type: 'buttons', buttons: [ + 'generic.reset', + tl('dialog.blend_transition_edit.ease_in_out', [6]), + tl('dialog.blend_transition_edit.ease_in_out', [10]), + 'dialog.blend_transition_edit.generate', + ], + click(index) { + function generate(easing, point_amount) { + points.empty(); + for (let i = 0; i < point_amount; i++) { + let time = i / (point_amount-1); + points.push({time, value: 1-easing(time), uuid: guid()}) + } + dialog.content_vue.updateGraph(); + } + if (index == 3) { + let easings = { + easeInSine: 'In Sine', + easeOutSine: 'Out Sine', + easeInOutSine: 'In Out Sine', + easeInQuad: 'In Quad', + easeOutQuad: 'Out Quad', + easeInOutQuad: 'In Out Quad', + easeInCubic: 'In Cubic', + easeOutCubic: 'Out Cubic', + easeInOutCubic: 'In Out Cubic', + easeInQuart: 'In Quart', + easeOutQuart: 'Out Quart', + easeInOutQuart: 'In Out Quart', + easeInQuint: 'In Quint', + easeOutQuint: 'Out Quint', + easeInOutQuint: 'In Out Quint', + easeInExpo: 'In Expo', + easeOutExpo: 'Out Expo', + easeInOutExpo: 'In Out Expo', + easeInCirc: 'In Circ', + easeOutCirc: 'Out Circ', + easeInOutCirc: 'In Out Circ', + easeInBack: 'In Back', + easeOutBack: 'Out Back', + easeInOutBack: 'In Out Back', + easeInElastic: 'In Elastic', + easeOutElastic: 'Out Elastic', + easeInOutElastic: 'In Out Elastic', + easeOutBounce: 'Out Bounce', + easeInBounce: 'In Bounce', + easeInOutBounce: 'In Out Bounce', + }; + let initial_points = points.slice(); + new Dialog('blend_transition_edit_easing', { + title: 'dialog.blend_transition_edit.generate', + width: 380, + form: { + easings: {type: 'info', text: tl('dialog.blend_transition_edit.generate.learn_more') + ': [easings.net](https://easings.net)'}, + curve: {type: 'select', label: 'dialog.blend_transition_edit.generate.curve', options: easings}, + steps: {type: 'number', label: 'dialog.blend_transition_edit.generate.steps', value: 10, step: 1, min: 3, max: 64} + }, + onFormChange(result) { + generate(Easings[result.curve], result.steps); + }, + onConfirm(result) { + generate(Easings[result.curve], result.steps); + }, + onCancel() { + points.replace(initial_points); + dialog.content_vue.updateGraph(); + } + }).show(); + + } else { + let point_amount = ([2, 6, 10])[index]; + function hermiteBlend(t) { + return 3*(t**2) - 2*(t**3); + } + generate(hermiteBlend, point_amount); + } + + } + } + }, + component: { + data() {return { + duration, + points, + graph_data: '', + zero_line: '', + preview_value: 0, + width: Math.min(340, window.innerWidth - 42), + height: 220, + scale_y: 220 + }}, + methods: { + dragPoint(point, e1) { + let scope = this; + let original_time = point.time; + let original_value = point.value; + let scale_y = this.scale_y; + + let drag = (e2) => { + point.time = original_time + (e2.clientX - e1.clientX) / this.width; + point.value = original_value - (e2.clientY - e1.clientY) / scale_y; + point.time = Math.clamp(point.time, 0, 1); + let limits = (this.scale_y > 188) ? [0, 1] : [-1, 2]; + point.value = Math.clamp(point.value, ...limits); + Blockbench.setCursorTooltip(`${Math.roundTo(point.time * this.duration, 4)} x ${Math.roundTo(point.value, 4)}`); + + scope.updateGraph(); + } + let stop = () => { + removeEventListeners(document, 'mousemove touchmove', drag); + removeEventListeners(document, 'mouseup touchend', stop); + Blockbench.setCursorTooltip(); + } + addEventListeners(document, 'mousemove touchmove', drag); + addEventListeners(document, 'mouseup touchend', stop); + }, + createNewPoint(event) { + if (event.target.id !== 'blend_transition_graph' || event.which == 3) return; + let offset_y = (this.height - this.scale_y) / 2; + let point = { + uuid: guid(), + time: (event.offsetX - 5) / this.width, + value: 1 - ((event.offsetY - 5 - offset_y) / this.scale_y), + } + this.points.push(point); + this.updateGraph(); + this.dragPoint(point, event); + }, + copy() { + let copy = points.map(p => ({time: p.time, value: p.value})); + Clipbench.setText(JSON.stringify(copy)); + }, + async paste() { + let input; + if (isApp) { + input = clipboard.readText(); + } else { + input = await navigator.clipboard.readText(); + } + if (!input) return; + try { + let parsed = JSON.parse(input); + if (!(parsed instanceof Array)) return; + points.empty(); + for (let point_data of parsed) { + let point = { + uuid: guid(), + time: point_data.time ?? 0, + value: point_data.value ?? 0, + } + points.push(point); + } + this.updateGraph(); + + } catch (err) {} + }, + contextMenu(event) { + new Menu([ + { + id: 'copy', + name: 'action.copy', + icon: 'fa-copy', + click: () => { + this.copy(); + } + }, { + id: 'paste', + name: 'action.paste', + icon: 'fa-clipboard', + click: () => { + this.paste(); + } + } + ]).open(event); + }, + pointContextMenu(point, event) { + new Menu([{ + id: 'remove', + name: 'generic.remove', + icon: 'clear', + click: () => { + points.remove(point); + this.updateGraph(); + } + }]).open(event.target); + }, + scaleY() { + let max_offset = 0; + for (let point of points) { + max_offset = Math.max(max_offset, -point.value, point.value-1); + } + return max_offset > 0.01 ? 90 : this.height; + }, + updateGraph() { + if (!this.points.length) { + this.graph_data = ''; + return; + } + let offset = 5; + let offset_y = 5 + (this.height - this.scale_y) / 2; + this.points.sort((a, b) => a.time - b.time); + let graph_data = `M${0} ${(1-this.points[0].value) * this.scale_y + offset_y} `; + for (let point of this.points) { + graph_data += `${graph_data ? 'L' : 'M'}${point.time * this.width + offset} ${(1-point.value) * this.scale_y + offset_y} `; + } + graph_data += `L${this.width + 10} ${(1-points.last().value) * this.scale_y + offset_y} `; + this.graph_data = graph_data; + + this.zero_line = `M0 ${offset_y} L${this.width} ${offset_y} M0 ${offset_y + this.scale_y} L${this.width} ${offset_y + this.scale_y}`; + }, + preview() { + if (this.points.length == 0) return 0; + let pause = 0.4; + let absolute_time = ((performance.now() - preview_loop_start_time) / 1000); + let time = (absolute_time % (this.duration + pause)) / this.duration; + if (time > 1) { + this.preview_value = 0; + return; + } + let prev_time = -Infinity, prev = 0; + let next_time = Infinity, next = 0; + for (let pt of points) { + if (pt.time <= time && pt.time > prev_time) { + prev = pt; prev_time = pt.time; + } + if (pt.time >= time && pt.time < next_time) { + next = pt; next_time = pt.time; + } + } + if (!prev) return next.value; + if (!next) return prev.value; + let two_point_blend = Math.getLerp(prev_time, next_time, time); + this.preview_value = Math.lerp(prev.value, next.value, two_point_blend); + } + }, + template: ` +
+
+ + + + + +
+
+
+
+
+
+ `, + mounted() { + this.updateGraph(); + } + }, + onFormChange(result) { + this.content_vue.duration = result.duration; + this.content_vue.scale_y = result.extended_graph ? 220 / 3 : 220; + this.content_vue.updateGraph(); + }, + onConfirm(result) { + clearInterval(preview_loop); + Undo.initEdit({animation_controller_state: state}); + state.blend_transition = result.duration; + state.blend_transition_curve = {}; + let is_linear = points.length == 2 && points.find(p => p.time == 0 && p.value == 1) && points.find(p => p.time == 1 && p.value == 0); + if (!is_linear) { + for (let point of points) { + state.blend_transition_curve[Math.clamp(point.time, 0, 1)] = point.value; + } + } + Undo.finishEdit('Change blend transition curve'); + }, + onCancel() { + clearInterval(preview_loop); + } + }); + dialog.show(); + } openMenu(event) { AnimationControllerState.prototype.menu.open(event, this); } getStateTime() { if (!this.start_timestamp) return 0; - return (Date.now() - this.start_timestamp) / 1000 * (AnimationController.playback_speed / 100); + return (performance.now() - this.start_timestamp) / 1000 * (AnimationController.playback_speed / 100); } } new Property(AnimationControllerState, 'string', 'name', {default: 'default'}); @@ -402,6 +794,7 @@ new Property(AnimationControllerState, 'array', 'particles'); new Property(AnimationControllerState, 'string', 'on_entry'); new Property(AnimationControllerState, 'string', 'on_exit'); new Property(AnimationControllerState, 'number', 'blend_transition'); +new Property(AnimationControllerState, 'object', 'blend_transition_curve'); new Property(AnimationControllerState, 'boolean', 'blend_via_shortest_path'); AnimationControllerState.prototype.menu = new Menu([ { @@ -863,6 +1256,24 @@ class AnimationController extends AnimationItem { } }, 'rename', + { + id: 'reload', + name: 'menu.animation.reload', + icon: 'refresh', + condition: (controller) => Format.animation_files && isApp && controller.saved, + click(controller) { + Blockbench.read([controller.path], {}, ([file]) => { + Undo.initEdit({animation_controllers: [controller]}) + let anim_index = AnimationController.all.indexOf(controller); + controller.remove(false, false); + let [new_ac] = Animator.loadFile(file, [controller.name]); + AnimationController.all.remove(new_ac); + AnimationController.all.splice(anim_index, 0, new_ac); + new_ac.select(); + Undo.finishEdit('Reload animation', {animation_controllers: [new_ac]}); + }) + } + }, { id: 'unload', name: 'menu.animation.unload', @@ -1415,12 +1826,15 @@ Interface.definePanels(() => { Undo.finishEdit('Change animation controller audio file') }) }, + editStateBlendTime(state) { + state.controller.saved = false; + }, updateLocatorSuggestionList() { Locator.updateAutocompleteList(); }, autocomplete(text, position) { - let test = Animator.autocompleteMolang(text, position, 'controller'); + let test = MolangAutocomplete.AnimationControllerContext.autocomplete(text, position); return test; } }, @@ -1730,7 +2144,15 @@ Interface.definePanels(() => {
- + +
+ + {{ Object.keys(state.blend_transition_curve).length }} +
diff --git a/js/animations/animation_mode.js b/js/animations/animation_mode.js index 3c7d6a42d..8e619a790 100644 --- a/js/animations/animation_mode.js +++ b/js/animations/animation_mode.js @@ -26,7 +26,7 @@ const Animator = { if (paths.length) { Blockbench.read(paths, {}, files => { files.forEach(file => { - Animator.importFile(file); + Animator.importFile(file, true); }) }) } @@ -318,6 +318,7 @@ const Animator = { let {selected_state, last_state} = controller; let state_time = selected_state.getStateTime(); let blend_progress = (last_state && last_state.blend_transition) ? Math.clamp(state_time / last_state.blend_transition, 0, 1) : 1; + let blend_value = last_state?.calculateBlendValue(blend_progress) ?? blend_progress; // Active State Timeline.time = state_time; @@ -328,13 +329,13 @@ const Animator = { let animation = Animation.all.find(anim => a.animation == anim.uuid); if (!animation) return; let user_blend_value = a.blend_value.trim() ? Animator.MolangParser.parse(a.blend_value) : 1; - controller_blend_values[animation.uuid] = user_blend_value * blend_progress; + controller_blend_values[animation.uuid] = user_blend_value * blend_value; animations_to_play.push(animation); }) Animator.stackAnimations(animations_to_play, in_loop, controller_blend_values); // Last State - if (blend_progress < 1 && last_state) { + if (blend_value < 1 && last_state) { Timeline.time = last_state.getStateTime(); controller_blend_values = {}; animations_to_play = []; @@ -344,7 +345,7 @@ const Animator = { if (!animation) return; let user_blend_value = a.blend_value.trim() ? Animator.MolangParser.parse(a.blend_value) : 1; if (!controller_blend_values[animation.uuid]) controller_blend_values[animation.uuid] = 0; - controller_blend_values[animation.uuid] += user_blend_value * (1-blend_progress); + controller_blend_values[animation.uuid] += user_blend_value * (1-blend_value); animations_to_play.push(animation); }) Animator.stackAnimations(animations_to_play, in_loop, controller_blend_values); @@ -689,8 +690,11 @@ const Animator = { animation_controllers: controllers } }, - importFile(file) { + importFile(file, auto_loaded) { let form = {}; + if (auto_loaded && file.path) { + form['_path'] = {type: 'info', text: file.path}; + } let json = autoParseJSON(file.content) let keys = []; let is_controller = !!json.animation_controllers; @@ -707,7 +711,7 @@ const Animator = { } if (is_already_loaded) continue; } - form[key.hashCode()] = {label: key, type: 'checkbox', value: true, nocolon: true}; + form['anim' + key.hashCode()] = {label: key, type: 'checkbox', value: true, nocolon: true}; keys.push(key); } file.json = json; @@ -721,15 +725,21 @@ const Animator = { } else { return new Promise(resolve => { + let buttons = ['dialog.ok', 'dialog.ignore']; + if (auto_loaded && Project?.memory_animation_files_to_load?.length > 1) { + buttons.push('dialog.ignore_all'); + } let dialog = new Dialog({ id: 'animation_import', title: 'dialog.animation_import.title', form, + buttons, + cancelIndex: 1, onConfirm(form_result) { this.hide(); let names = []; for (var key of keys) { - if (form_result[key.hashCode()]) { + if (form_result['anim' + key.hashCode()]) { names.push(key); } } @@ -738,7 +748,14 @@ const Animator = { Undo.finishEdit('Import animations', {animations: new_animations}) resolve(); }, - onCancel() { + onCancel(index) { + Project.memory_animation_files_to_load.remove(file.path); + resolve(); + }, + onButton(index) { + if (auto_loaded && index == 2) { + Project.memory_animation_files_to_load.empty(); + } resolve(); } }); @@ -747,7 +764,7 @@ const Animator = { buttons: ['generic.select_all', 'generic.select_none'], click(index) { let values = {}; - keys.forEach(key => values[key.hashCode()] = (index == 0)); + keys.forEach(key => values['anim' + key.hashCode()] = (index == 0)); dialog.setFormValues(values); } } @@ -1421,7 +1438,7 @@ Interface.definePanels(function() { addEventListeners(document, 'mousemove touchmove', move); }, autocomplete(text, position) { - let test = Animator.autocompleteMolang(text, position, 'placeholders'); + let test = MolangAutocomplete.VariablePlaceholdersContext.autocomplete(text, position); return test; } }, diff --git a/js/animations/keyframe.js b/js/animations/keyframe.js index 212ab6675..d840c0bb4 100644 --- a/js/animations/keyframe.js +++ b/js/animations/keyframe.js @@ -154,6 +154,7 @@ class Keyframe { value = trimFloatNumber(amount) +(value.substr(0,1)=='-'?'':'+')+ value } } + value = value.replace(/^(0\s*\+)/, '').replace(/^0\s*-/, '-'); this.set(axis, value, data_point) return value; } @@ -796,7 +797,14 @@ BARS.defineActions(function() { icon: 'add_circle', category: 'animation', condition: {modes: ['animate']}, - keybind: new Keybind({key: 'q', shift: null}), + keybind: new Keybind({key: 'q'}, { + reset_values: 'shift' + }), + variations: { + reset_values: { + name: 'action.add_keyframe.reset_values' + } + }, click: function (event) { var animator = Timeline.selected_animator; if (!animator) return; @@ -807,8 +815,9 @@ BARS.defineActions(function() { if (Timeline.vue.graph_editor_open && Prop.active_panel == 'timeline' && animator.channels[Timeline.vue.graph_editor_channel]) { channel = Timeline.vue.graph_editor_channel; } - animator.createKeyframe((event && (event.shiftKey || Pressing.overrides.shift)) ? {} : null, Timeline.time, channel, true); - if (event && (event.shiftKey || Pressing.overrides.shift)) { + let reset_values = BarItems.add_keyframe.keybind.additionalModifierTriggered(event) == 'reset_values'; + animator.createKeyframe(reset_values ? {} : null, Timeline.time, channel, true); + if (reset_values) { Animator.preview(); } } @@ -1446,7 +1455,7 @@ Interface.definePanels(function() { } }, autocomplete(text, position) { - let test = Animator.autocompleteMolang(text, position, 'keyframe'); + let test = MolangAutocomplete.KeyframeContext.autocomplete(text, position); return test; }, tl, diff --git a/js/animations/molang.js b/js/animations/molang.js index f028448db..7974c1dea 100644 --- a/js/animations/molang.js +++ b/js/animations/molang.js @@ -1,539 +1,169 @@ -Animator.MolangParser.context = {}; +Animator.MolangParser.context = {} Animator.MolangParser.global_variables = { - 'true': 1, - 'false': 0, + true: 1, + false: 0, get 'query.delta_time'() { - let time = (Date.now() - Timeline.last_frame_timecode) / 1000; - if (time < 0) time += 1; - return Math.clamp(time, 0, 0.1); + let time = (performance.now() - Timeline.last_frame_timecode) / 1000 + if (time < 0) time += 1 + return Math.clamp(time, 0, 0.1) }, get 'query.anim_time'() { - return Animator.MolangParser.context.animation ? Animator.MolangParser.context.animation.time : Timeline.time; + return Animator.MolangParser.context.animation + ? Animator.MolangParser.context.animation.time + : Timeline.time }, get 'query.life_time'() { - return Timeline.time; + return Timeline.time }, get 'query.time_stamp'() { - return Math.floor(Timeline.time * 20) / 20; + return Math.floor(Timeline.time * 20) / 20 }, get 'query.all_animations_finished'() { if (AnimationController.selected?.selected_state) { - let state = AnimationController.selected?.selected_state; - let state_time = state.getStateTime(); - let all_finished = state.animations.allAre(a => { - let animation = Animation.all.find(anim => anim.uuid == a.animation); - return !animation || state_time > animation.length; + let state = AnimationController.selected?.selected_state + let state_time = state.getStateTime() + let all_finished = state.animations.allAre((a) => { + let animation = Animation.all.find((anim) => anim.uuid == a.animation) + return !animation || state_time > animation.length }) - return all_finished ? 1 : 0; + return all_finished ? 1 : 0 } - return 0; + return 0 + }, + get 'query.state_time'() { + if (AnimationController.selected?.selected_state) { + AnimationController.selected.selected_state.getStateTime(); + } + return Timeline.time }, get 'query.any_animation_finished'() { if (AnimationController.selected?.selected_state) { - let state = AnimationController.selected?.selected_state; - let state_time = state.getStateTime(); - let finished_anim = state.animations.find(a => { - let animation = Animation.all.find(anim => anim.uuid == a.animation); - return animation && state_time > animation.length; + let state = AnimationController.selected?.selected_state + let state_time = state.getStateTime() + let finished_anim = state.animations.find((a) => { + let animation = Animation.all.find((anim) => anim.uuid == a.animation) + return animation && state_time > animation.length }) - return finished_anim ? 1 : 0; + return finished_anim ? 1 : 0 } - return 0; + return 0 }, 'query.camera_rotation'(axis) { - let val = cameraTargetToRotation(Preview.selected.camera.position.toArray(), Preview.selected.controls.target.toArray())[axis ? 0 : 1]; - if (axis == 0) val *= -1; - return val; + let val = cameraTargetToRotation( + Preview.selected.camera.position.toArray(), + Preview.selected.controls.target.toArray() + )[axis ? 0 : 1] + if (axis == 0) val *= -1 + return val }, 'query.rotation_to_camera'(axis) { - let val = cameraTargetToRotation([0, 0, 0], Preview.selected.camera.position.toArray())[axis ? 0 : 1] ; - if (axis == 0) val *= -1; - return val; + let val = cameraTargetToRotation([0, 0, 0], Preview.selected.camera.position.toArray())[ + axis ? 0 : 1 + ] + if (axis == 0) val *= -1 + return val }, get 'query.distance_from_camera'() { - return Preview.selected.camera.position.length() / 16; + return Preview.selected.camera.position.length() / 16 }, 'query.lod_index'(indices) { - indices.sort((a, b) => a - b); - let distance = Preview.selected.camera.position.length() / 16; - let index = indices.length; + indices.sort((a, b) => a - b) + let distance = Preview.selected.camera.position.length() / 16 + let index = indices.length indices.forEachReverse((val, i) => { - if (distance < val) index = i; + if (distance < val) index = i }) - return index; + return index }, 'query.camera_distance_range_lerp'(a, b) { - let distance = Preview.selected.camera.position.length() / 16; - return Math.clamp(Math.getLerp(a, b, distance), 0, 1); + let distance = Preview.selected.camera.position.length() / 16 + return Math.clamp(Math.getLerp(a, b, distance), 0, 1) }, get 'query.is_first_person'() { - return Project.bedrock_animation_mode == 'attachable_first' ? 1 : 0; + return Project.bedrock_animation_mode == 'attachable_first' ? 1 : 0 }, get 'context.is_first_person'() { - return Project.bedrock_animation_mode == 'attachable_first' ? 1 : 0; + return Project.bedrock_animation_mode == 'attachable_first' ? 1 : 0 + }, + get time() { + return Timeline.time }, - get 'time'() { - return Timeline.time; - } } Animator.MolangParser.variableHandler = function (variable, variables) { - var inputs = Interface.Panels.variable_placeholders.inside_vue.text.split('\n'); - var i = 0; + var inputs = Interface.Panels.variable_placeholders.inside_vue.text.split('\n') + var i = 0 while (i < inputs.length) { - let key, val; - [key, val] = inputs[i].split(/=\s*(.+)/); - key = key.replace(/[\s;]/g, ''); - key = key.replace(/^v\./, 'variable.').replace(/^q\./, 'query.').replace(/^t\./, 'temp.').replace(/^c\./, 'context.'); + let key, val + ;[key, val] = inputs[i].split(/=\s*(.+)/) + key = key.replace(/[\s;]/g, '') + key = key + .replace(/^v\./, 'variable.') + .replace(/^q\./, 'query.') + .replace(/^t\./, 'temp.') + .replace(/^c\./, 'context.') if (key === variable && val !== undefined) { - val = val.trim(); + val = val.trim() if (val.match(/^(slider|toggle|impulse)\(/)) { - let [type, content] = val.substring(0, val.length - 1).split(/\(/); - let [id] = content.split(/\(|, */); - id = id.replace(/['"]/g, ''); - - let button = Interface.Panels.variable_placeholders.inside_vue.buttons.find(b => b.id === id && b.type == type); - return button ? parseFloat(button.value) : 0; - - } else { - return val[0] == `'` ? val : Animator.MolangParser.parse(val, variables); - } - } - i++; - } -}; - -(function() { - let RootTokens = [ - 'true', - 'false', - 'math.', - 'query.', //'q.', - 'variable.',//'v.', - 'temp.', //'t.', - 'context.', //'c.', - 'this', - 'loop()', - 'return', - 'break', - 'continue', - ] - let MolangQueries = [ - // common - 'all_animations_finished', - 'any_animation_finished', - 'anim_time', - 'life_time', - 'yaw_speed', - 'ground_speed', - 'vertical_speed', - 'property', - 'has_property()', - 'variant', - 'mark_variant', - 'skin_id', - - - 'above_top_solid', - 'actor_count', - 'all()', - 'all_tags', - 'anger_level', - 'any()', - 'any_tag', - 'approx_eq()', - 'armor_color_slot', - 'armor_material_slot', - 'armor_texture_slot', - 'average_frame_time', - 'blocking', - 'body_x_rotation', - 'body_y_rotation', - 'bone_aabb', - 'bone_origin', - 'bone_rotation', - 'camera_distance_range_lerp', - 'camera_rotation()', - 'can_climb', - 'can_damage_nearby_mobs', - 'can_dash', - 'can_fly', - 'can_power_jump', - 'can_swim', - 'can_walk', - 'cape_flap_amount', - 'cardinal_facing', - 'cardinal_facing_2d', - 'cardinal_player_facing', - 'combine_entities()', - 'count', - 'current_squish_value', - 'dash_cooldown_progress', - 'day', - 'death_ticks', - 'debug_output', - 'delta_time', - 'distance_from_camera', - 'effect_emitter_count', - 'effect_particle_count', - 'equipment_count', - 'equipped_item_all_tags', - 'equipped_item_any_tag()', - 'equipped_item_is_attachable', - 'eye_target_x_rotation', - 'eye_target_y_rotation', - 'facing_target_to_range_attack', - 'frame_alpha', - 'get_actor_info_id', - 'get_animation_frame', - 'get_default_bone_pivot', - 'get_locator_offset', - 'get_root_locator_offset', - 'had_component_group()', - 'has_any_family()', - 'has_armor_slot', - 'has_biome_tag', - 'has_block_property', - 'has_cape', - 'has_collision', - 'has_dash_cooldown', - 'has_gravity', - 'has_owner', - 'has_rider', - 'has_target', - 'head_roll_angle', - 'head_x_rotation', - 'head_y_rotation', - 'health', - 'heartbeat_interval', - 'heartbeat_phase', - 'heightmap', - 'hurt_direction', - 'hurt_time', - 'in_range()', - 'invulnerable_ticks', - 'is_admiring', - 'is_alive', - 'is_angry', - 'is_attached_to_entity', - 'is_avoiding_block', - 'is_avoiding_mobs', - 'is_baby', - 'is_breathing', - 'is_bribed', - 'is_carrying_block', - 'is_casting', - 'is_celebrating', - 'is_celebrating_special', - 'is_charged', - 'is_charging', - 'is_chested', - 'is_critical', - 'is_croaking', - 'is_dancing', - 'is_delayed_attacking', - 'is_digging', - 'is_eating', - 'is_eating_mob', - 'is_elder', - 'is_emerging', - 'is_emoting', - 'is_enchanted', - 'is_fire_immune', - 'is_first_person', - 'is_ghost', - 'is_gliding', - 'is_grazing', - 'is_idling', - 'is_ignited', - 'is_illager_captain', - 'is_in_contact_with_water', - 'is_in_love', - 'is_in_ui', - 'is_in_water', - 'is_in_water_or_rain', - 'is_interested', - 'is_invisible', - 'is_item_equipped', - 'is_item_name_any()', - 'is_jump_goal_jumping', - 'is_jumping', - 'is_laying_down', - 'is_laying_egg', - 'is_leashed', - 'is_levitating', - 'is_lingering', - 'is_moving', - 'is_name_any()', - 'is_on_fire', - 'is_on_ground', - 'is_on_screen', - 'is_onfire', - 'is_orphaned', - 'is_owner_identifier_any()', - 'is_persona_or_premium_skin', - 'is_playing_dead', - 'is_powered', - 'is_pregnant', - 'is_ram_attacking', - 'is_resting', - 'is_riding', - 'is_roaring', - 'is_rolling', - 'is_saddled', - 'is_scared', - 'is_selected_item', - 'is_shaking', - 'is_shaking_wetness', - 'is_sheared', - 'is_shield_powered', - 'is_silent', - 'is_sitting', - 'is_sleeping', - 'is_sneaking', - 'is_sneezing', - 'is_sniffing', - 'is_sonic_boom', - 'is_spectator', - 'is_sprinting', - 'is_stackable', - 'is_stalking', - 'is_standing', - 'is_stunned', - 'is_swimming', - 'is_tamed', - 'is_transforming', - 'is_using_item', - 'is_wall_climbing', - 'item_in_use_duration', - 'item_is_charged', - 'item_max_use_duration', - 'item_remaining_use_duration', - 'item_slot_to_bone_name()', - 'key_frame_lerp_time', - 'last_frame_time', - 'last_hit_by_player', - 'lie_amount', - 'life_span', - 'lod_index', - 'log', - 'main_hand_item_max_duration', - 'main_hand_item_use_duration', - 'max_durability', - 'max_health', - 'max_trade_tier', - 'maximum_frame_time', - 'minimum_frame_time', - 'model_scale', - 'modified_distance_moved', - 'modified_move_speed', - 'moon_brightness', - 'moon_phase', - 'movement_direction', - 'noise', - 'on_fire_time', - 'out_of_control', - 'player_level', - 'position()', - 'position_delta()', - 'previous_squish_value', - 'remaining_durability', - 'roll_counter', - 'rotation_to_camera()', - 'shake_angle', - 'shake_time', - 'shield_blocking_bob', - 'show_bottom', - 'sit_amount', - 'sleep_rotation', - 'sneeze_counter', - 'spellcolor', - 'standing_scale', - 'structural_integrity', - 'surface_particle_color', - 'surface_particle_texture_coordinate', - 'surface_particle_texture_size', - 'swell_amount', - 'swelling_dir', - 'swim_amount', - 'tail_angle', - 'target_x_rotation', - 'target_y_rotation', - 'texture_frame_index', - 'time_of_day', - 'time_since_last_vibration_detection', - 'time_stamp', - 'total_emitter_count', - 'total_particle_count', - 'trade_tier', - 'unhappy_counter', - 'walk_distance', - 'wing_flap_position', - 'wing_flap_speed', - ]; - let MolangQueryLabels = { - 'in_range()': 'in_range( value, min, max )', - 'all()': 'in_range( value, values... )', - 'any()': 'in_range( value, values... )', - 'approx_eq()': 'in_range( value, values... )', - }; - let DefaultContext = [ - 'item_slot', - 'block_face', - 'cardinal_block_face_placed_on', - 'is_first_person', - 'owning_entity', - 'player_offhand_arm_height', - 'other', - 'count', - ]; - let DefaultVariables = [ - 'attack_time', - 'is_first_person', - ]; - let MathFunctions = [ - 'sin()', - 'cos()', - 'abs()', - 'clamp()', - 'pow()', - 'sqrt()', - 'random()', - 'ceil()', - 'round()', - 'trunc()', - 'floor()', - 'mod()', - 'min()', - 'max()', - 'exp()', - 'ln()', - 'lerp()', - 'lerprotate()', - 'pi', - 'asin()', - 'acos()', - 'atan()', - 'atan2()', - 'die_roll()', - 'die_roll_integer()', - 'hermite_blend()', - 'random_integer()', - ]; - let MathFunctionLabels = { - 'clamp()': 'clamp( value, min, max )', - 'pow()': 'pow( base, exponent )', - 'random()': 'random( low, high )', - 'mod()': 'mod( value, denominator )', - 'min()': 'min( A, B )', - 'max()': 'max( A, B )', - 'lerp()': 'lerp( start, end, 0_to_1 )', - 'lerprotate()': 'lerprotate( start, end, 0_to_1 )', - 'atan2()': 'atan2( y, x )', - 'die_roll()': 'die_roll( num, low, high )', - 'die_roll_integer()': 'die_roll_integer( num, low, high )', - 'random_integer()': 'random_integer( low, high )', - 'hermite_blend()': 'hermite_blend( 0_to_1 )', - }; - - function getProjectVariables(current) { - let set = new Set(); - let expressions = getAllMolangExpressions(); - expressions.forEach(exp => { - if (!exp.value) return; - let matches = exp.value.match(/(v|variable)\.\w+/gi); - if (!matches) return; - matches.forEach(match => { - let name = match.substring(match.indexOf('.')+1); - if (name !== current) set.add(name); - }) - }) - return set; - } - - function filterAndSortList(list, match, blacklist, labels) { - let result = list.filter(f => f.startsWith(match) && f.length != match.length); - list.forEach(f => { - if (!result.includes(f) && f.includes(match) && f.length != match.length) result.push(f); - }) - if (blacklist) blacklist.forEach(black => result.remove(black)); - return result.map(text => {return {text, label: labels && labels[text], overlap: match.length}}) - } - - Animator.autocompleteMolang = function(text, position, type) { - let beginning = text.substring(0, position).split(/[^a-zA-Z_.]\.*/g).last(); - if (!beginning) return []; + let [type, content] = val.substring(0, val.length - 1).split(/\(/) + let [id] = content.split(/\(|, */) + id = id.replace(/['"]/g, '') - beginning = beginning.toLowerCase(); - if (beginning.includes('.')) { - let [namespace, dir] = beginning.split('.'); - if (namespace == 'math') { - return filterAndSortList(MathFunctions, dir, null, MathFunctionLabels); - } - if (namespace == 'query' || namespace == 'q') { - return filterAndSortList(MolangQueries, dir, type !== 'controller' && ['all_animations_finished', 'any_animation_finished'], MolangQueryLabels); - } - if (namespace == 'temp' || namespace == 't') { - let temps = text.match(/([^a-z]|^)t(emp)?\.\w+/gi); - if (temps) { - temps = temps.map(t => t.split('.')[1]); - temps = temps.filter((t, i) => t !== dir && temps.indexOf(t) === i); - return filterAndSortList(temps, dir); - } - } - if (namespace == 'context' || namespace == 'c') { - return filterAndSortList(DefaultContext, dir); - } - if (namespace == 'variable' || namespace == 'v') { - let options = [...getProjectVariables(dir)]; - options.safePush(...DefaultVariables); - return filterAndSortList(options, dir); - } - } else { - let root_tokens = RootTokens.slice(); - let labels = {}; - if (type === 'placeholders') { - labels = { - 'toggle()': 'toggle( name )', - 'slider()': 'slider( name, step?, min?, max? )', - 'impulse()': 'impulse( name, duration )', - }; - root_tokens.push(...Object.keys(labels)); + let button = Interface.Panels.variable_placeholders.inside_vue.buttons.find( + (b) => b.id === id && b.type == type + ) + return button ? parseFloat(button.value) : 0 + } else { + return val[0] == `'` ? val : Animator.MolangParser.parse(val, variables) } - return filterAndSortList(root_tokens, beginning, null, labels); } - return []; + i++ } -})() +} function getAllMolangExpressions() { - let expressions = []; - Animation.all.forEach(animation => { + let expressions = [] + Animation.all.forEach((animation) => { for (let key in Animation.properties) { - let property = Animation.properties[key]; - if (Condition(property.condition, animation) && property.type == 'molang' && animation[key] && isNaN(animation[key])) { - let value = animation[key]; + let property = Animation.properties[key] + if ( + Condition(property.condition, animation) && + property.type == 'molang' && + animation[key] && + isNaN(animation[key]) + ) { + let value = animation[key] expressions.push({ value, type: 'animation', - key, animation - }); + key, + animation, + }) } } for (let key in animation.animators) { - let animator = animation.animators[key]; + let animator = animation.animators[key] for (let channel in animator.channels) { animator[channel].forEach((kf, i) => { - kf.data_points.forEach(data_point => { + kf.data_points.forEach((data_point) => { for (let key in KeyframeDataPoint.properties) { - let property = KeyframeDataPoint.properties[key]; - if (Condition(property.condition, data_point) && property.type == 'molang' && data_point[key] && isNaN(data_point[key])) { + let property = KeyframeDataPoint.properties[key] + if ( + Condition(property.condition, data_point) && + property.type == 'molang' && + data_point[key] && + isNaN(data_point[key]) + ) { expressions.push({ value: data_point[key], type: 'keyframe', - key, animation, animator, channel, kf + key, + animation, + animator, + channel, + kf, }) } } @@ -542,114 +172,1651 @@ function getAllMolangExpressions() { } } }) - AnimationController.all.forEach(controller => { - controller.states.forEach(state => { + AnimationController.all.forEach((controller) => { + controller.states.forEach((state) => { if (state.on_entry && isNaN(state.on_entry)) { expressions.push({ value: state.on_entry, type: 'controller', - controller, state + controller, + state, }) } if (state.on_entry && isNaN(state.on_exit)) { expressions.push({ value: state.on_exit, type: 'controller', - controller, state + controller, + state, }) } - state.animations.forEach(a => { + state.animations.forEach((a) => { if (a.blend_value && isNaN(a.blend_value)) { expressions.push({ value: a.blend_value, type: 'controller_animation', - controller, state + controller, + state, }) } }) - state.transitions.forEach(t => { + state.transitions.forEach((t) => { if (t.condition && isNaN(t.condition)) { expressions.push({ value: t.condition, type: 'controller_transition', - controller, state + controller, + state, }) } }) }) }) - return expressions; + return expressions } new ValidatorCheck('molang_syntax', { - condition: {features: ['animation_mode']}, + condition: { features: ['animation_mode'] }, update_triggers: ['update_keyframe_selection', 'edit_animation_properties'], run() { - let check = this; - let keywords = ['return', 'continue', 'break']; - let two_expression_regex = (isApp || window.chrome) ? new RegExp('(?|]/)) { - issues.push('Expression starts with an invalid character'); + issues.push('Expression starts with an invalid character') } - if ((clear_string.match(/[\w.]\s+[\w.]/) && !keywords.find(k => clear_string.includes(k))) || clear_string.match(/\)\(/) || (two_expression_regex && clear_string.match(two_expression_regex))) { - issues.push('Two expressions with no operator in between'); + if ( + (clear_string.match(/[\w.]\s+[\w.]/) && + !keywords.find((k) => clear_string.includes(k))) || + clear_string.match(/\)\(/) || + (two_expression_regex && clear_string.match(two_expression_regex)) + ) { + issues.push('Two expressions with no operator in between') } if (clear_string.match(/(^|[^a-z0-9_])[\d.]+[a-z_]+/i)) { - issues.push('Invalid token ' + clear_string.match(/(^|[^a-z0-9_])[\d.]+[a-z_]+/i)[0].replace(/[^a-z0-9._]/g, '')); + issues.push( + 'Invalid token ' + + clear_string + .match(/(^|[^a-z0-9_])[\d.]+[a-z_]+/i)[0] + .replace(/[^a-z0-9._]/g, '') + ) } if (clear_string.match(/[^\w\s+\-*/().,;:[\]!?=<>&|]/)) { - issues.push('Invalid character: ' + clear_string.match(/[^\s\w+\-*/().,;:[\]!?=<>&|]+/g).join(', ')); + issues.push( + 'Invalid character: ' + + clear_string.match(/[^\s\w+\-*/().,;:[\]!?=<>&|]+/g).join(', ') + ) } - let left = string.match(/\(/g) || 0; - let right = string.match(/\)/g) || 0; + let left = string.match(/\(/g) || 0 + let right = string.match(/\)/g) || 0 if (left.length !== right.length) { - issues.push('Brackets do not match'); + issues.push('Brackets do not match') } if (issues.length) { - let button; + let button if (instance instanceof Animation) { button = { name: 'Edit Animation', icon: 'movie', click() { - Dialog.open.close(); - instance.propertiesDialog(); - } + Dialog.open.close() + instance.propertiesDialog() + }, } } else { button = { name: 'Reveal Keyframe', icon: 'icon-keyframe', click() { - Dialog.open.close(); - instance.showInTimeline(); - } + Dialog.open.close() + instance.showInTimeline() + }, } } check.fail({ message: `${message} ${issues.join('; ')}. Script: \`${string}\``, - buttons: [button] + buttons: [button], }) } } - getAllMolangExpressions().forEach(ex => { + getAllMolangExpressions().forEach((ex) => { if (ex.type == 'animation') { - validateMolang(ex.value, `Property "${ex.key}" on animation "${ex.animation.name}" contains invalid molang:`, ex.animation); - + validateMolang( + ex.value, + `Property "${ex.key}" on animation "${ex.animation.name}" contains invalid molang:`, + ex.animation + ) } else if (ex.type == 'keyframe') { - let channel_name = ex.animator.channels[ex.channel].name; - validateMolang(ex.value, `${channel_name} keyframe at ${ex.kf.time.toFixed(2)} on "${ex.animator.name}" in "${ex.animation.name}" contains invalid molang:`, ex.kf); + let channel_name = ex.animator.channels[ex.channel].name + validateMolang( + ex.value, + `${channel_name} keyframe at ${ex.kf.time.toFixed(2)} on "${ + ex.animator.name + }" in "${ex.animation.name}" contains invalid molang:`, + ex.kf + ) } }) - } + }, }) + +/** + * Global Molang autocomplete object + */ +const MolangAutocomplete = {} + +/** + * Gets all the Molang variables used in the project + * @param {string[]} excluded Variable names to exclude + * @returns {Set} + */ +function getProjectMolangVariables(excluded) { + const variables = new Set() + const expressions = getAllMolangExpressions() + for (const expression of expressions) { + if (!expression.value) continue + const matches = expression.value.match(/(v|variable)\.(\w+)/gi) + if (!matches) continue + for (const match of matches) { + const name = match.split('.')[1] + if (!(excluded && excluded.includes(name))) variables.add(name) + } + } + return variables +} + +/** + * Gets the temporary Molang variables in a molang expression string + * @param {string} expression + * @param {string[]} excluded Variable names to exclude + * @returns {Set} + */ +function getTemporaryMolangVariables(expression, excluded) { + const variables = new Set() + const matches = expression.match(/(t|temp)\.(\w+)/gi) + if (!matches) return variables + for (const match of matches) { + const name = match.split('.')[1] + if (!(excluded && excluded.includes(name))) variables.add(name) + } + return variables +} + +/** + * Sorts autocomplete results based on how well they match the incomplete string, then alphabetically + * @param {MolangAutocompleteResult[]} results + * @param {string} incomplete + * @returns {MolangAutocompleteResult[]} + */ +function sortAutocompleteResults(results, incomplete) { + return results.sort((a, b) => { + if (a.priority && b.priority) return b.priority - a.priority + else if (a.priority) return -1 + else if (b.priority) return 1 + if (a.text.startsWith(incomplete) && !b.text.startsWith(incomplete)) return -1 + if (b.text.startsWith(incomplete) && !a.text.startsWith(incomplete)) return 1 + return a.text.localeCompare(b.text) + }) +} + +;(function () { + /** + * @typedef MolangAutocompleteResult + * @property {string} text The text to insert + * @property {string} [label] The label to display in the autocomplete menu + * @property {number} overlap The number of characters to overlap with the incomplete string + * @property {number} [priority] The suggestion priority. A higher number means it will be suggested first + */ + + /** + * @typedef RootToken + * @property {string} id The ID of the new root token + * @property {string[]} [arguments] The arguments of the root token + * @property {number} [priority] The suggestion priority of the root token. A higher number means it will be suggested first + */ + + /** + * @typedef Query + * @property {string} id The ID of the new query + * @property {string[]} [arguments] The arguments of the query + * @property {number} [priority] The suggestion priority of the query. A higher number means it will be suggested first + */ + + /** + * @typedef NamespaceOptions + * @property {string} id The ID of the new namespace + * @property {string} [shorthand] The shorthand of the new namespace. Eg. `q` for `query` + * @property {number} [priority] The suggestion priority of the namespace. A higher number means it will be suggested first + */ + + MolangAutocomplete.Namespace = class Namespace { + /** + * @type {string} The ID of the namespace. + */ + id + /** + * @type {string} + */ + shorthand + /** + * @type {Map} + */ + queries = new Map() + /** + * @type {Map Query[]>} + */ + queryGetters = new Map() + /** + * @param {NamespaceOptions} options + */ + constructor(options) { + this.id = options.id + this.shorthand = options.shorthand + } + + /** + * Adds a new query to the namespace + * @param {Query} query + * @returns {Namespace} This namespace + */ + addQuery(query) { + this.queries.set(query.id, query) + return this + } + + /** + * @param {string} queryID + * @returns {boolean} True if the query exists in the namespace + */ + hasQuery(queryID) { + // This function isn't used internally, but keeps the API consistent for plugin devs. + return this.queries.has(queryID) + } + + /** + * Removes a query from the namespace + * @param {string} queryID + * @returns {boolean} True if the query was removed, false if it did not exist + */ + removeQuery(queryID) { + return this.queries.delete(queryID) + } + + /** + * Adds a getter function that returns dynamically generated queries. + * @param {string} id + * @param {(incomplete: string) => Query[]} getter + * @returns {Namespace} This namespace + */ + addQueryGetter(id, getter) { + this.queryGetters.set(id, getter) + return this + } + + /** + * Removes a query getter function + * @param {string} id + */ + removeQueryGetter(id) { + this.queryGetters.delete(id) + } + + /** + * @typedef NamespaceUnionOptions + * @property {string} id The ID of the new namespace + * @property {string} [shorthand] The shorthand of the new namespace. Eg. `q` for `query` + * @property {number} [priority] The suggestion priority of the namespace. A higher number means it will be suggested first + */ + + /** + * Creates a new Namespace that is a union of this namespace and another + * @param {Namespace} other + * @param {NamespaceUnionOptions} [options] Options to override the new namespace's properties. If not provided, the new namespace will inherit this namespace's properties + * @returns {Namespace} The new namespace + */ + createUnion(other, options) { + const union = new MolangAutocomplete.Namespace({ + id: options?.id || this.id, + shorthand: options?.shorthand || this.shorthand, + priority: options?.priority || this.priority, + }) + union.queries = new Map([...this.queries, ...other.queries]) + union.queryGetters = new Map([...this.queryGetters, ...other.queryGetters]) + return union + } + + /** + * Returns any queries in this namespace who's ID starts with `incomplete`. + * @param {string} expression The expression the query is being used in + * @param {string} incomplete The incomplete query ID + * @param {boolean} [recursive=true] If true, will also search inherited contexts + * @returns {Query[]} The queries + */ + getPossibleQueries(expression, incomplete, recursive = true) { + const possibleQueries = [] + this.queries.forEach((query) => { + if (query.id.startsWith(incomplete)) possibleQueries.push(query) + }) + this.queryGetters.values().forEach((getter) => { + const queries = getter(expression, incomplete) + queries.forEach((query) => { + if (query.id.startsWith(incomplete)) possibleQueries.push(query) + }) + }) + if (recursive && this.inheritedContext) { + return [ + ...possibleQueries, + ...this.inheritedContext.getPossibleQueries(expression, incomplete), + ] + } + return possibleQueries + } + } + + /** + * @typedef MolangAutocompleteContextOptions + * @property {string} id + * @property {string[]} [rootTokens] + * @property {MolangAutocomplete.Context} [inheritedContext] + */ + + MolangAutocomplete.Context = class Context { + /** + * @type {MolangAutocomplete.Context[]} + */ + static all = [] + /** + * @type {string} + */ + id + /** + * @type {Map} + */ + rootTokens = new Map() + /** + * @type {Map} + */ + namespaces = new Map() + /** + * @type {MolangAutocomplete.Context} + */ + inheritedContext + + /** + * @param {MolangAutocompleteContextOptions} options + */ + constructor(options) { + this.id = options.id + this.inheritedContext = options.inheritedContext + MolangAutocomplete.Context.all.push(this) + } + + /** + * Adds a new root token to the context + * @param {RootToken} token + * @returns {Context} This context + */ + addRootToken(token) { + this.rootTokens.set(token.id, token) + return this + } + + /** + * Returns the root token with the given ID + * @param {string} tokenID + * @returns {RootToken} The root token, or undefined if it does not exist + */ + getRootToken(tokenID) { + return this.rootTokens.get(tokenID) + } + + /** + * Removes a root token from the context + * @param {string} tokenID + * @returns {boolean} True if the token was removed, false if it did not exist + * @returns {boolean} + */ + removeRootToken(tokenID) { + return this.rootTokens.delete(tokenID) + } + + /** + * Returns true if the context has a namespace with the given ID + * @param {string} namespaceID + * @param {boolean} [recursive=true] If true, will also search inherited contexts + * @returns {boolean} + */ + hasNamespace(namespaceID, recursive = true) { + if (this.namespaces.has(namespaceID)) return true + if (recursive && this.inheritedContext) + return this.inheritedContext.hasNamespace(namespaceID) + return false + } + + /** + * Adds a new namespace to the context + * @param {Namespace} namespace + * @param {boolean} [createUnion=true] If true, will create a union of the namespace with any existing namespaces with the same ID. If false, will overwrite any existing namespaces with the same ID. (Default: true) + * @returns {Context} This context + */ + addNamespace(namespace, createUnion = true) { + if (createUnion && this.namespaces.has(namespace.id)) { + this.namespaces.set( + namespace.id, + this.namespaces.get(namespace.id).createUnion(namespace) + ) + } else { + this.namespaces.set(namespace.id, namespace) + } + return this + } + + /** + * Returns the namespace with the given ID + * + * @param {string} namespaceID + * @param {boolean} [recursive=true] If true, will also search inherited contexts + * @returns {Namespace} The namespace, or undefined if it does not exist + */ + getNamespace(namespaceID, recursive = true) { + if (recursive && this.inheritedContext) { + const subNamespace = this.inheritedContext.getNamespace(namespaceID) + if (this.namespaces.has(namespaceID)) { + const namespace = this.namespaces.get(namespaceID) + if (subNamespace) { + return namespace.createUnion(subNamespace) + } + } + return subNamespace + } + if (this.namespaces.has(namespaceID)) return this.namespaces.get(namespaceID) + return undefined + } + + /** + * Removes a namespace from the context + * + * This will not remove namespaces from inherited contexts + * @param {string} namespaceID + * @returns {boolean} True if the namespace was removed, false if it did not exist + */ + removeNamespace(namespaceID) { + return this.namespaces.delete(namespaceID) + } + + /** + * Returns any namespaces in this context who's ID starts with `incomplete`. + * @param {string} incomplete + * @param {boolean} [recursive=true] If true, will also search inherited contexts + * @returns {Namespace[]} The namespaces + */ + getPossibleNamespaces(incomplete, recursive = true) { + const possibleNamespaces = new Map() + this.namespaces.forEach((namespace) => { + if ( + namespace.id.startsWith(incomplete) || + (namespace.shorthand && namespace.shorthand.startsWith(incomplete)) + ) + possibleNamespaces.set(namespace.id, namespace) + }) + if (recursive && this.inheritedContext) { + const inheritedNamespaces = this.inheritedContext.getPossibleNamespaces(incomplete) + inheritedNamespaces.forEach((namespace) => { + if (possibleNamespaces.has(namespace.id)) { + const union = possibleNamespaces.get(namespace.id).createUnion(namespace) + possibleNamespaces.set(namespace.id, union) + } else { + possibleNamespaces.set(namespace.id, namespace) + } + }) + } + return [...possibleNamespaces.values()] + } + + /** + * Returns any root tokens in this context who's ID starts with `incomplete`. + * @param {string} incomplete + * @param {boolean} [recursive=true] If true, will also search inherited contexts + * @returns {RootToken[]} The root tokens + */ + getPossibleRootTokens(incomplete, recursive = true) { + const possibleRootTokens = [] + this.rootTokens.forEach((token) => { + if (token.id.startsWith(incomplete)) possibleRootTokens.push(token) + }) + if (recursive && this.inheritedContext) { + return [ + ...possibleRootTokens, + ...this.inheritedContext.getPossibleRootTokens(incomplete), + ] + } + return possibleRootTokens + } + + /** + * Attempts to autocomplete the given text from the given position in the text + * @param {string} text The text to attempt to autocomplete + * @param {number} position The position of the cursor in the text + * @returns {MolangAutocompleteResult[]} The autocomplete results + */ + autocomplete(text, position) { + const result = [] + const start = text + .substring(0, position) + .split(/[^a-zA-Z_.]\.*/g) + .last() + .toLowerCase() + if (start.length === 0) return result + const [space, dir] = start.split('.').slice(-2) + + const possibleRootTokens = this.getPossibleRootTokens(start) + possibleRootTokens.forEach((token) => { + result.push({ + text: token.arguments ? `${token.id}()` : token.id, + label: token.arguments + ? `${token.id}( ${token.arguments.join(', ')} )` + : undefined, + overlap: start.length, + priority: token.priority, + }) + }) + + const possibleNamespaces = this.getPossibleNamespaces(space) + switch (possibleNamespaces.length) { + default: + possibleNamespaces.forEach((ns) => { + result.push({ text: ns.id, overlap: space.length, priority: ns.priority }) + if (ns.shorthand) + result.push({ + text: ns.shorthand, + overlap: space.length, + priority: ns.priority, + }) + }) + return sortAutocompleteResults(result, start) + case 0: + return sortAutocompleteResults(result, start) + case 1: { + const namespace = possibleNamespaces[0] + if (!dir && !start.endsWith('.')) { + return sortAutocompleteResults( + [ + ...result, + { + text: namespace.id, + overlap: space.length, + priority: namespace.priority, + }, + ], + start + ) + } + const possibleQueries = namespace.getPossibleQueries(text, dir) + switch (possibleQueries.length) { + default: + return sortAutocompleteResults( + [ + ...result, + ...possibleQueries.map((q) => ({ + text: q.arguments ? `${q.id}()` : q.id, + label: q.arguments + ? `${q.id}( ${q.arguments.join(', ')} )` + : undefined, + overlap: dir.length, + priority: q.priority, + })), + ], + dir + ) + case 0: + return sortAutocompleteResults(result, start) + case 1: { + const query = possibleQueries[0] + return sortAutocompleteResults( + [ + ...result, + { + text: query.arguments ? `${query.id}()` : query.id, + label: query.arguments + ? `${query.id}( ${query.arguments.join(', ')} )` + : undefined, + overlap: dir.length, + priority: query.priority, + }, + ], + dir + ) + } + } + } + } + } + + /** + * Removes the context from the list of all contexts + */ + delete() { + MolangAutocomplete.Context.all.remove(this) + } + } + + MolangAutocomplete.DefaultContext = new MolangAutocomplete.Context({ + id: 'defaultContext', + }) + .addRootToken({ + id: 'true', + }) + .addRootToken({ + id: 'false', + }) + .addRootToken({ + id: 'this', + }) + .addRootToken({ + id: 'loop', + arguments: ['count', 'expression'], + }) + .addRootToken({ + id: 'return', + }) + .addRootToken({ + id: 'break', + }) + .addRootToken({ + id: 'continue', + }) + .addNamespace( + new MolangAutocomplete.Namespace({ + id: 'query', + shorthand: 'q', + }) + .addQuery({ + id: 'anim_time', + }) + .addQuery({ + id: 'life_time', + }) + .addQuery({ + id: 'state_time', + }) + .addQuery({ + id: 'yaw_speed', + }) + .addQuery({ + id: 'ground_speed', + }) + .addQuery({ + id: 'vertical_speed', + }) + .addQuery({ + id: 'property', + arguments: ['property'], + }) + .addQuery({ + id: 'has_property', + arguments: ['property'], + }) + .addQuery({ + id: 'variant', + }) + .addQuery({ + id: 'mark_variant', + }) + .addQuery({ + id: 'skin_id', + }) + + .addQuery({ + id: 'above_top_solid', + }) + .addQuery({ + id: 'actor_count', + }) + .addQuery({ + id: 'all', + arguments: ['value', 'values...'], + }) + .addQuery({ + id: 'all_tags', + }) + .addQuery({ + id: 'anger_level', + }) + .addQuery({ + id: 'any', + arguments: ['value', 'values...'], + }) + .addQuery({ + id: 'any_tag', + }) + .addQuery({ + id: 'approx_eq', + arguments: ['value', 'values...'], + }) + .addQuery({ + id: 'armor_color_slot', + }) + .addQuery({ + id: 'armor_material_slot', + }) + .addQuery({ + id: 'armor_texture_slot', + }) + .addQuery({ + id: 'average_frame_time', + }) + .addQuery({ + id: 'blocking', + }) + .addQuery({ + id: 'body_x_rotation', + }) + .addQuery({ + id: 'body_y_rotation', + }) + .addQuery({ + id: 'bone_aabb', + }) + .addQuery({ + id: 'bone_origin', + }) + .addQuery({ + id: 'bone_rotation', + }) + .addQuery({ + id: 'camera_distance_range_lerp', + }) + .addQuery({ + id: 'camera_rotation', + arguments: ['axis'], + }) + .addQuery({ + id: 'can_climb', + }) + .addQuery({ + id: 'can_damage_nearby_mobs', + }) + .addQuery({ + id: 'can_dash', + }) + .addQuery({ + id: 'can_fly', + }) + .addQuery({ + id: 'can_power_jump', + }) + .addQuery({ + id: 'can_swim', + }) + .addQuery({ + id: 'can_walk', + }) + .addQuery({ + id: 'cape_flap_amount', + }) + .addQuery({ + id: 'cardinal_facing', + }) + .addQuery({ + id: 'cardinal_facing_2d', + }) + .addQuery({ + id: 'cardinal_player_facing', + }) + .addQuery({ + id: 'combine_entities', + arguments: ['entitiesReferences...'], + }) + .addQuery({ + id: 'count', + }) + .addQuery({ + id: 'current_squish_value', + }) + .addQuery({ + id: 'dash_cooldown_progress', + }) + .addQuery({ + id: 'day', + }) + .addQuery({ + id: 'death_ticks', + }) + .addQuery({ + id: 'debug_output', + }) + .addQuery({ + id: 'delta_time', + }) + .addQuery({ + id: 'distance_from_camera', + }) + .addQuery({ + id: 'effect_emitter_count', + }) + .addQuery({ + id: 'effect_particle_count', + }) + .addQuery({ + id: 'equipment_count', + }) + .addQuery({ + id: 'equipped_item_all_tags', + }) + .addQuery({ + id: 'equipped_item_any_tag', + arguments: ['tags...'], + }) + .addQuery({ + id: 'equipped_item_is_attachable', + }) + .addQuery({ + id: 'eye_target_x_rotation', + }) + .addQuery({ + id: 'eye_target_y_rotation', + }) + .addQuery({ + id: 'facing_target_to_range_attack', + }) + .addQuery({ + id: 'frame_alpha', + }) + .addQuery({ + id: 'get_actor_info_id', + }) + .addQuery({ + id: 'get_animation_frame', + }) + .addQuery({ + id: 'get_default_bone_pivot', + }) + .addQuery({ + id: 'get_locator_offset', + }) + .addQuery({ + id: 'get_root_locator_offset', + }) + .addQuery({ + id: 'had_component_group', + arguments: ['group'], + }) + .addQuery({ + id: 'has_any_family', + arguments: ['families...'], + }) + .addQuery({ + id: 'has_armor_slot', + }) + .addQuery({ + id: 'has_biome_tag', + }) + .addQuery({ + id: 'has_block_property', + }) + .addQuery({ + id: 'has_cape', + }) + .addQuery({ + id: 'has_collision', + }) + .addQuery({ + id: 'has_dash_cooldown', + }) + .addQuery({ + id: 'has_gravity', + }) + .addQuery({ + id: 'has_owner', + }) + .addQuery({ + id: 'has_rider', + }) + .addQuery({ + id: 'has_target', + }) + .addQuery({ + id: 'head_roll_angle', + }) + .addQuery({ + id: 'head_x_rotation', + }) + .addQuery({ + id: 'head_y_rotation', + }) + .addQuery({ + id: 'health', + }) + .addQuery({ + id: 'heartbeat_interval', + }) + .addQuery({ + id: 'heartbeat_phase', + }) + .addQuery({ + id: 'heightmap', + }) + .addQuery({ + id: 'hurt_direction', + }) + .addQuery({ + id: 'hurt_time', + }) + .addQuery({ + id: 'in_range', + arguments: ['value', 'min', 'max'], + }) + .addQuery({ + id: 'invulnerable_ticks', + }) + .addQuery({ + id: 'is_admiring', + }) + .addQuery({ + id: 'is_alive', + }) + .addQuery({ + id: 'is_angry', + }) + .addQuery({ + id: 'is_attached_to_entity', + }) + .addQuery({ + id: 'is_avoiding_block', + }) + .addQuery({ + id: 'is_avoiding_mobs', + }) + .addQuery({ + id: 'is_baby', + }) + .addQuery({ + id: 'is_breathing', + }) + .addQuery({ + id: 'is_bribed', + }) + .addQuery({ + id: 'is_carrying_block', + }) + .addQuery({ + id: 'is_casting', + }) + .addQuery({ + id: 'is_celebrating', + }) + .addQuery({ + id: 'is_celebrating_special', + }) + .addQuery({ + id: 'is_charged', + }) + .addQuery({ + id: 'is_charging', + }) + .addQuery({ + id: 'is_chested', + }) + .addQuery({ + id: 'is_critical', + }) + .addQuery({ + id: 'is_croaking', + }) + .addQuery({ + id: 'is_dancing', + }) + .addQuery({ + id: 'is_delayed_attacking', + }) + .addQuery({ + id: 'is_digging', + }) + .addQuery({ + id: 'is_eating', + }) + .addQuery({ + id: 'is_eating_mob', + }) + .addQuery({ + id: 'is_elder', + }) + .addQuery({ + id: 'is_emerging', + }) + .addQuery({ + id: 'is_emoting', + }) + .addQuery({ + id: 'is_enchanted', + }) + .addQuery({ + id: 'is_fire_immune', + }) + .addQuery({ + id: 'is_first_person', + }) + .addQuery({ + id: 'is_ghost', + }) + .addQuery({ + id: 'is_gliding', + }) + .addQuery({ + id: 'is_grazing', + }) + .addQuery({ + id: 'is_idling', + }) + .addQuery({ + id: 'is_ignited', + }) + .addQuery({ + id: 'is_illager_captain', + }) + .addQuery({ + id: 'is_in_contact_with_water', + }) + .addQuery({ + id: 'is_in_love', + }) + .addQuery({ + id: 'is_in_ui', + }) + .addQuery({ + id: 'is_in_water', + }) + .addQuery({ + id: 'is_in_water_or_rain', + }) + .addQuery({ + id: 'is_interested', + }) + .addQuery({ + id: 'is_invisible', + }) + .addQuery({ + id: 'is_item_equipped', + }) + .addQuery({ + id: 'is_item_name_any', + arguments: ['slotName', 'slot?', 'itemNames...'], + }) + .addQuery({ + id: 'is_jump_goal_jumping', + }) + .addQuery({ + id: 'is_jumping', + }) + .addQuery({ + id: 'is_laying_down', + }) + .addQuery({ + id: 'is_laying_egg', + }) + .addQuery({ + id: 'is_leashed', + }) + .addQuery({ + id: 'is_levitating', + }) + .addQuery({ + id: 'is_lingering', + }) + .addQuery({ + id: 'is_moving', + }) + .addQuery({ + id: 'is_name_any', + }) + .addQuery({ + id: 'is_on_fire', + }) + .addQuery({ + id: 'is_on_ground', + }) + .addQuery({ + id: 'is_on_screen', + }) + .addQuery({ + id: 'is_onfire', + }) + .addQuery({ + id: 'is_orphaned', + }) + .addQuery({ + id: 'is_owner_identifier_any', + arguments: ['identifiers...'], + }) + .addQuery({ + id: 'is_persona_or_premium_skin', + }) + .addQuery({ + id: 'is_playing_dead', + }) + .addQuery({ + id: 'is_powered', + }) + .addQuery({ + id: 'is_pregnant', + }) + .addQuery({ + id: 'is_ram_attacking', + }) + .addQuery({ + id: 'is_resting', + }) + .addQuery({ + id: 'is_riding', + }) + .addQuery({ + id: 'is_roaring', + }) + .addQuery({ + id: 'is_rolling', + }) + .addQuery({ + id: 'is_saddled', + }) + .addQuery({ + id: 'is_scared', + }) + .addQuery({ + id: 'is_selected_item', + }) + .addQuery({ + id: 'is_shaking', + }) + .addQuery({ + id: 'is_shaking_wetness', + }) + .addQuery({ + id: 'is_sheared', + }) + .addQuery({ + id: 'is_shield_powered', + }) + .addQuery({ + id: 'is_silent', + }) + .addQuery({ + id: 'is_sitting', + }) + .addQuery({ + id: 'is_sleeping', + }) + .addQuery({ + id: 'is_sneaking', + }) + .addQuery({ + id: 'is_sneezing', + }) + .addQuery({ + id: 'is_sniffing', + }) + .addQuery({ + id: 'is_sonic_boom', + }) + .addQuery({ + id: 'is_spectator', + }) + .addQuery({ + id: 'is_sprinting', + }) + .addQuery({ + id: 'is_stackable', + }) + .addQuery({ + id: 'is_stalking', + }) + .addQuery({ + id: 'is_standing', + }) + .addQuery({ + id: 'is_stunned', + }) + .addQuery({ + id: 'is_swimming', + }) + .addQuery({ + id: 'is_tamed', + }) + .addQuery({ + id: 'is_transforming', + }) + .addQuery({ + id: 'is_using_item', + }) + .addQuery({ + id: 'is_wall_climbing', + }) + .addQuery({ + id: 'item_in_use_duration', + }) + .addQuery({ + id: 'item_is_charged', + }) + .addQuery({ + id: 'item_max_use_duration', + }) + .addQuery({ + id: 'item_remaining_use_duration', + }) + .addQuery({ + id: 'item_slot_to_bone_name', + arguments: ['slotName'], + }) + .addQuery({ + id: 'key_frame_lerp_time', + }) + .addQuery({ + id: 'last_frame_time', + }) + .addQuery({ + id: 'last_hit_by_player', + }) + .addQuery({ + id: 'lie_amount', + }) + .addQuery({ + id: 'life_span', + }) + .addQuery({ + id: 'lod_index', + }) + .addQuery({ + id: 'log', + }) + .addQuery({ + id: 'main_hand_item_max_duration', + }) + .addQuery({ + id: 'main_hand_item_use_duration', + }) + .addQuery({ + id: 'max_durability', + }) + .addQuery({ + id: 'max_health', + }) + .addQuery({ + id: 'max_trade_tier', + }) + .addQuery({ + id: 'maximum_frame_time', + }) + .addQuery({ + id: 'minimum_frame_time', + }) + .addQuery({ + id: 'model_scale', + }) + .addQuery({ + id: 'modified_distance_moved', + }) + .addQuery({ + id: 'modified_move_speed', + }) + .addQuery({ + id: 'moon_brightness', + }) + .addQuery({ + id: 'moon_phase', + }) + .addQuery({ + id: 'movement_direction', + }) + .addQuery({ + id: 'noise', + }) + .addQuery({ + id: 'on_fire_time', + }) + .addQuery({ + id: 'out_of_control', + }) + .addQuery({ + id: 'player_level', + }) + .addQuery({ + id: 'position', + arguments: ['axis'], + }) + .addQuery({ + id: 'position_delta', + arguments: ['axis'], + }) + .addQuery({ + id: 'previous_squish_value', + }) + .addQuery({ + id: 'remaining_durability', + }) + .addQuery({ + id: 'roll_counter', + }) + .addQuery({ + id: 'rotation_to_camera', + arguments: ['axis'], + }) + .addQuery({ + id: 'shake_angle', + }) + .addQuery({ + id: 'shake_time', + }) + .addQuery({ + id: 'shield_blocking_bob', + }) + .addQuery({ + id: 'show_bottom', + }) + .addQuery({ + id: 'sit_amount', + }) + .addQuery({ + id: 'sleep_rotation', + }) + .addQuery({ + id: 'sneeze_counter', + }) + .addQuery({ + id: 'spellcolor', + }) + .addQuery({ + id: 'standing_scale', + }) + .addQuery({ + id: 'structural_integrity', + }) + .addQuery({ + id: 'surface_particle_color', + }) + .addQuery({ + id: 'surface_particle_texture_coordinate', + }) + .addQuery({ + id: 'surface_particle_texture_size', + }) + .addQuery({ + id: 'swell_amount', + }) + .addQuery({ + id: 'swelling_dir', + }) + .addQuery({ + id: 'swim_amount', + }) + .addQuery({ + id: 'tail_angle', + }) + .addQuery({ + id: 'target_x_rotation', + }) + .addQuery({ + id: 'target_y_rotation', + }) + .addQuery({ + id: 'texture_frame_index', + }) + .addQuery({ + id: 'time_of_day', + }) + .addQuery({ + id: 'time_since_last_vibration_detection', + }) + .addQuery({ + id: 'time_stamp', + }) + .addQuery({ + id: 'total_emitter_count', + }) + .addQuery({ + id: 'total_particle_count', + }) + .addQuery({ + id: 'trade_tier', + }) + .addQuery({ + id: 'unhappy_counter', + }) + .addQuery({ + id: 'walk_distance', + }) + .addQuery({ + id: 'wing_flap_position', + }) + .addQuery({ + id: 'wing_flap_speed', + }) + ) + .addNamespace( + new MolangAutocomplete.Namespace({ + id: 'math', + shorthand: 'm', + }) + .addQuery({ + id: 'sin', + arguments: ['value'], + }) + .addQuery({ + id: 'cos', + arguments: ['value'], + }) + .addQuery({ + id: 'abs', + arguments: ['value'], + }) + .addQuery({ + id: 'clamp', + arguments: ['value', 'min', 'max'], + }) + .addQuery({ + id: 'pow', + arguments: ['base', 'exponent'], + }) + .addQuery({ + id: 'sqrt', + arguments: ['value'], + }) + .addQuery({ + id: 'random', + arguments: ['low', 'high'], + }) + .addQuery({ + id: 'random_integer', + arguments: ['low', 'high'], + }) + .addQuery({ + id: 'ceil', + arguments: ['value'], + }) + .addQuery({ + id: 'round', + arguments: ['value'], + }) + .addQuery({ + id: 'trunc', + arguments: ['value'], + }) + .addQuery({ + id: 'floor', + arguments: ['value'], + }) + .addQuery({ + id: 'mod', + arguments: ['value', 'denominator'], + }) + .addQuery({ + id: 'min', + arguments: ['a', 'b'], + }) + .addQuery({ + id: 'max', + arguments: ['a', 'b'], + }) + .addQuery({ + id: 'exp', + arguments: ['value'], + }) + .addQuery({ + id: 'ln', + arguments: ['value'], + }) + .addQuery({ + id: 'lerp', + arguments: ['start', 'end', '0_to_1'], + }) + .addQuery({ + id: 'lerprotate', + arguments: ['start', 'end', '0_to_1'], + }) + .addQuery({ + id: 'pi', + }) + .addQuery({ + id: 'asin', + arguments: ['value'], + }) + .addQuery({ + id: 'acos', + arguments: ['value'], + }) + .addQuery({ + id: 'atan', + arguments: ['value'], + }) + .addQuery({ + id: 'atan2', + arguments: ['y', 'x'], + }) + .addQuery({ + id: 'die_roll', + arguments: ['num', 'low', 'high'], + }) + .addQuery({ + id: 'die_roll_integer', + arguments: ['num', 'low', 'high'], + }) + .addQuery({ + id: 'hermite_blend', + arguments: ['0_to_1'], + }) + ) + .addNamespace( + new MolangAutocomplete.Namespace({ + id: 'context', + shorthand: 'c', + }) + .addQuery({ + id: 'item_slot', + }) + .addQuery({ + id: 'block_face', + }) + .addQuery({ + id: 'cardinal_block_face_placed_on', + }) + .addQuery({ + id: 'is_first_person', + }) + .addQuery({ + id: 'owning_entity', + }) + .addQuery({ + id: 'player_offhand_arm_height', + }) + .addQuery({ + id: 'other', + }) + .addQuery({ + id: 'count', + }) + ) + .addNamespace( + new MolangAutocomplete.Namespace({ + id: 'variable', + shorthand: 'v', + }) + .addQuery({ + id: 'attack_time', + }) + .addQuery({ + id: 'is_first_person', + }) + .addQueryGetter('variables', (_, incomplete) => { + const variables = getProjectMolangVariables([incomplete]) + return [...variables].map((v) => ({ id: v })) + }) + ) + .addNamespace( + new MolangAutocomplete.Namespace({ + id: 'temp', + shorthand: 't', + }).addQueryGetter('temporary_variables', (expression, incomplete) => { + const variables = getTemporaryMolangVariables(expression, [incomplete]) + return [...variables].map((v) => ({ id: v })) + }) + ) + + MolangAutocomplete.KeyframeContext = new MolangAutocomplete.Context({ + id: 'keyframeContext', + inheritedContext: MolangAutocomplete.DefaultContext, + }) + + MolangAutocomplete.AnimationControllerContext = new MolangAutocomplete.Context({ + id: 'animationControllerContext', + inheritedContext: MolangAutocomplete.DefaultContext, + }).addNamespace( + new MolangAutocomplete.Namespace({ + id: 'query', + shorthand: 'q', + }) + .addQuery({ + id: 'all_animations_finished', + priority: 100, + }) + .addQuery({ + id: 'any_animation_finished', + priority: 100, + }) + ) + + MolangAutocomplete.AnimationContext = new MolangAutocomplete.Context({ + id: 'animationContext', + inheritedContext: MolangAutocomplete.DefaultContext, + }) + + MolangAutocomplete.VariablePlaceholdersContext = new MolangAutocomplete.Context({ + id: 'variablePlaceholdersContext', + inheritedContext: MolangAutocomplete.DefaultContext, + }) + .addRootToken({ + id: 'toggle', + arguments: ['name'], + }) + .addRootToken({ + id: 'slider', + arguments: ['name', 'step?', 'min?', 'max?'], + }) + .addRootToken({ + id: 'impulse', + arguments: ['name', 'duration'], + }) + + MolangAutocomplete.BedrockBindingContext = new MolangAutocomplete.Context({ + id: 'bedrockBindingContext', + inheritedContext: MolangAutocomplete.DefaultContext, + }) +})() diff --git a/js/animations/timeline.js b/js/animations/timeline.js index af5c34815..d5805e4ee 100644 --- a/js/animations/timeline.js +++ b/js/animations/timeline.js @@ -69,6 +69,7 @@ const Timeline = { get second() {return Timeline.time}, get animation_length() {return Animation.selected ? Animation.selected.length : 0;}, playing: false, + custom_range: [0, 0], graph_editor_limit: 10_000, selector: { start: [0, 0], @@ -535,9 +536,9 @@ const Timeline = { } Timeline.playing = true BarItems.play_animation.setIcon('pause') - Timeline.last_frame_timecode = Date.now(); + Timeline.last_frame_timecode = performance.now(); if (Animation.selected.loop == 'hold' && Timeline.time >= (Animation.selected.length||1e3)) { - Timeline.setTime(0) + Timeline.setTime(Timeline.custom_range[0]) } if (Timeline.time > 0) { Animator.animations.forEach(animation => { @@ -553,6 +554,7 @@ const Timeline = { if (!Animation.selected) return; let max_length = Animation.selected.length || 1e3; + let max_time = Timeline.custom_range[1] || max_length; let new_time; if (Animation.selected && Animation.selected.anim_time_update) { new_time = Animator.MolangParser.parse(Animation.selected.anim_time_update); @@ -562,21 +564,21 @@ const Timeline = { } let time = Timeline.time + (new_time - Timeline.time) * (Timeline.playback_speed/100) if (Animation.selected.loop == 'hold') { - time = Math.clamp(time, 0, Animation.selected.length); + time = Math.clamp(time, Timeline.custom_range[0], max_time); } - Timeline.last_frame_timecode = Date.now(); + Timeline.last_frame_timecode = performance.now(); - if (time < max_length) { + if (time < max_time) { Timeline.setTime(time); } else { if (Animation.selected.loop == 'loop' || BarItems.looped_animation_playback.value) { - Timeline.setTime(0) + Timeline.setTime(Timeline.custom_range[0]); } else if (Animation.selected.loop == 'once') { - Timeline.setTime(0) + Timeline.setTime(Timeline.custom_range[0]); Animator.preview() Timeline.pause() } else if (Animation.selected.loop == 'hold') { - Timeline.setTime(max_length); + Timeline.setTime(max_time); Timeline.pause() } } @@ -657,6 +659,9 @@ const Timeline = { 'looped_animation_playback', 'jump_to_timeline_start', 'jump_to_timeline_end', + 'set_timeline_range_start', + 'set_timeline_range_end', + 'disable_timeline_range', new MenuSeparator('copypaste'), 'paste', 'apply_animation_preset', @@ -726,10 +731,11 @@ Interface.definePanels(() => { animation_length: 0, scroll_left: 0, scroll_top: 0, - head_width: Interface.data.timeline_head, + head_width: Blockbench.isMobile ? 108 : Interface.data.timeline_head, timecodes: [], animators: Timeline.animators, markers: [], + custom_range: Timeline.custom_range, waveforms: Timeline.waveforms, focus_channel: null, playhead: Timeline.time, @@ -1473,6 +1479,9 @@ Interface.definePanels(() => {
+
{{ t.text }}
@@ -1797,7 +1806,8 @@ BARS.defineActions(function() { click: function () { let was_playing = Timeline.playing; if (Timeline.playing) Timeline.pause(); - Timeline.setTime(0); + let time = Timeline.custom_range[0] || 0; + Timeline.setTime(time); if (was_playing) { Timeline.start(); } else { @@ -1814,7 +1824,8 @@ BARS.defineActions(function() { click: function () { let was_playing = Timeline.playing; if (Timeline.playing) Timeline.pause(); - Timeline.setTime(Animation.selected ? Animation.selected.length : 0) + let time = Timeline.custom_range[1] || (Animation.selected ? Animation.selected.length : 0); + Timeline.setTime(time); if (was_playing) { Timeline.start(); } else { @@ -1852,6 +1863,34 @@ BARS.defineActions(function() { } } }) + new Action('set_timeline_range_start', { + icon: 'logout', + category: 'animation', + condition: {modes: ['animate']}, + click() { + Timeline.custom_range.set(0, Timeline.time); + BARS.updateConditions(); + } + }) + new Action('set_timeline_range_end', { + icon: 'login', + category: 'animation', + condition: {modes: ['animate']}, + click() { + Timeline.custom_range.set(1, Timeline.time); + BARS.updateConditions(); + } + }) + new Action('disable_timeline_range', { + icon: 'code_off', + category: 'animation', + condition: {modes: ['animate']}, + condition: () => Timeline.custom_range[0] || Timeline.custom_range[1], + click() { + Timeline.custom_range.replace([0, 0]); + BARS.updateConditions(); + } + }) new Action('bring_up_all_animations', { icon: 'fa-sort-amount-up', diff --git a/js/boot_loader.js b/js/boot_loader.js index 45729aff1..59ca14458 100644 --- a/js/boot_loader.js +++ b/js/boot_loader.js @@ -51,7 +51,7 @@ Settings.setupProfiles(); console.log(`Three.js r${THREE.REVISION}`) console.log('%cBlockbench ' + appVersion + (isApp - ? (' Desktop (' + Blockbench.operating_system +')') + ? (' Desktop (' + Blockbench.operating_system + ', ' + process.arch +')') : (' Web ('+capitalizeFirstLetter(Blockbench.browser) + (Blockbench.isPWA ? ', PWA)' : ')'))), 'border: 2px solid #3e90ff; padding: 4px 8px; font-size: 1.2em;' ) diff --git a/js/copy_paste.js b/js/copy_paste.js index e12a54df5..3201134fc 100644 --- a/js/copy_paste.js +++ b/js/copy_paste.js @@ -433,7 +433,15 @@ BARS.defineActions(function() { category: 'edit', work_in_dialog: true, condition: () => Clipbench.getCopyType(1, true) || SharedActions.condition('copy'), - keybind: new Keybind({key: 'c', ctrl: true, shift: null}), + keybind: new Keybind({key: 'c', ctrl: true}, { + multiple: 'shift' + }), + variations: { + multiple: { + name: 'action.copy.multiple', + description: 'action.copy.multiple.desc', + } + }, click(event) { Clipbench.copy(event) } @@ -443,7 +451,15 @@ BARS.defineActions(function() { category: 'edit', work_in_dialog: true, condition: () => Clipbench.getCopyType(1, true) || SharedActions.condition('copy'), - keybind: new Keybind({key: 'x', ctrl: true, shift: null}), + keybind: new Keybind({key: 'x', ctrl: true}, { + multiple: 'shift' + }), + variations: { + multiple: { + name: 'action.copy.multiple', + description: 'action.copy.multiple.desc', + } + }, click(event) { Clipbench.copy(event, true) } @@ -453,7 +469,14 @@ BARS.defineActions(function() { category: 'edit', work_in_dialog: true, condition: () => Clipbench.getCopyType(2, true) || SharedActions.condition('paste'), - keybind: new Keybind({key: 'v', ctrl: true, shift: null}), + keybind: new Keybind({key: 'v', ctrl: true}, { + multiple: 'shift' + }), + variations: { + multiple: { + name: 'action.paste.multiple', + } + }, click(event) { Clipbench.paste(event) } diff --git a/js/desktop.js b/js/desktop.js index 079031a76..3b8cbedb6 100644 --- a/js/desktop.js +++ b/js/desktop.js @@ -323,6 +323,11 @@ currentwindow.on('ready-to-show', e => updateWindowState(e, 'load')); //Image Editor function changeImageEditor(texture, not_found) { + let app_file_extension = { + 'win32': ['exe'], + 'linux': [], + 'darwin': ['app'], + }; new Dialog({ title: tl('message.image_editor.title'), id: 'image_editor', @@ -331,7 +336,7 @@ function changeImageEditor(texture, not_found) { editor: {type: 'select', full_width: true, options: { ps: Blockbench.platform == 'win32' ? 'Photoshop' : undefined, gimp: 'GIMP', - pdn: Blockbench.platform == 'win324' ? 'Paint.NET' : undefined, + pdn: Blockbench.platform == 'win32' ? 'Paint.NET' : undefined, other: 'message.image_editor.file' }}, file: { @@ -339,7 +344,7 @@ function changeImageEditor(texture, not_found) { nocolon: true, type: 'file', file_type: 'Program', - extensions: ['exe', 'app', 'desktop', 'appimage'], + extensions: app_file_extension[Blockbench.platform], description: 'message.image_editor.exe', condition: result => result.editor == 'other' } diff --git a/js/display_mode.js b/js/display_mode.js index 9784fa0c3..cf4c43891 100644 --- a/js/display_mode.js +++ b/js/display_mode.js @@ -16,6 +16,8 @@ class DisplaySlot { this.rotation = [0, 0, 0]; this.translation = [0, 0, 0]; this.scale = [1, 1, 1]; + this.rotation_pivot = [0, 0, 0]; + this.scale_pivot = [0, 0, 0]; this.mirror = [false, false, false] return this; } @@ -24,22 +26,27 @@ class DisplaySlot { rotation: this.rotation.slice(), translation: this.translation.slice(), scale: this.scale.slice(), + rotation_pivot: this.rotation_pivot.slice(), + scale_pivot: this.scale_pivot.slice(), mirror: this.mirror.slice() } } export() { - var build = {} - if (!this.rotation.allEqual(0)) build.rotation = this.rotation - if (!this.translation.allEqual(0)) build.translation = this.translation - if (!this.scale.allEqual(1) || !this.mirror.allEqual(false)) { + let build = {}; + let export_all = Format.id == 'bedrock_block'; + if (export_all || !this.rotation.allEqual(0)) build.rotation = this.rotation + if (export_all || !this.translation.allEqual(0)) build.translation = this.translation + if (export_all || !this.scale.allEqual(1) || !this.mirror.allEqual(false)) { build.scale = this.scale.slice() if (!this.mirror.allEqual(false)) { - for (var i = 0; i < 3; i++) { + for (let i = 0; i < 3; i++) { build.scale[i] *= this.mirror[i] ? -1 : 1; } } } + if (export_all || !this.rotation_pivot.allEqual(0)) build.rotation_pivot = this.rotation_pivot + if (export_all || !this.scale_pivot.allEqual(0)) build.scale_pivot = this.scale_pivot if (Object.keys(build).length) { return build; } @@ -48,9 +55,11 @@ class DisplaySlot { if (!data) return this; for (var i = 0; i < 3; i++) { if (data.rotation) Merge.number(this.rotation, data.rotation, i) - if (data.translation) Merge.number(this.translation, data.translation, i) if (data.mirror) Merge.boolean(this.mirror, data.mirror, i) if (data.scale) Merge.number(this.scale, data.scale, i) + if (data.translation) Merge.number(this.translation, data.translation, i) + if (data.rotation_pivot) Merge.number(this.rotation_pivot, data.rotation_pivot, i) + if (data.scale_pivot) Merge.number(this.scale_pivot, data.scale_pivot, i) this.scale[i] = Math.abs(this.scale[i]) if (data.scale && data.scale[i] < 0) this.mirror[i] = true; } @@ -225,6 +234,7 @@ class refModel { this.name = tl('display.reference.'+id); this.id = id; this.icon = options.icon || id; + this.condition = options.condition; this.initialized = false; this.pose_angles = {}; @@ -283,6 +293,11 @@ class refModel { } } break; + case 'fox': + this.updateBasePosition = function() { + setDisplayArea(0, 0, -6, 90, 180, 0, 1, 1, 1); + } + break; case 'zombie': this.updateBasePosition = function() { if (display_slot === 'thirdperson_righthand') { @@ -343,6 +358,16 @@ class refModel { setDisplayArea(side*-1.2, -6.75, 23, 0, side*10, 0, 1, 1, 1) } break; + + case 'eating': + this.updateBasePosition = function() { + var side = display_slot.includes('left') ? -1 : 1; + DisplayMode.setBase( + side*-1.7, -6.1, 23.4, + -92, side*100, side*119, + 0.8, 0.8, 0.8) + } + break; } } buildModel(things, texture, texture_res = [16, 16]) { @@ -454,9 +479,11 @@ class refModel { case 'armor_stand': this.buildArmorStand(); break; case 'baby_zombie': this.buildBabyZombie(); break; case 'armor_stand_small': this.buildArmorStandSmall(); break; + case 'fox': this.buildFox(); break; + case 'crossbow': + case 'bow': + case 'eating': case 'monitor': this.buildMonitor(); break; - case 'bow': this.buildMonitor(); break; - case 'crossbow': this.buildMonitor(); break; case 'block': this.buildBlock(); break; case 'frame': this.buildFrame(); break; case 'frame_invisible': this.buildFrameInvisible(); break; @@ -470,6 +497,10 @@ class refModel { DisplayMode.vue.pose_angle = this.pose_angles[display_slot] || 0; DisplayMode.vue.reference_model = this.id; + + if (display_slot == 'ground') { + ground_animation = this.id != 'fox'; + } ReferenceImage.updateAll() } @@ -933,6 +964,55 @@ class refModel { } ]`), 'assets/armor_stand.png', [64, 64]) } + buildFox() { + this.buildModel(JSON.parse(`[ + { + "size": [8, 6, 6], + "pos": [0, 4, 0], + "origin": [0, 0, 0], + "north": {"uv": [7, 11, 15, 17]}, + "east": {"uv": [1, 11, 7, 17]}, + "south": {"uv": [21, 11, 29, 17]}, + "west": {"uv": [15, 11, 21, 17]}, + "up": {"uv": [15, 11, 7, 5]}, + "down": {"uv": [23, 5, 15, 11]} + }, + { + "size": [4, 2, 3], + "pos": [0, 2, -4.5], + "origin": [0, 0, 0], + "north": {"uv": [9, 21, 13, 23]}, + "east": {"uv": [6, 21, 9, 23]}, + "south": {"uv": [16, 21, 20, 23]}, + "west": {"uv": [13, 21, 16, 23]}, + "up": {"uv": [13, 21, 9, 18]}, + "down": {"uv": [17, 18, 13, 21]} + }, + { + "size": [2, 2, 1], + "pos": [3, 8, -1.5], + "origin": [0, 0, 0], + "north": {"uv": [9, 2, 11, 4]}, + "east": {"uv": [8, 2, 9, 4]}, + "south": {"uv": [12, 2, 14, 4]}, + "west": {"uv": [11, 2, 12, 4]}, + "up": {"uv": [11, 2, 9, 1]}, + "down": {"uv": [13, 1, 11, 2]} + }, + { + "size": [2, 2, 1], + "pos": [-3, 8, -1.5], + "origin": [0, 0, 0], + "north": {"uv": [16, 2, 18, 4]}, + "east": {"uv": [15, 2, 16, 4]}, + "south": {"uv": [19, 2, 21, 4]}, + "west": {"uv": [18, 2, 19, 4]}, + "up": {"uv": [18, 2, 16, 1]}, + "down": {"uv": [20, 1, 18, 2]} + } + + ]`), 'assets/fox.png', [32, 32]) + } buildZombie() { this.buildModel(JSON.parse(`[ { @@ -1132,9 +1212,11 @@ window.displayReferenceObjects = { armor_stand: new refModel('armor_stand', {icon: 'icon-armor_stand'}), baby_zombie: new refModel('baby_zombie', {icon: 'icon-baby_zombie'}), armor_stand_small: new refModel('armor_stand_small', {icon: 'icon-armor_stand_small'}), + fox: new refModel('fox', {icon: 'pets', condition: {formats: ['java_block']}}), monitor: new refModel('monitor', {icon: 'fa-asterisk'}), bow: new refModel('bow', {icon: 'icon-bow'}), crossbow: new refModel('crossbow', {icon: 'icon-crossbow'}), + eating: new refModel('eating', {icon: 'fa-apple-whole'}), block: new refModel('block', {icon: 'fa-cube'}), frame: new refModel('frame', {icon: 'filter_frames'}), frame_invisible: new refModel('frame_invisible', {icon: 'visibility_off'}), @@ -1146,11 +1228,8 @@ window.displayReferenceObjects = { }, active: '', bar: function(buttons) { - $('#display_ref_bar').html('') - if (buttons.length === 10000) { - this.refmodels[buttons[0]].load() - return; - } + buttons = buttons.filter(id => Condition(this.refmodels[id])); + $('#display_ref_bar').html(''); if (buttons.length < 2) { $('.reference_model_bar').css('visibility', 'hidden') } else { @@ -1229,7 +1308,10 @@ enterDisplaySettings = function() { //Enterung Display Setting Mode, changes th Canvas.updateShading() scene.add(display_area); - if (Project.model_3d) Project.model_3d.position.copy(Canvas.scene.position); + if (Project.model_3d) { + Project.model_3d.position.copy(Canvas.scene.position); + Project.model_3d.position.y = -8; + } scene.position.set(0, 0, 0); resizeWindow() //Update panels and sidebars so that the camera can be loaded with the correct aspect ratio @@ -1250,13 +1332,14 @@ exitDisplaySettings = function() { //Enterung Display Setting Mode, changes the Canvas.global_light_side = 0; Canvas.updateShading(); scene.remove(display_area) - if (!Format.centered_grid) scene.position.set(-8, -8, -8); + if (!Format.centered_grid) scene.position.set(-8, 0, -8); display_base.children.forEachReverse(child => { display_base.remove(child); child.position.set(0, 0, 0); }) if (Project.model_3d) { scene.add(Project.model_3d); + Project.model_3d.position.set(0, 0, 0); } display_mode = false; @@ -1299,13 +1382,29 @@ DisplayMode.updateDisplayBase = function(slot) { display_base.scale.y = (slot.scale[1]||0.001) * (slot.mirror[1] ? -1 : 1); display_base.scale.z = (slot.scale[2]||0.001) * (slot.mirror[2] ? -1 : 1); + if (!slot.rotation_pivot.allEqual(0)) { + let rot_piv_offset = new THREE.Vector3().fromArray(slot.rotation_pivot).multiplyScalar(16); + let original = new THREE.Vector3().copy(rot_piv_offset); + rot_piv_offset.applyEuler(display_base.rotation); + rot_piv_offset.sub(original); + display_base.position.sub(rot_piv_offset); + } + if (!slot.scale_pivot.allEqual(0)) { + let scale_piv_offset = new THREE.Vector3().fromArray(slot.scale_pivot).multiplyScalar(16); + scale_piv_offset.applyEuler(display_base.rotation); + scale_piv_offset.x *= (1-slot.scale[0]); + scale_piv_offset.y *= (1-slot.scale[1]); + scale_piv_offset.z *= (1-slot.scale[2]); + display_base.position.add(scale_piv_offset) + } + Transformer.center() } DisplayMode.applyPreset = function(preset, all) { if (preset == undefined) return; - var slots = [display_slot] + var slots = [display_slot]; if (all) { slots = displayReferenceObjects.slots } else if (preset.areas[display_slot] == undefined) { @@ -1317,7 +1416,12 @@ DisplayMode.applyPreset = function(preset, all) { if (!Project.display_settings[sl]) { Project.display_settings[sl] = new DisplaySlot() } - Project.display_settings[sl].extend(preset.areas[sl]) + let preset_values = preset.areas[sl]; + if (preset_values) { + if (!preset_values.rotation_pivot) Project.display_settings[sl].rotation_pivot.replace([0, 0, 0]); + if (!preset_values.scale_pivot) Project.display_settings[sl].scale_pivot.replace([0, 0, 0]); + Project.display_settings[sl].extend(preset.areas[sl]); + } }) DisplayMode.updateDisplayBase() Undo.finishEdit('Apply display preset') @@ -1349,14 +1453,21 @@ var setDisplayArea = DisplayMode.setBase = function(x, y, z, rx, ry, rz, sx, sy, } DisplayMode.groundAnimation = function() { display_area.rotation.y += 0.015 - ground_timer += 1 - display_area.position.y = 5.5 + Math.sin(Math.PI * (ground_timer / 100)) * Math.PI/2 + ground_timer += 1; + let ground_offset = 3.8; + if (Format.id != 'bedrock_block') { + ground_offset = 1.9 + display_base.scale.y * 3.6; + } + display_area.position.y = ground_offset + Math.sin(Math.PI * (ground_timer / 100)) * Math.PI/2 Transformer.center() if (ground_timer === 200) ground_timer = 0; } DisplayMode.updateGUILight = function() { if (!Modes.display) return; - if (display_slot == 'gui' && Project.front_gui_light == true) { + if (Format.id == 'bedrock_block') { + Canvas.global_light_side = 0; + Canvas.updateShading(); + } else if (display_slot == 'gui' && Project.front_gui_light == true) { lights.rotation.set(-Math.PI, 0.6, 0); Canvas.global_light_side = 4; } else { @@ -1425,7 +1536,7 @@ DisplayMode.loadFirstRight = function() { //Loader }) display_preview.controls.enabled = false if (display_preview.orbit_gizmo) display_preview.orbit_gizmo.hide(); - displayReferenceObjects.bar(['monitor', 'bow', 'crossbow']) + displayReferenceObjects.bar(['monitor', 'bow', 'crossbow', 'eating']); $('.single_canvas_wrapper').append('
') } DisplayMode.loadFirstLeft = function() { //Loader @@ -1437,7 +1548,7 @@ DisplayMode.loadFirstLeft = function() { //Loader }) display_preview.controls.enabled = false if (display_preview.orbit_gizmo) display_preview.orbit_gizmo.hide(); - displayReferenceObjects.bar(['monitor', 'bow', 'crossbow']) + displayReferenceObjects.bar(['monitor', 'bow', 'crossbow', 'eating']); $('.single_canvas_wrapper').append('
') } DisplayMode.loadHead = function() { //Loader @@ -1472,7 +1583,7 @@ DisplayMode.loadGround = function() { //Loader setDisplayArea(8, 4, 8, 0, 0, 0, 1, 1, 1) ground_animation = true; ground_timer = 0 - displayReferenceObjects.bar(['block']) + displayReferenceObjects.bar(['block', 'fox']) } DisplayMode.loadFixed = function() { //Loader loadDisp('fixed') @@ -1653,6 +1764,21 @@ function updateDisplaySkin(feedback) { } DisplayMode.updateDisplaySkin = updateDisplaySkin; +DisplayMode.debugBase = function() { + new Dialog('display_base_debug', { + title: 'Debug Display Base', + darken: false, + form: { + translation: {type: 'vector', dimensions: 3, step: 0.1, value: [0, 0, 0], label: 'Translation'}, + rotation: {type: 'vector', dimensions: 3, step: 0.5, value: [0, 0, 0], label: 'Rotation'}, + scale: {type: 'vector', dimensions: 3, step: 0.05, value: [1, 1, 1], label: 'Scale'}, + }, + onFormChange(result) { + DisplayMode.setBase(...result.translation, ...result.rotation, ...result.scale) + } + }).show(); +} + BARS.defineActions(function() { new Action('add_display_preset', { icon: 'add', @@ -1748,7 +1874,7 @@ BARS.defineActions(function() { side: true, front: true, }, - condition: () => Modes.display && display_slot === 'gui', + condition: () => Modes.display && display_slot === 'gui' && Format.id == 'java_block', onChange: function(slider) { Project.front_gui_light = slider.get() == 'front'; DisplayMode.updateGUILight(); @@ -1795,6 +1921,15 @@ Interface.definePanels(function() { } }, methods: { + allowMirroring() { + return this.allow_mirroring && !this.isBedrockStyle(); + }, + allowEnablingMirroring() { + return Format.id != 'bedrock_block'; + }, + isBedrockStyle() { + return Format.id == 'bedrock_block'; + }, isMirrored: (axis) => { if (Project.display_settings[display_slot]) { return Project.display_settings[display_slot].scale[axis] < 0; @@ -1822,8 +1957,10 @@ Interface.definePanels(function() { } } else if (channel === 'translation') { DisplayMode.slot.translation[axis] = limitNumber(DisplayMode.slot.translation[axis], -80, 80)||0; - } else { + } else if (channel == 'rotation') { DisplayMode.slot.rotation[axis] = Math.trimDeg(DisplayMode.slot.rotation[axis])||0; + } else { + DisplayMode.slot[channel][axis] = DisplayMode.slot[channel][axis] ?? 0; } DisplayMode.updateDisplayBase() }, @@ -1914,11 +2051,11 @@ Interface.definePanels(function() {

${ tl('display.scale') }

-
flip
-
replay
+
flip
+
replay
-
+
${ tl('display.mirror') }
{{ slot.mirror[axis] ? 'check_box' : 'check_box_outline_blank' }}
@@ -1940,6 +2077,40 @@ Interface.definePanels(function() {
+ +
` diff --git a/js/interface/about.js b/js/interface/about.js index 143a71e86..c143bb9af 100644 --- a/js/interface/about.js +++ b/js/interface/about.js @@ -116,6 +116,7 @@ BARS.defineActions(() => {
  • jQuery UI
  • jQuery UI Touch Punch
  • FileSaver.js
  • +
  • easing-utils
  • PeerJS
  • Marked
  • DOMPurify
  • diff --git a/js/interface/actions.js b/js/interface/actions.js index 6bc9b710d..ad59a328a 100644 --- a/js/interface/actions.js +++ b/js/interface/actions.js @@ -38,12 +38,13 @@ class BarItem extends EventSystem { if (data.keybind) { this.default_keybind = data.keybind } - this.keybind = new Keybind() + this.keybind = new Keybind(null, this.default_keybind?.variations); if (Keybinds.stored[this.id]) { this.keybind.set(Keybinds.stored[this.id], this.default_keybind); } else { this.keybind.set(data.keybind); } + this.variations = data.variations; this.keybind.setAction(this.id) this.work_in_dialog = data.work_in_dialog === true this.uses = 0; @@ -219,10 +220,11 @@ class KeybindItem { this.default_keybind = data.keybind } if (Keybinds.stored[this.id]) { - this.keybind = new Keybind().set(Keybinds.stored[this.id], this.default_keybind); + this.keybind = new Keybind(null, this.default_keybind?.variations).set(Keybinds.stored[this.id], this.default_keybind); } else { - this.keybind = new Keybind().set(data.keybind); + this.keybind = new Keybind(null, this.default_keybind?.variations).set(data.keybind); } + this.variations = data.variations; Keybinds.actions.push(this) Keybinds.extra[this.id] = this; @@ -821,6 +823,25 @@ class NumSlider extends Widget { this.onAfter() } } + }, + { + id: 'round', + name: 'menu.slider.reset_vector', + icon: 'replay', + condition: this.slider_vector instanceof Array, + click: () => { + if (typeof this.onBefore === 'function') { + this.onBefore() + } + for (let slider of this.slider_vector) { + let value = slider.settings?.default ?? 0; + slider.change(n => value); + slider.update(); + } + if (typeof this.onAfter === 'function') { + this.onAfter() + } + } } ]).open(event); }); @@ -1762,7 +1783,14 @@ const BARS = { //Extras new KeybindItem('preview_select', { category: 'navigate', - keybind: new Keybind({key: Blockbench.isTouch ? 0 : 1, ctrl: null, shift: null, alt: null}) + keybind: new Keybind({key: Blockbench.isTouch ? 0 : 1}, + {multi_select: 'ctrl', group_select: 'shift', loop_select: 'alt'} + ), + variations: { + multi_select: {name: 'keybind.preview_select.multi_select'}, + group_select: {name: 'keybind.preview_select.group_select'}, + loop_select: {name: 'keybind.preview_select.loop_select'}, + } }) new KeybindItem('preview_rotate', { category: 'navigate', @@ -1892,6 +1920,31 @@ const BARS = { modes: ['edit'], keybind: new Keybind({key: 's', alt: true}), }) + new Action('randomize_marker_colors', { + icon: 'fa-shuffle', + category: 'edit', + condition: {modes: ['edit' ], project: true}, + click: function() { + let randomColor = function() { return Math.floor(Math.random() * markerColors.length)} + let elements = Outliner.selected.filter(element => element.setColor) + Undo.initEdit({outliner: true, elements: elements, selection: true}) + Group.all.forEach(group => { + if (group.selected) { + let lastColor = group.color + // Ensure chosen group color is never the same as before + do group.color = randomColor(); + while (group.color === lastColor) + } + }) + elements.forEach(element => { + let lastColor = element.color + // Ensure chosen element color is never the same as before + do element.setColor(randomColor()) + while (element.color === lastColor) + }) + Undo.finishEdit('Change marker color') + } + }) //File new Action('new_window', { @@ -2228,6 +2281,8 @@ const BARS = { 'color_erase_mode', 'lock_alpha', 'painting_grid', + 'image_tiled_view', + 'image_onion_skin_view', ] }) Toolbars.vertex_snap = new Toolbar({ diff --git a/js/interface/dialog.js b/js/interface/dialog.js index 1fe0abbdf..f5543b9f6 100644 --- a/js/interface/dialog.js +++ b/js/interface/dialog.js @@ -545,6 +545,18 @@ window.Dialog = class Dialog { this.sidebar = options.sidebar ? new DialogSidebar(options.sidebar, this) : null; this.title_menu = options.title_menu || null; + if (options.progress_bar) { + this.progress_bar = { + setProgress: (progress) => { + this.progress_bar.progress = progress; + if (this.progress_bar.node) { + this.progress_bar.node.style.setProperty('--progress', progress); + } + }, + progress: options.progress_bar.progress ?? 0, + node: null + } + } this.width = options.width this.draggable = options.draggable @@ -643,6 +655,16 @@ window.Dialog = class Dialog { } if (update) this.updateFormValues(); } + setFormToggles(values, update = true) { + for (let form_id in this.form) { + let data = this.form[form_id]; + if (values[form_id] != undefined && typeof data == 'object' && data.input_toggle && data.bar) { + data.input_toggle.checked = values[form_id]; + data.bar.toggleClass('form_toggle_disabled', !data.input_toggle.checked); + } + } + if (update) this.updateFormValues(); + } getFormResult() { let result = {} if (this.form) { @@ -798,6 +820,14 @@ window.Dialog = class Dialog { this.object.style.setProperty('--max_label_width', max_width + 'px'); } + if (this.progress_bar) { + this.progress_bar.node = Interface.createElement('div', {class: 'progress_bar'}, + Interface.createElement('div', {class: 'progress_bar_inner'}) + ) + this.progress_bar.setProgress(this.progress_bar.progress); + this.object.querySelector('content.dialog_content').append(this.progress_bar.node); + } + if (this.buttons.length) { let buttons = [] diff --git a/js/interface/interface.js b/js/interface/interface.js index 4c2f9076f..384a092c0 100644 --- a/js/interface/interface.js +++ b/js/interface/interface.js @@ -12,6 +12,7 @@ class ResizeLine { this.width = 0; this.get = data.get; this.set = data.set; + this.reset = data.reset; this.node = document.createElement('div'); this.node.className = 'resizer '+(data.horizontal ? 'horizontal' : 'vertical'); this.node.id = 'resizer_'+this.id; @@ -39,6 +40,13 @@ class ResizeLine { document.addEventListener('pointermove', move, false); document.addEventListener('pointerup', stop, false); }) + if (this.reset) { + this.node.addEventListener('dblclick', event => { + this.reset(); + updateInterface(); + this.update(); + }) + } } update() { if (BARS.condition(this.condition)) { @@ -146,9 +154,9 @@ const Interface = { }, getLeftPanels() { let list = []; - for (let key in Panels) { + for (let key of Interface.getModeData().left_bar) { let panel = Panels[key]; - if (panel.slot == 'left_bar' && Condition(panel.condition)) { + if (panel && panel.slot == 'left_bar' && Condition(panel.condition)) { list.push(panel); } } @@ -156,9 +164,9 @@ const Interface = { }, getRightPanels() { let list = []; - for (let key in Panels) { + for (let key of Interface.getModeData().right_bar) { let panel = Panels[key]; - if (panel.slot == 'right_bar' && Condition(panel.condition)) { + if (panel && panel.slot == 'right_bar' && Condition(panel.condition)) { list.push(panel); } } @@ -209,6 +217,10 @@ const Interface = { Prop.show_left_bar = true; } }, + reset() { + Interface.getModeData().left_bar_width = Interface.default_data.left_bar_width; + Prop.show_left_bar = true; + }, position() { this.setPosition({ top: 0, @@ -241,6 +253,10 @@ const Interface = { Prop.show_right_bar = true; } }, + reset() { + Interface.getModeData().right_bar_width = Interface.default_data.right_bar_width; + Prop.show_right_bar = true; + }, position() { this.setPosition({ top: 30, @@ -253,6 +269,9 @@ const Interface = { condition() {return Preview.split_screen.enabled && Preview.split_screen.mode != 'double_horizontal'}, get() {return Interface.data.quad_view_x}, set(o, diff) {Interface.data.quad_view_x = limitNumber(o + diff/Interface.preview.clientWidth*100, 5, 95)}, + reset() { + Interface.data.quad_view_x = Interface.default_data.quad_view_x; + }, position() { let p = Interface.preview; if (!p) return; @@ -274,6 +293,9 @@ const Interface = { set(o, diff) { Interface.data.quad_view_y = limitNumber(o + diff/Interface.preview.clientHeight*100, 5, 95) }, + reset() { + Interface.data.quad_view_y = Interface.default_data.quad_view_y; + }, position() { let p = Interface.preview; if (!p) return; @@ -330,13 +352,16 @@ const Interface = { }), timeline_head: new ResizeLine('timeline_head', { horizontal: false, - condition() {return Modes.animate}, + condition() {return Modes.animate && !Blockbench.isMobile}, get() {return Interface.data.timeline_head}, set(o, diff) { let value = limitNumber(o + diff, 90, Panels.timeline.node.clientWidth - 40); value = Math.snapToValues(value, [Interface.default_data.timeline_head], 12); Interface.data.timeline_head = Timeline.vue._data.head_width = value; }, + reset() { + Interface.data.timeline_head = Interface.default_data.timeline_head; + }, position() { let offset = $(Panels.timeline.vue.$el).offset(); this.setPosition({ @@ -539,8 +564,6 @@ function setupInterface() { reference.select(); }); - document.getElementById('texture_list').addEventListener('click', e => unselectTextures()); - $(Panels.timeline.node).mousedown((event) => { setActivePanel('timeline'); }) @@ -755,7 +778,7 @@ Interface.CustomElements.SelectInput = function(id, data) { } } let options = typeof data.options == 'function' ? data.options() : data.options; - let value = data.value || data.default || Object.keys(options)[0]; + let value = data.value || data.default || Object.keys(options).find(key => options[key]); let select = Interface.createElement('bb-select', {id, class: 'half', value: value}, getNameFor(options[value])); function setKey(key, options, input_event) { if (!options) { @@ -777,17 +800,16 @@ Interface.CustomElements.SelectInput = function(id, data) { let options = typeof data.options == 'function' ? data.options() : data.options; for (let key in options) { let val = options[key]; - if (val) { - items.push({ - name: getNameFor(options[key]), - icon: val.icon || ((value == key) ? 'far.fa-dot-circle' : 'far.fa-circle'), - color: val.color, - condition: val.condition, - click: (context, event) => { - setKey(key, options, event || 1); - } - }) - } + if (!val) continue; + items.push({ + name: getNameFor(options[key]), + icon: val.icon || ((value == key) ? 'far.fa-dot-circle' : 'far.fa-circle'), + color: val.color, + condition: val.condition, + click: (context, event) => { + setKey(key, options, event || 1); + } + }) } let menu = new Menu(id, items, {searchable: items.length > 16}); menu.node.style['min-width'] = select.clientWidth+'px'; diff --git a/js/interface/keyboard.js b/js/interface/keyboard.js index 569d05d75..6a3a7c6ac 100644 --- a/js/interface/keyboard.js +++ b/js/interface/keyboard.js @@ -9,7 +9,7 @@ class Keybind { * @param {boolean} keys.alt Alt key * @param {boolean} keys.meta Meta key */ - constructor(keys) { + constructor(keys, variations) { this.key = -1; this.ctrl = false; this.shift = false; @@ -27,6 +27,12 @@ class Keybind { } this.set(keys) } + if (variations) { + this.variations = {}; + for (let option in variations) { + this.variations[option] = variations[option]; + } + } } set(keys, dflt) { if (!keys || typeof keys !== 'object') return this; @@ -41,6 +47,11 @@ class Keybind { if (dflt.alt == null) this.alt = null; if (dflt.meta == null) this.meta = null; } + if (keys.variations && this.variations) { + for (let option in keys.variations) { + this.variations[option] = keys.variations[option]; + } + } this.label = this.getText() TickUpdates.keybind_conflicts = true; return this; @@ -57,7 +68,7 @@ class Keybind { } save(save) { if (this.action) { - var obj = { + let obj = { key: this.key } if (this.ctrl) obj.ctrl = true @@ -65,6 +76,13 @@ class Keybind { if (this.alt) obj.alt = true if (this.meta) obj.meta = true + if (this.variations && Object.keys(this.variations)) { + obj.variations = {}; + for (let option in this.variations) { + obj.variations[option] = this.variations[option]; + } + } + let key = this.sub_id ? (this.action + '.' + this.sub_id) : this.action; Keybinds.stored[key] = obj if (save !== false) { @@ -172,6 +190,13 @@ class Keybind { case 19: return 'pause'; case 1001: return 'mousewheel'; + case 106: return tl('keys.numpad', ['*']); + case 107: return tl('keys.numpad', ['+']); + case 108: return tl('keys.numpad', ['+']); + case 109: return tl('keys.numpad', ['-']); + case 110: return tl('keys.numpad', [',']); + case 111: return tl('keys.numpad', ['/']); + case 188: return ','; case 190: return '.'; case 189: return '-'; @@ -205,14 +230,35 @@ class Keybind { return this; } isTriggered(event) { + let modifiers_used = new Set(); + if (this.variations) { + for (let option in this.variations) { + modifiers_used.add(this.variations[option]); + } + } return ( (this.key === event.which || (this.key == 1001 && event instanceof MouseEvent)) && - (this.ctrl === (event.ctrlKey || Pressing.overrides.ctrl) || this.ctrl === null ) && - (this.shift === (event.shiftKey || Pressing.overrides.shift)|| this.shift === null ) && - (this.alt === (event.altKey || Pressing.overrides.alt) || this.alt === null ) && - (this.meta === event.metaKey || this.meta === null ) + (this.ctrl === (event.ctrlKey || Pressing.overrides.ctrl) || this.ctrl === null || modifiers_used.has('ctrl') ) && + (this.shift === (event.shiftKey || Pressing.overrides.shift)|| this.shift === null || modifiers_used.has('shift') ) && + (this.alt === (event.altKey || Pressing.overrides.alt) || this.alt === null || modifiers_used.has('alt') ) && + (this.meta === event.metaKey || this.meta === null || modifiers_used.has('ctrl') ) ) } + additionalModifierTriggered(event, variation) { + if (!this.variations) return; + for (let option in this.variations) { + if (variation && option != variation) continue; + let key = this.variations[option]; + if ( + (key == 'ctrl' && (event.ctrlOrCmd || Pressing.overrides.ctrl)) || + (key == 'shift' && (event.shiftKey || Pressing.overrides.shift)) || + (key == 'alt' && (event.altKey || Pressing.overrides.alt)) || + (key == 'meta' && (event.metaKey || Pressing.overrides.meta)) + ) { + return variation ? true : option; + } + } + } record() { var scope = this; Keybinds.recording = this; @@ -489,6 +535,12 @@ onVueSetup(function() { structure: Keybinds.structure, open_category: 'navigate', search_term: '', + modifier_options: { + ctrl: tl(Blockbench.platform == 'darwin' ? 'keys.meta' : 'keys.ctrl'), + shift: tl('keys.shift'), + alt: tl('keys.alt'), + '': '-', + } }}, methods: { record(item, sub_id) { @@ -538,7 +590,16 @@ onVueSetup(function() { }, hasSubKeybinds(item) { return item.sub_keybinds && typeof item.sub_keybinds === 'object' && Object.keys(item.sub_keybinds).length > 0; - } + }, + hasVariationConflict(keybind, variation_key) { + return keybind[keybind.variations[variation_key]]; + }, + getVariationText(action, variation) { + return tl(action.variations?.[variation]?.name, null, variation); + }, + getVariationDescription(action, variation) { + return action.variations?.[variation]?.description ? tl(action.variations[variation].description, null, '') : ''; + }, }, computed: { list() { @@ -602,6 +663,14 @@ onVueSetup(function() {
    clear
    +
      +
    • + + + warning +
    • +
    +
    • {{ sub_keybind.name }}
      @@ -641,7 +710,7 @@ window.addEventListener('blur', event => { Pressing.alt = false; Pressing.ctrl = false; if (changed) { - Blockbench.dispatchEvent('update_pressed_modifier_keys', {before, now: Pressing}); + Blockbench.dispatchEvent('update_pressed_modifier_keys', {before, now: Pressing, event}); } }) @@ -685,7 +754,7 @@ addEventListeners(document, 'keydown mousedown', function(e) { Pressing.alt = e.altKey; Pressing.ctrl = e.ctrlKey; if (modifiers_changed) { - Blockbench.dispatchEvent('update_pressed_modifier_keys', {before, now: Pressing}); + Blockbench.dispatchEvent('update_pressed_modifier_keys', {before, now: Pressing, event}); } if (e.which === 16) { @@ -811,8 +880,10 @@ addEventListeners(document, 'keydown mousedown', function(e) { for (let sub_id in action.sub_keybinds) { let sub = action.sub_keybinds[sub_id]; if (sub.keybind.isTriggered(e)) { + let value_before = action.value; sub.trigger(e) used = true; + if (action instanceof BarSelect && value_before != action.value) break; } } } @@ -933,6 +1004,6 @@ $(document).keyup(function(e) { Pressing.alt = e.altKey; Pressing.ctrl = e.ctrlKey; if (changed) { - Blockbench.dispatchEvent('update_pressed_modifier_keys', {before, now: Pressing}); + Blockbench.dispatchEvent('update_pressed_modifier_keys', {before, now: Pressing, event}); } }) diff --git a/js/interface/menu.js b/js/interface/menu.js index d843b0dd8..9762478ee 100644 --- a/js/interface/menu.js +++ b/js/interface/menu.js @@ -238,7 +238,6 @@ class Menu { let search_button = Interface.createElement('div', {}, Blockbench.getIconNode('search')); let search_bar = Interface.createElement('li', {class: 'menu_search_bar'}, [input, search_button]); menu_node.append(search_bar); - menu_node.append(Interface.createElement('li', {class: 'menu_separator'})); let object_list = []; list.forEach(function(s2, i) { @@ -477,6 +476,9 @@ class Menu { } entry = Interface.createElement('li', {title: s.description && tl(s.description), menu_item: s.id}, Interface.createElement('span', {}, tl(s.name))); entry.prepend(icon); + if (s.marked) { + entry.classList.add('marked'); + } if (s.keybind) { let label = document.createElement('label'); label.classList.add('keybinding_label') @@ -664,9 +666,14 @@ class Menu { this.structure.remove(action); this.structure.remove(action.id); action.menus.remove(this); + } else if (this.structure.includes(path)) { + this.structure.remove(path); } if (path === undefined) path = ''; - if (typeof path == 'string') path = path.split('.'); + if (typeof path == 'string') { + path = path.split('.'); + } + if (path instanceof Array == false) return; function traverse(arr, layer) { if (!isNaN(parseInt(path[layer]))) { diff --git a/js/interface/menu_bar.js b/js/interface/menu_bar.js index 7d1ce409e..67fb48b74 100644 --- a/js/interface/menu_bar.js +++ b/js/interface/menu_bar.js @@ -140,6 +140,7 @@ const MenuBar = { new MenuSeparator('project'), 'save_project', 'save_project_as', + 'save_project_incremental', 'convert_project', 'close_project', new MenuSeparator('import_export'), @@ -349,8 +350,10 @@ const MenuBar = { 'select_effect_animator', 'flip_animation', 'optimize_animation', + 'retarget_animators', 'bake_ik_animation', 'bake_animation_into_model', + 'merge_animation', new MenuSeparator('file'), 'load_animation_file', 'save_all_animations', diff --git a/js/interface/panels.js b/js/interface/panels.js index 718cb58d8..dbc12953e 100644 --- a/js/interface/panels.js +++ b/js/interface/panels.js @@ -15,6 +15,7 @@ class Panel extends EventSystem { this.growable = data.growable; this.resizable = data.resizable; + this.min_height = data.min_height ?? 60; this.onResize = data.onResize; this.onFold = data.onFold; @@ -153,6 +154,9 @@ class Panel extends EventSystem { let height_before = this.node.clientHeight; let started = false; let direction = this.node.classList.contains('bottommost_panel') ? -1 : 1; + let other_panel_height_before = {}; + + let other_panels = this.slot == 'right_bar' ? Interface.getRightPanels() : Interface.getLeftPanels(); let drag = e2 => { convertTouchEvent(e2); @@ -162,9 +166,25 @@ class Panel extends EventSystem { } if (!started) return; + let change_amount = (e2.clientY - e1.clientY) * direction; + let sidebar_gap = this.node.parentElement.clientHeight; + for (let panel of other_panels) { + sidebar_gap -= panel.node.clientHeight; + } + + let height1 = this.position_data.height; this.position_data.fixed_height = true; - this.position_data.height = height_before + (e2.clientY - e1.clientY) * direction; + this.position_data.height = Math.max(height_before + change_amount, this.min_height); this.update(); + let height_difference = this.position_data.height - height1; + + let panel_b = other_panels.find(p => p != this && p.resizable && p.min_height < p.height); + if (sidebar_gap < 1 && panel_b && change_amount > 0) { + if (!other_panel_height_before[panel_b.id]) other_panel_height_before[panel_b.id] = panel_b.height; + panel_b.position_data.fixed_height = true; + panel_b.position_data.height = Math.max(panel_b.position_data.height - height_difference, this.min_height); + panel_b.update(); + } } let stop = e2 => { convertTouchEvent(e2); @@ -641,9 +661,10 @@ class Panel extends EventSystem { let show = BARS.condition(this.condition); let work_screen = document.querySelector('div#work_screen'); let center_screen = document.querySelector('div#center'); + let slot = this.slot; if (show) { this.node.classList.remove('hidden'); - if (this.slot == 'float') { + if (slot == 'float') { if (!dragging && work_screen.clientWidth) { this.position_data.float_position[0] = Math.clamp(this.position_data.float_position[0], 0, work_screen.clientWidth - this.width); this.position_data.float_position[1] = Math.clamp(this.position_data.float_position[1], 0, work_screen.clientHeight - this.height); @@ -658,34 +679,35 @@ class Panel extends EventSystem { this.node.style.width = this.width + 'px'; this.node.style.height = this.height + 'px'; this.node.classList.remove('bottommost_panel'); + this.node.classList.remove('topmost_panel'); } else { this.node.style.width = this.node.style.height = this.node.style.left = this.node.style.top = null; } if (Blockbench.isMobile) { this.width = this.node.clientWidth; - } else if (this.slot == 'left_bar') { + } else if (slot == 'left_bar') { this.width = Interface.left_bar_width; - } else if (this.slot == 'right_bar') { + } else if (slot == 'right_bar') { this.width = Interface.right_bar_width; } - if (this.slot == 'top' || this.slot == 'bottom') { + if (slot == 'top' || slot == 'bottom') { if (Blockbench.isMobile && Blockbench.isLandscape) { this.height = center_screen.clientHeight; this.width = Math.clamp(this.position_data.height, 30, center_screen.clientWidth); if (this.folded) this.width = 72; } else { - let opposite_panel = this.slot == 'top' ? Interface.getBottomPanel() : Interface.getTopPanel(); + let opposite_panel = slot == 'top' ? Interface.getBottomPanel() : Interface.getTopPanel(); this.height = Math.clamp(this.position_data.height, 30, center_screen.clientHeight - (opposite_panel ? opposite_panel.height : 0)); if (this.folded) this.height = this.handle.clientHeight; this.width = Interface.work_screen.clientWidth - Interface.left_bar_width - Interface.right_bar_width; } this.node.style.width = this.width + 'px'; this.node.style.height = this.height + 'px'; - } else if (this.slot == 'left_bar' || this.slot == 'right_bar') { + } else if (slot == 'left_bar' || slot == 'right_bar') { if (this.fixed_height) { - //let other_panels = this.slot == 'left_bar' ? Interface.getLeftPanels() : Interface.getRightPanels(); - //let available_height = (this.slot == 'left_bar' ? Interface.left_bar : Interface.right_bar).clientHeight; + //let other_panels = slot == 'left_bar' ? Interface.getLeftPanels() : Interface.getRightPanels(); + //let available_height = (slot == 'left_bar' ? Interface.left_bar : Interface.right_bar).clientHeight; //let min_height = other_panels.reduce((sum, panel) => (panel == this ? sum : (sum - panel.node.clientHeight)), available_height); this.height = Math.clamp(this.position_data.height, 30, Interface.work_screen.clientHeight); this.node.style.height = this.height + 'px'; @@ -694,6 +716,18 @@ class Panel extends EventSystem { } if (!this.fixed_height) this.node.classList.remove('fixed_height'); + if (this.sidebar_resize_handle) { + this.sidebar_resize_handle.style.display = (slot == 'left_bar' || slot == 'right_bar') ? 'block' : 'none'; + } + if ((slot == 'right_bar' && Interface.getRightPanels().last() == this) || (slot == 'left_bar' && Interface.getLeftPanels().last() == this)) { + this.node.parentElement?.childNodes.forEach(n => n.classList.remove('bottommost_panel')); + this.node.classList.add('bottommost_panel'); + } + if ((slot == 'right_bar' && Interface.getRightPanels()[0] == this) || (slot == 'left_bar' && Interface.getLeftPanels()[0] == this)) { + this.node.parentElement?.childNodes.forEach(n => n.classList.remove('topmost_panel')); + this.node.classList.add('topmost_panel'); + } + if (Panels[this.id] && this.onResize) this.onResize() } else { this.node.classList.add('hidden'); @@ -773,8 +807,12 @@ function updateSidebarOrder() { let panel = Panels[panel_id]; if (panel && panel.slot == bar) { panel.node.classList.remove('bottommost_panel'); + panel.node.classList.remove('topmost_panel'); bar_node.append(panel.node); if (Condition(panel.condition)) { + if (panel_count == 0) { + panel.node.classList.add('topmost_panel'); + } panel_count++; last_panel = panel; } diff --git a/js/interface/settings.js b/js/interface/settings.js index 728563f19..f7708e7dc 100644 --- a/js/interface/settings.js +++ b/js/interface/settings.js @@ -363,6 +363,10 @@ const Settings = { updateStreamerModeNotification(); }}); new Setting('cdn_mirror', {value: false}); + new Setting('recovery_save_interval', {value: 30, type: 'number', min: 0, onChange() { + clearTimeout(AutoBackup.loop_timeout); + AutoBackup.backupProjectLoop(false); + }}); //Interface new Setting('interface_mode', {category: 'interface', value: 'auto', type: 'select', options: { @@ -396,7 +400,7 @@ const Settings = { } }}); new Setting('seethrough_outline', {category: 'interface', value: false}); - new Setting('outliner_colors', {category: 'interface', value: false}); + new Setting('outliner_colors', {category: 'interface', value: true}); new Setting('preview_checkerboard', {category: 'interface', value: true, onChange() { $('#center').toggleClass('checkerboard', settings.preview_checkerboard.value); }}); @@ -434,6 +438,7 @@ const Settings = { }, onChange() { Canvas.updateRenderSides(); }}); + new Setting('fps_limit', {category: 'preview', value: 144, min: 10, max: 1024, type: 'number'}); new Setting('background_rendering', {category: 'preview', value: true}); new Setting('texture_fps', {category: 'preview', value: 7, type: 'number', min: 0, max: 120, onChange() { TextureAnimator.updateSpeed() @@ -442,6 +447,7 @@ const Settings = { WinterskyScene.global_options.tick_rate = this.value; }}); new Setting('volume', {category: 'preview', value: 80, min: 0, max: 200, type: 'number'}); + new Setting('save_view_per_tab', {category: 'preview', value: true}); new Setting('display_skin', {category: 'preview', value: false, type: 'click', icon: 'icon-player', click: function() { changeDisplaySkin() }}); //Edit @@ -451,6 +457,7 @@ const Settings = { new Setting('highlight_cubes', {category: 'edit', value: true, onChange() { updateCubeHighlights(); }}); + new Setting('outliner_reveal_on_select', {category: 'edit', value: true}) new Setting('allow_display_slot_mirror', {category: 'edit', value: false, onChange(value) { DisplayMode.vue.allow_mirroring = value; }}) @@ -529,7 +536,8 @@ const Settings = { //Defaults new Setting('default_cube_size', {category: 'defaults', value: 2, type: 'number', min: 0, max: 32}); - new Setting('autouv', {category: 'defaults', value: true}); + new Setting('autouv', {category: 'defaults', value: true}); + new Setting('inherit_parent_color', {category: 'defaults', value: false}); new Setting('create_rename', {category: 'defaults', value: false}); new Setting('show_only_selected_uv', {category: 'defaults', value: false}); new Setting('default_path', {category: 'defaults', value: false, type: 'click', condition: isApp, icon: 'burst_mode', click: function() { openDefaultTexturePath() }}); @@ -563,6 +571,7 @@ const Settings = { new Setting('minify_bbmodel', {category: 'export', value: true}); new Setting('export_empty_groups', {category: 'export', value: true}); new Setting('export_groups', {category: 'export', value: true}); + new Setting('java_export_pivots', {category: 'export', value: true}); new Setting('optifine_save_default_texture',{category: 'export', value: true}); new Setting('obj_face_export_mode', {category: 'export', value: 'both', type: 'select', options: { both: tl('settings.obj_face_export_mode.both'), diff --git a/js/interface/start_screen.js b/js/interface/start_screen.js index 39020b399..79da8080a 100644 --- a/js/interface/start_screen.js +++ b/js/interface/start_screen.js @@ -182,23 +182,15 @@ onVueSetup(async function() { slideshow: [ { source: "./assets/splash_art/1.webp", - description: "Splash Art 1st Place by [skeleton_tiffay](https://twitter.com/Tiffany85635656)", + description: "Splash Art 1st Place by [BonoGakure](https://twitter.com/bonogakure) & [GlenFebrian](https://twitter.com/glenn_turu)", }, { source: "./assets/splash_art/2.webp", - description: "Splash Art 2nd Place by [AnzSama](https://twitter.com/AnzSamaEr) & [PICASSO](https://twitter.com/Picasso114514)", + description: "Splash Art 2nd Place by [Wanwin](https://wan-win.com/#3darts) & Artem x", }, { source: "./assets/splash_art/3.webp", - description: "Splash Art 3rd Place by [YunGui](https://twitter.com/AmosJea28222061) & [makstutis233](https://x.com/Maks2335770189)", - }, - { - source: "./assets/splash_art/4.webp", - description: "Splash Art 4th Place by [soul shadow](https://twitter.com/Ghost773748999) & NekoGabriel", - }, - { - source: "./assets/splash_art/5.webp", - description: "Splash Art 5th Place by [🌷Aza🌷](https://twitter.com/azagwen_art) & Shroomy", + description: "Splash Art 3rd Place by [FairyZelz](https://x.com/FairyZelz) & [AnolXD](https://x.com/_AnolXD_)", } ], show_splash_screen: (Blockbench.hasFlag('after_update') || settings.always_show_splash_art.value), diff --git a/js/interface/themes.js b/js/interface/themes.js index 00f91d913..d1378d27a 100644 --- a/js/interface/themes.js +++ b/js/interface/themes.js @@ -57,6 +57,7 @@ const CustomTheme = { data.id = file.name.replace(/\.\w+$/, ''); if (!data.name) data.name = data.id; data.sideloaded = true; + data.source = 'file'; data.path = file.path; CustomTheme.themes.push(data); @@ -114,7 +115,9 @@ const CustomTheme = { try { let {content} = await $.getJSON(file.git_url); let theme = JSON.parse(patchedAtob(content)); + if (theme.desktop_only && Blockbench.isMobile) return false; theme.id = file.name.replace(/\.\w+/, ''); + theme.source = 'repository'; CustomTheme.themes.push(theme); } catch (err) { console.error(err); @@ -128,7 +131,12 @@ const CustomTheme = { backup: '', data: CustomTheme.data, open_category: 'select', - themes: CustomTheme.themes + themes: CustomTheme.themes, + theme_icons: { + built_in: '', + repository: 'globe', + file: 'draft', + } }, components: { VuePrismEditor @@ -196,6 +204,19 @@ const CustomTheme = { openContextMenu(theme, event) { if (!theme.sideloaded) return; let menu = new Menu([ + { + name: 'menu.texture.folder', + icon: 'folder', + condition: isApp, + click: () => { + if (!isApp || !theme.path) return; + if (!fs.existsSync(theme.path)) { + Blockbench.showQuickMessage('texture.error.file'); + return; + } + shell.showItemInFolder(theme.path); + } + }, { name: 'generic.remove', icon: 'clear', @@ -224,7 +245,7 @@ const CustomTheme = {
      {{ tl('layout.restore_backup', [backup]) }} - clear + clear

      ${tl('layout.select')}

      @@ -250,6 +271,9 @@ const CustomTheme = {
    {{ theme.name }}
    +
    + {{ theme_icons[theme.source] }} +
    {{ theme.author }}
    @@ -508,6 +532,7 @@ const CustomTheme = { if (!data.name) data.name = data.id; data.sideloaded = true; + data.source = 'file'; data.path = file.path; CustomTheme.loadTheme(data); diff --git a/js/io/format.js b/js/io/format.js index d0508d412..5586464d0 100644 --- a/js/io/format.js +++ b/js/io/format.js @@ -65,7 +65,7 @@ class ModelFormat { scene.position.set(0, 0, 0); Canvas.ground_plane.position.x = Canvas.ground_plane.position.z = 8; } else { - scene.position.set(-8, -8, -8); + scene.position.set(-8, 0, -8); Canvas.ground_plane.position.x = Canvas.ground_plane.position.z = 0; } PreviewModel.getActiveModels().forEach(model => { @@ -279,7 +279,9 @@ new Property(ModelFormat, 'boolean', 'locators'); new Property(ModelFormat, 'boolean', 'rotation_limit'); new Property(ModelFormat, 'boolean', 'rotation_snap'); new Property(ModelFormat, 'boolean', 'uv_rotation'); +new Property(ModelFormat, 'boolean', 'java_cube_shading_properties'); new Property(ModelFormat, 'boolean', 'java_face_properties'); +new Property(ModelFormat, 'boolean', 'cullfaces'); new Property(ModelFormat, 'boolean', 'select_texture_for_particles'); new Property(ModelFormat, 'boolean', 'texture_mcmeta'); new Property(ModelFormat, 'boolean', 'bone_binding_expression'); diff --git a/js/io/formats/bbmodel.js b/js/io/formats/bbmodel.js index a3e03e686..3c9111759 100644 --- a/js/io/formats/bbmodel.js +++ b/js/io/formats/bbmodel.js @@ -189,6 +189,11 @@ var codec = new Codec('project', { if (options.absolute_paths == false) delete t.path; model.textures.push(t); }) + for (let texture_group of TextureGroup.all) { + if (!model.texture_groups) model.texture_groups = []; + let copy = texture_group.getSaveCopy(); + model.texture_groups.push(copy); + } if (Animation.all.length) { model.animations = []; @@ -328,6 +333,11 @@ var codec = new Codec('project', { Project.texture_height = model.resolution.height; } + if (model.texture_groups) { + model.texture_groups.forEach(tex_group => { + new TextureGroup(tex_group, tex_group.uuid).add(false); + }) + } if (model.textures) { model.textures.forEach(tex => { var tex_copy = new Texture(tex, tex.uuid).add(false); @@ -516,10 +526,6 @@ var codec = new Codec('project', { c++; tex_copy.id = c.toString(); } - if (isApp && tex.path && fs.existsSync(tex.path) && !model.meta.backup) { - tex_copy.loadContentFromPath(tex.path) - return tex_copy; - } if (isApp && tex.relative_path && path) { let resolved_path = PathModule.resolve(PathModule.dirname(path), tex.relative_path); if (fs.existsSync(resolved_path)) { @@ -527,12 +533,21 @@ var codec = new Codec('project', { return tex_copy; } } + if (isApp && tex.path && fs.existsSync(tex.path) && !model.meta.backup) { + tex_copy.loadContentFromPath(tex.path) + return tex_copy; + } if (tex.source && tex.source.substr(0, 5) == 'data:') { tex_copy.fromDataURL(tex.source) return tex_copy; } } + if (model.texture_groups) { + model.texture_groups.forEach(tex_group => { + new TextureGroup(tex_group, tex_group.uuid).add(false); + }) + } if (model.textures && (!Format.single_texture || Texture.all.length == 0)) { new_textures.replace(model.textures.map(loadTexture)) } @@ -700,6 +715,34 @@ BARS.defineActions(function() { } }) + new Action('save_project_incremental', { + icon: 'difference', + category: 'file', + keybind: new Keybind({key: 's', shift: true, alt: true}), + condition: isApp ? (() => Project && Project.save_path) : false, + click: function () { + saveTextures(true); + let projectTailRegex = /\.bbmodel$/; + let projectVerRegex = /([0-9]+)\.bbmodel$/; + let projectVerMatch = projectVerRegex.exec(Project.save_path); + + let file_path; + if (projectVerMatch) { + let projectVer = parseInt(projectVerMatch[1]); // Parse & store project ver int (capturing group 1) + file_path = Project.save_path.replace(projectVerRegex, `${projectVer + 1}.bbmodel`); + } else { + file_path = Project.save_path.replace(projectTailRegex, "_1.bbmodel"); + } + let original_file_path = file_path; + let i = 1; + while (fs.existsSync(file_path) && i < 100) { + file_path = original_file_path.replace(projectTailRegex, `_alt_${i == 1 ? '' : i}.bbmodel`); + i++; + } + codec.write(codec.compile(), file_path); + } + }) + new Action('save_project_as', { icon: 'save', category: 'file', diff --git a/js/io/formats/bedrock.js b/js/io/formats/bedrock.js index bc06a1ab3..cb155288e 100644 --- a/js/io/formats/bedrock.js +++ b/js/io/formats/bedrock.js @@ -441,7 +441,9 @@ window.BedrockBlockManager = class BedrockBlockManager { let texture_path = `textures/blocks/${material.texture || this.project.geometry_name}`; if (terrain_texture) { let texture_data = terrain_texture[material.texture]; - texture_path = texture_data.textures + if (typeof texture_data?.textures == 'string') { + texture_path = texture_data.textures; + } } let full_texture_path = PathModule.join(this.rp_root_path + osfs + texture_path.replace(/\.png$/i, '')); full_texture_path = findExistingFile([ @@ -714,6 +716,10 @@ function calculateVisibleBox() { Project.texture_height = description.texture_height; } + if (data.object.item_display_transforms !== undefined) { + DisplayMode.loadJSON(data.object.item_display_transforms) + } + var bones = {} if (data.object.bones) { @@ -730,7 +736,6 @@ function calculateVisibleBox() { codec.dispatchEvent('parsed', {model: data.object}); - loadTextureDraggable() Canvas.updateAllBones() setProjectTitle() if (isApp && Project.geometry_name) { @@ -1026,6 +1031,18 @@ let entity_file_codec = new Codec('bedrock_entity_file', { }) function getFormatVersion() { + if (Format.display_mode) { + let has_new_displays = false; + for (let i in DisplayMode.slots) { + let key = DisplayMode.slots[i] + if (Project.display_settings[key] && Project.display_settings[key].export) { + let data = Project.display_settings[key].export(); + if (data) { + return '1.21.20'; + } + } + } + } for (let cube of Cube.all) { for (let fkey in cube.faces) { if (cube.faces[fkey].rotation) return '1.21.0'; @@ -1129,6 +1146,20 @@ var codec = new Codec('bedrock', { if (bones.length) { entitymodel.bones = bones } + + let new_display = {}; + let has_new_displays = false; + for (let i in DisplayMode.slots) { + let key = DisplayMode.slots[i] + if (Project.display_settings[key] && Project.display_settings[key].export) { + new_display[key] = Project.display_settings[key].export(); + if (new_display[key]) has_new_displays = true; + } + } + if (has_new_displays) { + entitymodel.item_display_transforms = new_display + } + this.dispatchEvent('compile', {model: main_tag, options}); if (options.raw) { @@ -1358,6 +1389,7 @@ var block_format = new ModelFormat({ animated_textures: true, animation_files: false, animation_mode: false, + display_mode: true, texture_meshes: true, cube_size_limiter: { rotation_affected: true, diff --git a/js/io/formats/bedrock_old.js b/js/io/formats/bedrock_old.js index 819a4dbe5..5118a33cc 100644 --- a/js/io/formats/bedrock_old.js +++ b/js/io/formats/bedrock_old.js @@ -114,7 +114,6 @@ function parseGeometry(data) { codec.dispatchEvent('parsed', {model: data.object}); - loadTextureDraggable() Canvas.updateAllBones() setProjectTitle() if (isApp && Project.geometry_name && Project.BedrockEntityManager) { diff --git a/js/io/formats/generic.js b/js/io/formats/generic.js index 6d8448fbf..5582fbc79 100644 --- a/js/io/formats/generic.js +++ b/js/io/formats/generic.js @@ -3,7 +3,7 @@ new ModelFormat({ id: 'free', icon: 'icon-format_free', category: 'general', - target: ['Godot', 'Unity', 'Unreal Engine', 'Sketchfab', 'Blender'], + target: ['Godot', 'Unity', 'Unreal Engine', 'Sketchfab', 'Blender', tl('format.free.info.3d_printing')], format_page: { content: [ {type: 'h3', text: tl('mode.start.format.informations')}, diff --git a/js/io/formats/java_block.js b/js/io/formats/java_block.js index f5f657086..015ce4b51 100644 --- a/js/io/formats/java_block.js +++ b/js/io/formats/java_block.js @@ -47,7 +47,10 @@ var codec = new Codec('java_block', { if (s.shade === false) { element.shade = false } - if (!s.rotation.allEqual(0) || !s.origin.allEqual(0)) { + if (s.light_emission) { + element.light_emission = s.light_emission; + } + if (!s.rotation.allEqual(0) || (!s.origin.allEqual(0) && settings.java_export_pivots.value)) { var axis = s.rotationAxis()||'y'; element.rotation = new oneLiner({ angle: s.rotation[getAxisNumber(axis)], @@ -527,7 +530,9 @@ var format = new ModelFormat({ rotation_snap: true, optional_box_uv: true, uv_rotation: true, + java_cube_shading_properties: true, java_face_properties: true, + cullfaces: true, animated_textures: true, select_texture_for_particles: true, texture_mcmeta: true, diff --git a/js/io/formats/optifine_jem.js b/js/io/formats/optifine_jem.js index cec15cc70..c90b11bc3 100644 --- a/js/io/formats/optifine_jem.js +++ b/js/io/formats/optifine_jem.js @@ -383,4 +383,39 @@ BARS.defineActions(function() { }) }) +new ValidatorCheck('zero_wide_uv_faces', { + condition: {formats: ['optifine_entity', 'java_block']}, + update_triggers: ['update_selection'], + run() { + for (let cube of Cube.all) { + if (cube.box_uv && Format.id == 'optifine_entity') continue; + let select_cube_button = { + name: 'Select Cube', + icon: 'fa-cube', + click() { + Validator.dialog.hide(); + cube.select(); + } + }; + for (let fkey in cube.faces) { + let face = cube.faces[fkey]; + let uv_size = face.uv_size; + let size_issue; + for (let i of [0, 1]) { + let size = uv_size[i]; + if (Math.abs(size) < 0.00005) { + size_issue = true; + } + } + if (size_issue) { + this.warn({ + message: `The face "${fkey}" on cube "${cube.name}" has invalid UV sizes. UV sizes cannot be 0.`, + buttons: [select_cube_button] + }) + } + } + } + } +}) + })() diff --git a/js/io/formats/skin.js b/js/io/formats/skin.js index d1f7e7f30..9e9fa2cbe 100644 --- a/js/io/formats/skin.js +++ b/js/io/formats/skin.js @@ -149,7 +149,6 @@ const codec = new Codec('skin_model', { if (data.camera_angle) { main_preview.loadAnglePreset(DefaultCameraPresets.find(p => p.id == data.camera_angle)) } - loadTextureDraggable() Canvas.updateAllBones() Canvas.updateVisibility() setProjectTitle() diff --git a/js/io/io.js b/js/io/io.js index a270861a8..586bb0e9e 100644 --- a/js/io/io.js +++ b/js/io/io.js @@ -582,6 +582,14 @@ function autoParseJSON(data, feedback) { data = JSON.parse(data) } catch (err) { if (feedback === false) return; + if (data.match(/\n\r?[><]{7}/)) { + Blockbench.showMessageBox({ + title: 'message.invalid_file.title', + icon: 'fab.fa-git-alt', + message: 'message.invalid_file.merge_conflict' + }) + return; + } let error_part = ''; function logErrantPart(whole, start, length) { var line = whole.substr(0, start).match(/\n/gm) diff --git a/js/io/project.js b/js/io/project.js index 4b831cabf..e3ccdb9ec 100644 --- a/js/io/project.js +++ b/js/io/project.js @@ -59,6 +59,7 @@ class ModelProject { this.mesh_selection = {}; this.textures = []; this.selected_texture = null; + this.texture_groups = []; this.outliner = []; this.animations = []; this.animation_controllers = []; @@ -207,6 +208,7 @@ class ModelProject { BarItems.edit_mode_uv_overlay.updateEnabledState(); Panels.textures.inside_vue.textures = Texture.all; + Panels.textures.inside_vue.texture_groups = TextureGroup.all; Panels.layers.inside_vue.layers = Texture.selected ? Texture.selected.layers : []; scene.add(this.model_3d); @@ -225,19 +227,21 @@ class ModelProject { Panels.skin_pose.inside_vue.pose = this.skin_pose; UVEditor.loadViewportOffset(); - - Preview.all.forEach(preview => { - let data = this.previews[preview.id]; - if (data) { - preview.camera.position.fromArray(data.position); - preview.controls.target.fromArray(data.target); - preview.setProjectionMode(data.orthographic); - if (data.zoom) preview.camOrtho.zoom = data.zoom; - if (data.angle) preview.setLockedAngle(data.angle); - } else if (preview.default_angle !== undefined) { - preview.loadAnglePreset(preview.default_angle); - } - }) + + if (settings.save_view_per_tab.value) { + Preview.all.forEach(preview => { + let data = this.previews[preview.id]; + if (data) { + preview.camera.position.fromArray(data.position); + preview.controls.target.fromArray(data.target); + preview.setProjectionMode(data.orthographic); + if (data.zoom) preview.camOrtho.zoom = data.zoom; + if (data.angle) preview.setLockedAngle(data.angle); + } else if (preview.default_angle !== undefined) { + preview.loadAnglePreset(preview.default_angle); + } + }) + } Modes.options[this.mode].select(); if (BarItems[this.tool] && Condition(BarItems[this.tool].condition)) { @@ -279,8 +283,6 @@ class ModelProject { updateProjectResolution(); Validator.validate(); Vue.nextTick(() => { - loadTextureDraggable(); - if (this.on_next_upen instanceof Array) { this.on_next_upen.forEach(callback => callback()); delete this.on_next_upen; @@ -551,6 +553,7 @@ ModelProject.prototype.menu = new Menu([ new MenuSeparator('save'), 'save_project', 'save_project_as', + 'save_project_incremental', 'export_over', 'share_model', new MenuSeparator('overview'), @@ -607,6 +610,7 @@ function selectNoProject() { UVEditor.vue.all_elements = []; Interface.Panels.textures.inside_vue.textures = []; + Interface.Panels.textures.inside_vue.texture_groups = []; Panels.animations.inside_vue.animations = []; Panels.animations.inside_vue.animation_controllers = []; @@ -1195,12 +1199,15 @@ BARS.defineActions(function() { new Action('switch_tabs', { icon: 'swap_horiz', category: 'file', - keybind: new Keybind({key: 9, ctrl: true, shift: null}), + keybind: new Keybind({key: 9, ctrl: true}, {reverse_order: 'shift'}), + variations: { + reverse_order: {name: 'action.switch_tabs.reverse_order'} + }, condition: () => ModelProject.all.length > 1, click(event) { let index = ModelProject.all.indexOf(Project); let target; - if (event && event.shiftKey) { + if (this.keybind.additionalModifierTriggered(event) == 'reverse_order') { target = ModelProject.all[index-1] || ModelProject.all.last(); } else { target = ModelProject.all[index+1] || ModelProject.all[0]; diff --git a/js/misc.js b/js/misc.js index 65ea5659a..75a2cbfa3 100644 --- a/js/misc.js +++ b/js/misc.js @@ -145,7 +145,7 @@ function updateSelection(options = {}) { } }) for (var i = Outliner.selected.length-1; i >= 0; i--) { - if (!selected.includes(Outliner.selected[i])) { + if (!Project.elements.includes(Outliner.selected[i])) { Outliner.selected.splice(i, 1) } } @@ -203,6 +203,8 @@ function updateSelection(options = {}) { Preview.all.forEach(preview => { preview.updateAnnotations(); }) + if (Condition(BarItems.layer_opacity.condition)) BarItems.layer_opacity.update(); + if (Condition(BarItems.layer_blend_mode.condition)) BarItems.layer_blend_mode.set(TextureLayer.selected?.blend_mode); BARS.updateConditions(); delete TickUpdates.selection; @@ -283,6 +285,8 @@ const AutoBackup = { ] }) } + + AutoBackup.backupProjectLoop(false); } }, async backupOpenProject() { @@ -369,6 +373,21 @@ const AutoBackup = { reject(); } }); + }, + loop_timeout: null, + backupProjectLoop(run_save = true) { + if (run_save && Project && (Outliner.root.length || Project.textures.length)) { + try { + AutoBackup.backupOpenProject(); + } catch (err) { + console.error('Unable to create backup. ', err) + } + } + let interval = settings.recovery_save_interval.value; + if (interval != 0) { + interval = Math.max(interval, 5); + AutoBackup.loop_timeout = setTimeout(() => AutoBackup.backupProjectLoop(true), interval * 1000); + } } } @@ -376,13 +395,8 @@ const AutoBackup = { setInterval(function() { if (Project && (Outliner.root.length || Project.textures.length)) { Validator.validate(); - try { - AutoBackup.backupOpenProject(); - } catch (err) { - console.error('Unable to create backup. ', err) - } } -}, 1e3*30) +}, 1e3*30); //Misc const TickUpdates = { Run() { @@ -395,10 +409,6 @@ const TickUpdates = { delete TickUpdates.UVEditor; UVEditor.loadData() } - if (TickUpdates.texture_list) { - delete TickUpdates.texture_list; - loadTextureDraggable(); - } if (TickUpdates.keyframe_selection) { delete TickUpdates.keyframe_selection; Vue.nextTick(updateKeyframeSelection) diff --git a/js/modeling/mesh_editing.js b/js/modeling/mesh_editing.js index dde78f1ab..fd169796c 100644 --- a/js/modeling/mesh_editing.js +++ b/js/modeling/mesh_editing.js @@ -639,6 +639,227 @@ class KnifeToolContext { } static current = null; } +class KnifeToolCubeContext { + constructor(cube) { + this.cube = cube; + this.face; + this.first_point_set = false; + this.valid_position = false; + this.axis = 0; + this.face_axis = 0; + this.first_point = new THREE.Vector3(); + this.offset = 0; + this.preview_mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({color: Canvas.outlineMaterial.color}), + ) + this.cross_mesh = new THREE.Mesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial({ + map: KnifeToolCubeContext.map, + alphaTest: 0.1, + side: THREE.DoubleSide, + color: Canvas.outlineMaterial.color + }), + ); + + this.unselect_listener = Blockbench.on('unselect_project', context => { + if (this == KnifeToolContext.current) { + this.remove(); + } + }) + KnifeToolContext.current = this; + } + get mesh_3d() { + return this.cube.mesh; + } + get axis_letter() { + return getAxisLetter(this.axis); + } + hover(data) { + if (!data) { + if (this.cross_mesh.parent) { + this.cross_mesh.parent.remove(this.cross_mesh); + } + return; + } + let intersect = data.intersects[0]; + if (!this.first_point_set) { + this.cube = data.element; + this.face = data.face; + this.first_point.copy(intersect.point); + this.mesh_3d.worldToLocal(this.first_point); + + this.face_axis = KnifeToolCubeContext.face_axis[this.face]; + let off_axes = [0, 1, 2].filter(a1 => a1 != this.face_axis); + let snap = canvasGridSize(data.event?.shiftKey || Pressing.overrides.shift, data.event?.ctrlOrCmd || Pressing.overrides.ctrl); + let modified_from = this.cube.from.slice().V3_subtract(this.cube.inflate); + + this.first_point[getAxisLetter(off_axes[0])] = Math.round((this.first_point[getAxisLetter(off_axes[0])] - modified_from[off_axes[0]]) / snap) * snap + modified_from[off_axes[0]]; + this.first_point[getAxisLetter(off_axes[1])] = Math.round((this.first_point[getAxisLetter(off_axes[1])] - modified_from[off_axes[1]]) / snap) * snap + modified_from[off_axes[1]]; + + } else { + this.mesh_3d.add(this.preview_mesh); + this.face_axis = KnifeToolCubeContext.face_axis[this.face]; + let off_axes = [0, 1, 2].filter(a1 => a1 != this.face_axis); + + let second_point = intersect.point; + this.mesh_3d.worldToLocal(second_point); + let val_1 = this.first_point[getAxisLetter(off_axes[0])] - second_point[getAxisLetter(off_axes[0])]; + let val_2 = this.first_point[getAxisLetter(off_axes[1])] - second_point[getAxisLetter(off_axes[1])]; + let direction = Math.abs(val_1) > Math.abs(val_2); + switch (this.face_axis) { + case 0: this.axis = direction ? 2 : 1; break; + case 1: this.axis = direction ? 2 : 0; break; + case 2: this.axis = direction ? 1 : 0; break; + } + this.offset = this.first_point[this.axis_letter]; + } + this.updatePreview(); + } + updatePreview() { + let pos = this.mesh_3d.localToWorld(new THREE.Vector3().copy(this.first_point)); + let size = Preview.selected.calculateControlScale(pos) / 8; + if (!this.first_point_set) { + this.mesh_3d.add(this.cross_mesh); + this.cross_mesh.position.copy(this.first_point); + let face_axis = KnifeToolCubeContext.face_axis[this.face]; + let face_direction = ['north', 'west', 'down'].includes(this.face) ? -1 : 1; + this.cross_mesh.position[getAxisLetter(face_axis)] += face_direction * size; + switch (face_axis) { + case 0: this.cross_mesh.rotation.set(0, Math.PI/2, 0); break; + case 1: this.cross_mesh.rotation.set(Math.PI/2, 0, 0); break; + case 2: this.cross_mesh.rotation.set(0, 0, 0); break; + } + + this.cross_mesh.scale.set(size * 16, size * 16, size * 16); + } else { + if (this.cross_mesh.parent) { + this.cross_mesh.parent.remove(this.cross_mesh); + } + this.mesh_3d.add(this.preview_mesh); + this.preview_mesh.position.set( + Math.lerp(this.cube.from[0] - this.cube.origin[0], this.cube.to[0] - this.cube.origin[0], 0.5), + Math.lerp(this.cube.from[1] - this.cube.origin[1], this.cube.to[1] - this.cube.origin[1], 0.5), + Math.lerp(this.cube.from[2] - this.cube.origin[2], this.cube.to[2] - this.cube.origin[2], 0.5), + ) + this.preview_mesh.position[this.axis_letter] = this.offset; + + let pos = THREE.fastWorldPosition(this.preview_mesh); + let size = Preview.selected.calculateControlScale(pos) / 8; + this.preview_mesh.scale.set(...this.cube.size().map(v => v + this.cube.inflate * 2 + size)); + this.preview_mesh.scale[this.axis_letter] = size; + } + } + addPoint(data) { + if (!data?.element) return; + if (!this.first_point_set) { + this.first_point_set = true; + } else { + this.apply(); + } + } + apply() { + if (!this.cube || !Cube.all.includes(this.cube) || !this.first_point_set) { + this.cancel(); + return; + } + let elements = [this.cube]; + Undo.initEdit({elements}); + + if (this.cube.box_uv && Format.optional_box_uv) { + this.cube.box_uv = false; + } + let duplicate = this.cube.duplicate(); + Outliner.selected.safePush(duplicate); + elements.safePush(duplicate); + let modified_from = this.cube.from.slice().V3_subtract(this.cube.inflate); + let modified_to = this.cube.to.slice().V3_subtract(this.cube.inflate); + let offset = this.offset + this.cube.origin[this.axis]; + let offset_lerp = Math.getLerp(modified_from[this.axis], modified_to[this.axis], offset); + + this.cube.to[this.axis] = offset - this.cube.inflate; + duplicate.from[this.axis] = offset + this.cube.inflate; + + function modifyUV(face, index, inverted) { + index = (index - (face.rotation/90) + 8) % 4; + let index_opposite = (index+2)%4; + if (inverted) { + face.uv[index] = Math.lerp(face.uv[index], face.uv[index_opposite], offset_lerp); + } else { + face.uv[index] = Math.lerp(face.uv[index_opposite], face.uv[index], offset_lerp); + } + } + switch (this.axis) { + case 0: { + modifyUV(this.cube.faces.north, 0); + modifyUV(this.cube.faces.south, 2); + modifyUV(this.cube.faces.up, 2); + modifyUV(this.cube.faces.down, 2); + + modifyUV(duplicate.faces.north, 2, true); + modifyUV(duplicate.faces.south, 0, true); + modifyUV(duplicate.faces.up, 0, true); + modifyUV(duplicate.faces.down, 0, true); + break; + } + case 1: { + modifyUV(this.cube.faces.north, 1); + modifyUV(this.cube.faces.south, 1); + modifyUV(this.cube.faces.east, 1); + modifyUV(this.cube.faces.west, 1); + + modifyUV(duplicate.faces.north, 3, true); + modifyUV(duplicate.faces.south, 3, true); + modifyUV(duplicate.faces.east, 3, true); + modifyUV(duplicate.faces.west, 3, true); + break; + } + case 2: { + modifyUV(this.cube.faces.east, 0); + modifyUV(this.cube.faces.west, 2); + modifyUV(this.cube.faces.up, 3); + modifyUV(this.cube.faces.down, 1); + + modifyUV(duplicate.faces.east, 2, true); + modifyUV(duplicate.faces.west, 0, true); + modifyUV(duplicate.faces.up, 1, true); + modifyUV(duplicate.faces.down, 3, true); + break; + } + } + + Canvas.updateView({elements, element_aspects: {geometry: true, uv: true}, selection: true}); + Undo.finishEdit('Use knife tool'); + this.remove(); + } + cancel() { + this.remove(); + } + remove() { + if (this.cross_mesh.parent) { + this.cross_mesh.parent.remove(this.cross_mesh); + } + if (this.preview_mesh.parent) { + this.preview_mesh.parent.remove(this.preview_mesh); + } + delete this.cube; + delete this.cross_mesh; + delete this.precross_meshview_mesh; + this.unselect_listener.delete(); + KnifeToolContext.current = null; + } + static face_axis = { + north: 2, + south: 2, + up: 1, + down: 1, + east: 0, + west: 0 + } + static map = new THREE.TextureLoader().load('assets/crosshair.png'); +} +KnifeToolCubeContext.map.magFilter = KnifeToolCubeContext.map.minFilter = THREE.NearestFilter; async function autoFixMeshEdit() { let meshes = Mesh.selected; @@ -1085,8 +1306,8 @@ BARS.defineActions(function() { }); let group = getCurrentGroup(); if (group) { - mesh.addTo(group) - mesh.color = group.color; + mesh.addTo(group); + if (settings.inherit_parent_color.value) mesh.color = group.color; } let diameter_factor = result.align_edges ? 1 / Math.cos(Math.PI/result.sides) : 1; let off_ang = result.align_edges ? 0.5 : 0; @@ -1574,10 +1795,16 @@ BARS.defineActions(function() { vertices: true, }, modes: ['edit'], - condition: () => Modes.edit && Mesh.hasAny(), + condition: () => Modes.edit, onCanvasMouseMove(data) { - if (!KnifeToolContext.current && Mesh.selected[0] && Mesh.selected.length == 1) { - KnifeToolContext.current = new KnifeToolContext(Mesh.selected[0]); + if (Mesh.selected[0]) { + if (!KnifeToolContext.current && Mesh.selected.length == 1) { + KnifeToolContext.current = new KnifeToolContext(Mesh.selected[0]); + } + } else if (Cube.all[0]) { + if (!KnifeToolContext.current) { + KnifeToolContext.current = new KnifeToolCubeContext(); + } } if (KnifeToolContext.current) { KnifeToolContext.current.hover(data); @@ -1585,8 +1812,19 @@ BARS.defineActions(function() { }, onCanvasClick(data) { if (!data) return; - if (!KnifeToolContext.current && data.element instanceof Mesh) { - KnifeToolContext.current = new KnifeToolContext(data.element); + if (!KnifeToolContext.current) { + if (data.element instanceof Mesh) { + if (!KnifeToolContext.current && Mesh.selected.length == 1) { + KnifeToolContext.current = new KnifeToolContext(data.element); + } + if (KnifeToolContext.current) { + KnifeToolContext.current.hover(data); + } + } else if (data.element instanceof Cube) { + if (!KnifeToolContext.current) { + KnifeToolContext.current = new KnifeToolCubeContext(data.element); + } + } } let context = KnifeToolContext.current; context.addPoint(data); diff --git a/js/modeling/transform.js b/js/modeling/transform.js index b97e22d2c..ff928c1a2 100644 --- a/js/modeling/transform.js +++ b/js/modeling/transform.js @@ -19,7 +19,7 @@ function getSelectionCenter(all = false) { let center = (min[0] == Infinity) ? [0, 0, 0] : max.V3_add(min).V3_divide(2); if (!Format.centered_grid) { - center.V3_add(8, 8, 8) + center.V3_add(8, 0, 8) } return center; } @@ -1724,7 +1724,7 @@ BARS.defineActions(function() { new Action('toggle_shade', { icon: 'wb_sunny', category: 'transform', - condition: () => Format.java_face_properties && Modes.edit, + condition: () => Format.java_cube_shading_properties && Modes.edit, click() {toggleCubeProperty('shade')} }) new Action('toggle_mirror_uv', { @@ -1950,7 +1950,7 @@ BARS.defineActions(function() { }) new Action('auto_set_cullfaces', { icon: 'smart_button', - condition: () => Modes.edit && Format.java_face_properties, + condition: () => Modes.edit && Format.cullfaces, click() { if (!Cube.selected.length) { BarItems.select_all.click(); @@ -1977,7 +1977,8 @@ BARS.defineActions(function() { }); }) - Undo.finishEdit('Automatically set cullfaces') + updateSelection(); + Undo.finishEdit('Automatically set cullfaces'); } }) }) diff --git a/js/modeling/transform_gizmo.js b/js/modeling/transform_gizmo.js index ca57842c6..b97c2550b 100644 --- a/js/modeling/transform_gizmo.js +++ b/js/modeling/transform_gizmo.js @@ -960,14 +960,32 @@ display_base.getWorldPosition(Transformer.position); Transformer.position.sub(scene.position); + // todo: Fix positions when both rotation pivot and scale pivot are used if (Toolbox.selected.transformerMode === 'translate') { Transformer.rotation_ref = display_area; } else if (Toolbox.selected.transformerMode === 'scale') { + if (DisplayMode.slot.scale_pivot) { + let pivot_offset = new THREE.Vector3().fromArray(DisplayMode.slot.scale_pivot).multiplyScalar(-16); + pivot_offset.x *= DisplayMode.slot.scale[0]; + pivot_offset.y *= DisplayMode.slot.scale[1]; + pivot_offset.z *= DisplayMode.slot.scale[2]; + pivot_offset.applyQuaternion(display_base.getWorldQuaternion(new THREE.Quaternion())); + Transformer.position.sub(pivot_offset); + } + Transformer.rotation_ref = display_base; - } else if (Toolbox.selected.transformerMode === 'rotate' && display_slot == 'gui') { - Transformer.rotation_ref = display_gui_rotation + } else if (Toolbox.selected.transformerMode === 'rotate') { + if (DisplayMode.slot.rotation_pivot) { + let pivot_offset = new THREE.Vector3().fromArray(DisplayMode.slot.rotation_pivot).multiplyScalar(-16); + pivot_offset.applyQuaternion(display_base.getWorldQuaternion(new THREE.Quaternion())); + Transformer.position.sub(pivot_offset); + } + + if (display_slot == 'gui') { + Transformer.rotation_ref = display_gui_rotation; + } } Transformer.update() diff --git a/js/modes.js b/js/modes.js index 535202ce2..ca75c6657 100644 --- a/js/modes.js +++ b/js/modes.js @@ -205,6 +205,16 @@ BARS.defineActions(function() { Panels.uv.handle.firstChild.textContent = tl('mode.paint'); + let fill_mode = BarItems.fill_mode.value; + if (!Condition(BarItems.fill_mode.options[fill_mode].condition)) { + for (let key in BarItems.fill_mode.options) { + if (Condition(BarItems.fill_mode.options[key].condition)) { + BarItems.fill_mode.set(key); + break; + } + } + } + UVEditor.vue.setMode('paint'); three_grid.visible = false; }, diff --git a/js/outliner/cube.js b/js/outliner/cube.js index 2c9742d02..cc7719f31 100644 --- a/js/outliner/cube.js +++ b/js/outliner/cube.js @@ -619,7 +619,7 @@ class Cube extends OutlinerElement { vec.set(...coords.V3_subtract(this.origin)); vec.applyMatrix4( this.mesh.matrixWorld ); let arr = vec.toArray(); - arr.V3_add(8, 8, 8); + arr.V3_add(8, 0, 8); return arr; }) } @@ -952,6 +952,7 @@ class Cube extends OutlinerElement { } }}); }}, + "randomize_marker_colors", {name: 'menu.cube.texture', icon: 'collections', condition: () => !Format.single_texture && !Format.per_group_texture, children: function() { var arr = [ {icon: 'crop_square', name: Format.single_texture_default ? 'menu.cube.texture.default' : 'menu.cube.texture.blank', click(cube) { @@ -959,11 +960,27 @@ class Cube extends OutlinerElement { obj.applyTexture(false, true) }, 'texture blank', Format.per_group_texture ? 'all_in_group' : null) }} - ] + ]; + let applied_texture; + main_loop: for (let cube of Cube.selected) { + face_loop: for (let fkey in cube.faces) { + let texture = cube.faces[fkey].getTexture(); + if (texture) { + if (!applied_texture) { + applied_texture = texture; + } else if (applied_texture != texture) { + applied_texture = null; + break main_loop; + break face_loop; + } + } + } + } Texture.all.forEach(function(t) { arr.push({ name: t.name, icon: (t.mode === 'link' ? t.img : t.source), + marked: t == applied_texture, click: function(cube) { cube.forSelected(function(obj) { obj.applyTexture(t, true) @@ -975,6 +992,7 @@ class Cube extends OutlinerElement { }}, 'edit_material_instances', 'element_render_order', + 'cube_light_emission', new MenuSeparator('manage'), 'rename', 'toggle_visibility', @@ -993,6 +1011,7 @@ new Property(Cube, 'string', 'name', {default: 'cube'}); new Property(Cube, 'boolean', 'box_uv', {merge_validation: (value) => Format.optional_box_uv || value === Format.box_uv}); new Property(Cube, 'boolean', 'rescale'); new Property(Cube, 'boolean', 'locked'); +new Property(Cube, 'number', 'light_emission'); new Property(Cube, 'enum', 'render_order', {default: 'default', values: ['default', 'behind', 'in_front']}); OutlinerElement.registerType(Cube, 'cube'); @@ -1126,7 +1145,10 @@ new NodePreviewController(Cube, { mesh.geometry.setIndex(indices) if (Project.view_mode === 'solid') { - mesh.material = Canvas.solidMaterial + mesh.material = Canvas.monochromaticSolidMaterial + + } else if (Project.view_mode === 'colored_solid') { + mesh.material = Canvas.coloredSolidMaterials[element.color % Canvas.emptyMaterials.length] } else if (Project.view_mode === 'wireframe') { mesh.material = Canvas.wireframeMaterial @@ -1448,7 +1470,7 @@ BARS.defineActions(function() { let group = getCurrentGroup(); if (group) { base_cube.addTo(group) - base_cube.color = group.color; + if (settings.inherit_parent_color.value) base_cube.color = group.color; } if (Texture.all.length && Format.single_texture) { @@ -1477,7 +1499,7 @@ BARS.defineActions(function() { if (Group.selected) Group.selected.unselect() base_cube.select() - Canvas.updateView({elements: [base_cube], element_aspects: {transform: true, geometry: true}}) + Canvas.updateView({elements: [base_cube], element_aspects: {transform: true, geometry: true, faces: true}}) Undo.finishEdit('Add cube', {outliner: true, elements: selected, selection: true}); Blockbench.dispatchEvent( 'add_cube', {object: base_cube} ) @@ -1583,4 +1605,34 @@ BARS.defineActions(function() { BarItems.element_render_order.set(element.render_order); } }) + + new NumSlider('cube_light_emission', { + category: 'edit', + condition: {features: ['java_cube_shading_properties']}, + settings: { + min: 0, max: 15, default: 0, + show_bar: true + }, + getInterval(event) { + return 1; + }, + get() { + return Cube.selected[0]?.light_emission ?? 0; + }, + change(modify) { + for (let cube of Cube.selected) { + cube.light_emission = modify(cube.light_emission); + } + }, + onBefore() { + Undo.initEdit({elements: Cube.selected}); + }, + onAfter() { + Undo.finishEdit('Change cube light emission'); + } + }) + Blockbench.on('update_selection', () => { + let value = Cube.selected[0]?.light_emission ?? 0; + BarItems.cube_light_emission.setValue(value); + }) }) \ No newline at end of file diff --git a/js/outliner/group.js b/js/outliner/group.js index 6cf144f8d..eaddf05d9 100644 --- a/js/outliner/group.js +++ b/js/outliner/group.js @@ -457,6 +457,7 @@ class Group extends OutlinerNode { } }}) }}, + "randomize_marker_colors", {name: 'menu.cube.texture', icon: 'collections', condition: () => Format.per_group_texture, children() { function applyTexture(texture_value, undo_message) { let affected_groups = Group.all.filter(g => g.selected); @@ -476,6 +477,7 @@ class Group extends OutlinerNode { arr.push({ name: t.name, icon: (t.mode === 'link' ? t.img : t.source), + marked: t.uuid == Group.selected.texture, click(group) { applyTexture(t.uuid, 'Apply texture to group'); } @@ -727,7 +729,7 @@ BARS.defineActions(function() { ]).show(event.target); }, autocomplete(text, position) { - let test = Animator.autocompleteMolang(text, position, 'binding'); + let test = MolangAutocomplete.BedrockBindingContext.autocomplete(text, position); return test; } }, diff --git a/js/outliner/mesh.js b/js/outliner/mesh.js index e94169ed8..a26033caa 100644 --- a/js/outliner/mesh.js +++ b/js/outliner/mesh.js @@ -882,6 +882,7 @@ class Mesh extends OutlinerElement { } }}) }}, + "randomize_marker_colors", {name: 'menu.cube.texture', icon: 'collections', condition: () => !Format.single_texture, children() { var arr = [ {icon: 'crop_square', name: Format.single_texture_default ? 'menu.cube.texture.default' : 'menu.cube.texture.blank', click(mesh) { @@ -891,10 +892,26 @@ class Mesh extends OutlinerElement { }, 'texture blank') }} ] + let applied_texture; + main_loop: for (let mesh of Mesh.selected) { + face_loop: for (let fkey in mesh.faces) { + let texture = mesh.faces[fkey].getTexture(); + if (texture) { + if (!applied_texture) { + applied_texture = texture; + } else if (applied_texture != texture) { + applied_texture = null; + break main_loop; + break face_loop; + } + } + } + } Texture.all.forEach((t) => { arr.push({ name: t.name, icon: (t.mode === 'link' ? t.img : t.source), + marked: t == applied_texture, click(mesh) { let all_faces = BarItems.selection_mode.value != 'face' || Mesh.selected[0]?.getSelectedFaces().length == 0; mesh.forSelected((obj) => { @@ -1073,7 +1090,10 @@ new NodePreviewController(Mesh, { let {mesh} = element; if (Project.view_mode === 'solid') { - mesh.material = Canvas.solidMaterial + mesh.material = Canvas.monochromaticSolidMaterial + + } else if (Project.view_mode === 'colored_solid') { + mesh.material = Canvas.coloredSolidMaterials[element.color] } else if (Project.view_mode === 'wireframe') { mesh.material = Canvas.wireframeMaterial diff --git a/js/outliner/null_object.js b/js/outliner/null_object.js index 5ec33eff5..5e49da5a1 100644 --- a/js/outliner/null_object.js +++ b/js/outliner/null_object.js @@ -240,6 +240,7 @@ BARS.defineActions(function() { return { name: node.name + (node.uuid == NullObject.selected[0].ik_target ? ' (✔)' : ''), icon: node instanceof Locator ? 'fa-anchor' : 'folder', + marked: node.uuid == NullObject.selected[0].ik_target, color: markerColors[node.color % markerColors.length] && markerColors[node.color % markerColors.length].standard, click() { Undo.initEdit({elements: NullObject.selected}); @@ -280,6 +281,7 @@ BARS.defineActions(function() { return { name: node.name + (node.uuid == NullObject.selected[0].ik_source ? ' (✔)' : ''), icon: node instanceof Locator ? 'fa-anchor' : 'folder', + marked: node.uuid == NullObject.selected[0].ik_source, color: markerColors[node.color % markerColors.length] && markerColors[node.color % markerColors.length].standard, click() { Undo.initEdit({elements: NullObject.selected}); diff --git a/js/outliner/outliner.js b/js/outliner/outliner.js index 87942e63e..42d0d7433 100644 --- a/js/outliner/outliner.js +++ b/js/outliner/outliner.js @@ -25,30 +25,37 @@ const Outliner = { title: tl('switches.lock'), icon: ' fas fa-lock', icon_off: ' fas fa-lock-open', - advanced_option: true + advanced_option: true, + visibilityException(node) { + return node.locked + } }, export: { id: 'export', title: tl('switches.export'), - icon: ' fa fa-camera', + icon: ' far fa-square-check', icon_off: ' far fa-window-close', - advanced_option: true + advanced_option: true, + condition: {modes: ['edit']}, + visibilityException(node) { + return !node.export; + } }, shade: { id: 'shade', - condition: () => Format.java_face_properties, + condition: {modes: ['edit'], features: ['java_cube_shading_properties']}, title: tl('switches.shade'), icon: 'fa fa-star', icon_off: 'far fa-star', - advanced_option: true + advanced_option: true, }, mirror_uv: { id: 'mirror_uv', - condition: (element) => (element instanceof Group) ? element.children.find(c => c.box_uv) : element.box_uv, + condition: {modes: ['edit'], method: (element) => (element instanceof Group) ? element.children.find(c => c.box_uv) : element.box_uv}, title: tl('switches.mirror'), icon: 'icon-mirror_x icon', icon_off: 'icon-mirror_x icon', - advanced_option: true + advanced_option: true, }, autouv: { id: 'autouv', @@ -57,6 +64,7 @@ const Outliner = { icon_off: ' far fa-times-circle', icon_alt: ' fa fa-magic', advanced_option: true, + condition: {modes: ['edit']}, getState(element) { if (!element.autouv) { return false @@ -434,8 +442,9 @@ class OutlinerElement extends OutlinerNode { return false; } //Shift - var just_selected = [] - if (event && (event.shiftKey === true || Pressing.overrides.shift) && this.getParentArray().includes(selected[selected.length-1]) && !Modes.paint && is_outliner_click) { + var just_selected = []; + let allow_multi_select = (!Modes.paint || (Toolbox.selected.id == 'fill_tool' && BarItems.fill_mode.value == 'selected_elements')); + if (event && allow_multi_select && (event.shiftKey === true || Pressing.overrides.shift) && this.getParentArray().includes(selected[selected.length-1]) && is_outliner_click) { var starting_point; var last_selected = selected[selected.length-1] this.getParentArray().forEach((s, i) => { @@ -466,7 +475,7 @@ class OutlinerElement extends OutlinerNode { }) //Control - } else if (event && !Modes.paint && (event.ctrlOrCmd || event.shiftKey || Pressing.overrides.ctrl || Pressing.overrides.shift)) { + } else if (event && allow_multi_select && (event.ctrlOrCmd || event.shiftKey || Pressing.overrides.ctrl || Pressing.overrides.shift)) { if (selected.includes(this)) { selected.replace(selected.filter((e) => { return e !== this @@ -484,7 +493,9 @@ class OutlinerElement extends OutlinerNode { if (Group.selected) Group.selected.unselect() this.selectLow() just_selected.push(this) - this.showInOutliner() + if (settings.outliner_reveal_on_select.value) { + this.showInOutliner() + } } if (Group.selected) { Group.selected.unselect() @@ -1399,7 +1410,7 @@ Interface.definePanels(function() { `
    ` + @@ -1426,7 +1437,7 @@ Interface.definePanels(function() { //Other Entries '' + '', props: { @@ -1437,11 +1448,12 @@ Interface.definePanels(function() { depth: Number }, data() {return { - outliner_colors: settings.outliner_colors + outliner_colors: settings.outliner_colors, + markerColors }}, computed: { indentation() { - return limitNumber(this.depth, 0, (this.width-100) / 16) * 16; + return limitNumber(this.depth, 0, (this.width-100) / 16); }, visible_children() { let filtered = this.node.children; @@ -1474,6 +1486,19 @@ Interface.definePanels(function() { return [(typeof btn.icon_alt == 'function' ? btn.icon_alt(node) : btn.icon_alt), 'icon_alt']; } }, + getBtnTooltip: function (btn, node) { + let value = node.isIconEnabled(btn); + let text = btn.title + ': '; + if (value === true) { + return text + tl('generic.on'); + } else if (value === false) { + return text + tl('generic.off'); + } else if (value == 'alt') { + return text + tl(`switches.${btn.id}.alt`); + } else { + return text + value; + } + }, doubleClickIcon(node) { if (node.children && node.children.length) { node.isOpen = !node.isOpen; @@ -1575,7 +1600,7 @@ Interface.definePanels(function() { value = (typeof value == 'number') ? (value+1) % 3 : !value; if (!toggle_config) return; - if (!(key == 'locked' || key == 'visibility' || Modes.edit)) return; + if (!Condition(toggle_config.condition, selected[0])) return; function move(e2) { convertTouchEvent(e2); diff --git a/js/outliner/texture_mesh.js b/js/outliner/texture_mesh.js index f115bb26e..57dc977df 100644 --- a/js/outliner/texture_mesh.js +++ b/js/outliner/texture_mesh.js @@ -294,8 +294,11 @@ new NodePreviewController(TextureMesh, { let {mesh} = element; if (Project.view_mode === 'solid') { - mesh.material = Canvas.solidMaterial + mesh.material = Canvas.monochromaticSolidMaterial + } else if (Project.view_mode === 'colored_solid') { + mesh.material = Canvas.coloredSolidMaterials[0] + } else if (Project.view_mode === 'wireframe') { mesh.material = Canvas.wireframeMaterial diff --git a/js/plugin_loader.js b/js/plugin_loader.js index 87670bccb..456421fb2 100644 --- a/js/plugin_loader.js +++ b/js/plugin_loader.js @@ -36,6 +36,40 @@ const Plugins = { StateMemory.init('installed_plugins', 'array') Plugins.installed = StateMemory.installed_plugins = StateMemory.installed_plugins.filter(p => p && typeof p == 'object'); +async function runPluginFile(path, plugin_id) { + let file_content; + if (path.startsWith('http')) { + if (!path.startsWith('https')) { + throw 'Cannot load plugins over http: ' + path; + } + await new Promise((resolve, reject) => { + $.ajax({ + cache: false, + url: path, + success(data) { + file_content = data; + resolve(); + }, + error() { + reject('Failed to load plugin ' + plugin_id); + } + }); + }) + + } else if (isApp) { + file_content = fs.readFileSync(path, {encoding: 'utf-8'}); + + } else { + throw 'Failed to load plugin: Unknown URL format' + } + if (typeof file_content != 'string' || file_content.length < 20) { + throw `Issue loading plugin "${plugin_id}": Plugin file empty`; + } + let func = new Function(file_content + `\n//# sourceURL=PLUGINS/(Plugin):${plugin_id}.js`); + func(); + return file_content; +} + class Plugin { constructor(id, data) { this.id = id||'unknown'; @@ -137,10 +171,7 @@ class Plugin { if (!isApp && this.new_repository_format) { path = `${Plugins.path}${scope.id}/${scope.id}.js`; } - $.getScript(path, (content, status, context) => { - if (!content || content.length <= 20) { - console.warn(`Issue loading plugin "${this.id}": Plugin file empty`); - } + runPluginFile(path, this.id).then((content) => { if (cb) cb.bind(scope)() scope.bindGlobalData(first) if (first && scope.oninstall) { @@ -148,13 +179,14 @@ class Plugin { } if (first) Blockbench.showQuickMessage(tl('message.installed_plugin', [this.title])); resolve() - }).fail(() => { + }).catch((error) => { if (isApp) { console.log('Could not find file of plugin "'+scope.id+'". Uninstalling it instead.') scope.uninstall() } if (first) Blockbench.showQuickMessage(tl('message.installed_plugin_fail', [this.title])); reject() + console.error(error) }) this.remember() scope.installed = true; @@ -300,44 +332,39 @@ class Plugin { this.source = 'file'; this.tags.safePush('Local'); - return await new Promise((resolve, reject) => { - - if (isApp) { - $.getScript(file.path, () => { - if (window.plugin_data) { - scope.id = (plugin_data && plugin_data.id)||pathToName(file.path) - scope.extend(plugin_data) - scope.bindGlobalData() - } - if (first && scope.oninstall) { - scope.oninstall() - } - scope.installed = true; - scope.path = file.path; - this.remember(); - Plugins.sort(); - resolve() - }).fail(reject) - } else { - try { - new Function(file.content)(); - } catch (err) { - reject(err) - } - if (!Plugins.registered && window.plugin_data) { - scope.id = (plugin_data && plugin_data.id)||scope.id + if (isApp) { + let content = await runPluginFile(file.path, this.id).catch((error) => { + console.error(error); + }); + if (content) { + if (window.plugin_data) { + scope.id = (plugin_data && plugin_data.id)||pathToName(file.path) scope.extend(plugin_data) scope.bindGlobalData() } if (first && scope.oninstall) { scope.oninstall() } - scope.installed = true - this.remember() - Plugins.sort() - resolve() + scope.path = file.path; } - }) + } else { + try { + new Function(file.content + `\n//# sourceURL=PLUGINS/(Plugin):${this.id}.js`)(); + } catch (err) { + reject(err) + } + if (!Plugins.registered && window.plugin_data) { + scope.id = (plugin_data && plugin_data.id)||scope.id + scope.extend(plugin_data) + scope.bindGlobalData() + } + if (first && scope.oninstall) { + scope.oninstall() + } + } + this.installed = true; + this.remember(); + Plugins.sort(); } async loadFromURL(url, first) { if (first) { @@ -354,36 +381,36 @@ class Plugin { this.tags.safePush('Remote'); this.source = 'url'; - await new Promise((resolve, reject) => { - $.getScript(url, () => { - if (window.plugin_data) { - this.id = (plugin_data && plugin_data.id)||pathToName(url) - this.extend(plugin_data) - this.bindGlobalData() - } - if (first && this.oninstall) { - this.oninstall() - } - this.installed = true - this.path = url - this.remember() - Plugins.sort() - // Save - if (isApp) { - var file = originalFs.createWriteStream(Plugins.path+this.id+'.js') + let content = await runPluginFile(url, this.id).catch((error) => { + if (isApp) { + this.load().then(resolve).catch(resolve) + } + console.error(error); + }) + if (content) { + if (window.plugin_data) { + this.id = (plugin_data && plugin_data.id)||pathToName(url) + this.extend(plugin_data) + this.bindGlobalData() + } + if (first && this.oninstall) { + this.oninstall() + } + this.installed = true + this.path = url + this.remember() + Plugins.sort() + // Save + if (isApp) { + await new Promise((resolve) => { + let file = originalFs.createWriteStream(Plugins.path+this.id+'.js') https.get(url, (response) => { response.pipe(file); response.on('end', resolve) }).on('error', reject); - } else { - resolve() - } - }).fail(() => { - if (isApp) { - this.load().then(resolve).catch(resolve) - } - }) - }) + }) + } + } return this; } remember(id = this.id, path = this.path) { @@ -604,8 +631,12 @@ class Plugin { this.details[key + '_full'] = date.full; } if (this.source == 'store') { - if (!this.details.bug_tracker) this.details.bug_tracker = `https://github.com/JannisX11/blockbench-plugins/issues/new?title=[${this.title}]`; - if (!this.details.repository) this.details.repository = `https://github.com/JannisX11/blockbench-plugins/tree/master/plugins/${this.id + (this.new_repository_format ? '' : '.js')}`; + if (!this.details.bug_tracker) { + this.details.bug_tracker = `https://github.com/JannisX11/blockbench-plugins/issues/new?title=[${this.title}]`; + } + if (!this.details.repository) { + this.details.repository = `https://github.com/JannisX11/blockbench-plugins/tree/master/plugins/${this.id + (this.new_repository_format ? '' : '.js')}`; + } let github_path = (this.new_repository_format ? (this.id+'/'+this.id) : this.id) + '.js'; let commit_url = `https://api.github.com/repos/JannisX11/blockbench-plugins/commits?path=plugins/${github_path}`; @@ -895,22 +926,25 @@ BARS.defineActions(function() { }, computed: { plugin_search() { - var name = this.search_term.toUpperCase() - return this.items.filter(item => { - if ((this.tab == 'installed') == item.installed) { - if (name.length > 0) { - return ( - item.id.toUpperCase().includes(name) || - item.title.toUpperCase().includes(name) || - item.description.toUpperCase().includes(name) || - item.author.toUpperCase().includes(name) || - item.tags.find(tag => tag.toUpperCase().includes(name)) - ) - } - return true; - } - return false; - }) + let search_name = this.search_term.toUpperCase(); + if (search_name) { + let filtered = this.items.filter(item => { + return ( + item.id.toUpperCase().includes(search_name) || + item.title.toUpperCase().includes(search_name) || + item.description.toUpperCase().includes(search_name) || + item.author.toUpperCase().includes(search_name) || + item.tags.find(tag => tag.toUpperCase().includes(search_name)) + ) + }); + let installed = filtered.filter(p => p.installed); + let not_installed = filtered.filter(p => !p.installed); + return installed.concat(not_installed); + } else { + return this.items.filter(item => { + return (this.tab == 'installed') == item.installed; + }) + } }, suggested_rows() { let tags = ["Animation"]; @@ -1263,12 +1297,12 @@ BARS.defineActions(function() {
    home
    -
    +
    ${tl('dialog.plugins.installed')}
    ${tl('dialog.plugins.available')}
    - +
    diff --git a/js/texturing/texture_generator.js b/js/texturing/texture_generator.js index 7f22599e0..ff51d47c0 100644 --- a/js/texturing/texture_generator.js +++ b/js/texturing/texture_generator.js @@ -47,7 +47,6 @@ const TextureGenerator = { rearrange_uv:{label: 'dialog.create_texture.rearrange_uv', description: 'dialog.create_texture.rearrange_uv.desc', type: 'checkbox', value: true, condition: (form) => (form.type == 'template')}, box_uv: {label: 'dialog.project.uv_mode.box_uv', type: 'checkbox', value: false, condition: (form) => (form.type == 'template' && !Project.box_uv && Cube.all.length)}, - compress: {label: 'dialog.create_texture.compress', description: 'dialog.create_texture.compress.desc', type: 'checkbox', value: true, condition: (form) => (form.type == 'template' && Project.box_uv && form.rearrange_uv)}, power: {label: 'dialog.create_texture.power', description: 'dialog.create_texture.power.desc', type: 'checkbox', value: true, condition: (form) => (form.type !== 'blank' && (form.rearrange_uv || form.type == 'color_map'))}, double_use: {label: 'dialog.create_texture.double_use', description: 'dialog.create_texture.double_use.desc', type: 'checkbox', value: true, condition: ((form) => (form.type == 'template' && form.rearrange_uv))}, combine_polys: {label: 'dialog.create_texture.combine_polys', description: 'dialog.create_texture.combine_polys.desc', type: 'checkbox', value: true, condition: (form) => (form.type == 'template' && form.rearrange_uv && Mesh.selected.length)}, @@ -80,7 +79,6 @@ const TextureGenerator = { form: { color: {label: 'data.color', type: 'color', colorpicker: TextureGenerator.background_color, toggle_enabled: true, toggle_default: false}, box_uv: {label: 'dialog.project.uv_mode.box_uv', type: 'checkbox', value: false, condition: (form) => (!Project.box_uv && Cube.all.length)}, - compress: {label: 'dialog.create_texture.compress', description: 'dialog.create_texture.compress.desc', type: 'checkbox', value: true, condition: (form) => Project.box_uv}, power: {label: 'dialog.create_texture.power', description: 'dialog.create_texture.power.desc', type: 'checkbox', value: Math.isPowerOfTwo(texture.width)}, double_use: {label: 'dialog.create_texture.double_use', description: 'dialog.create_texture.double_use.desc', type: 'checkbox', value: true}, combine_polys: {label: 'dialog.create_texture.combine_polys', description: 'dialog.create_texture.combine_polys.desc', type: 'checkbox', value: true, condition: (form) => (Mesh.selected.length)}, @@ -95,11 +93,7 @@ const TextureGenerator = { if (Format.single_texture) { options.texture = Texture.getDefault() } - if (Project.box_uv || options.box_uv) { - TextureGenerator.generateBoxTemplate(options, texture); - } else { - TextureGenerator.generateFaceTemplate(options, texture); - } + TextureGenerator.generateTemplate(options, texture); return false; } }).show() @@ -143,11 +137,7 @@ const TextureGenerator = { return texture; } if (options.type == 'template') { - if (Project.box_uv || options.box_uv) { - TextureGenerator.generateBoxTemplate(options, makeTexture); - } else { - TextureGenerator.generateFaceTemplate(options, makeTexture); - } + TextureGenerator.generateTemplate(options, makeTexture); } else if (options.type == 'color_map') { TextureGenerator.generateColorMapTemplate(options, makeTexture); } else { @@ -189,320 +179,6 @@ const TextureGenerator = { this.width = 2* (this.x + this.z); return this; }, - //BoxUV Template - generateBoxTemplate(options, makeTexture) { - var res = options.resolution; - var background_color = options.color; - var texture = options.texture; - var min_size = (Project.box_uv || options.box_uv) ? 0 : 1; - var res_multiple = res / 16 - var templates = []; - var doubles = {}; - var extend_x = 0; - var extend_y = 0; - var avg_size = 0; - var new_resolution = []; - var cubes = (Format.single_texture && typeof makeTexture == 'function') ? Cube.all.slice() : Cube.selected.slice(); - - Undo.initEdit({ - textures: makeTexture instanceof Texture ? [makeTexture] : [], - elements: cubes, - uv_only: true, - bitmap: true, - selected_texture: true, - uv_mode: true - }) - - for (var i = cubes.length-1; i >= 0; i--) { - let obj = cubes[i]; - if (obj.visibility === true) { - let template = new TextureGenerator.boxUVCubeTemplate(obj, min_size); - let mirror_modeling_duplicate = BarItems.mirror_modeling.value && MirrorModeling.cached_elements[obj.uuid] && MirrorModeling.cached_elements[obj.uuid].is_copy; - if (mirror_modeling_duplicate) continue; - - if (options.double_use && Project.box_uv && Texture.all.length) { - let double_key = [...obj.uv_offset, ...obj.size(undefined, true), ].join('_') - if (doubles[double_key]) { - // improve chances that original is not mirrored - if (doubles[double_key][0].obj.mirror_uv && !obj.mirror_uv) { - templates[templates.indexOf(doubles[double_key][0])] = template; - doubles[double_key].splice(0, 0, template) - } else { - doubles[double_key].push(template) - } - doubles[double_key][0].duplicates = doubles[double_key]; - continue; - } else { - doubles[double_key] = [template] - } - } - templates.push(template) - avg_size += templates[templates.length-1].template_size - } - } - //Cancel if no cubes - if (templates.length == 0) { - if (Mesh.selected.length) { - Blockbench.showMessageBox({ - title: 'message.no_valid_elements', - message: 'message.meshes_and_box_uv', - icon: 'fa-gem' - }) - } else { - Blockbench.showMessage('message.no_valid_elements', 'center'); - } - return; - - } else if (Mesh.selected.length) { - Blockbench.showMessage('message.meshes_and_box_uv', 'center'); - } - templates.sort(function(a,b) { - return b.template_size - a.template_size; - }) - - if (options.rearrange_uv) { - - if (options.compress || makeTexture instanceof Texture) { - var fill_map = {}; - - if (makeTexture instanceof Texture) { - extend_x = makeTexture.width / res_multiple; - extend_y = makeTexture.height / res_multiple; - Cube.all.forEach(element => { - for (let fkey in element.faces) { - let face = element.faces[fkey]; - if (face.getTexture() !== makeTexture) continue; - - let rect = face.getBoundingRect(); - for (let x = Math.floor(rect.ax); x < Math.ceil(rect.bx); x++) { - for (let y = Math.floor(rect.ay); y < Math.ceil(rect.by); y++) { - if (!fill_map[x]) fill_map[x] = {}; - fill_map[x][y] = true; - } - } - - } - }) - } - - function occupy(x, y) { - if (!fill_map[x]) fill_map[x] = {} - fill_map[x][y] = true - } - function check(x, y) { - return fill_map[x] && fill_map[x][y] - } - function forTemplatePixel(tpl, sx, sy, cb) { - let w = tpl.width; - let h = tpl.height; - if (options.padding) { - w++; h++; - } - for (var x = 0; x < w; x++) { - for (var y = 0; y < h; y++) { - if (y >= tpl.z || (x >= tpl.z && x < (tpl.z + 2*tpl.x + (options.padding ? 1 : 0)))) { - if (cb(sx+x, sy+y)) return; - } - } - } - } - function place(tpl, x, y) { - var works = true; - forTemplatePixel(tpl, x, y, (tx, ty) => { - if (check(tx, ty)) { - works = false; - return true; - } - }) - if (works) { - forTemplatePixel(tpl, x, y, occupy) - tpl.posx = x; - tpl.posy = y; - extend_x = Math.max(extend_x, x + tpl.width); - extend_y = Math.max(extend_y, y + tpl.height); - return true; - } - } - templates.forEach(tpl => { - var vert = extend_x > extend_y; - //Scan for empty spot - - for (var line = 0; line < 2e3; line++) { - for (var space = 0; space <= line; space++) { - if (place(tpl, space, line)) return; - if (space == line) continue; - if (place(tpl, line, space)) return; - } - } - }) - } else { - //OLD ------------------------------------------- - var lines = [[]] - var line_length = Math.sqrt(cubes.length/2) - avg_size /= templates.length - var o = 0 - var i = 0 - var ox = 0 - templates.forEach(function(tpl) { - if (ox >= line_length) { - o = ox = 0 - i++ - lines[i] = [] - } - lines[i][o] = tpl - o++; - ox += tpl.template_size/avg_size - }) - - lines.forEach(function(temps) { - - var x_pos = 0 - var y_pos = 0 //Y Position of current area relative to this bone - var filled_x_pos = 0; - var max_height = 0 - //Find the maximum height of the line - temps.forEach(function(t) { - max_height = Math.max(max_height, t.height + (options.padding ? 1 : 0)) - }) - //Place - temps.forEach(function(t) { - let w = t.width; - let h = t.height; - if (options.padding) { - w++; h++; - } - if (y_pos > 0 && (y_pos + h) <= max_height) { - //same column - t.posx = x_pos - t.posy = y_pos + extend_y - filled_x_pos = Math.max(filled_x_pos, x_pos + w) - y_pos += h - } else { - //new column - x_pos = filled_x_pos - y_pos = h - t.posx = x_pos - t.posy = extend_y - filled_x_pos = Math.max(filled_x_pos, x_pos + w) - } - //size of widest bone - extend_x = Math.max(extend_x, filled_x_pos) - }) - extend_y += max_height - }) - } - - var max_size = Math.max(extend_x, extend_y); - if (options.power) { - max_size = Math.getNextPower(max_size, 16); - } else { - max_size = Math.ceil(max_size/16)*16; - } - new_resolution = [max_size, max_size]; - } else { - new_resolution = [Project.texture_width, Project.texture_height]; - } - - if (background_color) { - background_color = background_color.toRgbString() - } - let canvas = document.createElement('canvas'); - let ctx = canvas.getContext('2d'); - ctx.imageSmoothingEnabled = false; - if (makeTexture instanceof Texture) { - if (makeTexture.mode === 'link') { - makeTexture.convertToInternal(); - } - canvas.width = Math.max(new_resolution[0] * res_multiple, makeTexture.width); - canvas.height = Math.max(new_resolution[1] * res_multiple, makeTexture.height); - ctx.drawImage(makeTexture.img, 0, 0); - } else { - canvas.width = new_resolution[0] * res_multiple; - canvas.height = new_resolution[1] * res_multiple; - } - - - //Drawing - TextureGenerator.old_project_resolution = [Project.texture_width, Project.texture_height] - - templates.forEach(function(t) { - if (options.rearrange_uv) { - t.obj.uv_offset[0] = t.posx; - t.obj.uv_offset[1] = t.posy; - if (Project.box_uv || Format.optional_box_uv) t.obj.box_uv = true; - //if true, dupes must be flipped - let reverse_flip = t.obj.mirror_uv; - t.obj.mirror_uv = false; - - if (t.duplicates) { - t.duplicates.forEach(dupl => { - if (dupl.obj !== t.obj) { - dupl.obj.mirror_uv = dupl.obj.mirror_uv !== reverse_flip; - } - }) - } - } - TextureGenerator.paintCubeBoxTemplate(t.obj, texture, canvas, t, false, res_multiple); - }) - - var dataUrl = canvas.toDataURL() - var texture = typeof makeTexture == 'function' ? makeTexture(dataUrl) : makeTexture; - if (makeTexture instanceof Texture) { - makeTexture.updateSource(dataUrl); - } - let affected_elements = TextureGenerator.changeUVResolution(new_resolution[0], new_resolution[1], texture); - - if (texture) { - cubes.forEach(function(cube) { - if (!Format.single_texture) { - cube.applyTexture(texture, true); - } - cube.preview_controller.updateUV(cube); - cube.autouv = 0; - }) - } - if (options.box_uv && !Project.box_uv && Project.optional_box_uv) { - Project.box_uv = true; - } - templates.forEach(function(t) { - if (options.rearrange_uv) { - t.obj.uv_offset[0] = t.posx; - t.obj.uv_offset[1] = t.posy; - - if (t.duplicates) { - t.duplicates.forEach(dupl => { - if (dupl.obj !== t.obj) { - dupl.obj.uv_offset[0] = t.posx; - dupl.obj.uv_offset[1] = t.posy; - } - }) - } - } - }) - - updateSelection() - let updated_elements = cubes.slice(); - updated_elements.safePush(...affected_elements); - Undo.finishEdit('Create template', { - textures: [texture], - bitmap: true, - elements: updated_elements, - selected_texture: true, - uv_only: true, - uv_mode: true - }) - // Warning - if (cubes.find(cube => { - let size = cube.size(); - return (size[0] > 0.001 && size[0] < 0.999) || (size[1] > 0.001 && size[1] < 0.999) || (size[2] > 0.001 && size[2] < 0.999) - })) { - Blockbench.showMessageBox({ - title: 'message.small_face_dimensions.title', - message: tl('message.small_face_dimensions.message') + (Format.optional_box_uv ? '\n\n' + tl('message.small_face_dimensions.face_uv') : ''), - icon: 'warning', - }) - } - }, boxUVdrawTemplateRectangle(border_color, color, face, coords, texture, canvas, res_multiple) { if (typeof background_color === 'string') { border_color = background_color @@ -594,7 +270,7 @@ const TextureGenerator = { paintCubeBoxTemplate(cube, texture, canvas, template, transparent, res_multiple) { if (!template) { - template = new TextureGenerator.boxUVCubeTemplate(cube, Project.box_uv ? 0 : 1); + template = new TextureGenerator.boxUVCubeTemplate(cube, cube.box_uv ? 0 : 1); } for (var face in TextureGenerator.face_data) { @@ -647,11 +323,13 @@ const TextureGenerator = { } }, //Face Template - generateFaceTemplate(options, makeTexture) { - + async generateTemplate(options, makeTexture) { let res_multiple = options.resolution / 16; let background_color = options.color; let new_resolution = []; + let box_uv_templates = []; + let doubles = {}; + let avg_size = 0; let vec1 = new THREE.Vector3(), vec2 = new THREE.Vector3(), @@ -698,6 +376,28 @@ const TextureGenerator = { return uv_id + ':' + (texture ? texture.uuid : 'blank'); } + let cancelled = false; + const progress_dialog = new Dialog('generate_template_progress', { + title: 'action.create_texture', + cancel_on_click_outside: false, + progress_bar: {}, + buttons: ['dialog.cancel'], + onCancel() { + Undo.cancelEdit(false); + cancelled = true; + Blockbench.setProgress(); + } + }); + progress_dialog.show(); + + let last_timeout = performance.now(); + async function setProgress(progress) { + Blockbench.setProgress(progress); + progress_dialog.progress_bar.setProgress(progress ?? 0); + await new Promise(resolve => setTimeout(resolve, 1)); + last_timeout = performance.now(); + } + Undo.initEdit({ textures: makeTexture instanceof Texture ? [makeTexture] : [], elements: element_list, @@ -711,28 +411,56 @@ const TextureGenerator = { let mirror_modeling_duplicate = BarItems.mirror_modeling.value && MirrorModeling.cached_elements[element.uuid] && MirrorModeling.cached_elements[element.uuid].is_copy; if (mirror_modeling_duplicate) return; if (element instanceof Cube) { - for (let fkey in element.faces) { - let face = element.faces[fkey]; - let tex = face.getTexture(); - if (tex !== null) { - let face_old_pos_id; - if (tex instanceof Texture) { - face_old_pos_id = faceOldPositionIdentifier(face); - if (!double_use_faces[face_old_pos_id]) double_use_faces[face_old_pos_id] = []; - double_use_faces[face_old_pos_id].push([element, face]); + if (element.box_uv || options.box_uv) { + + let template = new TextureGenerator.boxUVCubeTemplate(element, element.box_uv ? 0 : 1); + let mirror_modeling_duplicate = BarItems.mirror_modeling.value && MirrorModeling.cached_elements[element.uuid] && MirrorModeling.cached_elements[element.uuid].is_copy; + if (mirror_modeling_duplicate) return; + + if (options.double_use && Texture.all.length) { + let double_key = [...element.uv_offset, ...element.size(undefined, true), ].join('_') + if (doubles[double_key]) { + // improve chances that original is not mirrored + if (doubles[double_key][0].obj.mirror_uv && !element.mirror_uv) { + box_uv_templates[box_uv_templates.indexOf(doubles[double_key][0])] = template; + doubles[double_key].splice(0, 0, template) + } else { + doubles[double_key].push(template) + } + doubles[double_key][0].duplicates = doubles[double_key]; + return; + } else { + doubles[double_key] = [template] } - let x = 0; - let y = 0; - switch (fkey) { - case 'north': x = element.size(0); y = element.size(1); break; - case 'east': x = element.size(2); y = element.size(1); break; - case 'south': x = element.size(0); y = element.size(1); break; - case 'west': x = element.size(2); y = element.size(1); break; - case 'up': x = element.size(0); y = element.size(2); break; - case 'down': x = element.size(0); y = element.size(2); break; + } + element.box_uv = true; + box_uv_templates.push(template) + avg_size += box_uv_templates[box_uv_templates.length-1].template_size + + } else { + for (let fkey in element.faces) { + let face = element.faces[fkey]; + let tex = face.getTexture(); + if (tex !== null) { + let face_old_pos_id; + if (tex instanceof Texture) { + face_old_pos_id = faceOldPositionIdentifier(face); + if (!double_use_faces[face_old_pos_id]) double_use_faces[face_old_pos_id] = []; + double_use_faces[face_old_pos_id].push([element, face]); + } + let x = 0; + let y = 0; + switch (fkey) { + case 'north': x = element.size(0); y = element.size(1); break; + case 'east': x = element.size(2); y = element.size(1); break; + case 'south': x = element.size(0); y = element.size(1); break; + case 'west': x = element.size(2); y = element.size(1); break; + case 'up': x = element.size(0); y = element.size(2); break; + case 'down': x = element.size(0); y = element.size(2); break; + } + let face_rect = new faceRect(element, fkey, tex, x, y, face_old_pos_id); + face_list.push(face_rect); } - let face_rect = new faceRect(element, fkey, tex, x, y, face_old_pos_id); - face_list.push(face_rect); } } } else { @@ -1108,16 +836,25 @@ const TextureGenerator = { } }) - if (face_list.length == 0) { - Blockbench.showMessage('message.no_valid_elements', 'center') + if (face_list.length == 0 && box_uv_templates.length == 0) { + progress_dialog.close(); + Blockbench.showMessage('message.no_valid_elements', 'center'); return; } + if (options.box_uv && !Project.box_uv) { + Project.box_uv = true; + } + + box_uv_templates.sort(function(a,b) { + return b.template_size - a.template_size; + }) + if (options.rearrange_uv) { - var extend_x = 0; - var extend_y = 0; - var fill_map = {}; + let extend_x = 0; + let extend_y = 0; + let fill_map = {}; // When appending to template, mark already used spots as occupied if (makeTexture instanceof Texture) { @@ -1147,6 +884,15 @@ const TextureGenerator = { }) } + + var max_size = Math.max(extend_x, extend_y); + if (options.power) { + max_size = Math.getNextPower(max_size, 16); + } else { + max_size = Math.ceil(max_size/16)*16; + } + new_resolution = [max_size, max_size]; + // Check for double occupancy if (options.double_use) { function findFaceListEntry(data, face_old_pos_id) { @@ -1185,6 +931,24 @@ const TextureGenerator = { return b.size - a.size; }) + + /*function forTemplatePixel(tpl, sx, sy, cb) { + let w = tpl.width; + let h = tpl.height; + if (options.padding) { + w++; h++; + } + for (var x = 0; x < w; x++) { + for (var y = 0; y < h; y++) { + if (y >= tpl.z || (x >= tpl.z && x < (tpl.z + 2*tpl.x + (options.padding ? 1 : 0)))) { + if (cb(sx+x, sy+y)) return; + } + } + } + }*/ + + + function occupy(x, y) { if (!fill_map[x]) fill_map[x] = {} fill_map[x][y] = true @@ -1238,16 +1002,41 @@ const TextureGenerator = { return true; } } - face_list.forEach(tpl => { + + let total = (box_uv_templates.length * 6 + face_list.length) * 1.05; + let handled = 0; + outer_loop: + for (let tpl of box_uv_templates) { + if (performance.now() - last_timeout > 24) { + await setProgress(handled/total); + } + if (cancelled) return; + handled += 6; + //Scan for empty spot + for (let line = 0; line < 2e3; line++) { + for (let space = 0; space <= line; space++) { + if (place(tpl, space, line)) continue outer_loop; + if (space == line) continue; + if (place(tpl, line, space)) continue outer_loop; + } + } + } + outer_loop2: + for (let tpl of face_list) { + if (performance.now() - last_timeout > 24) { + await setProgress(handled/total); + } + if (cancelled) return; + handled += 1; //Scan for empty spot for (var line = 0; line < 2e3; line++) { for (var space = 0; space <= line; space++) { - if (place(tpl, space, line)) return; + if (place(tpl, space, line)) continue outer_loop2; if (space == line) continue; - if (place(tpl, line, space)) return; + if (place(tpl, line, space)) continue outer_loop2; } } - }) + } var max_size = Math.max(extend_x, extend_y) @@ -1275,6 +1064,8 @@ const TextureGenerator = { }) } + await setProgress(1); + if (background_color) { background_color = background_color.toRgbString() } @@ -1293,6 +1084,8 @@ const TextureGenerator = { canvas.height = new_resolution[1] * res_multiple; } + TextureGenerator.old_project_resolution = [Project.texture_width, Project.texture_height] + function getPolygonOccupationMatrix(vertex_uv_faces, width, height) { let matrix = {}; function vSub(a, b) { @@ -1635,8 +1428,10 @@ const TextureGenerator = { } } if (!face.uv[vkey]) face.uv[vkey] = []; - face.uv[vkey][0] = source.vertex_uvs[source_fkey][source_vkey][0] + source.posx; - face.uv[vkey][1] = source.vertex_uvs[source_fkey][source_vkey][1] + source.posy; + if (source.vertex_uvs[source_fkey][source_vkey]) { + face.uv[vkey][0] = source.vertex_uvs[source_fkey][source_vkey][0] + source.posx; + face.uv[vkey][1] = source.vertex_uvs[source_fkey][source_vkey][1] + source.posy; + } }) }) } @@ -1649,6 +1444,26 @@ const TextureGenerator = { applyUV(ftemp, ftemp); } }) + box_uv_templates.forEach((t) => { + if (options.rearrange_uv) { + t.obj.uv_offset[0] = t.posx; + t.obj.uv_offset[1] = t.posy; + if (Project.box_uv || Format.optional_box_uv) t.obj.box_uv = true; + //if true, dupes must be flipped + let reverse_flip = t.obj.mirror_uv; + t.obj.mirror_uv = false; + + if (t.duplicates) { + t.duplicates.forEach(dupl => { + if (dupl.obj !== t.obj) { + dupl.obj.mirror_uv = dupl.obj.mirror_uv !== reverse_flip; + } + }) + } + } + TextureGenerator.paintCubeBoxTemplate(t.obj, options.texture, canvas, t, false, res_multiple); + }) + var dataUrl = canvas.toDataURL() let texture = typeof makeTexture == 'function' ? makeTexture(dataUrl) : makeTexture; if (makeTexture instanceof Texture) { @@ -1673,6 +1488,23 @@ const TextureGenerator = { } }) } + if (options.rearrange_uv) { + box_uv_templates.forEach(function(t) { + t.obj.uv_offset[0] = t.posx; + t.obj.uv_offset[1] = t.posy; + + if (t.duplicates) { + t.duplicates.forEach(dupl => { + if (dupl.obj !== t.obj) { + dupl.obj.uv_offset[0] = t.posx; + dupl.obj.uv_offset[1] = t.posy; + } + }) + } + }) + } + + updateSelection() setTimeout(Canvas.updatePixelGrid, 1); Undo.finishEdit(makeTexture instanceof Texture ? 'Append to template' : 'Create template', { @@ -1683,6 +1515,20 @@ const TextureGenerator = { uv_only: true, uv_mode: true }) + progress_dialog.close(); + setProgress(); + // Warning + if (element_list.find(element => { + if (element instanceof Cube == false || !element.box_uv) return false; + let size = element.size(); + return (size[0] > 0.001 && size[0] < 0.999) || (size[1] > 0.001 && size[1] < 0.999) || (size[2] > 0.001 && size[2] < 0.999) + })) { + Blockbench.showMessageBox({ + title: 'message.small_face_dimensions.title', + message: tl('message.small_face_dimensions.message') + (Format.optional_box_uv ? '\n\n' + tl('message.small_face_dimensions.face_uv') : ''), + icon: 'warning', + }) + } }, generateColorMapTemplate(options, cb) { @@ -1727,7 +1573,7 @@ const TextureGenerator = { } new_resolution = [max_size, max_size]; - if (background_color.getAlpha() != 0) { + if (background_color && background_color.getAlpha() != 0) { background_color = background_color.toRgbString() } var canvas = document.createElement('canvas') diff --git a/js/texturing/texture_groups.js b/js/texturing/texture_groups.js new file mode 100644 index 000000000..68cf0b315 --- /dev/null +++ b/js/texturing/texture_groups.js @@ -0,0 +1,135 @@ + +class TextureGroup { + constructor(data, uuid) { + this.uuid = uuid ?? guid(); + this.folded = false; + if (data) this.extend(data); + } + extend(data) { + for (let key in TextureGroup.properties) { + TextureGroup.properties[key].merge(this, data) + } + return this; + } + add() { + TextureGroup.all.push(this); + return this; + } + select() { + let textures = this.getTextures(); + if (textures[0]) textures[0].select(); + for (let texture of textures) { + if (!texture.selected) texture.multi_selected = true; + } + return this; + } + remove() { + TextureGroup.all.remove(this); + } + showContextMenu(event) { + Prop.active_panel = 'textures'; + TextureGroup.active_menu_group = this; + this.menu.open(event, this); + } + rename() { + Blockbench.textPrompt('generic.rename', this.name, (name) => { + if (name && name !== this.name) { + Undo.initEdit({texture_groups: [this]}); + this.name = name; + Undo.finishEdit('Rename texture group'); + } + }) + return this; + } + getTextures() { + return Texture.all.filter(texture => texture.group == this.uuid); + } + getUndoCopy() { + let copy = { + uuid: this.uuid, + index: TextureGroup.all.indexOf(this) + }; + for (let key in TextureGroup.properties) { + TextureGroup.properties[key].copy(this, copy) + } + return copy; + } + getSaveCopy() { + let copy = { + uuid: this.uuid + }; + for (let key in TextureGroup.properties) { + TextureGroup.properties[key].copy(this, copy) + } + return copy; + } +} +Object.defineProperty(TextureGroup, 'all', { + get() { + return Project.texture_groups || []; + }, + set(arr) { + Project.texture_groups.replace(arr); + } +}) +new Property(TextureGroup, 'string', 'name', {default: tl('data.texture_group')}); + +TextureGroup.prototype.menu = new Menu('texture_group', [ + new MenuSeparator('manage'), + 'rename', + { + icon: 'fa-leaf', + name: 'menu.texture_group.resolve', + click(texture_group) { + let textures = texture_group.getTextures(); + Undo.initEdit({textures, texture_groups: [texture_group]}); + texture_group.remove(); + textures.forEach(texture => { + texture.group = ''; + }) + Undo.finishEdit('Resolve texture group', {textures, texture_groups: []}); + } + }, +], { + onClose() { + setTimeout(() => { + TextureGroup.active_menu_group = null; + }, 10); + } +}) +/** +ToDo: +- Auto-generate groups +- Grid view? +- Search + */ + +SharedActions.add('rename', { + condition: () => Prop.active_panel == 'textures' && TextureGroup.active_menu_group, + run() { + TextureGroup.active_menu_group.rename(); + } +}) + + +BARS.defineActions(function() { + new Action('create_texture_group', { + icon: 'perm_media', + category: 'textures', + click() { + let texture_group = new TextureGroup(); + texture_group.name = 'Texture Group ' + (TextureGroup.all.length+1); + let textures_to_add = Texture.all.filter(tex => tex.selected || tex.multi_selected); + Undo.initEdit({texture_groups: [], textures: textures_to_add}); + if (textures_to_add.length) { + for (let texture of textures_to_add) { + texture.group = texture_group.uuid; + } + let first = Texture.selected || textures_to_add[0]; + texture_group.name = first.name.replace(/\.\w+$/, '') + ' Group'; + } + texture_group.add(false); + Undo.finishEdit('Add texture group', {texture_groups: [texture_group], textures: textures_to_add}); + } + }) +}); \ No newline at end of file diff --git a/js/texturing/textures.js b/js/texturing/textures.js index d00c05735..2e3923b9d 100644 --- a/js/texturing/textures.js +++ b/js/texturing/textures.js @@ -2,9 +2,9 @@ //Textures class Texture { constructor(data, uuid) { - var scope = this; + let scope = this; //Info - for (var key in Texture.properties) { + for (let key in Texture.properties) { Texture.properties[key].reset(this); } //meta @@ -206,6 +206,7 @@ class Texture { scope.canvas.width = scope.width; scope.canvas.height = scope.height; scope.ctx.drawImage(img, 0, 0); + if (UVEditor.vue.texture == this) UVEditor.updateOverlayCanvas(); } if (this.flags.has('update_uv_size_from_resolution')) { @@ -306,8 +307,9 @@ class Texture { } } get frameCount() { - if (Format.animated_textures && this.ratio !== 1 && this.ratio !== (this.getUVWidth() / this.getUVHeight()) && 1/this.ratio % 1 === 0) { - return (this.getUVWidth() / this.getUVHeight()) / this.ratio + if (Format.animated_textures && this.ratio !== (this.getUVWidth() / this.getUVHeight())) { + let frames = Math.ceil((this.getUVWidth() / this.getUVHeight()) / this.ratio - 0.05); + if (frames > 1) return frames; } } get display_height() { @@ -342,6 +344,15 @@ class Texture { case 3: return tl('texture.error.parent'); break; } } + getGroup() { + if (!this.group) return; + let group = TextureGroup.all.find(group => group.uuid == this.group); + if (group) { + return group; + } else { + this.group = ''; + } + } getUndoCopy(bitmap) { var copy = {}; for (var key in Texture.properties) { @@ -902,6 +913,10 @@ class Texture { if (this.layers_enabled && !this.selected_layer && this.layers[0]) { this.layers[0].select(); } + if (this.group) { + let group = this.getGroup(); + if (group) group.folded = false; + } this.scrollTo(); if (this.render_mode == 'layered') { Canvas.updatePixelGrid() @@ -937,7 +952,6 @@ class Texture { Project.textures.push(this) } Blockbench.dispatchEvent( 'add_texture', {texture: this}) - loadTextureDraggable() if ((Format.single_texture || Format.single_texture_default) && Cube.all.length) { Canvas.updateAllFaces() @@ -1401,7 +1415,7 @@ class Texture { return this; } scrollTo() { - var el = $(`#texture_list > li[texid=${this.uuid}]`) + var el = $(`#texture_list li.texture[texid=${this.uuid}]`) if (el.length === 0 || Texture.all.length < 2) return; var outliner_pos = $('#texture_list').offset().top @@ -1734,6 +1748,7 @@ class Texture { this.source = this.canvas.toDataURL('image/png', 1); this.updateImageFromCanvas(); } + if (UVEditor.vue.texture == this) UVEditor.updateOverlayCanvas(); } updateChangesAfterEdit() { if (this.layers_enabled) { @@ -2053,6 +2068,7 @@ class Texture { new Property(Texture, 'string', 'folder') new Property(Texture, 'string', 'namespace') new Property(Texture, 'string', 'id') + new Property(Texture, 'string', 'group') new Property(Texture, 'number', 'width') new Property(Texture, 'number', 'height') new Property(Texture, 'number', 'uv_width') @@ -2095,146 +2111,12 @@ function saveTextures(lazy = false) { }) } function loadTextureDraggable() { - Vue.nextTick(function() { - setTimeout(function() { - $('li.texture:not(.ui-draggable)').draggable({ - revertDuration: 0, - cursorAt: { left: 2, top: -5 }, - revert: 'invalid', - appendTo: 'body', - zIndex: 19, - distance: 12, - delay: 120, - helper: function(e) { - var t = $(e.target) - if (!t.hasClass('texture')) t = t.parent() - if (!t.hasClass('texture')) t = t.parent() - return t.find('.texture_icon_wrapper').clone().addClass('texture_drag_helper').attr('texid', t.attr('texid')) - }, - drag: function(event, ui) { - - $('.outliner_node[order]').attr('order', null); - $('.drag_hover').removeClass('drag_hover'); - $('.texture[order]').attr('order', null) - if ($('#cubes_list li.outliner_node:hover').length) { - var tar = $('#cubes_list li.outliner_node:hover').last() - tar.addClass('drag_hover').attr('order', '0'); - /* - var element = Outliner.root.findRecursive('uuid', tar.attr('id')) - if (element) { - tar.attr('order', '0') - }*/ - } else if ($('#texture_list li:hover').length) { - let node = $('#texture_list > .texture:hover') - if (node.length) { - var target_tex = Texture.all.findInArray('uuid', node.attr('texid')); - index = Texture.all.indexOf(target_tex); - let offset = event.clientY - node[0].offsetTop; - if (offset > 24) { - node.attr('order', '1') - } else { - node.attr('order', '-1') - } - } - } - }, - stop: function(event, ui) { - setTimeout(function() { - $('.texture[order]').attr('order', null); - $('.outliner_node[order]').attr('order', null); - var tex = Texture.all.findInArray('uuid', ui.helper.attr('texid')); - if (!tex) return; - if ($('.preview:hover').length > 0) { - var data = Canvas.raycast(event) - if (data.element && data.face) { - var elements = data.element.selected ? UVEditor.getMappableElements() : [data.element]; - - if (Format.per_group_texture) { - elements = []; - let groups = Group.selected ? [Group.selected] : []; - Outliner.selected.forEach(el => { - if (el.faces && el.parent instanceof Group) groups.safePush(el.parent); - }); - Undo.initEdit({outliner: true}); - groups.forEach(group => { - group.texture = ''; - group.forEachChild(child => { - if (child.preview_controller?.updateFaces) child.preview_controller.updateFaces(child); - }) - }) - } else { - Undo.initEdit({elements}) - elements.forEach(element => { - element.applyTexture(tex, event.shiftKey || Pressing.overrides.shift || [data.face]) - }) - } - Undo.finishEdit('Apply texture') - } - } else if ($('#texture_list:hover').length > 0) { - let index = Texture.all.length-1 - let node = $('#texture_list > .texture:hover') - if (node.length) { - var target_tex = Texture.all.findInArray('uuid', node.attr('texid')); - index = Texture.all.indexOf(target_tex); - let own_index = Texture.all.indexOf(tex) - if (own_index == index) return; - if (own_index < index) index--; - if (event.clientY - node[0].offsetTop > 24) index++; - } - Undo.initEdit({texture_order: true}) - Texture.all.remove(tex) - Texture.all.splice(index, 0, tex) - Canvas.updateLayeredTextures() - Undo.finishEdit('Reorder textures') - } else if ($('#cubes_list:hover').length) { - - let target_node = $('#cubes_list li.outliner_node.drag_hover').last().get(0); - $('.drag_hover').removeClass('drag_hover'); - if (!target_node) return; - let uuid = target_node.id; - let target = OutlinerNode.uuids[uuid]; - - let array = []; - if (target.type === 'group') { - target.forEachChild((element) => { - array.push(element); - }) - } else { - array = selected.includes(target) ? selected.slice() : [target]; - } - array = array.filter(element => element.applyTexture); - - if (Format.per_group_texture) { - let group = target.type === 'group' ? target : null; - if (!group) group = target.parent; - - array = []; - Undo.initEdit({group}); - group.texture = tex.uuid; - group.forEachChild(child => { - if (child.preview_controller?.updateFaces) child.preview_controller.updateFaces(child); - }) - } else { - Undo.initEdit({elements: array, uv_only: true}) - array.forEach(element => { - element.applyTexture(tex, true); - }); - } - Undo.finishEdit('Apply texture'); - UVEditor.loadData(); - - } else if ($('#uv_viewport:hover').length) { - UVEditor.applyTexture(tex); - } - }, 10) - } - }) - }, 42) - }) + console.warn('loadTextureDraggable no longer exists'); } function unselectTextures() { Texture.all.forEach(function(s) { s.selected = false; + s.multi_selected = false; }) Texture.selected = undefined; Canvas.updateLayeredTextures(); @@ -2331,15 +2213,15 @@ BARS.defineActions(function() { icon: 'library_add', category: 'textures', keybind: new Keybind({key: 't', ctrl: true}), - click() { - var start_path; + click(event, context) { + let start_path; if (!isApp) {} else if (Texture.all.length > 0) { - var arr = Texture.all[0].path.split(osfs) + let arr = Texture.all[0].path.split(osfs) arr.splice(-1) start_path = arr.join(osfs) } else if (Project.export_path) { - var arr = Project.export_path.split(osfs) + let arr = Project.export_path.split(osfs) arr.splice(-3) arr.push('textures') start_path = arr.join(osfs) @@ -2352,11 +2234,15 @@ BARS.defineActions(function() { multiple: true, startpath: start_path }, function(results) { - var new_textures = [] - Undo.initEdit({textures: new_textures}) + let new_textures = []; + let texture_group = context instanceof TextureGroup ? context : Texture.selected?.getGroup(); + Undo.initEdit({textures: new_textures}); results.forEach(function(f) { - var t = new Texture({name: f.name}).fromFile(f).add(false, true).fillParticle() - new_textures.push(t) + let t = new Texture({name: f.name}).fromFile(f).add(false, true).fillParticle(); + new_textures.push(t); + if (texture_group) { + t.group = texture_group.uuid; + } }) Undo.finishEdit('Add texture') }) @@ -2422,6 +2308,321 @@ BARS.defineActions(function() { Interface.definePanels(function() { + let texture_component = Vue.extend({ + props: { + texture: Texture + }, + data() {return { + temp_color: null + }}, + methods: { + getDescription(texture) { + if (texture.error) { + return texture.getErrorMessage() + } else { + let message = texture.width + ' x ' + texture.height + 'px'; + if (!Format.image_editor) { + let uv_size = texture.width / texture.getUVWidth() * 16; + message += ` (${trimFloatNumber(uv_size, 2)}x)`; + } + if (texture.frameCount > 1) { + message += ` - ${texture.currentFrame+1}/${texture.frameCount}` + } + return message; + } + }, + getTextureIconOffset(texture) { + if (!texture.currentFrame) return; + let val = texture.currentFrame * -48 * (texture.display_height / texture.width); + return `${val}px`; + }, + highlightTexture(event) { + if (!Format.single_texture && this.texture.error) { + let material = this.texture.getMaterial(); + let color = material.uniforms.LIGHTCOLOR.value; + this.temp_color = new THREE.Color().copy(color); + color.r += 0.3; + color.g += 0.3; + color.b += 0.3; + setTimeout(() => { + }, 150); + } + }, + unhighlightTexture(event) { + if (!Format.single_texture && this.temp_color) { + let material = this.texture.getMaterial(); + let color = material.uniforms.LIGHTCOLOR.value; + color.copy(this.temp_color); + } + }, + dragTexture(e1) { + if (e1.button == 1) return; + if (getFocusedTextInput()) return; + convertTouchEvent(e1); + + let texture = this.texture; + let active = false; + let helper; + let timeout; + let last_event = e1; + let vue_scope = this; + + // scrolling + let list = document.getElementById('texture_list'); + let list_offset = $(list).offset(); + let scrollInterval = function() { + if (!active) return; + if (mouse_pos.y < list_offset.top) { + list.scrollTop += (mouse_pos.y - list_offset.top) / 7 - 3; + } else if (mouse_pos.y > list_offset.top + list.clientHeight) { + list.scrollTop += (mouse_pos.y - (list_offset.top + list.clientHeight)) / 6 + 3; + } + } + let scrollIntervalID; + + function move(e2) { + convertTouchEvent(e2); + let offset = [ + e2.clientX - e1.clientX, + e2.clientY - e1.clientY, + ] + if (!active) { + let distance = Math.sqrt(Math.pow(offset[0], 2) + Math.pow(offset[1], 2)) + if (Blockbench.isTouch) { + if (distance > 20 && timeout) { + clearTimeout(timeout); + timeout = null; + } else { + document.getElementById('texture_list').scrollTop += last_event.clientY - e2.clientY; + } + } else if (distance > 6) { + active = true; + } + } + if (!active) return; + + if (e2) e2.preventDefault(); + + if (open_menu) open_menu.hide(); + + if (!helper) { + helper = vue_scope.$el.cloneNode(); + helper.classList.add('texture_drag_helper'); + helper.setAttribute('texid', texture.uuid); + + document.body.append(helper); + + scrollIntervalID = setInterval(scrollInterval, 1000/60) + + Blockbench.addFlag('dragging_textures'); + } + helper.style.left = `${e2.clientX}px`; + helper.style.top = `${e2.clientY}px`; + + // drag + $('.outliner_node[order]').attr('order', null); + $('.drag_hover').removeClass('drag_hover'); + $('.texture[order]').attr('order', null) + + if (isNodeUnderCursor(document.getElementById('cubes_list'), e2)) { + for (let node of document.querySelectorAll('.outliner_object')) { + if (isNodeUnderCursor(node, e2)) { + let parent = node.parentNode; + parent.classList.add('drag_hover'); + parent.setAttribute('order', '0'); + return; + } + } + } + if (isNodeUnderCursor(document.querySelector('#texture_list'), e2)) { + + let texture_target = findNodeUnderCursor('#texture_list li.texture', e2); + if (texture_target) { + let offset = e2.clientY - $(texture_target).offset().top; + texture_target.setAttribute('order', offset > 24 ? '1' : '-1'); + return; + } + let group_target = findNodeUnderCursor('#texture_list .texture_group_head', e2); + if (group_target) { + group_target.classList.add('drag_hover'); + group_target.setAttribute('order', '0'); + return; + } + + let nodes = document.querySelectorAll('#texture_list > li'); + if (nodes.length) { + let target = nodes[nodes.length-1]; + target.setAttribute('order', '1'); + target.classList.add('drag_hover'); + } + } + last_event = e2; + } + async function off(e2) { + convertTouchEvent(e2); + if (helper) helper.remove(); + clearInterval(scrollIntervalID); + removeEventListeners(document, 'mousemove touchmove', move); + removeEventListeners(document, 'mouseup touchend', off); + e2.stopPropagation(); + + let outliner_target_node = document.querySelector('#cubes_list li.outliner_node.drag_hover'); + + $('.outliner_node[order]').attr('order', null); + $('.drag_hover').removeClass('drag_hover'); + $('.texture[order]').attr('order', null) + if (Blockbench.isTouch) clearTimeout(timeout); + + if (!active || Menu.open) return; + + //await new Promise(r => setTimeout(r, 10)); + + Blockbench.removeFlag('dragging_textures'); + + + if (isNodeUnderCursor(Interface.preview, e2)) { + var data = Canvas.raycast(e2) + if (data.element && data.face) { + var elements = data.element.selected ? UVEditor.getMappableElements() : [data.element]; + + if (Format.per_group_texture) { + elements = []; + let groups = Group.selected ? [Group.selected] : []; + Outliner.selected.forEach(el => { + if (el.faces && el.parent instanceof Group) groups.safePush(el.parent); + }); + Undo.initEdit({outliner: true}); + groups.forEach(group => { + group.texture = texture.uuid; + group.forEachChild(child => { + if (child.preview_controller?.updateFaces) child.preview_controller.updateFaces(child); + }) + }) + } else { + Undo.initEdit({elements}); + elements.forEach(element => { + element.applyTexture(texture, e2.shiftKey || Pressing.overrides.shift || [data.face]) + }) + } + Undo.finishEdit('Apply texture') + } + } else if (isNodeUnderCursor(document.getElementById('texture_list'), e2)) { + + let index = Texture.all.length-1; + let texture_node = findNodeUnderCursor('#texture_list li.texture', e2); + let target_group_head = findNodeUnderCursor('#texture_list .texture_group_head', e2); + let new_group = ''; + if (target_group_head) { + new_group = target_group_head.parentNode.id; + + } else if (texture_node) { + let target_tex = Texture.all.findInArray('uuid', texture_node.getAttribute('texid')); + index = Texture.all.indexOf(target_tex); + let own_index = Texture.all.indexOf(texture) + if (own_index == index) return; + let offset = e2.clientY - $(texture_node).offset().top; + if (own_index < index) index--; + if (offset > 24) index++; + new_group = target_tex.group; + } + Undo.initEdit({texture_order: true, textures: texture.group != new_group ? [texture] : null}); + Texture.all.remove(texture); + Texture.all.splice(index, 0, texture); + texture.group = new_group; + Canvas.updateLayeredTextures(); + Undo.finishEdit('Rearrange textures'); + + } else if (outliner_target_node) { + let uuid = outliner_target_node.id; + let target = OutlinerNode.uuids[uuid]; + + let array = []; + if (target.type === 'group') { + target.forEachChild((element) => { + array.push(element); + }) + } else { + array = selected.includes(target) ? selected.slice() : [target]; + } + array = array.filter(element => element.applyTexture); + + if (Format.per_group_texture) { + let group = target.type === 'group' ? target : null; + if (!group) group = target.parent; + + array = []; + Undo.initEdit({group}); + group.texture = texture.uuid; + group.forEachChild(child => { + if (child.preview_controller?.updateFaces) child.preview_controller.updateFaces(child); + }) + } else { + Undo.initEdit({elements: array, uv_only: true}) + array.forEach(element => { + element.applyTexture(texture, true); + }); + } + Undo.finishEdit('Apply texture'); + UVEditor.loadData(); + + } else if (isNodeUnderCursor(document.getElementById('uv_viewport'), e2)) { + UVEditor.applyTexture(texture); + } + } + + if (Blockbench.isTouch) { + timeout = setTimeout(() => { + active = true; + move(e1); + }, 320) + } + + addEventListeners(document, 'mousemove touchmove', move, {passive: false}); + addEventListeners(document, 'mouseup touchend', off, {passive: false}); + } + }, + template: ` +
  • +
    + + priority_high + +
    +
    +
    {{ texture.name }}
    +
    {{ getDescription(texture) }}
    +
    + check + +
  • + ` + }) + new Panel('textures', { icon: 'fas.fa-images', growable: true, @@ -2438,6 +2639,7 @@ Interface.definePanels(function() { children: [ 'import_texture', 'create_texture', + 'create_texture_group', 'append_to_template', ] }) @@ -2450,26 +2652,16 @@ Interface.definePanels(function() { name: 'panel-textures', data() { return { textures: Texture.all, + texture_groups: TextureGroup.all, currentFrame: 0, }}, + components: {'Texture': texture_component}, methods: { openMenu(event) { Interface.Panels.textures.menu.show(event) }, - getDescription(texture) { - if (texture.error) { - return texture.getErrorMessage() - } else { - let message = texture.width + ' x ' + texture.height + 'px'; - if (!Format.image_editor) { - let uv_size = texture.width / texture.getUVWidth() * 16; - message += ` (${trimFloatNumber(uv_size, 2)}x)`; - } - if (texture.frameCount > 1) { - message += ` - ${texture.currentFrame+1}/${texture.frameCount}` - } - return message; - } + addTextureToGroup(texture_group) { + BarItems.import_texture.click(0, texture_group); }, slideTimelinePointer(e1) { let scope = this; @@ -2483,12 +2675,15 @@ Interface.definePanels(function() { convertTouchEvent(e2); let pos = e2.clientX - timeline_offset; + let previous_frame = scope.currentFrame; scope.currentFrame = Math.clamp(Math.round((pos / timeline_width) * maxFrameCount), 0, maxFrameCount-1); + if (previous_frame == scope.currentFrame) return; let textures = Texture.all.filter(tex => tex.frameCount > 1); Texture.all.forEach(tex => { tex.currentFrame = (scope.currentFrame % tex.frameCount) || 0; }) + UVEditor.previous_animation_frame = previous_frame; TextureAnimator.update(textures); } function off(e3) { @@ -2499,6 +2694,20 @@ Interface.definePanels(function() { addEventListeners(document, 'mouseup touchend', off); slide(e1); }, + scrollTimeline(event) { + + let slider_tex = [Texture.getDefault(), ...Texture.all].find(tex => tex && tex.frameCount > 1); + if (!slider_tex) return; + UVEditor.previous_animation_frame = slider_tex.currentFrame; + let offset = Math.sign(event.deltaY); + slider_tex.currentFrame = (slider_tex.currentFrame + slider_tex.frameCount + offset) % slider_tex.frameCount; + + let textures = Texture.all.filter(tex => tex.frameCount > 1); + textures.forEach(tex => { + tex.currentFrame = (slider_tex.currentFrame % tex.frameCount) || 0; + }) + TextureAnimator.update(textures); + }, getPlayheadPos() { if (!this.$refs.timeline) return 0; let width = this.$refs.timeline.clientWidth - 8; @@ -2509,58 +2718,189 @@ Interface.definePanels(function() { this.textures.forEach(tex => { if (tex.frameCount > count) count = tex.frameCount; }); + if (count == 1) return 0; return count; }, - getTextureIconOffset(texture) { - if (!texture.currentFrame) return; - let val = texture.currentFrame * -48 * (texture.display_height / texture.width); - return `${val}px`; + unselect(event) { + if (Blockbench.hasFlag('dragging_textures')) return; + unselectTextures(); + }, + getUngroupedTextures() { + return this.textures.filter(tex => !(tex.group && TextureGroup.all.find(g => g.uuid == tex.group))); + }, + dragTextureGroup(texture_group, e1) { + if (e1.button == 1) return; + convertTouchEvent(e1); + + let active = false; + let helper; + let timeout; + let last_event = e1; + let texture_group_target_node; + let order = 0; + + // scrolling + let list = document.getElementById('texture_list'); + let list_offset = $(list).offset(); + let scrollInterval = function() { + if (!active) return; + if (mouse_pos.y < list_offset.top) { + list.scrollTop += (mouse_pos.y - list_offset.top) / 7 - 3; + } else if (mouse_pos.y > list_offset.top + list.clientHeight) { + list.scrollTop += (mouse_pos.y - (list_offset.top + list.clientHeight)) / 6 + 3; + } + } + let scrollIntervalID; + + function move(e2) { + convertTouchEvent(e2); + let offset = [ + e2.clientX - e1.clientX, + e2.clientY - e1.clientY, + ] + if (!active) { + let distance = Math.sqrt(Math.pow(offset[0], 2) + Math.pow(offset[1], 2)) + if (Blockbench.isTouch) { + if (distance > 20 && timeout) { + clearTimeout(timeout); + timeout = null; + } else { + document.getElementById('texture_list').scrollTop += last_event.clientY - e2.clientY; + } + } else if (distance > 6) { + active = true; + } + } + if (!active) return; + + if (e2) e2.preventDefault(); + + if (open_menu) open_menu.hide(); + + if (!helper) { + helper = Interface.createElement('div', {class: 'texture_group_drag_helper'}, texture_group.name); + document.body.append(helper); + scrollIntervalID = setInterval(scrollInterval, 1000/60) + } + helper.style.left = `${e2.clientX}px`; + helper.style.top = `${e2.clientY}px`; + + // drag + $('.drag_hover').removeClass('drag_hover'); + $('.texture_group[order]').attr('order', null); + + let target = findNodeUnderCursor('#texture_list .texture_group', e2); + if (target) { + target.classList.add('drag_hover'); + let offset = e2.clientY - $(target).offset().top; + order = offset > (target.clientHeight/2) ? 1 : -1; + target.setAttribute('order', order.toString()); + texture_group_target_node = target; + + } else if (isNodeUnderCursor(document.querySelector('#texture_list'), e2)) { + let nodes = document.querySelectorAll('#texture_list > li.texture_group'); + if (nodes.length) { + let target = nodes[nodes.length-1]; + order = 1; + target.setAttribute('order', '1'); + target.classList.add('drag_hover'); + texture_group_target_node = target; + } + } + last_event = e2; + } + async function off(e2) { + if (helper) helper.remove(); + clearInterval(scrollIntervalID); + removeEventListeners(document, 'mousemove touchmove', move); + removeEventListeners(document, 'mouseup touchend', off); + e2.stopPropagation(); + + $('.drag_hover').removeClass('drag_hover'); + $('.texture_group[order]').attr('order', null); + if (Blockbench.isTouch) clearTimeout(timeout); + + if (!active || Menu.open) return; + + if (texture_group_target_node) { + let index = TextureGroup.all.length-1; + let texture_group_target = TextureGroup.all.find(tg => tg.uuid == texture_group_target_node.id); + if (texture_group_target) { + index = TextureGroup.all.indexOf(texture_group_target) + let own_index = TextureGroup.all.indexOf(texture_group) + if (own_index == index) return; + if (own_index < index) index--; + if (order == 1) index++; + } + Undo.initEdit({texture_groups: [texture_group]}); + TextureGroup.all.remove(texture_group); + TextureGroup.all.splice(index, 0, texture_group); + Undo.finishEdit('Rearrange texture groups'); + + } + } + + if (Blockbench.isTouch) { + timeout = setTimeout(() => { + active = true; + move(e1); + }, 320) + } + + addEventListeners(document, 'mousemove touchmove', move, {passive: false}); + addEventListeners(document, 'mouseup touchend', off, {passive: false}); } }, template: `
    -