diff --git a/docs/skill/references/docs/api/README.md b/docs/skill/references/docs/api/README.md index 8e028f68..ca39d190 100644 --- a/docs/skill/references/docs/api/README.md +++ b/docs/skill/references/docs/api/README.md @@ -187,7 +187,9 @@ This index includes generated docs for the public SimpleCAD API surface, includi ## Advanced Features +- [chamfer_corner_rsketch](chamfer_corner_rsketch.md) *(from operators/sketch.py)* `top-level` - [chamfer_rsolid](chamfer_rsolid.md) *(from operators/features.py)* `top-level` +- [fillet_corner_rsketch](fillet_corner_rsketch.md) *(from operators/sketch.py)* `top-level` - [fillet_rsolid](fillet_rsolid.md) *(from operators/features.py)* `top-level` - [helical_sweep_rsolid](helical_sweep_rsolid.md) *(from operators/features.py)* `top-level` - [shell_rsolid](shell_rsolid.md) *(from operators/features.py)* `top-level` @@ -380,8 +382,11 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [constrain_symmetric_rsketch](constrain_symmetric_rsketch.md) *(from operators/sketch.py)* `top-level` - [constrain_tangent_rsketch](constrain_tangent_rsketch.md) *(from operators/sketch.py)* `top-level` - [constrain_vertical_rsketch](constrain_vertical_rsketch.md) *(from operators/sketch.py)* `top-level` +- [diff_sketch_rsketchdiff](diff_sketch_rsketchdiff.md) *(from operators/sketch.py)* `top-level` - [draft_rsolid](draft_rsolid.md) *(from operators/features.py)* `top-level` - [explain_tag](explain_tag.md) *(from operators/selection.py)* `top-level` +- [explode_rassembly](explode_rassembly.md) *(from operators/product.py)* `top-level` +- [extend_rsketch](extend_rsketch.md) *(from operators/sketch.py)* `top-level` - [fill_holes_rshell](fill_holes_rshell.md) *(from operators/geometry.py)* `top-level` - [fit_point_grid_rface](fit_point_grid_rface.md) *(from operators/geometry.py)* `top-level` - [free_boundaries_rwirelist](free_boundaries_rwirelist.md) *(from operators/geometry.py)* `top-level` @@ -408,6 +413,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [placement_frame_mm](placement_frame_mm.md) *(from product/placement.py)* `submodule:product/placement` - [placement_from_canonical](placement_from_canonical.md) *(from product/placement.py)* `submodule:product/placement` - [placement_ticks](placement_ticks.md) *(from product/placement.py)* `submodule:product/placement` +- [project_rsketch](project_rsketch.md) *(from operators/sketch.py)* `top-level` - [prop](prop.md) *(from ql.py)* `submodule:ql` - [radial_pattern_rsolidlist](radial_pattern_rsolidlist.md) *(from operators/transform.py)* `top-level` - [register_property_resolver](register_property_resolver.md) *(from ql.py)* `submodule:ql` @@ -422,6 +428,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [source_topology](source_topology.md) *(from ql.py)* `submodule:ql` - [tag](tag.md) *(from ql.py)* `submodule:ql` - [transform_rsolid](transform_rsolid.md) *(from operators/transform.py)* `top-level` +- [trim_rsketch](trim_rsketch.md) *(from operators/sketch.py)* `top-level` - [trim_surface_rface](trim_surface_rface.md) *(from operators/geometry.py)* `top-level` - [twisted_sweep_rsolid](twisted_sweep_rsolid.md) *(from operators/features.py)* `top-level` - [unground_component_rassembly](unground_component_rassembly.md) *(from operators/product.py)* `top-level` diff --git a/docs/skill/references/docs/api/chamfer_corner_rsketch.md b/docs/skill/references/docs/api/chamfer_corner_rsketch.md new file mode 100644 index 00000000..45135a5b --- /dev/null +++ b/docs/skill/references/docs/api/chamfer_corner_rsketch.md @@ -0,0 +1,21 @@ +# chamfer_corner_rsketch + +## API Definition + +```python +def chamfer_corner_rsketch(sketch, a, b, distance) +``` + +*Source: operators/sketch.py* + +## Import Surface + +- top-level: `from simplecadapi import chamfer_corner_rsketch` + +## Description + +Cut the corner where lines ``a`` and ``b`` meet with a straight line ``distance`` from the corner along each line. + +Same rules as fillet_corner_rsketch: the lines must share an end point, both are shortened, the corner point goes +away unless something else uses it. The new line ``l`` joins the two points, with a driving length (its chord) +and a driving angle to ``a``. ``distance`` must be shorter than the shorter line (the message names it). diff --git a/docs/skill/references/docs/api/diff_sketch_rsketchdiff.md b/docs/skill/references/docs/api/diff_sketch_rsketchdiff.md new file mode 100644 index 00000000..0a1c1136 --- /dev/null +++ b/docs/skill/references/docs/api/diff_sketch_rsketchdiff.md @@ -0,0 +1,20 @@ +# diff_sketch_rsketchdiff + +## API Definition + +```python +def diff_sketch_rsketchdiff(before, after) +``` + +*Source: operators/sketch.py* + +## Import Surface + +- top-level: `from simplecadapi import diff_sketch_rsketchdiff` + +## Description + +Report what an edit did: entity and constraint ids created, removed or changed between two sketches. + +Returns a dict with ``created``, ``removed``, ``changed`` (entity ids, points included) and +``created_constraints``, ``removed_constraints``, ``changed_constraints``. Not recorded in the graph. diff --git a/docs/skill/references/docs/api/explode_rassembly.md b/docs/skill/references/docs/api/explode_rassembly.md new file mode 100644 index 00000000..de6d11f8 --- /dev/null +++ b/docs/skill/references/docs/api/explode_rassembly.md @@ -0,0 +1,28 @@ +# explode_rassembly + +## API Definition + +```python +def explode_rassembly(assembly, factor, *, center=None, axis=None, directions=None, weights=None) +``` + +*Source: operators/product.py* + +## Import Surface + +- top-level: `from simplecadapi import explode_rassembly` + +## Description + +Return a display copy of an assembly with its components pushed apart for an exploded view. + +Each top-level component moves by ``factor * weight * direction * size``: ``size`` is the diagonal of the box that +holds every component's bodies, ``weight`` is ``weights[component_id]`` (default 1; list a component with 0 to keep +it, for example the grounded base) and ``direction`` is ``directions[component_id]`` (normalised), else ``axis`` +(signed by the side of the centre the component is on), else the vector from the centre to the component's own +centre (the centre of the box of its bodies). ``center`` overrides the assembly centre (the centre of the box of +all components). A component with no direction (it is at the centre, or on the centre plane of ``axis``) stays and +is listed in ``get_metadata("explode")``, together with factor, size, centre and every displacement. ``factor=0`` +gives the same placements. Parts, constraints, grounding and public connectors are untouched and the constraints +are not re-solved: the result is a placement set for display, rendering and export, and solving it moves the +components back. diff --git a/docs/skill/references/docs/api/extend_rsketch.md b/docs/skill/references/docs/api/extend_rsketch.md new file mode 100644 index 00000000..18681da2 --- /dev/null +++ b/docs/skill/references/docs/api/extend_rsketch.md @@ -0,0 +1,21 @@ +# extend_rsketch + +## API Definition + +```python +def extend_rsketch(sketch, entity, end, *, to=None) +``` + +*Source: operators/sketch.py* + +## Import Surface + +- top-level: `from simplecadapi import extend_rsketch` + +## Description + +Lengthen a sketch line at ``end`` ('start' or 'end') until it meets ``to`` (default: the nearest entity ahead). + +The sketch is solved first; the end point moves onto the other entity and is tied to it with a point_on +constraint. Refused when the end point is shared with another entity. The length dimension of the line, and +dimensions or fix on the moved point, no longer hold and are dropped. diff --git a/docs/skill/references/docs/api/fillet_corner_rsketch.md b/docs/skill/references/docs/api/fillet_corner_rsketch.md new file mode 100644 index 00000000..6d175d33 --- /dev/null +++ b/docs/skill/references/docs/api/fillet_corner_rsketch.md @@ -0,0 +1,27 @@ +# fillet_corner_rsketch + +## API Definition + +```python +def fillet_corner_rsketch(sketch, a, b, radius) +``` + +*Source: operators/sketch.py* + +## Import Surface + +- top-level: `from simplecadapi import fillet_corner_rsketch` + +## Description + +Round the corner where lines ``a`` and ``b`` meet with an arc of ``radius``. + +The lines must share an end point (the corner). The sketch is solved first; both lines are shortened to the +tangent points and the arc (counter-clockwise from its start to its end around its centre) is tied to each line +with a tangent constraint and to ``radius`` with a driving radius dimension, so the result survives a re-solve. +The corner point goes away unless a constraint or another entity uses it (then it stays as the sharp corner and +the cut-off parts of the lines stay as construction lines). horizontal/vertical and the other constraints that +hold for a shorter line stay; lengths and other dimensions of the lines are dropped (see +diff_sketch_rsketchdiff). Refused: parallel lines, lines that share no point, a radius too large for the +shorter line (the message names the largest radius). New ids: points ``p``, arc ``a``, legs ``l``, +constraints ``c__``. diff --git a/docs/skill/references/docs/api/project_rsketch.md b/docs/skill/references/docs/api/project_rsketch.md new file mode 100644 index 00000000..ca601bcc --- /dev/null +++ b/docs/skill/references/docs/api/project_rsketch.md @@ -0,0 +1,28 @@ +# project_rsketch + +## API Definition + +```python +def project_rsketch(sketch, source, *, entities=None, construction=True) +``` + +*Source: operators/sketch.py* + +## Import Surface + +- top-level: `from simplecadapi import project_rsketch` + +## Description + +Bring geometry from another sketch, or from the edges of shapes, into the sketch as reference geometry. + +``source`` is a Sketch (any plane; it is solved first), a Solid / Face / Shell / Wire / Edge / Compound or a list +of shapes (what ``ql.edges().resolve(shape)`` returns). Points go through the plane frames: u = (P - origin) . +x_axis, v = (P - origin) . y_axis. Points, lines, circles and arcs in a parallel plane are copied (an arc keeps its +counter-clockwise sense); a circle or arc seen edge on becomes a line, a line along the sketch normal a point, an +oblique full circle an ellipse; an oblique arc, and edges that are not lines or circles, are refused by name. +``entities`` limits the source sketch entities (default: all lines, circles, arcs and free points; ellipses and +B-splines are skipped and listed in ``get_metadata("last_edit")``); it is not used with shapes. The copies are +``construction`` (default True) and fixed at their projected coordinates. Ids are ``proj__`` +(``proj_shape_`` for shapes); ``get_metadata("projections")`` lists {source, entity, projected} and projecting +the same source again reuses the entities already there. diff --git a/docs/skill/references/docs/api/trim_rsketch.md b/docs/skill/references/docs/api/trim_rsketch.md new file mode 100644 index 00000000..e8d0a8c7 --- /dev/null +++ b/docs/skill/references/docs/api/trim_rsketch.md @@ -0,0 +1,23 @@ +# trim_rsketch + +## API Definition + +```python +def trim_rsketch(sketch, entity, at, *, against=None) +``` + +*Source: operators/sketch.py* + +## Import Surface + +- top-level: `from simplecadapi import trim_rsketch` + +## Description + +Remove the part of a line, circle or arc that contains ``at``, between the nearest cuts. + +A cut is a crossing with another non-construction entity (``against`` limits which ones) or an end of the +entity. ``at`` is a point (x, y) in sketch coordinates or a point id. Lines and arcs keep the pieces on either +side, a circle keeps the arc left over (it needs two cuts), an entity nothing crosses is removed whole. Cut +points are tied to the cutter with point_on; line pieces inherit horizontal/vertical; constraints that name +the removed entity are removed. Use diff_sketch_rsketchdiff(before, after) to see what changed. diff --git a/docs/skill/references/docs/core/operation_graph_json_spec.md b/docs/skill/references/docs/core/operation_graph_json_spec.md index fe997c89..399d6427 100644 --- a/docs/skill/references/docs/core/operation_graph_json_spec.md +++ b/docs/skill/references/docs/core/operation_graph_json_spec.md @@ -910,8 +910,10 @@ fields when their target expression is unitless. - 多输出 graph 的最终结果集由 `leaf_ids` 显式声明 - replay 默认 strict;permissive 仅通过 API 参数显式 opt-in - importer 仍识别冻结的 `2.0` contract,但 `2.0` graph 不得使用 - `load_brep_region_rshell`、`load_brep_region_rsolid` 或 - `make_solid_from_shell_rsolid`。 + `load_brep_region_rshell`、`load_brep_region_rsolid`、 + `make_solid_from_shell_rsolid`、`trim_rsketch`、`extend_rsketch`、 + `fillet_corner_rsketch`、`chamfer_corner_rsketch`、`project_rsketch` 或 + `explode_rassembly`。 - 为兼容早期 `2.0` model payload,importer 将缺失的 `canonical_contract` 视为最小 legacy `{"contract_version": "2.0"}`; 新导出的 model payload 仍必须显式包含完整 contract。 @@ -1012,6 +1014,11 @@ New canonical profile nodes use the `make_*_r*` names listed in `canonical_contr - `add_circle_rsketch` - `add_arc_rsketch` - `add_bspline_rsketch` +- `trim_rsketch` +- `extend_rsketch` +- `fillet_corner_rsketch` +- `chamfer_corner_rsketch` +- `project_rsketch` - `make_constrain_coincident_rsketch` - `make_constrain_point_on_rsketch` - `make_constrain_horizontal_rsketch` @@ -1309,6 +1316,40 @@ Notes: - Inputs: 1 `Sketch` - Outputs: 1 updated `Sketch` - Params: `sketch_id`, `entity_id`, `start`, `end`, `control_points`, `degree`, `knots`, `multiplicities`, `weights`, `periodic`, `construction` +#### `trim_rsketch` + +- Inputs: 1 `Sketch` +- Outputs: 1 updated `Sketch` +- Params: `sketch_id`, `entity`, `at`, `against` +- `at` is `[x, y]` in sketch coordinates or a point id; `against` is a list of entity ids or `null` (every non-construction entity). The edit is made on the solved geometry, so replay is deterministic. + +#### `extend_rsketch` + +- Inputs: 1 `Sketch` +- Outputs: 1 updated `Sketch` +- Params: `sketch_id`, `entity`, `end` (`start` or `end`), `to` (entity id or `null`) + +#### `fillet_corner_rsketch` / `chamfer_corner_rsketch` + +- Inputs: 1 `Sketch` +- Outputs: 1 updated `Sketch` +- Params: `sketch_id`, `a`, `b` (line ids), `radius` (fillet) or `distance` (chamfer) +- The edit is made on the solved geometry and picks its own ids (`p`, `a`, `l`, `c__`), so replay is deterministic. `radius` / `distance` are recorded as symbolic parameters when they were `Var` / `Expr`. + +#### `project_rsketch` + +- Inputs: the target `Sketch` and either the source `Sketch` (`source_kind: "sketch"`: inputs `[target, source]`) or the projected shapes (`source_kind: "shapes"`: the shape inputs, then the target `Sketch`) +- Outputs: 1 updated `Sketch` +- Params: `sketch_id`, `source_kind`, `source` (source sketch id, or the number of shapes), `entities` (source entity ids or `null`; sketch sources only), `construction` +- Projected ids are `proj__` (`proj_shape_` for shapes); the target sketch's `projections` metadata lists `{source, entity, projected}`. + +#### `explode_rassembly` + +- Inputs: 1 `Assembly` +- Outputs: 1 `Assembly` (a display copy: only component placements differ) +- Params: `assembly_id`, `factor`, `center`, `axis` (3-vectors or `null`), `directions` (component id to 3-vector), `weights` (component id to number) +- `factor` is recorded as a symbolic parameter when it was a `Var` / `Expr`. Constraints are not re-solved; solving the result moves the components back. + #### `make_constrain_*_rsketch` - Inputs: 1 `Sketch` diff --git a/docs/skill/references/domains/assembly-and-product.md b/docs/skill/references/domains/assembly-and-product.md index 7f314743..caffc0c2 100644 --- a/docs/skill/references/domains/assembly-and-product.md +++ b/docs/skill/references/domains/assembly-and-product.md @@ -87,10 +87,35 @@ composing notebooks. `NotebookConfig`, `sca run` CLI. - Product build: `part`, `assemble`, `file_input`, `PartBuildResult`, `AssemblyBuildResult`. +- Exploded view: `explode_rassembly(assembly, factor, *, center, axis, + directions, weights)` returns a display copy whose component placements are + pushed apart (see "Exploded view" below). - Packages: `capture`, `CaptureResult`, `ProductPackage`, `build_product_package`, `read_product_package`, `load_product_package`, `validate_product_package`. +## Exploded view + +`explode_rassembly(assembly, factor, ...)` is a pure function of the assembly: +each top-level component moves by `factor * weight * direction * size`, where +`size` is the diagonal of the box that holds all component bodies, so +`factor=1` is a clear explosion and `factor=0` gives the same placements. +`direction` is `directions[id]`, else `axis` (signed by the side of the centre), +else from the assembly centre (`center`, default the centre of the bodies' box) +to the component's own centre. A component with no direction (at the centre) +stays and is listed in `get_metadata("explode")`; to keep a component, such as +the grounded base, put it in `weights` as `0` (nothing is special-cased). + +```python +view = scad.explode_rassembly(assembly, 1.0, weights={"base": 0.0}) +view = scad.explode_rassembly(assembly, 0.6, axis=(0, 0, 1), directions={"cover": (1, 0, 0)}) +``` + +The result is for display, rendering and export of an exploded view: parts, +constraints, grounding and public connectors are unchanged and the constraints +are not re-solved, so solving the result moves the components back. It is +recorded in the graph and replays from model JSON. + ## Validation gates - Placement semantics: moving a component changes placement only; the part's diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index 6a59c31e..4ada89a2 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -197,6 +197,60 @@ Errors there are the same errors `sca snapshot` reports per cell. A module that does not compile is reported as `setup failed` with the file and line; fix that file first, since every cell is blocked until it compiles. +## Features the person made with buttons + +The editor also has a Features tab and an operation palette (box, cylinder, extrude, hole, fillet, patterns ...). A click +writes two ordinary cells, so you read and edit them like any other: + +```python +# ---- params: hole ---- +HOLE_DIAMETER = scad.var(name="hole_diameter", default=6.0, unit="mm", comment="Diameter") # ui: slider(...) + +# ---- feature: hole (subtract) ---- +# gui: {"spec":"hole","v":1,"values":{...}} +...one operation that makes the new name `hole`... +``` + +- The `# gui:` line is how the form is rebuilt. A cell without it is code and the form never rewrites it. To take a + GUI feature over as code, delete the `# gui:` line (or ask the person to); to change a number, edit the `# params:` cell + or the form, not the call. +- GUI commands carry the code they were made from: if you edited the cell in between, the command is refused (409), it + does not overwrite you. The same holds for the person's undo of a GUI command. +- Selections in a GUI cell are written inline, resolved against the body the cell receives: the face tags the box, cylinder + or extrude was given (`.top`, `.side` ... they survive booleans, not fillets or chamfers) first, the picked + geometry second, with the count checked (`.exactly(n)`). If the model moved so that nothing matches, the cell fails and + the feature turns red: ask the person to pick again, never widen the selector until it matches. +- A blank notebook (`sca new NAME --kind blank`) has no cells; the first feature adds the product cell + (` = scad.Part(...)`), and later features that consume the body it builds from move it along. +- Unattended: the buttons are not available; write the cells yourself (see `discipline/notebook-cell-design.md`). + +## The same operations from the command line + +An agent does not click: `sca feature` and `sca sketch` do what the buttons do, with the same checks, as JSON in and JSON out. + +```bash +sca feature specs [SPEC] # operations and their typed slots (lengths mm, angles deg, bodies by name) +sca feature ls # features, bodies, tip, product, defaults +sca feature add box --name base --values '{"width": 40, "height": 30, "depth": 20}' +sca feature add fillet --values '{"radius": 2}' --selections '{"edges": [{"kind": "edge", "properties": {...}, "geometry": {...}}]}' +sca feature edit base --values '{"width": 50}' # a GUI feature; a code feature is refused +sca feature rm base | sca feature undo | sca feature redo +sca sketch ls | get CELL | put CELL -f doc.json | solve CELL +sca sketch op CELL trim --args '{"entity": "h", "at": [8, 0]}' [--dry-run] +sca sketch op CELL extend --args '{"entity": "h", "end": "end"}' +``` + +- The edits are the SDK's own operations, not editor features: `sca sketch op CELL trim ...` calls `scad.trim_rsketch` on the + sketch the document builds and writes the result back, so the same edit in a notebook cell is + `_sketch = scad.trim_rsketch(sketch=_sketch, entity="h", at=(8.0, 0.0))`; `scad.diff_sketch_rsketchdiff(before=..., after=...)` + says what changed (`created`, `removed`, `changed` entities and `created_constraints`, `removed_constraints`, + `changed_constraints`). `trim` cuts an entity at the crossings around the point `at` on the solved geometry and ties each cut + point to the entity that made it (`point_on`); a piece keeps the horizontal/vertical constraint of its line; dimensions that + no longer hold are removed (they appear in `removed_constraints`). New ids are the lowest free `p`, `l`, `a` and + `c__`. `--dry-run` prints without writing. +- Errors name ids (`there is no entity 'x'`, `nothing is ahead of the end of h`); fix the call, do not retry it unchanged. +- Everything a command writes is an ordinary cell; `sca cell` still works on it (see the `# gui:` rules above). + ## Rules of thumb - Re-read the cell before editing (`sca cell get`, or the file): the person edits and diff --git a/docs/skill/references/domains/sketch-and-features.md b/docs/skill/references/domains/sketch-and-features.md index 69c6e0cd..65413590 100644 --- a/docs/skill/references/domains/sketch-and-features.md +++ b/docs/skill/references/domains/sketch-and-features.md @@ -62,8 +62,12 @@ Read the exact page under `references/docs/api/` for every API used: `constrain_fix_rsketch`. Driving `constrain_angle_rsketch` holds the directed angle from the first line to the second (0-360 degrees; the solve keeps the branch nearest the initial geometry). +- Editing: `trim_rsketch`, `extend_rsketch` (see "Trim and extend" below), + `fillet_corner_rsketch`, `chamfer_corner_rsketch` (see "Fillet and chamfer a + corner"), `project_rsketch` (see "Relating sketches: project"). - Promotion: `make_wire_from_sketch_rwire`, `make_face_from_sketch_rface`. -- Diagnostics: `inspect_sketch_rsketchresult` (non-recording). +- Diagnostics: `inspect_sketch_rsketchresult` (non-recording), + `diff_sketch_rsketchdiff` (non-recording report of what an edit changed). ## Construction rules @@ -81,6 +85,93 @@ Read the exact page under `references/docs/api/` for every API used: - `inner_profiles` is valid only when adjacent-carrier evidence shows the loops belong to the same generating profile. +## Trim and extend + +Use `trim_rsketch` when overlapping or overshooting lines, circles and arcs must +be cut back to where other entities cross them (a rectangle drawn as four +over-long lines, a circle cut by a slot); use `extend_rsketch` to lengthen a line +until it meets another entity. Both solve the sketch first and cut or extend on +the solved geometry, return a new `Sketch`, are recorded in the graph and replay +from model JSON. They keep the sketch connected: a new cut point is tied to the +entity that cut it with `point_on`, a line piece keeps the `horizontal` / +`vertical` constraint of its line, constraints on `.start|end|center` +stay on the point, and constraints that name the removed entity (and a dimension +that no longer holds) are removed. The edit picks the new ids: points are +`p`, lines `l`, arcs `a` (the lowest number never used by the sketch) +and constraints `c__`. + +```python +before = sketch +# remove the part of line "h" that contains the point (8, 0), between the crossings +sketch = scad.trim_rsketch(sketch, "h", (8.0, 0.0)) # at: (x, y) or a point id +sketch = scad.trim_rsketch(sketch, "c", "click", against=["l1", "l2"]) # only these entities cut +sketch = scad.extend_rsketch(sketch, "spur", "end") # to the nearest entity ahead +sketch = scad.extend_rsketch(sketch, "spur", "end", to="wall") # or to a named one +print(scad.diff_sketch_rsketchdiff(before, sketch)) +# {'created': [...], 'removed': [...], 'changed': [...], +# 'created_constraints': [...], 'removed_constraints': [...], 'changed_constraints': [...]} +``` + +- A circle needs two cuts to leave an arc (one cut is an error, no cut removes + it whole); an entity nothing crosses is removed whole. +- `extend_rsketch` refuses an end point shared with another entity and names the + ids; ellipses and B-splines neither cut nor can be trimmed. +- Read `diff_sketch_rsketchdiff(before, after)` instead of diffing + `Sketch.to_dict()` yourself; the last edit's notes are in + `sketch.get_metadata("last_edit")`. + +## Fillet and chamfer a corner + +`fillet_corner_rsketch(sketch, a, b, radius)` rounds the corner where two lines +meet; `chamfer_corner_rsketch(sketch, a, b, distance)` cuts it with a line. The +lines must share an end point (build them from the same point id). Like trim, +both solve the sketch first, return a new `Sketch`, are recorded and replay. + +```python +sketch = scad.fillet_corner_rsketch(sketch, "bottom", "right", 2.0) # arc a1, points p1..p3 +sketch = scad.chamfer_corner_rsketch(sketch, "top", "left", 1.0) # line l +``` + +- Both lines are shortened to the tangent points; the arc runs counter-clockwise + from its start to its end around its centre and gets a `tangent` constraint + with each line plus a driving `radius`, so a re-solve keeps it. A chamfer gets + a driving `length` (its chord) and a driving `angle` to line `a`. +- The corner point goes away unless something else uses it; if a constraint + does, it stays as the sharp corner and the cut-off parts of the lines stay as + construction lines (collinear with their lines), so the lines keep their place. +- `horizontal`, `vertical` and the other constraints that hold for a shorter + line stay; lengths and other dimensions of the two lines are dropped and + listed by `diff_sketch_rsketchdiff` and `get_metadata("last_edit")["notes"]`. + Re-dimension what you need afterwards (the remaining DOF show what to add). +- Refused with the ids named: parallel lines, lines without a shared point, a + radius or distance that does not leave part of the shorter line (the message + gives the largest radius). + +## Relating sketches: project + +`project_rsketch(sketch, source, *, entities=None, construction=True)` brings +geometry from another sketch (any plane) or from shape edges into `sketch` as +reference geometry, so a sketch in one plane can be constrained to another. + +```python +plan = scad.project_rsketch(plan, front, entities=["wall", "hole"]) # another Sketch +plan = scad.project_rsketch(plan, scad.ql.edges().resolve(solid)) # edges of a shape, in world coordinates +plan = scad.constrain_point_on_rsketch(plan, "tail", "proj_front_3") # constrain to the projection +print(plan.get_metadata("projections")) # [{'source': 'front', 'entity': 'wall', 'projected': 'proj_front_3'}, ...] +``` + +- Coordinates go through both plane frames: `u = (P - origin) . x_axis`, + `v = (P - origin) . y_axis`. Points, lines, circles and arcs in a parallel + plane are copied (an arc keeps its sense); a circle or arc seen edge on becomes + a line, a line along the sketch normal a point, an oblique full circle an + ellipse. An oblique arc, a spline edge, or an unknown id is refused by name. +- Copies are `construction` (default) and fixed at their projected coordinates; + ids are `proj__` (`proj_shape_`), never colliding with + existing ids. Projecting the same source again reuses what is there. +- Without `entities`, ellipses and B-splines of a source sketch are skipped and + named in `get_metadata("last_edit")["notes"]`; `entities` is for source + sketches only. + ## Validation gates - Record plane mapping, closure, solve status, and remaining DOF in the brief. diff --git a/editor-web/package-lock.json b/editor-web/package-lock.json index 64fa773b..464564b2 100644 --- a/editor-web/package-lock.json +++ b/editor-web/package-lock.json @@ -31,10 +31,14 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.4.1", "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.4.1", "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-tooltip": "^1.2.16", "@types/node": "^24.13.3", @@ -43,7 +47,8 @@ "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", "typescript": "~6.0.2", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.1.0" } }, "node_modules/@babel/runtime": { @@ -755,6 +760,227 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", @@ -763,26 +989,491 @@ "license": "MIT", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.17.tgz", + "integrity": "sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.4.1.tgz", + "integrity": "sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.13", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-arrow": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz", + "integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", - "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-collection": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz", + "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-effect-event": "0.0.5" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -799,15 +1490,28 @@ } } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", - "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.4" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-direction": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz", + "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==", + "dev": true, + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -818,22 +1522,50 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", - "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz", + "integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==", "dev": true, "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-arrow": "1.1.17", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", - "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { @@ -851,14 +1583,14 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", - "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -876,10 +1608,10 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", - "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", "dev": true, "license": "MIT", "dependencies": { @@ -900,14 +1632,14 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", - "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-slot": "1.5.0" }, "peerDependencies": { "@types/react": "*", @@ -924,57 +1656,93 @@ } } }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", - "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { "optional": true } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", - "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.3", - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.13.tgz", + "integrity": "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", @@ -1035,27 +1803,162 @@ }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.4.1.tgz", + "integrity": "sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", - "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.5" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2107,6 +3010,31 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", @@ -2246,6 +3174,87 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2304,6 +3313,13 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.25.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", @@ -2317,6 +3333,33 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2354,6 +3397,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2672,6 +3725,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.85.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", @@ -2788,6 +3855,55 @@ "react": "^19.3.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-resizable-panels": { "version": "4.13.3", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.13.3.tgz", @@ -2798,6 +3914,29 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -2846,6 +3985,13 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", @@ -2887,6 +4033,26 @@ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2903,6 +4069,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "devOptional": true, + "license": "0BSD" + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -2933,6 +4106,51 @@ "devOptional": true, "license": "MIT" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", @@ -3010,11 +4228,117 @@ } } }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } } } } diff --git a/editor-web/package.json b/editor-web/package.json index 2b2afbd5..b90f4f95 100644 --- a/editor-web/package.json +++ b/editor-web/package.json @@ -8,6 +8,7 @@ "build": "tsc --noEmit -p tsconfig.json && vite build", "lint": "oxlint", "preview": "vite preview", + "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -34,10 +35,14 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.4.1", "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.4.1", "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-tooltip": "^1.2.16", "@types/node": "^24.13.3", @@ -46,6 +51,7 @@ "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", "typescript": "~6.0.2", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.1.0" } } diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index 4e43541b..0351227d 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -2,8 +2,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CellEditor, type NotebookCell, type NotebookFeature, type ParamMeta } from "@/components/CellEditor"; import { Dock } from "@/components/Dock"; +import { FeatureDialog, type DialogRequest } from "@/components/FeatureDialog"; +import { SelectionBar } from "@/components/SelectionBar"; +import { selectionAnchorMm } from "@/lib/selection-anchor"; +import { SketchScene } from "@/components/SketchScene"; +import { CommandBar } from "@/components/CommandBar"; +import { FEATURE_ICONS } from "@/components/feature-icons"; +import { FeatureTree, type TreeSketches } from "@/components/FeatureTree"; +import { assemblyCommands, commandsFromSpecs, fitScore, selectionContext, selectionHint, visibleCommands, whenFor, type Command } from "@/lib/commands"; +import { AssemblyPanel } from "@/components/AssemblyPanel"; +import { ConnectorGizmos, type Gizmo } from "@/components/ConnectorGizmos"; +import { setRef } from "@/lib/assembly"; +import { useAssemblyEditor, useConnectorDraft } from "@/lib/useAssembly"; import { Header } from "@/components/Header"; import { Inspector } from "@/components/Inspector"; +import { SketchMode, type FaceCheck, type SketchAfter, type SketchSession } from "@/components/sketch/SketchMode"; import { LoadingMask } from "@/components/LoadingMask"; import { ModuleView, type ModuleState } from "@/components/ModuleView"; import { NotebookList } from "@/components/NotebookList"; @@ -12,10 +25,19 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getWorkspace, newNotebook, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; import { parsePreview, type PreviewPart } from "@/lib/preview"; import { EnvelopeSocket, type Envelope } from "@/lib/envelope"; +import { emptyDoc, normalizeDoc } from "@/lib/sketch"; +import { frameFromEntity, type PlaneSpec } from "@/lib/sketch-plane"; +import { useFeatures } from "@/lib/useFeatures"; +import { describeSketch, nextSketchSelection, readHidden, sketchCentre, sketchInfos, toggleHidden, writeHidden } from "@/lib/sketch-scene"; +import { useSketchShapes } from "@/lib/useSketchShapes"; +import type { Vec3 } from "@/lib/sketch-plane"; +import { PALETTES } from "@/scene/theme"; +import { assertCellRan, isCellCommand, type FeatureCommand, type FeatureRow, type Spec } from "@/lib/features"; import { useRunLog } from "@/lib/runlog"; import { useTheme } from "@/lib/theme"; import { selectionPayload, variableNames, type SelectionItem } from "@/lib/selection"; import { cn } from "@/lib/utils"; +import { Boxes, Cable } from "lucide-react"; import { Viewport, type PickResult, type SelectionMode } from "@/scene/Viewport"; import type { SceneView } from "@/scene/scene-view"; @@ -25,6 +47,7 @@ const MODES: { mode: SelectionMode; label: string }[] = [ { mode: "face", label: "face" }, { mode: "edge", label: "edge" }, { mode: "vertex", label: "vert" }, + { mode: "sketch", label: "sketch" }, ]; /** Editor application: left = notebook (cells, workspace), middle = the live @@ -62,6 +85,55 @@ export function App() { // slider, one cell) are not veiled. const [loading, setLoading] = useState("connecting"); + // GUI features: the REST state is richer than `notebook.features`, so it is refreshed on every cells event. + const gui = useFeatures(); + const refreshFeatures = gui.refresh; + const [dialog, setDialog] = useState(null); + const [guiBusy, setGuiBusy] = useState(false); + const [guiError, setGuiError] = useState(null); + const modeBeforeDialog = useRef(null); + + // Sketch mode: drawing happens in the viewport (see components/sketch). `plane: null` is the plane choice. + const [sketchSession, setSketchSession] = useState(null); + const [sketchNotice, setSketchNotice] = useState(null); + const sketchCounter = useRef(0); + const modeBeforeSketch = useRef(null); + const sketching = sketchSession !== null; + const sketchingRef = useRef(false); + sketchingRef.current = sketching; + const choosingRef = useRef(false); + choosingRef.current = sketchSession !== null && sketchSession.plane === null; + const infoWaiters = useRef(new Map; geometry?: Record } | null) => void>()); + + // Assembly: the layout draft, the part's own connector draft, and what the viewport draws for them. + const asm = useAssemblyEditor(gui.state, currentPath); + const conn = useConnectorDraft(gui.state, currentPath); + + // Sketches as objects of the scene: the notebook's sketch features (solved by the SDK, drawn in the viewport), which of them a + // person hid (remembered per notebook), which are selected or under the pointer, and the arrow of the push a form describes. + const sketchList = useMemo(() => sketchInfos(gui.state.features), [gui.state.features]); + const sketchShapes = useSketchShapes(sketchList); + const [hiddenSketches, setHiddenSketches] = useState>(() => new Set()); + const hiddenRef = useRef(hiddenSketches); + hiddenRef.current = hiddenSketches; + useEffect(() => setHiddenSketches(readHidden(currentPath)), [currentPath]); + const toggleSketchHidden = useCallback( + (name: string) => { + const next = toggleHidden(hiddenRef.current, name); + writeHidden(currentPath, next); + setHiddenSketches(next); + }, + [currentPath], + ); + const [pickedSketches, setPickedSketches] = useState([]); + const [hoverSketch, setHoverSketch] = useState(null); + const [pushArrow, setPushArrow] = useState<{ sketch: string; direction: Vec3; distance: number } | null>(null); + const [barNote, setBarNote] = useState(null); + // A sketch that is gone (deleted, undone) is no longer selected. + useEffect(() => { + setPickedSketches((previous) => (previous.every((name) => sketchList.some((info) => info.name === name)) ? previous : previous.filter((name) => sketchList.some((info) => info.name === name)))); + }, [sketchList]); + const kind = notebooks.find((row) => row.path === currentPath)?.kind ?? null; const theme = useTheme(); const log = useRunLog(); @@ -128,6 +200,7 @@ export function App() { // an empty workspace: the way forward is the workspace tab's "new" if (!envelope["notebook"]) setLeftTab("workspace"); setKernelAlive(Boolean(envelope["kernel"])); + refreshFeatures(); // No notebook: nothing to wait for. Else the model is on its way. if (hasModel.current) return; const stage = envelope["notebook"] ? (envelope["kernel"] ? "building the model" : "starting the kernel") : null; @@ -146,6 +219,9 @@ export function App() { setParams([]); setFeatures([]); setPicked([]); + setDialog(null); + setSketchSession(null); + refreshFeatures(); setNotebook(String(envelope["notebook"] ?? "")); setCurrentPath((envelope["path"] as string) ?? null); setStatus(`opening ${envelope["notebook"]}…`); @@ -191,6 +267,7 @@ export function App() { const next = (envelope["cells"] as NotebookCell[]) ?? []; setCells(next); log.knowCells(next); + refreshFeatures(); }); // A notebook was added or removed under the workspace (an agent wrote one). socket.on("workspace.changed", () => refreshWorkspace()); @@ -221,6 +298,7 @@ export function App() { socket.on("entity.info", (envelope) => { const entityId = String(envelope["entity_id"]); const entity = envelope["entity"] as { properties?: Record; geometry?: Record; tags?: string[] } | null; + infoWaiters.current.get(entityId)?.(entity); setPicked((previous) => previous.map((item) => item.entityId === entityId @@ -255,13 +333,99 @@ export function App() { socket.connect(); return () => socket.close(); - }, [loadPreviewBytes, log.push, log.cellStatus, log.knowCells, log.resetBatch, log.modelShown]); + }, [loadPreviewBytes, refreshFeatures, log.push, log.cellStatus, log.knowCells, log.resetBatch, log.modelShown]); // -- selection ------------------------------------------------------------------- + /** The details of one entity from the kernel (null when it does not answer in time). */ + const askEntity = useCallback( + (nodeId: string, entityId: string) => + new Promise<{ properties?: Record; geometry?: Record } | null>((resolve) => { + const timer = window.setTimeout(() => { + infoWaiters.current.delete(entityId); + resolve(null); + }, 5000); + infoWaiters.current.set(entityId, (entity) => { + window.clearTimeout(timer); + infoWaiters.current.delete(entityId); + resolve(entity); + }); + socketRef.current?.send({ type: "entity.info", entity_id: entityId, node_id: nodeId }); + }), + [], + ); + + const choosePlane = useCallback((plane: PlaneSpec) => { + setSketchNotice(null); + setSketchSession((session) => (session ? { ...session, plane, doc: { ...session.doc, plane } } : session)); + }, []); + + /** What a face is to a sketch: its plane frame (null when it is not flat) and the kind of surface. The kernel is asked once + * per face (concurrent askers share the answer); a pick that already carries its details is not asked about. */ + const faceCache = useRef(new Map; type: string | null }>>()); + const resolveFace = useCallback( + (nodeId: string, entityId: string, entity?: { properties?: Record | null; geometry?: Record | null } | null) => { + const key = `${nodeId}::${entityId}`; + const cached = faceCache.current.get(key); + if (cached) return cached; + const answer = (async () => { + let geometry = entity?.geometry ?? undefined; + let properties = entity?.properties ?? undefined; + if (!geometry || Object.keys(geometry).length === 0) { + const info = await askEntity(nodeId, entityId); + geometry = info?.geometry; + properties = info?.properties; + } + const type = typeof geometry?.type === "string" ? geometry.type : null; + return { frame: geometry ? frameFromEntity(geometry, properties) : null, type }; + })(); + faceCache.current.set(key, answer); + return answer; + }, + [askEntity], + ); + useEffect(() => { + faceCache.current.clear(); + }, [sceneVersion]); + const faceCheck = useCallback( + async (result: PickResult): Promise => { + if (!result.entityId) return { planar: false, type: null }; + const face = await resolveFace(result.nodeId, result.entityId, result.entity); + return { planar: face.frame !== null, type: face.type }; + }, + [resolveFace], + ); + const refusal = (type: string | null) => `This face is ${type ? `a ${type.toLowerCase()}` : "curved"}: a sketch needs a flat face. Pick a flat face or a datum plane.`; + + /** While the sketch plane is being chosen a face pick means "sketch on this face" and never reaches the selection. */ + const pickSketchFace = useCallback( + async (result: PickResult) => { + if (!result.entityId || result.mode !== "face") return; + const face = await resolveFace(result.nodeId, result.entityId, result.entity); + if (!face.frame) { + setSketchNotice(refusal(face.type)); + return; + } + choosePlane(face.frame); + }, + [resolveFace, choosePlane], + ); + + const leftTabRef = useRef(leftTab); + leftTabRef.current = leftTab; + const selectComponent = asm.setSelected; const handlePick = useCallback((result: PickResult) => { + if (choosingRef.current) { + void pickSketchFace(result); + return; + } + if (sketchingRef.current) return; + setPickedSketches((previous) => (previous.length ? [] : previous)); + setBarNote(null); const view = viewRef.current; const node = view?.nodes.find((item) => item.node_id === result.nodeId); + // With the assembly tab open, clicking a part of the model selects its component (its connectors are drawn). + if (leftTabRef.current === "assembly" && node?.component_id && result.mode === "component") selectComponent(node.component_id); const entity = result.entity; const item: Omit = { key: `${result.nodeId}::${result.entityId ?? "component"}`, @@ -285,9 +449,29 @@ export function App() { ? previous.filter((other) => other.key !== item.key) : [...previous, item]; }); + }, [pickSketchFace, selectComponent]); + + const handleClear = useCallback(() => { + if (sketchingRef.current) return; + setPicked((previous) => (previous.length ? [] : previous)); + setPickedSketches((previous) => (previous.length ? [] : previous)); + setBarNote(null); }, []); - const handleClear = useCallback(() => setPicked((previous) => (previous.length ? [] : previous)), []); + /** A click on a sketch (or on nothing) in the sketch selection mode, or on a sketch row of the tree. */ + const pickSketch = useCallback((name: string | null, additive: boolean) => { + if (sketchingRef.current) return; + setBarNote(null); + if (name === null) { + if (!additive) { + setPickedSketches((previous) => (previous.length ? [] : previous)); + setPicked((previous) => (previous.length ? [] : previous)); + } + return; + } + setPicked((previous) => (previous.length ? [] : previous)); + setPickedSketches((previous) => nextSketchSelection(previous, name, additive)); + }, []); // The notebook follows the selection: the server writes it as ql code into // the selection cell, and the viewport image is saved for coding agents. @@ -319,6 +503,293 @@ export function App() { return () => window.clearTimeout(timer); }, [loading]); + // -- GUI features ------------------------------------------------------------------------------ + + const openDialog = useCallback( + (request: DialogRequest) => { + if (modeBeforeDialog.current === null) modeBeforeDialog.current = selectionMode; + setDialog(request); + }, + [selectionMode], + ); + const closeDialog = useCallback(() => { + setDialog(null); + if (modeBeforeDialog.current !== null) setSelectionMode(modeBeforeDialog.current); + modeBeforeDialog.current = null; + }, []); + + /** A command from the dialog: rejects with the server's message (shown in the dialog), closes on success. */ + const submitFeature = useCallback( + async (command: FeatureCommand) => { + const result = await gui.run(command); + log.push("ok", command.op === "undo" || command.op === "redo" ? command.op : `${command.op} ${result.name}`); + if (isCellCommand(command)) assertCellRan(result); // the cell failed: say so in the dialog instead of closing + closeDialog(); + }, + [gui.run, log.push, closeDialog], + ); + + /** A command from the tree or the shortcuts: its failure shows above the tree. */ + const runFeature = useCallback( + async (command: FeatureCommand) => { + setGuiBusy(true); + setGuiError(null); + try { + const result = await gui.run(command); + log.push("ok", command.op === "undo" || command.op === "redo" ? command.op : `${command.op} ${result.name}`); + } catch (error) { + setGuiError(error instanceof Error ? error.message : String(error)); + } finally { + setGuiBusy(false); + } + }, + [gui.run, log.push], + ); + const undo = useCallback(() => void runFeature({ op: "undo" }), [runFeature]); + const redo = useCallback(() => void runFeature({ op: "redo" }), [runFeature]); + + // Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z undo and redo a feature change, unless a text field has the focus. + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (sketchingRef.current) return; // the sketch has its own undo + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.key.toLowerCase() !== "z") return; + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, select, [contenteditable=true], .cm-editor")) return; + event.preventDefault(); + if (event.shiftKey) redo(); + else undo(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [undo, redo]); + + // The command registry the bar reads: today the feature catalog, later sketch and assembly commands too. + const beginSketch = useCallback( + (session: Omit) => { + setDialog(null); + setSketchNotice(null); + setBarNote(null); + setPickedSketches([]); + if (modeBeforeSketch.current === null) modeBeforeSketch.current = selectionMode; + setSelectionMode("face"); // a face pick chooses the sketch plane + setPicked([]); + setSketchSession({ ...session, id: ++sketchCounter.current }); + }, + [selectionMode], + ); + const startSketch = useCallback(() => { + if (!sketchingRef.current) beginSketch({ mode: "new", name: "", plane: null, doc: emptyDoc() }); + }, [beginSketch]); + const endSketch = useCallback(() => { + setSketchSession(null); + setSketchNotice(null); + if (modeBeforeSketch.current !== null) setSelectionMode(modeBeforeSketch.current); + modeBeforeSketch.current = null; + }, []); + /** Finish: the add or edit command; rejects with the server's message (shown in the sketch bar), leaves sketch mode on success. + * With `after.extrude` ("finish and extrude") the new sketch is selected and the extrude form opens on it. */ + const submitSketch = useCallback( + async (command: FeatureCommand, after?: SketchAfter) => { + const result = await gui.run(command); + log.push("ok", command.op === "undo" || command.op === "redo" ? command.op : `${command.op} ${result.name}`); + if (isCellCommand(command)) assertCellRan(result); + const restore = modeBeforeSketch.current; + endSketch(); + const extrude = after?.extrude ? gui.catalog?.specs.find((spec) => spec.id === "extrude_sketch") : undefined; + if (extrude && after?.extrude) { + setPicked([]); + setPickedSketches([result.name]); + modeBeforeDialog.current = restore ?? modeBeforeDialog.current; + setDialog({ spec: extrude, prefill: { profile: result.name, distance: after.extrude.distance } }); + } + }, + [gui.run, gui.catalog, log.push, endSketch], + ); + + const isSketchSpec = (spec: Spec) => spec.slots.some((slot) => slot.kind === "sketch"); + const openAssembly = useCallback( + (section: "assembly" | "connectors" = "assembly") => { + setLeftTab("assembly"); + if (section === "connectors") setSelectionMode("face"); // faces are what a connector is placed on + else setSelectionMode((mode) => (mode === "face" ? "component" : mode)); + }, + [], + ); + + // What the selection is, for the commands that fit it (kept in refs so the registry itself is not rebuilt at every click). + const selectionRef = useRef(selection); + selectionRef.current = selection; + const pickedSketchesRef = useRef(pickedSketches); + pickedSketchesRef.current = pickedSketches; + const sketchListRef = useRef(sketchList); + sketchListRef.current = sketchList; + const context = useMemo(() => selectionContext(selection, pickedSketches), [selection, pickedSketches]); + + /** "Sketch on this face": the plane chooser's own path, started from the selected face (a clear refusal for a curved one). */ + const sketchOnSelectedFace = useCallback(async () => { + const face = selectionRef.current.find((item) => item.kind === "face"); + if (!face || !face.entityId) { + startSketch(); + return; + } + const resolved = await resolveFace(face.nodeId, face.entityId, { geometry: face.geometry, properties: face.properties }); + if (!resolved.frame) { + setBarNote(refusal(resolved.type)); + return; + } + setBarNote(null); + beginSketch({ mode: "new", name: "", plane: resolved.frame, doc: emptyDoc(resolved.frame) }); + }, [resolveFace, startSketch, beginSketch]); + + const handleEditFeatureRef = useRef<(feature: FeatureRow, spec: Spec) => void>(() => undefined); + const editSelectedSketch = useCallback(() => { + const info = sketchListRef.current.find((item) => item.name === pickedSketchesRef.current[0]); + const feature = info ? gui.state.features.find((row) => row.cell === info.cell) : undefined; + const spec = feature?.gui ? gui.catalog?.specs.find((item) => item.id === feature.gui?.spec) : undefined; + if (feature && spec) handleEditFeatureRef.current(feature, spec); + }, [gui.state.features, gui.catalog]); + + const openSpec = useCallback( + (spec: Spec, preset?: Record) => { + if (isSketchSpec(spec)) startSketch(); + else openDialog({ spec, ...(preset ? { prefill: preset } : {}), ...(spec.id === "hole" && selectionRef.current.some((item) => item.kind === "face") ? { fromFace: true } : {}) }); + }, + [openDialog, startSketch], + ); + const allCommands = useMemo( + () => [ + // The assembly layout is not a dialog: it is its own panel (registered below), so its spec is left out of the generic list. + ...commandsFromSpecs((gui.catalog?.specs ?? []).filter((spec) => spec.id !== "assembly"), openSpec, FEATURE_ICONS), + { + id: "sketch_on_face", + label: "Sketch on face", + group: "Sketch", + keywords: ["plane", "surface", "draw", "sketch"], + description: "Start a sketch on the selected flat face.", + icon: FEATURE_ICONS.sketch, + when: whenFor("sketch_on_face"), + contextOnly: true, + run: () => void sketchOnSelectedFace(), + }, + { + id: "edit_sketch", + label: "Edit sketch", + group: "Sketch", + keywords: ["change", "modify", "redraw"], + description: "Open the selected sketch for editing.", + icon: FEATURE_ICONS.sketch, + when: whenFor("edit_sketch"), + contextOnly: true, + run: editSelectedSketch, + }, + ...assemblyCommands(openAssembly, { assembly: Boxes, connectors: Cable }), + ], + [gui.catalog, openSpec, sketchOnSelectedFace, editSelectedSketch, openAssembly], + ); + // The stand-alone combine commands (Cut, Union, Intersect, Cut/Boss with sketch) are offered only while several bodies are open: + // a new solid says how it meets the body in its own dialog. + const commands = useMemo(() => visibleCommands(allCommands, { openBodies: gui.state.open.length }), [allCommands, gui.state.open.length]); + // A curved face cannot carry a sketch: the action stays visible, greyed, with the reason. + const barDisabled = useMemo>(() => { + const faces = selection.filter((item) => item.kind === "face"); + const type = faces.length === 1 && typeof faces[0].geometry?.type === "string" ? faces[0].geometry.type : null; + const out: Record = {}; + if (type && type.toLowerCase() !== "plane") out.sketch_on_face = refusal(type); + return out; + }, [selection]); + const fit = useMemo(() => fitScore(context), [context]); + const hint = useMemo(() => selectionHint(context, commands), [context, commands]); + const anchorMm = useMemo(() => { + const view = viewRef.current; + if (!view) return null; + const first = sketchList.find((info) => info.name === pickedSketches[0]); + const sketchAt = first ? sketchCentre(first.doc, sketchShapes.get(first.name)?.shape ?? null) : null; + return selectionAnchorMm(view, selection, sketchAt); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selection, pickedSketches, sketchList, sketchShapes, sceneVersion]); + + /** A command from the assembly panel: rejects with the server's message (shown in the panel). */ + const runPanelCommand = useCallback( + async (command: FeatureCommand) => { + const result = await gui.run(command); + log.push("ok", command.op === "set_connectors" ? "connectors set" : `${command.op} ${result.name}`); + if (isCellCommand(command)) assertCellRan(result); + }, + [gui.run, log.push], + ); + + /** The frames the viewport draws: the selected component's connectors (all components' while a picker is armed), + * and the open part's own connectors. */ + const gizmos = useMemo(() => { + if (leftTab !== "assembly") return []; + const out: Gizmo[] = []; + for (const c of asm.doc.components) { + if (!(c.id === asm.selected || asm.target)) continue; + for (const frame of asm.cache[c.part]?.connectors ?? []) out.push({ key: `${c.id}.${frame.id}`, owner: c.id, frame }); + } + if (gui.state.product) for (const frame of conn.list) out.push({ key: frame.id, owner: null, frame }); + return out; + }, [leftTab, asm.doc.components, asm.selected, asm.target, asm.cache, gui.state.product, conn.list]); + const gizmoHighlight = useMemo(() => { + const set = new Set(); + if (conn.selected) set.add(conn.selected); + for (const k of asm.doc.constraints) for (const side of [k.a, k.b]) if (side.component && side.connector) set.add(`${side.component}.${side.connector}`); + return set; + }, [conn.selected, asm.doc.constraints]); + const pickGizmo = useCallback( + (g: Gizmo) => { + if (g.owner === null) { + conn.setSelected(conn.selected === g.frame.id ? null : g.frame.id); + return; + } + if (asm.target) { + asm.setDoc(setRef(asm.doc, asm.target.constraint, asm.target.side, { component: g.owner, connector: g.frame.id })); + asm.setTarget(null); + } else asm.setSelected(g.owner); + }, + [asm, conn], + ); + + const handleEditFeature = (feature: FeatureRow, spec: Spec) => { + if (spec.id === "assembly") { + openAssembly("assembly"); + return; + } + if (!isSketchSpec(spec)) { + openDialog({ spec, edit: feature }); + return; + } + const doc = normalizeDoc(feature.gui?.values?.sketch); + beginSketch({ mode: "edit", cell: feature.cell, name: feature.slug, plane: doc.plane, doc }); + }; + handleEditFeatureRef.current = handleEditFeature; + + const treeSketches = useMemo( + () => ({ + infos: sketchList, + hidden: hiddenSketches, + selected: pickedSketches, + hover: hoverSketch, + onToggleHidden: toggleSketchHidden, + onSelect: pickSketch, + onHover: setHoverSketch, + }), + [sketchList, hiddenSketches, pickedSketches, hoverSketch, toggleSketchHidden, pickSketch], + ); + const dialogSketches = useMemo( + () => ({ infos: sketchList, shapes: sketchShapes, picked: pickedSketches.length === 1 ? pickedSketches[0] : null, onPreview: setPushArrow }), + [sketchList, sketchShapes, pickedSketches], + ); + const inspectedSketches = useMemo( + () => + pickedSketches.flatMap((name) => { + const info = sketchList.find((item) => item.name === name); + const solved = sketchShapes.get(name); + return info ? [{ name, rows: describeSketch(info, solved?.shape ?? null, solved?.solve ?? null) }] : []; + }), + [pickedSketches, sketchList, sketchShapes], + ); + // -- actions ----------------------------------------------------------------------- const handleOpen = (path: string) => { @@ -338,6 +809,7 @@ export function App() { return (
+
{ @@ -352,15 +827,44 @@ export function App() { socketRef.current?.send({ type: "rerun.request" }); }} /> +
+
+ features + assembly notebook workspace {moduleView && {moduleView.name}} + + void runFeature({ op: "delete", cell: feature.cell })} + onUseProduct={(body) => void runFeature({ op: "use_as_product", body })} + onUndo={undo} + onRedo={redo} + sketches={treeSketches} + /> + + + + )} +
-
+
{MODES.map(({ mode, label }) => (
-
+
+
- +
+ +
+
void; +}; + +/** A small floating bar of actions hung near a point of its container (what is selected), the way CAD packages show the + * operations that fit the selection. The first action also answers Enter while the bar is shown (`enter`), so the most + * common next step is one key. `anchor` is in the container's pixels (null docks the bar at the top centre). + * `notice` is a one-line message under the bar (a refusal, for instance). */ +export function ActionBar({ + actions, + anchor, + bounds, + title, + notice, + enter = true, + className, +}: { + actions: readonly BarAction[]; + anchor: readonly [number, number] | null; + bounds: Size; + title?: string; + notice?: string | null; + enter?: boolean; + className?: string; +}) { + const ref = useRef(null); + const [size, setSize] = useState({ w: 260, h: 34 }); + useLayoutEffect(() => { + const el = ref.current; + if (el && (Math.abs(el.offsetWidth - size.w) > 1 || Math.abs(el.offsetHeight - size.h) > 1)) setSize({ w: el.offsetWidth, h: el.offsetHeight }); + }); + + // Clicking an action that cannot run says why, under the bar, until the actions change. + const [why, setWhy] = useState<{ id: string; text: string } | null>(null); + const signature = actions.map((action) => `${action.id}:${action.disabled ?? ""}`).join("|"); + useEffect(() => setWhy(null), [signature]); + + const first = actions.find((action) => !action.disabled); + const firstRef = useRef(first); + firstRef.current = first; + useEffect(() => { + if (!enter || actions.length === 0) return; + const onKey = (event: KeyboardEvent) => { + if (!enterRunsFirst(event)) return; + const action = firstRef.current; + if (!action) return; + event.preventDefault(); + action.run(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [enter, actions.length]); + + if (actions.length === 0) return null; + const at = placeBar(anchor, size, bounds); + return ( +
+
+ {title && {title}} + {actions.map((action) => { + const Icon = action.icon; + const isFirst = action === first; + return ( + + ); + })} +
+ {(notice ?? why?.text) && ( +

+ {notice ?? why?.text} +

+ )} +
+ ); +} diff --git a/editor-web/src/components/AssemblyPanel.tsx b/editor-web/src/components/AssemblyPanel.tsx new file mode 100644 index 00000000..176c3cf2 --- /dev/null +++ b/editor-web/src/components/AssemblyPanel.tsx @@ -0,0 +1,557 @@ +import { Anchor, Crosshair, Link2, Loader2, Plus, RefreshCw, Search, Trash2, TriangleAlert } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + AXES, + KINDS, + KIND_LABEL, + addComponent, + addConnectorFromFace, + addConstraint, + applyCommand, + assemblyProblems, + assemblyStatus, + compatibleAxes, + connectorProblems, + disableExplode, + enableExplode, + hasPhase, + idProblem, + needsDrive, + radiusFields, + removeComponent, + removeConnector, + removeConstraint, + renameComponent, + renameConnector, + setAxis, + setConnectorsCommand, + setExplodeFactor, + setExplodeVector, + setGround, + setKind, + setRef, + updateComponent, + updateConstraint, + type AssemblyDoc, + type AxisName, + type Component, + type Constraint, + type ConstraintKind, + type ConnectorFrame, +} from "@/lib/assembly"; +import type { FeatureCommand, FeatureState } from "@/lib/features"; +import { isDescribed, filterSelection } from "@/lib/features"; +import type { SelectionItem } from "@/lib/selection"; +import type { AssemblyEditor, ConnectorDraft } from "@/lib/useAssembly"; +import { cn } from "@/lib/utils"; +import type { SelectionMode } from "@/scene/Viewport"; + +const select = "h-7 min-w-0 flex-1 border-2 border-input bg-background px-1 text-[12px] text-foreground focus-visible:border-focus focus-visible:outline-none disabled:opacity-50 aria-[invalid=true]:border-fail"; +const head = "flex items-center gap-2 border-b border-border px-2 py-1.5"; + +/** A number box that holds its own text while typing and tells the document only a valid number (or "" for none). */ +function NumberField({ label, value, onChange, optional = false, width = "w-16", testid }: { label: string; value: number | undefined; onChange: (v: number | undefined) => void; optional?: boolean; width?: string; testid?: string }) { + const [text, setText] = useState(value === undefined ? "" : String(value)); + const [bad, setBad] = useState(false); + useEffect(() => { + // An outside change (another notebook, a reset) replaces what is shown; typing in progress is left alone. + setText((t) => (Number(t) === value && t.trim() !== "" ? t : value === undefined ? (t.trim() === "" ? t : "") : String(value))); + }, [value]); + return ( + + ); +} + +function Section({ title, count, children, aside }: { title: string; count?: number; children: React.ReactNode; aside?: React.ReactNode }) { + return ( +
+
+ {title}{count !== undefined && ` (${count})`} + + {aside} +
+ {children} +
+ ); +} + +function Problems({ items }: { items: string[] }) { + if (items.length === 0) return null; + return ( +
    + {items.map((text) => ( +
  • {text}
  • + ))} +
+ ); +} + +// -- the part's own connectors -------------------------------------------------------------------------------------------------- + +function ConnectorsSection({ + state, + draft, + selection, + busy, + onSelectionMode, + onRun, +}: { + state: FeatureState; + draft: ConnectorDraft; + selection: SelectionItem[]; + busy: boolean; + onSelectionMode: (mode: SelectionMode) => void; + onRun: (command: FeatureCommand) => Promise; +}) { + const [name, setName] = useState(""); + const [note, setNote] = useState<{ tone: "ok" | "fail"; text: string } | null>(null); + const [working, setWorking] = useState(false); + const faces = filterSelection(selection, "face"); + const problems = connectorProblems(draft.list); + + const addFromFace = () => { + if (faces.length !== 1) return setNote({ tone: "fail", text: "Pick exactly one planar face in the viewport (face mode)." }); + if (!isDescribed(faces[0])) return setNote({ tone: "fail", text: "The face details are still loading; try again in a moment." }); + const made = addConnectorFromFace(draft.list, faces[0], name); + if ("error" in made) return setNote({ tone: "fail", text: made.error }); + draft.setList(made.list); + draft.setSelected(made.id); + setName(""); + setNote({ tone: "ok", text: `connector "${made.id}" added on the picked face: apply to write it into the part` }); + }; + const apply = async () => { + if (problems.length || working) return; + setWorking(true); + try { + await onRun(setConnectorsCommand(draft.list)); + setNote({ tone: "ok", text: "connectors written into the part's product cell" }); + } catch (e) { + setNote({ tone: "fail", text: e instanceof Error ? e.message : String(e) }); + } finally { + setWorking(false); + } + }; + + return ( +
onSelectionMode("face")} data-testid="connector-face-mode"> + face mode + + } + > +
+ {draft.list.length === 0 &&

No connectors yet. A connector is a frame (origin, x, y) an assembly joins parts by: pick a planar face and add one.

} +
    + {draft.list.map((c) => ( + draft.setSelected(draft.selected === c.id ? null : c.id)} list={draft.list} setList={draft.setList} onSelected={draft.setSelected} /> + ))} +
