From a8a84c65c6d813cd261ede4b6eb2e0c3244f25ad Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Tue, 3 Nov 2026 01:20:18 +0800 Subject: [PATCH 01/19] editor: GUI-made features, a typed operation registry and the commands over it features.py is a hand-curated table of 24 operations (primitives, profiles, booleans, hole, fillet, chamfer, shell, patterns, transforms) with typed slots and a template that renders the cells; a drift test checks every template against the live signatures. gui_features.py adds, edits and deletes features through the editor's own cell commands (an edit carries the code it was made from, so an agent's concurrent edit is never overwritten) and keeps an undo/redo history. Routes: GET /api/features/specs, GET /api/features, POST /api/features. Generated cells are ordinary cells (params cell + feature cell, selectors inline, a '# gui:' line to rebuild the form). Primitives and extrudes carry readable face tags; a selection recipe tries the tags first and checks the count. Co-Authored-By: Claude Sonnet 5.5 --- src/simplecadapi/editor/features.py | 764 ++++++++++++++++++++++++ src/simplecadapi/editor/gui_features.py | 387 ++++++++++++ src/simplecadapi/editor/selection.py | 17 +- src/simplecadapi/editor/server.py | 42 ++ test/test_editor_features.py | 259 ++++++++ 5 files changed, 1463 insertions(+), 6 deletions(-) create mode 100644 src/simplecadapi/editor/features.py create mode 100644 src/simplecadapi/editor/gui_features.py create mode 100644 test/test_editor_features.py diff --git a/src/simplecadapi/editor/features.py b/src/simplecadapi/editor/features.py new file mode 100644 index 00000000..7fc1bde8 --- /dev/null +++ b/src/simplecadapi/editor/features.py @@ -0,0 +1,764 @@ +"""GUI-made features: the operations a person can add with a button, and the cells they become. + +A feature is an ordinary notebook cell (``# ---- feature: () ----``, one operation that makes one new public +name) with its numbers in a ``# ---- params: ----`` cell, so a person or an agent can read and edit it like any +other cell. A cell made here carries one more line, ``# gui: {...}``, from which the form is rebuilt; a cell without that +line is code, shown in the tree and never rewritten. + +Nothing in this module runs a kernel or touches a file. :func:`render_feature` turns validated values into source text; +every value that reaches the text is a number, a choice from a fixed list, a name from the notebook, or a tag built from +the slug, so a request cannot put code into a cell. + +Selections are written as *recipes* inside the feature cell, resolved against the body the cell receives: the face tags a +primitive or extrude was given (they survive booleans, not fillets) first, the picked geometry second, and the count of +what was picked is always checked. A recipe that no longer resolves is an error on the feature, never another face. +""" + +from __future__ import annotations + +import builtins +import json +import keyword +import math +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping, Sequence + +from .selection import selector_conditions + +GUI_MARKER = "# gui:" +VERSION = 1 +_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +_SLUG = re.compile(r"[a-z][a-z0-9_]{0,40}") +_TAG = re.compile(r"[a-z][a-z0-9_]*(\.[a-z0-9_]+)+") +_RESERVED = frozenset(keyword.kwlist) | frozenset(dir(builtins)) | {"scad", "app", "marimo"} +_AXES = { + "+x": (1.0, 0.0, 0.0), + "-x": (-1.0, 0.0, 0.0), + "+y": (0.0, 1.0, 0.0), + "-y": (0.0, -1.0, 0.0), + "+z": (0.0, 0.0, 1.0), + "-z": (0.0, 0.0, -1.0), +} +_AXIS_OPTIONS = [{"value": key, "label": key} for key in _AXES] +_PLANE_NORMALS = {"XY": (0.0, 0.0, 1.0), "XZ": (0.0, 1.0, 0.0), "YZ": (1.0, 0.0, 0.0)} +_PLANE_OPTIONS = [{"value": key, "label": key} for key in _PLANE_NORMALS] + + +class FeatureError(ValueError): + """A request the registry cannot turn into a feature; the message is meant for the person.""" + + +@dataclass(frozen=True) +class Slot: + """One input of a feature. + + kind: ``length`` (mm), ``angle`` (deg), ``float``, ``count``, ``bool``, ``enum``, ``point`` (three lengths), + ``body`` (a body made earlier), ``select`` (faces or edges picked in the viewport), ``points2d`` (``x, y`` per line). + """ + + name: str + label: str + kind: str + default: Any = None + unit: str | None = None + min: float | None = None + max: float | None = None + step: float | None = None + options: list[dict[str, str]] | None = None + entity: str | None = None # face | edge, for kind == "select" + help: str = "" + optional: bool = False + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"name": self.name, "label": self.label, "kind": self.kind} + for key in ("default", "unit", "min", "max", "step", "options", "entity"): + value = getattr(self, key) + if value is not None: + out[key] = value + if self.help: + out["help"] = self.help + if self.optional: + out["optional"] = True + return out + + +@dataclass(frozen=True) +class Spec: + id: str + label: str + group: str + role: str # build | add | subtract | modifier + produces: str # solid | solids + description: str + slots: tuple[Slot, ...] + build: Callable[["Context"], list[str]] = field(repr=False, compare=False) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "label": self.label, + "group": self.group, + "role": self.role, + "produces": self.produces, + "description": self.description, + "slots": [slot.to_dict() for slot in self.slots], + } + + +@dataclass +class Rendered: + slug: str + spec: str + params: list[str] # lines of the params cell ("" when the feature has no numbers) + feature: str # the feature cell + reads: list[str] # bodies this feature consumes + produces: str + gui: dict[str, Any] + + +class Context: + """What a template needs: the slug, the variable of each number, the bodies, the selection recipes.""" + + def __init__(self, spec: Spec, slug: str, values: Mapping[str, Any], bodies: Mapping[str, str], recipes: Mapping[str, list[str]]): + self.spec, self.slug, self.values = spec, slug, values + self._bodies, self._recipes = bodies, recipes + self.params: list[str] = [] + self.reads: list[str] = [] + self._base = slug.upper() + self.used: set[str] = set() + + # -- numbers -------------------------------------------------------------------------------------------------- + def var(self, slot: str) -> str: + """The params-cell variable holding this number (written on first use).""" + spec = self._slot(slot) + name = f"{self._base}_{slot.upper()}" + if name in self.used: + return name + self.used.add(name) + value = self.values[slot] + comment = spec.label + if spec.kind == "bool": + self.params.append(f"{name} = {bool(value)} # ui: toggle()") + elif spec.kind == "count": + low, high = _count_range(spec, int(value)) + self.params.append(f"{name} = {int(value)} # ui: number({low}, {high}, step=1, label={json.dumps(comment)})") + elif spec.kind == "float": + low, high = _range(spec, float(value)) + self.params.append(f"{name} = {_num(float(value))} # ui: number({_num(low)}, {_num(high)}, step={_num(_step(spec, low, high))}, label={json.dumps(comment)})") + else: + unit = "deg" if spec.kind == "angle" else "mm" + low, high = _range(spec, float(value)) + var_name = f"{self.slug}_{slot}" + self.params.append( + f"{name} = scad.var(name={json.dumps(var_name)}, default={_num(float(value))}, unit={json.dumps(unit)}, " + f"comment={json.dumps(comment)}) # ui: slider({_num(low)}, {_num(high)}, step={_num(_step(spec, low, high))}, label={json.dumps(comment)})" + ) + return name + + def point(self, slot: str) -> str: + """A ``(x, y, z)`` expression from the three length variables of a point slot.""" + spec = self._slot(slot) + names = [] + for axis, value in zip("xyz", self.values[slot]): + name = f"{self._base}_{slot.upper()}_{axis.upper()}" + if name not in self.used: + self.used.add(name) + low, high = _range(spec, float(value)) + self.params.append( + f"{name} = scad.var(name={json.dumps(f'{self.slug}_{slot}_{axis}')}, default={_num(float(value))}, unit=\"mm\", " + f"comment={json.dumps(f'{spec.label} {axis}')}) # ui: slider({_num(low)}, {_num(high)}, step=1, label={json.dumps(f'{spec.label} {axis}')})" + ) + names.append(name) + return f"({', '.join(names)})" + + def choice(self, slot: str) -> str: + """The value of an enum slot (already checked against its options).""" + return str(self.values[slot]) + + def axis(self, slot: str) -> str: + return repr(_AXES[str(self.values[slot])]) + + def normal(self, slot: str) -> str: + return repr(_PLANE_NORMALS[str(self.values[slot])]) + + # -- bodies and selections ------------------------------------------------------------------------------------ + def body(self, slot: str) -> str: + name = str(self.values[slot]) + if name not in self.reads: + self.reads.append(name) + return name + + def spread(self, slot: str) -> str: + """``*name`` when the body slot names a list of solids, else ``name``.""" + name = self.body(slot) + return f"*{name}" if self._bodies.get(name) == "solids" else name + + def selection(self, slot: str) -> str: + """The variable the recipe of this selection slot binds (the recipe is added to the cell first).""" + return f"_{slot}" + + def recipe(self, slot: str, tip: str) -> list[str]: + return list(self._recipes[slot]) + + def _slot(self, name: str) -> Slot: + for slot in self.spec.slots: + if slot.name == name: + return slot + raise KeyError(name) + + def tag(self, role: str) -> str: + """A readable face tag for this feature, e.g. ``base.top`` (survives booleans, not fillets).""" + return json.dumps(f"{self.slug}.{role}") + + +# ---- helpers ------------------------------------------------------------------------------------------------------ + + +def _num(value: float) -> str: + text = f"{value:.6g}" + if "e" in text or "E" in text: + text = f"{value:.9f}".rstrip("0").rstrip(".") + if "." not in text and "e" not in text: + text += ".0" + return "0.0" if text in ("-0.0", "") else text + + +def _range(spec: Slot, value: float) -> tuple[float, float]: + """Slider limits for a number: from the slot, else around the value.""" + if spec.min is None: + low, high = 0.1, max(value * 5, 10.0) + elif spec.min <= -1000: # a coordinate or a signed distance: a window around the value + low, high = math.floor(value - 100), math.ceil(value + 100) + else: + low = spec.min + high = spec.max if spec.max is not None else max(value * 5, low + 10.0) + if spec.max is not None and spec.min is not None and spec.min > -1000: + high = spec.max + if spec.kind == "angle" and spec.max is None: + high = 360.0 + return (low, high) if low < high else (low, low + 1.0) + + +def _count_range(spec: Slot, value: int) -> tuple[int, int]: + low = int(spec.min) if spec.min is not None else 1 + high = int(spec.max) if spec.max is not None else max(value * 4, 10) + return low, max(high, low + 1) + + +def _step(spec: Slot, low: float, high: float) -> float: + if spec.step is not None: + return spec.step + return 1.0 if high - low >= 20 else 0.1 + + +def _checked_number(slot: Slot, value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise FeatureError(f"{slot.label}: a number is required") + number = float(value) + if slot.kind == "length" and slot.min is None and number <= 0: + raise FeatureError(f"{slot.label}: must be greater than zero") + if slot.min is not None and number < slot.min: + raise FeatureError(f"{slot.label}: must be at least {slot.min:g}") + if slot.max is not None and number > slot.max: + raise FeatureError(f"{slot.label}: must be at most {slot.max:g}") + return number + + +def _length(name: str, label: str, default: float, **kw: Any) -> Slot: + return Slot(name, label, "length", default=default, unit="mm", **kw) + + +def _signed(name: str, label: str, default: float, **kw: Any) -> Slot: + return Slot(name, label, "length", default=default, unit="mm", min=kw.pop("min", -10000.0), **kw) + + +def _point(name: str, label: str, default: tuple[float, float, float] = (0.0, 0.0, 0.0), help: str = "") -> Slot: + return Slot(name, label, "point", default=list(default), unit="mm", min=-10000.0, help=help) + + +def _enum(name: str, label: str, options: list[dict[str, str]], default: str, help: str = "") -> Slot: + return Slot(name, label, "enum", default=default, options=options, help=help) + + +def _body(name: str, label: str, help: str = "") -> Slot: + return Slot(name, label, "body", help=help) + + +# ---- templates ---------------------------------------------------------------------------------------------------- + + +def _box(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.make_box_rsolid(", + f" width={c.var('width')}, height={c.var('height')}, depth={c.var('depth')},", + f" bottom_face_center={c.point('origin')},", + f" bottom_face_tag={c.tag('bottom')}, top_face_tag={c.tag('top')},", + f" front_face_tag={c.tag('front')}, back_face_tag={c.tag('back')},", + f" left_face_tag={c.tag('left')}, right_face_tag={c.tag('right')},", + ")", + ] + + +def _cylinder(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.make_cylinder_rsolid(", + f" radius={c.var('radius')}, height={c.var('height')},", + f" bottom_face_center={c.point('origin')}, axis={c.axis('axis')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_face_tag={c.tag('side')},", + ")", + ] + + +def _sphere(c: Context) -> list[str]: + return [f"{c.slug} = scad.make_sphere_rsolid(radius={c.var('radius')}, center={c.point('center')})"] + + +def _cone(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.make_cone_rsolid(", + f" bottom_radius={c.var('bottom_radius')}, top_radius={c.var('top_radius')}, height={c.var('height')},", + f" bottom_face_center={c.point('origin')}, axis={c.axis('axis')},", + # a true cone has no top face to name; the tag is written only when there is one + f" start_face_tag={c.tag('start')}, " + (f"end_face_tag={c.tag('end')}, " if c.values["top_radius"] > 0 else "") + f"side_face_tag={c.tag('side')},", + ")", + ] + + +def _extrude_rect(c: Context) -> list[str]: + return [ + f"_profile = scad.make_rectangle_rface(width={c.var('width')}, height={c.var('height')}, center={c.point('center')}, normal={c.normal('plane')})", + f"{c.slug} = scad.extrude_rsolid(", + f" profile=_profile, direction={c.normal('plane')}, distance={c.var('distance')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')},", + ")", + ] + + +def _extrude_circle(c: Context) -> list[str]: + return [ + f"_profile = scad.make_circle_rface(center={c.point('center')}, radius={c.var('radius')}, normal={c.normal('plane')})", + f"{c.slug} = scad.extrude_rsolid(", + f" profile=_profile, direction={c.normal('plane')}, distance={c.var('distance')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')},", + ")", + ] + + +def _points(values: Mapping[str, Any]) -> list[tuple[float, float]]: + return [(float(x), float(y)) for x, y in values["points"]] + + +def _extrude_polygon(c: Context) -> list[str]: + pts = ", ".join(f"({_num(x)}, {_num(y)}, 0.0)" for x, y in _points(c.values)) + return [ + f"_outline = scad.make_polyline_rwire(points=[{pts}], closed=True)", + "_profile = scad.make_face_from_wire_rface(wire=_outline, normal=(0.0, 0.0, 1.0))", + f"{c.slug} = scad.extrude_rsolid(", + f" profile=_profile, direction=(0.0, 0.0, 1.0), distance={c.var('distance')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')},", + ")", + ] + + +def _revolve(c: Context) -> list[str]: + return [ + "# the section sits in the XZ plane, beside the Z axis, and turns about it", + f"_profile = scad.make_rectangle_rface(width={c.var('width')}, height={c.var('height')},", + f" center=({c.var('offset')} + {c.var('width')} / 2, 0.0, {c.var('height')} / 2), normal=(0.0, 1.0, 0.0))", + f"{c.slug} = scad.revolve_rsolid(profile=_profile, axis=(0.0, 0.0, 1.0), angle={c.var('angle')}, origin=(0.0, 0.0, 0.0),", + # a full turn has no start or end face to name; those tags are written only for a partial turn + " " + (f"start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, " if c.values["angle"] < 360 else "") + f"side_faces_tag={c.tag('side')})", + ] + + +def _loft_circles(c: Context) -> list[str]: + return [ + "_bottom = scad.make_circle_rwire(center=(0.0, 0.0, 0.0), radius=" + c.var("bottom_radius") + ", normal=(0.0, 0.0, 1.0))", + "_top = scad.make_circle_rwire(center=(0.0, 0.0, " + c.var("height") + "), radius=" + c.var("top_radius") + ", normal=(0.0, 0.0, 1.0))", + f"{c.slug} = scad.loft_rsolid(profiles=[_bottom, _top], ruled={c.var('ruled')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')})", + ] + + +def _loft_rects(c: Context) -> list[str]: + return [ + f"_bottom = scad.make_rectangle_rwire(width={c.var('bottom_width')}, height={c.var('bottom_height')}, center=(0.0, 0.0, 0.0), normal=(0.0, 0.0, 1.0))", + f"_top = scad.make_rectangle_rwire(width={c.var('top_width')}, height={c.var('top_height')}, center=(0.0, 0.0, {c.var('height')}), normal=(0.0, 0.0, 1.0))", + f"{c.slug} = scad.loft_rsolid(profiles=[_bottom, _top], ruled={c.var('ruled')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')})", + ] + + +def _sweep(c: Context) -> list[str]: + pts = [(float(x), float(y), float(z)) for x, y, z in c.values["path"]] + path = ", ".join(f"({_num(x)}, {_num(y)}, {_num(z)})" for x, y, z in pts) + return [ + f"_path = scad.make_polyline_rwire(points=[{path}], closed=False)", + "# the circle stands on the first point, square to the first segment", + f"_a, _b = {pts[0]!r}, {pts[1]!r}", + "_dir = tuple(_q - _p for _p, _q in zip(_a, _b))", + "_len = sum(_d * _d for _d in _dir) ** 0.5", + "_dir = tuple(_d / _len for _d in _dir)", + f"_profile = scad.make_circle_rface(center=_a, radius={c.var('radius')}, normal=_dir)", + f"{c.slug} = scad.sweep_rsolid(profile=_profile, path=_path, is_frenet=False,", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')})", + ] + + +def _cut(c: Context) -> list[str]: + return [f"{c.slug} = scad.cut_rsolid({c.body('base')}, {c.spread('tool')}, skip_non_intersecting=False)"] + + +def _union(c: Context) -> list[str]: + return [f"{c.slug} = scad.union_rsolid({c.body('base')}, {c.spread('other')})"] + + +def _intersect(c: Context) -> list[str]: + return [f"{c.slug} = scad.intersect_rsolid({c.body('base')}, {c.spread('other')})"] + + +def _hole(c: Context) -> list[str]: + direction = c.axis("direction") + return [ + "# a cylinder from just outside the surface, in the drilling direction, taken out of the body", + f"_dir = {direction}", + "_overshoot = 1.0", + f"_start = tuple(_p - _overshoot * _d for _p, _d in zip({c.point('position')}, _dir))", + f"_tool = scad.make_cylinder_rsolid(radius={c.var('diameter')} / 2, height={c.var('depth')} + _overshoot, bottom_face_center=_start, axis=_dir)", + f"{c.slug} = scad.cut_rsolid({c.body('body')}, _tool, skip_non_intersecting=False)", + ] + + +def _fillet(c: Context) -> list[str]: + return [*c.recipe("edges", "tip"), f"{c.slug} = scad.fillet_rsolid(solid={c.body('body')}, edges={c.selection('edges')}, radius={c.var('radius')})"] + + +def _chamfer(c: Context) -> list[str]: + return [*c.recipe("edges", "tip"), f"{c.slug} = scad.chamfer_rsolid(solid={c.body('body')}, edges={c.selection('edges')}, distance={c.var('distance')})"] + + +def _shell(c: Context) -> list[str]: + return [ + *c.recipe("faces", "tip"), + f"{c.slug} = scad.shell_rsolid(solid={c.body('body')}, faces_to_remove={c.selection('faces')}, thickness={c.var('thickness')})", + ] + + +def _linear_pattern(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.linear_pattern_rsolidlist(shape={c.body('shape')}, direction={c.axis('direction')}, count={c.var('count')}, spacing={c.var('spacing')})" + ] + + +def _radial_pattern(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.radial_pattern_rsolidlist(shape={c.body('shape')}, center={c.point('center')}, axis={c.axis('axis')},", + f" count={c.var('count')}, total_rotation_angle={c.var('total_angle')})", + ] + + +def _mirror(c: Context) -> list[str]: + return [ + f"_mirrored = scad.mirror_shape(shape={c.body('body')}, plane_origin={c.point('origin')}, plane_normal={c.axis('normal')})", + f"{c.slug} = scad.union_rsolid({c.body('body')}, _mirrored)" if c.values.get("merge") else f"{c.slug} = _mirrored", + ] + + +def _translate(c: Context) -> list[str]: + return [f"{c.slug} = scad.translate_shape(shape={c.body('body')}, vector={c.point('offset')})"] + + +def _rotate(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.rotate_shape(shape={c.body('body')}, angle={c.var('angle')}, axis={c.axis('axis')}, origin={c.point('origin')})" + ] + + +def _scale(c: Context) -> list[str]: + factor = c.var("factor") + return [f"{c.slug} = scad.scale_rsolid(solid={c.body('body')}, factors=({factor}, {factor}, {factor}), center={c.point('origin')})"] + + +_AXIS = lambda name, label, default="+z": _enum(name, label, _AXIS_OPTIONS, default) # noqa: E731 +_PLANE = lambda: _enum("plane", "Sketch plane", _PLANE_OPTIONS, "XY", help="The profile lies in this plane and is pushed along its normal.") # noqa: E731 +_BODY = lambda: _body("body", "Body", "The body this feature works on (default: the last one made).") # noqa: E731 + +SPECS: tuple[Spec, ...] = ( + Spec("box", "Box", "Primitives", "build", "solid", "A rectangular block; its bottom face is centred on the origin point.", + (_length("width", "Width (x)", 40.0), _length("height", "Height (y)", 30.0), _length("depth", "Depth (z)", 20.0), + _point("origin", "Bottom face centre")), _box), + Spec("cylinder", "Cylinder", "Primitives", "build", "solid", "A cylinder standing on a centre point, along an axis.", + (_length("radius", "Radius", 10.0), _length("height", "Height", 30.0), _point("origin", "Bottom face centre"), + _AXIS("axis", "Axis")), _cylinder), + Spec("sphere", "Sphere", "Primitives", "build", "solid", "A sphere around a centre point.", + (_length("radius", "Radius", 15.0), _point("center", "Centre")), _sphere), + Spec("cone", "Cone / frustum", "Primitives", "build", "solid", "A cone, or a frustum when the top radius is above zero.", + (_length("bottom_radius", "Bottom radius", 15.0), Slot("top_radius", "Top radius", "length", default=0.0, unit="mm", min=0.0), + _length("height", "Height", 25.0), _point("origin", "Bottom face centre"), _AXIS("axis", "Axis")), _cone), + Spec("extrude_rect", "Extrude rectangle", "Profiles", "build", "solid", "A rectangle pushed along the normal of its plane.", + (_PLANE(), _length("width", "Width", 40.0), _length("height", "Height", 20.0), _point("center", "Centre"), _signed("distance", "Distance", 10.0, min=0.001)), _extrude_rect), + Spec("extrude_circle", "Extrude circle", "Profiles", "build", "solid", "A circle pushed along the normal of its plane.", + (_PLANE(), _length("radius", "Radius", 12.0), _point("center", "Centre"), _signed("distance", "Distance", 10.0, min=0.001)), _extrude_circle), + Spec("extrude_polygon", "Extrude polygon", "Profiles", "build", "solid", "A closed polygon in the XY plane pushed up the Z axis.", + (Slot("points", "Corners (x, y per line)", "points2d", default=[[0, 0], [40, 0], [40, 20], [20, 30], [0, 20]], unit="mm"), + _signed("distance", "Distance", 10.0, min=0.001)), _extrude_polygon), + Spec("revolve", "Revolve", "Profiles", "build", "solid", "A rectangle beside the Z axis, turned about it.", + (_length("width", "Section width", 10.0), _length("height", "Section height", 25.0), + Slot("offset", "Distance from the axis", "length", default=10.0, unit="mm", min=0.0), + Slot("angle", "Angle", "angle", default=360.0, unit="deg", min=1.0, max=360.0)), _revolve), + Spec("loft_circles", "Loft circles", "Profiles", "build", "solid", "A solid between two circles, one above the other.", + (_length("bottom_radius", "Bottom radius", 15.0), _length("top_radius", "Top radius", 8.0), _length("height", "Height", 25.0), + Slot("ruled", "Straight sides", "bool", default=False)), _loft_circles), + Spec("loft_rects", "Loft rectangles", "Profiles", "build", "solid", "A solid between two rectangles, one above the other.", + (_length("bottom_width", "Bottom width", 30.0), _length("bottom_height", "Bottom height", 20.0), + _length("top_width", "Top width", 15.0), _length("top_height", "Top height", 10.0), _length("height", "Height", 25.0), + Slot("ruled", "Straight sides", "bool", default=True)), _loft_rects), + Spec("sweep_circle", "Sweep circle along a path", "Profiles", "build", "solid", "A circle swept along a polyline.", + (_length("radius", "Radius", 4.0), Slot("path", "Path points (x, y, z per line)", "points3d", default=[[0, 0, 0], [0, 0, 30], [30, 0, 60]], unit="mm")), _sweep), + Spec("cut", "Cut", "Boolean", "subtract", "solid", "Take one or more bodies out of another.", + (_body("base", "Body to cut"), _body("tool", "Tool (taken out)")), _cut), + Spec("union", "Union", "Boolean", "add", "solid", "Join two bodies into one.", + (_body("base", "First body"), _body("other", "Second body")), _union), + Spec("intersect", "Intersect", "Boolean", "modifier", "solid", "Keep only what two bodies share.", + (_body("base", "First body"), _body("other", "Second body")), _intersect), + Spec("hole", "Hole", "Features", "subtract", "solid", "A round hole from a point on a surface, in a direction, to a depth.", + (_BODY(), _point("position", "Start point (on the surface)", help="Pick a face to fill the point and direction."), + _AXIS("direction", "Direction into the material", "-z"), _length("diameter", "Diameter", 6.0), _length("depth", "Depth", 10.0)), _hole), + Spec("fillet", "Fillet", "Features", "modifier", "solid", "Round the picked edges.", + (_BODY(), Slot("edges", "Edges", "select", entity="edge"), _length("radius", "Radius", 2.0)), _fillet), + Spec("chamfer", "Chamfer", "Features", "modifier", "solid", "Bevel the picked edges.", + (_BODY(), Slot("edges", "Edges", "select", entity="edge"), _length("distance", "Distance", 1.5)), _chamfer), + Spec("shell", "Shell", "Features", "modifier", "solid", "Hollow the body, opening the picked faces.", + (_BODY(), Slot("faces", "Faces to open", "select", entity="face"), _length("thickness", "Wall thickness", 2.0)), _shell), + Spec("linear_pattern", "Linear pattern", "Pattern", "build", "solids", "Copies of a body along a direction (the original is the first copy).", + (_body("shape", "Body to repeat"), _AXIS("direction", "Direction", "+x"), Slot("count", "Count", "count", default=3, min=2, max=500), + _length("spacing", "Spacing", 15.0)), _linear_pattern), + Spec("radial_pattern", "Radial pattern", "Pattern", "build", "solids", "Copies of a body around an axis.", + (_body("shape", "Body to repeat"), _point("center", "Axis point"), _AXIS("axis", "Axis"), + Slot("count", "Count", "count", default=6, min=2, max=500), + Slot("total_angle", "Total angle", "angle", default=360.0, unit="deg", min=1.0, max=360.0)), _radial_pattern), + Spec("mirror", "Mirror", "Transform", "build", "solid", "Mirror a body in a plane, and optionally join the halves.", + (_BODY(), _point("origin", "Plane point"), _AXIS("normal", "Plane normal", "+x"), Slot("merge", "Join with the original", "bool", default=True)), _mirror), + Spec("translate", "Move", "Transform", "build", "solid", "Move a body by an offset.", + (_BODY(), _point("offset", "Offset")), _translate), + Spec("rotate", "Rotate", "Transform", "build", "solid", "Turn a body about an axis.", + (_BODY(), Slot("angle", "Angle", "angle", default=45.0, unit="deg", min=-360.0, max=360.0), _AXIS("axis", "Axis"), _point("origin", "Axis point")), _rotate), + Spec("scale", "Scale", "Transform", "build", "solid", "Scale a body uniformly about a point.", + (_BODY(), Slot("factor", "Factor", "float", default=1.5, min=0.01, max=100.0), _point("origin", "Centre")), _scale), +) + +BY_ID: dict[str, Spec] = {spec.id: spec for spec in SPECS} + + +def catalog() -> dict[str, Any]: + """The registry as plain data, for the forms.""" + groups: list[str] = [] + for spec in SPECS: + if spec.group not in groups: + groups.append(spec.group) + return {"version": VERSION, "groups": [{"id": g, "label": g} for g in groups], "specs": [spec.to_dict() for spec in SPECS]} + + +# ---- names ---------------------------------------------------------------------------------------------------- + + +def default_slug(spec_id: str, taken: set[str]) -> str: + base = {"extrude_rect": "block", "extrude_circle": "disc", "extrude_polygon": "profile_solid"}.get(spec_id, spec_id) + candidate, number = base, 1 + while candidate in taken or candidate.upper() in taken or candidate in _RESERVED: + number += 1 + candidate = f"{base}_{number}" + return candidate + + +def check_slug(slug: str, taken: set[str]) -> str: + if not _SLUG.fullmatch(slug): + raise FeatureError("the name must be lowercase letters, digits and underscores, starting with a letter") + if slug in _RESERVED: + raise FeatureError(f"{slug!r} is a Python name; choose another") + if slug in taken or slug.upper() in taken: + raise FeatureError(f"the name {slug!r} is already used in this notebook") + return slug + + +# ---- values ----------------------------------------------------------------------------------------------------------- + + +def check_values(spec: Spec, values: Mapping[str, Any], bodies: Mapping[str, str]) -> dict[str, Any]: + """Validate *values* against the slots; returns the cleaned values. Raises :class:`FeatureError`.""" + clean: dict[str, Any] = {} + for slot in spec.slots: + raw = values.get(slot.name, slot.default) + if slot.kind == "select": + continue # the recipe is built from the picked items + if raw is None: + raise FeatureError(f"{slot.label}: a value is required") + if slot.kind in ("length", "angle", "float"): + clean[slot.name] = _checked_number(slot, raw) + elif slot.kind == "count": + number = _checked_number(slot, raw) + if number != int(number): + raise FeatureError(f"{slot.label}: a whole number is required") + clean[slot.name] = int(number) + elif slot.kind == "bool": + if not isinstance(raw, bool): + raise FeatureError(f"{slot.label}: true or false") + clean[slot.name] = raw + elif slot.kind == "enum": + allowed = {option["value"] for option in slot.options or []} + if raw not in allowed: + raise FeatureError(f"{slot.label}: choose one of {', '.join(sorted(allowed))}") + clean[slot.name] = str(raw) + elif slot.kind == "point": + if not (isinstance(raw, (list, tuple)) and len(raw) == 3): + raise FeatureError(f"{slot.label}: three numbers (x, y, z)") + clean[slot.name] = [_checked_number(Slot(slot.name, slot.label, "float", min=slot.min, max=slot.max), part) for part in raw] + elif slot.kind == "body": + name = str(raw) + if not _NAME.fullmatch(name) or name not in bodies: + raise FeatureError(f"{slot.label}: {name!r} is not a body in this notebook") + clean[slot.name] = name + elif slot.kind in ("points2d", "points3d"): + width = 2 if slot.kind == "points2d" else 3 + if not isinstance(raw, (list, tuple)) or len(raw) < width: + raise FeatureError(f"{slot.label}: at least {width} points") + rows = [] + for row in raw: + if not (isinstance(row, (list, tuple)) and len(row) == width): + raise FeatureError(f"{slot.label}: each point needs {width} numbers") + rows.append([_checked_number(Slot(slot.name, slot.label, "float"), part) for part in row]) + if slot.kind == "points2d" and len(rows) < 3: + raise FeatureError(f"{slot.label}: a polygon needs at least three corners") + if slot.kind == "points3d" and len(rows) < 2: + raise FeatureError(f"{slot.label}: a path needs at least two points") + clean[slot.name] = rows + else: # pragma: no cover - the registry is ours + raise FeatureError(f"{slot.label}: unsupported slot kind {slot.kind}") + return clean + + +# ---- selection recipes ---------------------------------------------------------------------------------------------- + + +def selection_recipe(slot: Slot, items: Sequence[Mapping[str, Any]], known_slugs: set[str]) -> list[str]: + """Source lines of a recipe that selects the picked *items* inside the feature cell (bound to ``_``).""" + factory = {"face": "faces", "edge": "edges"}[slot.entity or "face"] + picked = [item for item in items if item.get("kind") == slot.entity] + if not picked: + raise FeatureError(f"{slot.label}: pick at least one {slot.entity} in the viewport") + geometry: list[str] = [] + tags: list[str] = [] + all_tagged = True + for item in picked: + conditions = selector_conditions(str(item.get("kind")), item.get("properties") or {}, item.get("geometry") or {}) + if conditions is None: + raise FeatureError(f"{slot.label}: an item has no geometry to select it by") + geometry.append("scad.ql.and_(" + ", ".join(conditions) + ")") + own = [t for t in (item.get("tags") or []) if isinstance(t, str) and _TAG.fullmatch(t) and t.split(".")[0] in known_slugs] + if own: + tags.append(f"scad.ql.tag({json.dumps(sorted(own)[0])})") + else: + all_tagged = False + n = len(picked) + lines = [f"# {slot.label.lower()}: {n} picked in the viewport, resolved against the body this feature receives"] + wrap = (lambda parts: parts[0]) if n == 1 else (lambda parts: "scad.ql.or_(" + ", ".join(parts) + ")") + geo = f"scad.ql.{factory}().where({wrap(geometry)})" + if tags and all_tagged: + lines.append(f"_{slot.name}_by_tag = scad.ql.{factory}().where({wrap(tags)})") + lines.append(f"_{slot.name}_by_geometry = {geo}") + lines.append(f"_{slot.name}_source = {{BODY}}") + lines.append(f"_{slot.name} = _{slot.name}_by_tag if len(_{slot.name}_by_tag.resolve(_{slot.name}_source)) == {n} else _{slot.name}_by_geometry.exactly({n})") + else: + lines.append(f"_{slot.name} = {geo}.exactly({n})") + return lines + + +# ---- rendering ---------------------------------------------------------------------------------------------------------- + + +def extract_recipe(code: str, slot: str) -> list[str] | None: + """The recipe lines of a selection slot in an existing feature cell, so an edit can keep what was picked.""" + lines = code.splitlines() + start = next((i for i, line in enumerate(lines) if line.startswith("# ") and "picked in the viewport" in line), None) + if start is None: + return None + for end in range(start + 1, len(lines)): + if lines[end].startswith(f"_{slot} = "): + return [re.sub(r"(_\w+_source = ).*", r"\1{BODY}", line) for line in lines[start : end + 1]] + return None + + +def render_feature( + spec_id: str, + slug: str, + values: Mapping[str, Any], + *, + bodies: Mapping[str, str], + selections: Mapping[str, Sequence[Mapping[str, Any]]] | None = None, + known_slugs: set[str] | None = None, + keep_recipes: Mapping[str, list[str]] | None = None, +) -> Rendered: + """The cells of one feature. *bodies* maps each body variable of the notebook to ``solid`` or ``solids``.""" + spec = BY_ID.get(spec_id) + if spec is None: + raise FeatureError(f"unknown operation {spec_id!r}") + clean = check_values(spec, values, bodies) + recipes: dict[str, list[str]] = {} + for slot in spec.slots: + if slot.kind == "select": + if (selections or {}).get(slot.name): + recipes[slot.name] = selection_recipe(slot, selections[slot.name], known_slugs or set()) + elif keep_recipes and keep_recipes.get(slot.name): + recipes[slot.name] = list(keep_recipes[slot.name]) + else: + raise FeatureError(f"{slot.label}: pick at least one {slot.entity} in the viewport") + ctx = Context(spec, slug, clean, bodies, recipes) + code = spec.build(ctx) + # the recipe resolves against the body slot of the feature ({BODY} is filled with that name) + body_name = clean.get("body") or "" + code = [line.replace("{BODY}", body_name) for line in code] + gui = {"spec": spec.id, "v": VERSION, "values": clean} + if selections: + gui["picked"] = {k: len(v) for k, v in selections.items()} + header = f"# ---- feature: {slug} ({spec.role}) ----" + feature_lines = [header, f"{GUI_MARKER} {json.dumps(gui, separators=(',', ':'))}", *code] + params = [f"# ---- params: {slug} ----", *ctx.params] if ctx.params else [] + return Rendered( + slug=slug, + spec=spec.id, + params=params, + feature="\n".join(feature_lines), + reads=ctx.reads, + produces=spec.produces, + gui=gui, + ) + + +def parse_gui(code: str) -> dict[str, Any] | None: + """The ``# gui:`` payload of a feature cell, or ``None`` for a cell written as code.""" + for line in code.splitlines()[:6]: + stripped = line.strip() + if stripped.startswith(GUI_MARKER): + try: + data = json.loads(stripped[len(GUI_MARKER):]) + except ValueError: + return None + return data if isinstance(data, dict) and data.get("spec") in BY_ID else None + return None + + +__all__ = [ + "BY_ID", + "FeatureError", + "Rendered", + "SPECS", + "catalog", + "check_slug", + "check_values", + "default_slug", + "extract_recipe", + "parse_gui", + "render_feature", + "selection_recipe", +] diff --git a/src/simplecadapi/editor/gui_features.py b/src/simplecadapi/editor/gui_features.py new file mode 100644 index 00000000..e01ff47b --- /dev/null +++ b/src/simplecadapi/editor/gui_features.py @@ -0,0 +1,387 @@ +"""Adding, editing and removing GUI-made features in the open notebook, with undo. + +:mod:`.features` turns values into cells; this module finds where they go, writes them through the editor's own cell +commands (so the page shows them live, the file has one writer, and an agent editing the same notebook is never +overwritten: every edit carries the code it was made from), and keeps what is needed to undo. + +A feature is two cells: ``# ---- params: ----`` (its numbers) and ``# ---- feature: () ----``. The +product cell (the one that builds the ``Part`` from a body) is pointed at the new body when the feature consumed the body it +was reading; otherwise it is left alone and the state says which body it reads. +""" + +from __future__ import annotations + +import ast +import re +import threading +from typing import Any, Mapping + +from ..runtime.workspace import features_in_cell +from . import features as F + +_PARAMS_HEADER = re.compile(r"^#\s*-{3,}\s*params\b[^\n]*", re.MULTILINE) +_PARAMS_FOR = re.compile(r"^#\s*-{3,}\s*params:\s*(?P[a-z][a-z0-9_]*)\s*-{3,}\s*$") +_PRODUCT_BODY = re.compile(r"(\bbody\s*=\s*)([A-Za-z_][A-Za-z0-9_]*)") +_ROLES_WITH_BODIES = {"build", "add", "subtract", "modifier"} + + +class GuiError(Exception): + """A GUI command that cannot be done; ``conflict`` marks one that lost to a concurrent edit.""" + + def __init__(self, message: str, *, conflict: bool = False): + super().__init__(message) + self.conflict = conflict + + +def _names(code: str) -> tuple[list[str], set[str]]: + """(public names the cell defines at its top level, in order; every name it reads).""" + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return [], set() + defined: list[str] = [] + for node in tree.body: + targets: list[ast.expr] = [] + if isinstance(node, ast.Assign): + targets = list(node.targets) + elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): + targets = [node.target] + for target in targets: + for name in ast.walk(target): + if isinstance(name, ast.Name) and not name.id.startswith("_") and name.id not in defined: + defined.append(name.id) + loaded = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} + return defined, loaded + + +class FeatureService: + """The GUI-feature view and commands of one :class:`~simplecadapi.editor.server.WorkspaceState`.""" + + def __init__(self, state: Any): + self.state = state + self._lock = threading.RLock() + self._undo: list[list[dict]] = [] + self._redo: list[list[dict]] = [] + self._notebook: Any = None + + # -- reading ---------------------------------------------------------------------------------------------------- + def snapshot(self) -> dict[str, Any]: + """Features, bodies, the product, and the defaults a form should open with.""" + cells = list(self.state.cells) + features: list[dict[str, Any]] = [] + bodies: dict[str, str] = {} + consumed: set[str] = set() + order: list[str] = [] + taken: set[str] = set() + product: dict[str, Any] | None = None + params_cells: dict[str, str] = {} + for index, cell in enumerate(cells): + code = str(cell.get("code", "")) + defined, loaded = _names(code) + taken.update(defined) + first = code.strip().splitlines()[0] if code.strip() else "" + match = _PARAMS_FOR.match(first) + if match: + params_cells[match.group("slug")] = cell["id"] + continue + heads = features_in_cell(code, cell.get("key", "")) + if not heads: + if ("Part(" in code or "make_part_rpart(" in code) and _PRODUCT_BODY.search(code): + product = {"cell": cell["id"], "body": _PRODUCT_BODY.search(code).group(2)} # type: ignore[union-attr] + continue + head = heads[0] + gui = F.parse_gui(code) + consumed.update(loaded & set(bodies)) + produces = F.BY_ID[gui["spec"]].produces if gui else "solid" + made = defined if head.role in _ROLES_WITH_BODIES else [] + for name in made: + bodies[name] = produces + order.append(name) + features.append( + { + "cell": cell["id"], + "index": index + 1, + "slug": head.slug, + "role": head.role, + "gui": {"spec": gui["spec"], "values": gui.get("values", {}), "picked": gui.get("picked", {})} if gui else None, + "makes": made, + "reads": sorted(loaded & set(bodies) - set(made)), + "status": cell.get("status"), + "errors": cell.get("errors") or [], + } + ) + for feature in features: + feature["params_cell"] = params_cells.get(feature["slug"]) + open_bodies = [name for name in order if name not in consumed] + return { + "features": features, + "bodies": [{"name": name, "produces": bodies[name]} for name in order], + "open": open_bodies, + "tip": open_bodies[-1] if open_bodies else None, + "product": product, + "defaults": self._defaults(open_bodies), + "taken": sorted(taken), + "undo": len(self._undo), + "redo": len(self._redo), + } + + @staticmethod + def _defaults(open_bodies: list[str]) -> dict[str, dict[str, str]]: + last = open_bodies[-1] if open_bodies else None + before = open_bodies[-2] if len(open_bodies) > 1 else last + out: dict[str, dict[str, str]] = {} + for spec in F.SPECS: + chosen: dict[str, str] = {} + for slot in spec.slots: + if slot.kind != "body" or last is None: + continue + chosen[slot.name] = before if slot.name == "base" else last + out[spec.id] = chosen + return out + + # -- commands ----------------------------------------------------------------------------------------------------- + def add(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + spec_id = str(request.get("spec", "")) + if spec_id not in F.BY_ID: + raise GuiError(f"unknown operation {spec_id!r}") + taken = set(snap["taken"]) + slug = F.check_slug(str(request["name"]), taken) if request.get("name") else F.default_slug(spec_id, taken) + bodies = {b["name"]: b["produces"] for b in snap["bodies"]} + values = self._with_defaults(spec_id, dict(request.get("values") or {}), snap) + rendered = self._render(spec_id, slug, values, bodies, request.get("selections"), snap) + self._check_param_names(rendered, taken) + steps: list[dict] = [] + cells = self.state.cells + params_anchor = self._params_anchor(cells) + feature_anchor = request.get("after") or self._feature_anchor(snap, cells) + if rendered.params: + steps.append(self._create(params_anchor, "\n".join(rendered.params), run=False)) + if feature_anchor == params_anchor: # the params cell sits where the feature goes: keep params first + feature_anchor = steps[-1]["id"] + made = self._create(feature_anchor, rendered.feature, run=True) + steps.append(made) + steps.extend(self._repoint_product(snap, rendered)) + self._record(steps) + return self._result(made["id"], steps, slug) + + def edit(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + feature = self._feature(snap, request.get("cell")) + if not feature["gui"]: + raise GuiError("this feature was written as code; edit it in the notebook") + cell = self._cell(feature["cell"]) + spec_id = feature["gui"]["spec"] + spec = F.BY_ID[spec_id] + values = {**feature["gui"]["values"], **dict(request.get("values") or {})} + bodies = {b["name"]: b["produces"] for b in snap["bodies"] if b["name"] != feature["slug"]} + keep = {slot.name: F.extract_recipe(cell["code"], slot.name) for slot in spec.slots if slot.kind == "select"} + rendered = self._render(spec_id, feature["slug"], values, bodies, request.get("selections"), snap, keep={k: v for k, v in keep.items() if v}) + steps: list[dict] = [] + if feature["params_cell"]: + old = self._cell(feature["params_cell"])["code"] + new = "\n".join(rendered.params) + if rendered.params and new.strip() != old.strip(): + steps.append(self._edit(feature["params_cell"], old, new, run=False)) + elif rendered.params: + steps.append(self._create(self._params_anchor(self.state.cells), "\n".join(rendered.params), run=False)) + steps.append(self._edit(feature["cell"], cell["code"], rendered.feature, run=True)) + self._record(steps) + return self._result(feature["cell"], steps, feature["slug"]) + + def delete(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + feature = self._feature(snap, request.get("cell")) + users = [f["slug"] for f in snap["features"] if f["cell"] != feature["cell"] and set(f["reads"]) & set(feature["makes"])] + if users: + raise GuiError(f"{', '.join(users)} still use {feature['slug']}; remove those first") + steps: list[dict] = [] + product = snap["product"] + if product and product["body"] in feature["makes"]: + fallback = feature["reads"][0] if feature["reads"] else None + if fallback is None: + raise GuiError(f"the product is built from {feature['slug']}; point it at another body first") + steps.append(self._point_product(product, fallback)) + for cell_id in (feature["cell"], feature["params_cell"]): + if cell_id: + steps.append(self._remove(cell_id)) + self._record(steps) + return self._result(None, steps, feature["slug"]) + + def use_as_product(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + name = str(request.get("body", "")) + if name not in {b["name"] for b in snap["bodies"]}: + raise GuiError(f"{name!r} is not a body in this notebook") + if not snap["product"]: + raise GuiError("this notebook has no product cell that builds a Part from a body") + steps = [self._point_product(snap["product"], name)] + self._record(steps) + return self._result(snap["product"]["cell"], steps, name) + + def undo(self) -> dict[str, Any]: + with self._lock: + self._sync() + if not self._undo: + raise GuiError("nothing to undo") + steps = self._undo.pop() + for step in reversed(steps): + self._apply(step, forward=False) + self._redo.append(steps) + return self._result(None, [], "undo") + + def redo(self) -> dict[str, Any]: + with self._lock: + self._sync() + if not self._redo: + raise GuiError("nothing to redo") + steps = self._redo.pop() + for step in steps: + self._apply(step, forward=True) + self._undo.append(steps) + return self._result(None, [], "redo") + + # -- internals ------------------------------------------------------------------------------------------------------ + def _sync(self) -> None: + """History belongs to one open notebook.""" + current = getattr(self.state, "notebook", None) + if current != self._notebook: + self._notebook = current + self._undo, self._redo = [], [] + + def _record(self, steps: list[dict]) -> None: + if steps: + self._undo.append(steps) + self._redo.clear() + del self._undo[:-50] + + def _cell(self, ref: Any) -> dict: + for cell in self.state.cells: + if cell["id"] == ref: + return cell + raise GuiError(f"the cell {ref!r} is gone; reload the feature list") + + @staticmethod + def _feature(snap: Mapping[str, Any], ref: Any) -> dict[str, Any]: + for feature in snap["features"]: + if ref in (feature["cell"], feature["slug"]): + return feature + raise GuiError(f"no feature {ref!r}") + + @staticmethod + def _with_defaults(spec_id: str, values: dict[str, Any], snap: Mapping[str, Any]) -> dict[str, Any]: + for name, body in (snap["defaults"].get(spec_id) or {}).items(): + values.setdefault(name, body) + return values + + def _render(self, spec_id, slug, values, bodies, selections, snap, keep=None) -> F.Rendered: + known = {f["slug"] for f in snap["features"]} + picked = {k: list(v) for k, v in dict(selections or {}).items() if isinstance(v, (list, tuple))} + try: + return F.render_feature(spec_id, slug, values, bodies=bodies, selections=picked, known_slugs=known, keep_recipes=keep) + except F.FeatureError as exc: + raise GuiError(str(exc)) from exc + + @staticmethod + def _check_param_names(rendered: F.Rendered, taken: set[str]) -> None: + for line in rendered.params: + match = re.match(r"([A-Z][A-Z0-9_]*) = ", line) + if match and match.group(1) in taken: + raise GuiError(f"the name {match.group(1)} is already used in this notebook") + + @staticmethod + def _params_anchor(cells: list[dict]) -> str: + anchor = "start" + for cell in cells: + if _PARAMS_HEADER.match(str(cell.get("code", "")).strip()): + anchor = cell["id"] + return anchor + + @staticmethod + def _feature_anchor(snap: Mapping[str, Any], cells: list[dict]) -> str: + """After the last feature cell (never 'end': the selection cell must stay last).""" + if snap["features"]: + return snap["features"][-1]["cell"] + return FeatureService._params_anchor(cells) + + def _command(self, op: str, body: dict) -> dict: + report = self.state.cell_command(op, body) + if "error" in report: + message = str(report["error"]) + raise GuiError(message, conflict=message.startswith("conflict")) + return report + + def _create(self, anchor: str, code: str, *, run: bool) -> dict: + report = self._command("insert", {"after": anchor, "code": code, "run": run}) + index = (report.get("touched") or [None])[0] + cell = next((c for c in report["cells"] if c["index"] == index), None) + if cell is None: + raise GuiError("the kernel did not create the cell") + return {"kind": "created", "id": cell["id"], "after": anchor, "code": code} + + def _edit(self, cell_id: str, old: str, new: str, *, run: bool) -> dict: + self._command("edit", {"cell": cell_id, "code": new, "base": old, "run": run}) + return {"kind": "edited", "id": cell_id, "old": old, "new": new} + + def _remove(self, cell_id: str, expect: str | None = None) -> dict: + """Delete a cell; with *expect*, only if it still holds the code the GUI wrote (else it was edited since).""" + cells = self.state.cells + position = next((i for i, c in enumerate(cells) if c["id"] == cell_id), None) + if position is None: + raise GuiError("the cell is gone", conflict=True) + code = cells[position]["code"] + if expect is not None and code.strip() != expect.strip(): + raise GuiError(f"conflict: the cell {cell_id} was edited since", conflict=True) + before = cells[position - 1]["id"] if position else "start" + self._command("delete", {"cell": cell_id}) + return {"kind": "deleted", "id": cell_id, "after": before, "code": code} + + def _point_product(self, product: Mapping[str, Any], body: str) -> dict: + cell = self._cell(product["cell"]) + new = _PRODUCT_BODY.sub(lambda m: f"{m.group(1)}{body}", cell["code"], count=1) + return self._edit(product["cell"], cell["code"], new, run=True) + + def _repoint_product(self, snap: Mapping[str, Any], rendered: F.Rendered) -> list[dict]: + product = snap["product"] + if product and product["body"] in rendered.reads and rendered.produces == "solid": + return [self._point_product(product, rendered.slug)] + return [] + + def _apply(self, step: dict, *, forward: bool) -> None: + kind = step["kind"] + make, drop = (forward and kind == "created") or (not forward and kind == "deleted"), (forward and kind == "deleted") or (not forward and kind == "created") + try: + if make: + created = self._create(step["after"] if self._exists(step["after"]) else "end", step["code"], run=True) + step["id"] = created["id"] + elif drop: + self._remove(step["id"], expect=step["code"]) + else: # edited + old, new = (step["old"], step["new"]) if forward else (step["new"], step["old"]) + self._edit(step["id"], old, new, run=True) + except GuiError as exc: + raise GuiError(f"cannot {'redo' if forward else 'undo'}: the notebook changed since ({exc})", conflict=exc.conflict) from exc + + def _exists(self, ref: str) -> bool: + return ref in ("start", "end") or any(c["id"] == ref for c in self.state.cells) + + def _result(self, cell_id: str | None, steps: list[dict], name: str) -> dict[str, Any]: + report = self.state.cells_report(refresh=False) + cell = next((c for c in report["cells"] if c["id"] == cell_id), None) if cell_id else None + return { + "ok": True, + "name": name, + "cell": {k: cell[k] for k in ("id", "index", "status", "errors", "output", "console") if k in cell} if cell else None, + "state": self.snapshot(), + } + + +__all__ = ["FeatureService", "GuiError"] diff --git a/src/simplecadapi/editor/selection.py b/src/simplecadapi/editor/selection.py index 08898884..48157c6c 100644 --- a/src/simplecadapi/editor/selection.py +++ b/src/simplecadapi/editor/selection.py @@ -44,13 +44,12 @@ _RELATIVE_TOLERANCE = 0.01 -def selector_expression(kind: str, properties: Mapping[str, Any], geometry: Mapping[str, Any]) -> str | None: - """A ``scad.ql`` expression selecting the one entity, or ``None``. +def selector_conditions(kind: str, properties: Mapping[str, Any], geometry: Mapping[str, Any]) -> list[str] | None: + """The ``scad.ql.prop(...)`` conditions that single out one entity by its geometry, or ``None``. *properties* and *geometry* are the scene sidecar's entity fields. """ - factory = _FACTORY.get(kind) - if factory is None: + if kind not in _FACTORY: return None conditions: list[str] = [] ql_type = _QL_TYPES.get(str(geometry.get("type", ""))) @@ -70,10 +69,16 @@ def selector_expression(kind: str, properties: Mapping[str, Any], geometry: Mapp low, high = size * (1 - _RELATIVE_TOLERANCE), size * (1 + _RELATIVE_TOLERANCE) conditions.append(f'scad.ql.prop("{name}", ">=", {_number(low)})') conditions.append(f'scad.ql.prop("{name}", "<=", {_number(high)})') - if not conditions: + return conditions or None + + +def selector_expression(kind: str, properties: Mapping[str, Any], geometry: Mapping[str, Any]) -> str | None: + """A ``scad.ql`` expression selecting the one entity, or ``None``.""" + conditions = selector_conditions(kind, properties, geometry) + if conditions is None: return None body = ",\n ".join(conditions) - return f"scad.ql.{factory}().where(scad.ql.and_(\n {body},\n)).exactly(1)" + return f"scad.ql.{_FACTORY[kind]}().where(scad.ql.and_(\n {body},\n)).exactly(1)" def selection_cell_code(items: Sequence[Mapping[str, Any]]) -> str: diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 77f67fa9..6a0cc8b6 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -142,6 +142,9 @@ def __init__(self, workspace: Path, notebook: Path | None = None) -> None: self.selection_items: list[dict] = [] self.viewport_png: bytes | None = None self.cells: list[dict] = [] + from .gui_features import FeatureService + + self.features = FeatureService(self) # GUI-made features: commands and undo (see gui_features.py) self.specs: dict[str, ParamSpec] = {} self._pending_params: dict[str, float | int | bool] = {} self._rebuild_inflight = False @@ -1994,6 +1997,42 @@ async def api_cell_command(request: Request) -> JSONResponse: status = 409 if str(report.get("error", "")).startswith("conflict") else 400 if "error" in report else 200 return JSONResponse(report, status_code=status) + async def features_specs(request: Request) -> JSONResponse: + """The operations a person can add with a button, as typed forms (see features.py).""" + from .features import catalog + + return JSONResponse(catalog(), headers={"Cache-Control": "max-age=3600"}) + + async def features_state(request: Request) -> JSONResponse: + """The features of the open notebook, its bodies, the product, and the defaults a form opens with.""" + return JSONResponse(await asyncio.to_thread(state.features.snapshot)) + + async def features_command(request: Request) -> JSONResponse: + """``{op: add|edit|delete|use_as_product|undo|redo, ...}``: one GUI command on the open notebook.""" + from .gui_features import GuiError + + try: + body = json.loads(await request.body() or b"{}") + except json.JSONDecodeError: + return JSONResponse({"ok": False, "error": "body is not JSON"}, status_code=400) + body = body if isinstance(body, dict) else {} + op = str(body.get("op", "")) + handler = { + "add": state.features.add, + "edit": state.features.edit, + "delete": state.features.delete, + "use_as_product": state.features.use_as_product, + "undo": lambda _request: state.features.undo(), + "redo": lambda _request: state.features.redo(), + }.get(op) + if handler is None: + return JSONResponse({"ok": False, "error": f"unknown operation {op!r}"}, status_code=400) + try: + result = await asyncio.to_thread(handler, body) + except GuiError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=409 if exc.conflict else 400) + return JSONResponse(result) + async def sdk_index(request: Request) -> JSONResponse: """The SDK's public API (names and parameters), for the cell editor's completion.""" from .apiindex import build_index @@ -2136,6 +2175,9 @@ async def ws_endpoint(websocket) -> None: Route("/api/sdk", sdk_index, methods=["GET"]), Route("/api/cells", api_cells, methods=["GET"]), Route("/api/cells/{op}", api_cell_command, methods=["POST"]), + Route("/api/features/specs", features_specs, methods=["GET"]), + Route("/api/features", features_state, methods=["GET"]), + Route("/api/features", features_command, methods=["POST"]), Route("/api/workspace", workspace_list, methods=["GET"]), Route("/api/workspace/open", workspace_open, methods=["POST"]), Route("/api/workspace/new", workspace_new, methods=["POST"]), diff --git a/test/test_editor_features.py b/test/test_editor_features.py new file mode 100644 index 00000000..5445a8b1 --- /dev/null +++ b/test/test_editor_features.py @@ -0,0 +1,259 @@ +"""GUI-made features: every operation renders a cell that runs, uses only the SDK's real parameters, and cannot carry code.""" + +from __future__ import annotations + +import ast +import inspect + +import pytest + +import simplecadapi as scad +from simplecadapi.editor import features as F + +DEFAULTS = {spec.id: {slot.name: slot.default for slot in spec.slots if slot.kind != "select"} for spec in F.SPECS} + + +def _calls(source: str): + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "scad": + yield node + + +def _entity(shape_kind, entity, area=None, length=None, tags=()): + center = entity.get_center() + props = {"centroid": [center.x, center.y, center.z]} + if area is not None: + props["area"] = area + if length is not None: + props["length"] = length + return {"kind": shape_kind, "properties": props, "geometry": {"type": "plane" if shape_kind == "face" else "line"}, "tags": list(tags)} + + +def _run(rendered: F.Rendered, scope: dict) -> None: + """Run the params cell and the feature cell the way the notebook would (in order, one namespace).""" + if rendered.params: + exec("\n".join(rendered.params), scope) # noqa: S102 - the code under test is ours + exec(rendered.feature, scope) # noqa: S102 + + +def test_the_catalog_lists_every_operation_with_typed_slots(): + data = F.catalog() + ids = [spec["id"] for spec in data["specs"]] + assert len(ids) == len(set(ids)) >= 24 + for spec in data["specs"]: + assert spec["slots"], spec["id"] + assert {slot["kind"] for slot in spec["slots"]} <= {"length", "angle", "float", "count", "bool", "enum", "point", "body", "select", "points2d", "points3d"} + + +@pytest.mark.parametrize("spec", F.SPECS, ids=lambda s: s.id) +def test_every_template_uses_real_sdk_functions_and_parameters(spec): + """The drift test: an SDK change that breaks a template breaks this, not a user.""" + picks = {slot.name: [{"kind": slot.entity, "properties": {"centroid": [0, 0, 0], "area": 10.0, "length": 5.0}, "geometry": {"type": "plane"}}] for slot in spec.slots if slot.kind == "select"} + bodies = {"a": "solid", "b": "solid", "many": "solids"} + values = {k: (v if v is not None else None) for k, v in DEFAULTS[spec.id].items()} + for slot in spec.slots: + if slot.kind == "body": + values[slot.name] = "a" + rendered = F.render_feature(spec.id, "probe", values, bodies=bodies, selections=picks) + seen = 0 + for call in _calls(rendered.feature): + function = getattr(scad, call.func.attr, None) + assert function is not None, f"{spec.id}: scad.{call.func.attr} does not exist" + names = set(inspect.signature(function).parameters) + for keyword in call.keywords: + assert keyword.arg in names, f"{spec.id}: scad.{call.func.attr} has no parameter {keyword.arg!r}" + seen += 1 + assert seen, spec.id + ast.parse("\n".join(rendered.params)) + assert rendered.feature.splitlines()[0] == f"# ---- feature: probe ({spec.role}) ----" + assert rendered.feature.splitlines()[1].startswith("# gui: ") + + +def test_the_primitives_and_profiles_make_one_valid_solid_each(): + with scad.GraphSession(graph_id="features-primitives"): + for spec in F.SPECS: + if any(slot.kind in ("body", "select") for slot in spec.slots): + continue + scope: dict = {"scad": scad} + rendered = F.render_feature(spec.id, "made", DEFAULTS[spec.id], bodies={}) + _run(rendered, scope) + made = scope["made"] + assert made.get_volume() > 0, spec.id + + +def test_a_chain_of_features_builds_and_the_picked_face_still_resolves_after_a_hole(): + with scad.GraphSession(graph_id="features-chain"): + scope: dict = {"scad": scad} + bodies: dict[str, str] = {} + + def add(spec_id, slug, values=None, selections=None): + rendered = F.render_feature(spec_id, slug, {**DEFAULTS[spec_id], **(values or {})}, bodies=bodies, selections=selections, known_slugs=set(bodies)) + _run(rendered, scope) + bodies[slug] = rendered.produces + return rendered + + add("box", "base") + base = scope["base"] + top = scad.ql.faces().where(scad.ql.tag("base.top")).exactly(1).resolve(base)[0] + add("hole", "bore", {"body": "base", "position": [0, 0, 20], "direction": "-z", "diameter": 6.0, "depth": 20.0}) + assert scope["bore"].get_volume() < base.get_volume() + # the top face was tagged by the box; a feature after the hole selects it through the tag + picked = _entity("face", top, area=top.get_area(), tags=["base.top"]) + shelled = add("shell", "case", {"body": "bore", "thickness": 2.0}, {"faces": [picked]}) + assert "scad.ql.tag(\"base.top\")" in shelled.feature and "by_geometry" in shelled.feature + assert scope["case"].get_volume() < scope["bore"].get_volume() + + +def test_a_fillet_picks_edges_by_geometry_and_counts_them(): + with scad.GraphSession(graph_id="features-fillet"): + scope: dict = {"scad": scad} + bodies = {} + box = F.render_feature("box", "base", DEFAULTS["box"], bodies=bodies) + _run(box, scope) + bodies["base"] = "solid" + base = scope["base"] + vertical = scad.ql.edges().where(scad.ql.and_(scad.ql.prop("geom.center.z", ">=", 9.0), scad.ql.prop("geom.center.z", "<=", 11.0), scad.ql.prop("geom.length", ">=", 19.0))).resolve(base) + assert len(vertical) == 4 + items = [_entity("edge", e, length=e.get_length()) for e in vertical[:2]] + rendered = F.render_feature("fillet", "round", {"body": "base", "radius": 2.0}, bodies=bodies, selections={"edges": items}) + assert ".exactly(2)" in rendered.feature + _run(rendered, scope) + assert scope["round"].get_volume() < base.get_volume() + # the same recipe on a body where the geometry moved selects nothing: an error, not another edge + scope2 = {"scad": scad, "base": scad.make_box_rsolid(width=10, height=10, depth=10, bottom_face_center=(100, 100, 100))} + with pytest.raises(Exception): + exec(rendered.feature, scope2) # noqa: S102 + + +@pytest.mark.parametrize( + "spec_id, values, message", + [ + ("box", {"width": -1.0}, "greater than zero"), + ("box", {"width": "ten"}, "a number is required"), + ("box", {"origin": [0, 0]}, "three numbers"), + ("cylinder", {"axis": "+w"}, "choose one of"), + ("cut", {"base": "a; import os", "tool": "b"}, "is not a body"), + ("cut", {"base": "nope", "tool": "b"}, "is not a body"), + ("linear_pattern", {"shape": "a", "count": 2.5}, "whole number"), + ("mirror", {"body": "a", "merge": "yes"}, "true or false"), + ], +) +def test_bad_values_are_refused_with_a_message_and_never_reach_the_source(spec_id, values, message): + bodies = {"a": "solid", "b": "solid"} + base = {**DEFAULTS[spec_id], **({"base": "a", "tool": "b"} if spec_id == "cut" else {})} + with pytest.raises(F.FeatureError, match=message): + F.render_feature(spec_id, "x", {**base, **values}, bodies=bodies) + + +def test_names_are_checked_and_made_unique(): + taken = {"base", "BASE", "block"} + assert F.default_slug("box", taken) == "box" + assert F.default_slug("extrude_rect", taken) == "block_2" + with pytest.raises(F.FeatureError): + F.check_slug("Base", taken) + with pytest.raises(F.FeatureError, match="already used"): + F.check_slug("base", taken) + with pytest.raises(F.FeatureError): + F.check_slug("a; b", taken) + + +def test_the_gui_line_round_trips_and_code_cells_are_left_alone(): + rendered = F.render_feature("box", "base", DEFAULTS["box"], bodies={}) + parsed = F.parse_gui(rendered.feature) + assert parsed and parsed["spec"] == "box" and parsed["values"]["width"] == 40.0 + assert F.parse_gui("# ---- feature: x (build) ----\nx = scad.make_box_rsolid(width=1, height=1, depth=1)") is None + assert F.parse_gui("# ---- feature: x (build) ----\n# gui: {not json") is None + assert F.parse_gui('# ---- feature: x (build) ----\n# gui: {"spec": "no_such_op"}') is None + + +def test_numbers_become_slider_params_and_counts_plain_values(): + rendered = F.render_feature("linear_pattern", "row", {**DEFAULTS["linear_pattern"], "shape": "a"}, bodies={"a": "solid"}) + joined = "\n".join(rendered.params) + assert "ROW_SPACING = scad.var(name=\"row_spacing\"" in joined and "# ui: slider(" in joined + assert "ROW_COUNT = 3 # ui: number(2, 500, step=1" in joined + assert rendered.params[0] == "# ---- params: row ----" + + +# ---- the commands, against a real kernel ---------------------------------------------------------------------------------- + + +def _wait_idle(state, timeout=120): + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + statuses = {c.get("status") for c in state.cells} + if statuses <= {"idle", "stale", None}: + return + time.sleep(0.3) + + +def test_the_commands_add_edit_undo_and_redo_against_the_real_editor(tmp_path): + import threading + + import uvicorn + + from simplecadapi.editor.gui_features import GuiError + from simplecadapi.editor.server import WorkspaceState, create_app + from simplecadapi.editor.templates import create_notebook + + path = create_notebook(tmp_path, "bracket", "part") + state = WorkspaceState(tmp_path) + state.port = 17732 + server = uvicorn.Server(uvicorn.Config(create_app(state), port=state.port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + try: + state.start_kernel(path) + service = state.features + before = [c["code"] for c in state.cells] + snap = service.snapshot() + assert [f["slug"] for f in snap["features"]] == ["base"] and snap["tip"] == "base" + assert snap["product"]["body"] == "base" and snap["features"][0]["gui"] is None # the template's cell is code + + # a hole: two cells appear (numbers, then the feature), the product follows the new body + result = service.add({"spec": "hole", "values": {"position": [0.0, 0.0, 10.0], "direction": "-z", "diameter": 8.0, "depth": 12.0}}) + _wait_idle(state) + assert result["ok"] and result["name"] == "hole" + snap = service.snapshot() + assert [f["slug"] for f in snap["features"]] == ["base", "hole"] and snap["tip"] == "hole" + assert snap["product"]["body"] == "hole" + assert snap["features"][1]["gui"]["spec"] == "hole" and snap["features"][1]["params_cell"] + source = path.read_text() + assert "# gui: " in source and "HOLE_DIAMETER = scad.var" in source + assert all(not f["errors"] for f in snap["features"]), snap["features"] + + # editing the diameter rewrites both cells and keeps the name + service.edit({"cell": snap["features"][1]["cell"], "values": {"diameter": 10.0}}) + _wait_idle(state) + assert "default=10.0" in path.read_text() and snap["features"][1]["slug"] == "hole" + + # a fillet on the top rim picked by geometry; the count is written into the recipe + rim = {"kind": "edge", "properties": {"centroid": [0.0, 0.0, 10.0], "length": 2 * 3.141592653589793 * 25.0}, "geometry": {"type": "circle"}} + service.add({"spec": "fillet", "values": {"radius": 1.0}, "selections": {"edges": [rim]}}) + _wait_idle(state) + snap = service.snapshot() + assert snap["tip"] == "fillet" and snap["product"]["body"] == "fillet" + assert ".exactly(1)" in path.read_text() + + # undo walks back through the three commands, and the file is what it was + for _ in range(3): + service.undo() + _wait_idle(state) + assert [c["code"] for c in state.cells] == before + assert service.snapshot()["redo"] == 3 + service.redo() + _wait_idle(state) + assert [f["slug"] for f in service.snapshot()["features"]] == ["base", "hole"] + + # an edit made behind the GUI's back wins: undoing over it is refused, not forced + hole_cell = service.snapshot()["features"][1]["cell"] + state.cell_command("edit", {"cell": hole_cell, "code": "# ---- feature: hole (subtract) ----\nhole = base", "run": True}) + _wait_idle(state) + with pytest.raises(GuiError) as refused: + service.undo() + assert "changed since" in str(refused.value) + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True From 3171a01a936b7f31f9f08e9c40e42f7b77747cee Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Tue, 3 Nov 2026 01:22:50 +0800 Subject: [PATCH 02/19] editor: a blank notebook, the product cell with the first GUI feature, and the protocol in the skill sca new --kind blank writes a part notebook with no cells; the first GUI feature adds the product cell that builds the Part from it, and later features that consume that body move it along. editor-collaboration.md says what a GUI cell looks like, that GUI commands never overwrite an edit made in between, and how the inline selection recipes behave. Co-Authored-By: Claude Sonnet 5.5 --- .../domains/editor-collaboration.md | 27 ++++++++++++++ src/simplecadapi/editor/cli.py | 4 +-- src/simplecadapi/editor/gui_features.py | 9 +++++ src/simplecadapi/editor/templates.py | 3 ++ test/test_editor_features.py | 36 +++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index 6a59c31e..9a4ff2ed 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -197,6 +197,33 @@ 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`). + ## Rules of thumb - Re-read the cell before editing (`sca cell get`, or the file): the person edits and diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py index a199c585..dbc757fa 100644 --- a/src/simplecadapi/editor/cli.py +++ b/src/simplecadapi/editor/cli.py @@ -9,7 +9,7 @@ sca viewport [WORKSPACE] [--out PATH] # the image of what the person sees sca cell ls|get|put|insert|rm|run # edit the cells of the notebook the editor has open sca cell open NOTEBOOK [--wait S] # have the editor open another notebook - sca new NAME [--kind part|assembly] # a new notebook; the editor lists it at once + sca new NAME [--kind part|assembly|blank] # a new notebook; the editor lists it at once """ from __future__ import annotations @@ -138,7 +138,7 @@ def writable(parser: argparse.ArgumentParser) -> None: "current directory). A running editor on that workspace lists it without a reload.", ) new.add_argument("name") - new.add_argument("--kind", choices=("part", "assembly"), default="part") + new.add_argument("--kind", choices=("part", "assembly", "blank"), default="part") new.add_argument("--workspace", "-w", type=Path, default=None) new.set_defaults(handler=_new_notebook, printer=_print_json) diff --git a/src/simplecadapi/editor/gui_features.py b/src/simplecadapi/editor/gui_features.py index e01ff47b..7cdf3ec9 100644 --- a/src/simplecadapi/editor/gui_features.py +++ b/src/simplecadapi/editor/gui_features.py @@ -164,6 +164,7 @@ def add(self, request: Mapping[str, Any]) -> dict[str, Any]: made = self._create(feature_anchor, rendered.feature, run=True) steps.append(made) steps.extend(self._repoint_product(snap, rendered)) + steps.extend(self._first_product(snap, rendered, made["id"], taken)) self._record(steps) return self._result(made["id"], steps, slug) @@ -355,6 +356,14 @@ def _repoint_product(self, snap: Mapping[str, Any], rendered: F.Rendered) -> lis return [self._point_product(product, rendered.slug)] return [] + def _first_product(self, snap: Mapping[str, Any], rendered: F.Rendered, after: str, taken: set[str]) -> list[dict]: + """A notebook with no product yet gets one, built from its first body (a blank notebook starts without cells).""" + if snap["product"] is not None or rendered.produces != "solid" or any("Part(" in str(c.get("code", "")) for c in self.state.cells): + return [] + stem = re.sub(r"\W", "_", getattr(self.state.notebook, "stem", "part") or "part").strip("_").lower() or "part" + variable = stem if stem not in taken and stem not in F._RESERVED and not stem[0].isdigit() else f"{stem}_part" + return [self._create(after, f"{variable} = scad.Part(part_id=scad.notebook_id(), body={rendered.slug})", run=True)] + def _apply(self, step: dict, *, forward: bool) -> None: kind = step["kind"] make, drop = (forward and kind == "created") or (not forward and kind == "deleted"), (forward and kind == "deleted") or (not forward and kind == "created") diff --git a/src/simplecadapi/editor/templates.py b/src/simplecadapi/editor/templates.py index b03aa09d..8cd5d09f 100644 --- a/src/simplecadapi/editor/templates.py +++ b/src/simplecadapi/editor/templates.py @@ -94,6 +94,9 @@ def render_notebook(name: str, kind: str = "part", parts: list[tuple[str, str]] raise ValueError(f"invalid notebook name: {name!r}") variable = name.replace("-", "_") header = _HEADER.format(id=name) + if kind == "blank": + # No cells: the GUI adds the first feature, and the product cell with it (see gui_features.py). + return header + _RUNNER if kind == "part": return header + _PART.format(variable=variable) + _RUNNER if kind != "assembly": diff --git a/test/test_editor_features.py b/test/test_editor_features.py index 5445a8b1..44ea9e76 100644 --- a/test/test_editor_features.py +++ b/test/test_editor_features.py @@ -257,3 +257,39 @@ def test_the_commands_add_edit_undo_and_redo_against_the_real_editor(tmp_path): if state.kernel is not None: state.kernel.stop() server.should_exit = True + + +def test_a_blank_notebook_gets_its_product_with_the_first_feature(tmp_path): + import threading + + import uvicorn + + from simplecadapi.editor.server import WorkspaceState, create_app + from simplecadapi.editor.templates import create_notebook + + path = create_notebook(tmp_path, "plate", "blank") + assert "@app.cell" not in path.read_text() + state = WorkspaceState(tmp_path) + state.port = 17733 + server = uvicorn.Server(uvicorn.Config(create_app(state), port=state.port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + try: + state.start_kernel(path) + service = state.features + assert service.snapshot()["features"] == [] and service.snapshot()["product"] is None + service.add({"spec": "box", "name": "base", "values": {}}) + _wait_idle(state) + snap = service.snapshot() + assert [f["slug"] for f in snap["features"]] == ["base"] + assert snap["product"] == {"cell": snap["product"]["cell"], "body": "base"} + assert "plate = scad.Part(part_id=scad.notebook_id(), body=base)" in path.read_text() + service.add({"spec": "cylinder", "values": {"radius": 5.0, "height": 40.0, "origin": [0.0, 0.0, -10.0]}}) + service.add({"spec": "cut", "values": {}}) # base is the second-last open body, the cylinder the last: it is cut out + _wait_idle(state) + snap = service.snapshot() + assert snap["product"]["body"] == "cut" and all(not f["errors"] for f in snap["features"]), snap["features"] + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True From ff6c8a573eb5222206508042910f6fe2e7bd027c Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Tue, 3 Nov 2026 01:44:08 +0800 Subject: [PATCH 03/19] editor-web: a feature tree, a command bar and spec-generated parameter dialogs The Features tab lists the notebook's features (GUI-made ones editable, code ones marked), shows each one's errors, offers undo/redo and a banner when the product does not build from the tip. Operations are added from a floating command bar (category chips, keyword search, Up/Down/Enter, docked top or bottom, collapsible) fed by a command registry; each one opens a non-modal dialog generated from the operation's typed slots, with viewport picks for faces and edges and a 'use picked face' helper for holes. Co-Authored-By: Claude Sonnet 5.5 --- editor-web/package-lock.json | 1486 ++++++++++++++++++- editor-web/package.json | 8 +- editor-web/src/App.tsx | 115 +- editor-web/src/components/CommandBar.tsx | 242 +++ editor-web/src/components/FeatureDialog.tsx | 413 ++++++ editor-web/src/components/FeatureTree.tsx | 154 ++ editor-web/src/components/Header.tsx | 9 + editor-web/src/components/feature-icons.ts | 30 + editor-web/src/components/ui/dialog.tsx | 52 + editor-web/src/components/ui/input.tsx | 16 + editor-web/src/components/ui/label.tsx | 15 + editor-web/src/components/ui/select.tsx | 69 + editor-web/src/components/ui/switch.tsx | 20 + editor-web/src/lib/commands.test.ts | 41 + editor-web/src/lib/commands.ts | 115 ++ editor-web/src/lib/features.test.ts | 117 ++ editor-web/src/lib/features.ts | 358 +++++ editor-web/src/lib/useFeatures.ts | 54 + editor-web/vite.config.ts | 4 +- 19 files changed, 3233 insertions(+), 85 deletions(-) create mode 100644 editor-web/src/components/CommandBar.tsx create mode 100644 editor-web/src/components/FeatureDialog.tsx create mode 100644 editor-web/src/components/FeatureTree.tsx create mode 100644 editor-web/src/components/feature-icons.ts create mode 100644 editor-web/src/components/ui/dialog.tsx create mode 100644 editor-web/src/components/ui/input.tsx create mode 100644 editor-web/src/components/ui/label.tsx create mode 100644 editor-web/src/components/ui/select.tsx create mode 100644 editor-web/src/components/ui/switch.tsx create mode 100644 editor-web/src/lib/commands.test.ts create mode 100644 editor-web/src/lib/commands.ts create mode 100644 editor-web/src/lib/features.test.ts create mode 100644 editor-web/src/lib/features.ts create mode 100644 editor-web/src/lib/useFeatures.ts 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..573bbe7c 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -2,6 +2,11 @@ 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 { CommandBar } from "@/components/CommandBar"; +import { FEATURE_ICONS } from "@/components/feature-icons"; +import { FeatureTree } from "@/components/FeatureTree"; +import { commandsFromSpecs } from "@/lib/commands"; import { Header } from "@/components/Header"; import { Inspector } from "@/components/Inspector"; import { LoadingMask } from "@/components/LoadingMask"; @@ -12,6 +17,8 @@ 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 { useFeatures } from "@/lib/useFeatures"; +import type { FeatureCommand, FeatureRow } from "@/lib/features"; import { useRunLog } from "@/lib/runlog"; import { useTheme } from "@/lib/theme"; import { selectionPayload, variableNames, type SelectionItem } from "@/lib/selection"; @@ -62,6 +69,14 @@ 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); + const kind = notebooks.find((row) => row.path === currentPath)?.kind ?? null; const theme = useTheme(); const log = useRunLog(); @@ -128,6 +143,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 +162,8 @@ export function App() { setParams([]); setFeatures([]); setPicked([]); + setDialog(null); + refreshFeatures(); setNotebook(String(envelope["notebook"] ?? "")); setCurrentPath((envelope["path"] as string) ?? null); setStatus(`opening ${envelope["notebook"]}…`); @@ -191,6 +209,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()); @@ -255,7 +274,7 @@ 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 ------------------------------------------------------------------- @@ -319,6 +338,72 @@ 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}`); + 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 (!(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 commands = useMemo( + () => commandsFromSpecs(gui.catalog?.specs ?? [], (spec) => openDialog({ spec }), FEATURE_ICONS), + [gui.catalog, openDialog], + ); + + const handleEditFeature = (feature: FeatureRow, spec: DialogRequest["spec"]) => openDialog({ spec, edit: feature }); + // -- actions ----------------------------------------------------------------------- const handleOpen = (path: string) => { @@ -345,6 +430,9 @@ export function App() { status={status} themePreference={theme.preference} onTheme={theme.cycle} + features={gui.state} + onUndo={undo} + onRedo={redo} running={running} onStop={handleStop} onRunAll={() => { @@ -357,10 +445,24 @@ export function App() { + features 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} + /> + -
+
+
@@ -446,6 +549,14 @@ export function App() { + (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 }: { commands: Command[] }) { + 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 pool = group && !query.trim() ? commands.filter((command) => command.group === group) : commands; + return searchCommands(pool, query, recent); + }, [commands, group, query, recent]); + 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} + {command.group} +
  • + ); + })} +
