diff --git a/jest.config.js b/jest.config.js index c3830298..cdd675f1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,5 +6,6 @@ module.exports = { moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], moduleNameMapper: { threeRenderBuilder: "/packages/chili-three/test/threeRenderBuilder.ts", + "camera-controls": "/packages/chili-three/test/cameraControls.ts", }, }; diff --git a/package-lock.json b/package-lock.json index 777c45b5..fef20a4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chili3d", - "version": "0.4-beta", + "version": "0.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "chili3d", - "version": "0.4-beta", + "version": "0.4.0", "workspaces": [ "packages/*" ], @@ -1948,9 +1948,9 @@ "dev": true }, "node_modules/@types/three": { - "version": "0.170.0", - "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.170.0.tgz", - "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", + "version": "0.172.0", + "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.172.0.tgz", + "integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==", "dev": true, "license": "MIT", "dependencies": { @@ -2706,6 +2706,16 @@ "node": ">=6" } }, + "node_modules/camera-controls": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/camera-controls/-/camera-controls-2.9.0.tgz", + "integrity": "sha512-TpCujnP0vqPppTXXJRYpvIy0xq9Tro6jQf2iYUxlDpPCNxkvE/XGaTuwIxnhINOkVP/ob2CRYXtY3iVYXeMEzA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001627", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001627.tgz", @@ -8427,9 +8437,9 @@ } }, "node_modules/three": { - "version": "0.171.0", - "resolved": "https://registry.npmmirror.com/three/-/three-0.171.0.tgz", - "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==", + "version": "0.172.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", "dev": true, "license": "MIT" }, @@ -9498,7 +9508,7 @@ } }, "packages/chili": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-core": "*", "chili-geo": "*", @@ -9506,7 +9516,7 @@ } }, "packages/chili-builder": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili": "*", "chili-three": "*", @@ -9515,11 +9525,11 @@ } }, "packages/chili-core": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": {} }, "packages/chili-geo": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-core": "*" } @@ -9538,23 +9548,24 @@ "devDependencies": {} }, "packages/chili-storage": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-core": "*" } }, "packages/chili-three": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { - "@types/three": "0.170.0", + "@types/three": "0.172.0", + "camera-controls": "^2.9.0", "chili-core": "*", "chili-vis": "*", - "three": "0.171.0", + "three": "0.172.0", "three-mesh-bvh": "0.8.3" } }, "packages/chili-ui": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-core": "*", "chili-geo": "*", @@ -9562,19 +9573,19 @@ } }, "packages/chili-vis": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-core": "*" } }, "packages/chili-wasm": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-core": "*" } }, "packages/chili-web": { - "version": "0.4-beta", + "version": "0.4.0", "devDependencies": { "chili-builder": "*" } @@ -11141,9 +11152,9 @@ "dev": true }, "@types/three": { - "version": "0.170.0", - "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.170.0.tgz", - "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", + "version": "0.172.0", + "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.172.0.tgz", + "integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==", "dev": true, "requires": { "@tweenjs/tween.js": "~23.1.3", @@ -11771,6 +11782,13 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true }, + "camera-controls": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/camera-controls/-/camera-controls-2.9.0.tgz", + "integrity": "sha512-TpCujnP0vqPppTXXJRYpvIy0xq9Tro6jQf2iYUxlDpPCNxkvE/XGaTuwIxnhINOkVP/ob2CRYXtY3iVYXeMEzA==", + "dev": true, + "requires": {} + }, "caniuse-lite": { "version": "1.0.30001627", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001627.tgz", @@ -11828,10 +11846,11 @@ "chili-three": { "version": "file:packages/chili-three", "requires": { - "@types/three": "0.170.0", + "@types/three": "0.172.0", + "camera-controls": "^2.9.0", "chili-core": "*", "chili-vis": "*", - "three": "0.171.0", + "three": "0.172.0", "three-mesh-bvh": "0.8.3" } }, @@ -15929,9 +15948,9 @@ "requires": {} }, "three": { - "version": "0.171.0", - "resolved": "https://registry.npmmirror.com/three/-/three-0.171.0.tgz", - "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==", + "version": "0.172.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", "dev": true }, "three-mesh-bvh": { diff --git a/packages/chili-core/src/visual/cameraController.ts b/packages/chili-core/src/visual/cameraController.ts deleted file mode 100644 index 8a5933ff..00000000 --- a/packages/chili-core/src/visual/cameraController.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. - -export interface Point { - x: number; - y: number; - z: number; -} - -export interface ICameraController { - cameraType: "perspective" | "orthographic"; - fitContent(): void; - lookAt(eye: Point, target: Point, up: Point): void; - pan(dx: number, dy: number): void; - startRotate(x: number, y: number): void; - rotate(dx: number, dy: number): void; - zoom(x: number, y: number, delta: number): void; - update(): void; -} diff --git a/packages/chili-core/src/visual/index.ts b/packages/chili-core/src/visual/index.ts index 9f406997..c81085e3 100644 --- a/packages/chili-core/src/visual/index.ts +++ b/packages/chili-core/src/visual/index.ts @@ -1,6 +1,5 @@ // Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. -export * from "./cameraController"; export * from "./cursorType"; export * from "./detectedData"; export * from "./eventHandler"; diff --git a/packages/chili-core/src/visual/view.ts b/packages/chili-core/src/visual/view.ts index f63c63ad..9909c4c1 100644 --- a/packages/chili-core/src/visual/view.ts +++ b/packages/chili-core/src/visual/view.ts @@ -5,23 +5,35 @@ import { IDisposable, IPropertyChanged } from "../foundation"; import { Plane, Ray, XY, XYZ } from "../math"; import { INodeFilter, IShapeFilter } from "../selectionFilter"; import { ShapeType } from "../shape"; -import { ICameraController } from "./cameraController"; import { VisualShapeData } from "./detectedData"; import { IVisualObject } from "./visualObject"; +export enum CameraType { + perspective, + orthographic, +} + export interface IView extends IPropertyChanged, IDisposable { readonly document: IDocument; - readonly cameraController: ICameraController; get isClosed(): boolean; name: string; workplane: Plane; + cameraType: CameraType; + cameraTarget: XYZ; + cameraPosition: XYZ; + onKeyDown(e: KeyboardEvent): void; + onKeyUp(e: KeyboardEvent): void; update(): void; up(): XYZ; toImage(): string; direction(): XYZ; + rotate(dx: number, dy: number): void; + zoomIn(): void; + zoomOut(): void; rayAt(mx: number, my: number): Ray; screenToWorld(mx: number, my: number): XYZ; worldToScreen(point: XYZ): XY; + fitContent(): void; resize(width: number, heigth: number): void; setDom(element: HTMLElement): void; close(): void; diff --git a/packages/chili-core/src/visual/visual.ts b/packages/chili-core/src/visual/visual.ts index 616817be..bc397611 100644 --- a/packages/chili-core/src/visual/visual.ts +++ b/packages/chili-core/src/visual/visual.ts @@ -11,7 +11,6 @@ import { IVisualContext } from "./visualContext"; export interface IVisual extends IDisposable { readonly document: IDocument; readonly context: IVisualContext; - readonly viewHandler: IEventHandler; readonly highlighter: IHighlighter; update(): void; eventHandler: IEventHandler; diff --git a/packages/chili-three/package.json b/packages/chili-three/package.json index dbb47e1e..bdacbee7 100644 --- a/packages/chili-three/package.json +++ b/packages/chili-three/package.json @@ -4,10 +4,11 @@ "description": "", "main": "src/index.ts", "devDependencies": { + "@types/three": "0.172.0", + "camera-controls": "^2.9.0", "chili-core": "*", "chili-vis": "*", - "@types/three": "0.170.0", - "three": "0.171.0", + "three": "0.172.0", "three-mesh-bvh": "0.8.3" } } diff --git a/packages/chili-three/src/cameraController.ts b/packages/chili-three/src/cameraController.ts deleted file mode 100644 index 7fee3ea7..00000000 --- a/packages/chili-three/src/cameraController.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. - -import { GeometryNode, ICameraController, Point, ShapeType } from "chili-core"; -import { Box3, Matrix4, OrthographicCamera, PerspectiveCamera, Sphere, Vector3 } from "three"; -import { ThreeGeometry } from "./threeGeometry"; -import { ThreeHelper } from "./threeHelper"; -import { ThreeView } from "./threeView"; -import { ThreeVisualContext } from "./threeVisualContext"; - -const DegRad = Math.PI / 180.0; - -export class CameraController implements ICameraController { - zoomSpeed: number = 0.05; - rotateSpeed: number = 0.01; - private _up: Vector3 = new Vector3(0, 0, 1); - private _target: Vector3 = new Vector3(); - private _position: Vector3 = new Vector3(1500, 1500, 1500); - private _fov: number = 50; - private _rotateCenter: Vector3 | undefined; - private _camera: PerspectiveCamera | OrthographicCamera; - - private _cameraType: "perspective" | "orthographic" = "orthographic"; - get cameraType(): "perspective" | "orthographic" { - return this._cameraType; - } - set cameraType(value: "perspective" | "orthographic") { - if (this._cameraType === value) { - return; - } - this._cameraType = value; - this._camera = this.newCamera(); - } - - get target() { - return this._target; - } - - set target(value: Vector3) { - this._target.copy(value); - } - - get camera(): PerspectiveCamera | OrthographicCamera { - return this._camera; - } - - constructor(readonly view: ThreeView) { - this._camera = this.newCamera(); - } - - private newCamera() { - return this._cameraType === "perspective" - ? new PerspectiveCamera(this._fov, 1, 0.1, 1e4) - : new OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.1, 1e4); - } - - pan(dx: number, dy: number): void { - let ratio = 0.0015 * this._target.distanceTo(this._position); - let direction = this._target.clone().sub(this._position).normalize(); - let hor = direction.clone().cross(this._up).normalize(); - let ver = hor.clone().cross(direction).normalize(); - let vector = hor.multiplyScalar(-dx).add(ver.multiplyScalar(dy)).multiplyScalar(ratio); - this._target.add(vector); - this._position.add(vector); - - this.update(); - } - - update() { - this._camera.position.copy(this._position); - this._camera.up.copy(this._up); - this._camera.lookAt(this._target); - if (this._camera instanceof OrthographicCamera) { - this.updateOrthographicCamera(this._camera); - } - - this._camera.updateProjectionMatrix(); - } - - private updateOrthographicCamera(camera: OrthographicCamera) { - let aspect = this.view.width! / this.view.height!; - let length = this._position.distanceTo(this._target); - let frustumHalfHeight = length * Math.tan((this._fov * DegRad) / 2); - camera.left = -frustumHalfHeight * aspect; - camera.right = frustumHalfHeight * aspect; - camera.top = frustumHalfHeight; - camera.bottom = -frustumHalfHeight; - } - - startRotate(x: number, y: number): void { - let shape = this.view.detectShapes(ShapeType.Shape, x, y).at(0)?.owner; - if (!(shape instanceof ThreeGeometry)) { - this._rotateCenter = undefined; - return; - } - this._rotateCenter = new Vector3(); - let box = new Box3(); - box.setFromObject(shape); - box.getCenter(this._rotateCenter); - } - - rotate(dx: number, dy: number): void { - let center = this._rotateCenter ?? this._target; - let direction = this._position.clone().sub(center); - let hor = this._up.clone().cross(direction).normalize(); - let matrixX = new Matrix4().makeRotationAxis(hor, -dy * this.rotateSpeed); - let matrixY = new Matrix4().makeRotationAxis(new Vector3(0, 0, 1), -dx * this.rotateSpeed); - let matrix = new Matrix4().multiplyMatrices(matrixY, matrixX); - this._position = ThreeHelper.transformVector(matrix, direction).add(center); - if (this._rotateCenter) { - let targetToEye = this._target.clone().sub(this._camera.position); - this._target = ThreeHelper.transformVector(matrix, targetToEye).add(this._position); - } - - this._up.transformDirection(matrix); - - this.update(); - } - - fitContent(): void { - let context = this.view.document.visual.context as ThreeVisualContext; - let sphere = this.getBoundingSphere(context); - let fieldOfView = this._fov / 2.0; - if (this.view.width! < this.view.height!) { - fieldOfView = (fieldOfView * this.view.width!) / this.view.height!; - } - let distance = sphere.radius / Math.sin(fieldOfView * DegRad); - let direction = this._target.clone().sub(this._position).normalize(); - - this._target.copy(sphere.center); - this._position.copy(this._target.clone().sub(direction.clone().multiplyScalar(distance))); - this.updateCameraNearFar(); - this.update(); - } - - private getBoundingSphere(context: ThreeVisualContext) { - let sphere = new Sphere(); - let shapes = this.view.document.selection - .getSelectedNodes() - .filter((x) => x instanceof GeometryNode); - if (shapes.length === 0) { - new Box3().setFromObject(context.visualShapes).getBoundingSphere(sphere); - return sphere; - } - - let box = new Box3(); - for (let shape of shapes) { - let threeGeometry = context.getVisual(shape) as ThreeGeometry; - let boundingBox = new Box3().setFromObject(threeGeometry); - if (boundingBox) { - box.union(boundingBox); - } - } - box.getBoundingSphere(sphere); - return sphere; - } - - zoom(x: number, y: number, delta: number): void { - let scale = delta > 0 ? this.zoomSpeed : -this.zoomSpeed; - let direction = this._target.clone().sub(this._position); - let mouse = this.mouseToWorld(x, y); - if (this._camera instanceof PerspectiveCamera) { - mouse = this.caculePerspectiveCameraMouse(direction, mouse); - } - let vector = this._target.clone().sub(mouse).multiplyScalar(scale); - this._target.add(vector); - this._position.copy(this._target.clone().sub(direction.clone().multiplyScalar(1 + scale))); - - this.updateTarget(direction); - this.updateCameraNearFar(); - - this.update(); - } - - private updateTarget(vector: Vector3) { - let direction = vector.clone().normalize(); - let sphere = this.getBoundingSphere(this.view.document.visual.context as ThreeVisualContext); - let length = sphere.center.sub(this._position).dot(direction); - this._target.copy(this._position.clone().add(direction.multiplyScalar(length))); - } - - private updateCameraNearFar() { - let distance = this._position.distanceTo(this._target); - if (distance < 1000.0) { - this.camera.near = 0.1; - this.camera.far = 10000.0; - } else if (distance < 100000.0) { - this.camera.near = 10; - this.camera.far = 1000000.0; - } else if (distance < 1000000.0) { - this.camera.near = 1000.0; - this.camera.far = 10000000.0; - } else { - this.camera.near = 10000.0; - this.camera.far = 100000000.0; - } - } - - private caculePerspectiveCameraMouse(direction: Vector3, mouse: Vector3) { - let directionNormal = direction.clone().normalize(); - let dot = mouse.clone().sub(this._position).dot(directionNormal); - let project = this._position.clone().add(directionNormal.clone().multiplyScalar(dot)); - let length = (project.distanceTo(mouse) * direction.length()) / project.distanceTo(this._position); - let v = mouse.clone().sub(project).normalize().multiplyScalar(length); - mouse = this._target.clone().add(v); - return mouse; - } - - lookAt(eye: Point, target: Point, up: Point): void { - this._position.set(eye.x, eye.y, eye.z); - this._target.set(target.x, target.y, target.z); - this._up.set(up.x, up.y, up.z); - this.update(); - } - - private mouseToWorld(mx: number, my: number) { - let x = (2.0 * mx) / this.view.width! - 1; - let y = (-2.0 * my) / this.view.height! + 1; - let dist = this._position.distanceTo(this._target); - let z = (this._camera.far + this._camera.near - 2 * dist) / (this._camera.near - this._camera.far); - - return new Vector3(x, y, z).unproject(this._camera); - } -} diff --git a/packages/chili-three/src/threeView.ts b/packages/chili-three/src/threeView.ts index ee97f22b..572a49a4 100644 --- a/packages/chili-three/src/threeView.ts +++ b/packages/chili-three/src/threeView.ts @@ -1,6 +1,8 @@ // Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. +import CameraControls from "camera-controls"; import { + CameraType, IDocument, INodeFilter, IShape, @@ -19,9 +21,12 @@ import { VisualShapeData, XY, XYZ, + XYZLike, debounce, } from "chili-core"; import { + Box3, + Clock, DirectionalLight, Intersection, Mesh, @@ -30,13 +35,17 @@ import { PerspectiveCamera, Raycaster, Scene, + Sphere, + Spherical, + Matrix4 as ThreeMatrix4, + Quaternion as ThreeQuaternion, Vector2, Vector3, + Vector4, WebGLRenderer, } from "three"; import { SelectionBox } from "three/examples/jsm/interactive/SelectionBox"; import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2"; -import { CameraController } from "./cameraController"; import { Constants } from "./constants"; import { ThreeGeometry } from "./threeGeometry"; import { ThreeHelper } from "./threeHelper"; @@ -45,16 +54,29 @@ import { ThreeVisualContext } from "./threeVisualContext"; import { ThreeMeshObject, ThreeVisualObject } from "./threeVisualObject"; import { ViewGizmo } from "./viewGizmo"; +CameraControls.install({ + THREE: { + Vector2: Vector2, + Vector3: Vector3, + Vector4: Vector4, + Quaternion: ThreeQuaternion, + Matrix4: ThreeMatrix4, + Spherical: Spherical, + Box3: Box3, + Sphere: Sphere, + Raycaster: Raycaster, + }, +}); + export class ThreeView extends Observable implements IView { private _dom?: HTMLElement; + private _needsUpdate: boolean = false; + private _controls?: CameraControls; private readonly _resizeObserver: ResizeObserver; - private readonly _scene: Scene; private readonly _renderer: WebGLRenderer; private readonly _workplane: Plane; - private _needsUpdate: boolean = false; private readonly _gizmo: ViewGizmo; - readonly cameraController: CameraController; readonly dynamicLight = new DirectionalLight(0xffffff, 2); get name(): string { @@ -69,10 +91,54 @@ export class ThreeView extends Observable implements IView { return this._isClosed; } - get camera(): PerspectiveCamera | OrthographicCamera { - return this.cameraController.camera; + get cameraType(): CameraType { + return this.getPrivateValue("cameraType", CameraType.perspective); + } + set cameraType(value: CameraType) { + if (this.setProperty("cameraType", value)) { + this._camera = this.createCamera(); + if (this._controls) { + let target = this.cameraTarget; + let position = this.cameraPosition; + this.initCameraControls(); + this._controls.setTarget(target.x, target.y, target.z); + this._controls.setPosition(position.x, position.y, position.z); + } + } } + private _camera?: OrthographicCamera | PerspectiveCamera; + get camera() { + if (!this._camera) { + this._camera = this.createCamera(); + } + return this._camera; + } + + get cameraTarget(): XYZ { + let position = new Vector3(); + this._controls?.getTarget(position); + return ThreeHelper.toXYZ(position); + } + set cameraTarget(value: XYZLike) { + if (this._controls) { + this._controls.setTarget(value.x, value.y, value.z); + } + } + + get cameraPosition(): XYZ { + let position = new Vector3(); + this._controls?.getPosition(position); + return ThreeHelper.toXYZ(position); + } + set cameraPosition(value: XYZLike) { + if (this._controls) { + this._controls.setPosition(value.x, value.y, value.z); + } + } + + private readonly clock = new Clock(); + constructor( readonly document: IDocument, name: string, @@ -86,12 +152,11 @@ export class ThreeView extends Observable implements IView { this._workplane = workplane; let resizerObserverCallback = debounce(this._resizerObserverCallback, 100); this._resizeObserver = new ResizeObserver(resizerObserverCallback); - this.cameraController = new CameraController(this); this._renderer = this.initRenderer(); this._scene.add(this.dynamicLight); this._gizmo = new ViewGizmo(this); - this.animate(); this.document.application.views.push(this); + this.animate(); } override dispose(): void { @@ -99,6 +164,27 @@ export class ThreeView extends Observable implements IView { this._resizeObserver.disconnect(); } + private createCamera() { + let camera: PerspectiveCamera | OrthographicCamera; + let aspect = this.width! / (this.height ?? 1); + if (this.cameraType === CameraType.perspective) { + camera = new PerspectiveCamera(45, aspect, 1, 1e6); + } else { + let length = this.cameraPosition.distanceTo(this.cameraTarget); + let frustumHalfHeight = length * Math.tan((45 * Math.PI) / 180 / 2); + camera = new OrthographicCamera( + -frustumHalfHeight * aspect, + frustumHalfHeight * aspect, + frustumHalfHeight, + -frustumHalfHeight, + 1, + 1e6, + ); + } + camera.position.set(1000, 1000, 1000); + return camera; + } + close(): void { if (this._isClosed) return; this._isClosed = true; @@ -147,9 +233,45 @@ export class ThreeView extends Observable implements IView { element.appendChild(this._renderer.domElement); this.resize(element.clientWidth, element.clientHeight); this._resizeObserver.observe(element); - setTimeout(() => { - this.cameraController.update(); - }, 50); + + this.initCameraControls(); + } + + private initCameraControls() { + if (this._controls) { + this._controls.dispose(); + } + this._controls = new CameraControls(this.camera, this.renderer.domElement); + this._controls.draggingSmoothTime = 0.06; + this._controls.dollyToCursor = true; + this._controls.polarRotateSpeed = 0.8; + this._controls.azimuthRotateSpeed = 0.8; + this._controls.mouseButtons.left = CameraControls.ACTION.NONE; + this._controls.mouseButtons.middle = CameraControls.ACTION.TRUCK; + this._controls.mouseButtons.right = CameraControls.ACTION.NONE; + } + + onKeyDown(e: KeyboardEvent) { + if (e.shiftKey && this._controls) { + this._controls.mouseButtons.middle = CameraControls.ACTION.ROTATE; + + let shapes = this.document.selection.getSelectedNodes().filter((x) => x instanceof VisualNode); + let point = new Vector3(); + let box = new Box3(); + for (let shape of shapes) { + let threeGeometry = this.content.getVisual(shape) as ThreeGeometry; + box.union(new Box3().setFromObject(threeGeometry)); + } + box.getCenter(point); + this._controls?.setOrbitPoint(point.x, point.y, point.z); + } + } + + onKeyUp(e: KeyboardEvent) { + if (!e.shiftKey && this._controls) { + this._controls.mouseButtons.middle = CameraControls.ACTION.TRUCK; + this._controls.setOrbitPoint(0, 0, 0); + } } toImage(): string { @@ -173,28 +295,53 @@ export class ThreeView extends Observable implements IView { requestAnimationFrame(() => { this.animate(); }); - if (!this._needsUpdate) return; - let dir = this.camera.position.clone().sub(this.cameraController.target); + if (!this._camera) { + return; + } + + let needsUpdate = this._controls?.update(this.clock.getDelta()); + if (!this._needsUpdate && !needsUpdate) return; + + let dir = this.cameraPosition.sub(this.cameraTarget); this.dynamicLight.position.copy(dir); - this._renderer.render(this._scene, this.camera); + this._renderer.render(this._scene, this._camera); this._gizmo?.update(); this._needsUpdate = false; } resize(width: number, height: number) { - if (this.camera instanceof PerspectiveCamera) { - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - } else if (this.camera instanceof OrthographicCamera) { - this.camera.updateProjectionMatrix(); - } this._renderer.setSize(width, height); - this.cameraController.update(); + this._camera = this.createCamera(); + if (this._controls) { + this._controls.camera = this._camera; + } + if (this._camera instanceof PerspectiveCamera) { + this._camera.aspect = width / height; + this._camera.updateProjectionMatrix(); + } else if (this._camera instanceof OrthographicCamera) { + this._camera.updateProjectionMatrix(); + } this.update(); } + fitContent(): void { + let box = new Box3(); + let shapes = this.document.selection.getSelectedNodes().filter((x) => x instanceof VisualNode); + if (shapes.length === 0) { + box.setFromObject(this.content.visualShapes); + } else { + for (let shape of shapes) { + let threeGeometry = this.content.getVisual(shape) as ThreeVisualObject; + box.union(new Box3().setFromObject(threeGeometry)); + } + } + let sphere = new Sphere(); + box.getBoundingSphere(sphere); + this._controls?.fitToSphere(sphere, true); + } + get width() { return this._dom?.clientWidth; } @@ -203,6 +350,26 @@ export class ThreeView extends Observable implements IView { return this._dom?.clientHeight; } + rotate(dx: number, dy: number): void { + this._controls?.rotate(dx, dy); + } + + zoomIn(): void { + if (this.cameraType === CameraType.orthographic) { + this._controls?.zoom(this.camera.zoom * 0.3); + } else { + this._controls?.dolly(this.cameraPosition.distanceTo(this.cameraTarget) * 0.2); + } + } + + zoomOut(): void { + if (this.cameraType === CameraType.orthographic) { + this._controls?.zoom(-this.camera.zoom * 0.3); + } else { + this._controls?.dolly(this.cameraPosition.distanceTo(this.cameraTarget) * -0.2); + } + } + screenToCameraRect(mx: number, my: number) { return { x: (mx / this.width!) * 2 - 1, @@ -218,10 +385,10 @@ export class ThreeView extends Observable implements IView { private directionAt(mx: number, my: number) { let position = this.mouseToWorld(mx, my); let direction = new Vector3(); - if (this.camera instanceof PerspectiveCamera) { - direction = position.clone().sub(this.camera.position).normalize(); - } else if (this.camera instanceof OrthographicCamera) { - this.camera.getWorldDirection(direction); + if (this._camera instanceof PerspectiveCamera) { + direction = position.clone().sub(this._camera.position).normalize(); + } else if (this._camera instanceof OrthographicCamera) { + this._camera.getWorldDirection(direction); } return { position, direction }; } @@ -240,7 +407,10 @@ export class ThreeView extends Observable implements IView { direction(): XYZ { const vec = new Vector3(); - this.camera.getWorldDirection(vec); + if (!this._camera) { + return XYZ.unitX; + } + this._camera.getWorldDirection(vec); return ThreeHelper.toXYZ(vec); } diff --git a/packages/chili-three/src/threeViewEventHandler.ts b/packages/chili-three/src/threeViewEventHandler.ts deleted file mode 100644 index 845d39c9..00000000 --- a/packages/chili-three/src/threeViewEventHandler.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. - -import { IEventHandler, IView } from "chili-core"; - -interface MouseDownData { - time: number; - key: number; -} - -const MIDDLE = 4; - -export class ThreeViewHandler implements IEventHandler { - private _lastDown: MouseDownData | undefined; - private _clearDownId: number | undefined; - private _offsetPoint: { x: number; y: number } | undefined; - - canRotate: boolean = true; - - dispose() { - this.clearTimeout(); - } - - mouseWheel(view: IView, event: WheelEvent): void { - view.cameraController.zoom(event.offsetX, event.offsetY, event.deltaY); - view.update(); - } - - pointerMove(view: IView, event: PointerEvent): void { - if (event.buttons !== MIDDLE) { - return; - } - let [dx, dy] = [0, 0]; - if (this._offsetPoint) { - dx = event.offsetX - this._offsetPoint.x; - dy = event.offsetY - this._offsetPoint.y; - this._offsetPoint = { x: event.offsetX, y: event.offsetY }; - } - if (event.shiftKey && this.canRotate) { - view.cameraController.rotate(dx, dy); - } else if (!event.shiftKey) { - view.cameraController.pan(dx, dy); - } - if (dx !== 0 && dy !== 0) this._lastDown = undefined; - view.update(); - } - - pointerDown(view: IView, event: PointerEvent): void { - this.clearTimeout(); - if (this._lastDown && this._lastDown.time + 500 > Date.now() && event.buttons === MIDDLE) { - this._lastDown = undefined; - view.cameraController.fitContent(); - view.update(); - } else if (event.buttons === MIDDLE) { - view.cameraController.startRotate(event.offsetX, event.offsetY); - this._lastDown = { - time: Date.now(), - key: event.buttons, - }; - this._offsetPoint = { x: event.offsetX, y: event.offsetY }; - } - } - - private clearTimeout() { - if (this._clearDownId) { - clearTimeout(this._clearDownId); - this._clearDownId = undefined; - } - } - - pointerOut(view: IView, event: PointerEvent): void { - this._lastDown = undefined; - } - - pointerUp(view: IView, event: PointerEvent): void { - if (event.buttons === MIDDLE && this._lastDown) { - this._clearDownId = window.setTimeout(() => { - this._lastDown = undefined; - this._clearDownId = undefined; - }, 500); - } - this._offsetPoint = undefined; - } - - keyDown(view: IView, event: KeyboardEvent): void {} -} diff --git a/packages/chili-three/src/threeVisual.ts b/packages/chili-three/src/threeVisual.ts index 5b36472f..455ade38 100644 --- a/packages/chili-three/src/threeVisual.ts +++ b/packages/chili-three/src/threeVisual.ts @@ -1,11 +1,10 @@ // Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. -import { IDisposable, IDocument, IEventHandler, ITextGenerator, IVisual, Logger, Plane } from "chili-core"; +import { IDisposable, IDocument, IEventHandler, IVisual, Logger, Plane } from "chili-core"; import { NodeSelectionHandler } from "chili-vis"; import { AmbientLight, AxesHelper, Object3D, Scene } from "three"; import { ThreeHighlighter } from "./threeHighlighter"; import { ThreeView } from "./threeView"; -import { ThreeViewHandler } from "./threeViewEventHandler"; import { ThreeVisualContext } from "./threeVisualContext"; Object3D.DEFAULT_UP.set(0, 0, 1); @@ -14,9 +13,7 @@ export class ThreeVisual implements IVisual { readonly defaultEventHandler: IEventHandler; readonly context: ThreeVisualContext; readonly scene: Scene; - readonly viewHandler: IEventHandler; readonly highlighter: ThreeHighlighter; - // readonly textGenerator: ITextGenerator; private _eventHandler: IEventHandler; @@ -34,9 +31,7 @@ export class ThreeVisual implements IVisual { this.scene = this.initScene(); this.defaultEventHandler = new NodeSelectionHandler(document, true); this.context = new ThreeVisualContext(this, this.scene); - this.viewHandler = new ThreeViewHandler(); this.highlighter = new ThreeHighlighter(this.context); - // this.textGenerator = new ThreeTextGenerator(); this._eventHandler = this.defaultEventHandler; } @@ -69,7 +64,6 @@ export class ThreeVisual implements IVisual { dispose() { this.context.dispose(); this.defaultEventHandler.dispose(); - this.viewHandler.dispose(); this._eventHandler.dispose(); this.scene.traverse((x) => { if (IDisposable.isDisposable(x)) x.dispose(); diff --git a/packages/chili-three/src/viewGizmo.ts b/packages/chili-three/src/viewGizmo.ts index 5e8260e9..95fbe0b3 100644 --- a/packages/chili-three/src/viewGizmo.ts +++ b/packages/chili-three/src/viewGizmo.ts @@ -1,8 +1,6 @@ // Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. -import { XYZ } from "chili-core"; import { Matrix4, Vector3 } from "three"; -import { CameraController } from "./cameraController"; import { ThreeView } from "./threeView"; const options = { @@ -38,14 +36,12 @@ export class ViewGizmo extends HTMLElement { private readonly _center: Vector3; private readonly _canvas: HTMLCanvasElement; private readonly _context: CanvasRenderingContext2D; - readonly cameraController: CameraController; private _canClick: boolean = true; private _selectedAxis?: Axis; private _mouse?: Vector3; constructor(readonly view: ThreeView) { super(); - this.cameraController = view.cameraController; this._axes = this._initAxes(); this._center = new Vector3(options.size * 0.5, options.size * 0.5, 0); this._canvas = this._initCanvas(); @@ -128,6 +124,7 @@ export class ViewGizmo extends HTMLElement { this._canvas.addEventListener("pointermove", this._onPointerMove, false); this._canvas.addEventListener("pointerenter", this._onPointerEnter, false); this._canvas.addEventListener("pointerout", this._onPointerOut, false); + this._canvas.addEventListener("pointerdown", this._onPointDown, false); this._canvas.addEventListener("click", this._onClick, false); } @@ -135,13 +132,14 @@ export class ViewGizmo extends HTMLElement { this._canvas.removeEventListener("pointermove", this._onPointerMove, false); this._canvas.removeEventListener("pointerenter", this._onPointerEnter, false); this._canvas.removeEventListener("pointerout", this._onPointerOut, false); + this._canvas.removeEventListener("pointerdown", this._onPointDown, false); this._canvas.removeEventListener("click", this._onClick, false); } - private _onPointerMove = (e: PointerEvent) => { + private readonly _onPointerMove = (e: PointerEvent) => { e.stopPropagation(); if (e.buttons === 1 && !(e.movementX === 0 && e.movementY === 0)) { - this.cameraController.rotate(e.movementX * 4, e.movementY * 4); + this.view.rotate(-e.movementX * 0.08, -e.movementY * 0.08); this._canClick = false; } const rect = this._canvas.getBoundingClientRect(); @@ -149,35 +147,32 @@ export class ViewGizmo extends HTMLElement { this.view.update(); }; - private _onPointerOut = (e: PointerEvent) => { + private readonly _onPointDown = (e: PointerEvent) => { + e.stopPropagation(); + }; + + private readonly _onPointerOut = (e: PointerEvent) => { this._mouse = undefined; this.style.backgroundColor = "transparent"; }; - private _onPointerEnter = (e: PointerEvent) => { + private readonly _onPointerEnter = (e: PointerEvent) => { this.style.backgroundColor = "rgba(255, 255, 255, .2)"; }; - private _onClick = (e: MouseEvent) => { + private readonly _onClick = (e: MouseEvent) => { if (!this._canClick) { this._canClick = true; return; } + if (this._selectedAxis) { - let distance = this.cameraController.camera.position.distanceTo(this.cameraController.target); + let distance = this.view.cameraPosition.distanceTo(this.view.cameraTarget); let position = this._selectedAxis.direction .clone() .multiplyScalar(distance) - .add(this.cameraController.target); - this.cameraController.camera.position.copy(position); - let up = new XYZ(0, 0, 1); - if (this._selectedAxis.axis === "z") up = new XYZ(0, 1, 0); - else if (this._selectedAxis.axis === "-z") up = new XYZ(0, -1, 0); - this.cameraController.lookAt( - this.cameraController.camera.position, - this.cameraController.target, - up, - ); + .add(this.view.cameraTarget); + this.view.cameraPosition = position; } }; @@ -187,7 +182,7 @@ export class ViewGizmo extends HTMLElement { update() { this.clear(); - let invRotMat = new Matrix4().makeRotationFromEuler(this.cameraController.camera.rotation).invert(); + let invRotMat = new Matrix4().makeRotationFromEuler(this.view.camera.rotation).invert(); this._axes.forEach( (axis) => (axis.position = this.getBubblePosition(axis.direction.clone().applyMatrix4(invRotMat))), diff --git a/packages/chili-three/test/cameraControls.ts b/packages/chili-three/test/cameraControls.ts new file mode 100644 index 00000000..ea76445b --- /dev/null +++ b/packages/chili-three/test/cameraControls.ts @@ -0,0 +1,7 @@ +export default class CameraControls { + static install() {} + static ACTION = {}; + mouseButtons = {}; + setPosition() {} + setTarget() {} +} diff --git a/packages/chili-three/test/testView.ts b/packages/chili-three/test/testView.ts index 0c496e06..22a8fb3e 100644 --- a/packages/chili-three/test/testView.ts +++ b/packages/chili-three/test/testView.ts @@ -1,11 +1,10 @@ import { IDocument, Plane } from "chili-core"; import * as THREE from "three"; -import { Renderer } from "three"; +import { ThreeHighlighter } from "../src/threeHighlighter"; import { ThreeView } from "../src/threeView"; import { ThreeVisualContext } from "../src/threeVisualContext"; -import { ThreeHighlighter } from "../src/threeHighlighter"; -class TestWebGLRenderer implements THREE.Renderer { +class TestWebGLRenderer { constructor(readonly domElement = document.createElement("canvas")) {} render(scene: THREE.Object3D, camera: THREE.Camera): void {} @@ -63,11 +62,8 @@ export class TestView extends ThreeView { constructor(document: IDocument, content: ThreeVisualContext) { super(document, "test", Plane.XY, new ThreeHighlighter(content), content); this.setDom(container); - this.cameraController.lookAt( - new THREE.Vector3(0, 0, 100), - new THREE.Vector3(), - new THREE.Vector3(0, 1, 0), - ); + this.camera.position.set(0, 0, 100); + this.camera.lookAt(0, 0, 0); } protected override initRenderer() { diff --git a/packages/chili-three/test/three.test.ts b/packages/chili-three/test/three.test.ts index 1ddbe69c..b23e4977 100644 --- a/packages/chili-three/test/three.test.ts +++ b/packages/chili-three/test/three.test.ts @@ -6,10 +6,6 @@ import { TestDocument } from "./testDocument"; import { TestNode } from "./testEdge"; import { TestView } from "./testView"; -jest.mock("../src/threeRenderBuilder", () => ({ - ThreeRenderBuilder: jest.fn(), -})); - (global as any).ResizeObserver = jest.fn().mockImplementation(() => ({ observe: jest.fn(), unobserve: jest.fn(), diff --git a/packages/chili-ui/src/home/home.ts b/packages/chili-ui/src/home/home.ts index 9d18f7b5..96746dda 100644 --- a/packages/chili-ui/src/home/home.ts +++ b/packages/chili-ui/src/home/home.ts @@ -128,7 +128,7 @@ export class Home extends HTMLElement { "showPermanent", async () => { let document = await this.app.openDocument(item.id); - document?.application.activeView?.cameraController.fitContent(); + document?.application.activeView?.fitContent(); }, "toast.excuting{0}", I18n.translate("command.document.open"), diff --git a/packages/chili-ui/src/viewport/viewport.ts b/packages/chili-ui/src/viewport/viewport.ts index 6886f37e..b108cd48 100644 --- a/packages/chili-ui/src/viewport/viewport.ts +++ b/packages/chili-ui/src/viewport/viewport.ts @@ -1,11 +1,11 @@ // Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. -import { IView } from "chili-core"; -import { Flyout } from "../components"; +import { CameraType, IView } from "chili-core"; +import { button, div, Flyout, localize } from "../components"; import style from "./viewport.module.css"; export class Viewport extends HTMLElement { - private _flyout: Flyout; + private readonly _flyout: Flyout; private readonly _eventCaches: [keyof HTMLElementEventMap, (e: any) => void][] = []; constructor(readonly view: IView) { @@ -66,30 +66,25 @@ export class Viewport extends HTMLElement { this._eventCaches.length = 0; } - private pointerMove = (view: IView, event: PointerEvent) => { + private readonly pointerMove = (view: IView, event: PointerEvent) => { view.document.visual.eventHandler.pointerMove(view, event); - view.document.visual.viewHandler.pointerMove(view, event); }; - private pointerDown = (view: IView, event: PointerEvent) => { + private readonly pointerDown = (view: IView, event: PointerEvent) => { view.document.application.activeView = view; view.document.visual.eventHandler.pointerDown(view, event); - view.document.visual.viewHandler.pointerDown(view, event); }; - private pointerUp = (view: IView, event: PointerEvent) => { + private readonly pointerUp = (view: IView, event: PointerEvent) => { view.document.visual.eventHandler.pointerUp(view, event); - view.document.visual.viewHandler.pointerUp(view, event); }; - private pointerOut = (view: IView, event: PointerEvent) => { + private readonly pointerOut = (view: IView, event: PointerEvent) => { view.document.visual.eventHandler.pointerOut?.(view, event); - view.document.visual.viewHandler.pointerOut?.(view, event); }; - private mouseWheel = (view: IView, event: WheelEvent) => { + private readonly mouseWheel = (view: IView, event: WheelEvent) => { view.document.visual.eventHandler.mouseWheel?.(view, event); - view.document.visual.viewHandler.mouseWheel?.(view, event); }; } diff --git a/packages/chili/src/application.ts b/packages/chili/src/application.ts index 8f202ac2..7825cd22 100644 --- a/packages/chili/src/application.ts +++ b/packages/chili/src/application.ts @@ -17,7 +17,7 @@ import { ObservableCollection, Plane, PubSub, - Serialized + Serialized, } from "chili-core"; import { Document } from "./document"; import { importFiles } from "./utils"; @@ -128,7 +128,7 @@ export class Application implements IApplication { for (const file of opens) { let json: Serialized = JSON.parse(await file.text()); await this.loadDocument(json); - this.activeView?.cameraController.fitContent(); + this.activeView?.fitContent(); } }, "toast.excuting{0}", diff --git a/packages/chili/src/commands/application/openDocument.ts b/packages/chili/src/commands/application/openDocument.ts index aae70af7..256f810f 100644 --- a/packages/chili/src/commands/application/openDocument.ts +++ b/packages/chili/src/commands/application/openDocument.ts @@ -16,7 +16,7 @@ export class OpenDocument implements ICommand { if (files.isOk) { let json: Serialized = JSON.parse(files.value[0].data); let document = await app.loadDocument(json); - document?.application.activeView?.cameraController.fitContent(); + document?.application.activeView?.fitContent(); } }, "toast.excuting{0}", diff --git a/packages/chili/src/services/hotkeyService.ts b/packages/chili/src/services/hotkeyService.ts index 1bffafa9..db4f2f1f 100644 --- a/packages/chili/src/services/hotkeyService.ts +++ b/packages/chili/src/services/hotkeyService.ts @@ -36,26 +36,37 @@ export class HotkeyService implements IService { } start(): void { - window.addEventListener("keydown", this.eventHandlerKeyDown); + window.addEventListener("keydown", this.interactiveKeyDown); window.addEventListener("keydown", this.commandKeyDown); + window.addEventListener("keyup", this.interactiveHandlerKeyUp); Logger.info(`${HotkeyService.name} started`); } stop(): void { - window.removeEventListener("keydown", this.eventHandlerKeyDown); + window.removeEventListener("keydown", this.interactiveKeyDown); window.removeEventListener("keydown", this.commandKeyDown); + window.removeEventListener("keyup", this.interactiveHandlerKeyUp); Logger.info(`${HotkeyService.name} stoped`); } - private readonly eventHandlerKeyDown = (e: KeyboardEvent) => { + private readonly interactiveKeyDown = (e: KeyboardEvent) => { e.preventDefault(); - let visual = this.app?.activeView?.document?.visual; + let view = this.app?.activeView; - if (view && visual) { - visual.eventHandler.keyDown(view, e); - visual.viewHandler.keyDown(view, e); - if (this.app!.executingCommand) e.stopImmediatePropagation(); - } + if (!view) return; + + view.onKeyDown(e); + view.document?.visual.eventHandler.keyDown(view, e); + + if (this.app?.executingCommand) e.stopImmediatePropagation(); + }; + + private readonly interactiveHandlerKeyUp = (e: KeyboardEvent) => { + e.preventDefault(); + + this.app?.activeView?.onKeyUp(e); + + if (this.app?.executingCommand) e.stopImmediatePropagation(); }; private readonly commandKeyDown = (e: KeyboardEvent) => { diff --git a/packages/chili/src/utils.ts b/packages/chili/src/utils.ts index 1182d5f6..f3b0f8a4 100644 --- a/packages/chili/src/utils.ts +++ b/packages/chili/src/utils.ts @@ -10,9 +10,9 @@ export async function importFiles(application: IApplication, files: File[] | Fil await Transaction.excuteAsync(document, "import model", async () => { await document.application.dataExchange.import(document, files); }); - document.application.activeView?.cameraController.fitContent(); + document.application.activeView?.fitContent(); }, "toast.excuting{0}", I18n.translate("command.import"), ); -} \ No newline at end of file +}