Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,51 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and

## Unreleased

## 2.2.6.post1 — 2026-10-20
## 2.2.6.post1 — 2026-10-21

### Fixed

- Editor: the parameter panel reads `scad.var(name="j1", default=0.0, ...)` — the keyword
form — as well as `scad.var("j1", 0.0, ...)`. Before, only the second positional argument
was taken as the default, so keyword declarations never showed up as parameters.
- Add-ons: `[compat] sca` ranges and the add-on commands accept a post-release version such as
`2.2.6.post1` (it sorts after `2.2.6` and before `2.2.7`); the parser rejected it.

### Changed

Cache and runtime speed, measured on the desktop six-axis arm (cold 80.5 s to 34.6 s, no
change 23.6 s to 4.0 s, one joint edited 18.2 s to 3.0 s, cache 1.7 GB to 96 MB):

- Restoring a cached shape is cheaper: adding a child to a shape no longer refreshes its
whole subtree's tag cache unless a tag can pass down, and a restore finds entities by
position and refreshes each tag cache once. A restored shape is then built when something
first uses it, so a cached value that no running cell reads is never built; pickling it
again hands back what it was restored from.
- The cell cache stores less and compresses it: no values for a cell's private (`_name`)
variables, geometry bytes and cache entries zlib-compressed (level 1). Entries written
before are still read.
- `scad.var(...)` values and the expressions made from them are keyed by content, so a cell
that reads one parameter is no longer redone because another one, defined in the same cell,
changed (marimo keyed any object it cannot hash by the code of the cell that made it).
- A cell that does `from lib.dims import A` depends on the definition of `A` (and what it
reads, across local modules), not on the whole file. A module the analysis cannot prove
(calls or loops at top level, star imports, a name defined twice, a plain
`import lib.dims`) still depends on the whole file.
- A cell that runs a notebook with `use("child.py")` (a literal path) is keyed by what that
child is (its text, the modules it imports, the notebooks it uses in turn) instead of by
everything a run recorded: editing one child redoes the cell that uses it and not those
that use its siblings, and the editor kernel caches such cells too (it ran them live
before). A path computed at run time still depends on what a run recorded.
- The `use` calls a cell is about to make (literal path; arguments made of values it reads,
`float(...)`, `notebook_id()`) are started together in worker processes, which fill the
notebooks' caches; the cell then finds them built. OpenCASCADE keeps the interpreter lock,
so these are processes, not threads. `SCA_JOBS` sets how many (default: the smaller of 4
and the cores; 1 turns it off). Workers are only started when the builds they take off the
cell's hands are worth the seconds a worker takes to start (a notebook remembers how long
it last took to build), and a notebook that has not changed since it last ran is not
started at all. A notebook's state directory is locked between processes while it runs.
- Editor: a slider drag is sent when it pauses (250 ms) or ends, not step by step; clicks and
number-box commits still go at once.

## 2.2.6 — 2026-10-19

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ the full crank cycle sweeps clean in the same sim ·

- **Keyword `scad.var` is a parameter again.** `scad.var(name="j1", default=0.0)` is read
by the editor's parameter panel like the positional form; before, it was skipped.
- **A cached run costs much less.** On the desktop arm: cold 80 s to 35 s, nothing changed
24 s to 4 s, one joint edited 18 s to 3 s, cache 1.7 GB to 96 MB. Restored shapes are built
on first use, parameters and imported names are keyed by value, and the `use` calls of a
cell run in worker processes (`SCA_JOBS`, 1 turns it off).
- **Slider drags are sent when they settle**, not step by step.

Full notes for this version: [`docs/updates/2.2.6.post1.md`](docs/updates/2.2.6.post1.md).
Earlier versions: the [changelog](CHANGELOG.md) and [`docs/updates/`](docs/updates/).
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真

- **关键字写法的 `scad.var` 重新被识别为参数。** `scad.var(name="j1", default=0.0)` 现在和
位置参数写法一样出现在参数面板;之前会被跳过。
- **缓存运行便宜得多。** 桌面机械臂:冷启动 80 秒到 35 秒,什么都没改 24 秒到 4 秒,改一个关节 18 秒到 3 秒,
缓存 1.7 GB 到 96 MB。形状在第一次用到时才构建,参数和导入的名字按值定键,cell 里的 `use` 调用在 worker
进程里并行(`SCA_JOBS`,设为 1 关闭)。
- **滑块拖动在停下来时才发送**,不再逐步发送。