+ )} +
+
+ {groups.map((name) => ( + + ))} +
+
+ + + +
+
+
+
+ ); +} diff --git a/editor-web/src/components/FeatureDialog.tsx b/editor-web/src/components/FeatureDialog.tsx new file mode 100644 index 00000000..67ee69a9 --- /dev/null +++ b/editor-web/src/components/FeatureDialog.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from "react"; +import { MousePointerClick } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +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 { + bodyChoices, + filterSelection, + formToValues, + holeFromFace, + initialForm, + isDescribed, + pickedPayload, + suggestName, + validateName, + type FeatureCommand, + type FeatureRow, + type FeatureState, + type PickedItem, + type RawForm, + type Slot, + type Spec, +} from "@/lib/features"; +import type { SelectionItem } from "@/lib/selection"; +import type { SelectionMode } from "@/scene/Viewport"; + +export type DialogRequest = { spec: Spec; edit?: FeatureRow }; + +/** 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, + onSelectionMode, + onSubmit, + onClose, +}: { + request: DialogRequest | null; + state: FeatureState; + selection: SelectionItem[]; + 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, + onSelectionMode, + onSubmit, + onClose, +}: { + request: DialogRequest; + state: FeatureState; + selection: SelectionItem[]; + onSelectionMode: (mode: SelectionMode) => void; + onSubmit: (command: FeatureCommand) => Promise; + onClose: () => void; +}) { + const { spec, edit } = request; + const editing = Boolean(edit); + const [form, setForm] = useState(() => initialForm(spec, state.defaults[spec.id] ?? {}, edit?.gui?.values ?? {})); + 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); + + // 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 viewport picks what the form asks for while it is open, and goes back after. + const wanted = (selectSlots[0]?.entity ?? (spec.id === "hole" ? "face" : 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, errors } = useMemo(() => formToValues(spec, form), [spec, form]); + const nameError = editing ? 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 } + : { op: "add", spec: spec.id, ...(name.trim() ? { name: name.trim() } : {}), values, ...withSelections }; + try { + await onSubmit(command); + } catch (error) { + setServerError(error instanceof Error ? error.message : String(error)); + setBusy(false); + } + }; + + const useFace = () => { + 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); + }; + + 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"} {spec.label.toLowerCase()} + + {spec.description} + +
+ + setName(event.target.value)} + /> + + {spec.slots.map((slot) => ( + + + {spec.id === "hole" && slot.name === "position" && ( +
+ + {note && {note}} +
+ )} +
+ ))} + {serverError && ( +

+ {serverError} +

+ )} +
+ + + + +
+ ); +} + +function Field({ + label, + unit, + help, + error, + children, +}: { + label: string; + unit?: string; + help?: string; + error?: string | null; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {help && !error && {help}} + {error && {error}} +
+ ); +} + +function SlotWidget({ + slot, + form, + set, + invalid, + bodies, + selection, + kept, + picks, +}: { + slot: Slot; + form: RawForm; + set: (slot: string, value: RawForm[string]) => void; + invalid: boolean; + bodies: { name: string; open: boolean }[]; + selection: SelectionItem[]; + kept: boolean; + picks: number; +}) { + const raw = form[slot.name]; + switch (slot.kind) { + case "bool": + return set(slot.name, checked)} />; + case "enum": + return ( + + ); + case "body": { + const open = bodies.filter((body) => body.open); + const used = bodies.filter((body) => !body.open); + return ( + + ); + } + 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 ( +