+
+ + +
+ + {faces.length === 1 ? "1 face picked." : faces.length === 0 ? "Pick a face in the viewport (face mode)." : `${faces.length} faces picked: pick exactly one.`} + + + {note && ( +

+ {note.text} +

+ )} +
+ + {draft.dirty && unsent changes} + {!state.product && no product yet} +
+
+
+ ); +} + +function ConnectorRow({ c, active, onSelect, list, setList, onSelected }: { c: ConnectorFrame; active: boolean; onSelect: () => void; list: ConnectorFrame[]; setList: (l: ConnectorFrame[]) => void; onSelected: (id: string | null) => void }) { + const [text, setText] = useState(c.id); + const [error, setError] = useState(null); + useEffect(() => setText(c.id), [c.id]); + const commit = () => { + if (text === c.id) return setError(null); + const out = renameConnector(list, c.id, text); + if ("error" in out) return setError(out.error); + setError(null); + setList(out.list); + onSelected(text); + }; + const f = (v: readonly number[]) => v.map((n) => Math.round(n * 1000) / 1000).join(", "); + return ( +
  • +
    + + setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} /> + +
    +
    + at {f(c.origin)} mm
    x ({f(c.x_axis)}) z ({f(c.z_axis ?? [0, 0, 0])}) +
    + {error &&

    {error}

    } +
  • + ); +} + +// -- the assembly ---------------------------------------------------------------------------------------------------------------- + +function PartPicker({ editor, onPick }: { editor: AssemblyEditor; onPick: (path: string) => void }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const rows = useMemo(() => { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + return editor.parts.filter((p) => words.every((w) => `${p.name} ${p.path}`.toLowerCase().includes(w))); + }, [editor.parts, query]); + return ( +
    + + {open && ( +
    +
    + + setQuery(e.target.value)} /> +
    +
      + {rows.map((p) => ( +
    • + +
    • + ))} + {rows.length === 0 &&
    • {editor.partsError ?? (editor.parts.length === 0 ? "No part notebooks in this workspace yet." : "No part matches.")}
    • } +
    +
    + )} +
    + ); +} + +function ComponentCard({ c, doc, editor }: { c: Component; doc: AssemblyDoc; editor: AssemblyEditor }) { + const [text, setText] = useState(c.id); + useEffect(() => setText(c.id), [c.id]); + const problem = text === c.id ? null : idProblem(doc, c.id, text); + const selected = editor.selected === c.id; + const commit = () => { + if (text === c.id || problem) { + if (problem) setText(c.id); + return; + } + editor.setDoc(renameComponent(doc, c.id, text)); + if (selected) editor.setSelected(text); + }; + const entry = editor.cache[c.part]; + return ( +
  • +
    + + setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} /> + {c.part} + + +
    + {problem &&

    {problem}

    } +
    + {(["x", "y", "z"] as const).map((axis, i) => ( + v !== undefined && editor.setDoc(updateComponent(doc, c.id, { origin: c.origin.map((o, j) => (j === i ? v : o)) as [number, number, number] }))} /> + ))} + + +
    + {entry?.status === "error" &&

    connectors of {c.part}: {entry.error}

    } +
  • + ); +} + +function ConnectorPicker({ k, side, doc, editor }: { k: Constraint; side: "a" | "b"; doc: AssemblyDoc; editor: AssemblyEditor }) { + const ref = k[side]; + const component = doc.components.find((c) => c.id === ref.component); + const path = component?.part ?? ""; + const entry = path ? editor.cache[path] : undefined; + const { loadConnectors } = editor; + useEffect(() => { + if (path) loadConnectors(path); + }, [path, loadConnectors]); + const armed = editor.target?.constraint === k.id && editor.target.side === side; + const ids = entry?.connectors.map((c) => c.id) ?? []; + const manual = entry?.status === "error" || (entry?.status === "ok" && ids.length === 0); + return ( +
    + {side} + + {manual ? ( + editor.setDoc(setRef(doc, k.id, side, { connector: e.target.value.trim() }))} title={entry?.error ?? "this part declares no connectors: type an id"} /> + ) : ( + + )} + {entry?.status === "loading" && } + +
    + ); +} + +function ConstraintCard({ k, doc, editor }: { k: Constraint; doc: AssemblyDoc; editor: AssemblyEditor }) { + const patch = (p: Partial) => editor.setDoc(updateConstraint(doc, k.id, p)); + const unit = k.kind === "revolute" ? "deg" : "mm"; + return ( +
  • +
    + + {k.id} + + +
    +
    + + +
    + {(needsDrive(k.kind) || radiusFields(k.kind).length > 0 || hasPhase(k.kind)) && ( +
    + {needsDrive(k.kind) && ( + <> + patch({ drive: v })} /> + patch({ limit: v === undefined && k.limit?.[1] === undefined ? undefined : [v ?? Number.NaN, k.limit?.[1] ?? Number.NaN] })} /> + patch({ limit: v === undefined && k.limit?.[0] === undefined ? undefined : [k.limit?.[0] ?? Number.NaN, v ?? Number.NaN] })} /> + + )} + {radiusFields(k.kind).map((f) => ( + patch({ [f.key]: v } as Partial)} /> + ))} + {hasPhase(k.kind) && patch({ phase: v })} />} +
    + )} +
  • + ); +} + +/** The exploded view: a switch, the factor and an optional centre and axis. The notebook shows the pushed-apart placement + * (scad.explode_rassembly); the factor also becomes a slider in the parameters panel, so 0 puts everything back. */ +function ExplodeSection({ doc, editor }: { doc: AssemblyDoc; editor: AssemblyEditor }) { + const explode = doc.explode; + const entry = (v: number | undefined) => (v === undefined || Number.isNaN(v) ? undefined : v); + return ( +
    editor.setDoc(on ? enableExplode(doc) : disableExplode(doc))} />} + > +
    +

    Components move away from the assembly centre; with an axis they move along it, to the side they are on.

    + {explode && ( + <> +
    + editor.setDoc(setExplodeFactor(doc, v ?? Number.NaN))} /> + 0 puts everything back +
    + {(["center", "axis"] as const).map((key) => ( +
    + {key}{key === "center" ? " mm" : ""} + {(["x", "y", "z"] as const).map((axis, i) => ( + editor.setDoc(setExplodeVector(doc, key, i, v))} /> + ))} +
    + ))} + centre: empty = the centre of the whole assembly; axis: empty = away from the centre. + + )} +
    +
    + ); +} + +function AssemblySection({ state, editor, busy, onRun }: { state: FeatureState; editor: AssemblyEditor; busy: boolean; onRun: (command: FeatureCommand) => Promise }) { + const { doc } = editor; + const [name, setName] = useState(""); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + const [applied, setApplied] = useState(false); + const { refreshParts } = editor; + useEffect(() => refreshParts(), [refreshParts]); + // A limit with only one side typed is not a limit yet: it is sent as none until both are numbers. + const sendable = useMemo( + () => ({ ...doc, constraints: doc.constraints.map((k) => (k.limit && k.limit.some((v) => !Number.isFinite(v)) ? { ...k, limit: undefined } : k)) }), + [doc], + ); + const half = doc.constraints.filter((k) => k.limit && k.limit.some((v) => !Number.isFinite(v))).map((k) => `${k.id}: type both limits or neither`); + const problems = [...assemblyProblems(sendable), ...half]; + const status = assemblyStatus(state); + const taken = state.taken; + + const apply = async () => { + if (problems.length || working) return; + setWorking(true); + setError(null); + setApplied(false); + try { + await onRun(applyCommand(sendable, state.assembly ?? null, state.assembly ? undefined : name)); + setApplied(true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setWorking(false); + } + }; + const nameTaken = !state.assembly && name.trim() !== "" && taken.includes(name.trim()); + + return ( + <> +
    } + > +
    + {doc.components.length === 0 &&

    No components. Add a part notebook of this workspace; the first one added is the ground.

    } +
      + {doc.components.map((c) => )} +
    + { const made = addComponent(doc, path); editor.setDoc(made.doc); editor.setSelected(made.id); editor.loadConnectors(path); }} /> +
    +
    +
    editor.setDoc(addConstraint(doc).doc)}> + constraint + + } + > +
    + {doc.constraints.length === 0 &&

    No constraints: every component sits at its placement. A joint moves one component about a connector of another; a drive becomes a slider.

    } +
      + {doc.constraints.map((k) => )} +
    + {editor.target &&

    Click a connector gizmo in the viewport to fill {editor.target.constraint} {editor.target.side.toUpperCase()}.

    } +
    +
    + +
    + {!state.assembly && ( + + )} + + {error &&

    {error}

    } +
    + + {editor.dirty && unsent changes} + {applied && !editor.dirty && applied} +
    +
    + + + {!status + ? "not in the notebook yet" + : status.errors.length + ? {status.errors.join("\n")} + : status.running + ? "solving…" + : "solved: the constraints hold (drag a joint's slider in the notebook's parameters)"} + +
    +
    + + ); +} + +/** The left column's assembly tab: the part's connectors (when the notebook has a product) and the assembly layout. */ +export function AssemblyPanel({ + state, + editor, + connectors, + selection, + busy, + onSelectionMode, + onRun, +}: { + state: FeatureState; + editor: AssemblyEditor; + connectors: ConnectorDraft; + selection: SelectionItem[]; + busy: boolean; + onSelectionMode: (mode: SelectionMode) => void; + /** Runs the command; rejects with the server's message (shown inline). */ + onRun: (command: FeatureCommand) => Promise; +}) { + return ( +
    + {state.product && } + +
    + ); +} diff --git a/editor-web/src/components/CommandBar.tsx b/editor-web/src/components/CommandBar.tsx new file mode 100644 index 00000000..7d8350e2 --- /dev/null +++ b/editor-web/src/components/CommandBar.tsx @@ -0,0 +1,248 @@ +import { ChevronsDownUp, ChevronsUpDown, PanelBottom, PanelTop, Search } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { commandGroups, pushRecent, searchCommands, type Command } from "@/lib/commands"; +import { cn } from "@/lib/utils"; + +const DOCK_KEY = "sca.commandbar.dock"; +const RECENT_KEY = "sca.commandbar.recent"; +const VISIBLE_ROWS = 6; + +function readStore(key: string, fallback: T, parse: (raw: string) => T | null): T { + try { + const raw = window.localStorage.getItem(key); + return raw === null ? fallback : (parse(raw) ?? fallback); + } catch { + return fallback; + } +} +function writeStore(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + /* private window or blocked storage: the bar still works */ + } +} + +/** A floating command bar over the viewport: category chips, a search box, a short result list. + * `/` or Ctrl/Cmd+K focuses the search; Up/Down move, Enter runs, Esc closes the list. + * It collapses to a pill and docks at the bottom (default) or the top. */ +export function CommandBar({ commands, fit, hint }: { commands: Command[]; /** How much a command fits the selection (see fitScore): the fitting ones come first. */ fit?: (command: Command) => number; /** One line saying what the selection can do. */ hint?: string | null }) { + const [dock, setDock] = useState<"top" | "bottom">(() => readStore<"top" | "bottom">(DOCK_KEY, "bottom", (raw) => (raw === "top" || raw === "bottom" ? raw : null))); + const [collapsed, setCollapsed] = useState(false); + const [recent, setRecent] = useState(() => readStore(RECENT_KEY, [], (raw) => (Array.isArray(JSON.parse(raw)) ? (JSON.parse(raw) as string[]) : null))); + const [query, setQuery] = useState(""); + const [group, setGroup] = useState(null); + const [open, setOpen] = useState(false); + const [highlight, setHighlight] = useState(0); + const [focusAsked, setFocusAsked] = useState(0); + const root = useRef(null); + const input = useRef(null); + const listRef = useRef(null); + + const groups = useMemo(() => commandGroups(commands), [commands]); + const results = useMemo(() => { + const listed = commands.filter((command) => !command.contextOnly || (fit?.(command) ?? 0) > 0); + const pool = group && !query.trim() ? listed.filter((command) => command.group === group) : listed; + return searchCommands(pool, query, recent, fit); + }, [commands, group, query, recent, fit]); + const shown = open ? results : []; + const at = Math.min(highlight, Math.max(shown.length - 1, 0)); + + const run = useCallback( + (command: Command) => { + const next = pushRecent(recent, command.id); + setRecent(next); + writeStore(RECENT_KEY, JSON.stringify(next)); + setOpen(false); + setQuery(""); + setGroup(null); + input.current?.blur(); + command.run(); + }, + [recent], + ); + + // `/` and Ctrl/Cmd+K focus the search (`/` only when no text field has the focus). + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const typing = Boolean(target?.closest("input, textarea, select, [contenteditable=true], .cm-editor")); + const palette = (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "k"; + const slash = event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey && !typing; + if (!palette && !slash) return; + event.preventDefault(); + setCollapsed(false); + setFocusAsked((count) => count + 1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + useEffect(() => { + if (focusAsked > 0 && !collapsed) { + input.current?.focus(); + input.current?.select(); + setOpen(true); + setHighlight(0); + } + }, [focusAsked, collapsed]); + + // A click outside closes the list. + useEffect(() => { + const onDown = (event: MouseEvent) => { + if (root.current && !root.current.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, []); + + useEffect(() => { + listRef.current?.querySelector("[data-active=true]")?.scrollIntoView({ block: "nearest" }); + }, [at, shown.length]); + + const onInputKey = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!open) { + setOpen(true); + return; + } + const step = event.key === "ArrowDown" ? 1 : -1; + setHighlight((at + step + shown.length) % Math.max(shown.length, 1)); + } else if (event.key === "Enter") { + event.preventDefault(); + const command = shown[at]; + if (command) run(command); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (open) setOpen(false); + else input.current?.blur(); + } + }; + + const bottom = dock === "bottom"; + const toggleDock = () => { + const next = bottom ? "top" : "bottom"; + setDock(next); + writeStore(DOCK_KEY, next); + }; + const place = bottom ? "bottom-3" : "top-3"; + + if (collapsed) { + return ( +
    + +
    + ); + } + + return ( +
    +
    + {shown.length > 0 && ( +
      + {shown.map((command, index) => { + const Icon = command.icon; + return ( +
    • setHighlight(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => run(command)} + > + {Icon ? : } + {command.label} + {command.description} + 0 ? "font-bold" : "opacity-70")}>{fit && fit(command) > 0 ? "fits selection" : command.group} +
    • + ); + })} +
    + )} + {hint && ( +

    0 ? "hidden" : bottom ? "bottom-full mb-1" : "top-full mt-1")}> + {hint} +

    + )} +
    +
    + {groups.map((name) => ( + + ))} +
    +
    + + + +
    +
    +
    +
    + ); +} diff --git a/editor-web/src/components/ConnectorGizmos.tsx b/editor-web/src/components/ConnectorGizmos.tsx new file mode 100644 index 00000000..856d8257 --- /dev/null +++ b/editor-web/src/components/ConnectorGizmos.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import * as THREE from "three"; + +import { createAxisTriad } from "@/components/scene/axis-triad"; +import { SceneLabel } from "@/components/scene/SceneLabel"; +import { useSceneLayer } from "@/components/scene/useSceneLayer"; +import { placementDirToWorld, placementToWorld, type Component, type ConnectorFrame } from "@/lib/assembly"; +import { cadFramePose, connectorSizeMm, type V3 } from "@/scene/cad-pose"; +import { placementMatrix } from "@/scene/cad-three"; +import { fitDistance } from "@/scene/camera-fit"; +import { applyApparentScale } from "@/scene/layers/apparent-size"; +import { layerHostFor, type Anchor } from "@/scene/layers/layer-host"; +import type { SceneView } from "@/scene/scene-view"; + +/** One connector frame to draw: `owner` is the component it belongs to (null for the open part's own connectors). */ +export type Gizmo = { key: string; owner: string | null; frame: ConnectorFrame }; + +const LAYER = "connectors"; +/** Gizmo size = 12 % of the model's largest extent, clamped to [3, 120] mm. */ +const SIZE = { fraction: 0.12, min: 3, max: 120 }; +/** The apparent-size compromise: the size follows the camera distance (roughly constant on screen) between 0.15x and 3x. */ +const FACTORS = { minFactor: 0.15, maxFactor: 3 }; + +const cross = (a: V3, b: V3): [number, number, number] => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + +/** The pose (glTF, relative to the layer root) of a component at its document placement: used while the viewer holds no node of it. */ +function placementFallback(comp: Component): THREE.Matrix4 { + const x = placementDirToWorld(comp, [1, 0, 0]); + const y = placementDirToWorld(comp, [0, 1, 0]); + return placementMatrix({ origin: placementToWorld(comp, [0, 0, 0]), x_axis: x, y_axis: y, z_axis: cross(x, y) }); +} + +/** The gizmo's `Gizmo` of a pick, label text of a gizmo. */ +const labelOf = (g: Gizmo) => `${g.owner ? `${g.owner}.` : ""}${g.frame.id}`; + +/** Connector frames drawn as REAL 3D objects in the viewer's scene (a "connectors" scene layer): X red / Y green / Z blue + * arrows with cone heads, an origin dot and a contact-plane square, so the camera gives exact perspective and + * foreshortening, the model hides them (with a faint always-visible second pass), and a solved joint or the exploded + * view moves them with their part (layer items follow the component's node object). + * + * This component renders only the labels (hovered, highlighted or, with `armed`, all) and manages the layer. A click + * on a gizmo reports it through `onPick` (and is not seen by the model picker). + * + * Size: world-sized from the model (12 % of its largest extent, clamped), with the apparent size kept roughly + * constant while zooming (scaled by camera distance relative to the model's framing distance, clamped to 0.15x..3x). */ +export function ConnectorGizmos({ + view, + gizmos, + components, + highlighted, + onPick, + armed = false, +}: { + view: SceneView; + gizmos: Gizmo[]; + components: Component[]; + highlighted: ReadonlySet; + onPick?: (gizmo: Gizmo) => void; + /** A connector picker is armed: every frame is emphasised and labelled. */ + armed?: boolean; +}) { + const layer = useSceneLayer(view, LAYER); + const [hover, setHover] = useState(null); + const byKey = useRef(new Map()); + const pickRef = useRef(onPick); + useEffect(() => { + pickRef.current = onPick; + }); + + // The frames: rebuilt when the list (or the components' placements) change. + useEffect(() => { + if (!layer) return; + layer.clear(); + byKey.current = new Map(gizmos.map((g) => [g.key, g])); + const sizing = { nodes: null as unknown, count: -1, baseM: 0.01, referenceDistance: 0 }; + const refresh = () => { + const { engine } = view; + const count = engine.modelRoot.children.length; + if (sizing.nodes === view.nodes && sizing.count === count) return; + sizing.nodes = view.nodes; + sizing.count = count; + const bounds = view.modelBoundsMm(); + sizing.baseM = connectorSizeMm(bounds, SIZE) / 1000; + const radiusM = bounds ? Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2000 : 0; + sizing.referenceDistance = radiusM > 0 ? fitDistance(radiusM, THREE.MathUtils.degToRad(engine.camera.fov), engine.camera.aspect) : engine.camera.position.distanceTo(engine.controls.target); + }; + for (const g of gizmos) { + const pose = cadFramePose(g.frame); + if (!pose) continue; + const triad = createAxisTriad(); + triad.object.position.copy(pose.position); + triad.object.quaternion.copy(pose.quaternion); + const comp = g.owner ? components.find((c) => c.id === g.owner) : undefined; + const anchor: Anchor | null = g.owner ? { componentId: g.owner } : null; + layer.add({ + key: g.key, + object: triad.object, + anchor, + fallback: comp ? placementFallback(comp) : undefined, + onState: (state) => triad.setState(state), + onFrame: ({ camera }) => { + refresh(); + // The pose (position) is in metres already; the scale carries the world size. + applyApparentScale(triad.object, camera, sizing.baseM, { referenceDistance: sizing.referenceDistance, ...FACTORS }); + }, + }); + } + layerHostFor(view).sync(true); + }, [layer, gizmos, components, view]); + + // Emphasis: highlighted = selected, armed = active for every frame, hover from the pointer. + useEffect(() => { + if (!layer) return; + layer.setSelected(highlighted); + layer.setActive(armed ? gizmos.map((g) => g.key) : []); + }, [layer, highlighted, armed, gizmos]); + useEffect(() => { + layer?.setHover(hover); + }, [layer, hover]); + + // Pointer: hover feedback and click picking on the 3D frames (capture phase: a hit never reaches the model picker). + useEffect(() => { + if (!layer) return; + const element = view.engine.domElement; + const previousCursor = element.style.cursor; + let down: { x: number; y: number } | null = null; + let frame = 0; + let last: { x: number; y: number } | null = null; + const onMove = (event: PointerEvent) => { + if (event.buttons !== 0) return; + last = { x: event.clientX, y: event.clientY }; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + const hit = last ? layer.pick(last.x, last.y) : null; + setHover(hit ? hit.key : null); + element.style.cursor = hit && pickRef.current ? "pointer" : previousCursor; + }); + }; + const onLeave = () => { + setHover(null); + element.style.cursor = previousCursor; + }; + const onDown = (event: PointerEvent) => { + down = event.button === 0 ? { x: event.clientX, y: event.clientY } : null; + }; + const onUp = (event: PointerEvent) => { + const start = down; + down = null; + if (!start || event.button !== 0 || !pickRef.current) return; + if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 4) return; + const hit = layer.pick(event.clientX, event.clientY); + const gizmo = hit ? byKey.current.get(hit.key) : undefined; + if (!gizmo) return; + event.stopImmediatePropagation(); + event.preventDefault(); + pickRef.current(gizmo); + }; + element.addEventListener("pointermove", onMove); + element.addEventListener("pointerleave", onLeave); + element.addEventListener("pointerdown", onDown, true); + element.addEventListener("pointerup", onUp, true); + return () => { + if (frame) cancelAnimationFrame(frame); + element.removeEventListener("pointermove", onMove); + element.removeEventListener("pointerleave", onLeave); + element.removeEventListener("pointerdown", onDown, true); + element.removeEventListener("pointerup", onUp, true); + element.style.cursor = previousCursor; + setHover(null); + }; + }, [layer, view]); + + const labelled = useMemo(() => gizmos.filter((g) => armed || g.key === hover || highlighted.has(g.key)), [gizmos, armed, hover, highlighted]); + + return ( +
    + {layer && + labelled.map((g) => ( + layer.get(g.key)?.worldPosition() ?? null} + className="rounded border border-border bg-background/85 px-1 font-mono text-[11px] font-bold text-foreground" + > + {labelOf(g)} + + ))} +
    + ); +} diff --git a/editor-web/src/components/FeatureDialog.tsx b/editor-web/src/components/FeatureDialog.tsx new file mode 100644 index 00000000..2e398687 --- /dev/null +++ b/editor-web/src/components/FeatureDialog.tsx @@ -0,0 +1,672 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowUpDown, Box, Combine, Crosshair, MousePointerClick, Scissors, SquarePlus, type LucideIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { OperationChooser } from "@/components/OperationChooser"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input, Textarea } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectLabel, SelectGroup, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { plainDescription, plainLabel } from "@/lib/commands"; +import { OPERATION_SLOT, TARGET_SLOT, formDefaults, type CombineState, operationOptions, targetDisplay, visibleSlots } from "@/lib/combine"; +import { defaultExtrudeDistance, profileSummary, type SketchInfo } from "@/lib/sketch-scene"; +import type { SketchSolved } from "@/lib/useSketchShapes"; +import { frameNormal, normalizePlane, dot, type Vec3 } from "@/lib/sketch-plane"; +import { directionVector } from "@/scene/sketch-layer"; +import { + CellFailure, + FLIP_NORMAL, + FRIENDLY_NAMES, + bodyChoices, + directionForProfile, + directionOptions, + resolveDirection, + findProfile, + filterSelection, + formToValues, + holeFromFace, + initialForm, + profilePlaneLabel, + isDescribed, + pickedPayload, + suggestName, + validateName, + type FeatureCommand, + type FeatureRow, + type FeatureState, + type PickedItem, + type RawForm, + type Slot, + type SlotOption, + type Spec, +} from "@/lib/features"; +import type { SelectionItem } from "@/lib/selection"; +import type { SelectionMode } from "@/scene/Viewport"; + +/** The icon of each way a new solid meets the body (colour stays reserved for meaning, so the icons carry no ink). */ +const OPERATION_ICONS: Record = { join: SquarePlus, cut: Scissors, intersect: Combine, new: Box }; + +/** `prefill` sets starting values of slots (a sketch to extrude, a distance); `fromFace` fills a hole from the selected face. */ +export type DialogRequest = { spec: Spec; edit?: FeatureRow; prefill?: Record; fromFace?: boolean }; + +/** What the form knows of the notebook's sketches: who they are, their solved shapes, and the one selected in the viewport. */ +export type DialogSketches = { + infos: readonly SketchInfo[]; + shapes: ReadonlyMap; + picked: string | null; + /** The arrow the viewport draws for the push the form describes (null: none). */ + onPreview: (arrow: { sketch: string; direction: Vec3; distance: number } | null) => void; +}; + +/** The form of one operation, generated from its spec. A docked card, not a modal: the viewport stays live. */ +export function FeatureDialog({ + request, + state, + selection, + sketches, + onSelectionMode, + onSubmit, + onClose, +}: { + request: DialogRequest | null; + state: FeatureState; + selection: SelectionItem[]; + sketches: DialogSketches; + onSelectionMode: (mode: SelectionMode) => void; + /** Runs the command; rejects with the server's message, shown inline. */ + onSubmit: (command: FeatureCommand) => Promise; + onClose: () => void; +}) { + return ( + !open && onClose()}> + {request && ( + + )} + + ); +} + +function FeatureForm({ + request, + state, + selection, + sketches, + onSelectionMode, + onSubmit, + onClose, +}: { + request: DialogRequest; + state: FeatureState; + selection: SelectionItem[]; + sketches: DialogSketches; + onSelectionMode: (mode: SelectionMode) => void; + onSubmit: (command: FeatureCommand) => Promise; + onClose: () => void; +}) { + const { spec, edit } = request; + const editing = Boolean(edit); + const profileSlotOf = spec.slots.find((slot) => slot.kind === "profile"); + const hasDirection = spec.slots.some((slot) => slot.name === "direction"); + const serverFlips = Boolean(spec.slots.find((slot) => slot.name === "direction")?.options?.some((option) => option.value === FLIP_NORMAL)); + const [form, setForm] = useState(() => { + // A new feature takes the sketch selected in the viewport as its profile (the prefill of a command wins). + const chosen = !edit && profileSlotOf && sketches.picked && findProfile(state, sketches.picked) ? { [profileSlotOf.name]: sketches.picked } : {}; + const base = initialForm(spec, formDefaults(spec, state.defaults[spec.id] ?? {}, editing), { ...(edit?.gui?.values ?? {}), ...chosen, ...(request.prefill ?? {}) }); + const named = profileSlotOf && typeof base[profileSlotOf.name] === "string" ? (base[profileSlotOf.name] as string) : null; + // A new push takes about a quarter of the sketch's largest extent as its distance (unless the command said one). + const sizeSlot = spec.slots.find((slot) => slot.name === "distance" || slot.name === "depth"); + const shape = named ? sketches.shapes.get(named)?.shape : undefined; + const sized: RawForm = !edit && sizeSlot && shape?.extent && !(request.prefill && sizeSlot.name in request.prefill) ? { ...base, [sizeSlot.name]: String(defaultExtrudeDistance(shape, Number(base[sizeSlot.name]) || 10)) } : base; + // A new feature on a sketch pushes along that sketch's plane normal. + const normal = !edit && named ? directionForProfile(state, named) : null; + if (!(normal && hasDirection)) return sized; + // A cut goes into the material: for a sketch drawn on a face (a frame, its normal pointing out) that is against the normal. + const profile = named ? findProfile(state, named) : undefined; + const into = (spec.id === "cut_sketch" || base.operation === "cut") && profile && profilePlaneLabel(profile) === "face"; + return { ...sized, direction: into ? FLIP_NORMAL : normal }; + }); + const [name, setName] = useState(edit?.slug ?? ""); + const [busy, setBusy] = useState(false); + const [serverError, setServerError] = useState(null); + const [touched, setTouched] = useState(false); + const [note, setNote] = useState(null); + // A new feature whose cell failed exists already: the next submit edits it instead of adding a second one. + const [created, setCreated] = useState(null); + + // Picks present when the form opened: in edit mode only a different pick replaces the recipe. + const [initialKeys] = useState(() => new Set(selection.map((item) => item.key))); + const selectSlots = spec.slots.filter((slot) => slot.kind === "select"); + const picksOf = (slot: Slot) => filterSelection(selection, slot.entity ?? "face"); + const replaced = (slot: Slot) => { + const keys = picksOf(slot).map((item) => item.key); + if (!keys.length) return false; + return !editing || keys.length !== initialKeys.size || keys.some((key) => !initialKeys.has(key)); + }; + + // The sketch field is armed: a click on a sketch in the viewport or the tree fills it (until the person turns it off). + const [armed, setArmed] = useState(() => Boolean(profileSlotOf) && !(request.prefill && profileSlotOf && request.prefill[profileSlotOf.name])); + + // The viewport picks what the form asks for while it is open, and goes back after. + const wanted = (selectSlots[0]?.entity ?? (spec.id === "hole" ? "face" : profileSlotOf && armed ? "sketch" : null)) as SelectionMode | null; + useEffect(() => { + if (wanted) onSelectionMode(wanted); + }, [wanted, onSelectionMode]); + + const set = (slot: string, value: RawForm[string]) => { + setForm((previous) => ({ ...previous, [slot]: value })); + setServerError(null); + }; + + const { values: formValues, errors: formErrors } = useMemo(() => formToValues(spec, form), [spec, form]); + const profileSlotName = spec.slots.find((slot) => slot.kind === "profile")?.name; + const profileRaw = profileSlotName ? form[profileSlotName] : undefined; + const profileName = typeof profileRaw === "string" ? profileRaw : null; + const values = useMemo( + () => (typeof formValues.direction === "string" ? { ...formValues, direction: resolveDirection(formValues.direction, state, profileName, serverFlips) } : formValues), + [formValues, state, profileName, serverFlips], + ); + const errors = formErrors; + + /** The direction a profile starts with: its normal, or into the material for a cut on a face. */ + const startDirection = (profile: string, operation: unknown): string | null => { + const normal = directionForProfile(state, profile); + const found = findProfile(state, profile); + return normal && (spec.id === "cut_sketch" || operation === "cut") && found && profilePlaneLabel(found) === "face" ? FLIP_NORMAL : normal; + }; + const applyProfile = (profile: string) => { + const direction = startDirection(profile, form[OPERATION_SLOT]); + setForm((previous) => ({ ...previous, ...(profileSlotOf ? { [profileSlotOf.name]: profile } : {}), ...(direction && hasDirection ? { direction } : {}) })); + setServerError(null); + }; + + /** Choosing Cut for a sketch drawn on a face aims the push into the material (and back out for another operation), unless the + * person already turned it: the direction is only changed while it still is the one the previous choice started with. */ + const onOperation = (operation: string) => { + const before = form[OPERATION_SLOT]; + setForm((previous) => { + const next: RawForm = { ...previous, [OPERATION_SLOT]: operation }; + if (!(profileName && hasDirection)) return next; + const was = startDirection(profileName, before); + if (previous.direction === was) { + const now = startDirection(profileName, operation); + if (now) next.direction = now; + } + return next; + }); + setServerError(null); + }; + + // A sketch picked in the viewport or the tree fills the armed profile field. + const lastPicked = useRef(sketches.picked); + useEffect(() => { + if (sketches.picked === lastPicked.current) return; + lastPicked.current = sketches.picked; + if (armed && sketches.picked && profileSlotOf && findProfile(state, sketches.picked)) applyProfile(sketches.picked); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sketches.picked]); + + // The viewport shows which way the push goes, from the sketch, as an arrow (and says nothing once the form is gone). + const previewRef = useRef(sketches.onPreview); + previewRef.current = sketches.onPreview; + const directionText = typeof values.direction === "string" ? values.direction : null; + const pushed = typeof values.distance === "number" ? values.distance : typeof values.depth === "number" ? values.depth : null; + useEffect(() => { + const found = profileName ? findProfile(state, profileName) : undefined; + const vector = found && directionText ? directionVector(directionText, normalizePlane(found.plane)) : null; + previewRef.current(found && vector ? { sketch: found.name, direction: vector, distance: Math.abs(pushed ?? 10) } : null); + }, [profileName, directionText, pushed, state]); + useEffect(() => () => previewRef.current(null), []); + + /** Which way the push goes relative to the sketch's own normal (null without a sketch), and whether it can be flipped. */ + const flip = useMemo(() => { + const found = profileName ? findProfile(state, profileName) : undefined; + if (!found || !hasDirection || !directionText) return null; + const plane = normalizePlane(found.plane); + const normal = found.normal_vec ?? frameNormal(plane as never as { origin: Vec3; x_axis: Vec3; y_axis: Vec3 }); + const vector = directionVector(directionText, plane); + const against = vector ? dot(vector, normal as Vec3) < 0 : false; + const axis = /^[+-][xyz]$/.test(found.normal) ? found.normal : "normal"; + return { against, axis, can: directionOptions(spec.slots.find((slot) => slot.name === "direction")?.options ?? [], state, profileName).some((option) => option.value === FLIP_NORMAL) }; + }, [profileName, directionText, state, hasDirection, spec]); + const onFlip = () => set("direction", flip?.against ? "normal" : FLIP_NORMAL); + + const nameError = editing || created ? null : validateName(name, state.taken); + const suggested = suggestName(spec.id, state.taken); + + const selectionErrors: Record = {}; + const selections: Record = {}; + for (const slot of selectSlots) { + const picks = picksOf(slot); + if (replaced(slot)) { + if (picks.some((item) => !isDescribed(item))) selectionErrors[slot.name] = "details of the picked items are still loading"; + else selections[slot.name] = picks.map(pickedPayload); + } else if (!editing && !slot.optional) selectionErrors[slot.name] = `pick ${slot.entity === "edge" ? "edges" : "faces"} in the viewport`; + } + + const allErrors = { ...errors, ...selectionErrors }; + const invalid = Object.keys(allErrors).length > 0 || nameError !== null; + + const submit = async () => { + setTouched(true); + if (invalid || busy) return; + setBusy(true); + setServerError(null); + const withSelections = Object.keys(selections).length ? { selections } : {}; + const command: FeatureCommand = edit + ? { op: "edit", cell: edit.cell, values, ...withSelections } + : created + ? { op: "edit", cell: created, values, ...withSelections } + : { op: "add", spec: spec.id, ...(name.trim() ? { name: name.trim() } : FRIENDLY_NAMES.has(spec.id) ? { name: suggested } : {}), values, ...withSelections }; + try { + await onSubmit(command); + } catch (error) { + if (error instanceof CellFailure && !edit) setCreated(error.cell); + setServerError(error instanceof Error ? error.message : String(error)); + setBusy(false); + } + }; + + const fillFromFace = () => { + const faces = filterSelection(selection, "face"); + if (faces.length !== 1) { + setNote("Pick exactly one face in the viewport first."); + return; + } + const face = faces[0]; + if (!isDescribed(face)) { + setNote("The face details are still loading; try again in a moment."); + return; + } + const { point, direction } = holeFromFace(face); + if (!point) { + setNote("This face has no centroid; type the start point."); + return; + } + setForm((previous) => ({ + ...previous, + position: point.map((c) => String(Math.round(c * 1e4) / 1e4)), + ...(direction ? { direction } : {}), + })); + setNote( + direction + ? `Start point set from the face centroid, direction ${direction} (into the material).` + : "Start point set from the face centroid. The face reports no normal: check the direction.", + ); + setServerError(null); + }; + + // A hole opened from a selected face takes its start point and direction from it as soon as the face is described. + const faceUsed = useRef(false); + useEffect(() => { + if (!request.fromFace || faceUsed.current) return; + const faces = filterSelection(selection, "face"); + if (faces.length === 1 && isDescribed(faces[0])) { + faceUsed.current = true; + fillFromFace(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selection]); + + // The feature the combine controls describe: the one being edited, or the one this form just created and whose cell failed + // (its product is then an open body, but not one it combines with). + const shaped = edit ?? (created ? state.features.find((row) => row.cell === created) : undefined); + // A new solid is described against the bodies open when the form opened: once it is added the state changes before the form + // closes, and "Combined with" must not flip to the solid just made. + const [openAtStart] = useState(() => state.open); + const combine = edit || created ? state : { open: openAtStart }; + const own = new Set(edit?.makes ?? []); + const bodies = bodyChoices(state).filter((body) => !own.has(body.name)); + + return ( + event.preventDefault()} + onKeyDown={(event) => { + if (event.key === "Enter" && !(event.target instanceof HTMLTextAreaElement) && !(event.target instanceof HTMLButtonElement)) { + event.preventDefault(); + void submit(); + } + }} + > + + + {editing ? "Edit" : "Add"} {plainLabel(spec.id, spec.label).toLowerCase()} + + {plainDescription(spec.id, spec.description)} + +
    + + setName(event.target.value)} + /> + + {visibleSlots(spec, form).map((slot) => slot.name === TARGET_SLOT && spec.combinable && targetDisplay(combine, shaped, String(form[slot.name] ?? "")).kind === "hidden" ? null : ( + + onOperation(String(value)) : set} + edit={shaped} + combine={combine} + invalid={touched && Boolean(errors[slot.name])} + bodies={bodies} + selection={selection} + state={state} + onProfile={applyProfile} + sketches={sketches} + armed={armed} + onArm={setArmed} + flip={slot.name === "direction" && slot.kind === "enum" ? flip : null} + onFlip={onFlip} + kept={editing && !replaced(slot)} + picks={slot.kind === "select" ? picksOf(slot).length : 0} + options={slot.name === "direction" && slot.kind === "enum" ? directionOptions(slot.options ?? [], state, profileName) : undefined} + /> + {spec.id === "hole" && slot.name === "position" && ( +
    + + {note && {note}} +
    + )} +
    + ))} + {serverError && ( +

    + {serverError} +

    + )} +
    + + + + +
    + ); +} + +/** Whether the target slot is shown as the one line "Combined with: " (its field then needs no label). */ +function combineLine(spec: Spec, slot: Slot, form: RawForm, state: CombineState, edit?: FeatureRow): boolean { + if (!spec.combinable || slot.name !== TARGET_SLOT) return false; + return targetDisplay(state, edit, typeof form[slot.name] === "string" ? (form[slot.name] as string) : "").kind !== "choose"; +} + +function Field({ + label, + unit, + help, + error, + children, +}: { + label: string; + unit?: string; + help?: string; + error?: string | null; + children: React.ReactNode; +}) { + return ( +
    + {label && ( + + )} + {children} + {help && !error && {help}} + {error && {error}} +
    + ); +} + +function SlotWidget({ + slot, + form, + set, + invalid, + bodies, + selection, + state, + onProfile, + kept, + picks, + options, + sketches, + armed, + onArm, + flip, + onFlip, + edit, + combine, +}: { + edit?: FeatureRow; + /** The bodies the operation and target controls describe. */ + combine: CombineState; + sketches: DialogSketches; + armed: boolean; + onArm: (armed: boolean) => void; + flip: { against: boolean; axis: string; can: boolean } | null; + onFlip: () => void; + slot: Slot; + form: RawForm; + set: (slot: string, value: RawForm[string]) => void; + invalid: boolean; + bodies: { name: string; open: boolean }[]; + selection: SelectionItem[]; + state: FeatureState; + onProfile: (name: string) => void; + kept: boolean; + picks: number; + options?: SlotOption[]; +}) { + const raw = form[slot.name]; + if (slot.kind === "enum" && slot.name === OPERATION_SLOT && slot.options?.some((option) => option.value === "join")) { + const { options: choices, note } = operationOptions(slot.options, combine, edit); + return set(slot.name, value)} icons={OPERATION_ICONS} label={slot.label} note={note} />; + } + if (slot.kind === "body" && slot.name === TARGET_SLOT) { + const shown = targetDisplay(combine, edit, typeof raw === "string" ? raw : ""); + if (shown.kind === "hidden") return null; + if (shown.kind === "fixed") { + return ( + + Combined with: {shown.name} + + ); + } + } + switch (slot.kind) { + case "bool": + return set(slot.name, checked)} />; + case "enum": + return ( +
    + + {flip && ( +
    + + + pushes {flip.against ? "against" : "along"} the sketch normal ({flip.axis}); the arrow in the viewport shows it + +
    + )} +
    + ); + case "body": { + const open = bodies.filter((body) => body.open); + const used = bodies.filter((body) => !body.open); + return ( + + ); + } + case "profile": { + const profiles = state.profiles ?? []; + const chosen = typeof raw === "string" ? findProfile(state, raw) : undefined; + const info = chosen ? sketches.infos.find((item) => item.name === chosen.name) : undefined; + const loops = chosen ? sketches.shapes.get(chosen.name)?.shape.loopCount ?? 0 : 0; + const summary = info ? profileSummary(info.doc, loops) : null; + return ( +
    +
    + + Sketch: {chosen ? {chosen.name} : none picked} + + +
    + + {chosen && ( + + Plane {profilePlaneLabel(chosen)}, normal {chosen.normal} + + )} + {summary && ( + + This sketch has several loops; it uses what was chosen in the sketch ({summary}). + + )} +
    + ); + } + case "point": { + const parts = Array.isArray(raw) ? raw : ["", "", ""]; + return ( +
    + {["x", "y", "z"].map((axis, index) => ( + set(slot.name, parts.map((part, i) => (i === index ? event.target.value : part)))} + /> + ))} +
    + ); + } + case "points2d": + case "points3d": + return ( +