From 158d770c549de59449498bf51f5db2cb523ec3c6 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Tue, 30 Jun 2026 22:38:18 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20cyclegui-app-devel?= =?UTF-8?q?opment=20=E9=A1=B9=E7=9B=AE=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 CycleGUI 开发技能(含 Duplicated id 防碰撞规范)纳入 .cursor/skills,并调整 gitignore 仅放行 skills 目录可提交。 Co-authored-by: Cursor --- .../skills/cyclegui-app-development/SKILL.md | 99 +++ .../agents/openai.yaml | 7 + .../cyclegui-app-development/examples.md | 8 + .../cyclegui-app-development/reference.md | 18 + .../references/api-signatures.md | 643 ++++++++++++++++++ .../references/examples.md | 555 +++++++++++++++ .../references/panels-controls-menus.md | 310 +++++++++ .../references/project-setup-and-packaging.md | 151 ++++ .../references/terminals-and-web.md | 86 +++ .../references/workspace-interaction.md | 140 ++++ .../references/workspace-scene-and-painter.md | 129 ++++ .gitignore | 4 +- 12 files changed, 2149 insertions(+), 1 deletion(-) create mode 100644 .cursor/skills/cyclegui-app-development/SKILL.md create mode 100644 .cursor/skills/cyclegui-app-development/agents/openai.yaml create mode 100644 .cursor/skills/cyclegui-app-development/examples.md create mode 100644 .cursor/skills/cyclegui-app-development/reference.md create mode 100644 .cursor/skills/cyclegui-app-development/references/api-signatures.md create mode 100644 .cursor/skills/cyclegui-app-development/references/examples.md create mode 100644 .cursor/skills/cyclegui-app-development/references/panels-controls-menus.md create mode 100644 .cursor/skills/cyclegui-app-development/references/project-setup-and-packaging.md create mode 100644 .cursor/skills/cyclegui-app-development/references/terminals-and-web.md create mode 100644 .cursor/skills/cyclegui-app-development/references/workspace-interaction.md create mode 100644 .cursor/skills/cyclegui-app-development/references/workspace-scene-and-painter.md diff --git a/.cursor/skills/cyclegui-app-development/SKILL.md b/.cursor/skills/cyclegui-app-development/SKILL.md new file mode 100644 index 0000000..e576ec3 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/SKILL.md @@ -0,0 +1,99 @@ +--- +name: cyclegui-app-development +description: 构建或修改基于 CycleGUI 框架的 .NET 桌面/Web 应用。CycleGUI 提供 ImGui 式立即模式 UI 面板和 3D Workspace 渲染能力。用于用户提到 CycleGUI、RefCycleGUI.dll、Costura.Fody、SimpleLite、PanelBuilder、GUI.PromptPanel、GUI.DeclarePanel、Workspace、Painter、LocalTerminal、LocalTerminal.Start、MinimizeToTray、HideConsoleOnStart、libVRender、WebTerminal、TCPTerminal、3D 场景、立即模式 UI、LoadModel、PutPointCloud,或需要新建/修改 CycleGUI 应用、示例、面板、工具栏、HoverMenu、菜单栏、视口、对象选择、坐标拾取、调试绘制时。 +--- + +# CycleGUI 应用开发 + +本 skill 是 CycleGUI 应用开发的启动卡片。普通任务应能在没有 CycleGUI 源码的情况下完成:先用本文搭出正确结构;需要完整签名、长示例或专题规则时,再按主题加载 reference。只有维护 CycleGUI 框架本体、修复底层 bug、或本地仓库已存在且需要核对变更时,才读取源码。 + +## 按需读取 + +- 项目引用、宿主/插件边界、启动流程:`references/project-setup-and-packaging.md` +- 面板、工具栏、HoverMenu、控件、菜单栏:`references/panels-controls-menus.md` +- Workspace 模型、点云、相机、视口、Painter:`references/workspace-scene-and-painter.md` +- Workspace 对象选择、坐标拾取、async 操作陷阱:`references/workspace-interaction.md` +- WebTerminal、LocalTerminal、LeastServer:`references/terminals-and-web.md` +- 完整 API 签名:`references/api-signatures.md` +- 完整示例:`references/examples.md` + +## 引用策略 + +先判断项目类型: + +1. **同解决方案示例**:例如 `LearnCycleGUI`,可以 `ProjectReference` 到 `CycleGUI\CycleGUI.csproj`。 +2. **宿主/调度程序**:例如 `SimpleLite`,引用真实 `CycleGUI.dll`,并用 `Costura.Fody` 把托管程序集包进宿主 Assembly。 +3. **插件或二次开发项目**:默认引用 `RefCycleGUI.dll`,不要引用真实 `CycleGUI.dll`。真实 CycleGUI 实现由宿主提供,用于保护 CycleGUI 代码资产并隔离二开项目。 + +最容易写错的是插件/二开项目: + +```xml + + $(CGUILibDir)RefCycleGUI.dll + false + +``` + +不要指导插件二开项目显式部署或打包真实 `CycleGUI.dll`。 + +## 最小骨架 + +常用 using: + +```csharp +using CycleGUI; +using CycleGUI.API; +using CycleGUI.Terminals; +using System.Drawing; +using System.Numerics; +``` + +启动本地窗口: + +```csharp +Terminal.RegisterRemotePanel(CreateMainPanel); +LocalTerminal.SetTitle("MyApp"); +LocalTerminal.Start(); +GUI.PromptPanel(CreateMainPanel(GUI.defaultTerminal)); +``` + +最小面板: + +```csharp +PanelBuilder.CycleGUIHandler CreateMainPanel(Terminal terminal) +{ + return pb => + { + pb.Panel.ShowTitle("Main").InitSize(400, 300); + pb.Label("Ready"); + if (pb.Button("Run")) { } + if (pb.Closing()) pb.Panel.Exit(); + }; +} +``` + +需要保留 panel 引用、控制 dock/toolbar/单实例时,用 `GUI.DeclarePanel().Define(...)`;细节见 `references/panels-controls-menus.md`。 + +## 常见任务路由 + +- 新建普通桌面/Web 应用:用本文骨架;需要完整 csproj 和 WebTerminal 细节时读 `references/project-setup-and-packaging.md`、`references/terminals-and-web.md`。 +- 做插件二开:先确认引用 `RefCycleGUI.dll`;不要把真实 `CycleGUI.dll` 带进插件。 +- 做表单、表格、toolbar、HoverMenu、菜单栏:读 `references/panels-controls-menus.md`。 +- 加载模型、点云、相机、Painter 调试绘制:读 `references/workspace-scene-and-painter.md`。 +- 做对象选择、坐标拾取、拖拽/放置或 async 交互:读 `references/workspace-interaction.md`。 +- 不确定 API 签名:读 `references/api-signatures.md`,不要凭 ImGui 习惯猜 CycleGUI 方法。 + +## 必须记住 + +- 普通应用开发不要求用户或下游 agent 读取 CycleGUI 源码。 +- CycleGUI 公开颜色字段使用 `System.Drawing.Color`;不要把 ARGB `uint` 直接塞给颜色字段。 +- 所有 `namePattern` 是 glob,不是 regex:`"UISite-*"` 正确,`"UISite-.*"` 错。 +- `InitPosRelative` 的参数是 `left` / `top`;不要写不存在的 `offsetX` / `offsetY`。 +- CycleGUI 没有 `BeginChild` / `ChildWindow` / `BeginTabBar` / `BeginTabItem`;分区用 `Table(height:)`、`CollapsingHeader`,标签页用 `TabButtons`(支持 `forceSelect` 程序化切 Tab;`Table` 支持 `scrollToRow` 程序化滚动到行,变化检测在 API 内部)。 +- 不要依赖空壳控件:`Progress`、`BulletText`、`Indent` / `UnIndent`、`QuestionMark`、`ToolTip`。 +- 控件 id 由标签经 **ASCII** 哈希得到(中文、全角符号等非 ASCII 字符全部折叠成 `?`):同一面板内两个「ASCII 折叠后相同」的标签会抛 `Duplicated id`——最典型是**同字数的纯中文**标签(如 `筛选条件` 撞 `设为类型`),或仅中文字符不同、ASCII 部分相同的两个标签(如 `起点筛选(站点ID或名称)` 撞 `终点筛选(站点ID或名称)`)。修法:标签加 `###唯一ASCII后缀`(显示不变,`###` 会重置哈希),`Button` 用唯一 `distinct:`,或加 `1. `/`2. ` 等数字前缀;循环内渲染带状态控件须给每项拼唯一 id(如 `$"启用###en-{item.Id}"`)。详见 `references/panels-controls-menus.md`。 +- 立即模式:面板默认**不会**每帧重绘;只在 `pb.Panel.Repaint()` 被调用时重新执行 handler。静态面板(Properties / Actions 等)不要无条件 Repaint;仅实时数据区(Status、MiniPlot、后台线程推送等)才持续 Repaint。延迟回调或跨线程改数据后须显式 Repaint。详见 `references/panels-controls-menus.md`。 +- 画布/Workspace 里的业务对象需要点击或选择时,默认用原生 `SelectObject` + 可选 Workspace 对象,不要用 `GetPosition` 手写命中测试。 +- `SelectObject` 和 `GetPosition` 是互斥交互;进入拾取前结束选择 op,结束后重新启动选择 op。 +- 修改 CycleGUI C# API、维护框架本体、排查 libVRender/协议问题、或发现 reference 缺失时,才核对源码并同步更新 references。 + diff --git a/.cursor/skills/cyclegui-app-development/agents/openai.yaml b/.cursor/skills/cyclegui-app-development/agents/openai.yaml new file mode 100644 index 0000000..d6c4565 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "CycleGUI App Development" + short_description: "Build CycleGUI panels, HoverMenus, and 3D workspace apps" + default_prompt: "Use $cyclegui-app-development to build or modify a CycleGUI panel, HoverMenu, workspace, viewport, or demo app." + +policy: + allow_implicit_invocation: true diff --git a/.cursor/skills/cyclegui-app-development/examples.md b/.cursor/skills/cyclegui-app-development/examples.md new file mode 100644 index 0000000..14cefd3 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/examples.md @@ -0,0 +1,8 @@ +# CycleGUI Examples + +完整示例已整理到 `references/examples.md`。优先按任务主题读取: + +- 最小应用、多面板、模态、实时数据、表格:`references/examples.md` 示例 1-5。 +- 模型、点云、Painter、选择、坐标拾取:`references/examples.md` 示例 6-9、13。 +- 工具栏、Local + Web:`references/examples.md` 示例 11-12。 +- Medulla2 插件结构:`references/examples.md` 示例 10、14。 diff --git a/.cursor/skills/cyclegui-app-development/reference.md b/.cursor/skills/cyclegui-app-development/reference.md new file mode 100644 index 0000000..eaa406c --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/reference.md @@ -0,0 +1,18 @@ +# CycleGUI Reference Index + +按需读取主题文件,不要一次加载所有 reference。 + +## 主题文件 + +- `references/project-setup-and-packaging.md`:项目类型、`RefCycleGUI.dll`、`CycleGUI.dll`、Costura、启动流程。 +- `references/panels-controls-menus.md`:Panel 生命周期、定位、toolbar、HoverMenu、PanelBuilder、菜单栏。 +- `references/workspace-scene-and-painter.md`:模型、点云、线、相机、外观、Viewport、Painter。 +- `references/workspace-interaction.md`:对象选择、sticky pattern、坐标拾取、WorkspaceUIOperation async 包装。 +- `references/terminals-and-web.md`:LocalTerminal、WebTerminal、TCPTerminal、LeastServer。 +- `references/api-signatures.md`:完整 API 签名和维护者核对路径。 +- `references/examples.md`:完整示例代码。 + +## 选择规则 + +普通应用开发优先使用 `SKILL.md`。只有任务需要某个领域的完整签名、长示例或排障细节时,再读取对应主题文件。 + diff --git a/.cursor/skills/cyclegui-app-development/references/api-signatures.md b/.cursor/skills/cyclegui-app-development/references/api-signatures.md new file mode 100644 index 0000000..e24d27e --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/api-signatures.md @@ -0,0 +1,643 @@ +# CycleGUI API 参考 + +> **颜色字段统一为 `System.Drawing.Color`**(2026-05 改造):所有公开 API 的 color/colors/_shine/_border_color/team_color/shine_color 字段都是 `Color`,不再有 `uint` 入口。详见 `SKILL.md` `## 颜色契约` 章节。如果你拿到的是引擎 wire-format `uint`(bit0..7=R),用 `Extensions.FromRgba8(uint)` 升回 `Color`;System.Drawing ARGB 布局(`0xAARRGGBB`)则用 `Color.FromArgb(unchecked((int)argb))`。 + +## PanelBuilder 控件完整签名 + +维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\PanelBuilder.Controls.cs` + +### 布局 + +```csharp +void Label(string text) +void Separator() +void SeparatorText(string text) +void SameLine(int spacing = 0) +void CollapsingHeaderStart(string label) // 必须与 End 配对;不可嵌套 +void CollapsingHeaderEnd() +``` + +> CycleGUI **没有** `BeginChild` / `ChildWindow` / `BeginTabBar` / `BeginTabItem`。容器分区用 `Table(height:)` + `CollapsingHeader`;Tab 切换用 `TabButtons`(见下方"列表与选择")。 + +### 按钮与切换 + +```csharp +// 返回 true 表示被点击 +bool Button(string text, string shortcut = "", string hint = "", string distinct = "", bool disabled = false) + +// 弹出菜单按钮 +void PopMenuButton(string buttonTxt, MenuItem[] menu, Action clickBtn = null) + +// 按钮组,返回 true 表示有选择,selecting 为选中索引 +bool ButtonGroup(string prompt, string[] buttonText, out int selecting, bool sameLine = false) + +// 复选框,返回 true 表示值改变 +bool CheckBox(string desc, ref bool chk) + +// 开关,返回 true 表示值改变 +bool Toggle(string desc, ref bool on) + +// 单选按钮,返回 true 表示选择改变 +bool RadioButtons(string prompt, string[] items, ref int selected, bool sameLine = false) +``` + +### 输入 + +```csharp +// 文本输入。ret 为当前文本,doneInput 为 true 表示按下回车 +(string ret, bool doneInput) TextInput(string prompt, string defaultText = "", + string hintText = "", bool focusOnAppearing = false, + bool hidePrompt = false, bool alwaysReturnString = false) +``` + +### 数值控件 + +```csharp +bool DragFloat(string prompt, ref float valf, float step, + float min = float.MinValue, float max = float.MaxValue, bool disableCache = false) + +bool SliderInt(string prompt, ref int val, int min, int max, bool disableCache = false) + +bool SliderFloat(string prompt, ref float val, float min, float max, bool disableCache = false) + +bool DragVector2(string prompt, ref Vector2 vector, float step = 0.01f, + float min = float.MinValue, float max = float.MaxValue) + +bool DragMatrix(int m, int n, float[] vals) + +bool BezierEditor(string prompt, ref Vector4 controlPoints, ref float startY, ref float endY) +``` + +### 颜色 + +```csharp +bool ColorEdit(string label, ref Color color, bool alphaEnabled = true) +``` + +### 列表与选择 + +```csharp +// 返回选中索引,-1 无选择 +int ListBox(string prompt, string[] items, int height = 5, + bool persistentSelecting = false, int preselected = -1) + +bool DropdownBox(string prompt, string[] items, ref int selected) + +// 返回选中标签页索引 +int TabButtons(string prompt, string[] items, int forceSelect = -1) +``` + +`forceSelect`:希望激活的标签页索引(0-based);`-1` 不强制。API 内建变化检测:仅当该值相对上次变化时才下发 `ImGuiTabItemFlags_SetSelected`,调用方可每帧传入当前目标 Tab,无需自己做 one-shot 门控。用户手动切换 Tab 后,只要 `forceSelect` 不变就不会再被拉回。 + +### 表格 + +```csharp +unsafe void Table(string strId, string[] header, int rows, + Action content, int height = 0, + bool enableSearch = false, string title = "", + bool freezeFirstCol = false, int scrollToRow = -1, + Action? onHeaderSort = null) +``` + +`height`:可见数据行数;`<=0` 表示不限制高度、显示全部行。`scrollToRow`:希望滚动到可见的数据行索引(0-based);`-1` 不滚动。API 内建变化检测:仅当该值相对上次变化时才下发 `ImGui::SetScrollHereY`,调用方可每帧传入当前选中行,用户之后可自由滚动列表。传入 `-1` 会复位持久态,便于「取消选中后再选同一行」能重新触发滚动。 + +Row 对象可用方法:`Label`, `ButtonGroup`, `Checkbox`, `SetColor`, `Image` + +### 图表 + +```csharp +// 实时折线图,返回可选按钮索引 +int RealtimePlot(string prompt, float val, bool freeze = false, string[] optionalButtons = null) + +// 迷你指标 +bool MiniPlot(string name, float value) +bool MiniPlot(string name, string value) +bool MiniPlot(string name, TEnum value) where TEnum : Enum + +// 静态折线图 +void Plot2D(string prompt, float[] vals, int height = 200) + +// ⚠️ 以下为占位/空壳(源码 body 为空,调用无效果,请勿依赖): +// Progress, BulletText, Indent / UnIndent, QuestionMark, ToolTip +// - 需要进度展示:用 RealtimePlot 或 Label 渲染百分比 +// - 需要 tooltip:用 Button(hint:) / Table.Row.Label(hint:) 等已实现的入口 +void Progress(float val, float max = 1) // 空壳,无效果 +``` + +### 图像 + +```csharp +void Image(string prompt, string rgba, int height = -1) + +// 图像列表,返回选中索引 +int ImageList(string prompt, (string rgba, string top_title, string bottom_title)[] items, + bool persistentSelecting = false, int height_px = 100, + bool hideSeparator = false, int selecting = -1) +``` + +### 聊天框 + +```csharp +(bool ret, string sending) ChatBox(string prompt, string[] appending, + int lines = 18, bool input = true) +``` + +### 文本展示 + +```csharp +void SelectableText(string prompt, string content, bool copyButton = true) +``` + +### 文件对话框 + +```csharp +bool OpenFile(string prompt, string filters, out string dir, string defaultFn = "") +bool SaveFile(string prompt, string filter, out string dir, string defaultFn = "") +bool SelectFolder(string prompt, out string dir) +``` + +### Web 与链接 + +```csharp +void OpenWebview(string name, string url, string hint = "") +void DisplayFileLink(string localfilename, string displayName) +``` + +### 菜单栏 + +```csharp +void MenuBar(List menu) +``` + +MenuItem 构造: +```csharp +new MenuItem(string label, Action onClick = null, string shortcut = null, + bool selected = false, bool enabled = true, List subItems = null) +// label = "-" 表示分隔线 +``` + +### 面板控制 + +```csharp +bool Closing() // 返回 true 表示用户点击关闭或终端断开 +void DelegateUI() // 执行通过 Panel.dels 注入的委托 UI +``` + +--- + +## Panel 配置方法 + +维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\Panel.cs` + +```csharp +Panel ShowTitle(string title) // null 隐藏标题栏 +Panel InitSize(int w = 320, int h = 240) +Panel FixSize(int w, int h) // 固定不可调整大小 +Panel AutoSize(bool set) +Panel InitPos(bool pin, int left, int top, + float myPivotX = 0, float myPivotY = 0, + float screenPivotX = 0, float screenPivotY = 0) +Panel InitPosRelative(Panel panel, int left, int top, + float relPivotX = 0, float relPivotY = 0, + float myPivotX = 0, float myPivotY = 0) +Panel SetDefaultDocking(Docking docking, bool auxiliary = false) +Panel Modal(bool set) +Panel TopMost(bool set) +Panel AsToolbarPanel(int height = 38, ToolbarAnchor anchor = ToolbarAnchor.RightTop) +``` + +枚举: +- `Panel.Docking`:`Left`, `Top`, `Right`, `Bottom`, `None`, `Full` +- `Panel.ToolbarAnchor`:`RightTop`, `LeftBottom` + +实例方法: +```csharp +void Define(CycleGUIHandler handler) +void Repaint(bool dropCurrent = false, int repaintTimeMs = 30) +// dropCurrent=true:丢弃当前帧,立即重绘(编辑后同步 UI 时用) +// repaintTimeMs:持续 Repaint 时的间隔,默认 30ms +// 只在有实时数据的面板持续调用;静态面板不要无条件 Repaint +void BringToFront() +void Exit() +void Freeze() / void UnFreeze() +void SwitchTerminal(Terminal newTerminal) +bool Probe(CycleGUIProber prober, out T val) +void FireAndForget(...) +``` + +--- + +## HoverMenu + +维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\API\HoverMenu.cs` + +```csharp +HoverMenu GUI.DeclareHoverMenu() + +enum HoverMenuPlacement { + Cursor = 0, + ObjectScreenAnchor = 1, // 默认;使用 hovered object 的屏幕投影锚点 + ObjectBounds = 2, // 预留,v1 不支持 +} + +class HoverMenu : IDisposable { + string Name { get; set; } + string TargetObjectName { get; set; } + HoverMenuPlacement Placement { get; set; } // 默认 ObjectScreenAnchor + int OffsetX { get; set; } // 默认 12 + int OffsetY { get; set; } // 默认 12 + int CloseDelayMs { get; set; } // 默认 180 + PanelBuilder.CycleGUIHandler Build { get; set; } + Terminal Terminal { get; } + void Start() + void StartOnTerminal(Terminal terminal) + void Stop() + void Dispose() +} +``` + +实现语义:每个 terminal 一个 manager/native listener,同一时间复用一个 transient panel;C# 侧按 `TargetObjectName` 字典匹配,暂假设一个 object 一个 HoverMenu,重复注册同一 object 会抛异常。native feedback 按 hovered object、object anchor、dragging 去重,不按鼠标移动持续推送。 + +--- + +## Workspace Props 完整列表 + +维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\API\Workspace.Props.cs` + +### 模型 + +```csharp +// 加载 GLTF 模型类(不可 Remove) +LoadModel { name, detail: ModelDetail } + +// ModelDetail 结构 +ModelDetail(byte[] gltfBytes) { + Center = Vector3, + Rotate = Quaternion, + Scale = float, + ColorBias = Vector3, + ColorScale = float, + Brightness = float, + ForceDblFace = bool, + NormalShading = float, +} + +// 更新已加载模型的参数(不重新传 GLTF 数据) +ReloadModel { name, detail: ModelDetail } + +// 放置模型实例 +PutModelObject { name, clsName, newPosition: Vector3, newQuaternion: Quaternion, baseColor?: Color /* 预留,引擎当前忽略 */ } + +// 自定义网格 +DefineMesh { clsname, positions: float[] /* 三角面顶点,长度必须是9的倍数 */, color: Color, smooth: bool } +``` + +### 点云 + +```csharp +PutPointCloud { + name: string, + xyzSzs: Vector4[], // xyz + size + colors: Color[], // 每点一个 System.Drawing.Color + newPosition: Vector3, + newQuaternion: Quaternion, + handleString: string, // 空=无句柄, 单字符=字符句柄, 多字符=PNG图标 + type: int, // 0=普通, 1=SLAM地图 +} +``` + +### 线和曲线 + +```csharp +PutStraightLine { + name, start: Vector3, end: Vector3, + propStart: string, propEnd: string, // 绑定到对象(空则用 start/end) + arrowType: Painter.ArrowType, width: int, color: Color, dashDensity: int, +} + +PutBezierCurve { + name, start: Vector3, end: Vector3, + control1: Vector3, control2: Vector3, + width: int, color: Color, +} + +PutVector { name, from: Vector3, dir: Vector3, color: Color, length: int } +``` + +### 图像与纹理 + +```csharp +// 静态图像 +PutImage { name, rgbaName, displayType, displayH, displayW, /* transform fields */ } + +// 可更新 RGBA 纹理 +PutRGBA { name, width, height, requestRGBA / rgba } + .StartStreaming() -> Action // 返回更新函数 + .Invalidate() + .UpdateRGBA(byte[]) + +// SVG +DeclareSVG { name, svgContent: string } +``` + +### 对象操作 + +```csharp +// 变换对象位置/旋转 +TransformObject { name, pos: Vector3, quat: Quaternion, timeMs: int, coord: Coord } +// Coord: Absolute / Relative + +// 变换子对象 +TransformSubObject { objectNamePattern, subObjectName/subObjectId, translation, rotation, timeMs } + +// 对象锚定(moon 跟随 earth) +SetObjectMoonTo { name: "moon", earth: "earth_obj", pos: Vector3, quat: Quaternion } + +// 删除对象(通配符) +WorkspaceProp.RemoveNamePattern(string namePattern) // 支持 * 和 ? +``` + +### 文本与图标 + +```csharp +PutHandleIcon { name, ... } +PutTextAlongLine { name, ... } +SpotText { /* fluent builder */ } +``` + +### 高斯溅射(Gaussian Splatting) + +```csharp +PutGaussianSplats { + name, splats: GaussianSplat[], + globalOpacityScale, globalSizeScale, renderMode, ... +} +PutGaussianSplats.FromPLY(path, name, axisPreset) +PutGaussianSplats.FromPointCloud(...) +PutGaussianSplats4D { /* 4D 时序数据 */ } +``` + +--- + +## Workspace UI 操作 + +维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\API\Workspace.UIOps.cs` + +### 相机 + +```csharp +SetCamera { + lookAt: Vector3, + azimuth: float, // 默认 -PI/2 + altitude: float, // 默认 PI/2 + distance: float, + fov: float, + projectionMode: Perspective / Orthographic, + displayMode: Normal / VR / EyeTrackedHolography / EyeTrackedLenticular, + anchor_type: Both / StareOnly / PositionOnly / CopyCamera, + azimuth_range: Vector2, // 限制旋转范围 + altitude_range: Vector2, + pan_range: Vector3, // 限制平移范围 + mmb_freelook: bool, // 中键自由视角 +} +// 提交方式:.IssueToDefault() / .IssueToAllTerminals() / .IssueToTerminal(t) +``` + +### 外观 + +```csharp +SetAppearance { + useEDL: bool, // Eye-Dome Lighting + useSSAO: bool, // 环境光遮蔽 + useGround: bool, // 地面 + useBorder: bool, // 边框 + useBloom: bool, // 泛光 + drawGroundGrid: bool, // 地面网格 + drawGuizmo: bool, // 坐标轴指示器 + useDefaultSky: bool, // 默认天空盒 + sun_altitude: float, // 太阳高度 + hover_shine: Color, // 悬停高亮色 + selected_shine: Color, // 选中高亮色 + hover_border_color: Color, + selected_border_color: Color, + world_border_color: Color, + voxel_quantize: float, // 体素量化 + voxel_opacity: float, // 体素不透明度 + clippingPlanes: (Vector3 center, Vector3 direction)[], // 最多4个裁剪面 + bring2front_onhovering: bool, +} +``` + +### 对象外观 + +```csharp +SetObjectApperance { namePattern, bring_to_front, shine_color: Color, use_border, transparency } +SetModelObjectProperty { namePattern, baseAnimId, nextAnimId, material_variant, team_color: Color, + base_stopatend, next_stopatend, animate_asap } +SetPropShowHide { namePattern, show: bool } // sticky pattern (2026-05+) +SetPropApplyCrossSection { namePattern, apply: bool } // sticky pattern (2026-05+) +``` + +> **Sticky pattern 语义**(适用于 `SetPropShowHide` / `SetPropApplyCrossSection` / +> `SelectObject.SetObjectSelectable` / `SelectObject.SetObjectSubSelectable`):每次调用先按 +> pattern 扫一遍 `global_name_map` 立即生效,**同时**把 pattern 字符串记入当前 viewport 的 +> `workspace_state.*_patterns`。之后 `Workspace.AddProp` 加入的新对象会被引擎在 `indexier::add` +> 自动按 memo 重匹配并设位/隐藏/纳入 selectables。要取消粘性,用**同一 pattern 字符串**反向调 +> 一次(`SetPropShowHide{show=true}` / `SetObjectUnselectable(pattern)` 等)。 +> +> **Pattern 是 glob**(`*` / `?`),引擎全栈唯一语义。底层是 `libVRender::wildcardMatch`, +> 2026-05 统一化后已删除 `RegexMatcher::match` / `regexMatch` 全套 regex 路径。详见 +> SKILL.md 的 `## namePattern 语法 = glob` 章节,那里有完整的 API 列表 + 历史根因 + +> "真要 regex 怎么办" 指引。 + +### 交互操作 + +所有交互操作继承 `WorkspaceUIOperation`,通过 `.Start()` 启动,`.End()` 结束,`.feedback` 回调接收结果。 + +```csharp +// 拾取世界坐标 +GetPosition { + method: PickMode.GridPlane / Holo3D, + snaps: string[], // 可吸附对象名 +} +// feedback: Action +// WorldPosition { mouse_pos, snapping_object, object_pos, sub_id } + +// 拖拽跟随 +FollowMouse { + method: FollowingMethod.LineOnGrid / RectOnGrid / PointOnGrid / CircleOnGrid / Line3D / ..., + follower_objects: string[], + start_snapping_objects: string[], + end_snapping_objects: string[], + realtime: bool, +} +// feedback: Action + +// 对象选择 +SelectObject { + fineSelectOnPointClouds: bool, + fineSelectOnHandle: bool, +} +// feedback: Action<(string name, BitArray selector, string firstSub)[], op> +// feedback 返回的是“当前完整选中集”(不是 delta),由引擎处理 Shift+加选 / Alt+减选 / 框选 +// 运行时切换模式:selectOp.SetSelectionMode(SelectionMode.Rectangle, paintRadius) +// SelectionMode: Click / Rectangle / Paint +// +// SetObjectSelectable(pattern) / SetObjectSubSelectable(pattern) +// - pattern 是 glob:"*" = 任意子串、"?" = 任意单字符;不是 ECMAScript 正则 +// - 立即对已存在对象按 glob 设可选位 +// - 并把 pattern 记入 workspace_state.selectable_patterns(sticky,2026-05 起) +// - 之后 AddProp 加入的新对象会被引擎自动匹配 + 设位(indexier::add hook) +// - 取消 sticky:用同一 pattern 调 SetObjectUnselectable / SetObjectSubUnselectable + +// Guizmo 操作(移动/旋转 gizmo) +GuizmoAction { + dof: GuizmoDof, // 默认 TranslateXYZ + realtimeResult: bool, +} +// GuizmoDof: None, TranslateX/Y/Z, RotateX/Y/Z, Roll, Pitch, Yaw, +// TranslateXYZ, RotateXYZ, PlanarXY, PlanarXYYaw, All +// feedback: Action<(string name, Vector3 pos, Quaternion rot)[], op> + +// 原始鼠标事件 +RegisterMouseAction { + listen_MouseDown, listen_MouseUp, listen_MouseMove, listen_Wheel: bool, +} +// feedback: Action +// MouseAction { mouseX, mouseY, workspaceX, workspaceY, mouseLB/RB/MB, wheelDelta } +``` + +### 工作区行为 + +```csharp +SetWorkspaceBehaviour { + operation_trigger: Mouse.MouseLB, // 操作触发键 + workspace_pan: Mouse.MouseRB, // 平移键 + workspace_orbit: Mouse.MouseMB, // 旋转键 +} +// Mouse: MouseLB, MouseMB, MouseRB, CtrlMouseLB, CtrlMouseMB, CtrlMouseRB +``` + +### 查询操作 + +```csharp +// 查询视口相机状态 +new QueryViewportState { callback = state => { + // state.CameraPosition, state.LookAt, state.Up +}}.IssueToDefault(); + +// 捕获渲染画面 +new CaptureRenderedViewport { callback = img => { + // img.bytes (RGB), img.width, img.height +}}.IssueToDefault(); + +// 查询图形信息 +new QueryGraphics { callback = state => { + // state.MonitorCount, state.Monitors[], state.FPS +}}.IssueToDefault(); + +// 聚焦到对象 +new FrameToFit { name = "objectName", margin = 1.1f }.IssueToDefault(); + +// 全屏 +new SetFullScreen { fullscreen = true, screen_id = -1 }.IssueToDefault(); + +// 自定义网格 +new SetOperatingGridAppearance { + show = true, pivot = Vector3.Zero, + unitX = Vector3.UnitX, unitY = Vector3.UnitY, +}.IssueToDefault(); + +// ImGui 样式 +new SetImGUIStyle { + windowBg = 0xFF171717, button = 0xFF3D3835, + windowRounding = 6f, frameRounding = 6f, +}.IssueToDefault(); + +// 主窗口菜单栏 +new SetMainMenuBar { + menu = new List { ... }, show = true +}.IssueToDefault(); + +// Viewport 专属菜单栏 +var viewport = GUI.PromptWorkspaceViewport(); +new SetWorkspaceMenuBar { + viewport = viewport, + menu = new List { ... }, show = true +}.Issue(); + +// 面板持久菜单栏(从 handler 外部设置,自动注入到每次重绘) +new SetPanelMenuBar { + panel = myPanel, + menu = new List { ... } +}.Issue(); +// 清除:new SetPanelMenuBar { panel = myPanel, menu = null }.Issue(); +``` + +--- + +## Painter 完整 API + +维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\Painter.cs` + +```csharp +// 获取/创建命名 Painter(单例) +static Painter GetPainter(string name) + +// 清除当前帧数据(双缓冲交换) +void Clear() + +// 绘制点(单位:米) +void DrawDot(Color color, Vector3 xyz, float size = 1f) + +// 绘制线段 +void DrawLine(Color color, Vector3 start, Vector3 end, + float width = 1f, ArrowType arrow = ArrowType.None, int dashScale = 0) +// ArrowType: None, Start, End + +// 绘制方向向量(像素长度) +void DrawVector(Vector3 from, Vector3 dir, Color color, + float width = 1f, int pixels = 10) + +// 绘制 3D 文本 +void DrawText(Color color, Vector3 tCenter, string s) + +// 绘制区域体素 +void DrawRegion3D(Color color, Vector3 center) + +// 绘制填充多边形(2D 点 + 3D 变换) +void DrawPolygonFilled(Vector2[] points2D, Vector3 trans, Quaternion quat, + Color borderColor, Color fillColor, float thickness = 0f) +// thickness=0: 平面多边形; >0: 拉伸立体 + +// 终端隔离 +Painter.terminal = specificTerminal; // null 则广播到所有终端 +``` + +--- + +## LeastServer(HTTP/WebSocket 服务) + +全局类(无命名空间),维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\Terminals\LeastServer.cs` + +```csharp +static void Listener(int port) // 阻塞式启动监听 +static void AddGetHandler(string path, Func handler) +static void AddPostTextHandler(string path, Func handler) +static void AddPostByteHandler(string path, Func handler) +static void AddServingFiles(string urlPrefix, string localPath) +static void AddServingResources(string urlPrefix, Assembly, string ns) +static void AddSpecialTreat(string path, Action<...> handler) // WebSocket 升级 +``` + +--- + +## Viewport(面板内嵌 3D 视口) + +```csharp +// 创建内嵌 Workspace 视口面板 +var viewport = GUI.PromptWorkspaceViewport(); +``` + +`Viewport` 继承 `Panel`,拥有独立的 `ViewportSubTerminal`,Workspace 状态与主终端隔离。 + diff --git a/.cursor/skills/cyclegui-app-development/references/examples.md b/.cursor/skills/cyclegui-app-development/references/examples.md new file mode 100644 index 0000000..abc7c89 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/examples.md @@ -0,0 +1,555 @@ +# CycleGUI 实用示例 + +## 示例 1:最小应用 + +最简可运行 CycleGUI 应用: + +```csharp +using CycleGUI; +using CycleGUI.Terminals; + +LocalTerminal.SetTitle("Hello CycleGUI"); +LocalTerminal.AddMenuItem("Exit", LocalTerminal.Terminate); +LocalTerminal.Start(); + +GUI.PromptPanel(pb => +{ + pb.Panel.ShowTitle("Hello"); + pb.Label("Hello from CycleGUI!"); + if (pb.Button("Exit")) + pb.Panel.Exit(); +}); +``` + +## 示例 2:多面板导航(LearnCycleGUI 模式) + +主面板按钮打开子面板,子面板停靠在左侧: + +```csharp +Panel mainPanel = null; +Panel settingsPanel = null; +List activePanels = new(); + +mainPanel = GUI.PromptPanel(pb => +{ + pb.Panel.ShowTitle("Main"); + + if (pb.Button("Open Settings")) + { + if (!activePanels.Contains(settingsPanel)) + { + settingsPanel = GUI.DeclarePanel() + .ShowTitle("Settings") + .InitPosRelative(mainPanel, 0, 16, 0, 1) + .SetDefaultDocking(Panel.Docking.Left); + settingsPanel.Define(CreateSettingsHandler()); + activePanels.Add(settingsPanel); + } + else + settingsPanel.BringToFront(); + } +}); + +PanelBuilder.CycleGUIHandler CreateSettingsHandler() +{ + float volume = 0.5f; + bool darkMode = true; + int quality = 1; + + return pb => + { + pb.SliderFloat("Volume", ref volume, 0, 1); + pb.Toggle("Dark Mode", ref darkMode); + pb.RadioButtons("Quality", new[] { "Low", "Medium", "High" }, ref quality); + + pb.Separator(); + if (pb.Closing()) + { + activePanels.Remove(settingsPanel); + pb.Panel.Exit(); + } + }; +} +``` + +## 示例 3:模态对话框 + +阻塞式确认对话框,返回用户选择: + +```csharp +bool confirmed = false; + +GUI.PromptAndWaitPanel(pb => +{ + pb.Panel.ShowTitle("Confirm").Modal(true).AutoSize(true); + pb.Label("Are you sure you want to delete?"); + pb.SameLine(); + if (pb.Button("Yes", distinct: "yes")) + { + confirmed = true; + pb.Panel.Exit(); + } + pb.SameLine(); + if (pb.Button("No", distinct: "no")) + { + pb.Panel.Exit(); + } +}); + +if (confirmed) { /* proceed */ } +``` + +## 示例 4:实时数据面板 + +后台线程更新数据,面板实时刷新: + +```csharp +float temperature = 20f; +float humidity = 50f; +bool running = true; + +var panel = GUI.DeclarePanel() + .ShowTitle("Sensor Monitor") + .InitSize(350, 200) + .SetDefaultDocking(Panel.Docking.Right); + +panel.Define(pb => +{ + pb.MiniPlot("Temperature", temperature); + pb.MiniPlot("Humidity", humidity); + pb.RealtimePlot("Temp Chart", temperature); + + pb.Separator(); + if (pb.Button("Stop")) + running = false; + + pb.Panel.Repaint(); // 实时数据:handler 内持续 Repaint +}); + +Task.Run(() => +{ + var rng = new Random(); + while (running) + { + temperature = 20f + (float)rng.NextDouble() * 5; + humidity = 50f + (float)rng.NextDouble() * 10; + Thread.Sleep(100); + // 也可在此 panel.Repaint() 替代 handler 内 Repaint,二选一 + } +}); +``` + +## 示例 5:表格展示与交互 + +```csharp +string[] names = { "Alice", "Bob", "Charlie", "Diana" }; +float[] scores = { 95, 87, 73, 91 }; +bool[] enabled = { true, true, false, true }; + +panel.Define(pb => +{ + pb.Table("students", new[] { "Name", "Score", "Enabled", "Action" }, + names.Length, (row, i) => + { + row.Label(names[i]); + row.Label(scores[i].ToString("F1")); + row.Checkbox(ref enabled[i]); + if (row.ButtonGroup("", new[] { "Edit", "Delete" }, out int act)) + { + if (act == 0) Console.WriteLine($"Edit {names[i]}"); + if (act == 1) Console.WriteLine($"Delete {names[i]}"); + } + }, height: 200, enableSearch: true, title: "Student Records"); +}); +``` + +## 示例 6:3D 场景 - 加载模型和点云 + +```csharp +using CycleGUI; +using CycleGUI.API; +using System.Numerics; + +// 加载模型类 +Workspace.AddProp(new LoadModel +{ + name = "car_model", + detail = new Workspace.ModelDetail(File.ReadAllBytes("car.glb")) + { + Scale = 0.001f, + Center = new Vector3(0, 0, 0), + } +}); + +// 放置两个实例 +Workspace.AddProp(new PutModelObject +{ + name = "car_a", + clsName = "car_model", + newPosition = new Vector3(0, 0, 0), + newQuaternion = Quaternion.Identity, +}); + +Workspace.AddProp(new PutModelObject +{ + name = "car_b", + clsName = "car_model", + newPosition = new Vector3(5, 0, 0), + newQuaternion = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2), +}); + +// 添加地面点云 +var gridPoints = new List(); +var gridColors = new List(); +for (float x = -10; x <= 10; x += 0.5f) +for (float y = -10; y <= 10; y += 0.5f) +{ + gridPoints.Add(new Vector4(x, y, 0, 2)); + gridColors.Add(0xFF808080); +} + +Workspace.AddProp(new PutPointCloud +{ + name = "ground_grid", + xyzSzs = gridPoints.ToArray(), + colors = gridColors.ToArray(), + newPosition = Vector3.Zero, +}); + +// 设置相机 +new SetCamera +{ + lookAt = new Vector3(2.5f, 0, 0), + altitude = MathF.PI / 4, + azimuth = -MathF.PI / 3, + distance = 15f, +}.IssueToAllTerminals(); +``` + +## 示例 7:Painter 实时调试绘制 + +```csharp +var painter = Painter.GetPainter("debug_overlay"); + +Task.Run(() => +{ + float t = 0; + while (true) + { + painter.Clear(); + + // 绘制坐标轴 + painter.DrawLine(Color.Red, Vector3.Zero, Vector3.UnitX * 2, 2f, + Painter.ArrowType.End); + painter.DrawLine(Color.Green, Vector3.Zero, Vector3.UnitY * 2, 2f, + Painter.ArrowType.End); + painter.DrawLine(Color.Blue, Vector3.Zero, Vector3.UnitZ * 2, 2f, + Painter.ArrowType.End); + + // 绘制旋转点 + float x = MathF.Cos(t) * 3; + float y = MathF.Sin(t) * 3; + painter.DrawDot(Color.Yellow, new Vector3(x, y, 0), 5f); + painter.DrawText(Color.White, new Vector3(x, y, 0.3f), + $"({x:F1}, {y:F1})"); + + // 绘制轨迹线 + for (int i = 0; i < 20; i++) + { + float t1 = t - i * 0.1f; + float t2 = t - (i + 1) * 0.1f; + painter.DrawLine(Color.FromArgb(255 - i * 12, 255, 255, 0), + new Vector3(MathF.Cos(t1) * 3, MathF.Sin(t1) * 3, 0), + new Vector3(MathF.Cos(t2) * 3, MathF.Sin(t2) * 3, 0)); + } + + t += 0.05f; + Thread.Sleep(33); + } +}); +``` + +## 示例 8:对象选择与 Guizmo 操作 + +```csharp +SelectObject selectOp = null; +GuizmoAction guizmoOp = null; +string selectedObject = null; + +void StartSelect() +{ + selectOp = new SelectObject(); + selectOp.feedback = (results, op) => + { + if (results.Length > 0) + { + selectedObject = results[0].name; + Console.WriteLine($"Selected: {selectedObject}"); + } + }; + selectOp.Start(); +} + +void StartGuizmo() +{ + if (selectedObject == null) return; + selectOp?.End(); + + guizmoOp = new GuizmoAction + { + dof = GuizmoAction.GuizmoDof.PlanarXYYaw, + realtimeResult = true, + }; + guizmoOp.feedback = (results, op) => + { + foreach (var (name, pos, rot) in results) + Console.WriteLine($"Moved {name} to {pos}"); + }; + guizmoOp.finished = () => StartSelect(); + guizmoOp.Start(); +} +``` + +## 示例 9:拾取坐标绘制线段 + +```csharp +Vector3? lineStart = null; + +var getPos = new GetPosition +{ + Name = "Draw Line", + method = GetPosition.PickMode.GridPlane, +}; + +getPos.feedback = (wp, op) => +{ + if (lineStart == null) + { + lineStart = wp.mouse_pos; + } + else + { + Workspace.AddProp(new PutStraightLine + { + name = $"line_{DateTime.Now.Ticks}", + start = lineStart.Value, + end = wp.mouse_pos, + color = Color.Cyan, + width = 2, + arrowType = Painter.ArrowType.End, + }); + lineStart = null; + } +}; + +getPos.Start(); +``` + +## 示例 10:Medulla2 风格的 IOObject 面板 + +Medulla2 中每个设备对象(IOObject)通过 `OpenUI` 打开独立的控制面板: + +```csharp +public class Camera3DIOObject : IOObject +{ + private Panel uiPanel; + private bool displaying = false; + + public void OpenUI() + { + displaying = true; + uiPanel = GUI.DeclarePanel() + .ShowTitle($"Camera: {Name}") + .SetDefaultDocking(Panel.Docking.Right); + uiPanel.Define(pb => + { + if (pb.Button("Capture")) TakeSnapshot(); + pb.Toggle("Live View", ref displaying); + pb.MiniPlot("FPS", currentFps); + + if (pb.Closing()) + { + displaying = false; + Painter.GetPainter(Name).Clear(); + uiPanel.Exit(); + } + }); + } + + public void CloseUI() + { + displaying = false; + Painter.GetPainter(Name).Clear(); + uiPanel?.Exit(); + } + + public void draw() + { + if (!displaying) return; + var pp = Painter.GetPainter(Name); + pp.Clear(); + foreach (var pt in cachedPoints) + { + var cc = Color.FromArgb(pt.r, pt.g, pt.b); + pp.DrawDot(cc, new Vector3(pt.X, pt.Y, pt.Z) / 1000f, 1); + } + } +} +``` + +## 示例 11:工具栏面板 + +```csharp +var toolbar = GUI.DeclarePanel() + .ShowTitle(null) + .AsToolbarPanel(38, Panel.ToolbarAnchor.RightTop); + +toolbar.Define(pb => +{ + var tpb = new ToolbarPanelBuilder(pb); + tpb.Label($"{IconFonts.ForkAwesome.Cube} Scene"); + tpb.Separator(); + + if (tpb.Button($"{IconFonts.ForkAwesome.Play} Run", distinct: "tb-run")) + StartSimulation(); + + if (tpb.Button($"{IconFonts.ForkAwesome.Stop} Stop", distinct: "tb-stop")) + StopSimulation(); + + tpb.PopMenuButton($"{IconFonts.ForkAwesome.Camera} View", new[] + { + new MenuItem("Front", () => new SetCamera { azimuth = 0, altitude = MathF.PI/2 }.IssueToDefault()), + new MenuItem("Top", () => new SetCamera { azimuth = 0, altitude = 0 }.IssueToDefault()), + new MenuItem("Reset", () => new SetCamera().IssueToDefault()), + }); +}); +``` + +## 示例 12:同时启用 Local + Web 终端 + +```csharp +static void Main(string[] args) +{ + LocalTerminal.SetTitle("Dual Terminal App"); + LocalTerminal.SetIcon(LoadIcon(), "DualApp"); + LocalTerminal.AddMenuItem("Exit", LocalTerminal.Terminate); + LocalTerminal.Start(); + + // Web 终端在后台启动 + Task.Run(() => + { + var htdocs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "htdocs"); + if (Directory.Exists(htdocs)) + LeastServer.AddServingFiles("/", htdocs); + + // 添加自定义 API + LeastServer.AddGetHandler("/api/status", () => + JsonConvert.SerializeObject(new { status = "running", time = DateTime.Now })); + + WebTerminal.Use(port: 8081, ico: LoadIcon()); + }); + + // 远程连接时显示的面板 + Terminal.RegisterRemotePanel(terminal => + CreateMainPanel(terminal)); + + // 本地面板 + GUI.PromptPanel(CreateMainPanel(GUI.defaultTerminal)); +} + +static PanelBuilder.CycleGUIHandler CreateMainPanel(Terminal t) +{ + return pb => + { + pb.Panel.ShowTitle("Control Panel"); + pb.Label($"Terminal: {t.GetType().Name}"); + // 同样的 UI 在两个终端上都可见... + }; +} + +// 图标统一用 .ico 字节(也可 File.ReadAllBytes 从磁盘读) +// 需 csproj: ;exe 图标另用 +static byte[] LoadIcon() +{ + var asm = Assembly.GetExecutingAssembly(); + using var s = asm.GetManifestResourceStream( + asm.GetManifestResourceNames().First(p => p.Contains(".ico"))); + return new BinaryReader(s).ReadBytes((int)s.Length); +} +``` + +## 示例 13:Painter 填充多边形 + +```csharp +var painter = Painter.GetPainter("polygon_demo"); +painter.Clear(); + +// 绘制地面上的三角形 +var triangle = new[] +{ + new Vector2(0, 0), + new Vector2(1, 0), + new Vector2(0.5f, 0.866f), +}; +painter.DrawPolygonFilled(triangle, + trans: new Vector3(0, 0, 0), + quat: Quaternion.Identity, + borderColor: Color.White, + fillColor: Color.FromArgb(128, Color.Green)); + +// 拉伸为柱体 +painter.DrawPolygonFilled(triangle, + trans: new Vector3(3, 0, 0), + quat: Quaternion.Identity, + borderColor: Color.Yellow, + fillColor: Color.FromArgb(100, Color.Blue), + thickness: 1.5f); +``` + +## 示例 14:Medulla2 插件结构 + +Medulla2 插件 DLL 必须暴露 `MainIOObject` 类: + +```csharp +// MyPlugin/MainIOObject.cs +public class MainIOObject : IOObject +{ + public object init(string typeName, dynamic[] args) + { + // 根据 typeName 创建设备实例 + if (typeName == "sensor_a") + return new SensorA((string)args[0], (int)args[1]); + return null; + } +} + +// MyPlugin/SensorA.cs +public class SensorA : IOObject +{ + public SensorA(string port, int baudRate) { /* init */ } + + [IOObjectUtility] + public void Calibrate() { /* 显示为 UI 按钮 */ } + + [IOObjectMonitor] + public float Temperature => ReadTemp(); +} +``` + +csproj 引用 refasmer 生成的参考程序集: +```xml + + $(ReleaseDir)\RefMedullaCore.dll + +``` + +输出到插件目录: +```xml +$(DebugDir)\plugins +``` + +加载方式(在 .iocmd 脚本中): +``` +sensor = io load plugins/MyPlugin.dll +mySensor = sensor init sensor_a COM3 115200 +``` diff --git a/.cursor/skills/cyclegui-app-development/references/panels-controls-menus.md b/.cursor/skills/cyclegui-app-development/references/panels-controls-menus.md new file mode 100644 index 0000000..f2847e6 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/panels-controls-menus.md @@ -0,0 +1,310 @@ +# 面板、控件、工具栏和菜单栏 + +## Panel 创建方式 + +- `GUI.PromptPanel(handler)`:立即显示面板。 +- `GUI.DeclarePanel()`:先创建 `Panel`,后续 `Define(handler)` 绑定内容。 +- `GUI.PromptAndWaitPanel(handler)`:阻塞式模态面板。 +- `GUI.PromptOrBringToFront(handler, instancingObject: key)`:单实例面板。 + +```csharp +var panel = GUI.DeclarePanel() + .ShowTitle("Settings") + .InitSize(400, 300) + .InitPos(false, 100, 100) + .SetDefaultDocking(Panel.Docking.Left); + +panel.Define(pb => +{ + pb.Label("Ready"); + if (pb.Closing()) panel.Exit(); +}); +``` + +## Panel 配置 + +- `ShowTitle(null)` 隐藏标题栏;传字符串设置标题。 +- `InitSize(w, h)` 设置初始大小。 +- `InitPos(pin, left, top, myPivotX, myPivotY, screenPivotX, screenPivotY)` 设置屏幕相对位置。 +- `InitPosRelative(panel, left, top, relPivotX, relPivotY, myPivotX, myPivotY)` 设置相对位置。 +- `SetDefaultDocking(Panel.Docking.Left/Top/Right/Bottom/None/Full)` 设置默认停靠。 +- `AutoSize(true)`、`Modal(true)`、`TopMost(true)` 分别控制自适应大小、模态、置顶。 + +`InitPosRelative` 没有 `offsetX` / `offsetY` 命名参数。 + +## Panel 生命周期 + +- `panel.Repaint()` / `pb.Panel.Repaint()`:请求重绘;handler 会在约 `repaintTimeMs`(默认 30ms)后再次执行。 +- `panel.Repaint(true)`:丢弃当前帧并立即重绘(数据刚被用户编辑、需要同步其它面板时用)。 +- `panel.Repaint()` 也可从其它线程调用,用于后台数据推送。 +- `panel.BringToFront()`:置前。 +- `panel.Exit()`:关闭。 +- `panel.Freeze()` / `panel.UnFreeze()`:冻结/解冻。 +- `pb.Closing()`:用户点击关闭时返回 true,通常随后调用 `panel.Exit()`。 + +### 立即模式与 Repaint 策略(重要) + +CycleGUI 是立即模式:**handler 只在「被请求重绘」时才重新执行**。若 handler 末尾没有 `Repaint()`,面板通常只在控件返回 true(用户点击、输入等)那一帧重绘。 + +#### 默认原则:不要无条件 Repaint + +**不要在每个面板 handler 末尾无条件写 `pb.Panel.Repaint()`**,除非该面板确实有实时变化的数据。静态配置页、Actions 按钮组、Properties 表格等在无交互时不应持续刷新——否则 handler 会以 ~30ms 间隔空转,浪费 CPU。 + +| 面板内容 | 是否持续 Repaint | +|---------|-----------------| +| 静态 Properties / Actions / 配置表单 | 否,交互时自动重绘 | +| Status 字段、实时指标、MiniPlot / RealtimePlot | 是 | +| 后台线程推送的数据(遥测、诊断日志等) | 是(线程侧或对应 Tab 内 Repaint) | +| 延迟回调改完 ListBox / Table 数据 | 仅回调内 Repaint 一次 | + +#### 何时必须显式 Repaint + +1. **延迟回调改数据**:`PopMenuButton` / `MenuItem`、`UITools.Input` 等对话框确认回调发生时不在正常渲染帧内,`ListBox` / `Table` 不会自动反映新数据。 + +```csharp +pb.PopMenuButton("Add", types.Select(t => new MenuItem(t.Name, () => +{ + if (UITools.Input("Name", def, out var name, ...)) + { + list.Add(Create(t, name)); + pb.Panel.Repaint(); // 否则上面的 ListBox 不刷新 + } +})).ToArray()); +var sel = pb.ListBox("Existing", list.Select(x => x.name).ToArray()); +``` + +2. **用户编辑后需同步其它面板**:`pb.Panel.Repaint(true)`,并可 `otherPanel?.Repaint()`。 + +3. **后台线程更新 UI 数据**:在更新完共享数据后 `panel.Repaint()`(见 `examples.md` 实时图表示例)。 + +#### 复合面板:按 Tab / 区块决定是否 Repaint + +多 Tab 面板(`TabButtons`)应只在**当前 Tab 或区块有实时数据**时 Repaint,而不是在 handler 末尾一刀切: + +```csharp +var tab = pb.TabButtons("edit", new[] { "Properties", "Status", "Actions" }); +if (tab == 0) +{ + // 静态配置,交互时控件自身触发重绘,无需 Repaint +} +else if (tab == 1) +{ + pb.Table("Status", headers, rows, (row, i) => { row.Label(liveValues[i]); }); + pb.Panel.Repaint(); // Status 实时刷新 +} +else if (tab == 2) +{ + if (pb.ButtonGroup("Actions", actionNames, out var sel)) + actions[sel](); + // Actions 静态按钮,不 Repaint +} +``` + +若 handler 上半部还有**独立的实时区块**(例如 Locator 的 `DrawUI` 显示 `Status` / `Working`),下半部 Tab 可能是静态的——用标志位或虚属性标记「上半部需要 live 刷新」,仅在该标志为 true 时 Repaint: + +```csharp +inst.DrawUI(pb); // 子类可 override DrawUILive => true +ShowPSA(pb, ...); // Status Tab 内部自行 Repaint +if (inst.DrawUILive) + pb.Panel.Repaint(); +``` + +#### 典型实时面板 + +状态栏、诊断条、带 `MiniPlot` / `RealtimePlot` 的监控面板——handler 末尾 **可以** 无条件 `pb.Panel.Repaint()`,因为内容每帧都在变。 + +```csharp +return pb => +{ + pb.Label($"Pos: {state.x:F1}, {state.y:F1}"); + pb.MiniPlot("Loop ms", state.loopMs); + pb.Panel.Repaint(); +}; +``` + +## PanelBuilder 常用控件 + +```csharp +pb.Label("Status"); +pb.Separator(); +pb.SeparatorText("Section"); +pb.SameLine(); + +if (pb.Button("Click", hint: "tooltip")) { } +var (text, done) = pb.TextInput("Name", defaultText: "hello", hintText: "placeholder"); +pb.CheckBox("Enable", ref enabled); +pb.Toggle("Dark Mode", ref darkMode); +pb.RadioButtons("Choice", new[] { "A", "B" }, ref selected); +pb.DropdownBox("Type", new[] { "X", "Y" }, ref selectedIdx); +pb.SliderFloat("Volume", ref volume, 0, 1); +pb.SliderInt("Count", ref count, 0, 100); +pb.DragFloat("Speed", ref speed, 0.1f, 0, 10); +pb.DragVector2("Position", ref pos, 0.01f); +pb.ColorEdit("Color", ref color); +``` + +布局约束:没有 `BeginChild` / `ChildWindow` / `BeginTabBar` / `BeginTabItem`。分区列表用 `Table(..., height: N)`,折叠区域用 `CollapsingHeaderStart/End`,Tab 切换用 `TabButtons`。 + +空壳控件不要依赖:`Progress`、`BulletText`、`Indent` / `UnIndent`、`QuestionMark`、`ToolTip`。进度用 `RealtimePlot` 或 `Label`,tooltip 用 `Button(hint:)` 等入口。 + +## 控件 id 与 Duplicated id(重要) + +CycleGUI 给每个**带状态的控件**用标签算一个 id(`ImHashStr`)。同一面板(同一次 handler 执行)内出现两个**相同 id** 就会抛异常并使该面板崩溃: + +``` +(Exception): Duplicated id `终点筛选(站点ID或名称)` + at CycleGUI.PanelBuilder.TextInput(...) +``` + +### 根因:id 用 ASCII 算哈希 + +`ImHashStr` 内部是 `Encoding.ASCII.GetBytes(label)` 再做 CRC32。**所有非 ASCII 字符(中文、全角括号()【】、全角空格等)都会被折叠成 `?`**,于是不同的中文可能折叠出相同的字节序列: + +- 两个**同字数的纯中文**标签 → 折叠后完全相同 → 同 id → 崩。 + 例:`筛选条件`(TextInput) 与 `设为类型`(DropdownBox) 都是 4 个汉字 → 都折叠成 `????`。 +- 两个仅中文字符不同、ASCII 部分相同的标签 → 同样相撞。 + 例:`起点筛选(站点ID或名称)` 与 `终点筛选(站点ID或名称)` → 折叠后只剩 `ID` 等 ASCII 一致 → 相撞。 + +### 哪些控件受影响 + +凡是用「第一个标签参数」算 id 的都受影响:`TextInput` / `DropdownBox` / `CheckBox` / `Toggle` / `ButtonGroup` / `ListBox` / `RadioButtons` / `Table` / `TabButtons` / `ColorEdit` / `SliderInt` / `SliderFloat` / `DragFloat` / `DragVector2` / `RealtimePlot` / `ChatBox` / `CollapsingHeaderStart` / `MiniPlot` 等;`Button` 的 id = `text + "-" + distinct`。 + +不受影响:`Label` / `SeparatorText` / `Separator`(无 id);`SelectableText` 用 `content`(不是 prompt)算 id。 + +### 修法(三选一,优先 ①) + +① **加 `###` 唯一后缀**:`ImHashStr` 实现了 ImGui 的 `###` 约定——遇到 `###` 会重置哈希,只用 `###` 之后的 ASCII 算 id;显示文字仍是 `###` 之前的部分。后缀必须**纯 ASCII** 且在本面板内唯一。 + +```csharp +// 错:两个 4 字纯中文标签 → Duplicated id +pb.TextInput("筛选条件", ...); +pb.DropdownBox("设为类型", ...); + +// 对:显示不变,id 唯一 +pb.TextInput("筛选条件###flt-keyword", ...); +pb.DropdownBox("设为类型###set-kind", ...); +``` + +② **按钮用唯一 `distinct:`**:给每个 `Button` 一个唯一的纯 ASCII `distinct`,即使文案是纯中文也不撞。 + +```csharp +if (pb.Button("保存", distinct: "cfg-save")) { } +if (pb.Button("删除", distinct: "cfg-del")) { } +``` + +③ **加 ASCII 前缀**:表单字段统一用 `1. ` / `2. ` / `3. ` 等数字前缀(数字是 ASCII,天然区分),既排版整齐又避免相撞。 + +```csharp +pb.TextInput("1. 名称", ...); +pb.TextInput("2. 备注", ...); +``` + +### 作用域与循环 + +- id 唯一性是**「每面板每帧」**判定:不同面板之间不会相撞(panelId 已混入哈希种子),只需保证同一 handler 内不重复。 +- **循环里渲染同一标签的带状态控件**也会自撞(第二次迭代即重复 id)。给每项拼上唯一 ASCII(索引或主键): + +```csharp +foreach (var item in items) + pb.CheckBox($"启用###en-{item.Id}", ref item.Enabled); // 不要写死 "启用" +``` + +> 表格行内的 `row.Label` / `row.ButtonGroup` / `row.Checkbox` 由 `Table` 在原生侧按单元格分配 id,不走 `ImHashStr`,不受此限制。 + +## 表格和标签页 + +```csharp +pb.Table("data", new[] { "Name", "Value", "Action" }, rowCount, (row, i) => +{ + row.Label(names[i]); + row.Label(values[i].ToString()); + if (row.ButtonGroup("", new[] { "Edit", "Del" }, out int act)) { } +}, height: 12, enableSearch: true); + +int tab = pb.TabButtons("Tabs", new[] { "General", "Advanced" }); +``` + +### 程序化导航(选中同步 Tab / 列表滚动) + +`TabButtons` 与 `Table` 支持声明式导航参数,**变化检测在 API 内部完成**,调用方每帧传入当前目标即可: + +```csharp +// 根据业务选中态每帧推导目标 Tab;仅当目标 Tab 变化时才自动切换 +var nav = pb.TabButtons("cmp-nav", navLabels, forceSelect: (int)targetNavKind); + +// 根据当前选中对象在 rows 中的索引每帧传入;仅当索引变化时才滚动列表 +var scrollRow = FindSelectedRowIndex(rows); +pb.Table("cmp-scn-all", headers, rows.Count, (row, i) => { ... }, + height: visibleRows, scrollToRow: scrollRow); +``` + +- `forceSelect` / `scrollToRow` 传 `-1` 表示无目标;会复位 API 内部持久态,便于「取消选中后再选同一项」重新触发。 +- 用户手动切 Tab 或滚动列表后,只要 `forceSelect` / `scrollToRow` 不变,就不会被每帧拉回。 +- 面板置顶仍用 `panel.BringToFront()`(一次性,由应用在选中变更时自行触发)。 + +## 工具栏 + +```csharp +var toolbar = GUI.DeclarePanel() + .ShowTitle(null) + .AsToolbarPanel(40, Panel.ToolbarAnchor.RightTop); + +toolbar.Define(pb => +{ + var tpb = new ToolbarPanelBuilder(pb); + tpb.Label("Tools"); + tpb.Separator(); + if (tpb.Button("Run", distinct: "run")) { } + tpb.PopMenuButton("More", new[] + { + new MenuItem("Option A", () => { }), + new MenuItem("-"), + new MenuItem("Option B", () => { }), + }); +}); +``` + +规则:高度建议 `>= 40`;工具栏无自动换行/横向滚动;项目过多时放进 `PopMenuButton`;要让 toolbar 避让旁边面板,旁边面板必须真的 dock 进 dockspace。 + +## HoverMenu + +`HoverMenu` 用于 3D Workspace 对象 hover 后出现的可点击浮窗。它不是 tooltip:默认按对象屏幕锚点定位,鼠标移向浮窗按钮时不会跟随鼠标逃逸。 + +```csharp +var menu = GUI.DeclareHoverMenu(); +menu.TargetObjectName = "site_a"; +menu.OffsetX = 14; +menu.OffsetY = 14; +menu.CloseDelayMs = 180; +menu.Build = pb => +{ + pb.Label("Site A"); + pb.Separator(); + if (pb.Button("Move", distinct: "site_a")) { } + if (pb.Button("Properties", distinct: "site_a")) { } +}; +menu.StartOnTerminal(terminal); +``` + +规则:一个 object 暂只注册一个 HoverMenu;manager 内部按 `TargetObjectName` 建字典索引,几千个对象时匹配不是线性扫描。`Placement` 默认 `ObjectScreenAnchor`;`Cursor` 只适合一次性定位类场景,不要作为可点击菜单默认模式;`ObjectBounds` 预留,暂不实现。关闭菜单或移除对象时调用 `menu.Stop()` / `Dispose()`。 + +## 菜单栏 + +- `pb.MenuBar(...)`:handler 内的一次性菜单。 +- `new SetMainMenuBar { menu = items, show = true }.IssueToDefault()`:主窗口菜单。 +- `new SetWorkspaceMenuBar { viewport = vp, menu = items, show = true }.Issue()`:viewport 菜单。 +- `new SetPanelMenuBar { panel = p, menu = items }.Issue()`:面板持久菜单。 + +```csharp +pb.MenuBar(new List +{ + new("File", subItems: new() + { + new("Open", () => { }), + new("-"), + new("Exit", () => panel.Exit()), + }), +}); +``` + +完整签名见 `api-signatures.md`。 diff --git a/.cursor/skills/cyclegui-app-development/references/project-setup-and-packaging.md b/.cursor/skills/cyclegui-app-development/references/project-setup-and-packaging.md new file mode 100644 index 0000000..c0d1e89 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/project-setup-and-packaging.md @@ -0,0 +1,151 @@ +# 项目搭建、引用策略和启动流程 + +## 三类项目 + +1. **CycleGUI 同解决方案内示例**:例如 `LearnCycleGUI`,可 `ProjectReference` 到 `CycleGUI\CycleGUI.csproj`。 +2. **宿主/调度程序**:例如 `SimpleLite`,引用真实 `CycleGUI.dll`,用 `Costura.Fody` 把托管程序集包进宿主 Assembly。 +3. **插件或二次开发项目**:默认引用 `RefCycleGUI.dll`,不要引用真实 `CycleGUI.dll`。真实实现由宿主提供,以保护和隔离 CycleGUI 代码资产。 + +## 插件/二开项目模板 + +```xml + + + net8.0 + enable + enable + true + + + + + $(CGUILibDir)RefCycleGUI.dll + false + + + +``` + +`Private=false` 用于避免把 Ref 程序集复制成插件自己的运行时依赖。宿主进程负责提供真实 CycleGUI。 + +## 宿主程序模板 + +```xml + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + $(CGUILibDir)CycleGUI.dll + + +``` + +`FodyWeavers.xml`: + +```xml + + + +``` + +native 依赖如 `libVRender.dll` 按宿主自己的资源、输出目录或加载策略处理,不要把插件二开项目设计成显式部署真实 `CycleGUI.dll`。 + +## 同解决方案示例模板 + +```xml + + + +``` + +只在跟 CycleGUI 源码一起开发和验证的示例中使用该模式。 + +## 常用 using + +```csharp +using CycleGUI; +using CycleGUI.API; +using CycleGUI.Terminals; +using System.Drawing; +using System.Numerics; +``` + +## 本地启动 + +```csharp +Terminal.RegisterRemotePanel(CreateMainPanel); + +LocalTerminal.SetTitle("MyApp"); +LocalTerminal.SetIcon(icoBytes, "MyApp"); +LocalTerminal.AddMenuItem("Exit", LocalTerminal.Terminate); +LocalTerminal.Start(); +GUI.PromptPanel(CreateMainPanel(GUI.defaultTerminal)); +``` + +`Terminal.RegisterRemotePanel` 让 TCP/Web 终端连接后也能拿到欢迎面板。`GUI.PromptPanel` 显示本地默认终端上的主面板。 + +## Web 启动 + +```csharp +Task.Run(() => WebTerminal.Use(port: 8081, ico: icoBytes)); +``` + +可配合 `LeastServer` 发布静态文件和简单 HTTP API。Web 细节见 `terminals-and-web.md`。 + +## 图标设置(exe / 托盘 / Web favicon) + +三处图标**统一用 `.ico` 格式**(Windows 图标;建议内含 16/32/48 多尺寸,需要高清可再加 256)。常见做法是一个 `.ico` 同时用于三处。 + +1. **exe 可执行程序图标**:csproj 里 ``,编译期写入 PE 资源,资源管理器/任务栏看到的就是它。 + +```xml + + res\app_icon.ico + +``` + +2. **LocalTerminal 托盘 / 窗口图标**:`LocalTerminal.SetIcon(byte[] icoBytes, string name)`,传 `.ico` 文件字节,须在 `LocalTerminal.Start()` 之前调用。 + +3. **WebTerminal 浏览器 favicon**:`WebTerminal.Use(int port, byte[] ico = null)`,把同一份 `.ico` 字节作为 `ico:` 传入。 + +`icoBytes` 的两种来源: + +```csharp +// A) 把 .ico 作为嵌入资源(csproj: ),运行时读出 +static byte[] LoadIcon() +{ + var asm = Assembly.GetExecutingAssembly(); + using var s = asm.GetManifestResourceStream( + asm.GetManifestResourceNames().First(p => p.Contains(".ico"))); + return new BinaryReader(s).ReadBytes((int)s.Length); +} + +// B) 直接从磁盘文件读 +byte[] icoBytes = File.ReadAllBytes("res/app_icon.ico"); +``` + +嵌入式(A)适合单文件分发,图标随程序集走、不依赖外部文件;磁盘式(B)适合图标可替换的场景。 + +完整三处一起设置: + +```xml + + + res\app_icon.ico + + + + +``` + +```csharp +var icoBytes = LoadIcon(); +LocalTerminal.SetTitle("MyApp"); +LocalTerminal.SetIcon(icoBytes, "MyApp"); // 托盘 + 窗口 +LocalTerminal.Start(); +WebTerminal.Use(port: 8081, ico: icoBytes); // Web favicon +``` + +> `SetIcon` / `WebTerminal.Use` 只接受 `.ico` 字节,不要传 PNG/JPG 字节。要从 PNG 生成,请先离线转成多尺寸 `.ico`。 diff --git a/.cursor/skills/cyclegui-app-development/references/terminals-and-web.md b/.cursor/skills/cyclegui-app-development/references/terminals-and-web.md new file mode 100644 index 0000000..4fa52c6 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/terminals-and-web.md @@ -0,0 +1,86 @@ +# 终端和 Web + +## 终端类型 + +- `LocalTerminal`:桌面原生窗口,使用 `LocalTerminal.Start()`。 +- `WebTerminal`:浏览器/WebSocket 终端,使用 `WebTerminal.Use(port, ico)`。 +- `TCPTerminal`:TCP 远程终端,使用 `TCPTerminal.Serve(port)`。 + +## 本地窗口 + +```csharp +LocalTerminal.SetTitle("MyApp"); +LocalTerminal.SetIcon(icoBytes, "MyApp"); +LocalTerminal.AddMenuItem("Exit", LocalTerminal.Terminate); +LocalTerminal.Start(); // 或 LocalTerminal.Start(hideAfterInit: true) +GUI.PromptPanel(CreateMainPanel(GUI.defaultTerminal)); +``` + +`LocalTerminal.Start()` 应在 `GUI.PromptPanel` 之前调用。`SetIcon(byte[] icoBytes, string name)` 设置托盘和窗口图标。 + +### 启动后隐藏 libVRender 窗口 / 控制台 + +`LocalTerminal.Start(bool hideAfterInit = false)` 的 `hideAfterInit` 为 `true` 时:libVRender **仍会启动**,首帧渲染后自动隐藏主窗口(`glfwHideWindow`),渲染循环继续;托盘双击可恢复。这是**隐藏**,不是终止渲染后端。 + +控制台隐藏需应用自行处理(例如 Detour/Medulla 用 `ShowWindow(GetConsoleWindow(), SW_HIDE)`),与 `hideAfterInit` 无关。 + +常见做法是把开关放进工作目录 JSON,由静态构造函数或启动代码读取后传入 `LocalTerminal.Start`: + +| 应用 | 配置文件 | 字段 | +| --- | --- | --- | +| DetourLite | `detourconsole.json` | `MinimizeToTray`、`HideConsoleOnStart`、`CPort` | +| Medulla | `medullaconsole.json` | `MinimizeToTray`、`HideConsoleOnStart` | + +示例(启动 libVRender 后立即隐藏主窗口): + +```json +{ + "HideConsoleOnStart": false, + "MinimizeToTray": true +} +``` + +Detour 配置细节见 `detour-configuration` skill;Medulla 见 `medulla-startup-config` skill。 + +## WebTerminal + +```csharp +Terminal.RegisterRemotePanel(CreateMainPanel); +Task.Run(() => WebTerminal.Use(port: 8081, ico: icoBytes)); +``` + +`RegisterRemotePanel` 让 Web/TCP 连接可以创建欢迎面板。`ico:` 是浏览器 favicon。 + +## 图标 icoBytes + +`SetIcon` 和 `WebTerminal.Use(ico:)` 都接受 **`.ico` 文件字节**(不接受 PNG/JPG)。连同 csproj 的 ``(exe 图标),三处统一用一个 `.ico` 即可。`icoBytes` 可从嵌入资源或磁盘读取: + +```csharp +static byte[] LoadIcon() // 嵌入资源:csproj 里 +{ + var asm = Assembly.GetExecutingAssembly(); + using var s = asm.GetManifestResourceStream( + asm.GetManifestResourceNames().First(p => p.Contains(".ico"))); + return new BinaryReader(s).ReadBytes((int)s.Length); +} +// 或:byte[] icoBytes = File.ReadAllBytes("res/app_icon.ico"); +``` + +三处图标(exe / 托盘 / web favicon)的完整设置见 `project-setup-and-packaging.md` 的「图标设置」。 + +## LeastServer + +`WebTerminal.Use()` 内部启动 `LeastServer` 并提供 WebSocket 路由和 webVRender 页面。可增加静态文件和简单 HTTP API: + +```csharp +LeastServer.AddServingFiles("/static", "path/to/htdocs"); +LeastServer.AddGetHandler("/api/status", () => "OK"); +LeastServer.AddPostTextHandler("/api/data", body => ProcessData(body)); +``` + +## 多终端注意事项 + +- `IssueToDefault()` 发给默认终端。 +- `IssueToAllTerminals()` 发给所有终端。 +- `panel.Terminal` 可用于把弹窗、Painter 或操作限定到当前终端。 +- Painter 需要终端隔离时设置 `painter.terminal = specificTerminal`。 diff --git a/.cursor/skills/cyclegui-app-development/references/workspace-interaction.md b/.cursor/skills/cyclegui-app-development/references/workspace-interaction.md new file mode 100644 index 0000000..bc8d80d --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/workspace-interaction.md @@ -0,0 +1,140 @@ +# Workspace 交互、对象选择和坐标拾取 + +## namePattern 语法 + +所有 `namePattern` 使用 glob 语义,不是 regex: + +```csharp +selectOp.SetObjectSelectable("UISite-*"); // 命中 UISite-3 +selectOp.SetObjectSelectable("UISite-.*"); // 错:这不是 regex +``` + +需要正则时,在 C# 侧用 `Regex` 先筛出具体名字,再逐个传具体名字给 CycleGUI API。 + +## 对象选择 + +画布或 Workspace 中的业务对象需要点击、选择、框选或刷选时,优先把它们绘制成 CycleGUI 原生可选对象,然后使用 `SelectObject`。常见做法是用 `PutModelObject`、`PutPointCloud`、`PutHandleIcon`、`PutStraightLine`,或其它可被 `SetObjectSelectable` 命中的 Workspace/Painter 对象来承载可视化主体。 + +无特殊情况,不要用 `GetPosition` 读取鼠标坐标再手写命中测试来选择画布对象。`GetPosition` 只用于自由坐标拾取、放置点、测量、绘制轮廓、吸附到点位等确实需要返回坐标的位置交互。 + +Painter 更适合调试绘制和非交互 overlay;需要被用户点击选择的业务对象,应尽量有对应的原生可选对象作为主表示。 + +```csharp +var selectOp = new SelectObject { fineSelectOnPointClouds = true }; +selectOp.feedback = (result, op) => +{ + foreach (var (name, selector, firstSub) in result) + Console.WriteLine(name); +}; +selectOp.Start(); +selectOp.SetSelectionMode(SelectObject.SelectionMode.Click); +selectOp.SetObjectSelectable("UISite-*"); +``` + +`SetSelectionMode` 支持 `Click`、`Rectangle`、`Paint`。 + +修饰键语义:Shift + 左键追加选择,Alt + 左键从选择集中移除,左键点击空白清空选择。不要重新发明 Ctrl 多选;Ctrl 留给其它操作。 + +## sticky pattern + +`SetObjectSelectable(pattern)` 是 sticky: + +- 调用时对已存在对象做一次 glob 匹配并设为可选。 +- 之后新增对象时自动按该 pattern 匹配并设为可选。 +- 不需要在每次 `Workspace.AddProp` 后重复调用。 + +取消 sticky 必须用同一个 pattern 字符串: + +```csharp +selectOp.SetObjectUnselectable("UISite-*"); +``` + +`SetObjectUnselectable("UISite-3")` 只清具体对象,不会移除 `"UISite-*"` 的 sticky 规则。 + +## 拾取世界坐标 + +```csharp +var gp = new GetPosition +{ + method = GetPosition.PickMode.GridPlane, + snaps = new[] { "UISite-*" }, + realtime = true, +}; + +gp.feedback = (wp, _) => +{ + var xy = new Vector2(wp.mouse_pos.X, wp.mouse_pos.Y); +}; +gp.finished = () => { }; +gp.terminated = () => { }; +gp.Start(); +``` + +`realtime = true` 时,鼠标移动也会触发 `feedback`,适合用 Painter 做橡皮筋预览;最终左键点击时再触发 `finished`。 + +## SelectObject 和 GetPosition 互斥 + +Workspace 输入由当前 UI operation 消费。进入放置/拾取模式前先 `selectOp.End()`;结束后再创建并启动新的 `SelectObject`。sticky pattern 是 per-op 状态,重启选择 op 后需要重新设置 pattern。 + +## async/await 包装 + +把 `WorkspaceUIOperation` 包成 `Task` 时,`feedback` 只更新数据或 `TrySetResult`,不要在 `feedback` 后半段重启新 op 或做全局清理。清理放到调用方 `try/finally`。 + +推荐模式: + +```csharp +private static Task PickPoint(State st, Action? onPreview = null) +{ + var tcs = new TaskCompletionSource(); + Vector2 last = default; + bool got = false; + + var gp = new GetPosition { realtime = onPreview != null }; + gp.feedback = (wp, _) => + { + last = new Vector2(wp.mouse_pos.X, wp.mouse_pos.Y); + got = true; + onPreview?.Invoke(last); + }; + gp.finished = () => tcs.TrySetResult(got ? last : null); + gp.terminated = () => tcs.TrySetResult(null); + gp.Start(); + st.CurrentOp = gp; + return tcs.Task; +} +``` + +业务取消按钮必须显式唤醒 pending task: + +```csharp +st.CurrentOp?.End(); +st.PendingTcs?.TrySetResult(null); +``` + +`End()` 不会触发 `terminated`。 + +## Guizmo 自由度限制 + +`GuizmoAction` 使用 `dof` 声明允许的平移/旋转自由度。CycleGUI 是 Z-up 语义,`Yaw` 等价于 `RotateZ`,常用于地面 XY 平面编辑。 + +```csharp +new GuizmoAction +{ + dof = GuizmoAction.GuizmoDof.PlanarXYYaw, + realtimeResult = true, + feedback = (items, _) => + { + foreach (var (name, pos, rot) in items) + Console.WriteLine($"{name}: {pos}, {rot}"); + }, +}.Start(); +``` + +常用组合: + +- `TranslateXYZ`:只允许三轴平移。 +- `PlanarXY`:只允许 X/Y 平面平移。 +- `PlanarXYYaw`:只允许 X/Y 平移和绕 Z 轴旋转。 +- `Yaw`:只允许绕 Z 轴旋转。 +- `RotateXYZ`:只允许三轴旋转。 +- `All`:允许三轴平移和三轴旋转。 diff --git a/.cursor/skills/cyclegui-app-development/references/workspace-scene-and-painter.md b/.cursor/skills/cyclegui-app-development/references/workspace-scene-and-painter.md new file mode 100644 index 0000000..f60dcb1 --- /dev/null +++ b/.cursor/skills/cyclegui-app-development/references/workspace-scene-and-painter.md @@ -0,0 +1,129 @@ +# Workspace 场景、Viewport 和 Painter + +## 颜色契约 + +CycleGUI 公开颜色字段统一使用 `System.Drawing.Color`。不要把 ARGB `uint` 直接塞给颜色字段;旧 wire-format 用 `Extensions.FromRgba8(uint)` 转回 `Color`。 + +## 模型加载和实例 + +```csharp +Workspace.AddProp(new LoadModel +{ + name = "robot_class", + detail = new Workspace.ModelDetail(File.ReadAllBytes("robot.glb")) + { + Scale = 0.01f, + Center = Vector3.Zero, + } +}); + +Workspace.AddProp(new PutModelObject +{ + name = "robot_1", + clsName = "robot_class", + newPosition = new Vector3(1, 0, 0), + newQuaternion = Quaternion.Identity, +}); +``` + +用 `TransformObject` 更新位置和姿态: + +```csharp +Workspace.AddProp(new TransformObject +{ + name = "robot_1", + pos = new Vector3(2, 0, 0), + quat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 4), + timeMs = 500, +}); +``` + +## 点云 + +```csharp +Workspace.AddProp(new PutPointCloud +{ + name = "scan", + xyzSzs = points.Select(p => new Vector4(p, 3f)).ToArray(), + colors = points.Select(_ => Color.White).ToArray(), + newPosition = Vector3.Zero, +}); +``` + +`xyzSzs` 的 `Vector4.W` 是点大小。颜色数组使用 `Color[]`。 + +## 线、文本和对象删除 + +常用 prop 可在 `api-signatures.md` 查询。删除对象可保留 prop 引用调用 `Remove()`,或使用 name pattern 删除。 + +```csharp +myProp.Remove(); +WorkspaceProp.RemoveNamePattern("robot_*"); +``` + +name pattern 是 glob,不是 regex。 + +## 相机和外观 + +```csharp +new SetCamera +{ + lookAt = Vector3.Zero, + azimuth = -MathF.PI / 2, + altitude = MathF.PI / 4, + distance = 10f, + fov = 45f, + projectionMode = SetCamera.ProjectionMode.Perspective, +}.IssueToAllTerminals(); +``` + +```csharp +new SetAppearance +{ + useEDL = true, + useSSAO = true, + useGround = true, + drawGroundGrid = true, + drawGuizmo = true, + useDefaultSky = true, +}.IssueToDefault(); +``` + +## Viewport + +```csharp +var vp = GUI.PromptWorkspaceViewport( + panel => panel.ShowTitle("Aux View"), + closeEvent: () => true); + +vp.OnClosing(() => true); +``` + +不传 `closeEvent` 时不显示关闭按钮。 + +## Painter + +Painter 用于调试绘制,数据按帧刷新,不持久化为业务对象。 + +```csharp +var p = Painter.GetPainter("debug"); +p.Clear(); +p.DrawDot(Color.Red, new Vector3(1, 0, 0), size: 3f); +p.DrawLine(Color.Yellow, start, end, width: 2f, arrow: Painter.ArrowType.End); +p.DrawVector(origin, direction, Color.Green, width: 1f, pixels: 20); +p.DrawText(Color.White, new Vector3(0, 0, 1), "Label"); +p.DrawRegion3D(Color.Blue, center); +``` + +同名 Painter 复用同一层。需要限定终端时设置 `painter.terminal = terminal`。 + +## 填充多边形 + +```csharp +p.DrawPolygonFilled(points2D, translation, rotation, + borderColor: Color.White, + fillColor: Color.FromArgb(128, Color.Blue), + thickness: 0.1f); +``` + +更多 prop 和 Painter 签名见 `api-signatures.md`。 diff --git a/.gitignore b/.gitignore index 62717c2..2450829 100644 --- a/.gitignore +++ b/.gitignore @@ -364,4 +364,6 @@ FodyWeavers.xsd build/ /build/SimpleComposer.exe /build/plugins/StandardScene.dll -/.cursor +/.cursor/* +!/.cursor/skills/ +!/.cursor/skills/**