projects/components/src/lib/model/model.component.ts
ModelData interface to follow
Properties |
| backgroundColor |
backgroundColor:
|
Type : any
|
|
Background of the scene |
| file |
file:
|
Type : string
|
|
URL of the 3d model |
| font |
font:
|
Type : string
|
|
URL of the font |
| handleText |
handleText:
|
Type : string
|
|
Handle description |
| licensePlateText |
licensePlateText:
|
Type : string
|
|
If we load model and has license plate, this gets used |
| promoteModel |
promoteModel:
|
Type : boolean
|
|
Whether to show promotion for custom model |
| promoteText |
promoteText:
|
Type : string
|
|
Promotion description for custom model |
| rotation |
rotation:
|
Type : number
|
|
Rotation of the model |
| showHandle |
showHandle:
|
Type : boolean
|
|
Whether to show handle or not |
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
Input,
OnInit,
ViewChild,
EventEmitter,
SimpleChanges,
Output,
} from '@angular/core';
import {
Scene,
Color,
Fog,
PerspectiveCamera,
WebGLRenderer,
LinearToneMapping,
PCFShadowMap,
HemisphereLight,
DirectionalLight,
MeshPhongMaterial,
PlaneBufferGeometry,
MeshLambertMaterial,
Mesh,
DoubleSide,
sRGBEncoding,
Raycaster,
Vector2,
MeshBasicMaterial,
SphereGeometry,
BackSide,
} from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { Projector } from 'three/examples/jsm/renderers/Projector.js';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry';
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader';
import { AssetStatus } from '../Interfaces/AssetStatus';
/**
* ModelData interface to follow
*/
export interface ModelData {
/**
* URL of the 3d model
*/
file: string;
/**
* URL of the font
*/
font: string;
/**
* Rotation of the model
*/
rotation: number;
/**
* Background of the scene
*/
backgroundColor: any;
/**
* Whether to show handle or not
*/
showHandle: boolean;
/**
* Handle description
*/
handleText: string;
/**
* Whether to show promotion for custom model
*/
promoteModel: boolean;
/**
* Promotion description for custom model
*/
promoteText: string;
/**
* If we load model and has license plate, this gets used
*/
licensePlateText: string;
}
/**
* Model component
*
* Visualizes any model we pass in
*
* tag: no-model
*/
@Component({
selector: 'no-model',
templateUrl: './model.component.html',
styleUrls: ['./model.component.scss'],
})
export class ModelComponent implements OnInit, AfterViewInit {
/**
* Three scene
*/
scene!: Scene;
/**
* Three camera
*/
camera!: PerspectiveCamera;
/**
* Three renderer
*/
renderer!: WebGLRenderer;
/**
* Three orbit controls
*/
controls!: OrbitControls;
/**
* Three GLTFLoader
*/
loader!: GLTFLoader;
/**
* Floor mesh
*/
floorMesh!: Mesh<PlaneBufferGeometry, MeshPhongMaterial>;
/**
* Adds the model to be used
* @example
* ```
* var modelData = {
* file: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/master/2.0/Duck/glTF-Binary/Duck.glb',
* rotation: -89,
* showHandle:true,
* }
* const model = document.querySelector('no-model');
* model.model = modelData;
* ```
*/
@Input() model!: ModelData | any;
/**
* Adds annotations to the model (ONLY WHEN MODEL LOADED)
* heavily relies on placement definition from the model itself
* @example
* ```
* var modelAnnotations = [
* {
* placement: 'anno_wheel_rear_left',
* type: 'Error',
* content: [
* {
* title: 'Wheel just bad',
* text: 'bla bla bla',
* },
* ],
* },
* {
* placement: 'anno_engine_battery',
* type: 'Error',
* content: [
* {
* title: 'BatteryVoltage under 12v',
* text: 'bla bla bla',
* },
* {
* title: 'BatteryVoltage under 12v',
* text: 'bla bla bla',
* },
* {
* title: 'BatteryVoltage under 12v',
* text: 'bla bla bla',
* },
* ],
* },
* ];
* const model = document.querySelector('no-model');
* model.annotations = modelAnnotations;
* ```
*/
@Input() annotations!: any;
/**
* Trigger to enable rotating wheels on model WIP
*/
@Input() isInUse: string = 'not_active';
/**
* Model container reference
*/
@ViewChild('modelContainer') modelContainer!: ElementRef;
/**
* Holder for canvas element
*/
canvas: any;
/**
* Floor geometry
*/
floorGeom = new PlaneBufferGeometry(1000, 1000, 30);
/**
* Floor mesh
*/
floorMat!: MeshPhongMaterial;
/**
* Holder for render animations
*/
renderCalls: any[] = [];
/**
* THREE projector
*/
projector!: Projector;
/**
* mouse holder for hovering
*/
mouse = new Vector2(0, 0);
/**
* THREE Raycaster object
*/
intersectedObject: any;
/**
* THREE Raycaster object
*/
intersectedObjectHover: any;
/**
* THREE Raycaster
*/
ray = new Raycaster();
/**
* Current active annotation holder
*/
annotation: any = null;
/**
* Current model holder
*/
object: any;
/**
* Listener if model is loaded
*
* @example
* in js
* ```
* const model = document.querySelector('no-model');
* model.addEventListener('modelLoaded', (res) => {
//could be res.detail, since its event
* console.log(res); //bool
* })
* ```
*
*/
@Output() modelLoaded: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* Listener if model is loaded
*
* @example
* in js
* ```
* const model = document.querySelector('no-model');
* model.addEventListener('modelDragged', (res) => {
//could be res.detail, since its event
* console.log(res); //bool -> save to the cookies to never show it again
* })
* ```
*
*/
@Output() modelDragged: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* internal holder if model is loaded
*/
modelIsLoaded: boolean = false;
/**
* Holder for current annotations
*/
sprites: any[] = [];
/**
* Variable to hold progress percentage in component
*/
progress: number = 0;
/**
* Holder for all wheels
*/
wheels: any[] = [];
/**
* Holder for all annotation backgrounds
*/
annoRears: any[] = [];
/**
* Listener to resize canvas based on parent
* @param event
*/
@HostListener('window:resize', ['$event'])
onResize(event: any) {
this.camera.aspect =
this.canvas.parentNode.clientWidth / this.canvas.parentNode.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(
this.canvas.parentNode.clientWidth,
this.canvas.parentNode.clientHeight
);
}
/**
* @ignore
*/
constructor() {}
/**
* @ignore
*/
ngOnInit(): void {}
/**
* Listener for changes when model or annotations are updated
* @param changes
*/
ngOnChanges(changes: SimpleChanges) {
if (changes.model) {
this.ngAfterViewInit();
}
if (changes.annotations) {
if (changes.annotations.currentValue?.length > 0) {
if (this.sprites.length > 0) {
this.sprites.forEach((spriteId) => {
let object: any = this.scene.getObjectById(spriteId);
let parent: any = object?.parent;
if (parent) {
parent!.remove(object);
}
});
this.sprites = [];
this.annoRears = [];
}
//annotations
if (this.object) {
changes.annotations.currentValue.forEach((annotation: any) => {
const annotationMesh: any = this.scene.getObjectByName(
annotation.placement
);
if (annotationMesh) {
annotationMesh.userData.annotation = annotation;
switch (annotation.type) {
case AssetStatus.Error:
annotationMesh!.material.color = new Color(0xd30000);
annotationMesh!.material.visible = true;
annotationMesh!.material.opacity = 1;
annotationMesh!.material.alphaTest = 1;
break;
case AssetStatus.Warning:
annotationMesh!.material.color = new Color(0xff6b02);
annotationMesh!.material.visible = true;
annotationMesh!.material.opacity = 1;
annotationMesh!.material.alphaTest = 1;
break;
}
const annoRearMaterial = new MeshBasicMaterial({
name: 'depth_' + annotationMesh.name,
color: annotationMesh.material.color,
//wireframe: true,
opacity: 0.3,
transparent: true,
alphaTest: 0.03,
depthTest: false,
side: BackSide,
});
const geometry = new SphereGeometry(
annotationMesh.geometry.boundingSphere.radius + 1,
18,
16
);
const annoRear = new Mesh(geometry, annoRearMaterial);
annoRear.position.copy(annotationMesh.position);
annoRear.scale.set(
annotationMesh.scale.x / 10,
annotationMesh.scale.y / 10,
annotationMesh.scale.z / 10
);
this.object.add(annoRear);
this.annoRears.push(annoRear);
}
});
}
} else {
this.sprites.forEach((spriteId) => {
let object: any = this.scene.getObjectById(spriteId);
let parent: any = object?.parent;
if (parent) {
parent!.remove(object);
}
});
this.sprites = [];
this.annoRears.forEach((aR) => {
this.object.remove(aR);
});
this.annoRears = [];
if (this.object) {
this.object.traverse((e: any) => {
if (e.userData.hasOwnProperty('annotation')) {
const basicMaterial = new MeshBasicMaterial({
name: e.name,
color: 0xffffff,
opacity: 0.0,
transparent: true,
alphaTest: 0.0,
side: DoubleSide,
});
e.material = basicMaterial;
e.originalScale = e.scale;
}
});
}
}
}
if (changes.isInUse) {
if (changes.isInUse.currentValue === 'not_active') {
this.angleFrontWheels();
}
}
}
/**
* Toggle to rotate wheels to the side when vehicle not in use
*/
angleFrontWheels() {
const wheel_left = this.wheels[0];
const wheel_right = this.wheels[1];
if (wheel_left) {
wheel_left.rotation.x = 0;
wheel_left.rotation.y = -25 * (Math.PI / 180);
}
if (wheel_right) {
wheel_right.rotation.x = 0;
wheel_right.rotation.y = -25 * (Math.PI / 180);
}
}
/**
* Initiation of the whole model
*/
ngAfterViewInit(): void {
if (!this.modelIsLoaded && this.model && this.modelContainer) {
this.floorMat = new MeshPhongMaterial({
color: this.model?.backgroundColor,
shininess: 0,
side: DoubleSide,
});
this.setupScene();
this.canvas = this.modelContainer.nativeElement;
this.setupCamera();
this.setupRenderer();
this.setupControls();
this.setupLights();
this.setupModel();
this.projector = new Projector();
this.canvas.addEventListener(
'click',
this.onModelMouseClick.bind(this),
false
);
this.canvas.addEventListener(
'mousedown',
this.mouseDrag.bind(this),
false
);
this.canvas.addEventListener(
'mousemove',
this.onModelMouseMove.bind(this),
false
);
this.setupFloor();
this.renderCalls.push(() => {
if (this.isInUse == 'active') {
const time = -performance.now() / 1000;
for (let i = 0; i < this.wheels.length; i++) {
this.wheels[i].rotation.y = 0;
this.wheels[i].rotation.x = -(time * Math.PI * 2);
}
}
});
let counttx = 0,
countup = true;
//good luck to the one, who modifies this
this.renderCalls.push(() => {
if (countup) {
counttx += 0.001;
for (let i = 0; i < this.annoRears.length; i++) {
this.annoRears[i].scale.x =
counttx >= 0.2
? 0.052
: this.annoRears[i].scale.x + Math.sin(counttx) / 100;
this.annoRears[i].scale.y =
counttx >= 0.2
? 0.052
: this.annoRears[i].scale.y + Math.sin(counttx) / 100;
this.annoRears[i].scale.z =
counttx >= 0.2
? 0.052
: this.annoRears[i].scale.z + Math.sin(counttx) / 100;
}
if (counttx >= 0.06) countup = false;
} else {
counttx -= 0.001;
for (let i = 0; i < this.annoRears.length; i++) {
this.annoRears[i].scale.x =
counttx <= 0
? 0.05
: this.annoRears[i].scale.x - Math.sin(counttx) / 100;
this.annoRears[i].scale.y =
counttx <= 0
? 0.05
: this.annoRears[i].scale.y - Math.sin(counttx) / 100;
this.annoRears[i].scale.z =
counttx <= 0
? 0.05
: this.annoRears[i].scale.z - Math.sin(counttx) / 100;
}
if (counttx <= 0) countup = true;
}
});
this.render();
}
/* this.renderCalls.push(() => {
this.updateMouse();
}); */
}
/**
* Setting up scene
*/
setupScene(): void {
this.scene = new Scene();
this.scene.background = new Color(this.model?.backgroundColor);
this.scene.fog = new Fog(this.model?.backgroundColor, 20, 100);
}
/**
* Setting up camera
*/
setupCamera(): void {
this.camera = new PerspectiveCamera(
55,
this.canvas.parentNode.clientWidth / this.canvas.parentNode.clientHeight,
0.1,
800
);
this.camera.position.set(0, 5, 5);
}
/**
* Setting up render
*/
setupRenderer() {
this.renderer = new WebGLRenderer({
canvas: this.canvas,
antialias: true,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(
this.canvas.parentNode.clientWidth,
this.canvas.parentNode.clientHeight
);
this.renderer.setClearColor(this.model?.backgroundColor);
this.renderer.toneMapping = LinearToneMapping;
this.renderer.toneMappingExposure = Math.pow(0.94, 5.0);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = PCFShadowMap;
this.renderer.outputEncoding = sRGBEncoding;
this.canvas.insertAdjacentHTML('beforeend', this.renderer.domElement);
this.renderCalls.push(() => {
this.renderer.render(this.scene, this.camera);
if (this.scene.getObjectByName('car') && !this.modelIsLoaded) {
this.modelIsLoaded = true;
this.modelLoaded.next(this.modelIsLoaded);
this.angleFrontWheels();
}
});
}
/**
* Setting up controls
*/
setupControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.rotateSpeed = 0.3;
this.controls.zoomSpeed = 0.9;
this.controls.minDistance = 3;
this.controls.maxDistance = 5;
this.controls.enableZoom = false;
this.controls.minPolarAngle = Math.PI / 2; // radians
this.controls.maxPolarAngle = Math.PI / 3; // radians
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.enablePan = false;
this.renderCalls.push(() => {
this.controls.update();
});
}
/**
* Setting up lights
*/
setupLights() {
let lightDirect = new DirectionalLight(0xffffff, 2.4);
lightDirect.position.set(-3, 5, 8);
lightDirect.castShadow = true;
lightDirect.shadow.camera.near = 3;
lightDirect.shadow.camera.far = 30;
lightDirect.shadow.mapSize.width = 1024;
lightDirect.shadow.mapSize.height = 1024;
this.scene.add(lightDirect);
let light = new HemisphereLight(0xffffff, 0xbebec5, 1);
light.position.set(0, 5, 0);
this.scene.add(light);
}
/**
* Setting up the model
*/
setupModel() {
this.loader = new GLTFLoader();
this.loader.load(
this.model?.file,
(gltf: any) => {
let object = gltf.scene;
object.traverse((e: any) => {
if (e.isMesh === true && !e.userData.hasOwnProperty('annotation')) {
e.castShadow = true;
e.recieveShadow = true;
}
if (e.userData.hasOwnProperty('annotation')) {
const basicMaterial = new MeshBasicMaterial({
name: e.name,
color: 0xffffff,
opacity: 0.0,
transparent: true,
alphaTest: 0.0,
side: DoubleSide,
});
e.material = basicMaterial;
e.originalScale = e.scale;
}
if (
e.userData.name == 'license_plate_rear' &&
this.model?.licensePlateText
) {
e.visible = false;
const loader = new FontLoader();
loader.load(this.model?.font, (font: any) => {
var material = new MeshLambertMaterial({
color: 0x000000,
});
var geometry = new TextGeometry(this.model?.licensePlateText, {
font: font,
size: 70,
height: 5,
bevelEnabled: true,
bevelThickness: 4,
bevelSize: 4,
bevelSegments: 5,
curveSegments: 12,
});
var licensePlate = new Mesh(geometry, material);
licensePlate.position.set(
e.position.x,
e.position.y,
e.position.z
);
licensePlate.scale.set(
e.scale.x / 100,
e.scale.y / 100,
e.scale.z / 100
);
licensePlate.rotation.set(
e.rotation.x + Math.PI / 2,
e.rotation.y,
e.rotation.z
);
object.children[0].add(licensePlate);
});
}
});
object.scale.set(1, 1, 1);
object.position.set(0, -1, 0);
object.rotation.y = this.model?.rotation; //-Math.PI / 2;
this.object = object;
this.wheels = [];
this.wheels.push(
object.getObjectByName('wheel_front_left'),
object.getObjectByName('wheel_front_right'),
object.getObjectByName('wheel_rear_left'),
object.getObjectByName('wheel_rear_right')
);
this.scene.add(object);
},
(xhr) => {
this.progress = Math.trunc((xhr.loaded / xhr.total) * 100);
}
);
}
/**
* Event to know if user dragged the model
* @param e
*/
mouseDrag(e: MouseEvent) {
if (this.model?.showHandle) {
this.modelDragged.emit(true);
}
this.model.showHandle = false;
}
/**
* Raycaster to know at which object we clicked
* @param event
*/
onModelMouseClick(event: MouseEvent) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x =
((event.clientX - rect.left) / (rect.right - rect.left)) * 2 - 1;
this.mouse.y =
-((event.clientY - rect.top) / (rect.bottom - rect.top)) * 2 + 1;
this.ray.setFromCamera(this.mouse, this.camera);
var intersects = this.ray.intersectObjects(this.scene.children, true);
if (intersects.length > 0) {
if (intersects[0].object != this.intersectedObject) {
this.intersectedObject = intersects[0].object;
if (
this.intersectedObject.userData.hasOwnProperty('annotation') &&
typeof this.intersectedObject.userData.annotation === 'object'
) {
this.annotation = this.intersectedObject.userData.annotation;
}
}
} else {
this.intersectedObject = null;
}
}
/**
* Raycaster to know at which object we hover over
* @param event
*/
onModelMouseMove(event: MouseEvent) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x =
((event.clientX - rect.left) / (rect.right - rect.left)) * 2 - 1;
this.mouse.y =
-((event.clientY - rect.top) / (rect.bottom - rect.top)) * 2 + 1;
this.ray.setFromCamera(this.mouse, this.camera);
var intersects = this.ray.intersectObjects(this.scene.children, true);
if (intersects.length > 0) {
if (intersects[0].object != this.intersectedObjectHover) {
this.intersectedObjectHover = intersects[0].object;
if (
this.intersectedObjectHover.userData.hasOwnProperty('annotation') &&
typeof this.intersectedObjectHover.userData.annotation === 'object'
) {
this.canvas.style.cursor = 'pointer';
} else {
this.canvas.style.cursor = 'grab';
}
}
} else {
this.canvas.style.cursor = 'grab';
this.intersectedObjectHover = null;
}
}
/**
* Setting up floor
*/
setupFloor() {
this.floorMesh = new Mesh(this.floorGeom, this.floorMat);
this.floorMesh.receiveShadow = true;
this.floorMesh.rotation.x = -Math.PI / 2;
this.floorMesh.position.y = -1;
this.scene.add(this.floorMesh);
}
/**
* Resetting annotation
*/
closeAnnotation() {
this.annotation = null;
this.intersectedObject = null;
}
/**
* Render trigger to animate the model
*/
render() {
window.requestAnimationFrame(this.render.bind(this));
this.renderCalls.forEach((callback) => {
callback();
});
}
}