diff --git a/CHANGELOG.md b/CHANGELOG.md index 1604527..989b14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +## 2.2.2 — 2026-10-14 + +### Added + +- Editor: code completion from the SDK itself. `scad.` (and `scad.ql.`, `scad.std.gear.`, `ql.`) + lists the real functions with their signatures and docstrings; accepting one, or typing `(` + after a known name, writes the call with every required parameter as `name=` and Tab moves + between them; inside a call the parameters not given yet complete as `name=` (required first). + The names come from the running package (`GET /api/sdk`), so they cannot drift from the code. +- Editor: a **new** action in the workspace tab starts a notebook from the SDK's template (a part, + or an assembly that uses the first two parts of the workspace), with the name checked as you + type. An empty workspace opens on that form, so `sca edit` on an empty directory is usable. + +### Fixed + +- Editor: the cell editors rebuilt their configuration on every render (a new `onChange` and + `basicSetup` each time), which dropped any snippet in progress and could disturb typing; both + are stable now. + ## 2.2.1 — 2026-10-13 The first stable release of the 2.2 line (2.2.0b1 was a pre-release): everything diff --git a/README.md b/README.md index ad8d5cd..430b377 100644 --- a/README.md +++ b/README.md @@ -149,37 +149,24 @@ the full crank cycle sweeps clean in the same sim · --- -## Update Notes (2.2.1) +## Update Notes (2.2.2) > Validate generated definitions, assembly constraints, and manufacturing > geometry before production use. -- **New editor.** `sca edit` is part of the SDK now (`pip install - "simplecadapi[editor]"`). It runs a workspace's notebooks in a marimo kernel - with the SDK's own cell executor, previews the live model straight from the - solids, writes your viewport picks as selection code, and lets an agent see - what you see (`sca snapshot`). **Re-Studio is removed**: the reverse- - engineering studio (the `Re-mode` workspace of the standalone sca-web-editor) - is gone, and the standalone sca-web-editor is replaced by `sca edit`. STEP - reconstruction continues through the inspection API and the skill's - step-reconstruction workflow. -- **One runtime, one cache.** The editor, `sca run` and - `simplecadapi.runtime.run_notebook()` share the per-cell cache, so what one - computed the others restore. Notebook parameters come from `# ---- params - ----` cells with an optional `# ui:` comment; local modules (shared - dimensions) get their own view in the editor. -- **Landing path.** `docs/landing.md` is the script an agent follows to install - the SDK and its skill and open an example; `sca example u_link` copies the - bundled workspace and `sca skill where` shows where the skill goes. -- Earlier: 2.1.3b1 shipped the addon ecosystem (`sca addon ...`, the - `sca-addon.toml` descriptor, the `.scadpkg` format spec); see its - [update notes](docs/updates/2.1.3b1.md). - -Every example model is a notebook; its export scripts load the product with -`run_notebook`, capture one `.scadpkg`, and translate AP242 `.step` and -editable `.FCStd` from that same package. The AP242/Gmsh example under -`examples/ap242_gmsh_volume_mesh/` exposes each export and FEM stage as a -separate directly runnable script. +- **Code completion from the SDK.** In the editor, `scad.` (and `scad.ql.`, + `scad.std.gear.`) lists the real functions with their signatures and + docstrings. Accepting one, or typing `(` after its name, writes the call with + every required parameter as `name=`, and Tab moves between them; inside a call + the parameters not given yet complete as `name=`. +- **New notebooks from the workspace tab.** A **new** action starts a part, or + an assembly that uses the first two parts, from the SDK's template; an empty + workspace opens on that form. +- **Fix.** The cell editors no longer rebuild their configuration on every + render, which dropped a snippet in progress. + +Full notes for this version: [`docs/updates/2.2.2.md`](docs/updates/2.2.2.md). +Earlier versions: the [changelog](CHANGELOG.md) and [`docs/updates/`](docs/updates/). --- @@ -199,7 +186,7 @@ in a compact public API for creating solids, applying features, tagging semantic intent, querying topology, exporting manufacturing files, and translating recorded models into FreeCAD workflows. -Current release: `simplecadapi==2.2.1`. +Current release: `simplecadapi==2.2.2`. ## What It Provides @@ -714,6 +701,7 @@ and the [STEP reconstruction workflow](docs/skill/references/workflows/step-reco ## Documentation +- 2.2.2 update notes: [`docs/updates/2.2.2.md`](docs/updates/2.2.2.md) - 2.2.1 update notes: [`docs/updates/2.2.1.md`](docs/updates/2.2.1.md) - 2.1.3b1 update notes: [`docs/updates/2.1.3b1.md`](docs/updates/2.1.3b1.md) - 2.1.2 update notes: [`docs/updates/2.1.2.md`](docs/updates/2.1.2.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 161501c..801891d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -133,30 +133,19 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 --- -## 更新日志(2.2.1) +## 更新日志(2.2.2) > 投入生产前请验证生成的定义、装配约束和制造几何。 -- **新编辑器。** `sca edit` 现在是 SDK 的一部分(`pip install - "simplecadapi[editor]"`)。它用 SDK 自己的 cell 执行器在 marimo 内核里运行工作 - 区的 notebook,直接由实体生成实时预览,把你在视口里的点选写成选择代码,并让 - agent 看到你看到的(`sca snapshot`)。**Re-Studio 已移除**:逆向工程 studio - (独立 sca-web-editor 的 `Re-mode` 工作区)不再存在,独立的 sca-web-editor 由 - `sca edit` 取代。STEP 重建继续通过检查 API 和 skill 的 step-reconstruction - 工作流进行。 -- **一个运行时,一份缓存。** 编辑器、`sca run` 和 - `simplecadapi.runtime.run_notebook()` 共用逐 cell 缓存,一方算过的另一方直接 - 复用。notebook 参数来自 `# ---- params ----` cell,可加 `# ui:` 注释指定控件; - 本地模块(共用尺寸)在编辑器里有独立视图。 -- **落地路径。** `docs/landing.md` 是 agent 安装 SDK 和 skill、打开示例时遵循的 - 脚本;`sca example u_link` 复制内置工作区,`sca skill where` 显示 skill 的安装位置。 -- 之前:2.1.3b1 带来插件生态(`sca addon ...`、`sca-addon.toml` 描述文件、 - `.scadpkg` 格式规范),见[更新说明](docs/updates/2.1.3b1.zh-CN.md)。 - -每个示例模型都是 notebook;导出脚本用 `run_notebook` 载入产品,捕获一个 -`.scadpkg`,再从同一个产品包转换出 AP242 `.step` 和可编辑 `.FCStd`。 -`examples/ap242_gmsh_volume_mesh/` 下的 AP242/Gmsh 示例把每个导出和 FEM 阶段 -都做成可直接运行的独立脚本。 +- **来自 SDK 的代码补全。** 在编辑器里,输入 `scad.`(以及 `scad.ql.`、`scad.std.gear.`) + 会列出真实存在的函数、签名和文档说明。选中一个函数,或在函数名后输入 `(`,会把每个必填 + 参数写成 `name=`,Tab 在它们之间跳转;在调用括号内,还没写的参数会补全成 `name=`。 +- **在工作区页面创建 notebook。** 新增的 **new** 按 SDK 模板创建一个零件,或一个使用前两个 + 零件的装配;空工作区会直接打开这个表单。 +- **修复。** cell 编辑器不再每次渲染都重建配置,这会让进行中的占位跳转丢失。 + +本版本的完整说明:[`docs/updates/2.2.2.zh-CN.md`](docs/updates/2.2.2.zh-CN.md)。 +更早的版本:见[更新日志](CHANGELOG.md)和 [`docs/updates/`](docs/updates/)。 --- @@ -173,7 +162,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 SimpleCADAPI 是一个基于 OCP 的 Python CAD SDK,提供清晰的函数式建模操作和可重放的模型图。它在 OpenCascade 几何内核之上提供精简的公共 API,可用于创建实体、应用特征、添加语义标签、查询拓扑、导出制造文件,以及将记录的模型转换为 FreeCAD 工作流。 -当前版本:`simplecadapi==2.2.1`。 +当前版本:`simplecadapi==2.2.2`。 ## 核心能力 @@ -599,6 +588,7 @@ uv run --extra fem python examples/ap242_gmsh_volume_mesh/run_calculix.py ## 文档 +- 2.2.2 更新说明:[`docs/updates/2.2.2.zh-CN.md`](docs/updates/2.2.2.zh-CN.md) - 2.2.1 更新说明:[`docs/updates/2.2.1.zh-CN.md`](docs/updates/2.2.1.zh-CN.md) - 2.1.3b1 更新说明:[`docs/updates/2.1.3b1.zh-CN.md`](docs/updates/2.1.3b1.zh-CN.md) - 2.1.2 更新说明:[`docs/updates/2.1.2.zh-CN.md`](docs/updates/2.1.2.zh-CN.md) diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 9cd131c..81dc3a8 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.1 + version: 2.2.2 package-name: simplecadapi - package-version: 2.2.1 + package-version: 2.2.2 --- # SimpleCAD SDK Skill diff --git a/docs/updates/2.2.2.md b/docs/updates/2.2.2.md new file mode 100644 index 0000000..bea6c78 --- /dev/null +++ b/docs/updates/2.2.2.md @@ -0,0 +1,27 @@ +# 2.2.2 Update Notes + +Two additions to the code-first editor (`sca edit`), and a fix that made one of them usable. + +## Code completion from the SDK + +`scad.` (and `scad.ql.`, `scad.std.gear.`, `ql.`, `Q.`) lists the real functions with their +signatures; the side panel shows every parameter with its default and the docstring. + +- Accepting a function, or typing `(` after a known name, writes the call with every + **required** parameter as `name=`: `scad.make_box_rsolid(width=, height=, depth=)`. + **Tab** moves to the next parameter. +- Inside a call, the parameters not given yet complete as `name=`, required ones first. +- The names come from the running package (`GET /api/sdk`), so they cannot drift from the code. + +## Create a notebook from the workspace tab + +A **new** action in the notebooks header starts a notebook from the SDK's template: a part, or an +assembly that uses the first two parts of the workspace. The name is checked as you type. An empty +workspace opens on that form, so `sca edit` on an empty directory is usable. + +## Fixed + +The cell editors rebuilt their configuration on every render, which dropped a snippet in progress +and could disturb typing; the editor's `onChange` and options are stable now. + +See the [changelog](../../CHANGELOG.md) for the complete list. diff --git a/docs/updates/2.2.2.zh-CN.md b/docs/updates/2.2.2.zh-CN.md new file mode 100644 index 0000000..c2592d9 --- /dev/null +++ b/docs/updates/2.2.2.zh-CN.md @@ -0,0 +1,25 @@ +# 2.2.2 更新说明 + +`sca edit` 编辑器的两项新增,以及一个让其中一项能正常使用的修复。 + +## 来自 SDK 的代码补全 + +输入 `scad.`(以及 `scad.ql.`、`scad.std.gear.`、`ql.`、`Q.`)会列出真实存在的函数和签名; +右侧面板显示每个参数、默认值和文档说明。 + +- 选中一个函数,或在已知函数名后输入 `(`,会把每个**必填**参数写成 `name=`: + `scad.make_box_rsolid(width=, height=, depth=)`。**Tab** 跳到下一个参数。 +- 在调用括号内,还没写的参数会补全成 `name=`,必填的排在最前。 +- 名字来自正在运行的包(`GET /api/sdk`),不会和代码不一致。 + +## 在工作区页面创建 notebook + +notebooks 标题处新增 **new**,按 SDK 模板创建 notebook:一个零件,或一个使用工作区前两个零件的装配。 +边输入边检查名字。空工作区会直接打开这个表单,所以对空目录运行 `sca edit` 也能用了。 + +## 修复 + +cell 编辑器每次渲染都会重建配置,导致进行中的占位跳转丢失,也可能干扰输入;现在编辑器的 +`onChange` 和选项都是稳定的。 + +完整列表见[更新日志](../../CHANGELOG.md)。 diff --git a/editor-web/package-lock.json b/editor-web/package-lock.json index 378d381..64fa773 100644 --- a/editor-web/package-lock.json +++ b/editor-web/package-lock.json @@ -8,8 +8,10 @@ "name": "simplecad-editor-web", "version": "0.0.0", "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/language": "^6.13.1", + "@codemirror/state": "^6.7.6", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", diff --git a/editor-web/package.json b/editor-web/package.json index ab30b07..2b2afbd 100644 --- a/editor-web/package.json +++ b/editor-web/package.json @@ -11,8 +11,10 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/language": "^6.13.1", + "@codemirror/state": "^6.7.6", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index 2d481e4..a19d286 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -9,7 +9,7 @@ import { ModuleView, type ModuleState } from "@/components/ModuleView"; import { NotebookList } from "@/components/NotebookList"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { fetchPreview, getWorkspace, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; +import { fetchPreview, getWorkspace, newNotebook, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; import { parsePreview } from "@/lib/preview"; import { EnvelopeSocket, type Envelope } from "@/lib/envelope"; import { useRunLog } from "@/lib/runlog"; @@ -118,12 +118,14 @@ export function App() { socket.on("hello", (envelope) => { setNotebook((envelope["notebook"] as string) ?? null); setCurrentPath((envelope["path"] as string) ?? null); + // an empty workspace: the way forward is the workspace tab's "new" + if (!envelope["notebook"]) setLeftTab("workspace"); setKernelAlive(Boolean(envelope["kernel"])); // No notebook: nothing to wait for. Else the model is on its way. if (hasModel.current) return; const stage = envelope["notebook"] ? (envelope["kernel"] ? "building the model" : "starting the kernel") : null; setLoading(stage); - if (stage) setStatus(`${stage[0].toUpperCase()}${stage.slice(1)}…`); + setStatus(stage ? `${stage[0].toUpperCase()}${stage.slice(1)}…` : "No notebook yet: create one in the workspace tab"); setMarimoUrl((envelope["marimo_url"] as string) ?? null); refreshWorkspace(); }); @@ -312,6 +314,13 @@ export function App() { }); }; + const handleCreate = async (name: string, kind: "part" | "assembly") => { + setStatus(`creating ${name}…`); + await newNotebook(name, kind); + // the server opens it: show its cells + setLeftTab("notebook"); + }; + return (
socketRef.current?.send({ type: "module.open", path })} + onCreate={handleCreate} /> {moduleView && ( diff --git a/editor-web/src/components/CellEditor.tsx b/editor-web/src/components/CellEditor.tsx index f1581cb..7b87b23 100644 --- a/editor-web/src/components/CellEditor.tsx +++ b/editor-web/src/components/CellEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { python } from "@codemirror/lang-python"; import { EditorView } from "@codemirror/view"; @@ -20,6 +20,7 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl"; import { cadTheme } from "@/lib/cm-theme"; +import { sdkCompletion } from "@/lib/sdk-complete"; import { cn } from "@/lib/utils"; export type NotebookCell = { @@ -49,7 +50,9 @@ export type { ParamMeta, ParamValue }; type SocketLike = { send: (message: Record) => void } | null; const AUTOSAVE_MS = 700; -const EXTENSIONS = [python(), EditorView.lineWrapping]; +const EXTENSIONS = [python(), EditorView.lineWrapping, sdkCompletion]; +/** a constant: a new object each render makes the editor wrapper rebuild its configuration, which drops a snippet in progress */ +const BASIC_SETUP = { lineNumbers: false, foldGutter: false, highlightActiveLine: false }; function loadCollapsed(notebook: string | null): Set { try { @@ -317,6 +320,10 @@ function CellBlock({ onDelete: () => void; onEdit: (value: string) => void; }) { + // the editor takes this once: a new function each render would rebuild its configuration + const onEditRef = useRef(onEdit); + onEditRef.current = onEdit; + const handleChange = useCallback((value: string) => onEditRef.current(value), []); const failed = cell.status === "exception" || cell.status === "error" || (cell.errors?.length ?? 0) > 0; const stale = cell.status === "stale"; const dirty = draft !== undefined && draft !== cell.code; @@ -411,8 +418,8 @@ function CellBlock({ extensions={EXTENSIONS} theme={cadTheme} editable={!cell.readonly} - basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }} - onChange={onEdit} + basicSetup={BASIC_SETUP} + onChange={handleChange} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); diff --git a/editor-web/src/components/ModuleView.tsx b/editor-web/src/components/ModuleView.tsx index 4dc7c04..2f12221 100644 --- a/editor-web/src/components/ModuleView.tsx +++ b/editor-web/src/components/ModuleView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { python } from "@codemirror/lang-python"; import { EditorView } from "@codemirror/view"; @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl"; import { cadTheme } from "@/lib/cm-theme"; +import { sdkCompletion } from "@/lib/sdk-complete"; /** A local module of the workspace (shared dimensions, helpers): what the * server sends as the `module` envelope. */ @@ -21,7 +22,9 @@ export type ModuleState = { }; const AUTOSAVE_MS = 700; -const EXTENSIONS = [python(), EditorView.lineWrapping]; +const EXTENSIONS = [python(), EditorView.lineWrapping, sdkCompletion]; +/** a constant: a new object each render makes the editor wrapper rebuild its configuration, which drops a snippet in progress */ +const BASIC_SETUP = { lineNumbers: true, foldGutter: false, highlightActiveLine: false }; /** A module open next to the notebooks: its dataclass fields as controls, its * source below. Every edit is written to the file at once, and every notebook @@ -60,6 +63,11 @@ export function ModuleView({ }, AUTOSAVE_MS); }; + // the editor takes this once: a new function each render would rebuild its configuration + const editRef = useRef(edit); + editRef.current = edit; + const handleChange = useCallback((value: string) => editRef.current(value), []); + const forward = (id: string, value: ParamValue) => { pending.current.set(id, value); if (paramTimer.current !== null) return; @@ -124,8 +132,8 @@ export function ModuleView({ height="auto" extensions={EXTENSIONS} theme={cadTheme} - basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: false }} - onChange={edit} + basicSetup={BASIC_SETUP} + onChange={handleChange} />
diff --git a/editor-web/src/components/NotebookList.tsx b/editor-web/src/components/NotebookList.tsx index 0551015..21e6d05 100644 --- a/editor-web/src/components/NotebookList.tsx +++ b/editor-web/src/components/NotebookList.tsx @@ -1,11 +1,18 @@ +import { Plus } from "lucide-react"; +import { useRef, useState } from "react"; + import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import type { ModuleRow, NotebookRow } from "@/lib/api"; +/** Names the template accepts: the notebook's id and its file name. */ +const NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/; + /** The workspace: its SimpleCAD notebooks (assemblies show what they use) and * the local modules they import. Editing a module changes every notebook - * that imports it. */ + * that imports it. A new notebook starts from the SDK's template. */ export function NotebookList({ notebooks, current, @@ -13,6 +20,7 @@ export function NotebookList({ openModule, onOpen, onOpenModule, + onCreate, }: { notebooks: NotebookRow[]; /** Workspace-relative path of the open notebook. */ @@ -21,13 +29,27 @@ export function NotebookList({ openModule: string | null; onOpen: (path: string) => void; onOpenModule: (path: string) => void; + /** Write a new notebook and open it; rejects with the reason it could not. */ + onCreate: (name: string, kind: "part" | "assembly") => Promise; }) { - if (notebooks.length === 0 && modules.length === 0) { - return

No SimpleCAD notebooks in this workspace.

; - } + const [adding, setAdding] = useState(false); + const empty = notebooks.length === 0; return ( -

notebooks

+
+ notebooks + +
+ {(adding || empty) && ( + setAdding(false)} onCancel={() => setAdding(false)} /> + )}
    {notebooks.map((row) => (
  • @@ -82,3 +104,105 @@ export function NotebookList({ ); } + +/** The form for a new notebook: a name and what it is. */ +function NewEntry({ + notebooks, + first, + onCreate, + onDone, + onCancel, +}: { + notebooks: NotebookRow[]; + /** The workspace has no notebook yet. */ + first: boolean; + onCreate: (name: string, kind: "part" | "assembly") => Promise; + onDone: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [kind, setKind] = useState<"part" | "assembly">("part"); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const input = useRef(null); + const parts = notebooks.filter((row) => row.kind === "part"); + const taken = (value: string) => notebooks.some((row) => row.path === `${value}.py`); + + const submit = async () => { + const value = name.trim(); + if (!NAME.test(value)) return setError("Use letters, digits, - and _, starting with a letter or _."); + if (taken(value)) return setError(`${value}.py already exists.`); + setBusy(true); + setError(null); + try { + await onCreate(value, kind); + onDone(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not create the notebook."); + } finally { + setBusy(false); + } + }; + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > + {first &&

    An empty workspace. Start with a part: a notebook that builds one solid.

    } + { + setName(event.target.value); + setError(null); + }} + onKeyDown={(event) => event.key === "Escape" && !first && onCancel()} + placeholder="name, e.g. bracket" + aria-label="Name of the new notebook" + spellCheck={false} + className="w-full border-2 border-input bg-transparent px-2 py-1 focus:border-focus focus:outline-none" + /> +
    + {(["part", "assembly"] as const).map((option) => ( + + ))} +
    + {kind === "assembly" && parts.length > 0 && ( +

    + Starts with {parts.slice(0, 2).map((row) => row.name).join(" and ")}; change what it uses in its cells. +

    + )} + {error &&

    {error}

    } +
    + + {!first && ( + + )} +
    +
    + ); +} diff --git a/editor-web/src/lib/api.ts b/editor-web/src/lib/api.ts index 8509c6c..d7e601f 100644 --- a/editor-web/src/lib/api.ts +++ b/editor-web/src/lib/api.ts @@ -47,13 +47,16 @@ export function openNotebook(path: string): Promise<{ ok: boolean }> { }).then((response) => json<{ ok: boolean }>(response)); } -/** Provision a new entry notebook from the server template and open it. */ -export function newNotebook(name: string, kind: "part" | "assembly"): Promise<{ created: string }> { - return fetch("/api/workspace/new", { +/** Provision a new entry notebook from the server template and open it. Rejects with the server's reason (a bad or taken name). */ +export async function newNotebook(name: string, kind: "part" | "assembly"): Promise<{ created: string }> { + const response = await fetch("/api/workspace/new", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, kind }), - }).then((response) => json<{ created: string }>(response)); + }); + const body = (await response.json().catch(() => null)) as { created?: string; error?: string } | null; + if (!response.ok || !body?.created) throw new Error(body?.error ?? `${response.status} ${response.statusText}`); + return { created: body.created }; } /** What the viewport shows (PNG data URL), for coding agents to look at. */ diff --git a/editor-web/src/lib/cm-theme.ts b/editor-web/src/lib/cm-theme.ts index 8ca5599..9df73ae 100644 --- a/editor-web/src/lib/cm-theme.ts +++ b/editor-web/src/lib/cm-theme.ts @@ -22,10 +22,29 @@ const theme = EditorView.theme( color: "var(--popover-foreground)", border: "2px solid var(--border)", }, + ".cm-tooltip-autocomplete": { fontSize: "12px" }, + ".cm-tooltip-autocomplete > ul": { fontFamily: "var(--mono)", maxHeight: "16em" }, + ".cm-tooltip-autocomplete ul li": { padding: "2px 8px" }, ".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: "var(--primary)", color: "var(--primary-foreground)", }, + ".cm-completionDetail": { color: "var(--muted-foreground)", fontStyle: "normal", marginLeft: "1em" }, + ".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionDetail": { color: "inherit", opacity: "0.75" }, + ".cm-completionMatchedText": { textDecoration: "none", color: "var(--focus)", fontWeight: "700" }, + ".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionMatchedText": { color: "inherit" }, + ".cm-completionIcon": { opacity: "0.6" }, + ".cm-completionInfo": { + backgroundColor: "var(--popover)", + color: "var(--popover-foreground)", + border: "2px solid var(--border)", + padding: "6px 8px", + maxWidth: "420px", + }, + ".sca-api-signature": { color: "var(--name)", wordBreak: "break-word" }, + ".sca-api-doc": { marginTop: "4px", color: "var(--muted-foreground)" }, + // the parameter to fill in next + ".cm-snippetField": { backgroundColor: "var(--code-selection)", outline: "1px solid var(--focus)" }, }, { dark: false }, ); diff --git a/editor-web/src/lib/sdk-complete.ts b/editor-web/src/lib/sdk-complete.ts new file mode 100644 index 0000000..756b8f8 --- /dev/null +++ b/editor-web/src/lib/sdk-complete.ts @@ -0,0 +1,208 @@ +import { acceptCompletion, snippet, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete"; +import { pythonLanguage } from "@codemirror/lang-python"; +import { Prec, type Extension } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; + +/** + * Code completion from the SDK itself. The server describes the running package (GET /api/sdk: + * every public function and class with its parameters); from that: + * + * - `scad.` lists the functions (also `scad.ql.`, `scad.std.gear.`, and `ql.` / `Q.`); + * - accepting one, or typing `(` after a known name, writes the call with every required + * parameter as `name=` and moves between them with Tab; + * - inside a call, the parameters not given yet complete as `name=` (required ones first). + */ + +interface Param { + n: string; + r: boolean; + /** "p" positional only, "a" the *args parameter, "w" the **kwargs one, "" an ordinary one */ + k: "p" | "a" | "w" | ""; + d?: string; +} +interface Entry { + n: string; + k: "f" | "c"; + p: Param[]; + d: string; +} +interface SdkIndex { + namespaces: Record; + submodules: Record; +} + +let index: SdkIndex | null = null; +let loading: Promise | null = null; +const load = (): Promise => + (loading ??= fetch("/api/sdk") + .then((response) => (response.ok ? (response.json() as Promise) : null)) + .then((data) => (index = data)) + .catch(() => null)); + +/** how a name written in a cell reaches a namespace of the index */ +const HEADS: Record = { scad: "scad", ql: "scad.ql", Q: "scad.ql" }; +const namespaceOf = (path: string): string | null => { + const [head, ...rest] = path.split("."); + const base = HEADS[head]; + return base ? [base, ...rest].join(".") : null; +}; + +/** `scad.ql.faces` before the cursor: the namespace path and the name being typed */ +const MEMBER = /(? { + const space = namespaceOf(path); + return (space && index?.namespaces[space]?.find((entry) => entry.n === name)) || null; +}; + +const escapeSnippet = (text: string) => text.replace(/[\\${}]/g, "\\$&"); + +/** `(width=${1}, height=${2})`: every parameter that has to be written, in order */ +export const callTemplate = (entry: Entry): string => { + let slot = 0; + const args = entry.p + .filter((param) => param.k !== "w" && (param.r || param.k === "a")) + .map((param) => { + slot += 1; + // positional ones cannot be named: a hint the person overwrites + return param.k === "p" || param.k === "a" ? `\${${slot}:${escapeSnippet(param.n)}}` : `${param.n}=\${${slot}}`; + }); + return `(${args.length ? args.join(", ") : "${}"})`; +}; + +const signature = (entry: Entry): string => { + const required = entry.p.filter((param) => param.r || param.k === "a").map((param) => (param.k === "a" ? `*${param.n}` : param.n)); + const more = entry.p.some((param) => !param.r && param.k !== "a" && param.k !== "w"); + return `(${required.join(", ")}${more ? (required.length ? ", …" : "…") : ""})`; +}; + +const info = (entry: Entry) => () => { + const root = document.createElement("div"); + const call = document.createElement("div"); + call.className = "sca-api-signature"; + call.textContent = + entry.n + + "(" + + entry.p.map((param) => (param.k === "a" ? "*" : param.k === "w" ? "**" : "") + param.n + (param.d !== undefined ? `=${param.d}` : "")).join(", ") + + ")"; + root.append(call); + if (entry.d) { + const doc = document.createElement("div"); + doc.className = "sca-api-doc"; + doc.textContent = entry.d; + root.append(doc); + } + return root; +}; + +const entryCompletion = (entry: Entry): Completion => ({ + label: entry.n, + type: entry.k === "c" ? "class" : "function", + detail: signature(entry), + info: info(entry), + apply: (view, completion, from, to) => { + // editing an existing call: only the name + if (view.state.sliceDoc(to, to + 1) === "(") { + view.dispatch({ changes: { from, to, insert: entry.n }, selection: { anchor: from + entry.n.length } }); + return; + } + snippet(`${escapeSnippet(entry.n)}${callTemplate(entry)}`)(view, completion, from, to); + }, +}); + +/** `scad.` + the typed start of a name */ +const memberSource = async (context: CompletionContext): Promise => { + const line = context.state.doc.lineAt(context.pos); + const match = MEMBER.exec(line.text.slice(0, context.pos - line.from)); + if (!match) return null; + const space = namespaceOf(match[1]); + if (!space) return null; + const data = index ?? (await load()); + if (!data || (!data.namespaces[space] && !data.submodules[space])) return null; + const options: Completion[] = [ + ...(data.namespaces[space] ?? []).map(entryCompletion), + ...(data.submodules[space] ?? []).map((name): Completion => ({ label: name, type: "namespace", detail: "module" })), + ]; + return { from: context.pos - match[2].length, options, validFor: /^\w*$/ }; +}; + +/** the innermost call the cursor is in: its function, and what is written between the parentheses */ +const enclosingCall = (text: string, pos: number): { path: string; name: string; args: string } | null => { + let depth = 0; + for (let i = pos - 1; i >= Math.max(0, pos - 4000); i--) { + const ch = text[i]; + if (ch === ")" || ch === "]" || ch === "}") depth++; + else if (ch === "(" || ch === "[" || ch === "{") { + if (depth > 0) depth--; + else if (ch !== "(") return null; + else { + const named = /(? { + let depth = 0; + let out = ""; + for (const ch of args) { + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") depth = Math.max(0, depth - 1); + else if (depth === 0) out += ch; + } + return out; +}; + +/** inside `scad.f(`: the parameters not given yet, as `name=` */ +const kwargSource = async (context: CompletionContext): Promise => { + const word = context.matchBefore(/\w*/); + if (!word || (word.from === word.to && !context.explicit)) return null; + const text = context.state.sliceDoc(0, context.pos); + const call = enclosingCall(text, context.pos); + if (!call) return null; + const data = index ?? (await load()); + if (!data) return null; + const entry = lookup(call.path, call.name); + if (!entry) return null; + const top = topLevel(call.args); + const segments = top.split(","); + // only where an argument starts: not after `name=` and not inside an expression + if (!/^\s*\w*$/.test(segments[segments.length - 1])) return null; + const given = new Set([...top.matchAll(/(?])\b([A-Za-z_]\w*)\s*=(?!=)/g)].map((m) => m[1])); + const positional = segments.slice(0, -1).filter((segment) => segment.trim() && !/(?])=(?!=)/.test(segment)).length; + const free = entry.p.filter((param) => param.k !== "a" && param.k !== "w"); + const options: Completion[] = free + .filter((param, k) => !given.has(param.n) && k >= positional && param.k !== "p") + .map((param) => ({ + label: `${param.n}=`, + type: "property", + detail: param.r ? "required" : param.d !== undefined ? `default ${param.d}` : "", + // above Python's own names (bool, ...): inside this call these are what is wanted + boost: param.r ? 99 : 50, + })); + return options.length ? { from: word.from, options, validFor: /^\w*$/ } : null; +}; + +/** `(` typed after a known function: write the call with its required parameters */ +const parenthesis = EditorView.inputHandler.of((view, from, to, text) => { + if (text !== "(" || from !== to || !index) return false; + const line = view.state.doc.lineAt(from); + const match = MEMBER.exec(line.text.slice(0, from - line.from)); + const entry = match && match[2] ? lookup(match[1], match[2]) : null; + if (!entry || view.state.sliceDoc(to, to + 1) === "(") return false; + snippet(callTemplate(entry))(view, null, from, to); + return true; +}); + +export const sdkCompletion: Extension = [ + pythonLanguage.data.of({ autocomplete: memberSource }), + pythonLanguage.data.of({ autocomplete: kwargSource }), + Prec.high(parenthesis), + // Tab takes the highlighted suggestion (else it moves to the next parameter of a call) + Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])), +]; + +void load(); diff --git a/pyproject.toml b/pyproject.toml index 91a9e5e..461392f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.1" +version = "2.2.2" description = "A simplified OCP-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/simplecadapi/editor/apiindex.py b/src/simplecadapi/editor/apiindex.py new file mode 100644 index 0000000..d811e5d --- /dev/null +++ b/src/simplecadapi/editor/apiindex.py @@ -0,0 +1,120 @@ +"""The SDK's public API as plain data, for the editor's code completion. + +The cell editor completes ``scad.``, and a call's parameters as ``name=`` placeholders, from +this index. It is read from the live package (``inspect``), so it is always the API of the +version that is running and cannot drift from the code. Namespaces are the places a notebook +reaches names through: ``scad`` (the package), ``scad.ql`` and ``scad.std.``. +""" + +from __future__ import annotations + +import functools +import inspect +import types +from typing import Any + +import simplecadapi + +_KINDS = { + inspect.Parameter.POSITIONAL_ONLY: "p", + inspect.Parameter.POSITIONAL_OR_KEYWORD: "", + inspect.Parameter.KEYWORD_ONLY: "", + inspect.Parameter.VAR_POSITIONAL: "a", + inspect.Parameter.VAR_KEYWORD: "w", +} +_DOC_LIMIT = 240 +_DEFAULT_LIMIT = 40 + + +def _summary(obj: Any) -> str: + """The first paragraph of the docstring, on one line.""" + + doc = inspect.getdoc(obj) or "" + first = doc.strip().split("\n\n", 1)[0] + text = " ".join(first.split()) + return text if len(text) <= _DOC_LIMIT else text[: _DOC_LIMIT - 1].rstrip() + "…" + + +def _parameters(obj: Any) -> list[dict[str, Any]] | None: + try: + signature = inspect.signature(obj) + except (TypeError, ValueError): + return None + rows: list[dict[str, Any]] = [] + for parameter in signature.parameters.values(): + if parameter.name == "self" or parameter.name.startswith("_"): + continue + required = parameter.default is inspect.Parameter.empty and parameter.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + row: dict[str, Any] = {"n": parameter.name, "r": required, "k": _KINDS[parameter.kind]} + if parameter.default is not inspect.Parameter.empty and not str(parameter.default).startswith("<"): + text = repr(parameter.default) + row["d"] = text if len(text) <= _DEFAULT_LIMIT else text[: _DEFAULT_LIMIT - 1] + "…" + rows.append(row) + return rows + + +def _entry(name: str, obj: Any) -> dict[str, Any] | None: + if inspect.isclass(obj): + kind = "c" + elif inspect.isfunction(obj) or inspect.isbuiltin(obj): + kind = "f" + else: + return None + parameters = _parameters(obj) + if parameters is None: + return None + return {"n": name, "k": kind, "p": parameters, "d": _summary(obj)} + + +def _ours(obj: Any) -> bool: + """Defined by the SDK itself, not a typing helper or another library re-exported.""" + + module = getattr(obj, "__module__", "") or "" + return module == "simplecadapi" or module.startswith("simplecadapi.") + + +def _entries(module: types.ModuleType, names: list[str] | None = None, *, own: bool = False) -> list[dict[str, Any]]: + """Entries for the callables of *module*; with *own*, only those defined in that module (not re-exports).""" + + rows = [] + for name in sorted(names if names is not None else dir(module)): + if name.startswith("_"): + continue + obj = getattr(module, name, None) + if obj is None or isinstance(obj, types.ModuleType) or not _ours(obj): + continue + if own and not (getattr(obj, "__module__", "") or "").startswith(module.__name__): + continue + row = _entry(name, obj) + if row is not None: + rows.append(row) + return rows + + +@functools.lru_cache(maxsize=1) +def build_index() -> dict[str, Any]: + """``{"namespaces": {path: [entry]}, "submodules": {path: [name]}}``. + + An entry is ``{"n": name, "k": "f"|"c", "p": [parameter], "d": summary}``; a parameter is + ``{"n": name, "r": required, "k": "p"|"a"|"w"|"", "d"?: default}`` (``p`` positional only, + ``a`` the ``*args`` one, ``w`` the ``**kwargs`` one). + """ + + from simplecadapi import ql, std + + namespaces: dict[str, list[dict[str, Any]]] = { + "scad": _entries(simplecadapi, list(getattr(simplecadapi, "__all__", dir(simplecadapi)))), + "scad.ql": _entries(ql, own=True), + } + std_modules = sorted( + name for name in dir(std) if not name.startswith("_") and isinstance(getattr(std, name), types.ModuleType) + ) + for name in std_modules: + module = getattr(std, name) + namespaces[f"scad.std.{name}"] = _entries(module, list(getattr(module, "__all__", dir(module))), own=True) + submodules = {"scad": ["ql", "std"], "scad.std": std_modules} + return {"version": getattr(simplecadapi, "__version__", ""), "namespaces": namespaces, "submodules": submodules} diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 35d48ce..7c5054f 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -1365,6 +1365,12 @@ async def latest_package(request: Request) -> Response: ) return JSONResponse({"error": "no package made yet"}, status_code=404) + async def sdk_index(request: Request) -> JSONResponse: + """The SDK's public API (names and parameters), for the cell editor's completion.""" + from .apiindex import build_index + + return JSONResponse(build_index(), headers={"Cache-Control": "max-age=3600"}) + async def workspace_list(request: Request) -> JSONResponse: """The SimpleCAD notebooks of the workspace.""" current = state.notebook.relative_to(state.workspace).as_posix() if state.notebook else None @@ -1484,6 +1490,7 @@ async def ws_endpoint(websocket) -> None: Route("/api/preview/latest", latest_preview, methods=["GET"]), Route("/api/runtime/package", push_package, methods=["POST"]), Route("/api/package/latest", latest_package, methods=["GET"]), + Route("/api/sdk", sdk_index, methods=["GET"]), Route("/api/workspace", workspace_list, methods=["GET"]), Route("/api/workspace/open", workspace_open, methods=["POST"]), Route("/api/workspace/new", workspace_new, methods=["POST"]), diff --git a/test/test_editor.py b/test/test_editor.py index f0021c0..d747b84 100644 --- a/test/test_editor.py +++ b/test/test_editor.py @@ -823,3 +823,61 @@ def test_a_running_cell_is_stopped_and_the_kernel_stays_usable(self) -> None: if kernel is not None: kernel.stop() server.should_exit = True + + +class ApiIndexTest(unittest.TestCase): + """The SDK's API as data, for the cell editor's completion.""" + + def setUp(self) -> None: + from simplecadapi.editor.apiindex import build_index + + self.index = build_index() + self.scad = {entry["n"]: entry for entry in self.index["namespaces"]["scad"]} + + def test_it_is_plain_json_and_small(self) -> None: + text = json.dumps(self.index, separators=(",", ":")) + self.assertLess(len(text), 400_000) + + def test_a_function_lists_its_parameters_and_which_are_required(self) -> None: + box = self.scad["make_box_rsolid"] + self.assertEqual(box["k"], "f") + required = [p["n"] for p in box["p"] if p["r"]] + self.assertEqual(required, ["width", "height", "depth"]) + optional = {p["n"]: p for p in box["p"] if not p["r"]} + self.assertIn("bottom_face_center", optional) + self.assertEqual(optional["bottom_face_center"]["d"], "(0, 0, 0)") + self.assertTrue(box["d"]) # the first line of its docstring + + def test_star_args_are_marked_and_not_required(self) -> None: + cut = self.scad["cut_rsolid"] + solids = next(p for p in cut["p"] if p["n"] == "solids") + self.assertEqual((solids["k"], solids["r"]), ("a", False)) + + def test_classes_list_their_constructor_without_private_fields(self) -> None: + part = self.scad["Part"] + self.assertEqual(part["k"], "c") + names = [p["n"] for p in part["p"]] + self.assertEqual([p["n"] for p in part["p"] if p["r"]], ["part_id", "body"]) + self.assertFalse([n for n in names if n.startswith("_")]) + + def test_namespaces_and_submodules(self) -> None: + namespaces = self.index["namespaces"] + self.assertIn("faces", {e["n"] for e in namespaces["scad.ql"]}) + self.assertIn("make_spur_gear_rsolid", {e["n"] for e in namespaces["scad.std.gear"]}) + self.assertEqual(self.index["submodules"]["scad"], ["ql", "std"]) + self.assertIn("gear", self.index["submodules"]["scad.std"]) + # nothing private, and the errors ql re-exports are not ql functions + for entries in namespaces.values(): + self.assertFalse([e["n"] for e in entries if e["n"].startswith("_")]) + self.assertNotIn("SimpleCADError", {e["n"] for e in namespaces["scad.ql"]}) + + def test_the_server_serves_it(self) -> None: + from starlette.testclient import TestClient + + from simplecadapi.editor.server import create_app + + with tempfile.TemporaryDirectory() as tmp: + app = create_app(WorkspaceState(Path(tmp))) + response = TestClient(app).get("/api/sdk") + self.assertEqual(response.status_code, 200) + self.assertIn("scad", response.json()["namespaces"]) diff --git a/uv.lock b/uv.lock index 7d795ef..6650fff 100644 --- a/uv.lock +++ b/uv.lock @@ -3227,7 +3227,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.1" +version = "2.2.2" source = { editable = "." } dependencies = [ { name = "cadquery-ocp" },