diff --git a/sdk-src/plugins/hotpoint/HotpointPlugin.ts b/sdk-src/plugins/hotpoint/HotpointPlugin.ts
new file mode 100644
index 0000000..1e8613b
--- /dev/null
+++ b/sdk-src/plugins/hotpoint/HotpointPlugin.ts
@@ -0,0 +1,171 @@
+import * as THREE from "three";
+import { CSS2DObject, CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
+
+import { BaseViewer, CSS2DObjectUtils, log, Hotpoint, matrixAutoUpdate, Plugin, ViewerEvent, Vector2, Vector3 } from "src/core";
+
+/**
+ * Hotpoint plugin manages hotpoints in a viewer.
+ * It can be used by DxfViewer and BimViewer.
+ * The hotpoint feature in VRViewer is more complex, that one has nothing to do with this plugin.
+ * VRViewer is able to use this plugin though.
+ * - A hotpoint is created and stored by user. User define its html and css.
+ * - A hotpoint can be added to, and removed from viewer.
+ * - Caller should set a hotpointId that is unique in the session of current viewer.
+ * - DxfViewer doesn't maintain the relationship between hotpoint and layout.
+ */
+export class HotpointPlugin extends Plugin {
+ protected hotpointRoot?: THREE.Group;
+ protected css2dRenderer: CSS2DRenderer;
+
+ constructor(viewer: BaseViewer) {
+ super(viewer, { id: "HotpointPlugin" });
+
+ // eslint-disable-next-line
+ this.css2dRenderer = (this.viewer as any).css2dRenderer;
+
+ this.viewer.addEventListener(ViewerEvent.AfterRender, this.onAfterRender);
+ }
+
+ /**
+ * @description {en} Adds a hotpoint.
+ * Caller should set a hotpointId that is unique in the session of current viewer.
+ * @description {zh} 添加热点。
+ * 调用者应该设置一个在当前Viewer会话中唯一的热点id。
+ * @param hotpoint
+ * - {en} hotpoint data.
+ * - {zh} 热点数据。
+ * @example
+ * ``` typescript
+ * const hotpoint = {
+ * hotpointId: "c6ea70a3-ddb0-4dd0-87c8-bd2491936428",
+ * anchorPosition: [0, 0, 0],
+ * html: "
hotpoint
",
+ * visible: true,
+ * };
+ * const plugin = new HotpointPlugin(viewer);
+ * plugin.add(hotpoint);
+ * ```
+ */
+ add(hotpoint: Hotpoint): void {
+ const exists = this.has(hotpoint.hotpointId);
+ if (exists) {
+ log.warn(`[Hotpoint] Hotpoint with id '${hotpoint.hotpointId}' already exist!`);
+ return;
+ }
+ const p = hotpoint.anchorPosition;
+ const object = CSS2DObjectUtils.createHotpoint(hotpoint.html);
+ object.position.set(p[0] || 0, p[1] || 0, p[2] || 0);
+ object.visible = hotpoint.visible !== false;
+ object.userData.hotpoint = hotpoint;
+
+ if (!this.hotpointRoot) {
+ this.hotpointRoot = new THREE.Group();
+ this.hotpointRoot.matrixAutoUpdate = matrixAutoUpdate;
+ this.hotpointRoot.matrixWorldAutoUpdate = false;
+ this.hotpointRoot.name = "HotpointRoot";
+ this.viewer.scene?.add(this.hotpointRoot);
+ }
+ this.hotpointRoot.add(object);
+ object.updateWorldMatrix(false, false);
+ this.viewer.enableRender();
+ }
+
+ /**
+ * @description {en} Removes a hotpoint by given hotpointId.
+ * @description {zh} 根据热点id删除热点。
+ * @param {string} hotpointId
+ * - {en} hotpoint id.
+ * - {zh} 热点id。
+ * @example
+ * ``` typescript
+ * const hotpointId = "c6ea70a3-ddb0-4dd0-87c8-bd2491936428";
+ * const plugin = new HotpointPlugin(viewer);
+ * plugin.remove(hotpointId);
+ * ```
+ */
+ remove(hotpointId: string): void {
+ const objects = this.hotpointRoot?.children || [];
+ for (let i = 0; i < objects.length; ++i) {
+ const obj = objects[i];
+ if (obj.userData.hotpoint?.hotpointId === hotpointId) {
+ obj.removeFromParent();
+ }
+ }
+ }
+
+ /**
+ * @description {en} Clears all hotpoints.
+ * @description {zh} 清除所有热点。
+ * @example
+ * ``` typescript
+ * const plugin = new HotpointPlugin(viewer);
+ * plugin.clear();
+ * ```
+ */
+ clear(): void {
+ this.hotpointRoot?.clear();
+ }
+
+ /**
+ * Checks if hotpoint with specific id already exist
+ * Caller should set a hotpointId that is unique in the session of current DxfViewer.
+ * @internal
+ */
+ has(hotpointId: string): boolean {
+ return !!this.findHotpointObject(hotpointId);
+ }
+
+ /**
+ * @description {en} Moves a hotpoint.
+ * @description {zh} 移动热点的位置。
+ * @example
+ * ``` typescript
+ * const hotpointId = "c6ea70a3-ddb0-4dd0-87c8-bd2491936428";
+ * const plugin = new HotpointPlugin(viewer);
+ * plugin.move(hotpointId, [10, 10, 0]);
+ * ```
+ */
+ move(hotpointId: string, position: Vector2 | Vector3): void {
+ const object = this.findHotpointObject(hotpointId);
+ if (object) {
+ const z = (position as Vector3).z || 0;
+ object.position.set(position.x, position.y, z);
+ object.updateWorldMatrix(false, false);
+ this.viewer.enableRender();
+ // we probably don't need to update the anchorPosition
+ }
+ }
+
+ /**
+ * @description {en} Hides or show a hotpoint.
+ * @description {zh} 显示或隐藏一个热点。
+ * @example
+ * ``` typescript
+ * const hotpointId = "c6ea70a3-ddb0-4dd0-87c8-bd2491936428";
+ * const plugin = new HotpointPlugin(viewer);
+ * plugin.setVisible(hotpointId, false);
+ * ```
+ */
+ setVisible(hotpointId: string, visible: boolean): void {
+ const object = this.findHotpointObject(hotpointId);
+ if (object) {
+ object.visible = visible;
+ }
+ }
+
+ protected findHotpointObject(hotpointId: string): CSS2DObject | undefined {
+ const objects = this.hotpointRoot?.children || [];
+ const object = objects.find((obj) => obj.userData.hotpoint?.hotpointId === hotpointId);
+ return object as CSS2DObject;
+ }
+
+ protected onAfterRender = () => {
+ const scene = this.viewer.scene;
+ const camera = this.viewer.camera;
+ if (!scene || !camera || !this.hotpointRoot || this.hotpointRoot.children.length === 0) {
+ return;
+ }
+ // TODO: if css2dRenderer.render() is already called in viewer, then we don't need to call it again.
+ this.css2dRenderer?.render(scene, camera);
+ };
+}