@bimdata/2d-engine
Version:
An object oriented 2D viewer to load thousands of objects that you can select, highlight, hide, move with high performances
695 lines (560 loc) • 18.7 kB
Markdown
<p align="center"><img src="./favicon.ico" alt="2D Engine"></p>
<p align="center">An easy to use 2D engine to load model with objects that you can select, highlight, hide, update shapes...</p>

# Quick start
## Simple example
This simple example shows how to load the engine with two simple objects and interact with them.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BIMData 2D Engine</title>
<style>
* {
margin: 0px;
padding: 0px;
}
</style>
</head>
<body style="background-color: gainsboro;">
<canvas id="canvas2d" style="width: 100vw; height: 100vh;"></canvas>
<script type="module">
import makeViewer from "https://www.unpkg.com/@bimdata/2d-engine";
const canvas = document.getElementById("canvas2d");
const viewer = makeViewer({ canvas });
// Select object on click, fitview on right-click.
viewer.ui.on("click", ({ object, rightClick }) => {
if (object) {
if (rightClick) {
viewer.camera.fitView(object);
} else {
object.selected = !object.selected;
}
}
});
// Highlight object on hover.
viewer.ui.on("mousemove", ({ object, previousObject }) => {
if (object !== previousObject) {
if (object) {
viewer.scene.highlightObjects([object.id]);
}
if (previousObject) {
viewer.scene.unhighlightObjects([previousObject.id]);
}
}
});
viewer.ui.on("mouseleave", () => {
const highlightedObjectIds = viewer.scene.highlightedObjects.map(
o => o.id
);
viewer.scene.unhighlightObjects(highlightedObjectIds);
});
// Add a model with two simple objects. A line and a circle.
const model = await viewer.scene.addModel({
objects: [
{
// Line
shapes: [
{
style {
lineWidth: 2,
lineOpacity: 0.8,
lineColor: 0x0000ff,
},
paths: [
[0, 0, 50, 50],
[0, 50, 50, 0],
],
}
]
},
{
// Circle
shapes: [
{
style {
textureOpacity: 0.5,
texture: "solid",
textureTint: 0xff00ff,
},
paths: [
{
type: "arc",
x: 25,
y: 25,
radius: 20,
},
],
}
]
},
],
});
// Fit view once the model is loaded.
viewer.camera.fitView(model);
</script>
</body>
</html>
```
The result:

## More complex example
Of course you can do more that just drawing circles and lines.
In this example, a complete building storey is drawn and doors and space names are displayed using addons.
These addons are simple javascript code that use the 2D Engine API.

To see more about these addons:
- [FPS](./dev/fps.js)
- [Grid](./dev/grid.js)
- [Scale](./dev/scale.js)
## Features
The 2D Engine provide tools to customise and interact with models/objects you load.
- Draw object shapes with line and texture cutsomization.
- Show, hide, select and highlight objects.
- The camera can scale, rotate, pan and fitview objects/models.
- ...
See the [API reference](#API) to know all available features.
# Install
## NPM
```bash
npm i @bimdata/2d-engine
```
```javascript
import makeViewer from "@bimdata/2d-engine";
```
## `<script>` tag
```html
<script src="https://www.unpkg.com/@bimdata/2d-engine"></script>
```
Add `makeViewer` available on the window object.
Or with a `<script type="module">`:
```html
<script type="module">
import makeViewer from "https://www.unpkg.com/@bimdata/2d-engine";
// have fun
</script>
```
# API
## Init factory
The package default export is a factory function that create an Engine 2D instance:
```javascript
import makeViewer from "@bimdata/2d-engine";
const canvas = document.getElementById("canvas");
const viewer = makeViewer({ canvas });
```
You must pass a `canvas` to the factory and some additional options can be provided to customize the Engine 2D behavior:
| Property | Type | Description |
| :------------ | :------------------ | :---------------------------------------------------------------------------------------- |
| `canvas` | `HTMLCanvasElement` | **Required**. |
| `autoStart` | `boolean` | **Default**: `true`. If `false` you must call `viewer.ticker.start()` to start rendering. |
| `select` | `boolean` | **Default**: `true`. If `false` objects are not selectable. |
| `highlight` | `boolean` | **Default**: `true`. If `false` objects are not highlighted on hover. |
| `picking` | `boolean` | **Default**: `true`. If `false` objects are not pickable. |
| `showPicking` | `boolean` | **Default**: `false`. |
| `snap` | `boolean` | **Default**: `true`. If `false` snap is not enabled. |
| `showSnap` | `boolean` | **Default**: `false`. |
| `text` | `boolean` | **Default**: `true`. If `false` objects text are not displayed. |
The factory returns a `Viewer` object with the following interface:
```typescript
interface Viewer extends {
camera: Camera;
canvas: HTMLCanvasElement;
constants: Constants;
scene: Scene;
picker: Picker;
renderer: Renderer;
settings: Settings;
ticker: Ticker;
ui: UI;
utils: {
vector2D: Vector2DUtils;
};
destroy(): void;
}
```
## Scene
The viewer **scene** is where model and objects lives.
You can add/remove models and objects to/from the scene.
Objects can be accessed/modified in batch via specific getters and setters.
The `Scene` has the following interface:
```typescript
interface Scene {
readonly viewer: Viewer;
// Models
readonly models: Model[];
readonly modelsMap: Map<string, Model>;
addModel(modelData: ModelData): Promise<Model>;
removeModel(modelId: string): boolean;
// Objects
readonly objects: SceneObject[];
readonly objectsMap: Map<string, SceneObject>;
addObject(objectData: SceneObjectData, model?: Model): SceneObject;
removeObject(objectId: string): boolean;
// Object getters
readonly shownObjects: SceneObject[];
readonly hiddenObjects: SceneObject[];
readonly selectedObjects: SceneObject[];
readonly unselectedObjects: SceneObject[];
readonly highlightedObjects: SceneObject[];
readonly unhighlightedObjects: SceneObject[];
readonly pickableObjects: SceneObject[];
readonly unpickableObjects: SceneObject[];
// Objects setters
showObjects(ids: number[]): void;
hideObjects(ids: number[]): void;
selectObjects(ids: number[]): void;
deselectObjects(ids: number[]): void;
highlightObjects(ids: number[]): void;
unhighlightObjects(ids: number[]): void;
setObjectsPickable(ids: number[]): void;
setObjectsUnpickable(ids: number[]): void;
}
```
### Model and Objects
`Model` and `Object` have the following interfaces:
```typescript
interface Model {
readonly id: string;
readonly scene: Scene;
readonly objects: SceneObject[];
addObject(objectData: SceneObjectData): SceneObject;
removeObject(objectId: string): boolean;
readonly center: Point;
readonly aabb: AABB;
}
interface SceneObject {
readonly id: string;
readonly scene: Scene;
readonly model: Model;
readonly shapes: Shape[];
readonly zIndex: number;
readonly pickingZIndex: number;
// Scene state properties
visible: boolean;
selected: boolean;
highlighted: boolean;
pickable: boolean;
}
```
Objects have shapes. A shape have the following interface:
```typescript
interface Shape {
readonly id: number;
readonly object: SceneObject;
readonly center: Point;
readonly aabb: AABB;
readonly paths: Path[];
getPath(index?: number): Path | undefined;
addPath(pathData: PathData, index?: number): Path;
removePath(index?: number): Path | undefined;
}
```
The viewer handle three types of shape: `line`, `arc` and `curve`.
```typescript
interface Path {
type: PathType;
close: boolean; // If true, the shape draw is closed after this path and the next path restart the draw. Equivalent of the svg command "Z".
start: boolean; // If true, a new draw is started before drawing this path. Equivalent of the svg command "M".
}
enum PathType {
line = "line",
arc = "arc",
curve = "curve",
}
```
By default, a draw is automatically started before the first path of a shape, and after a close path command.
This behaviour can be changed using the `start` property.
```typescript
interface Point {
x: number;
y: number;
}
interface Line {
readonly type: PathType.line;
readonly points: Point[];
getPoint(index?: number): Point | undefined;
addPoint(point: Point, index?: number): Point;
removePoint(index?: number): Point | undefined;
}
interface Arc {
readonly type: PathType.arc;
x: number;
y: number;
radius: number;
startAngle: number;
endAngle: number;
anticlockwise?: boolean;
}
interface Curve {
readonly type: PathType.curve;
x: number;
y: number;
cpX1: number;
cpY1: number;
cpX2: number;
cpY2: number;
toX: number;
toY: number;
}
```
When adding objects on a scene, shapes can be provided to object using the `shapes` property.
Note: array of numbers will be displayed as lines by default.
```javascript
const model = await viewer.scene.addModel({
objects: [
{
shapes: [
{
paths: [
[0, 0, 50, 50], // a line
{
type: "line", // another line
points: [0, 50, 50, 0],
},
{
type: "arc", // a default arc => full circle
x: 25,
y: 25,
radius: 20,
},
]
}
],
},
],
});
```
### Events
The following events are emitted by the **Scene** :
| Event | Payload |
| ------------------ | ------------------------- |
| `"model-added"` | The added model. |
| `"model-removed"` | The removed model. |
| `"object-added"` | The added object. |
| `"object-removed"` | The removed object. |
| `"object-update"` | `{ object, property, value?, oldValue? }`. No `value` and `oldValue` for the "shapes" property. |
Events can be listened using `scene.on()`:
```javascript
viewer.scene.on("model-added", model =>
console.log(`A model is loaded with the id ${model.id}`)
);
```
<!--
## UI
The UI is the only component connected to a DOM element, listening to events. It has the following interface:
```typescript
interface UI extends EventHandler<UIHandlerEvents> {
connect(el: HTMLElement): void;
disconnect(): boolean;
}
```
The `camera` and the `picker` listen to the UI events. Disconnecting the UI will make them not reactive to user interactions.
### Events
- "click", payload: { canvasPosition, keys }
- "right-click", payload: { canvasPosition, keys }
- "move", payload: { canvasPosition, keys }
- "draw", payload: { dx, dy, keys }
- "drag", payload: { dx, dy, keys }
- "scroll", payload: { canvasPosition, dx, dy, keys }
- "exit", no payload, when the mouse leave `el`.
-->
## Camera
The camera is binded on the mouse events. It has the following interface:
```typescript
interface Camera extends Readonly<EventHandler<{ update: PIXITransform }>> {
fitView(positionable: Positionable | Positionable[]): void;
destroy(): void;
controller: CameraController;
/**
* @param factor ]-Infinity, +Infinity[
* @param origin the zoom transform center.
*/
zoomIn(factor: number, origin: Point): void;
/**
* @param factor ]-Infinity, +Infinity[
* @param origin the zoom transform center.
*/
zoomOut(factor: number, origin: Point): void;
/**
* @param angle in degree clockwise.
* @param origin the rotation transform center.
*/
rotate(angle: number, origin: Point): void;
translate(dx: number, dy: number): void;
/**
* @param ds delta scale ]0, +Infinity[
* @param origin the scale transform center.
*/
scale(ds: number, origin: Point): void;
move(position: Point): void;
transform: PIXIMatrix;
position: Point;
/**
* @returns { number } the camera rotation angle in degree clockwise.
*/
rotation: number;
/**
* @returns { number } ]-Infinity, +Infinity[
*/
getScale(): number;
/**
* @returns { number } ]0, +Infinity[
*/
zoom: number;
/**
* Returns the position of the given canvas position.
*/
getPosition(canvasPosition: Point): Point;
/**
* Returns the canvas position of the given position.
*/
getCanvasPosition(position: Point): Point;
getViewpoint(): Viewpoint;
setViewpoint(viewpoint: Viewpoint): Viewpoint;
}
```
It is possible to customise the camera behaviour by changing the properties of the `camera.controller` that has the following interface:
```typescript
interface CameraController {
translatable: boolean;
rotatable: boolean;
scallable: boolean;
}
```
All properties are `true` by default.
## Settings
Settings are predefined values/keys that define the Engine behaviour. It has the following interface:
```typescript
interface Settings {
dragKey: string; // default "shift"
rotateKey: string; // default "shift"
clickMaxOffset: number; // default 5
scaleSpeed: number; // default 0.002
rotateSpeed: number; // default 0.1
maxScale: number; // default 100
minScale: number; // default 0.1
fitViewRatio: number; // 1
curves: {
adaptive: boolean;
maxLength: number;
maxSegments: number;
minSegments: number;
};
}
```
## Ticker
Use `viewer.ticker.start/stop()` to start or stop the rendering.
Use `viewer.ticker.add/addOnce(name: string, callback: Function)` to schedule a callback on the next tick.
The callback will be called with `dt`, the delta time since the next tick.
**WARNING:** adding many tasks with the same name will override the previous tasks and only the last added will be called on the next tick.
This is to avoid a callback to be executed more than once per tick.
## Renderer
Renderer emits events you can use for addons:
| Event | Payload | Description |
| ------------------ | ---------------------------- | ----------- |
| `"resize"` | `{ dx: number, dy: number }` | Emited when the renderer resizes. |
| `"pre-draw"` | None | Emited before the draw phase. |
| `"post-draw"` | None | Emited after the draw phase. |
## Styles
Styles define how objects are drawn by default when they are visible, selected and highlighted.
It can be customized by changing the default styles or loading objects with non default style.
**WARNING:** updating styles.default won't affect already loaded objects.
The `Style` object has the following interface:
```typescript
interface Style {
// LINE
lineWidth?: number;
lineColor?: number;
lineOpacity?: number;
lineDash?: number[];
lineCap?: Constants.LINE_CAP;
lineJoin?: Constants.LINE_JOIN;
// FILL
fill?: number | string; // number for hex color, string for a registered texture name
fillOpacity?: number;
// TEXTURE
texture?: string;
textureTint?: number;
textureOpacity?: number;
}
```
You can change `textureTint` and `lineColor` using hexadecimal numbers.
```javascript
viewer.styles.default.textureTint = 0xff0000; // For red. (RGB)
```
`textureOpacity` and `lineOpacity` are numbers between 0 and 1.
`lineDash` is an array of numbers describing the dash pattern you want to use.
See [here](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setLineDash#parameters) for more informations.
## Texture Manager
The Texture Manager has the following interface:
```typescript
interface TextureManager {
textureMatrix: TextureMatrix;
load(name: string, src: string, width?: number, height?: number): Object;
}
// and the interface of the texture matrix
interface TextureMatrix {
translate(x: number, y: number): void;
rotate(angle: number): void;
scale(x: number, y?: number): void;
}
```
The viewer only have "solid" texture built in but it is possible to load more:
```javascript
await viewer.textureManager.load(
"myTextureName",
"./my/textures/folder/myTexture.png"
);
```
NOTE: this is async code and must be awaited in order to the texture to be available in the viewer.
This is what you can get: (wall are textured using a cross hatch texture)
<img src="./assets/doc/wallTextureRaw.png" alt="wall texture raw" width="400" height="300"/>
Notice that the textrure is aligned with the axis. If the model is not aligned with the axis,
you can use the Texture Manager textureMatrix to rotate the textures.
```javascript
viewer.textureManager.textureMatrix.rotate(30);
```
<img src="./assets/doc/wallTextureRotated.png" alt="wall texture rotated" width="400" height="300"/>
## Vector2DUtils
The `Viewer.utils.vector2D` object exposes some methods to work with 2D vectors:
```typescript
interface Vector2DUtils {
distance(v1: Vector2D, v2: Vector2D): number;
sub(v1: Vector2D, v2: Vector2D): Vector2D;
add(v1: Vector2D, v2: Vector2D): Vector2D;
normalize(v: Vector2D): Vector2D;
length(v: Vector2D): number;
dot(v1: Vector2D, v2: Vector2D): number;
cross(v1: Vector2D, v2: Vector2D): number;
rotateAround(v: Vector2D, center: Vector2D, angle: number): Vector2D;
angle(v1: Vector2D, v2: Vector2D): number;
scale(v1: Vector2D, origin: Point, factor: number): Vector2D;
}
```
# Development
### Start dev server:
```
npm run dev
```
### Run unit tests
```
npm run test:unit
```
### Run e2e tests
```
npm run build:prod
npm run test:e2e
```
Or in dev mode (will open a browser):
```
npm run test:e2e-dev
```
# Publish
To publish a new version of the package you need to:
- **1)** Upgrade pakage version using `npm version`
- **2)** Push the version commit and tag to `main` in order to trigger the CI that will publish the new version on the NPM registry
```
npm version [patch | minor | major]
git push --tags origin main
```