Skip to content

Commit b5ce470

Browse files
committed
Add up-axis selection for the file's coordinate system (#14)
Files exported from Z-up tools (Blender) or Y-down conventions (OpenCV, COLMAP) appeared tilted because three.js is Y-up. Add the `3dpreview.upAxis` setting and an "Up axis" GUI dropdown offering +X, -X, +Y, -Y, +Z and -Z. Instead of rewriting vertex data, all loaded objects now live in a THREE.Group that is rotated about the model's bounding-box centre, so the model stays in place, switching is instant and repeatable, and no mirroring can occur. The axes helper is a child of that group and shows the file's own axes; the grid helper stays in world space so it always reads as the floor. The camera is left untouched when the axis changes. Bounding-box helpers in utils.js now accept either a geometry or a THREE.Box3 so that world-space boxes can be used for camera placement and grid positioning. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kvyv4yLA7eMxRmEGDhHt4m
1 parent 234a375 commit b5ce470

6 files changed

Lines changed: 127 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to the "vscode-3dpreview" extension will be documented in th
44

55
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
66

7+
## Unreleased
8+
9+
- Add the `3dpreview.upAxis` setting and GUI dropdown to choose which axis of the file points up (+X, -X, +Y, -Y, +Z, -Z). The model is rotated in place, the axes helper follows the file's coordinate system and the grid stays on the world floor ([#14](https://github.com/tatsy/vscode-3d-preview/issues/14)).
10+
711
## v0.2.5
812

