docs: 添加 cyclegui-app-development 项目技能

将 CycleGUI 开发技能(含 Duplicated id 防碰撞规范)纳入 .cursor/skills,并调整 gitignore 仅放行 skills 目录可提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-30 22:38:18 +08:00
co-authored by Cursor
parent 1724ea57f6
commit 158d770c54
12 changed files with 2149 additions and 1 deletions
@@ -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<Row, int> content, int height = 0,
bool enableSearch = false, string title = "",
bool freezeFirstCol = false, int scrollToRow = -1,
Action<int, bool>? 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<TEnum>(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<MenuItem> menu)
```
MenuItem 构造:
```csharp
new MenuItem(string label, Action onClick = null, string shortcut = null,
bool selected = false, bool enabled = true, List<MenuItem> 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<T>(CycleGUIProber<T> 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 panelC# 侧按 `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<byte[]> // 返回更新函数
.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<T>`,通过 `.Start()` 启动,`.End()` 结束,`.feedback` 回调接收结果。
```csharp
// 拾取世界坐标
GetPosition {
method: PickMode.GridPlane / Holo3D,
snaps: string[], // 可吸附对象名
}
// feedback: Action<WorldPosition, op>
// 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<FollowFeedback, op>
// 对象选择
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_patternssticky2026-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, op>
// 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<MenuItem> { ... }, show = true
}.IssueToDefault();
// Viewport 专属菜单栏
var viewport = GUI.PromptWorkspaceViewport();
new SetWorkspaceMenuBar {
viewport = viewport,
menu = new List<MenuItem> { ... }, show = true
}.Issue();
// 面板持久菜单栏(从 handler 外部设置,自动注入到每次重绘)
new SetPanelMenuBar {
panel = myPanel,
menu = new List<MenuItem> { ... }
}.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 则广播到所有终端
```
---
## LeastServerHTTP/WebSocket 服务)
全局类(无命名空间),维护者核对路径:`D:\MDCS\Source\Core\CycleGUI\CycleGUI\Terminals\LeastServer.cs`
```csharp
static void Listener(int port) // 阻塞式启动监听
static void AddGetHandler(string path, Func<string> handler)
static void AddPostTextHandler(string path, Func<string, string> handler)
static void AddPostByteHandler(string path, Func<byte[], byte[]> 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 状态与主终端隔离。
@@ -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<Panel> 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<Vector4>();
var gridColors = new List<uint>();
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();
```
## 示例 7Painter 实时调试绘制
```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();
```
## 示例 10Medulla2 风格的 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: <EmbeddedResource Include="app_icon.ico" />exe 图标另用 <ApplicationIcon>
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);
}
```
## 示例 13Painter 填充多边形
```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);
```
## 示例 14Medulla2 插件结构
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
<Reference Include="RefMedullaCore">
<HintPath>$(ReleaseDir)\RefMedullaCore.dll</HintPath>
</Reference>
```
输出到插件目录:
```xml
<OutputPath>$(DebugDir)\plugins</OutputPath>
```
加载方式(在 .iocmd 脚本中):
```
sensor = io load plugins/MyPlugin.dll
mySensor = sensor init sensor_a COM3 115200
```
@@ -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 暂只注册一个 HoverMenumanager 内部按 `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<MenuItem>
{
new("File", subItems: new()
{
new("Open", () => { }),
new("-"),
new("Exit", () => panel.Exit()),
}),
});
```
完整签名见 `api-signatures.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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<Reference Include="CycleGUI">
<HintPath>$(CGUILibDir)RefCycleGUI.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
```
`Private=false` 用于避免把 Ref 程序集复制成插件自己的运行时依赖。宿主进程负责提供真实 CycleGUI。
## 宿主程序模板
```xml
<ItemGroup>
<PackageReference Include="Costura.Fody" Version="5.7.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<Reference Include="CycleGUI">
<HintPath>$(CGUILibDir)CycleGUI.dll</HintPath>
</Reference>
</ItemGroup>
```
`FodyWeavers.xml`
```xml
<Weavers>
<Costura />
</Weavers>
```
native 依赖如 `libVRender.dll` 按宿主自己的资源、输出目录或加载策略处理,不要把插件二开项目设计成显式部署真实 `CycleGUI.dll`
## 同解决方案示例模板
```xml
<ItemGroup>
<ProjectReference Include="..\..\CycleGUI\CycleGUI.csproj" />
</ItemGroup>
```
只在跟 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 里 `<ApplicationIcon>`,编译期写入 PE 资源,资源管理器/任务栏看到的就是它。
```xml
<PropertyGroup>
<ApplicationIcon>res\app_icon.ico</ApplicationIcon>
</PropertyGroup>
```
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: <EmbeddedResource Include="app_icon.ico" />),运行时读出
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
<!-- .csproj -->
<PropertyGroup>
<ApplicationIcon>res\app_icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="res\app_icon.ico" />
</ItemGroup>
```
```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`。
@@ -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` skillMedulla 见 `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 的 `<ApplicationIcon>`exe 图标),三处统一用一个 `.ico` 即可。`icoBytes` 可从嵌入资源或磁盘读取:
```csharp
static byte[] LoadIcon() // 嵌入资源:csproj 里 <EmbeddedResource Include="app_icon.ico" />
{
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`
@@ -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<Vector2?> PickPoint(State st, Action<Vector2>? onPreview = null)
{
var tcs = new TaskCompletionSource<Vector2?>();
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`:允许三轴平移和三轴旋转。
@@ -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`