BREP_app 0.16.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! The CAM workbench: setups, tools, operations and NC programs in the
//! document's `cam` block (`document::cam`, on the shared `brep_cam::model`),
//! and the pane's selection. These commands are the operations `panels::cam`
//! performs, on the same helpers, so an agent authors the same document a
//! person does. Generation, dialogs and target picking arrive with their rows
//! (lane C1 rows 3-7); until then `cam_generate` says so rather than
//! succeeding at nothing.
use crate::automation::command::{app_command, parse_args, schema_of, CommandSpec, Ctx, Empty, NoArgs, Outcome};
use crate::automation::cmd_document::parse;
use crate::panels::cam;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NamedArgs {
    /// The record's name; a default (`Setup N` / `Tool N`) is minted when absent.
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AddOperationArgs {
    /// The setup the operation goes into (`cam_state` lists them).
    pub setup: String,
    /// The tool it uses (`cam_state` lists them).
    pub tool: String,
    /// The strategy kind (`cam_state` lists them under `strategies`:
    /// `parallel`, `contour3D`, `pocket2D`, `drill`, …); every parameter
    /// starts at its default, which the operation dialog edits.
    pub strategy: String,
    /// The operation's name; `<strategy> N` when absent.
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AddProgramArgs {
    /// The program's name; `Program N` when absent.
    #[serde(default)]
    pub name: Option<String>,
    /// The post-processor's id (a built-in name or a plugin script id);
    /// empty takes the default post of the first operation's setup machine.
    #[serde(default)]
    pub post: String,
    /// The operations to post, in order; each must exist.
    #[serde(default)]
    pub operations: Vec<String>,
    /// Post property values by name (a built-in post's names and enum
    /// values are checked); absent ones take the post's defaults.
    #[serde(default)]
    pub properties: std::collections::BTreeMap<String, Value>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateProgramArgs {
    /// The NC program (`NCn`).
    pub id: String,
    /// Post properties to set; a `null` value clears one back to the post's default.
    #[serde(default)]
    pub properties: std::collections::BTreeMap<String, Value>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OpenArgs {
    /// The setup whose dialog to open; absent closes the open one.
    #[serde(default)]
    pub id: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateSetupArgs {
    pub id: String,
    /// A patch of the setup form's keys (`name`, `wcsNumber`, `zRef` | `zDir` (a direction: `+X`, `-Y`, ... or
    /// `x,y,z`, for a tilted setup), `flipZ`, `xRef`, `originRef`,
    /// `stockKind` + `stockX`/`stockY`/`stockTop`/`stockBottom` | `stockMin`/`stockMax` |
    /// `stockBase`/`stockAxis`/`stockRadius`/`stockHeight` | `stockSolid` | `stockSetup` (`fromRest`: an earlier
    /// setup's simulated stock), `clearance`,
    /// `retract`, `feed`, `targets`, `protected`, `turningStickOut` (positive length or null)); absent keys are unchanged.
    pub patch: Value,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateOperationArgs {
    pub id: String,
    /// A patch of the operation form's keys, nested as the form has them (deep-merged; absent keys
    /// unchanged): `name`, `enabled`, `tool`, `liveTool` (true/false/null),
    /// `liveToolMode` (live/off/auto), `feedsOverride` + `feeds.{spindleRpm,cuttingFeed,plungeFeed}`,
    /// `targets.{targetSolids,driveFaces,avoidFaces,protectedSolids}` (entity names), `heightsOverride` +
    /// `heights.{clearance,retract,feed}`, `tolerance`, `stockToLeave.{radial,axial}`, `strategy.*` (the
    /// strategy's own parameters, by the model's field names) and `linking.*`.
    pub patch: Value,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetRefArgs {
    /// The setup or the operation.
    pub id: String,
    /// A setup's reference field (`zRef`, `xRef`, `originRef`, `stockSolid`, `targets`, `protected`) or an
    /// operation's (`targetSolids`, `driveFaces`, `avoidFaces`, `protectedSolids`, `chainEdges`, `chainProfile`, `boundaryProfile`).
    pub field: String,
    /// The entity names to pick (a face `Box_PZ`, an edge, a vertex `Box@8,0,0`, a solid);
    /// a single-valued field takes the first. Empty clears the field.
    #[serde(default)]
    pub names: Vec<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AddMachineArgs {
    /// The machine to copy: a built-in id (`vmc-3axis`, ...; see `cam_state.machines`) or a document machine's id.
    pub base: String,
    /// The copy's name; absent = `<base name> (custom)`.
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateMachineArgs {
    pub id: String,
    /// A nested patch of the machine editor's keys (`name`, `kind`, `post`, `toolChangeSeconds`,
    /// `spindle.minRpm`/`maxRpm`, `feed.maxCutting`/`rapid`/`acceleration`, `accelerationSet`,
    /// `travelSet`, `travel.min`/`max`, `kinematics`, `yAxis`, `axes.<i>.name`/`direction`/
    /// `origin`/`limitsSet`/`limitsMin`/`limitsMax`); absent keys are unchanged.
    pub patch: Value,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateToolArgs {
    pub id: String,
    /// A patch of the tool form's keys (`name`, `number`, `kind`, `description`, `material`,
    /// `diameter`, `cuttingLength`, `shaftDiameter`, `shaftLength`, `overallLength`, `cornerRadius`,
    /// `includedAngleDeg`, `ballDiameter`, `tipDiameter`, `threadPitch`, `fluteCount`, `leftHand`,
    /// `holderFamily`/`holderBore`/`holderHeight`, `spindleRpm`, `spindleDirection`, `cuttingFeed`,
    /// `plungeFeed`, `coolant`, `turningGeometry` (tagged insert/blade geometry or null)); absent keys are unchanged.
    pub patch: Value,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FeedsArgs {
    pub id: String,
    /// The work material (`aluminium`, `mild-steel`, `alloy-steel`, `stainless-steel`, `cast-iron`,
    /// `brass`, `titanium`, `plastics`, `wood`, `mdf`).
    pub material: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LibraryArgs {
    /// `list` (the user library's tools), `save` (document tool `id` into the user library),
    /// `add` (user-library tool `id` into the document), `remove` (from the user library),
    /// `import` (`name` + `text` of a Fusion 360 / CSV / library file into the document),
    /// `export` (the document's tools, or the user library with `id: "user"`, as library text).
    pub action: String,
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub text: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GenerateArgs {
    /// The operations to generate; absent = every enabled operation.
    #[serde(default)]
    pub ids: Option<Vec<String>>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VisibleArgs {
    /// The operation id.
    pub id: String,
    /// Whether its toolpath is drawn in the viewport.
    pub visible: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HoldArgs {
    /// Keep finished generation replies back (true) or deliver them (false).
    pub hold: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScrubArgs {
    /// The preview's scrub position on the selected operation, 0..1 along its segments; absent clears it.
    #[serde(default)]
    pub fraction: Option<f64>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IdArgs {
    pub id: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SelectArgs {
    /// The row to select; absent clears the selection.
    #[serde(default)]
    pub id: Option<String>,
}

#[derive(Serialize, schemars::JsonSchema)]
pub struct Added {
    /// The id the block minted (`SETUP1`, `T2`, `OP3`).
    pub id: String,
}

fn cam_state(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    ctx.app.cam.refresh_geometry(ctx.app.docs.engine());
    Ok(Outcome::Done(parse(&cam::cam_state_json(ctx.app.docs.engine(), &ctx.app.cam))))
}

fn added(id: String) -> Result<Outcome, String> {
    serde_json::to_value(Added { id }).map(Outcome::Done).map_err(|e| e.to_string())
}

fn cam_add_setup(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: NamedArgs = parse_args(args)?;
    let id = cam::add_setup(ctx.app.docs.engine_mut(), a.name.as_deref())?;
    ctx.app.cam.select(Some(id.clone()));
    added(id)
}

fn cam_add_tool(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: NamedArgs = parse_args(args)?;
    let id = cam::add_tool(ctx.app.docs.engine_mut(), a.name.as_deref())?;
    ctx.app.cam.select(Some(id.clone()));
    added(id)
}

fn cam_add_machine(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: AddMachineArgs = parse_args(args)?;
    let id = cam::add_machine(ctx.app.docs.engine_mut(), &a.base, a.name.as_deref())?;
    ctx.app.cam.select(Some(id.clone()));
    added(id)
}

fn cam_update_machine(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: UpdateMachineArgs = parse_args(args)?;
    cam::update_machine(ctx.app.docs.engine_mut(), &a.id, &a.patch, None)?;
    Ok(Outcome::Done(json!({})))
}

fn cam_add_operation(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: AddOperationArgs = parse_args(args)?;
    let id = cam::add_operation(ctx.app.docs.engine_mut(), &a.setup, &a.tool, &a.strategy, a.name.as_deref())?;
    ctx.app.cam.select(Some(id.clone()));
    added(id)
}

fn cam_add_program(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: AddProgramArgs = parse_args(args)?;
    let id = cam::add_program_with(ctx.app.docs.engine_mut(), a.name.as_deref(), &a.post, &a.operations, &a.properties)?;
    ctx.app.cam.select(Some(id.clone()));
    added(id)
}

fn cam_update_program(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: UpdateProgramArgs = parse_args(args)?;
    cam::set_program_properties(ctx.app.docs.engine_mut(), &a.id, &a.properties)?;
    Ok(Outcome::Done(json!({})))
}

fn cam_open(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: OpenArgs = parse_args(args)?;
    if let Some(id) = &a.id {
        let doc = cam::cam_document(ctx.app.docs.engine())?;
        if doc.setup(id).is_none() && doc.tool(id).is_none() && doc.operation(id).is_none() && !doc.machines.iter().any(|m| m.id.as_str() == id) {
            return Err(format!("no setup, tool, operation or document machine `{id}` (see cam_state)"));
        }
        ctx.app.cam.select(Some(id.clone()));
    } else if ctx.app.docs.engine().ref_select_active() {
        ctx.app.docs.engine_mut().finish_ref_select();
    }
    ctx.app.cam.set_open(a.id);
    ctx.app.show_pane(crate::panels::dock::PaneKind::Cam);
    Ok(Outcome::Done(json!({ "open": ctx.app.cam.open_setup() })))
}

fn cam_update_setup(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: UpdateSetupArgs = parse_args(args)?;
    cam::update_setup(ctx.app.docs.engine_mut(), &a.id, &a.patch, None)?;
    Ok(Outcome::Done(json!({})))
}

fn cam_set_ref(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: SetRefArgs = parse_args(args)?;
    let engine = ctx.app.docs.engine_mut();
    let is_operation = cam::cam_document(engine)?.operation(&a.id).is_some();
    if is_operation {
        cam::begin_operation_pick(engine, &a.id, &a.field)?;
    } else {
        cam::begin_setup_pick(engine, &a.id, &a.field)?;
    }
    // The seed is the field's current names; the call's names REPLACE them.
    while !engine.ref_select_names().is_empty() {
        engine.ref_select_remove(0);
    }
    for name in &a.names {
        engine.ref_select_add_name(name.clone());
    }
    engine.finish_ref_select();
    let frame_error = if is_operation {
        cam::sync_operation_faces(engine, &a.id)?;
        None
    } else {
        cam::sync_setup_frame(engine, &a.id)
    };
    ctx.app.cam.refresh_geometry(ctx.app.docs.engine());
    Ok(Outcome::Done(json!({ "frameError": frame_error })))
}

fn cam_update_operation(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: UpdateOperationArgs = parse_args(args)?;
    cam::update_operation(ctx.app.docs.engine_mut(), &a.id, &a.patch, None)?;
    Ok(Outcome::Done(json!({})))
}

fn cam_update_tool(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: UpdateToolArgs = parse_args(args)?;
    cam::update_tool(ctx.app.docs.engine_mut(), &a.id, &a.patch, None)?;
    Ok(Outcome::Done(json!({})))
}

fn cam_duplicate_tool(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: IdArgs = parse_args(args)?;
    let id = cam::duplicate_tool(ctx.app.docs.engine_mut(), &a.id)?;
    ctx.app.cam.select(Some(id.clone()));
    added(id)
}

fn cam_calculate_feeds(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: FeedsArgs = parse_args(args)?;
    let adjustments = cam::calculate_tool_feeds(ctx.app.docs.engine_mut(), &a.id, &a.material)?;
    let doc = cam::cam_document(ctx.app.docs.engine())?;
    let feeds = doc.tool(&a.id).map(|t| serde_json::to_value(&t.feeds).unwrap_or(Value::Null)).unwrap_or(Value::Null);
    Ok(Outcome::Done(json!({ "feeds": feeds, "adjustments": adjustments })))
}

fn cam_library(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    use crate::panels::cam_library;
    let a: LibraryArgs = parse_args(args)?;
    let store = ctx.app.model_store.as_ref();
    let need_id = || a.id.clone().ok_or_else(|| format!("`{}` needs `id`", a.action));
    match a.action.as_str() {
        "list" => {
            let library = cam_library::load(store)?;
            Ok(Outcome::Done(json!({ "name": library.library.name, "tools": library.library.tools })))
        }
        "save" => {
            let id = need_id()?;
            let doc = cam::cam_document(ctx.app.docs.engine())?;
            let tool = doc.tool(&id).ok_or_else(|| format!("no tool `{id}`"))?;
            cam_library::save_tool(store, tool)?;
            ctx.app.cam.refresh_user_library(store);
            Ok(Outcome::Done(json!({})))
        }
        "add" => {
            let id = need_id()?;
            let new_id = cam::add_tool_from_library(ctx.app.docs.engine_mut(), store, &id)?;
            ctx.app.cam.select(Some(new_id.clone()));
            added(new_id)
        }
        "remove" => {
            cam_library::remove_tool(store, &need_id()?)?;
            ctx.app.cam.refresh_user_library(store);
            Ok(Outcome::Done(json!({})))
        }
        "import" => {
            let name = a.name.clone().unwrap_or_else(|| "tools.json".to_string());
            let text = a.text.clone().ok_or_else(|| "`import` needs `text`".to_string())?;
            let (ids, issues) = cam::import_tools(ctx.app.docs.engine_mut(), &name, &text)?;
            Ok(Outcome::Done(json!({ "ids": ids, "skipped": issues })))
        }
        "export" => {
            let text = if a.id.as_deref() == Some("user") { cam_library::export_text(store)? } else { cam::export_document_tools(ctx.app.docs.engine())? };
            Ok(Outcome::Done(json!({ "text": text })))
        }
        other => Err(format!("no library action `{other}` (list, save, add, remove, import, export)")),
    }
}

fn cam_remove(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: IdArgs = parse_args(args)?;
    cam::remove_record(ctx.app.docs.engine_mut(), &a.id)?;
    if ctx.app.cam.selected() == Some(a.id.as_str()) {
        ctx.app.cam.select(None);
    }
    Ok(Outcome::Done(json!({})))
}

fn cam_select(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: SelectArgs = parse_args(args)?;
    if let Some(id) = &a.id {
        if !cam::cam_document(ctx.app.docs.engine())?.has(id) {
            return Err(format!("no CAM record `{id}` (see cam_state)"));
        }
    }
    ctx.app.cam.select(a.id);
    Ok(Outcome::Done(json!({ "selected": ctx.app.cam.selected() })))
}

fn cam_generate(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: GenerateArgs = parse_args(args)?;
    let doc = cam::cam_document(ctx.app.docs.engine())?;
    if doc.operations.is_empty() {
        return Err("nothing to generate: add an operation first".to_string());
    }
    let (queued, refused) = cam::generate(ctx.app.docs.engine_mut(), a.ids.as_deref())?;
    // Read before any frame runs: a fast job can land on the very next frame,
    // so this is the one moment the job in flight is certain.
    let engine = ctx.app.docs.engine();
    // The chord each queued job's part mesh is tessellated at (a quarter of
    // its operation's tolerance, half for scallop), for a reader to check.
    let chords: serde_json::Map<String, Value> = queued
        .iter()
        .filter_map(|id| doc.operation(id).and_then(|op| crate::panels::cam_generate::job_chord(id, op).ok()).map(|c| (id.clone(), json!(c))))
        .collect();
    Ok(Outcome::Done(json!({
        "queued": queued,
        "chords": chords,
        "refused": refused.iter().map(|(id, why)| json!({ "id": id, "reason": why })).collect::<Vec<_>>(),
        "inFlight": engine.cam.in_flight(),
        "queue": engine.cam.queued(),
    })))
}

fn cam_hold_replies(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: HoldArgs = parse_args(args)?;
    ctx.app.docs.engine_mut().cam_hold_replies(a.hold);
    Ok(Outcome::Done(json!({ "hold": a.hold })))
}

fn cam_cancel(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    let dropped = ctx.app.docs.engine_mut().cam_cancel();
    Ok(Outcome::Done(json!({ "dropped": dropped })))
}

fn cam_toolpath(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: IdArgs = parse_args(args)?;
    crate::panels::cam_generate::toolpath_json(ctx.app.docs.engine(), &a.id)
        .map(Outcome::Done)
        .ok_or_else(|| format!("no generated toolpath for `{}` (see cam_state statuses)", a.id))
}

fn cam_toolpath_visible(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: VisibleArgs = parse_args(args)?;
    let doc = cam::cam_document(ctx.app.docs.engine())?;
    if doc.operation(&a.id).is_none() {
        return Err(format!("no operation `{}`", a.id));
    }
    ctx.app.cam.set_toolpath_visible(&a.id, a.visible);
    Ok(Outcome::Done(json!({ "id": a.id, "visible": a.visible, "hidden": ctx.app.cam.hidden_toolpaths().iter().collect::<Vec<_>>() })))
}

fn cam_scrub(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: ScrubArgs = parse_args(args)?;
    ctx.app.cam.set_scrub(a.fraction);
    let doc = cam::cam_document(ctx.app.docs.engine())?;
    let point = ctx.app.cam.selected().and_then(|op| {
        crate::panels::cam_generate::scrub_world(ctx.app.docs.engine(), &doc, op, a.fraction?).map(|(p, index)| json!({ "point": [p.x, p.y, p.z], "segment": index }))
    });
    Ok(Outcome::Done(json!({ "scrub": a.fraction, "at": point })))
}

// Small CAM control replies share a stable envelope. The larger CAM state,
// library and toolpath models are separate contracts.
fn cam_result_object(properties: Value) -> Value {
    let required: Vec<_> = properties.as_object().unwrap().keys().cloned().collect();
    json!({"type":"object","properties":properties,"required":required})
}
fn cam_open_result_schema() -> Value {
    cam_result_object(json!({"open":{"type":["string","null"]}}))
}
fn cam_select_result_schema() -> Value {
    cam_result_object(json!({"selected":{"type":["string","null"]}}))
}
fn cam_set_ref_result_schema() -> Value {
    cam_result_object(json!({"frameError":{"type":["string","null"]}}))
}
fn cam_generate_result_schema() -> Value {
    let strings = json!({"type":"array","items":{"type":"string"}});
    cam_result_object(json!({
        "queued":strings,"chords":{"type":"object","additionalProperties":{"type":"number"}},
        "refused":{"type":"array","items":cam_result_object(json!({"id":{"type":"string"},"reason":{"type":"string"}}))},
        "inFlight":{"type":["string","null"]},"queue":strings
    }))
}
fn cam_cancel_result_schema() -> Value {
    cam_result_object(json!({"dropped":{"type":"integer","minimum":0}}))
}
fn cam_hold_replies_result_schema() -> Value {
    cam_result_object(json!({"hold":{"type":"boolean"}}))
}
fn cam_toolpath_visible_result_schema() -> Value {
    cam_result_object(json!({"id":{"type":"string"},"visible":{"type":"boolean"},"hidden":{"type":"array","items":{"type":"string"}}}))
}
fn cam_scrub_result_schema() -> Value {
    cam_result_object(json!({"scrub":{"type":["number","null"]},"at":{"anyOf":[
        {"type":"null"},cam_result_object(json!({
            "point":{"type":"array","items":{"type":["number","null"]},"minItems":3,"maxItems":3},
            "segment":{"type":"integer","minimum":0}
        }))
    ]}}))
}

pub static COMMANDS: &[CommandSpec] = &[
    app_command!("cam", "cam_autoprogram", "Preview automatic programming from a setup, including additional fixed-axis setups for certified side features and named refusals for unreachable features. Document tools are preferred; builtin fallback tools are labelled. Select or untick setups (selectSetup) and features, then create setups, carried-stock links, tools and ordered operations in one undo step. State reports busy/stale; model edits re-recognise bound features at generation, or refuse missing features by name.", crate::panels::cam_autoprogram::CommandArgs, crate::panels::cam_autoprogram::command, Mutate, MUTATE_NOWAIT, super::cam_autoprogram_output_schema::result),
    app_command!("cam", "cam_state", "The document's CAM block in the shared model's form: every setup (with the ids of its operations), the tools, the operations (each naming its setup, tool and a `kind`-tagged strategy), the NC programs and the id counter; `strategies` lists the strategy kinds; plus the CAM pane's selected row, its last notice, and `error` when the block cannot be read (then every edit is refused); `generation` (busy, the operation in flight, the queue, progress, every result's summary) and `statuses` (per operation: idle / queued / running / ok / stale / error with its motion count or reason) describe the background generation, which never writes the document.", NoArgs, cam_state, Read, READ, super::cam_state_output_schema::state),
    app_command!("cam", "cam_add_setup", "Add a setup (the toolbar's New setup) and select it. Returns its id. The work coordinate system, stock and heights are edited in the setup dialog once it lands.", NamedArgs, cam_add_setup, Mutate, MUTATE, schema_of::<Added>),
    app_command!("cam", "cam_add_tool", "Add a tool to the document's library (the toolbar's New tool) with the next free tool number, and select it. Returns its id.", NamedArgs, cam_add_tool, Mutate, MUTATE, schema_of::<Added>),
    app_command!("cam", "cam_add_operation", "Add an operation of strategy kind `strategy` (every parameter at its default) to `setup` using `tool` and select it; refused, naming what is missing, when the setup, the tool or the kind does not exist. Returns its id.", AddOperationArgs, cam_add_operation, Mutate, MUTATE, schema_of::<Added>),
    app_command!("cam", "cam_add_program", "Add an NC program: an ordered group of existing operations posted through one post-processor to one file (the post lane assembles and writes it). Returns its id.", AddProgramArgs, cam_add_program, Mutate, MUTATE, schema_of::<Added>),
    app_command!("cam", "cam_update_program", "Set or clear (null) an NC program's post properties: a mill-turn lathe post's builder codes, a program number. A built-in post's names and enum values are checked against its own property list.", UpdateProgramArgs, cam_update_program, Mutate, MUTATE),
    app_command!("cam", "cam_add_machine", "Copy a machine into the document (the Machines section's New machine): `base` is a built-in id or a document machine's id; the copy gets a document id (`M5`), keeps the base's kinematics, limits, post and collision solids, and is selected. A setup names it through `cam_update_setup { patch: { machine } }`.", AddMachineArgs, cam_add_machine, Mutate, MUTATE, schema_of::<Added>),
    app_command!("cam", "cam_update_machine", "Apply a nested patch of the machine editor's keys to a DOCUMENT machine `id` (see the argument): one undo step; a refused value names its field (`spindle.maxRpm: ...`, `post: no post ...`, `machine: <the model's rule>`) with nothing applied; a new `kind` starts that kind's axis layout. A built-in is refused: copy it first with `cam_add_machine`.", UpdateMachineArgs, cam_update_machine, Mutate, MUTATE),
    app_command!("cam", "cam_update_tool", "Patch a tool through its form's keys (what typing in the tool dialog does): name, number, kind, description, material, the geometry, the holder (family, bore, height: designed through the holder library), spindle and feeds, coolant. Validated by the library's validate_tool; a refusal names the field with nothing applied.", UpdateToolArgs, cam_update_tool, Mutate, MUTATE),
    app_command!("cam", "cam_duplicate_tool", "Copy a tool under a fresh id and the next free tool number (`<name> (copy)`) and select it. Returns the new id.", IdArgs, cam_duplicate_tool, Mutate, MUTATE, schema_of::<Added>),
    app_command!("cam", "cam_calculate_feeds", "Feeds and speeds for a tool cutting `material` on a default 3-axis mill, through the feeds calculator, written into the tool. Returns the feeds and the calculator's adjustments.", FeedsArgs, cam_calculate_feeds, Mutate, MUTATE, super::cam_tool_output_schema::calculate_feeds),
    app_command!("cam", "cam_library", "The USER tool library (kept across documents in the model store): `list`; `save` a document tool into it; `add` one of its tools to the document (fresh id and number); `remove`; `import` a Fusion 360 (.json / .tools), CSV or library file's `text` into the document's tools; `export` the document's tools (or the user library with id `user`) as library text. The pane's Import… and Export buttons do the same through the file chooser and the save door.", LibraryArgs, cam_library, Mutate, MUTATE, super::cam_tool_output_schema::library),
    app_command!("cam", "cam_update_operation", "Patch an operation through its form's keys (what typing in the operation dialog does): name, enabled, tool, liveTool (true/false/null) or liveToolMode (live/off/auto), feed and height overrides, the bodies and faces by name, tolerance, stock to leave, and `strategy.*` — the strategy's own parameters by the model's field names, which the dialog walks generically — and `linking.*`. Read back as the model's types and validated; a refusal names the field or group with nothing applied.", UpdateOperationArgs, cam_update_operation, Mutate, MUTATE),
    app_command!("cam", "cam_open", "Open a setup's, a tool's or an operation's dialog in the CAM pane (a setup: its work coordinate system by picking, stock, heights and bodies, with the frame and stock drawn in the viewport; a tool: its kind, geometry, holder, feeds and coolant; an operation: Tool / Geometry / Heights / Passes / Linking over its strategy's parameters), or close the open one with no id.", OpenArgs, cam_open, Mutate, MUTATE_NOWAIT, cam_open_result_schema),
    app_command!("cam", "cam_update_setup", "Patch a setup through its form's keys (what typing in the setup dialog does): name, work offset number, the orientation references, the stock kind and its parameters, the heights, the bodies. A value the field refuses is refused here by field name with nothing applied; the frame the references resolve to is written back as `modelToMachine`. The Machine group: `machine` is `none`, a built-in id (3-axis VMC, 4th-axis A table, trunnions A/C and B/C, head-head, head-table, lathe, mill-turn C/Y) or `custom` (a copy under a document id with `machineBase`, `machineName`, `machinePost` and `rotary1Min/Max`, `rotary2Min/Max` editable, empty = continuous); generation, the simulation player and an empty-post NC program use it.", UpdateSetupArgs, cam_update_setup, Mutate, MUTATE),
    app_command!("cam", "cam_set_ref", "Pick a setup's or an operation's reference field by entity name, without a pointer: arms the picker on the field, takes the names, finishes. A setup's frame is re-resolved (`frameError` says why when a reference does not resolve); an operation's picked faces get their owning solid from the scene. `boundaryProfile` (a strategy with a containment) takes a sketch profile and samples its loops, in the setup's machine XY within a quarter of the operation's tolerance, into the containment boundary; anything but one sketch profile across the setup's Z is refused by name and leaves the boundary as it was.", SetRefArgs, cam_set_ref, Mutate, MUTATE, cam_set_ref_result_schema),
    app_command!("cam", "cam_remove", "Delete a setup (and its operations), an operation, a tool (refused while an operation uses it) or an NC program by id.", IdArgs, cam_remove, Mutate, DESTRUCTIVE),
    app_command!("cam", "cam_select", "Select a row of the CAM pane by id (a setup, operation, tool or program), or clear the selection with no id. The selection is what New operation reads: the setup it goes into and the tool it uses.", SelectArgs, cam_select, Mutate, MUTATE_NOWAIT, cam_select_result_schema),
    app_command!("cam", "cam_generate", "Queue the generation of every enabled operation (or `ids`) on the background runner, one operation per job: returns the ids queued (an id named twice, or already queued or in flight, is queued once), per operation that cannot be queued why (no targets, a missing tool, a body not on screen), the chord each queued job's part mesh is tessellated at (`chords`: a quarter of its operation's tolerance, half for scallop, which certifies the mesh sag itself), and the job in flight and the queue as they stand when the command returns — before any frame can land a fast job's reply. The results land over the following frames — `cam_state` `generation` (busy, in flight, queue, progress) and `statuses` (per operation: idle / queued / running / ok / stale / error) say when; `wait_state` on `/__brepCam/generation/busy` eq false waits for them. A toolpath is never stored in the document.", GenerateArgs, cam_generate, Mutate, MUTATE_NOWAIT, cam_generate_result_schema),
    app_command!("cam", "cam_cancel", "Cancel the generation: the queue is dropped at once; the operation in flight stops at its next work chunk where the runner can reach it (the native thread) and its reply is dropped either way; results already landed stay. Returns how many jobs were dropped.", NoArgs, cam_cancel, Mutate, MUTATE_NOWAIT, cam_cancel_result_schema),
    app_command!("cam", "cam_hold_replies", "TEST HOLD for scripts: while `hold` is true the runner's finished generation replies are kept back, so a job stays in flight on the app's side however fast it ran — a scripted `cam_cancel` then always meets it, and on release its reply is dropped as cancelled. `hold: false` delivers what was kept. Not a user feature; the browser worker ignores it.", HoldArgs, cam_hold_replies, Mutate, MUTATE_NOWAIT, cam_hold_replies_result_schema),
    app_command!("cam", "cam_toolpath", "One operation's generated toolpath: the typed motion stream (rapid / plunge / cut / arcs / retract / leads / links / cycles / spindle and coolant), the generator's report, the cutting length and `cutBounds` (the box around every cut's end point, machine coordinates; null without a cut). Refused while the operation has no result.", IdArgs, cam_toolpath, Read, READ, super::cam_path_output_schema::toolpath),
    app_command!("cam", "cam_toolpath_visible", "Show or hide one operation's generated toolpath in the viewport (the row menu's Hide / Show toolpath): pane state, not the document's; `cam_state` lists the hidden ids under `hiddenToolpaths`. Refused for an operation the document does not have.", VisibleArgs, cam_toolpath_visible, Mutate, MUTATE_NOWAIT, cam_toolpath_visible_result_schema),
    app_command!("cam", "cam_scrub", "Set the toolpath preview's scrub position on the SELECTED operation (0..1 along its segments): the viewport marks the tool position there; returns that world point and the segment index. Absent clears the marker.", ScrubArgs, cam_scrub, Mutate, MUTATE_NOWAIT, cam_scrub_result_schema),
];