From d28fdf9f0bd576081f1abdf031d6118f2ba83d36 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 14:27:58 +0000 Subject: [PATCH 01/15] work on faces --- src/attachments/export_attachment_vs.ts | 15 ++++++++--- src/attachments/importer.ts | 12 +++++++++ src/attachments/presets.ts | 30 ++++++++++++--------- src/export.ts | 7 +++-- src/export_model.ts | 8 +++++- src/export_model/cube/faces.ts | 25 ++++++++++------- src/mods/settingsMod.ts | 36 +++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 28 deletions(-) diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 6011413..76f0ed2 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -145,7 +145,8 @@ export function exportAttachmentsVS(selection: Group[]) { // Filter selection to only ROOT attachment groups // A root attachment is one whose parent either: // 1. Has no clothingSlot, OR - // 2. Has a different clothingSlot + // 2. Has a different clothingSlot, OR + // 3. Is explicitly selected (to allow exporting child groups independently) const rootAttachments = selection.filter(element => { const myClothingSlot = (element as any).clothingSlot; if (!myClothingSlot || myClothingSlot.trim() === '') { @@ -163,8 +164,16 @@ export function exportAttachmentsVS(selection: Group[]) { return true; // Parent has different or no clothingSlot = root attachment } - if (DEBUG) console.log(`[VS Attachment Export] Skipping "${element.name}" - child of attachment with same clothingSlot`); - return false; // Parent has same clothingSlot = child attachment + // If the parent has the same clothingSlot but is NOT in the selection, + // treat this group as a root attachment (allows exporting nested groups independently) + const parentInSelection = parent instanceof Group && selection.includes(parent); + if (!parentInSelection) { + if (DEBUG) console.log(`[VS Attachment Export] "${element.name}" is a root attachment (parent not in selection)`); + return true; + } + + if (DEBUG) console.log(`[VS Attachment Export] Skipping "${element.name}" - child of attachment with same clothingSlot that is also selected`); + return false; // Parent has same clothingSlot and is selected = child attachment }); if (DEBUG) console.log(`[VS Attachment Export] Filtered ${selection.length} elements to ${rootAttachments.length} root attachments`); diff --git a/src/attachments/importer.ts b/src/attachments/importer.ts index 1817eca..4867a4b 100644 --- a/src/attachments/importer.ts +++ b/src/attachments/importer.ts @@ -46,6 +46,12 @@ function buildTextureMap(model: BBModel): Map { if (!texture) { texture = new Texture(texData).add(); + // Preserve textureLocation if it exists in the imported data + if (typeof (texData as any).textureLocation === 'string') { + (texture as any).textureLocation = (texData as any).textureLocation; + logDebug(`[Import BB] Set textureLocation: ${(texData as any).textureLocation}`); + } + // Load texture from embedded base64 or from disk path (when available) if (typeof texData.source === 'string' && texData.source.length > 0) { texture.fromDataURL(texData.source); @@ -61,6 +67,12 @@ function buildTextureMap(model: BBModel): Map { if (typeof texData.uv_width === 'number') texture.uv_width = texData.uv_width; if (typeof texData.uv_height === 'number') texture.uv_height = texData.uv_height; + // Update textureLocation if it exists in the imported data and isn't already set + if (typeof (texData as any).textureLocation === 'string' && !(texture as any).textureLocation) { + (texture as any).textureLocation = (texData as any).textureLocation; + logDebug(`[Import BB] Updated textureLocation: ${(texData as any).textureLocation}`); + } + logDebug( `[Import BB] Using existing texture: ${texture.name}, updated UV size to ${texture.uv_width}x${texture.uv_height}` ); diff --git a/src/attachments/presets.ts b/src/attachments/presets.ts index 93264a2..8d77051 100644 --- a/src/attachments/presets.ts +++ b/src/attachments/presets.ts @@ -17,21 +17,19 @@ export const GLINT_PRESET: AttachmentPreset = { slots: [ 'Outerwear', 'Top', - 'Bottom', - 'Boot', - 'Glove', + 'Bottoms', + 'Shoes', + 'Gloves', + 'Headwear', 'Eyebrows', 'Eyes', 'Nose', 'Mouth', 'FacialHair', - 'Earring', + 'Earrings', 'Ears', 'FaceItem', - 'Face', - 'Hair Base', - 'Hair Extra', - 'Hair Face' + 'Hair' ] }; @@ -93,24 +91,32 @@ const PATH_TO_SLOT_MAPPINGS: { [key: string]: string } = { 'arm': 'Arm', 'emblem': 'Emblem', 'faceitem': 'FaceItem', - 'hair-base': 'Hair Base', - 'hair-extra': 'Hair Extra', - 'hair-face': 'Hair Face', // Glint paths 'outerwear': 'Outerwear', 'top': 'Top', 'bottom': 'Bottoms', - 'boot': 'Boots', + 'bottoms': 'Bottoms', + 'boot': 'Shoes', + 'boots': 'Shoes', + 'shoes': 'Shoes', 'glove': 'Gloves', + 'gloves': 'Gloves', + 'headwear': 'Headwear', 'eyebrows': 'Eyebrows', 'eyes': 'Eyes', 'nose': 'Nose', 'mouth': 'Mouth', 'facialhair': 'FacialHair', 'earring': 'Earrings', + 'earrings': 'Earrings', 'ears': 'Ears', + + // Hair (shared) 'hair': 'Hair', + 'hair-base': 'Hair', + 'hair-extra': 'Hair', + 'hair-face': 'Hair', }; /** diff --git a/src/export.ts b/src/export.ts index 00581cf..0c8b69b 100644 --- a/src/export.ts +++ b/src/export.ts @@ -9,6 +9,8 @@ const fs = requireNativeModule('fs'); // @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); +declare var Settings: any; + /** * Saves a texture to disk, respecting VS folder structure (shapes -> textures). * @param texture - The Blockbench texture object @@ -125,8 +127,9 @@ export function ex(options): VS_Shape { } } - // If still no location and we have an export directory, save the texture - if ((!location || location === "") && exportDir) { + // If still no location and we have an export directory, save the texture (if enabled in settings) + const exportTextures = Settings.get("vs_export_textures") || false; + if ((!location || location === "") && exportDir && exportTextures) { location = saveTextureToFile(texture, textureBaseDir, textureSubPath); } diff --git a/src/export_model.ts b/src/export_model.ts index 5704867..91fad8b 100644 --- a/src/export_model.ts +++ b/src/export_model.ts @@ -1,6 +1,8 @@ import {traverse} from "./export_model/traverse"; import { VS_Element } from "./vs_shape_def"; +declare var Settings: any; + /** * Exports the Blockbench model hierarchy to the Vintage Story element format. * @returns An array of VS model elements. @@ -9,7 +11,11 @@ export function export_model(): Array { const elements = []; const topLevelNodes = Outliner.root; - let offset: [number,number,number] = [0, 0, 0]; + // Apply [8, 0, 8] offset if enabled in settings (default: true) + // Vintage Story engine requires models to be centered at [8, 0, 8] + // instead of [0, 0, 0] to properly align with the block grid + const applyOffset = Settings.get("vs_apply_model_offset") ?? true; + let offset: [number,number,number] = applyOffset ? [8, 0, 8] : [0, 0, 0]; traverse(null, topLevelNodes, elements, offset); return elements; diff --git a/src/export_model/cube/faces.ts b/src/export_model/cube/faces.ts index cb71d49..3052e80 100644 --- a/src/export_model/cube/faces.ts +++ b/src/export_model/cube/faces.ts @@ -2,14 +2,20 @@ import { VS_FACE_PROPS } from "../../property"; import { VS_Direction, VS_Face } from "../../vs_shape_def"; /** - * Passes through UV coordinates and rotation unchanged. - * With XYZ euler order native support, no transformation is needed. + * Transforms UV coordinates and rotation for export. + * Applies 180° rotation correction for downward-facing faces. * @param uv - The UV coordinates [x1, y1, x2, y2] * @param rotation - The face rotation in degrees (0, 90, 180, 270) - * @returns Object with unchanged UV and rotation + * @param direction - The face direction + * @returns Object with transformed UV and rotation */ -function transformUV(uv: [number,number,number,number], rotation: number): { uv: [number,number,number,number], rotation: number } { - // Pass through unchanged to preserve flipped/mirrored UVs +function transformUV(uv: [number, number, number, number], rotation: number, direction: VS_Direction): { uv: [number, number, number, number], rotation: number } { + // Apply 180° rotation for down faces to correct orientation in Vintage Story + if (direction === VS_Direction.DOWN) { + const correctedRotation = (rotation + 180) % 360; + return { uv, rotation: correctedRotation }; + } + // Pass through unchanged for other faces return { uv, rotation }; } @@ -34,7 +40,7 @@ export function process_faces(faces: Partial const isUvDefault = face.uv[0] === 0 && face.uv[1] === 0 && face.uv[2] === 0 && face.uv[3] === 0; const rotation = face.rotation || 0; - const transformed = transformUV(face.uv, rotation); + const transformed = transformUV(face.uv, rotation, direction); const transformedUV = transformed.uv; const transformedRotation = transformed.rotation; @@ -48,7 +54,7 @@ export function process_faces(faces: Partial snapUv: false, }; - for(const prop of VS_FACE_PROPS) { + for (const prop of VS_FACE_PROPS) { const prop_name = prop.name; const value = face[prop_name]; @@ -77,10 +83,9 @@ export function process_faces(faces: Partial */ function get_texture_name(face_texture: string): string { const texture = Texture.all.find(t => t.uuid === face_texture); - if(texture) { + if (texture) { return texture.name; - } else - { + } else { console.error("Texture not found for UUID:", face_texture); return 'missing_texture'; } diff --git a/src/mods/settingsMod.ts b/src/mods/settingsMod.ts index 6d2b4b9..595489d 100644 --- a/src/mods/settingsMod.ts +++ b/src/mods/settingsMod.ts @@ -158,4 +158,40 @@ createBlockbenchMod( } ); +createBlockbenchMod( + `${PACKAGE.name}:vs_model_offset_settings_mod`, + {}, + _context => { + const setting = new Setting("vs_apply_model_offset", { + name: "Apply Model Offset", + description: "Apply [8, 0, 8] offset to exported models for Vintage Story engine centering. Disable if your models are already positioned correctly.", + category: "general", + type: "checkbox", + value: true + }); + return setting; + }, + context => { + //context?.delete(); + } +); + +createBlockbenchMod( + `${PACKAGE.name}:vs_export_textures_settings_mod`, + {}, + _context => { + const setting = new Setting("vs_export_textures", { + name: "Export Texture Files", + description: "Automatically save texture files to disk when exporting models. Disable to only include texture references in the JSON without saving the actual texture files.", + category: "general", + type: "checkbox", + value: false + }); + return setting; + }, + context => { + //context?.delete(); + } +); + From 33ed67dbda8538d51709a113f3492e6c48ca5c63 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 14:45:41 +0000 Subject: [PATCH 02/15] parent matching update --- src/attachments/post_import.ts | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/attachments/post_import.ts b/src/attachments/post_import.ts index 565c706..04004fa 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -127,6 +127,54 @@ export async function processImportedAttachments(elementsBefore: Set, fileP Blockbench.showQuickMessage(`Matched ${matchingTable.length} attachment group(s) to existing model`, 3000); } + // STEP 1.5: Parent Matching by Group Name (for groups with same name as existing) + // This handles the case where the imported attachment has a group with the same name + // as a group already existing in the model hierarchy (e.g., importing "Ears" when "Head > Ears" exists) + if (DEBUG) console.log(`[${logPrefix}] Processing top-level groups for name-based parent matching`); + + // Get remaining top-level groups (those not matched in Step 1) + const remainingTopLevelGroups = newElements.filter(element => { + if (!(element instanceof Group)) return false; + const parent = element.parent; + return !parent || !newElementsSet.has(parent); + }) as Group[]; + + remainingTopLevelGroups.forEach(newGroup => { + // Find existing groups with the same name (case-insensitive) + const allMatches = findAllGroupsByName(newGroup.name, Outliner.root); + const existingMatch = allMatches.find(g => !newElementsSet.has(g)); + + if (existingMatch) { + // Move all children of the new group into the existing group + const childrenToMove = [...newGroup.children]; + + if (DEBUG) console.log(`[${logPrefix}] Name Match: Moving children of "${newGroup.name}" into existing group`); + + childrenToMove.forEach(child => { + try { + child.addTo(existingMatch); + } catch (e) { + console.error(`[${logPrefix}] Failed to move "${child.name}" to existing "${existingMatch.name}":`, e); + } + }); + + // Copy clothingSlot from imported group if the existing group doesn't have one + if (newGroup.clothingSlot && !existingMatch.clothingSlot) { + existingMatch.clothingSlot = newGroup.clothingSlot; + if (DEBUG) console.log(`[${logPrefix}] Copied clothingSlot "${newGroup.clothingSlot}" to existing "${existingMatch.name}"`); + } + + // Remove the now-empty imported group + if (newGroup.children.length === 0) { + newGroup.remove(); + // Remove from newElements and newElementsSet to avoid processing later + const idx = newElements.indexOf(newGroup); + if (idx !== -1) newElements.splice(idx, 1); + newElementsSet.delete(newGroup); + } + } + }); + // STEP 2: Re-parenting based on stepParentName if (DEBUG) console.log(`[${logPrefix}] Processing ${newElements.length} new elements for stepParent reparenting`); newElements.forEach(element => { From 2836c5dff0c1b484a158aa0e173a7dc999b98d7f Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 14:59:38 +0000 Subject: [PATCH 03/15] slot helpers feature --- src/attachments/actions.ts | 38 ++- src/attachments/constants.ts | 15 + src/attachments/delete_section.ts | 24 +- src/attachments/dialogs.ts | 183 ++++++++++- src/attachments/export_attachment_bb.ts | 7 +- src/attachments/export_attachment_vs.ts | 25 +- src/attachments/panel.ts | 401 +++++++++++++++++++++--- src/attachments/post_import.ts | 45 ++- src/attachments/slot_helpers.ts | 141 +++++++++ 9 files changed, 770 insertions(+), 109 deletions(-) create mode 100644 src/attachments/constants.ts create mode 100644 src/attachments/slot_helpers.ts diff --git a/src/attachments/actions.ts b/src/attachments/actions.ts index f6551dc..9ca175f 100644 --- a/src/attachments/actions.ts +++ b/src/attachments/actions.ts @@ -9,6 +9,13 @@ import { VS_Shape } from '../vs_shape_def'; import { cleanJSONString } from '../util/json'; import { processImportedAttachments } from './post_import'; import { mergeBBModel, mergeVSAttachment } from './importer'; +import { IMPORT_SETTLE_DELAY, QUICK_MESSAGE_DURATION } from './constants'; + +const DEBUG = false; + +function logDebug(message: string, ...args: any[]) { + if (DEBUG) console.log(message, ...args); +} interface ImportActionConfig { id: string; @@ -43,7 +50,8 @@ function createImportAction(config: ImportActionConfig) { }, function(files) { if (!files || !files.length) return; - Undo.initEdit({ outliner: true }); + const fileName = files[0]?.name || 'attachment'; + Undo.initEdit({ outliner: true }, `Import attachment: ${fileName}`); const elementsBefore = new Set([...Group.all, ...Cube.all]); @@ -53,36 +61,48 @@ function createImportAction(config: ImportActionConfig) { const model = autoParseJSON(cleanedContent); if (!model || typeof model !== 'object') { - console.error(`[${config.logPrefix}] Invalid model data in file:`, file.path); - Blockbench.showQuickMessage(`Failed to import ${file.name}: Invalid JSON structure`, 3000); + if (DEBUG) console.error(`[${config.logPrefix}] Invalid model data in file:`, file.path); + Blockbench.showQuickMessage(`Failed to import ${file.name}: Invalid JSON structure`, QUICK_MESSAGE_DURATION); return; } if (model.animations && Array.isArray(model.animations) && model.animations.length > 0) { - console.log(`[${config.logPrefix}] Skipping`, model.animations.length, 'animations from attachment file'); + logDebug(`[${config.logPrefix}] Skipping`, model.animations.length, 'animations from attachment file'); delete model.animations; } config.mergeFn(model, file.path); } catch (err) { - console.error(`[${config.logPrefix}] Error importing file:`, file.path, err); - Blockbench.showQuickMessage(`Failed to import ${file.name}: ${err.message || err}`, 3000); + if (DEBUG) console.error(`[${config.logPrefix}] Error importing file:`, file.path, err); + const errorMsg = err instanceof Error ? err.message : String(err); + Blockbench.showQuickMessage(`Failed to import ${file.name}: ${errorMsg}`, QUICK_MESSAGE_DURATION); } }); const currentProject = Project; + // Store first model for preview dialog + let firstModel: any = null; + if (files.length > 0) { + try { + const cleanedContent = cleanJSONString(files[0].content as string); + firstModel = autoParseJSON(cleanedContent); + } catch (e) { + if (DEBUG) console.warn(`[${config.logPrefix}] Could not parse model for preview:`, e); + } + } + // WORKAROUND: Use a timeout to wait for Blockbench's internal processes to complete. // After an import, the outliner and other project data are not updated instantaneously. // Running processImportedAttachments immediately would mean it can't find the new elements. // This delay gives Blockbench time to settle before we run our post-processing logic. setTimeout(async () => { if (!currentProject || Project !== currentProject) { - console.warn(`[${config.logPrefix}] Project changed or closed, skipping post-import processing`); + if (DEBUG) console.warn(`[${config.logPrefix}] Project changed or closed, skipping post-import processing`); return; } - await processImportedAttachments(elementsBefore, files[0].path, config.logPrefix); - }, 100); + await processImportedAttachments(elementsBefore, files[0].path, config.logPrefix, firstModel); + }, IMPORT_SETTLE_DELAY); }); } }); diff --git a/src/attachments/constants.ts b/src/attachments/constants.ts new file mode 100644 index 0000000..faf1621 --- /dev/null +++ b/src/attachments/constants.ts @@ -0,0 +1,15 @@ +/** + * Constants for attachment module configuration + */ + +/** Delay in milliseconds to wait for Blockbench to settle after import before post-processing */ +export const IMPORT_SETTLE_DELAY = 100; + +/** Debounce delay in milliseconds for attachment discovery/refresh operations */ +export const DISCOVERY_DEBOUNCE_MS = 150; + +/** Default duration for quick message notifications in milliseconds */ +export const QUICK_MESSAGE_DURATION = 3000; + +/** Minimum touch target size for accessibility (in pixels) */ +export const MIN_TOUCH_TARGET_SIZE = 32; diff --git a/src/attachments/delete_section.ts b/src/attachments/delete_section.ts index 27aebf1..efbf74a 100644 --- a/src/attachments/delete_section.ts +++ b/src/attachments/delete_section.ts @@ -1,24 +1,24 @@ +import { QUICK_MESSAGE_DURATION } from './constants'; + /** * Deletes all attachments within a given section. * @param {Array} elements - An array of attachment groups/elements in the section to be deleted. */ export function deleteSection(elements: any[]) { if (!elements || elements.length === 0) { - alert("There are no attachments in this section to delete."); + Blockbench.showQuickMessage("There are no attachments in this section to delete.", QUICK_MESSAGE_DURATION); return; } - const confirmed = confirm(`Are you sure you want to delete all ${elements.length} attachments in this section?`); - - if (confirmed) { - Undo.initEdit({ outliner: true }); + // Confirmation is handled in panel.ts confirmDelete method + Undo.initEdit({ outliner: true }, `Delete ${elements.length} attachment(s)`); - // The 'elements' array from the panel is the definitive list of top-level attachments for this section. - // Blockbench's .remove() method handles removing children automatically, regardless of their properties. - elements.forEach(element => element.remove()); + // The 'elements' array from the panel is the definitive list of top-level attachments for this section. + // Blockbench's .remove() method handles removing children automatically, regardless of their properties. + elements.forEach(element => element.remove()); - Undo.finishEdit('delete attachment section'); - - Blockbench.dispatchEvent('attachments_changed', {}); - } + Undo.finishEdit(`Delete ${elements.length} attachment(s)`); + + Blockbench.dispatchEvent('attachments_changed', {}); + Blockbench.showQuickMessage(`Deleted ${elements.length} attachment(s)`, QUICK_MESSAGE_DURATION); } \ No newline at end of file diff --git a/src/attachments/dialogs.ts b/src/attachments/dialogs.ts index 91ce8e6..106239a 100644 --- a/src/attachments/dialogs.ts +++ b/src/attachments/dialogs.ts @@ -1,38 +1,185 @@ import { getActiveSlotNames } from './presets'; +import { suggestSlotFromName, getSlotInfo } from './slot_helpers'; +import { QUICK_MESSAGE_DURATION } from './constants'; + +const DEBUG = false; + +// Store last used slot per file pattern +const slotMemory = new Map(); /** - * Shows a dialog to let the user select a clothing slot for imported attachments + * Gets remembered slot for a file pattern + */ +function getRememberedSlot(filePath: string): string | null { + const pattern = extractFilePattern(filePath); + return slotMemory.get(pattern) || null; +} + +/** + * Remembers slot choice for a file pattern + */ +function rememberSlot(filePath: string, slot: string) { + const pattern = extractFilePattern(filePath); + slotMemory.set(pattern, slot); + // Limit memory size + if (slotMemory.size > 100) { + const firstKey = slotMemory.keys().next().value; + slotMemory.delete(firstKey); + } +} + +/** + * Extracts a pattern from file path for memory matching + */ +function extractFilePattern(filePath: string): string { + const fileName = filePath.split(/[/\\]/).pop() || ''; + // Remove extension and numeric suffixes + return fileName.replace(/\.[^.]+$/, '').replace(/\d+$/, '').toLowerCase(); +} + +/** + * Analyzes imported model to extract preview information + */ +function analyzeImportPreview(model: any): { + elementCount: number; + groupCount: number; + cubeCount: number; + textureCount: number; + detectedSlots: string[]; + elementNames: string[]; +} { + const result = { + elementCount: 0, + groupCount: 0, + cubeCount: 0, + textureCount: 0, + detectedSlots: [] as string[], + elementNames: [] as string[] + }; + + if (!model) return result; + + // Count textures + if (model.textures && Array.isArray(model.textures)) { + result.textureCount = model.textures.length; + } else if (model.textures && typeof model.textures === 'object') { + result.textureCount = Object.keys(model.textures).length; + } + + // Analyze elements + const elements = model.elements || []; + const groups = model.groups || []; + const outliner = model.outliner || []; + + result.elementCount = elements.length + groups.length; + result.cubeCount = elements.length; + result.groupCount = groups.length; + + // Extract element names + const nameSet = new Set(); + + function extractNames(item: any) { + if (typeof item === 'string') { + // UUID reference + return; + } + if (item && typeof item === 'object') { + if (item.name) nameSet.add(item.name); + if (item.children && Array.isArray(item.children)) { + item.children.forEach(extractNames); + } + } + } + + outliner.forEach(extractNames); + elements.forEach((el: any) => { + if (el && el.name) nameSet.add(el.name); + }); + groups.forEach((g: any) => { + if (g && g.name) nameSet.add(g.name); + }); + + result.elementNames = Array.from(nameSet); + + // Detect slots from element names + const availableSlots = getActiveSlotNames(); + const detectedSlotsSet = new Set(); + + result.elementNames.forEach(name => { + const suggested = suggestSlotFromName(name, availableSlots); + if (suggested) detectedSlotsSet.add(suggested); + }); + + result.detectedSlots = Array.from(detectedSlotsSet); + + return result; +} + +/** + * Shows an enhanced import preview dialog with slot selection * @param inferredSlot The slot inferred from the file path (if any) * @param filePath The file path being imported + * @param model The imported model data (for preview) * @returns Promise that resolves to the selected clothing slot, or null if cancelled */ -export function showClothingSlotDialog(inferredSlot: string | null, filePath: string): Promise { +export function showClothingSlotDialog( + inferredSlot: string | null, + filePath: string, + model?: any +): Promise<{ slot: string | null; rememberChoice: boolean }> { return new Promise((resolve) => { const availableSlots = getActiveSlotNames(); const fileName = filePath.split(/[/\\]/).pop() || 'attachment'; + const preview = model ? analyzeImportPreview(model) : null; + + // Get remembered slot + const rememberedSlot = getRememberedSlot(filePath); + + // Determine default selection (priority: remembered > inferred > suggested) + let defaultSlot = ''; + if (rememberedSlot && availableSlots.includes(rememberedSlot)) { + defaultSlot = rememberedSlot; + } else if (inferredSlot && availableSlots.includes(inferredSlot)) { + defaultSlot = inferredSlot; + } else if (preview && preview.detectedSlots.length > 0) { + defaultSlot = preview.detectedSlots[0]; + } - // Create options for the dialog + // Create options for the dialog with visual indicators const options: { [key: string]: string } = {}; availableSlots.forEach(slot => { - options[slot] = slot; + let label = slot; + if (slot === rememberedSlot) label += ' (remembered)'; + else if (slot === inferredSlot) label += ' (detected from path)'; + else if (preview && preview.detectedSlots.includes(slot)) label += ' (suggested)'; + options[slot] = label; }); // Add a "None" option options[''] = '(None)'; - // Determine default selection - const defaultSlot = inferredSlot && availableSlots.includes(inferredSlot) ? inferredSlot : ''; + const previewHtml = preview ? ` +
+
Import Preview:
+
+
• ${preview.elementCount} element(s) (${preview.groupCount} groups, ${preview.cubeCount} cubes)
+
• ${preview.textureCount} texture(s)
+ ${preview.detectedSlots.length > 0 ? `
• Detected slots: ${preview.detectedSlots.join(', ')}
` : ''} + ${preview.elementNames.length > 0 ? `
Elements: ${preview.elementNames.slice(0, 5).join(', ')}${preview.elementNames.length > 5 ? '...' : ''}
` : ''} +
+
+ ` : ''; new Dialog({ id: 'clothing_slot_selector', - title: 'Select Clothing Slot', + title: 'Import Attachment', component: { template: `

Choose the clothing slot for imported elements from:

-

${fileName}

- ${inferredSlot ? `

Auto-detected: ${inferredSlot}

` : ''} +

${fileName}

+ ${previewHtml}
` }, @@ -42,14 +189,28 @@ export function showClothingSlotDialog(inferredSlot: string | null, filePath: st type: 'select', options: options, value: defaultSlot + }, + remember_choice: { + label: 'Remember this choice for similar files', + type: 'checkbox', + value: !!rememberedSlot } }, onConfirm(formData: any) { const selectedSlot = formData.clothing_slot; - resolve(selectedSlot === '' ? null : selectedSlot); + const rememberChoice = formData.remember_choice || false; + + if (rememberChoice && selectedSlot) { + rememberSlot(filePath, selectedSlot); + } + + resolve({ + slot: selectedSlot === '' ? null : selectedSlot, + rememberChoice + }); }, onCancel() { - resolve(null); + resolve({ slot: null, rememberChoice: false }); } }).show(); }); diff --git a/src/attachments/export_attachment_bb.ts b/src/attachments/export_attachment_bb.ts index 4759556..839b78c 100644 --- a/src/attachments/export_attachment_bb.ts +++ b/src/attachments/export_attachment_bb.ts @@ -1,4 +1,7 @@ import { createExportCodec } from './codec'; +import { QUICK_MESSAGE_DURATION } from './constants'; + +const DEBUG = false; /** * Exports the selected attachments to the standard Blockbench (.bbmodel) format. @@ -6,12 +9,12 @@ import { createExportCodec } from './codec'; */ export function exportAttachmentsBB(selection: Group[]) { if (!selection || selection.length === 0) { - alert("Please select one or more attachments to export."); + Blockbench.showQuickMessage("Please select one or more attachments to export.", QUICK_MESSAGE_DURATION); return; } const bb_codec = createExportCodec(); bb_codec.export(selection); - console.log("Exporting selected attachments to .bbmodel format..."); + if (DEBUG) console.log("Exporting selected attachments to .bbmodel format..."); } diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 76f0ed2..b08a28c 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -3,11 +3,16 @@ import { traverse } from '../export_model/traverse'; import * as props from "../property"; import * as util from '../util'; import { VS_Element } from '../vs_shape_def'; +import { QUICK_MESSAGE_DURATION } from './constants'; const fs = requireNativeModule('fs'); const DEBUG = false; +function logDebug(message: string, ...args: any[]) { + if (DEBUG) console.log(message, ...args); +} + /** * Populates textureSizes and textures from the current project. * @param {Object} data - The export data object to populate. @@ -125,7 +130,7 @@ function getStepParentName(group: Group): string | null { */ export function exportAttachmentsVS(selection: Group[]) { if (!selection || selection.length === 0) { - alert("Please select one or more attachments to export."); + Blockbench.showQuickMessage("Please select one or more attachments to export.", QUICK_MESSAGE_DURATION); return; } @@ -160,7 +165,7 @@ export function exportAttachmentsVS(selection: Group[]) { const parentClothingSlot = (parent as any).clothingSlot; if (!parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== myClothingSlot) { - if (DEBUG) console.log(`[VS Attachment Export] "${element.name}" is a root attachment (parent has different/no clothingSlot)`); + logDebug(`[VS Attachment Export] "${element.name}" is a root attachment (parent has different/no clothingSlot)`); return true; // Parent has different or no clothingSlot = root attachment } @@ -168,15 +173,15 @@ export function exportAttachmentsVS(selection: Group[]) { // treat this group as a root attachment (allows exporting nested groups independently) const parentInSelection = parent instanceof Group && selection.includes(parent); if (!parentInSelection) { - if (DEBUG) console.log(`[VS Attachment Export] "${element.name}" is a root attachment (parent not in selection)`); + logDebug(`[VS Attachment Export] "${element.name}" is a root attachment (parent not in selection)`); return true; } - if (DEBUG) console.log(`[VS Attachment Export] Skipping "${element.name}" - child of attachment with same clothingSlot that is also selected`); + logDebug(`[VS Attachment Export] Skipping "${element.name}" - child of attachment with same clothingSlot that is also selected`); return false; // Parent has same clothingSlot and is selected = child attachment }); - if (DEBUG) console.log(`[VS Attachment Export] Filtered ${selection.length} elements to ${rootAttachments.length} root attachments`); + logDebug(`[VS Attachment Export] Filtered ${selection.length} elements to ${rootAttachments.length} root attachments`); try { // Process each root attachment group with its own relative offset @@ -196,7 +201,7 @@ export function exportAttachmentsVS(selection: Group[]) { // Stop when we find a parent with a different (or no) clothingSlot if (!parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== myClothingSlot) { stepParentName = currentParent.name; - if (DEBUG) console.log(`[VS Attachment Export] Found first non-matching parent for "${group.name}": "${stepParentName}"`); + logDebug(`[VS Attachment Export] Found first non-matching parent for "${group.name}": "${stepParentName}"`); break; } currentParent = currentParent.parent; @@ -208,10 +213,10 @@ export function exportAttachmentsVS(selection: Group[]) { if (!originalStepParent || originalStepParent.trim() === '') { (group as any).stepParentName = stepParentName; modifiedGroups.push(group); - if (DEBUG) console.log(`[VS Attachment Export] Set stepParentName="${stepParentName}" on "${group.name}"`); + logDebug(`[VS Attachment Export] Set stepParentName="${stepParentName}" on "${group.name}"`); } } else { - console.warn(`Could not determine a step-parent for attachment: ${group.name}`); + if (DEBUG) console.warn(`Could not determine a step-parent for attachment: ${group.name}`); } // Find the parent group to make the attachment's position relative @@ -224,7 +229,7 @@ export function exportAttachmentsVS(selection: Group[]) { // Adjust the offset by subtracting the parent's absolute position. // This makes the attachment's root position relative to its parent. offset = util.vector_sub(offset, parentGroup.origin); - if (DEBUG) console.log(`[VS Attachment Export] Calculated offset for "${group.name}" relative to "${stepParentName}": [${offset.join(', ')}]`); + logDebug(`[VS Attachment Export] Calculated offset for "${group.name}" relative to "${stepParentName}": [${offset.join(', ')}]`); } // Use the main model traversal logic to process the attachment @@ -264,7 +269,7 @@ export function exportAttachmentsVS(selection: Group[]) { data.textures = existingData.textures; } - if (DEBUG) console.log('[VS Attachment Export] Preserved textureSizes and textures from existing file'); + logDebug('[VS Attachment Export] Preserved textureSizes and textures from existing file'); } catch (e) { console.error('[VS Attachment Export] Error reading existing file:', e); // Warn user about malformed JSON diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 489b910..3c8611a 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -2,6 +2,39 @@ import { exportAttachmentsVS } from './export_attachment_vs'; import { exportAttachmentsBB } from './export_attachment_bb'; import { deleteSection } from './delete_section'; import { findAttachments } from './discovery'; +import { DISCOVERY_DEBOUNCE_MS, MIN_TOUCH_TARGET_SIZE, QUICK_MESSAGE_DURATION } from './constants'; +import { getActiveSlotNames } from './presets'; +import { getSlotInfo, getSlotCategory } from './slot_helpers'; + +const DEBUG = false; + +function logDebug(message: string, ...args: any[]) { + if (DEBUG) console.log(message, ...args); +} + +// Track recently imported elements (within last 5 minutes) +const RECENT_IMPORT_THRESHOLD = 5 * 60 * 1000; // 5 minutes in ms +const recentImports = new Map(); + +// Mark element as recently imported +export function markAsRecentlyImported(element: any) { + recentImports.set(element, Date.now()); + // Clean up old entries periodically + if (recentImports.size > 100) { + const now = Date.now(); + for (const [el, time] of recentImports.entries()) { + if (now - time > RECENT_IMPORT_THRESHOLD) { + recentImports.delete(el); + } + } + } +} + +function isRecentlyImported(element: any): boolean { + const time = recentImports.get(element); + if (!time) return false; + return Date.now() - time < RECENT_IMPORT_THRESHOLD; +} /** * Creates a debounced function that delays invoking the provided function @@ -50,7 +83,167 @@ function updateOutlinerSelection(elements: any[]) { const vuePanel = { template: ` -
+
+
folder

No attachments found in model.

@@ -58,65 +251,162 @@ const vuePanel = {
-

- - {{ isSectionOpen(section.slot) ? 'arrow_drop_down' : 'arrow_right' }} - - {{ section.slot }} - {{ section.elements.length }} - - - select_all - - {{ getSectionVisibility(section.elements) ? 'visibility' : 'visibility_off' }} +
+

+ + {{ isSectionOpen(section.slot) ? 'arrow_drop_down' : 'arrow_right' }} - save - file_download - delete - -

-
    -
  • + {{ getSlotInfo(section.slot).icon }} + {{ section.slot }} + + {{ getSectionStats(section.elements) }} + {{ section.elements.length }} + + +
    + select_all + + {{ getSectionVisibility(section.elements) ? 'visibility' : 'visibility_off' }} + +
    +
    + save + file_download +
    +
    + delete +
    +
    +

+
+
{{ section.slot }}
+
+
+ • {{ element.name }} +
+
+ ... and {{ section.elements.length - 10 }} more +
+
+
+
+
+
+ :class="{ selected: isSelected(element), 'recently-imported': isRecentlyImported(element) }" + class="element-item" + @click="selectElement(element)" + :title="getElementTooltip(element)"> - - {{ getIcon(element) }} + + {{ getIcon(element) }} + + {{ element.name }} + ● - - +
+
`, data: () => ({ sections: [], - openSections: [] + openSections: [], + hoveredSection: null as string | null }), methods: { + /** + * Gets slot information for styling + */ + getSlotInfo(slot: string) { + return getSlotInfo(slot); + }, + /** + * Gets section statistics (groups vs cubes) + */ + getSectionStats(elements: any[]): string { + const groups = elements.filter(e => e instanceof Group).length; + const cubes = elements.filter(e => e instanceof Cube).length; + const parts: string[] = []; + if (groups > 0) parts.push(`${groups} group${groups !== 1 ? 's' : ''}`); + if (cubes > 0) parts.push(`${cubes} cube${cubes !== 1 ? 's' : ''}`); + return parts.join(', ') || '0 items'; + }, + /** + * Checks if element was recently imported + */ + isRecentlyImported(element: any): boolean { + return isRecentlyImported(element); + }, + /** + * Gets tooltip text for an element + */ + getElementTooltip(element: any): string { + const parts: string[] = []; + parts.push(element.name || 'Unnamed'); + if (element instanceof Group) { + parts.push(`Group (${element.children?.length || 0} children)`); + } else { + parts.push('Cube'); + } + if (element.clothingSlot) { + parts.push(`Slot: ${element.clothingSlot}`); + } + if (element.stepParentName) { + parts.push(`Step Parent: ${element.stepParentName}`); + } + return parts.join(' | '); + }, /** * Exports the given elements to a .bbmodel file. * @param {Array} elements The elements to export. */ exportBB(elements: any[]) { - exportAttachmentsBB(elements); + try { + (this as any).isExporting = true; + exportAttachmentsBB(elements); + // Note: exportAttachmentsBB uses Blockbench.export which is async but doesn't return a promise + // The success message will be shown after the export dialog completes + setTimeout(() => { + (this as any).isExporting = false; + }, 100); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + Blockbench.showQuickMessage(`Export failed: ${errorMsg}`, QUICK_MESSAGE_DURATION); + if (DEBUG) console.error('Export BB error:', e); + (this as any).isExporting = false; + } }, /** * Exports the given elements to a Vintage Story .json file. * @param {Array} elements The elements to export. */ exportVS(elements: any[]) { - exportAttachmentsVS(elements); + try { + (this as any).isExporting = true; + exportAttachmentsVS(elements); + // Note: exportAttachmentsVS uses Blockbench.export which is async but doesn't return a promise + // The success message will be shown after the export dialog completes + setTimeout(() => { + (this as any).isExporting = false; + }, 100); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + Blockbench.showQuickMessage(`Export failed: ${errorMsg}`, QUICK_MESSAGE_DURATION); + if (DEBUG) console.error('Export VS error:', e); + (this as any).isExporting = false; + } }, /** - * Deletes the given elements from the project. - * @param {Array} elements The elements to delete. + * Confirms deletion before deleting section */ - deleteSection(elements: any[]) { - deleteSection(elements); + confirmDelete(elements: any[], slotName: string) { + if (confirm(`Are you sure you want to delete all ${elements.length} attachment(s) in "${slotName}"?\n\nThis action cannot be undone.`)) { + deleteSection(elements); + } }, /** * Updates the list of attachments by calling the discovery function. @@ -168,23 +458,32 @@ const vuePanel = { toggleVisibility(elements: any[], isVisible: boolean) { if (!elements || !Array.isArray(elements)) return; - elements.forEach(element => { - if (!element) return; - try { - (this as any)._walk(element, (node: any) => { - if (!node) return; - if (typeof node.toggleVisibility === 'function') { - if (node.visibility !== isVisible) node.toggleVisibility(isVisible); - } else if ('visibility' in node) { - node.visibility = isVisible; - } - }); - } catch (e) { - console.warn('Error toggling visibility for element:', element?.name, e); - } - }); - Canvas.updateVisibility?.(); - Canvas.updateAll?.(); + try { + Undo.initEdit({ outliner: true }, `Toggle visibility: ${elements.length} element(s)`); + + elements.forEach(element => { + if (!element) return; + try { + (this as any)._walk(element, (node: any) => { + if (!node) return; + if (typeof node.toggleVisibility === 'function') { + if (node.visibility !== isVisible) node.toggleVisibility(isVisible); + } else if ('visibility' in node) { + node.visibility = isVisible; + } + }); + } catch (e) { + if (DEBUG) console.warn('Error toggling visibility for element:', element?.name, e); + } + }); + + Undo.finishEdit('Toggle visibility'); + Canvas.updateVisibility?.(); + Canvas.updateAll?.(); + } catch (e) { + if (DEBUG) console.error('Error in toggleVisibility:', e); + Blockbench.showQuickMessage('Failed to toggle visibility', QUICK_MESSAGE_DURATION); + } }, /** * Traverses a node and its children, applying a callback to each. @@ -224,7 +523,7 @@ const vuePanel = { }, mounted() { // Debounce refresh calls to avoid unnecessary updates when multiple events fire - (this as any).refresh = debounce(() => (this as any).updateAttachments(), 50); + (this as any).refresh = debounce(() => (this as any).updateAttachments(), DISCOVERY_DEBOUNCE_MS); (this as any).updateAttachments(); // Initial load without debounce (this as any)._bbListeners = [ diff --git a/src/attachments/post_import.ts b/src/attachments/post_import.ts index 04004fa..233fbcd 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -2,10 +2,15 @@ import { inferClothingSlotFromPath } from './presets'; import { showClothingSlotDialog } from './dialogs'; import { findAllGroupsByName, findGroupByName, stripNumericSuffix, collectGroupsDepthFirst, isDescendantOf } from '../util/outliner'; - +import { QUICK_MESSAGE_DURATION } from './constants'; +import { markAsRecentlyImported } from './panel'; const DEBUG = false; +function logDebug(message: string, ...args: any[]) { + if (DEBUG) console.log(message, ...args); +} + /** * Finds a group in the existing model that matches the given clothing slot. * Prioritizes groups that: @@ -66,8 +71,9 @@ function findBestMatchingGroupBySlot(clothingSlot: string, groupName: string | n * @param elementsBefore A `Set` of all elements that existed before the import. * @param filePath Path to the first imported file, used for clothing slot inference. * @param logPrefix A prefix for console log messages (e.g., "Import BB"). + * @param model Optional model data for import preview dialog. */ -export async function processImportedAttachments(elementsBefore: Set, filePath: string, logPrefix: string) { +export async function processImportedAttachments(elementsBefore: Set, filePath: string, logPrefix: string, model?: any) { const elementsAfter = new Set([...Group.all, ...Cube.all]); const newElements = [...elementsAfter].filter(e => !elementsBefore.has(e)); const newElementsSet = new Set(newElements); @@ -102,7 +108,7 @@ export async function processImportedAttachments(elementsBefore: Set, fileP try { child.addTo(matchedGroup); } catch (e) { - console.error(`[${logPrefix}] Failed to move "${child.name}" to matched group "${matchedGroup.name}":`, e); + if (DEBUG) console.error(`[${logPrefix}] Failed to move "${child.name}" to matched group "${matchedGroup.name}":`, e); } }); @@ -122,9 +128,11 @@ export async function processImportedAttachments(elementsBefore: Set, fileP // Display matching table if any matches were found if (matchingTable.length > 0) { - console.log(`[${logPrefix}] Smart Matching Results:`); - console.table(matchingTable); - Blockbench.showQuickMessage(`Matched ${matchingTable.length} attachment group(s) to existing model`, 3000); + if (DEBUG) { + logDebug(`[${logPrefix}] Smart Matching Results:`); + console.table(matchingTable); + } + Blockbench.showQuickMessage(`Matched ${matchingTable.length} attachment group(s) to existing model`, QUICK_MESSAGE_DURATION); } // STEP 1.5: Parent Matching by Group Name (for groups with same name as existing) @@ -154,7 +162,7 @@ export async function processImportedAttachments(elementsBefore: Set, fileP try { child.addTo(existingMatch); } catch (e) { - console.error(`[${logPrefix}] Failed to move "${child.name}" to existing "${existingMatch.name}":`, e); + if (DEBUG) console.error(`[${logPrefix}] Failed to move "${child.name}" to existing "${existingMatch.name}":`, e); } }); @@ -194,7 +202,7 @@ export async function processImportedAttachments(elementsBefore: Set, fileP element.addTo(parentGroup); if (DEBUG) console.log(`[${logPrefix}] Reparented "${element.name}" under "${stepParentName}" in outliner (keeping stepParentName for mesh positioning)`); } catch (e) { - console.error(`[${logPrefix}] Failed to reparent "${element.name}" to "${stepParentName}":`, e); + if (DEBUG) console.error(`[${logPrefix}] Failed to reparent "${element.name}" to "${stepParentName}":`, e); } } } @@ -225,7 +233,7 @@ export async function processImportedAttachments(elementsBefore: Set, fileP try { child.addTo(originalGroup); } catch (e) { - console.error(`[${logPrefix}] Failed to move "${child.name}" to "${originalGroup.name}":`, e); + if (DEBUG) console.error(`[${logPrefix}] Failed to move "${child.name}" to "${originalGroup.name}":`, e); } }); groupsToDelete.push(group); @@ -236,7 +244,8 @@ export async function processImportedAttachments(elementsBefore: Set, fileP // STEP 4: Apply Clothing Slot - Show dialog and apply user's selection const inferredSlot = inferClothingSlotFromPath(filePath); - const masterClothingSlot = await showClothingSlotDialog(inferredSlot, filePath); + const result = await showClothingSlotDialog(inferredSlot, filePath, model); + const masterClothingSlot = result.slot; if (masterClothingSlot) { if (DEBUG) console.log(`[${logPrefix}] Applying user-selected clothing slot "${masterClothingSlot}" to ${newElements.length} new elements.`); @@ -260,7 +269,15 @@ export async function processImportedAttachments(elementsBefore: Set, fileP if (DEBUG) console.log(`[${logPrefix}] Found ${topLevelNewElements.length} top-level new elements to process`); - topLevelNewElements.forEach(element => applySlotRecursive(element, masterClothingSlot)); + topLevelNewElements.forEach(element => { + applySlotRecursive(element, masterClothingSlot); + // Mark as recently imported + markAsRecentlyImported(element); + // Also mark all children + if (element.children) { + element.children.forEach((child: any) => markAsRecentlyImported(child)); + } + }); } else { // User cancelled - remove all imported elements to prevent orphaned attachments if (DEBUG) console.log(`[${logPrefix}] User cancelled clothing slot selection, removing ${newElements.length} imported elements`); @@ -270,14 +287,14 @@ export async function processImportedAttachments(elementsBefore: Set, fileP try { element.remove(); } catch (e) { - console.error(`[${logPrefix}] Failed to remove element "${element.name}":`, e); + if (DEBUG) console.error(`[${logPrefix}] Failed to remove element "${element.name}":`, e); } }); - Blockbench.showQuickMessage('Import cancelled - no elements added', 2000); + Blockbench.showQuickMessage('Import cancelled - no elements added', QUICK_MESSAGE_DURATION); } - Undo.finishEdit('Import and parent attachment'); + Undo.finishEdit(`Import and parent attachment: ${filePath.split(/[/\\]/).pop() || 'attachment'}`); Canvas.updateAll(); updateSelection?.(); } diff --git a/src/attachments/slot_helpers.ts b/src/attachments/slot_helpers.ts new file mode 100644 index 0000000..2486a3f --- /dev/null +++ b/src/attachments/slot_helpers.ts @@ -0,0 +1,141 @@ +/** + * Helper functions for slot categorization and visual styling + */ + +export type SlotCategory = 'clothing' | 'armor' | 'facial' | 'accessory' | 'other'; + +export interface SlotInfo { + category: SlotCategory; + icon: string; + color: string; +} + +/** + * Categorizes a slot name into a category type + */ +export function getSlotCategory(slot: string): SlotCategory { + const lowerSlot = slot.toLowerCase(); + + // Armor slots + if (lowerSlot.includes('armor')) { + return 'armor'; + } + + // Facial features + const facialSlots = ['face', 'eyes', 'eyebrows', 'nose', 'mouth', 'facialhair', 'ears', 'hair']; + if (facialSlots.some(f => lowerSlot.includes(f))) { + return 'facial'; + } + + // Accessories + const accessorySlots = ['earrings', 'faceitem', 'emblem', 'neck']; + if (accessorySlots.some(a => lowerSlot.includes(a))) { + return 'accessory'; + } + + // Clothing (default for most body parts) + const clothingSlots = ['upperbody', 'lowerbody', 'top', 'bottoms', 'outerwear', 'shoes', 'boots', 'gloves', 'hand', 'foot', 'arm', 'shoulder', 'waist', 'headwear']; + if (clothingSlots.some(c => lowerSlot.includes(c))) { + return 'clothing'; + } + + return 'other'; +} + +/** + * Gets the icon for a slot category + */ +export function getSlotIcon(category: SlotCategory): string { + switch (category) { + case 'armor': + return 'shield'; + case 'facial': + return 'face'; + case 'accessory': + return 'star'; + case 'clothing': + return 'checkroom'; + default: + return 'label'; + } +} + +/** + * Gets the color for a slot category (CSS color or hex) + */ +export function getSlotColor(category: SlotCategory): string { + switch (category) { + case 'armor': + return '#9c27b0'; // Purple + case 'facial': + return '#ff9800'; // Orange + case 'accessory': + return '#2196f3'; // Blue + case 'clothing': + return '#4caf50'; // Green + default: + return '#757575'; // Grey + } +} + +/** + * Gets complete slot information + */ +export function getSlotInfo(slot: string): SlotInfo { + const category = getSlotCategory(slot); + return { + category, + icon: getSlotIcon(category), + color: getSlotColor(category) + }; +} + +/** + * Suggests a slot based on element names + */ +export function suggestSlotFromName(elementName: string, availableSlots: string[]): string | null { + const lowerName = elementName.toLowerCase(); + + // Common name-to-slot mappings + const nameMappings: { [key: string]: string[] } = { + 'hat': ['Headwear', 'Head'], + 'cap': ['Headwear', 'Head'], + 'helmet': ['Armor Head', 'Headwear', 'Head'], + 'shirt': ['Top', 'UpperBody'], + 'top': ['Top', 'UpperBody'], + 'pants': ['Bottoms', 'LowerBody'], + 'trousers': ['Bottoms', 'LowerBody'], + 'shoes': ['Shoes', 'Foot'], + 'boots': ['Shoes', 'Foot'], + 'gloves': ['Gloves', 'Hand'], + 'eyes': ['Eyes', 'Face'], + 'nose': ['Nose', 'Face'], + 'mouth': ['Mouth', 'Face'], + 'hair': ['Hair'], + 'earrings': ['Earrings', 'Ears'], + 'jacket': ['Outerwear', 'UpperBodyOver'], + 'coat': ['Outerwear', 'UpperBodyOver'] + }; + + // Check for exact matches first + for (const [key, slots] of Object.entries(nameMappings)) { + if (lowerName.includes(key)) { + // Return first matching slot that's available + for (const slot of slots) { + if (availableSlots.includes(slot)) { + return slot; + } + } + } + } + + // Check if any slot name is contained in the element name + for (const slot of availableSlots) { + const lowerSlot = slot.toLowerCase(); + if (lowerName.includes(lowerSlot) || lowerSlot.includes(lowerName)) { + return slot; + } + } + + return null; +} From 481b5fd35c7d2c1d8df94cf02a7f44812c36cb78 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 15:06:18 +0000 Subject: [PATCH 04/15] update spacing --- src/attachments/panel.ts | 46 +++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 3c8611a..7431488 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -147,7 +147,7 @@ const vuePanel = { } .section-buttons { display: flex; - gap: 2px; + gap: 0; margin-left: auto; align-items: center; flex-shrink: 0; @@ -161,7 +161,7 @@ const vuePanel = { align-items: center; justify-content: center; cursor: pointer; - border-radius: 3px; + border-radius: 0; font-size: 16px; transition: background 0.15s; padding: 0; @@ -169,14 +169,24 @@ const vuePanel = { .section-buttons .material-icons:hover { background: rgba(255, 255, 255, 0.1); } + .section-buttons .material-icons:first-child { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; + } + .section-buttons .material-icons:last-child { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + } .section-buttons .action-group { display: flex; - gap: 1px; - padding: 0 2px; - border-left: 1px solid rgba(255, 255, 255, 0.08); + gap: 0; } - .section-buttons .action-group:first-child { - border-left: none; + .section-buttons .action-divider { + width: 1px; + height: 16px; + background: rgba(255, 255, 255, 0.15); + margin: 0 2px; + flex-shrink: 0; } .element-list { list-style: none; @@ -264,19 +274,15 @@ const vuePanel = { {{ section.elements.length }} -
- select_all - - {{ getSectionVisibility(section.elements) ? 'visibility' : 'visibility_off' }} - -
-
- save - file_download -
-
- delete -
+ select_all + + {{ getSectionVisibility(section.elements) ? 'visibility' : 'visibility_off' }} + + + save + file_download + + delete
From 541c8b7aeaa74afcd5614d9aeaf29d2c10b1c10c Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 15:13:03 +0000 Subject: [PATCH 05/15] flatten elements --- src/attachments/panel.ts | 82 +++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 7431488..5e6332c 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -68,6 +68,42 @@ function getAllChildElements(element: any) { return elements; } +/** + * Calculates the depth of an element in the outliner hierarchy. + * Depth is measured from the root (root = 0, first child = 1, etc.) + * @param element The element to calculate depth for. + * @returns The depth of the element in the hierarchy. + */ +function getElementDepth(element: any): number { + let depth = 0; + let current = element.parent; + while (current) { + depth++; + current = current.parent; + } + return depth; +} + +/** + * Calculates the minimum depth among all elements in a section. + * @param elements Array of elements in the section. + * @returns The minimum depth found. + */ +function getMinDepth(elements: any[]): number { + if (elements.length === 0) return 0; + return Math.min(...elements.map(el => getElementDepth(el))); +} + +/** + * Calculates the maximum depth among all elements in a section. + * @param elements Array of elements in the section. + * @returns The maximum depth found. + */ +function getMaxDepth(elements: any[]): number { + if (elements.length === 0) return 0; + return Math.max(...elements.map(el => getElementDepth(el))); +} + /** * Updates the outliner selection with the provided elements. * @param {Array} elements The elements to select. @@ -192,16 +228,16 @@ const vuePanel = { list-style: none; margin: 0; padding: 0; - padding-left: 4px; + padding-left: 2px; } .element-item { display: flex; align-items: center; - gap: 4px; - padding: 2px 6px 2px 20px; + gap: 3px; + padding: 1px 4px; cursor: pointer; - font-size: 12px; - min-height: 22px; + font-size: 11px; + min-height: 18px; position: relative; } .element-item:hover { @@ -211,12 +247,11 @@ const vuePanel = { background: rgba(66, 165, 245, 0.2); } .element-icon { - font-size: 14px; - width: 16px; + font-size: 13px; + width: 14px; text-align: center; flex-shrink: 0; - position: absolute; - left: 2px; + margin-right: 2px; } .element-name { flex: 1; @@ -288,7 +323,7 @@ const vuePanel = {
{{ section.slot }}
-
+
• {{ element.name }}
@@ -302,6 +337,7 @@ const vuePanel = { :key="element.uuid" :class="{ selected: isSelected(element), 'recently-imported': isRecentlyImported(element) }" class="element-item" + :style="{ paddingLeft: (getElementIndent(element, section.elements) * 12 + 4) + 'px' }" @click="selectElement(element)" :title="getElementTooltip(element)"> @@ -366,6 +402,32 @@ const vuePanel = { } return parts.join(' | '); }, + /** + * Gets a flattened list of element names (for tooltip display). + * @param elements Array of elements to flatten. + * @returns Array of elements in a flat list. + */ + getFlattenedElementList(elements: any[]): any[] { + // Return elements in a simple flat list, sorted by name for consistency + return [...elements].sort((a, b) => { + const nameA = (a.name || '').toLowerCase(); + const nameB = (b.name || '').toLowerCase(); + return nameA.localeCompare(nameB); + }); + }, + /** + * Calculates the indentation level for an element based on its depth in the hierarchy. + * Indentation is normalized so the shallowest element starts at 0. + * @param element The element to calculate indentation for. + * @param allElements All elements in the section (to find min depth). + * @returns The indentation level (0 = shallowest element, increases with relative depth). + */ + getElementIndent(element: any, allElements: any[]): number { + const minDepth = getMinDepth(allElements); + const elementDepth = getElementDepth(element); + // Normalize: subtract minDepth so shallowest element is at 0 + return elementDepth - minDepth; + }, /** * Exports the given elements to a .bbmodel file. * @param {Array} elements The elements to export. From 6425415becae2f14d61fea70b50d284683f8c868 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 15:15:23 +0000 Subject: [PATCH 06/15] properly flattened now --- src/attachments/panel.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 5e6332c..9bad6b2 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -322,13 +322,13 @@ const vuePanel = {
{{ section.slot }}
-
-
- • {{ element.name }} -
-
+
+ + {{ element.name }}, + + ... and {{ section.elements.length - 10 }} more -
+
From 619da6789087cdf45b89facb933f453aec8bf6ce Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 17:07:36 +0000 Subject: [PATCH 07/15] clothing (and also IK placeholder) --- src/attachments/delete_section.ts | 101 +++++++++- src/attachments/panel.ts | 34 ++-- src/export_animation.ts | 35 ++++ src/mods/ikMod.ts | 296 ++++++++++++++++++++++++++++++ src/vs_plugin.ts | 1 + 5 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 src/mods/ikMod.ts diff --git a/src/attachments/delete_section.ts b/src/attachments/delete_section.ts index efbf74a..30d974c 100644 --- a/src/attachments/delete_section.ts +++ b/src/attachments/delete_section.ts @@ -1,7 +1,106 @@ import { QUICK_MESSAGE_DURATION } from './constants'; +import { isAttachment } from './discovery'; /** - * Deletes all attachments within a given section. + * Recursively collects all attachment elements (those with clothingSlot) from a node and its children. + * @param element The element to traverse + * @param rootElements Set of root elements to exclude from deletion + * @returns Array of all attachment elements found (excluding root elements) + */ +function collectAttachmentElements(element: any, rootElements: Set): any[] { + const attachments: any[] = []; + + function traverse(node: any) { + // Skip if this is a root element (should be preserved) + if (rootElements.has(node)) { + // Still traverse children to find nested attachments + if (node instanceof Group && node.children) { + node.children.forEach(traverse); + } + return; + } + + if (isAttachment(node)) { + attachments.push(node); + } + + // Continue traversing children + if (node instanceof Group && node.children) { + node.children.forEach(traverse); + } + } + + traverse(element); + return attachments; +} + +/** + * Identifies root attachment groups - the first level groups with clothingSlot that are children of base model groups. + * @param elements Array of top-level attachment elements + * @returns Set of root attachment groups to preserve + */ +function identifyRootAttachmentGroups(elements: any[]): Set { + const rootGroups = new Set(); + + elements.forEach(element => { + // If this element has a clothingSlot and its parent doesn't have a clothingSlot, + // it's a root attachment group that should be preserved + if (isAttachment(element)) { + const parent = element.parent; + if (!parent || !isAttachment(parent)) { + // This is a root attachment group - preserve it + rootGroups.add(element); + } + } + }); + + return rootGroups; +} + +/** + * Delete (-) Root: Deletes attachment elements but preserves root attachment groups and base model groups. + * @param {Array} elements - An array of attachment groups/elements in the section to be deleted. + */ +export function deleteSectionSafe(elements: any[]) { + if (!elements || elements.length === 0) { + Blockbench.showQuickMessage("There are no attachments in this section to delete.", QUICK_MESSAGE_DURATION); + return; + } + + Undo.initEdit({ outliner: true }, `Delete (-) Root`); + + // Identify root attachment groups (first level with clothingSlot) that should be preserved + const rootGroups = identifyRootAttachmentGroups(elements); + + // Collect all attachment elements (those with clothingSlot), excluding root groups + const allAttachments = new Set(); + elements.forEach(element => { + const attachments = collectAttachmentElements(element, rootGroups); + attachments.forEach(att => allAttachments.add(att)); + }); + + if (allAttachments.size === 0) { + Undo.finishEdit('Delete attachment(s)'); + Blockbench.showQuickMessage("No attachment elements found to delete.", QUICK_MESSAGE_DURATION); + return; + } + + // Delete only the nested attachment elements, preserving: + // 1. Base model groups (no clothingSlot) + // 2. Root attachment groups (first level with clothingSlot) + allAttachments.forEach(attachment => { + // Delete the attachment element - root groups and base model groups are preserved + attachment.remove(); + }); + + Undo.finishEdit(`Delete (-) Root: ${allAttachments.size} attachment(s)`); + + Blockbench.dispatchEvent('attachments_changed', {}); + Blockbench.showQuickMessage(`Delete (-) Root: Deleted ${allAttachments.size} attachment(s) (root groups preserved)`, QUICK_MESSAGE_DURATION); +} + +/** + * Deletes all attachments within a given section (original behavior - deletes everything including parent groups). * @param {Array} elements - An array of attachment groups/elements in the section to be deleted. */ export function deleteSection(elements: any[]) { diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 9bad6b2..3703c66 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -1,6 +1,6 @@ import { exportAttachmentsVS } from './export_attachment_vs'; import { exportAttachmentsBB } from './export_attachment_bb'; -import { deleteSection } from './delete_section'; +import { deleteSection, deleteSectionSafe } from './delete_section'; import { findAttachments } from './discovery'; import { DISCOVERY_DEBOUNCE_MS, MIN_TOUCH_TARGET_SIZE, QUICK_MESSAGE_DURATION } from './constants'; import { getActiveSlotNames } from './presets'; @@ -158,7 +158,7 @@ const vuePanel = { gap: 3px; padding: 1px 6px; border-radius: 10px; - font-size: 10px; + font-size: 9px; font-weight: 500; line-height: 1.3; flex-shrink: 0; @@ -176,7 +176,7 @@ const vuePanel = { flex-shrink: 0; } .section-stats { - font-size: 9px; + font-size: 8px; color: rgba(255, 255, 255, 0.5); margin-left: 4px; flex-shrink: 0; @@ -303,10 +303,8 @@ const vuePanel = { {{ getSlotInfo(section.slot).icon }} - {{ section.slot }} + {{ section.slot }}: {{ section.elements.length }} {{ getSectionStats(section.elements) }} - {{ getSectionStats(section.elements) }} - {{ section.elements.length }} select_all @@ -317,7 +315,8 @@ const vuePanel = { save file_download - delete + remove_circle_outline + delete
@@ -367,15 +366,16 @@ const vuePanel = { return getSlotInfo(slot); }, /** - * Gets section statistics (groups vs cubes) + * Gets section statistics (groups vs cubes) in format: (5 Groups, 2 Cubes) */ getSectionStats(elements: any[]): string { const groups = elements.filter(e => e instanceof Group).length; const cubes = elements.filter(e => e instanceof Cube).length; const parts: string[] = []; - if (groups > 0) parts.push(`${groups} group${groups !== 1 ? 's' : ''}`); - if (cubes > 0) parts.push(`${cubes} cube${cubes !== 1 ? 's' : ''}`); - return parts.join(', ') || '0 items'; + if (groups > 0) parts.push(`${groups} Group${groups !== 1 ? 's' : ''}`); + if (cubes > 0) parts.push(`${cubes} Cube${cubes !== 1 ? 's' : ''}`); + if (parts.length === 0) return '(0 items)'; + return `(${parts.join(', ')})`; }, /** * Checks if element was recently imported @@ -469,10 +469,18 @@ const vuePanel = { } }, /** - * Confirms deletion before deleting section + * Confirms delete minus root (preserves root attachment groups and base model groups) + */ + confirmDeleteMinusRoot(elements: any[], slotName: string) { + if (confirm(`Delete (-) Root in "${slotName}"?\n\nThis will delete attachment content but preserve:\n- Base model groups (like "Ears")\n- Root attachment groups (first level with clothingSlot)\n\nThis action cannot be undone.`)) { + deleteSectionSafe(elements); + } + }, + /** + * Confirms deletion before deleting section (original behavior - deletes everything) */ confirmDelete(elements: any[], slotName: string) { - if (confirm(`Are you sure you want to delete all ${elements.length} attachment(s) in "${slotName}"?\n\nThis action cannot be undone.`)) { + if (confirm(`Are you sure you want to delete all ${elements.length} attachment(s) in "${slotName}"?\n\nThis will delete everything including base model groups.\n\nThis action cannot be undone.`)) { deleteSection(elements); } }, diff --git a/src/export_animation.ts b/src/export_animation.ts index b79f7fa..8b5cca8 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -14,15 +14,26 @@ export function export_animations(): Array { return []; } + // Track animations with non-linear interpolations for warning + const animationsWithNonLinearInterpolation: string[] = []; + (Animation as unknown as typeof _Animation).all.forEach(animation => { const keyframes: Record = {}; const fps = util.fps; const animators = Object.values(animation.animators || {}); + let hasNonLinearInterpolation = false; + animators.forEach(animator => { if (animator.keyframes.length > 0 && animator.type === 'bone') { const bone_name = animator.name; animator.keyframes.forEach(kf => { + // Check if keyframe uses non-linear interpolation + // VS only supports linear interpolation, so warn if other types are used + if (kf.interpolation && kf.interpolation !== 'linear') { + hasNonLinearInterpolation = true; + } + const frame = Math.round(kf.time * fps); keyframes[frame] = keyframes[frame] || { frame, elements: {} }; keyframes[frame].elements[bone_name] = keyframes[frame].elements[bone_name] || {}; @@ -80,9 +91,17 @@ export function export_animations(): Array { if (vsAnimation.keyframes.length > 0) { animations.push(vsAnimation); + if (hasNonLinearInterpolation) { + animationsWithNonLinearInterpolation.push(animation.name); + } } }); + // Warn user if any animations use non-linear interpolations + if (animationsWithNonLinearInterpolation.length > 0) { + display_interpolation_warning(animationsWithNonLinearInterpolation); + } + return animations; } @@ -111,4 +130,20 @@ function display_animation_length_warning(animation_name: string) { `This is not supported by Vintage Story, so the animation length was inceased by 1. ` + `If you want to prevent this, please move the keyframes away from the last frame.` }); +} + +function display_interpolation_warning(animation_names: string[]) { + const animationList = animation_names.length === 1 + ? `"${animation_names[0]}"` + : animation_names.map(name => `"${name}"`).join(', '); + + Blockbench.showMessageBox({ + title: 'Interpolation Warning', + message: + `The following animation(s) use non-linear interpolation: ${animationList}\n\n` + + `Vintage Story only supports linear interpolation between keyframes. ` + + `All keyframes will be exported with linear interpolation, which may change ` + + `the animation's appearance. Consider adding more keyframes to approximate ` + + `the desired easing curves, or use linear interpolation in Blockbench.` + }); } \ No newline at end of file diff --git a/src/mods/ikMod.ts b/src/mods/ikMod.ts new file mode 100644 index 0000000..6af2740 --- /dev/null +++ b/src/mods/ikMod.ts @@ -0,0 +1,296 @@ +import { createBlockbenchMod } from "../util/moddingTools"; +import * as PACKAGE from "../../package.json"; +import { is_vs_project } from "../util"; +import { codecVS } from "../codec"; + +/** + * IK Improvement Mod for VS Plugin + * + * This mod addresses IK limitations for VS export: + * 1. Detects IK usage and warns users that VS doesn't support IK natively + * 2. Bakes IK animations to keyframes on export (converts IK-driven motion to keyframe animation) + * 3. Provides user feedback during the baking process + * + * Known Limitations: + * - VS format does not support IK, so all IK must be converted to keyframes + * - IK controller transform inheritance issues (when controllers aren't at dead center) are + * a Blockbench core issue that may require fixes in Blockbench itself + * - The baking process samples IK at regular intervals, which may create many keyframes + * - Complex IK setups may need manual refinement after baking + * + * Future Improvements: + * - Add IK settings panel with baking options (sample rate, which controllers to bake) + * - Improve transform inheritance fixes for IK controllers + * - Add IK constraint visualization/feedback + */ + +createBlockbenchMod( + `${PACKAGE.name}:ik_mod`, + { + // Store original methods we'll patch + originalCompile: codecVS.compile.bind(codecVS), + }, + context => { + // Hook into codec compile to bake IK animations before export + codecVS.compile = function(options: any) { + if (is_vs_project(Project)) { + // Check if any IK controllers exist + const hasIK = checkForIKControllers(); + if (hasIK) { + // Warn user that IK will be baked to keyframes + const shouldBake = Blockbench.showMessageBox({ + title: 'IK Animation Detected', + message: + 'Your model uses Inverse Kinematics (IK) controllers.\n\n' + + 'Vintage Story does not support IK natively. IK animations will be ' + + 'automatically converted to keyframe animations during export.\n\n' + + 'This may result in a large number of keyframes. Continue?', + buttons: ['Bake IK to Keyframes', 'Cancel Export'] + }); + + if (shouldBake === 0) { + // Bake IK to keyframes + bakeIKAnimations(); + } else { + // User cancelled + throw new Error('Export cancelled: IK animations need to be baked to keyframes'); + } + } + } + return context.originalCompile(options); + }; + + return context; + }, + context => { + // Restore original compile method + codecVS.compile = context.originalCompile; + } +); + +/** + * Bakes IK animations to keyframes for VS export + * Since VS doesn't support IK natively, we need to convert IK-driven animations + * to regular keyframe animations by sampling the IK-driven bone transforms + */ +function bakeIKAnimations() { + if (!Project || !is_vs_project(Project)) return; + + const ikControllers = findAllIKControllers(); + if (ikControllers.length === 0) return; + + //@ts-expect-error: Animation type + const animations = (Animation as unknown as typeof _Animation).all; + const fps = 20; // VS default FPS + + let totalBakedKeyframes = 0; + + // For each animation, bake IK-driven bones + animations.forEach(animation => { + // Get all bones that are part of IK chains + const ikBones = new Set(); + ikControllers.forEach(({ chain }) => { + chain.forEach(bone => ikBones.add(bone)); + }); + + if (ikBones.size === 0) return; + + // Sample animation at regular intervals + const frameCount = Math.ceil(animation.length * fps); + const sampleInterval = 1 / fps; // Sample every frame + + // Store original animation state + const originalTime = animation.time; + const originalSelected = animation.selected; + const wasPlaying = Animator.playing; + + // Stop animation if playing + if (wasPlaying) { + Animator.pause(); + } + + // Select animation to ensure it's active + animation.select(); + + // Enable animation mode if not already + const wasInAnimationMode = Format.animation_mode; + if (!wasInAnimationMode) { + Format.animation_mode = true; + } + + // Sample each frame + for (let frame = 0; frame <= frameCount; frame++) { + const time = Math.min(frame * sampleInterval, animation.length); + + // Set animation time + animation.time = time; + + // Force animation update + //@ts-expect-error: Animation update + if (Animator.update) { + //@ts-expect-error: Animation update + Animator.update(); + } + + // Update viewport to ensure IK is calculated + Blockbench.updateViewport(); + + // Small delay to ensure IK solver has time to calculate + // (This is a workaround - in a real implementation we'd want a proper callback) + + // For each bone in IK chains, sample and add keyframes + ikBones.forEach(bone => { + const animator = animation.getBoneAnimator(bone); + if (!animator) return; + + // Get the bone's current transforms after IK is applied + // The bone's rotation and origin should reflect IK calculations + const rotation = bone.rotation; + const origin = bone.origin; + + // Calculate position offset relative to parent + const parentOrigin = bone.parent ? bone.parent.origin : [0, 0, 0]; + const offset = [ + origin[0] - parentOrigin[0], + origin[1] - parentOrigin[1], + origin[2] - parentOrigin[2] + ]; + + // Check if keyframe already exists at this time (within tolerance) + const existingKf = animator.keyframes.find((kf: any) => + Math.abs(kf.time - time) < sampleInterval / 4 + ); + + if (!existingKf) { + // Always add rotation keyframe + // Even if rotation appears zero, IK might have modified it + animator.addKeyframe({ + interpolation: 'linear', + time, + channel: 'rotation', + data_points: [{ + x: rotation[0] || 0, + y: rotation[1] || 0, + z: rotation[2] || 0 + }] + }); + totalBakedKeyframes++; + + // Add position keyframe if offset is non-zero (VS uses offsetX/Y/Z) + // Use a small threshold to avoid unnecessary keyframes for tiny movements + if (Math.abs(offset[0]) > 0.001 || Math.abs(offset[1]) > 0.001 || Math.abs(offset[2]) > 0.001) { + animator.addKeyframe({ + interpolation: 'linear', + time, + channel: 'position', + data_points: [{ + x: offset[0], + y: offset[1], + z: offset[2] + }] + }); + totalBakedKeyframes++; + } + } else { + // Update existing keyframe if it's close enough + // This ensures we capture IK-driven changes even if there was a pre-existing keyframe + const kf = existingKf; + if (kf.channel === 'rotation' && kf.data_points && kf.data_points[0]) { + kf.data_points[0].x = rotation[0] || 0; + kf.data_points[0].y = rotation[1] || 0; + kf.data_points[0].z = rotation[2] || 0; + } else if (kf.channel === 'position' && kf.data_points && kf.data_points[0]) { + kf.data_points[0].x = offset[0]; + kf.data_points[0].y = offset[1]; + kf.data_points[0].z = offset[2]; + } + } + }); + } + + // Restore original state + animation.time = originalTime; + if (originalSelected) { + animation.select(); + } else { + animation.deselect(); + } + + if (!wasInAnimationMode) { + Format.animation_mode = false; + } + + if (wasPlaying) { + Animator.play(); + } + }); + + Blockbench.showQuickMessage( + `Baked ${ikControllers.length} IK controller(s) to ${totalBakedKeyframes} keyframes`, + 3000 + ); +} + +/** + * Finds all IK controllers and their target bones + */ +function findAllIKControllers(): Array<{ controller: Group; target: Group | Locator; chain: Group[] }> { + const ikControllers: Array<{ controller: Group; target: Group | Locator; chain: Group[] }> = []; + + //@ts-expect-error: IK properties may not be in types + const nulls = Group.all.filter(g => g.isNull); + + for (const nullObj of nulls) { + //@ts-expect-error: IK properties + if (nullObj.ikTarget) { + //@ts-expect-error: IK properties + const target = nullObj.ikTarget; + const chain = getIKChain(target); + ikControllers.push({ + controller: nullObj, + target, + chain + }); + } + } + + return ikControllers; +} + +/** + * Gets the IK chain starting from a target bone + */ +function getIKChain(target: Group | Locator): Group[] { + const chain: Group[] = []; + + if (target instanceof Group) { + // Walk up the parent chain to find all bones in the IK chain + let current: any = target; + while (current && current instanceof Group) { + chain.unshift(current); + current = current.parent; + } + } + + return chain; +} + + +/** + * Checks if the project has any IK controllers (Null objects with IK targets) + */ +function checkForIKControllers(): boolean { + if (!Project) return false; + + //@ts-expect-error: IK properties may not be in types + const nulls = Group.all.filter(g => g.isNull); + + for (const nullObj of nulls) { + //@ts-expect-error: IK properties + if (nullObj.ikTarget) { + return true; + } + } + + return false; +} diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index 3491736..c1a4933 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -20,6 +20,7 @@ import "./mods/settingsMod"; import "./mods/legacyFormatConverterMod"; import "./mods/nodePreviewControllerMod"; import "./mods/attachmentsMod"; +import "./mods/ikMod"; BBPlugin.register(PACKAGE.name, { title: PACKAGE.title, From e0e53dd555a6c9000657a1246637212f2fc25b52 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 8 Jan 2026 17:20:15 +0000 Subject: [PATCH 08/15] font size tweaking tweaking --- src/attachments/panel.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 3703c66..027af50 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -140,10 +140,13 @@ const vuePanel = { cursor: pointer; user-select: none; position: relative; - font-size: 13px; + font-size: 18px !important; font-weight: 500; min-height: 32px; } + .attachment-section h2 .slot-badge { + font-size: 6px !important; + } .attachment-section h2:hover { background: rgba(255, 255, 255, 0.05); } @@ -158,13 +161,27 @@ const vuePanel = { gap: 3px; padding: 1px 6px; border-radius: 10px; - font-size: 9px; + font-size: 6px !important; font-weight: 500; line-height: 1.3; flex-shrink: 0; } + .attachment-section h2 .slot-badge { + font-size: 18px !important; + } + .slot-badge-text { + font-size: 18px !important; + line-height: 1.3; + display: inline-block; + } + .attachment-section h2 .slot-badge-text { + font-size: 18px !important; + } .slot-icon { - font-size: 12px; + font-size: 8px !important; + } + .attachment-section h2 .slot-badge .material-icons.slot-icon { + font-size: 8px !important; } .attachment-count { background: rgba(255, 255, 255, 0.1); @@ -303,7 +320,7 @@ const vuePanel = { {{ getSlotInfo(section.slot).icon }} - {{ section.slot }}: {{ section.elements.length }} {{ getSectionStats(section.elements) }} + {{ section.slot }}: {{ section.elements.length }} {{ getSectionStats(section.elements) }} @@ -536,7 +553,7 @@ const vuePanel = { try { Undo.initEdit({ outliner: true }, `Toggle visibility: ${elements.length} element(s)`); - + elements.forEach(element => { if (!element) return; try { @@ -552,7 +569,7 @@ const vuePanel = { if (DEBUG) console.warn('Error toggling visibility for element:', element?.name, e); } }); - + Undo.finishEdit('Toggle visibility'); Canvas.updateVisibility?.(); Canvas.updateAll?.(); From 11001ace97700e47dc9e4f515c70fefd96c8cb25 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 16 Jan 2026 23:03:49 +0000 Subject: [PATCH 09/15] wip --- src/attachments/codec.ts | 51 +- src/attachments/discovery.ts | 67 +- src/attachments/export_attachment_vs.ts | 455 +++++- src/attachments/importer.ts | 27 + src/attachments/panel.ts | 22 +- src/attachments/post_import.ts | 404 +++--- src/codec.ts | 52 +- src/mods/ikMod.ts | 1721 ++++++++++++++++++++++- src/util/misc.ts | 7 +- 9 files changed, 2406 insertions(+), 400 deletions(-) diff --git a/src/attachments/codec.ts b/src/attachments/codec.ts index f3295c0..f7b3cf6 100644 --- a/src/attachments/codec.ts +++ b/src/attachments/codec.ts @@ -1,12 +1,33 @@ export function createExportCodec() { - function compileGroupsFrom(rootGroups: any[], undo: boolean): any[] { + /** + * Compiles groups for the outliner, preserving clothingSlot only on root groups. + * Also sets stepParentName on root groups so they can be correctly placed in hierarchy on import. + * @param rootGroups The root groups being exported (these should keep clothingSlot) + * @param undo Whether this is for undo purposes + * @param rootGroupUuids Set of UUIDs for root groups that should keep clothingSlot + * @param parentNameMap Map of root group UUIDs to their original parent group names + */ + function compileGroupsFrom(rootGroups: any[], undo: boolean, rootGroupUuids: Set, parentNameMap: Map): any[] { const result: any[] = []; - function iterate(array: any[], save_array: any[]) { + function iterate(array: any[], save_array: any[], isRoot: boolean = false) { for (const element of array) { if (element.type === 'group') { const obj = element.compile(undo); + // Only preserve clothingSlot on root attachment groups + // Clear it from nested groups to prevent duplicate attachment detection on import + if (!rootGroupUuids.has(element.uuid)) { + delete obj.clothingSlot; + } + // Set stepParentName on root groups so they can be placed correctly on import + // This tells the importer where this group should go in the target model's hierarchy + if (isRoot && rootGroupUuids.has(element.uuid)) { + const parentName = parentNameMap.get(element.uuid); + if (parentName) { + obj.stepParentName = parentName; + } + } if (element.children.length > 0) { - iterate(element.children, obj.children); + iterate(element.children, obj.children, false); } save_array.push(obj); } else { @@ -14,7 +35,7 @@ export function createExportCodec() { } } } - iterate(rootGroups, result); + iterate(rootGroups, result, true); return result; } @@ -90,19 +111,37 @@ export function createExportCodec() { outliner: [] }; + // Track root group UUIDs - these are the groups that should keep their clothingSlot + // Also track their parent names for correct hierarchy placement on import + const rootGroupUuids = new Set(); + const parentNameMap = new Map(); + selection.forEach(el => { + if (el instanceof Group) { + rootGroupUuids.add(el.uuid); + // Store the parent group name so the importer knows where to place this group + if (el.parent && el.parent instanceof Group) { + parentNameMap.set(el.uuid, el.parent.name); + } + } + }); + // Use the recursive function to collect all nested elements const allElements = collectAllElements(selection); // Collect all cubes from the selection + // Strip clothingSlot from cubes to prevent them from being detected as individual attachments on import const cubes: any[] = []; allElements.forEach(el => { if (el instanceof Cube) { cubes.push(el); - model.elements.push(el.getSaveCopy()); + const saveCopy = el.getSaveCopy(); + // Remove clothingSlot from cubes - they inherit from their parent group + delete saveCopy.clothingSlot; + model.elements.push(saveCopy); } }); - model.outliner = compileGroupsFrom(selection, true); + model.outliner = compileGroupsFrom(selection, true, rootGroupUuids, parentNameMap); // Only export textures that are actually used by the selected elements const usedTextureUuids = collectUsedTextureUuids(cubes); diff --git a/src/attachments/discovery.ts b/src/attachments/discovery.ts index 5596210..57e17b4 100644 --- a/src/attachments/discovery.ts +++ b/src/attachments/discovery.ts @@ -1,39 +1,33 @@ import { getActiveSlotNames } from './presets'; export interface IAttachmentSection { - slot: string; - elements: (Group | Cube)[]; + slot: string; + elements: (Group | Cube)[]; } /** - * Checks if a group is just a structural parent (only contains other attachments, no real content). - * These should be filtered out from display since they're just pass-through parents. - * IMPORTANT: Groups that themselves have a clothingSlot are NOT structural parents - they are the attachment root! + * Checks if a group is a structural parent (only contains attachments, no real content). + * Groups with a clothingSlot are NOT structural parents - they are attachment roots. */ function isStructuralParentOnly(node: any): boolean { - if (!(node instanceof Group)) return false; - if (!node.children || node.children.length === 0) return false; + if (!(node instanceof Group)) return false; + if (!node.children || node.children.length === 0) return false; - // If this group itself has a clothingSlot, it's NOT a structural parent - it's the attachment root - if (node.clothingSlot && node.clothingSlot.trim() !== '') { - return false; - } + if (node.clothingSlot && node.clothingSlot.trim() !== '') { + return false; + } - // Check if ALL children are attachments (have clothingSlot set) - // If so, this group is just a structural parent - const allChildrenAreAttachments = node.children.every((child: any) => { - if (child instanceof Cube) { - // Cubes with geometry are real content - return child.clothingSlot && child.clothingSlot.trim() !== ''; - } - if (child instanceof Group) { - // Groups that are attachments or structural parents - return (child.clothingSlot && child.clothingSlot.trim() !== '') || isStructuralParentOnly(child); - } - return false; - }); + const allChildrenAreAttachments = node.children.every((child: any) => { + if (child instanceof Cube) { + return child.clothingSlot && child.clothingSlot.trim() !== ''; + } + if (child instanceof Group) { + return (child.clothingSlot && child.clothingSlot.trim() !== '') || isStructuralParentOnly(child); + } + return false; + }); - return allChildrenAreAttachments; + return allChildrenAreAttachments; } /** @@ -55,24 +49,32 @@ export function findAttachments(): IAttachmentSection[] { } function walk(node: any) { - // Check if this element has an explicit clothingSlot property if ((node instanceof Group || node instanceof Cube) && node.clothingSlot && node.clothingSlot.trim() !== '') { - // Skip structural parent groups that only contain other attachments + if (node instanceof Group) { + const parent = node.parent; + if (parent && parent instanceof Group) { + const parentSlot = (parent as any).clothingSlot; + if (parentSlot && parentSlot.trim() === node.clothingSlot.trim()) { + if (Array.isArray(node.children)) { + node.children.forEach(walk); + } + return; + } + } + } + if (!isStructuralParentOnly(node)) { const bucket = getOrCreateBucket(node.clothingSlot); bucket.elements.push(node); } } - // Always descend to find nested clothing items if (Array.isArray(node.children)) { node.children.forEach(walk); } } (Outliner.root || []).forEach(walk); - - // Sort results to have a consistent order results.sort((a, b) => a.slot.localeCompare(b.slot)); return results; @@ -87,7 +89,6 @@ export function getAttachments(): (Group | Cube)[] { } export function isAttachment(node: any): boolean { - if (!node) return false; - // Check if it has an explicit clothingSlot property - return (node instanceof Group || node instanceof Cube) && node.clothingSlot && node.clothingSlot.trim() !== ''; + if (!node) return false; + return (node instanceof Group || node instanceof Cube) && node.clothingSlot && node.clothingSlot.trim() !== ''; } \ No newline at end of file diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index b08a28c..79a68a8 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -4,29 +4,108 @@ import * as props from "../property"; import * as util from '../util'; import { VS_Element } from '../vs_shape_def'; import { QUICK_MESSAGE_DURATION } from './constants'; +import { process_group } from '../export_model/group'; +import { process_cube } from '../export_model/cube'; +import { visit_tree } from '../util/element_tree'; const fs = requireNativeModule('fs'); -const DEBUG = false; +const DEBUG = false; // Enable debug to see what's being exported function logDebug(message: string, ...args: any[]) { if (DEBUG) console.log(message, ...args); } /** - * Populates textureSizes and textures from the current project. + * Collects texture names that are actually used by the given elements. + * @param elements The exported VS elements to scan for texture references. + * @returns A set of texture names that are used by the elements. + */ +function getUsedTextureNames(elements: VS_Element[]): Set { + const usedTextures = new Set(); + + elements.forEach(element => { + visit_tree(element, (elem) => { + if (elem.faces) { + Object.values(elem.faces).forEach(face => { + if (face.texture) { + // Texture references are in the format "#texture_name" + const textureName = face.texture.startsWith('#') + ? face.texture.substring(1) + : face.texture; + usedTextures.add(textureName); + } + }); + } + }); + }); + + return usedTextures; +} + +/** + * Final cleanup pass that removes any textureSizes and textures entries + * that are not actually used by the exported elements. + * Also removes the keys entirely if they end up empty. + * @param {Object} data - The export data object to clean up. + * @param {Set} usedTextureNames - Set of texture names that are actually used. + */ +function cleanupUnusedTextures(data: any, usedTextureNames: Set) { + // Clean up textureSizes - remove any entries not in usedTextureNames + if (data.textureSizes) { + for (const name of Object.keys(data.textureSizes)) { + if (!usedTextureNames.has(name)) { + logDebug(`[VS Attachment Export] Removing unused textureSizes entry: "${name}"`); + delete data.textureSizes[name]; + } + } + // Remove the key entirely if empty + if (Object.keys(data.textureSizes).length === 0) { + logDebug('[VS Attachment Export] Removing empty textureSizes object'); + delete data.textureSizes; + } + } + + // Clean up textures - remove any entries not in usedTextureNames + if (data.textures) { + for (const name of Object.keys(data.textures)) { + if (!usedTextureNames.has(name)) { + logDebug(`[VS Attachment Export] Removing unused textures entry: "${name}"`); + delete data.textures[name]; + } + } + // Remove the key entirely if empty + if (Object.keys(data.textures).length === 0) { + logDebug('[VS Attachment Export] Removing empty textures object'); + delete data.textures; + } + } +} + +/** + * Populates textureSizes and textures from the current project, + * filtered to only include textures that are actually used. * @param {Object} data - The export data object to populate. + * @param {Set} usedTextureNames - Set of texture names that are actually used. */ -function populateTexturesFromProject(data: any) { - // Populate Texture Sizes from the current project +function populateTexturesFromProject(data: any, usedTextureNames?: Set) { + // Populate Texture Sizes from the current project (only for used textures) for (const texture of Texture.all) { + // Skip textures that aren't used (if filter is provided) + if (usedTextureNames && !usedTextureNames.has(texture.name)) { + continue; + } if (texture.getUVWidth() && texture.getUVHeight()) { data.textureSizes[texture.name] = [texture.uv_width, texture.uv_height]; } } - // Populate Textures from the current project + // Populate Textures from the current project (only for used textures) for (const texture of Texture.all) { + // Skip textures that aren't used (if filter is provided) + if (usedTextureNames && !usedTextureNames.has(texture.name)) { + continue; + } let tmp: any = {}; props.VS_TEXTURE_PROPS.find(p => p.name === 'textureLocation')?.copy(texture, tmp); data.textures[texture.name] = tmp.textureLocation; @@ -124,6 +203,166 @@ function getStepParentName(group: Group): string | null { return stepParentName || null; } +/** + * Processes a Blockbench Group for attachment export, filtering out base model elements. + * Only processes children that have the matching clothingSlot or no slot (intermediate groups). + * This is a specialized version of process_group that filters children by clothingSlot. + */ +function process_attachment_group( + parent: Group | null, + node: Group, + accu: Array, + offset: [number, number, number], + targetSlot: string, + stepParentName?: string | null +) { + if (node.backdrop) { + return; + } + const parent_pos: [number, number, number] = parent ? parent.origin : [0, 0, 0]; + const converted_rotation = node.rotation; + + let from = util.vector_sub(node.origin, parent_pos); + let to = util.vector_sub(node.origin, parent_pos); + let rotationOrigin = util.vector_sub(node.origin, parent_pos); + + if (parent === null) { + from = util.vector_add(from, offset); + to = util.vector_add(to, offset); + rotationOrigin = util.vector_add(rotationOrigin, offset); + } + + const vsElement: VS_Element = { + name: node.name, + from: from, + to: to, + rotationOrigin: rotationOrigin, + ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), + ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), + ...(converted_rotation[2] !== 0 && { rotationZ: converted_rotation[2] }), + children: [] + }; + + for (const prop of props.VS_GROUP_PROPS) { + const prop_name = prop.name; + // Use stepParentName if provided (for top-level groups), otherwise use the node's property + const value = (prop_name === 'stepParentName' && stepParentName) ? stepParentName : node[prop_name]; + + // Skip properties with default/empty values + if (value !== undefined && value !== null && value !== '' && value !== false) { + vsElement[prop_name] = value; + } + } + + // Process child locators as attachment points + const locators = node.children.filter(child => child instanceof Locator) as Array; + if (locators.length > 0) { + const { process_locators } = require('../export_model/locator'); + const attachmentPoints = process_locators(node, locators); + if (attachmentPoints.length > 0) { + vsElement.attachmentpoints = attachmentPoints; + } + } + + accu.push(vsElement); + + // Filter children to ONLY include those with matching clothingSlot + // Don't include groups with no slot, as they might be base model elements + const filteredChildren = (node.children || []).filter((child: any) => { + if (!(child instanceof Group || child instanceof Cube)) return false; + const childSlot = (child as any).clothingSlot; + // Only include children with the matching slot - exclude everything else + const childHasMatchingSlot = childSlot && childSlot.trim() !== '' && childSlot.trim() === targetSlot.trim(); + return childHasMatchingSlot; + }); + + logDebug(`[VS Attachment Export] Filtered children of "${node.name}": ${filteredChildren.length} of ${node.children.length} match slot "${targetSlot}"`); + + // Recursively process filtered children (don't pass stepParentName - it's only for top-level elements) + traverseAttachment(node, filteredChildren, vsElement.children!, offset, targetSlot, undefined); +} + +/** + * Traverses attachment elements and processes them for export, filtering out base model elements. + * Only processes children that have the matching clothingSlot or no slot (intermediate groups). + * @param parent The parent node in the hierarchy + * @param nodes The array of nodes to process + * @param accu The accumulator for the VS elements + * @param offset The position offset to apply + * @param targetSlot The clothingSlot to filter by - only children with this slot or no slot are processed + * @param stepParentName The step parent name to apply to top-level elements (null if not top-level) + */ +function traverseAttachment(parent: Group | null, nodes: Array, accu: Array, offset: [number, number, number], targetSlot: string, stepParentName?: string | null) { + for (const node of nodes) { + if (!node.export) continue; + + // Filter: ONLY process nodes with matching clothingSlot + // Don't include nodes with no slot, as they might be base model elements + const nodeSlot = (node as any).clothingSlot; + const hasMatchingSlot = nodeSlot && nodeSlot.trim() !== '' && nodeSlot.trim() === targetSlot.trim(); + + // Skip nodes that don't have the matching slot + if (!hasMatchingSlot) { + logDebug(`[VS Attachment Export] Skipping node "${node.name}" - slot "${nodeSlot || '(none)'}" doesn't match target "${targetSlot}"`); + continue; + } + + // If this is a top-level element (parent is null) and we have a stepParentName, apply it + const isTopLevel = parent === null; + const stepParentToUse = isTopLevel ? stepParentName : undefined; + + if (node instanceof Group) { + process_attachment_group(parent, node, accu, offset, targetSlot, stepParentToUse); + } else if (node instanceof Cube) { + // For cubes, we need to apply stepParentName manually since process_cube doesn't accept it + // Save the original stepParentName to restore later + const originalStepParent = (node as any).stepParentName; + if (stepParentToUse && (!originalStepParent || originalStepParent.trim() === '')) { + (node as any).stepParentName = stepParentToUse; + } + process_cube(parent, node, accu, offset); + // Restore original stepParentName + if (stepParentToUse) { + (node as any).stepParentName = originalStepParent; + } + } + // Locator nodes are handled as attachment points on their parent elements, so skip them here + } +} + +/** + * Finds the topmost parent group that should be the root of this attachment hierarchy. + * This handles the case where clothingSlot is on cubes but not on their parent groups. + * @param element The element to find the root for + * @param clothingSlot The clothing slot to look for + * @returns The topmost group that should contain this attachment, or null + */ +function findTopmostAttachmentRoot(element: any, clothingSlot: string): Group | null { + // Walk up the tree to find the highest group with the matching clothingSlot + // Only return groups that have the matching clothingSlot (don't include groups with no slot) + let current = element.parent; + let topmostCandidate: Group | null = null; + + while (current && current instanceof Group) { + // Check if this group's clothingSlot matches + const groupSlot = (current as any).clothingSlot; + const hasMatchingSlot = groupSlot && groupSlot.trim() !== '' && groupSlot === clothingSlot; + + if (hasMatchingSlot) { + // This group has the same clothingSlot - it could be the root + topmostCandidate = current; + } else { + // This group doesn't have the matching slot - stop here + // Don't include groups with no slot, as they might be base model elements + break; + } + + current = current.parent; + } + + return topmostCandidate; +} + /** * Exports the selected attachments to the Vintage Story format. * @param {Array} selection - An array of selected attachment groups. @@ -147,65 +386,95 @@ export function exportAttachmentsVS(selection: Group[]) { // Track groups that had stepParentName temporarily set for cleanup const modifiedGroups: Group[] = []; - // Filter selection to only ROOT attachment groups - // A root attachment is one whose parent either: - // 1. Has no clothingSlot, OR - // 2. Has a different clothingSlot, OR - // 3. Is explicitly selected (to allow exporting child groups independently) - const rootAttachments = selection.filter(element => { + // First pass: find the actual root groups for all elements + // This handles cases where clothingSlot is on cubes but not parent groups + const rootGroupsSet = new Set(); + const processedElements = new Set(); + + for (const element of selection) { const myClothingSlot = (element as any).clothingSlot; if (!myClothingSlot || myClothingSlot.trim() === '') { - return false; // Not an attachment at all + continue; // Not an attachment } - const parent = element.parent; - if (!parent || !(parent instanceof Group)) { - return true; // No parent or not a group parent = root attachment - } + // Find the topmost group that should be the root + const topmostRoot = findTopmostAttachmentRoot(element, myClothingSlot); - const parentClothingSlot = (parent as any).clothingSlot; - if (!parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== myClothingSlot) { - logDebug(`[VS Attachment Export] "${element.name}" is a root attachment (parent has different/no clothingSlot)`); - return true; // Parent has different or no clothingSlot = root attachment + if (topmostRoot && topmostRoot instanceof Group) { + // Use the topmost group as the root + if (!rootGroupsSet.has(topmostRoot)) { + rootGroupsSet.add(topmostRoot); + logDebug(`[VS Attachment Export] Found topmost root "${topmostRoot.name}" for element "${element.name}"`); + } + processedElements.add(element); + } else if (element instanceof Group) { + // Element is itself a group with no parent to promote to + rootGroupsSet.add(element); + processedElements.add(element); } + // Note: Cubes without a parent group are handled below + } - // If the parent has the same clothingSlot but is NOT in the selection, - // treat this group as a root attachment (allows exporting nested groups independently) - const parentInSelection = parent instanceof Group && selection.includes(parent); - if (!parentInSelection) { - logDebug(`[VS Attachment Export] "${element.name}" is a root attachment (parent not in selection)`); - return true; + // Second pass: handle cubes that have no parent group + // These will be exported as individual elements + const orphanCubes: Cube[] = []; + for (const element of selection) { + if (processedElements.has(element)) continue; + + const myClothingSlot = (element as any).clothingSlot; + if (!myClothingSlot || myClothingSlot.trim() === '') continue; + + if (element instanceof Cube) { + orphanCubes.push(element); + logDebug(`[VS Attachment Export] Orphan cube found: "${element.name}"`); } + } - logDebug(`[VS Attachment Export] Skipping "${element.name}" - child of attachment with same clothingSlot that is also selected`); - return false; // Parent has same clothingSlot and is selected = child attachment + // Convert to array and filter out groups with no clothingSlot + // Only export groups that have a clothingSlot (don't export base model groups like "Root") + const rootAttachments = Array.from(rootGroupsSet).filter(group => { + const groupSlot = (group as any).clothingSlot; + const hasSlot = groupSlot && groupSlot.trim() !== ''; + if (!hasSlot) { + logDebug(`[VS Attachment Export] Filtering out group "${group.name}" - no clothingSlot`); + } + return hasSlot; }); - logDebug(`[VS Attachment Export] Filtered ${selection.length} elements to ${rootAttachments.length} root attachments`); + logDebug(`[VS Attachment Export] Filtered ${selection.length} elements to ${rootAttachments.length} root groups and ${orphanCubes.length} orphan cubes`); try { // Process each root attachment group with its own relative offset rootAttachments.forEach(group => { - const originalStepParent = group.stepParentName; let stepParentName = getStepParentName(group); - // If no stepParentName was determined, traverse UP the outliner hierarchy - // to find the first parent that does NOT have the same clothingSlot + // If no stepParentName was determined, find the first parent with a different clothingSlot + // This parent should be the base model group that the attachment attaches to if (!stepParentName || stepParentName.trim() === '') { - const myClothingSlot = (group as any).clothingSlot; + const groupClothingSlot = (group as any).clothingSlot; let currentParent = group.parent; + // Walk up the parent chain to find the first parent with a different (or no) clothingSlot + // This should be the base model group that the attachment attaches to while (currentParent && currentParent instanceof Group) { const parentClothingSlot = (currentParent as any).clothingSlot; - // Stop when we find a parent with a different (or no) clothingSlot - if (!parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== myClothingSlot) { + const parentHasDifferentSlot = !parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== groupClothingSlot; + + if (parentHasDifferentSlot) { + // Found a parent with a different slot (or no slot) - this is the step parent stepParentName = currentParent.name; - logDebug(`[VS Attachment Export] Found first non-matching parent for "${group.name}": "${stepParentName}"`); + logDebug(`[VS Attachment Export] Found step parent for "${group.name}": "${stepParentName}" (parent slot: "${parentClothingSlot || '(none)'}", attachment slot: "${groupClothingSlot}")`); break; } currentParent = currentParent.parent; } + + // If we still don't have a step parent, try using the immediate parent as a last resort + if (!stepParentName && group.parent && group.parent instanceof Group) { + stepParentName = group.parent.name; + logDebug(`[VS Attachment Export] Using immediate parent as fallback step parent for "${group.name}": "${stepParentName}"`); + } } if (stepParentName && stepParentName.trim() !== '') { @@ -232,14 +501,75 @@ export function exportAttachmentsVS(selection: Group[]) { logDebug(`[VS Attachment Export] Calculated offset for "${group.name}" relative to "${stepParentName}": [${offset.join(', ')}]`); } - // Use the main model traversal logic to process the attachment + // Use filtered attachment traversal logic to process the attachment // We create a temporary array to hold the elements for this single attachment const attachmentElements: VS_Element[] = []; - traverse(null, [group], attachmentElements, offset); + const groupClothingSlot = (group as any).clothingSlot || ''; + traverseAttachment(null, [group], attachmentElements, offset, groupClothingSlot, stepParentName); // Add the processed elements to the main elements array data.elements.push(...attachmentElements); }); + + // Process orphan cubes (cubes with clothingSlot but no parent group) + // This is a fallback for edge cases where cubes are at the root level + if (orphanCubes.length > 0) { + logDebug(`[VS Attachment Export] Processing ${orphanCubes.length} orphan cubes`); + + // Group orphan cubes by clothingSlot to determine stepParentName for each group + const cubesBySlot = new Map(); + orphanCubes.forEach(cube => { + const slot = (cube as any).clothingSlot || ''; + if (!cubesBySlot.has(slot)) { + cubesBySlot.set(slot, []); + } + cubesBySlot.get(slot)!.push(cube); + }); + + // Process each orphan cube individually to determine its correct stepParentName + // Each cube should use its immediate base model parent, not a shared parent + const orphanElements: VS_Element[] = []; + orphanCubes.forEach(cube => { + // Determine stepParentName for this specific cube + let stepParentName: string | null = null; + const cubeSlot = (cube as any).clothingSlot || ''; + let currentParent = cube.parent; + + // Walk up the parent chain to find the first parent with a different (or no) clothingSlot + // This should be the immediate base model parent for this cube + while (currentParent && currentParent instanceof Group) { + const parentClothingSlot = (currentParent as any).clothingSlot; + const parentHasDifferentSlot = !parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== cubeSlot; + + if (parentHasDifferentSlot) { + // Found a parent with a different slot (or no slot) - this is the step parent + stepParentName = currentParent.name; + logDebug(`[VS Attachment Export] Found step parent for orphan cube "${cube.name}": "${stepParentName}" (parent slot: "${parentClothingSlot || '(none)'}", cube slot: "${cubeSlot}")`); + break; + } + currentParent = currentParent.parent; + } + + // If we still don't have a step parent, try using the immediate parent as a last resort + if (!stepParentName && cube.parent && cube.parent instanceof Group) { + stepParentName = cube.parent.name; + logDebug(`[VS Attachment Export] Using immediate parent as fallback step parent for orphan cube "${cube.name}": "${stepParentName}"`); + } + + // Temporarily set stepParentName on the cube + const originalStepParent = (cube as any).stepParentName; + if (stepParentName && (!originalStepParent || originalStepParent.trim() === '')) { + (cube as any).stepParentName = stepParentName; + } + // Process the cube + process_cube(null, cube, orphanElements, [0, 0, 0]); + // Restore original stepParentName + if (stepParentName) { + (cube as any).stepParentName = originalStepParent; + } + }); + data.elements.push(...orphanElements); + } } finally { // Clean up the temporarily added property to avoid side effects modifiedGroups.forEach(group => { @@ -254,37 +584,64 @@ export function exportAttachmentsVS(selection: Group[]) { name: `${selection[0].name}_attachment.json`, startpath: Project.save_path, custom_writer: (content: any, path: string) => { + logDebug(`[VS Attachment Export] Writing to path: ${path}`); + + // Collect texture names that are actually used by the exported elements + const usedTextureNames = getUsedTextureNames(data.elements); + logDebug(`[VS Attachment Export] Used textures: ${Array.from(usedTextureNames).join(', ')}`); + // Check if the file already exists if (fs.existsSync(path)) { + logDebug('[VS Attachment Export] File exists, will overwrite'); try { // Read the existing file const existingContent = fs.readFileSync(path, 'utf8'); const existingData = JSON.parse(existingContent); - // Use textureSizes and textures from the existing file + // Use textureSizes and textures from the existing file, but only for used textures if (existingData.textureSizes) { - data.textureSizes = existingData.textureSizes; + for (const [name, size] of Object.entries(existingData.textureSizes)) { + if (usedTextureNames.has(name)) { + data.textureSizes[name] = size; + } + } + logDebug('[VS Attachment Export] Preserved textureSizes from existing file (filtered to used textures)'); } if (existingData.textures) { - data.textures = existingData.textures; + for (const [name, location] of Object.entries(existingData.textures)) { + if (usedTextureNames.has(name)) { + data.textures[name] = location; + } + } + logDebug('[VS Attachment Export] Preserved textures from existing file (filtered to used textures)'); } - - logDebug('[VS Attachment Export] Preserved textureSizes and textures from existing file'); } catch (e) { console.error('[VS Attachment Export] Error reading existing file:', e); // Warn user about malformed JSON Blockbench.showQuickMessage('Warning: Existing file has invalid JSON. Using project textures instead.', 3000); - // Fall back to current project textures - populateTexturesFromProject(data); + // Fall back to current project textures (filtered to used textures) + populateTexturesFromProject(data, usedTextureNames); } } else { - // File doesn't exist, use textures from current project - populateTexturesFromProject(data); + // File doesn't exist, use textures from current project (filtered to used textures) + logDebug('[VS Attachment Export] File does not exist, using project textures'); + populateTexturesFromProject(data, usedTextureNames); } - // Write the final content + // Final cleanup pass: remove any textureSizes/textures entries not actually used + // This prevents crashes in VS when unused texture references are present + cleanupUnusedTextures(data, usedTextureNames); + + // Write the final content (this will overwrite if file exists) const finalContent = autoStringify(data); - fs.writeFileSync(path, finalContent); + try { + fs.writeFileSync(path, finalContent, 'utf8'); + logDebug(`[VS Attachment Export] Successfully wrote file: ${path}`); + Blockbench.showQuickMessage(`Exported attachment to ${path.split(/[/\\]/).pop()}`, 2000); + } catch (e) { + console.error('[VS Attachment Export] Error writing file:', e); + Blockbench.showQuickMessage(`Failed to write file: ${e instanceof Error ? e.message : String(e)}`, 5000); + } } }); } diff --git a/src/attachments/importer.ts b/src/attachments/importer.ts index 4867a4b..141e3dd 100644 --- a/src/attachments/importer.ts +++ b/src/attachments/importer.ts @@ -109,6 +109,33 @@ function createCubeFromElementData(elemData: BBCubeElement, parentGroup: any, te remapCubeFaceTextures(cubeProps, textureMap); + // Handle legacy bbmodel files where clothingSlot is on cubes instead of groups. + // If the cube has a clothingSlot, propagate it up the hierarchy to parent groups. + // Stop when we reach a parent that already has a clothingSlot (this is a boundary - either + // an existing attachment group or a base model group that has been marked). + // Then clear clothingSlot from the cube since cubes should inherit from their parent group. + const cubeClothingSlot = cubeProps.clothingSlot; + if (cubeClothingSlot && typeof cubeClothingSlot === 'string' && cubeClothingSlot.trim() !== '') { + // Propagate clothingSlot up the hierarchy, but stop at boundaries + let currentGroup: any = parentGroup; + while (currentGroup && currentGroup instanceof Group) { + const existingSlot = currentGroup.clothingSlot; + // If this group already has a clothingSlot, stop propagation (we've hit a boundary) + if (existingSlot && existingSlot.trim() !== '') { + logDebug(`[Import BB] Stopped propagation at group "${currentGroup.name}" which already has clothingSlot "${existingSlot}"`); + break; + } + // This group doesn't have a clothingSlot - propagate it up + currentGroup.clothingSlot = cubeClothingSlot; + logDebug(`[Import BB] Propagated clothingSlot "${cubeClothingSlot}" from cube to group "${currentGroup.name}"`); + // Move up to the parent group + currentGroup = currentGroup.parent; + } + // Clear clothingSlot from the cube to prevent it from being detected as a separate attachment + delete cubeProps.clothingSlot; + logDebug(`[Import BB] Cleared clothingSlot from cube: ${cubeProps.name ?? '(unnamed)'}`); + } + const cube = new Cube(cubeProps); cube.addTo(parentGroup).init(); logDebug(`[Import BB] Created cube: ${cube.name ?? '(unnamed)'}`); diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 027af50..3198cea 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -144,8 +144,11 @@ const vuePanel = { font-weight: 500; min-height: 32px; } - .attachment-section h2 .slot-badge { - font-size: 6px !important; + .attachments-panel .attachment-section h2 { + font-size: 18px !important; + } + .attachments-panel .attachment-section h2 * { + font-size: inherit !important; } .attachment-section h2:hover { background: rgba(255, 255, 255, 0.05); @@ -161,20 +164,25 @@ const vuePanel = { gap: 3px; padding: 1px 6px; border-radius: 10px; - font-size: 6px !important; + font-size: 18px !important; font-weight: 500; line-height: 1.3; flex-shrink: 0; } - .attachment-section h2 .slot-badge { + .attachments-panel .attachment-section h2 .slot-badge { font-size: 18px !important; } - .slot-badge-text { + /* Override w3-css h2 styles specifically for our text span */ + .attachments-panel .slot-badge-text, + .attachments-panel .attachment-section h2 .slot-badge .slot-badge-text, + .attachments-panel .attachment-section h2 .slot-badge-text, + .attachments-panel .attachment-section h2 span.slot-badge span.slot-badge-text { font-size: 18px !important; line-height: 1.3; display: inline-block; } - .attachment-section h2 .slot-badge-text { + /* Maximum specificity to override w3-css */ + .attachments-panel .attachment-section h2 .slot-badge .slot-badge-text { font-size: 18px !important; } .slot-icon { @@ -320,7 +328,7 @@ const vuePanel = { {{ getSlotInfo(section.slot).icon }} - {{ section.slot }}: {{ section.elements.length }} {{ getSectionStats(section.elements) }} + {{ section.slot }}: {{ section.elements.length }} {{ getSectionStats(section.elements) }} diff --git a/src/attachments/post_import.ts b/src/attachments/post_import.ts index 233fbcd..025afac 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -1,4 +1,3 @@ - import { inferClothingSlotFromPath } from './presets'; import { showClothingSlotDialog } from './dialogs'; import { findAllGroupsByName, findGroupByName, stripNumericSuffix, collectGroupsDepthFirst, isDescendantOf } from '../util/outliner'; @@ -11,18 +10,21 @@ function logDebug(message: string, ...args: any[]) { if (DEBUG) console.log(message, ...args); } +function getGroupPath(group: Group): string { + const parts: string[] = []; + let current: any = group; + while (current && current instanceof Group) { + parts.unshift(current.name || 'Unnamed'); + current = current.parent; + } + return parts.join(' > ') || group.name || 'Unknown'; +} + /** - * Finds a group in the existing model that matches the given clothing slot. - * Prioritizes groups that: - * 1. Have the same clothing slot AND same name (case-insensitive) - * 2. Have the same clothing slot (any name) - * @param clothingSlot The clothing slot to search for - * @param groupName Optional name to help prioritize matches - * @param existingElements Elements that existed before import (to exclude new elements) - * @returns The best matching group, or null if none found + * Finds a matching group in the existing model based on clothing slot and optional name. */ function findBestMatchingGroupBySlot(clothingSlot: string, groupName: string | null, existingElements: Set): Group | null { - if (!clothingSlot || clothingSlot.trim() === '') return null; + if (!clothingSlot || !clothingSlot.trim()) return null; const normalizedSlot = clothingSlot.trim().toLowerCase(); const normalizedName = groupName ? groupName.trim().toLowerCase() : ''; @@ -36,22 +38,16 @@ function findBestMatchingGroupBySlot(clothingSlot: string, groupName: string | n const elemSlot = (element.clothingSlot || '').trim().toLowerCase(); if (elemSlot === normalizedSlot) { - // This group has matching clothing slot if (normalizedName && (element.name || '').trim().toLowerCase() === normalizedName) { - // Perfect match: same slot AND same name exactNameMatch = element; - return; // Stop searching, we found the best possible match - } - if (!bestMatch) { - // First match with this slot - bestMatch = element; + return; } + if (!bestMatch) bestMatch = element; } } - if (element.children && element.children.length > 0) { search(element.children); - if (exactNameMatch) return; // Early exit if we found exact match + if (exactNameMatch) return; } } } @@ -61,240 +57,230 @@ function findBestMatchingGroupBySlot(clothingSlot: string, groupName: string | n } /** - * Performs post-import processing on newly added elements to automate project organization. - * The process runs in a specific order: - * 1. **Smart Clothing Slot Matching:** Matches top-level imported groups to existing groups with same clothing slot. - * 2. **Re-parenting:** Moves elements under their designated parent based on the `stepParentName` property. - * 3. **Merge Duplicates:** Merges groups that were duplicated on import (e.g., `head2` into `head`). - * 4. **Apply Clothing Slot:** Shows a dialog for user to select clothing slot, then applies it to all new elements. - * This function is critical for a smooth user workflow, as it handles tedious manual organization tasks. - * @param elementsBefore A `Set` of all elements that existed before the import. - * @param filePath Path to the first imported file, used for clothing slot inference. - * @param logPrefix A prefix for console log messages (e.g., "Import BB"). - * @param model Optional model data for import preview dialog. + * Applies the selected clothing slot to all new elements. */ -export async function processImportedAttachments(elementsBefore: Set, filePath: string, logPrefix: string, model?: any) { - const elementsAfter = new Set([...Group.all, ...Cube.all]); - const newElements = [...elementsAfter].filter(e => !elementsBefore.has(e)); +function applyClothingSlot(newElements: any[], slot: string, logPrefix: string) { + if (DEBUG) console.log(`[${logPrefix}] Applying slot "${slot}" to ${newElements.length} elements.`); + + const apply = (element: any) => { + if (element instanceof Group || element instanceof Cube) { + element.clothingSlot = slot; + } + if (element.children) { + element.children.forEach(apply); + } + }; + + // Filter for top-level new elements to avoid double processing const newElementsSet = new Set(newElements); + const topLevel = newElements.filter(e => !e.parent || !newElementsSet.has(e.parent)); - // Track matches for user feedback - const matchingTable: { imported: string; matchedTo: string; slot: string }[] = []; + topLevel.forEach(element => { + apply(element); + markAsRecentlyImported(element); + element.children?.forEach((child: any) => markAsRecentlyImported(child)); + }); +} - // STEP 1: Smart Clothing Slot Matching - // Find top-level groups from the import and try to match them to existing groups by clothing slot - const topLevelNewGroups = newElements.filter(element => { - if (!(element instanceof Group)) return false; - const parent = element.parent; - return !parent || !newElementsSet.has(parent); - }) as Group[]; +/** + * Matches new groups to existing ones based on clothing slot. + */ +function smartMatchGroups(newElements: any[], newElementsSet: Set, existingElements: Set, logPrefix: string): number { + const topLevelGroups = newElements.filter(e => e instanceof Group && (!e.parent || !newElementsSet.has(e.parent))) as Group[]; + let matchCount = 0; - if (DEBUG) console.log(`[${logPrefix}] Found ${topLevelNewGroups.length} top-level new groups for smart matching`); + if (DEBUG) console.log(`[${logPrefix}] Smart match: checking ${topLevelGroups.length} groups.`); - topLevelNewGroups.forEach(newGroup => { - const clothingSlot = newGroup.clothingSlot?.trim(); - if (!clothingSlot) return; + for (const newGroup of topLevelGroups) { + const slot = newGroup.clothingSlot?.trim(); + if (!slot) continue; - // Try to find a matching group in the existing model - const matchedGroup = findBestMatchingGroupBySlot(clothingSlot, newGroup.name, elementsBefore); + const match = findBestMatchingGroupBySlot(slot, newGroup.name, existingElements); + if (match) { + if (DEBUG) console.log(`[${logPrefix}] Matched "${newGroup.name}" -> "${match.name}" (slot: ${slot})`); - if (matchedGroup) { - // Move all children of newGroup to the matched group - const childrenToMove = [...newGroup.children]; + [...newGroup.children].forEach(child => child.addTo(match)); + matchCount++; - if (DEBUG) console.log(`[${logPrefix}] Smart Match: "${newGroup.name}" (slot: ${clothingSlot}) -> "${matchedGroup.name}"`); + if (newGroup.children.length === 0) newGroup.remove(); + } + } + return matchCount; +} - childrenToMove.forEach(child => { - try { - child.addTo(matchedGroup); - } catch (e) { - if (DEBUG) console.error(`[${logPrefix}] Failed to move "${child.name}" to matched group "${matchedGroup.name}":`, e); - } - }); +/** + * Merges new groups into existing groups with the same name (hierarchical merge). + */ +function mergeHierarchicalGroups(newElements: any[], newElementsSet: Set, logPrefix: string) { + if (DEBUG) console.log(`[${logPrefix}] merging hierarchies.`); - // Track this match for display - matchingTable.push({ - imported: newGroup.name || 'Unnamed', - matchedTo: matchedGroup.name || 'Unnamed', - slot: clothingSlot - }); + const allNewGroups = collectGroupsDepthFirst(newElements.filter(e => e instanceof Group && newElementsSet.has(e))); - // Remove the now-empty imported group - if (newGroup.children.length === 0) { - newGroup.remove(); - } - } - }); + // Process deepest first + for (let i = allNewGroups.length - 1; i >= 0; i--) { + const newGroup = allNewGroups[i]; + if (!newElementsSet.has(newGroup)) continue; - // Display matching table if any matches were found - if (matchingTable.length > 0) { - if (DEBUG) { - logDebug(`[${logPrefix}] Smart Matching Results:`); - console.table(matchingTable); - } - Blockbench.showQuickMessage(`Matched ${matchingTable.length} attachment group(s) to existing model`, QUICK_MESSAGE_DURATION); - } + const matches = findAllGroupsByName(newGroup.name, Outliner.root).filter(g => !newElementsSet.has(g)); - // STEP 1.5: Parent Matching by Group Name (for groups with same name as existing) - // This handles the case where the imported attachment has a group with the same name - // as a group already existing in the model hierarchy (e.g., importing "Ears" when "Head > Ears" exists) - if (DEBUG) console.log(`[${logPrefix}] Processing top-level groups for name-based parent matching`); - - // Get remaining top-level groups (those not matched in Step 1) - const remainingTopLevelGroups = newElements.filter(element => { - if (!(element instanceof Group)) return false; - const parent = element.parent; - return !parent || !newElementsSet.has(parent); - }) as Group[]; - - remainingTopLevelGroups.forEach(newGroup => { - // Find existing groups with the same name (case-insensitive) - const allMatches = findAllGroupsByName(newGroup.name, Outliner.root); - const existingMatch = allMatches.find(g => !newElementsSet.has(g)); - - if (existingMatch) { - // Move all children of the new group into the existing group - const childrenToMove = [...newGroup.children]; - - if (DEBUG) console.log(`[${logPrefix}] Name Match: Moving children of "${newGroup.name}" into existing group`); - - childrenToMove.forEach(child => { - try { - child.addTo(existingMatch); - } catch (e) { - if (DEBUG) console.error(`[${logPrefix}] Failed to move "${child.name}" to existing "${existingMatch.name}":`, e); - } - }); + if (matches.length > 0) { + const target = matches[0]; // Take first match + if (DEBUG) console.log(`[${logPrefix}] Merging "${newGroup.name}" into "${target.name}"`); + + [...newGroup.children].forEach(child => child.addTo(target)); - // Copy clothingSlot from imported group if the existing group doesn't have one - if (newGroup.clothingSlot && !existingMatch.clothingSlot) { - existingMatch.clothingSlot = newGroup.clothingSlot; - if (DEBUG) console.log(`[${logPrefix}] Copied clothingSlot "${newGroup.clothingSlot}" to existing "${existingMatch.name}"`); + if (newGroup.clothingSlot && !target.clothingSlot) { + target.clothingSlot = newGroup.clothingSlot; } - // Remove the now-empty imported group if (newGroup.children.length === 0) { newGroup.remove(); - // Remove from newElements and newElementsSet to avoid processing later - const idx = newElements.indexOf(newGroup); - if (idx !== -1) newElements.splice(idx, 1); newElementsSet.delete(newGroup); } } + } +} + +/** + * Merges groups that share the same clothing slot and have similar names. + */ +function mergeGroupsByCommonSlot(newElements: any[], newElementsSet: Set, logPrefix: string) { + if (DEBUG) console.log(`[${logPrefix}] merging by common slot.`); + + const bySlot = new Map(); + newElements.forEach(e => { + if (e instanceof Group && newElementsSet.has(e) && e.clothingSlot) { + const slot = e.clothingSlot.trim(); + if (!bySlot.has(slot)) bySlot.set(slot, []); + bySlot.get(slot)!.push(e); + } }); - // STEP 2: Re-parenting based on stepParentName - if (DEBUG) console.log(`[${logPrefix}] Processing ${newElements.length} new elements for stepParent reparenting`); - newElements.forEach(element => { - const stepParentName = element.stepParentName?.trim(); - if (stepParentName) { - const allMatches = findAllGroupsByName(stepParentName, Outliner.root); + bySlot.forEach((groups, slot) => { + if (groups.length < 2) return; - let parentGroup = allMatches.find(g => !newElementsSet.has(g)) || null; + // Sort by name length to find the "base" group (shortest name) + groups.sort((a, b) => (a.name || '').length - (b.name || '').length); + const base = groups[0]; + const baseName = (base.name || '').toLowerCase(); - if (!parentGroup && allMatches.length === 0) { - parentGroup = new Group({ name: stepParentName }).addTo().init(); - if (DEBUG) console.log(`[${logPrefix}] Created missing stepParent group: "${stepParentName}"`); - } + if (!baseName) return; + + // Find groups extending the base name (e.g. "Hair" vs "HairOuter") + const targets = groups.slice(1).filter(g => (g.name || '').toLowerCase().startsWith(baseName)); + if (targets.length === 0) return; - if (parentGroup && parentGroup !== element && !(element instanceof Group && isDescendantOf(parentGroup, element))) { - try { - element.addTo(parentGroup); - if (DEBUG) console.log(`[${logPrefix}] Reparented "${element.name}" under "${stepParentName}" in outliner (keeping stepParentName for mesh positioning)`); - } catch (e) { - if (DEBUG) console.error(`[${logPrefix}] Failed to reparent "${element.name}" to "${stepParentName}":`, e); + // Check if base exists in old model, otherwise use the new one as base + const existMatches = findAllGroupsByName(base.name, Outliner.root).filter(g => !newElementsSet.has(g)); + const finalTarget = existMatches[0] || base; + + if (finalTarget === base || !newElementsSet.has(finalTarget)) { + targets.forEach(g => { + if (!newElementsSet.has(g)) return; + + if (DEBUG) console.log(`[${logPrefix}] Merging "${g.name}" into "${finalTarget.name}" (slot: ${slot})`); + [...g.children].forEach(c => c.addTo(finalTarget)); + + if (g.children.length === 0) { + g.remove(); + newElementsSet.delete(g); } - } + }); + } + }); +} + +/** + * Reparents elements based on their `stepParentName` property. + */ +function reparentByStepParent(newElements: any[], newElementsSet: Set, logPrefix: string) { + if (DEBUG) console.log(`[${logPrefix}] reparenting by stepParent.`); + + newElements.forEach(element => { + const stepParent = element.stepParentName?.trim(); + if (!stepParent) return; + + const matches = findAllGroupsByName(stepParent, Outliner.root); + let parent = matches.find(g => !newElementsSet.has(g)); + + if (!parent && matches.length === 0) { + parent = new Group({ name: stepParent }).addTo().init(); + if (DEBUG) console.log(`[${logPrefix}] Created stepParent group: "${stepParent}"`); + } + + if (parent && parent !== element && !isDescendantOf(parent, element)) { + element.addTo(parent); } }); +} - // STEP 3: Merge Duplicates - const groupsToDelete: Group[] = []; - const updatedGroups = collectGroupsDepthFirst(Outliner.root); - updatedGroups.forEach(group => { - const gname = group.name || ''; - const baseName = stripNumericSuffix(gname); - if (baseName !== gname && baseName) { - const originalGroup = findGroupByName(baseName, Outliner.root); - if (originalGroup && originalGroup !== group) { +/** + * Merges any duplicate groups in the model. + */ +function mergeDuplicateGroups(logPrefix: string) { + const toDelete: Group[] = []; + collectGroupsDepthFirst(Outliner.root).forEach(group => { + const name = group.name || ''; + const base = stripNumericSuffix(name); + + if (base !== name && base) { + const original = findGroupByName(base, Outliner.root); + if (original && original !== group) { + // Move children if safe [...group.children].forEach(child => { - if (child === originalGroup || child.uuid === originalGroup.uuid) { - if (DEBUG) console.warn(`[${logPrefix}] Skipping move - child "${child.name}" is the target group`); - return; - } - if (child.parent === originalGroup) { - if (DEBUG) console.warn(`[${logPrefix}] Skipping move - child "${child.name}" already under "${originalGroup.name}"`); - return; - } - if (isDescendantOf(originalGroup, child)) { - if (DEBUG) console.warn(`[${logPrefix}] Skipping move - "${originalGroup.name}" is descendant of "${child.name}"`); - return; - } - try { - child.addTo(originalGroup); - } catch (e) { - if (DEBUG) console.error(`[${logPrefix}] Failed to move "${child.name}" to "${originalGroup.name}":`, e); + if (child !== original && child.parent !== original && !isDescendantOf(original, child)) { + child.addTo(original); } }); - groupsToDelete.push(group); + toDelete.push(group); } } }); - groupsToDelete.forEach(group => group.remove()); + toDelete.forEach(g => g.remove()); +} + +/** + * Main entry point: Organizes imported attachments. + */ +export async function processImportedAttachments(elementsBefore: Set, filePath: string, logPrefix: string, model?: any) { + const elementsAfter = new Set([...Group.all, ...Cube.all]); + const newElements = [...elementsAfter].filter(e => !elementsBefore.has(e)); + const newElementsSet = new Set(newElements); - // STEP 4: Apply Clothing Slot - Show dialog and apply user's selection - const inferredSlot = inferClothingSlotFromPath(filePath); - const result = await showClothingSlotDialog(inferredSlot, filePath, model); - const masterClothingSlot = result.slot; + // 1. User selects clothing slot + const inferred = inferClothingSlotFromPath(filePath); + const result = await showClothingSlotDialog(inferred, filePath, model); + const masterSlot = result.slot; - if (masterClothingSlot) { - if (DEBUG) console.log(`[${logPrefix}] Applying user-selected clothing slot "${masterClothingSlot}" to ${newElements.length} new elements.`); + if (!masterSlot) { + if (DEBUG) console.log(`[${logPrefix}] Import cancelled.`); + newElements.forEach(e => e.remove()); + Blockbench.showQuickMessage('Import cancelled', QUICK_MESSAGE_DURATION); + return; + } - function applySlotRecursive(element: any, slot: string) { - if (element instanceof Group || element instanceof Cube) { - if (!element.clothingSlot || element.clothingSlot.trim() === '') { - element.clothingSlot = slot; - if (DEBUG) console.log(`[${logPrefix}] Applied slot "${slot}" to ${element instanceof Group ? 'group' : 'cube'}: ${element.name}`); - } - } - if (element.children) { - element.children.forEach((child: any) => applySlotRecursive(child, slot)); - } - } + // 2. Apply slot to new elements + applyClothingSlot(newElements, masterSlot, logPrefix); - const topLevelNewElements = newElements.filter(element => { - const parent = element.parent; - return !parent || !newElementsSet.has(parent); - }); + // 3. Smart Match: New groups -> Old groups with same slot + const matchCount = smartMatchGroups(newElements, newElementsSet, elementsBefore, logPrefix); + if (matchCount > 0) { + Blockbench.showQuickMessage(`Matched ${matchCount} groups`, QUICK_MESSAGE_DURATION); + } - if (DEBUG) console.log(`[${logPrefix}] Found ${topLevelNewElements.length} top-level new elements to process`); + // 4. Hierarchical Match: New groups -> Old groups with same name/path + mergeHierarchicalGroups(newElements, newElementsSet, logPrefix); - topLevelNewElements.forEach(element => { - applySlotRecursive(element, masterClothingSlot); - // Mark as recently imported - markAsRecentlyImported(element); - // Also mark all children - if (element.children) { - element.children.forEach((child: any) => markAsRecentlyImported(child)); - } - }); - } else { - // User cancelled - remove all imported elements to prevent orphaned attachments - if (DEBUG) console.log(`[${logPrefix}] User cancelled clothing slot selection, removing ${newElements.length} imported elements`); - - // Remove all newly imported elements - newElements.forEach(element => { - try { - element.remove(); - } catch (e) { - if (DEBUG) console.error(`[${logPrefix}] Failed to remove element "${element.name}":`, e); - } - }); + // 5. Common Slot Match: "HairOuter" -> "Hair" if same slot + mergeGroupsByCommonSlot(newElements, newElementsSet, logPrefix); - Blockbench.showQuickMessage('Import cancelled - no elements added', QUICK_MESSAGE_DURATION); - } + // 6. Step Parent Reparenting + reparentByStepParent(newElements, newElementsSet, logPrefix); + + // 7. Cleanup Duplicates + mergeDuplicateGroups(logPrefix); - Undo.finishEdit(`Import and parent attachment: ${filePath.split(/[/\\]/).pop() || 'attachment'}`); + Undo.finishEdit(`Import attachment: ${filePath.split(/[/\\]/).pop()}`); Canvas.updateAll(); - updateSelection?.(); + if (typeof updateSelection === 'function') updateSelection(); } diff --git a/src/codec.ts b/src/codec.ts index e152cd1..7918d51 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -1,38 +1,32 @@ import { ex } from "./export"; import { im } from "./import"; -import {schema} from "./generated/vs_shape_schema"; +import { schema } from "./generated/vs_shape_schema"; import Ajv from "ajv"; import { VS_Shape } from "./vs_shape_def"; +import JSON5 from "json5"; export const codecVS = new Codec("codecVS", { - name: "Vintage Story Codec", - extension: "json", - remember: true, - load_filter: { - extensions: ["json"], - type: 'text', - condition(model) { - const content = autoParseJSON(model); - // Quick check for VS-specific structure before full validation - if (!content || typeof content !== 'object') return false; - if (!content.elements || !Array.isArray(content.elements)) return false; - if (!content.textures || typeof content.textures !== 'object') return false; - // Full schema validation - return validate_json(content); - } - }, - compile(options) { - // Removed for now since it doesn't work - // resetStepparentTransforms(); - return autoStringify(ex(options)); - }, - parse(data, file_path, _add) { - im(autoParseJSON(data) as VS_Shape, file_path, false); - // Removed for now since it doesn't work - // loadBackDropShape(); - // resolveStepparentTransforms(); - }, - }); + name: "Vintage Story Codec", + extension: "json", + remember: true, + load_filter: { + extensions: ["json"], + type: 'text', + condition(model) { + const content = JSON5.parse(model); + if (!content || typeof content !== 'object') return false; + if (!content.elements || !Array.isArray(content.elements)) return false; + if (!content.textures || typeof content.textures !== 'object') return false; + return validate_json(content); + } + }, + compile(options) { + return autoStringify(ex(options)); + }, + parse(data, file_path, _add) { + im(JSON5.parse(data) as VS_Shape, file_path, false); + }, +}); function validate_json(content) { const ajv = new Ajv(); diff --git a/src/mods/ikMod.ts b/src/mods/ikMod.ts index 6af2740..f40da64 100644 --- a/src/mods/ikMod.ts +++ b/src/mods/ikMod.ts @@ -2,14 +2,41 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; import { codecVS } from "../codec"; +import { createPanel } from "../util/moddingTools"; + +// Blockbench global types +declare var Property: any; +declare var Action: any; +declare var Dialog: any; +declare var MenuBar: any; +declare var BarItems: any; /** * IK Improvement Mod for VS Plugin * - * This mod addresses IK limitations for VS export: + * This mod addresses IK limitations for VS export and adds interactive IK features: * 1. Detects IK usage and warns users that VS doesn't support IK natively * 2. Bakes IK animations to keyframes on export (converts IK-driven motion to keyframe animation) * 3. Provides user feedback during the baking process + * 4. Adds advanced IK constraint features: + * - Animatable IK weight/enable toggle + * - Lock IK end controller position during animation + * - Rotation constraints (axis limits and restrictions) + * - External void object helpers for orientation influence + * - Manual keyframe override support + * 5. Interactive IK features for faster animation workflow: + * - Pin/unpin bones to lock them in place during IK solving + * - Interactive IK mode: When enabled, dragging bones in IK chains will + * automatically adjust connected joints (works with Blockbench's built-in IK) + * - Forward kinematics support: Pin end effectors (like feet) and move + * the root/torso, having intermediate joints auto-adjust + * + * Usage for Interactive IK: + * - Enable "Interactive IK Mode" on an IK controller + * - Use "Toggle Pin Bone" action or checkbox to pin bones that should stay fixed + * - Drag bones in the IK chain - connected joints will auto-adjust + * - Example: Pin the hoof and thigh, then drag the knee to adjust leg position + * - Example: Pin all feet, then move the torso - legs will auto-adjust to keep feet in place * * Known Limitations: * - VS format does not support IK, so all IK must be converted to keyframes @@ -17,13 +44,46 @@ import { codecVS } from "../codec"; * a Blockbench core issue that may require fixes in Blockbench itself * - The baking process samples IK at regular intervals, which may create many keyframes * - Complex IK setups may need manual refinement after baking - * - * Future Improvements: - * - Add IK settings panel with baking options (sample rate, which controllers to bake) - * - Improve transform inheritance fixes for IK controllers - * - Add IK constraint visualization/feedback + * - Real-time IK solving uses CCD (Cyclic Coordinate Descent) algorithm which works well + * for 2-3 bone chains but may need more iterations for longer chains + * - Bone length is calculated from child bones or chain relationships, which may not + * always match the visual bone length in Blockbench */ +/** + * IK Constraint Data Structure + */ +interface IKConstraintData { + // Weight/enable (0-1, where 0 = disabled, 1 = fully enabled) + weight?: number; + // Lock controller position during animation + lockPosition?: boolean; + // Locked position (set when lockPosition is enabled) + lockedPosition?: [number, number, number]; + // Rotation constraints per bone in chain + boneConstraints?: Record; + // External orientation helper (void object name) + orientationHelper?: string; + // Interactive IK mode (for real-time dragging) + interactiveMode?: boolean; + // Pinned bones (bones that stay fixed during IK solving) + pinnedBones?: string[]; +} + +/** + * Bone rotation constraints + */ +interface BoneConstraint { + // Allowed rotation axes (true = can rotate, false = locked) + allowedAxes?: { x: boolean; y: boolean; z: boolean }; + // Rotation limits in degrees + rotationLimits?: { + x?: { min: number; max: number }; + y?: { min: number; max: number }; + z?: { min: number; max: number }; + }; +} + createBlockbenchMod( `${PACKAGE.name}:ik_mod`, { @@ -31,6 +91,455 @@ createBlockbenchMod( originalCompile: codecVS.compile.bind(codecVS), }, context => { + // Add IK constraint properties to Group (for IK controllers) + //@ts-expect-error: Property may not be in types + const weightProp = new Property(Group, "number", "vsIKWeight", { + default: 1.0, + label: "IK Weight", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + // Check if it's a null object or locator + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Weight condition error:', e); + return false; + } + }, + inputs: { + element_panel: { + input: { + label: 'IK Weight (0-1)', + type: 'number', + min: 0, + max: 1, + step: 0.01 + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + // Initialize property from constraint data if not set + if (obj.vsIKWeight === undefined) { + const constraintData = getIKConstraintData(obj); + obj.vsIKWeight = constraintData.weight ?? 1.0; + } + // Sync to constraint data + const constraintData = getIKConstraintData(obj); + constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); + setIKConstraintData(obj, constraintData); + + // If in animation mode, create/update keyframe for weight + if (Format.animation_mode) { + //@ts-expect-error: Animation type + const currentAnimation = Animation.selected; + if (currentAnimation) { + const animator = currentAnimation.getBoneAnimator(obj); + if (animator) { + const currentTime = currentAnimation.time; + const weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); + + // Check if keyframe already exists at this time + const existingKf = animator.keyframes.find((kf: any) => + kf.channel === 'ik_weight' && Math.abs(kf.time - currentTime) < 0.01 + ); + + if (existingKf) { + // Update existing keyframe + if (existingKf.data_points && existingKf.data_points[0]) { + existingKf.data_points[0].x = weight; + } + } else { + // Add new keyframe + animator.addKeyframe({ + interpolation: 'linear', + time: currentTime, + channel: 'ik_weight', + data_points: [{ x: weight, y: 0, z: 0 }] + }); + } + } + } + } + } + }, + keyframeable: true + }); + //@ts-expect-error: Property may not be in types + const lockProp = new Property(Group, "boolean", "vsIKLockPosition", { + default: false, + label: "Lock IK Position", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + // Check if it's a null object or locator + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Lock Position condition error:', e); + return false; + } + }, + inputs: { + element_panel: { + input: { + label: 'Lock Controller Position', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + // Initialize property from constraint data if not set + if (obj.vsIKLockPosition === undefined) { + const constraintData = getIKConstraintData(obj); + obj.vsIKLockPosition = constraintData.lockPosition ?? false; + } + // Sync to constraint data + const constraintData = getIKConstraintData(obj); + constraintData.lockPosition = obj.vsIKLockPosition ?? false; + if (constraintData.lockPosition) { + // Store current position as locked position + constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; + } + setIKConstraintData(obj, constraintData); + + // If in animation mode, create/update keyframe for lock state + if (Format.animation_mode) { + //@ts-expect-error: Animation type + const currentAnimation = Animation.selected; + if (currentAnimation) { + const animator = currentAnimation.getBoneAnimator(obj); + if (animator) { + const currentTime = currentAnimation.time; + const lockValue = obj.vsIKLockPosition ? 1 : 0; + + // Check if keyframe already exists at this time + const existingKf = animator.keyframes.find((kf: any) => + kf.channel === 'ik_lock' && Math.abs(kf.time - currentTime) < 0.01 + ); + + if (existingKf) { + // Update existing keyframe + if (existingKf.data_points && existingKf.data_points[0]) { + existingKf.data_points[0].x = lockValue; + } + } else { + // Add new keyframe + animator.addKeyframe({ + interpolation: 'linear', + time: currentTime, + channel: 'ik_lock', + data_points: [{ x: lockValue, y: 0, z: 0 }] + }); + } + } + } + } + } + }, + keyframeable: true + }); + //@ts-expect-error: Property may not be in types + const helperProp = new Property(Group, "string", "vsIKOrientationHelper", { + default: '', + label: "IK Orientation Helper", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + // Check if it's a null object or locator + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Orientation Helper condition error:', e); + return false; + } + }, + options: () => { + const nulls = Group.all.filter(g => g.isNull); + const options: {[key: string]: string} = { '': 'None' }; + nulls.forEach(nullObj => { + options[nullObj.name] = nullObj.name; + }); + return options; + }, + inputs: { + element_panel: { + input: { + label: 'Orientation Helper (Void Object)', + type: 'select', + options: () => { + const nulls = Group.all.filter(g => g.isNull); + const options: {[key: string]: string} = { '': 'None' }; + nulls.forEach(nullObj => { + options[nullObj.name] = nullObj.name; + }); + return options; + } + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + // Initialize property from constraint data if not set + if (obj.vsIKOrientationHelper === undefined) { + const constraintData = getIKConstraintData(obj); + obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; + } + // Sync to constraint data + const constraintData = getIKConstraintData(obj); + constraintData.orientationHelper = obj.vsIKOrientationHelper || undefined; + setIKConstraintData(obj, constraintData); + } + } + }); + // Also create properties on Locator type (in case they're separate) + //@ts-expect-error: Property may not be in types + try { + new Property(Locator, "number", "vsIKWeight", { + default: 1.0, + label: "IK Weight", + exposed: true, + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return selected[0] instanceof Locator; + }, + inputs: { + element_panel: { + input: { + label: 'IK Weight (0-1)', + type: 'number', + min: 0, + max: 1, + step: 0.01 + } + } + }, + onChange() { + const obj = this as any; + if (obj instanceof Locator) { + const constraintData = getIKConstraintData(obj as any); + constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); + setIKConstraintData(obj as any, constraintData); + } + } + }); + } catch (e) { + // Locator properties might not be needed + } + + //@ts-expect-error: Property may not be in types + try { + new Property(Locator, "boolean", "vsIKLockPosition", { + default: false, + label: "Lock IK Position", + exposed: true, + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return selected[0] instanceof Locator; + }, + inputs: { + element_panel: { + input: { + label: 'Lock Controller Position', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj instanceof Locator) { + const constraintData = getIKConstraintData(obj as any); + constraintData.lockPosition = obj.vsIKLockPosition ?? false; + if (constraintData.lockPosition) { + constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; + } + setIKConstraintData(obj as any, constraintData); + } + } + }); + } catch (e) { + // Locator properties might not be needed + } + + // Add action to open IK constraint editor + const editIKConstraintsAction = new Action(`${PACKAGE.name}:edit_ik_constraints`, { + name: 'Edit IK Constraints...', + icon: 'settings', + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + // Check if it's a null object or locator + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('Edit IK Constraints condition error:', e); + return false; + } + }, + click: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const controller = selected[0] as Group | Locator; + //@ts-expect-error: IK properties + if ((!controller.isNull && !(controller instanceof Locator)) || !controller.ikTarget) return; + + openIKConstraintEditor(controller as Group); + } + }); + + // Add action to context menu + MenuBar.addAction(editIKConstraintsAction, 'edit'); + + // Add pin/unpin action for bones + const togglePinBoneAction = new Action(`${PACKAGE.name}:toggle_pin_bone`, { + name: 'Toggle Pin Bone', + icon: 'push_pin', + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + return obj instanceof Group && !obj.isNull; + }, + click: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const bone = selected[0] as Group; + if (bone.isNull) return; + + togglePinBone(bone); + } + }); + + MenuBar.addAction(togglePinBoneAction, 'edit'); + + // Add property for interactive IK mode on controllers + //@ts-expect-error: Property may not be in types + new Property(Group, "boolean", "vsIKInteractiveMode", { + default: false, + label: "Interactive IK Mode", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + // Check if it's a null object or locator + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Interactive Mode condition error:', e); + return false; + } + }, + inputs: { + element_panel: { + input: { + label: 'Enable Interactive IK (Drag limbs to auto-adjust joints)', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + const constraintData = getIKConstraintData(obj); + constraintData.interactiveMode = obj.vsIKInteractiveMode ?? false; + setIKConstraintData(obj, constraintData); + + if (constraintData.interactiveMode) { + Blockbench.showQuickMessage( + 'Interactive IK enabled: Drag bones in IK chains to auto-adjust connected joints. Use "Toggle Pin Bone" to pin joints.', + 4000 + ); + } + } + } + }); + + // Add property to show if a bone is pinned + //@ts-expect-error: Property may not be in types + new Property(Group, "boolean", "vsIKPinned", { + default: false, + label: "IK Pinned", + exposed: true, + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + return obj instanceof Group && !obj.isNull; + }, + inputs: { + element_panel: { + input: { + label: 'Pin Bone (prevents IK from moving this bone)', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj && obj instanceof Group && !obj.isNull) { + updatePinnedBones(); + } + } + }); + + // Sync property values from constraint data when selection changes + const syncIKProperties = () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const obj = selected[0]; + //@ts-expect-error: IK properties + //@ts-expect-error: IK properties + if ((obj instanceof Group || obj instanceof Locator) && (obj.isNull || obj instanceof Locator) && obj.ikTarget) { + const constraintData = getIKConstraintData(obj as Group); + // Sync property values from constraint data + if (obj.vsIKWeight === undefined) { + obj.vsIKWeight = constraintData.weight ?? 1.0; + } + if (obj.vsIKLockPosition === undefined) { + obj.vsIKLockPosition = constraintData.lockPosition ?? false; + } + if (obj.vsIKOrientationHelper === undefined) { + obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; + } + } + }; + + Blockbench.on('select', syncIKProperties); + Blockbench.on('update_selection', syncIKProperties); + + // Initialize interactive IK system + setupInteractiveIK(); + + // Initialize pinned bones on project load + Blockbench.on('load_project', () => { + setTimeout(updatePinnedBones, 100); + }); + // Hook into codec compile to bake IK animations before export codecVS.compile = function(options: any) { if (is_vs_project(Project)) { @@ -60,11 +569,18 @@ createBlockbenchMod( return context.originalCompile(options); }; - return context; + return { ...context, editIKConstraintsAction, togglePinBoneAction }; }, context => { // Restore original compile method codecVS.compile = context.originalCompile; + // Remove actions + if (context.editIKConstraintsAction) { + context.editIKConstraintsAction.delete(); + } + if (context.togglePinBoneAction) { + context.togglePinBoneAction.delete(); + } } ); @@ -118,6 +634,9 @@ function bakeIKAnimations() { Format.animation_mode = true; } + // Process each IK controller to handle locked positions + // Note: Lock position is now animatable, so we'll check it per frame + // Sample each frame for (let frame = 0; frame <= frameCount; frame++) { const time = Math.min(frame * sampleInterval, animation.length); @@ -125,6 +644,24 @@ function bakeIKAnimations() { // Set animation time animation.time = time; + // Apply locked positions for controllers (check animatable lock state) + ikControllers.forEach(({ controller }) => { + const constraintData = getIKConstraintData(controller); + const isLocked = getIKLockAtTime(controller, animation, time); + + if (isLocked && constraintData.lockedPosition) { + // Use stored locked position + controller.origin[0] = constraintData.lockedPosition[0]; + controller.origin[1] = constraintData.lockedPosition[1]; + controller.origin[2] = constraintData.lockedPosition[2]; + } else if (isLocked && !constraintData.lockedPosition) { + // Lock was just enabled, store current position + const pos = controller.origin; + constraintData.lockedPosition = [pos[0], pos[1], pos[2]]; + setIKConstraintData(controller, constraintData); + } + }); + // Force animation update //@ts-expect-error: Animation update if (Animator.update) { @@ -138,73 +675,111 @@ function bakeIKAnimations() { // Small delay to ensure IK solver has time to calculate // (This is a workaround - in a real implementation we'd want a proper callback) - // For each bone in IK chains, sample and add keyframes - ikBones.forEach(bone => { - const animator = animation.getBoneAnimator(bone); - if (!animator) return; - - // Get the bone's current transforms after IK is applied - // The bone's rotation and origin should reflect IK calculations - const rotation = bone.rotation; - const origin = bone.origin; + // For each IK controller, process its chain + ikControllers.forEach(({ controller, chain }) => { + const constraintData = getIKConstraintData(controller); + const weight = getIKWeightAtTime(controller, animation, time); - // Calculate position offset relative to parent - const parentOrigin = bone.parent ? bone.parent.origin : [0, 0, 0]; - const offset = [ - origin[0] - parentOrigin[0], - origin[1] - parentOrigin[1], - origin[2] - parentOrigin[2] - ]; - - // Check if keyframe already exists at this time (within tolerance) - const existingKf = animator.keyframes.find((kf: any) => - Math.abs(kf.time - time) < sampleInterval / 4 - ); + // Skip if IK is disabled (weight = 0) + if (weight <= 0) { + return; + } - if (!existingKf) { - // Always add rotation keyframe - // Even if rotation appears zero, IK might have modified it - animator.addKeyframe({ - interpolation: 'linear', - time, - channel: 'rotation', - data_points: [{ - x: rotation[0] || 0, - y: rotation[1] || 0, - z: rotation[2] || 0 - }] - }); - totalBakedKeyframes++; + // Process each bone in the chain + chain.forEach(bone => { + const animator = animation.getBoneAnimator(bone); + if (!animator) return; + + // Get the bone's current transforms after IK is applied + const ikRotation: [number, number, number] = [ + bone.rotation[0] || 0, + bone.rotation[1] || 0, + bone.rotation[2] || 0 + ]; + + // Apply constraints and blend with manual keyframes + const finalRotation = blendIKWithManual(bone, ikRotation, animation, time, constraintData, weight); + + // Apply weight to final rotation (blend with original if weight < 1) + // Get original rotation from keyframes (if any) or bone's base rotation + let originalRotation: [number, number, number] = [0, 0, 0]; + if (animator.interpolate) { + try { + const interp = animator.interpolate('rotation'); + originalRotation = [interp[0] || 0, interp[1] || 0, interp[2] || 0]; + } catch (e) { + // If interpolation fails, use bone's base rotation + originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; + } + } else { + originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; + } + + const weightedRotation: [number, number, number] = [ + originalRotation[0] * (1 - weight) + finalRotation[0] * weight, + originalRotation[1] * (1 - weight) + finalRotation[1] * weight, + originalRotation[2] * (1 - weight) + finalRotation[2] * weight + ]; + + const origin = bone.origin; + + // Calculate position offset relative to parent + const parentOrigin = bone.parent ? bone.parent.origin : [0, 0, 0]; + const offset = [ + origin[0] - parentOrigin[0], + origin[1] - parentOrigin[1], + origin[2] - parentOrigin[2] + ]; - // Add position keyframe if offset is non-zero (VS uses offsetX/Y/Z) - // Use a small threshold to avoid unnecessary keyframes for tiny movements - if (Math.abs(offset[0]) > 0.001 || Math.abs(offset[1]) > 0.001 || Math.abs(offset[2]) > 0.001) { + // Check if keyframe already exists at this time (within tolerance) + const existingKf = animator.keyframes.find((kf: any) => + Math.abs(kf.time - time) < sampleInterval / 4 + ); + + if (!existingKf) { + // Always add rotation keyframe animator.addKeyframe({ interpolation: 'linear', time, - channel: 'position', + channel: 'rotation', data_points: [{ - x: offset[0], - y: offset[1], - z: offset[2] + x: weightedRotation[0], + y: weightedRotation[1], + z: weightedRotation[2] }] }); totalBakedKeyframes++; + + // Add position keyframe if offset is non-zero (VS uses offsetX/Y/Z) + // Use a small threshold to avoid unnecessary keyframes for tiny movements + if (Math.abs(offset[0]) > 0.001 || Math.abs(offset[1]) > 0.001 || Math.abs(offset[2]) > 0.001) { + animator.addKeyframe({ + interpolation: 'linear', + time, + channel: 'position', + data_points: [{ + x: offset[0], + y: offset[1], + z: offset[2] + }] + }); + totalBakedKeyframes++; + } + } else { + // Update existing keyframe if it's close enough + // This ensures we capture IK-driven changes even if there was a pre-existing keyframe + const kf = existingKf; + if (kf.channel === 'rotation' && kf.data_points && kf.data_points[0]) { + kf.data_points[0].x = weightedRotation[0]; + kf.data_points[0].y = weightedRotation[1]; + kf.data_points[0].z = weightedRotation[2]; + } else if (kf.channel === 'position' && kf.data_points && kf.data_points[0]) { + kf.data_points[0].x = offset[0]; + kf.data_points[0].y = offset[1]; + kf.data_points[0].z = offset[2]; + } } - } else { - // Update existing keyframe if it's close enough - // This ensures we capture IK-driven changes even if there was a pre-existing keyframe - const kf = existingKf; - if (kf.channel === 'rotation' && kf.data_points && kf.data_points[0]) { - kf.data_points[0].x = rotation[0] || 0; - kf.data_points[0].y = rotation[1] || 0; - kf.data_points[0].z = rotation[2] || 0; - } else if (kf.channel === 'position' && kf.data_points && kf.data_points[0]) { - kf.data_points[0].x = offset[0]; - kf.data_points[0].y = offset[1]; - kf.data_points[0].z = offset[2]; - } - } + }); }); } @@ -259,6 +834,7 @@ function findAllIKControllers(): Array<{ controller: Group; target: Group | Loca /** * Gets the IK chain starting from a target bone + * Includes all parent bones up to the root, so the shoulder should be included if it's a parent */ function getIKChain(target: Group | Locator): Group[] { const chain: Group[] = []; @@ -267,7 +843,11 @@ function getIKChain(target: Group | Locator): Group[] { // Walk up the parent chain to find all bones in the IK chain let current: any = target; while (current && current instanceof Group) { - chain.unshift(current); + // Only include actual bones (not null objects/locators) in the chain + // But include the target itself even if it's a locator + if (current === target || !current.isNull) { + chain.unshift(current); + } current = current.parent; } } @@ -294,3 +874,1016 @@ function checkForIKControllers(): boolean { return false; } + +/** + * Gets IK constraint data for a controller + */ +function getIKConstraintData(controller: Group): IKConstraintData { + //@ts-expect-error: Custom property + if (!controller.vsIKConstraints) { + //@ts-expect-error: Custom property + controller.vsIKConstraints = { + weight: 1.0, + lockPosition: false, + boneConstraints: {} + }; + } + //@ts-expect-error: Custom property + return controller.vsIKConstraints; +} + +/** + * Sets IK constraint data for a controller + */ +function setIKConstraintData(controller: Group, data: IKConstraintData): void { + //@ts-expect-error: Custom property + controller.vsIKConstraints = { ...getIKConstraintData(controller), ...data }; + // Mark project as modified + if (Project) { + Project.save(); + } +} + +/** + * Gets animatable IK weight for a controller at a specific time + * Returns the weight value (0-1) considering animation keyframes + */ +function getIKWeightAtTime(controller: Group, animation: any, time: number): number { + const constraintData = getIKConstraintData(controller); + const baseWeight = constraintData.weight ?? 1.0; + + // Check for animatable weight keyframes + try { + const animator = animation.getBoneAnimator(controller); + if (animator) { + // Find weight keyframes + const weightKeyframes = animator.keyframes.filter((kf: any) => + kf.channel === 'ik_weight' + ); + + if (weightKeyframes.length > 0) { + // Sort by time + weightKeyframes.sort((a: any, b: any) => a.time - b.time); + + // Find surrounding keyframes + let beforeKf: any = null; + let afterKf: any = null; + + for (const kf of weightKeyframes) { + if (kf.time <= time) { + beforeKf = kf; + } else if (!afterKf) { + afterKf = kf; + break; + } + } + + // Interpolate between keyframes + if (beforeKf && afterKf) { + // Linear interpolation + const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); + const beforeWeight = beforeKf.data_points?.[0]?.x ?? baseWeight; + const afterWeight = afterKf.data_points?.[0]?.x ?? baseWeight; + return Math.max(0, Math.min(1, beforeWeight + (afterWeight - beforeWeight) * t)); + } else if (beforeKf) { + // Use last keyframe value + return Math.max(0, Math.min(1, beforeKf.data_points?.[0]?.x ?? baseWeight)); + } else if (afterKf) { + // Use first keyframe value + return Math.max(0, Math.min(1, afterKf.data_points?.[0]?.x ?? baseWeight)); + } + } + } + } catch (e) { + // If animator doesn't exist or error occurs, fall back to base weight + console.warn('Error getting animatable IK weight:', e); + } + + return baseWeight; +} + +/** + * Gets animatable IK lock state for a controller at a specific time + * Returns true if the controller position should be locked + */ +function getIKLockAtTime(controller: Group, animation: any, time: number): boolean { + const constraintData = getIKConstraintData(controller); + const baseLock = constraintData.lockPosition ?? false; + + // Check for animatable lock keyframes + try { + const animator = animation.getBoneAnimator(controller); + if (animator) { + // Find lock keyframes + const lockKeyframes = animator.keyframes.filter((kf: any) => + kf.channel === 'ik_lock' + ); + + if (lockKeyframes.length > 0) { + // Sort by time + lockKeyframes.sort((a: any, b: any) => a.time - b.time); + + // Find surrounding keyframes + let beforeKf: any = null; + let afterKf: any = null; + + for (const kf of lockKeyframes) { + if (kf.time <= time) { + beforeKf = kf; + } else if (!afterKf) { + afterKf = kf; + break; + } + } + + // Interpolate between keyframes (threshold at 0.5) + if (beforeKf && afterKf) { + // Linear interpolation + const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); + const beforeLock = (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + const afterLock = (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + const interpolated = beforeLock ? (1 - t) : t; + return interpolated > 0.5; + } else if (beforeKf) { + // Use last keyframe value + return (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + } else if (afterKf) { + // Use first keyframe value + return (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + } + } + } + } catch (e) { + // If animator doesn't exist or error occurs, fall back to base lock state + console.warn('Error getting animatable IK lock state:', e); + } + + return baseLock; +} + +/** + * Applies rotation constraints to a bone rotation + */ +function applyRotationConstraints(bone: Group, rotation: [number, number, number], constraintData: IKConstraintData): [number, number, number] { + const boneName = bone.name; + const boneConstraint = constraintData.boneConstraints?.[boneName]; + + if (!boneConstraint) { + return rotation; + } + + let [rx, ry, rz] = rotation; + + // Apply axis restrictions + if (boneConstraint.allowedAxes) { + if (!boneConstraint.allowedAxes.x) rx = 0; + if (!boneConstraint.allowedAxes.y) ry = 0; + if (!boneConstraint.allowedAxes.z) rz = 0; + } + + // Apply rotation limits + if (boneConstraint.rotationLimits) { + const limits = boneConstraint.rotationLimits; + + if (limits.x) { + rx = Math.max(limits.x.min, Math.min(limits.x.max, rx)); + } + if (limits.y) { + ry = Math.max(limits.y.min, Math.min(limits.y.max, ry)); + } + if (limits.z) { + rz = Math.max(limits.z.min, Math.min(limits.z.max, rz)); + } + } + + return [rx, ry, rz]; +} + +/** + * Gets orientation influence from external helper object + */ +function getOrientationInfluence(constraintData: IKConstraintData): [number, number, number] | null { + if (!constraintData.orientationHelper) { + return null; + } + + const helper = Group.all.find(g => g.name === constraintData.orientationHelper && g.isNull); + if (!helper) { + return null; + } + + // Return helper's rotation as influence + return [helper.rotation[0] || 0, helper.rotation[1] || 0, helper.rotation[2] || 0]; +} + +/** + * Blends IK-driven rotation with manual keyframes and constraints + */ +function blendIKWithManual( + bone: Group, + ikRotation: [number, number, number], + animation: any, + time: number, + constraintData: IKConstraintData, + weight: number +): [number, number, number] { + const animator = animation.getBoneAnimator(bone); + if (!animator) { + return applyRotationConstraints(bone, ikRotation, constraintData); + } + + // Check for manual rotation keyframes at this time + const rotationKeyframes = animator.keyframes.filter((kf: any) => + kf.channel === 'rotation' && Math.abs(kf.time - time) < 0.01 + ); + + if (rotationKeyframes.length > 0) { + // Manual keyframe exists - blend with IK based on weight + const manualRot = rotationKeyframes[0].data_points[0]; + const manualRotation: [number, number, number] = [ + manualRot.x || 0, + manualRot.y || 0, + manualRot.z || 0 + ]; + + // Blend: manual * (1 - weight) + ik * weight + const blended: [number, number, number] = [ + manualRotation[0] * (1 - weight) + ikRotation[0] * weight, + manualRotation[1] * (1 - weight) + ikRotation[1] * weight, + manualRotation[2] * (1 - weight) + ikRotation[2] * weight + ]; + + return applyRotationConstraints(bone, blended, constraintData); + } + + // Apply orientation influence from helper + const orientationInfluence = getOrientationInfluence(constraintData); + if (orientationInfluence) { + // Blend IK rotation with orientation helper (50/50 blend) + const blended: [number, number, number] = [ + ikRotation[0] * 0.5 + orientationInfluence[0] * 0.5, + ikRotation[1] * 0.5 + orientationInfluence[1] * 0.5, + ikRotation[2] * 0.5 + orientationInfluence[2] * 0.5 + ]; + return applyRotationConstraints(bone, blended, constraintData); + } + + return applyRotationConstraints(bone, ikRotation, constraintData); +} + +/** + * Opens a dialog to edit IK constraints for a controller + */ +function openIKConstraintEditor(controller: Group) { + //@ts-expect-error: IK properties + const target = controller.ikTarget; + if (!target) return; + + const chain = getIKChain(target); + const constraintData = getIKConstraintData(controller); + + // Build form for bone constraints + const form: any = {}; + + chain.forEach((bone, index) => { + const boneName = bone.name; + const boneConstraint = constraintData.boneConstraints?.[boneName] || {}; + + // Allowed axes + form[`${boneName}_axis_x`] = { + label: `${boneName} - Allow X Rotation`, + type: 'checkbox', + value: boneConstraint.allowedAxes?.x !== false + }; + form[`${boneName}_axis_y`] = { + label: `${boneName} - Allow Y Rotation`, + type: 'checkbox', + value: boneConstraint.allowedAxes?.y !== false + }; + form[`${boneName}_axis_z`] = { + label: `${boneName} - Allow Z Rotation`, + type: 'checkbox', + value: boneConstraint.allowedAxes?.z !== false + }; + + // Rotation limits + form[`${boneName}_rot_x_min`] = { + label: `${boneName} - X Rotation Min (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.x?.min ?? -180, + step: 1 + }; + form[`${boneName}_rot_x_max`] = { + label: `${boneName} - X Rotation Max (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.x?.max ?? 180, + step: 1 + }; + form[`${boneName}_rot_y_min`] = { + label: `${boneName} - Y Rotation Min (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.y?.min ?? -180, + step: 1 + }; + form[`${boneName}_rot_y_max`] = { + label: `${boneName} - Y Rotation Max (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.y?.max ?? 180, + step: 1 + }; + form[`${boneName}_rot_z_min`] = { + label: `${boneName} - Z Rotation Min (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.z?.min ?? -180, + step: 1 + }; + form[`${boneName}_rot_z_max`] = { + label: `${boneName} - Z Rotation Max (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.z?.max ?? 180, + step: 1 + }; + }); + + new Dialog('ik_constraint_editor', { + title: `IK Constraints: ${controller.name}`, + form, + width: 500, + onConfirm: (formResult: any) => { + const updatedConstraints: Record = {}; + + chain.forEach(bone => { + const boneName = bone.name; + const constraint: BoneConstraint = {}; + + // Parse allowed axes + constraint.allowedAxes = { + x: formResult[`${boneName}_axis_x`] !== false, + y: formResult[`${boneName}_axis_y`] !== false, + z: formResult[`${boneName}_axis_z`] !== false + }; + + // Parse rotation limits + const xMin = formResult[`${boneName}_rot_x_min`]; + const xMax = formResult[`${boneName}_rot_x_max`]; + const yMin = formResult[`${boneName}_rot_y_min`]; + const yMax = formResult[`${boneName}_rot_y_max`]; + const zMin = formResult[`${boneName}_rot_z_min`]; + const zMax = formResult[`${boneName}_rot_z_max`]; + + constraint.rotationLimits = {}; + if (xMin !== -180 || xMax !== 180) { + constraint.rotationLimits.x = { min: xMin, max: xMax }; + } + if (yMin !== -180 || yMax !== 180) { + constraint.rotationLimits.y = { min: yMin, max: yMax }; + } + if (zMin !== -180 || zMax !== 180) { + constraint.rotationLimits.z = { min: zMin, max: zMax }; + } + + // Only add constraint if it has meaningful restrictions + if (!constraint.allowedAxes.x || !constraint.allowedAxes.y || !constraint.allowedAxes.z || + constraint.rotationLimits.x || constraint.rotationLimits.y || constraint.rotationLimits.z) { + updatedConstraints[boneName] = constraint; + } + }); + + constraintData.boneConstraints = updatedConstraints; + setIKConstraintData(controller, constraintData); + + Blockbench.showQuickMessage('IK constraints updated', 2000); + } + }).show(); +} + +/** + * Toggles pin state for a bone + */ +function togglePinBone(bone: Group): void { + //@ts-expect-error: Custom property + bone.vsIKPinned = !bone.vsIKPinned; + updatePinnedBones(); + + Blockbench.showQuickMessage( + `${bone.name} ${bone.vsIKPinned ? 'pinned' : 'unpinned'}`, + 2000 + ); +} + +/** + * Updates pinned bones list in all IK controllers + */ +function updatePinnedBones(): void { + const pinnedBones = Group.all + .filter(g => !g.isNull && (g as any).vsIKPinned) + .map(g => g.name); + + // Update all IK controllers with pinned bones list + const ikControllers = findAllIKControllers(); + ikControllers.forEach(({ controller }) => { + const constraintData = getIKConstraintData(controller); + constraintData.pinnedBones = pinnedBones; + setIKConstraintData(controller, constraintData); + }); +} + +/** + * Gets the world position of a bone (accounting for parent transforms) + */ +function getBoneWorldPosition(bone: Group): [number, number, number] { + let pos: [number, number, number] = [bone.origin[0], bone.origin[1], bone.origin[2]]; + let current: any = bone.parent; + + while (current && current instanceof Group) { + pos[0] += current.origin[0]; + pos[1] += current.origin[1]; + pos[2] += current.origin[2]; + current = current.parent; + } + + return pos; +} + +/** + * Gets the world position of a bone's end (considering bone length/direction) + * In Blockbench, bones are points, so we calculate the "end" based on children + */ +function getBoneEndWorldPosition(bone: Group): [number, number, number] { + const start = getBoneWorldPosition(bone); + + // If bone has children, use first child's position as end + if (bone.children && bone.children.length > 0) { + const firstChild = bone.children[0]; + if (firstChild instanceof Group) { + return getBoneWorldPosition(firstChild); + } + } + + // If no children, check if this bone is part of a chain + // and use the next bone in the chain as the end point + const ikControllers = findAllIKControllers(); + for (const { chain } of ikControllers) { + const boneIndex = chain.indexOf(bone); + if (boneIndex >= 0 && boneIndex < chain.length - 1) { + const nextBone = chain[boneIndex + 1]; + return getBoneWorldPosition(nextBone); + } + } + + // Fallback: assume bone extends 1 unit in Y direction + // This is a simplification for bones without clear children + return [start[0], start[1] + 1, start[2]]; +} + +/** + * Gets the length of a bone (distance to its end) + */ +function getBoneLength(bone: Group): number { + const start = getBoneWorldPosition(bone); + const end = getBoneEndWorldPosition(bone); + return vec3Length(vec3Sub(end, start)); +} + +/** + * Calculates the maximum reachable distance of an IK chain + * This is the sum of all bone lengths in the chain + */ +function getChainMaxReach(chain: Group[]): number { + let totalLength = 0; + for (let i = 0; i < chain.length - 1; i++) { + const bone = chain[i]; + const nextBone = chain[i + 1]; + const bonePos = getBoneWorldPosition(bone); + const nextBonePos = getBoneWorldPosition(nextBone); + totalLength += vec3Length(vec3Sub(nextBonePos, bonePos)); + } + return totalLength; +} + +/** + * Clamps a target position to be within the IK chain's reach + * Returns the clamped position + */ +function clampTargetToChainReach( + chain: Group[], + targetPosition: [number, number, number], + maxReach: number +): [number, number, number] { + const root = chain[0]; + const rootPos = getBoneWorldPosition(root); + const toTarget = vec3Sub(targetPosition, rootPos); + const distance = vec3Length(toTarget); + + // If target is within reach, return as-is + if (distance <= maxReach) { + return targetPosition; + } + + // Clamp to maximum reach + const clampedDirection = vec3Normalize(toTarget); + const clampedDistance = maxReach * 0.95; // Use 95% to leave some margin + const clampedTarget = vec3Add(rootPos, [ + clampedDirection[0] * clampedDistance, + clampedDirection[1] * clampedDistance, + clampedDirection[2] * clampedDistance + ]); + + return clampedTarget; +} + +/** + * Vector math utilities + */ +function vec3Sub(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function vec3Add(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +function vec3Length(v: [number, number, number]): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function vec3Normalize(v: [number, number, number]): [number, number, number] { + const len = vec3Length(v); + if (len < 0.0001) return [0, 0, 0]; + return [v[0] / len, v[1] / len, v[2] / len]; +} + +function vec3Dot(a: [number, number, number], b: [number, number, number]): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function vec3Cross(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + ]; +} + +/** + * Convert Euler angles (degrees) to rotation matrix + */ +function eulerToRotationMatrix(euler: [number, number, number]): number[][] { + const [rx, ry, rz] = euler.map(deg => deg * Math.PI / 180); + + const cx = Math.cos(rx), sx = Math.sin(rx); + const cy = Math.cos(ry), sy = Math.sin(ry); + const cz = Math.cos(rz), sz = Math.sin(rz); + + return [ + [cy * cz, -cy * sz, sy], + [cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy], + [sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy] + ]; +} + +/** + * Apply rotation to a vector + */ +function rotateVector(v: [number, number, number], euler: [number, number, number]): [number, number, number] { + const m = eulerToRotationMatrix(euler); + return [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + ]; +} + +/** + * Calculate rotation needed to align one vector to another + * Returns Euler angles in degrees + */ +function alignVectors(from: [number, number, number], to: [number, number, number]): [number, number, number] { + const fromNorm = vec3Normalize(from); + const toNorm = vec3Normalize(to); + + const dot = vec3Dot(fromNorm, toNorm); + + // If vectors are already aligned + if (Math.abs(dot - 1) < 0.0001) { + return [0, 0, 0]; + } + + // If vectors are opposite + if (Math.abs(dot + 1) < 0.0001) { + // Find perpendicular axis + const perp = Math.abs(fromNorm[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const axis = vec3Normalize(vec3Cross(fromNorm, perp)); + const angle = Math.PI; + // Convert axis-angle to Euler (simplified - assumes rotation around Y) + return [0, angle * 180 / Math.PI, 0]; + } + + // Calculate rotation axis and angle + const axis = vec3Normalize(vec3Cross(fromNorm, toNorm)); + const angle = Math.acos(Math.max(-1, Math.min(1, dot))); + + // Convert axis-angle to Euler angles (simplified approximation) + // For better results, would use proper quaternion conversion + const angleDeg = angle * 180 / Math.PI; + + // Approximate Euler angles based on axis + // This is a simplification - proper implementation would use quaternions + if (Math.abs(axis[1]) > 0.9) { + // Rotation primarily around Y axis + return [0, angleDeg * (axis[1] > 0 ? 1 : -1), 0]; + } else if (Math.abs(axis[0]) > 0.9) { + // Rotation primarily around X axis + return [angleDeg * (axis[0] > 0 ? 1 : -1), 0, 0]; + } else { + // Rotation primarily around Z axis + return [0, 0, angleDeg * (axis[2] > 0 ? 1 : -1)]; + } +} + +/** + * CCD (Cyclic Coordinate Descent) IK solver + * Solves IK chain to reach target position, respecting pinned bones and constraints + */ +function solveIKChain( + chain: Group[], + targetPosition: [number, number, number], + pinnedBones: Set, + constraintData: IKConstraintData, + maxIterations: number = 15, + tolerance: number = 0.1 +): boolean { + if (chain.length < 2) return false; + + const endEffector = chain[chain.length - 1]; + const root = chain[0]; + + // Don't solve if end effector or root is pinned + if (pinnedBones.has(endEffector.name) || pinnedBones.has(root.name)) { + return false; + } + + // Store original rotations to restore if solving fails + const originalRotations = chain.map(bone => [ + bone.rotation[0] || 0, + bone.rotation[1] || 0, + bone.rotation[2] || 0 + ] as [number, number, number]); + + // CCD solver: iterate from end effector towards root + for (let iter = 0; iter < maxIterations; iter++) { + // Get current end effector position + const currentEndPos = getBoneEndWorldPosition(endEffector); + + // Check if we're close enough + const distance = vec3Length(vec3Sub(targetPosition, currentEndPos)); + if (distance < tolerance) { + return true; // Successfully reached target + } + + // Work backwards from end effector to root + // This should include the shoulder if it's in the chain + for (let i = chain.length - 2; i >= 0; i--) { + const bone = chain[i]; + if (pinnedBones.has(bone.name)) { + continue; + } + + const boneWorldPos = getBoneWorldPosition(bone); + const currentEndEffectorPos = getBoneEndWorldPosition(endEffector); + + // Vector from bone to current end effector position + const toEnd = vec3Sub(currentEndEffectorPos, boneWorldPos); + const toEndLen = vec3Length(toEnd); + + // Vector from bone to target + const toTarget = vec3Sub(targetPosition, boneWorldPos); + const toTargetLen = vec3Length(toTarget); + + // Skip if vectors are too short + if (toEndLen < 0.001 || toTargetLen < 0.001) { + continue; + } + + // Calculate rotation needed to align toEnd with toTarget + // Use a damping factor to make solving smoother + const damping = 0.5; // Adjust this to control solving speed (0-1) + const rotationDelta = alignVectors(toEnd, toTarget); + + // Apply rotation to bone (additive with damping) + const currentRot: [number, number, number] = [ + bone.rotation[0] || 0, + bone.rotation[1] || 0, + bone.rotation[2] || 0 + ]; + + let newRot: [number, number, number] = [ + currentRot[0] + rotationDelta[0] * damping, + currentRot[1] + rotationDelta[1] * damping, + currentRot[2] + rotationDelta[2] * damping + ]; + + // Apply constraints + newRot = applyRotationConstraints(bone, newRot, constraintData); + + // Update bone rotation + bone.rotation[0] = newRot[0]; + bone.rotation[1] = newRot[1]; + bone.rotation[2] = newRot[2]; + + // Update viewport to reflect changes + Blockbench.updateViewport(); + } + } + + // If we didn't converge, check final distance + const finalEndPos = getBoneEndWorldPosition(endEffector); + const finalDistance = vec3Length(vec3Sub(targetPosition, finalEndPos)); + + return finalDistance < tolerance * 2; // Allow slightly larger tolerance +} + +/** + * Interactive IK drag state tracking + */ +interface DragState { + isActive: boolean; + draggedBone: Group | null; + originalBoneState: Map; + ikChain: Group[] | null; + controller: Group | null; + constraintData: IKConstraintData | null; + startPosition: [number, number, number] | null; +} + +const dragState: DragState = { + isActive: false, + draggedBone: null, + originalBoneState: new Map(), + ikChain: null, + controller: null, + constraintData: null, + startPosition: null +}; + +/** + * Hook into bone dragging for interactive IK + * Implements real-time IK solving when dragging bones in IK chains + */ +function setupInteractiveIK(): void { + let lastSelectedBone: Group | null = null; + let transformCount = 0; + + // Track when selection changes to detect drag start + Blockbench.on('select', () => { + // Work in both edit and animation mode + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + + const bone = selected[0]; + if (!(bone instanceof Group) || bone.isNull) return; + + // Check if this bone is part of an IK chain + // Always enable interactive IK for any IK chain to keep bones connected + const ikControllers = findAllIKControllers(); + for (const { controller, chain } of ikControllers) { + const constraintData = getIKConstraintData(controller); + // Always enable - don't require interactiveMode flag + + if (chain.includes(bone)) { + const pinnedBones = new Set(constraintData.pinnedBones || []); + + // Don't allow dragging pinned bones + if (pinnedBones.has(bone.name)) { + Blockbench.showQuickMessage( + `Cannot drag pinned bone: ${bone.name}`, + 2000 + ); + return; + } + + // Initialize drag state if not already active + if (!dragState.isActive || dragState.draggedBone !== bone) { + dragState.isActive = true; + dragState.draggedBone = bone; + dragState.ikChain = chain; + dragState.controller = controller; + dragState.constraintData = constraintData; + dragState.startPosition = getBoneWorldPosition(bone); + dragState.originalBoneState.clear(); + transformCount = 0; + + // Store original state of all bones in chain + chain.forEach(chainBone => { + dragState.originalBoneState.set(chainBone.name, { + position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], + rotation: [ + chainBone.rotation[0] || 0, + chainBone.rotation[1] || 0, + chainBone.rotation[2] || 0 + ] + }); + }); + } + + lastSelectedBone = bone; + break; + } + } + }); + + // Track drag updates and solve IK + Blockbench.on('transform_selection', () => { + // Work in both edit and animation mode + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + + const bone = selected[0]; + if (!(bone instanceof Group) || bone.isNull) return; + + // Check if this bone is part of an IK chain + // Always enable interactive IK to keep chains connected + if (!dragState.isActive || dragState.draggedBone !== bone) { + // Try to initialize if not already active + const ikControllers = findAllIKControllers(); + for (const { controller, chain } of ikControllers) { + const constraintData = getIKConstraintData(controller); + // Always enable - keep all IK chains constrained + + if (chain.includes(bone)) { + const pinnedBones = new Set(constraintData.pinnedBones || []); + if (pinnedBones.has(bone.name)) return; + + dragState.isActive = true; + dragState.draggedBone = bone; + dragState.ikChain = chain; + dragState.controller = controller; + dragState.constraintData = constraintData; + dragState.startPosition = getBoneWorldPosition(bone); + dragState.originalBoneState.clear(); + transformCount = 0; + + chain.forEach(chainBone => { + dragState.originalBoneState.set(chainBone.name, { + position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], + rotation: [ + chainBone.rotation[0] || 0, + chainBone.rotation[1] || 0, + chainBone.rotation[2] || 0 + ] + }); + }); + break; + } + } + } + + if (!dragState.isActive || !dragState.draggedBone || !dragState.ikChain || !dragState.constraintData) { + return; + } + + if (dragState.draggedBone !== bone) return; + + transformCount++; + const chain = dragState.ikChain; + const constraintData = dragState.constraintData; + const pinnedBones = new Set(constraintData.pinnedBones || []); + + // Get current target position (where the bone is being dragged to) + let targetPosition = getBoneWorldPosition(bone); + + // If this is the end effector, clamp target to chain's maximum reach + const endEffectorIndex = chain.length - 1; + if (chain[endEffectorIndex] === bone) { + // Calculate maximum reach of the chain + const maxReach = getChainMaxReach(chain); + const rootPos = getBoneWorldPosition(chain[0]); + const distance = vec3Length(vec3Sub(targetPosition, rootPos)); + + // If dragged beyond reach, clamp it + if (distance > maxReach) { + const clampedTarget = clampTargetToChainReach(chain, targetPosition, maxReach); + + // Move the bone back to the clamped position + const boneParent = bone.parent; + if (boneParent instanceof Group) { + const parentPos = getBoneWorldPosition(boneParent); + bone.origin[0] = clampedTarget[0] - parentPos[0]; + bone.origin[1] = clampedTarget[1] - parentPos[1]; + bone.origin[2] = clampedTarget[2] - parentPos[2]; + } else { + bone.origin[0] = clampedTarget[0]; + bone.origin[1] = clampedTarget[1]; + bone.origin[2] = clampedTarget[2]; + } + + targetPosition = clampedTarget; + } + + // Solve IK to reach target position (now clamped to reach) + solveIKChain(chain, targetPosition, pinnedBones, constraintData); + } else { + // Bone is in the middle of the chain + // Find the bone's index in the chain + const boneIndex = chain.indexOf(bone); + if (boneIndex === -1) return; + + // Get the sub-chain from this bone to the end + const subChain = chain.slice(boneIndex); + const endEffector = subChain[subChain.length - 1]; + + // Clamp the dragged bone's position to maintain chain connectivity + const maxReach = getChainMaxReach(subChain); + const rootPos = getBoneWorldPosition(subChain[0]); + const distance = vec3Length(vec3Sub(targetPosition, rootPos)); + + if (distance > maxReach) { + const clampedTarget = clampTargetToChainReach(subChain, targetPosition, maxReach); + const boneParent = bone.parent; + if (boneParent instanceof Group) { + const parentPos = getBoneWorldPosition(boneParent); + bone.origin[0] = clampedTarget[0] - parentPos[0]; + bone.origin[1] = clampedTarget[1] - parentPos[1]; + bone.origin[2] = clampedTarget[2] - parentPos[2]; + } else { + bone.origin[0] = clampedTarget[0]; + bone.origin[1] = clampedTarget[1]; + bone.origin[2] = clampedTarget[2]; + } + targetPosition = clampedTarget; + } + + const endEffectorTarget = getBoneEndWorldPosition(endEffector); + + // Solve IK for the sub-chain + solveIKChain(subChain, endEffectorTarget, pinnedBones, constraintData); + } + + // Update viewport (throttled to avoid performance issues) + if (transformCount % 2 === 0) { + Blockbench.updateViewport(); + } + }); + + // Track drag end + Blockbench.on('finish_edit', () => { + if (dragState.isActive && dragState.ikChain && dragState.draggedBone) { + // If in animation mode, create keyframes for the solved positions + if (Format.animation_mode) { + //@ts-expect-error: Animation type + const currentAnimation = Animation.selected; + if (currentAnimation) { + const currentTime = currentAnimation.time; + + dragState.ikChain.forEach(bone => { + const animator = currentAnimation.getBoneAnimator(bone); + if (!animator) return; + + // Check if keyframe already exists at this time + const existingKf = animator.keyframes.find((kf: any) => + kf.channel === 'rotation' && Math.abs(kf.time - currentTime) < 0.01 + ); + + if (!existingKf) { + // Add keyframe for the solved rotation + animator.addKeyframe({ + interpolation: 'linear', + time: currentTime, + channel: 'rotation', + data_points: [{ + x: bone.rotation[0] || 0, + y: bone.rotation[1] || 0, + z: bone.rotation[2] || 0 + }] + }); + } else { + // Update existing keyframe + if (existingKf.data_points && existingKf.data_points[0]) { + existingKf.data_points[0].x = bone.rotation[0] || 0; + existingKf.data_points[0].y = bone.rotation[1] || 0; + existingKf.data_points[0].z = bone.rotation[2] || 0; + } + } + }); + } + } + + // Reset drag state + dragState.isActive = false; + dragState.draggedBone = null; + dragState.ikChain = null; + dragState.controller = null; + dragState.constraintData = null; + dragState.startPosition = null; + dragState.originalBoneState.clear(); + transformCount = 0; + } + }); + + // Also reset on selection change if not dragging + Blockbench.on('update_selection', () => { + if (!dragState.isActive) { + lastSelectedBone = null; + } + }); +} diff --git a/src/util/misc.ts b/src/util/misc.ts index 78e961c..f6846d3 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -1,15 +1,16 @@ import { im } from "../import"; +import JSON5 from "json5"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- +// @ts-expect-error: requireNativeModule is missing in blockbench types const path = requireNativeModule('path'); -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- +// @ts-expect-error: requireNativeModule is missing in blockbench types const fs = requireNativeModule('fs'); export function load_back_drop_shape(backDropShape: string) { Blockbench.read([get_shape_location(null, backDropShape)], { readtype: "text", errorbox: false }, (files) => { - im(autoParseJSON(files[0].content as string), files[0].path, true); + im(JSON5.parse(files[0].content as string), files[0].path, true); }); } From 20210fb71fd7bceade1fc6edc93fb26ee856edce Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Jan 2026 20:27:36 +0000 Subject: [PATCH 10/15] more IK work --- src/mods/ik/baking.ts | 396 ++++++++ src/mods/ik/chain_utils.ts | 86 ++ src/mods/ik/constraints.ts | 205 ++++ src/mods/ik/index.ts | 81 ++ src/mods/ik/interactive.ts | 302 ++++++ src/mods/ik/math.ts | 102 ++ src/mods/ik/properties.ts | 445 +++++++++ src/mods/ik/solver.ts | 156 +++ src/mods/ik/types.ts | 51 + src/mods/ik/utils.ts | 58 ++ src/mods/ikMod.ts | 1889 ------------------------------------ src/vs_plugin.ts | 12 +- 12 files changed, 1888 insertions(+), 1895 deletions(-) create mode 100644 src/mods/ik/baking.ts create mode 100644 src/mods/ik/chain_utils.ts create mode 100644 src/mods/ik/constraints.ts create mode 100644 src/mods/ik/index.ts create mode 100644 src/mods/ik/interactive.ts create mode 100644 src/mods/ik/math.ts create mode 100644 src/mods/ik/properties.ts create mode 100644 src/mods/ik/solver.ts create mode 100644 src/mods/ik/types.ts create mode 100644 src/mods/ik/utils.ts delete mode 100644 src/mods/ikMod.ts diff --git a/src/mods/ik/baking.ts b/src/mods/ik/baking.ts new file mode 100644 index 0000000..4561759 --- /dev/null +++ b/src/mods/ik/baking.ts @@ -0,0 +1,396 @@ + +import { findAllIKControllers } from "./chain_utils"; +import { getIKConstraintData, setIKConstraintData, applyRotationConstraints, getOrientationInfluence } from "./constraints"; +import { IKConstraintData } from "./types"; + +// Blockbench global types +declare var Blockbench: any; +declare var Project: any; +declare var Animation: any; +declare var _Animation: any; +declare var Animator: any; +declare var Format: any; +declare var Group: any; + +// Constants +const VS_DEFAULT_FPS = 20; +const KEYFRAME_TIME_TOLERANCE = 0.01; +const POSITION_THRESHOLD = 0.001; + +/** + * Gets the animatable IK weight for a controller at a specific time. + * Interpolates between keyframes if weight is animated. + * + * @param controller - The IK controller + * @param animation - The animation to check + * @param time - The time to evaluate + * @returns Weight value between 0-1 (0 = disabled, 1 = fully enabled) + */ +export function getIKWeightAtTime(controller: any, animation: any, time: number): number { + const constraintData = getIKConstraintData(controller); + const baseWeight = constraintData.weight ?? 1.0; + + try { + const animator = animation.getBoneAnimator(controller); + if (animator) { + + const weightKeyframes = animator.keyframes.filter((kf: any) => + kf.channel === 'ik_weight' + ); + + if (weightKeyframes.length > 0) { + + weightKeyframes.sort((a: any, b: any) => a.time - b.time); + + let beforeKf: any = null; + let afterKf: any = null; + + for (const kf of weightKeyframes) { + if (kf.time <= time) { + beforeKf = kf; + } else if (!afterKf) { + afterKf = kf; + break; + } + } + + if (beforeKf && afterKf) { + + const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); + const beforeWeight = beforeKf.data_points?.[0]?.x ?? baseWeight; + const afterWeight = afterKf.data_points?.[0]?.x ?? baseWeight; + return Math.max(0, Math.min(1, beforeWeight + (afterWeight - beforeWeight) * t)); + } else if (beforeKf) { + + return Math.max(0, Math.min(1, beforeKf.data_points?.[0]?.x ?? baseWeight)); + } else if (afterKf) { + + return Math.max(0, Math.min(1, afterKf.data_points?.[0]?.x ?? baseWeight)); + } + } + } + } catch (e) { + + console.warn('Error getting animatable IK weight:', e); + } + + return baseWeight; +} + +/** + * Gets the animatable IK lock state for a controller at a specific time. + * + * @param controller - The IK controller + * @param animation - The animation to check + * @param time - The time to evaluate + * @returns True if the controller position should be locked at this time + */ +export function getIKLockAtTime(controller: any, animation: any, time: number): boolean { + const constraintData = getIKConstraintData(controller); + const baseLock = constraintData.lockPosition ?? false; + + try { + const animator = animation.getBoneAnimator(controller); + if (animator) { + + const lockKeyframes = animator.keyframes.filter((kf: any) => + kf.channel === 'ik_lock' + ); + + if (lockKeyframes.length > 0) { + + lockKeyframes.sort((a: any, b: any) => a.time - b.time); + + let beforeKf: any = null; + let afterKf: any = null; + + for (const kf of lockKeyframes) { + if (kf.time <= time) { + beforeKf = kf; + } else if (!afterKf) { + afterKf = kf; + break; + } + } + + if (beforeKf && afterKf) { + + const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); + const beforeLock = (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + const afterLock = (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + const interpolated = beforeLock ? (1 - t) : t; + return interpolated > 0.5; + } else if (beforeKf) { + + return (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + } else if (afterKf) { + + return (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; + } + } + } + } catch (e) { + + console.warn('Error getting animatable IK lock state:', e); + } + + return baseLock; +} + +/** + * Blends IK-driven rotation with manual keyframes and constraints. + * Allows manual keyframe overrides to take precedence over IK when present. + * + * @param bone - The bone to blend rotation for + * @param ikRotation - The IK-calculated rotation + * @param animation - The current animation + * @param time - The current time + * @param constraintData - IK constraint configuration + * @param weight - IK weight (0-1) + * @returns Final blended rotation with constraints applied + */ +export function blendIKWithManual( + bone: any, + ikRotation: [number, number, number], + animation: any, + time: number, + constraintData: IKConstraintData, + weight: number +): [number, number, number] { + const animator = animation.getBoneAnimator(bone); + if (!animator) { + return applyRotationConstraints(bone, ikRotation, constraintData); + } + + const rotationKeyframes = animator.keyframes.filter((kf: any) => + kf.channel === 'rotation' && Math.abs(kf.time - time) < KEYFRAME_TIME_TOLERANCE + ); + + if (rotationKeyframes.length > 0) { + + const manualRot = rotationKeyframes[0].data_points[0]; + const manualRotation: [number, number, number] = [ + manualRot.x || 0, + manualRot.y || 0, + manualRot.z || 0 + ]; + + const blended: [number, number, number] = [ + manualRotation[0] * (1 - weight) + ikRotation[0] * weight, + manualRotation[1] * (1 - weight) + ikRotation[1] * weight, + manualRotation[2] * (1 - weight) + ikRotation[2] * weight + ]; + + return applyRotationConstraints(bone, blended, constraintData); + } + + const orientationInfluence = getOrientationInfluence(constraintData); + if (orientationInfluence) { + + const blended: [number, number, number] = [ + ikRotation[0] * 0.5 + orientationInfluence[0] * 0.5, + ikRotation[1] * 0.5 + orientationInfluence[1] * 0.5, + ikRotation[2] * 0.5 + orientationInfluence[2] * 0.5 + ]; + return applyRotationConstraints(bone, blended, constraintData); + } + + return applyRotationConstraints(bone, ikRotation, constraintData); +} + +/** + * Bakes IK animations to keyframes for VS export. + * Since VS doesn't support IK natively, this converts IK-driven animations + * to regular keyframe animations by sampling the IK-driven bone transforms. + * + * Process: + * 1. Samples animation at regular intervals (VS_DEFAULT_FPS) + * 2. Applies IK constraints and weights + * 3. Creates rotation and position keyframes for all bones in IK chains + * 4. Shows progress notification when complete + * + * @param is_vs_project - Function to check if current project is a VS project + */ +export function bakeIKAnimations(is_vs_project: (project: any) => boolean) { + if (!Project || !is_vs_project(Project)) return; + + const ikControllers = findAllIKControllers(); + if (ikControllers.length === 0) return; + + const animations = (Animation as unknown as typeof _Animation).all; + const fps = VS_DEFAULT_FPS; + + let totalBakedKeyframes = 0; + + animations.forEach((animation: any) => { + + const ikBones = new Set(); + ikControllers.forEach(({ chain }) => { + chain.forEach(bone => ikBones.add(bone)); + }); + + if (ikBones.size === 0) return; + + const frameCount = Math.ceil(animation.length * fps); + const sampleInterval = 1 / fps; // Sample every frame + + const originalTime = animation.time; + const originalSelected = animation.selected; + const wasPlaying = Animator.playing; + + if (wasPlaying) { + Animator.pause(); + } + + animation.select(); + + const wasInAnimationMode = Format.animation_mode; + if (!wasInAnimationMode) { + Format.animation_mode = true; + } + + for (let frame = 0; frame <= frameCount; frame++) { + const time = Math.min(frame * sampleInterval, animation.length); + + animation.time = time; + + ikControllers.forEach(({ controller }) => { + const constraintData = getIKConstraintData(controller); + const isLocked = getIKLockAtTime(controller, animation, time); + + if (isLocked && constraintData.lockedPosition) { + + controller.origin[0] = constraintData.lockedPosition[0]; + controller.origin[1] = constraintData.lockedPosition[1]; + controller.origin[2] = constraintData.lockedPosition[2]; + } else if (isLocked && !constraintData.lockedPosition) { + + const pos = controller.origin; + constraintData.lockedPosition = [pos[0], pos[1], pos[2]]; + setIKConstraintData(controller, constraintData); + } + }); + + if (Animator.update) { + Animator.update(); + } + + Blockbench.updateViewport(); + + ikControllers.forEach(({ controller, chain }) => { + const constraintData = getIKConstraintData(controller); + const weight = getIKWeightAtTime(controller, animation, time); + + if (weight <= 0) { + return; + } + + chain.forEach(bone => { + const animator = animation.getBoneAnimator(bone); + if (!animator) return; + + const ikRotation: [number, number, number] = [ + bone.rotation[0] || 0, + bone.rotation[1] || 0, + bone.rotation[2] || 0 + ]; + + const finalRotation = blendIKWithManual(bone, ikRotation, animation, time, constraintData, weight); + + let originalRotation: [number, number, number] = [0, 0, 0]; + if (animator.interpolate) { + try { + const interp = animator.interpolate('rotation'); + originalRotation = [interp[0] || 0, interp[1] || 0, interp[2] || 0]; + } catch (e) { + + originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; + } + } else { + originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; + } + + const weightedRotation: [number, number, number] = [ + originalRotation[0] * (1 - weight) + finalRotation[0] * weight, + originalRotation[1] * (1 - weight) + finalRotation[1] * weight, + originalRotation[2] * (1 - weight) + finalRotation[2] * weight + ]; + + const origin = bone.origin; + + const parentOrigin = bone.parent ? bone.parent.origin : [0, 0, 0]; + const offset = [ + origin[0] - parentOrigin[0], + origin[1] - parentOrigin[1], + origin[2] - parentOrigin[2] + ]; + + const existingKf = animator.keyframes.find((kf: any) => + Math.abs(kf.time - time) < sampleInterval / 4 + ); + + if (!existingKf) { + + animator.addKeyframe({ + interpolation: 'linear', + time, + channel: 'rotation', + data_points: [{ + x: weightedRotation[0], + y: weightedRotation[1], + z: weightedRotation[2] + }] + }); + totalBakedKeyframes++; + + if (Math.abs(offset[0]) > POSITION_THRESHOLD || Math.abs(offset[1]) > POSITION_THRESHOLD || Math.abs(offset[2]) > POSITION_THRESHOLD) { + animator.addKeyframe({ + interpolation: 'linear', + time, + channel: 'position', + data_points: [{ + x: offset[0], + y: offset[1], + z: offset[2] + }] + }); + totalBakedKeyframes++; + } + } else { + + const kf = existingKf; + if (kf.channel === 'rotation' && kf.data_points && kf.data_points[0]) { + kf.data_points[0].x = weightedRotation[0]; + kf.data_points[0].y = weightedRotation[1]; + kf.data_points[0].z = weightedRotation[2]; + } else if (kf.channel === 'position' && kf.data_points && kf.data_points[0]) { + kf.data_points[0].x = offset[0]; + kf.data_points[0].y = offset[1]; + kf.data_points[0].z = offset[2]; + } + } + }); + }); + } + + animation.time = originalTime; + if (originalSelected) { + animation.select(); + } else { + animation.deselect(); + } + + if (!wasInAnimationMode) { + Format.animation_mode = false; + } + + if (wasPlaying) { + Animator.play(); + } + }); + + Blockbench.showQuickMessage( + `Baked ${ikControllers.length} IK controller(s) to ${totalBakedKeyframes} keyframes`, + 3000 + ); +} \ No newline at end of file diff --git a/src/mods/ik/chain_utils.ts b/src/mods/ik/chain_utils.ts new file mode 100644 index 0000000..84f4219 --- /dev/null +++ b/src/mods/ik/chain_utils.ts @@ -0,0 +1,86 @@ + +// Blockbench global types +declare var Group: any; +declare var Locator: any; +declare var Project: any; + +/** + * Chain Utility helper interface + */ +export interface IKControllerInfo { + controller: any; + target: any; + chain: any[]; +} + +/** + * Gets the IK chain starting from a target bone. + * Walks up the parent hierarchy to include all bones up to the root. + * Only includes actual bones (not null objects/locators) in the chain, + * except for the target itself. + * + * @param target - The target bone or locator + * @returns Array of bones forming the IK chain, ordered from root to target + */ +export function getIKChain(target: any): any[] { + const chain: any[] = []; + + if (target instanceof Group) { + + let current: any = target; + while (current && current instanceof Group) { + + if (current === target || !current.isNull) { + chain.unshift(current); + } + current = current.parent; + } + } + + return chain; +} + +/** + * Finds all IK controllers in the current project. + * IK controllers are null objects (Groups with isNull=true) that have an ikTarget property. + * + * @returns Array of IK controller information including controller, target, and chain + */ +export function findAllIKControllers(): IKControllerInfo[] { + const ikControllers: IKControllerInfo[] = []; + + const nulls = Group.all.filter((g: any) => g.isNull); + + for (const nullObj of nulls) { + if (nullObj.ikTarget) { + const target = nullObj.ikTarget; + const chain = getIKChain(target); + ikControllers.push({ + controller: nullObj, + target, + chain + }); + } + } + + return ikControllers; +} + +/** + * Checks if the current project has any IK controllers. + * + * @returns True if at least one IK controller exists in the project + */ +export function checkForIKControllers(): boolean { + if (!Project) return false; + + const nulls = Group.all.filter((g: any) => g.isNull); + + for (const nullObj of nulls) { + if (nullObj.ikTarget) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/src/mods/ik/constraints.ts b/src/mods/ik/constraints.ts new file mode 100644 index 0000000..699a8d5 --- /dev/null +++ b/src/mods/ik/constraints.ts @@ -0,0 +1,205 @@ + +import * as PACKAGE from "../../../package.json"; +import { IKConstraintData, BoneConstraint } from "./types"; + +// Blockbench global types +declare var Group: any; +declare var Dialog: any; +declare var Blockbench: any; +declare var Project: any; + +/** + * Gets IK constraint data for a controller + */ +export function getIKConstraintData(controller: any): IKConstraintData { + if (!controller.vsIKConstraints) { + controller.vsIKConstraints = { + weight: 1.0, + lockPosition: false, + boneConstraints: {} + }; + } + return controller.vsIKConstraints; +} + +/** + * Sets IK constraint data for a controller + */ +export function setIKConstraintData(controller: any, data: IKConstraintData): void { + controller.vsIKConstraints = { ...getIKConstraintData(controller), ...data }; + + if (Project) { + Project.save(); + } +} + +/** + * Applies rotation constraints to a bone rotation + */ +export function applyRotationConstraints(bone: any, rotation: [number, number, number], constraintData: IKConstraintData): [number, number, number] { + const boneName = bone.name; + const boneConstraint = constraintData.boneConstraints?.[boneName]; + + if (!boneConstraint) { + return rotation; + } + + let [rx, ry, rz] = rotation; + + if (boneConstraint.allowedAxes) { + if (!boneConstraint.allowedAxes.x) rx = 0; + if (!boneConstraint.allowedAxes.y) ry = 0; + if (!boneConstraint.allowedAxes.z) rz = 0; + } + + if (boneConstraint.rotationLimits) { + const limits = boneConstraint.rotationLimits; + + if (limits.x) { + rx = Math.max(limits.x.min, Math.min(limits.x.max, rx)); + } + if (limits.y) { + ry = Math.max(limits.y.min, Math.min(limits.y.max, ry)); + } + if (limits.z) { + rz = Math.max(limits.z.min, Math.min(limits.z.max, rz)); + } + } + + return [rx, ry, rz]; +} + +/** + * Gets orientation influence from external helper object + */ +export function getOrientationInfluence(constraintData: IKConstraintData): [number, number, number] | null { + if (!constraintData.orientationHelper) { + return null; + } + + const helper = Group.all.find((g: any) => g.name === constraintData.orientationHelper && g.isNull); + if (!helper) { + return null; + } + + return [helper.rotation[0] || 0, helper.rotation[1] || 0, helper.rotation[2] || 0]; +} + +/** + * Opens a dialog to edit IK constraints for a controller + */ +export function openIKConstraintEditor(controller: any, getIKChain: (target: any) => any[]) { + const target = controller.ikTarget; + if (!target) return; + + const chain = getIKChain(target); + const constraintData = getIKConstraintData(controller); + + const form: any = {}; + + chain.forEach((bone, index) => { + const boneName = bone.name; + const boneConstraint = constraintData.boneConstraints?.[boneName] || {}; + + form[`${boneName}_axis_x`] = { + label: `${boneName} - Allow X Rotation`, + type: 'checkbox', + value: boneConstraint.allowedAxes?.x !== false + }; + form[`${boneName}_axis_y`] = { + label: `${boneName} - Allow Y Rotation`, + type: 'checkbox', + value: boneConstraint.allowedAxes?.y !== false + }; + form[`${boneName}_axis_z`] = { + label: `${boneName} - Allow Z Rotation`, + type: 'checkbox', + value: boneConstraint.allowedAxes?.z !== false + }; + + form[`${boneName}_rot_x_min`] = { + label: `${boneName} - X Rotation Min (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.x?.min ?? -180, + step: 1 + }; + form[`${boneName}_rot_x_max`] = { + label: `${boneName} - X Rotation Max (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.x?.max ?? 180, + step: 1 + }; + form[`${boneName}_rot_y_min`] = { + label: `${boneName} - Y Rotation Min (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.y?.min ?? -180, + step: 1 + }; + form[`${boneName}_rot_y_max`] = { + label: `${boneName} - Y Rotation Max (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.y?.max ?? 180, + step: 1 + }; + form[`${boneName}_rot_z_min`] = { + label: `${boneName} - Z Rotation Min (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.z?.min ?? -180, + step: 1 + }; + form[`${boneName}_rot_z_max`] = { + label: `${boneName} - Z Rotation Max (degrees)`, + type: 'number', + value: boneConstraint.rotationLimits?.z?.max ?? 180, + step: 1 + }; + }); + + new Dialog('ik_constraint_editor', { + title: `IK Constraints: ${controller.name}`, + form, + width: 500, + onConfirm: (formResult: any) => { + const updatedConstraints: Record = {}; + + chain.forEach(bone => { + const boneName = bone.name; + const constraint: BoneConstraint = {}; + + constraint.allowedAxes = { + x: formResult[`${boneName}_axis_x`] !== false, + y: formResult[`${boneName}_axis_y`] !== false, + z: formResult[`${boneName}_axis_z`] !== false + }; + + const xMin = formResult[`${boneName}_rot_x_min`]; + const xMax = formResult[`${boneName}_rot_x_max`]; + const yMin = formResult[`${boneName}_rot_y_min`]; + const yMax = formResult[`${boneName}_rot_y_max`]; + const zMin = formResult[`${boneName}_rot_z_min`]; + const zMax = formResult[`${boneName}_rot_z_max`]; + + constraint.rotationLimits = {}; + if (xMin !== -180 || xMax !== 180) { + constraint.rotationLimits.x = { min: xMin, max: xMax }; + } + if (yMin !== -180 || yMax !== 180) { + constraint.rotationLimits.y = { min: yMin, max: yMax }; + } + if (zMin !== -180 || zMax !== 180) { + constraint.rotationLimits.z = { min: zMin, max: zMax }; + } + + if (!constraint.allowedAxes.x || !constraint.allowedAxes.y || !constraint.allowedAxes.z || + constraint.rotationLimits.x || constraint.rotationLimits.y || constraint.rotationLimits.z) { + updatedConstraints[boneName] = constraint; + } + }); + + constraintData.boneConstraints = updatedConstraints; + setIKConstraintData(controller, constraintData); + + Blockbench.showQuickMessage('IK constraints updated', 2000); + } + }).show(); +} \ No newline at end of file diff --git a/src/mods/ik/index.ts b/src/mods/ik/index.ts new file mode 100644 index 0000000..a0dc96f --- /dev/null +++ b/src/mods/ik/index.ts @@ -0,0 +1,81 @@ + +import { createBlockbenchMod } from "../../util/moddingTools"; +import * as PACKAGE from "../../../package.json"; +import { is_vs_project } from "../../util"; +import { codecVS } from "../../codec"; +import { setupIKProperties } from "./properties"; +import { setupInteractiveIK, updatePinnedBones } from "./interactive"; +import { bakeIKAnimations } from "./baking"; +import { checkForIKControllers } from "./chain_utils"; + +// Blockbench global types +declare var Blockbench: any; +declare var Project: any; + +/** + * IK Improvement Mod for VS Plugin + * + * This mod addresses IK limitations for VS export and adds interactive IK features: + * 1. Detects IK usage and warns users that VS doesn't support IK natively + * 2. Bakes IK animations to keyframes on export (converts IK-driven motion to keyframe animation) + * 3. Provides user feedback during the baking process + * 4. Adds advanced IK constraint features + * 5. Interactive IK features for faster animation workflow + */ +createBlockbenchMod( + `${PACKAGE.name}:ik_mod`, + { + + originalCompile: codecVS.compile.bind(codecVS), + }, + context => { + const { editIKConstraintsAction, togglePinBoneAction } = setupIKProperties(context); + + setupInteractiveIK(); + + Blockbench.on('load_project', () => { + setTimeout(updatePinnedBones, 100); + }); + + codecVS.compile = function (options: any) { + if (is_vs_project(Project)) { + + const hasIK = checkForIKControllers(); + if (hasIK) { + + const shouldBake = Blockbench.showMessageBox({ + title: 'IK Animation Detected', + message: + 'Your model uses Inverse Kinematics (IK) controllers.\n\n' + + 'Vintage Story does not support IK natively. IK animations will be ' + + 'automatically converted to keyframe animations during export.\n\n' + + 'This may result in a large number of keyframes. Continue?', + buttons: ['Bake IK to Keyframes', 'Cancel Export'] + }); + + if (shouldBake === 0) { + + bakeIKAnimations(is_vs_project); + } else { + + throw new Error('Export cancelled: IK animations need to be baked to keyframes'); + } + } + } + return context.originalCompile(options); + }; + + return { ...context, editIKConstraintsAction, togglePinBoneAction }; + }, + context => { + + codecVS.compile = context.originalCompile; + + if (context.editIKConstraintsAction) { + context.editIKConstraintsAction.delete(); + } + if (context.togglePinBoneAction) { + context.togglePinBoneAction.delete(); + } + } +); \ No newline at end of file diff --git a/src/mods/ik/interactive.ts b/src/mods/ik/interactive.ts new file mode 100644 index 0000000..65a97e6 --- /dev/null +++ b/src/mods/ik/interactive.ts @@ -0,0 +1,302 @@ + +import { vec3Length, vec3Sub, vec3Normalize, vec3Add } from "./math"; +import { getBoneWorldPosition, getBoneEndWorldPosition } from "./utils"; +import { getChainMaxReach, clampTargetToChainReach } from "./solver"; +import { getIKConstraintData, setIKConstraintData } from "./constraints"; +import { DragState } from "./types"; +import { solveIKChain } from "./solver"; +import { findAllIKControllers as findAllControllers } from "./chain_utils"; + +// Blockbench global types +declare var Blockbench: any; +declare var Group: any; +declare var Outliner: any; +declare var Format: any; +declare var Animation: any; + +// Constants +const VIEWPORT_UPDATE_THROTTLE = 2; +const KEYFRAME_TIME_TOLERANCE = 0.01; + +const dragState: DragState = { + isActive: false, + draggedBone: null, + originalBoneState: new Map(), + ikChain: null, + controller: null, + constraintData: null, + startPosition: null +}; + +/** + * Toggles the pin state for a bone. + * Pinned bones will not move during IK solving. + * + * @param bone - The bone to toggle pin state for + */ +export function togglePinBone(bone: any): void { + bone.vsIKPinned = !bone.vsIKPinned; + updatePinnedBones(); + + Blockbench.showQuickMessage( + `${bone.name} ${bone.vsIKPinned ? 'pinned' : 'unpinned'}`, + 2000 + ); +} + +/** + * Updates the pinned bones list in all IK controllers. + * Syncs the global pinned bones state across all IK controller constraints. + */ +export function updatePinnedBones(): void { + const pinnedBones = Group.all + .filter((g: any) => !g.isNull && (g as any).vsIKPinned) + .map((g: any) => g.name); + + const ikControllers = findAllControllers(); + ikControllers.forEach(({ controller }) => { + const constraintData = getIKConstraintData(controller); + constraintData.pinnedBones = pinnedBones; + setIKConstraintData(controller, constraintData); + }); +} + +/** + * Sets up interactive IK by hooking into Blockbench bone dragging events. + * Implements real-time IK solving when dragging bones in IK chains. + * + * Features: + * - Automatic IK solving during bone transforms + * - Respects pinned bones + * - Creates keyframes in animation mode + * - Throttled viewport updates for performance + */ +export function setupInteractiveIK(): void { + let transformCount = 0; + + Blockbench.on('select', () => { + + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + + const bone = selected[0]; + if (!(bone instanceof Group) || bone.isNull) return; + + const ikControllers = findAllControllers(); + for (const { controller, chain } of ikControllers) { + const constraintData = getIKConstraintData(controller); + + if (chain.includes(bone)) { + const pinnedBones = new Set(constraintData.pinnedBones || []); + + if (pinnedBones.has(bone.name)) { + Blockbench.showQuickMessage( + `Cannot drag pinned bone: ${bone.name}`, + 2000 + ); + return; + } + + if (!dragState.isActive || dragState.draggedBone !== bone) { + dragState.isActive = true; + dragState.draggedBone = bone; + dragState.ikChain = chain; + dragState.controller = controller; + dragState.constraintData = constraintData; + dragState.startPosition = getBoneWorldPosition(bone); + dragState.originalBoneState.clear(); + transformCount = 0; + + chain.forEach((chainBone: any) => { + dragState.originalBoneState.set(chainBone.name, { + position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], + rotation: [ + chainBone.rotation[0] || 0, + chainBone.rotation[1] || 0, + chainBone.rotation[2] || 0 + ] + }); + }); + } + + break; + } + } + }); + + Blockbench.on('transform_selection', () => { + + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + + const bone = selected[0]; + if (!(bone instanceof Group) || bone.isNull) return; + + if (!dragState.isActive || dragState.draggedBone !== bone) { + + const ikControllers = findAllControllers(); + for (const { controller, chain } of ikControllers) { + const constraintData = getIKConstraintData(controller); + + if (chain.includes(bone)) { + const pinnedBones = new Set(constraintData.pinnedBones || []); + if (pinnedBones.has(bone.name)) return; + + dragState.isActive = true; + dragState.draggedBone = bone; + dragState.ikChain = chain; + dragState.controller = controller; + dragState.constraintData = constraintData; + dragState.startPosition = getBoneWorldPosition(bone); + dragState.originalBoneState.clear(); + transformCount = 0; + + chain.forEach((chainBone: any) => { + dragState.originalBoneState.set(chainBone.name, { + position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], + rotation: [ + chainBone.rotation[0] || 0, + chainBone.rotation[1] || 0, + chainBone.rotation[2] || 0 + ] + }); + }); + break; + } + } + } + + if (!dragState.isActive || !dragState.draggedBone || !dragState.ikChain || !dragState.constraintData) { + return; + } + + if (dragState.draggedBone !== bone) return; + + transformCount++; + const chain = dragState.ikChain; + const constraintData = dragState.constraintData; + const pinnedBones = new Set(constraintData.pinnedBones || []); + + let targetPosition = getBoneWorldPosition(bone); + + const endEffectorIndex = chain.length - 1; + if (chain[endEffectorIndex] === bone) { + + const maxReach = getChainMaxReach(chain); + const rootPos = getBoneWorldPosition(chain[0]); + const distance = vec3Length(vec3Sub(targetPosition, rootPos)); + + if (distance > maxReach) { + const clampedTarget = clampTargetToChainReach(chain, targetPosition, maxReach); + + const boneParent = bone.parent; + if (boneParent instanceof Group) { + const parentPos = getBoneWorldPosition(boneParent); + bone.origin[0] = clampedTarget[0] - parentPos[0]; + bone.origin[1] = clampedTarget[1] - parentPos[1]; + bone.origin[2] = clampedTarget[2] - parentPos[2]; + } else { + bone.origin[0] = clampedTarget[0]; + bone.origin[1] = clampedTarget[1]; + bone.origin[2] = clampedTarget[2]; + } + + targetPosition = clampedTarget; + } + + solveIKChain(chain, targetPosition, pinnedBones, constraintData); + } else { + + const boneIndex = chain.indexOf(bone); + if (boneIndex === -1) return; + + const subChain = chain.slice(boneIndex); + const endEffector = subChain[subChain.length - 1]; + + const maxReach = getChainMaxReach(subChain); + const rootPos = getBoneWorldPosition(subChain[0]); + const distance = vec3Length(vec3Sub(targetPosition, rootPos)); + + if (distance > maxReach) { + const clampedTarget = clampTargetToChainReach(subChain, targetPosition, maxReach); + const boneParent = bone.parent; + if (boneParent instanceof Group) { + const parentPos = getBoneWorldPosition(boneParent); + bone.origin[0] = clampedTarget[0] - parentPos[0]; + bone.origin[1] = clampedTarget[1] - parentPos[1]; + bone.origin[2] = clampedTarget[2] - parentPos[2]; + } else { + bone.origin[0] = clampedTarget[0]; + bone.origin[1] = clampedTarget[1]; + bone.origin[2] = clampedTarget[2]; + } + targetPosition = clampedTarget; + } + + const endEffectorTarget = getBoneEndWorldPosition(endEffector); + + solveIKChain(subChain, endEffectorTarget, pinnedBones, constraintData); + } + + if (transformCount % VIEWPORT_UPDATE_THROTTLE === 0) { + Blockbench.updateViewport(); + } + }); + + Blockbench.on('finish_edit', () => { + if (dragState.isActive && dragState.ikChain && dragState.draggedBone) { + + if (Format.animation_mode) { + const currentAnimation = Animation.selected; + if (currentAnimation) { + const currentTime = currentAnimation.time; + + dragState.ikChain.forEach((bone: any) => { + const animator = currentAnimation.getBoneAnimator(bone); + if (!animator) return; + + const existingKf = animator.keyframes.find((kf: any) => + kf.channel === 'rotation' && Math.abs(kf.time - currentTime) < KEYFRAME_TIME_TOLERANCE + ); + + if (!existingKf) { + + animator.addKeyframe({ + interpolation: 'linear', + time: currentTime, + channel: 'rotation', + data_points: [{ + x: bone.rotation[0] || 0, + y: bone.rotation[1] || 0, + z: bone.rotation[2] || 0 + }] + }); + } else { + + if (existingKf.data_points && existingKf.data_points[0]) { + existingKf.data_points[0].x = bone.rotation[0] || 0; + existingKf.data_points[0].y = bone.rotation[1] || 0; + existingKf.data_points[0].z = bone.rotation[2] || 0; + } + } + }); + } + } + + dragState.isActive = false; + dragState.draggedBone = null; + dragState.ikChain = null; + dragState.controller = null; + dragState.constraintData = null; + dragState.startPosition = null; + dragState.originalBoneState.clear(); + transformCount = 0; + } + }); + + Blockbench.on('update_selection', () => { + + if (!dragState.isActive) { + } + }); +} \ No newline at end of file diff --git a/src/mods/ik/math.ts b/src/mods/ik/math.ts new file mode 100644 index 0000000..a91ec59 --- /dev/null +++ b/src/mods/ik/math.ts @@ -0,0 +1,102 @@ + +/** + * Vector math utilities + */ +export function vec3Sub(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +export function vec3Add(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +export function vec3Length(v: [number, number, number]): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +export function vec3Normalize(v: [number, number, number]): [number, number, number] { + const len = vec3Length(v); + if (len < 0.0001) return [0, 0, 0]; + return [v[0] / len, v[1] / len, v[2] / len]; +} + +export function vec3Dot(a: [number, number, number], b: [number, number, number]): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +export function vec3Cross(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + ]; +} + +/** + * Convert Euler angles (degrees) to rotation matrix + */ +export function eulerToRotationMatrix(euler: [number, number, number]): number[][] { + const [rx, ry, rz] = euler.map(deg => deg * Math.PI / 180); + + const cx = Math.cos(rx), sx = Math.sin(rx); + const cy = Math.cos(ry), sy = Math.sin(ry); + const cz = Math.cos(rz), sz = Math.sin(rz); + + return [ + [cy * cz, -cy * sz, sy], + [cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy], + [sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy] + ]; +} + +/** + * Apply rotation to a vector + */ +export function rotateVector(v: [number, number, number], euler: [number, number, number]): [number, number, number] { + const m = eulerToRotationMatrix(euler); + return [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + ]; +} + +/** + * Calculate rotation needed to align one vector to another + * Returns Euler angles in degrees + */ +export function alignVectors(from: [number, number, number], to: [number, number, number]): [number, number, number] { + const fromNorm = vec3Normalize(from); + const toNorm = vec3Normalize(to); + + const dot = vec3Dot(fromNorm, toNorm); + + if (Math.abs(dot - 1) < 0.0001) { + return [0, 0, 0]; + } + + if (Math.abs(dot + 1) < 0.0001) { + + const perp = Math.abs(fromNorm[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const axis = vec3Normalize(vec3Cross(fromNorm, perp)); + const angle = Math.PI; + + return [0, angle * 180 / Math.PI, 0]; + } + + const axis = vec3Normalize(vec3Cross(fromNorm, toNorm)); + const angle = Math.acos(Math.max(-1, Math.min(1, dot))); + + const angleDeg = angle * 180 / Math.PI; + + if (Math.abs(axis[1]) > 0.9) { + + return [0, angleDeg * (axis[1] > 0 ? 1 : -1), 0]; + } else if (Math.abs(axis[0]) > 0.9) { + + return [angleDeg * (axis[0] > 0 ? 1 : -1), 0, 0]; + } else { + + return [0, 0, angleDeg * (axis[2] > 0 ? 1 : -1)]; + } +} \ No newline at end of file diff --git a/src/mods/ik/properties.ts b/src/mods/ik/properties.ts new file mode 100644 index 0000000..0b03e47 --- /dev/null +++ b/src/mods/ik/properties.ts @@ -0,0 +1,445 @@ + +import * as PACKAGE from "../../../package.json"; +import { getIKConstraintData, setIKConstraintData, openIKConstraintEditor } from "./constraints"; +import { getIKChain } from "./chain_utils"; +import { updatePinnedBones, togglePinBone } from "./interactive"; + +// Blockbench global types +declare var Property: any; +declare var Action: any; +declare var Group: any; +declare var Outliner: any; +declare var Locator: any; +declare var Format: any; +declare var Animation: any; +declare var Blockbench: any; +declare var MenuBar: any; + +export function setupIKProperties(context: any): any { + + const weightProp = new Property(Group, "number", "vsIKWeight", { + default: 1.0, + label: "IK Weight", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Weight condition error:', e); + return false; + } + }, + inputs: { + element_panel: { + input: { + label: 'IK Weight (0-1)', + type: 'number', + min: 0, + max: 1, + step: 0.01 + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + + if (obj.vsIKWeight === undefined) { + const constraintData = getIKConstraintData(obj); + obj.vsIKWeight = constraintData.weight ?? 1.0; + } + + const constraintData = getIKConstraintData(obj); + constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); + setIKConstraintData(obj, constraintData); + + if (Format.animation_mode) { + + const currentAnimation = Animation.selected; + if (currentAnimation) { + const animator = currentAnimation.getBoneAnimator(obj); + if (animator) { + const currentTime = currentAnimation.time; + const weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); + + const existingKf = animator.keyframes.find((kf: any) => + kf.channel === 'ik_weight' && Math.abs(kf.time - currentTime) < 0.01 + ); + + if (existingKf) { + + if (existingKf.data_points && existingKf.data_points[0]) { + existingKf.data_points[0].x = weight; + } + } else { + + animator.addKeyframe({ + interpolation: 'linear', + time: currentTime, + channel: 'ik_weight', + data_points: [{ x: weight, y: 0, z: 0 }] + }); + } + } + } + } + } + }, + keyframeable: true + }); + + const lockProp = new Property(Group, "boolean", "vsIKLockPosition", { + default: false, + label: "Lock IK Position", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Lock Position condition error:', e); + return false; + } + }, + inputs: { + element_panel: { + input: { + label: 'Lock Controller Position', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + + if (obj.vsIKLockPosition === undefined) { + const constraintData = getIKConstraintData(obj); + obj.vsIKLockPosition = constraintData.lockPosition ?? false; + } + + const constraintData = getIKConstraintData(obj); + constraintData.lockPosition = obj.vsIKLockPosition ?? false; + if (constraintData.lockPosition) { + + constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; + } + setIKConstraintData(obj, constraintData); + + if (Format.animation_mode) { + + const currentAnimation = Animation.selected; + if (currentAnimation) { + const animator = currentAnimation.getBoneAnimator(obj); + if (animator) { + const currentTime = currentAnimation.time; + const lockValue = obj.vsIKLockPosition ? 1 : 0; + + const existingKf = animator.keyframes.find((kf: any) => + kf.channel === 'ik_lock' && Math.abs(kf.time - currentTime) < 0.01 + ); + + if (existingKf) { + + if (existingKf.data_points && existingKf.data_points[0]) { + existingKf.data_points[0].x = lockValue; + } + } else { + + animator.addKeyframe({ + interpolation: 'linear', + time: currentTime, + channel: 'ik_lock', + data_points: [{ x: lockValue, y: 0, z: 0 }] + }); + } + } + } + } + } + }, + keyframeable: true + }); + + const helperProp = new Property(Group, "string", "vsIKOrientationHelper", { + default: '', + label: "IK Orientation Helper", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Orientation Helper condition error:', e); + return false; + } + }, + options: () => { + const nulls = Group.all.filter((g: any) => g.isNull); + const options: { [key: string]: string } = { '': 'None' }; + nulls.forEach((nullObj: any) => { + options[nullObj.name] = nullObj.name; + }); + return options; + }, + inputs: { + element_panel: { + input: { + label: 'Orientation Helper (Void Object)', + type: 'select', + options: () => { + const nulls = Group.all.filter((g: any) => g.isNull); + const options: { [key: string]: string } = { '': 'None' }; + nulls.forEach((nullObj: any) => { + options[nullObj.name] = nullObj.name; + }); + return options; + } + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + + if (obj.vsIKOrientationHelper === undefined) { + const constraintData = getIKConstraintData(obj); + obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; + } + + const constraintData = getIKConstraintData(obj); + constraintData.orientationHelper = obj.vsIKOrientationHelper || undefined; + setIKConstraintData(obj, constraintData); + } + } + }); + + try { + new Property(Locator, "number", "vsIKWeight", { + default: 1.0, + label: "IK Weight", + exposed: true, + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return selected[0] instanceof Locator; + }, + inputs: { + element_panel: { + input: { + label: 'IK Weight (0-1)', + type: 'number', + min: 0, + max: 1, + step: 0.01 + } + } + }, + onChange() { + const obj = this as any; + if (obj instanceof Locator) { + const constraintData = getIKConstraintData(obj as any); + constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); + setIKConstraintData(obj as any, constraintData); + } + } + }); + } catch (e) { + + } + + try { + new Property(Locator, "boolean", "vsIKLockPosition", { + default: false, + label: "Lock IK Position", + exposed: true, + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return selected[0] instanceof Locator; + }, + inputs: { + element_panel: { + input: { + label: 'Lock Controller Position', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj instanceof Locator) { + const constraintData = getIKConstraintData(obj as any); + constraintData.lockPosition = obj.vsIKLockPosition ?? false; + if (constraintData.lockPosition) { + constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; + } + setIKConstraintData(obj as any, constraintData); + } + } + }); + } catch (e) { + + } + + const editIKConstraintsAction = new Action(`${PACKAGE.name}:edit_ik_constraints`, { + name: 'Edit IK Constraints...', + icon: 'settings', + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('Edit IK Constraints condition error:', e); + return false; + } + }, + click: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const controller = selected[0] as unknown as any; + + if ((!controller.isNull && !(controller instanceof Locator)) || !controller.ikTarget) return; + + openIKConstraintEditor(controller, getIKChain); + } + }); + + MenuBar.addAction(editIKConstraintsAction, 'edit'); + + const togglePinBoneAction = new Action(`${PACKAGE.name}:toggle_pin_bone`, { + name: 'Toggle Pin Bone', + icon: 'push_pin', + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + return obj instanceof Group && !obj.isNull; + }, + click: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const bone = selected[0] as unknown as any; + if (bone.isNull) return; + + togglePinBone(bone); + } + }); + + MenuBar.addAction(togglePinBoneAction, 'edit'); + + new Property(Group, "boolean", "vsIKInteractiveMode", { + default: false, + label: "Interactive IK Mode", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + + const isNull = obj instanceof Group && (obj as any).isNull === true; + const isLocator = obj instanceof Locator; + return isNull || isLocator; + } catch (e) { + console.error('IK Interactive Mode condition error:', e); + return false; + } + }, + inputs: { + element_panel: { + input: { + label: 'Enable Interactive IK (Drag limbs to auto-adjust joints)', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj && (obj.isNull || obj instanceof Locator)) { + const constraintData = getIKConstraintData(obj); + constraintData.interactiveMode = obj.vsIKInteractiveMode ?? false; + setIKConstraintData(obj, constraintData); + + if (constraintData.interactiveMode) { + Blockbench.showQuickMessage( + 'Interactive IK enabled: Drag bones in IK chains to auto-adjust connected joints. Use "Toggle Pin Bone" to pin joints.', + 4000 + ); + } + } + } + }); + + new Property(Group, "boolean", "vsIKPinned", { + default: false, + label: "IK Pinned", + exposed: true, + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + return obj instanceof Group && !obj.isNull; + }, + inputs: { + element_panel: { + input: { + label: 'Pin Bone (prevents IK from moving this bone)', + type: 'checkbox' + } + } + }, + onChange() { + const obj = this as any; + if (obj && obj instanceof Group && !obj.isNull) { + updatePinnedBones(); + } + } + }); + + const syncIKProperties = () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const obj = selected[0]; + + if ((obj instanceof Group || obj instanceof Locator) && (obj.isNull || obj instanceof Locator) && obj.ikTarget) { + const constraintData = getIKConstraintData(obj); + + if (obj.vsIKWeight === undefined) { + obj.vsIKWeight = constraintData.weight ?? 1.0; + } + if (obj.vsIKLockPosition === undefined) { + obj.vsIKLockPosition = constraintData.lockPosition ?? false; + } + if (obj.vsIKOrientationHelper === undefined) { + obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; + } + } + }; + + Blockbench.on('select', syncIKProperties); + Blockbench.on('update_selection', syncIKProperties); + + return { editIKConstraintsAction, togglePinBoneAction }; +} \ No newline at end of file diff --git a/src/mods/ik/solver.ts b/src/mods/ik/solver.ts new file mode 100644 index 0000000..cfc49a8 --- /dev/null +++ b/src/mods/ik/solver.ts @@ -0,0 +1,156 @@ + +import { vec3Length, vec3Sub, alignVectors, vec3Normalize, vec3Add } from "./math"; +import { getBoneWorldPosition, getBoneEndWorldPosition } from "./utils"; +import { IKConstraintData, BoneConstraint } from "./types"; +import { applyRotationConstraints } from "./constraints"; + +// Blockbench global types +declare var Blockbench: any; +declare var Group: any; + +// Constants +const CHAIN_REACH_MARGIN = 0.95; +const IK_SOLVER_DAMPING = 0.5; +const IK_SOLVER_MAX_ITERATIONS = 15; +const IK_SOLVER_TOLERANCE = 0.1; + +/** + * Calculates the maximum reachable distance of an IK chain. + * This is the sum of all bone lengths in the chain. + * + * @param chain - Array of bones forming the IK chain + * @returns Total maximum reach distance + */ +export function getChainMaxReach(chain: any[]): number { + let totalLength = 0; + for (let i = 0; i < chain.length - 1; i++) { + const bone = chain[i]; + const nextBone = chain[i + 1]; + const bonePos = getBoneWorldPosition(bone); + const nextBonePos = getBoneWorldPosition(nextBone); + totalLength += vec3Length(vec3Sub(nextBonePos, bonePos)); + } + return totalLength; +} + +/** + * Clamps a target position to be within the IK chain's reach. + * Uses a margin (CHAIN_REACH_MARGIN) to prevent overextension. + * + * @param chain - Array of bones forming the IK chain + * @param targetPosition - Desired target position + * @param maxReach - Maximum reach distance of the chain + * @returns Clamped position within chain reach + */ +export function clampTargetToChainReach( + chain: any[], + targetPosition: [number, number, number], + maxReach: number +): [number, number, number] { + const root = chain[0]; + const rootPos = getBoneWorldPosition(root); + const toTarget = vec3Sub(targetPosition, rootPos); + const distance = vec3Length(toTarget); + + if (distance <= maxReach) { + return targetPosition; + } + + const clampedDirection = vec3Normalize(toTarget); + const clampedDistance = maxReach * CHAIN_REACH_MARGIN; + const clampedTarget = vec3Add(rootPos, [ + clampedDirection[0] * clampedDistance, + clampedDirection[1] * clampedDistance, + clampedDirection[2] * clampedDistance + ]); + + return clampedTarget; +} + +/** + * CCD (Cyclic Coordinate Descent) IK solver. + * Solves IK chain to reach target position, respecting pinned bones and rotation constraints. + * + * @param chain - Array of bones forming the IK chain + * @param targetPosition - Target position for the end effector + * @param pinnedBones - Set of bone names that should not move + * @param constraintData - IK constraint configuration + * @param maxIterations - Maximum solver iterations (default: IK_SOLVER_MAX_ITERATIONS) + * @param tolerance - Distance tolerance for convergence (default: IK_SOLVER_TOLERANCE) + * @returns True if target was reached within tolerance + */ +export function solveIKChain( + chain: any[], + targetPosition: [number, number, number], + pinnedBones: Set, + constraintData: IKConstraintData, + maxIterations: number = IK_SOLVER_MAX_ITERATIONS, + tolerance: number = IK_SOLVER_TOLERANCE +): boolean { + if (chain.length < 2) return false; + + const endEffector = chain[chain.length - 1]; + const root = chain[0]; + + if (pinnedBones.has(endEffector.name) || pinnedBones.has(root.name)) { + return false; + } + + for (let iter = 0; iter < maxIterations; iter++) { + + const currentEndPos = getBoneEndWorldPosition(endEffector); + + const distance = vec3Length(vec3Sub(targetPosition, currentEndPos)); + if (distance < tolerance) { + return true; + } + + for (let i = chain.length - 2; i >= 0; i--) { + const bone = chain[i]; + if (pinnedBones.has(bone.name)) { + continue; + } + + const boneWorldPos = getBoneWorldPosition(bone); + const currentEndEffectorPos = getBoneEndWorldPosition(endEffector); + + const toEnd = vec3Sub(currentEndEffectorPos, boneWorldPos); + const toEndLen = vec3Length(toEnd); + + const toTarget = vec3Sub(targetPosition, boneWorldPos); + const toTargetLen = vec3Length(toTarget); + + if (toEndLen < 0.001 || toTargetLen < 0.001) { + continue; + } + + const damping = IK_SOLVER_DAMPING; + const rotationDelta = alignVectors(toEnd, toTarget); + + const currentRot: [number, number, number] = [ + bone.rotation[0] || 0, + bone.rotation[1] || 0, + bone.rotation[2] || 0 + ]; + + let newRot: [number, number, number] = [ + currentRot[0] + rotationDelta[0] * damping, + currentRot[1] + rotationDelta[1] * damping, + currentRot[2] + rotationDelta[2] * damping + ]; + + newRot = applyRotationConstraints(bone, newRot, constraintData); + + bone.rotation[0] = newRot[0]; + bone.rotation[1] = newRot[1]; + bone.rotation[2] = newRot[2]; + + Blockbench.updateViewport(); + } + } + + const finalEndPos = getBoneEndWorldPosition(endEffector); + const finalDistance = vec3Length(vec3Sub(targetPosition, finalEndPos)); + + return finalDistance < tolerance * 2; +} \ No newline at end of file diff --git a/src/mods/ik/types.ts b/src/mods/ik/types.ts new file mode 100644 index 0000000..c8f1cdd --- /dev/null +++ b/src/mods/ik/types.ts @@ -0,0 +1,51 @@ + +// Blockbench global types +declare var Group: any; +declare var Locator: any; + +/** + * IK Constraint Data Structure + */ +export interface IKConstraintData { + + weight?: number; + + lockPosition?: boolean; + + lockedPosition?: [number, number, number]; + + boneConstraints?: Record; + + orientationHelper?: string; + + interactiveMode?: boolean; + + pinnedBones?: string[]; +} + +/** + * Bone rotation constraints + */ +export interface BoneConstraint { + + allowedAxes?: { x: boolean; y: boolean; z: boolean }; + + rotationLimits?: { + x?: { min: number; max: number }; + y?: { min: number; max: number }; + z?: { min: number; max: number }; + }; +} + +/** + * Interactive IK drag state tracking + */ +export interface DragState { + isActive: boolean; + draggedBone: any | null; + originalBoneState: Map; + ikChain: any[] | null; + controller: any | null; + constraintData: IKConstraintData | null; + startPosition: [number, number, number] | null; +} \ No newline at end of file diff --git a/src/mods/ik/utils.ts b/src/mods/ik/utils.ts new file mode 100644 index 0000000..62956fa --- /dev/null +++ b/src/mods/ik/utils.ts @@ -0,0 +1,58 @@ + +import { vec3Length, vec3Sub } from "./math"; +import { findAllIKControllers } from "./chain_utils"; + +// Blockbench global types +declare var Group: any; + +/** + * Gets the world position of a bone (accounting for parent transforms) + */ +export function getBoneWorldPosition(bone: any): [number, number, number] { + let pos: [number, number, number] = [bone.origin[0], bone.origin[1], bone.origin[2]]; + let current: any = bone.parent; + + while (current && current instanceof Group) { + pos[0] += current.origin[0]; + pos[1] += current.origin[1]; + pos[2] += current.origin[2]; + current = current.parent; + } + + return pos; +} + +/** + * Gets the world position of a bone's end (considering bone length/direction) + * In Blockbench, bones are points, so we calculate the "end" based on children + */ +export function getBoneEndWorldPosition(bone: any): [number, number, number] { + const start = getBoneWorldPosition(bone); + + if (bone.children && bone.children.length > 0) { + const firstChild = bone.children[0]; + if (firstChild instanceof Group) { + return getBoneWorldPosition(firstChild); + } + } + + const ikControllers = findAllIKControllers(); + for (const { chain } of ikControllers) { + const boneIndex = chain.indexOf(bone); + if (boneIndex >= 0 && boneIndex < chain.length - 1) { + const nextBone = chain[boneIndex + 1]; + return getBoneWorldPosition(nextBone); + } + } + + return [start[0], start[1] + 1, start[2]]; +} + +/** + * Gets the length of a bone (distance to its end) + */ +export function getBoneLength(bone: any): number { + const start = getBoneWorldPosition(bone); + const end = getBoneEndWorldPosition(bone); + return vec3Length(vec3Sub(end, start)); +} \ No newline at end of file diff --git a/src/mods/ikMod.ts b/src/mods/ikMod.ts deleted file mode 100644 index f40da64..0000000 --- a/src/mods/ikMod.ts +++ /dev/null @@ -1,1889 +0,0 @@ -import { createBlockbenchMod } from "../util/moddingTools"; -import * as PACKAGE from "../../package.json"; -import { is_vs_project } from "../util"; -import { codecVS } from "../codec"; -import { createPanel } from "../util/moddingTools"; - -// Blockbench global types -declare var Property: any; -declare var Action: any; -declare var Dialog: any; -declare var MenuBar: any; -declare var BarItems: any; - -/** - * IK Improvement Mod for VS Plugin - * - * This mod addresses IK limitations for VS export and adds interactive IK features: - * 1. Detects IK usage and warns users that VS doesn't support IK natively - * 2. Bakes IK animations to keyframes on export (converts IK-driven motion to keyframe animation) - * 3. Provides user feedback during the baking process - * 4. Adds advanced IK constraint features: - * - Animatable IK weight/enable toggle - * - Lock IK end controller position during animation - * - Rotation constraints (axis limits and restrictions) - * - External void object helpers for orientation influence - * - Manual keyframe override support - * 5. Interactive IK features for faster animation workflow: - * - Pin/unpin bones to lock them in place during IK solving - * - Interactive IK mode: When enabled, dragging bones in IK chains will - * automatically adjust connected joints (works with Blockbench's built-in IK) - * - Forward kinematics support: Pin end effectors (like feet) and move - * the root/torso, having intermediate joints auto-adjust - * - * Usage for Interactive IK: - * - Enable "Interactive IK Mode" on an IK controller - * - Use "Toggle Pin Bone" action or checkbox to pin bones that should stay fixed - * - Drag bones in the IK chain - connected joints will auto-adjust - * - Example: Pin the hoof and thigh, then drag the knee to adjust leg position - * - Example: Pin all feet, then move the torso - legs will auto-adjust to keep feet in place - * - * Known Limitations: - * - VS format does not support IK, so all IK must be converted to keyframes - * - IK controller transform inheritance issues (when controllers aren't at dead center) are - * a Blockbench core issue that may require fixes in Blockbench itself - * - The baking process samples IK at regular intervals, which may create many keyframes - * - Complex IK setups may need manual refinement after baking - * - Real-time IK solving uses CCD (Cyclic Coordinate Descent) algorithm which works well - * for 2-3 bone chains but may need more iterations for longer chains - * - Bone length is calculated from child bones or chain relationships, which may not - * always match the visual bone length in Blockbench - */ - -/** - * IK Constraint Data Structure - */ -interface IKConstraintData { - // Weight/enable (0-1, where 0 = disabled, 1 = fully enabled) - weight?: number; - // Lock controller position during animation - lockPosition?: boolean; - // Locked position (set when lockPosition is enabled) - lockedPosition?: [number, number, number]; - // Rotation constraints per bone in chain - boneConstraints?: Record; - // External orientation helper (void object name) - orientationHelper?: string; - // Interactive IK mode (for real-time dragging) - interactiveMode?: boolean; - // Pinned bones (bones that stay fixed during IK solving) - pinnedBones?: string[]; -} - -/** - * Bone rotation constraints - */ -interface BoneConstraint { - // Allowed rotation axes (true = can rotate, false = locked) - allowedAxes?: { x: boolean; y: boolean; z: boolean }; - // Rotation limits in degrees - rotationLimits?: { - x?: { min: number; max: number }; - y?: { min: number; max: number }; - z?: { min: number; max: number }; - }; -} - -createBlockbenchMod( - `${PACKAGE.name}:ik_mod`, - { - // Store original methods we'll patch - originalCompile: codecVS.compile.bind(codecVS), - }, - context => { - // Add IK constraint properties to Group (for IK controllers) - //@ts-expect-error: Property may not be in types - const weightProp = new Property(Group, "number", "vsIKWeight", { - default: 1.0, - label: "IK Weight", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - // Check if it's a null object or locator - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; - } catch (e) { - console.error('IK Weight condition error:', e); - return false; - } - }, - inputs: { - element_panel: { - input: { - label: 'IK Weight (0-1)', - type: 'number', - min: 0, - max: 1, - step: 0.01 - } - } - }, - onChange() { - const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - // Initialize property from constraint data if not set - if (obj.vsIKWeight === undefined) { - const constraintData = getIKConstraintData(obj); - obj.vsIKWeight = constraintData.weight ?? 1.0; - } - // Sync to constraint data - const constraintData = getIKConstraintData(obj); - constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - setIKConstraintData(obj, constraintData); - - // If in animation mode, create/update keyframe for weight - if (Format.animation_mode) { - //@ts-expect-error: Animation type - const currentAnimation = Animation.selected; - if (currentAnimation) { - const animator = currentAnimation.getBoneAnimator(obj); - if (animator) { - const currentTime = currentAnimation.time; - const weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - - // Check if keyframe already exists at this time - const existingKf = animator.keyframes.find((kf: any) => - kf.channel === 'ik_weight' && Math.abs(kf.time - currentTime) < 0.01 - ); - - if (existingKf) { - // Update existing keyframe - if (existingKf.data_points && existingKf.data_points[0]) { - existingKf.data_points[0].x = weight; - } - } else { - // Add new keyframe - animator.addKeyframe({ - interpolation: 'linear', - time: currentTime, - channel: 'ik_weight', - data_points: [{ x: weight, y: 0, z: 0 }] - }); - } - } - } - } - } - }, - keyframeable: true - }); - //@ts-expect-error: Property may not be in types - const lockProp = new Property(Group, "boolean", "vsIKLockPosition", { - default: false, - label: "Lock IK Position", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - // Check if it's a null object or locator - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; - } catch (e) { - console.error('IK Lock Position condition error:', e); - return false; - } - }, - inputs: { - element_panel: { - input: { - label: 'Lock Controller Position', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - // Initialize property from constraint data if not set - if (obj.vsIKLockPosition === undefined) { - const constraintData = getIKConstraintData(obj); - obj.vsIKLockPosition = constraintData.lockPosition ?? false; - } - // Sync to constraint data - const constraintData = getIKConstraintData(obj); - constraintData.lockPosition = obj.vsIKLockPosition ?? false; - if (constraintData.lockPosition) { - // Store current position as locked position - constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; - } - setIKConstraintData(obj, constraintData); - - // If in animation mode, create/update keyframe for lock state - if (Format.animation_mode) { - //@ts-expect-error: Animation type - const currentAnimation = Animation.selected; - if (currentAnimation) { - const animator = currentAnimation.getBoneAnimator(obj); - if (animator) { - const currentTime = currentAnimation.time; - const lockValue = obj.vsIKLockPosition ? 1 : 0; - - // Check if keyframe already exists at this time - const existingKf = animator.keyframes.find((kf: any) => - kf.channel === 'ik_lock' && Math.abs(kf.time - currentTime) < 0.01 - ); - - if (existingKf) { - // Update existing keyframe - if (existingKf.data_points && existingKf.data_points[0]) { - existingKf.data_points[0].x = lockValue; - } - } else { - // Add new keyframe - animator.addKeyframe({ - interpolation: 'linear', - time: currentTime, - channel: 'ik_lock', - data_points: [{ x: lockValue, y: 0, z: 0 }] - }); - } - } - } - } - } - }, - keyframeable: true - }); - //@ts-expect-error: Property may not be in types - const helperProp = new Property(Group, "string", "vsIKOrientationHelper", { - default: '', - label: "IK Orientation Helper", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - // Check if it's a null object or locator - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; - } catch (e) { - console.error('IK Orientation Helper condition error:', e); - return false; - } - }, - options: () => { - const nulls = Group.all.filter(g => g.isNull); - const options: {[key: string]: string} = { '': 'None' }; - nulls.forEach(nullObj => { - options[nullObj.name] = nullObj.name; - }); - return options; - }, - inputs: { - element_panel: { - input: { - label: 'Orientation Helper (Void Object)', - type: 'select', - options: () => { - const nulls = Group.all.filter(g => g.isNull); - const options: {[key: string]: string} = { '': 'None' }; - nulls.forEach(nullObj => { - options[nullObj.name] = nullObj.name; - }); - return options; - } - } - } - }, - onChange() { - const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - // Initialize property from constraint data if not set - if (obj.vsIKOrientationHelper === undefined) { - const constraintData = getIKConstraintData(obj); - obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; - } - // Sync to constraint data - const constraintData = getIKConstraintData(obj); - constraintData.orientationHelper = obj.vsIKOrientationHelper || undefined; - setIKConstraintData(obj, constraintData); - } - } - }); - // Also create properties on Locator type (in case they're separate) - //@ts-expect-error: Property may not be in types - try { - new Property(Locator, "number", "vsIKWeight", { - default: 1.0, - label: "IK Weight", - exposed: true, - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return selected[0] instanceof Locator; - }, - inputs: { - element_panel: { - input: { - label: 'IK Weight (0-1)', - type: 'number', - min: 0, - max: 1, - step: 0.01 - } - } - }, - onChange() { - const obj = this as any; - if (obj instanceof Locator) { - const constraintData = getIKConstraintData(obj as any); - constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - setIKConstraintData(obj as any, constraintData); - } - } - }); - } catch (e) { - // Locator properties might not be needed - } - - //@ts-expect-error: Property may not be in types - try { - new Property(Locator, "boolean", "vsIKLockPosition", { - default: false, - label: "Lock IK Position", - exposed: true, - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return selected[0] instanceof Locator; - }, - inputs: { - element_panel: { - input: { - label: 'Lock Controller Position', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (obj instanceof Locator) { - const constraintData = getIKConstraintData(obj as any); - constraintData.lockPosition = obj.vsIKLockPosition ?? false; - if (constraintData.lockPosition) { - constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; - } - setIKConstraintData(obj as any, constraintData); - } - } - }); - } catch (e) { - // Locator properties might not be needed - } - - // Add action to open IK constraint editor - const editIKConstraintsAction = new Action(`${PACKAGE.name}:edit_ik_constraints`, { - name: 'Edit IK Constraints...', - icon: 'settings', - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - // Check if it's a null object or locator - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; - } catch (e) { - console.error('Edit IK Constraints condition error:', e); - return false; - } - }, - click: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - const controller = selected[0] as Group | Locator; - //@ts-expect-error: IK properties - if ((!controller.isNull && !(controller instanceof Locator)) || !controller.ikTarget) return; - - openIKConstraintEditor(controller as Group); - } - }); - - // Add action to context menu - MenuBar.addAction(editIKConstraintsAction, 'edit'); - - // Add pin/unpin action for bones - const togglePinBoneAction = new Action(`${PACKAGE.name}:toggle_pin_bone`, { - name: 'Toggle Pin Bone', - icon: 'push_pin', - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - return obj instanceof Group && !obj.isNull; - }, - click: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - const bone = selected[0] as Group; - if (bone.isNull) return; - - togglePinBone(bone); - } - }); - - MenuBar.addAction(togglePinBoneAction, 'edit'); - - // Add property for interactive IK mode on controllers - //@ts-expect-error: Property may not be in types - new Property(Group, "boolean", "vsIKInteractiveMode", { - default: false, - label: "Interactive IK Mode", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - // Check if it's a null object or locator - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; - } catch (e) { - console.error('IK Interactive Mode condition error:', e); - return false; - } - }, - inputs: { - element_panel: { - input: { - label: 'Enable Interactive IK (Drag limbs to auto-adjust joints)', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - const constraintData = getIKConstraintData(obj); - constraintData.interactiveMode = obj.vsIKInteractiveMode ?? false; - setIKConstraintData(obj, constraintData); - - if (constraintData.interactiveMode) { - Blockbench.showQuickMessage( - 'Interactive IK enabled: Drag bones in IK chains to auto-adjust connected joints. Use "Toggle Pin Bone" to pin joints.', - 4000 - ); - } - } - } - }); - - // Add property to show if a bone is pinned - //@ts-expect-error: Property may not be in types - new Property(Group, "boolean", "vsIKPinned", { - default: false, - label: "IK Pinned", - exposed: true, - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - return obj instanceof Group && !obj.isNull; - }, - inputs: { - element_panel: { - input: { - label: 'Pin Bone (prevents IK from moving this bone)', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (obj && obj instanceof Group && !obj.isNull) { - updatePinnedBones(); - } - } - }); - - // Sync property values from constraint data when selection changes - const syncIKProperties = () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - const obj = selected[0]; - //@ts-expect-error: IK properties - //@ts-expect-error: IK properties - if ((obj instanceof Group || obj instanceof Locator) && (obj.isNull || obj instanceof Locator) && obj.ikTarget) { - const constraintData = getIKConstraintData(obj as Group); - // Sync property values from constraint data - if (obj.vsIKWeight === undefined) { - obj.vsIKWeight = constraintData.weight ?? 1.0; - } - if (obj.vsIKLockPosition === undefined) { - obj.vsIKLockPosition = constraintData.lockPosition ?? false; - } - if (obj.vsIKOrientationHelper === undefined) { - obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; - } - } - }; - - Blockbench.on('select', syncIKProperties); - Blockbench.on('update_selection', syncIKProperties); - - // Initialize interactive IK system - setupInteractiveIK(); - - // Initialize pinned bones on project load - Blockbench.on('load_project', () => { - setTimeout(updatePinnedBones, 100); - }); - - // Hook into codec compile to bake IK animations before export - codecVS.compile = function(options: any) { - if (is_vs_project(Project)) { - // Check if any IK controllers exist - const hasIK = checkForIKControllers(); - if (hasIK) { - // Warn user that IK will be baked to keyframes - const shouldBake = Blockbench.showMessageBox({ - title: 'IK Animation Detected', - message: - 'Your model uses Inverse Kinematics (IK) controllers.\n\n' + - 'Vintage Story does not support IK natively. IK animations will be ' + - 'automatically converted to keyframe animations during export.\n\n' + - 'This may result in a large number of keyframes. Continue?', - buttons: ['Bake IK to Keyframes', 'Cancel Export'] - }); - - if (shouldBake === 0) { - // Bake IK to keyframes - bakeIKAnimations(); - } else { - // User cancelled - throw new Error('Export cancelled: IK animations need to be baked to keyframes'); - } - } - } - return context.originalCompile(options); - }; - - return { ...context, editIKConstraintsAction, togglePinBoneAction }; - }, - context => { - // Restore original compile method - codecVS.compile = context.originalCompile; - // Remove actions - if (context.editIKConstraintsAction) { - context.editIKConstraintsAction.delete(); - } - if (context.togglePinBoneAction) { - context.togglePinBoneAction.delete(); - } - } -); - -/** - * Bakes IK animations to keyframes for VS export - * Since VS doesn't support IK natively, we need to convert IK-driven animations - * to regular keyframe animations by sampling the IK-driven bone transforms - */ -function bakeIKAnimations() { - if (!Project || !is_vs_project(Project)) return; - - const ikControllers = findAllIKControllers(); - if (ikControllers.length === 0) return; - - //@ts-expect-error: Animation type - const animations = (Animation as unknown as typeof _Animation).all; - const fps = 20; // VS default FPS - - let totalBakedKeyframes = 0; - - // For each animation, bake IK-driven bones - animations.forEach(animation => { - // Get all bones that are part of IK chains - const ikBones = new Set(); - ikControllers.forEach(({ chain }) => { - chain.forEach(bone => ikBones.add(bone)); - }); - - if (ikBones.size === 0) return; - - // Sample animation at regular intervals - const frameCount = Math.ceil(animation.length * fps); - const sampleInterval = 1 / fps; // Sample every frame - - // Store original animation state - const originalTime = animation.time; - const originalSelected = animation.selected; - const wasPlaying = Animator.playing; - - // Stop animation if playing - if (wasPlaying) { - Animator.pause(); - } - - // Select animation to ensure it's active - animation.select(); - - // Enable animation mode if not already - const wasInAnimationMode = Format.animation_mode; - if (!wasInAnimationMode) { - Format.animation_mode = true; - } - - // Process each IK controller to handle locked positions - // Note: Lock position is now animatable, so we'll check it per frame - - // Sample each frame - for (let frame = 0; frame <= frameCount; frame++) { - const time = Math.min(frame * sampleInterval, animation.length); - - // Set animation time - animation.time = time; - - // Apply locked positions for controllers (check animatable lock state) - ikControllers.forEach(({ controller }) => { - const constraintData = getIKConstraintData(controller); - const isLocked = getIKLockAtTime(controller, animation, time); - - if (isLocked && constraintData.lockedPosition) { - // Use stored locked position - controller.origin[0] = constraintData.lockedPosition[0]; - controller.origin[1] = constraintData.lockedPosition[1]; - controller.origin[2] = constraintData.lockedPosition[2]; - } else if (isLocked && !constraintData.lockedPosition) { - // Lock was just enabled, store current position - const pos = controller.origin; - constraintData.lockedPosition = [pos[0], pos[1], pos[2]]; - setIKConstraintData(controller, constraintData); - } - }); - - // Force animation update - //@ts-expect-error: Animation update - if (Animator.update) { - //@ts-expect-error: Animation update - Animator.update(); - } - - // Update viewport to ensure IK is calculated - Blockbench.updateViewport(); - - // Small delay to ensure IK solver has time to calculate - // (This is a workaround - in a real implementation we'd want a proper callback) - - // For each IK controller, process its chain - ikControllers.forEach(({ controller, chain }) => { - const constraintData = getIKConstraintData(controller); - const weight = getIKWeightAtTime(controller, animation, time); - - // Skip if IK is disabled (weight = 0) - if (weight <= 0) { - return; - } - - // Process each bone in the chain - chain.forEach(bone => { - const animator = animation.getBoneAnimator(bone); - if (!animator) return; - - // Get the bone's current transforms after IK is applied - const ikRotation: [number, number, number] = [ - bone.rotation[0] || 0, - bone.rotation[1] || 0, - bone.rotation[2] || 0 - ]; - - // Apply constraints and blend with manual keyframes - const finalRotation = blendIKWithManual(bone, ikRotation, animation, time, constraintData, weight); - - // Apply weight to final rotation (blend with original if weight < 1) - // Get original rotation from keyframes (if any) or bone's base rotation - let originalRotation: [number, number, number] = [0, 0, 0]; - if (animator.interpolate) { - try { - const interp = animator.interpolate('rotation'); - originalRotation = [interp[0] || 0, interp[1] || 0, interp[2] || 0]; - } catch (e) { - // If interpolation fails, use bone's base rotation - originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; - } - } else { - originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; - } - - const weightedRotation: [number, number, number] = [ - originalRotation[0] * (1 - weight) + finalRotation[0] * weight, - originalRotation[1] * (1 - weight) + finalRotation[1] * weight, - originalRotation[2] * (1 - weight) + finalRotation[2] * weight - ]; - - const origin = bone.origin; - - // Calculate position offset relative to parent - const parentOrigin = bone.parent ? bone.parent.origin : [0, 0, 0]; - const offset = [ - origin[0] - parentOrigin[0], - origin[1] - parentOrigin[1], - origin[2] - parentOrigin[2] - ]; - - // Check if keyframe already exists at this time (within tolerance) - const existingKf = animator.keyframes.find((kf: any) => - Math.abs(kf.time - time) < sampleInterval / 4 - ); - - if (!existingKf) { - // Always add rotation keyframe - animator.addKeyframe({ - interpolation: 'linear', - time, - channel: 'rotation', - data_points: [{ - x: weightedRotation[0], - y: weightedRotation[1], - z: weightedRotation[2] - }] - }); - totalBakedKeyframes++; - - // Add position keyframe if offset is non-zero (VS uses offsetX/Y/Z) - // Use a small threshold to avoid unnecessary keyframes for tiny movements - if (Math.abs(offset[0]) > 0.001 || Math.abs(offset[1]) > 0.001 || Math.abs(offset[2]) > 0.001) { - animator.addKeyframe({ - interpolation: 'linear', - time, - channel: 'position', - data_points: [{ - x: offset[0], - y: offset[1], - z: offset[2] - }] - }); - totalBakedKeyframes++; - } - } else { - // Update existing keyframe if it's close enough - // This ensures we capture IK-driven changes even if there was a pre-existing keyframe - const kf = existingKf; - if (kf.channel === 'rotation' && kf.data_points && kf.data_points[0]) { - kf.data_points[0].x = weightedRotation[0]; - kf.data_points[0].y = weightedRotation[1]; - kf.data_points[0].z = weightedRotation[2]; - } else if (kf.channel === 'position' && kf.data_points && kf.data_points[0]) { - kf.data_points[0].x = offset[0]; - kf.data_points[0].y = offset[1]; - kf.data_points[0].z = offset[2]; - } - } - }); - }); - } - - // Restore original state - animation.time = originalTime; - if (originalSelected) { - animation.select(); - } else { - animation.deselect(); - } - - if (!wasInAnimationMode) { - Format.animation_mode = false; - } - - if (wasPlaying) { - Animator.play(); - } - }); - - Blockbench.showQuickMessage( - `Baked ${ikControllers.length} IK controller(s) to ${totalBakedKeyframes} keyframes`, - 3000 - ); -} - -/** - * Finds all IK controllers and their target bones - */ -function findAllIKControllers(): Array<{ controller: Group; target: Group | Locator; chain: Group[] }> { - const ikControllers: Array<{ controller: Group; target: Group | Locator; chain: Group[] }> = []; - - //@ts-expect-error: IK properties may not be in types - const nulls = Group.all.filter(g => g.isNull); - - for (const nullObj of nulls) { - //@ts-expect-error: IK properties - if (nullObj.ikTarget) { - //@ts-expect-error: IK properties - const target = nullObj.ikTarget; - const chain = getIKChain(target); - ikControllers.push({ - controller: nullObj, - target, - chain - }); - } - } - - return ikControllers; -} - -/** - * Gets the IK chain starting from a target bone - * Includes all parent bones up to the root, so the shoulder should be included if it's a parent - */ -function getIKChain(target: Group | Locator): Group[] { - const chain: Group[] = []; - - if (target instanceof Group) { - // Walk up the parent chain to find all bones in the IK chain - let current: any = target; - while (current && current instanceof Group) { - // Only include actual bones (not null objects/locators) in the chain - // But include the target itself even if it's a locator - if (current === target || !current.isNull) { - chain.unshift(current); - } - current = current.parent; - } - } - - return chain; -} - - -/** - * Checks if the project has any IK controllers (Null objects with IK targets) - */ -function checkForIKControllers(): boolean { - if (!Project) return false; - - //@ts-expect-error: IK properties may not be in types - const nulls = Group.all.filter(g => g.isNull); - - for (const nullObj of nulls) { - //@ts-expect-error: IK properties - if (nullObj.ikTarget) { - return true; - } - } - - return false; -} - -/** - * Gets IK constraint data for a controller - */ -function getIKConstraintData(controller: Group): IKConstraintData { - //@ts-expect-error: Custom property - if (!controller.vsIKConstraints) { - //@ts-expect-error: Custom property - controller.vsIKConstraints = { - weight: 1.0, - lockPosition: false, - boneConstraints: {} - }; - } - //@ts-expect-error: Custom property - return controller.vsIKConstraints; -} - -/** - * Sets IK constraint data for a controller - */ -function setIKConstraintData(controller: Group, data: IKConstraintData): void { - //@ts-expect-error: Custom property - controller.vsIKConstraints = { ...getIKConstraintData(controller), ...data }; - // Mark project as modified - if (Project) { - Project.save(); - } -} - -/** - * Gets animatable IK weight for a controller at a specific time - * Returns the weight value (0-1) considering animation keyframes - */ -function getIKWeightAtTime(controller: Group, animation: any, time: number): number { - const constraintData = getIKConstraintData(controller); - const baseWeight = constraintData.weight ?? 1.0; - - // Check for animatable weight keyframes - try { - const animator = animation.getBoneAnimator(controller); - if (animator) { - // Find weight keyframes - const weightKeyframes = animator.keyframes.filter((kf: any) => - kf.channel === 'ik_weight' - ); - - if (weightKeyframes.length > 0) { - // Sort by time - weightKeyframes.sort((a: any, b: any) => a.time - b.time); - - // Find surrounding keyframes - let beforeKf: any = null; - let afterKf: any = null; - - for (const kf of weightKeyframes) { - if (kf.time <= time) { - beforeKf = kf; - } else if (!afterKf) { - afterKf = kf; - break; - } - } - - // Interpolate between keyframes - if (beforeKf && afterKf) { - // Linear interpolation - const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); - const beforeWeight = beforeKf.data_points?.[0]?.x ?? baseWeight; - const afterWeight = afterKf.data_points?.[0]?.x ?? baseWeight; - return Math.max(0, Math.min(1, beforeWeight + (afterWeight - beforeWeight) * t)); - } else if (beforeKf) { - // Use last keyframe value - return Math.max(0, Math.min(1, beforeKf.data_points?.[0]?.x ?? baseWeight)); - } else if (afterKf) { - // Use first keyframe value - return Math.max(0, Math.min(1, afterKf.data_points?.[0]?.x ?? baseWeight)); - } - } - } - } catch (e) { - // If animator doesn't exist or error occurs, fall back to base weight - console.warn('Error getting animatable IK weight:', e); - } - - return baseWeight; -} - -/** - * Gets animatable IK lock state for a controller at a specific time - * Returns true if the controller position should be locked - */ -function getIKLockAtTime(controller: Group, animation: any, time: number): boolean { - const constraintData = getIKConstraintData(controller); - const baseLock = constraintData.lockPosition ?? false; - - // Check for animatable lock keyframes - try { - const animator = animation.getBoneAnimator(controller); - if (animator) { - // Find lock keyframes - const lockKeyframes = animator.keyframes.filter((kf: any) => - kf.channel === 'ik_lock' - ); - - if (lockKeyframes.length > 0) { - // Sort by time - lockKeyframes.sort((a: any, b: any) => a.time - b.time); - - // Find surrounding keyframes - let beforeKf: any = null; - let afterKf: any = null; - - for (const kf of lockKeyframes) { - if (kf.time <= time) { - beforeKf = kf; - } else if (!afterKf) { - afterKf = kf; - break; - } - } - - // Interpolate between keyframes (threshold at 0.5) - if (beforeKf && afterKf) { - // Linear interpolation - const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); - const beforeLock = (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - const afterLock = (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - const interpolated = beforeLock ? (1 - t) : t; - return interpolated > 0.5; - } else if (beforeKf) { - // Use last keyframe value - return (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - } else if (afterKf) { - // Use first keyframe value - return (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - } - } - } - } catch (e) { - // If animator doesn't exist or error occurs, fall back to base lock state - console.warn('Error getting animatable IK lock state:', e); - } - - return baseLock; -} - -/** - * Applies rotation constraints to a bone rotation - */ -function applyRotationConstraints(bone: Group, rotation: [number, number, number], constraintData: IKConstraintData): [number, number, number] { - const boneName = bone.name; - const boneConstraint = constraintData.boneConstraints?.[boneName]; - - if (!boneConstraint) { - return rotation; - } - - let [rx, ry, rz] = rotation; - - // Apply axis restrictions - if (boneConstraint.allowedAxes) { - if (!boneConstraint.allowedAxes.x) rx = 0; - if (!boneConstraint.allowedAxes.y) ry = 0; - if (!boneConstraint.allowedAxes.z) rz = 0; - } - - // Apply rotation limits - if (boneConstraint.rotationLimits) { - const limits = boneConstraint.rotationLimits; - - if (limits.x) { - rx = Math.max(limits.x.min, Math.min(limits.x.max, rx)); - } - if (limits.y) { - ry = Math.max(limits.y.min, Math.min(limits.y.max, ry)); - } - if (limits.z) { - rz = Math.max(limits.z.min, Math.min(limits.z.max, rz)); - } - } - - return [rx, ry, rz]; -} - -/** - * Gets orientation influence from external helper object - */ -function getOrientationInfluence(constraintData: IKConstraintData): [number, number, number] | null { - if (!constraintData.orientationHelper) { - return null; - } - - const helper = Group.all.find(g => g.name === constraintData.orientationHelper && g.isNull); - if (!helper) { - return null; - } - - // Return helper's rotation as influence - return [helper.rotation[0] || 0, helper.rotation[1] || 0, helper.rotation[2] || 0]; -} - -/** - * Blends IK-driven rotation with manual keyframes and constraints - */ -function blendIKWithManual( - bone: Group, - ikRotation: [number, number, number], - animation: any, - time: number, - constraintData: IKConstraintData, - weight: number -): [number, number, number] { - const animator = animation.getBoneAnimator(bone); - if (!animator) { - return applyRotationConstraints(bone, ikRotation, constraintData); - } - - // Check for manual rotation keyframes at this time - const rotationKeyframes = animator.keyframes.filter((kf: any) => - kf.channel === 'rotation' && Math.abs(kf.time - time) < 0.01 - ); - - if (rotationKeyframes.length > 0) { - // Manual keyframe exists - blend with IK based on weight - const manualRot = rotationKeyframes[0].data_points[0]; - const manualRotation: [number, number, number] = [ - manualRot.x || 0, - manualRot.y || 0, - manualRot.z || 0 - ]; - - // Blend: manual * (1 - weight) + ik * weight - const blended: [number, number, number] = [ - manualRotation[0] * (1 - weight) + ikRotation[0] * weight, - manualRotation[1] * (1 - weight) + ikRotation[1] * weight, - manualRotation[2] * (1 - weight) + ikRotation[2] * weight - ]; - - return applyRotationConstraints(bone, blended, constraintData); - } - - // Apply orientation influence from helper - const orientationInfluence = getOrientationInfluence(constraintData); - if (orientationInfluence) { - // Blend IK rotation with orientation helper (50/50 blend) - const blended: [number, number, number] = [ - ikRotation[0] * 0.5 + orientationInfluence[0] * 0.5, - ikRotation[1] * 0.5 + orientationInfluence[1] * 0.5, - ikRotation[2] * 0.5 + orientationInfluence[2] * 0.5 - ]; - return applyRotationConstraints(bone, blended, constraintData); - } - - return applyRotationConstraints(bone, ikRotation, constraintData); -} - -/** - * Opens a dialog to edit IK constraints for a controller - */ -function openIKConstraintEditor(controller: Group) { - //@ts-expect-error: IK properties - const target = controller.ikTarget; - if (!target) return; - - const chain = getIKChain(target); - const constraintData = getIKConstraintData(controller); - - // Build form for bone constraints - const form: any = {}; - - chain.forEach((bone, index) => { - const boneName = bone.name; - const boneConstraint = constraintData.boneConstraints?.[boneName] || {}; - - // Allowed axes - form[`${boneName}_axis_x`] = { - label: `${boneName} - Allow X Rotation`, - type: 'checkbox', - value: boneConstraint.allowedAxes?.x !== false - }; - form[`${boneName}_axis_y`] = { - label: `${boneName} - Allow Y Rotation`, - type: 'checkbox', - value: boneConstraint.allowedAxes?.y !== false - }; - form[`${boneName}_axis_z`] = { - label: `${boneName} - Allow Z Rotation`, - type: 'checkbox', - value: boneConstraint.allowedAxes?.z !== false - }; - - // Rotation limits - form[`${boneName}_rot_x_min`] = { - label: `${boneName} - X Rotation Min (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.x?.min ?? -180, - step: 1 - }; - form[`${boneName}_rot_x_max`] = { - label: `${boneName} - X Rotation Max (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.x?.max ?? 180, - step: 1 - }; - form[`${boneName}_rot_y_min`] = { - label: `${boneName} - Y Rotation Min (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.y?.min ?? -180, - step: 1 - }; - form[`${boneName}_rot_y_max`] = { - label: `${boneName} - Y Rotation Max (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.y?.max ?? 180, - step: 1 - }; - form[`${boneName}_rot_z_min`] = { - label: `${boneName} - Z Rotation Min (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.z?.min ?? -180, - step: 1 - }; - form[`${boneName}_rot_z_max`] = { - label: `${boneName} - Z Rotation Max (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.z?.max ?? 180, - step: 1 - }; - }); - - new Dialog('ik_constraint_editor', { - title: `IK Constraints: ${controller.name}`, - form, - width: 500, - onConfirm: (formResult: any) => { - const updatedConstraints: Record = {}; - - chain.forEach(bone => { - const boneName = bone.name; - const constraint: BoneConstraint = {}; - - // Parse allowed axes - constraint.allowedAxes = { - x: formResult[`${boneName}_axis_x`] !== false, - y: formResult[`${boneName}_axis_y`] !== false, - z: formResult[`${boneName}_axis_z`] !== false - }; - - // Parse rotation limits - const xMin = formResult[`${boneName}_rot_x_min`]; - const xMax = formResult[`${boneName}_rot_x_max`]; - const yMin = formResult[`${boneName}_rot_y_min`]; - const yMax = formResult[`${boneName}_rot_y_max`]; - const zMin = formResult[`${boneName}_rot_z_min`]; - const zMax = formResult[`${boneName}_rot_z_max`]; - - constraint.rotationLimits = {}; - if (xMin !== -180 || xMax !== 180) { - constraint.rotationLimits.x = { min: xMin, max: xMax }; - } - if (yMin !== -180 || yMax !== 180) { - constraint.rotationLimits.y = { min: yMin, max: yMax }; - } - if (zMin !== -180 || zMax !== 180) { - constraint.rotationLimits.z = { min: zMin, max: zMax }; - } - - // Only add constraint if it has meaningful restrictions - if (!constraint.allowedAxes.x || !constraint.allowedAxes.y || !constraint.allowedAxes.z || - constraint.rotationLimits.x || constraint.rotationLimits.y || constraint.rotationLimits.z) { - updatedConstraints[boneName] = constraint; - } - }); - - constraintData.boneConstraints = updatedConstraints; - setIKConstraintData(controller, constraintData); - - Blockbench.showQuickMessage('IK constraints updated', 2000); - } - }).show(); -} - -/** - * Toggles pin state for a bone - */ -function togglePinBone(bone: Group): void { - //@ts-expect-error: Custom property - bone.vsIKPinned = !bone.vsIKPinned; - updatePinnedBones(); - - Blockbench.showQuickMessage( - `${bone.name} ${bone.vsIKPinned ? 'pinned' : 'unpinned'}`, - 2000 - ); -} - -/** - * Updates pinned bones list in all IK controllers - */ -function updatePinnedBones(): void { - const pinnedBones = Group.all - .filter(g => !g.isNull && (g as any).vsIKPinned) - .map(g => g.name); - - // Update all IK controllers with pinned bones list - const ikControllers = findAllIKControllers(); - ikControllers.forEach(({ controller }) => { - const constraintData = getIKConstraintData(controller); - constraintData.pinnedBones = pinnedBones; - setIKConstraintData(controller, constraintData); - }); -} - -/** - * Gets the world position of a bone (accounting for parent transforms) - */ -function getBoneWorldPosition(bone: Group): [number, number, number] { - let pos: [number, number, number] = [bone.origin[0], bone.origin[1], bone.origin[2]]; - let current: any = bone.parent; - - while (current && current instanceof Group) { - pos[0] += current.origin[0]; - pos[1] += current.origin[1]; - pos[2] += current.origin[2]; - current = current.parent; - } - - return pos; -} - -/** - * Gets the world position of a bone's end (considering bone length/direction) - * In Blockbench, bones are points, so we calculate the "end" based on children - */ -function getBoneEndWorldPosition(bone: Group): [number, number, number] { - const start = getBoneWorldPosition(bone); - - // If bone has children, use first child's position as end - if (bone.children && bone.children.length > 0) { - const firstChild = bone.children[0]; - if (firstChild instanceof Group) { - return getBoneWorldPosition(firstChild); - } - } - - // If no children, check if this bone is part of a chain - // and use the next bone in the chain as the end point - const ikControllers = findAllIKControllers(); - for (const { chain } of ikControllers) { - const boneIndex = chain.indexOf(bone); - if (boneIndex >= 0 && boneIndex < chain.length - 1) { - const nextBone = chain[boneIndex + 1]; - return getBoneWorldPosition(nextBone); - } - } - - // Fallback: assume bone extends 1 unit in Y direction - // This is a simplification for bones without clear children - return [start[0], start[1] + 1, start[2]]; -} - -/** - * Gets the length of a bone (distance to its end) - */ -function getBoneLength(bone: Group): number { - const start = getBoneWorldPosition(bone); - const end = getBoneEndWorldPosition(bone); - return vec3Length(vec3Sub(end, start)); -} - -/** - * Calculates the maximum reachable distance of an IK chain - * This is the sum of all bone lengths in the chain - */ -function getChainMaxReach(chain: Group[]): number { - let totalLength = 0; - for (let i = 0; i < chain.length - 1; i++) { - const bone = chain[i]; - const nextBone = chain[i + 1]; - const bonePos = getBoneWorldPosition(bone); - const nextBonePos = getBoneWorldPosition(nextBone); - totalLength += vec3Length(vec3Sub(nextBonePos, bonePos)); - } - return totalLength; -} - -/** - * Clamps a target position to be within the IK chain's reach - * Returns the clamped position - */ -function clampTargetToChainReach( - chain: Group[], - targetPosition: [number, number, number], - maxReach: number -): [number, number, number] { - const root = chain[0]; - const rootPos = getBoneWorldPosition(root); - const toTarget = vec3Sub(targetPosition, rootPos); - const distance = vec3Length(toTarget); - - // If target is within reach, return as-is - if (distance <= maxReach) { - return targetPosition; - } - - // Clamp to maximum reach - const clampedDirection = vec3Normalize(toTarget); - const clampedDistance = maxReach * 0.95; // Use 95% to leave some margin - const clampedTarget = vec3Add(rootPos, [ - clampedDirection[0] * clampedDistance, - clampedDirection[1] * clampedDistance, - clampedDirection[2] * clampedDistance - ]); - - return clampedTarget; -} - -/** - * Vector math utilities - */ -function vec3Sub(a: [number, number, number], b: [number, number, number]): [number, number, number] { - return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; -} - -function vec3Add(a: [number, number, number], b: [number, number, number]): [number, number, number] { - return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; -} - -function vec3Length(v: [number, number, number]): number { - return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); -} - -function vec3Normalize(v: [number, number, number]): [number, number, number] { - const len = vec3Length(v); - if (len < 0.0001) return [0, 0, 0]; - return [v[0] / len, v[1] / len, v[2] / len]; -} - -function vec3Dot(a: [number, number, number], b: [number, number, number]): number { - return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; -} - -function vec3Cross(a: [number, number, number], b: [number, number, number]): [number, number, number] { - return [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0] - ]; -} - -/** - * Convert Euler angles (degrees) to rotation matrix - */ -function eulerToRotationMatrix(euler: [number, number, number]): number[][] { - const [rx, ry, rz] = euler.map(deg => deg * Math.PI / 180); - - const cx = Math.cos(rx), sx = Math.sin(rx); - const cy = Math.cos(ry), sy = Math.sin(ry); - const cz = Math.cos(rz), sz = Math.sin(rz); - - return [ - [cy * cz, -cy * sz, sy], - [cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy], - [sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy] - ]; -} - -/** - * Apply rotation to a vector - */ -function rotateVector(v: [number, number, number], euler: [number, number, number]): [number, number, number] { - const m = eulerToRotationMatrix(euler); - return [ - m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], - m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], - m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] - ]; -} - -/** - * Calculate rotation needed to align one vector to another - * Returns Euler angles in degrees - */ -function alignVectors(from: [number, number, number], to: [number, number, number]): [number, number, number] { - const fromNorm = vec3Normalize(from); - const toNorm = vec3Normalize(to); - - const dot = vec3Dot(fromNorm, toNorm); - - // If vectors are already aligned - if (Math.abs(dot - 1) < 0.0001) { - return [0, 0, 0]; - } - - // If vectors are opposite - if (Math.abs(dot + 1) < 0.0001) { - // Find perpendicular axis - const perp = Math.abs(fromNorm[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; - const axis = vec3Normalize(vec3Cross(fromNorm, perp)); - const angle = Math.PI; - // Convert axis-angle to Euler (simplified - assumes rotation around Y) - return [0, angle * 180 / Math.PI, 0]; - } - - // Calculate rotation axis and angle - const axis = vec3Normalize(vec3Cross(fromNorm, toNorm)); - const angle = Math.acos(Math.max(-1, Math.min(1, dot))); - - // Convert axis-angle to Euler angles (simplified approximation) - // For better results, would use proper quaternion conversion - const angleDeg = angle * 180 / Math.PI; - - // Approximate Euler angles based on axis - // This is a simplification - proper implementation would use quaternions - if (Math.abs(axis[1]) > 0.9) { - // Rotation primarily around Y axis - return [0, angleDeg * (axis[1] > 0 ? 1 : -1), 0]; - } else if (Math.abs(axis[0]) > 0.9) { - // Rotation primarily around X axis - return [angleDeg * (axis[0] > 0 ? 1 : -1), 0, 0]; - } else { - // Rotation primarily around Z axis - return [0, 0, angleDeg * (axis[2] > 0 ? 1 : -1)]; - } -} - -/** - * CCD (Cyclic Coordinate Descent) IK solver - * Solves IK chain to reach target position, respecting pinned bones and constraints - */ -function solveIKChain( - chain: Group[], - targetPosition: [number, number, number], - pinnedBones: Set, - constraintData: IKConstraintData, - maxIterations: number = 15, - tolerance: number = 0.1 -): boolean { - if (chain.length < 2) return false; - - const endEffector = chain[chain.length - 1]; - const root = chain[0]; - - // Don't solve if end effector or root is pinned - if (pinnedBones.has(endEffector.name) || pinnedBones.has(root.name)) { - return false; - } - - // Store original rotations to restore if solving fails - const originalRotations = chain.map(bone => [ - bone.rotation[0] || 0, - bone.rotation[1] || 0, - bone.rotation[2] || 0 - ] as [number, number, number]); - - // CCD solver: iterate from end effector towards root - for (let iter = 0; iter < maxIterations; iter++) { - // Get current end effector position - const currentEndPos = getBoneEndWorldPosition(endEffector); - - // Check if we're close enough - const distance = vec3Length(vec3Sub(targetPosition, currentEndPos)); - if (distance < tolerance) { - return true; // Successfully reached target - } - - // Work backwards from end effector to root - // This should include the shoulder if it's in the chain - for (let i = chain.length - 2; i >= 0; i--) { - const bone = chain[i]; - if (pinnedBones.has(bone.name)) { - continue; - } - - const boneWorldPos = getBoneWorldPosition(bone); - const currentEndEffectorPos = getBoneEndWorldPosition(endEffector); - - // Vector from bone to current end effector position - const toEnd = vec3Sub(currentEndEffectorPos, boneWorldPos); - const toEndLen = vec3Length(toEnd); - - // Vector from bone to target - const toTarget = vec3Sub(targetPosition, boneWorldPos); - const toTargetLen = vec3Length(toTarget); - - // Skip if vectors are too short - if (toEndLen < 0.001 || toTargetLen < 0.001) { - continue; - } - - // Calculate rotation needed to align toEnd with toTarget - // Use a damping factor to make solving smoother - const damping = 0.5; // Adjust this to control solving speed (0-1) - const rotationDelta = alignVectors(toEnd, toTarget); - - // Apply rotation to bone (additive with damping) - const currentRot: [number, number, number] = [ - bone.rotation[0] || 0, - bone.rotation[1] || 0, - bone.rotation[2] || 0 - ]; - - let newRot: [number, number, number] = [ - currentRot[0] + rotationDelta[0] * damping, - currentRot[1] + rotationDelta[1] * damping, - currentRot[2] + rotationDelta[2] * damping - ]; - - // Apply constraints - newRot = applyRotationConstraints(bone, newRot, constraintData); - - // Update bone rotation - bone.rotation[0] = newRot[0]; - bone.rotation[1] = newRot[1]; - bone.rotation[2] = newRot[2]; - - // Update viewport to reflect changes - Blockbench.updateViewport(); - } - } - - // If we didn't converge, check final distance - const finalEndPos = getBoneEndWorldPosition(endEffector); - const finalDistance = vec3Length(vec3Sub(targetPosition, finalEndPos)); - - return finalDistance < tolerance * 2; // Allow slightly larger tolerance -} - -/** - * Interactive IK drag state tracking - */ -interface DragState { - isActive: boolean; - draggedBone: Group | null; - originalBoneState: Map; - ikChain: Group[] | null; - controller: Group | null; - constraintData: IKConstraintData | null; - startPosition: [number, number, number] | null; -} - -const dragState: DragState = { - isActive: false, - draggedBone: null, - originalBoneState: new Map(), - ikChain: null, - controller: null, - constraintData: null, - startPosition: null -}; - -/** - * Hook into bone dragging for interactive IK - * Implements real-time IK solving when dragging bones in IK chains - */ -function setupInteractiveIK(): void { - let lastSelectedBone: Group | null = null; - let transformCount = 0; - - // Track when selection changes to detect drag start - Blockbench.on('select', () => { - // Work in both edit and animation mode - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - - const bone = selected[0]; - if (!(bone instanceof Group) || bone.isNull) return; - - // Check if this bone is part of an IK chain - // Always enable interactive IK for any IK chain to keep bones connected - const ikControllers = findAllIKControllers(); - for (const { controller, chain } of ikControllers) { - const constraintData = getIKConstraintData(controller); - // Always enable - don't require interactiveMode flag - - if (chain.includes(bone)) { - const pinnedBones = new Set(constraintData.pinnedBones || []); - - // Don't allow dragging pinned bones - if (pinnedBones.has(bone.name)) { - Blockbench.showQuickMessage( - `Cannot drag pinned bone: ${bone.name}`, - 2000 - ); - return; - } - - // Initialize drag state if not already active - if (!dragState.isActive || dragState.draggedBone !== bone) { - dragState.isActive = true; - dragState.draggedBone = bone; - dragState.ikChain = chain; - dragState.controller = controller; - dragState.constraintData = constraintData; - dragState.startPosition = getBoneWorldPosition(bone); - dragState.originalBoneState.clear(); - transformCount = 0; - - // Store original state of all bones in chain - chain.forEach(chainBone => { - dragState.originalBoneState.set(chainBone.name, { - position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], - rotation: [ - chainBone.rotation[0] || 0, - chainBone.rotation[1] || 0, - chainBone.rotation[2] || 0 - ] - }); - }); - } - - lastSelectedBone = bone; - break; - } - } - }); - - // Track drag updates and solve IK - Blockbench.on('transform_selection', () => { - // Work in both edit and animation mode - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - - const bone = selected[0]; - if (!(bone instanceof Group) || bone.isNull) return; - - // Check if this bone is part of an IK chain - // Always enable interactive IK to keep chains connected - if (!dragState.isActive || dragState.draggedBone !== bone) { - // Try to initialize if not already active - const ikControllers = findAllIKControllers(); - for (const { controller, chain } of ikControllers) { - const constraintData = getIKConstraintData(controller); - // Always enable - keep all IK chains constrained - - if (chain.includes(bone)) { - const pinnedBones = new Set(constraintData.pinnedBones || []); - if (pinnedBones.has(bone.name)) return; - - dragState.isActive = true; - dragState.draggedBone = bone; - dragState.ikChain = chain; - dragState.controller = controller; - dragState.constraintData = constraintData; - dragState.startPosition = getBoneWorldPosition(bone); - dragState.originalBoneState.clear(); - transformCount = 0; - - chain.forEach(chainBone => { - dragState.originalBoneState.set(chainBone.name, { - position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], - rotation: [ - chainBone.rotation[0] || 0, - chainBone.rotation[1] || 0, - chainBone.rotation[2] || 0 - ] - }); - }); - break; - } - } - } - - if (!dragState.isActive || !dragState.draggedBone || !dragState.ikChain || !dragState.constraintData) { - return; - } - - if (dragState.draggedBone !== bone) return; - - transformCount++; - const chain = dragState.ikChain; - const constraintData = dragState.constraintData; - const pinnedBones = new Set(constraintData.pinnedBones || []); - - // Get current target position (where the bone is being dragged to) - let targetPosition = getBoneWorldPosition(bone); - - // If this is the end effector, clamp target to chain's maximum reach - const endEffectorIndex = chain.length - 1; - if (chain[endEffectorIndex] === bone) { - // Calculate maximum reach of the chain - const maxReach = getChainMaxReach(chain); - const rootPos = getBoneWorldPosition(chain[0]); - const distance = vec3Length(vec3Sub(targetPosition, rootPos)); - - // If dragged beyond reach, clamp it - if (distance > maxReach) { - const clampedTarget = clampTargetToChainReach(chain, targetPosition, maxReach); - - // Move the bone back to the clamped position - const boneParent = bone.parent; - if (boneParent instanceof Group) { - const parentPos = getBoneWorldPosition(boneParent); - bone.origin[0] = clampedTarget[0] - parentPos[0]; - bone.origin[1] = clampedTarget[1] - parentPos[1]; - bone.origin[2] = clampedTarget[2] - parentPos[2]; - } else { - bone.origin[0] = clampedTarget[0]; - bone.origin[1] = clampedTarget[1]; - bone.origin[2] = clampedTarget[2]; - } - - targetPosition = clampedTarget; - } - - // Solve IK to reach target position (now clamped to reach) - solveIKChain(chain, targetPosition, pinnedBones, constraintData); - } else { - // Bone is in the middle of the chain - // Find the bone's index in the chain - const boneIndex = chain.indexOf(bone); - if (boneIndex === -1) return; - - // Get the sub-chain from this bone to the end - const subChain = chain.slice(boneIndex); - const endEffector = subChain[subChain.length - 1]; - - // Clamp the dragged bone's position to maintain chain connectivity - const maxReach = getChainMaxReach(subChain); - const rootPos = getBoneWorldPosition(subChain[0]); - const distance = vec3Length(vec3Sub(targetPosition, rootPos)); - - if (distance > maxReach) { - const clampedTarget = clampTargetToChainReach(subChain, targetPosition, maxReach); - const boneParent = bone.parent; - if (boneParent instanceof Group) { - const parentPos = getBoneWorldPosition(boneParent); - bone.origin[0] = clampedTarget[0] - parentPos[0]; - bone.origin[1] = clampedTarget[1] - parentPos[1]; - bone.origin[2] = clampedTarget[2] - parentPos[2]; - } else { - bone.origin[0] = clampedTarget[0]; - bone.origin[1] = clampedTarget[1]; - bone.origin[2] = clampedTarget[2]; - } - targetPosition = clampedTarget; - } - - const endEffectorTarget = getBoneEndWorldPosition(endEffector); - - // Solve IK for the sub-chain - solveIKChain(subChain, endEffectorTarget, pinnedBones, constraintData); - } - - // Update viewport (throttled to avoid performance issues) - if (transformCount % 2 === 0) { - Blockbench.updateViewport(); - } - }); - - // Track drag end - Blockbench.on('finish_edit', () => { - if (dragState.isActive && dragState.ikChain && dragState.draggedBone) { - // If in animation mode, create keyframes for the solved positions - if (Format.animation_mode) { - //@ts-expect-error: Animation type - const currentAnimation = Animation.selected; - if (currentAnimation) { - const currentTime = currentAnimation.time; - - dragState.ikChain.forEach(bone => { - const animator = currentAnimation.getBoneAnimator(bone); - if (!animator) return; - - // Check if keyframe already exists at this time - const existingKf = animator.keyframes.find((kf: any) => - kf.channel === 'rotation' && Math.abs(kf.time - currentTime) < 0.01 - ); - - if (!existingKf) { - // Add keyframe for the solved rotation - animator.addKeyframe({ - interpolation: 'linear', - time: currentTime, - channel: 'rotation', - data_points: [{ - x: bone.rotation[0] || 0, - y: bone.rotation[1] || 0, - z: bone.rotation[2] || 0 - }] - }); - } else { - // Update existing keyframe - if (existingKf.data_points && existingKf.data_points[0]) { - existingKf.data_points[0].x = bone.rotation[0] || 0; - existingKf.data_points[0].y = bone.rotation[1] || 0; - existingKf.data_points[0].z = bone.rotation[2] || 0; - } - } - }); - } - } - - // Reset drag state - dragState.isActive = false; - dragState.draggedBone = null; - dragState.ikChain = null; - dragState.controller = null; - dragState.constraintData = null; - dragState.startPosition = null; - dragState.originalBoneState.clear(); - transformCount = 0; - } - }); - - // Also reset on selection change if not dragging - Blockbench.on('update_selection', () => { - if (!dragState.isActive) { - lastSelectedBone = null; - } - }); -} diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index c1a4933..327308c 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -20,7 +20,7 @@ import "./mods/settingsMod"; import "./mods/legacyFormatConverterMod"; import "./mods/nodePreviewControllerMod"; import "./mods/attachmentsMod"; -import "./mods/ikMod"; +import "./mods/ik/index"; BBPlugin.register(PACKAGE.name, { title: PACKAGE.title, @@ -41,9 +41,9 @@ BBPlugin.register(PACKAGE.name, { events.UNLOAD.dispatch(); }, oninstall() { - events.INSTALL.dispatch(); - }, - onuninstall() { - events.UNINSTALL.dispatch(); - }, + events.INSTALL.dispatch(); + }, + onuninstall() { + events.UNINSTALL.dispatch(); + }, }); \ No newline at end of file From b537188a0f72ef10fff399da46d4cbcf68e0cb87 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Jan 2026 20:28:48 +0000 Subject: [PATCH 11/15] package.json --- package.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f49221b..ba6df99 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "vs_plugin", "title": "Vintage Story Format Support", "description": "A Blockbench plugin that adds support for Vintage Story models", - "version": "0.12.0", + "version": "0.12.1", "author": { "name": "Darkluke1111", "email": "darkluke1111@gmx.de", @@ -20,6 +20,7 @@ "url": "https://github.com/Darkluke1111/BlockbenchPlugins" }, "devDependencies": { + "@types/json5": "^0.0.30", "@types/node": "^24.9.1", "blockbench-types": "https://github.com/SnaveSutit/blockbench-types.git", "esbuild": "0.27.0", @@ -37,12 +38,13 @@ }, "scripts": { "gen_schema": "npx tsx src/buildtime_scripts/gen_schema.ts", - "build": "node ./esbuild.ts --mode=prod", - "dev": "node ./esbuild.ts --mode=dev", + "build": "npx tsx ./esbuild.ts --mode=prod", + "dev": "npx tsx ./esbuild.ts --mode=dev", "lint": "npx eslint .", "gen_type_docs": "npx typedoc ./src/vs_shape_def.ts" }, "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "json5": "^2.2.3" } -} +} \ No newline at end of file From d680609b8c87efd502d097c9a5732dc9a2f3fb03 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 11 Feb 2026 17:08:52 +0000 Subject: [PATCH 12/15] round-trip vs -> bb -> vs support --- src/debug_actions.ts | 240 +++++++++++++++++++++++++++---- src/export.ts | 8 +- src/export_animation.ts | 16 ++- src/export_model/cube.ts | 11 +- src/export_model/cube/faces.ts | 7 +- src/export_model/cube/factory.ts | 6 +- src/export_model/group.ts | 201 ++++++++++++++++++++++++-- src/export_model/traverse.ts | 49 ++++++- src/import.ts | 6 + src/import_animation.ts | 8 ++ src/import_model.ts | 9 +- src/import_model/cube/faces.ts | 15 +- src/import_model/cube/factory.ts | 18 ++- src/import_model/group.ts | 39 ++++- src/import_model/traverse.ts | 13 +- src/property.ts | 24 ++++ src/vs_shape_def.ts | 2 + 17 files changed, 597 insertions(+), 75 deletions(-) diff --git a/src/debug_actions.ts b/src/debug_actions.ts index 06770e8..835271c 100644 --- a/src/debug_actions.ts +++ b/src/debug_actions.ts @@ -1,9 +1,12 @@ import { createAction } from "./util/moddingTools"; import * as PACKAGE from "../package.json"; import { is_vs_project } from "./util"; +import JSON5 from "json5"; // @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); +// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- +const fs = requireNativeModule('fs'); const reExportAction = createAction(`${PACKAGE.name}:reExport`, { @@ -25,39 +28,31 @@ const reExportAction = createAction(`${PACKAGE.name}:reExport`, { onConfirm(form_result) { const test_folder = form_result.select_folder; console.log(test_folder); - const test_files = fs!.readdirSync(test_folder, { recursive: true, encoding: "utf-8" }); + const test_files: string[] = fs!.readdirSync(test_folder, { encoding: "utf-8" }); for (const test_file of test_files) { - if (!test_file.includes("reexport_")) { - - const test_file_rel_path = test_folder + path.sep + path.dirname(test_file); - const test_file_name = path.basename(test_file); - - const input_path = path.resolve(test_folder, test_file_rel_path, test_file_name); - const output_path = path.resolve(test_folder, test_file_rel_path, `reexport_${test_file_name}`); - - if (!fs?.statSync(input_path).isFile()) continue; - try { + if (!test_file.endsWith('.json') || test_file.startsWith('reexport_')) continue; + const input_path = path.resolve(test_folder, test_file); + const output_path = path.resolve(test_folder, `reexport_${test_file}`); - Blockbench.readFile([input_path], {}, (files) => { + if (!fs?.statSync(input_path).isFile()) continue; + console.log(`Processing: ${input_path} → ${output_path}`); + try { + Blockbench.readFile([input_path], {}, (files) => { + try { //@ts-expect-error: Missing in type --- IGNORE --- loadModelFile(files[0], []); - // codec should be valid if condition is met const reexport_content = Format.codec!.compile(); - Blockbench.writeFile(output_path, { - content: reexport_content, - savetype: "text" - }); - }); - - - - } catch (e) { - console.error(e); - } - // project.close(true); + fs.writeFileSync(output_path, reexport_content, 'utf-8'); + console.log(`Reexported: ${test_file}`); + } catch (inner) { + console.error(`Error inside callback for ${test_file}:`, inner); + } + }); + } catch (e) { + console.error(`Error reexporting ${test_file}:`, e); } } } @@ -76,4 +71,197 @@ const debugAction = createAction(`${PACKAGE.name}:printDebug`, { console.log(Outliner.selected); } }); -MenuBar.addAction(debugAction, "edit"); \ No newline at end of file +MenuBar.addAction(debugAction, "edit"); + +// ---- Round-Trip Diff Test ---- + +const FLOAT_EPSILON = 0.001; + +/** + * Checks if a value is "empty" in the VS format sense (equivalent to being absent). + */ +function isVsEmpty(val: unknown): boolean { + if (val === undefined || val === null) return true; + if (val === false) return true; + if (Array.isArray(val) && val.length === 0) return true; + if (typeof val === 'object' && val !== null && Object.keys(val).length === 0) return true; + // [0,0] uv is equivalent to absent + if (Array.isArray(val) && val.length === 2 && val[0] === 0 && val[1] === 0) return true; + return false; +} + +/** Keys where VS treats missing and empty/default as equivalent */ +const VS_EMPTY_EQUIVALENT_KEYS = new Set([ + 'faces', 'children', 'attachmentpoints', 'uv', 'autoUnwrap', + 'disableRandomDrawOffset' +]); + +/** + * Deep-compare two JSON values and return a list of human-readable diff descriptions. + */ +function deepCompare(a: unknown, b: unknown, path: string): string[] { + const diffs: string[] = []; + + if (a === b) return diffs; + if (a === undefined && b === undefined) return diffs; + if (a === null && b === null) return diffs; + + // One is null/undefined but not the other + if (a == null || b == null) { + diffs.push(`${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`); + return diffs; + } + + // Both are numbers — use epsilon comparison + if (typeof a === 'number' && typeof b === 'number') { + if (Math.abs(a - b) > FLOAT_EPSILON) { + diffs.push(`${path}: ${a} vs ${b}`); + } + return diffs; + } + + // Type mismatch + if (typeof a !== typeof b) { + diffs.push(`${path}: type ${typeof a} vs ${typeof b} (${JSON.stringify(a)} vs ${JSON.stringify(b)})`); + return diffs; + } + + // Both are arrays + if (Array.isArray(a) && Array.isArray(b)) { + const maxLen = Math.max(a.length, b.length); + if (a.length !== b.length) { + diffs.push(`${path}: array length ${a.length} vs ${b.length}`); + } + for (let i = 0; i < maxLen; i++) { + diffs.push(...deepCompare(a[i], b[i], `${path}[${i}]`)); + } + return diffs; + } + + // Both are objects + if (typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)]); + for (const key of allKeys) { + // Skip keys where both values are VS-empty (equivalent defaults) + if (VS_EMPTY_EQUIVALENT_KEYS.has(key) && isVsEmpty(aObj[key]) && isVsEmpty(bObj[key])) { + continue; + } + diffs.push(...deepCompare(aObj[key], bObj[key], `${path}.${key}`)); + } + return diffs; + } + + // Strings that are both numeric — compare as numbers (e.g. "0.0" vs "0") + if (typeof a === 'string' && typeof b === 'string') { + const aNum = Number(a); + const bNum = Number(b); + if (!isNaN(aNum) && !isNaN(bNum)) { + if (Math.abs(aNum - bNum) > FLOAT_EPSILON) { + diffs.push(`${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`); + } + return diffs; + } + } + + // Primitives (string, boolean) + if (a !== b) { + diffs.push(`${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`); + } + + return diffs; +} + +const roundTripDiffAction = createAction(`${PACKAGE.name}:roundTripDiff`, { + name: 'Round-Trip Diff Test', + icon: 'fa-rotate', + condition() { + return is_vs_project(Project); + }, + click: function () { + new Dialog("roundtrip_diff", { + title: "Round-Trip Diff Test", + form: { + select_folder: { + label: "Select Folder to test", + description: "Imports each VS shape file, re-exports it, and compares the JSON. Differences are logged to the console (F12).", + type: "folder", + } + }, + onConfirm(form_result) { + const test_folder = form_result.select_folder; + const test_files: string[] = fs.readdirSync(test_folder, { encoding: "utf-8" }); + let totalFiles = 0; + let filesWithDiffs = 0; + let totalDiffs = 0; + + for (const test_file of test_files) { + if (!test_file.endsWith('.json') || test_file.includes('reexport_')) continue; + + const input_path = path.resolve(test_folder, test_file); + if (!fs.statSync(input_path).isFile()) continue; + + try { + const originalContent = fs.readFileSync(input_path, 'utf-8'); + const originalJson = JSON5.parse(originalContent); + + // Skip non-VS shape files + if (!originalJson.elements || !originalJson.textures) continue; + + totalFiles++; + + Blockbench.readFile([input_path], {}, (files) => { + //@ts-expect-error: Missing in type --- IGNORE --- + loadModelFile(files[0], []); + + const reexportContent = Format.codec!.compile(); + const reexportJson = JSON.parse(reexportContent); + + // Compare, ignoring the editor section (not a round-trip concern) + const originalForCompare = { ...originalJson }; + const reexportForCompare = { ...reexportJson }; + delete originalForCompare.editor; + delete reexportForCompare.editor; + + // Compare animations by name instead of index + if (originalForCompare.animations) { + const animMap: Record = {}; + for (const a of originalForCompare.animations) animMap[a.name] = a; + originalForCompare.animations = animMap; + } + if (reexportForCompare.animations) { + const animMap: Record = {}; + for (const a of reexportForCompare.animations) animMap[a.name] = a; + reexportForCompare.animations = animMap; + } + + const diffs = deepCompare(originalForCompare, reexportForCompare, '$'); + + if (diffs.length > 0) { + filesWithDiffs++; + totalDiffs += diffs.length; + console.group(`%c DIFFS in ${test_file} (${diffs.length} differences)`, 'color: orange; font-weight: bold'); + for (const diff of diffs.slice(0, 50)) { + console.warn(` ${diff}`); + } + if (diffs.length > 50) { + console.warn(` ... and ${diffs.length - 50} more`); + } + console.groupEnd(); + } else { + console.log(`%c OK: ${test_file}`, 'color: green'); + } + }); + } catch (e) { + console.error(`Error testing ${test_file}:`, e); + } + } + + // Summary (logged after loop, but files process async so counts may lag) + console.log(`%c Round-trip test complete: ${totalFiles} files tested`, 'font-weight: bold'); + } + }).show(); + } +}); +MenuBar.addAction(roundTripDiffAction, "file"); diff --git a/src/export.ts b/src/export.ts index 0c8b69b..bcff549 100644 --- a/src/export.ts +++ b/src/export.ts @@ -103,10 +103,12 @@ export function ex(options): VS_Shape { } } - // Populate Texture Sizes - const textureSizes: Record = {}; + // Populate Texture Sizes — start with stored sizes from import, then override with live textures + const textureSizes: Record = { + ...((Project as any).vs_textureSizes || {}) + }; for (const texture of Texture.all) { - if (texture.getUVWidth() && texture.getUVHeight()) { + if (texture.uv_width && texture.uv_height) { textureSizes[texture.name] = [texture.uv_width, texture.uv_height]; } } diff --git a/src/export_animation.ts b/src/export_animation.ts index 8b5cca8..ea42cd1 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -70,12 +70,20 @@ export function export_animations(): Array { } }); + // Use preserved VS values if available, otherwise compute defaults + // @ts-expect-error: custom property from import + const storedCode = animation.vs_code; + // @ts-expect-error: custom property from import + const storedOnActivityStopped = animation.vs_onActivityStopped; + // @ts-expect-error: custom property from import + const storedOnAnimationEnd = animation.vs_onAnimationEnd; + const vsAnimation : VS_Animation = { name: animation.name, - code: animation.name.toLowerCase().replace(/ /g, ''), - quantityframes: Math.round(animation.length * fps) + 1, - onActivityStopped: "EaseOut", - onAnimationEnd: animation.loop === 'loop' ? "Repeat" : "Hold", + code: storedCode || animation.name.toLowerCase().replace(/ /g, ''), + quantityframes: Math.round(animation.length * fps), + onActivityStopped: storedOnActivityStopped || "EaseOut", + onAnimationEnd: storedOnAnimationEnd || (animation.loop === 'loop' ? "Repeat" : "Hold"), keyframes: Object.values(keyframes).sort((a, b) => a.frame - b.frame) }; diff --git a/src/export_model/cube.ts b/src/export_model/cube.ts index d078adf..f8c19fa 100644 --- a/src/export_model/cube.ts +++ b/src/export_model/cube.ts @@ -9,12 +9,13 @@ import {create_VS_element} from "./cube/factory"; * @param node The Cube node to process. * @param accu The accumulator for the VS elements. * @param offset The position offset to apply. + * @param parent_from_override Absolute position of the parent's VS `from`. */ -export function process_cube(parent: Group | null, node: Cube, accu: Array, offset: [number,number,number]) { +export function process_cube(parent: Group | null, node: Cube, accu: Array, offset: [number,number,number], parent_from_override?: [number, number, number]) { if(node.backdrop) { return; } - const parent_pos: [number,number,number] = parent ? parent.origin : [0, 0, 0]; + const parent_pos: [number,number,number] = parent_from_override ? parent_from_override : (parent ? ((parent as any).vs_group_from ?? parent.origin) : [0, 0, 0]); const reduced_faces = process_faces(node.faces); const vsElement = create_VS_element(parent, node, parent_pos, offset, reduced_faces); @@ -33,6 +34,12 @@ export function process_cube(parent: Group | null, node: Cube, accu: Array for (const direction of Object.values(VS_Direction)) { const face = faces[direction]; - // Skip disabled faces or faces without textures - if (!face || face.enabled === false || !face.texture) { + // Skip faces without textures + if (!face || !face.texture) { continue; } @@ -48,10 +48,9 @@ export function process_faces(faces: Partial const processed_face = { texture: `#${texture_name}`, + ...(face.enabled === false && { enabled: false }), ...(!isUvDefault && { uv: transformedUV }), ...(transformedRotation !== 0 && { rotation: transformedRotation }), - autoUv: false, - snapUv: false, }; for (const prop of VS_FACE_PROPS) { diff --git a/src/export_model/cube/factory.ts b/src/export_model/cube/factory.ts index 4e27ab1..05cdaef 100644 --- a/src/export_model/cube/factory.ts +++ b/src/export_model/cube/factory.ts @@ -29,12 +29,14 @@ export function create_VS_element(parent: Group | null, node: Cube, parent_pos: rotationOrigin = util.vector_add(rotationOrigin, offset); } + const includeRotationOrigin = !util.vector_equals(rotationOrigin, from) || (node as any).vs_has_rotation_origin; + return { name: node.name, from: from, to: to, - rotationOrigin: rotationOrigin, - ...((node.uv_offset[0] !== 0 || node.uv_offset[1] !== 0) && { uv: node.uv_offset }), + ...(includeRotationOrigin && { rotationOrigin: rotationOrigin }), + ...((node as any).vs_uv ? { uv: (node as any).vs_uv } : ((node.uv_offset[0] !== 0 || node.uv_offset[1] !== 0) && { uv: node.uv_offset })), faces: faces, ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), diff --git a/src/export_model/group.ts b/src/export_model/group.ts index e55096c..6f8c0ee 100644 --- a/src/export_model/group.ts +++ b/src/export_model/group.ts @@ -1,29 +1,71 @@ import { VS_Element } from "../vs_shape_def"; import { traverse } from "./traverse"; import * as util from "../util"; -import { VS_GROUP_PROPS } from "../property"; +import { VS_CUBE_PROPS, VS_GROUP_PROPS } from "../property"; import { process_locators } from "./locator"; +import { process_faces } from "./cube/faces"; + +/** + * Computes the parent reference position for child element positioning. + * In VS format, children are positioned relative to the parent's `from`. + * Uses the stored vs_group_from (original VS `from` in absolute BB coordinates) + * if available, falling back to origin. + * + * @param parent The parent Blockbench Group (null for root). + * @param parent_from_override Absolute position override for the parent's VS `from`. + * @returns The absolute position to subtract from children to get VS-relative coordinates. + */ +function get_parent_pos(parent: Group | null, parent_from_override?: [number, number, number]): [number, number, number] { + if (parent_from_override) { + return parent_from_override; + } + if (!parent) return [0, 0, 0]; + // Use stored VS from if available (set during import), otherwise fall back to origin + return (parent as any).vs_group_from ?? parent.origin; +} + +/** + * Gets the VS `from` position for a group in absolute BB coordinates. + * Uses vs_group_from if set during import, otherwise falls back to origin. + */ +function get_group_vs_from(node: Group): [number, number, number] { + return (node as any).vs_group_from ?? [...node.origin] as [number, number, number]; +} + +/** + * Gets the VS `to` position for a group in absolute BB coordinates. + * Uses vs_group_to if set during import, otherwise falls back to origin. + */ +function get_group_vs_to(node: Group): [number, number, number] { + return (node as any).vs_group_to ?? [...node.origin] as [number, number, number]; +} + /** * Processes a Blockbench Group and converts it to a VS element. * @param parent The parent node in the hierarchy. * @param node The Group node to process. * @param accu The accumulator for the VS elements. * @param offset The position offset to apply. + * @param parent_from_override Absolute position of the parent's VS `from`. */ export function process_group( parent: Group | null, node: Group, accu: Array, - offset: [number,number,number] + offset: [number, number, number], + parent_from_override?: [number, number, number] ) { - if(node.backdrop) { + if (node.backdrop) { return; } - const parent_pos: [number,number,number] = parent ? parent.origin : [0, 0, 0]; + const parent_pos = get_parent_pos(parent, parent_from_override); const converted_rotation = node.rotation; - let from = util.vector_sub(node.origin, parent_pos); - let to = util.vector_sub(node.origin, parent_pos); + const node_vs_from = get_group_vs_from(node); + const node_vs_to = get_group_vs_to(node); + + let from = util.vector_sub(node_vs_from, parent_pos); + let to = util.vector_sub(node_vs_to, parent_pos); let rotationOrigin = util.vector_sub(node.origin, parent_pos); if (parent === null) { @@ -32,18 +74,22 @@ export function process_group( rotationOrigin = util.vector_add(rotationOrigin, offset); } + const includeRotationOrigin = !util.vector_equals(rotationOrigin, from) || (node as any).vs_has_rotation_origin; + const vsElement: VS_Element = { name: node.name, from: from, to: to, - rotationOrigin: rotationOrigin, + ...(includeRotationOrigin && { rotationOrigin: rotationOrigin }), + ...((node as any).vs_uv ? { uv: (node as any).vs_uv } : undefined), ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), ...(converted_rotation[2] !== 0 && { rotationZ: converted_rotation[2] }), + ...((node as any).vs_zero_size_faces ? { faces: (node as any).vs_zero_size_faces } : undefined), children: [] }; - for(const prop of VS_GROUP_PROPS) { + for (const prop of VS_GROUP_PROPS) { const prop_name = prop.name; const value = node[prop_name]; @@ -53,6 +99,135 @@ export function process_group( } } + // Also export VS_CUBE_PROPS stored on group-like elements (e.g. unwrapMode on eyesroot) + for (const prop of VS_CUBE_PROPS) { + const prop_name = prop.name; + const value = node[prop_name]; + if (prop_name === 'shade') { + if (value !== undefined && value !== true) { + vsElement[prop_name] = value; + } + continue; + } + if (value !== undefined && value !== null && value !== '' && value !== false) { + if (prop_name === 'renderPass' && value === -1) continue; + if (prop_name === 'unwrapMode' && value === 0) continue; + if (prop_name === 'unwrapRotation' && value === 0) continue; + vsElement[prop_name] = value; + } + } + + // Process child locators as attachment points + const locators = node.children.filter(child => child instanceof Locator) as Array; + if (locators.length > 0) { + const attachmentPoints = process_locators(node, locators); + if (attachmentPoints.length > 0) { + vsElement.attachmentpoints = attachmentPoints; + } + } + + accu.push(vsElement); + + // Pass this group's VS from as the parent reference for children + traverse(node, node.children, vsElement.children!, offset, node_vs_from); +} + +/** + * Processes a Blockbench Group that has a _geo child Cube, collapsing them back + * into a single VS element with both geometry and children. + * This reverses the expand_complex_element() transformation done during import. + * + * @param parent The parent node in the hierarchy. + * @param node The Group node (provides rotation, origin, hierarchy, attachments). + * @param geoChild The _geo Cube child (provides geometry: from/to, faces, cube properties). + * @param accu The accumulator for the VS elements. + * @param offset The position offset to apply. + * @param parent_from_override Absolute position of the parent's VS `from`. + */ +export function process_collapsed_group( + parent: Group | null, + node: Group, + geoChild: Cube, + accu: Array, + offset: [number, number, number], + parent_from_override?: [number, number, number] +) { + if (node.backdrop) { + return; + } + const parent_pos = get_parent_pos(parent, parent_from_override); + const converted_rotation = node.rotation; + + // Geometry from/to comes from the _geo cube (relative to parent's VS from) + let from = util.vector_sub(geoChild.from, parent_pos); + let to = util.vector_sub(geoChild.to, parent_pos); + + // Apply inflate value from the _geo cube + if (geoChild.inflate && geoChild.inflate !== 0) { + from = [from[0] - geoChild.inflate, from[1] - geoChild.inflate, from[2] - geoChild.inflate]; + to = [to[0] + geoChild.inflate, to[1] + geoChild.inflate, to[2] + geoChild.inflate]; + } + + // rotationOrigin comes from the group's origin (the element's rotation pivot) + let rotationOrigin = util.vector_sub(node.origin, parent_pos); + + if (parent === null) { + from = util.vector_add(from, offset); + to = util.vector_add(to, offset); + rotationOrigin = util.vector_add(rotationOrigin, offset); + } + + // Process faces from the _geo cube + const reduced_faces = process_faces(geoChild.faces); + + const includeRotationOrigin = !util.vector_equals(rotationOrigin, from) || (node as any).vs_has_rotation_origin; + + const vsElement: VS_Element = { + name: node.name, + from: from, + to: to, + ...(includeRotationOrigin && { rotationOrigin: rotationOrigin }), + ...((geoChild as any).vs_uv ? { uv: (geoChild as any).vs_uv } : ((geoChild.uv_offset[0] !== 0 || geoChild.uv_offset[1] !== 0) && { uv: geoChild.uv_offset })), + ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), + ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), + ...(converted_rotation[2] !== 0 && { rotationZ: converted_rotation[2] }), + faces: reduced_faces, + children: [] + }; + + // Copy VS_GROUP_PROPS from the group node (e.g. stepParentName) + for (const prop of VS_GROUP_PROPS) { + const prop_name = prop.name; + const value = node[prop_name]; + if (value !== undefined && value !== null && value !== '' && value !== false) { + vsElement[prop_name] = value; + } + } + + // Copy VS_CUBE_PROPS from the _geo cube (e.g. shade, climateColorMap, unwrapMode) + for (const prop of VS_CUBE_PROPS) { + const prop_name = prop.name; + const value = geoChild[prop_name]; + if (prop_name === 'shade') { + if (value !== true) { + vsElement[prop_name] = value; + } + continue; + } + if (value !== undefined && value !== null && value !== '' && value !== false) { + if (prop_name === 'renderPass' && value === -1) { + continue; + } + if (prop_name === 'unwrapMode' && value === 0) { + continue; + } + if (prop_name === 'unwrapRotation' && value === 0) { + continue; + } + vsElement[prop_name] = value; + } + } + // Process child locators as attachment points const locators = node.children.filter(child => child instanceof Locator) as Array; if (locators.length > 0) { @@ -63,6 +238,12 @@ export function process_group( } accu.push(vsElement); - traverse(node, node.children, vsElement.children!, offset); -} \ No newline at end of file + // In VS format, children are positioned relative to the parent's `from`. + // The _geo cube's absolute `from` position IS the VS element's `from` in absolute BB space. + // Pass it as the parent_from_override so children compute positions correctly. + const otherChildren = node.children.filter( + child => child !== geoChild + ); + traverse(node, otherChildren, vsElement.children!, offset, geoChild.from as [number, number, number]); +} diff --git a/src/export_model/traverse.ts b/src/export_model/traverse.ts index c81d2b0..844a46f 100644 --- a/src/export_model/traverse.ts +++ b/src/export_model/traverse.ts @@ -1,6 +1,6 @@ import { VS_Element } from "../vs_shape_def"; import {process_cube} from "./cube"; -import {process_group} from "./group"; +import {process_group, process_collapsed_group} from "./group"; /** * Traverses the Blockbench outliner and processes nodes for export. @@ -8,15 +8,54 @@ import {process_group} from "./group"; * @param nodes The array of nodes to process. * @param accu The accumulator for the VS elements. * @param offset The position offset to apply. + * @param parent_from_override When set, child positions are computed relative to this + * instead of parent.origin. Used when the VS element's `from` differs from its + * `rotationOrigin` (complex elements with geometry + children). */ -export function traverse(parent: Group | null, nodes: Array, accu: Array, offset: [number,number,number]) { +export function traverse(parent: Group | null, nodes: Array, accu: Array, offset: [number,number,number], parent_from_override?: [number, number, number]) { + // Collect _geo cubes that will be collapsed into their parent group + const collapsedGeoCubes = new Set(); + + // First pass: identify groups with _geo children + for (const node of nodes) { + if (!node.export) continue; + if (node instanceof Group) { + const geoChild = find_geo_child(node); + if (geoChild) { + collapsedGeoCubes.add(geoChild); + } + } + } + + // Second pass: process nodes, skipping collapsed _geo cubes for (const node of nodes) { if(!node.export) continue; if (node instanceof Group) { - process_group(parent, node, accu, offset); + const geoChild = find_geo_child(node); + if (geoChild) { + process_collapsed_group(parent, node, geoChild, accu, offset, parent_from_override); + } else { + process_group(parent, node, accu, offset, parent_from_override); + } } else if (node instanceof Cube) { - process_cube(parent, node, accu, offset); + // Skip cubes that were collapsed into their parent group + if (collapsedGeoCubes.has(node)) continue; + process_cube(parent, node, accu, offset, parent_from_override); } // Locator nodes are handled as attachment points on their parent elements, so skip them here } -} \ No newline at end of file +} + +/** + * Finds a _geo child cube that was created by expand_complex_element during import. + * @param group The group to search in. + * @returns The _geo Cube if found, undefined otherwise. + */ +function find_geo_child(group: Group): Cube | undefined { + const geoName = `${group.name}_geo`; + return group.children.find( + child => child instanceof Cube + && child.name === geoName + && child.export + ) as Cube | undefined; +} diff --git a/src/import.ts b/src/import.ts index 064e840..c8e3e6b 100644 --- a/src/import.ts +++ b/src/import.ts @@ -15,6 +15,12 @@ export function im(content: VS_Shape, _path: string, asBackdrop: boolean) { Project.texture_width = content.textureWidth || 16; Project.texture_height = content.textureHeight || 16; + // Store original textureSizes for round-trip (includes entries for textures not in the textures map) + if (content.textureSizes) { + // @ts-expect-error: custom property for round-trip fidelity + Project.vs_textureSizes = { ...content.textureSizes }; + } + // Load textures for (const name in content.textures) { const texturePath = util.get_texture_location(null, content.textures[name]); diff --git a/src/import_animation.ts b/src/import_animation.ts index b23ab11..88561cd 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -21,6 +21,14 @@ export function import_animations(animations: Array) { snapping: FPS }) as unknown) as _Animation).add(); + // Preserve VS-specific animation properties for round-trip fidelity + // @ts-expect-error: custom property for round-trip + animation.vs_code = vsAnimation.code; + // @ts-expect-error: custom property for round-trip + animation.vs_onActivityStopped = vsAnimation.onActivityStopped; + // @ts-expect-error: custom property for round-trip + animation.vs_onAnimationEnd = vsAnimation.onAnimationEnd; + vsAnimation.keyframes.forEach(vsKeyframe => { const time = vsKeyframe.frame / FPS; for (const boneName in vsKeyframe.elements) { diff --git a/src/import_model.ts b/src/import_model.ts index 428aa10..1b71c72 100644 --- a/src/import_model.ts +++ b/src/import_model.ts @@ -3,6 +3,8 @@ import { VS_Element, VS_Shape } from "./vs_shape_def"; import {traverse} from "./import_model/traverse"; import { expand_complex_elements} from "./transform"; +declare var Settings: any; + /** * Recursively traverses the Vintage Story element tree and creates Blockbench groups and cubes. * @param shape The VS_Shape object which elements should be imported @@ -11,8 +13,11 @@ import { expand_complex_elements} from "./transform"; */ export function import_model(shape: VS_Shape, asBackdrop: boolean, filePath?: string) { - const expanded = expand_complex_elements(shape); - traverse(null, [0, 0, 0], expanded.elements, asBackdrop, filePath); + // Reverse the [8, 0, 8] offset that export applies to root elements + const applyOffset = Settings.get("vs_apply_model_offset") ?? true; + const offset: [number,number,number] = applyOffset ? [-8, 0, -8] : [0, 0, 0]; + + traverse(null, offset, expanded.elements, asBackdrop, filePath); } \ No newline at end of file diff --git a/src/import_model/cube/faces.ts b/src/import_model/cube/faces.ts index 6ea6b17..3995b6a 100644 --- a/src/import_model/cube/faces.ts +++ b/src/import_model/cube/faces.ts @@ -27,10 +27,17 @@ export function process_faces(faces: Partial> | un }); texture.fromDataURL(texture.getBase64()).add(); } - processed_faces[direction] = { - texture: texture, - uv: faceData.uv, - rotation: faceData.rotation + // Apply 180° rotation correction for down faces (inverse of export correction) + let rotation = faceData.rotation; + if (direction === VS_Direction.DOWN) { + rotation = ((rotation || 0) + 180) % 360; + } + + processed_faces[direction] = { + texture: texture, + uv: faceData.uv, + rotation: rotation, + ...(faceData.enabled === false && { enabled: false }), }; } } diff --git a/src/import_model/cube/factory.ts b/src/import_model/cube/factory.ts index fab57cf..3574278 100644 --- a/src/import_model/cube/factory.ts +++ b/src/import_model/cube/factory.ts @@ -15,16 +15,28 @@ export function create_cube(object_space_pos: [number,number,number], vsElement: from: util.vector_add(vsElement.from, object_space_pos), to: util.vector_add(vsElement.to, object_space_pos), uv_offset: vsElement.uv, - shade: vsElement.shade, + shade: vsElement.shade ?? true, rotation: [vsElement.rotationX || 0, vsElement.rotationY || 0, vsElement.rotationZ || 0], origin: vsElement.rotationOrigin ? util.vector_add(vsElement.rotationOrigin, object_space_pos) : object_space_pos, faces: faces, }; const cube = new Cube(cube_options); + // @ts-expect-error: custom property for round-trip fidelity + cube.vs_has_rotation_origin = vsElement.rotationOrigin !== undefined; + + // Preserve element-level uv offset (Blockbench may not preserve uv_offset in per-face UV mode) + if (vsElement.uv) { + // @ts-expect-error: custom property for round-trip fidelity + cube.vs_uv = vsElement.uv; + } + for (const cube_prop of VS_CUBE_PROPS) { - const prop_name = cube_prop.name; - cube[prop_name] = vsElement[prop_name]; + const prop_name = cube_prop.name; + if (prop_name === 'shade') continue; // Already handled in constructor + if (vsElement[prop_name] !== undefined) { + cube[prop_name] = vsElement[prop_name]; + } } return cube; diff --git a/src/import_model/group.ts b/src/import_model/group.ts index ffc2b72..93aadc6 100644 --- a/src/import_model/group.ts +++ b/src/import_model/group.ts @@ -1,6 +1,6 @@ import { VS_Element } from "../vs_shape_def"; import * as util from "../util"; -import {VS_GROUP_PROPS } from "../property"; +import {VS_GROUP_PROPS, VS_CUBE_PROPS } from "../property"; import { process_attachment_points } from "./locator"; import { getActiveSlotNames } from "../attachments/presets"; @@ -15,12 +15,37 @@ import { getActiveSlotNames } from "../attachments/presets"; * @returns The created Blockbench Group. */ export function process_group(parent: Group | null, object_space_pos: [number,number,number], vsElement: VS_Element, asBackdrop: boolean, filePath?: string): Group { + const absolute_from: [number,number,number] = util.vector_add(vsElement.from, object_space_pos); + const absolute_to: [number,number,number] = util.vector_add(vsElement.to, object_space_pos); + const group = new Group({ name: vsElement.name, - origin: vsElement.rotationOrigin ? util.vector_add(vsElement.rotationOrigin, object_space_pos) : object_space_pos, + origin: vsElement.rotationOrigin ? util.vector_add(vsElement.rotationOrigin, object_space_pos) : absolute_from, rotation: [vsElement.rotationX || 0, vsElement.rotationY || 0, vsElement.rotationZ || 0], }); + // Store VS from/to in absolute BB coordinates for export round-trip. + // Groups only have `origin` (= rotationOrigin), but VS elements can have + // from/to that differ from rotationOrigin. + // @ts-expect-error: custom property for round-trip fidelity + group.vs_group_from = absolute_from; + // @ts-expect-error: custom property for round-trip fidelity + group.vs_group_to = absolute_to; + // @ts-expect-error: custom property for round-trip fidelity + group.vs_has_rotation_origin = vsElement.rotationOrigin !== undefined; + + // Preserve faces on zero-size elements (from==to but with non-empty faces) + if (vsElement.faces && Object.keys(vsElement.faces).length > 0 && util.vector_equals(vsElement.from, vsElement.to)) { + // @ts-expect-error: custom property for round-trip fidelity + group.vs_zero_size_faces = vsElement.faces; + } + + // Preserve element-level uv offset (Blockbench may not preserve uv_offset in per-face UV mode) + if (vsElement.uv) { + // @ts-expect-error: custom property for round-trip fidelity + group.vs_uv = vsElement.uv; + } + if (asBackdrop) { group.backdrop = true; group.locked = true; @@ -33,6 +58,16 @@ export function process_group(parent: Group | null, object_space_pos: [number,nu } } + // Also preserve VS_CUBE_PROPS that may appear on group-like elements + // (e.g. elements with from≠to but empty faces, like "eyesroot") + for (const prop of VS_CUBE_PROPS) { + const prop_name = prop.name; + if (prop_name === 'shade') continue; + if (vsElement[prop_name] !== undefined) { + group[prop_name] = vsElement[prop_name]; + } + } + // If this is a top-level group with stepParentName and no clothingSlot set, infer from path if (vsElement.stepParentName && !group.clothingSlot && filePath && !parent) { const inferredSlot = getActiveSlotNames().includes(vsElement.stepParentName) diff --git a/src/import_model/traverse.ts b/src/import_model/traverse.ts index 797e8c6..f1fa304 100644 --- a/src/import_model/traverse.ts +++ b/src/import_model/traverse.ts @@ -16,18 +16,15 @@ import { has_children, has_attachments, has_geometry } from "../transform"; export function traverse(parent: Group | null, object_space_pos: [number,number,number], vsElements: Array, asBackdrop: boolean, filePath?: string) { for (const vsElement of vsElements) { - // Elements with children or attachments (but no geometry) become groups - if(!has_geometry(vsElement) && (has_children(vsElement) || has_attachments(vsElement))) { + if (has_geometry(vsElement) && !has_children(vsElement) && !has_attachments(vsElement)) { + // Elements with geometry but no children/attachments become cubes + process_cube(parent, object_space_pos, vsElement, asBackdrop); + } else if (!has_geometry(vsElement)) { + // Elements without geometry become groups (containers, anchors, placeholders) const group = process_group(parent, object_space_pos, vsElement, asBackdrop, filePath); - // Recursively traverse child elements if they exist if (has_children(vsElement)) { traverse(group, util.vector_add(vsElement.from, object_space_pos), vsElement.children!, asBackdrop, filePath); } } - - // Elements with geometry but no children/attachments become cubes - if (has_geometry(vsElement) && !has_children(vsElement) && !has_attachments(vsElement)) { - process_cube(parent, object_space_pos, vsElement, asBackdrop); - } } } \ No newline at end of file diff --git a/src/property.ts b/src/property.ts index f51f733..1cb6331 100644 --- a/src/property.ts +++ b/src/property.ts @@ -141,6 +141,26 @@ export const VS_CUBE_PROPS = [ } }, }), + new Property(Cube, "number", "unwrapMode", { + default: 0, + label: "Unwrap Mode", + exposed: false, + }), + new Property(Cube, "boolean", "autoUnwrap", { + default: false, + label: "Auto Unwrap", + exposed: false, + }), + new Property(Cube, "boolean", "disableRandomDrawOffset", { + default: false, + label: "Disable Random Draw Offset", + exposed: false, + }), + new Property(Cube, "number", "unwrapRotation", { + default: 0, + label: "Unwrap Rotation", + exposed: false, + }), ]; // Blockbench-only properties (not exported to VS JSON format) @@ -249,6 +269,10 @@ declare global { gradientShade?: boolean; renderPass?: number; seasonColorMap?: string; + unwrapMode?: number; + autoUnwrap?: boolean; + disableRandomDrawOffset?: boolean; + unwrapRotation?: number; backdrop?: boolean; } } \ No newline at end of file diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 6546786..96ac511 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -30,6 +30,8 @@ export interface VS_Element { name: string, from: [number, number, number], to: [number, number, number], + unwrapMode?: number, + unwrapRotation?: number, autoUnwrap?: boolean, disableRandomDrawOffset?: boolean, climateColorMap?: string, From dafcd73b2cbeb5cc4f6722481258962113906d48 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 25 Mar 2026 19:05:49 +0000 Subject: [PATCH 13/15] more shape stuff --- src/actions.ts | 34 +-- src/codec.ts | 11 +- src/export_animation.ts | 76 ++++- src/export_model/cube.ts | 10 +- src/export_model/cube/faces.ts | 4 + src/export_model/cube/factory.ts | 3 +- src/export_model/group.ts | 30 +- src/export_model/locator.ts | 17 +- src/format_definition.ts | 1 + src/import_model/cube/factory.ts | 2 +- src/import_model/locator.ts | 14 +- src/import_model/traverse.ts | 12 +- src/mods/boneAnimatorMod.ts | 210 ++++++++++++- src/mods/ik/baking.ts | 13 +- src/mods/ik/chain_utils.ts | 77 +++-- src/mods/ik/constraint_visualizer.ts | 309 +++++++++++++++++++ src/mods/ik/constraints.ts | 430 ++++++++++++++++++++++---- src/mods/ik/index.ts | 40 ++- src/mods/ik/interactive.ts | 80 ++--- src/mods/ik/properties.ts | 189 ++++-------- src/mods/ik/solver.ts | 3 +- src/mods/ik/utils.ts | 50 ++- src/mods/nodePreviewControllerMod.ts | 73 ++++- src/panels/vs_face_panel.ts | 440 +++++++++++++++++++++++++++ src/property.ts | 65 +++- src/vs_plugin.ts | 5 +- src/vs_shape_def.ts | 1 + 27 files changed, 1845 insertions(+), 354 deletions(-) create mode 100644 src/mods/ik/constraint_visualizer.ts create mode 100644 src/panels/vs_face_panel.ts diff --git a/src/actions.ts b/src/actions.ts index 5856d55..f50a1b3 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -22,28 +22,28 @@ const export_action = createAction(`${PACKAGE.name}:export_vs`, { throw new Error("No project loaded during export"); } - // Use Blockbench's file save dialog + // Use Blockbench's file save dialog with custom_writer to avoid + // writing empty content before the actual data is ready Blockbench.export({ name: Project.name, type: 'json', extensions: ['json'], savetype: 'text', - content: '' // We'll save manually after getting the path - }, (exportPath) => { - // This callback receives the path where the user wants to save - if (!exportPath) return; - - // Get the export directory - const exportDir = path.dirname(exportPath); - - // Call ex() with the export directory - const data = ex({ path: exportPath, exportDir: exportDir }); - - // Save the JSON file - const jsonContent = autoStringify(data); - fs.writeFileSync(exportPath, jsonContent); - - Blockbench.showQuickMessage('Model and textures exported successfully'); + custom_writer: (_content, exportPath) => { + try { + const exportDir = path.dirname(exportPath); + const data = ex({ path: exportPath, exportDir: exportDir }); + const jsonContent = autoStringify(data); + fs.writeFileSync(exportPath, jsonContent); + Blockbench.showQuickMessage('Model and textures exported successfully'); + } catch (e) { + console.error('[VS Export] Export failed:', e); + Blockbench.showMessageBox({ + title: 'VS Export Error', + message: `Export failed: ${e instanceof Error ? e.message : String(e)}` + }); + } + } }); } }); diff --git a/src/codec.ts b/src/codec.ts index 7918d51..ab36d47 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -21,7 +21,16 @@ export const codecVS = new Codec("codecVS", { } }, compile(options) { - return autoStringify(ex(options)); + try { + return autoStringify(ex(options)); + } catch (e) { + console.error('[VS Codec] Compile failed:', e); + Blockbench.showMessageBox({ + title: 'VS Export Error', + message: `Failed to compile model: ${e instanceof Error ? e.message : String(e)}` + }); + return undefined; + } }, parse(data, file_path, _add) { im(JSON5.parse(data) as VS_Shape, file_path, false); diff --git a/src/export_animation.ts b/src/export_animation.ts index ea42cd1..bf96e9f 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -24,7 +24,11 @@ export function export_animations(): Array { let hasNonLinearInterpolation = false; animators.forEach(animator => { - if (animator.keyframes.length > 0 && animator.type === 'bone') { + if (animator.type === 'bone' && animator.keyframes && animator.keyframes.length > 0) { + // Skip NullObject animators (IK controllers) — they don't exist as VS elements + if (typeof NullObject !== 'undefined' && NullObject.all?.some((n: any) => n.uuid === animator.uuid)) { + return; + } const bone_name = animator.name; animator.keyframes.forEach(kf => { @@ -60,6 +64,23 @@ export function export_animations(): Array { }); } + // Process effect animators for texture swap keyframes + if (animator.type === 'effect' && animator.keyframes && animator.keyframes.length > 0) { + animator.keyframes.forEach(kf => { + if (kf.channel === 'timeline') { + const script = kf.data_points[0]?.script; + if (script) { + const textures = parseTextureSwapScript(script); + if (textures) { + const frame = Math.round(kf.time * fps); + keyframes[frame] = keyframes[frame] || { frame, elements: {} }; + keyframes[frame].textures = textures; + } + } + } + }); + } + // Wraps all animation elements into oneLiner wrappers for(const keyframe of Object.values(keyframes)) { const wrapped_elements = {}; @@ -81,7 +102,7 @@ export function export_animations(): Array { const vsAnimation : VS_Animation = { name: animation.name, code: storedCode || animation.name.toLowerCase().replace(/ /g, ''), - quantityframes: Math.round(animation.length * fps), + quantityframes: get_frame_quantity(animation, keyframes), onActivityStopped: storedOnActivityStopped || "EaseOut", onAnimationEnd: storedOnAnimationEnd || (animation.loop === 'loop' ? "Repeat" : "Hold"), keyframes: Object.values(keyframes).sort((a, b) => a.frame - b.frame) @@ -97,6 +118,23 @@ export function export_animations(): Array { } } + // For looping animations, insert a virtual end frame copying frame 0 + // so VS can interpolate back to the start. quantityframes is 0-indexed + // in VS, so the last frame is quantityframes - 1. + if (vsAnimation.onAnimationEnd === "Repeat" && vsAnimation.quantityframes > 0) { + const lastFrame = vsAnimation.quantityframes - 1; + const hasLastFrame = vsAnimation.keyframes.some(kf => kf.frame === lastFrame); + if (!hasLastFrame) { + const frame0 = vsAnimation.keyframes.find(kf => kf.frame === 0); + if (frame0) { + const virtualFrame = JSON.parse(JSON.stringify(frame0)); + virtualFrame.frame = lastFrame; + vsAnimation.keyframes.push(virtualFrame); + vsAnimation.keyframes.sort((a, b) => a.frame - b.frame); + } + } + } + if (vsAnimation.keyframes.length > 0) { animations.push(vsAnimation); if (hasNonLinearInterpolation) { @@ -119,24 +157,46 @@ export function export_animations(): Array { * @param keyframes Keyframes */ function get_frame_quantity(animation: _Animation, keyframes: Record): number { - let quantityframes = Math.round(animation.length * util.fps); + const quantityframes = Math.round(animation.length * util.fps); const keyframe_frames = Object.keys(keyframes).map(kf => parseInt(kf)); + if (keyframe_frames.length === 0) { + return quantityframes; + } const max_keyframe = Math.max(...keyframe_frames); - if (max_keyframe == quantityframes) { + if (max_keyframe >= quantityframes) { display_animation_length_warning(animation.name); - quantityframes = max_keyframe + 1; } return quantityframes; } +/** + * Parses a Blockbench effect timeline script for texture swap data. + * Expected format: "textures": { "slot": "path", ... } + * Returns a Record mapping texture codes to asset paths, or null if not a texture swap. + */ +function parseTextureSwapScript(script: string): Record | null { + try { + // The script content is a JSON fragment like: "textures": { "mouth": "entity/mouth/*_smile" } + // Wrap in braces to make it valid JSON + const trimmed = script.trim(); + const json = JSON.parse('{' + trimmed + '}'); + if (json.textures && typeof json.textures === 'object') { + return json.textures; + } + } catch { + // Not valid JSON texture swap data, ignore + } + return null; +} + function display_animation_length_warning(animation_name: string) { Blockbench.showMessageBox({ title: 'Animation Length Warning', message: - `The animation "${animation_name}" has keyframes on the last frame. ` + - `This is not supported by Vintage Story, so the animation length was inceased by 1. ` + - `If you want to prevent this, please move the keyframes away from the last frame.` + `The animation "${animation_name}" has keyframes on or past the last frame. ` + + `This may not animate correctly in Vintage Story. ` + + `Consider moving the keyframes away from the last frame if you experience issues.` }); } diff --git a/src/export_model/cube.ts b/src/export_model/cube.ts index f8c19fa..ecacd11 100644 --- a/src/export_model/cube.ts +++ b/src/export_model/cube.ts @@ -31,16 +31,18 @@ export function process_cube(parent: Group | null, node: Cube, accu: Array if (Array.isArray(value) && value.every(v => v === 0)) { continue; } + // Skip false for boolean face properties (autoUv, snapUv default to false in VS) + if (typeof value === 'boolean' && value === false) { + continue; + } processed_face[prop_name] = value; } } diff --git a/src/export_model/cube/factory.ts b/src/export_model/cube/factory.ts index 05cdaef..032d32e 100644 --- a/src/export_model/cube/factory.ts +++ b/src/export_model/cube/factory.ts @@ -29,7 +29,8 @@ export function create_VS_element(parent: Group | null, node: Cube, parent_pos: rotationOrigin = util.vector_add(rotationOrigin, offset); } - const includeRotationOrigin = !util.vector_equals(rotationOrigin, from) || (node as any).vs_has_rotation_origin; + const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; + const includeRotationOrigin = hasRotation || (node as any).vs_has_rotation_origin; return { name: node.name, diff --git a/src/export_model/group.ts b/src/export_model/group.ts index 6f8c0ee..dbc95c4 100644 --- a/src/export_model/group.ts +++ b/src/export_model/group.ts @@ -74,7 +74,8 @@ export function process_group( rotationOrigin = util.vector_add(rotationOrigin, offset); } - const includeRotationOrigin = !util.vector_equals(rotationOrigin, from) || (node as any).vs_has_rotation_origin; + const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; + const includeRotationOrigin = hasRotation || (node as any).vs_has_rotation_origin; const vsElement: VS_Element = { name: node.name, @@ -110,15 +111,17 @@ export function process_group( continue; } if (value !== undefined && value !== null && value !== '' && value !== false) { - if (prop_name === 'renderPass' && value === -1) continue; - if (prop_name === 'unwrapMode' && value === 0) continue; - if (prop_name === 'unwrapRotation' && value === 0) continue; - vsElement[prop_name] = value; + // Coerce to number for numeric properties (select dropdowns may return strings) + const numValue = prop.type === 'number' ? Number(value) : value; + if (prop_name === 'renderPass' && numValue === -1) continue; + if (prop_name === 'unwrapMode' && numValue === 0) continue; + if (prop_name === 'unwrapRotation' && numValue === 0) continue; + vsElement[prop_name] = numValue; } } // Process child locators as attachment points - const locators = node.children.filter(child => child instanceof Locator) as Array; + const locators = node.children.filter(child => child instanceof Locator && !(typeof NullObject !== 'undefined' && child instanceof NullObject)) as Array; if (locators.length > 0) { const attachmentPoints = process_locators(node, locators); if (attachmentPoints.length > 0) { @@ -180,7 +183,8 @@ export function process_collapsed_group( // Process faces from the _geo cube const reduced_faces = process_faces(geoChild.faces); - const includeRotationOrigin = !util.vector_equals(rotationOrigin, from) || (node as any).vs_has_rotation_origin; + const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; + const includeRotationOrigin = hasRotation || (node as any).vs_has_rotation_origin; const vsElement: VS_Element = { name: node.name, @@ -215,21 +219,23 @@ export function process_collapsed_group( continue; } if (value !== undefined && value !== null && value !== '' && value !== false) { - if (prop_name === 'renderPass' && value === -1) { + // Coerce to number for numeric properties (select dropdowns may return strings) + const numValue = prop.type === 'number' ? Number(value) : value; + if (prop_name === 'renderPass' && numValue === -1) { continue; } - if (prop_name === 'unwrapMode' && value === 0) { + if (prop_name === 'unwrapMode' && numValue === 0) { continue; } - if (prop_name === 'unwrapRotation' && value === 0) { + if (prop_name === 'unwrapRotation' && numValue === 0) { continue; } - vsElement[prop_name] = value; + vsElement[prop_name] = numValue; } } // Process child locators as attachment points - const locators = node.children.filter(child => child instanceof Locator) as Array; + const locators = node.children.filter(child => child instanceof Locator && !(typeof NullObject !== 'undefined' && child instanceof NullObject)) as Array; if (locators.length > 0) { const attachmentPoints = process_locators(node, locators); if (attachmentPoints.length > 0) { diff --git a/src/export_model/locator.ts b/src/export_model/locator.ts index 0f06bb2..8479c72 100644 --- a/src/export_model/locator.ts +++ b/src/export_model/locator.ts @@ -17,19 +17,26 @@ export function process_locators( return attachmentPoints; } - const parent_pos: [number, number, number] = parent ? parent.origin : [0, 0, 0]; + // Use vs_group_from for consistency with group/cube export parent reference + const parent_pos: [number, number, number] = parent + ? ((parent as any).vs_group_from ?? parent.origin) + : [0, 0, 0]; for (const locator of locators) { if (!locator.export) continue; - // Get locator position - const locator_pos: [number, number, number] = (locator as any).position || [0, 0, 0]; + // Locator position is stored in .from (not .position) + const locator_pos: [number, number, number] = (locator.from ?? (locator as any).position ?? [0, 0, 0]) as [number, number, number]; // Calculate position relative to parent const relative_pos = util.vector_sub(locator_pos, parent_pos); - // Get rotation (Blockbench uses degrees, VS uses degrees too) - const rotation: [number, number, number] = (locator as any).rotation || [0, 0, 0]; + // Read rotation from registered properties (persisted in .bbmodel) + const rotation: [number, number, number] = [ + (locator as any).rotationX || 0, + (locator as any).rotationY || 0, + (locator as any).rotationZ || 0, + ]; const attachmentPoint: VS_AttachmentPoint = { code: locator.name, diff --git a/src/format_definition.ts b/src/format_definition.ts index ae91851..239a358 100644 --- a/src/format_definition.ts +++ b/src/format_definition.ts @@ -24,6 +24,7 @@ export function create_format(): ModelFormat { meshes: false, texture_meshes: false, locators: true, + null_object: true, rotation_limit: false, rotation_snap: false, uv_rotation: true, diff --git a/src/import_model/cube/factory.ts b/src/import_model/cube/factory.ts index 3574278..4d026e6 100644 --- a/src/import_model/cube/factory.ts +++ b/src/import_model/cube/factory.ts @@ -17,7 +17,7 @@ export function create_cube(object_space_pos: [number,number,number], vsElement: uv_offset: vsElement.uv, shade: vsElement.shade ?? true, rotation: [vsElement.rotationX || 0, vsElement.rotationY || 0, vsElement.rotationZ || 0], - origin: vsElement.rotationOrigin ? util.vector_add(vsElement.rotationOrigin, object_space_pos) : object_space_pos, + origin: vsElement.rotationOrigin ? util.vector_add(vsElement.rotationOrigin, object_space_pos) : util.vector_add(vsElement.from, object_space_pos), faces: faces, }; const cube = new Cube(cube_options); diff --git a/src/import_model/locator.ts b/src/import_model/locator.ts index a496211..954dd41 100644 --- a/src/import_model/locator.ts +++ b/src/import_model/locator.ts @@ -19,14 +19,12 @@ export function process_attachment_points( const posX = parseFloat(ap.posX); const posY = parseFloat(ap.posY); const posZ = parseFloat(ap.posZ); - const rotX = parseFloat(ap.rotationX); - const rotY = parseFloat(ap.rotationY); - const rotZ = parseFloat(ap.rotationZ); - // Calculate absolute position from relative position + // Calculate absolute position: VS attachment positions are relative to parent's `from`, + // which is stored as vs_group_from in absolute BB space (not rotationOrigin/origin) const absolute_pos = util.vector_add( [posX, posY, posZ], - parent.origin + (parent as any).vs_group_from ?? parent.origin ); const locator = new Locator({ @@ -34,8 +32,10 @@ export function process_attachment_points( from: absolute_pos }); - // Set rotation - (locator as any).rotation = [rotX, rotY, rotZ]; + // Set rotation via registered properties (persists in .bbmodel) + (locator as any).rotationX = parseFloat(ap.rotationX) || 0; + (locator as any).rotationY = parseFloat(ap.rotationY) || 0; + (locator as any).rotationZ = parseFloat(ap.rotationZ) || 0; if (asBackdrop) { locator.locked = true; diff --git a/src/import_model/traverse.ts b/src/import_model/traverse.ts index f1fa304..69a684b 100644 --- a/src/import_model/traverse.ts +++ b/src/import_model/traverse.ts @@ -19,7 +19,17 @@ export function traverse(parent: Group | null, object_space_pos: [number,number, if (has_geometry(vsElement) && !has_children(vsElement) && !has_attachments(vsElement)) { // Elements with geometry but no children/attachments become cubes process_cube(parent, object_space_pos, vsElement, asBackdrop); - } else if (!has_geometry(vsElement)) { + } else if (has_geometry(vsElement) && (has_children(vsElement) || has_attachments(vsElement))) { + // Elements with geometry AND children/attachments — should have been expanded by + // expand_complex_elements, but handle defensively: create cube for geometry, + // then process children under a group + console.warn(`[VS Import] Element "${vsElement.name}" has geometry with children/attachments but wasn't expanded. Importing geometry and children separately.`); + process_cube(parent, object_space_pos, vsElement, asBackdrop); + const group = process_group(parent, object_space_pos, vsElement, asBackdrop, filePath); + if (has_children(vsElement)) { + traverse(group, util.vector_add(vsElement.from, object_space_pos), vsElement.children!, asBackdrop, filePath); + } + } else { // Elements without geometry become groups (containers, anchors, placeholders) const group = process_group(parent, object_space_pos, vsElement, asBackdrop, filePath); if (has_children(vsElement)) { diff --git a/src/mods/boneAnimatorMod.ts b/src/mods/boneAnimatorMod.ts index 646c5b7..8d14148 100644 --- a/src/mods/boneAnimatorMod.ts +++ b/src/mods/boneAnimatorMod.ts @@ -1,6 +1,174 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; +import { + getAnimatedConstraintsForBone, + getConstraintCacheEntries, + getIKConstraintData, + applyRotationConstraints, + isIKControllerAnimated +} from "./ik/constraints"; +import { findAllIKControllers, getIKChain } from "./ik/chain_utils"; +import { refreshIKPreview, isIKAnimationContextActive } from "./ik/utils"; +import { getIKWeightAtTime } from "./ik/baking"; +import { BoneConstraint } from "./ik/types"; + +// @ts-expect-error: THREE is global in Blockbench +declare const THREE: typeof import('three'); + +const DEG2RAD = Math.PI / 180; +const ROTATION_EPSILON = 0.0001; + +/** + * Map of patched bone names to their cleanup functions. + * Used to restore original updateMatrixWorld when constraints change. + */ +const patchedBones = new Map void>(); + +/** + * Patches updateMatrixWorld on constrained bone meshes so that + * IK constraints are enforced during the render pass — AFTER + * Blockbench's IK solver has set the bone rotations. + */ +export function syncConstraintEnforcement(): void { + // Clean up old patches + for (const cleanup of patchedBones.values()) cleanup(); + patchedBones.clear(); + + const entries = getConstraintCacheEntries(); + if (!entries) return; + + for (const [boneName] of entries) { + const bone = Group.all.find((g: any) => g.name === boneName); + if (!bone?.mesh) continue; + + const originalFn = bone.mesh.updateMatrixWorld; + + bone.mesh.updateMatrixWorld = function (this: any, force: boolean) { + if (!isIKAnimationContextActive()) { + return originalFn.call(this, force); + } + + const constraint = getAnimatedConstraintsForBone(boneName, getActiveAnimation()); + if (!constraint) { + return originalFn.call(this, force); + } + + // Clamp rotation before matrix is recomputed + const euler = this.rotation; + if (constraint.allowedAxes) { + if (!constraint.allowedAxes.x) euler.x = 0; + if (!constraint.allowedAxes.y) euler.y = 0; + if (!constraint.allowedAxes.z) euler.z = 0; + } + if (constraint.rotationLimits) { + if (constraint.rotationLimits.x) { + euler.x = Math.max(constraint.rotationLimits.x.min * DEG2RAD, + Math.min(constraint.rotationLimits.x.max * DEG2RAD, euler.x)); + } + if (constraint.rotationLimits.y) { + euler.y = Math.max(constraint.rotationLimits.y.min * DEG2RAD, + Math.min(constraint.rotationLimits.y.max * DEG2RAD, euler.y)); + } + if (constraint.rotationLimits.z) { + euler.z = Math.max(constraint.rotationLimits.z.min * DEG2RAD, + Math.min(constraint.rotationLimits.z.max * DEG2RAD, euler.z)); + } + } + originalFn.call(this, force); + }; + + patchedBones.set(boneName, () => { + if (bone.mesh) { + bone.mesh.updateMatrixWorld = originalFn; + } + }); + } +} + +/** + * Removes all constraint enforcement patches. + */ +export function clearConstraintEnforcement(): void { + for (const cleanup of patchedBones.values()) cleanup(); + patchedBones.clear(); +} + +function getActiveAnimation(): any | null { + try { + return Animation?.selected ?? Animator?.selected ?? null; + } catch { + return null; + } +} + +function enforceControllerAnimationConstraints(controller: any, animation: any | null): any[] { + if (!controller?.ik_target) { + return []; + } + + const chain = getIKChain(controller); + if (chain.length === 0) { + return []; + } + + if (!isIKControllerAnimated(controller, animation)) { + return []; + } + + if (animation) { + const weight = getIKWeightAtTime(controller, animation, animation.time); + if (weight <= 0) { + return []; + } + } + + const constraintData = getIKConstraintData(controller); + const changedBones: any[] = []; + + for (const bone of chain) { + const currentRotation: [number, number, number] = [ + bone.rotation?.[0] || 0, + bone.rotation?.[1] || 0, + bone.rotation?.[2] || 0 + ]; + const constrainedRotation = applyRotationConstraints(bone, currentRotation, constraintData); + + const changed = + Math.abs(constrainedRotation[0] - currentRotation[0]) > ROTATION_EPSILON || + Math.abs(constrainedRotation[1] - currentRotation[1]) > ROTATION_EPSILON || + Math.abs(constrainedRotation[2] - currentRotation[2]) > ROTATION_EPSILON; + + if (!changed) { + continue; + } + + bone.rotation[0] = constrainedRotation[0]; + bone.rotation[1] = constrainedRotation[1]; + bone.rotation[2] = constrainedRotation[2]; + changedBones.push(bone); + } + + return changedBones; +} + +function enforceAnimatedIKConstraints(): void { + if (!is_vs_project(Project) || !isIKAnimationContextActive()) { + return; + } + + const animation = getActiveAnimation(); + const changedBones = new Set(); + + for (const { controller } of findAllIKControllers()) { + const changed = enforceControllerAnimationConstraints(controller, animation); + changed.forEach((bone: any) => changedBones.add(bone)); + } + + if (changedBones.size > 0) { + refreshIKPreview(Array.from(changedBones)); + } +} /** * Patches the BoneAnimator to flip rotation and position application when a VS file is loaded @@ -9,7 +177,8 @@ createBlockbenchMod( `${PACKAGE.name}:bone_animator_mod`, { original: Blockbench.BoneAnimator.prototype.displayFrame, - additional_function: Blockbench.BoneAnimator.prototype.flippedDisplayPosition + additional_function: Blockbench.BoneAnimator.prototype.flippedDisplayPosition, + originalPreview: Animator.preview }, context => { // Inject code here @@ -23,6 +192,33 @@ createBlockbenchMod( const rotation = this.interpolate('rotation'); const position = this.interpolate('position'); + // Apply IK constraints to interpolated rotation during playback + // (for bones with keyframes — IK-driven bones are handled by updateMatrixWorld patches) + if (rotation) { + const group = this.group; + if (group) { + const constraint = getAnimatedConstraintsForBone(group.name, getActiveAnimation()); + if (constraint) { + if (constraint.allowedAxes) { + if (!constraint.allowedAxes.x) rotation[0] = 0; + if (!constraint.allowedAxes.y) rotation[1] = 0; + if (!constraint.allowedAxes.z) rotation[2] = 0; + } + if (constraint.rotationLimits) { + if (constraint.rotationLimits.x) { + rotation[0] = Math.max(constraint.rotationLimits.x.min, Math.min(constraint.rotationLimits.x.max, rotation[0])); + } + if (constraint.rotationLimits.y) { + rotation[1] = Math.max(constraint.rotationLimits.y.min, Math.min(constraint.rotationLimits.y.max, rotation[1])); + } + if (constraint.rotationLimits.z) { + rotation[2] = Math.max(constraint.rotationLimits.z.min, Math.min(constraint.rotationLimits.z.max, rotation[2])); + } + } + } + } + } + //@ts-expect-error: Copied from blockbench itself, so it should work :P if (!this.muted.rotation) this.displayRotation(rotation, multiplier); @@ -37,6 +233,15 @@ createBlockbenchMod( return context.original.call(this, multiplier); }; + Animator.preview = function (this: any, in_loop?: boolean) { + const result = context.originalPreview.call(this, in_loop); + + if (is_vs_project(Project) && isIKAnimationContextActive()) { + enforceAnimatedIKConstraints(); + } + + return result; + }; Blockbench.BoneAnimator.prototype.flippedDisplayPosition = function (this: BoneAnimator, position, rotation, multiplier) { @@ -59,9 +264,10 @@ createBlockbenchMod( return context; }, context => { + clearConstraintEnforcement(); + Animator.preview = context.originalPreview; Blockbench.BoneAnimator.prototype.flippedDisplayPosition = context.additional_function; Blockbench.BoneAnimator.prototype.displayFrame = context.original; } ); - diff --git a/src/mods/ik/baking.ts b/src/mods/ik/baking.ts index 4561759..4298efe 100644 --- a/src/mods/ik/baking.ts +++ b/src/mods/ik/baking.ts @@ -2,6 +2,7 @@ import { findAllIKControllers } from "./chain_utils"; import { getIKConstraintData, setIKConstraintData, applyRotationConstraints, getOrientationInfluence } from "./constraints"; import { IKConstraintData } from "./types"; +import { setForcedIKAnimationContext } from "./utils"; // Blockbench global types declare var Blockbench: any; @@ -222,7 +223,10 @@ export function bakeIKAnimations(is_vs_project: (project: any) => boolean) { let totalBakedKeyframes = 0; - animations.forEach((animation: any) => { + setForcedIKAnimationContext(true); + + try { + animations.forEach((animation: any) => { const ikBones = new Set(); ikControllers.forEach(({ chain }) => { @@ -387,10 +391,13 @@ export function bakeIKAnimations(is_vs_project: (project: any) => boolean) { if (wasPlaying) { Animator.play(); } - }); + }); + } finally { + setForcedIKAnimationContext(false); + } Blockbench.showQuickMessage( `Baked ${ikControllers.length} IK controller(s) to ${totalBakedKeyframes} keyframes`, 3000 ); -} \ No newline at end of file +} diff --git a/src/mods/ik/chain_utils.ts b/src/mods/ik/chain_utils.ts index 84f4219..a2271e2 100644 --- a/src/mods/ik/chain_utils.ts +++ b/src/mods/ik/chain_utils.ts @@ -1,7 +1,8 @@ // Blockbench global types declare var Group: any; -declare var Locator: any; +declare var NullObject: any; +declare var OutlinerNode: any; declare var Project: any; /** @@ -14,27 +15,44 @@ export interface IKControllerInfo { } /** - * Gets the IK chain starting from a target bone. - * Walks up the parent hierarchy to include all bones up to the root. - * Only includes actual bones (not null objects/locators) in the chain, - * except for the target itself. - * - * @param target - The target bone or locator - * @returns Array of bones forming the IK chain, ordered from root to target + * Resolves a value that may be a UUID string to the actual outliner node. */ -export function getIKChain(target: any): any[] { +function resolveNode(nodeOrUuid: any): any { + if (typeof nodeOrUuid === 'string') { + return OutlinerNode.uuids[nodeOrUuid] ?? null; + } + return nodeOrUuid; +} + +/** + * Gets the IK chain for a controller. + * Walks up from the ik_target's parent bone to either the ik_source bone + * or the controller's parent bone (whichever is hit first). + * Returns the chain ordered from root to leaf. + * + * @param controller - The NullObject IK controller + * @returns Array of Group bones forming the IK chain, ordered root to leaf + */ +export function getIKChain(controller: any): any[] { const chain: any[] = []; - if (target instanceof Group) { - - let current: any = target; - while (current && current instanceof Group) { - - if (current === target || !current.isNull) { - chain.unshift(current); - } - current = current.parent; - } + const target = resolveNode(controller.ik_target); + if (!target) return chain; + + const source = resolveNode(controller.ik_source); + const controllerParent = controller.parent; + + // Find the starting bone: the target's parent if target is a locator/element, + // or the target itself if it's a Group + let start: any = target instanceof Group ? target : target.parent; + + // Walk up from the start bone to the source or controller's parent + let current: any = start; + while (current && current instanceof Group) { + chain.unshift(current); + if (source && current === source) break; + if (!source && current === controllerParent) break; + current = current.parent; } return chain; @@ -42,19 +60,20 @@ export function getIKChain(target: any): any[] { /** * Finds all IK controllers in the current project. - * IK controllers are null objects (Groups with isNull=true) that have an ikTarget property. - * + * IK controllers are NullObjects that have an ik_target property set. + * * @returns Array of IK controller information including controller, target, and chain */ export function findAllIKControllers(): IKControllerInfo[] { const ikControllers: IKControllerInfo[] = []; - const nulls = Group.all.filter((g: any) => g.isNull); + const nulls = NullObject.all; for (const nullObj of nulls) { - if (nullObj.ikTarget) { - const target = nullObj.ikTarget; - const chain = getIKChain(target); + if (nullObj.ik_target) { + const target = resolveNode(nullObj.ik_target); + if (!target) continue; + const chain = getIKChain(nullObj); ikControllers.push({ controller: nullObj, target, @@ -68,19 +87,19 @@ export function findAllIKControllers(): IKControllerInfo[] { /** * Checks if the current project has any IK controllers. - * + * * @returns True if at least one IK controller exists in the project */ export function checkForIKControllers(): boolean { if (!Project) return false; - const nulls = Group.all.filter((g: any) => g.isNull); + const nulls = NullObject.all; for (const nullObj of nulls) { - if (nullObj.ikTarget) { + if (nullObj.ik_target) { return true; } } return false; -} \ No newline at end of file +} diff --git a/src/mods/ik/constraint_visualizer.ts b/src/mods/ik/constraint_visualizer.ts new file mode 100644 index 0000000..722e4a9 --- /dev/null +++ b/src/mods/ik/constraint_visualizer.ts @@ -0,0 +1,309 @@ + +import { BoneConstraint } from "./types"; + +// @ts-expect-error: THREE is global in Blockbench +declare const THREE: typeof import('three'); +declare var Canvas: any; + +const ARC_SEGMENTS = 32; +const ARC_RADIUS = 3; +const FILL_OPACITY = 0.15; +const OUTLINE_OPACITY = 0.8; +const RENDER_ORDER = 998; +const DISABLED_MARKER_SIZE = 1.5; +const DEG_TO_RAD = Math.PI / 180; + +const AXIS_COLORS = { + x: 0xef3333, + y: 0x7bef37, + z: 0x3b8aef, +}; + +type Axis = 'x' | 'y' | 'z'; +const AXES: Axis[] = ['x', 'y', 'z']; + +interface AxisVisual { + fillMesh: any | null; + outlineLine: any | null; + disabledMarker: any | null; +} + +interface BoneVisual { + bone: any; + container: any; + axes: Record; +} + +function getAxisRotation(axis: Axis): any { + switch (axis) { + case 'x': return new THREE.Euler(0, Math.PI / 2, 0); + case 'y': return new THREE.Euler(Math.PI / 2, 0, 0); + case 'z': return new THREE.Euler(0, 0, 0); + } +} + +function createArcFillGeometry(minDeg: number, maxDeg: number): any { + const minRad = minDeg * DEG_TO_RAD; + const maxRad = maxDeg * DEG_TO_RAD; + const range = maxRad - minRad; + const step = range / ARC_SEGMENTS; + const vertices: number[] = []; + + for (let i = 0; i < ARC_SEGMENTS; i++) { + const a1 = minRad + step * i; + const a2 = minRad + step * (i + 1); + vertices.push(0, 0, 0); + vertices.push(Math.cos(a1) * ARC_RADIUS, Math.sin(a1) * ARC_RADIUS, 0); + vertices.push(Math.cos(a2) * ARC_RADIUS, Math.sin(a2) * ARC_RADIUS, 0); + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); + return geometry; +} + +function createArcOutlineGeometry(minDeg: number, maxDeg: number): any { + const minRad = minDeg * DEG_TO_RAD; + const maxRad = maxDeg * DEG_TO_RAD; + const range = maxRad - minRad; + const step = range / ARC_SEGMENTS; + const points: number[] = []; + + // Radial line to min + points.push(0, 0, 0); + points.push(Math.cos(minRad) * ARC_RADIUS, Math.sin(minRad) * ARC_RADIUS, 0); + + // Arc from min to max + for (let i = 0; i <= ARC_SEGMENTS; i++) { + const a = minRad + step * i; + points.push(Math.cos(a) * ARC_RADIUS, Math.sin(a) * ARC_RADIUS, 0); + } + + // Radial line back to center + points.push(0, 0, 0); + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); + return geometry; +} + +function createCircleOutlineGeometry(): any { + const step = (Math.PI * 2) / ARC_SEGMENTS; + const points: number[] = []; + + for (let i = 0; i <= ARC_SEGMENTS; i++) { + const a = step * i; + points.push(Math.cos(a) * ARC_RADIUS, Math.sin(a) * ARC_RADIUS, 0); + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); + return geometry; +} + +function createDisabledMarkerGeometry(): any { + const s = DISABLED_MARKER_SIZE; + const vertices = [ + -s, -s, 0, s, s, 0, + s, -s, 0, -s, s, 0, + ]; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); + return geometry; +} + +export class ConstraintVisualizer { + private boneVisuals: Map = new Map(); + private fillMaterials: Record = {} as any; + private outlineMaterials: Record = {} as any; + private disabledMaterials: Record = {} as any; + + constructor(private chain: any[], constraints: Record) { + this.createMaterials(); + + for (const bone of chain) { + const constraint = constraints[bone.name] || {}; + this.createBoneVisual(bone, constraint); + } + } + + private createMaterials(): void { + for (const axis of AXES) { + const color = AXIS_COLORS[axis]; + this.fillMaterials[axis] = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: FILL_OPACITY, + depthTest: false, + side: THREE.DoubleSide, + }); + this.outlineMaterials[axis] = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: OUTLINE_OPACITY, + depthTest: false, + }); + this.disabledMaterials[axis] = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: OUTLINE_OPACITY, + depthTest: false, + }); + } + } + + private createBoneVisual(bone: any, constraint: BoneConstraint): void { + const container = new THREE.Group(); + container.renderOrder = RENDER_ORDER; + + const axes: Record = { + x: { fillMesh: null, outlineLine: null, disabledMarker: null }, + y: { fillMesh: null, outlineLine: null, disabledMarker: null }, + z: { fillMesh: null, outlineLine: null, disabledMarker: null }, + }; + + const visual: BoneVisual = { bone, container, axes }; + this.boneVisuals.set(bone.name, visual); + + for (const axis of AXES) { + const allowed = constraint.allowedAxes?.[axis] !== false; + const limits = constraint.rotationLimits?.[axis]; + this.updateAxisVisual(visual, axis, allowed, limits); + } + + if (bone.mesh) { + bone.mesh.add(container); + } else { + Canvas.scene.add(container); + } + } + + private updateAxisVisual( + visual: BoneVisual, + axis: Axis, + allowed: boolean, + limits: { min: number; max: number } | undefined + ): void { + const av = visual.axes[axis]; + const container = visual.container; + + if (!allowed) { + // Hide arcs + if (av.fillMesh) av.fillMesh.visible = false; + if (av.outlineLine) av.outlineLine.visible = false; + + // Show disabled marker + if (!av.disabledMarker) { + const geom = createDisabledMarkerGeometry(); + av.disabledMarker = new THREE.LineSegments(geom, this.disabledMaterials[axis]); + av.disabledMarker.rotation.copy(getAxisRotation(axis)); + av.disabledMarker.renderOrder = RENDER_ORDER; + container.add(av.disabledMarker); + } + av.disabledMarker.visible = true; + return; + } + + // Axis is enabled — hide disabled marker + if (av.disabledMarker) av.disabledMarker.visible = false; + + const minDeg = limits?.min ?? -180; + const maxDeg = limits?.max ?? 180; + const isFullRange = minDeg <= -180 && maxDeg >= 180; + + if (isFullRange) { + // Full range — outline circle only, no fill + if (av.fillMesh) av.fillMesh.visible = false; + + if (av.outlineLine) { + av.outlineLine.geometry.dispose(); + av.outlineLine.geometry = createCircleOutlineGeometry(); + } else { + const geom = createCircleOutlineGeometry(); + av.outlineLine = new THREE.Line(geom, this.outlineMaterials[axis]); + av.outlineLine.rotation.copy(getAxisRotation(axis)); + av.outlineLine.renderOrder = RENDER_ORDER; + container.add(av.outlineLine); + } + av.outlineLine.visible = true; + } else { + // Limited range — fill wedge + outline + const safeMin = Math.min(minDeg, maxDeg); + const safeMax = Math.max(minDeg, maxDeg); + + // Fill + if (av.fillMesh) { + av.fillMesh.geometry.dispose(); + av.fillMesh.geometry = createArcFillGeometry(safeMin, safeMax); + } else { + const geom = createArcFillGeometry(safeMin, safeMax); + av.fillMesh = new THREE.Mesh(geom, this.fillMaterials[axis]); + av.fillMesh.rotation.copy(getAxisRotation(axis)); + av.fillMesh.renderOrder = RENDER_ORDER; + container.add(av.fillMesh); + } + av.fillMesh.visible = true; + + // Outline + if (av.outlineLine) { + av.outlineLine.geometry.dispose(); + av.outlineLine.geometry = createArcOutlineGeometry(safeMin, safeMax); + } else { + const geom = createArcOutlineGeometry(safeMin, safeMax); + av.outlineLine = new THREE.Line(geom, this.outlineMaterials[axis]); + av.outlineLine.rotation.copy(getAxisRotation(axis)); + av.outlineLine.renderOrder = RENDER_ORDER; + container.add(av.outlineLine); + } + av.outlineLine.visible = true; + } + } + + update(constraints: Record): void { + for (const bone of this.chain) { + const visual = this.boneVisuals.get(bone.name); + if (!visual) continue; + + const constraint = constraints[bone.name] || {}; + for (const axis of AXES) { + const allowed = constraint.allowedAxes?.[axis] !== false; + const limits = constraint.rotationLimits?.[axis]; + this.updateAxisVisual(visual, axis, allowed, limits); + } + } + } + + dispose(): void { + for (const [, visual] of this.boneVisuals) { + for (const axis of AXES) { + const av = visual.axes[axis]; + if (av.fillMesh) { + av.fillMesh.geometry.dispose(); + visual.container.remove(av.fillMesh); + } + if (av.outlineLine) { + av.outlineLine.geometry.dispose(); + visual.container.remove(av.outlineLine); + } + if (av.disabledMarker) { + av.disabledMarker.geometry.dispose(); + visual.container.remove(av.disabledMarker); + } + } + + if (visual.bone.mesh) { + visual.bone.mesh.remove(visual.container); + } else { + Canvas.scene.remove(visual.container); + } + } + + for (const axis of AXES) { + this.fillMaterials[axis].dispose(); + this.outlineMaterials[axis].dispose(); + this.disabledMaterials[axis].dispose(); + } + + this.boneVisuals.clear(); + } +} diff --git a/src/mods/ik/constraints.ts b/src/mods/ik/constraints.ts index 699a8d5..27b3e16 100644 --- a/src/mods/ik/constraints.ts +++ b/src/mods/ik/constraints.ts @@ -1,22 +1,36 @@ import * as PACKAGE from "../../../package.json"; import { IKConstraintData, BoneConstraint } from "./types"; +import { ConstraintVisualizer } from "./constraint_visualizer"; +import { findAllIKControllers } from "./chain_utils"; // Blockbench global types declare var Group: any; +declare var NullObject: any; declare var Dialog: any; declare var Blockbench: any; declare var Project: any; +declare var Animation: any; +declare var Animator: any; /** - * Gets IK constraint data for a controller + * Gets IK constraint data for a controller. + * Loads persisted boneConstraints from the vsIKBoneConstraints string property. */ export function getIKConstraintData(controller: any): IKConstraintData { if (!controller.vsIKConstraints) { + let boneConstraints: Record = {}; + try { + if (controller.vsIKBoneConstraints && controller.vsIKBoneConstraints !== '{}') { + boneConstraints = JSON.parse(controller.vsIKBoneConstraints); + } + } catch (e) { + console.error('Error parsing vsIKBoneConstraints:', e); + } controller.vsIKConstraints = { - weight: 1.0, - lockPosition: false, - boneConstraints: {} + weight: controller.vsIKWeight ?? 1.0, + lockPosition: controller.vsIKLockPosition ?? false, + boneConstraints }; } return controller.vsIKConstraints; @@ -27,10 +41,139 @@ export function getIKConstraintData(controller: any): IKConstraintData { */ export function setIKConstraintData(controller: any, data: IKConstraintData): void { controller.vsIKConstraints = { ...getIKConstraintData(controller), ...data }; - - if (Project) { - Project.save(); + constraintCache = null; + + // Persist boneConstraints to the Blockbench Property (saved in bbmodel file) + try { + const bc = controller.vsIKConstraints.boneConstraints; + controller.vsIKBoneConstraints = bc ? JSON.stringify(bc) : '{}'; + } catch (e) { + console.error('Error serializing boneConstraints:', e); + } + + try { + if (Project && Project.save) { + Project.save(); + } + } catch (e) { + console.error('Error saving project after IK constraint update:', e); + } + + ensureCache(); +} + +/** + * Cached constraint lookup for playback performance. + * Invalidated whenever setIKConstraintData is called. + */ +let constraintCache: Map | null = null; + +/** Callback invoked after constraint cache is rebuilt */ +let onCacheRebuilt: (() => void) | null = null; + +/** + * Registers a callback that fires whenever the constraint cache is rebuilt. + * Used by the mesh enforcement system to re-patch bone meshes. + */ +export function setOnConstraintCacheRebuilt(cb: () => void): void { + onCacheRebuilt = cb; +} + +/** + * Returns the full constraint cache (rebuilding if needed). + * Used by the mesh enforcement system. + */ +export function getConstraintCacheEntries(): Map | null { + // Ensure cache is built + ensureCache(); + return constraintCache; +} + +function ensureCache(): void { + if (!constraintCache) { + constraintCache = new Map(); + try { + const controllers = findAllIKControllers(); + for (const { controller } of controllers) { + const data = getIKConstraintData(controller); + if (data.boneConstraints) { + for (const [name, constraint] of Object.entries(data.boneConstraints)) { + constraintCache.set(name, constraint); + } + } + } + } catch (e) { + // During early load, NullObject etc. may not exist yet + } + if (onCacheRebuilt) onCacheRebuilt(); + } +} + +/** + * Gets the bone constraint for a given bone name across all IK controllers. + * Uses a lazy cache rebuilt on constraint changes. + */ +export function getConstraintsForBone(boneName: string): BoneConstraint | null { + ensureCache(); + return constraintCache?.get(boneName) ?? null; +} + +function getSelectedAnimation(): any | null { + try { + return Animation?.selected ?? Animator?.selected ?? null; + } catch { + return null; + } +} + +function getExistingAnimator(animation: any, node: any): any | null { + if (!animation?.animators || !node) { + return null; + } + + return animation.animators[node.uuid] + ?? Object.values(animation.animators).find((animator: any) => + animator?.uuid === node.uuid || animator?.name === node.name + ) + ?? null; +} + +export function isIKControllerAnimated(controller: any, animation: any | null = getSelectedAnimation()): boolean { + const animator = getExistingAnimator(animation, controller); + if (!animator?.keyframes?.length) { + return false; + } + + return animator.keyframes.some((kf: any) => + kf?.channel === 'position' || + kf?.channel === 'rotation' || + kf?.channel === 'scale' + ); +} + +export function getAnimatedConstraintsForBone( + boneName: string, + animation: any | null = getSelectedAnimation() +): BoneConstraint | null { + let activeConstraint: BoneConstraint | null = null; + + try { + const controllers = findAllIKControllers(); + for (const { controller } of controllers) { + if (!isIKControllerAnimated(controller, animation)) { + continue; + } + + const constraint = getIKConstraintData(controller).boneConstraints?.[boneName] ?? null; + if (constraint) { + activeConstraint = constraint; + } + } + } catch { + return null; } + + return activeConstraint; } /** @@ -47,9 +190,10 @@ export function applyRotationConstraints(bone: any, rotation: [number, number, n let [rx, ry, rz] = rotation; if (boneConstraint.allowedAxes) { - if (!boneConstraint.allowedAxes.x) rx = 0; - if (!boneConstraint.allowedAxes.y) ry = 0; - if (!boneConstraint.allowedAxes.z) rz = 0; + // Disabled axis: keep the bone's current rotation (don't move on this axis) + if (!boneConstraint.allowedAxes.x) rx = bone.rotation[0] || 0; + if (!boneConstraint.allowedAxes.y) ry = bone.rotation[1] || 0; + if (!boneConstraint.allowedAxes.z) rz = bone.rotation[2] || 0; } if (boneConstraint.rotationLimits) { @@ -77,7 +221,7 @@ export function getOrientationInfluence(constraintData: IKConstraintData): [numb return null; } - const helper = Group.all.find((g: any) => g.name === constraintData.orientationHelper && g.isNull); + const helper = NullObject.all.find((g: any) => g.name === constraintData.orientationHelper); if (!helper) { return null; } @@ -85,22 +229,101 @@ export function getOrientationInfluence(constraintData: IKConstraintData): [numb return [helper.rotation[0] || 0, helper.rotation[1] || 0, helper.rotation[2] || 0]; } +const JOINT_PRESETS: Record = { + 'Unconstrained': { + allowedAxes: { x: true, y: true, z: true }, + rotationLimits: {} + }, + 'Hinge (X)': { + allowedAxes: { x: true, y: false, z: false }, + rotationLimits: { x: { min: 0, max: 150 } } + }, + 'Hinge (Y)': { + allowedAxes: { x: false, y: true, z: false }, + rotationLimits: { y: { min: 0, max: 150 } } + }, + 'Hinge (Z)': { + allowedAxes: { x: false, y: false, z: true }, + rotationLimits: { z: { min: 0, max: 150 } } + }, + 'Ball & Socket': { + allowedAxes: { x: true, y: true, z: true }, + rotationLimits: { x: { min: -90, max: 90 }, y: { min: -90, max: 90 }, z: { min: -90, max: 90 } } + }, + 'Pivot (X)': { + allowedAxes: { x: true, y: true, z: true }, + rotationLimits: { y: { min: -45, max: 45 }, z: { min: -45, max: 45 } } + }, + 'Pivot (Y)': { + allowedAxes: { x: true, y: true, z: true }, + rotationLimits: { x: { min: -45, max: 45 }, z: { min: -45, max: 45 } } + }, + 'Fixed': { + allowedAxes: { x: false, y: false, z: false }, + rotationLimits: { x: { min: 0, max: 0 }, y: { min: 0, max: 0 }, z: { min: 0, max: 0 } } + } +}; + +function detectPreset(boneConstraint: BoneConstraint): string { + for (const [name, preset] of Object.entries(JOINT_PRESETS)) { + const axMatch = + (boneConstraint.allowedAxes?.x !== false) === (preset.allowedAxes?.x !== false) && + (boneConstraint.allowedAxes?.y !== false) === (preset.allowedAxes?.y !== false) && + (boneConstraint.allowedAxes?.z !== false) === (preset.allowedAxes?.z !== false); + if (!axMatch) continue; + + const limMatch = (['x', 'y', 'z'] as const).every(axis => { + const a = boneConstraint.rotationLimits?.[axis]; + const b = preset.rotationLimits?.[axis]; + if (!a && !b) return true; + if (!a || !b) return false; + return a.min === b.min && a.max === b.max; + }); + if (limMatch) return name; + } + return ''; +} + +function getPresetFormValues(boneName: string, preset: BoneConstraint): Record { + return { + [`${boneName}_axis_x`]: preset.allowedAxes?.x !== false, + [`${boneName}_axis_y`]: preset.allowedAxes?.y !== false, + [`${boneName}_axis_z`]: preset.allowedAxes?.z !== false, + [`${boneName}_rot_x_min`]: preset.rotationLimits?.x?.min ?? -180, + [`${boneName}_rot_x_max`]: preset.rotationLimits?.x?.max ?? 180, + [`${boneName}_rot_y_min`]: preset.rotationLimits?.y?.min ?? -180, + [`${boneName}_rot_y_max`]: preset.rotationLimits?.y?.max ?? 180, + [`${boneName}_rot_z_min`]: preset.rotationLimits?.z?.min ?? -180, + [`${boneName}_rot_z_max`]: preset.rotationLimits?.z?.max ?? 180, + }; +} + /** * Opens a dialog to edit IK constraints for a controller */ -export function openIKConstraintEditor(controller: any, getIKChain: (target: any) => any[]) { - const target = controller.ikTarget; - if (!target) return; +export function openIKConstraintEditor(controller: any, getIKChain: (controller: any) => any[]) { + if (!controller.ik_target) return; - const chain = getIKChain(target); + const chain = getIKChain(controller); const constraintData = getIKConstraintData(controller); - + + const presetOptions: Record = { '': 'Custom' }; + for (const name of Object.keys(JOINT_PRESETS)) { + presetOptions[name] = name; + } + const form: any = {}; chain.forEach((bone, index) => { const boneName = bone.name; const boneConstraint = constraintData.boneConstraints?.[boneName] || {}; - + + form[`${boneName}_preset`] = { + label: `${boneName} - Joint Preset`, + type: 'select', + options: presetOptions, + value: detectPreset(boneConstraint) + }; form[`${boneName}_axis_x`] = { label: `${boneName} - Allow X Rotation`, type: 'checkbox', @@ -116,7 +339,6 @@ export function openIKConstraintEditor(controller: any, getIKChain: (target: any type: 'checkbox', value: boneConstraint.allowedAxes?.z !== false }; - form[`${boneName}_rot_x_min`] = { label: `${boneName} - X Rotation Min (degrees)`, type: 'number', @@ -155,51 +377,149 @@ export function openIKConstraintEditor(controller: any, getIKChain: (target: any }; }); - new Dialog('ik_constraint_editor', { + // Track last preset selection per bone to detect actual preset changes + const lastPreset: Record = {}; + chain.forEach(bone => { + lastPreset[bone.name] = detectPreset(constraintData.boneConstraints?.[bone.name] || {}); + }); + + let visualizer: ConstraintVisualizer | null = null; + + const dialog = new Dialog('ik_constraint_editor', { title: `IK Constraints: ${controller.name}`, form, width: 500, - onConfirm: (formResult: any) => { - const updatedConstraints: Record = {}; + onFormChange(formResult: any) { + const updates: Record = {}; + let presetChanged = false; chain.forEach(bone => { const boneName = bone.name; - const constraint: BoneConstraint = {}; - - constraint.allowedAxes = { - x: formResult[`${boneName}_axis_x`] !== false, - y: formResult[`${boneName}_axis_y`] !== false, - z: formResult[`${boneName}_axis_z`] !== false - }; - - const xMin = formResult[`${boneName}_rot_x_min`]; - const xMax = formResult[`${boneName}_rot_x_max`]; - const yMin = formResult[`${boneName}_rot_y_min`]; - const yMax = formResult[`${boneName}_rot_y_max`]; - const zMin = formResult[`${boneName}_rot_z_min`]; - const zMax = formResult[`${boneName}_rot_z_max`]; - - constraint.rotationLimits = {}; - if (xMin !== -180 || xMax !== 180) { - constraint.rotationLimits.x = { min: xMin, max: xMax }; - } - if (yMin !== -180 || yMax !== 180) { - constraint.rotationLimits.y = { min: yMin, max: yMax }; - } - if (zMin !== -180 || zMax !== 180) { - constraint.rotationLimits.z = { min: zMin, max: zMax }; - } - - if (!constraint.allowedAxes.x || !constraint.allowedAxes.y || !constraint.allowedAxes.z || - constraint.rotationLimits.x || constraint.rotationLimits.y || constraint.rotationLimits.z) { - updatedConstraints[boneName] = constraint; + const presetName = formResult[`${boneName}_preset`]; + + // Preset dropdown changed — apply preset values + if (presetName !== lastPreset[boneName]) { + lastPreset[boneName] = presetName; + presetChanged = true; + const preset = JOINT_PRESETS[presetName]; + if (preset) { + Object.assign(updates, getPresetFormValues(boneName, preset)); + } } }); - constraintData.boneConstraints = updatedConstraints; - setIKConstraintData(controller, constraintData); + // If no preset dropdown changed, check if manual edits + // invalidate the current preset selection + if (!presetChanged) { + chain.forEach(bone => { + const boneName = bone.name; + const currentPreset = formResult[`${boneName}_preset`]; + if (currentPreset) { + // Build a BoneConstraint from current form values to compare + const current: BoneConstraint = { + allowedAxes: { + x: formResult[`${boneName}_axis_x`] !== false, + y: formResult[`${boneName}_axis_y`] !== false, + z: formResult[`${boneName}_axis_z`] !== false, + }, + rotationLimits: { + x: { min: formResult[`${boneName}_rot_x_min`], max: formResult[`${boneName}_rot_x_max`] }, + y: { min: formResult[`${boneName}_rot_y_min`], max: formResult[`${boneName}_rot_y_max`] }, + z: { min: formResult[`${boneName}_rot_z_min`], max: formResult[`${boneName}_rot_z_max`] }, + } + }; + const detected = detectPreset(current); + if (detected !== currentPreset) { + lastPreset[boneName] = ''; + updates[`${boneName}_preset`] = ''; + } + } + }); + } + + if (Object.keys(updates).length > 0) { + dialog.setFormValues(updates); + } + + // Update visualizer with current form state + if (visualizer) { + const currentConstraints: Record = {}; + const merged = { ...formResult, ...updates }; + chain.forEach(bone => { + const boneName = bone.name; + currentConstraints[boneName] = { + allowedAxes: { + x: merged[`${boneName}_axis_x`] !== false, + y: merged[`${boneName}_axis_y`] !== false, + z: merged[`${boneName}_axis_z`] !== false, + }, + rotationLimits: { + x: { min: merged[`${boneName}_rot_x_min`], max: merged[`${boneName}_rot_x_max`] }, + y: { min: merged[`${boneName}_rot_y_min`], max: merged[`${boneName}_rot_y_max`] }, + z: { min: merged[`${boneName}_rot_z_min`], max: merged[`${boneName}_rot_z_max`] }, + } + }; + }); + visualizer.update(currentConstraints); + } + }, + onConfirm: (formResult: any) => { + try { + const updatedConstraints: Record = {}; - Blockbench.showQuickMessage('IK constraints updated', 2000); + chain.forEach(bone => { + const boneName = bone.name; + const constraint: BoneConstraint = {}; + + constraint.allowedAxes = { + x: formResult[`${boneName}_axis_x`] !== false, + y: formResult[`${boneName}_axis_y`] !== false, + z: formResult[`${boneName}_axis_z`] !== false + }; + + const xMin = formResult[`${boneName}_rot_x_min`]; + const xMax = formResult[`${boneName}_rot_x_max`]; + const yMin = formResult[`${boneName}_rot_y_min`]; + const yMax = formResult[`${boneName}_rot_y_max`]; + const zMin = formResult[`${boneName}_rot_z_min`]; + const zMax = formResult[`${boneName}_rot_z_max`]; + + constraint.rotationLimits = {}; + if (xMin !== -180 || xMax !== 180) { + constraint.rotationLimits.x = { min: xMin, max: xMax }; + } + if (yMin !== -180 || yMax !== 180) { + constraint.rotationLimits.y = { min: yMin, max: yMax }; + } + if (zMin !== -180 || zMax !== 180) { + constraint.rotationLimits.z = { min: zMin, max: zMax }; + } + + if (!constraint.allowedAxes.x || !constraint.allowedAxes.y || !constraint.allowedAxes.z || + constraint.rotationLimits.x || constraint.rotationLimits.y || constraint.rotationLimits.z) { + updatedConstraints[boneName] = constraint; + } + }); + + constraintData.boneConstraints = updatedConstraints; + setIKConstraintData(controller, constraintData); + + Blockbench.showQuickMessage('IK constraints updated', 2000); + } catch (e) { + console.error('Error saving IK constraints:', e); + Blockbench.showQuickMessage('Error saving IK constraints', 2000); + } + + visualizer?.dispose(); + visualizer = null; + }, + onCancel: () => { + visualizer?.dispose(); + visualizer = null; } - }).show(); -} \ No newline at end of file + }); + dialog.show(); + + // Create visualizer after dialog is shown so it appears in the viewport + visualizer = new ConstraintVisualizer(chain, constraintData.boneConstraints || {}); +} diff --git a/src/mods/ik/index.ts b/src/mods/ik/index.ts index a0dc96f..5e6acb0 100644 --- a/src/mods/ik/index.ts +++ b/src/mods/ik/index.ts @@ -7,6 +7,9 @@ import { setupIKProperties } from "./properties"; import { setupInteractiveIK, updatePinnedBones } from "./interactive"; import { bakeIKAnimations } from "./baking"; import { checkForIKControllers } from "./chain_utils"; +import { setOnConstraintCacheRebuilt } from "./constraints"; +import { refreshIKPreview } from "./utils"; +import { syncConstraintEnforcement, clearConstraintEnforcement } from "../boneAnimatorMod"; // Blockbench global types declare var Blockbench: any; @@ -33,33 +36,25 @@ createBlockbenchMod( setupInteractiveIK(); + // Re-patch bone meshes whenever constraint cache is rebuilt + setOnConstraintCacheRebuilt(() => { + refreshIKPreview(undefined, true); + syncConstraintEnforcement(); + }); + Blockbench.on('load_project', () => { - setTimeout(updatePinnedBones, 100); + setTimeout(() => { + updatePinnedBones(); + refreshIKPreview(undefined, true); + syncConstraintEnforcement(); + }, 100); }); codecVS.compile = function (options: any) { if (is_vs_project(Project)) { - const hasIK = checkForIKControllers(); if (hasIK) { - - const shouldBake = Blockbench.showMessageBox({ - title: 'IK Animation Detected', - message: - 'Your model uses Inverse Kinematics (IK) controllers.\n\n' + - 'Vintage Story does not support IK natively. IK animations will be ' + - 'automatically converted to keyframe animations during export.\n\n' + - 'This may result in a large number of keyframes. Continue?', - buttons: ['Bake IK to Keyframes', 'Cancel Export'] - }); - - if (shouldBake === 0) { - - bakeIKAnimations(is_vs_project); - } else { - - throw new Error('Export cancelled: IK animations need to be baked to keyframes'); - } + bakeIKAnimations(is_vs_project); } } return context.originalCompile(options); @@ -70,7 +65,8 @@ createBlockbenchMod( context => { codecVS.compile = context.originalCompile; - + clearConstraintEnforcement(); + if (context.editIKConstraintsAction) { context.editIKConstraintsAction.delete(); } @@ -78,4 +74,4 @@ createBlockbenchMod( context.togglePinBoneAction.delete(); } } -); \ No newline at end of file +); diff --git a/src/mods/ik/interactive.ts b/src/mods/ik/interactive.ts index 65a97e6..6ba678b 100644 --- a/src/mods/ik/interactive.ts +++ b/src/mods/ik/interactive.ts @@ -1,6 +1,6 @@ import { vec3Length, vec3Sub, vec3Normalize, vec3Add } from "./math"; -import { getBoneWorldPosition, getBoneEndWorldPosition } from "./utils"; +import { getBoneWorldPosition, getBoneEndWorldPosition, refreshIKPreview } from "./utils"; import { getChainMaxReach, clampTargetToChainReach } from "./solver"; import { getIKConstraintData, setIKConstraintData } from "./constraints"; import { DragState } from "./types"; @@ -10,6 +10,7 @@ import { findAllIKControllers as findAllControllers } from "./chain_utils"; // Blockbench global types declare var Blockbench: any; declare var Group: any; +declare var NullObject: any; declare var Outliner: any; declare var Format: any; declare var Animation: any; @@ -31,7 +32,7 @@ const dragState: DragState = { /** * Toggles the pin state for a bone. * Pinned bones will not move during IK solving. - * + * * @param bone - The bone to toggle pin state for */ export function togglePinBone(bone: any): void { @@ -50,9 +51,9 @@ export function togglePinBone(bone: any): void { */ export function updatePinnedBones(): void { const pinnedBones = Group.all - .filter((g: any) => !g.isNull && (g as any).vsIKPinned) + .filter((g: any) => (g as any).vsIKPinned) .map((g: any) => g.name); - + const ikControllers = findAllControllers(); ikControllers.forEach(({ controller }) => { const constraintData = getIKConstraintData(controller); @@ -64,7 +65,7 @@ export function updatePinnedBones(): void { /** * Sets up interactive IK by hooking into Blockbench bone dragging events. * Implements real-time IK solving when dragging bones in IK chains. - * + * * Features: * - Automatic IK solving during bone transforms * - Respects pinned bones @@ -73,22 +74,22 @@ export function updatePinnedBones(): void { */ export function setupInteractiveIK(): void { let transformCount = 0; - + Blockbench.on('select', () => { - + const selected = Outliner.selected; if (!selected || selected.length !== 1) return; const bone = selected[0]; - if (!(bone instanceof Group) || bone.isNull) return; - + if (!(bone instanceof Group)) return; + const ikControllers = findAllControllers(); for (const { controller, chain } of ikControllers) { const constraintData = getIKConstraintData(controller); if (chain.includes(bone)) { const pinnedBones = new Set(constraintData.pinnedBones || []); - + if (pinnedBones.has(bone.name)) { Blockbench.showQuickMessage( `Cannot drag pinned bone: ${bone.name}`, @@ -96,7 +97,7 @@ export function setupInteractiveIK(): void { ); return; } - + if (!dragState.isActive || dragState.draggedBone !== bone) { dragState.isActive = true; dragState.draggedBone = bone; @@ -106,7 +107,7 @@ export function setupInteractiveIK(): void { dragState.startPosition = getBoneWorldPosition(bone); dragState.originalBoneState.clear(); transformCount = 0; - + chain.forEach((chainBone: any) => { dragState.originalBoneState.set(chainBone.name, { position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], @@ -123,17 +124,17 @@ export function setupInteractiveIK(): void { } } }); - + Blockbench.on('transform_selection', () => { - + const selected = Outliner.selected; if (!selected || selected.length !== 1) return; const bone = selected[0]; - if (!(bone instanceof Group) || bone.isNull) return; - + if (!(bone instanceof Group)) return; + if (!dragState.isActive || dragState.draggedBone !== bone) { - + const ikControllers = findAllControllers(); for (const { controller, chain } of ikControllers) { const constraintData = getIKConstraintData(controller); @@ -176,19 +177,19 @@ export function setupInteractiveIK(): void { const chain = dragState.ikChain; const constraintData = dragState.constraintData; const pinnedBones = new Set(constraintData.pinnedBones || []); - + let targetPosition = getBoneWorldPosition(bone); - + const endEffectorIndex = chain.length - 1; if (chain[endEffectorIndex] === bone) { - + const maxReach = getChainMaxReach(chain); const rootPos = getBoneWorldPosition(chain[0]); const distance = vec3Length(vec3Sub(targetPosition, rootPos)); - + if (distance > maxReach) { const clampedTarget = clampTargetToChainReach(chain, targetPosition, maxReach); - + const boneParent = bone.parent; if (boneParent instanceof Group) { const parentPos = getBoneWorldPosition(boneParent); @@ -203,16 +204,16 @@ export function setupInteractiveIK(): void { targetPosition = clampedTarget; } - + solveIKChain(chain, targetPosition, pinnedBones, constraintData); } else { - + const boneIndex = chain.indexOf(bone); if (boneIndex === -1) return; - + const subChain = chain.slice(boneIndex); const endEffector = subChain[subChain.length - 1]; - + const maxReach = getChainMaxReach(subChain); const rootPos = getBoneWorldPosition(subChain[0]); const distance = vec3Length(vec3Sub(targetPosition, rootPos)); @@ -234,18 +235,19 @@ export function setupInteractiveIK(): void { } const endEffectorTarget = getBoneEndWorldPosition(endEffector); - + solveIKChain(subChain, endEffectorTarget, pinnedBones, constraintData); } - + if (transformCount % VIEWPORT_UPDATE_THROTTLE === 0) { - Blockbench.updateViewport(); + refreshIKPreview(chain); } }); - + Blockbench.on('finish_edit', () => { if (dragState.isActive && dragState.ikChain && dragState.draggedBone) { - + const finalChain = dragState.ikChain; + if (Format.animation_mode) { const currentAnimation = Animation.selected; if (currentAnimation) { @@ -254,13 +256,13 @@ export function setupInteractiveIK(): void { dragState.ikChain.forEach((bone: any) => { const animator = currentAnimation.getBoneAnimator(bone); if (!animator) return; - + const existingKf = animator.keyframes.find((kf: any) => kf.channel === 'rotation' && Math.abs(kf.time - currentTime) < KEYFRAME_TIME_TOLERANCE ); if (!existingKf) { - + animator.addKeyframe({ interpolation: 'linear', time: currentTime, @@ -272,7 +274,7 @@ export function setupInteractiveIK(): void { }] }); } else { - + if (existingKf.data_points && existingKf.data_points[0]) { existingKf.data_points[0].x = bone.rotation[0] || 0; existingKf.data_points[0].y = bone.rotation[1] || 0; @@ -282,7 +284,9 @@ export function setupInteractiveIK(): void { }); } } - + + refreshIKPreview(finalChain); + dragState.isActive = false; dragState.draggedBone = null; dragState.ikChain = null; @@ -293,10 +297,10 @@ export function setupInteractiveIK(): void { transformCount = 0; } }); - + Blockbench.on('update_selection', () => { - + if (!dragState.isActive) { } }); -} \ No newline at end of file +} diff --git a/src/mods/ik/properties.ts b/src/mods/ik/properties.ts index 0b03e47..87002c5 100644 --- a/src/mods/ik/properties.ts +++ b/src/mods/ik/properties.ts @@ -8,16 +8,20 @@ import { updatePinnedBones, togglePinBone } from "./interactive"; declare var Property: any; declare var Action: any; declare var Group: any; +declare var NullObject: any; declare var Outliner: any; -declare var Locator: any; declare var Format: any; declare var Animation: any; declare var Blockbench: any; declare var MenuBar: any; +function isNullObject(obj: any): boolean { + return obj instanceof NullObject; +} + export function setupIKProperties(context: any): any { - const weightProp = new Property(Group, "number", "vsIKWeight", { + const weightProp = new Property(NullObject, "number", "vsIKWeight", { default: 1.0, label: "IK Weight", exposed: true, @@ -25,11 +29,7 @@ export function setupIKProperties(context: any): any { try { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; + return isNullObject(selected[0]); } catch (e) { console.error('IK Weight condition error:', e); return false; @@ -48,17 +48,17 @@ export function setupIKProperties(context: any): any { }, onChange() { const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - + if (isNullObject(obj)) { + if (obj.vsIKWeight === undefined) { const constraintData = getIKConstraintData(obj); obj.vsIKWeight = constraintData.weight ?? 1.0; } - + const constraintData = getIKConstraintData(obj); constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); setIKConstraintData(obj, constraintData); - + if (Format.animation_mode) { const currentAnimation = Animation.selected; @@ -67,18 +67,18 @@ export function setupIKProperties(context: any): any { if (animator) { const currentTime = currentAnimation.time; const weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - + const existingKf = animator.keyframes.find((kf: any) => kf.channel === 'ik_weight' && Math.abs(kf.time - currentTime) < 0.01 ); if (existingKf) { - + if (existingKf.data_points && existingKf.data_points[0]) { existingKf.data_points[0].x = weight; } } else { - + animator.addKeyframe({ interpolation: 'linear', time: currentTime, @@ -94,7 +94,7 @@ export function setupIKProperties(context: any): any { keyframeable: true }); - const lockProp = new Property(Group, "boolean", "vsIKLockPosition", { + const lockProp = new Property(NullObject, "boolean", "vsIKLockPosition", { default: false, label: "Lock IK Position", exposed: true, @@ -102,11 +102,7 @@ export function setupIKProperties(context: any): any { try { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; + return isNullObject(selected[0]); } catch (e) { console.error('IK Lock Position condition error:', e); return false; @@ -122,21 +118,21 @@ export function setupIKProperties(context: any): any { }, onChange() { const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - + if (isNullObject(obj)) { + if (obj.vsIKLockPosition === undefined) { const constraintData = getIKConstraintData(obj); obj.vsIKLockPosition = constraintData.lockPosition ?? false; } - + const constraintData = getIKConstraintData(obj); constraintData.lockPosition = obj.vsIKLockPosition ?? false; if (constraintData.lockPosition) { - - constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; + + constraintData.lockedPosition = [obj.position[0], obj.position[1], obj.position[2]]; } setIKConstraintData(obj, constraintData); - + if (Format.animation_mode) { const currentAnimation = Animation.selected; @@ -145,18 +141,18 @@ export function setupIKProperties(context: any): any { if (animator) { const currentTime = currentAnimation.time; const lockValue = obj.vsIKLockPosition ? 1 : 0; - + const existingKf = animator.keyframes.find((kf: any) => kf.channel === 'ik_lock' && Math.abs(kf.time - currentTime) < 0.01 ); if (existingKf) { - + if (existingKf.data_points && existingKf.data_points[0]) { existingKf.data_points[0].x = lockValue; } } else { - + animator.addKeyframe({ interpolation: 'linear', time: currentTime, @@ -172,7 +168,7 @@ export function setupIKProperties(context: any): any { keyframeable: true }); - const helperProp = new Property(Group, "string", "vsIKOrientationHelper", { + const helperProp = new Property(NullObject, "string", "vsIKOrientationHelper", { default: '', label: "IK Orientation Helper", exposed: true, @@ -180,18 +176,14 @@ export function setupIKProperties(context: any): any { try { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; + return isNullObject(selected[0]); } catch (e) { console.error('IK Orientation Helper condition error:', e); return false; } }, options: () => { - const nulls = Group.all.filter((g: any) => g.isNull); + const nulls = NullObject.all; const options: { [key: string]: string } = { '': 'None' }; nulls.forEach((nullObj: any) => { options[nullObj.name] = nullObj.name; @@ -201,10 +193,10 @@ export function setupIKProperties(context: any): any { inputs: { element_panel: { input: { - label: 'Orientation Helper (Void Object)', + label: 'Orientation Helper (Null Object)', type: 'select', options: () => { - const nulls = Group.all.filter((g: any) => g.isNull); + const nulls = NullObject.all; const options: { [key: string]: string } = { '': 'None' }; nulls.forEach((nullObj: any) => { options[nullObj.name] = nullObj.name; @@ -216,13 +208,13 @@ export function setupIKProperties(context: any): any { }, onChange() { const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { - + if (isNullObject(obj)) { + if (obj.vsIKOrientationHelper === undefined) { const constraintData = getIKConstraintData(obj); obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; } - + const constraintData = getIKConstraintData(obj); constraintData.orientationHelper = obj.vsIKOrientationHelper || undefined; setIKConstraintData(obj, constraintData); @@ -230,74 +222,6 @@ export function setupIKProperties(context: any): any { } }); - try { - new Property(Locator, "number", "vsIKWeight", { - default: 1.0, - label: "IK Weight", - exposed: true, - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return selected[0] instanceof Locator; - }, - inputs: { - element_panel: { - input: { - label: 'IK Weight (0-1)', - type: 'number', - min: 0, - max: 1, - step: 0.01 - } - } - }, - onChange() { - const obj = this as any; - if (obj instanceof Locator) { - const constraintData = getIKConstraintData(obj as any); - constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - setIKConstraintData(obj as any, constraintData); - } - } - }); - } catch (e) { - - } - - try { - new Property(Locator, "boolean", "vsIKLockPosition", { - default: false, - label: "Lock IK Position", - exposed: true, - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return selected[0] instanceof Locator; - }, - inputs: { - element_panel: { - input: { - label: 'Lock Controller Position', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (obj instanceof Locator) { - const constraintData = getIKConstraintData(obj as any); - constraintData.lockPosition = obj.vsIKLockPosition ?? false; - if (constraintData.lockPosition) { - constraintData.lockedPosition = [obj.origin[0], obj.origin[1], obj.origin[2]]; - } - setIKConstraintData(obj as any, constraintData); - } - } - }); - } catch (e) { - - } - const editIKConstraintsAction = new Action(`${PACKAGE.name}:edit_ik_constraints`, { name: 'Edit IK Constraints...', icon: 'settings', @@ -305,11 +229,7 @@ export function setupIKProperties(context: any): any { try { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; + return isNullObject(selected[0]); } catch (e) { console.error('Edit IK Constraints condition error:', e); return false; @@ -318,16 +238,16 @@ export function setupIKProperties(context: any): any { click: () => { const selected = Outliner.selected; if (!selected || selected.length !== 1) return; - const controller = selected[0] as unknown as any; + const controller = selected[0] as any; - if ((!controller.isNull && !(controller instanceof Locator)) || !controller.ikTarget) return; + if (!isNullObject(controller) || !controller.ik_target) return; openIKConstraintEditor(controller, getIKChain); } }); - + MenuBar.addAction(editIKConstraintsAction, 'edit'); - + const togglePinBoneAction = new Action(`${PACKAGE.name}:toggle_pin_bone`, { name: 'Toggle Pin Bone', icon: 'push_pin', @@ -335,13 +255,12 @@ export function setupIKProperties(context: any): any { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; const obj = selected[0]; - return obj instanceof Group && !obj.isNull; + return obj instanceof Group; }, click: () => { const selected = Outliner.selected; if (!selected || selected.length !== 1) return; - const bone = selected[0] as unknown as any; - if (bone.isNull) return; + const bone = selected[0] as any; togglePinBone(bone); } @@ -349,7 +268,13 @@ export function setupIKProperties(context: any): any { MenuBar.addAction(togglePinBoneAction, 'edit'); - new Property(Group, "boolean", "vsIKInteractiveMode", { + new Property(NullObject, "string", "vsIKBoneConstraints", { + default: '{}', + label: "IK Bone Constraints", + exposed: false + }); + + new Property(NullObject, "boolean", "vsIKInteractiveMode", { default: false, label: "Interactive IK Mode", exposed: true, @@ -357,11 +282,7 @@ export function setupIKProperties(context: any): any { try { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - - const isNull = obj instanceof Group && (obj as any).isNull === true; - const isLocator = obj instanceof Locator; - return isNull || isLocator; + return isNullObject(selected[0]); } catch (e) { console.error('IK Interactive Mode condition error:', e); return false; @@ -377,7 +298,7 @@ export function setupIKProperties(context: any): any { }, onChange() { const obj = this as any; - if (obj && (obj.isNull || obj instanceof Locator)) { + if (isNullObject(obj)) { const constraintData = getIKConstraintData(obj); constraintData.interactiveMode = obj.vsIKInteractiveMode ?? false; setIKConstraintData(obj, constraintData); @@ -400,7 +321,7 @@ export function setupIKProperties(context: any): any { const selected = Outliner.selected; if (!selected || selected.length !== 1) return false; const obj = selected[0]; - return obj instanceof Group && !obj.isNull; + return obj instanceof Group; }, inputs: { element_panel: { @@ -412,20 +333,20 @@ export function setupIKProperties(context: any): any { }, onChange() { const obj = this as any; - if (obj && obj instanceof Group && !obj.isNull) { + if (obj && obj instanceof Group) { updatePinnedBones(); } } }); - + const syncIKProperties = () => { const selected = Outliner.selected; if (!selected || selected.length !== 1) return; const obj = selected[0]; - if ((obj instanceof Group || obj instanceof Locator) && (obj.isNull || obj instanceof Locator) && obj.ikTarget) { + if (isNullObject(obj) && obj.ik_target) { const constraintData = getIKConstraintData(obj); - + if (obj.vsIKWeight === undefined) { obj.vsIKWeight = constraintData.weight ?? 1.0; } @@ -442,4 +363,4 @@ export function setupIKProperties(context: any): any { Blockbench.on('update_selection', syncIKProperties); return { editIKConstraintsAction, togglePinBoneAction }; -} \ No newline at end of file +} diff --git a/src/mods/ik/solver.ts b/src/mods/ik/solver.ts index cfc49a8..b624aaf 100644 --- a/src/mods/ik/solver.ts +++ b/src/mods/ik/solver.ts @@ -145,7 +145,6 @@ export function solveIKChain( bone.rotation[1] = newRot[1]; bone.rotation[2] = newRot[2]; - Blockbench.updateViewport(); } } @@ -153,4 +152,4 @@ export function solveIKChain( const finalDistance = vec3Length(vec3Sub(targetPosition, finalEndPos)); return finalDistance < tolerance * 2; -} \ No newline at end of file +} diff --git a/src/mods/ik/utils.ts b/src/mods/ik/utils.ts index 62956fa..c57ed06 100644 --- a/src/mods/ik/utils.ts +++ b/src/mods/ik/utils.ts @@ -4,6 +4,14 @@ import { findAllIKControllers } from "./chain_utils"; // Blockbench global types declare var Group: any; +declare var Canvas: any; +declare var Blockbench: any; +declare var Format: any; +declare var Timeline: any; +declare var Animation: any; +declare var Animator: any; + +let forcedIKAnimationContext = false; /** * Gets the world position of a bone (accounting for parent transforms) @@ -55,4 +63,44 @@ export function getBoneLength(bone: any): number { const start = getBoneWorldPosition(bone); const end = getBoneEndWorldPosition(bone); return vec3Length(vec3Sub(end, start)); -} \ No newline at end of file +} + +export function setForcedIKAnimationContext(active: boolean): void { + forcedIKAnimationContext = active; +} + +export function isIKAnimationContextActive(): boolean { + if (forcedIKAnimationContext) { + return true; + } + + if (!Format?.animation_mode) { + return false; + } + + return !!( + Timeline?.playing || + Animation?.selected?.playing || + Animator?.selected?.playing + ); +} + +/** + * Refreshes Blockbench's preview state after IK updates. + * `Blockbench.updateViewport()` only re-renders the scene; it does not + * force the canvas controllers to pull updated bone transforms. + */ +export function refreshIKPreview(bones?: any[] | null, fullRefresh = false): void { + const targetBones = bones && bones.length > 0 ? bones : undefined; + + try { + Canvas.updateAllBones?.(targetBones); + + if (fullRefresh) { + Canvas.updateAllPositions?.(); + Canvas.updateAll?.(); + } + } finally { + Blockbench.updateViewport?.(); + } +} diff --git a/src/mods/nodePreviewControllerMod.ts b/src/mods/nodePreviewControllerMod.ts index 789b856..7bf0191 100644 --- a/src/mods/nodePreviewControllerMod.ts +++ b/src/mods/nodePreviewControllerMod.ts @@ -1,6 +1,8 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; +import { getAnimatedConstraintsForBone } from "./ik/constraints"; +import { isIKAnimationContextActive } from "./ik/utils"; createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, @@ -14,6 +16,9 @@ createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, return updateStepChildTransform(this, node); } + const result = inject_context.original.call(this, node); + applyConstrainedGroupRotation(node); + return result; } return inject_context.original.call(this, node); }; @@ -27,7 +32,7 @@ createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, /** * Does the same as the original method but skips parenting root groups to the internal root mesh (commented section) */ -function updateStepChildTransform(controller: NodePreviewController, element: OutlinerNode) { +function updateStepChildTransform(controller: NodePreviewController, element: any) { //@ts-expect-error: missing types const mesh = element.mesh; @@ -39,12 +44,13 @@ function updateStepChildTransform(controller: NodePreviewController, element: Ou //@ts-expect-error: missing types if (element.getTypeBehavior('rotatable')) { + const rotation = getPreviewRotation(element); //@ts-expect-error: missing types - mesh.rotation.x = Math.degToRad(element.rotation[0]); + mesh.rotation.x = Math.degToRad(rotation[0]); //@ts-expect-error: missing types - mesh.rotation.y = Math.degToRad(element.rotation[1]); + mesh.rotation.y = Math.degToRad(rotation[1]); //@ts-expect-error: missing types - mesh.rotation.z = Math.degToRad(element.rotation[2]); + mesh.rotation.z = Math.degToRad(rotation[2]); } //@ts-expect-error: missing types @@ -89,3 +95,62 @@ function updateStepChildTransform(controller: NodePreviewController, element: Ou controller.dispatchEvent('update_transform', { element }); } +function getPreviewRotation(element: any): [number, number, number] { + const rotation: [number, number, number] = [ + element.rotation?.[0] || 0, + element.rotation?.[1] || 0, + element.rotation?.[2] || 0 + ]; + + if (!isIKAnimationContextActive()) { + return rotation; + } + + if (!(element instanceof Group)) { + return rotation; + } + + const constraint = getAnimatedConstraintsForBone(element.name); + if (!constraint) { + return rotation; + } + + if (constraint.allowedAxes) { + if (!constraint.allowedAxes.x) rotation[0] = 0; + if (!constraint.allowedAxes.y) rotation[1] = 0; + if (!constraint.allowedAxes.z) rotation[2] = 0; + } + + if (constraint.rotationLimits) { + if (constraint.rotationLimits.x) { + rotation[0] = Math.max(constraint.rotationLimits.x.min, Math.min(constraint.rotationLimits.x.max, rotation[0])); + } + if (constraint.rotationLimits.y) { + rotation[1] = Math.max(constraint.rotationLimits.y.min, Math.min(constraint.rotationLimits.y.max, rotation[1])); + } + if (constraint.rotationLimits.z) { + rotation[2] = Math.max(constraint.rotationLimits.z.min, Math.min(constraint.rotationLimits.z.max, rotation[2])); + } + } + + return rotation; +} + +function applyConstrainedGroupRotation(node: any): void { + if (!(node instanceof Group)) { + return; + } + + //@ts-expect-error: missing types + const mesh = node.mesh; + if (!mesh?.rotation) { + return; + } + + const rotation = getPreviewRotation(node); + mesh.rotation.x = Math.degToRad(rotation[0]); + mesh.rotation.y = Math.degToRad(rotation[1]); + mesh.rotation.z = Math.degToRad(rotation[2]); + mesh.updateMatrixWorld?.(); +} + diff --git a/src/panels/vs_face_panel.ts b/src/panels/vs_face_panel.ts new file mode 100644 index 0000000..10ba971 --- /dev/null +++ b/src/panels/vs_face_panel.ts @@ -0,0 +1,440 @@ +import { createBlockbenchMod } from "../util/moddingTools"; +import * as PACKAGE from "../../package.json"; + +const WIND_MODE_OPTIONS: Record = { + '': 'Default', + '0': 'NoWind', + '1': 'WeakWind', + '2': 'NormalWind', + '3': 'Leaves', + '4': 'Bend', + '5': 'TallBend', + '6': 'Water', + '7': 'ExtraWeakWind', + '8': 'Fruit', + '9': 'WeakWindNoBend', + '10': 'Vines', + '11': 'Seaweed', + '12': 'WaterWaves', + '13': 'WeakWindReducedAlpha', +}; + +const REFLECTIVE_MODE_OPTIONS: Record = { + '0': 'None', + '1': 'Weak', + '2': 'Medium', + '3': 'Strong', + '4': 'Sparkly', + '5': 'Mild', +}; + +const FACE_DIRECTIONS = ['north', 'east', 'south', 'west', 'up', 'down'] as const; + +/** + * Returns the 4 vertex positions for a given face direction on a cube. + * Order matches VS Model Creator's CubeVertices array (Face.java). + * Maps unit cube (-1,-1,-1)→(1,1,1) to Blockbench cube from→to. + */ +function getFaceVertices(cube: Cube, direction: string): [number, number, number][] { + const [x1, y1, z1] = cube.from; + const [x2, y2, z2] = cube.to; + + switch (direction) { + case 'north': return [[x1,y1,z1], [x1,y2,z1], [x2,y2,z1], [x2,y1,z1]]; + case 'east': return [[x2,y1,z1], [x2,y2,z1], [x2,y2,z2], [x2,y1,z2]]; + case 'south': return [[x1,y1,z2], [x2,y1,z2], [x2,y2,z2], [x1,y2,z2]]; + case 'west': return [[x1,y1,z1], [x1,y1,z2], [x1,y2,z2], [x1,y2,z1]]; + case 'up': return [[x1,y2,z1], [x1,y2,z2], [x2,y2,z2], [x2,y2,z1]]; + case 'down': return [[x1,y1,z1], [x2,y1,z1], [x2,y1,z2], [x1,y1,z2]]; + default: return [[0,0,0], [0,0,0], [0,0,0], [0,0,0]]; + } +} + +// Vertex highlight dot — shared THREE.js objects +let vertexDot: any = null; + +function createVertexDot() { + if (vertexDot) return; + // @ts-expect-error: THREE is global in Blockbench + const geo = new THREE.SphereGeometry(0.5, 8, 8); + // @ts-expect-error: THREE is global in Blockbench + const mat = new THREE.MeshBasicMaterial({ color: 0xff4444, depthTest: false, transparent: true, opacity: 0.9 }); + // @ts-expect-error: THREE is global in Blockbench + vertexDot = new THREE.Mesh(geo, mat); + vertexDot.renderOrder = 999; + vertexDot.visible = false; +} + +function showVertexDot(cube: Cube, direction: string, vertexIndex: number) { + createVertexDot(); + const vertices = getFaceVertices(cube, direction); + const pos = vertices[vertexIndex]; + if (!pos) return; + + // Convert absolute position to mesh-local space (relative to cube's origin/pivot), + // then transform to world space via the mesh's matrix (handles rotation + parent groups) + // @ts-expect-error: THREE is global in Blockbench + const localPos = new THREE.Vector3( + pos[0] - cube.origin[0], + pos[1] - cube.origin[1], + pos[2] - cube.origin[2] + ); + + // @ts-expect-error: mesh access + if (cube.mesh) { + // @ts-expect-error: mesh access + cube.mesh.updateMatrixWorld(); + // @ts-expect-error: mesh access + const worldPos = cube.mesh.localToWorld(localPos); + vertexDot.position.copy(worldPos); + } else { + vertexDot.position.set(pos[0], pos[1], pos[2]); + } + + vertexDot.visible = true; + + // @ts-expect-error: scene access + if (!vertexDot.parent) { + Canvas.scene.add(vertexDot); + } +} + +function hideVertexDot() { + if (vertexDot) { + vertexDot.visible = false; + } +} + +function removeVertexDot() { + if (vertexDot) { + if (vertexDot.parent) vertexDot.parent.remove(vertexDot); + vertexDot.geometry.dispose(); + vertexDot.material.dispose(); + vertexDot = null; + } +} + +const vueComponent = { + template: ` +
+

Select a face to edit VS properties

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ `, + data() { + return { + selectedCube: null as Cube | null, + selectedFaceName: '' as string, + applyToAll: false, + glow: 0, + reflectiveMode: '0', + windMode: [null, null, null, null] as (number | null)[], + windData: [0, 0, 0, 0] as number[], + windModeOptions: WIND_MODE_OPTIONS, + reflectiveModeOptions: REFLECTIVE_MODE_OPTIONS, + _listeners: [] as Array<() => void>, + }; + }, + computed: { + hasFace(): boolean { + return !!(this as any).selectedCube && !!(this as any).selectedFaceName; + } + }, + methods: { + getSelectedFace(): any | null { + const self = this as any; + if (!self.selectedCube || !self.selectedFaceName) return null; + return self.selectedCube.faces[self.selectedFaceName]; + }, + + getTargetFaces(): Array<{ face: any, direction: string }> { + const self = this as any; + if (!self.selectedCube) return []; + if (self.applyToAll) { + return FACE_DIRECTIONS + .filter(d => self.selectedCube.faces[d]) + .map(d => ({ face: self.selectedCube.faces[d], direction: d })); + } + const face = self.getSelectedFace(); + if (!face) return []; + return [{ face, direction: self.selectedFaceName }]; + }, + + highlightVertex(index: number) { + const self = this as any; + if (self.selectedCube && self.selectedFaceName) { + showVertexDot(self.selectedCube, self.selectedFaceName, index); + } + }, + + clearVertex() { + hideVertexDot(); + }, + + setGlow(value: string) { + const num = Math.max(0, Math.min(255, parseInt(value) || 0)); + (this as any).glow = num; + for (const { face } of (this as any).getTargetFaces()) { + face.glow = num; + } + }, + + setReflectiveMode(value: string) { + const num = parseInt(value) || 0; + (this as any).reflectiveMode = String(num); + for (const { face } of (this as any).getTargetFaces()) { + face.reflectiveMode = num; + } + }, + + getWindModeComponent(index: number): string { + const val = (this as any).windMode[index]; + return val === null || val === undefined ? '' : String(val); + }, + + setWindModeComponent(index: number, value: string) { + const self = this as any; + const num = value === '' ? null : parseInt(value); + self.windMode[index] = num; + // Force reactivity + self.windMode = [...self.windMode]; + + for (const { face } of self.getTargetFaces()) { + if (!face.windMode) { + face.windMode = [0, 0, 0, 0]; + } + face.windMode[index] = num ?? 0; + // If all components are 0/null, clear windMode + if (face.windMode.every((v: number) => v === 0)) { + face.windMode = undefined; + } + } + }, + + getWindDataComponent(index: number): number { + return (this as any).windData[index] || 0; + }, + + setWindDataComponent(index: number, value: string) { + const self = this as any; + const num = parseInt(value) || 0; + self.windData[index] = num; + // Force reactivity + self.windData = [...self.windData]; + + for (const { face } of self.getTargetFaces()) { + if (!face.windData) { + face.windData = [0, 0, 0, 0]; + } + face.windData[index] = num; + // If all components are 0, clear windData + if (face.windData.every((v: number) => v === 0)) { + face.windData = undefined; + } + } + }, + + loadFromFace() { + const self = this as any; + const face = self.getSelectedFace(); + if (!face) { + self.glow = 0; + self.reflectiveMode = '0'; + self.windMode = [null, null, null, null]; + self.windData = [0, 0, 0, 0]; + return; + } + self.glow = face.glow || 0; + self.reflectiveMode = String(face.reflectiveMode || 0); + self.windMode = face.windMode ? [...face.windMode] : [null, null, null, null]; + self.windData = face.windData ? [...face.windData] : [0, 0, 0, 0]; + }, + + getSelectedFaceName(): string { + // @ts-expect-error: UVEditor types incomplete + const selectedFaces = UVEditor?.vue?.selected_faces; + if (selectedFaces && selectedFaces.length > 0) { + return selectedFaces[0]; + } + return 'north'; + }, + + updateSelection() { + const self = this as any; + hideVertexDot(); + + // Find selected cube + const selected = Cube.selected; + if (!selected || selected.length === 0) { + self.selectedCube = null; + self.selectedFaceName = ''; + return; + } + self.selectedCube = selected[0]; + self.selectedFaceName = self.getSelectedFaceName(); + self.loadFromFace(); + }, + + // Polls for face selection changes (UV editor doesn't fire events for face clicks) + pollFaceSelection() { + const self = this as any; + if (!self.selectedCube) return; + const currentFace = self.getSelectedFaceName(); + if (currentFace !== self.selectedFaceName) { + self.selectedFaceName = currentFace; + self.loadFromFace(); + } + } + }, + mounted() { + const self = this as any; + self.updateSelection(); + + // Listen for element selection changes + const onSelectionUpdate = () => self.updateSelection(); + Blockbench.on('update_selection' as EventName, onSelectionUpdate); + Blockbench.on('update_faces' as EventName, onSelectionUpdate); + + // Poll for face selection changes (UV editor face clicks don't fire events) + const pollInterval = setInterval(() => self.pollFaceSelection(), 100); + + self._listeners = [ + () => Blockbench.removeListener('update_selection' as EventName, onSelectionUpdate), + () => Blockbench.removeListener('update_faces' as EventName, onSelectionUpdate), + () => clearInterval(pollInterval), + ]; + }, + beforeDestroy() { + const self = this as any; + hideVertexDot(); + for (const unsub of self._listeners) { + unsub(); + } + } +}; + +let panel: Panel | null = null; + +createBlockbenchMod( + `${PACKAGE.name}:vs_face_panel`, + {}, + () => { + panel = new Panel('vs_face_properties', { + name: 'VS Face Properties', + icon: 'grain', + condition: () => Format?.id === 'formatVS', + default_position: { + slot: 'right_bar', + float_position: [0, 0], + float_size: [300, 300], + height: 300 + }, + component: vueComponent + }); + + // Add CSS styles + const style = document.createElement('style'); + style.id = 'vs-face-panel-styles'; + style.textContent = ` + .vs_face_props_header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + border-bottom: 1px solid var(--color-border); + margin-bottom: 6px; + } + .vs_face_props_face_label { + font-weight: bold; + text-transform: capitalize; + } + .vs_face_props_apply_all { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + cursor: pointer; + } + .vs_face_props_section { + padding: 2px 8px; + margin-bottom: 4px; + } + .vs_face_props_label { + display: block; + font-size: 12px; + color: var(--color-subtle_text); + margin-bottom: 2px; + } + .vs_face_props_section input[type="number"], + .vs_face_props_section select { + width: 100%; + box-sizing: border-box; + } + .vs_face_props_vector4 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + } + .vs_face_props_v4_input { + width: 100% !important; + box-sizing: border-box; + } + .panel_message { + padding: 8px; + color: var(--color-subtle_text); + text-align: center; + } + `; + document.head.appendChild(style); + + return { panel, style }; + }, + (context) => { + context.panel?.delete(); + removeVertexDot(); + const style = document.getElementById('vs-face-panel-styles'); + if (style) style.remove(); + } +); diff --git a/src/property.ts b/src/property.ts index 1cb6331..b5c0501 100644 --- a/src/property.ts +++ b/src/property.ts @@ -14,6 +14,14 @@ export const VS_GROUP_PROPS = [ default: '', label: "Step Parent", exposed: true, + inputs: { + element_panel: { + input: { + label: 'Step Parent', + type: 'text' + } + } + }, onChange() { Canvas.updateAllBones(); Canvas.updateAllPositions(); @@ -70,6 +78,14 @@ export const VS_CUBE_PROPS = [ default: '', label: "Step Parent", exposed: true, + inputs: { + element_panel: { + input: { + label: 'Step Parent', + type: 'text' + } + } + }, onChange() { Canvas.updateAllBones(); Canvas.updateAllPositions(); @@ -118,12 +134,31 @@ export const VS_CUBE_PROPS = [ default: -1, label: "Render Pass", exposed: true, + options: { + '-1': 'Default', + '0': 'Opaque', + '1': 'OpaqueNoCull', + '2': 'BlendNoCull', + '3': 'Transparent', + '4': 'Liquid', + '5': 'TopSoil', + '6': 'Meta', + }, inputs: { element_panel: { input: { label: 'Render Pass', - type: 'number', - step: 1 + type: 'select', + options: { + '-1': 'Default', + '0': 'Opaque', + '1': 'OpaqueNoCull', + '2': 'BlendNoCull', + '3': 'Transparent', + '4': 'Liquid', + '5': 'TopSoil', + '6': 'Meta', + } } } }, @@ -223,13 +258,23 @@ export const VS_TEXTURE_PROPS = [ }), ]; +export const VS_LOCATOR_PROPS = [ + new Property(Locator, "number", "rotationX", { default: 0 }), + new Property(Locator, "number", "rotationY", { default: 0 }), + new Property(Locator, "number", "rotationZ", { default: 0 }), +]; + export const VS_FACE_PROPS = [ new Property(Face, "number", "glow"), new Property(Face, "number", "reflectiveMode"), - // @ts-expect-error: vector4 is missing in blockbench types here for some reason - new Property(Face, "vector4", "windMode"), - // @ts-expect-error: vector4 is missing in blockbench types here for some reason - new Property(Face, "vector4", "windData"), + // @ts-expect-error: Face is not in blockbench types for Property + new Property(Face, "array", "windMode"), + // @ts-expect-error: Face is not in blockbench types for Property + new Property(Face, "array", "windData"), + // @ts-expect-error: Face is not in blockbench types for Property + new Property(Face, "boolean", "autoUv", { default: false }), + // @ts-expect-error: Face is not in blockbench types for Property + new Property(Face, "boolean", "snapUv", { default: false }), ]; /** @@ -241,6 +286,8 @@ declare global { reflectiveMode?: VS_ReflectiveMode; windMode?: [number, number, number, number]; windData?: [number, number, number, number]; + autoUv?: boolean; + snapUv?: boolean; } interface Texture { @@ -275,4 +322,10 @@ declare global { unwrapRotation?: number; backdrop?: boolean; } + + interface Locator { + rotationX?: number; + rotationY?: number; + rotationZ?: number; + } } \ No newline at end of file diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index 327308c..c9ddf60 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -13,7 +13,7 @@ import "./actions"; // Properties import "./property"; -// Mods +// Mods (order matters — these must load before panels) import "./mods/boneAnimatorMod"; import "./mods/formatMod"; import "./mods/settingsMod"; @@ -22,6 +22,9 @@ import "./mods/nodePreviewControllerMod"; import "./mods/attachmentsMod"; import "./mods/ik/index"; +// Panels (loaded after mods to avoid blocking critical format registration) +import "./panels/vs_face_panel"; + BBPlugin.register(PACKAGE.name, { title: PACKAGE.title, icon: 'fa-cookie-bite', diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 96ac511..a1b2984 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -89,6 +89,7 @@ export type VS_OnAnimationEnd = "Hold" | "Repeat" | "Stop" | "EaseOut" export interface VS_Keyframe { frame: number, elements: Record, + textures?: Record, } /** From 180df325e8a2ed6e0942b1b4483bd5ac874d7f90 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 27 Mar 2026 12:04:59 +0000 Subject: [PATCH 14/15] always include rotation origin --- src/export_animation.ts | 21 +-- src/export_model/cube/faces.ts | 32 ++-- src/export_model/group.ts | 6 +- src/panels/vs_face_panel.ts | 284 +++++++++++++++++++-------------- 4 files changed, 194 insertions(+), 149 deletions(-) diff --git a/src/export_animation.ts b/src/export_animation.ts index bf96e9f..235be79 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -80,16 +80,16 @@ export function export_animations(): Array { } }); } + }); - // Wraps all animation elements into oneLiner wrappers - for(const keyframe of Object.values(keyframes)) { - const wrapped_elements = {}; - for (const [element, content] of Object.entries(keyframe.elements)) { - wrapped_elements[element] = new oneLiner(content); - } - keyframe.elements = wrapped_elements; + // Wraps all animation elements into oneLiner wrappers (after all animators are processed) + for(const keyframe of Object.values(keyframes)) { + const wrapped_elements = {}; + for (const [element, content] of Object.entries(keyframe.elements)) { + wrapped_elements[element] = new oneLiner(content); } - }); + keyframe.elements = wrapped_elements; + } // Use preserved VS values if available, otherwise compute defaults // @ts-expect-error: custom property from import @@ -157,7 +157,9 @@ export function export_animations(): Array { * @param keyframes Keyframes */ function get_frame_quantity(animation: _Animation, keyframes: Record): number { - const quantityframes = Math.round(animation.length * util.fps); + // VS uses QuantityFrames as array size — valid frames are 0..QuantityFrames-1. + // We need QuantityFrames > max keyframe frame number. + const quantityframes = Math.round(animation.length * util.fps) + 1; const keyframe_frames = Object.keys(keyframes).map(kf => parseInt(kf)); if (keyframe_frames.length === 0) { return quantityframes; @@ -165,6 +167,7 @@ function get_frame_quantity(animation: _Animation, keyframes: Record= quantityframes) { display_animation_length_warning(animation.name); + return max_keyframe + 1; } return quantityframes; } diff --git a/src/export_model/cube/faces.ts b/src/export_model/cube/faces.ts index 9e10caf..17826fa 100644 --- a/src/export_model/cube/faces.ts +++ b/src/export_model/cube/faces.ts @@ -57,22 +57,26 @@ export function process_faces(faces: Partial const prop_name = prop.name; const value = face[prop_name]; - // Skip properties with default/empty values - if (value !== undefined && value !== null) { - // Skip 0 for numeric properties (glow, reflectiveMode) - if (typeof value === 'number' && value === 0) { - continue; - } - // Skip default arrays like [0,0,0,0] for windMode/windData - if (Array.isArray(value) && value.every(v => v === 0)) { - continue; - } - // Skip false for boolean face properties (autoUv, snapUv default to false in VS) - if (typeof value === 'boolean' && value === false) { - continue; - } + if (value === undefined || value === null) continue; + + // windMode: skip when all vertices are "Default" (-1); [0,0,0,0] = NoWind is valid + if (prop_name === 'windMode') { + if (Array.isArray(value) && value.every((v: number) => v === -1)) continue; + processed_face[prop_name] = value; + continue; + } + // windData: skip when all zeros + if (prop_name === 'windData') { + if (Array.isArray(value) && value.every((v: number) => v === 0)) continue; processed_face[prop_name] = value; + continue; } + // Skip 0 for numeric properties (glow, reflectiveMode) + if (typeof value === 'number' && value === 0) continue; + // Skip false for boolean face properties (autoUv, snapUv default to false in VS) + if (typeof value === 'boolean' && value === false) continue; + + processed_face[prop_name] = value; } processed_faces[direction] = new oneLiner(processed_face); } diff --git a/src/export_model/group.ts b/src/export_model/group.ts index dbc95c4..a865604 100644 --- a/src/export_model/group.ts +++ b/src/export_model/group.ts @@ -75,13 +75,12 @@ export function process_group( } const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; - const includeRotationOrigin = hasRotation || (node as any).vs_has_rotation_origin; const vsElement: VS_Element = { name: node.name, from: from, to: to, - ...(includeRotationOrigin && { rotationOrigin: rotationOrigin }), + rotationOrigin: rotationOrigin, ...((node as any).vs_uv ? { uv: (node as any).vs_uv } : undefined), ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), @@ -184,13 +183,12 @@ export function process_collapsed_group( const reduced_faces = process_faces(geoChild.faces); const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; - const includeRotationOrigin = hasRotation || (node as any).vs_has_rotation_origin; const vsElement: VS_Element = { name: node.name, from: from, to: to, - ...(includeRotationOrigin && { rotationOrigin: rotationOrigin }), + rotationOrigin: rotationOrigin, ...((geoChild as any).vs_uv ? { uv: (geoChild as any).vs_uv } : ((geoChild.uv_offset[0] !== 0 || geoChild.uv_offset[1] !== 0) && { uv: geoChild.uv_offset })), ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), diff --git a/src/panels/vs_face_panel.ts b/src/panels/vs_face_panel.ts index 10ba971..f577817 100644 --- a/src/panels/vs_face_panel.ts +++ b/src/panels/vs_face_panel.ts @@ -2,7 +2,7 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; const WIND_MODE_OPTIONS: Record = { - '': 'Default', + '-1': 'Default', '0': 'NoWind', '1': 'WeakWind', '2': 'NormalWind', @@ -20,15 +20,33 @@ const WIND_MODE_OPTIONS: Record = { }; const REFLECTIVE_MODE_OPTIONS: Record = { - '0': 'None', - '1': 'Weak', - '2': 'Medium', - '3': 'Strong', + '0': 'Not reflective', + '1': 'Weakly random reflective', + '2': 'Weakly reflective', + '3': 'Strongly reflective', '4': 'Sparkly', '5': 'Mild', }; const FACE_DIRECTIONS = ['north', 'east', 'south', 'west', 'up', 'down'] as const; +const FACE_LABELS: Record = { + north: 'N', + east: 'E', + south: 'S', + west: 'W', + up: 'U', + down: 'D', +}; + +// Colors matching VSMC Face.ColorsByFace * DefaultBlockSideBrightnessByFacing +const FACE_COLORS: Record = { + north: 'rgb(143,154,204)', + east: 'rgb(204,143,143)', + south: 'rgb(143,154,204)', + west: 'rgb(204,143,143)', + up: 'rgb(187,255,179)', + down: 'rgb(131,179,125)', +}; /** * Returns the 4 vertex positions for a given face direction on a cube. @@ -50,6 +68,37 @@ function getFaceVertices(cube: Cube, direction: string): [number, number, number } } +/** + * Auto-compute wind data for a face from vertex world Y positions. + * Matches VSMC FacePropertiesPanel.getWindData(): windData[i] = (int)(worldY / 16) + */ +function computeWindData(cube: Cube, direction: string): [number, number, number, number] { + const vertices = getFaceVertices(cube, direction); + const result: [number, number, number, number] = [0, 0, 0, 0]; + + for (let i = 0; i < 4; i++) { + const pos = vertices[i]; + // @ts-expect-error: THREE is global in Blockbench + const localPos = new THREE.Vector3( + pos[0] - cube.origin[0], + pos[1] - cube.origin[1], + pos[2] - cube.origin[2] + ); + + // @ts-expect-error: mesh access + if (cube.mesh) { + // @ts-expect-error: mesh access + cube.mesh.updateMatrixWorld(); + // @ts-expect-error: mesh access + const worldPos = cube.mesh.localToWorld(localPos); + result[i] = Math.trunc(worldPos.y / 16); + } else { + result[i] = Math.trunc(pos[1] / 16); + } + } + return result; +} + // Vertex highlight dot — shared THREE.js objects let vertexDot: any = null; @@ -71,8 +120,6 @@ function showVertexDot(cube: Cube, direction: string, vertexIndex: number) { const pos = vertices[vertexIndex]; if (!pos) return; - // Convert absolute position to mesh-local space (relative to cube's origin/pivot), - // then transform to world space via the mesh's matrix (handles rotation + parent groups) // @ts-expect-error: THREE is global in Blockbench const localPos = new THREE.Vector3( pos[0] - cube.origin[0], @@ -93,7 +140,6 @@ function showVertexDot(cube: Cube, direction: string, vertexIndex: number) { vertexDot.visible = true; - // @ts-expect-error: scene access if (!vertexDot.parent) { Canvas.scene.add(vertexDot); } @@ -117,48 +163,45 @@ function removeVertexDot() { const vueComponent = { template: `
-

Select a face to edit VS properties

+

Select a cube to edit face properties

-
- -