本版本的完整说明:[`docs/updates/2.2.6.post1.zh-CN.md`](docs/updates/2.2.6.post1.zh-CN.md)。
更早的版本:见[更新日志](CHANGELOG.md)和 [`docs/updates/`](docs/updates/)。
Expand Down
31 changes: 30 additions & 1 deletion docs/updates/2.2.6.post1.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 2.2.6.post1 Update Notes

A hotfix for the editor's parameter panel.
A hotfix for the editor's parameter panel, and a round of speed work on the notebook cache
and on `use`, measured on the desktop six-axis arm.

## `scad.var(name=..., default=...)` is a parameter again

Expand All @@ -13,3 +14,31 @@ J1 = scad.var(name="j1", default=0.0, comment="Joint 1")

— was skipped: no slider, no commit back to the file. Both forms are read now, and
committing a value rewrites the right argument in either.

## What a cached run costs

| desktop arm | before | now |
|---|---|---|
| cold run | 80.5 s | 34.6 s |
| nothing changed | 23.6 s | 4.0 s |
| one joint angle edited | 18.2 s | 3.0 s |
| a link length edited | 35.1 s | 19 s |
| cache on disk | 1.7 GB | 96 MB |

- **Restoring is cheaper.** A restored shape is built when something first uses it, so a
cached value no running cell reads costs nothing; the tag caches are rebuilt once, not
once per ancestor. The cache no longer stores a cell's private (`_name`) values and
compresses what it writes; entries from before are still read.
- **Parameters are keyed by value.** A cell that reads one `scad.var` is not redone because a
neighbour defined in the same cell changed.
- **A name, not a file.** `from lib.dims import A` depends on the definition of `A`.
- **`use` is followed one call at a time.** A cell that uses a notebook by a literal path
depends on that notebook (its text, the modules it imports, the notebooks it uses): editing
one child redoes the cell that uses it, not its siblings. The editor kernel caches such
cells too.
- **`use` calls run side by side.** The `use` calls a cell is about to make are started in
worker processes (threads would not help: OpenCASCADE keeps the interpreter lock) and the
cell finds them built. `SCA_JOBS` sets the number (default: the smaller of 4 and the
cores; `1` turns it off). Workers are started only when that saves more than they take to
start, and a notebook is locked between processes while it runs.
- **Editor sliders.** A drag is sent when it pauses (250 ms) or ends, not step by step.
23 changes: 22 additions & 1 deletion docs/updates/2.2.6.post1.zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 2.2.6.post1 更新说明

编辑器参数面板的热修复。
编辑器参数面板的热修复,以及一轮针对 notebook 缓存和 `use` 的速度优化,数据来自桌面六轴机械臂。

## `scad.var(name=..., default=...)` 重新被识别为参数

