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 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/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/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/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..30d974c 100644 --- a/src/attachments/delete_section.ts +++ b/src/attachments/delete_section.ts @@ -1,24 +1,123 @@ +import { QUICK_MESSAGE_DURATION } from './constants'; +import { isAttachment } from './discovery'; + +/** + * 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; +} + /** - * Deletes all attachments within a given section. + * 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 deleteSection(elements: any[]) { +export function deleteSectionSafe(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?`); + Undo.initEdit({ outliner: true }, `Delete (-) Root`); - if (confirmed) { - Undo.initEdit({ outliner: true }); + // Identify root attachment groups (first level with clothingSlot) that should be preserved + const rootGroups = identifyRootAttachmentGroups(elements); - // 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()); + // 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)); + }); - Undo.finishEdit('delete attachment section'); - - Blockbench.dispatchEvent('attachments_changed', {}); + 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[]) { + if (!elements || elements.length === 0) { + Blockbench.showQuickMessage("There are no attachments in this section to delete.", QUICK_MESSAGE_DURATION); + return; + } + + // 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()); + + 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/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_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 6011413..79a68a8 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -3,25 +3,109 @@ 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'; +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); +} + +/** + * 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. + * 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; @@ -119,13 +203,173 @@ 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. */ 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; } @@ -142,56 +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 - 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); + + 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 + } + + // 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; - 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)`); - return true; // Parent has different or no clothingSlot = root attachment + if (element instanceof Cube) { + orphanCubes.push(element); + logDebug(`[VS Attachment Export] Orphan cube found: "${element.name}"`); } + } - if (DEBUG) console.log(`[VS Attachment Export] Skipping "${element.name}" - child of attachment with same clothingSlot`); - return false; // Parent has same clothingSlot = 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; }); - 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 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; - if (DEBUG) console.log(`[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() !== '') { @@ -199,10 +482,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 @@ -215,17 +498,78 @@ 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 + // 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 => { @@ -240,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)'); } - - if (DEBUG) console.log('[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 1817eca..141e3dd 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}` ); @@ -97,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 489b910..3198cea 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -1,7 +1,40 @@ 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'; +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 @@ -35,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. @@ -50,7 +119,201 @@ function updateOutlinerSelection(elements: any[]) { const vuePanel = { template: ` -
+
+
folder

No attachments found in model.

@@ -58,65 +321,193 @@ 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 }}: {{ section.elements.length }} {{ getSectionStats(section.elements) }} + + + + select_all + + {{ getSectionVisibility(section.elements) ? 'visibility' : 'visibility_off' }} + + + save + file_download + + remove_circle_outline + delete + +

