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,343 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { Editor } from "../editor/Editor";
|
|
3
|
+
import { AddNodeCommand } from "../editor/commands/AddNodeCommand";
|
|
4
|
+
import { RemoveNodeCommand } from "../editor/commands/RemoveNodeCommand";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hierarchy panel showing the model tree structure
|
|
8
|
+
*/
|
|
9
|
+
export class HierarchyPanel {
|
|
10
|
+
private editor: Editor;
|
|
11
|
+
private container: HTMLElement;
|
|
12
|
+
private treeContainer!: HTMLElement;
|
|
13
|
+
private contextMenu: HTMLElement | null = null;
|
|
14
|
+
private contextTarget: THREE.Object3D | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(editor: Editor, containerId: string) {
|
|
17
|
+
this.editor = editor;
|
|
18
|
+
const container = document.getElementById(containerId);
|
|
19
|
+
if (!container) {
|
|
20
|
+
throw new Error(`Hierarchy panel container not found: ${containerId}`);
|
|
21
|
+
}
|
|
22
|
+
this.container = container;
|
|
23
|
+
|
|
24
|
+
this.buildUI();
|
|
25
|
+
this.setupEventListeners();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the hierarchy panel UI
|
|
30
|
+
*/
|
|
31
|
+
private buildUI(): void {
|
|
32
|
+
this.container.innerHTML = `
|
|
33
|
+
<div class="hierarchy-tree" id="hierarchy-tree"></div>
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
this.treeContainer = document.getElementById("hierarchy-tree") as HTMLElement;
|
|
37
|
+
|
|
38
|
+
// Create context menu (hidden by default)
|
|
39
|
+
this.contextMenu = document.createElement("div");
|
|
40
|
+
this.contextMenu.className = "context-menu";
|
|
41
|
+
this.contextMenu.innerHTML = `
|
|
42
|
+
<div class="context-menu-item" data-action="add-box">Add Child Box</div>
|
|
43
|
+
<div class="context-menu-item" data-action="add-group">Add Child Group</div>
|
|
44
|
+
<div class="context-menu-separator"></div>
|
|
45
|
+
<div class="context-menu-item" data-action="duplicate">Duplicate</div>
|
|
46
|
+
<div class="context-menu-separator"></div>
|
|
47
|
+
<div class="context-menu-item context-menu-danger" data-action="delete">Delete</div>
|
|
48
|
+
`;
|
|
49
|
+
this.contextMenu.style.display = "none";
|
|
50
|
+
document.body.appendChild(this.contextMenu);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Setup event listeners
|
|
55
|
+
*/
|
|
56
|
+
private setupEventListeners(): void {
|
|
57
|
+
// Editor events
|
|
58
|
+
this.editor.on("modelLoaded", () => {
|
|
59
|
+
this.refresh();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.editor.on("selectionChanged", () => {
|
|
63
|
+
this.updateSelection();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.editor.on("objectChanged", () => {
|
|
67
|
+
this.refresh();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Context menu handlers
|
|
71
|
+
this.contextMenu?.addEventListener("click", (e) => {
|
|
72
|
+
const target = e.target as HTMLElement;
|
|
73
|
+
const action = target.dataset.action;
|
|
74
|
+
if (action && this.contextTarget) {
|
|
75
|
+
this.handleContextAction(action, this.contextTarget);
|
|
76
|
+
}
|
|
77
|
+
this.hideContextMenu();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Hide context menu on click outside
|
|
81
|
+
document.addEventListener("click", (e) => {
|
|
82
|
+
if (this.contextMenu && !this.contextMenu.contains(e.target as Node)) {
|
|
83
|
+
this.hideContextMenu();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Keyboard shortcuts
|
|
88
|
+
document.addEventListener("keydown", (e) => {
|
|
89
|
+
if (e.key === "Delete" || e.key === "Backspace") {
|
|
90
|
+
const selected = this.editor.getSelected();
|
|
91
|
+
if (selected && selected.parent && !(e.target instanceof HTMLInputElement)) {
|
|
92
|
+
this.deleteNode(selected);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refresh the hierarchy tree
|
|
100
|
+
*/
|
|
101
|
+
refresh(): void {
|
|
102
|
+
const model = this.editor.getModel();
|
|
103
|
+
if (!model) {
|
|
104
|
+
this.treeContainer.innerHTML = `<p class="hierarchy-empty">No model loaded</p>`;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.treeContainer.innerHTML = "";
|
|
109
|
+
this.buildTree(model, this.treeContainer, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build tree recursively
|
|
114
|
+
*/
|
|
115
|
+
private buildTree(object: THREE.Object3D, parent: HTMLElement, depth: number): void {
|
|
116
|
+
const item = document.createElement("div");
|
|
117
|
+
item.className = "hierarchy-item";
|
|
118
|
+
item.dataset.objectId = object.uuid;
|
|
119
|
+
|
|
120
|
+
const indent = document.createElement("span");
|
|
121
|
+
indent.className = "hierarchy-indent";
|
|
122
|
+
indent.style.width = `${depth * 16}px`;
|
|
123
|
+
|
|
124
|
+
const expander = document.createElement("span");
|
|
125
|
+
expander.className = "hierarchy-expander";
|
|
126
|
+
if (object.children.length > 0) {
|
|
127
|
+
expander.textContent = "▼";
|
|
128
|
+
expander.addEventListener("click", (e) => {
|
|
129
|
+
e.stopPropagation();
|
|
130
|
+
this.toggleExpand(item);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const icon = document.createElement("span");
|
|
135
|
+
icon.className = "hierarchy-icon";
|
|
136
|
+
icon.textContent = this.getIcon(object);
|
|
137
|
+
|
|
138
|
+
const label = document.createElement("span");
|
|
139
|
+
label.className = "hierarchy-label";
|
|
140
|
+
label.textContent = object.name || `(${object.type})`;
|
|
141
|
+
|
|
142
|
+
item.appendChild(indent);
|
|
143
|
+
item.appendChild(expander);
|
|
144
|
+
item.appendChild(icon);
|
|
145
|
+
item.appendChild(label);
|
|
146
|
+
|
|
147
|
+
// Click to select
|
|
148
|
+
item.addEventListener("click", () => {
|
|
149
|
+
this.editor.select(object);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Right-click for context menu
|
|
153
|
+
item.addEventListener("contextmenu", (e) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
this.showContextMenu(e, object);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
parent.appendChild(item);
|
|
159
|
+
|
|
160
|
+
// Children container
|
|
161
|
+
if (object.children.length > 0) {
|
|
162
|
+
const children = document.createElement("div");
|
|
163
|
+
children.className = "hierarchy-children";
|
|
164
|
+
for (const child of object.children) {
|
|
165
|
+
// Skip helper objects
|
|
166
|
+
if (child.type === "TransformControlsGizmo" || child.type === "TransformControlsPlane") {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
this.buildTree(child, children, depth + 1);
|
|
170
|
+
}
|
|
171
|
+
parent.appendChild(children);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get icon for object type
|
|
177
|
+
*/
|
|
178
|
+
private getIcon(object: THREE.Object3D): string {
|
|
179
|
+
if (object instanceof THREE.Mesh) {
|
|
180
|
+
const shapeType = object.userData.shapeType;
|
|
181
|
+
if (shapeType === "box") return "📦";
|
|
182
|
+
if (shapeType === "quad") return "▫️";
|
|
183
|
+
return "🔷";
|
|
184
|
+
}
|
|
185
|
+
if (object instanceof THREE.Group) return "📁";
|
|
186
|
+
return "◯";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Toggle expand/collapse
|
|
191
|
+
*/
|
|
192
|
+
private toggleExpand(item: HTMLElement): void {
|
|
193
|
+
const children = item.nextElementSibling;
|
|
194
|
+
if (children?.classList.contains("hierarchy-children")) {
|
|
195
|
+
const isCollapsed = children.classList.toggle("collapsed");
|
|
196
|
+
const expander = item.querySelector(".hierarchy-expander");
|
|
197
|
+
if (expander) {
|
|
198
|
+
expander.textContent = isCollapsed ? "▶" : "▼";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Update selection highlight
|
|
205
|
+
*/
|
|
206
|
+
private updateSelection(): void {
|
|
207
|
+
// Remove all selection highlights
|
|
208
|
+
this.treeContainer.querySelectorAll(".hierarchy-item.selected").forEach((el) => {
|
|
209
|
+
el.classList.remove("selected");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Highlight selected object
|
|
213
|
+
const selected = this.editor.getSelected();
|
|
214
|
+
if (selected) {
|
|
215
|
+
const item = this.treeContainer.querySelector(`[data-object-id="${selected.uuid}"]`);
|
|
216
|
+
if (item) {
|
|
217
|
+
item.classList.add("selected");
|
|
218
|
+
// Scroll into view
|
|
219
|
+
item.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Show context menu
|
|
226
|
+
*/
|
|
227
|
+
private showContextMenu(event: MouseEvent, object: THREE.Object3D): void {
|
|
228
|
+
if (!this.contextMenu) return;
|
|
229
|
+
|
|
230
|
+
this.contextTarget = object;
|
|
231
|
+
this.contextMenu.style.display = "block";
|
|
232
|
+
this.contextMenu.style.left = `${event.clientX}px`;
|
|
233
|
+
this.contextMenu.style.top = `${event.clientY}px`;
|
|
234
|
+
|
|
235
|
+
// Ensure menu stays within viewport
|
|
236
|
+
const rect = this.contextMenu.getBoundingClientRect();
|
|
237
|
+
if (rect.right > window.innerWidth) {
|
|
238
|
+
this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
|
|
239
|
+
}
|
|
240
|
+
if (rect.bottom > window.innerHeight) {
|
|
241
|
+
this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Hide context menu
|
|
247
|
+
*/
|
|
248
|
+
private hideContextMenu(): void {
|
|
249
|
+
if (this.contextMenu) {
|
|
250
|
+
this.contextMenu.style.display = "none";
|
|
251
|
+
}
|
|
252
|
+
this.contextTarget = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Handle context menu action
|
|
257
|
+
*/
|
|
258
|
+
private handleContextAction(action: string, target: THREE.Object3D): void {
|
|
259
|
+
switch (action) {
|
|
260
|
+
case "add-box":
|
|
261
|
+
this.addChildBox(target);
|
|
262
|
+
break;
|
|
263
|
+
case "add-group":
|
|
264
|
+
this.addChildGroup(target);
|
|
265
|
+
break;
|
|
266
|
+
case "duplicate":
|
|
267
|
+
this.duplicateNode(target);
|
|
268
|
+
break;
|
|
269
|
+
case "delete":
|
|
270
|
+
this.deleteNode(target);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Add a child box
|
|
277
|
+
*/
|
|
278
|
+
private addChildBox(parent: THREE.Object3D): void {
|
|
279
|
+
const geometry = new THREE.BoxGeometry(10, 10, 10);
|
|
280
|
+
const material = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.7 });
|
|
281
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
282
|
+
|
|
283
|
+
mesh.name = `Box_${Date.now().toString(36)}`;
|
|
284
|
+
mesh.userData.id = mesh.name;
|
|
285
|
+
mesh.userData.shapeType = "box";
|
|
286
|
+
mesh.userData.originalSize = { x: 10, y: 10, z: 10 };
|
|
287
|
+
|
|
288
|
+
this.editor.execute(new AddNodeCommand(parent, mesh));
|
|
289
|
+
this.editor.select(mesh);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Add a child group
|
|
294
|
+
*/
|
|
295
|
+
private addChildGroup(parent: THREE.Object3D): void {
|
|
296
|
+
const group = new THREE.Group();
|
|
297
|
+
group.name = `Group_${Date.now().toString(36)}`;
|
|
298
|
+
group.userData.id = group.name;
|
|
299
|
+
group.userData.shapeType = "none";
|
|
300
|
+
|
|
301
|
+
this.editor.execute(new AddNodeCommand(parent, group));
|
|
302
|
+
this.editor.select(group);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Duplicate a node
|
|
307
|
+
*/
|
|
308
|
+
private duplicateNode(node: THREE.Object3D): void {
|
|
309
|
+
if (!node.parent) return;
|
|
310
|
+
|
|
311
|
+
const clone = node.clone(true);
|
|
312
|
+
clone.name = `${node.name}_copy`;
|
|
313
|
+
clone.userData.id = clone.name;
|
|
314
|
+
clone.position.x += 10; // Offset to make it visible
|
|
315
|
+
|
|
316
|
+
this.editor.execute(new AddNodeCommand(node.parent, clone));
|
|
317
|
+
this.editor.select(clone);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Delete a node
|
|
322
|
+
*/
|
|
323
|
+
private deleteNode(node: THREE.Object3D): void {
|
|
324
|
+
if (!node.parent) return;
|
|
325
|
+
|
|
326
|
+
// Don't delete root model
|
|
327
|
+
const model = this.editor.getModel();
|
|
328
|
+
if (node === model) return;
|
|
329
|
+
|
|
330
|
+
this.editor.execute(new RemoveNodeCommand(node));
|
|
331
|
+
this.editor.select(null);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Cleanup
|
|
336
|
+
*/
|
|
337
|
+
dispose(): void {
|
|
338
|
+
if (this.contextMenu) {
|
|
339
|
+
document.body.removeChild(this.contextMenu);
|
|
340
|
+
}
|
|
341
|
+
this.container.innerHTML = "";
|
|
342
|
+
}
|
|
343
|
+
}
|