blockymodel-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/blockymodel-web.js +2717 -0
- package/dist/blockymodel-web.js.map +1 -0
- package/dist/blockymodel-web.umd.cjs +122 -0
- package/dist/blockymodel-web.umd.cjs.map +1 -0
- package/dist/editor/Editor.d.ts +94 -0
- package/dist/editor/Editor.d.ts.map +1 -0
- package/dist/editor/History.d.ts +52 -0
- package/dist/editor/History.d.ts.map +1 -0
- package/dist/editor/SelectionManager.d.ts +57 -0
- package/dist/editor/SelectionManager.d.ts.map +1 -0
- package/dist/editor/Serializer.d.ts +44 -0
- package/dist/editor/Serializer.d.ts.map +1 -0
- package/dist/editor/TransformManager.d.ts +73 -0
- package/dist/editor/TransformManager.d.ts.map +1 -0
- package/dist/editor/commands/AddNodeCommand.d.ts +24 -0
- package/dist/editor/commands/AddNodeCommand.d.ts.map +1 -0
- package/dist/editor/commands/Command.d.ts +50 -0
- package/dist/editor/commands/Command.d.ts.map +1 -0
- package/dist/editor/commands/RemoveNodeCommand.d.ts +28 -0
- package/dist/editor/commands/RemoveNodeCommand.d.ts.map +1 -0
- package/dist/editor/commands/SetPositionCommand.d.ts +24 -0
- package/dist/editor/commands/SetPositionCommand.d.ts.map +1 -0
- package/dist/editor/commands/SetPropertyCommand.d.ts +41 -0
- package/dist/editor/commands/SetPropertyCommand.d.ts.map +1 -0
- package/dist/editor/commands/SetRotationCommand.d.ts +24 -0
- package/dist/editor/commands/SetRotationCommand.d.ts.map +1 -0
- package/dist/editor/commands/SetScaleCommand.d.ts +24 -0
- package/dist/editor/commands/SetScaleCommand.d.ts.map +1 -0
- package/dist/editor/index.d.ts +15 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/loaders/BlockyModelLoader.d.ts +48 -0
- package/dist/loaders/BlockyModelLoader.d.ts.map +1 -0
- package/dist/types/blockymodel.d.ts +72 -0
- package/dist/types/blockymodel.d.ts.map +1 -0
- package/dist/ui/HierarchyPanel.d.ts +73 -0
- package/dist/ui/HierarchyPanel.d.ts.map +1 -0
- package/dist/ui/PropertyPanel.d.ts +59 -0
- package/dist/ui/PropertyPanel.d.ts.map +1 -0
- package/dist/ui/UVEditor.d.ts +56 -0
- package/dist/ui/UVEditor.d.ts.map +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/viewer/ViewerController.d.ts +71 -0
- package/dist/viewer/ViewerController.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/editor/Editor.ts +196 -0
- package/src/editor/History.ts +123 -0
- package/src/editor/SelectionManager.ts +183 -0
- package/src/editor/Serializer.ts +212 -0
- package/src/editor/TransformManager.ts +270 -0
- package/src/editor/commands/AddNodeCommand.ts +53 -0
- package/src/editor/commands/Command.ts +63 -0
- package/src/editor/commands/RemoveNodeCommand.ts +59 -0
- package/src/editor/commands/SetPositionCommand.ts +51 -0
- package/src/editor/commands/SetPropertyCommand.ts +100 -0
- package/src/editor/commands/SetRotationCommand.ts +51 -0
- package/src/editor/commands/SetScaleCommand.ts +51 -0
- package/src/editor/index.ts +19 -0
- package/src/index.ts +49 -0
- package/src/loaders/BlockyModelLoader.ts +281 -0
- package/src/main.ts +290 -0
- package/src/styles.css +597 -0
- package/src/types/blockymodel.ts +82 -0
- package/src/ui/HierarchyPanel.ts +343 -0
- package/src/ui/PropertyPanel.ts +434 -0
- package/src/ui/UVEditor.ts +336 -0
- package/src/ui/index.ts +4 -0
- package/src/viewer/ViewerController.ts +295 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Command } from "./commands/Command";
|
|
2
|
+
|
|
3
|
+
type HistoryCallback = () => void;
|
|
4
|
+
|
|
5
|
+
const MERGE_WINDOW_MS = 500; // Commands within this window can be merged
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages undo/redo history for editor commands
|
|
9
|
+
*/
|
|
10
|
+
export class History {
|
|
11
|
+
private undoStack: Command[] = [];
|
|
12
|
+
private redoStack: Command[] = [];
|
|
13
|
+
private eventListeners: Map<string, Set<HistoryCallback>> = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Execute a command and add it to history
|
|
17
|
+
*/
|
|
18
|
+
execute(command: Command): void {
|
|
19
|
+
command.execute();
|
|
20
|
+
|
|
21
|
+
// Try to merge with last command if within time window and updatable
|
|
22
|
+
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
|
23
|
+
if (
|
|
24
|
+
lastCommand &&
|
|
25
|
+
lastCommand.updatable &&
|
|
26
|
+
command.updatable &&
|
|
27
|
+
lastCommand.type === command.type &&
|
|
28
|
+
lastCommand.object === command.object &&
|
|
29
|
+
command.timestamp - lastCommand.timestamp < MERGE_WINDOW_MS
|
|
30
|
+
) {
|
|
31
|
+
// Merge into existing command
|
|
32
|
+
if (lastCommand.update) {
|
|
33
|
+
lastCommand.update(command);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
// Add as new command
|
|
37
|
+
this.undoStack.push(command);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clear redo stack on new action
|
|
41
|
+
this.redoStack = [];
|
|
42
|
+
|
|
43
|
+
this.emit("historyChanged");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Undo the last command
|
|
48
|
+
*/
|
|
49
|
+
undo(): void {
|
|
50
|
+
const command = this.undoStack.pop();
|
|
51
|
+
if (command) {
|
|
52
|
+
command.undo();
|
|
53
|
+
this.redoStack.push(command);
|
|
54
|
+
this.emit("historyChanged");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Redo the last undone command
|
|
60
|
+
*/
|
|
61
|
+
redo(): void {
|
|
62
|
+
const command = this.redoStack.pop();
|
|
63
|
+
if (command) {
|
|
64
|
+
command.execute();
|
|
65
|
+
this.undoStack.push(command);
|
|
66
|
+
this.emit("historyChanged");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if undo is available
|
|
72
|
+
*/
|
|
73
|
+
canUndo(): boolean {
|
|
74
|
+
return this.undoStack.length > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if redo is available
|
|
79
|
+
*/
|
|
80
|
+
canRedo(): boolean {
|
|
81
|
+
return this.redoStack.length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clear all history
|
|
86
|
+
*/
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.undoStack = [];
|
|
89
|
+
this.redoStack = [];
|
|
90
|
+
this.emit("historyChanged");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the number of items in undo stack
|
|
95
|
+
*/
|
|
96
|
+
getUndoCount(): number {
|
|
97
|
+
return this.undoStack.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the number of items in redo stack
|
|
102
|
+
*/
|
|
103
|
+
getRedoCount(): number {
|
|
104
|
+
return this.redoStack.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Add event listener
|
|
109
|
+
*/
|
|
110
|
+
on(event: string, callback: HistoryCallback): void {
|
|
111
|
+
if (!this.eventListeners.has(event)) {
|
|
112
|
+
this.eventListeners.set(event, new Set());
|
|
113
|
+
}
|
|
114
|
+
this.eventListeners.get(event)!.add(callback);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Emit event
|
|
119
|
+
*/
|
|
120
|
+
private emit(event: string): void {
|
|
121
|
+
this.eventListeners.get(event)?.forEach((callback) => callback());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { Editor } from "./Editor";
|
|
3
|
+
|
|
4
|
+
type SelectionCallback = (object: THREE.Object3D | null) => void;
|
|
5
|
+
|
|
6
|
+
const HIGHLIGHT_EMISSIVE = 0x4488ff;
|
|
7
|
+
const HIGHLIGHT_INTENSITY = 0.3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manages object selection via raycasting
|
|
11
|
+
*/
|
|
12
|
+
export class SelectionManager {
|
|
13
|
+
private editor: Editor;
|
|
14
|
+
private raycaster: THREE.Raycaster;
|
|
15
|
+
private mouse: THREE.Vector2;
|
|
16
|
+
private selected: THREE.Object3D | null = null;
|
|
17
|
+
private highlightedMaterials: Map<THREE.Mesh, { emissive: THREE.Color; emissiveIntensity: number }> = new Map();
|
|
18
|
+
private eventListeners: Map<string, Set<SelectionCallback>> = new Map();
|
|
19
|
+
|
|
20
|
+
constructor(editor: Editor) {
|
|
21
|
+
this.editor = editor;
|
|
22
|
+
this.raycaster = new THREE.Raycaster();
|
|
23
|
+
this.mouse = new THREE.Vector2();
|
|
24
|
+
|
|
25
|
+
// Bind event handlers
|
|
26
|
+
this.handleClick = this.handleClick.bind(this);
|
|
27
|
+
this.editor.domElement.addEventListener("click", this.handleClick);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handle click events for selection
|
|
32
|
+
*/
|
|
33
|
+
private handleClick(event: MouseEvent): void {
|
|
34
|
+
// Ignore if clicking on UI elements or during transform
|
|
35
|
+
if (event.target !== this.editor.renderer.domElement) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rect = this.editor.domElement.getBoundingClientRect();
|
|
40
|
+
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
41
|
+
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
42
|
+
|
|
43
|
+
this.raycaster.setFromCamera(this.mouse, this.editor.camera);
|
|
44
|
+
|
|
45
|
+
// Get pickable objects (meshes from current model)
|
|
46
|
+
const model = this.editor.getModel();
|
|
47
|
+
if (!model) {
|
|
48
|
+
this.deselect();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const pickables: THREE.Object3D[] = [];
|
|
53
|
+
model.traverse((object) => {
|
|
54
|
+
if (object instanceof THREE.Mesh && object.visible) {
|
|
55
|
+
// Skip TransformControls gizmo objects
|
|
56
|
+
if (!this.isTransformControlPart(object)) {
|
|
57
|
+
pickables.push(object);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const intersects = this.raycaster.intersectObjects(pickables, false);
|
|
63
|
+
|
|
64
|
+
if (intersects.length > 0) {
|
|
65
|
+
// Find the most relevant parent (node with name/id)
|
|
66
|
+
let target = intersects[0].object;
|
|
67
|
+
while (target.parent && !target.userData.id && target.parent !== model) {
|
|
68
|
+
target = target.parent;
|
|
69
|
+
}
|
|
70
|
+
this.select(target);
|
|
71
|
+
} else {
|
|
72
|
+
this.deselect();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if object is part of TransformControls
|
|
78
|
+
*/
|
|
79
|
+
private isTransformControlPart(object: THREE.Object3D): boolean {
|
|
80
|
+
let current: THREE.Object3D | null = object;
|
|
81
|
+
while (current) {
|
|
82
|
+
if (current.type === "TransformControlsGizmo" || current.type === "TransformControlsPlane") {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
current = current.parent;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Select an object
|
|
92
|
+
*/
|
|
93
|
+
select(object: THREE.Object3D | null): void {
|
|
94
|
+
if (object === this.selected) return;
|
|
95
|
+
|
|
96
|
+
// Remove highlight from previous selection
|
|
97
|
+
this.removeHighlight();
|
|
98
|
+
|
|
99
|
+
this.selected = object;
|
|
100
|
+
|
|
101
|
+
// Add highlight to new selection
|
|
102
|
+
if (object) {
|
|
103
|
+
this.applyHighlight(object);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.emit("selectionChanged", object);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Deselect current object
|
|
111
|
+
*/
|
|
112
|
+
deselect(): void {
|
|
113
|
+
this.select(null);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get currently selected object
|
|
118
|
+
*/
|
|
119
|
+
getSelected(): THREE.Object3D | null {
|
|
120
|
+
return this.selected;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply highlight effect to object
|
|
125
|
+
*/
|
|
126
|
+
private applyHighlight(object: THREE.Object3D): void {
|
|
127
|
+
object.traverse((child) => {
|
|
128
|
+
if (child instanceof THREE.Mesh) {
|
|
129
|
+
const material = child.material as THREE.MeshStandardMaterial;
|
|
130
|
+
if (material.emissive) {
|
|
131
|
+
// Store original values
|
|
132
|
+
this.highlightedMaterials.set(child, {
|
|
133
|
+
emissive: material.emissive.clone(),
|
|
134
|
+
emissiveIntensity: material.emissiveIntensity,
|
|
135
|
+
});
|
|
136
|
+
// Apply highlight
|
|
137
|
+
material.emissive.setHex(HIGHLIGHT_EMISSIVE);
|
|
138
|
+
material.emissiveIntensity = HIGHLIGHT_INTENSITY;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove highlight effect
|
|
146
|
+
*/
|
|
147
|
+
private removeHighlight(): void {
|
|
148
|
+
this.highlightedMaterials.forEach((original, mesh) => {
|
|
149
|
+
const material = mesh.material as THREE.MeshStandardMaterial;
|
|
150
|
+
if (material.emissive) {
|
|
151
|
+
material.emissive.copy(original.emissive);
|
|
152
|
+
material.emissiveIntensity = original.emissiveIntensity;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
this.highlightedMaterials.clear();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add event listener
|
|
160
|
+
*/
|
|
161
|
+
on(event: string, callback: SelectionCallback): void {
|
|
162
|
+
if (!this.eventListeners.has(event)) {
|
|
163
|
+
this.eventListeners.set(event, new Set());
|
|
164
|
+
}
|
|
165
|
+
this.eventListeners.get(event)!.add(callback);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Emit event
|
|
170
|
+
*/
|
|
171
|
+
private emit(event: string, ...args: unknown[]): void {
|
|
172
|
+
this.eventListeners.get(event)?.forEach((callback) => callback(args[0] as THREE.Object3D | null));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Clean up
|
|
177
|
+
*/
|
|
178
|
+
dispose(): void {
|
|
179
|
+
this.editor.domElement.removeEventListener("click", this.handleClick);
|
|
180
|
+
this.removeHighlight();
|
|
181
|
+
this.eventListeners.clear();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type {
|
|
3
|
+
BlockyModel,
|
|
4
|
+
BlockyNode,
|
|
5
|
+
BlockyShape,
|
|
6
|
+
Vec3,
|
|
7
|
+
Quaternion,
|
|
8
|
+
TextureLayout,
|
|
9
|
+
ShadingMode,
|
|
10
|
+
ShapeType,
|
|
11
|
+
} from "../types/blockymodel";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a unique ID
|
|
15
|
+
*/
|
|
16
|
+
function generateId(): string {
|
|
17
|
+
return `node_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serializer for exporting Three.js scene to BlockyModel format
|
|
22
|
+
*/
|
|
23
|
+
export class Serializer {
|
|
24
|
+
/**
|
|
25
|
+
* Serialize a Three.js model group to BlockyModel JSON
|
|
26
|
+
*/
|
|
27
|
+
serialize(model: THREE.Group): BlockyModel {
|
|
28
|
+
const blockyModel: BlockyModel = {
|
|
29
|
+
format: model.userData.format || "prop",
|
|
30
|
+
lod: model.userData.lod || "auto",
|
|
31
|
+
nodes: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Serialize root-level children (skip helpers)
|
|
35
|
+
for (const child of model.children) {
|
|
36
|
+
if (this.shouldSerialize(child)) {
|
|
37
|
+
blockyModel.nodes.push(this.serializeNode(child));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return blockyModel;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if an object should be serialized
|
|
46
|
+
*/
|
|
47
|
+
private shouldSerialize(object: THREE.Object3D): boolean {
|
|
48
|
+
// Skip transform controls and other helpers
|
|
49
|
+
if (
|
|
50
|
+
object.type === "TransformControlsGizmo" ||
|
|
51
|
+
object.type === "TransformControlsPlane" ||
|
|
52
|
+
object.type === "GridHelper" ||
|
|
53
|
+
object.type === "AxesHelper"
|
|
54
|
+
) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Serialize a single node
|
|
62
|
+
*/
|
|
63
|
+
private serializeNode(object: THREE.Object3D): BlockyNode {
|
|
64
|
+
const node: BlockyNode = {
|
|
65
|
+
id: object.userData.id || object.name || generateId(),
|
|
66
|
+
name: object.name || "Unnamed",
|
|
67
|
+
position: this.serializePosition(object.position),
|
|
68
|
+
orientation: this.serializeQuaternion(object.quaternion),
|
|
69
|
+
shape: this.serializeShape(object),
|
|
70
|
+
children: [],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Serialize children
|
|
74
|
+
for (const child of object.children) {
|
|
75
|
+
if (this.shouldSerialize(child)) {
|
|
76
|
+
node.children.push(this.serializeNode(child));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return node;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Serialize position
|
|
85
|
+
*/
|
|
86
|
+
private serializePosition(position: THREE.Vector3): Vec3 {
|
|
87
|
+
return {
|
|
88
|
+
x: parseFloat(position.x.toFixed(4)),
|
|
89
|
+
y: parseFloat(position.y.toFixed(4)),
|
|
90
|
+
z: parseFloat(position.z.toFixed(4)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Serialize quaternion
|
|
96
|
+
*/
|
|
97
|
+
private serializeQuaternion(quaternion: THREE.Quaternion): Quaternion {
|
|
98
|
+
return {
|
|
99
|
+
w: parseFloat(quaternion.w.toFixed(6)),
|
|
100
|
+
x: parseFloat(quaternion.x.toFixed(6)),
|
|
101
|
+
y: parseFloat(quaternion.y.toFixed(6)),
|
|
102
|
+
z: parseFloat(quaternion.z.toFixed(6)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Serialize shape
|
|
108
|
+
*/
|
|
109
|
+
private serializeShape(object: THREE.Object3D): BlockyShape {
|
|
110
|
+
const shapeType = (object.userData.shapeType || "none") as ShapeType;
|
|
111
|
+
|
|
112
|
+
// Base shape with defaults
|
|
113
|
+
const shape: BlockyShape = {
|
|
114
|
+
type: shapeType,
|
|
115
|
+
offset: object.userData.offset || { x: 0, y: 0, z: 0 },
|
|
116
|
+
stretch: object.userData.stretch || { x: 1, y: 1, z: 1 },
|
|
117
|
+
settings: {
|
|
118
|
+
size: object.userData.originalSize || { x: 1, y: 1, z: 1 },
|
|
119
|
+
isPiece: object.userData.isPiece || false,
|
|
120
|
+
isStaticBox: object.userData.isStaticBox || false,
|
|
121
|
+
},
|
|
122
|
+
textureLayout: object.userData.textureLayout || this.getDefaultTextureLayout(),
|
|
123
|
+
unwrapMode: object.userData.unwrapMode || "custom",
|
|
124
|
+
visible: object.visible !== false,
|
|
125
|
+
doubleSided: object.userData.doubleSided || false,
|
|
126
|
+
shadingMode: (object.userData.shadingMode || "standard") as ShadingMode,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// For mesh objects, try to extract geometry info
|
|
130
|
+
if (object instanceof THREE.Mesh) {
|
|
131
|
+
const geometry = object.geometry;
|
|
132
|
+
|
|
133
|
+
// Extract size from BoxGeometry if available
|
|
134
|
+
if (geometry instanceof THREE.BoxGeometry) {
|
|
135
|
+
const params = geometry.parameters;
|
|
136
|
+
if (!object.userData.originalSize) {
|
|
137
|
+
shape.settings.size = {
|
|
138
|
+
x: params.width,
|
|
139
|
+
y: params.height,
|
|
140
|
+
z: params.depth,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Extract material properties
|
|
146
|
+
const material = object.material as THREE.MeshStandardMaterial;
|
|
147
|
+
if (material) {
|
|
148
|
+
shape.doubleSided = material.side === THREE.DoubleSide;
|
|
149
|
+
|
|
150
|
+
// Infer shading mode from material properties
|
|
151
|
+
if (material.emissiveIntensity > 0.5) {
|
|
152
|
+
shape.shadingMode = "fullbright";
|
|
153
|
+
} else if (material.flatShading) {
|
|
154
|
+
shape.shadingMode = "flat";
|
|
155
|
+
} else if (material.metalness > 0.5) {
|
|
156
|
+
shape.shadingMode = "reflective";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// For quads, include normal direction
|
|
162
|
+
if (shapeType === "quad" && object.userData.normal) {
|
|
163
|
+
shape.settings.normal = object.userData.normal;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return shape;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get default texture layout
|
|
171
|
+
*/
|
|
172
|
+
private getDefaultTextureLayout(): TextureLayout {
|
|
173
|
+
const defaultFace = {
|
|
174
|
+
offset: { x: 0, y: 0 },
|
|
175
|
+
mirror: { x: false, y: false },
|
|
176
|
+
angle: 0 as const,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
front: { ...defaultFace },
|
|
181
|
+
back: { ...defaultFace },
|
|
182
|
+
left: { ...defaultFace },
|
|
183
|
+
right: { ...defaultFace },
|
|
184
|
+
top: { ...defaultFace },
|
|
185
|
+
bottom: { ...defaultFace },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Export model to JSON string
|
|
191
|
+
*/
|
|
192
|
+
toJSON(model: THREE.Group): string {
|
|
193
|
+
const blockyModel = this.serialize(model);
|
|
194
|
+
return JSON.stringify(blockyModel, null, 2);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Download model as .blockymodel file
|
|
199
|
+
*/
|
|
200
|
+
download(model: THREE.Group, filename: string = "model.blockymodel"): void {
|
|
201
|
+
const json = this.toJSON(model);
|
|
202
|
+
const blob = new Blob([json], { type: "application/json" });
|
|
203
|
+
const url = URL.createObjectURL(blob);
|
|
204
|
+
|
|
205
|
+
const link = document.createElement("a");
|
|
206
|
+
link.href = url;
|
|
207
|
+
link.download = filename.endsWith(".blockymodel") ? filename : `${filename}.blockymodel`;
|
|
208
|
+
link.click();
|
|
209
|
+
|
|
210
|
+
URL.revokeObjectURL(url);
|
|
211
|
+
}
|
|
212
|
+
}
|