+
+
{{ section.slot }}
+
+ + {{ element.name }}, + + + ... and {{ section.elements.length - 10 }} more + +
+
+
+
+
+ :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)"> - - {{ 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) 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' : ''}`); + if (parts.length === 0) return '(0 items)'; + return `(${parts.join(', ')})`; + }, + /** + * 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(' | '); + }, + /** + * 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. */ 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; + } + }, + /** + * 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); + } }, /** - * Deletes the given elements from the project. - * @param {Array} elements The elements to delete. + * Confirms deletion before deleting section (original behavior - deletes everything) */ - 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 will delete everything including base model groups.\n\nThis action cannot be undone.`)) { + deleteSection(elements); + } }, /** * Updates the list of attachments by calling the discovery function. @@ -168,23 +559,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 +624,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 565c706..025afac 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -1,23 +1,30 @@ - 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); +} + +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() : ''; @@ -31,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; } } } @@ -56,180 +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"). + * Applies the selected clothing slot to all new elements. */ -export async function processImportedAttachments(elementsBefore: Set, filePath: string, logPrefix: string) { - 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)); + + topLevel.forEach(element => { + apply(element); + markAsRecentlyImported(element); + element.children?.forEach((child: any) => markAsRecentlyImported(child)); + }); +} + +/** + * 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; - // Track matches for user feedback - const matchingTable: { imported: string; matchedTo: string; slot: string }[] = []; + if (DEBUG) console.log(`[${logPrefix}] Smart match: checking ${topLevelGroups.length} groups.`); - // 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[]; + for (const newGroup of topLevelGroups) { + const slot = newGroup.clothingSlot?.trim(); + if (!slot) continue; - if (DEBUG) console.log(`[${logPrefix}] Found ${topLevelNewGroups.length} top-level new groups for smart matching`); + const match = findBestMatchingGroupBySlot(slot, newGroup.name, existingElements); + if (match) { + if (DEBUG) console.log(`[${logPrefix}] Matched "${newGroup.name}" -> "${match.name}" (slot: ${slot})`); - topLevelNewGroups.forEach(newGroup => { - const clothingSlot = newGroup.clothingSlot?.trim(); - if (!clothingSlot) return; + [...newGroup.children].forEach(child => child.addTo(match)); + matchCount++; - // Try to find a matching group in the existing model - const matchedGroup = findBestMatchingGroupBySlot(clothingSlot, newGroup.name, elementsBefore); + if (newGroup.children.length === 0) newGroup.remove(); + } + } + return matchCount; +} - if (matchedGroup) { - // Move all children of newGroup to the matched group - const childrenToMove = [...newGroup.children]; +/** + * 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.`); - if (DEBUG) console.log(`[${logPrefix}] Smart Match: "${newGroup.name}" (slot: ${clothingSlot}) -> "${matchedGroup.name}"`); + const allNewGroups = collectGroupsDepthFirst(newElements.filter(e => e instanceof Group && newElementsSet.has(e))); - childrenToMove.forEach(child => { - try { - child.addTo(matchedGroup); - } catch (e) { - console.error(`[${logPrefix}] Failed to move "${child.name}" to matched group "${matchedGroup.name}":`, e); - } - }); + // Process deepest first + for (let i = allNewGroups.length - 1; i >= 0; i--) { + const newGroup = allNewGroups[i]; + if (!newElementsSet.has(newGroup)) continue; - // Track this match for display - matchingTable.push({ - imported: newGroup.name || 'Unnamed', - matchedTo: matchedGroup.name || 'Unnamed', - slot: clothingSlot - }); + const matches = findAllGroupsByName(newGroup.name, Outliner.root).filter(g => !newElementsSet.has(g)); + + 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)); + + if (newGroup.clothingSlot && !target.clothingSlot) { + target.clothingSlot = newGroup.clothingSlot; + } - // Remove the now-empty imported group if (newGroup.children.length === 0) { newGroup.remove(); + 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); + } }); - // 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); - } + bySlot.forEach((groups, slot) => { + if (groups.length < 2) return; - // 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); + // 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(); - let parentGroup = allMatches.find(g => !newElementsSet.has(g)) || null; + if (!baseName) return; - if (!parentGroup && allMatches.length === 0) { - parentGroup = new Group({ name: stepParentName }).addTo().init(); - if (DEBUG) console.log(`[${logPrefix}] Created missing stepParent group: "${stepParentName}"`); - } + // 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; + + // 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 (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) { - console.error(`[${logPrefix}] Failed to reparent "${element.name}" to "${stepParentName}":`, e); + 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); } - } + }); } }); +} - // 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) { +/** + * 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); + } + }); +} + +/** + * 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) { - 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()); +} - // STEP 4: Apply Clothing Slot - Show dialog and apply user's selection - const inferredSlot = inferClothingSlotFromPath(filePath); - const masterClothingSlot = await showClothingSlotDialog(inferredSlot, filePath); +/** + * 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); - if (masterClothingSlot) { - if (DEBUG) console.log(`[${logPrefix}] Applying user-selected clothing slot "${masterClothingSlot}" to ${newElements.length} new elements.`); + // 1. User selects clothing slot + const inferred = inferClothingSlotFromPath(filePath); + const result = await showClothingSlotDialog(inferred, filePath, model); + const masterSlot = result.slot; - 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)); - } - } + if (!masterSlot) { + if (DEBUG) console.log(`[${logPrefix}] Import cancelled.`); + newElements.forEach(e => e.remove()); + Blockbench.showQuickMessage('Import cancelled', QUICK_MESSAGE_DURATION); + return; + } - const topLevelNewElements = newElements.filter(element => { - const parent = element.parent; - return !parent || !newElementsSet.has(parent); - }); + // 2. Apply slot to new elements + applyClothingSlot(newElements, masterSlot, logPrefix); - if (DEBUG) console.log(`[${logPrefix}] Found ${topLevelNewElements.length} top-level new elements to process`); + // 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); + } - topLevelNewElements.forEach(element => applySlotRecursive(element, masterClothingSlot)); - } 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`); + // 4. Hierarchical Match: New groups -> Old groups with same name/path + mergeHierarchicalGroups(newElements, newElementsSet, logPrefix); - // Remove all newly imported elements - newElements.forEach(element => { - try { - element.remove(); - } catch (e) { - 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', 2000); - } + // 6. Step Parent Reparenting + reparentByStepParent(newElements, newElementsSet, logPrefix); + + // 7. Cleanup Duplicates + mergeDuplicateGroups(logPrefix); - Undo.finishEdit('Import and parent attachment'); + Undo.finishEdit(`Import attachment: ${filePath.split(/[/\\]/).pop()}`); Canvas.updateAll(); - updateSelection?.(); + if (typeof updateSelection === 'function') updateSelection(); } 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/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; +} diff --git a/src/codec.ts b/src/codec.ts index e152cd1..ab36d47 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -1,38 +1,41 @@ 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) { + 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); + }, +}); function validate_json(content) { const ajv = new Ajv(); 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 00581cf..bcff549 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 @@ -101,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]; } } @@ -125,8 +129,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_animation.ts b/src/export_animation.ts index b79f7fa..1c896fc 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -14,15 +14,31 @@ 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 baseFrameCount = get_base_frame_quantity(animation); const animators = Object.values(animation.animators || {}); + 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 => { + // 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] || {}; @@ -41,30 +57,57 @@ export function export_animations(): Array { keyframes[frame].elements[bone_name].offsetZ = Number(dataPoint.z); break; case 'scale': - if (dataPoint.x !== 1) keyframes[frame].elements[bone_name].scaleX = Number(dataPoint.x); - if (dataPoint.y !== 1) keyframes[frame].elements[bone_name].scaleY = Number(dataPoint.y); - if (dataPoint.z !== 1) keyframes[frame].elements[bone_name].scaleZ = Number(dataPoint.z); + if (dataPoint.x !== 1) keyframes[frame].elements[bone_name].stretchX = Number(dataPoint.x); + if (dataPoint.y !== 1) keyframes[frame].elements[bone_name].stretchY = Number(dataPoint.y); + if (dataPoint.z !== 1) keyframes[frame].elements[bone_name].stretchZ = Number(dataPoint.z); break; } }); } - // 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; + // 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; + } + } + } + }); } }); + normalize_terminal_keyframe(keyframes, baseFrameCount); + + // 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 + 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: 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) }; @@ -78,11 +121,36 @@ 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 a frame count, + // so the last valid 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) { + animationsWithNonLinearInterpolation.push(animation.name); + } } }); + // Warn user if any animations use non-linear interpolations + if (animationsWithNonLinearInterpolation.length > 0) { + display_interpolation_warning(animationsWithNonLinearInterpolation); + } + return animations; } @@ -92,23 +160,123 @@ 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); + // VS uses QuantityFrames as a frame count. + // A 60-frame animation should export with QuantityFrames = 60 and a last valid frame of 59. + const quantityframes = get_base_frame_quantity(animation); 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 max_keyframe + 1; } return quantityframes; } +function get_base_frame_quantity(animation: _Animation): number { + return Math.max(1, Math.round(animation.length * util.fps)); +} + +function normalize_terminal_keyframe( + keyframes: Record, + quantityframes: number +) { + const terminalFrame = quantityframes; + const lastExportableFrame = quantityframes - 1; + const terminalKeyframe = keyframes[terminalFrame]; + + if (!terminalKeyframe) { + return; + } + + const previousKeyframe = keyframes[lastExportableFrame]; + if (previousKeyframe) { + if (keyframe_contents_match(previousKeyframe, terminalKeyframe)) { + delete keyframes[terminalFrame]; + return; + } + return; + } + + const shiftedKeyframe = clone_keyframe(terminalKeyframe); + shiftedKeyframe.frame = lastExportableFrame; + keyframes[lastExportableFrame] = shiftedKeyframe; + delete keyframes[terminalFrame]; +} + +function keyframe_contents_match(a: VS_Keyframe, b: VS_Keyframe): boolean { + return stable_keyframe_content(a) === stable_keyframe_content(b); +} + +function stable_keyframe_content(keyframe: VS_Keyframe): string { + return JSON.stringify({ + elements: sort_nested_object(keyframe.elements), + textures: keyframe.textures ? sort_nested_object(keyframe.textures) : undefined + }); +} + +function sort_nested_object(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sort_nested_object); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nestedValue]) => [key, sort_nested_object(nestedValue)]) + ); + } + return value; +} + +function clone_keyframe(keyframe: VS_Keyframe): VS_Keyframe { + return JSON.parse(JSON.stringify(keyframe)) as VS_Keyframe; +} + +/** + * 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.` }); -} \ No newline at end of file +} + +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.` + }); +} 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.ts b/src/export_model/cube.ts index d078adf..ecacd11 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); @@ -30,10 +31,18 @@ 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; } @@ -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; @@ -42,28 +48,35 @@ 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) { + for (const prop of VS_FACE_PROPS) { 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; - } + 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); } @@ -77,10 +90,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/export_model/cube/factory.ts b/src/export_model/cube/factory.ts index 4e27ab1..032d32e 100644 --- a/src/export_model/cube/factory.ts +++ b/src/export_model/cube/factory.ts @@ -29,12 +29,15 @@ export function create_VS_element(parent: Group | null, node: Cube, parent_pos: rotationOrigin = util.vector_add(rotationOrigin, offset); } + 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, 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..a865604 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 hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; + const vsElement: VS_Element = { name: node.name, from: from, to: to, 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,8 +99,141 @@ 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) { + // 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 && !(typeof NullObject !== 'undefined' && child instanceof NullObject)) 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 hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; + + const vsElement: VS_Element = { + name: node.name, + from: from, + to: to, + 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) { + // 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) { @@ -63,6 +242,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/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/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/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.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..ce96355 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) { @@ -40,8 +48,8 @@ export function import_animations(animations: Array) { animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'position', data_points: [{ x: position[0] || 0, y: position[1] || 0, z: position[2] || 0 }] }); } - if (transform.scaleX != null || transform.scaleY != null || transform.scaleZ != null) { - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'scale', data_points: [{ x: transform.scaleX ?? 1, y: transform.scaleY ?? 1, z: transform.scaleZ ?? 1 }] }); + if (transform.stretchX != null || transform.stretchY != null || transform.stretchZ != null) { + animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'scale', data_points: [{ x: transform.stretchX ?? 1, y: transform.stretchY ?? 1, z: transform.stretchZ ?? 1 }] }); } } } 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..4d026e6 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, + 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); + // @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/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 797e8c6..69a684b 100644 --- a/src/import_model/traverse.ts +++ b/src/import_model/traverse.ts @@ -16,18 +16,25 @@ 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) && (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); - // 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/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 new file mode 100644 index 0000000..4298efe --- /dev/null +++ b/src/mods/ik/baking.ts @@ -0,0 +1,403 @@ + +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; +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; + + setForcedIKAnimationContext(true); + + try { + 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(); + } + }); + } finally { + setForcedIKAnimationContext(false); + } + + Blockbench.showQuickMessage( + `Baked ${ikControllers.length} IK controller(s) to ${totalBakedKeyframes} keyframes`, + 3000 + ); +} diff --git a/src/mods/ik/chain_utils.ts b/src/mods/ik/chain_utils.ts new file mode 100644 index 0000000..a2271e2 --- /dev/null +++ b/src/mods/ik/chain_utils.ts @@ -0,0 +1,105 @@ + +// Blockbench global types +declare var Group: any; +declare var NullObject: any; +declare var OutlinerNode: any; +declare var Project: any; + +/** + * Chain Utility helper interface + */ +export interface IKControllerInfo { + controller: any; + target: any; + chain: any[]; +} + +/** + * Resolves a value that may be a UUID string to the actual outliner node. + */ +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[] = []; + + 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; +} + +/** + * Finds all IK controllers in the current project. + * 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 = NullObject.all; + + for (const nullObj of nulls) { + if (nullObj.ik_target) { + const target = resolveNode(nullObj.ik_target); + if (!target) continue; + const chain = getIKChain(nullObj); + 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 = NullObject.all; + + for (const nullObj of nulls) { + if (nullObj.ik_target) { + return true; + } + } + + return false; +} 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 new file mode 100644 index 0000000..27b3e16 --- /dev/null +++ b/src/mods/ik/constraints.ts @@ -0,0 +1,525 @@ + +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. + * 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: controller.vsIKWeight ?? 1.0, + lockPosition: controller.vsIKLockPosition ?? 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 }; + 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; +} + +/** + * 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) { + // 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) { + 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 = NullObject.all.find((g: any) => g.name === constraintData.orientationHelper); + if (!helper) { + return null; + } + + 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: (controller: any) => any[]) { + if (!controller.ik_target) return; + + 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', + 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 + }; + }); + + // 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, + onFormChange(formResult: any) { + const updates: Record = {}; + let presetChanged = false; + + chain.forEach(bone => { + const boneName = bone.name; + 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)); + } + } + }); + + // 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 = {}; + + 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; + } + }); + 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 new file mode 100644 index 0000000..5e6acb0 --- /dev/null +++ b/src/mods/ik/index.ts @@ -0,0 +1,77 @@ + +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"; +import { setOnConstraintCacheRebuilt } from "./constraints"; +import { refreshIKPreview } from "./utils"; +import { syncConstraintEnforcement, clearConstraintEnforcement } from "../boneAnimatorMod"; + +// 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(); + + // Re-patch bone meshes whenever constraint cache is rebuilt + setOnConstraintCacheRebuilt(() => { + refreshIKPreview(undefined, true); + syncConstraintEnforcement(); + }); + + Blockbench.on('load_project', () => { + setTimeout(() => { + updatePinnedBones(); + refreshIKPreview(undefined, true); + syncConstraintEnforcement(); + }, 100); + }); + + codecVS.compile = function (options: any) { + if (is_vs_project(Project)) { + const hasIK = checkForIKControllers(); + if (hasIK) { + bakeIKAnimations(is_vs_project); + } + } + return context.originalCompile(options); + }; + + return { ...context, editIKConstraintsAction, togglePinBoneAction }; + }, + context => { + + codecVS.compile = context.originalCompile; + clearConstraintEnforcement(); + + if (context.editIKConstraintsAction) { + context.editIKConstraintsAction.delete(); + } + if (context.togglePinBoneAction) { + context.togglePinBoneAction.delete(); + } + } +); diff --git a/src/mods/ik/interactive.ts b/src/mods/ik/interactive.ts new file mode 100644 index 0000000..6ba678b --- /dev/null +++ b/src/mods/ik/interactive.ts @@ -0,0 +1,306 @@ + +import { vec3Length, vec3Sub, vec3Normalize, vec3Add } from "./math"; +import { getBoneWorldPosition, getBoneEndWorldPosition, refreshIKPreview } 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 NullObject: 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 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)) 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)) 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) { + 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) { + 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; + } + } + }); + } + } + + refreshIKPreview(finalChain); + + 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) { + } + }); +} 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..87002c5 --- /dev/null +++ b/src/mods/ik/properties.ts @@ -0,0 +1,366 @@ + +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 NullObject: any; +declare var Outliner: 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(NullObject, "number", "vsIKWeight", { + default: 1.0, + label: "IK Weight", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return isNullObject(selected[0]); + } 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 (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; + 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(NullObject, "boolean", "vsIKLockPosition", { + default: false, + label: "Lock IK Position", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return isNullObject(selected[0]); + } 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 (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.position[0], obj.position[1], obj.position[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(NullObject, "string", "vsIKOrientationHelper", { + default: '', + label: "IK Orientation Helper", + exposed: true, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return isNullObject(selected[0]); + } catch (e) { + console.error('IK Orientation Helper condition error:', e); + return false; + } + }, + options: () => { + const nulls = NullObject.all; + const options: { [key: string]: string } = { '': 'None' }; + nulls.forEach((nullObj: any) => { + options[nullObj.name] = nullObj.name; + }); + return options; + }, + inputs: { + element_panel: { + input: { + label: 'Orientation Helper (Null Object)', + type: 'select', + options: () => { + const nulls = NullObject.all; + const options: { [key: string]: string } = { '': 'None' }; + nulls.forEach((nullObj: any) => { + options[nullObj.name] = nullObj.name; + }); + return options; + } + } + } + }, + onChange() { + const obj = this as any; + 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); + } + } + }); + + 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; + return isNullObject(selected[0]); + } 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 any; + + 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', + condition: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + const obj = selected[0]; + return obj instanceof Group; + }, + click: () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const bone = selected[0] as any; + + togglePinBone(bone); + } + }); + + MenuBar.addAction(togglePinBoneAction, 'edit'); + + 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, + condition: () => { + try { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return false; + return isNullObject(selected[0]); + } 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 (isNullObject(obj)) { + 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; + }, + 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) { + updatePinnedBones(); + } + } + }); + + const syncIKProperties = () => { + const selected = Outliner.selected; + if (!selected || selected.length !== 1) return; + const obj = selected[0]; + + if (isNullObject(obj) && obj.ik_target) { + 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 }; +} diff --git a/src/mods/ik/solver.ts b/src/mods/ik/solver.ts new file mode 100644 index 0000000..b624aaf --- /dev/null +++ b/src/mods/ik/solver.ts @@ -0,0 +1,155 @@ + +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]; + + } + } + + const finalEndPos = getBoneEndWorldPosition(endEffector); + const finalDistance = vec3Length(vec3Sub(targetPosition, finalEndPos)); + + return finalDistance < tolerance * 2; +} 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..c57ed06 --- /dev/null +++ b/src/mods/ik/utils.ts @@ -0,0 +1,106 @@ + +import { vec3Length, vec3Sub } from "./math"; +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) + */ +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)); +} + +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/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(); + } +); + diff --git a/src/panels/vs_face_panel.ts b/src/panels/vs_face_panel.ts new file mode 100644 index 0000000..f577817 --- /dev/null +++ b/src/panels/vs_face_panel.ts @@ -0,0 +1,480 @@ +import { createBlockbenchMod } from "../util/moddingTools"; +import * as PACKAGE from "../../package.json"; + +const WIND_MODE_OPTIONS: Record = { + '-1': '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': '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. + * 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]]; + } +} + +/** + * 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; + +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; + + // @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; + + 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 cube to edit face properties

+
+
+
+
{{ faceLabels[dir] }}
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ `, + data() { + return { + selectedCube: null as Cube | null, + selectedFaceName: 'north' as string, + applyToAll: false, + glow: 0, + reflectiveMode: '0', + windMode: [-1, -1, -1, -1] as number[], + windData: [0, 0, 0, 0] as number[], + windModeOptions: WIND_MODE_OPTIONS, + reflectiveModeOptions: REFLECTIVE_MODE_OPTIONS, + faceDirections: FACE_DIRECTIONS, + faceLabels: FACE_LABELS, + faceColors: FACE_COLORS, + _listeners: [] as Array<() => void>, + }; + }, + computed: { + windDataDisplay(): string { + return (this as any).windData.join(', '); + } + }, + 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 }]; + }, + + selectFace(direction: string) { + const self = this as any; + self.selectedFaceName = direction; + self.loadFromFace(); + }, + + 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 { + return String((this as any).windMode[index] ?? -1); + }, + + setWindModeComponent(index: number, value: string) { + const self = this as any; + const num = parseInt(value); + self.windMode[index] = num; + // Force reactivity + self.windMode = [...self.windMode]; + + for (const { face, direction } of self.getTargetFaces()) { + if (!face.windMode) { + face.windMode = [-1, -1, -1, -1]; + } + face.windMode[index] = num; + + // Auto-compute wind data when wind mode changes (matching VSMC behavior) + if (self.selectedCube) { + const autoData = computeWindData(self.selectedCube, direction); + if (autoData[0] !== 0 || autoData[1] !== 0 || autoData[2] !== 0 || autoData[3] !== 0) { + face.windData = autoData; + } else { + face.windData = undefined; + } + } + + // If all components are default (-1), clear windMode + if (face.windMode.every((v: number) => v === -1)) { + face.windMode = undefined; + face.windData = undefined; + } + } + + // Update wind data display + self.updateWindDataDisplay(); + }, + + updateWindDataDisplay() { + const self = this as any; + const face = self.getSelectedFace(); + if (face && face.windData) { + self.windData = [...face.windData]; + } else if (self.selectedCube && self.selectedFaceName) { + self.windData = computeWindData(self.selectedCube, self.selectedFaceName); + } else { + self.windData = [0, 0, 0, 0]; + } + }, + + loadFromFace() { + const self = this as any; + const face = self.getSelectedFace(); + if (!face) { + self.glow = 0; + self.reflectiveMode = '0'; + self.windMode = [-1, -1, -1, -1]; + self.windData = [0, 0, 0, 0]; + return; + } + self.glow = face.glow || 0; + self.reflectiveMode = String(face.reflectiveMode || 0); + self.windMode = face.windMode ? [...face.windMode] : [-1, -1, -1, -1]; + self.updateWindDataDisplay(); + }, + + updateSelection() { + const self = this as any; + hideVertexDot(); + + const selected = Cube.selected; + if (!selected || selected.length === 0) { + self.selectedCube = null; + self.selectedFaceName = 'north'; + return; + } + self.selectedCube = selected[0]; + self.loadFromFace(); + }, + }, + mounted() { + const self = this as any; + self.updateSelection(); + + const onSelectionUpdate = () => self.updateSelection(); + Blockbench.on('update_selection' as EventName, onSelectionUpdate); + + self._listeners = [ + () => Blockbench.removeListener('update_selection' as EventName, onSelectionUpdate), + ]; + }, + 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 + }); + + const style = document.createElement('style'); + style.id = 'vs-face-panel-styles'; + style.textContent = ` + .vs_fp_message { + padding: 8px; + color: var(--color-subtle_text); + text-align: center; + } + .vs_fp_header { + display: flex; + align-items: center; + padding: 4px 8px; + border-bottom: 1px solid var(--color-border); + margin-bottom: 6px; + gap: 6px; + } + .vs_fp_face_buttons { + display: flex; + gap: 2px; + flex: 1; + } + .vs_fp_face_btn { + flex: 1; + text-align: center; + padding: 3px 0; + font-size: 12px; + font-weight: bold; + cursor: pointer; + border-radius: 3px; + color: var(--face-color); + background: var(--color-back); + border: 1px solid var(--color-border); + transition: background 0.1s; + } + .vs_fp_face_btn:hover { + background: var(--color-button); + } + .vs_fp_face_btn.active { + background: var(--color-button); + border-color: var(--face-color); + box-shadow: 0 0 0 1px var(--face-color); + } + .vs_fp_apply_all { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + } + .vs_fp_section { + padding: 2px 8px; + margin-bottom: 4px; + } + .vs_fp_label { + display: block; + font-size: 12px; + color: var(--color-subtle_text); + margin-bottom: 2px; + } + .vs_fp_section input[type="number"], + .vs_fp_section input[type="text"], + .vs_fp_section select { + width: 100%; + box-sizing: border-box; + } + `; + 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 f51f733..01591a1 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', + } } } }, @@ -141,6 +176,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) @@ -203,13 +258,25 @@ 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: CubeFace is not in blockbench types for Property + new Property(CubeFace, "number", "glow"), + // @ts-expect-error: CubeFace is not in blockbench types for Property + new Property(CubeFace, "number", "reflectiveMode"), + // @ts-expect-error: CubeFace is not in blockbench types for Property + new Property(CubeFace, "array", "windMode"), + // @ts-expect-error: CubeFace is not in blockbench types for Property + new Property(CubeFace, "array", "windData"), + // @ts-expect-error: CubeFace is not in blockbench types for Property + new Property(CubeFace, "boolean", "autoUv", { default: false }), + // @ts-expect-error: CubeFace is not in blockbench types for Property + new Property(CubeFace, "boolean", "snapUv", { default: false }), ]; /** @@ -221,6 +288,8 @@ declare global { reflectiveMode?: VS_ReflectiveMode; windMode?: [number, number, number, number]; windData?: [number, number, number, number]; + autoUv?: boolean; + snapUv?: boolean; } interface Texture { @@ -249,6 +318,16 @@ declare global { gradientShade?: boolean; renderPass?: number; seasonColorMap?: string; + unwrapMode?: number; + autoUnwrap?: boolean; + disableRandomDrawOffset?: boolean; + unwrapRotation?: number; backdrop?: boolean; } + + interface Locator { + rotationX?: number; + rotationY?: number; + rotationZ?: number; + } } \ No newline at end of file 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); }); } diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index 3491736..c9ddf60 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -13,13 +13,17 @@ import "./actions"; // Properties import "./property"; -// Mods +// Mods (order matters — these must load before panels) import "./mods/boneAnimatorMod"; import "./mods/formatMod"; import "./mods/settingsMod"; import "./mods/legacyFormatConverterMod"; 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, @@ -40,9 +44,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 diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 6546786..3a158cb 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, @@ -87,6 +89,7 @@ export type VS_OnAnimationEnd = "Hold" | "Repeat" | "Stop" | "EaseOut" export interface VS_Keyframe { frame: number, elements: Record, + textures?: Record, } /** @@ -104,9 +107,9 @@ export interface VS_AnimationKey { rotShortestDistanceX?: boolean, rotShortestDistanceY?: boolean, rotShortestDistanceZ?: boolean, - scaleX?: number, - scaleY?: number, - scaleZ?: number, + stretchX?: number, + stretchY?: number, + stretchZ?: number, } /**