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,336 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { Editor } from "../editor/Editor";
|
|
3
|
+
import { SetPropertyCommand } from "../editor/commands/SetPropertyCommand";
|
|
4
|
+
import type { FaceUV, TextureLayout } from "../types/blockymodel";
|
|
5
|
+
|
|
6
|
+
type FaceName = "front" | "back" | "left" | "right" | "top" | "bottom";
|
|
7
|
+
|
|
8
|
+
const FACE_NAMES: FaceName[] = ["front", "back", "left", "right", "top", "bottom"];
|
|
9
|
+
const FACE_LABELS: Record<FaceName, string> = {
|
|
10
|
+
front: "Front (+Z)",
|
|
11
|
+
back: "Back (-Z)",
|
|
12
|
+
left: "Left (-X)",
|
|
13
|
+
right: "Right (+X)",
|
|
14
|
+
top: "Top (+Y)",
|
|
15
|
+
bottom: "Bottom (-Y)",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FACE_UV: FaceUV = {
|
|
19
|
+
offset: { x: 0, y: 0 },
|
|
20
|
+
mirror: { x: false, y: false },
|
|
21
|
+
angle: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* UV Editor panel for per-face texture UV editing
|
|
26
|
+
*/
|
|
27
|
+
export class UVEditor {
|
|
28
|
+
private editor: Editor;
|
|
29
|
+
private container: HTMLElement;
|
|
30
|
+
private currentObject: THREE.Object3D | null = null;
|
|
31
|
+
private isUpdating: boolean = false;
|
|
32
|
+
private faceInputs: Map<FaceName, {
|
|
33
|
+
offsetX: HTMLInputElement;
|
|
34
|
+
offsetY: HTMLInputElement;
|
|
35
|
+
mirrorX: HTMLInputElement;
|
|
36
|
+
mirrorY: HTMLInputElement;
|
|
37
|
+
angle: HTMLSelectElement;
|
|
38
|
+
}> = new Map();
|
|
39
|
+
|
|
40
|
+
constructor(editor: Editor, containerId: string) {
|
|
41
|
+
this.editor = editor;
|
|
42
|
+
const container = document.getElementById(containerId);
|
|
43
|
+
if (!container) {
|
|
44
|
+
throw new Error(`UV Editor container not found: ${containerId}`);
|
|
45
|
+
}
|
|
46
|
+
this.container = container;
|
|
47
|
+
|
|
48
|
+
this.buildUI();
|
|
49
|
+
this.setupEventListeners();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the UV editor UI
|
|
54
|
+
*/
|
|
55
|
+
private buildUI(): void {
|
|
56
|
+
let facesHtml = "";
|
|
57
|
+
|
|
58
|
+
for (const face of FACE_NAMES) {
|
|
59
|
+
facesHtml += `
|
|
60
|
+
<div class="uv-face">
|
|
61
|
+
<div class="uv-face-header">${FACE_LABELS[face]}</div>
|
|
62
|
+
<div class="uv-face-row">
|
|
63
|
+
<label>Offset</label>
|
|
64
|
+
<input type="number" id="uv-${face}-offset-x" class="uv-input" step="1" placeholder="X" />
|
|
65
|
+
<input type="number" id="uv-${face}-offset-y" class="uv-input" step="1" placeholder="Y" />
|
|
66
|
+
</div>
|
|
67
|
+
<div class="uv-face-row">
|
|
68
|
+
<label>Mirror</label>
|
|
69
|
+
<label class="uv-checkbox-label">
|
|
70
|
+
<input type="checkbox" id="uv-${face}-mirror-x" class="uv-checkbox" /> X
|
|
71
|
+
</label>
|
|
72
|
+
<label class="uv-checkbox-label">
|
|
73
|
+
<input type="checkbox" id="uv-${face}-mirror-y" class="uv-checkbox" /> Y
|
|
74
|
+
</label>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="uv-face-row">
|
|
77
|
+
<label>Rotation</label>
|
|
78
|
+
<select id="uv-${face}-angle" class="uv-select">
|
|
79
|
+
<option value="0">0°</option>
|
|
80
|
+
<option value="90">90°</option>
|
|
81
|
+
<option value="180">180°</option>
|
|
82
|
+
<option value="270">270°</option>
|
|
83
|
+
</select>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.container.innerHTML = `
|
|
90
|
+
<div class="uv-editor-content" id="uv-editor-content">
|
|
91
|
+
${facesHtml}
|
|
92
|
+
</div>
|
|
93
|
+
<div class="uv-editor-empty" id="uv-editor-empty">
|
|
94
|
+
<p>Select a box to edit UVs</p>
|
|
95
|
+
</div>
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
// Cache input references
|
|
99
|
+
for (const face of FACE_NAMES) {
|
|
100
|
+
this.faceInputs.set(face, {
|
|
101
|
+
offsetX: document.getElementById(`uv-${face}-offset-x`) as HTMLInputElement,
|
|
102
|
+
offsetY: document.getElementById(`uv-${face}-offset-y`) as HTMLInputElement,
|
|
103
|
+
mirrorX: document.getElementById(`uv-${face}-mirror-x`) as HTMLInputElement,
|
|
104
|
+
mirrorY: document.getElementById(`uv-${face}-mirror-y`) as HTMLInputElement,
|
|
105
|
+
angle: document.getElementById(`uv-${face}-angle`) as HTMLSelectElement,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Initially show empty state
|
|
110
|
+
this.showEmpty();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Setup event listeners
|
|
115
|
+
*/
|
|
116
|
+
private setupEventListeners(): void {
|
|
117
|
+
// Editor events
|
|
118
|
+
this.editor.on("selectionChanged", (object) => {
|
|
119
|
+
this.setObject(object as THREE.Object3D | null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.editor.on("objectChanged", () => {
|
|
123
|
+
if (this.currentObject) {
|
|
124
|
+
this.updateFromObject();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Setup input handlers for each face
|
|
129
|
+
for (const face of FACE_NAMES) {
|
|
130
|
+
const inputs = this.faceInputs.get(face)!;
|
|
131
|
+
|
|
132
|
+
// Offset X
|
|
133
|
+
inputs.offsetX.addEventListener("change", () => {
|
|
134
|
+
this.handleFaceChange(face);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Offset Y
|
|
138
|
+
inputs.offsetY.addEventListener("change", () => {
|
|
139
|
+
this.handleFaceChange(face);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Mirror X
|
|
143
|
+
inputs.mirrorX.addEventListener("change", () => {
|
|
144
|
+
this.handleFaceChange(face);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Mirror Y
|
|
148
|
+
inputs.mirrorY.addEventListener("change", () => {
|
|
149
|
+
this.handleFaceChange(face);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Angle
|
|
153
|
+
inputs.angle.addEventListener("change", () => {
|
|
154
|
+
this.handleFaceChange(face);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Handle face UV change
|
|
161
|
+
*/
|
|
162
|
+
private handleFaceChange(face: FaceName): void {
|
|
163
|
+
if (!this.currentObject || this.isUpdating) return;
|
|
164
|
+
|
|
165
|
+
const inputs = this.faceInputs.get(face)!;
|
|
166
|
+
const textureLayout = this.getTextureLayout();
|
|
167
|
+
const oldFaceUV = textureLayout[face] || { ...DEFAULT_FACE_UV };
|
|
168
|
+
|
|
169
|
+
const newFaceUV: FaceUV = {
|
|
170
|
+
offset: {
|
|
171
|
+
x: parseInt(inputs.offsetX.value) || 0,
|
|
172
|
+
y: parseInt(inputs.offsetY.value) || 0,
|
|
173
|
+
},
|
|
174
|
+
mirror: {
|
|
175
|
+
x: inputs.mirrorX.checked,
|
|
176
|
+
y: inputs.mirrorY.checked,
|
|
177
|
+
},
|
|
178
|
+
angle: parseInt(inputs.angle.value) as 0 | 90 | 180 | 270,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const newTextureLayout = { ...textureLayout, [face]: newFaceUV };
|
|
182
|
+
|
|
183
|
+
this.editor.execute(
|
|
184
|
+
new SetPropertyCommand(
|
|
185
|
+
this.currentObject,
|
|
186
|
+
"userData.textureLayout",
|
|
187
|
+
newTextureLayout,
|
|
188
|
+
textureLayout
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Apply UV changes to the geometry
|
|
193
|
+
this.applyTextureLayout(newTextureLayout);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get texture layout from current object
|
|
198
|
+
*/
|
|
199
|
+
private getTextureLayout(): TextureLayout {
|
|
200
|
+
return this.currentObject?.userData.textureLayout || {};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Set the object to edit UVs for
|
|
205
|
+
*/
|
|
206
|
+
setObject(object: THREE.Object3D | null): void {
|
|
207
|
+
// Only show UV editor for box meshes
|
|
208
|
+
if (object && object instanceof THREE.Mesh && object.userData.shapeType === "box") {
|
|
209
|
+
this.currentObject = object;
|
|
210
|
+
this.showContent();
|
|
211
|
+
this.updateFromObject();
|
|
212
|
+
} else {
|
|
213
|
+
this.currentObject = null;
|
|
214
|
+
this.showEmpty();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Update UI from current object
|
|
220
|
+
*/
|
|
221
|
+
private updateFromObject(): void {
|
|
222
|
+
if (!this.currentObject) return;
|
|
223
|
+
|
|
224
|
+
this.isUpdating = true;
|
|
225
|
+
|
|
226
|
+
const textureLayout = this.getTextureLayout();
|
|
227
|
+
|
|
228
|
+
for (const face of FACE_NAMES) {
|
|
229
|
+
const inputs = this.faceInputs.get(face)!;
|
|
230
|
+
const faceUV = textureLayout[face] || DEFAULT_FACE_UV;
|
|
231
|
+
|
|
232
|
+
inputs.offsetX.value = faceUV.offset?.x?.toString() || "0";
|
|
233
|
+
inputs.offsetY.value = faceUV.offset?.y?.toString() || "0";
|
|
234
|
+
inputs.mirrorX.checked = faceUV.mirror?.x || false;
|
|
235
|
+
inputs.mirrorY.checked = faceUV.mirror?.y || false;
|
|
236
|
+
inputs.angle.value = faceUV.angle?.toString() || "0";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.isUpdating = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Apply texture layout to geometry UVs
|
|
244
|
+
* Note: This is a simplified implementation - full UV mapping would require
|
|
245
|
+
* knowing the texture atlas dimensions
|
|
246
|
+
*/
|
|
247
|
+
private applyTextureLayout(layout: TextureLayout): void {
|
|
248
|
+
if (!this.currentObject || !(this.currentObject instanceof THREE.Mesh)) return;
|
|
249
|
+
|
|
250
|
+
const mesh = this.currentObject;
|
|
251
|
+
const geometry = mesh.geometry as THREE.BufferGeometry;
|
|
252
|
+
const uvAttribute = geometry.getAttribute("uv");
|
|
253
|
+
|
|
254
|
+
if (!uvAttribute) return;
|
|
255
|
+
|
|
256
|
+
// BoxGeometry has 6 faces, 2 triangles each, 3 vertices each = 36 vertices
|
|
257
|
+
// Face order: +x, -x, +y, -y, +z, -z
|
|
258
|
+
// For each face: 6 vertices (2 triangles)
|
|
259
|
+
const faceIndexMap: Record<FaceName, number> = {
|
|
260
|
+
right: 0,
|
|
261
|
+
left: 1,
|
|
262
|
+
top: 2,
|
|
263
|
+
bottom: 3,
|
|
264
|
+
front: 4,
|
|
265
|
+
back: 5,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const uvs = uvAttribute.array as Float32Array;
|
|
269
|
+
|
|
270
|
+
for (const face of FACE_NAMES) {
|
|
271
|
+
const faceUV = layout[face] || DEFAULT_FACE_UV;
|
|
272
|
+
const faceIndex = faceIndexMap[face];
|
|
273
|
+
const startVertex = faceIndex * 6;
|
|
274
|
+
|
|
275
|
+
// Offset (in texture units, normalized to 0-1 assuming 64px texture)
|
|
276
|
+
const offsetX = (faceUV.offset?.x || 0) / 64;
|
|
277
|
+
const offsetY = (faceUV.offset?.y || 0) / 64;
|
|
278
|
+
|
|
279
|
+
// Apply offset to each vertex's UV
|
|
280
|
+
for (let i = 0; i < 6; i++) {
|
|
281
|
+
const idx = (startVertex + i) * 2;
|
|
282
|
+
let u = uvs[idx];
|
|
283
|
+
let v = uvs[idx + 1];
|
|
284
|
+
|
|
285
|
+
// Mirror
|
|
286
|
+
if (faceUV.mirror?.x) u = 1 - u;
|
|
287
|
+
if (faceUV.mirror?.y) v = 1 - v;
|
|
288
|
+
|
|
289
|
+
// Rotation around center
|
|
290
|
+
if (faceUV.angle) {
|
|
291
|
+
const rad = (faceUV.angle * Math.PI) / 180;
|
|
292
|
+
const cu = u - 0.5;
|
|
293
|
+
const cv = v - 0.5;
|
|
294
|
+
u = cu * Math.cos(rad) - cv * Math.sin(rad) + 0.5;
|
|
295
|
+
v = cu * Math.sin(rad) + cv * Math.cos(rad) + 0.5;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Apply offset
|
|
299
|
+
uvs[idx] = u + offsetX;
|
|
300
|
+
uvs[idx + 1] = v + offsetY;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
uvAttribute.needsUpdate = true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Show content
|
|
309
|
+
*/
|
|
310
|
+
private showContent(): void {
|
|
311
|
+
const content = document.getElementById("uv-editor-content");
|
|
312
|
+
const empty = document.getElementById("uv-editor-empty");
|
|
313
|
+
|
|
314
|
+
if (content) content.style.display = "block";
|
|
315
|
+
if (empty) empty.style.display = "none";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Show empty state
|
|
320
|
+
*/
|
|
321
|
+
private showEmpty(): void {
|
|
322
|
+
const content = document.getElementById("uv-editor-content");
|
|
323
|
+
const empty = document.getElementById("uv-editor-empty");
|
|
324
|
+
|
|
325
|
+
if (content) content.style.display = "none";
|
|
326
|
+
if (empty) empty.style.display = "block";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Cleanup
|
|
331
|
+
*/
|
|
332
|
+
dispose(): void {
|
|
333
|
+
this.container.innerHTML = "";
|
|
334
|
+
this.faceInputs.clear();
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
3
|
+
import { BlockyModelLoader, applyTextureToModel } from "../loaders/BlockyModelLoader";
|
|
4
|
+
|
|
5
|
+
export interface ViewerOptions {
|
|
6
|
+
container: HTMLElement;
|
|
7
|
+
backgroundColor?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Main viewer controller
|
|
12
|
+
* Manages Three.js scene, camera, controls, and model loading
|
|
13
|
+
*/
|
|
14
|
+
export class ViewerController {
|
|
15
|
+
public readonly scene: THREE.Scene;
|
|
16
|
+
public readonly camera: THREE.PerspectiveCamera;
|
|
17
|
+
public readonly renderer: THREE.WebGLRenderer;
|
|
18
|
+
public readonly controls: OrbitControls;
|
|
19
|
+
public readonly domElement: HTMLElement;
|
|
20
|
+
|
|
21
|
+
private loader: BlockyModelLoader;
|
|
22
|
+
private textureLoader: THREE.TextureLoader;
|
|
23
|
+
private currentModel: THREE.Group | null = null;
|
|
24
|
+
private animationId: number | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(options: ViewerOptions) {
|
|
27
|
+
const { container, backgroundColor = 0x1a1a2e } = options;
|
|
28
|
+
|
|
29
|
+
this.domElement = container;
|
|
30
|
+
|
|
31
|
+
// Scene setup
|
|
32
|
+
this.scene = new THREE.Scene();
|
|
33
|
+
this.scene.background = new THREE.Color(backgroundColor);
|
|
34
|
+
|
|
35
|
+
// Camera setup
|
|
36
|
+
const aspect = container.clientWidth / container.clientHeight;
|
|
37
|
+
this.camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000);
|
|
38
|
+
this.camera.position.set(50, 50, 100);
|
|
39
|
+
|
|
40
|
+
// Renderer setup
|
|
41
|
+
this.renderer = new THREE.WebGLRenderer({
|
|
42
|
+
antialias: true,
|
|
43
|
+
alpha: true,
|
|
44
|
+
});
|
|
45
|
+
this.renderer.setSize(container.clientWidth, container.clientHeight);
|
|
46
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
47
|
+
this.renderer.shadowMap.enabled = true;
|
|
48
|
+
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
49
|
+
container.appendChild(this.renderer.domElement);
|
|
50
|
+
|
|
51
|
+
// Controls setup
|
|
52
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
53
|
+
this.controls.enableDamping = true;
|
|
54
|
+
this.controls.dampingFactor = 0.1;
|
|
55
|
+
this.controls.minDistance = 10;
|
|
56
|
+
this.controls.maxDistance = 500;
|
|
57
|
+
|
|
58
|
+
// Loaders
|
|
59
|
+
this.loader = new BlockyModelLoader();
|
|
60
|
+
this.textureLoader = new THREE.TextureLoader();
|
|
61
|
+
|
|
62
|
+
// Lighting
|
|
63
|
+
this.setupLighting();
|
|
64
|
+
|
|
65
|
+
// Grid helper
|
|
66
|
+
this.setupHelpers();
|
|
67
|
+
|
|
68
|
+
// Handle resize
|
|
69
|
+
window.addEventListener("resize", () => this.handleResize(container));
|
|
70
|
+
|
|
71
|
+
// Start render loop
|
|
72
|
+
this.animate();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private setupLighting(): void {
|
|
76
|
+
// Ambient light for base illumination
|
|
77
|
+
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
|
|
78
|
+
this.scene.add(ambient);
|
|
79
|
+
|
|
80
|
+
// Main directional light
|
|
81
|
+
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
82
|
+
mainLight.position.set(50, 100, 50);
|
|
83
|
+
mainLight.castShadow = true;
|
|
84
|
+
mainLight.shadow.mapSize.width = 2048;
|
|
85
|
+
mainLight.shadow.mapSize.height = 2048;
|
|
86
|
+
this.scene.add(mainLight);
|
|
87
|
+
|
|
88
|
+
// Fill light from opposite side
|
|
89
|
+
const fillLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
|
90
|
+
fillLight.position.set(-50, 50, -50);
|
|
91
|
+
this.scene.add(fillLight);
|
|
92
|
+
|
|
93
|
+
// Rim light for edge definition
|
|
94
|
+
const rimLight = new THREE.DirectionalLight(0x88ccff, 0.2);
|
|
95
|
+
rimLight.position.set(0, -50, -100);
|
|
96
|
+
this.scene.add(rimLight);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private setupHelpers(): void {
|
|
100
|
+
// Grid on XZ plane
|
|
101
|
+
const grid = new THREE.GridHelper(200, 20, 0x444444, 0x333333);
|
|
102
|
+
grid.position.y = -0.1;
|
|
103
|
+
this.scene.add(grid);
|
|
104
|
+
|
|
105
|
+
// Axes helper
|
|
106
|
+
const axes = new THREE.AxesHelper(20);
|
|
107
|
+
this.scene.add(axes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private handleResize(container: HTMLElement): void {
|
|
111
|
+
const width = container.clientWidth;
|
|
112
|
+
const height = container.clientHeight;
|
|
113
|
+
|
|
114
|
+
this.camera.aspect = width / height;
|
|
115
|
+
this.camera.updateProjectionMatrix();
|
|
116
|
+
this.renderer.setSize(width, height);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private animate = (): void => {
|
|
120
|
+
this.animationId = requestAnimationFrame(this.animate);
|
|
121
|
+
this.controls.update();
|
|
122
|
+
this.renderer.render(this.scene, this.camera);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Load a .blockymodel file
|
|
127
|
+
*/
|
|
128
|
+
async loadModel(file: File): Promise<THREE.Group> {
|
|
129
|
+
// Remove existing model
|
|
130
|
+
if (this.currentModel) {
|
|
131
|
+
this.scene.remove(this.currentModel);
|
|
132
|
+
this.disposeModel(this.currentModel);
|
|
133
|
+
this.currentModel = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
this.currentModel = await this.loader.loadFromFile(file);
|
|
138
|
+
this.scene.add(this.currentModel);
|
|
139
|
+
|
|
140
|
+
// Center and fit camera to model
|
|
141
|
+
this.fitCameraToModel();
|
|
142
|
+
|
|
143
|
+
console.log(`Loaded model with ${this.countMeshes(this.currentModel)} meshes`);
|
|
144
|
+
return this.currentModel;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error("Failed to load model:", error);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the currently loaded model
|
|
153
|
+
*/
|
|
154
|
+
getModel(): THREE.Group | null {
|
|
155
|
+
return this.currentModel;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Load a texture and apply to current model
|
|
160
|
+
*/
|
|
161
|
+
async loadTexture(file: File): Promise<void> {
|
|
162
|
+
if (!this.currentModel) {
|
|
163
|
+
console.warn("No model loaded to apply texture to");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const url = URL.createObjectURL(file);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const texture = await new Promise<THREE.Texture>((resolve, reject) => {
|
|
171
|
+
this.textureLoader.load(url, resolve, undefined, reject);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
applyTextureToModel(this.currentModel, texture);
|
|
175
|
+
console.log("Texture applied");
|
|
176
|
+
} finally {
|
|
177
|
+
URL.revokeObjectURL(url);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fit camera to view the entire model
|
|
183
|
+
*/
|
|
184
|
+
fitCameraToModel(): void {
|
|
185
|
+
if (!this.currentModel) return;
|
|
186
|
+
|
|
187
|
+
const box = new THREE.Box3().setFromObject(this.currentModel);
|
|
188
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
189
|
+
const size = box.getSize(new THREE.Vector3());
|
|
190
|
+
|
|
191
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
192
|
+
const fov = this.camera.fov * (Math.PI / 180);
|
|
193
|
+
const cameraDistance = maxDim / (2 * Math.tan(fov / 2)) * 1.5;
|
|
194
|
+
|
|
195
|
+
this.camera.position.set(
|
|
196
|
+
center.x + cameraDistance * 0.5,
|
|
197
|
+
center.y + cameraDistance * 0.5,
|
|
198
|
+
center.z + cameraDistance
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
this.controls.target.copy(center);
|
|
202
|
+
this.controls.update();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reset camera to default position
|
|
207
|
+
*/
|
|
208
|
+
resetCamera(): void {
|
|
209
|
+
if (this.currentModel) {
|
|
210
|
+
this.fitCameraToModel();
|
|
211
|
+
} else {
|
|
212
|
+
this.camera.position.set(50, 50, 100);
|
|
213
|
+
this.controls.target.set(0, 0, 0);
|
|
214
|
+
this.controls.update();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Set background color
|
|
220
|
+
*/
|
|
221
|
+
setBackgroundColor(color: number | string): void {
|
|
222
|
+
this.scene.background = new THREE.Color(color);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Toggle wireframe mode on all meshes
|
|
227
|
+
*/
|
|
228
|
+
toggleWireframe(enabled: boolean): void {
|
|
229
|
+
this.currentModel?.traverse((object) => {
|
|
230
|
+
if (object instanceof THREE.Mesh) {
|
|
231
|
+
const material = object.material as THREE.MeshStandardMaterial;
|
|
232
|
+
material.wireframe = enabled;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get model hierarchy for debugging
|
|
239
|
+
*/
|
|
240
|
+
getModelHierarchy(): object | null {
|
|
241
|
+
if (!this.currentModel) return null;
|
|
242
|
+
|
|
243
|
+
const buildHierarchy = (object: THREE.Object3D): object => ({
|
|
244
|
+
name: object.name,
|
|
245
|
+
type: object.type,
|
|
246
|
+
userData: object.userData,
|
|
247
|
+
children: object.children.map(buildHierarchy),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return buildHierarchy(this.currentModel);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Count meshes in model
|
|
255
|
+
*/
|
|
256
|
+
private countMeshes(object: THREE.Object3D): number {
|
|
257
|
+
let count = 0;
|
|
258
|
+
object.traverse((child) => {
|
|
259
|
+
if (child instanceof THREE.Mesh) count++;
|
|
260
|
+
});
|
|
261
|
+
return count;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Dispose of model resources
|
|
266
|
+
*/
|
|
267
|
+
private disposeModel(model: THREE.Group): void {
|
|
268
|
+
model.traverse((object) => {
|
|
269
|
+
if (object instanceof THREE.Mesh) {
|
|
270
|
+
object.geometry.dispose();
|
|
271
|
+
if (Array.isArray(object.material)) {
|
|
272
|
+
object.material.forEach((m) => m.dispose());
|
|
273
|
+
} else {
|
|
274
|
+
object.material.dispose();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clean up all resources
|
|
282
|
+
*/
|
|
283
|
+
dispose(): void {
|
|
284
|
+
if (this.animationId !== null) {
|
|
285
|
+
cancelAnimationFrame(this.animationId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (this.currentModel) {
|
|
289
|
+
this.disposeModel(this.currentModel);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.controls.dispose();
|
|
293
|
+
this.renderer.dispose();
|
|
294
|
+
}
|
|
295
|
+
}
|