diff --git a/packages/chili-core/src/base/collection.ts b/packages/chili-core/src/base/collection.ts index de0f473f..c766227a 100644 --- a/packages/chili-core/src/base/collection.ts +++ b/packages/chili-core/src/base/collection.ts @@ -135,3 +135,38 @@ export class ObservableCollection implements ICollectionChanged, IDisposable this.#items.length = 0; } } + +export enum SelectMode { + check, + radio, + combo, +} + +export class SelectableItems { + readonly items: ReadonlyArray; + selectedItems: Set; + + get selectedIndexes(): number[] { + let indexes: number[] = []; + this.selectedItems.forEach((x) => { + let index = this.items.indexOf(x); + if (index > -1) { + indexes.push(index); + } + }); + return indexes; + } + + firstSelectedItem() { + return this.selectedItems.values().next().value; + } + + constructor( + items: T[], + readonly mode: SelectMode = SelectMode.radio, + selectedItems?: T[], + ) { + this.items = items; + this.selectedItems = new Set(selectedItems ?? []); + } +} diff --git a/packages/chili-core/src/base/pubsub.ts b/packages/chili-core/src/base/pubsub.ts index 9f69c3ae..44a6e3b7 100644 --- a/packages/chili-core/src/base/pubsub.ts +++ b/packages/chili-core/src/base/pubsub.ts @@ -9,6 +9,7 @@ import { AsyncController } from "./asyncController"; import { IDisposable } from "./disposable"; import { NodeRecord } from "./history"; import { MessageType } from "./messageType"; +import { IPropertyChanged } from "./observer"; import { Result } from "./result"; export interface PubSubEventMap { @@ -33,6 +34,7 @@ export interface PubSubEventMap { closeCommandContext: () => void; showHome: () => void; showToast: (message: I18nKeys, ...args: any[]) => void; + showDialog: (title: I18nKeys, context: IPropertyChanged, callback: () => void) => void; } export class PubSub implements IDisposable { diff --git a/packages/chili-core/src/command/commandKeys.ts b/packages/chili-core/src/command/commandKeys.ts index 961b2d06..370af7b7 100644 --- a/packages/chili-core/src/command/commandKeys.ts +++ b/packages/chili-core/src/command/commandKeys.ts @@ -29,4 +29,6 @@ export type CommandKeys = | "modify.move" | "modify.rotate" | "modify.mirror" - | "modify.delete"; + | "modify.delete" + | "workingPlane.alignToPlane" + | "workingPlane.set"; diff --git a/packages/chili-core/src/i18n/en.ts b/packages/chili-core/src/i18n/en.ts index 017a0f9c..9ead05c3 100644 --- a/packages/chili-core/src/i18n/en.ts +++ b/packages/chili-core/src/i18n/en.ts @@ -26,6 +26,7 @@ export default { "ribbon.group.modify": "Modify", "ribbon.group.converter": "Converter", "ribbon.group.boolean": "Boolean", + "ribbon.group.workingPlane": "Working Plane", "ribbon.group.importExport": "Import/Export", "items.header": "Items", "items.tool.newFolder": "New Folder", @@ -131,5 +132,7 @@ export default { "operate.pickNextPoint": "pick next point, ESC key to cancel", "operate.pickCircleCenter": "pick center, ESC key to cancel", "operate.pickRadius": "input radius, ESC key to cancel", + "workingPlane.alignToPlane": "Align to plane", + "workingPlane.set": "Set workplane", }, } satisfies Locale; diff --git a/packages/chili-core/src/i18n/local.ts b/packages/chili-core/src/i18n/local.ts index 10d33b3c..1f9652a7 100644 --- a/packages/chili-core/src/i18n/local.ts +++ b/packages/chili-core/src/i18n/local.ts @@ -41,6 +41,7 @@ export type I18nKeys = | "ribbon.group.converter" | "ribbon.group.selection" | "ribbon.group.boolean" + | "ribbon.group.workingPlane" | "ribbon.group.importExport" | "items.header" | "items.tool.newFolder" @@ -132,4 +133,6 @@ export type I18nKeys = | "error.input.unsupportedInputs" | "error.input.invalidNumber" | "error.input.threeNumberCanBeInput" - | "error.input.cannotInputANumber"; + | "error.input.cannotInputANumber" + | "workingPlane.alignToPlane" + | "workingPlane.set"; diff --git a/packages/chili-core/src/i18n/zh-cn.ts b/packages/chili-core/src/i18n/zh-cn.ts index f40d2391..d70a7ee7 100644 --- a/packages/chili-core/src/i18n/zh-cn.ts +++ b/packages/chili-core/src/i18n/zh-cn.ts @@ -26,6 +26,7 @@ export default { "ribbon.group.converter": "转换", "ribbon.group.selection": "选择", "ribbon.group.boolean": "布尔运算", + "ribbon.group.workingPlane": "工作平面", "ribbon.group.importExport": "导入/导出", "items.header": "项目", "items.tool.newFolder": "文件夹", @@ -130,5 +131,7 @@ export default { "operate.pickNextPoint": "请选择下一个点, 按 ESC 键取消", "operate.pickCircleCenter": "请选择圆心 按 ESC 键取消", "operate.pickRadius": "请选择半径, 按 ESC 键取消", + "workingPlane.alignToPlane": "对齐到平面", + "workingPlane.set": "设置工作平面", }, } satisfies Locale; diff --git a/packages/chili-ui/src/controls/controls.ts b/packages/chili-ui/src/controls/controls.ts index 3e53357f..48b9a5df 100644 --- a/packages/chili-ui/src/controls/controls.ts +++ b/packages/chili-ui/src/controls/controls.ts @@ -40,6 +40,12 @@ export interface CheckboxProps extends Props { checked: boolean | Binding; } +export interface RadioProps extends Props { + type: "radio"; + value: string | Binding; + checked: boolean | Binding; +} + export interface ColorProps extends Props { type: "color"; value?: string | Binding; @@ -62,6 +68,7 @@ function createFunction(tag: K) { } } dom.append(...children); + return dom; }; } @@ -97,7 +104,7 @@ function setStyle(dom: HTMLElement | SVGElement, style: StyleProps) { export const div = createFunction("div"); export const span = createFunction("span"); export const button = createFunction("button"); -export const input = createFunction<"input", CheckboxProps | ColorProps>("input"); +export const input = createFunction<"input", CheckboxProps | ColorProps | RadioProps>("input"); export const textarea = createFunction("textarea"); export const select = createFunction<"select", SelectProps>("select"); export const option = createFunction<"option", OptionProps>("option"); diff --git a/packages/chili-ui/src/controls/index.ts b/packages/chili-ui/src/controls/index.ts index 9872a7cd..e0a8eb8f 100644 --- a/packages/chili-ui/src/controls/index.ts +++ b/packages/chili-ui/src/controls/index.ts @@ -3,4 +3,3 @@ export * from "./binding"; export * from "./controls"; export * from "./localize"; -export * from "./state"; diff --git a/packages/chili-ui/src/controls/itemsControl.module.css b/packages/chili-ui/src/controls/itemsControl.module.css new file mode 100644 index 00000000..a60ca67f --- /dev/null +++ b/packages/chili-ui/src/controls/itemsControl.module.css @@ -0,0 +1,23 @@ +.radioGroup { + padding: 8px; + + & span { + margin-left: 4px; + } + + & ul { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0; + } + + & li { + margin: 4px; + list-style-type: none; + } + + & input { + margin-right: 4px; + } +} diff --git a/packages/chili-ui/src/controls/itemsControl.ts b/packages/chili-ui/src/controls/itemsControl.ts new file mode 100644 index 00000000..b5d2709a --- /dev/null +++ b/packages/chili-ui/src/controls/itemsControl.ts @@ -0,0 +1,51 @@ +// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. + +import { SelectableItems } from "chili-core"; +import { div, h2, input, li, span, ul } from "./controls"; +import style from "./itemsControl.module.css"; + +export class RadioGroup extends HTMLElement { + constructor( + readonly header: string, + readonly context: SelectableItems, + ) { + super(); + this.appendChild(this.render()); + } + + render() { + return div( + { className: style.radioGroup }, + span(this.header + ": "), + ul( + ...this.context.items.map((x) => { + return li( + input({ type: "radio", value: x, checked: this.context.selectedItems.has(x) }), + x, + ); + }), + ), + ); + } + + connectedCallback() { + this.addEventListener("click", this.#onClick); + } + + disconnectedCallback() { + this.removeEventListener("click", this.#onClick); + } + + #onClick = (e: MouseEvent) => { + const target = e.target as HTMLInputElement; + if (target?.type === "radio") { + this.querySelectorAll("input").forEach((x) => { + if (x !== target) x.checked = false; + }); + target.checked = true; + this.context.selectedItems = new Set([target.value]); + } + }; +} + +customElements.define("chili-radios", RadioGroup); diff --git a/packages/chili-ui/src/controls/state.ts b/packages/chili-ui/src/controls/state.ts deleted file mode 100644 index 5a207f93..00000000 --- a/packages/chili-ui/src/controls/state.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { IPropertyChanged, PropertyChangedHandler } from "chili-core"; -import { Binding } from "./binding"; - -export class State implements IPropertyChanged { - #handlers: PropertyChangedHandler[] = []; - - onPropertyChanged(handler: PropertyChangedHandler): void { - this.#handlers.push(handler); - } - - removePropertyChanged(handler: PropertyChangedHandler): void { - const index = this.#handlers.indexOf(handler); - if (index !== -1) { - this.#handlers.splice(index, 1); - } - } - - #value: T; - get value(): T { - return this.#value; - } - set value(newValue: T) { - if (newValue === this.#value) return; - const oldValue = this.#value; - this.#value = newValue; - this.#handlers.forEach((handler) => handler("value", this, oldValue)); - } - - constructor(initialValue: T) { - this.#value = initialValue; - } -} - -export function useState(initialValue: T): [Binding, "value">, (newValue: T) => void] { - const state = new State(initialValue); - const setState = (newValue: T) => (state.value = newValue); - return [new Binding(state, "value"), setState]; -} diff --git a/packages/chili-ui/src/dialog.module.css b/packages/chili-ui/src/dialog.module.css index 7bb82cbb..4dd60c02 100644 --- a/packages/chili-ui/src/dialog.module.css +++ b/packages/chili-ui/src/dialog.module.css @@ -1,4 +1,15 @@ -.dialog { +dialog { + border: none; + box-shadow: 0px 1px 2px #999; + border-radius: 8px; + padding: 0px; +} + +dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +.root { display: flex; flex-direction: column; width: 240px; @@ -9,6 +20,7 @@ .title { padding: 16px; font-size: medium; + margin: 0px auto; } .content { @@ -17,10 +29,13 @@ } .buttons { - margin-top: 16px; - padding: 16px; -} + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 8px 4px; -.button { - width: 64px; + & button { + width: 64px; + margin: 4px; + } } diff --git a/packages/chili-ui/src/dialog.ts b/packages/chili-ui/src/dialog.ts index 8a939c7e..87da71ba 100644 --- a/packages/chili-ui/src/dialog.ts +++ b/packages/chili-ui/src/dialog.ts @@ -1,30 +1,44 @@ // Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. +import { I18n, I18nKeys, IPropertyChanged, Property, SelectableItems } from "chili-core"; import style from "./dialog.module.css"; +import { button, div } from "./controls"; +import { RadioGroup } from "./controls/itemsControl"; export class Dialog { private constructor() {} - static show(msg: string, title?: string) { + static show(title: I18nKeys, context: IPropertyChanged, callback: () => void) { + let properties = Property.getProperties(context); let dialog = document.createElement("dialog"); - dialog.style.padding = "0"; - dialog.innerHTML = ` -
-
${title ?? "chili3d"}
-
${msg}
-
- -
-
- `; + dialog.appendChild( + div( + { className: style.root }, + div({ className: style.title }, I18n.translate(title) ?? "chili3d"), + ...properties.map((x) => { + let value = (context as any)[x.name]; + if (value instanceof SelectableItems) { + return new RadioGroup(I18n.translate(x.display), value); + } + return ""; + }), + div( + { className: style.buttons }, + button({ + textContent: I18n.translate("common.confirm"), + onclick: () => { + dialog.close(); + callback(); + }, + }), + button({ + textContent: I18n.translate("common.cancel"), + onclick: () => dialog.close(), + }), + ), + ), + ); document.body.appendChild(dialog); - let button = dialog.querySelector(`.${style.button}`)!; - let handler = () => { - dialog.close(); - document.body.removeChild(dialog); - button.removeEventListener("click", handler); - }; - button.addEventListener("click", handler); dialog.showModal(); } } diff --git a/packages/chili-ui/src/mainWindow.ts b/packages/chili-ui/src/mainWindow.ts index b4666a58..6a543cba 100644 --- a/packages/chili-ui/src/mainWindow.ts +++ b/packages/chili-ui/src/mainWindow.ts @@ -12,6 +12,7 @@ import { import { Editor } from "./editor"; import { Home } from "./home"; import { Toast } from "./toast"; +import { Dialog } from "./dialog"; document.oncontextmenu = (e) => e.preventDefault(); @@ -58,6 +59,7 @@ export class MainWindow { this.#vm.onPropertyChanged(this.onPropertyChanged); this.setHomeDisplay(); PubSub.default.sub("showToast", this.#toast.show); + PubSub.default.sub("showDialog", Dialog.show); } private onDocumentClick = (document: RecentDocumentDTO) => { diff --git a/packages/chili-ui/src/profile/ribbon.ts b/packages/chili-ui/src/profile/ribbon.ts index a0ae225b..0b0197d3 100644 --- a/packages/chili-ui/src/profile/ribbon.ts +++ b/packages/chili-ui/src/profile/ribbon.ts @@ -20,6 +20,10 @@ export const DefaultRibbon: RibbonData = [ groupName: "ribbon.group.boolean", items: ["boolean.common", "boolean.cut", "boolean.fuse"], }, + { + groupName: "ribbon.group.workingPlane", + items: ["workingPlane.set", "workingPlane.alignToPlane"], + }, { groupName: "ribbon.group.importExport", items: ["file.import", "file.export.iges", "file.export.stp"], diff --git a/packages/chili/src/commands/index.ts b/packages/chili/src/commands/index.ts index bd99eab6..2ea5513b 100644 --- a/packages/chili/src/commands/index.ts +++ b/packages/chili/src/commands/index.ts @@ -5,7 +5,8 @@ export * from "./boolean"; export * from "./create"; export * from "./delete"; export * from "./folder"; +export * from "./importExport"; export * from "./modify"; export * from "./redo"; -export * from "./importExport"; export * from "./undo"; +export * from "./workingPlane"; diff --git a/packages/chili/src/commands/workingPlane.ts b/packages/chili/src/commands/workingPlane.ts new file mode 100644 index 00000000..8a737b1c --- /dev/null +++ b/packages/chili/src/commands/workingPlane.ts @@ -0,0 +1,64 @@ +// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license. + +import { + AsyncController, + IApplication, + ICommand, + IFace, + Observable, + Plane, + Property, + PubSub, + SelectMode, + SelectableItems, + ShapeType, + XYZ, + command, +} from "chili-core"; +import { SelectShapeStep } from "../step"; + +export class WorkingPlaneViewModel extends Observable { + @Property.define("workingPlane.set") + planes: SelectableItems = new SelectableItems(["XOY", "YOZ", "ZOX"], SelectMode.radio, ["XOY"]); +} + +@command({ + name: "workingPlane.set", + display: "workingPlane.set", + icon: "", +}) +export class SetWorkplane implements ICommand { + async execute(application: IApplication): Promise { + let view = application.activeDocument?.visual.viewer.activeView; + if (!view) return; + let vm = new WorkingPlaneViewModel(); + PubSub.default.pub("showDialog", "workingPlane.set", vm, () => { + let planes = [Plane.XY, Plane.YZ, Plane.ZX]; + view!.workplane = planes[vm.planes.selectedIndexes[0]]; + }); + } +} + +@command({ + name: "workingPlane.alignToPlane", + display: "workingPlane.alignToPlane", + icon: "", +}) +export class AlignToPlane implements ICommand { + async execute(application: IApplication): Promise { + let view = application.activeDocument?.visual.viewer.activeView; + if (!view) return; + let controller = new AsyncController(); + let data = await new SelectShapeStep(ShapeType.Face, "prompt.select.faces", false).execute( + application.activeDocument!, + controller, + ); + if (!data || data.shapes.length === 0) return; + let [point, normal] = (data.shapes[0].shape as IFace).normal(0, 0); + let xvec = XYZ.unitX; + if (!normal.isParallelTo(XYZ.unitZ)) { + xvec = XYZ.unitZ.cross(normal).normalize()!; + } + view.workplane = new Plane(point, normal, xvec); + } +}