Skip to content
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
}
}
34 changes: 17 additions & 17 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
});
}
}
});
}
});
Expand Down
38 changes: 29 additions & 9 deletions src/attachments/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);

Expand 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);
});
}
});
Expand Down
51 changes: 45 additions & 6 deletions src/attachments/codec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
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<string>, parentNameMap: Map<string, string>): 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 {
save_array.push(element.uuid);
}
}
}
iterate(rootGroups, result);
iterate(rootGroups, result, true);
return result;
}

Expand Down Expand Up @@ -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<string>();
const parentNameMap = new Map<string, string>();
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);
Expand Down
15 changes: 15 additions & 0 deletions src/attachments/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading