Implement the reviewed UI icon system and durable review workflow (#853)

Thanks @Nathan-Dane.
This commit is contained in:
Nathan Dane authored and GitHub committed 2026-10-17 04:07:16 -04:00
1 parent c599238653
commit 64966bab88
374 files changed
+8187 -2221

No files matched your search

+8 -8
View File
@@ -13,16 +13,16 @@ use crate::params::{bad, bool_, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
CommandSpec::new("body.rename", "Rename Body", rename_body).icon("rename").params("body: its name, name: the new name (features that use the body follow)"),
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
.icon("plane")
.params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
CommandSpec::new("browser.group", "New Group", group)
.icon("folder")
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
CommandSpec::new("browser.ungroup", "Ungroup", ungroup).params("group: id (its items go back to the folder)"),
CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"),
CommandSpec::new("browser.move", "Move Browser Items", move_items)
CommandSpec::new("browser.ungroup", "Ungroup", ungroup).icon("folder").params("group: id (its items go back to the folder)"),
CommandSpec::new("browser.rename_group", "Rename Group", rename_group).icon("rename").params("group: id, name"),
CommandSpec::new("browser.move", "Move Browser Items", move_items).icon("reorder")
.params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"),
CommandSpec::new("document.units", "Change Design Units", document_units)
.icon("settings")
@@ -30,13 +30,13 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.save", "New Named View", view_save)
.icon("camera")
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
+2 -1
View File
@@ -12,8 +12,9 @@ use crate::params::{bad, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.copy", "Copy Features", copy).noundo().params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.paste", "Paste Features", paste)
.icon("paste")
.params("translate?: [x,y,z] (moves the copies' geometry); the copies go at the marker"),
];
+1 -1
View File
@@ -12,7 +12,7 @@ use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params(
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
+10 -10
View File
@@ -29,8 +29,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.list", "List Components", list).noundo().params(
CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
@@ -40,20 +40,20 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params("bodies: [names], component"),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).icon("move").params("sketches: [ids or names], component"),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
];
+3 -1
View File
@@ -20,12 +20,14 @@ pub static COMMANDS: &[CommandSpec] = &[
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
),
CommandSpec::new("component.update_links", "Update Links", update_links).params(
CommandSpec::new("component.update_links", "Update Links", update_links).icon("update").params(
"component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed",
),
CommandSpec::new("component.break_link", "Break Link", break_link)
.icon("break")
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
CommandSpec::new("component.links", "List Links", list_links)
.icon("chain")
.noundo()
.params("→ linked components: component, name, path, file (resolved), found"),
];
+7 -7
View File
@@ -14,17 +14,17 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("config.table", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.icon("configuration_table")
.noundo()
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"),
CommandSpec::new("config.row", "Configuration Row", row).params(
CommandSpec::new("config.column", "Configuration Column", column).icon("configuration_table").params("param? | feature? | material? | appearance? (target name); delete?: bool"),
CommandSpec::new("config.row", "Configuration Row", row).icon("configuration_table").params(
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
),
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"),
CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"),
CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"),
CommandSpec::new("config.activate", "Activate Configuration", activate).icon("activate").params("row: name — sets its parameter values and suppressions"),
CommandSpec::new("config.preview", "Preview Configuration", preview).icon("eye").noundo().params("row: name — validates a rebuild without changing the design or history"),
CommandSpec::new("config.rule", "Configuration Rule", rule).icon("configuration_table").params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"),
CommandSpec::new("config.rules", "Configuration Rules", show).icon("list").noundo().params("ordered typed rules, rows and active state"),
];
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
+17 -17
View File
@@ -21,69 +21,69 @@ const REF: &str =
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.icon("axis_through_cylinder")
.params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"),
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.icon("axis_normal_to_face")
.params("face: ref; point: ref (the axis goes through it, square to the face); name?"),
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.icon("axis_two_planes")
.params("a, b: plane or planar face refs; name?"),
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.icon("axis_two_points")
.params("a, b: point refs (vertices, sketch points, points); name?"),
CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.icon("axis_edge")
.params("edge: ref (a straight edge or sketch line); name?"),
CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at)
.at("SOLID", "CONSTRUCT")
.icon("point")
.icon("point_vertex")
.params("point: ref (vertex, sketch point, point); name?"),
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges)
.at("SOLID", "CONSTRUCT")
.icon("point")
.icon("point_two_edges")
.params("a, b: edge or line refs (where they meet, or come closest); name?"),
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes)
.at("SOLID", "CONSTRUCT")
.icon("point")
.icon("point_three_planes")
.params("a, b, c: plane or planar face refs; name?"),
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center)
.at("SOLID", "CONSTRUCT")
.icon("point")
.icon("point_center")
.params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"),
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane)
.at("SOLID", "CONSTRUCT")
.icon("point")
.icon("point_edge_plane")
.params("edge: edge or line ref; plane: plane or planar face ref; name?"),
CommandSpec::new("construct.point.along_path", "Point Along Path", point_along)
.at("SOLID", "CONSTRUCT")
.icon("point")
.icon("point_along_path")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"),
CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.icon("plane_midplane")
.params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"),
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.icon("plane_two_edges")
.params("a, b: straight edge or line refs in one plane; name?"),
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.icon("plane_three_points")
.params("a, b, c: point refs; name?"),
CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.icon("plane_tangent")
.params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"),
CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.icon("plane_along_path")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane").params(
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane_perpendicular").params(
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
),
];
+4 -3
View File
@@ -8,20 +8,21 @@ use crate::params::{bad, str_};
use crate::{EngineError, Result, Session, crash};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).noundo().params(
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).icon("bug_report").noundo().params(
"limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \
command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \
Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay <id>`",
),
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).noundo().params(
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).icon("bug_report").noundo().params(
"id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \
crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \
(`solidcraft-cli run <replay_path> --in <design_path>`)",
),
CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear)
.icon("delete")
.noundo()
.params("id?: one report (default: all). Returns {deleted}"),
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).noundo().enabled(crash_test_enabled).params(
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).icon("bug_report").noundo().enabled(crash_test_enabled).params(
"mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \
the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \
at the next start). Only when the SOLIDCRAFT_CRASH_TEST environment variable is set",
+18 -18
View File
@@ -12,11 +12,11 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint).icon("checkpoint")
.noundo()
.doc_only()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint).icon("restore")
.noundo()
.doc_only()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
@@ -26,48 +26,48 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
.doc_only()
.params("group: id | groups: [ids] (features remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback")
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set)
CommandSpec::new("select.set", "Select", select_set).icon("select")
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
CommandSpec::new("select.clear", "Clear Selection", select_clear).noundo(),
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
];
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
+6 -6
View File
@@ -48,7 +48,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.icon("pattern_path")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (origin coordinates may be length expressions; the mirror follows them, or a construction plane)"),
CommandSpec::new("solid.hole", "Hole", hole)
@@ -62,7 +62,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.icon("bounding_solid")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("solid.scale", "Scale", scale)
.at("SOLID", "MODIFY")
@@ -70,7 +70,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.icon("delete_face")
.params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
@@ -94,17 +94,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.icon("plane_angle")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split")
.icon("split_face")
.params(
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.icon("split_body")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
+3 -3
View File
@@ -28,7 +28,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
CommandSpec::new("solid.web", "Web", web)
.at("SOLID", "CREATE")
.icon("rib")
.icon("web")
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
CommandSpec::new("solid.emboss", "Emboss", emboss)
.at("SOLID", "CREATE")
@@ -36,11 +36,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"),
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.icon("replace_face")
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
.icon("align")
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
+5 -5
View File
@@ -38,23 +38,23 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("save")
.noundo()
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
CommandSpec::new("doc.recover", "Recover Design", recover)
CommandSpec::new("doc.recover", "Recover Design", recover).icon("restore")
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10)"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
.at("MESH", "MODIFY")
.icon("export")
.icon("mesh_export")
.noundo()
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id, name or program path from file.slicers (open the saved file there)"),
CommandSpec::new("file.slicers", "Installed Slicers", slicers)
CommandSpec::new("file.slicers", "Installed Slicers", slicers).icon("export")
.noundo()
.params("→ the slicers found on this machine: [{id, name, icon?}]; pass an id or name as file.save_mesh's `slicer`"),
];
+9 -9
View File
@@ -14,14 +14,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("I")
.noundo()
.params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params(
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements)"),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).icon("inspect").noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", model_edges).icon("inspect").noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).icon("inspect").noundo().params(
"body: name → per face: index, name (what face.delete, sheet.convert `face`, solid.shell … take in place of a point), exact, area, centroid (not always on the face), \
plane_normal, point (a point on the face that picks it), cylinder? ({origin, axis, radius} of a cylindrical face)",
),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("model.threads", "List Threads", model_threads).icon("inspect").noundo(),
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
@@ -31,17 +31,17 @@ pub static COMMANDS: &[CommandSpec] = &[
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
),
CommandSpec::new("appearance.library", "Appearance Library", appearance_library)
CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library")
.noundo()
.params("→ the built-in appearances (name, colour, opacity)"),
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list")
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list)
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
.noundo()
.doc_only()
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
];
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
+6 -6
View File
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
the occurrence named)",
),
CommandSpec::new("joint.reach", "Reach Point", reach).params(
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
),
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
@@ -64,14 +64,14 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is",
),
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).noundo().params(
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", edit).icon("edit").params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
"→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
),
+3 -3
View File
@@ -6,11 +6,11 @@ use serde_json::{Value, json};
use solidcraft_io::catalog::{Catalog, Definition, Provenance};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("library.inspect", "Inspect Local Library", inspect).noundo().doc_only()
CommandSpec::new("library.inspect", "Inspect Local Library", inspect).icon("inspect").noundo().doc_only()
.params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"),
CommandSpec::new("library.export", "Export Design Library", export).noundo().doc_only()
CommandSpec::new("library.export", "Export Design Library", export).icon("export").noundo().doc_only()
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"),
CommandSpec::new("library.apply", "Apply Local Library Entry", apply).enabled(super::not_in_sketch)
CommandSpec::new("library.apply", "Apply Local Library Entry", apply).icon("import").enabled(super::not_in_sketch)
.params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"),
];
+1 -1
View File
@@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look",
),
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
CommandSpec::new("material.delete", "Delete Physical Material", delete).icon("delete").enabled(has_user_materials).params(
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
),
];
+1
View File
@@ -22,6 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]",
),
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
.icon("delete")
.doc_only()
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
];
+25 -18
View File
@@ -30,35 +30,41 @@ macro_rules! trace_doc {
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("contact_sets").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
),
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.icon("contact_all")
.params("every occurrence takes part in contact (one set of all)"),
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("contact_off").params("contact off"),
CommandSpec::new("contact.create", "New Contact Set", new_set)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.icon("contact_new")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set)
.icon("edit")
.params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).icon("list").noundo().doc_only().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check)
.icon("interference")
.noundo()
.params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("play").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
(values: degrees for angles, mm for distances, or expressions)",
),
CommandSpec::new("motion.play", "Play Motion Study", play).params(
CommandSpec::new("motion.play", "Play Motion Study", play).icon("play").params(
"study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them); quiet?: bool \
(positions of only the occurrences that moved) → values: [{joint, values: [{name, value, unit}]}], positions, moved: whether \
any occurrence moved, unmoved: [{joint, name, reason}] (keyed joints that can move nothing, such as a chain joined to \
nothing grounded)",
),
CommandSpec::new("motion.export", "Export Motion Study", export)
.icon("export")
.noundo()
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).noundo().params(
CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).icon("export").noundo().params(
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?; path: a .gif file, or a folder for PNG frames (frame-0001.png, frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, or 100); width?, height? (16…4096 pixels, default 800 × 500); camera?: {target, yaw, pitch, roll?, distance, fov?, up?} (ui.camera's reply; fields left out keep the default view: view.apply's camera, or the home view framing the model); fps? (1…50, default 20); grid?, axes? (default true: the ground grid and the origin's axes) - draws the study as it plays, or the joint as it is driven, with the headless renderer → path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the design is not changed)",
),
CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!(
@@ -66,24 +72,25 @@ pub static COMMANDS: &[CommandSpec] = &[
" → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \
ends there), stopped_by_loop, stopped_by_contact (the design is not changed)"
)),
CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).params(concat!(
CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).icon("trace").params(concat!(
trace_doc!(),
"; name? (default Motion TraceN) - keeps the trace as a 3D sketch spline through its points (a new sketch with 3D Sketch \
on, in the root component, where no joint moves it; a fixed curve, it does not follow later changes) → sketch, name, curves, points"
)),
CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).noundo().params(analysis::PLOT_DOC),
CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).noundo().params(analysis::INTERFERENCE_DOC),
CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(),
CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"),
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).icon("graph").noundo().params(analysis::PLOT_DOC),
CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).icon("interference").noundo().params(analysis::INTERFERENCE_DOC),
CommandSpec::new("motion.list", "List Motion Studies", list_studies).icon("list").noundo(),
CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).icon("delete").params("study"),
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").params(
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
),
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
.icon("explode")
.noundo()
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
CommandSpec::new("explode.list", "List Exploded Views", explode_list).noundo(),
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).params("view"),
CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(),
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"),
];
const MAX_ITEMS: usize = 10_000;
+15 -5
View File
@@ -15,34 +15,44 @@ pub static COMMANDS: &[CommandSpec] = &[
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params(
CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
),
CommandSpec::new("parameters.add", "Add User Parameter", add).params("name, expression, unit?, comment?, favorite?: bool"),
CommandSpec::new("parameters.add", "Add User Parameter", add).icon("new").params("name, expression, unit?, comment?, favorite?: bool"),
CommandSpec::new("parameters.rename", "Rename Parameter", rename)
.icon("rename")
.params("name, new_name (expressions, feature inputs and dimensions that use it follow)"),
CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"),
CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"),
CommandSpec::new("parameters.delete", "Delete Parameter", delete)
.icon("delete")
.params("name (refused while something uses it; the error lists the users)"),
CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).icon("star").params("name, favorite?: bool (default true)"),
CommandSpec::new("parameters.move", "Move Parameter", move_param)
.icon("reorder")
.params("name, before | after: another user parameter; the user parameter is listed just before or after it"),
CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"),
CommandSpec::new("parameters.comment", "Comment Parameter", comment).icon("text").params("name, comment"),
CommandSpec::new("parameters.users", "Parameter Users", users)
.icon("graph")
.noundo()
.params("name → users (parameters, feature inputs, dimensions) and the features it drives"),
CommandSpec::new("parameters.graph", "Parameter Graph", graph)
.icon("graph")
.noundo()
.params("→ nodes, edges [user, used] (users include Feature:<name>), cycles, errors"),
CommandSpec::new("parameters.export", "Export Parameters", export)
.icon("export")
.noundo()
.params("format?: csv|json (default csv, or from the path), path?: write to a file → text"),
CommandSpec::new("parameters.import", "Import Parameters", import)
.icon("import")
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
.icon("params")
.noundo()
.doc_only()
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
CommandSpec::new("parameters.complete", "Complete Expression", complete)
.icon("params")
.noundo()
.doc_only()
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
+2 -2
View File
@@ -15,7 +15,7 @@ use crate::params::{bad, num, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parts.library", "Standard Parts", library)
CommandSpec::new("parts.library", "Standard Parts", library).icon("library")
.noundo()
.params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"),
CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params(
@@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[
length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?",
),
CommandSpec::new("parts.resize", "Change Part Size", resize)
CommandSpec::new("parts.resize", "Change Part Size", resize).icon("scale")
.params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"),
];
+2 -2
View File
@@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap_fit").params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?",
),
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
"bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults",
),
CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"),
CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).icon("plastic_rule").noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"),
];
fn rule_json(s: &Session, r: &PlasticRule) -> Value {
+10 -10
View File
@@ -13,27 +13,27 @@ use crate::{Result, Session};
mod jobs;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).noundo().doc_only()
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("box")
CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("render_image")
.params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"),
CommandSpec::new("render.status", "Render Progress", jobs::status).noundo().doc_only()
CommandSpec::new("render.status", "Render Progress", jobs::status).icon("list").noundo().doc_only()
.params("job: {session, serial, generation}. Non-blocking progress, error and completed frame metadata; no image bytes and no rendering in this call."),
CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).noundo().doc_only()
CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).icon("delete").noundo().doc_only()
.params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."),
CommandSpec::new("render.export", "Export Render Image", jobs::export).noundo().doc_only()
CommandSpec::new("render.export", "Export Render Image", jobs::export).icon("export").noundo().doc_only()
.params("job: {session, serial, generation}, path: PNG destination, overwrite?: false. Exports a retained completed image atomically; an existing destination is preserved on failure. See docs/render-jobs.md"),
CommandSpec::new("render.backends", "Render Backends", backends)
CommandSpec::new("render.backends", "Render Backends", backends).icon("list")
.noundo()
.doc_only()
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"),
CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo().doc_only()
CommandSpec::new("render.settings.get", "Render Settings", get_settings).icon("settings").noundo().doc_only()
.params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"),
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).doc_only()
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).icon("settings").doc_only()
.params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background). Creates from defaults, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"),
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("box")
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("render_scene")
.params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"),
CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).noundo().doc_only()
CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).icon("camera").noundo().doc_only()
.params("name: saved setup. Applies its existing Camera snapshot including roll/up as view state, without modifying the document or starting a render."),
];
+2 -2
View File
@@ -14,12 +14,12 @@ use crate::params::{bad, str_};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.add", "Add Recorded Feature", add).params(
CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params(
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; id and name are kept when free, \
so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
dimensions use); the feature goes at the marker → feature, name, error, warning",
),
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).noundo().doc_only().params(
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params(
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
steps, omitted: what the script leaves out (appearances, folders, views, configurations); components and assemblies \
are not supported yet",
+4 -4
View File
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \
and so is the corner relief on a base or contour flange",
),
CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("hem").params(
CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("perforate").params(
"diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)",
),
CommandSpec::new("sheet.hem", "Hem", hem)
@@ -59,7 +59,7 @@ pub static COMMANDS: &[CommandSpec] = &[
`corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; default notch); \
active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
@@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
),
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf).icon("dxf")
.noundo()
.params(
"body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those), \
@@ -77,7 +77,7 @@ pub static COMMANDS: &[CommandSpec] = &[
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table).icon("bendtable")
.noundo()
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
];
+11 -11
View File
@@ -38,7 +38,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("L")
.enabled(in_sketch)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned)
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
.enabled(in_sketch)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
@@ -91,12 +91,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.icon("polygon_circumscribed")
.enabled(in_sketch)
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
.at("SKETCH", "CREATE")
.icon("polygon")
.icon("polygon_edge")
.enabled(in_sketch)
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
@@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
.at("SKETCH", "CREATE")
.icon("slot")
.icon("slot_overall")
.enabled(in_sketch)
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
@@ -174,27 +174,27 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("X")
.enabled(in_sketch)
.params("curves: [ids], value?: bool (default: construction unless all of them already are)"),
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete)
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).icon("delete")
.enabled(in_sketch)
.params("entities: [curve, point or constraint ids]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params(
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
),
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params(
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params(
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
),
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).icon("edit")
.enabled(in_sketch)
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
),
CommandSpec::new("sketch.chain", "Chain", chain_cmd)
CommandSpec::new("sketch.chain", "Chain", chain_cmd).icon("chain")
.enabled(in_sketch)
.noundo()
.params("curve: id: the curves joined to it end to end (what a double-click selects)"),
CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd)
CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).icon("compute")
.enabled(in_sketch)
.noundo()
.params("reports the solver status and degrees of freedom"),
+4 -1
View File
@@ -12,17 +12,20 @@ use crate::params::{bad, req_vec3, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line3d", "3D Line", line3d).enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch).params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
.icon("spline")
.enabled(in_sketch)
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.point3d", "3D Point", point3d)
.icon("point")
.enabled(in_sketch)
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
.icon("sketch_move")
.enabled(in_sketch)
.params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"),
];
+2 -2
View File
@@ -18,9 +18,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("decal")
.params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"),
CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).at("SKETCH", "INSERT").icon("canvas").params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"),
CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate)
CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate).icon("measure")
.params("canvas: id or name; a, b: two points on the canvas plane [x,y]; distance: their true distance (mm): scales the image about a"),
CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).params("canvas: id or name"),
CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).icon("delete").params("canvas: id or name"),
];
fn image_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
+3 -3
View File
@@ -17,14 +17,14 @@ use crate::params::{bad, string_list, vec2};
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy)
CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy).icon("copy")
.noundo()
.enabled(in_sketch)
.params("entities?: [curve or point ids] (default: the selected sketch geometry); the constraints and dimensions among them come along"),
CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut)
CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut).icon("cut")
.enabled(in_sketch)
.params("entities?: [curve or point ids] (default: the selected sketch geometry): copies them as sketch.copy does, then deletes them (one undo step)"),
CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).enabled(in_sketch).params(
CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).icon("paste").enabled(in_sketch).params(
"at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)",
),
];
+3 -3
View File
@@ -41,14 +41,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("constrainer")
.enabled(in_sketch)
.params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"),
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven)
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension")
.enabled(in_sketch)
.params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"),
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs)
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye")
.enabled(in_sketch)
.noundo()
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
CommandSpec::new("sketch.snap", "Snap Inference", snap)
CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap")
.enabled(in_sketch)
.noundo()
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
+5 -5
View File
@@ -47,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot")
.icon("slot_center")
.enabled(in_sketch)
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
@@ -57,7 +57,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.icon("arc_slot_center")
.enabled(in_sketch)
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
@@ -97,16 +97,16 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("centerline")
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line)
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite")
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
text_style_doc!(),
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit")
.enabled(in_sketch)
.live(edit_text_live)
.params(concat!(
+2 -2
View File
@@ -15,13 +15,13 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
.at("SKETCH", "INSERT")
.icon("dxf")
.icon("sketch_dxf")
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
.at("SKETCH", "INSERT")
.icon("svg")
.params(concat!("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf)
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
.noundo()
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
];
+6 -6
View File
@@ -31,12 +31,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("point | a, b: a corner of two lines; distance", created_doc!())),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.icon("sketch_chamfer_angle")
.enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.icon("sketch_chamfer_two_distances")
.enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())),
CommandSpec::new("sketch.offset", "Offset", offset)
@@ -50,7 +50,7 @@ pub static COMMANDS: &[CommandSpec] = &[
"distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions",
created_doc!()
)),
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset).icon("sketch_break")
.enabled(in_sketch)
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
CommandSpec::new("sketch.trim", "Trim", trim)
@@ -66,17 +66,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
CommandSpec::new("sketch.break", "Break", break_cmd)
.at("SKETCH", "MODIFY")
.icon("break")
.icon("sketch_break")
.enabled(in_sketch)
.params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
.icon("sketch_scale")
.enabled(in_sketch)
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.at("SKETCH", "MODIFY")
.icon("move")
.icon("sketch_move")
.enabled(in_sketch)
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
+2 -2
View File
@@ -54,10 +54,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("fit_section")
.enabled(in_sketch)
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
CommandSpec::new("sketch.break_link", "Break Link", break_link)
CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break")
.enabled(in_sketch)
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project)
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project")
.noundo()
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
];
+10 -10
View File
@@ -18,23 +18,23 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.icon("surface_patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges) or [{edge: [x,y,z], g: g0|g1|g2}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or g2 edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
.icon("surface_stitch")
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
CommandSpec::new("surface.thicken", "Thicken", thicken)
.at("SURFACE", "CREATE")
.icon("thicken")
.icon("surface_thicken")
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
CommandSpec::new("surface.extend", "Extend", extend)
.at("SURFACE", "MODIFY")
.icon("extend")
.icon("surface_extend")
.params("body: surface name; edges: [[x,y,z]] (points on boundary edges to push out); distance: expr (unless up_to); mode?: natural (the surface itself continued: exact for planes and surfaces of revolution, a B-spline's end spans continued; the default) | tangent (a ruled run-off face along each edge, G1) | curvature (a run-off face keeping the surface's curvature across the edge, G2); up_to?: plane (XY|XZ|YZ|plane name|{origin, normal}) | {body, point} (a face, its surface extended) | \"bounds\" (natural: back to the surface's own bounds, undoing a trim); neighbours?: bool (default true: the edges next to an extended edge are lengthened to meet it; false: the extension's sides run square to the edge)"),
CommandSpec::new("surface.trim", "Trim", trim)
.at("SURFACE", "MODIFY")
.icon("trim")
.icon("surface_trim")
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
CommandSpec::new("surface.extrude", "Surface Extrude", surface_extrude)
.at("SURFACE", "CREATE")
@@ -58,7 +58,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
.at("SURFACE", "CREATE")
.icon("ruled")
.icon("surface_ruled")
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"),
CommandSpec::new("surface.blend", "Blend Surface", blend)
.at("SURFACE", "CREATE")
@@ -66,19 +66,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"),
CommandSpec::new("surface.untrim", "Untrim", untrim)
.at("SURFACE", "MODIFY")
.icon("untrim")
.icon("surface_untrim")
.params("face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove; default: all); outer?: bool (restore the outer boundary to the surface's natural bounds: a free-form surface's own, an analytic surface's span of the face); in_place?: bool (untrim a surface body's face where it is; a solid's face is always copied out). A surface body; the face keeps its name"),
CommandSpec::new("surface.unstitch", "Unstitch", unstitch)
.at("SURFACE", "MODIFY")
.icon("unstitch")
.icon("surface_unstitch")
.params("body; faces?: [[x,y,z]] (points on the faces to take out, each its own surface body; the rest stay together under the body's name; default: every face its own surface body). A solid comes apart into surfaces"),
CommandSpec::new("surface.reverse_normal", "Reverse Normal", reverse_normal)
.at("SURFACE", "MODIFY")
.icon("reverse_normal")
.icon("surface_reverse_normal")
.params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"),
CommandSpec::new("surface.fair", "Fair Surface", fair)
.at("SURFACE", "MODIFY")
.icon("spline_cv")
.icon("surface_fair")
.params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"),
planned!("surface.fillet", "Surface Fillet", "769")
.at("SURFACE", "MODIFY")
+1 -1
View File
@@ -21,7 +21,7 @@ const MAX_SAMPLES: usize = 512;
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
.at("SURFACE", "CREATE")
.icon("boundary")
.icon("surface_boundary")
.params(
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
)];
+1
View File
@@ -558,6 +558,7 @@ fn blend_corners_where_edges_meet_on_a_curved_wall() {
fn a_boundary_found_across_a_seam_is_not_used() {
let r = fuzz::run(20, 8);
assert_eq!(r.outcome.kind, "ok", "seed 20: {:?}\n{}", r.outcome, r.script());
assert!(r.steps.iter().any(|s| s.kind == "circular pattern"), "the pattern must build, not be skipped as unsupported: {:?}", r.unsupported);
}
/// Seed 33 ends in a fillet on its second body (#854's corner blends build it differently from
+44
View File
@@ -300,6 +300,14 @@ thread_local! {
/// The result carries its exact history ([`Body::history`]) over the operands `[a, b]`,
/// whichever way it was made, or none where a step it took does not report one.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Earlier coarse bores can leave a junction a later pattern copy cannot intersect.
// Resolve conical-cut junctions from the first copy, while the operands are unchanged.
if op == BoolOp::Cut && !APART.with(|c| c.get()) && conical_cut_junction(a, b) {
let refined = |a: &Body, b: &Body, op| boolean_whole_with_refinement(a, b, op, true);
if let Ok(result) = boolean_via(a, b, op, &refined) {
return Ok(result);
}
}
let r = boolean_via(a, b, op, &boolean_whole);
if r.is_ok() || !crate::tangent_cylinders::needed(a, b) {
return r;
@@ -312,6 +320,19 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
boolean_via(a, b, op, &split).or(r)
}
/// A conical cutter meeting a body with an earlier conical cut needs finer tracing at
/// their junctions. Guard the analytic recognition as well as the Boolean itself.
fn conical_cut_junction(a: &Body, b: &Body) -> bool {
guard("conical cut junction", || {
let conical = |body: &Body| {
let tol = (body.size() * 1e-5).max(1e-7);
body.solid.face_iter().any(|face| matches!(crate::offset::surf_of(face, tol), Some(crate::offset::Surf::Cone { .. })))
};
Ok(conical(b) && conical(a))
})
.unwrap_or(false)
}
/// [`boolean`], the boolean of the whole bodies taken by `whole`.
fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, BoolOp) -> Result<Option<Body>>) -> Result<Option<Body>> {
let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| whole(a, b, op));
@@ -548,6 +569,10 @@ fn swept_prism(a: &Body, b: &Body, op: BoolOp) -> Option<Body> {
}
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
boolean_whole_with_refinement(a, b, op, false)
}
fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -> Result<Option<Body>> {
if let Some(body) = swept_prism(a, b, op) {
return Ok(Some(body));
}
@@ -622,6 +647,25 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
};
(consistent, v)
};
if refine {
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
// Bounded to two finer meshes; no tool displacement or relaxed acceptance tests.
for k in [1e-5, 1e-6] {
let tol = (size * k).max(1e-6);
if let Ok((solid, history)) = truck_boolean(a, b, op, None, tol)
&& !solid.boundaries().is_empty()
&& let Ok(body) = guard("refined boolean result", || recorded::truck_result(solid, history, size, &keep, &counts))
&& check(&body).0
&& crate::boolean_guard::check_retry(a, b, op, Some(&body)).is_ok()
{
let mut event = BooleanFallback::new(BooleanStrategy::RefinedTolerance);
event.tolerance_mm = tol;
record(event);
return Ok(Some(body));
}
}
return Err(KernelError::Failed("no reliable result at refined tolerances".into()));
}
// One body clear of or inside the other: no intersection needed.
// Kept for the end when the facetings disagree with it: bodies that touch along curved
// faces can pass through each other slightly, and often can't be intersected.
@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 739d5cbd9e471e0ff65bc2b7c1b9a323c2e769939c73def2e74fb17b0536c1d0 # shrinks to a = [0.0, 2.5, 30.0], sa = [22.5, 5.0, 5.0], b = [22.5, 7.5, 15.0], sb = [5.0, 5.0, 17.5]
cc 2adfe5ac6b6916fa22a415348bdd7ece29ad33e8114b94dea86ba8bc39156dee # shrinks to a = Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }, b = Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] }
+51 -5
View File
@@ -67,6 +67,18 @@ fn prim(c: impl Strategy<Value = f64> + Clone, s: impl Strategy<Value = f64> + C
fn grid_prim() -> impl Strategy<Value = Prim> {
prim(grid(), grid_size())
}
/// A union across only a box edge or corner is non-manifold, so the kernel may
/// refuse it. This is the same analytic boundary case allowed by box_box_exact.
fn edge_only_boxes(a: &Prim, b: &Prim) -> bool {
let (Prim::Box { at: a, size: sa }, Prim::Box { at: b, size: sb }) = (a, b) else { return false };
let gap = [0, 1, 2].map(|i| a[i].max(b[i]) - (a[i] + sa[i]).min(b[i] + sb[i]));
gap.iter().all(|g| *g <= 0.0) && gap.iter().filter(|g| **g == 0.0).count() >= 2
}
fn edge_contact_refusal(a: &Prim, b: &Prim, op: BoolOp, result: &Result<f64, TestCaseError>) -> bool {
op == BoolOp::Union && edge_only_boxes(a, b) && result.as_ref().is_err_and(|e| e.to_string().contains("zero-thickness edge contact"))
}
fn free_prim() -> impl Strategy<Value = Prim> {
prim(free(), free_size())
}
@@ -95,8 +107,13 @@ fn close(x: f64, y: f64, scale: f64) -> bool {
fn identity(a: &Prim, b: &Prim) -> Result<(), TestCaseError> {
let (ba, bb) = (a.body(), b.body());
let (va, vb) = (vol(&ba), vol(&bb));
let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union)?, run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?);
prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb);
let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union), run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?);
if edge_contact_refusal(a, b, BoolOp::Union, &vu) {
prop_assert!(close(vi, 0.0, va + vb), "edge-only contact must have no intersection volume");
} else {
let vu = vu?;
prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb);
}
prop_assert!(close(vc, va - vi, va + vb), "cut {vc:.3} != A-I {:.3}", va - vi);
Ok(())
}
@@ -137,7 +154,11 @@ proptest! {
let (ba, bb) = (a.body(), b.body());
let s = vol(&ba) + vol(&bb);
for op in [BoolOp::Union, BoolOp::Intersect] {
let (ab, ba_) = (run(&ba, &bb, op)?, run(&bb, &ba, op)?);
let (ab, ba_) = (run(&ba, &bb, op), run(&bb, &ba, op));
if edge_contact_refusal(&a, &b, op, &ab) && edge_contact_refusal(&a, &b, op, &ba_) {
continue;
}
let (ab, ba_) = (ab?, ba_?);
prop_assert!(close(ab, ba_, s), "{op:?}: A,B gives {ab:.4} but B,A gives {ba_:.4}");
}
}
@@ -152,7 +173,11 @@ proptest! {
let (ta, tb) = (mv(&ba), mv(&bb));
let s = vol(&ba) + vol(&bb);
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
let (here, there) = (run(&ba, &bb, op)?, run(&ta, &tb, op)?);
let (here, there) = (run(&ba, &bb, op), run(&ta, &tb, op));
if edge_contact_refusal(&a, &b, op, &here) && edge_contact_refusal(&a, &b, op, &there) {
continue;
}
let (here, there) = (here?, there?);
prop_assert!(close(here, there, s), "{op:?} at origin {here:.4}, moved by {off} gives {there:.4}");
}
}
@@ -169,7 +194,11 @@ proptest! {
let (ba, bb, ka, kb) = (a.body(), b.body(), sc(&a).body(), sc(&b).body());
let s = vol(&ba) + vol(&bb);
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
let (one, big) = (run(&ba, &bb, op)?, run(&ka, &kb, op)? / (k * k * k));
let (one, big) = (run(&ba, &bb, op), run(&ka, &kb, op));
if edge_contact_refusal(&a, &b, op, &one) && edge_contact_refusal(&a, &b, op, &big) {
continue;
}
let (one, big) = (one?, big? / (k * k * k));
prop_assert!(close(one, big, s), "{op:?} at scale 1 gives {one:.4}, at scale {k} gives {big:.4} (normalised)");
}
}
@@ -233,6 +262,23 @@ proptest! {
// Cases the property tests found, as plain tests. Ignored until the kernel bug is fixed: run them
// with `cargo test -p solidcraft-kernel --test boolean_props -- --ignored`.
/// The saved commutativity case is an intentional non-manifold join refusal,
/// not a failure to join overlapping volumes. Intersection and cut remain valid.
#[test]
fn edge_only_box_join_is_refused_in_both_orders() {
let a = Prim::Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] };
let b = Prim::Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] };
assert!(edge_only_boxes(&a, &b));
assert!(!edge_only_boxes(&a, &Prim::Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 5.0] }));
let (a, b) = (a.body(), b.body());
for (left, right) in [(&a, &b), (&b, &a)] {
let error = run(left, right, BoolOp::Union).expect_err("an edge-only join is non-manifold");
assert!(error.to_string().contains("zero-thickness edge contact"), "{error}");
assert_eq!(run(left, right, BoolOp::Intersect).unwrap(), 0.0);
assert!(close(run(left, right, BoolOp::Cut).unwrap(), vol(left), vol(left)));
}
}
fn check(r: Result<(), TestCaseError>) {
if let Err(e) = r {
panic!("{e}");
+4
View File
@@ -25,6 +25,8 @@ semver = { workspace = true }
sha2 = { workspace = true }
minisign-verify = "0.3"
zip = { workspace = true }
# Rasterises the icon SVGs (assets/icons/ui/design/svg) at the size they are drawn.
tiny-skia = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
# The offscreen viewport render test opens a device without a window.
@@ -32,3 +34,5 @@ pollster = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
# Reads assets/icons/ui/design/palette.toml and aliases.toml into compiled tables.
toml = { workspace = true }
+156
View File
@@ -36,6 +36,10 @@ fn main() {
}
println!("cargo:rerun-if-changed=build.rs");
scenario_tests();
if let Err(e) = icon_data() {
eprintln!("icon data: {e}");
std::process::exit(1);
}
}
/// `$OUT_DIR/scenario_tests.rs`: a `#[test]` per scenario file, named after it; a scenario with
@@ -67,3 +71,155 @@ fn scenario_tests() {
let path = std::path::Path::new(&std::env::var("OUT_DIR").unwrap_or_else(|_| ".".into())).join("scenario_tests.rs");
let _ = std::fs::write(path, out);
}
/// `assets/icons/ui/design`: the SVG masters, `palette.toml` (every colour role in light and dark,
/// and the accents) and `aliases.toml`, compiled into `$OUT_DIR/icon_data.rs` for `icon_svg.rs`.
/// A missing hex, a role an accent does not know or an alias to a missing master fails the build.
fn icon_data() -> Result<(), String> {
use std::fmt::Write as _;
let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?;
let root = std::path::Path::new(&manifest).join("../../assets/icons/ui/design");
let read = |name: &str| std::fs::read_to_string(root.join(name)).map_err(|e| format!("{name}: {e}"));
let table = |name: &str| read(name)?.parse::<toml::Table>().map_err(|e| format!("{name}: {e}"));
let hex = |v: Option<&toml::Value>, what: &str| -> Result<String, String> {
let s = v.and_then(toml::Value::as_str).ok_or_else(|| format!("{what}: missing hex"))?;
let ok = s.len() == 7 && s.starts_with('#') && s[1..].bytes().all(|b| b.is_ascii_hexdigit());
if ok { Ok(s.to_ascii_lowercase()) } else { Err(format!("{what}: `{s}` is not #rrggbb")) }
};
let rgb = |h: &str| format!("[0x{}, 0x{}, 0x{}]", &h[1..3], &h[3..5], &h[5..7]);
let palette = table("palette.toml")?;
let roles = palette.get("roles").and_then(toml::Value::as_table).ok_or("palette.toml: no [roles]")?;
let names: Vec<&String> = roles.keys().collect();
let mut out = String::new();
let _ = writeln!(out, "pub(crate) const ROLE_NAMES: &[&str] = &{:?};", names);
let (mut lights, mut darks, mut light_hex) = (Vec::new(), Vec::new(), Vec::new());
for (name, role) in roles {
let l = hex(role.get("light"), &format!("role {name} light"))?;
let d = hex(role.get("dark"), &format!("role {name} dark"))?;
lights.push(rgb(&l));
darks.push(rgb(&d));
light_hex.push(hex(role.get("source").or_else(|| role.get("light")), &format!("role {name} source"))?);
}
let _ = writeln!(out, "pub(crate) const ROLE_SOURCE_HEX: &[&str] = &{:?};", light_hex);
let _ = writeln!(out, "pub(crate) const PALETTE: [&[[u8; 3]]; 2] = [&[{}], &[{}]];", lights.join(", "), darks.join(", "));
let mut accent_names = Vec::new();
let mut accents = Vec::new();
if let Some(table) = palette.get("accents").and_then(toml::Value::as_table) {
for (acc, roles_of) in table {
let roles_of = roles_of.as_table().ok_or_else(|| format!("accent {acc}: not a table"))?;
let mut items = Vec::new();
for (role, v) in roles_of {
let idx = names.iter().position(|n| *n == role).ok_or_else(|| format!("accent {acc}: unknown role {role}"))?;
let v = if let Some(link) = v.get("use").and_then(toml::Value::as_str) {
roles_of.get(link).ok_or_else(|| format!("accent {acc}: unknown linked role {link}"))?
} else {
v
};
let l = hex(v.get("light"), &format!("accent {acc} {role} light"))?;
let d = hex(v.get("dark"), &format!("accent {acc} {role} dark"))?;
items.push(format!("({idx}, {}, {})", rgb(&l), rgb(&d)));
}
accent_names.push(acc.clone());
accents.push(format!("&[{}]", items.join(", ")));
}
}
let _ = writeln!(out, "pub(crate) const ACCENT_NAMES: &[&str] = &{:?};", accent_names);
let _ = writeln!(out, "pub(crate) const ACCENTS: &[&[(usize, [u8; 3], [u8; 3])]] = &[{}];", accents.join(", "));
// UI tokens and SVGs share the same primary strokes, including accessibility overrides.
for (constant, value) in [
("SKETCH_PRIMARY", palette.get("accents").and_then(|v| v.get("sketch"))),
("CONSTRAINT_PRIMARY", palette.get("accents").and_then(|v| v.get("constraint"))),
("CONSTRUCTION_PRIMARY", palette.get("accents").and_then(|v| v.get("construction"))),
("SKETCH_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("sketch"))),
("CONSTRUCTION_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("construction"))),
("CONSTRUCTION_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("construction"))),
("CONSTRAINT_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("constraint"))),
] {
let role = value.and_then(|v| v.get("active-3"));
let l = hex(role.and_then(|v| v.get("light")), constant)?;
let d = hex(role.and_then(|v| v.get("dark")), constant)?;
let _ = writeln!(out, "pub(crate) const {constant}: [[u8; 3]; 2] = [{}, {}];", rgb(&l), rgb(&d));
}
let mut variants = Vec::new();
if let Some(groups) = palette.get("variants").and_then(toml::Value::as_table) {
for (vision, group) in groups {
if !matches!(vision.as_str(), "red-green" | "blue-yellow") {
return Err(format!("unknown icon colour-vision group {vision}"));
}
for (accent, overrides) in group.as_table().ok_or("invalid variant group")? {
let a = accent_names.iter().position(|n| n == accent).ok_or_else(|| format!("variant: unknown accent {accent}"))?;
for (role, value) in overrides.as_table().ok_or("invalid variant roles")? {
let r = names.iter().position(|n| *n == role).ok_or_else(|| format!("variant: unknown role {role}"))?;
let l = hex(value.get("light"), "variant light")?;
let d = hex(value.get("dark"), "variant dark")?;
variants.push(format!("({vision:?}, {a}, {r}, {}, {})", rgb(&l), rgb(&d)));
}
}
}
}
let _ = writeln!(out, "pub(crate) const ACCENT_VARIANTS: &[(&str, usize, usize, [u8; 3], [u8; 3])] = &[{}];", variants.join(", "));
// The masters, by family.
let mut svgs = Vec::new();
let mut ids = std::collections::BTreeSet::new();
let svg_dir = root.join("svg");
println!("cargo:rerun-if-changed={}", root.display());
for fam in ["model", "small", "flat", "workspace"] {
let dir = svg_dir.join(fam);
let mut files: Vec<_> =
std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.filter_map(Result::ok).map(|e| e.path()).collect();
files.sort();
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == "svg")) {
let id = f.file_stem().and_then(|s| s.to_str()).ok_or("bad file name")?;
if fam != "small" {
ids.insert(id.to_owned());
}
let abs = f.canonicalize().map_err(|e| e.to_string())?;
println!("cargo:rerun-if-changed={}", abs.display());
svgs.push(format!("({id:?}, {fam:?}, include_str!({:?}))", abs.display().to_string()));
}
}
let _ = writeln!(out, "pub(crate) static SVGS: &[(&str, &str, &str)] = &[{}];", svgs.join(", "));
let aliases = table("aliases.toml")?;
let mut al = Vec::new();
if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) {
for (alias, v) in t {
let (master, accent) = match v {
toml::Value::String(m) => (m.clone(), String::new()),
toml::Value::Table(t) => (
t.get("master").and_then(toml::Value::as_str).ok_or_else(|| format!("alias {alias}: no master"))?.to_string(),
t.get("accent").and_then(toml::Value::as_str).unwrap_or("").to_string(),
),
_ => return Err(format!("alias {alias}: expected a string or a table")),
};
if !accent.is_empty() && !accent_names.contains(&accent) {
return Err(format!("alias {alias}: unknown accent {accent}"));
}
if !ids.contains(&master) {
return Err(format!("alias {alias}: unknown master {master}"));
}
al.push(format!("({alias:?}, {master:?}, {accent:?})"));
}
}
let _ = writeln!(out, "pub(crate) const ALIASES: &[(&str, &str, &str)] = &[{}];", al.join(", "));
if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) {
ids.extend(t.keys().cloned());
}
let mut defaults = std::collections::BTreeMap::new();
if let Some(groups) = palette.get("icon-accents").and_then(toml::Value::as_table) {
for (accent, icons) in groups {
if !accent_names.contains(accent) {
return Err(format!("icon-accents: unknown accent {accent}"));
}
for icon in icons.as_array().ok_or("icon-accents: expected icon array")? {
let icon = icon.as_str().ok_or("icon-accents: expected icon id")?;
if !ids.contains(icon) || defaults.insert(icon, accent).is_some() {
return Err(format!("icon-accents: unknown or repeated icon {icon}"));
}
}
}
}
let defaults: Vec<_> = defaults.iter().map(|(id, accent)| format!("({id:?}, {accent:?})")).collect();
let _ = writeln!(out, "pub(crate) const ICON_ACCENTS: &[(&str, &str)] = &[{}];", defaults.join(", "));
let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("icon_data.rs");
std::fs::write(dest, out).map_err(|e| e.to_string())
}
+1 -1
View File
@@ -1363,7 +1363,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
key: f.id.to_string(),
row_key: format!("p:{}", f.id),
label: f.name.clone(),
icon: "plane",
icon: crate::timeline::icon_of(&f.kind),
visible: !app.session.visibility.hidden_origin.contains(&f.name),
selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)),
color: None,
+7 -99
View File
@@ -2,11 +2,11 @@
//! axis tags, the surface analysis colours and every colour an icon draws. These tests hold that
//! the tokens equal what was drawn before, and that a new colour cannot skip them.
use egui::{Color32, Pos2, Rect, vec2};
use egui::Color32;
use solidcraft_engine::render::Rgb;
use solidcraft_engine::view::colors;
use crate::theme::{IconColors, Tokens};
use crate::theme::Tokens;
fn rgb(c: Rgb) -> Color32 {
Color32::from_rgb(c.0, c.1, c.2)
@@ -56,103 +56,10 @@ fn analysis_colours_reach_the_shader_in_its_order() {
assert_eq!(crate::gpu::analysis_colors(&striped)[32], 1.0);
}
/// Every name a `match` arm in the icon source draws.
fn icon_names() -> Vec<&'static str> {
let src = include_str!("../../../assets/icons/ui/source/icons.rs");
let body = src.split("#[cfg(test)]").next().unwrap_or(src);
let mut names = Vec::new();
for line in body.lines() {
let line = line.trim_start();
if !line.starts_with('"') {
continue;
}
let Some((pattern, _)) = line.split_once("=>") else { continue };
let pattern = pattern.split(" if ").next().unwrap_or(pattern);
for part in pattern.split('|') {
let part = part.trim();
if part.len() > 2 && part.starts_with('"') && part.ends_with('"') {
names.push(&part[1..part.len() - 1]);
}
}
}
names.sort_unstable();
names.dedup();
names
}
/// Marker icon colours: every field its own colour, from `seed`.
fn marker_icons(seed: u8) -> IconColors {
let c = |i: u8| Color32::from_rgb(seed, i, seed ^ i);
IconColors {
sheet: c(1),
sheet_2: c(2),
sheet_light: c(3),
sheet_line: c(4),
highlight: c(5),
glint: c(6),
folder: c(7),
plane: c(8),
fold: c(9),
stripe: c(10),
sky: c(11),
floor: c(12),
paint: [c(13), c(14), c(15)],
axes: [c(16), c(17), c(18)],
access: [c(19), c(20)],
draft: [c(21), c(22)],
curvature: [c(23), c(24), c(25)],
interference: c(26),
warning: c(27),
agent: c(28),
marker_halo: c(29),
}
}
/// The colours an icon draws (premultiplied `#RR_GG_BB_AA`, in drawing order).
fn drawn_colours(name: &str, seed: u8) -> Vec<String> {
let ctx = egui::Context::default();
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() };
let icons = marker_icons(seed);
let out = ctx.run_ui(input, |ui| {
let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0));
let ink = Color32::from_rgb(seed, 101, 1);
let fill = Color32::from_rgb(seed, 102, 2);
let accent = Color32::from_rgb(seed, 103, 3);
crate::icons::paint_with(ui.painter(), r, name, ink, fill, accent, &icons);
});
let text = format!("{:?}", out.shapes);
let b = text.as_bytes();
let mut colours = Vec::new();
for i in 0..b.len() {
let hex = |k: usize| b.get(i + k).is_some_and(u8::is_ascii_hexdigit);
if b[i] == b'#' && (1..=11).all(|k| if k % 3 == 0 { b.get(i + k) == Some(&b'_') } else { hex(k) }) {
colours.push(text[i..i + 12].to_string());
}
}
colours
}
/// "All icons past, present and future" follow the colour vision mode: an icon may draw only the
/// colours its caller passes (ink, fill, accent) and the `IconColors` tokens, or colours made
/// from them. Each icon is drawn twice with different marker colours; a colour that comes out the
/// same both times was written into the icon, and a mode could not reach it.
#[test]
fn every_icon_draws_only_colours_a_mode_can_reach() {
let names = icon_names();
assert!(names.len() > 150, "icon names were not found in the icon source: {}", names.len());
let mut fixed = Vec::new();
for name in &names {
let (a, b) = (drawn_colours(name, 40), drawn_colours(name, 200));
assert_eq!(a.len(), b.len(), "{name}: the two drawings differ in shape");
for (x, y) in a.iter().zip(&b) {
if x == y && !x.ends_with("_00") {
fixed.push(format!("{name}: {x}"));
}
}
}
fixed.dedup();
assert!(fixed.is_empty(), "icons that draw a colour of their own: {fixed:?}");
}
// Icons no longer take their colours from the caller or from `IconColors`: the SVG masters name
// palette roles (`assets/icons/ui/design/palette.toml`, light and dark, plus accents) that
// `icon_svg` resolves. `icon_svg`'s own tests hold that every master parses and that only the
// palette's colours exist; the colour vision modes do not reach them yet.
/// The most colour literals each UI file may hold, and why. None of them carries a meaning that a
/// colour vision mode must change. A file with more fails here: make the new colour a token in
@@ -171,6 +78,7 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("documents.rs", 4, "white hover overlays on document tabs"),
("frame.rs", 1, "the window shadow"),
("gizmo.rs", 2, "the gizmo's neutral grey ball"),
("icons.rs", 1, "the white tint that carries an icon image's opacity"),
("home.rs", 4, "white ink on accent buttons, and thumbnail tints"),
("ref_images.rs", 1, "a reference image's opacity tint"),
("sketch_palette.rs", 1, "white text on the accent button"),
+6 -5
View File
@@ -4,8 +4,9 @@
//! The instruments are checked first: CIEDE2000 on the 34 pairs Sharma, Wu and Dalal published
//! with their paper, Brettel on white and its own anchors.
//!
//! When a token edit breaks a mode here, give that token a value in the mode's table
//! (`assets/styles/ui/palettes.rs`) that passes.
//! When a token edit breaks a mode here, adjust its measured variant. Shared icon accents live
//! in `assets/icons/ui/design/palette.toml`; `assets/styles/ui/palettes.rs` links those generated
//! values to UI tokens and owns the remaining UI-only colour-vision adjustments.
use std::collections::BTreeSet;
@@ -627,14 +628,14 @@ fn each_mode_changes_only_its_table() {
"preview_sketch",
];
let mut red_green_light = red_green_both.to_vec();
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]);
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom"]);
for (dark, mode, want) in [
(false, ColorVision::Protan, set(&red_green_light)),
(true, ColorVision::Protan, set(&red_green_both)),
(false, ColorVision::Deutan, set(&red_green_light)),
(true, ColorVision::Deutan, set(&red_green_both)),
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "preview_sketch", "analysis.hatch"])),
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "glyph_text", "preview_sketch", "analysis.hatch"])),
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "preview_sketch", "analysis.hatch"])),
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "glyph_text", "preview_sketch", "analysis.hatch"])),
] {
let base = Tokens::themed(dark, ColorVision::Off);
assert_eq!(changed_fields(&base, &Tokens::themed(dark, mode)), want, "{mode:?}, dark {dark}");
+2 -2
View File
@@ -342,8 +342,8 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::Workspace => {
let rendering = crate::render_workspace::active(app);
let mut v = vec![
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.render", "Render", "camera")
act("ui.workspace.design", "Design", "ws_design").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.render", "Render", "ws_render")
.with(json!({"workspace":"render"}))
.key(if rendering { "current" } else { "" })
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
+6 -5
View File
@@ -108,11 +108,12 @@ impl SolveApp {
let _ = tx.send(());
})
.ok()?;
match rx.recv_timeout(wait) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
}
_ => None,
// Zero explicitly requests off-thread application, even if a fast worker
// finishes before this thread is scheduled again. Apply on the next frame.
if wait.is_zero() || matches!(rx.recv_timeout(wait), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) {
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
} else {
None
}
}
+894
View File
@@ -0,0 +1,894 @@
//! The UI icon renderer: the SVG masters in `assets/icons/ui/design/svg`, drawn at the pixel size
//! they are shown.
//!
//! The masters are a small, linted SVG subset (`tools/lint_icons.py`): absolute `M L C Q A Z`
//! paths, circles, groups with a clip or opacity, masks and clip paths. They are embedded at build
//! time together with `palette.toml` (every colour role in light and dark, and the accents) and
//! `aliases.toml` (see `build.rs`), parsed once, and rasterised with tiny-skia into a texture per
//! (icon, accent, theme, pixel size). A master never carries a colour of its own: it names a
//! palette role, which resolves to the theme's hex, or to the accent's hex for the `active-*`
//! roles. GAP instead erases preceding artwork to transparency; it never paints a background.
//! Changing a colour is an edit to `palette.toml` only.
//!
//! Nothing here panics on bad artwork: a master that does not parse is logged and drawn as the
//! placeholder, and `every_master_parses` holds that none do.
use std::collections::HashMap;
use std::sync::OnceLock;
use tiny_skia as sk;
include!(concat!(env!("OUT_DIR"), "/icon_data.rs"));
/// The id drawn for every icon that has no master yet.
pub const PLACEHOLDER: &str = "placeholder";
/// How a shape is painted.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Paint {
None,
/// Erase earlier artwork; never paint a theme background into the texture.
Knockout,
/// `currentColor`: the `line` role.
Line,
/// A palette role (index into `ROLE_NAMES`).
Role(usize),
/// A fixed colour (mask artwork).
Fixed([u8; 3]),
}
#[derive(Debug)]
struct Node {
path: sk::Path,
fill: Paint,
rule: sk::FillRule,
stroke: Paint,
width: f32,
dash: Vec<f32>,
opacity: f32,
mask: Option<usize>,
}
#[derive(Debug)]
enum Item {
Node(Node),
Group { clip: Option<usize>, opacity: f32, items: Vec<Item> },
}
/// A parsed master.
#[derive(Debug)]
pub struct Doc {
items: Vec<Item>,
masks: Vec<Vec<Node>>,
clips: Vec<Vec<Node>>,
}
// ---------------------------------------------------------------------------------------------
// Parsing
struct Tag<'a> {
name: &'a str,
attrs: Vec<(&'a str, &'a str)>,
close: bool,
selfclose: bool,
}
impl Tag<'_> {
fn get(&self, k: &str) -> Option<&str> {
self.attrs.iter().find(|(n, _)| *n == k).map(|(_, v)| *v)
}
fn num(&self, k: &str) -> Result<f32, String> {
self.get(k).ok_or_else(|| format!("missing {k}"))?.trim().parse::<f32>().map_err(|e| format!("{k}: {e}"))
}
}
fn tags(src: &str) -> Result<Vec<Tag<'_>>, String> {
let mut out = Vec::new();
let mut rest = src;
while let Some(i) = rest.find('<') {
let after = rest.get(i + 1..).ok_or("truncated")?;
let j = after.find('>').ok_or("unterminated tag")?;
let body = after.get(..j).ok_or("truncated")?;
rest = after.get(j + 1..).ok_or("truncated")?;
if body.starts_with('?') || body.starts_with('!') {
continue;
}
let (close, body) = match body.strip_prefix('/') {
Some(b) => (true, b),
None => (false, body),
};
let (selfclose, body) = match body.strip_suffix('/') {
Some(b) => (true, b),
None => (false, body),
};
let body = body.trim();
let name_end = body.find(char::is_whitespace).unwrap_or(body.len());
let name = body.get(..name_end).ok_or("bad tag")?;
let mut attrs = Vec::new();
let mut a = body.get(name_end..).unwrap_or("");
while let Some(eq) = a.find('=') {
let key = a.get(..eq).ok_or("bad attribute")?.trim();
let v = a.get(eq + 1..).ok_or("bad attribute")?.trim_start();
let q = v.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute")?;
let v = v.get(1..).ok_or("bad attribute")?;
let end = v.find(q).ok_or("unterminated attribute")?;
attrs.push((key, v.get(..end).ok_or("bad attribute")?));
a = v.get(end + 1..).unwrap_or("");
}
out.push(Tag { name, attrs, close, selfclose });
}
Ok(out)
}
/// An SVG elliptical arc as cubic Béziers appended to `pb`.
#[allow(clippy::too_many_arguments)]
fn arc(pb: &mut sk::PathBuilder, x1: f32, y1: f32, rx: f32, ry: f32, rot_deg: f32, large: bool, sweep: bool, x2: f32, y2: f32) {
let (mut rx, mut ry) = (rx.abs(), ry.abs());
if rx < 1e-6 || ry < 1e-6 || ((x1 - x2).abs() < 1e-6 && (y1 - y2).abs() < 1e-6) {
pb.line_to(x2, y2);
return;
}
let phi = rot_deg.to_radians();
let (sp, cp) = phi.sin_cos();
let (dx, dy) = ((x1 - x2) / 2.0, (y1 - y2) / 2.0);
let (x1p, y1p) = (cp * dx + sp * dy, -sp * dx + cp * dy);
let lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
if lam > 1.0 {
let s = lam.sqrt();
rx *= s;
ry *= s;
}
let num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
let sign = if large == sweep { -1.0 } else { 1.0 };
let coef = sign * (num / den).max(0.0).sqrt();
let (cxp, cyp) = (coef * rx * y1p / ry, -coef * ry * x1p / rx);
let (cx, cy) = (cp * cxp - sp * cyp + (x1 + x2) / 2.0, sp * cxp + cp * cyp + (y1 + y2) / 2.0);
let ang = |ux: f32, uy: f32, vx: f32, vy: f32| (ux * vy - uy * vx).atan2(ux * vx + uy * vy);
let (ux, uy) = ((x1p - cxp) / rx, (y1p - cyp) / ry);
let (vx, vy) = ((-x1p - cxp) / rx, (-y1p - cyp) / ry);
let theta = ang(1.0, 0.0, ux, uy);
let mut dtheta = ang(ux, uy, vx, vy);
if !sweep && dtheta > 0.0 {
dtheta -= std::f32::consts::TAU;
} else if sweep && dtheta < 0.0 {
dtheta += std::f32::consts::TAU;
}
let n = ((dtheta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8);
let delta = dtheta / n as f32;
let t = 4.0 / 3.0 * (delta / 4.0).tan();
let map = |x: f32, y: f32| (cx + rx * cp * x - ry * sp * y, cy + rx * sp * x + ry * cp * y);
for i in 0..n {
let a1 = theta + delta * i as f32;
let a2 = a1 + delta;
let (s1, c1) = a1.sin_cos();
let (s2, c2) = a2.sin_cos();
let (p1x, p1y) = map(c1 - t * s1, s1 + t * c1);
let (p2x, p2y) = map(c2 + t * s2, s2 - t * c2);
let (ex, ey) = map(c2, s2);
pb.cubic_to(p1x, p1y, p2x, p2y, ex, ey);
}
}
fn parse_path(d: &str) -> Result<sk::Path, String> {
let mut toks: Vec<&str> = Vec::new();
let b = d.as_bytes();
let mut i = 0;
while i < b.len() {
let c = *b.get(i).ok_or("path")?;
if c.is_ascii_alphabetic() {
toks.push(d.get(i..=i).ok_or("path")?);
i += 1;
} else if c == b'-' || c == b'.' || c.is_ascii_digit() {
let s = i;
i += 1;
while let Some(&n) = b.get(i) {
if n.is_ascii_digit()
|| n == b'.'
|| ((n == b'-' || n == b'+') && matches!(b.get(i - 1), Some(b'e' | b'E')))
|| n == b'e'
|| n == b'E'
{
i += 1;
} else {
break;
}
}
toks.push(d.get(s..i).ok_or("path")?);
} else {
i += 1;
}
}
let mut pb = sk::PathBuilder::new();
let (mut k, mut cmd) = (0usize, ' ');
let (mut x, mut y) = (0.0f32, 0.0f32);
let mut open = false;
let num = |k: &mut usize| -> Result<f32, String> {
let t = toks.get(*k).ok_or("path: missing number")?;
*k += 1;
t.parse::<f32>().map_err(|e| format!("path: {e}"))
};
while k < toks.len() {
let t = *toks.get(k).ok_or("path")?;
if t.len() == 1 && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) {
cmd = t.chars().next().ok_or("path")?;
k += 1;
}
match cmd {
'M' => {
(x, y) = (num(&mut k)?, num(&mut k)?);
pb.move_to(x, y);
open = true;
cmd = 'L';
}
'L' => {
(x, y) = (num(&mut k)?, num(&mut k)?);
pb.line_to(x, y);
}
'C' => {
let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?];
pb.cubic_to(v[0], v[1], v[2], v[3], v[4], v[5]);
(x, y) = (v[4], v[5]);
}
'Q' => {
let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?];
pb.quad_to(v[0], v[1], v[2], v[3]);
(x, y) = (v[2], v[3]);
}
'A' => {
let (rx, ry, rot) = (num(&mut k)?, num(&mut k)?, num(&mut k)?);
let (fa, fs) = (num(&mut k)? != 0.0, num(&mut k)? != 0.0);
let (ex, ey) = (num(&mut k)?, num(&mut k)?);
arc(&mut pb, x, y, rx, ry, rot, fa, fs, ex, ey);
(x, y) = (ex, ey);
}
'Z' => {
if open {
pb.close();
}
}
other => return Err(format!("path command {other} is not supported")),
}
}
pb.finish().ok_or_else(|| "empty path".to_string())
}
fn parse_paint(v: Option<&str>, in_mask: bool) -> Result<Paint, String> {
let Some(v) = v else { return Ok(Paint::None) };
match v {
"none" => Ok(Paint::None),
"currentColor" => Ok(Paint::Line),
"white" if in_mask => Ok(Paint::Fixed([255, 255, 255])),
"black" if in_mask => Ok(Paint::Fixed([0, 0, 0])),
_ => {
let lower = v.to_ascii_lowercase();
ROLE_SOURCE_HEX
.iter()
.position(|h| *h == lower)
.map(|role| if ROLE_NAMES.get(role) == Some(&"gap") { Paint::Knockout } else { Paint::Role(role) })
.ok_or_else(|| format!("colour {v} is not a palette colour"))
}
}
}
fn parse_dash(v: Option<&str>) -> Vec<f32> {
let mut d: Vec<f32> = v.map(|s| s.split([' ', ',']).filter_map(|n| n.parse().ok()).collect()).unwrap_or_default();
if d.len() % 2 == 1 {
d.extend(d.clone());
}
d
}
fn url_ref(v: &str) -> Option<&str> {
v.strip_prefix("url(#")?.strip_suffix(')')
}
enum Frame {
Group { clip: Option<usize>, opacity: f32, items: Vec<Item> },
Mask(usize),
Clip(usize),
Defs,
}
impl Doc {
pub fn parse(src: &str) -> Result<Doc, String> {
let mut doc = Doc { items: Vec::new(), masks: Vec::new(), clips: Vec::new() };
let (mut mask_ids, mut clip_ids): (HashMap<String, usize>, HashMap<String, usize>) = (HashMap::new(), HashMap::new());
let mut stack: Vec<Frame> = Vec::new();
for t in tags(src)? {
match (t.name, t.close) {
("svg" | "title", _) => {}
("defs", false) => stack.push(Frame::Defs),
("mask", false) => {
doc.masks.push(Vec::new());
mask_ids.insert(t.get("id").ok_or("mask without id")?.to_string(), doc.masks.len() - 1);
stack.push(Frame::Mask(doc.masks.len() - 1));
}
("clipPath", false) => {
doc.clips.push(Vec::new());
clip_ids.insert(t.get("id").ok_or("clipPath without id")?.to_string(), doc.clips.len() - 1);
stack.push(Frame::Clip(doc.clips.len() - 1));
}
("g", false) => {
let clip = match t.get("clip-path").and_then(url_ref) {
Some(id) => Some(*clip_ids.get(id).ok_or("unknown clip")?),
None => None,
};
let opacity = t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0);
stack.push(Frame::Group { clip, opacity, items: Vec::new() });
}
("defs" | "mask" | "clipPath" | "g", true) => match stack.pop() {
Some(Frame::Group { clip, opacity, items }) => {
let item = Item::Group { clip, opacity, items };
match stack.last_mut() {
Some(Frame::Group { items, .. }) => items.push(item),
_ => doc.items.push(item),
}
}
Some(_) => {}
None => return Err("unbalanced tags".into()),
},
("path" | "circle", _) => {
let in_mask = matches!(stack.last(), Some(Frame::Mask(_)));
let path = if t.name == "path" {
parse_path(t.get("d").ok_or("path without d")?)?
} else {
sk::PathBuilder::from_circle(t.num("cx")?, t.num("cy")?, t.num("r")?).ok_or("bad circle")?
};
let mask = match t.get("mask").and_then(url_ref) {
Some(id) => Some(*mask_ids.get(id).ok_or("unknown mask")?),
None => None,
};
let dash = parse_dash(t.get("stroke-dasharray"));
let mut stroke = parse_paint(t.get("stroke"), in_mask)?;
let visible_stroke = match stroke {
Paint::Line => true,
Paint::Role(role) => !matches!(ROLE_NAMES.get(role).copied(), Some("gap" | "active-1" | "active-2" | "active-3")),
_ => false,
};
// Non-blue dotted/dashed references share the guide colour.
// Blue source paints retain their semantic accent; keep
// background knockouts and literal mask paints unchanged.
if !dash.is_empty() && visible_stroke && !in_mask {
stroke = Paint::Role(ROLE_NAMES.iter().position(|name| *name == "guide").ok_or("missing guide colour")?);
}
let node = Node {
path,
fill: if t.get("fill").is_none() && in_mask { Paint::None } else { parse_paint(t.get("fill").or(Some("none")), in_mask)? },
rule: if t.get("fill-rule") == Some("evenodd") { sk::FillRule::EvenOdd } else { sk::FillRule::Winding },
stroke,
width: t.get("stroke-width").and_then(|w| w.parse().ok()).unwrap_or(1.0),
dash,
opacity: t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0),
mask,
};
match stack.last_mut() {
Some(Frame::Mask(i)) => doc.masks.get_mut(*i).ok_or("mask")?.push(node),
Some(Frame::Clip(i)) => doc.clips.get_mut(*i).ok_or("clip")?.push(node),
Some(Frame::Group { items, .. }) => items.push(Item::Node(node)),
_ => doc.items.push(Item::Node(node)),
}
}
(other, _) => return Err(format!("element <{other}> is not supported")),
}
let _ = t.selfclose;
}
if !stack.is_empty() {
return Err("unbalanced tags".into());
}
Ok(doc)
}
}
// ---------------------------------------------------------------------------------------------
// Rendering
/// The colours of one drawing: the theme and an accent (index into `ACCENT_NAMES`, or `None` for
/// the base palette).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Look {
pub dark: bool,
pub accent: Option<usize>,
pub vision: crate::theme::ColorVision,
}
impl Look {
fn rgb(self, role: usize) -> [u8; 3] {
let theme = usize::from(self.dark);
if ROLE_NAMES.get(role) == Some(&"guide") {
return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]);
}
let vision = match self.vision {
crate::theme::ColorVision::Off => "",
crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green",
crate::theme::ColorVision::Tritan => "blue-yellow",
};
if let Some((_, _, _, l, d)) = ACCENT_VARIANTS.iter().find(|(v, a, r, _, _)| *v == vision && Some(*a) == self.accent && *r == role) {
return if self.dark { *d } else { *l };
}
if let Some((_, l, d)) = self.accent.and_then(|a| ACCENTS.get(a)).and_then(|items| items.iter().find(|(r, _, _)| *r == role)) {
return if self.dark { *d } else { *l };
}
PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255])
}
fn resolve(self, p: Paint) -> Option<[u8; 3]> {
match p {
Paint::None | Paint::Knockout => None,
Paint::Fixed(c) => Some(c),
Paint::Role(r) => Some(self.rgb(r)),
Paint::Line => ROLE_NAMES.iter().position(|n| *n == "line").map(|r| self.rgb(r)),
}
}
}
fn paint_of(c: [u8; 3], alpha: f32) -> sk::Paint<'static> {
let mut p = sk::Paint::default();
p.set_color_rgba8(c[0], c[1], c[2], (alpha.clamp(0.0, 1.0) * 255.0).round() as u8);
p.anti_alias = true;
p
}
fn shape_paint(paint: Paint, look: Look, alpha: f32) -> Option<sk::Paint<'static>> {
if paint == Paint::Knockout {
let mut paint = paint_of([0, 0, 0], alpha);
paint.blend_mode = sk::BlendMode::DestinationOut;
Some(paint)
} else {
look.resolve(paint).map(|colour| paint_of(colour, alpha))
}
}
fn draw_node(pm: &mut sk::Pixmap, n: &Node, look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) {
let own;
let mask = if let Some(m) = n.mask {
own = doc.masks.get(m).and_then(|nodes| {
let mut sub = sk::Pixmap::new(pm.width(), pm.height())?;
for node in nodes {
draw_node(&mut sub, node, look, tf, doc, None, 1.0);
}
let mut own = sk::Mask::from_pixmap(sub.as_ref(), sk::MaskType::Luminance);
if let Some(parent) = clip {
for (value, parent) in own.data_mut().iter_mut().zip(parent.data()) {
*value = ((u16::from(*value) * u16::from(*parent) + 127) / 255) as u8;
}
}
Some(own)
});
own.as_ref()
} else {
clip
};
let alpha = n.opacity * opacity;
if let Some(paint) = shape_paint(n.fill, look, alpha) {
pm.fill_path(&n.path, &paint, n.rule, tf, mask);
}
if let Some(paint) = shape_paint(n.stroke, look, alpha) {
let stroke = sk::Stroke {
width: n.width,
line_cap: sk::LineCap::Round,
line_join: sk::LineJoin::Round,
dash: if n.dash.is_empty() { None } else { sk::StrokeDash::new(n.dash.clone(), 0.0) },
..sk::Stroke::default()
};
pm.stroke_path(&n.path, &paint, &stroke, tf, mask);
}
}
fn draw_items(pm: &mut sk::Pixmap, items: &[Item], look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) {
for item in items {
match item {
Item::Node(n) => draw_node(pm, n, look, tf, doc, clip, opacity),
Item::Group { clip: c, opacity: o, items } => {
let mask = c.and_then(|c| doc.clips.get(c)).and_then(|nodes| {
let mut m = sk::Mask::new(pm.width(), pm.height())?;
for n in nodes {
m.fill_path(&n.path, n.rule, true, tf);
}
Some(m)
});
draw_items(pm, items, look, tf, doc, mask.as_ref().or(clip), opacity * o);
}
}
}
}
/// Rasterise `doc` into a square `px` × `px` premultiplied RGBA image.
pub fn render(doc: &Doc, look: Look, px: u32) -> Option<sk::Pixmap> {
let mut pm = sk::Pixmap::new(px, px)?;
let s = px as f32 / 64.0;
draw_items(&mut pm, &doc.items, look, sk::Transform::from_scale(s, s), doc, None, 1.0);
Some(pm)
}
// ---------------------------------------------------------------------------------------------
// The icon table
struct Entry {
doc: Doc,
}
fn table() -> &'static (HashMap<&'static str, Entry>, HashMap<&'static str, Entry>) {
static T: OnceLock<(HashMap<&'static str, Entry>, HashMap<&'static str, Entry>)> = OnceLock::new();
T.get_or_init(|| {
let (mut big, mut small) = (HashMap::new(), HashMap::new());
for (id, fam, src) in SVGS {
match Doc::parse(src) {
Ok(doc) => {
let map = if *fam == "small" { &mut small } else { &mut big };
// Model masters precede flat glyphs: keep the reviewed command artwork.
map.entry(*id).or_insert(Entry { doc });
}
Err(e) => log::warn!("icon {fam}/{id}: {e}"),
}
}
(big, small)
})
}
/// A resolved icon: the master to draw and the accent it is drawn in.
#[derive(Clone, Copy, Debug)]
pub struct Resolved {
pub master: &'static str,
pub accent: Option<usize>,
pub placeholder: bool,
}
fn accent_index(name: &str) -> Option<usize> {
if name.is_empty() { None } else { ACCENT_NAMES.iter().position(|a| *a == name) }
}
/// A semantic default belongs to the requested id, never to the caller's current workspace.
fn default_accent(name: &str) -> Option<usize> {
ICON_ACCENTS.iter().find(|(id, _)| *id == name).and_then(|(_, accent)| accent_index(accent))
}
/// Primary semantic stroke, also used by toolbar affordances. The palette owns its value.
pub(crate) fn primary(name: &str, dark: bool, vision: crate::theme::ColorVision) -> egui::Color32 {
let look = Look { dark, accent: resolve(name).accent, vision };
role_colour("active-3", look)
}
pub(crate) fn role_colour(name: &str, look: Look) -> egui::Color32 {
if name == "gap" {
return egui::Color32::TRANSPARENT;
}
let role = ROLE_NAMES.iter().position(|n| *n == name);
let [r, g, b] = role.map(|role| look.rgb(role)).unwrap_or([255, 0, 255]);
crate::theme::icon_color([r, g, b])
}
/// The master and accent for an app icon id: an alias (with its accent), a master of that name,
/// or the placeholder.
pub fn resolve(name: &str) -> Resolved {
let (big, _) = table();
let placeholder = Resolved { master: PLACEHOLDER, accent: None, placeholder: true };
if let Some((_, master, accent)) = ALIASES.iter().find(|(a, _, _)| *a == name) {
return match big.get_key_value(master) {
Some((k, _)) => Resolved { master: k, accent: accent_index(accent).or_else(|| default_accent(name)), placeholder: false },
None => placeholder,
};
}
match big.get_key_value(name) {
Some((k, _)) if name != PLACEHOLDER => Resolved { master: k, accent: default_accent(name), placeholder: false },
_ => placeholder,
}
}
/// The master to draw at `px` pixels: the small-slot variant where one exists.
pub fn doc_for(master: &str, px: u32) -> Option<&'static Doc> {
let (big, small) = table();
if let Some(e) = small.get(master).filter(|_| px <= 24) {
return Some(&e.doc);
}
big.get(master).or_else(|| big.get(PLACEHOLDER)).map(|e| &e.doc)
}
/// Every master id (not the small variants), for tests and tooling.
pub fn master_ids() -> Vec<&'static str> {
let mut v: Vec<_> = table().0.keys().copied().collect();
v.sort_unstable();
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_master_parses() {
for (id, fam, src) in SVGS {
if let Err(e) = Doc::parse(src) {
panic!("{fam}/{id}: {e}");
}
}
assert!(table().0.contains_key(PLACEHOLDER), "the placeholder master exists");
}
#[test]
fn aliases_point_at_masters_and_known_accents() {
for (alias, master, accent) in ALIASES {
assert!(table().0.contains_key(master), "{alias} -> {master}");
assert!(accent.is_empty() || ACCENT_NAMES.contains(accent), "{alias}: accent {accent}");
assert!(!resolve(alias).placeholder);
}
}
#[test]
fn non_blue_dashed_strokes_use_guides_but_preserve_accents_and_knockouts() {
let svg = "<svg><path d='M8 20L56 20' stroke='#8e9faf' stroke-width='4.25' stroke-dasharray='3 5'/><path d='M8 40L56 40' stroke='#ffffff' stroke-width='6' stroke-dasharray='3 5'/><path d='M8 52L56 52' stroke='#236ead' stroke-width='4.25' stroke-dasharray='3 5'/></svg>";
let doc = Doc::parse(svg).unwrap();
let guide = ROLE_NAMES.iter().position(|name| *name == "guide").unwrap();
let active = ROLE_NAMES.iter().position(|name| *name == "active-3").unwrap();
let Item::Node(line) = &doc.items[0] else { panic!("expected line") };
let Item::Node(knockout) = &doc.items[1] else { panic!("expected knockout") };
let Item::Node(blue) = &doc.items[2] else { panic!("expected blue line") };
assert_eq!(line.stroke, Paint::Role(guide));
assert_eq!(knockout.stroke, Paint::Knockout);
assert_eq!(blue.stroke, Paint::Role(active));
for dark in [false, true] {
for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) {
let look = Look { dark, accent, vision: crate::theme::ColorVision::Off };
assert_eq!(look.rgb(guide), PALETTE[usize::from(dark)][guide]);
let pm = render(&doc, look, 64).unwrap();
assert_eq!([pm.pixel(9, 20).unwrap().red(), pm.pixel(9, 20).unwrap().green(), pm.pixel(9, 20).unwrap().blue()], look.rgb(guide));
assert_eq!([pm.pixel(9, 52).unwrap().red(), pm.pixel(9, 52).unwrap().green(), pm.pixel(9, 52).unwrap().blue()], look.rgb(active));
}
}
}
#[test]
fn knockout_fills_and_strokes_erase_earlier_layers_and_allow_later_foreground() {
let doc = Doc::parse("<svg><path d='M0 0L64 0L64 64L0 64Z' fill='#8e9faf'/><circle cx='20' cy='20' r='10' fill='#ffffff'/><circle cx='20' cy='20' r='3' fill='#236ead'/><path d='M8 44L56 44' stroke='#ffffff' stroke-width='6'/><circle cx='44' cy='20' r='6' fill='#ffffff' opacity='0.5'/></svg>").unwrap();
for dark in [false, true] {
for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) {
let look = Look { dark, accent, vision: crate::theme::ColorVision::Off };
let pm = render(&doc, look, 64).unwrap();
assert_eq!(pm.pixel(26, 20).unwrap().alpha(), 0, "fill must reveal the real background");
assert_eq!(pm.pixel(20, 44).unwrap().alpha(), 0, "stroke must reveal the real background");
assert_eq!(pm.pixel(20, 20).unwrap().alpha(), 255, "foreground is drawn after erasure");
assert_eq!(pm.pixel(2, 2).unwrap().alpha(), 255, "unaffected base stays opaque");
assert!((126..=128).contains(&pm.pixel(44, 20).unwrap().alpha()), "partial opacity erases partially");
}
}
}
#[test]
fn knockout_respects_its_clip_and_combines_explicit_masks_with_clips() {
let doc = Doc::parse("<svg><defs><clipPath id='left'><path d='M0 0L32 0L32 64L0 64Z'/></clipPath><mask id='all'><path d='M0 0L64 0L64 64L0 64Z' fill='white'/></mask></defs><path d='M0 0L64 0L64 64L0 64Z' fill='#8e9faf'/><g clip-path='url(#left)'><circle cx='32' cy='32' r='16' fill='#ffffff' mask='url(#all)'/></g></svg>").unwrap();
let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap();
assert_eq!(pm.pixel(24, 32).unwrap().alpha(), 0);
assert_eq!(pm.pixel(40, 32).unwrap().alpha(), 255, "cutout must not escape its parent clip");
}
#[test]
fn accepted_badges_and_environment_sun_have_transparent_holes() {
for dark in [false, true] {
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
assert_eq!(role_colour("gap", look), egui::Color32::TRANSPARENT);
for (id, x, y) in [("contact_new", 48, 48), ("environment_map", 45, 19), ("line_mid", 35, 28)] {
let pm = render(doc_for(id, 64).unwrap(), look, 64).unwrap();
assert_eq!(pm.pixel(x, y).unwrap().alpha(), 0, "{id}: GAP must not be a painted theme colour");
}
}
}
#[test]
fn native_clip_paths_and_plus_mask_keep_the_artwork_inside_its_shape() {
for dark in [false, true] {
for px in [16, 24, 64] {
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
for id in ["environment_map", "curvature_map", "iso_analysis", "zebra"] {
let doc = doc_for(id, px).unwrap();
let pm = render(doc, look, px).unwrap();
assert_eq!(pm.pixel(px - 2, px - 2).unwrap().alpha(), 0, "{id} escapes its native clip");
assert!(pm.pixel(px / 2, px / 2).unwrap().alpha() > 0, "{id} lost its clipped contents");
}
}
let pm = render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap();
assert_eq!(pm.pixel(45, 45).unwrap().alpha(), 0, "the plus is genuinely punched out in the native renderer");
}
}
#[test]
fn command_meanings_choose_the_same_accent_on_every_surface() {
let accent = |name| resolve(name).accent.map(|i| ACCENT_NAMES[i]);
for c in solidcraft_engine::command_specs() {
let want = if c.id.starts_with("surface.") {
Some("surface")
} else if c.panel == "CONSTRAINTS" {
Some("constraint")
} else if matches!(c.id, "sketch.construction" | "sketch.centerline") {
Some("construction")
} else if c.id.starts_with("sketch.") && matches!(c.panel, "CREATE" | "MODIFY" | "PATTERN" | "FINISH SKETCH") {
Some("sketch")
} else if c.id == "file.save_mesh" {
Some("mesh")
} else {
continue;
};
assert_eq!(accent(c.icon), want, "{} ({})", c.id, c.icon);
}
for id in ["move", "scale", "copy", "delete", "export", "ground", "axis_two_planes"] {
assert_eq!(accent(id), None, "shared utility / modelling icon {id}");
}
for (alias, master) in [("sketch_move", "move"), ("sketch_scale", "scale"), ("surface_fair", "spline_cv"), ("mesh_export", "export")] {
assert_eq!(resolve(alias).master, resolve(master).master, "reuse accepted shapes");
}
}
#[test]
fn semantic_ui_tokens_and_icon_strokes_share_the_palette_including_accessibility() {
for dark in [false, true] {
for vision in crate::theme::ColorVision::ALL {
let tokens = crate::theme::Tokens::themed(dark, vision);
assert_eq!(primary("line", dark, vision), tokens.sketch_accent);
assert_eq!(primary("c_parallel", dark, vision), tokens.glyph_text);
assert_eq!(primary("centerline", dark, vision), tokens.construction_geom);
assert_eq!(primary("construction", dark, vision), tokens.construction_geom);
}
}
}
#[test]
fn semantic_ramps_preserve_silhouettes_and_passive_faces_in_all_themes() {
use crate::theme::ColorVision;
for id in ["line", "c_parallel", "construction", "centerline", "sketch_rect_pattern", "surface_extrude", "mesh_export"] {
let resolved = resolve(id);
for dark in [false, true] {
for vision in ColorVision::ALL {
let look = Look { dark, accent: resolved.accent, vision };
let base = Look { accent: None, ..look };
for role in ["ink", "line", "passive-1", "passive-2", "passive-3", "gap"] {
let role = ROLE_NAMES.iter().position(|name| *name == role).unwrap();
assert_eq!(look.rgb(role), base.rgb(role), "{id} changes passive paint");
}
for px in [16, 24, 64] {
let doc = doc_for(resolved.master, px).unwrap();
let changed = render(doc, look, px).unwrap();
let original = render(doc, base, px).unwrap();
assert!(changed.pixels().iter().zip(original.pixels()).all(|(a, b)| a.alpha() == b.alpha()), "{id}: silhouette at {px}px");
}
}
}
}
}
#[test]
fn command_artwork_resolves_and_construct_methods_are_distinct() {
let specs = solidcraft_engine::command_specs();
for command in &specs {
assert!(!resolve(command.icon).placeholder, "{}: {}", command.id, command.icon);
}
let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect();
let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect();
assert_eq!(construction.len(), 19);
assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities");
}
#[test]
fn helper_controls_resolve_without_command_registry_entries() {
for name in ["agent_chat", "normal", "infinite", "pattern_flip", "pattern_center"] {
assert!(!resolve(name).placeholder, "UI helper: {name}");
}
}
#[test]
fn sketch_construction_modes_have_distinct_icons() {
let families: &[&[&str]] = &[
&["sketch.polygon.inscribed", "sketch.polygon.circumscribed", "sketch.polygon.edge"],
&["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"],
&["sketch.slot.arc_three_point", "sketch.slot.arc_center"],
&["sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"],
];
for commands in families {
let icons: std::collections::HashSet<_> = commands.iter().map(|id| solidcraft_engine::find_command(id).unwrap().icon).collect();
assert_eq!(icons.len(), commands.len(), "Sketch modes share an icon: {commands:?}");
for icon in icons {
assert!(!resolve(icon).placeholder, "{icon}");
}
}
}
#[test]
fn contact_actions_have_distinct_icons_from_interference_analysis() {
let mut icons = std::collections::HashSet::new();
let interference = solidcraft_engine::find_command("inspect.interference").unwrap().icon;
for id in ["contact.enable_sets", "contact.enable_all", "contact.disable_all", "contact.create"] {
let icon = solidcraft_engine::find_command(id).unwrap().icon;
assert_ne!(icon, interference, "{id} is a contact control, not an interference result");
assert!(icons.insert(icon), "contact controls share an icon: {id}");
assert!(!resolve(icon).placeholder, "{id}: {icon}");
}
}
#[test]
fn reviewed_model_folder_is_also_the_folder_drawn_in_the_app() {
let source = SVGS.iter().find(|(id, fam, _)| *id == "folder" && *fam == "model").unwrap().2;
let expected = Doc::parse(source).unwrap();
for dark in [false, true] {
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
let actual = render(doc_for("folder", 64).unwrap(), look, 64).unwrap();
let reviewed = render(&expected, look, 64).unwrap();
assert_eq!(actual.data(), reviewed.data());
}
}
#[test]
fn unknown_names_draw_the_placeholder() {
assert!(resolve("no_such_icon").placeholder);
assert!(resolve("").placeholder);
assert!(!resolve("extrude").placeholder);
}
fn coverage(pm: &sk::Pixmap) -> usize {
pm.pixels().iter().filter(|p| p.alpha() > 0).count()
}
#[test]
fn every_master_draws_something_at_every_size_in_both_themes() {
for id in master_ids() {
for dark in [false, true] {
for px in [16, 20, 26, 64] {
let Some(doc) = doc_for(id, px) else { panic!("{id}") };
let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, px)
.unwrap_or_else(|| panic!("{id} at {px}"));
assert!(coverage(&pm) > 8, "{id} at {px}px (dark {dark}) is blank");
}
}
}
}
fn rgba(pm: &sk::Pixmap) -> Vec<u8> {
pm.data().to_vec()
}
#[test]
fn dark_theme_and_accents_change_colour_not_shape() {
let doc = doc_for("extrude", 64).unwrap_or_else(|| panic!("extrude"));
let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
assert_ne!(rgba(&light), rgba(&dark));
let r = resolve("surface_extrude");
assert_eq!(r.master, "extrude");
let surface =
render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
assert!(r.accent.is_some());
assert_ne!(rgba(&light), rgba(&surface));
// Same silhouette: the alpha channel is identical.
let alpha = |pm: &sk::Pixmap| pm.pixels().iter().map(|p| p.alpha()).collect::<Vec<_>>();
assert_eq!(alpha(&light), alpha(&surface));
}
#[test]
fn hostile_artwork_is_an_error_not_a_panic() {
for bad in [
"",
"<",
"<svg",
"<svg><path d=\"M 1\"/></svg>",
"<svg><path d=\"X\"/></svg>",
"<svg><g></svg>",
"<svg><rect/></svg>",
"<svg><path d=\"M 0 0 L 1 1\" fill=\"#123456\"/></svg>",
"<svg><path d='M0 0 L 1 1' mask='url(#x)'/></svg>",
] {
let _ = Doc::parse(bad);
}
assert!(Doc::parse("<svg><path d=\"M 1\"/></svg>").is_err());
assert!(Doc::parse("<svg><g></svg>").is_err());
}
#[test]
fn palette_roles_are_distinct_and_complete() {
assert_eq!(ROLE_NAMES.len(), ROLE_SOURCE_HEX.len());
let mut seen = std::collections::HashSet::new();
assert!(
ROLE_NAMES.iter().zip(ROLE_SOURCE_HEX).filter(|(n, _)| **n != "line").all(|(_, h)| seen.insert(*h)),
"two roles share a source identifier"
);
assert!(ROLE_NAMES.contains(&"line"));
assert_eq!(PALETTE.len(), 2);
assert!(PALETTE.iter().all(|p| p.len() == ROLE_NAMES.len()));
}
}
+106 -6
View File
@@ -1,9 +1,16 @@
//! Stable UI rendering boundary for the collected artwork. Compiled only at L5.
#[path = "../../../assets/icons/ui/source/icons.rs"]
mod source;
pub use source::paint;
#[cfg(test)]
pub(crate) use source::paint_with;
//! Stable UI rendering boundary for the icon artwork. Compiled only at L5.
//!
//! Command, menu, browser and timeline icons are the SVG masters in `assets/icons/ui/design/svg`,
//! rendered by [`crate::icon_svg`]: colours come from `palette.toml` (light and dark, and the
//! accents), never from the caller. An icon with no master yet draws the placeholder.
//! The glyph, marker, navigation and pointer sources below are not command icons.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use egui::{Color32, Painter, Rect, TextureHandle, TextureOptions, pos2};
use crate::icon_svg::{self, Look};
#[path = "../../../assets/icons/ui/source/glyphs.rs"]
pub(crate) mod glyphs;
#[path = "../../../assets/icons/ui/source/markers.rs"]
@@ -12,3 +19,96 @@ pub(crate) mod markers;
pub(crate) mod navigation;
#[path = "../../../assets/icons/ui/source/pointer.rs"]
pub(crate) mod pointer;
/// Textures already rasterised: one per (master, look, pixel size).
#[derive(Clone, Default)]
struct Cache(Arc<Mutex<HashMap<(&'static str, Look, u32), TextureHandle>>>);
/// Draw the icon `name` in `r`. The ink, fill and accent colours the callers pass are no longer
/// used: the palette decides. Only the ink's opacity is kept, so a dimmed (disabled) button dims
/// its icon.
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, _fill: Color32, _accent: Color32) {
paint_icon(p, r, name, f32::from(ink.a()) / 255.0);
}
/// [`paint`] with an explicit opacity (0 to 1).
pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) {
paint_on(p, r, name, None, opacity);
}
/// Draw the icon `name` for a surface that is always dark (the application bar), whatever the
/// theme: `dark: Some(true)`. `None` follows the theme.
pub fn paint_on(p: &Painter, r: Rect, name: &str, dark: Option<bool>, opacity: f32) {
let ctx = p.ctx();
let ppp = ctx.pixels_per_point().max(0.5);
let side = r.width().min(r.height());
let px = (side * ppp).round().clamp(8.0, 512.0) as u32;
let resolved = icon_svg::resolve(name);
let look = Look { dark: dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), accent: resolved.accent, vision: crate::theme::vision() };
let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone());
let Ok(mut map) = cache.0.lock() else { return };
let key = (resolved.master, look, px);
if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(key) {
let Some(doc) = icon_svg::doc_for(resolved.master, px) else { return };
let Some(pm) = icon_svg::render(doc, look, px) else { return };
let image = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data());
slot.insert(ctx.load_texture(format!("icon:{}:{px}", resolved.master), image, TextureOptions::LINEAR));
}
let Some(tex) = map.get(&key) else { return };
// Snap the image to whole physical pixels so it stays crisp.
let size = px as f32 / ppp;
let min = pos2(((r.center().x - size / 2.0) * ppp).round() / ppp, ((r.center().y - size / 2.0) * ppp).round() / ppp);
let tint = Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8);
p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{self, ColorVision};
#[test]
fn actual_uploaded_icon_pixels_follow_semantics_and_vision_cache_changes() {
let ctx = egui::Context::default();
let previous = theme::vision();
for dark in [false, true] {
for vision in [ColorVision::Off, ColorVision::Protan, ColorVision::Tritan] {
theme::set_vision(vision);
for icon in ["line", "c_parallel", "construction", "centerline", "surface_extrude", "mesh_export", "contact_new"] {
let expected = if icon == "construction" {
icon_svg::role_colour("guide", Look { dark, accent: None, vision })
} else {
icon_svg::primary(icon, dark, vision)
};
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let r = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0));
paint_on(ui.painter(), r, icon, Some(dark), 1.0);
});
assert!(
output.textures_delta.set.iter().any(|(_, deltas)| deltas.iter().any(|delta| match &delta.image {
egui::ImageData::Color(image) => image.pixels.contains(&expected),
})),
"{icon}, {dark}, {vision:?}: actual texture misses its semantic stroke or reused a stale cache entry"
);
if icon == "contact_new" {
let image = output
.textures_delta
.set
.values()
.flatten()
.find_map(|delta| match &delta.image {
egui::ImageData::Color(image) if image.size == [64, 64] => Some(image),
_ => None,
})
.unwrap();
assert_eq!(image.pixels.get(48 * 64 + 48), Some(&Color32::TRANSPARENT), "uploaded plus must stay transparent");
}
// This headless test inspects uploads instead of sending them
// to a GPU; acknowledge the deltas before they are dropped.
output.textures_delta.clear();
}
}
}
theme::set_vision(previous);
}
}
+1
View File
@@ -60,6 +60,7 @@ pub mod i18n;
mod i18n_menu_tests;
#[cfg(test)]
mod i18n_ui_tests;
pub mod icon_svg;
pub mod icons;
pub mod inference;
pub mod install;
+2 -1
View File
@@ -283,7 +283,8 @@ fn moved(x: &str, y: &str, angle: &str) -> Mod {
fn the_command_now_has_a_toolbar_placement_and_opens_a_window() {
let spec = solidcraft_engine::find_command("sketch.move").expect("sketch.move is registered");
assert_eq!((spec.tab, spec.panel), ("SKETCH", "MODIFY"));
assert_eq!(spec.icon, "move");
assert_eq!(spec.icon, "sketch_move");
assert_eq!(crate::icon_svg::resolve(spec.icon).master, "move", "reuse the finished Move drawing");
let mut h = rectangle();
let before = curve_ids(&h).len();
+29 -6
View File
@@ -3,7 +3,6 @@
//! uses comes from [`Tokens`].
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
#[path = "../../../assets/styles/ui/palettes.rs"]
mod palettes;
@@ -11,10 +10,11 @@ mod palettes;
use egui::{Color32, Stroke};
use serde::{Deserialize, Serialize};
static DARK: AtomicBool = AtomicBool::new(true);
thread_local! {
/// The colour vision mode, per thread, where the dark theme is one global (`DARK`). Each app
/// Theme state belongs to the UI thread drawing the frame. Concurrent app/test renderers
/// must not change one another's palette while a frame or icon texture is being built.
static DARK: Cell<bool> = const { Cell::new(true) };
/// The colour vision mode, per thread. Each app
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
/// with one global, a test in a mode changed the colours under another one
@@ -26,7 +26,12 @@ thread_local! {
/// Is the dark theme on?
pub fn is_dark() -> bool {
DARK.load(Ordering::Relaxed)
DARK.get()
}
/// The common conversion boundary for compiled icon palette data.
pub(crate) const fn icon_color(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
/// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for
@@ -333,7 +338,7 @@ pub fn material_color(name: &str) -> Option<Color32> {
/// Switch themes (the visuals follow on the next [`apply`]).
pub fn set_dark(dark: bool) {
DARK.store(dark, Ordering::Relaxed);
DARK.set(dark);
}
fn visuals(dark: bool) -> egui::Visuals {
@@ -373,6 +378,24 @@ pub fn apply(ctx: &egui::Context) {
#[cfg(test)]
mod tests {
#[test]
fn concurrent_ui_renderers_keep_their_own_theme_and_vision() {
let barrier = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
for (dark, vision) in [(false, super::ColorVision::Off), (true, super::ColorVision::Protan)] {
let barrier = &barrier;
scope.spawn(move || {
super::set_dark(dark);
super::set_vision(vision);
barrier.wait();
assert_eq!(super::is_dark(), dark);
assert_eq!(super::vision(), vision);
assert_eq!(super::Tokens::get().sketch_accent, super::Tokens::themed(dark, vision).sketch_accent);
});
}
});
}
/// Title and section bands stand out from a dialog, the title more than its sections.
#[test]
fn heading_bands_are_darker_than_the_dialog_in_order() {
+149 -8
View File
@@ -3,7 +3,10 @@
pub(crate) use crate::icons::glyphs::transport_glyph;
use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2};
use serde_json::json;
use solidcraft_engine::doc::FeatureKind;
use solidcraft_engine::doc::{
FeatureKind, PlaneRef,
construct::{AxisDef, PointDef},
};
use crate::theme::Tokens;
use crate::{SolveApp, icons};
@@ -13,14 +16,18 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
FeatureKind::SurfaceExtrude { .. } => "surface_extrude",
FeatureKind::Unstitch { .. } => "unstitch",
FeatureKind::ReverseNormal { .. } => "reverse_normal",
FeatureKind::Unstitch { .. } => "surface_unstitch",
FeatureKind::ReverseNormal { .. } => "surface_reverse_normal",
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
FeatureKind::SurfaceLoft { .. } => "surface_loft",
FeatureKind::SurfaceOffset { .. } => "surface_offset",
FeatureKind::SurfaceRuled { .. } => "ruled",
FeatureKind::SurfaceUntrim { .. } => "untrim",
FeatureKind::SurfaceRuled { .. } => "surface_ruled",
FeatureKind::SurfaceUntrim { .. } => "surface_untrim",
FeatureKind::SurfaceBlend { .. } => "surface_blend",
FeatureKind::SurfaceBoundary { .. } => "surface_boundary",
FeatureKind::SurfaceMatch { .. } => "continuity",
FeatureKind::SurfaceFair { .. } => "surface_fair",
FeatureKind::Revolve { .. } => "revolve",
FeatureKind::Fillet { .. } => "fillet",
FeatureKind::Chamfer { .. } => "chamfer",
@@ -30,11 +37,25 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Torus { .. } => "torus",
FeatureKind::Combine { .. } => "combine",
FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect",
FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Path { .. }, .. } => "pattern_path",
FeatureKind::Pattern { .. } => "pattern_circ",
FeatureKind::Mirror { .. } => "mirror",
FeatureKind::ConstructionPlane { .. } => "plane",
FeatureKind::ConstructionAxis { .. } => "axis",
FeatureKind::ConstructionPoint { .. } => "point",
FeatureKind::ConstructionPlane { plane } => construction_plane_icon(plane),
FeatureKind::ConstructionAxis { def } => match def {
AxisDef::ThroughFace { .. } => "axis_through_cylinder",
AxisDef::NormalToFace { .. } => "axis_normal_to_face",
AxisDef::TwoPlanes { .. } => "axis_two_planes",
AxisDef::TwoPoints { .. } => "axis_two_points",
AxisDef::Edge { .. } => "axis_edge",
},
FeatureKind::ConstructionPoint { def } => match def {
PointDef::At { .. } => "point_vertex",
PointDef::TwoEdges { .. } => "point_two_edges",
PointDef::ThreePlanes { .. } => "point_three_planes",
PointDef::Center { .. } => "point_center",
PointDef::EdgeAndPlane { .. } => "point_edge_plane",
PointDef::AlongPath { .. } => "point_along_path",
},
FeatureKind::Hole { .. } => "hole",
FeatureKind::Loft { .. } => "loft",
FeatureKind::Sweep { .. } => "sweep",
@@ -43,10 +64,43 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Split { .. } => "split",
FeatureKind::Move { .. } => "move",
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import",
FeatureKind::BodyTransfer { .. } => "move",
FeatureKind::Thread { .. } => "thread",
FeatureKind::SplitFace { .. } => "split_face",
FeatureKind::Scale { .. } => "scale",
FeatureKind::OffsetFace { .. } => "offset_face",
FeatureKind::DeleteFace { .. } => "delete_face",
FeatureKind::Remove { .. } => "delete",
FeatureKind::BoundingSolid { .. } => "bounding_solid",
FeatureKind::Pipe { .. } => "pipe",
FeatureKind::Emboss { .. } => "emboss",
FeatureKind::ReplaceFace { .. } => "replace_face",
FeatureKind::Align { .. } => "align",
FeatureKind::Coil { .. } => "coil",
FeatureKind::SheetPerforate { .. } => "perforate",
FeatureKind::BoundaryFill { .. } => "boundary_fill",
FeatureKind::Patch { .. } => "surface_patch",
FeatureKind::Stitch { .. } => "surface_stitch",
FeatureKind::Thicken { .. } => "surface_thicken",
FeatureKind::SurfaceExtend { .. } => "surface_extend",
FeatureKind::SurfaceTrim { .. } => "surface_trim",
other => crate::workspace::feature_icon(other).unwrap_or("feature"),
}
}
fn construction_plane_icon(plane: &PlaneRef) -> &'static str {
match plane {
PlaneRef::AtAngle { .. } => "plane_angle",
PlaneRef::Midplane { .. } => "plane_midplane",
PlaneRef::TwoEdges { .. } => "plane_two_edges",
PlaneRef::ThreePoints { .. } => "plane_three_points",
PlaneRef::Tangent { .. } => "plane_tangent",
PlaneRef::AlongPath { .. } => "plane_along_path",
PlaneRef::Perpendicular { .. } => "plane_perpendicular",
_ => "plane",
}
}
#[derive(Clone, Debug, PartialEq)]
enum Drag {
Marker,
@@ -439,6 +493,93 @@ mod tests {
use crate::scenario::Harness;
#[test]
fn construction_history_matches_the_tool_that_created_it() {
use solidcraft_engine::doc::{
FeatureKind, PlaneRef,
construct::{AxisDef, GeoRef, PointDef},
};
use solidcraft_engine::geom::Vec3;
let point = || GeoRef::Point { p: Vec3::ZERO };
let reference = || Box::new(point());
let mut cases = Vec::new();
for (id, def) in [
("through_cylinder", AxisDef::ThroughFace { face: point() }),
("normal_to_face", AxisDef::NormalToFace { face: point(), point: point() }),
("two_planes", AxisDef::TwoPlanes { a: point(), b: point() }),
("two_points", AxisDef::TwoPoints { a: point(), b: point() }),
("edge", AxisDef::Edge { edge: point() }),
] {
cases.push((format!("construct.axis.{id}"), FeatureKind::ConstructionAxis { def }));
}
for (id, def) in [
("vertex", PointDef::At { point: point() }),
("two_edges", PointDef::TwoEdges { a: point(), b: point() }),
("three_planes", PointDef::ThreePlanes { a: point(), b: point(), c: point() }),
("center", PointDef::Center { of: point() }),
("edge_plane", PointDef::EdgeAndPlane { edge: point(), plane: point() }),
("along_path", PointDef::AlongPath { path: point(), t: "0.5".into() }),
] {
cases.push((format!("construct.point.{id}"), FeatureKind::ConstructionPoint { def }));
}
for (id, plane) in [
("midplane", PlaneRef::Midplane { a: reference(), b: reference() }),
("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }),
("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }),
("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }),
("along_path", PlaneRef::AlongPath { path: reference(), t: "0.5".into() }),
("perpendicular", PlaneRef::Perpendicular { plane: reference(), line: reference() }),
] {
cases.push((format!("construct.plane.{id}"), FeatureKind::ConstructionPlane { plane }));
}
for (id, feature) in cases {
let command = solidcraft_engine::find_command(&id).unwrap();
let icon = super::icon_of(&feature);
assert_eq!(icon, command.icon, "{id}: history and toolbar disagree");
assert!(!crate::icon_svg::resolve(icon).placeholder, "{id}");
}
}
#[test]
fn new_surface_and_web_history_icons_match_their_commands() {
let cases = [
("surface.ruled", json!({"type":"surface_ruled", "body":"A", "edges":[], "distance":"5"})),
("surface.untrim", json!({"type":"surface_untrim", "face":{"body":"A", "point":[0,0,0]}})),
("surface.unstitch", json!({"type":"unstitch", "body":"A"})),
("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})),
("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})),
("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})),
("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})),
("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})),
("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})),
("solid.web", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2", "web":true})),
];
for (id, definition) in cases {
let feature: solidcraft_engine::doc::FeatureKind = serde_json::from_value(definition).unwrap();
let command = solidcraft_engine::find_command(id).unwrap();
assert_eq!(super::icon_of(&feature), command.icon, "{id}: history and toolbar disagree");
}
}
#[test]
fn a_path_pattern_keeps_its_path_icon_in_history() {
use solidcraft_engine::doc::{FeatureKind, PatternKind};
let feature = FeatureKind::Pattern {
features: Vec::new(),
bodies: Vec::new(),
pattern: PatternKind::Path {
path_sketch: solidcraft_engine::doc::SketchId(0),
path: Vec::new(),
count: "2".into(),
spacing: "10".into(),
extent: false,
flip: false,
orient: false,
},
};
assert_eq!(super::icon_of(&feature), solidcraft_engine::find_command("solid.pattern.path").unwrap().icon);
}
fn two_boxes(left: bool) -> Harness {
let mut h = Harness::new();
h.app.preferences.timeline_left = left;
+12 -32
View File
@@ -1,5 +1,6 @@
//! A failed feature is marked in the bottom timeline and the Feature History by a shape, not only
//! its colour (#972): the error mark (a disc with a cross) where a warning's mark goes.
//! its colour (#972): the error mark where a warning's mark goes (now an icon image, so these
//! tests look for the 10-point image at the mark's place).
use egui::{Pos2, Rect, Shape, vec2};
use serde_json::json;
@@ -27,20 +28,23 @@ fn features(app: &SolveApp) -> Vec<(String, bool)> {
app.session.doc.features.iter().map(|f| (f.name.clone(), app.session.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect()
}
/// The discs filled in the error colour (either theme's) that one frame of `draw` paints.
/// The centres of the 10 to 12-point icon images one frame of `draw` paints: the failed-feature mark
/// (the sample has no warnings, so every such image is an error mark).
fn error_discs(app: &mut SolveApp, draw: fn(&mut SolveApp, &mut egui::Ui)) -> Vec<Pos2> {
crate::scenario::clear_handles();
let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0));
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui));
let errors = [crate::theme::Tokens::light().error, crate::theme::Tokens::dark().error];
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui));
// Icons upload textures; dropping their deltas unapplied would panic.
out.textures_delta.clear();
let mut found = Vec::new();
for clipped in &out.shapes {
each(&clipped.shape, &mut |s| {
if let Shape::Circle(c) = s
&& errors.contains(&c.fill)
{
found.push(c.center);
if let Shape::Mesh(m) = s {
let b = m.calc_bounds();
if matches!(m.texture_id, egui::TextureId::Managed(_)) && (b.width() - 11.0).abs() < 1.5 && (b.height() - 11.0).abs() < 1.5 {
found.push(b.center());
}
}
});
}
@@ -81,27 +85,3 @@ fn a_failed_feature_is_marked_in_the_feature_history() {
let got = marked(&mut app, crate::timeline_left::left_timeline, |item, d| (d.y - item.y).abs() < 1.5 && d.x > item.x);
assert_eq!(got, want, "an error mark on each failed feature and on no other");
}
/// The mark is its own drawing: a disc with a cross in the caller's colours, not the fallback
/// square, and a different shape from the warning's triangle.
#[test]
fn the_error_mark_is_a_disc_with_a_cross() {
let ctx = egui::Context::default();
let (ink, fill) = (egui::Color32::from_rgb(1, 2, 3), egui::Color32::from_rgb(4, 5, 6));
let draw = |name: &'static str| {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
crate::icons::paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)), name, ink, fill, fill);
});
let mut shapes = Vec::new();
for c in &out.shapes {
each(&c.shape, &mut |s| shapes.push(s.clone()));
}
shapes
};
let error = draw("error");
let discs = error.iter().filter(|s| matches!(s, Shape::Circle(c) if c.fill == fill && c.stroke.color == ink)).count();
let strokes = error.iter().filter(|s| matches!(s, Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink))).count();
assert_eq!((discs, strokes), (1, 2), "one disc and the two strokes of a cross: {error:?}");
assert_ne!(format!("{error:?}"), format!("{:?}", draw("warning")));
assert_ne!(format!("{error:?}"), format!("{:?}", draw("no such icon")), "drawn, not the fallback square");
}
+22 -113
View File
@@ -10,39 +10,10 @@ use crate::{SolveApp, icons};
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
/// The accent stroke colour for a panel's icons: constraint commands use the same red as the
/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep
/// `sketch_accent`, and every other tab keeps the plain `accent`.
fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
if panel == "CONSTRAINTS" {
t.glyph_text
} else if tab == "SKETCH" {
t.sketch_accent
} else {
t.accent
}
}
/// The accent stroke colour a command's icon keeps on every surface that draws it, where the
/// command's own meaning fixes it. Construction geometry carries one fixed meaning across the
/// whole app. `sketch.construction` and `sketch.centerline` therefore draw their icon in the
/// theme's construction orange on the ribbon, in a panel's drop-down and under the pointer while
/// being dragged. The Sketch Palette's own line type buttons pass that same orange. `icons::paint`
/// draws both of these icon names in the accent its caller hands it, never in a colour of its own.
/// The caller is the only place the rule can live. Every other command has no fixed colour. It
/// keeps the accent of whichever surface draws it.
fn fixed_accent(t: Tokens, id: &str) -> Option<Color32> {
match id {
"sketch.construction" | "sketch.centerline" => Some(t.construction_geom),
_ => None,
}
}
/// The accent stroke colour for one command's icon on the ribbon. It covers the command's own
/// button and a collapsed panel's single button. A command with a `fixed_accent` keeps that.
/// Every other command takes its `panel_accent`.
fn command_accent(t: Tokens, tab: &str, panel: &str, id: &str) -> Color32 {
fixed_accent(t, id).unwrap_or_else(|| panel_accent(t, tab, panel))
/// Legacy paint arguments and non-SVG affordances follow the same semantic palette as the icon.
/// The requested icon id determines its meaning on ribbons, menus and drag previews alike.
fn command_accent(icon: &str) -> Color32 {
crate::icon_svg::primary(icon, crate::theme::is_dark(), crate::theme::vision())
}
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
@@ -281,8 +252,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
if enabled && resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) };
icons::paint(ui.painter(), br.shrink(5.0), icon, dim(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2]));
icons::paint_on(ui.painter(), br.shrink(5.0), icon, Some(true), if enabled { 1.0 } else { 0.35 });
let clicked = resp.on_hover_text(tip).clicked();
enabled && clicked
};
@@ -351,7 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar });
ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar);
icons::paint_on(ui.painter(), icon, "bug_report", Some(true), 1.0);
if report_label {
ui.painter().text(pos2(report.left() + REPORT_TEXT_X, report.center().y), Align2::LEFT_CENTER, report_text, report_font, t.app_bar_text);
}
@@ -364,7 +334,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
if gresp.hovered() {
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
}
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
icons::paint_on(ui.painter(), gear.shrink(4.0), "settings", Some(true), 1.0);
crate::scenario::publish_handle("titlebar:preferences", gear.center());
let prefs_tip = if cfg!(target_os = "macos") {
format!("{} (Cmd+,)", app.tr("ui.preferences"))
@@ -597,7 +567,15 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0));
let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click());
painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent);
let workspace_icon = if crate::render_workspace::active(app) { "ws_render" } else { "ws_design" };
icons::paint(
&painter,
Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)),
workspace_icon,
t.icon,
t.icon_fill,
t.accent,
);
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
@@ -719,7 +697,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.rect_filled(br, 4.0, t.hover);
}
if let Some(c) = shown.first().or(cmds.first()) {
let acc = command_accent(t, &tab, panel, c.id);
let acc = command_accent(c.icon);
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
}
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
@@ -769,14 +747,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
} else if resp.hovered() && info.enabled {
painter.rect_filled(br, 4.0, t.hover);
}
let (ink, fill, acc) = if info.enabled {
(t.icon, t.icon_fill, command_accent(t, &tab, panel, selected.id))
} else {
(t.border, t.panel_header, t.border)
};
let (ink, fill, acc) =
if info.enabled { (t.icon, t.icon_fill, command_accent(selected.icon)) } else { (t.border, t.panel_header, t.border) };
let icon = match selected.id {
"sketch.finish" => "finish",
"config.table" => "configuration_table",
_ => selected.icon,
};
let icon_rect =
@@ -1105,11 +1079,9 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine
p.rect_filled(rect, 3.0, t.hover);
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
}
// Every other row keeps the plain accent it always has. A constraint command's row switches
// to the constraint red, to match its button on the ribbon. A command with a `fixed_accent`
// keeps that colour ahead of either.
// The icon keeps its semantic accent in a drop-down as well as on the ribbon.
let (ink, fill, acc, text) = if info.enabled {
let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent });
let acc = command_accent(c.icon);
(t.icon, t.icon_fill, acc, t.text)
} else {
(t.border, t.panel_header, t.border, t.text_dim)
@@ -1243,7 +1215,7 @@ fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) {
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sc_toolbar_drag")));
let r = Rect::from_center_size(at + vec2(14.0, 16.0), vec2(30.0, 30.0));
p.rect(r, 4.0, t.toolbar.gamma_multiply(0.92), Stroke::new(1.0, if removing { t.error } else { t.accent }), egui::StrokeKind::Inside);
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent));
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, command_accent(c.icon));
if removing {
let s = Stroke::new(2.0, t.error);
let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0));
@@ -1788,69 +1760,6 @@ mod panel_drop_tests {
}
}
#[cfg(test)]
mod icon_accent_tests {
use super::*;
/// `icons.rs` pins one half of the construction icon contract. `construction` and
/// `centerline` take their colour from whatever accent their caller hands over. That half
/// stays green when the caller hands over the wrong colour. That is exactly what the toolbar
/// did. This pins the other half: the colour the toolbar actually passes for those two
/// commands, on every surface the toolbar draws them on.
#[test]
fn the_toolbar_passes_construction_orange_for_both_line_type_commands() {
for t in [Tokens::light(), Tokens::dark()] {
// The regression itself: the SKETCH tab's accent is green. These two icons drew in
// it. Construction orange has to be a colour no panel accent can supply.
assert_ne!(t.construction_geom, t.sketch_accent, "construction orange must differ from the sketch accent");
assert_ne!(t.construction_geom, t.accent, "construction orange must differ from the plain accent");
assert_ne!(t.construction_geom, t.glyph_text, "construction orange must differ from the constraint red");
for id in ["sketch.construction", "sketch.centerline"] {
assert_eq!(fixed_accent(t, id), Some(t.construction_geom), "{id} must have a fixed accent");
// Its ribbon button, a collapsed panel's single button, and a drop-down row.
assert_eq!(command_accent(t, "SKETCH", "CREATE", id), t.construction_geom, "{id} on the SKETCH ribbon");
assert_eq!(command_accent(t, "SOLID", "CREATE", id), t.construction_geom, "{id} on the SOLID ribbon");
assert_eq!(fixed_accent(t, id).unwrap_or(t.accent), t.construction_geom, "{id} in a drop-down row");
}
}
}
/// Those two and no others. Every other command's icon keeps the accent of the panel it sits
/// on, so the constraint red and the sketch green both survive the exception above.
#[test]
fn no_other_command_overrides_the_accent_of_its_own_panel() {
let t = Tokens::light();
let specs = solidcraft_engine::command_specs();
let mut fixed: Vec<&str> = specs.iter().map(|c| c.id).filter(|id| fixed_accent(t, id).is_some()).collect();
fixed.sort_unstable();
assert_eq!(fixed, ["sketch.centerline", "sketch.construction"], "only the two line type commands fix their own accent");
for c in &specs {
if fixed.contains(&c.id) {
continue;
}
let want = panel_accent(t, c.tab, c.panel);
assert_eq!(command_accent(t, c.tab, c.panel, c.id), want, "{} must keep the accent of its own panel", c.id);
}
// The fix did not flatten every icon to one colour. A sketch tool is still green, a
// constraint still red, a solid tool still blue. A missing id fails this as well.
let accent_of = |id: &str| solidcraft_engine::find_command(id).map(|c| command_accent(t, c.tab, c.panel, c.id));
assert_eq!(accent_of("sketch.text"), Some(t.sketch_accent));
assert_eq!(accent_of("sketch.constraint.polygon"), Some(t.glyph_text));
assert_eq!(accent_of("solid.extrude"), Some(t.accent));
}
/// Both line type commands sit on the SKETCH tab's CREATE panel. The accent above is
/// therefore a colour a person can actually see. Their icons are the two `icons.rs` draws
/// from the caller's accent.
#[test]
fn both_line_type_commands_are_create_panel_commands_with_their_own_icons() {
for (id, icon) in [("sketch.construction", "construction"), ("sketch.centerline", "centerline")] {
let seen = solidcraft_engine::find_command(id).map(|c| (c.tab, c.panel, c.icon));
assert_eq!(seen, Some(("SKETCH", "CREATE", icon)), "{id} must be on the SKETCH CREATE panel");
}
}
}
#[cfg(test)]
mod dropdown_toggle_tests {
use crate::scenario::{Harness, rect_of};
+2 -1
View File
@@ -152,8 +152,9 @@ pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> {
FeatureKind::SheetConvert { .. } => "convert_sheet",
FeatureKind::Boss { .. } => "boss",
FeatureKind::Lip { .. } => "lip",
FeatureKind::SnapFit { .. } => "snap",
FeatureKind::SnapFit { .. } => "snap_fit",
FeatureKind::Rest { .. } => "rest",
FeatureKind::Rib { web: true, .. } => "web",
FeatureKind::Rib { .. } => "rib",
_ => return None,
})