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,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
+ }
@@ -0,0 +1,4 @@
1
+ // UI Panels
2
+ export { PropertyPanel } from "./PropertyPanel";
3
+ export { HierarchyPanel } from "./HierarchyPanel";
4
+ export { UVEditor } from "./UVEditor";
@@ -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
+ }