第四章:用户界面与交互系统
4.1 UI架构概述
4.1.1 组件化设计
Chili3D的用户界面采用组件化设计,将复杂的界面分解为可复用的小组件。这种设计使得代码更容易维护、测试和扩展。
核心UI包结构:
packages/chili-ui/src/
├── cursor/ # 光标组件
├── home/ # 首页组件
├── project/ # 项目树组件
├── property/ # 属性面板组件
├── ribbon/ # 功能区组件
├── statusbar/ # 状态栏组件
├── toast/ # Toast提示组件
├── viewport/ # 视口组件
├── dialog.ts # 对话框
├── editor.ts # 编辑器主界面
├── mainWindow.ts # 主窗口
├── okCancel.ts # 确认取消按钮
└── permanent.ts # 永久性UI元素
4.1.2 样式系统
Chili3D使用CSS Modules进行样式管理,每个组件都有对应的样式文件:
// 样式导入示例
import styles from "./mainWindow.module.css";
export class MainWindow extends HTMLElement {
constructor() {
super();
this.className = styles.mainWindow;
}
}
CSS变量定义:
/* 主题变量 */
:root {
--primary-color: #0078d4;
--background-color: #ffffff;
--text-color: #333333;
--border-color: #e0e0e0;
--hover-color: #f0f0f0;
--selected-color: #cce5ff;
/* 尺寸变量 */
--ribbon-height: 120px;
--statusbar-height: 24px;
--sidebar-width: 250px;
--property-panel-width: 300px;
}
/* 暗色主题 */
[data-theme="dark"] {
--primary-color: #4dabf7;
--background-color: #1e1e1e;
--text-color: #ffffff;
--border-color: #404040;
--hover-color: #2d2d2d;
--selected-color: #264f78;
}
4.1.3 Web Components
Chili3D大量使用Web Components技术构建UI组件:
// 自定义元素注册
export class RibbonButton extends HTMLElement {
static get observedAttributes(): string[] {
return ["icon", "label", "disabled"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback(): void {
this.render();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string): void {
if (oldValue !== newValue) {
this.render();
}
}
private render(): void {
const icon = this.getAttribute("icon") || "";
const label = this.getAttribute("label") || "";
this.shadowRoot!.innerHTML = `
<style>
:host {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 8px;
cursor: pointer;
}
:host(:hover) {
background-color: var(--hover-color);
}
.icon {
font-size: 24px;
}
.label {
font-size: 12px;
margin-top: 4px;
}
</style>
<span class="icon">${icon}</span>
<span class="label">${label}</span>
`;
}
}
customElements.define("ribbon-button", RibbonButton);
4.2 主窗口结构
4.2.1 MainWindow组件
主窗口是应用程序的容器,负责组织各个子组件:
// chili-ui/src/mainWindow.ts
export class MainWindow extends HTMLElement {
private _ribbon: Ribbon;
private _projectTree: ProjectTree;
private _propertyPanel: PropertyPanel;
private _viewportContainer: ViewportContainer;
private _statusBar: StatusBar;
constructor() {
super();
this.className = styles.mainWindow;
// 创建子组件
this._ribbon = new Ribbon();
this._projectTree = new ProjectTree();
this._propertyPanel = new PropertyPanel();
this._viewportContainer = new ViewportContainer();
this._statusBar = new StatusBar();
}
connectedCallback(): void {
// 构建布局
const layout = document.createElement("div");
layout.className = styles.layout;
// 添加功能区
this.appendChild(this._ribbon);
// 创建主内容区
const mainContent = document.createElement("div");
mainContent.className = styles.mainContent;
// 左侧边栏
const leftSidebar = document.createElement("div");
leftSidebar.className = styles.leftSidebar;
leftSidebar.appendChild(this._projectTree);
mainContent.appendChild(leftSidebar);
// 中央视口区
mainContent.appendChild(this._viewportContainer);
// 右侧属性面板
const rightSidebar = document.createElement("div");
rightSidebar.className = styles.rightSidebar;
rightSidebar.appendChild(this._propertyPanel);
mainContent.appendChild(rightSidebar);
this.appendChild(mainContent);
// 添加状态栏
this.appendChild(this._statusBar);
}
}
customElements.define("main-window", MainWindow);
4.2.2 布局样式
/* mainWindow.module.css */
.mainWindow {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.mainContent {
display: flex;
flex: 1;
overflow: hidden;
}
.leftSidebar {
width: var(--sidebar-width);
min-width: 200px;
max-width: 400px;
border-right: 1px solid var(--border-color);
overflow: auto;
resize: horizontal;
}
.rightSidebar {
width: var(--property-panel-width);
min-width: 250px;
max-width: 500px;
border-left: 1px solid var(--border-color);
overflow: auto;
resize: horizontal;
}
.viewportContainer {
flex: 1;
position: relative;
overflow: hidden;
}
4.3 功能区(Ribbon)
4.3.1 Ribbon架构
功能区采用选项卡式设计,每个选项卡包含多个命令组:
// chili-ui/src/ribbon/ribbon.ts
export class Ribbon extends HTMLElement {
private _tabs: Map<string, RibbonTab> = new Map();
private _activeTab: RibbonTab | undefined;
private _tabBar: HTMLElement;
private _content: HTMLElement;
constructor() {
super();
this.className = styles.ribbon;
// 创建选项卡栏
this._tabBar = document.createElement("div");
this._tabBar.className = styles.tabBar;
// 创建内容区
this._content = document.createElement("div");
this._content.className = styles.content;
}
addTab(id: string, label: string): RibbonTab {
const tab = new RibbonTab(id, label);
this._tabs.set(id, tab);
// 创建选项卡按钮
const tabButton = document.createElement("button");
tabButton.className = styles.tabButton;
tabButton.textContent = t(label);
tabButton.onclick = () => this.activateTab(id);
this._tabBar.appendChild(tabButton);
// 添加到内容区
this._content.appendChild(tab);
tab.style.display = "none";
// 如果是第一个选项卡,激活它
if (this._tabs.size === 1) {
this.activateTab(id);
}
return tab;
}
activateTab(id: string): void {
const tab = this._tabs.get(id);
if (!tab) return;
// 隐藏当前活动选项卡
if (this._activeTab) {
this._activeTab.style.display = "none";
}
// 显示新选项卡
tab.style.display = "flex";
this._activeTab = tab;
// 更新选项卡按钮样式
this.updateTabButtonStyles(id);
}
}
4.3.2 RibbonTab组件
// chili-ui/src/ribbon/ribbonTab.ts
export class RibbonTab extends HTMLElement {
private _groups: Map<string, RibbonGroup> = new Map();
constructor(readonly id: string, readonly label: string) {
super();
this.className = styles.tab;
}
addGroup(id: string, label: string): RibbonGroup {
const group = new RibbonGroup(id, label);
this._groups.set(id, group);
this.appendChild(group);
return group;
}
}
4.3.3 RibbonGroup组件
// chili-ui/src/ribbon/ribbonGroup.ts
export class RibbonGroup extends HTMLElement {
private _content: HTMLElement;
private _label: HTMLElement;
constructor(readonly id: string, label: string) {
super();
this.className = styles.group;
// 创建内容区
this._content = document.createElement("div");
this._content.className = styles.groupContent;
this.appendChild(this._content);
// 创建标签
this._label = document.createElement("div");
this._label.className = styles.groupLabel;
this._label.textContent = t(label);
this.appendChild(this._label);
}
addButton(options: RibbonButtonOptions): RibbonButton {
const button = new RibbonButton(options);
this._content.appendChild(button);
return button;
}
addSplitButton(options: SplitButtonOptions): SplitButton {
const button = new SplitButton(options);
this._content.appendChild(button);
return button;
}
}
4.3.4 功能区配置
// chili-builder/src/ribbon.ts
export function configureRibbon(ribbon: Ribbon): void {
// 首页选项卡
const homeTab = ribbon.addTab("home", "ribbon.home");
const fileGroup = homeTab.addGroup("file", "ribbon.file");
fileGroup.addButton({
icon: "icon-new",
label: "command.new",
command: "Application.New"
});
fileGroup.addButton({
icon: "icon-open",
label: "command.open",
command: "Application.Open"
});
fileGroup.addButton({
icon: "icon-save",
label: "command.save",
command: "Application.Save"
});
const editGroup = homeTab.addGroup("edit", "ribbon.edit");
editGroup.addButton({
icon: "icon-undo",
label: "command.undo",
command: "Edit.Undo"
});
editGroup.addButton({
icon: "icon-redo",
label: "command.redo",
command: "Edit.Redo"
});
// 绘图选项卡
const drawTab = ribbon.addTab("draw", "ribbon.draw");
const primitivesGroup = drawTab.addGroup("primitives", "ribbon.primitives");
primitivesGroup.addButton({
icon: "icon-box",
label: "command.box",
command: "Create.Box"
});
primitivesGroup.addButton({
icon: "icon-sphere",
label: "command.sphere",
command: "Create.Sphere"
});
primitivesGroup.addButton({
icon: "icon-cylinder",
label: "command.cylinder",
command: "Create.Cylinder"
});
const sketchGroup = drawTab.addGroup("sketch", "ribbon.sketch");
sketchGroup.addButton({
icon: "icon-line",
label: "command.line",
command: "Create.Line"
});
sketchGroup.addButton({
icon: "icon-circle",
label: "command.circle",
command: "Create.Circle"
});
sketchGroup.addButton({
icon: "icon-rectangle",
label: "command.rectangle",
command: "Create.Rectangle"
});
// 修改选项卡
const modifyTab = ribbon.addTab("modify", "ribbon.modify");
const transformGroup = modifyTab.addGroup("transform", "ribbon.transform");
transformGroup.addButton({
icon: "icon-move",
label: "command.move",
command: "Modify.Move"
});
transformGroup.addButton({
icon: "icon-rotate",
label: "command.rotate",
command: "Modify.Rotate"
});
transformGroup.addButton({
icon: "icon-mirror",
label: "command.mirror",
command: "Modify.Mirror"
});
const booleanGroup = modifyTab.addGroup("boolean", "ribbon.boolean");
booleanGroup.addButton({
icon: "icon-union",
label: "command.union",
command: "Modify.Boolean.Union"
});
booleanGroup.addButton({
icon: "icon-cut",
label: "command.cut",
command: "Modify.Boolean.Cut"
});
booleanGroup.addButton({
icon: "icon-intersect",
label: "command.intersect",
command: "Modify.Boolean.Intersect"
});
}
4.4 项目树
4.4.1 ProjectTree组件
项目树显示文档的层级结构:
// chili-ui/src/project/projectTree.ts
export class ProjectTree extends HTMLElement {
private _document: IDocument | undefined;
private _treeView: TreeView;
constructor() {
super();
this.className = styles.projectTree;
// 创建工具栏
const toolbar = this.createToolbar();
this.appendChild(toolbar);
// 创建树视图
this._treeView = new TreeView();
this.appendChild(this._treeView);
}
setDocument(document: IDocument | undefined): void {
this._document = document;
this.refresh();
}
refresh(): void {
this._treeView.clear();
if (!this._document) return;
// 构建树节点
const rootItem = this.createTreeItem(this._document.rootNode);
this._treeView.addItem(rootItem);
}
private createTreeItem(node: INode): TreeItem {
const item = new TreeItem({
id: node.id,
label: node.name,
icon: this.getNodeIcon(node),
data: node
});
// 添加子节点
for (const child of node.children) {
item.addChild(this.createTreeItem(child));
}
// 绑定事件
item.onSelect = () => this.onNodeSelected(node);
item.onDoubleClick = () => this.onNodeDoubleClick(node);
item.onContextMenu = (e) => this.showContextMenu(e, node);
return item;
}
private getNodeIcon(node: INode): string {
if (node instanceof GeometryNode) {
return "icon-shape";
} else if (node instanceof FolderNode) {
return "icon-folder";
} else if (node instanceof GroupNode) {
return "icon-group";
}
return "icon-node";
}
private onNodeSelected(node: INode): void {
if (this._document) {
this._document.selection.select([node]);
}
}
}
4.4.2 TreeView组件
// chili-ui/src/project/treeView.ts
export class TreeView extends HTMLElement {
private _items: Map<string, TreeItem> = new Map();
private _selectedItems: Set<string> = new Set();
constructor() {
super();
this.className = styles.treeView;
}
addItem(item: TreeItem): void {
this._items.set(item.id, item);
this.appendChild(item);
}
removeItem(id: string): void {
const item = this._items.get(id);
if (item) {
item.remove();
this._items.delete(id);
}
}
clear(): void {
this.innerHTML = "";
this._items.clear();
this._selectedItems.clear();
}
selectItem(id: string, addToSelection: boolean = false): void {
if (!addToSelection) {
this.clearSelection();
}
const item = this._items.get(id);
if (item) {
item.selected = true;
this._selectedItems.add(id);
}
}
clearSelection(): void {
for (const id of this._selectedItems) {
const item = this._items.get(id);
if (item) {
item.selected = false;
}
}
this._selectedItems.clear();
}
}
4.4.3 TreeItem组件
// chili-ui/src/project/treeItem.ts
export class TreeItem extends HTMLElement {
private _expanded: boolean = true;
private _selected: boolean = false;
private _children: TreeItem[] = [];
private _header: HTMLElement;
private _childContainer: HTMLElement;
readonly id: string;
readonly data: any;
onSelect?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: MouseEvent) => void;
constructor(options: TreeItemOptions) {
super();
this.className = styles.treeItem;
this.id = options.id;
this.data = options.data;
// 创建头部
this._header = document.createElement("div");
this._header.className = styles.itemHeader;
// 展开/折叠按钮
const expander = document.createElement("span");
expander.className = styles.expander;
expander.onclick = (e) => {
e.stopPropagation();
this.toggle();
};
this._header.appendChild(expander);
// 图标
const icon = document.createElement("span");
icon.className = `${styles.icon} ${options.icon}`;
this._header.appendChild(icon);
// 标签
const label = document.createElement("span");
label.className = styles.label;
label.textContent = options.label;
this._header.appendChild(label);
this._header.onclick = () => this.onSelect?.();
this._header.ondblclick = () => this.onDoubleClick?.();
this._header.oncontextmenu = (e) => {
e.preventDefault();
this.onContextMenu?.(e);
};
this.appendChild(this._header);
// 子节点容器
this._childContainer = document.createElement("div");
this._childContainer.className = styles.children;
this.appendChild(this._childContainer);
}
get selected(): boolean {
return this._selected;
}
set selected(value: boolean) {
this._selected = value;
this._header.classList.toggle(styles.selected, value);
}
get expanded(): boolean {
return this._expanded;
}
set expanded(value: boolean) {
this._expanded = value;
this._childContainer.style.display = value ? "block" : "none";
this.classList.toggle(styles.collapsed, !value);
}
toggle(): void {
this.expanded = !this.expanded;
}
addChild(child: TreeItem): void {
this._children.push(child);
this._childContainer.appendChild(child);
}
}
4.5 属性面板
4.5.1 PropertyPanel组件
// chili-ui/src/property/propertyPanel.ts
export class PropertyPanel extends HTMLElement {
private _editors: Map<string, PropertyEditor> = new Map();
private _currentTarget: any;
constructor() {
super();
this.className = styles.propertyPanel;
}
setTarget(target: any): void {
this._currentTarget = target;
this.refresh();
}
refresh(): void {
this.innerHTML = "";
this._editors.clear();
if (!this._currentTarget) return;
// 获取可编辑属性
const properties = this.getEditableProperties(this._currentTarget);
for (const prop of properties) {
const editor = this.createEditor(prop);
if (editor) {
this._editors.set(prop.key, editor);
this.appendChild(editor);
}
}
}
private getEditableProperties(target: any): PropertyInfo[] {
const metadata = getPropertyMetadata(target);
return metadata.properties.filter(p => !p.options.hidden);
}
private createEditor(prop: PropertyInfo): PropertyEditor | null {
const value = this._currentTarget[prop.key];
switch (prop.options.type || typeof value) {
case "string":
return new StringEditor(prop, value, v => this.updateProperty(prop.key, v));
case "number":
return new NumberEditor(prop, value, v => this.updateProperty(prop.key, v));
case "boolean":
return new BooleanEditor(prop, value, v => this.updateProperty(prop.key, v));
case "color":
return new ColorEditor(prop, value, v => this.updateProperty(prop.key, v));
case "xyz":
return new XYZEditor(prop, value, v => this.updateProperty(prop.key, v));
default:
return null;
}
}
private updateProperty(key: string, value: any): void {
this._currentTarget[key] = value;
}
}
4.5.2 属性编辑器
// chili-ui/src/property/numberEditor.ts
export class NumberEditor extends PropertyEditor {
private _input: HTMLInputElement;
constructor(
prop: PropertyInfo,
value: number,
onChange: (value: number) => void
) {
super(prop);
this._input = document.createElement("input");
this._input.type = "number";
this._input.value = value.toString();
this._input.step = prop.options.step?.toString() || "0.1";
if (prop.options.min !== undefined) {
this._input.min = prop.options.min.toString();
}
if (prop.options.max !== undefined) {
this._input.max = prop.options.max.toString();
}
this._input.onchange = () => {
const newValue = parseFloat(this._input.value);
if (!isNaN(newValue)) {
onChange(newValue);
}
};
this.valueContainer.appendChild(this._input);
}
}
// chili-ui/src/property/colorEditor.ts
export class ColorEditor extends PropertyEditor {
private _input: HTMLInputElement;
private _preview: HTMLElement;
constructor(
prop: PropertyInfo,
value: number,
onChange: (value: number) => void
) {
super(prop);
this._preview = document.createElement("div");
this._preview.className = styles.colorPreview;
this._preview.style.backgroundColor = `#${value.toString(16).padStart(6, "0")}`;
this._input = document.createElement("input");
this._input.type = "color";
this._input.value = `#${value.toString(16).padStart(6, "0")}`;
this._input.className = styles.colorInput;
this._input.onchange = () => {
const hex = this._input.value.substring(1);
const newValue = parseInt(hex, 16);
this._preview.style.backgroundColor = this._input.value;
onChange(newValue);
};
this._preview.onclick = () => this._input.click();
this.valueContainer.appendChild(this._preview);
this.valueContainer.appendChild(this._input);
}
}
4.6 视口系统
4.6.1 ViewportContainer组件
// chili-ui/src/viewport/viewportContainer.ts
export class ViewportContainer extends HTMLElement {
private _views: Map<string, ThreeView> = new Map();
private _activeView: ThreeView | undefined;
constructor() {
super();
this.className = styles.viewportContainer;
}
createView(document: IDocument): ThreeView {
const view = new ThreeView(document);
const id = crypto.randomUUID();
this._views.set(id, view);
const wrapper = document.createElement("div");
wrapper.className = styles.viewWrapper;
wrapper.appendChild(view.domElement);
this.appendChild(wrapper);
this.setActiveView(view);
return view;
}
setActiveView(view: ThreeView): void {
if (this._activeView) {
this._activeView.domElement.parentElement?.classList.remove(styles.active);
}
this._activeView = view;
view.domElement.parentElement?.classList.add(styles.active);
}
removeView(id: string): void {
const view = this._views.get(id);
if (view) {
view.dispose();
view.domElement.parentElement?.remove();
this._views.delete(id);
}
}
}
4.6.2 ThreeView类
// chili-three/src/threeView.ts
export class ThreeView implements IView {
private _renderer: THREE.WebGLRenderer;
private _camera: THREE.PerspectiveCamera;
private _scene: THREE.Scene;
private _cameraController: CameraController;
private _visualContext: ThreeVisualContext;
private _eventHandler: ThreeViewEventHandler;
readonly domElement: HTMLElement;
constructor(readonly document: IDocument) {
// 创建渲染器
this._renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
this._renderer.setPixelRatio(window.devicePixelRatio);
this._renderer.setClearColor(0xf0f0f0);
this.domElement = this._renderer.domElement;
this.domElement.className = styles.threeView;
// 创建场景
this._scene = new THREE.Scene();
// 创建相机
this._camera = new THREE.PerspectiveCamera(45, 1, 0.1, 10000);
this._camera.position.set(100, 100, 100);
this._camera.lookAt(0, 0, 0);
// 创建相机控制器
this._cameraController = new CameraController(
this._camera,
this.domElement
);
// 创建可视化上下文
this._visualContext = new ThreeVisualContext(this._scene, document);
// 创建事件处理器
this._eventHandler = new ThreeViewEventHandler(this);
// 添加网格
this.addGrid();
// 添加灯光
this.addLights();
// 开始渲染循环
this.animate();
// 监听窗口大小变化
window.addEventListener("resize", () => this.resize());
}
private addGrid(): void {
const grid = new THREE.GridHelper(1000, 100, 0xcccccc, 0xe0e0e0);
grid.rotateX(Math.PI / 2);
this._scene.add(grid);
}
private addLights(): void {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this._scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 100, 100);
this._scene.add(directionalLight);
}
private animate(): void {
requestAnimationFrame(() => this.animate());
this._cameraController.update();
this._renderer.render(this._scene, this._camera);
}
resize(): void {
const container = this.domElement.parentElement;
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight;
this._camera.aspect = width / height;
this._camera.updateProjectionMatrix();
this._renderer.setSize(width, height);
}
// 视图控制
fitAll(): void {
const box = this._visualContext.getBoundingBox();
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const distance = maxDim / (2 * Math.tan(this._camera.fov * Math.PI / 360));
this._camera.position.copy(center).add(new THREE.Vector3(1, 1, 1).normalize().multiplyScalar(distance));
this._camera.lookAt(center);
this._cameraController.target.copy(center);
}
setStandardView(view: StandardView): void {
const directions = {
[StandardView.Front]: new THREE.Vector3(0, -1, 0),
[StandardView.Back]: new THREE.Vector3(0, 1, 0),
[StandardView.Left]: new THREE.Vector3(-1, 0, 0),
[StandardView.Right]: new THREE.Vector3(1, 0, 0),
[StandardView.Top]: new THREE.Vector3(0, 0, 1),
[StandardView.Bottom]: new THREE.Vector3(0, 0, -1),
[StandardView.Isometric]: new THREE.Vector3(1, 1, 1).normalize()
};
const dir = directions[view];
const distance = this._camera.position.distanceTo(this._cameraController.target);
this._camera.position.copy(this._cameraController.target).add(dir.multiplyScalar(distance));
this._camera.lookAt(this._cameraController.target);
}
dispose(): void {
this._cameraController.dispose();
this._eventHandler.dispose();
this._renderer.dispose();
}
}
4.6.3 相机控制器
// chili-three/src/cameraController.ts
export class CameraController {
private _camera: THREE.PerspectiveCamera;
private _domElement: HTMLElement;
readonly target: THREE.Vector3 = new THREE.Vector3();
private _rotating: boolean = false;
private _panning: boolean = false;
private _lastMousePosition: { x: number; y: number } = { x: 0, y: 0 };
// 旋转参数
private _theta: number = 0;
private _phi: number = Math.PI / 4;
private _radius: number = 100;
// 限制
minPhi: number = 0.01;
maxPhi: number = Math.PI - 0.01;
minRadius: number = 1;
maxRadius: number = 10000;
constructor(camera: THREE.PerspectiveCamera, domElement: HTMLElement) {
this._camera = camera;
this._domElement = domElement;
this.bindEvents();
this.updateFromCamera();
}
private bindEvents(): void {
this._domElement.addEventListener("mousedown", this.onMouseDown);
this._domElement.addEventListener("mousemove", this.onMouseMove);
this._domElement.addEventListener("mouseup", this.onMouseUp);
this._domElement.addEventListener("wheel", this.onWheel);
this._domElement.addEventListener("contextmenu", e => e.preventDefault());
}
private onMouseDown = (e: MouseEvent): void => {
if (e.button === 1) { // 中键
if (e.shiftKey) {
this._panning = true;
} else {
this._rotating = true;
}
this._lastMousePosition = { x: e.clientX, y: e.clientY };
}
};
private onMouseMove = (e: MouseEvent): void => {
const deltaX = e.clientX - this._lastMousePosition.x;
const deltaY = e.clientY - this._lastMousePosition.y;
if (this._rotating) {
this._theta -= deltaX * 0.01;
this._phi = Math.max(this.minPhi, Math.min(this.maxPhi, this._phi - deltaY * 0.01));
}
if (this._panning) {
const panSpeed = this._radius * 0.001;
const right = new THREE.Vector3();
const up = new THREE.Vector3();
right.setFromMatrixColumn(this._camera.matrix, 0);
up.setFromMatrixColumn(this._camera.matrix, 1);
this.target.add(right.multiplyScalar(-deltaX * panSpeed));
this.target.add(up.multiplyScalar(deltaY * panSpeed));
}
this._lastMousePosition = { x: e.clientX, y: e.clientY };
};
private onMouseUp = (): void => {
this._rotating = false;
this._panning = false;
};
private onWheel = (e: WheelEvent): void => {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
this._radius = Math.max(this.minRadius, Math.min(this.maxRadius, this._radius * zoomFactor));
};
update(): void {
// 计算相机位置
const x = this._radius * Math.sin(this._phi) * Math.cos(this._theta);
const y = this._radius * Math.sin(this._phi) * Math.sin(this._theta);
const z = this._radius * Math.cos(this._phi);
this._camera.position.set(
this.target.x + x,
this.target.y + y,
this.target.z + z
);
this._camera.lookAt(this.target);
}
private updateFromCamera(): void {
const offset = this._camera.position.clone().sub(this.target);
this._radius = offset.length();
this._theta = Math.atan2(offset.y, offset.x);
this._phi = Math.acos(offset.z / this._radius);
}
dispose(): void {
this._domElement.removeEventListener("mousedown", this.onMouseDown);
this._domElement.removeEventListener("mousemove", this.onMouseMove);
this._domElement.removeEventListener("mouseup", this.onMouseUp);
this._domElement.removeEventListener("wheel", this.onWheel);
}
}
4.7 选择系统
4.7.1 Selection类
// chili/src/selection.ts
export class Selection extends Observable implements ISelection {
private _selectedNodes: Set<INode> = new Set();
private _selectedShapes: Map<INode, IShape[]> = new Map();
constructor(readonly document: IDocument) {
super();
}
get selectedNodes(): INode[] {
return Array.from(this._selectedNodes);
}
select(nodes: INode[], toggle: boolean = false): void {
if (!toggle) {
this.clear();
}
for (const node of nodes) {
if (toggle && this._selectedNodes.has(node)) {
this._selectedNodes.delete(node);
} else {
this._selectedNodes.add(node);
}
}
this.notify("selectionChanged", this.selectedNodes);
}
selectShape(node: INode, shapes: IShape[], toggle: boolean = false): void {
if (!toggle) {
this._selectedShapes.clear();
}
const existing = this._selectedShapes.get(node) || [];
if (toggle) {
// 切换选择状态
for (const shape of shapes) {
const index = existing.findIndex(s => s.equals(shape));
if (index >= 0) {
existing.splice(index, 1);
} else {
existing.push(shape);
}
}
} else {
existing.push(...shapes);
}
this._selectedShapes.set(node, existing);
this.notify("shapeSelectionChanged");
}
clear(): void {
this._selectedNodes.clear();
this._selectedShapes.clear();
this.notify("selectionChanged", []);
}
async pickPoint(options: PickOptions): Promise<Result<XYZ>> {
return new Promise((resolve) => {
const handler = new PointPickHandler(this.document, options, resolve);
this.document.visual.setEventHandler(handler);
});
}
async pickShape(options: PickShapeOptions): Promise<Result<PickedShape>> {
return new Promise((resolve) => {
const handler = new ShapePickHandler(this.document, options, resolve);
this.document.visual.setEventHandler(handler);
});
}
}
4.7.2 拾取处理器
// chili/src/snap/pointPickHandler.ts
export class PointPickHandler implements IEventHandler {
private _snappers: ISnapper[];
private _currentPoint: XYZ | undefined;
constructor(
private document: IDocument,
private options: PickOptions,
private resolve: (result: Result<XYZ>) => void
) {
this._snappers = [
new EndpointSnapper(),
new MidpointSnapper(),
new CenterSnapper(),
new IntersectionSnapper(),
new GridSnapper()
];
}
onMouseMove(e: MouseEvent, view: IView): void {
const ray = view.screenToRay(e.clientX, e.clientY);
// 尝试各种捕捉
for (const snapper of this._snappers) {
const result = snapper.snap(ray, this.document);
if (result) {
this._currentPoint = result.point;
this.showSnapIndicator(result);
return;
}
}
// 默认捕捉到工作平面
const planePoint = this.snapToWorkplane(ray);
this._currentPoint = planePoint;
}
onClick(e: MouseEvent, view: IView): void {
if (this._currentPoint) {
this.resolve(Result.ok(this._currentPoint));
this.cleanup();
}
}
onKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape") {
this.resolve(Result.error("Cancelled"));
this.cleanup();
}
}
private cleanup(): void {
this.document.visual.setEventHandler(undefined);
}
}
4.8 状态栏
// chili-ui/src/statusbar/statusBar.ts
export class StatusBar extends HTMLElement {
private _message: HTMLElement;
private _coordinates: HTMLElement;
private _snapStatus: HTMLElement;
constructor() {
super();
this.className = styles.statusBar;
// 消息区
this._message = document.createElement("div");
this._message.className = styles.message;
this.appendChild(this._message);
// 坐标显示
this._coordinates = document.createElement("div");
this._coordinates.className = styles.coordinates;
this.appendChild(this._coordinates);
// 捕捉状态
this._snapStatus = document.createElement("div");
this._snapStatus.className = styles.snapStatus;
this.appendChild(this._snapStatus);
}
setMessage(message: string): void {
this._message.textContent = message;
}
setCoordinates(x: number, y: number, z: number): void {
this._coordinates.textContent = `X: ${x.toFixed(2)} Y: ${y.toFixed(2)} Z: ${z.toFixed(2)}`;
}
setSnapStatus(snaps: SnapType[]): void {
this._snapStatus.innerHTML = snaps
.map(s => `<span class="${styles.snapIcon} ${this.getSnapIcon(s)}"></span>`)
.join("");
}
private getSnapIcon(snap: SnapType): string {
const icons: Record<SnapType, string> = {
[SnapType.Endpoint]: "icon-snap-endpoint",
[SnapType.Midpoint]: "icon-snap-midpoint",
[SnapType.Center]: "icon-snap-center",
[SnapType.Intersection]: "icon-snap-intersection",
[SnapType.Perpendicular]: "icon-snap-perpendicular",
[SnapType.Grid]: "icon-snap-grid"
};
return icons[snap] || "";
}
}
4.9 对话框系统
// chili-ui/src/dialog.ts
export class Dialog extends HTMLElement {
private _overlay: HTMLElement;
private _container: HTMLElement;
private _title: HTMLElement;
private _content: HTMLElement;
private _footer: HTMLElement;
private _resolvePromise?: (value: any) => void;
constructor(options: DialogOptions) {
super();
this.className = styles.dialog;
// 创建遮罩
this._overlay = document.createElement("div");
this._overlay.className = styles.overlay;
this.appendChild(this._overlay);
// 创建容器
this._container = document.createElement("div");
this._container.className = styles.container;
// 标题
this._title = document.createElement("div");
this._title.className = styles.title;
this._title.textContent = options.title;
this._container.appendChild(this._title);
// 内容
this._content = document.createElement("div");
this._content.className = styles.content;
this._container.appendChild(this._content);
// 底部按钮
this._footer = document.createElement("div");
this._footer.className = styles.footer;
this._container.appendChild(this._footer);
this.appendChild(this._container);
}
setContent(content: HTMLElement | string): void {
if (typeof content === "string") {
this._content.innerHTML = content;
} else {
this._content.innerHTML = "";
this._content.appendChild(content);
}
}
addButton(label: string, onClick: () => void, primary: boolean = false): void {
const button = document.createElement("button");
button.className = primary ? styles.primaryButton : styles.button;
button.textContent = t(label);
button.onclick = onClick;
this._footer.appendChild(button);
}
show(): Promise<any> {
document.body.appendChild(this);
return new Promise(resolve => {
this._resolvePromise = resolve;
});
}
close(result?: any): void {
this.remove();
this._resolvePromise?.(result);
}
static confirm(message: string, title: string = "dialog.confirm"): Promise<boolean> {
const dialog = new Dialog({ title: t(title) });
dialog.setContent(message);
dialog.addButton("button.cancel", () => dialog.close(false));
dialog.addButton("button.ok", () => dialog.close(true), true);
return dialog.show();
}
static alert(message: string, title: string = "dialog.alert"): Promise<void> {
const dialog = new Dialog({ title: t(title) });
dialog.setContent(message);
dialog.addButton("button.ok", () => dialog.close(), true);
return dialog.show();
}
}
customElements.define("chili-dialog", Dialog);
4.10 本章小结
本章详细介绍了Chili3D的用户界面与交互系统,包括:
- UI架构概述:组件化设计、CSS Modules样式系统、Web Components技术
- 主窗口结构:MainWindow组件和整体布局
- 功能区系统:Ribbon、RibbonTab、RibbonGroup的设计和配置
- 项目树:TreeView组件和节点管理
- 属性面板:PropertyPanel和各种属性编辑器
- 视口系统:ThreeView类和相机控制器
- 选择系统:Selection类和拾取处理器
- 状态栏:StatusBar组件
- 对话框系统:Dialog组件和常用对话框
理解这些UI组件的设计和实现,对于进行Chili3D的界面定制和扩展开发非常重要。在下一章中,我们将开始进入二次开发的实践阶段。
下一章预告:第五章将介绍Chili3D二次开发的入门知识,包括开发环境配置、自定义命令开发、扩展点和API使用等内容。