Expand All @@ -11,3 +11,24 @@ J1 = scad.var(name="j1", default=0.0, comment="Joint 1")
```

——会被跳过:没有滑块,也无法写回文件。现在两种写法都能读取,提交数值时也会改写对应的那个参数。

## 缓存运行的代价

| 桌面机械臂 | 之前 | 现在 |
|---|---|---|
| 冷启动 | 80.5 s | 34.6 s |
| 什么都没改 | 23.6 s | 4.0 s |
| 改一个关节角 | 18.2 s | 3.0 s |
| 改一个连杆长度 | 35.1 s | 19 s |
| 磁盘缓存 | 1.7 GB | 96 MB |

- **恢复更便宜。** 恢复出来的形状在第一次被用到时才构建,没有 cell 读取的缓存值不花时间;标签缓存只重建一次,
不再每个祖先一次。缓存不再存 cell 私有(`_name`)变量的值,并压缩写入的内容;旧条目照样能读。
- **参数按值定键。** 读取某个 `scad.var` 的 cell,不会因为同一个 cell 里的邻居参数变化而重跑。
- **按名字而不是按文件。** `from lib.dims import A` 只依赖 `A` 的定义。
- **`use` 按调用逐个跟踪。** 用字面量路径 `use` 一个 notebook 的 cell 依赖那个 notebook(文本、它导入的模块、
它再 `use` 的 notebook):改一个子 notebook 只重跑用它的 cell,不动它的兄弟。编辑器内核也缓存这类 cell。
- **`use` 并行。** cell 即将发出的 `use` 调用交给 worker 进程同时构建(线程没用:OpenCASCADE 不释放解释器锁),
cell 随后发现已经建好。`SCA_JOBS` 设置数量(默认 4 与核数的较小值;`1` 关闭)。只有省下的时间大于 worker
启动成本才会启动,并且 notebook 运行期间其状态目录在进程间加锁。
- **编辑器滑块。** 拖动在停顿 250 ms 或松开时才发送,不再逐步发送。
24 changes: 12 additions & 12 deletions editor-web/src/components/CellEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl";
import { createSettler, SETTLE_MS } from "@/lib/settle";
import { cadTheme } from "@/lib/cm-theme";
import { sdkCompletion } from "@/lib/sdk-complete";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -205,17 +206,16 @@ export function CellEditor({
);
};

// Forward slider bursts; the server coalesces (latest-wins rebuild).
const pendingParams = useRef(new Map<string, ParamValue>());
const paramTimer = useRef<number | null>(null);
const forwardParam = (id: string, value: ParamValue) => {
pendingParams.current.set(id, value);
if (paramTimer.current !== null) return;
paramTimer.current = window.setTimeout(() => {
paramTimer.current = null;
socket?.send({ type: "param.set", values: Object.fromEntries(pendingParams.current) });
pendingParams.current.clear();
}, 60);
// A drag is sent when it pauses or ends, not step by step: every value the
// server gets is a rebuild.
const socketRef = useRef(socket);
socketRef.current = socket;
const settler = useRef(
createSettler<ParamValue>(SETTLE_MS, (values) => socketRef.current?.send({ type: "param.set", values })),
);
const forwardParam = (id: string, value: ParamValue, now = false) => {
settler.current.push(id, value);
if (now) settler.current.flush();
};

// Disarm delete when clicking anywhere outside the armed button.
Expand Down Expand Up @@ -364,7 +364,7 @@ function CellBlock({
live: boolean;
armed: boolean;
socket: SocketLike;
forwardParam: (id: string, value: ParamValue) => void;
forwardParam: (id: string, value: ParamValue, now?: boolean) => void;
onToggle: () => void;
onRun: () => void;
onInterrupt: () => void;
Expand Down
27 changes: 17 additions & 10 deletions editor-web/src/components/ModuleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { createSettler, SETTLE_MS } from "@/lib/settle";
import { sdkCompletion } from "@/lib/sdk-complete";

/** A local module of the workspace (shared dimensions, helpers): what the
Expand Down Expand Up @@ -41,8 +42,17 @@ export function ModuleView({
const [draft, setDraft] = useState(module.code);
const dirty = useRef(false);
const saveTimer = useRef<number | null>(null);
const paramTimer = useRef<number | null>(null);
const pending = useRef(new Map<string, ParamValue>());
const socketRef = useRef(socket);
socketRef.current = socket;
const pathRef = useRef(module.path);
pathRef.current = module.path;
const settler = useRef(
createSettler<ParamValue>(SETTLE_MS, (values) =>
socketRef.current?.send({ type: "module.param", path: pathRef.current, values }),
),
);
// values of the module that was left are not the next one's
useEffect(() => () => settler.current.cancel(), [module.path]);

// A different module, an external edit or a parameter edit replaces the
// text; our own autosave echo must not clobber what is being typed.
Expand All @@ -68,14 +78,11 @@ export function ModuleView({
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;
paramTimer.current = window.setTimeout(() => {
paramTimer.current = null;
socket?.send({ type: "module.param", path: module.path, values: Object.fromEntries(pending.current) });
pending.current.clear();
}, 120);
// A drag is sent when it pauses or ends, not step by step: every value the
// server gets is a rebuild.
const forward = (id: string, value: ParamValue, now = false) => {
settler.current.push(id, value);
if (now) settler.current.flush();
};

const groups = new Map<string, ParamMeta[]>();
Expand Down
26 changes: 21 additions & 5 deletions editor-web/src/components/ParamControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ export function ParamControl({
forwardParam,
}: {
param: ParamMeta;
forwardParam: (id: string, value: ParamValue) => void;
/** `now`: send at once (a click, a release) instead of when the changes settle. */
forwardParam: (id: string, value: ParamValue, now?: boolean) => void;
}) {
// A drag shows its own value: the server only hears of it when it settles,
// so the value it echoes back cannot move the thumb meanwhile.
const [drag, setDrag] = useState<number | null>(null);
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setDrag(null);
}, [param.value]);
const label = param.label || param.id;
const title = [param.comment, ...(param.issues ?? [])].filter(Boolean).join("\n") || undefined;
const ink = param.modified ? "text-focus" : "text-name";
Expand All @@ -42,7 +50,7 @@ export function ParamControl({
type="button"
className="flex items-center justify-between gap-2 text-left text-[11px]"
title={title}
onClick={() => forwardParam(param.id, !on)}
onClick={() => forwardParam(param.id, !on, true)}
>
<span className={ink}>{label}</span>
<span className={cn("border-2 px-1.5 text-[10px] font-bold uppercase", on ? "border-primary bg-primary text-primary-foreground" : "border-input text-muted-foreground")}>
Expand All @@ -51,7 +59,7 @@ export function ParamControl({
</button>
);
}
const value = Number(param.value);
const value = drag ?? Number(param.value);
return (
<div title={title}>
<div className="mb-1 flex items-baseline justify-between gap-2 text-[11px]">
Expand All @@ -63,7 +71,7 @@ export function ParamControl({
min={param.min}
max={param.max}
step={param.step ?? (param.kind === "int" ? 1 : null)}
onCommit={(next) => forwardParam(param.id, next)}
onCommit={(next) => forwardParam(param.id, next, true)}
/>
) : (
<span>{value}</span>
Expand All @@ -78,7 +86,15 @@ export function ParamControl({
max={param.max}
modified={param.modified}
step={param.step ?? (param.kind === "int" ? 1 : (param.max - param.min) / 200)}
onValueChange={([next]) => forwardParam(param.id, next)}
onValueChange={([next]) => {
dragging.current = true;
setDrag(next);
forwardParam(param.id, next);
}}
onValueCommit={([next]) => {
dragging.current = false;
forwardParam(param.id, next, true);
}}
/>
)}
</div>
Expand Down
70 changes: 70 additions & 0 deletions editor-web/src/lib/settle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Run with: node --test src/lib/settle.test.ts (Node >= 22.6 strips the types)
import assert from "node:assert/strict";
import { mock, test } from "node:test";

import { createSettler } from "./settle.ts";

function setup(delay = 250) {
mock.timers.enable({ apis: ["setTimeout"] });
const sent: Record<string, number>[] = [];
const settler = createSettler<number>(delay, (values) => sent.push(values));
return { sent, settler };
}

test("a burst of changes is sent once, after it stops", () => {
const { sent, settler } = setup();
for (let step = 1; step <= 20; step++) {
settler.push("j1", step);
mock.timers.tick(100); // faster than the delay: the drag never pauses
}
assert.equal(sent.length, 0);
mock.timers.tick(250);
assert.deepEqual(sent, [{ j1: 20 }]);
mock.reset();
});

test("a pause in the middle of a drag sends what is there", () => {
const { sent, settler } = setup();
settler.push("j1", 5);
mock.timers.tick(300);
settler.push("j1", 9);
mock.timers.tick(300);
assert.deepEqual(sent, [{ j1: 5 }, { j1: 9 }]);
mock.reset();
});

test("flush sends at once and cancels the wait", () => {
const { sent, settler } = setup();
settler.push("j1", 7);
settler.flush();
assert.deepEqual(sent, [{ j1: 7 }]);
mock.timers.tick(1000);
assert.equal(sent.length, 1);
mock.reset();
});

test("the latest value of each parameter wins, and all of them go together", () => {
const { sent, settler } = setup();
settler.push("j1", 1);
settler.push("j2", 2);
settler.push("j1", 3);
settler.flush();
assert.deepEqual(sent, [{ j1: 3, j2: 2 }]);
mock.reset();
});

test("flush with nothing pending sends nothing", () => {
const { sent, settler } = setup();
settler.flush();
assert.equal(sent.length, 0);
mock.reset();
});

test("cancel drops what is pending", () => {
const { sent, settler } = setup();
settler.push("j1", 1);
settler.cancel();
mock.timers.tick(1000);
assert.equal(sent.length, 0);
mock.reset();
});
Loading