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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/blockymodel-web.js +2717 -0
  3. package/dist/blockymodel-web.js.map +1 -0
  4. package/dist/blockymodel-web.umd.cjs +122 -0
  5. package/dist/blockymodel-web.umd.cjs.map +1 -0
  6. package/dist/editor/Editor.d.ts +94 -0
  7. package/dist/editor/Editor.d.ts.map +1 -0
  8. package/dist/editor/History.d.ts +52 -0
  9. package/dist/editor/History.d.ts.map +1 -0
  10. package/dist/editor/SelectionManager.d.ts +57 -0
  11. package/dist/editor/SelectionManager.d.ts.map +1 -0
  12. package/dist/editor/Serializer.d.ts +44 -0
  13. package/dist/editor/Serializer.d.ts.map +1 -0
  14. package/dist/editor/TransformManager.d.ts +73 -0
  15. package/dist/editor/TransformManager.d.ts.map +1 -0
  16. package/dist/editor/commands/AddNodeCommand.d.ts +24 -0
  17. package/dist/editor/commands/AddNodeCommand.d.ts.map +1 -0
  18. package/dist/editor/commands/Command.d.ts +50 -0
  19. package/dist/editor/commands/Command.d.ts.map +1 -0
  20. package/dist/editor/commands/RemoveNodeCommand.d.ts +28 -0
  21. package/dist/editor/commands/RemoveNodeCommand.d.ts.map +1 -0
  22. package/dist/editor/commands/SetPositionCommand.d.ts +24 -0
  23. package/dist/editor/commands/SetPositionCommand.d.ts.map +1 -0
  24. package/dist/editor/commands/SetPropertyCommand.d.ts +41 -0
  25. package/dist/editor/commands/SetPropertyCommand.d.ts.map +1 -0
  26. package/dist/editor/commands/SetRotationCommand.d.ts +24 -0
  27. package/dist/editor/commands/SetRotationCommand.d.ts.map +1 -0
  28. package/dist/editor/commands/SetScaleCommand.d.ts +24 -0
  29. package/dist/editor/commands/SetScaleCommand.d.ts.map +1 -0
  30. package/dist/editor/index.d.ts +15 -0
  31. package/dist/editor/index.d.ts.map +1 -0
  32. package/dist/index.d.ts +21 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/loaders/BlockyModelLoader.d.ts +48 -0
  35. package/dist/loaders/BlockyModelLoader.d.ts.map +1 -0
  36. package/dist/types/blockymodel.d.ts +72 -0
  37. package/dist/types/blockymodel.d.ts.map +1 -0
  38. package/dist/ui/HierarchyPanel.d.ts +73 -0
  39. package/dist/ui/HierarchyPanel.d.ts.map +1 -0
  40. package/dist/ui/PropertyPanel.d.ts +59 -0
  41. package/dist/ui/PropertyPanel.d.ts.map +1 -0
  42. package/dist/ui/UVEditor.d.ts +56 -0
  43. package/dist/ui/UVEditor.d.ts.map +1 -0
  44. package/dist/ui/index.d.ts +4 -0
  45. package/dist/ui/index.d.ts.map +1 -0
  46. package/dist/viewer/ViewerController.d.ts +71 -0
  47. package/dist/viewer/ViewerController.d.ts.map +1 -0
  48. package/package.json +63 -0
  49. package/src/editor/Editor.ts +196 -0
  50. package/src/editor/History.ts +123 -0
  51. package/src/editor/SelectionManager.ts +183 -0
  52. package/src/editor/Serializer.ts +212 -0
  53. package/src/editor/TransformManager.ts +270 -0
  54. package/src/editor/commands/AddNodeCommand.ts +53 -0
  55. package/src/editor/commands/Command.ts +63 -0
  56. package/src/editor/commands/RemoveNodeCommand.ts +59 -0
  57. package/src/editor/commands/SetPositionCommand.ts +51 -0
  58. package/src/editor/commands/SetPropertyCommand.ts +100 -0
  59. package/src/editor/commands/SetRotationCommand.ts +51 -0
  60. package/src/editor/commands/SetScaleCommand.ts +51 -0
  61. package/src/editor/index.ts +19 -0
  62. package/src/index.ts +49 -0
  63. package/src/loaders/BlockyModelLoader.ts +281 -0
  64. package/src/main.ts +290 -0
  65. package/src/styles.css +597 -0
  66. package/src/types/blockymodel.ts +82 -0
  67. package/src/ui/HierarchyPanel.ts +343 -0
  68. package/src/ui/PropertyPanel.ts +434 -0
  69. package/src/ui/UVEditor.ts +336 -0
  70. package/src/ui/index.ts +4 -0
  71. 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
+ }