znlgis 博客

GIS开发与技术分享

第四章:用户界面与交互系统

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的用户界面与交互系统,包括:

  1. UI架构概述:组件化设计、CSS Modules样式系统、Web Components技术
  2. 主窗口结构:MainWindow组件和整体布局
  3. 功能区系统:Ribbon、RibbonTab、RibbonGroup的设计和配置
  4. 项目树:TreeView组件和节点管理
  5. 属性面板:PropertyPanel和各种属性编辑器
  6. 视口系统:ThreeView类和相机控制器
  7. 选择系统:Selection类和拾取处理器
  8. 状态栏:StatusBar组件
  9. 对话框系统:Dialog组件和常用对话框

理解这些UI组件的设计和实现,对于进行Chili3D的界面定制和扩展开发非常重要。在下一章中,我们将开始进入二次开发的实践阶段。


下一章预告:第五章将介绍Chili3D二次开发的入门知识,包括开发环境配置、自定义命令开发、扩展点和API使用等内容。