913
- Restore free rotation with `TrackballControls` and add the `3dpreview.cameraControls` setting to switch between trackball and orbit controls ([#12](https://github.com/tatsy/vscode-3d-preview/issues/12)).

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ All settings live under the `3dpreview` namespace and act as the initial values
4343
| Setting | Default | Description |
4444
|:--|:--|:--|
4545
| `hideControlsOnStart` | `false` | Fold the control panel when the viewer opens. |
46+
| `upAxis` | `"+Y"` | Axis of the file's coordinate system that points up in the viewer. Choose `"+Z"` for Z-up data such as Blender exports and `"-Y"` for OpenCV/COLMAP conventions. The axes helper shows the file's own axes; the grid stays on the world floor. |
4647
| `cameraControls` | `"trackball"` | `"trackball"` allows free rotation in any direction. `"orbit"` keeps the camera upright and cannot pass over the poles. |
4748
| `showMesh` | `true` | Show mesh triangles. |
4849
| `flatShading` | `false` | Use flat shading instead of smooth shading for meshes. STL files are always rendered flat because the format stores no shared vertices. |

media/utils.js

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,38 +57,38 @@ function createModelLoader(fileToLoad) {
5757
}
5858
}
5959

60-
function getBBoxCenter(geometry) {
61-
geometry.computeBoundingBox();
62-
63-
var center = new THREE.Vector3();
64-
center.x = (geometry.boundingBox.max.x + geometry.boundingBox.min.x) / 2;
65-
center.y = (geometry.boundingBox.max.y + geometry.boundingBox.min.y) / 2;
66-
center.z = (geometry.boundingBox.max.z + geometry.boundingBox.min.z) / 2;
67-
return center;
60+
// Accept either a THREE.Box3 or a geometry whose bounding box is (re)computed.
61+
function toBox3(geometryOrBox) {
62+
if (geometryOrBox.isBox3) {
63+
return geometryOrBox;
64+
}
65+
geometryOrBox.computeBoundingBox();
66+
return geometryOrBox.boundingBox;
6867
}
6968

70-
function getBBoxMaxExtent(geometry) {
71-
geometry.computeBoundingBox();
72-
73-
var cx = geometry.boundingBox.max.x - geometry.boundingBox.min.x;
74-
var cy = geometry.boundingBox.max.y - geometry.boundingBox.min.y;
75-
var cz = geometry.boundingBox.max.z - geometry.boundingBox.min.z;
69+
function getBBoxCenter(geometryOrBox) {
70+
const box = toBox3(geometryOrBox);
71+
return box.getCenter(new THREE.Vector3());
72+
}
7673

77-
return Math.max(cx, Math.max(cy, cz));
74+
function getBBoxMaxExtent(geometryOrBox) {
75+
const box = toBox3(geometryOrBox);
76+
const size = box.getSize(new THREE.Vector3());
77+
return Math.max(size.x, Math.max(size.y, size.z));
7878
}
7979

80-
function autoCameraPos(geometry) {
81-
geometry.computeBoundingBox();
80+
function autoCameraPos(geometryOrBox) {
81+
const box = toBox3(geometryOrBox);
8282

83-
var cx = (geometry.boundingBox.max.x - geometry.boundingBox.min.x) / 2;
84-
var cy = (geometry.boundingBox.max.y - geometry.boundingBox.min.y) / 2;
85-
var cz = (geometry.boundingBox.max.z - geometry.boundingBox.min.z) / 2;
83+
var cx = (box.max.x - box.min.x) / 2;
84+
var cy = (box.max.y - box.min.y) / 2;
85+
var cz = (box.max.z - box.min.z) / 2;
8686
var sx = cx > 0 ? 1.0 : -1.0;
8787
var sy = cy > 0 ? 1.0 : -1.0;
8888
var sz = cz > 0 ? 1.0 : -1.0;
8989
var d = Math.max(cx, Math.max(cy, cz)) * 2.0;
9090

91-
var center = getBBoxCenter(geometry);
91+
var center = getBBoxCenter(box);
9292
var cameraPos = new THREE.Vector3(d * sx, d * sy, d * sz);
9393
cameraPos.add(center);
9494
cameraPos.multiplyScalar(1);

media/viewer.js

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import * as utils from './utils.js';
1313
// The point size slider is mapped onto [0, MAX_POINT_PIXEL_SIZE] in that mode.
1414
const MAX_POINT_PIXEL_SIZE = 30;
1515

16+
// Rotation that brings the file's up axis to three.js' +Y. Each entry is a pure
17+
// rotation, so handedness is preserved and no mirroring is introduced.
18+
const UP_AXIS_ROTATIONS = {
19+
'+X': new THREE.Euler(0, 0, Math.PI / 2),
20+
'-X': new THREE.Euler(0, 0, -Math.PI / 2),
21+
'+Y': new THREE.Euler(0, 0, 0),
22+
'-Y': new THREE.Euler(Math.PI, 0, 0),
23+
'+Z': new THREE.Euler(-Math.PI / 2, 0, 0),
24+
'-Z': new THREE.Euler(Math.PI / 2, 0, 0),
25+
};
26+
1627
class Viewer {
1728
controls;
1829
points;
@@ -58,6 +69,13 @@ class Viewer {
5869
const light = new THREE.HemisphereLight(0x888888, 0x333333, 1.0);
5970
this.scene.add(light);
6071

72+
// All loaded objects (points, mesh, wireframe) live in this group, so that the
73+
// file's coordinate system can be re-oriented by rotating the group alone.
74+
// Camera and grid helper stay in world space.
75+
this.model = new THREE.Group();
76+
this.model.name = 'model';
77+
this.scene.add(this.model);
78+
6179
// Camera
6280
this.camera = new THREE.PerspectiveCamera(
6381
45.0,
@@ -87,22 +105,46 @@ class Viewer {
87105
this.stats.end();
88106
}
89107

108+
// Bounding box of the loaded data in the file's own coordinate system.
109+
localBBox() {
110+
this.points.geometry.computeBoundingBox();
111+
return this.points.geometry.boundingBox;
112+
}
113+
114+
// Bounding box of the loaded data in world space, i.e. after the up-axis rotation.
115+
worldBBox() {
116+
this.model.updateMatrixWorld(true);
117+
return new THREE.Box3().setFromObject(this.points);
118+
}
119+
120+
// Rotate the model group so that the file's up axis points to +Y. The rotation is
121+
// taken about the model's bounding-box centre, so the model stays where it is and
122+
// only its orientation changes. The camera is intentionally left untouched.
123+
applyUpAxis() {
124+
const rotation = UP_AXIS_ROTATIONS[this.params.upAxis] || UP_AXIS_ROTATIONS['+Y'];
125+
const center = utils.getBBoxCenter(this.localBBox());
126+
this.model.rotation.copy(rotation);
127+
this.model.position.copy(center).sub(center.clone().applyEuler(rotation));
128+
this.model.updateMatrixWorld(true);
129+
}
130+
90131
updateHelpers() {
91132
// Remove current helpers
92133
if (this.gridHelper !== null) {
93134
this.scene.remove(this.gridHelper);
94135
}
95136

96137
if (this.axesHelper !== null) {
97-
this.scene.remove(this.axesHelper);
138+
this.model.remove(this.axesHelper);
98139
}
99140

100-
// BBox center
101-
const center = utils.getBBoxCenter(this.points.geometry);
102-
const extent = utils.getBBoxMaxExtent(this.points.geometry);
103-
104-
// Grid helper
141+
// Grid helper: lives in world space so that it always reads as the floor,
142+
// whatever up axis the file uses.
105143
if (this.params.showGridHelper) {
144+
const worldBox = this.worldBBox();
145+
const center = utils.getBBoxCenter(worldBox);
146+
const extent = utils.getBBoxMaxExtent(worldBox);
147+
106148
const size = this.params.gridHelper.size;
107149
const unit = this.params.gridHelper.unit;
108150
const divisions = size / unit;
@@ -114,28 +156,37 @@ class Viewer {
114156
const colorCenterLine = new THREE.Color('#888888');
115157
const colorGrid = new THREE.Color('#888888');
116158
this.gridHelper = new THREE.GridHelper(size, divisions, colorCenterLine, colorGrid);
117-
this.gridHelper.position.x += center.x - extent * 0.5;
118-
this.gridHelper.position.y += center.y - extent * 0.5;
119-
this.gridHelper.position.z += center.z - extent * 0.5;
120159
this.gridHelper.material.linewidth = 10;
121160
this.gridHelper.name = 'gridHelper';
122161
}
162+
this.gridHelper.position.set(
163+
center.x - extent * 0.5,
164+
center.y - extent * 0.5,
165+
center.z - extent * 0.5
166+
);
123167

124168
this.scene.add(this.gridHelper);
125169
}
126170

127-
// Axis helper
171+
// Axes helper: child of the model group, so it shows the axes of the file's
172+
// own coordinate system and follows the up-axis rotation.
128173
if (this.params.showAxesHelper) {
174+
const localBox = this.localBBox();
175+
const center = utils.getBBoxCenter(localBox);
176+
const extent = utils.getBBoxMaxExtent(localBox);
177+
129178
if (this.axesHelper === null) {
130179
this.axesHelper = new THREE.AxesHelper(extent);
131-
this.axesHelper.position.x += center.x - extent * 0.5;
132-
this.axesHelper.position.y += center.y - extent * 0.5;
133-
this.axesHelper.position.z += center.z - extent * 0.5;
134180
this.axesHelper.material.linewidth = 10;
135181
this.axesHelper.name = 'axesHelper';
136182
}
183+
this.axesHelper.position.set(
184+
center.x - extent * 0.5,
185+
center.y - extent * 0.5,
186+
center.z - extent * 0.5
187+
);
137188

138-
this.scene.add(this.axesHelper);
189+
this.model.add(this.axesHelper);
139190
}
140191
}
141192

@@ -177,9 +228,9 @@ class Viewer {
177228
this.mesh.material.needsUpdate = true;
178229
}
179230

180-
this.scene.remove(this.mesh);
231+
this.model.remove(this.mesh);
181232
if (this.params.showMesh) {
182-
this.scene.add(this.mesh);
233+
this.model.add(this.mesh);
183234
}
184235
}
185236

@@ -188,9 +239,9 @@ class Viewer {
188239
this.wireframe.material.color = new THREE.Color(this.params.wireframeColor);
189240
this.wireframe.material.linewidth = this.params.wireframeWidth;
190241

191-
this.scene.remove(this.wireframe);
242+
this.model.remove(this.wireframe);
192243
if (this.params.showWireframe) {
193-
this.scene.add(this.wireframe);
244+
this.model.add(this.wireframe);
194245
}
195246
}
196247
}
@@ -208,7 +259,7 @@ class Viewer {
208259
const target =
209260
this.controls !== undefined
210261
? this.controls.target.clone()
211-
: utils.getBBoxCenter(this.points.geometry);
262+
: utils.getBBoxCenter(this.worldBBox());
212263

213264
if (this.controls !== undefined) {
214265
this.controls.dispose();
@@ -298,7 +349,7 @@ class Viewer {
298349
console.warn(e);
299350
self.monochrome = true;
300351
}
301-
self.scene.add(self.points);
352+
self.model.add(self.points);
302353

303354
self.onMeshLoaded();
304355
self.updateHelpers();
@@ -321,7 +372,7 @@ class Viewer {
321372
self.mesh.castShadow = true;
322373
self.mesh.receiveShadow = true;
323374
self.mesh.name = base + '_mesh';
324-
self.scene.add(self.mesh);
375+
self.model.add(self.mesh);
325376

326377
// Wireframe
327378
const wireMaterial = new LineMaterial({
@@ -332,26 +383,29 @@ class Viewer {
332383
const edges = new WireframeGeometry2(geometry);
333384
self.wireframe = new Line2(edges, wireMaterial);
334385
self.wireframe.name = base + '_wireframe';
335-
self.scene.add(self.wireframe);
386+
self.model.add(self.wireframe);
336387
} catch (e) {
337388
console.error(e);
338389
}
339390
});
340391
}
341392

342393
onMeshLoaded() {
394+
// Orient the model according to the configured up axis before placing the camera
395+
this.applyUpAxis();
396+
const worldBox = this.worldBBox();
397+
343398
// Camera setup
344-
this.points.geometry.computeBoundingBox();
345-
const camTarget = utils.getBBoxCenter(this.points.geometry);
346-
const camPos = utils.autoCameraPos(this.points.geometry);
399+
const camTarget = utils.getBBoxCenter(worldBox);
400+
const camPos = utils.autoCameraPos(worldBox);
347401

348402
this.camera.position.copy(camPos);
349403
this.camera.lookAt(camTarget);
350404
this.setupControls();
351405
window.addEventListener('resize', () => this.onWindowResize());
352406

353407
// GUI setup
354-
const extent = utils.getBBoxMaxExtent(this.points.geometry);
408+
const extent = utils.getBBoxMaxExtent(worldBox);
355409
this.params.pointSize = extent / 100.0;
356410
this.params.pointMaxSize = extent / 10.0;
357411

@@ -419,6 +473,13 @@ class Viewer {
419473
.add(this.params, 'cameraControls', { Trackball: 'trackball', Orbit: 'orbit' })
420474
.name('Camera controls')
421475
.onChange(() => this.setupControls());
476+
this.gui
477+
.add(this.params, 'upAxis', Object.keys(UP_AXIS_ROTATIONS))
478+
.name('Up axis')
479+
.onChange(() => {
480+
this.applyUpAxis();
481+
this.updateHelpers();
482+
});
422483

423484
let folder = this.gui.addFolder('Grid Helper');
424485
folder.open();

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,19 @@
146146
"minimum": 0,
147147
"description": "Intensity of the directional light that follows the camera. Set 0 to disable it."
148148
},
149+
"3dpreview.upAxis": {
150+
"type": "string",
151+
"default": "+Y",
152+
"enum": [
153+
"+X",
154+
"-X",
155+
"+Y",
156+
"-Y",
157+
"+Z",
158+
"-Z"
159+
],
160+
"description": "Axis of the file's coordinate system that points up in the viewer. Use \"+Z\" for Z-up data such as Blender exports and \"-Y\" for OpenCV/COLMAP conventions."
161+
},
149162
"3dpreview.cameraControls": {
150163
"type": "string",
151164
"default": "trackball",

src/meshProvider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export class MeshViewProvider implements vscode.CustomReadonlyEditorProvider<Mes
128128
fogDensity: config.get('fogDensity', 0.01),
129129
lightIntensity: config.get('lightIntensity', 1.0),
130130
cameraControls: config.get('cameraControls', 'trackball'),
131+
upAxis: config.get('upAxis', '+Y'),
131132
};
132133
return `<meta id="vscode-3dviewer-data" data-settings="${JSON.stringify(initialData).replace(
133134
/"/g,

0 commit comments

Comments
 (0)