diff --git a/.agents/skills/marimo-pair/SKILL.md b/.agents/skills/marimo-pair/SKILL.md deleted file mode 100644 index 15d5b088..00000000 --- a/.agents/skills/marimo-pair/SKILL.md +++ /dev/null @@ -1,293 +0,0 @@ ---- -name: marimo-pair -description: >- - Drive a live marimo notebook as a workspace: run Python in the same kernel - the user does, inspect live notebook state, and commit durable notebook - changes. Use when the user wants to start a marimo notebook or pair on an - active marimo session. -allowed-tools: Bash(bash **/scripts/discover-servers.sh *), Bash(bash **/scripts/execute-code.sh *), Read ---- - -marimo is a reactive Python runtime for building reproducible Python programs -(marimo notebooks). Cells are connected by the variables they define and -reference. Running a cell re-executes dependents in dataflow order. The active -runtime holds the kernel namespace, cell state, and dataflow graph. The -notebook (`.py` file) is the artifact the kernel writes from that state while a -session is running. - -A user interacts with the same runtime via a notebook UI with cells, outputs, -and widgets. - -**WARNING. The active runtime is the source of truth.** During a session, you -SHOULD NOT modify the associated `.py` file directly. File edits WILL NOT reach -the active kernel or user, and the kernel may overwrite them on save. Use -`marimo._code_mode` (`cm`) for notebook changes. Reading disk is fine, but -prefer `ctx.cells[...].code` for current cell code. - -The harness reports the absolute path to this `SKILL.md`. Resolve bundled -`scripts/...` and `reference/...` paths from its parent directory, even when -the current working directory is a notebook workspace. In command examples, -replace `/absolute/path/to/marimo-pair` with that directory. - -## Required first kernel command - -Start every code-mode session with this dedicated command: - -```bash -bash /absolute/path/to/marimo-pair/scripts/execute-code.sh \ - --url http://localhost:2718 \ - -c "import marimo._code_mode as cm; help(cm)" -``` - -Follow this order for each kernel, including read-only tasks: - -1. Run the inspection command once. -2. Wait for successful `help(cm)` output. -3. Then use `cm.get_context()` or another `cm` API in a later call. - -Do not run task-specific `cm` code before the inspection command succeeds. - -## Connect to a Notebook - -Use the bundled `execute-code.sh` from the reported skill directory or MCP -(`execute_code(...)`) to run Python in a live marimo kernel. - -`execute-code.sh` always takes `--url`. If the user provides a notebook URL, -run the required inspection against it directly: - -```bash -bash /absolute/path/to/marimo-pair/scripts/execute-code.sh \ - --url http://localhost:2718 \ - -c "import marimo._code_mode as cm; help(cm)" -``` - -After that command succeeds, pass task code with `-c CODE`, `-` for stdin, or -a file path: - -```bash -bash /absolute/path/to/marimo-pair/scripts/execute-code.sh \ - --url http://localhost:2718 - <<'PY' -import marimo._code_mode as cm - -async with cm.get_context() as ctx: - cid = ctx.create_cell("x = df.head()") - ctx.run_cell(cid) -PY -``` - -If the user gives no URL, find or start a notebook. Look for a running server -with `bash /absolute/path/to/marimo-pair/scripts/discover-servers.sh`, MCP -`list_sessions()`, or local process context, and pass the `url` it reports to -`--url`. With one notebook open, the script targets it automatically; with -several, pass `--file` with the notebook's file key. - -If no server is running and the user wants a notebook, start marimo with -`--no-token` (and without `--headless`) so it auto-registers for discovery. The -notebook UI must be open for `execute-code` to target it. The right invocation -depends on context (project tooling, global install, sandbox mode). If the -notebook file contains a PEP 723 `# -/// script` header, it MUST be opened with `--sandbox` — otherwise marimo -ignores the inline dependencies. See -[finding-marimo.md](reference/finding-marimo.md) for the full decision tree and -[execution-context.md](reference/execution-context.md) for selector resolution, -scripts, MCP, and shell quoting. - -## Scratchpad Scope - -`execute-code` evaluates Python in marimo's scratchpad: a temporary namespace -with a shallow copy of the kernel globals. Notebook variables are available by -name, but new top-level bindings and rebindings are discarded after each call. -In-place mutations to notebook-owned objects can persist because those names -still reference live objects. - -Each call reports stdout and stderr from the scratchpad, plus console output -from notebook cells it causes to run, including reactive descendants. - -### Ordinary Python - -Use ordinary Python in the scratchpad to inspect variables, sample data, test -transformations, probe APIs, check imports, and read widget state. - -```python -print(df.head()) - -x = 10 -print(x) -``` - -Here `df` comes from notebook globals, while `x` is a scratchpad-local binding. -`x` exists for this call only and WILL NOT be added to notebook globals. - -### Persist with `cm` - -Top-level scratchpad assignments and rebindings are temporary. To persist work, -including new variables, you MUST submit changes through `marimo._code_mode` -(`cm`). - -`marimo._code_mode` is a PRIVATE, UNSTABLE agent API (note the leading -underscore). It exists for tools like this skill to drive a live kernel from -the scratchpad. DO NOT import it from notebook cells, library code, or -anything a user would run — methods can change or disappear across marimo -versions and kernels. Treat every `import marimo._code_mode as cm` as -scratchpad-only. - -Open a code-mode context to queue notebook changes. - -```python -import marimo._code_mode as cm - -async with cm.get_context() as ctx: - cid = ctx.create_cell("x = df.head()") - ctx.run_cell(cid) -``` - -The scratchpad supports top-level async code. Use `async with` directly; -wrapping it in `asyncio.run(...)` is unnecessary and can conflict with the -kernel's event loop. - -After this block exits and the new cell runs, `x` is notebook state. Later -scratchpad calls can read `x` by name. Code later in the same scratchpad call -should read `ctx.globals["x"]`, because the scratchpad namespace was copied -before the cell ran. - -Inside the context, queued mutation methods are synchronous. Call them -directly; do not `await` them. Each call queues an operation for marimo to -apply when the context exits normally. If the block raises, the queue is -discarded. - -On clean exit, marimo applies packages, validates and applies structural cell -changes, runs queued cells, then may run dependents. Validation is only -structural since queued cell runs can still error. `create_cell` and -`edit_cell` change notebook structure only. Use `run_cell` to execute. - -`create_cell` currently defaults to `hide_code=True`, which collapses the code -editor in the UI. Pass `hide_code=False` if the user wants created cells to -be visible without manually expanding them. - - -## Marimo Rules - -marimo imposes a small contract on notebook code so it can keep the notebook as -a directed acyclic graph (DAG): - -- **No cycles** - cells cannot depend on each other in a cycle. -- **No public redefinitions across cells** - each name has one owning cell. -- **No wildcard imports** - `import *` prevents static analysis of definitions. - -These rules keep the kernel, UI, and saved artifact consistent. - -When `cm` submits a cell body, marimo parses its top-level definitions and -references. Public names enter the graph. Names that start with `_` are local -to their cell and unavailable to other cells. If a `cm` edit violates the -contract, marimo rejects the structural change and returns the validation -error. - -## The Notebook's Shape - -A notebook is an ordered collection of cells. `ctx.cells` is the document view -and `ctx.graph` is the dataflow view. - -```python -for cell in ctx.cells: - cell # .id, .code, .name, .config, .status, .errors - -ctx.cells["setup"] # by name -ctx.cells[0] # by position -list(ctx.cells.keys()) # all IDs, in notebook order -``` - -Cell IDs are opaque strings which can be queried from the notebook or captured -from `cm` return values: - -```python -cid = ctx.create_cell("df = pd.read_csv('data.csv')") -print(cid) # e.g. 'Hbol' -``` - -Alternatively, cells can be assigned and referenced by `name`. The graph can be -used to understand its role in the dataflow. - -```python -for cid, impl in ctx.graph.cells.items(): - impl # .defs, .refs (sets of public names) - -ctx.graph.descendants(cid) # cells that re-run when this one changes -ctx.graph.ancestors(cid) # cells this one depends on -``` - -In marimo, deletes are *destructive* so it can be useful to query the -descendants prior to deleting to understand it's impact. - -## Writing Notebook Changes - -The graph contract keeps marimo able to run and save the notebook. Passing -those checks alone does not guarantee a useful artifact. Committed cells should -still be readable, rerunnable, and editable. - -Make durable edits that reuse the notebook's existing names, imports, -dependencies, and UI model. Don't be lazy. Avoid one-off workarounds that pass -`cm` validation but leave a brittle notebook. - -### Cell Bodies - -Submit the code that belongs in the cell. - -- **Submit cell contents** - `create_cell` and `edit_cell` take cell contents, - not saved-file `@app.cell` wrappers. -- **Read before replacing** - for now, another editor may change a cell between - scratchpad calls. Before `edit_cell`, read the current body from - `ctx.cells[...]` and submit the full replacement. -- **Reuse notebook imports** - if `np` already exists, use it or edit the owning - import cell. DO NOT add `import numpy as _np` just to bypass the graph. -- **Define each public name once** - a public name has one owning cell. - Reassigning it in another cell fails with `Multiply-defined names`; edit the - owning cell or give the result a new name. See - [gotchas.md](reference/gotchas.md). -- **Run cells deliberately** - `create_cell` and `edit_cell` change structure - only. Queue `ctx.run_cell(...)` when the cell should execute. - -### Cell Boundaries - -A cell is also a rerun boundary. Put expensive or reusable computation -upstream of presentation so UI edits stay cheap. Keep cheap, -presentation-specific work with the view when that is easier to read. - -Use `mo.vstack` and `mo.hstack` only when the composition is part of the UI. -Narrative often reads better in an adjacent markdown cell. - -### Prefer `cm`-Managed Changes - -Use `cm` APIs when they exist. Avoid direct file edits, shell package commands, -and scratchpad-only state for changes that should persist. - -- **Do not edit the `.py` artifact** - DO NOT use `Edit`, `Write`, or - `NotebookEdit` on the notebook file during a live session. Use - `ctx.edit_cell(...)` even for small changes. -- **Manage packages through `cm`** - use `ctx.packages.add()` or - `ctx.packages.remove()` instead of direct `uv` or `pip`; confirm - non-obvious dependency changes. -- **Avoid transient paths** - persisted cells should not depend on `/tmp/...` - unless the work is intentionally transient. -- **Delete deliberately** - deleting a cell removes globals it defines. Reuse - empty cells when convenient and delete cells left empty after edits. - -### UI and Widgets - -Inspect the object before changing it. Different UI objects update through -different paths. - -- **Set `mo.ui.*` through `cm`** - use `ctx.set_ui_value(element, value)` inside - `cm.get_context()`. -- **Set anywidget traitlets directly** - synced traitlets are Python - attributes, for example `widget.value = 5`. - -For designing custom visual or interactive output, see -[rich-representations.md](reference/rich-representations.md). - -## References - -- [execution-context.md](reference/execution-context.md) — scripts, MCP, auth, startup, and shell quoting -- [finding-marimo.md](reference/finding-marimo.md) — choosing the right marimo invocation -- [gotchas.md](reference/gotchas.md) — name redefinition, cached module proxies, and notebook traps -- [rich-representations.md](reference/rich-representations.md) — custom widgets and visualizations -- [notebook-improvements.md](reference/notebook-improvements.md) — improving existing notebooks diff --git a/.agents/skills/marimo-pair/reference/execution-context.md b/.agents/skills/marimo-pair/reference/execution-context.md deleted file mode 100644 index 6db6f322..00000000 --- a/.agents/skills/marimo-pair/reference/execution-context.md +++ /dev/null @@ -1,70 +0,0 @@ -# Connection Troubleshooting - -Use this reference when `execute-code.sh` or MCP cannot reach the intended -marimo session, cannot select a session, or fails because code was passed -incorrectly. - -## Targeting - -- Pass the discovered `--url` unchanged; `null` means no address was reachable. -- With one open notebook, the script resolves its session. -- Use `--file` for an exact, stable file key or path. It resolves the current - session on every call. -- Use `--session` for an exact active session ID; it goes stale on reconnect. -- The selectors are mutually exclusive. Preserve supplied values; never guess. - -## Auth - -For token-authenticated servers, prefer `MARIMO_TOKEN`. - -```bash -MARIMO_TOKEN=... bash scripts/execute-code.sh --url http://localhost:2718 -c "1 + 1" -``` - -`--token` also works, but may expose the token in process listings. If both are -present, `--token` overrides `MARIMO_TOKEN`. The script sends the token as -`Authorization: ******` on session discovery and code execution requests. - -## Code Input - -Pass code with `-c CODE`, `-` for stdin, or a file path. - -```bash -bash scripts/execute-code.sh --url http://localhost:2718 - <<'PY' -print(df.head()) -PY -``` - -```bash -bash scripts/execute-code.sh --url http://localhost:2718 /tmp/code.py -``` - -## Common Script Errors - -- **`[]` from discover-servers.sh** - nothing is registered. Only servers - started with `--no-token` register; otherwise ask the user for the URL, or - start marimo with the project's normal tooling. -- **No active sessions on the server** - open the notebook in the browser. -- **No active session matches `--file`** - check the exact file key against the - available paths printed by the script. -- **Multiple sessions on server** - several notebooks are open; rerun with the - `--file` path or `--session` ID shown for the notebook you want. -- **Failed to connect** - check the URL, token, and whether the server is still - running. -- **Execution did not complete** - the server ended the stream without a - result. With `--file`, retry the same command so it resolves the current - session. With `--session`, the ID is probably stale after a page refresh. -- **... is running on the Windows host but answered at no address reachable - from WSL** - WSL's network cannot reach it. The message lists the fixes; the - usual one is restarting marimo on the host with `--host 0.0.0.0`. Running the - scripts from Git Bash or PowerShell on the Windows side also works. -- **needs jq / curl on PATH** - install them in whichever environment runs the - scripts. Inside WSL that means the distro, not Windows. -- **SyntaxError** - the submitted Python was malformed; use a heredoc or file. -- **ImportError** - diagnose in the notebook kernel. Install packages through - `cm` when needed. - -## Starting marimo - -Discover first. If no server is running and the user wants a notebook, use -[finding-marimo.md](finding-marimo.md). diff --git a/.agents/skills/marimo-pair/reference/finding-marimo.md b/.agents/skills/marimo-pair/reference/finding-marimo.md deleted file mode 100644 index 793afc68..00000000 --- a/.agents/skills/marimo-pair/reference/finding-marimo.md +++ /dev/null @@ -1,117 +0,0 @@ -# Finding and Invoking marimo - -Only servers started with `--no-token` register in the local server registry -and are auto-discoverable — starting without a token makes discovery easier. -If a server has a token, set the `MARIMO_TOKEN` environment variable before -calling the execute script (avoids leaking the token in process listings). - -```sh -marimo edit notebook.py --no-token [--sandbox] -``` - -Start marimo in edit mode without `--headless` unless the user asks for a -headless server. The notebook UI must be open in a browser before marimo has an -active session for `execute-code` to target. If running headless, give the user -the local URL and wait for them to open it before executing code. - -How you invoke `marimo` depends on context — find the right way to run it. - -## Where the registry lives - -A registering server writes one JSON file per instance and removes it on clean -shutdown: `$XDG_STATE_HOME/marimo/servers` (default `~/.local/state/marimo/servers`) -on Linux and macOS, `%USERPROFILE%\.marimo\servers` on Windows. - -Inside WSL, both are in play. A notebook started in the distro registers at the -XDG path; one started on the Windows host registers in the Windows profile, -which `discover-servers.sh` also reads. Windows-host servers show up with -`"origin": "windows-host"`, and reaching them depends on WSL networking: under -the default NAT the distro has its own loopback, so a host notebook bound to -`127.0.0.1` is unreachable and only `--host 0.0.0.0` (or mirrored networking) -lets WSL connect. The `url` in each entry already accounts for this, so use it. - -Cross-OS discovery needs WSL interop: `cmd.exe` to locate the Windows profile, -and `tasklist.exe` to tell a live Windows PID from a dead one. With interop -disabled, only servers inside the distro are found. - -## Notebooks with PEP 723 metadata require `--sandbox` - -Before picking a runner, check the notebook file for a PEP 723 header: - -```python -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo", -# "polars", -# ] -# /// -``` - -If the block is present, the notebook was authored as a self-contained -sandboxed script and **SHOULD be opened with `--sandbox`**. Without the flag -marimo runs in the ambient environment and silently ignores the inline -dependencies. Imports will fail or, worse, resolve to a different version than -the author pinned. - -`--sandbox` works regardless of project context: inside a uv project, `uv run -marimo edit notebook.py --no-token --sandbox` still creates the isolated env -from the PEP 723 block rather than the project's `.venv`. - -## Inside a Python project - -If there's a `pyproject.toml` in cwd or a parent directory, check that marimo -is actually in the dependencies before using the project's runner. Look for -`marimo` in: - -- `[project.dependencies]` -- `[project.optional-dependencies]` or `[dependency-groups]` (dev deps) -- `[tool.pixi.dependencies]` -- The project's `.venv` (`uv pip show marimo` or check `.venv/bin/marimo`) - -If marimo is in a named dependency group (not the default), you need to -specify it: - -```sh -# marimo is in [dependency-groups] → "notebooks" group -uv run --group notebooks marimo edit notebook.py --no-token -``` - -Once you know marimo is available, use whatever CLI runner the project uses: - -```sh -# uv-managed project -uv run marimo edit notebook.py --no-token -# pixi-managed project -pixi run marimo edit notebook.py --no-token -``` - -Skip `--sandbox` here — the project already manages dependencies. - -If `pyproject.toml` exists but marimo is **not** in the deps, treat this as -"outside a project" (see below). - -## Outside a Python project - -Prefer `--sandbox`. Sandbox mode creates an isolated environment for the -notebook and writes dependencies into the script itself as inline PEP 723 -metadata — so the notebook stays self-contained and reproducible. - -```sh -# With uv available (preferred) -uvx marimo@latest edit notebook.py --no-token --sandbox - -# With marimo installed globally -marimo edit notebook.py --no-token --sandbox -``` - -## Global marimo install - -If marimo is installed globally, check the version — code mode shipped in -v0.21.1. If the installed version is older, prompt the user to upgrade before -proceeding. - -## Nothing found - -If no project marimo, no `uv`/`uvx`, and no global `marimo` on PATH, tell the -user to install `uv` (). diff --git a/.agents/skills/marimo-pair/reference/gotchas.md b/.agents/skills/marimo-pair/reference/gotchas.md deleted file mode 100644 index 28440b2b..00000000 --- a/.agents/skills/marimo-pair/reference/gotchas.md +++ /dev/null @@ -1,90 +0,0 @@ -# Gotchas - -## Private variables are cell-scoped - -Variables with a `_` prefix are **private to the cell that defines them** in -marimo. They cannot be referenced from other cells — you'll get a `NameError`. - -This matters when building notebooks programmatically. A common mistake: - -```python -# Cell A -_df = pd.DataFrame(results) # _df is private to this cell - -# Cell B — FAILS -mo.ui.table(_df) # NameError: name '_df' is not defined -``` - -**Fix:** Either merge both into one cell, or use a non-private name (`df`). - -## Redefining a public name across cells - -Each public name has one owning cell. Defining it again in another cell fails -with `Multiply-defined names`. This is easy to hit when building a notebook -incrementally — a second cell reassigns `df`, `results`, `data`, etc. - -```python -# Cell A -df = pd.read_csv("data.csv") - -# Cell B — FAILS: df already defined in Cell A -df = df.dropna() # Multiply-defined names: df -``` - -**Fix — pick one:** - -- **Edit the owning cell** if the step belongs there (`ctx.edit_cell`). -- **Use a new name** when later cells need the result (`clean = df.dropna()`). - -`ctx.graph.cells[cid].defs` shows what a cell already owns. - -## Duplicate public imports across cells - -The same single-definition rule applies to imports: a public name (like `pd`) -can only be defined in one cell. If two cells both `import pandas as pd`, you -get a `Multiply-defined names` error at validation. - -**Fix:** Reuse the existing import — reference `pd` directly, or edit the -owning cell (`ctx.edit_cell`) if the import belongs there instead. If several -cells each need their own copy, consolidate them into the setup cell or a -shared import-only cell (see -[notebook-improvements.md](notebook-improvements.md#setup-cell)). - -## `inspect.getsource()` on methods is indented - -`inspect.getsource()` on a class method preserves the original indentation. -Passing this to `ast.parse()` fails with `IndentationError`. - -```python -# FAILS -src = inspect.getsource(SomeClass.some_method) -tree = ast.parse(src) # IndentationError: unexpected indent - -# FIX -import textwrap -src = textwrap.dedent(inspect.getsource(SomeClass.some_method)) -tree = ast.parse(src) -``` - -## Cached module availability - -Some libraries cache optional-dependency availability at import time. Installing -a package mid-session via `ctx.packages.add()` won't update those caches. -The user may need to restart the kernel — but try known workarounds first. - -### Polars + pyarrow - -`df.to_pandas()` fails with `ModuleNotFoundError: pa.Table requires 'pyarrow'`. - -**Workaround** — if this error occurs after installing pyarrow mid-session, -run the following via `execute-code` (scratchpad), NOT in a cell. The patch -mutates the cached module object in the running kernel, so it doesn't need to -persist in the notebook. - -```python -import pyarrow as _pa -import polars.dataframe.frame as _frame_mod -_frame_mod.pa = _pa -``` - -Then re-run the failing cell. diff --git a/.agents/skills/marimo-pair/reference/notebook-improvements.md b/.agents/skills/marimo-pair/reference/notebook-improvements.md deleted file mode 100644 index e0a273ca..00000000 --- a/.agents/skills/marimo-pair/reference/notebook-improvements.md +++ /dev/null @@ -1,118 +0,0 @@ -# Notebook Improvements - -When the user asks to improve, optimize, or clean up their notebook, scan the -current cells for these opportunities. Use your judgment — don't over-apply, -and if you're unsure whether a change is worthwhile, ask the user. - -## Cell names - -Low priority unless the user asks. `setup` and cells defining -functions/classes are auto-named by marimo. Beyond that, naming is optional. -Note that naming markdown cells clutters the UI by showing the cell header -that's normally hidden. - -## Review dataflow names - -Review names after the cells work and their dependencies are visible. - -- Keep values consumed by later cells public. -- Inline small one-use bindings. -- Move dense procedural work into a function. -- Use a private top-level binding when it makes one cell easier to read. - -Many private names can mean that a useful stage is hidden or that a cell is -doing too much. Treat this as a prompt to inspect, not a rule. - -## Setup cell - -A setup cell is named `"setup"` and is guaranteed to run before all other -cells. It's the place for module imports. Consolidating imports here keeps -the notebook clean and ensures every cell can rely on those modules being -available. - -**The setup cell cannot reference other cells' variables.** It runs first, so -it must be self-contained: imports, constants, and definitions that depend only -on each other. Reading a name defined elsewhere (e.g. `df`, a UI element) fails -with `The setup cell cannot have references`. - -**Keep the setup cell import-only if you can.** marimo skips re-running -descendants when an edited cell contains *only* import statements, since -imports resolve independently of the notebook's reactive dataflow — this -applies to any import-only cell, not just `setup`. But it matters most for -`setup`, since every other cell depends on it: if the setup cell also holds -constants or other non-import definitions, editing it re-runs the *entire* -notebook, not just the imports. If you need setup-time constants too, put -them in their own cell downstream of `setup` rather than mixing them in. - -First check if the notebook already has a cell named `"setup"`. If not, create -one and hoist scattered imports into it. `name="setup"` auto-positions the cell -first — no `before`/`after` needed: - -```python -cid = ctx.create_cell('''import polars as pl -import marimo as mo -import anywidget -import traitlets''', name="setup") -ctx.run_cell(cid) -``` - -If a setup cell already exists, `create_cell(name="setup")` raises `ValueError`; -use `ctx.edit_cell("setup", code=...)` and `ctx.run_cell("setup")` instead. - -## Lift reusable functions into their own cells - -When a cell contains a single function or class that doesn't reference -variables from other cells, marimo treats it specially — it can be written as -a standalone definition and reused outside the notebook. These functions can -use modules from the setup cell. - -Look for functions that **could belong in a library**: data loading, transforms, -parsers, domain logic, custom widgets. If someone might reasonably `import` it -from another module, it's a good candidate to lift into its own cell. - -Don't lift everything — notebook-specific wiring (UI layout, display logic, -cell-level orchestration) should stay where it is. Use `_prefix` for -cell-internal helpers that aren't meant to be reused. - -```python -# before: useful logic buried in a larger cell -objects = pl.read_csv("https://example.com/objects.csv") -artists = pl.read_csv("https://example.com/artists.csv") - -def top_counts(df, col, n=5): - return df.group_by(col).len().sort("len", descending=True).head(n) - -result = top_counts(objects.join(artists, on="id"), "category") -``` - -```python -# after: top_counts is general-purpose — give it its own cell - -# cell 1 -def top_counts(df, col, n=5): - return df.group_by(col).len().sort("len", descending=True).head(n) -``` - -```python -# cell 2 -result = top_counts(df, "category") -``` - -## `mo.persistent_cache` - -`@mo.persistent_cache` caches a function's result to disk so it isn't -recomputed on subsequent runs. The cache persists across kernel restarts. - -```python -@mo.persistent_cache -def load_data(): - objects = pl.read_csv("https://example.com/objects.csv") - artists = pl.read_csv("https://example.com/artists.csv") - return objects.join(artists, on="id", how="left") - -df = load_data() -``` - -Good candidates: data loading, ETL, expensive computation that rarely changes. -Don't over-optimize — if you're unsure, suggest it to the user rather than -applying it. diff --git a/.agents/skills/marimo-pair/reference/rich-representations.md b/.agents/skills/marimo-pair/reference/rich-representations.md deleted file mode 100644 index 64cb43eb..00000000 --- a/.agents/skills/marimo-pair/reference/rich-representations.md +++ /dev/null @@ -1,303 +0,0 @@ -# Rich Representations - -Custom visual encodings for data that go beyond standard charts and tables. - -## Guiding principles - -**Visualization matters.** Helping users build custom visual representations -is one of the highest-impact things the agent can do. A bespoke encoding -tailored to the task — labeling, batch review, comparing variants — lets -users *see* their data in ways that tables and numbers never will. marimo -is an environment where users create their own views, not just consume -library charts. Help them imagine what's possible, then build it. - -**Use modern web APIs.** Models may default to older browser patterns; prefer -modern HTML, CSS, and JavaScript that are supported in current browsers. Avoid -build steps unless the task clearly needs them. - -**Prefer compact output.** marimo clips cell output at ~610px and scrolls. -Avoid hitting that limit; if you need more space, manage your own scrolling -inside a fixed-height container. - -**Keep it thin, make it compose.** A widget is a thin layer over data, not -an application. One clear purpose, few traitlets, small `_esm`. Build small -pieces that compose in the notebook — combine with other cells, UI elements, -and views. Don't over-engineer. - -## Decision tree - -| Need | Approach | -|------|----------| -| Custom output or interaction | **anywidget** — flexible enough to grow from display-only to interactive | -| Tiny static HTML representation | `_display_()` or `mo.Html` | -| Built-in control used as-is (slider, dropdown) | `mo.ui.*` | - -For custom representations, prefer anywidget unless the output is clearly a -small static one-off. - -## anywidget - -[anywidget](https://anywidget.dev) bridges Python and JavaScript via -traitlets. `.tag(sync=True)` makes a traitlet bidirectional — Python sets a -value → JS sees it; JS calls `model.set()` + `model.save_changes()` → -Python sees it. `_css` is optional global CSS. - -**marimo does not render traditional Jupyter widgets.** Libraries like jscatter, -ipyvolume, etc. often have a top-level object whose default representation is a -Jupyter widget (`application/vnd.jupyter.widget-view+json`). marimo cannot -display these — you need to find the underlying **anywidget** instance, which -marimo *does* support. - -Common pattern: look for a `.widget` attribute on the library object: - -```python -# jscatter example — Scatter is not renderable, but .widget is an anywidget -scatter = jscatter.Scatter(data=df, x="x", y="y") -scatter.widget # <-- use this in the cell output -``` - -When unsure, check in the scratchpad: - -```python -import anywidget -obj = scatter.widget # or whatever accessor the library provides -print(isinstance(obj, anywidget.AnyWidget)) # True = marimo can render it -``` - -### `_esm` lifecycle - -**Render only** (most widgets): - -```js -function render({ model, el }) { /* ... */ } -export default { render }; -``` - -**Initialize + render** (shared state across views, one-time setup): - -```js -export default () => { - return { - initialize({ model }) { - // Once per widget instance — timers, connections, shared handlers - return () => { /* cleanup */ }; - }, - render({ model, el }) { - // Once per view — display in 3 cells = 3 renders - return () => { /* cleanup DOM listeners */ }; - } - }; -}; -``` - -- `model.on()` is auto-cleaned when a view is removed -- DOM `addEventListener` is **not** — clean up with `AbortController` - -### Timer example (initialize + render) - -`initialize` owns one interval; each `render` view displays it. - -```python -import anywidget -import traitlets - -_TIMER_ESM = """ -export default () => { - return { - initialize({ model }) { - const id = setInterval(() => { - if (model.get("running")) { - model.set("seconds", model.get("seconds") + 1); - model.save_changes(); - } - }, 1000); - return () => clearInterval(id); - }, - render({ model, el }) { - const controller = new AbortController(); - const { signal } = controller; - - const span = document.createElement("span"); - span.style.cssText = "font: 24px monospace;"; - - const btn = document.createElement("button"); - btn.style.cssText = "margin-left: 8px; cursor: pointer;"; - - function update() { - const s = model.get("seconds"); - const mm = String(Math.floor(s / 60)).padStart(2, "0"); - const ss = String(s % 60).padStart(2, "0"); - span.textContent = `${mm}:${ss}`; - btn.textContent = model.get("running") ? "⏸" : "▶"; - } - - model.on("change:seconds", update); - model.on("change:running", update); - - btn.addEventListener("click", () => { - model.set("running", !model.get("running")); - model.save_changes(); - }, { signal }); - - update(); - el.append(span, btn); - return () => controller.abort(); - } - }; -}; -""" - -class Timer(anywidget.AnyWidget): - seconds = traitlets.Int(0).tag(sync=True) - running = traitlets.Bool(True).tag(sync=True) - _esm = _TIMER_ESM -``` - -### Composing with the notebook - -Widgets become reactive notebook citizens when you bridge a traitlet to -`mo.state`. This is a two-cell pattern — create the widget and wire up the -observer in one cell, read the value in another: - -```python -# Cell 1 — widget + observer -timer = Timer() - -get_seconds, set_seconds = mo.state(timer.seconds) -timer.observe(lambda _: set_seconds(timer.seconds), names=["seconds"]) - -timer # display the widget -``` - -```python -# Cell 2 — reacts to changes -seconds = get_seconds() -mo.md(f"Timer is at **{seconds}s** — {'running' if seconds > 0 else 'stopped'}") -``` - -The common pattern is `mo.state(widget.trait)` for the initial value, -`.observe()` on the specific trait name, and reading with the getter in a -downstream cell. See [Reactive anywidgets](#reactive-anywidgets-in-marimo) -for the details. - -### CDN dependencies - -Import JS libraries from [esm.sh](https://esm.sh) — no build step: - -```js -import * as d3 from "https://esm.sh/d3@7"; -import { tableFromIPC } from "https://esm.sh/@uwdata/flechette@2"; -``` - -### DataFrames and binary data - -**Prefer reducing data on the Python side.** Aggregate, filter, sample — -send the widget only what it needs. Most widgets should receive a small, -pre-processed payload via simple traitlets (lists, dicts). This keeps the -widget simple and avoids extra dependencies. - -**For large tabular data (>2k rows)** where the widget genuinely needs -row-level access, send Arrow IPC bytes instead of JSON. This adds -complexity and dependencies, so only reach for it when the data volume -justifies it. - -**Python — serialize:** - -```python -# Polars (native, no pyarrow needed) -_ipc=df.write_ipc(None).getvalue() - -# Any __arrow_c_stream__ source (pandas, narwhals, pyarrow, etc.) -import io, pyarrow as pa, pyarrow.feather as feather - -def to_arrow_ipc(data) -> bytes: - table = pa.RecordBatchReader.from_stream(data).read_all() - sink = io.BytesIO() - feather.write_feather(table, sink, compression="uncompressed") - return sink.getvalue() -``` - -**JS — deserialize with `@uwdata/flechette`:** - -```js -import { tableFromIPC } from "https://esm.sh/@uwdata/flechette@2"; -const table = tableFromIPC(new Uint8Array(model.get("_ipc").buffer)); -// table.numRows, table.numCols, table.get(i), table.getChild("col_name") -``` - -Use `traitlets.Any().tag(sync=True)` for the IPC bytes traitlet. - -## Reactive anywidgets in marimo - -When an anywidget trait (selection, value, zoom, etc.) should drive a -downstream marimo cell, use `mo.state()` + `.observe()` on the **specific -trait**. This is the preferred pattern: - -```python -# In the cell that creates the widget: -get_selection, set_selection = mo.state(widget.selection) -widget.observe( - lambda _: set_selection(widget.selection), - names=["selection"], -) - -# In a downstream cell — re-executes when selection changes: -selection = get_selection() -``` - -Initialize `mo.state()` with the widget's current trait value — not a -hardcoded default. Read the trait directly off the widget in the lambda. -Do **not** use `change["new"]` or `allow_self_loops=True`. - -### `mo.state` + `.observe()` vs `mo.ui.anywidget()` - -Two strategies for reactive anywidgets. Choose one per widget — don't mix them. - -| Strategy | Reactivity | Best for | -|----------|-----------|----------| -| `mo.state` + `.observe()` | Specific traits you pick | Precision — only named traits trigger downstream cells | -| `mo.ui.anywidget(widget)` | All synced traits as one `.value` dict | Convenience — observe everything at once | - -### Programmatic widget control (scratchpad) - -Read widget state or set UI controls from the scratchpad — no clicking: - -```python -print(timer.seconds) # read -timer.seconds = 0 # set — frontend updates automatically -``` - -`mo.ui.*` elements need `ctx.set_ui_value(...)` from code mode; anywidgets use -direct assignment. - -## `_display_()` protocol - -Any object with a `_display_()` method renders richly in marimo. Return -anything marimo can render — `mo.Html`, `mo.md()`, a chart, a string. - -Precedence: `_display_()` > built-in formatters > `_mime_()` > IPython -`_repr_*_()` methods. - -```python -from dataclasses import dataclass -import marimo as mo - -@dataclass -class ColorSwatch: - colors: list[str] - - def _display_(self): - divs = "".join( - f'
' - for c in self.colors - ) - return mo.Html(f'
{divs}
') -``` - -For inline ` + + diff --git a/editor-web/package-lock.json b/editor-web/package-lock.json new file mode 100644 index 00000000..378d3815 --- /dev/null +++ b/editor-web/package-lock.json @@ -0,0 +1,3018 @@ +{ + "name": "simplecad-editor-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplecad-editor-web", + "version": "0.0.0", + "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.13.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.14", + "@lezer/highlight": "^1.2.5", + "@tailwindcss/vite": "^4.3.3", + "@types/three": "^0.186.0", + "@uiw/react-codemirror": "^4.25.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fflate": "^0.8.3", + "lucide-react": "^1.48.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-resizable-panels": "^4.13.3", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", + "three": "^0.186.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.14", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", + "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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-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-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "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-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "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-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "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-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-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-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-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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-slider": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@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-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "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-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-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "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-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "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-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "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-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@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-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "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-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "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-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "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-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "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-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "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-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "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-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.12", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.12.tgz", + "integrity": "sha512-wR5lz7jbVcBGyiiLEObAmDikcc39KNqrrd4bdUhav85WZu48ttgnSf1YV+grHuMsj5l/WCw6u+r9ZuSfURJgiA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.12", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.12.tgz", + "integrity": "sha512-RE0Gj/QesD7/JWPnJREG8Kq8brGv2NAH17iUECcBrLkqXzzEAgsj/4Qqvz/OlK8XWKmzlsenLRkObT9H+gYQ0w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.12", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-resizable-panels": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.13.3.tgz", + "integrity": "sha512-k41s4nP8IXiGjnso8TsZLzoRbuxBb6buTJUOrFWrYpmbV4PGH5GHYM2RvNWWBRIJ6e+Zxlf3m9Tubk8VoPU9Ww==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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" + } + } +} diff --git a/editor-web/package.json b/editor-web/package.json new file mode 100644 index 00000000..ab30b075 --- /dev/null +++ b/editor-web/package.json @@ -0,0 +1,49 @@ +{ + "name": "simplecad-editor-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.json && vite build", + "lint": "oxlint", + "preview": "vite preview", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.13.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.14", + "@lezer/highlight": "^1.2.5", + "@tailwindcss/vite": "^4.3.3", + "@types/three": "^0.186.0", + "@uiw/react-codemirror": "^4.25.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fflate": "^0.8.3", + "lucide-react": "^1.48.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-resizable-panels": "^4.13.3", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", + "three": "^0.186.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/editor-web/public/favicon.svg b/editor-web/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/editor-web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/editor-web/public/icons.svg b/editor-web/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/editor-web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx new file mode 100644 index 00000000..56133986 --- /dev/null +++ b/editor-web/src/App.tsx @@ -0,0 +1,428 @@ +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 { Header } from "@/components/Header"; +import { Inspector } from "@/components/Inspector"; +import { LoadingMask } from "@/components/LoadingMask"; +import { ModuleView, type ModuleState } from "@/components/ModuleView"; +import { NotebookList } from "@/components/NotebookList"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { fetchPreview, getWorkspace, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; +import { parsePreview } from "@/lib/preview"; +import { EnvelopeSocket, type Envelope } from "@/lib/envelope"; +import { useRunLog } from "@/lib/runlog"; +import { selectionPayload, variableNames, type SelectionItem } from "@/lib/selection"; +import { cn } from "@/lib/utils"; +import { Viewport, type PickResult, type SelectionMode } from "@/scene/Viewport"; +import type { SceneView } from "@/scene/scene-view"; + +const MODES: { mode: SelectionMode; label: string }[] = [ + { mode: "component", label: "comp" }, + { mode: "solid", label: "solid" }, + { mode: "face", label: "face" }, + { mode: "edge", label: "edge" }, + { mode: "vertex", label: "vert" }, +]; + +/** Editor application: left = notebook (cells, workspace), middle = the live + * model, right = the selection. The model is the in-memory scadpkg the kernel + * publishes whenever the cells settle; picking in the viewport writes the + * selection into the notebook (see `selection.set`). */ +export function App() { + const viewRef = useRef(null); + // Whether a model of the open notebook is on screen (it may be the last run's, + // restored while the kernel boots): then nothing is being waited for. + const hasModel = useRef(false); + const socketRef = useRef(null); + + const [notebooks, setNotebooks] = useState([]); + const [modules, setModules] = useState([]); + const [moduleView, setModuleView] = useState(null); + const [leftTab, setLeftTab] = useState("notebook"); + const [notebook, setNotebook] = useState(null); + // The notebook the server says is open (workspace-relative): what the list + // highlights. It changes the moment another is asked for, not when its + // kernel is up, and a late answer of the workspace list cannot undo it. + const [currentPath, setCurrentPath] = useState(null); + const [kernelAlive, setKernelAlive] = useState(false); + const [marimoUrl, setMarimoUrl] = useState(null); + const [status, setStatus] = useState("Connecting…"); + + const [params, setParams] = useState([]); + const [cells, setCells] = useState([]); + const [features, setFeatures] = useState([]); + const [picked, setPicked] = useState[]>([]); + const [selectionMode, setSelectionMode] = useState("solid"); + const [sceneVersion, setSceneVersion] = useState(0); + // The workspace is veiled while a model is being made: the kernel starting, a + // notebook opening, a module reloading, every cell running. It ends when the + // model arrives (`package`) or the work fails. Interactive rebuilds (a + // slider, one cell) are not veiled. + const [loading, setLoading] = useState("connecting"); + + const kind = notebooks.find((row) => row.path === currentPath)?.kind ?? null; + const log = useRunLog(); + const running = cells.some((cell) => cell.status === "running" || cell.status === "queued"); + const handleStop = useCallback(() => { + socketRef.current?.send({ type: "interrupt.request" }); + setLoading(null); + log.push("info", "stop requested"); + log.resetBatch(); + }, [log.push, log.resetBatch]); + + const selection: SelectionItem[] = useMemo(() => { + const names = variableNames(picked); + return picked.map((item, index) => ({ ...item, variable: names[index] })); + }, [picked]); + + // -- package → viewport ------------------------------------------------------- + + // Camera framing is disruptive during slider drags; frame once per notebook. + const framedNotebook = useRef(null); + + const loadPreviewBytes = useCallback(async (buffer: ArrayBuffer, name: string | null) => { + const view = viewRef.current; + if (!view) return; + try { + const preview = parsePreview(buffer); + view.loadPreview(preview); + setStatus(`${preview.nodes.length} ${preview.nodes.length === 1 ? "part" : "parts"} · ${preview.parts.size} distinct`); + if (framedNotebook.current !== name) { + view.frame(); + framedNotebook.current = name; + } + setSceneVersion((version) => version + 1); + log.push("ok", `model shown: ${preview.nodes.length} ${preview.nodes.length === 1 ? "part" : "parts"}`); + } catch (error) { + log.push("error", error instanceof Error ? error.message : "unable to open the preview"); + } + }, [log.push]); + + // -- socket + lifecycle -------------------------------------------------------- + + useEffect(() => { + const socket = new EnvelopeSocket(`ws://${window.location.host}/ws`); + socketRef.current = socket; + + const refreshWorkspace = () => + void getWorkspace().then((data) => { + setNotebooks(data.notebooks); + setModules(data.modules ?? []); + setCurrentPath((path) => path ?? data.notebooks.find((row) => row.current)?.path ?? null); + }); + + socket.on("hello", (envelope) => { + setNotebook((envelope["notebook"] as string) ?? null); + setCurrentPath((envelope["path"] as string) ?? null); + setKernelAlive(Boolean(envelope["kernel"])); + // 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; + setLoading(stage); + if (stage) setStatus(`${stage[0].toUpperCase()}${stage.slice(1)}…`); + setMarimoUrl((envelope["marimo_url"] as string) ?? null); + refreshWorkspace(); + }); + + // Another notebook is being opened: drop the old model and its state now, + // the kernel takes a few seconds to boot and publish the new one. + socket.on("opening", (envelope) => { + hasModel.current = false; + viewRef.current?.clearScene(); + framedNotebook.current = null; + setCells([]); + setParams([]); + setFeatures([]); + setPicked([]); + setNotebook(String(envelope["notebook"] ?? "")); + setCurrentPath((envelope["path"] as string) ?? null); + setStatus(`opening ${envelope["notebook"]}…`); + setLoading(`opening ${envelope["notebook"]}`); + log.resetBatch(); + log.push("info", `opening ${envelope["notebook"]}`); + setSceneVersion((version) => version + 1); + refreshWorkspace(); + }); + + socket.on("params.schema", (envelope) => { + const vars = (envelope["vars"] as ParamMeta[] | undefined) ?? []; + setParams(vars.map((param) => ({ ...param }))); + }); + + socket.on("params.values", (envelope) => { + const values = envelope["values"] as Record; + setParams((previous) => + previous.map((param) => (param.id in values ? { ...param, value: values[param.id] } : param)), + ); + }); + + socket.on("module", (envelope) => { + if (!envelope["path"]) { + setModuleView(null); + setLeftTab((tab) => (tab === "module" ? "notebook" : tab)); + return; + } + const state = envelope as unknown as ModuleState; + setModuleView(state); + if (state.origin === "open") setLeftTab("module"); + }); + + // The live state of a cell's run: its button is run or stop by it. + socket.on("cell.status", (envelope) => { + const id = String(envelope["id"]); + const status = String(envelope["status"]); + setCells((previous) => previous.map((cell) => (cell.id === id ? { ...cell, status } : cell))); + log.cellStatus(id, status, (envelope["title"] as string | null) ?? null); + }); + + socket.on("notebook.cells", (envelope) => { + const next = (envelope["cells"] as NotebookCell[]) ?? []; + setCells(next); + log.knowCells(next); + }); + socket.on("notebook.features", (envelope) => setFeatures((envelope["features"] as NotebookFeature[]) ?? [])); + + socket.on("preview", (envelope) => { + setLoading(null); + const shown = hasModel.current; + hasModel.current = true; + // The kernel published the model that is already on screen: nothing to + // reload. (A page that has none yet, a refresh replaying this envelope, needs it.) + if (envelope["unchanged"] && shown) { + log.modelShown(); + return; + } + void fetchPreview().then((buffer) => loadPreviewBytes(buffer, (envelope["name"] as string) ?? null)); + log.modelShown(); + }); + + // The details of a picked entity (a preview carries none), asked for when it was picked. + socket.on("entity.info", (envelope) => { + const entityId = String(envelope["entity_id"]); + const entity = envelope["entity"] as { properties?: Record; geometry?: Record; tags?: string[] } | null; + setPicked((previous) => + previous.map((item) => + item.entityId === entityId + ? { ...item, pending: false, properties: entity?.properties ?? item.properties ?? {}, geometry: entity?.geometry ?? item.geometry ?? {}, tags: entity?.tags ?? item.tags } + : item, + ), + ); + }); + + // Messages of the server. Errors go to the log (the dock opens on one), not the header. + socket.on("status", (envelope) => { + if (typeof envelope["error"] === "string") { + if (envelope["error"]) { + log.push("error", envelope["error"]); + setLoading(null); + } + } else if (typeof envelope["info"] === "string") { + setStatus(envelope["info"]); + log.push("info", envelope["info"]); + } else if (typeof envelope["working"] === "string") { + setStatus(envelope["working"]); + log.push("run", envelope["working"]); + setLoading(envelope["working"].replace(/…$/, "")); + } else if (typeof envelope["exported"] === "string") { + setStatus(`exported ${envelope["exported"]}`); + log.push("ok", `exported ${envelope["exported"]}`); + } else if (typeof envelope["created"] === "string") { + setStatus(`created ${envelope["created"]}`); + log.push("ok", `created ${envelope["created"]}`); + } + }); + + socket.connect(); + return () => socket.close(); + }, [loadPreviewBytes, log.push, log.cellStatus, log.knowCells, log.resetBatch, log.modelShown]); + + // -- selection ------------------------------------------------------------------- + + const handlePick = useCallback((result: PickResult) => { + const view = viewRef.current; + const node = view?.nodes.find((item) => item.node_id === result.nodeId); + const entity = result.entity; + const item: Omit = { + key: `${result.nodeId}::${result.entityId ?? "component"}`, + kind: result.mode === "component" ? "component" : ((entity?.kind ?? result.mode) as SelectionItem["kind"]), + nodeId: result.nodeId, + entityId: result.entityId, + definitionId: node?.definition_id ?? null, + properties: entity?.properties ?? null, + geometry: entity?.geometry ?? null, + tags: entity?.tags ?? null, + }; + // A preview carries no entity details: ask the kernel for them. + const needsDetails = Boolean(view?.isPreview) && Boolean(item.entityId) && Object.keys(item.properties ?? {}).length === 0; + if (needsDetails && item.entityId) { + item.pending = true; + socketRef.current?.send({ type: "entity.info", entity_id: item.entityId, node_id: item.nodeId }); + } + setPicked((previous) => { + if (!result.additive) return [item]; + return previous.some((other) => other.key === item.key) + ? previous.filter((other) => other.key !== item.key) + : [...previous, item]; + }); + }, []); + + const handleClear = useCallback(() => setPicked((previous) => (previous.length ? [] : previous)), []); + + // The notebook follows the selection: the server writes it as ql code into + // the selection cell, and the viewport image is saved for coding agents. + const firstSelection = useRef(true); + useEffect(() => { + if (firstSelection.current) { + firstSelection.current = false; + return; + } + // The notebook gets the selection when every picked entity is described. + const waiting = selection.some((item) => item.pending); + const send = window.setTimeout(() => { + if (!waiting) socketRef.current?.send({ type: "selection.set", items: selectionPayload(selection) }); + }, 40); + const snapshot = window.setTimeout(() => { + const png = viewRef.current?.snapshotPng(); + if (png) void postViewport(png).catch(() => undefined); + }, 600); + return () => { + window.clearTimeout(send); + window.clearTimeout(snapshot); + }; + }, [selection]); + + // Never veil for ever: whatever failed to say so ends it after three minutes. + useEffect(() => { + if (loading === null) return; + const timer = window.setTimeout(() => setLoading(null), 180_000); + return () => window.clearTimeout(timer); + }, [loading]); + + // -- actions ----------------------------------------------------------------------- + + const handleOpen = (path: string) => { + setStatus(`opening ${path}…`); + openNotebook(path).catch((error) => { + log.push("error", `open failed: ${error.message}`); + setLoading(null); + }); + }; + + return ( +
+
{ + setLoading("running all cells"); + socketRef.current?.send({ type: "rerun.request" }); + }} + /> +
+ + + + + notebook + workspace + {moduleView && {moduleView.name}} + + + + + + socketRef.current?.send({ type: "module.open", path })} + /> + + {moduleView && ( + + socketRef.current?.send({ type: "module.close" })} + /> + + )} + + + + +
+
+ {MODES.map(({ mode, label }) => ( + + ))} + + ctrl+click: add + +
+
+ +
+
+
+ + + + +
+ +
+ +
+ ); +} + +export type { Envelope }; diff --git a/editor-web/src/assets/hero.png b/editor-web/src/assets/hero.png new file mode 100644 index 00000000..02251f4b Binary files /dev/null and b/editor-web/src/assets/hero.png differ diff --git a/editor-web/src/assets/vite.svg b/editor-web/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/editor-web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/editor-web/src/components/CellEditor.tsx b/editor-web/src/components/CellEditor.tsx new file mode 100644 index 00000000..1fd9de41 --- /dev/null +++ b/editor-web/src/components/CellEditor.tsx @@ -0,0 +1,491 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import CodeMirror from "@uiw/react-codemirror"; +import { python } from "@codemirror/lang-python"; +import { EditorView } from "@codemirror/view"; +import { + ChevronRight, + ChevronsDownUp, + ChevronsUp, + ChevronsUpDown, + Check, + Copy, + Loader2, + Play, + Plus, + Square, + Trash2, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl"; +import { cadTheme } from "@/lib/cm-theme"; +import { cn } from "@/lib/utils"; + +export type NotebookCell = { + id: string; + name: string; + code: string; + status?: string | null; + errors?: string[] | null; + /** Not run: a cell it depends on (or the setup) failed. */ + blocked?: boolean; + output?: string | null; + console?: string[]; + /** Slug of the cell's `# ---- feature: ... ----` header, if it has one. */ + feature?: string | null; + /** The step's name: feature slug, params header, or the first variable it defines. */ + title?: string; + /** An @app.function cell: edited in the file, not here. */ + readonly?: boolean; + /** Where the cell is in the notebook file, for pasting to a coding agent: + * `:L-L (cell 03 \`name\`)`. */ + ref?: string; +}; +/** One feature header; `cell` is the id of the cell it opens. */ +export type NotebookFeature = { cell: string; label: string; kind?: string }; +export type { ParamMeta, ParamValue }; + +type SocketLike = { send: (message: Record) => void } | null; + +const AUTOSAVE_MS = 700; +const EXTENSIONS = [python(), EditorView.lineWrapping]; + +function loadCollapsed(notebook: string | null): Set { + try { + const raw = window.localStorage.getItem(`sca-collapsed:${notebook}`); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); + } catch { + return new Set(); + } +} + +/** The notebook: one block per cell. A block's header is its handle — the + * step's name — and folds the cell away; the buttons run it, run everything + * above it, or delete it. Every edit is autosaved to the notebook file, so a + * coding agent reading the file sees what is on screen. */ +export function CellEditor({ + cells, + features, + params, + socket, + notebook, + onInterrupt, +}: { + cells: NotebookCell[]; + features: NotebookFeature[]; + params: ParamMeta[]; + socket: SocketLike; + notebook: string | null; + onInterrupt: () => void; +}) { + const [drafts, setDrafts] = useState>(new Map()); + const [running, setRunning] = useState(null); + const [collapsed, setCollapsed] = useState>(() => loadCollapsed(notebook)); + // Two-stage delete: arm on the first click (button turns red), confirm on + // the second; any click elsewhere disarms. + const [armed, setArmed] = useState(null); + const saveTimers = useRef(new Map()); + + const featureByCell = useMemo(() => new Map(features.map((feature) => [feature.cell, feature])), [features]); + + useEffect(() => { + setCollapsed(loadCollapsed(notebook)); + setDrafts(new Map()); + }, [notebook]); + + const remember = (next: Set) => { + setCollapsed(next); + try { + window.localStorage.setItem(`sca-collapsed:${notebook}`, JSON.stringify([...next])); + } catch { + /* private mode: folding just does not persist */ + } + }; + const toggle = (id: string) => { + const next = new Set(collapsed); + if (next.has(id)) next.delete(id); + else next.add(id); + remember(next); + }; + + const cancelSave = (id: string) => { + const timer = saveTimers.current.get(id); + if (timer !== undefined) window.clearTimeout(timer); + saveTimers.current.delete(id); + }; + + const run = (cell: NotebookCell) => { + cancelSave(cell.id); + const code = drafts.get(cell.id) ?? cell.code; + setRunning(cell.id); + socket?.send({ type: "code.edit", cell: cell.id, code }); + window.setTimeout(() => setRunning((current) => (current === cell.id ? null : current)), 2500); + }; + + const runAbove = (cell: NotebookCell) => { + setRunning(cell.id); + socket?.send({ type: "rerun.request", cell: cell.id, above: true }); + window.setTimeout(() => setRunning((current) => (current === cell.id ? null : current)), 2500); + }; + + const remove = (cell: NotebookCell) => { + setArmed(null); + cancelSave(cell.id); + socket?.send({ type: "rollback.request", cell: cell.id }); + }; + + const insert = (after: string | null) => socket?.send({ type: "insert.request", after }); + + const edit = (cell: NotebookCell, value: string) => { + setDrafts((previous) => new Map(previous).set(cell.id, value)); + if (cell.readonly) return; + cancelSave(cell.id); + saveTimers.current.set( + cell.id, + window.setTimeout(() => { + saveTimers.current.delete(cell.id); + socket?.send({ type: "cell.save", cell: cell.id, code: value }); + }, AUTOSAVE_MS), + ); + }; + + // Forward slider bursts; the server coalesces (latest-wins rebuild). + const pendingParams = useRef(new Map()); + const paramTimer = useRef(null); + const forwardParam = (id: string, value: ParamValue) => { + pendingParams.current.set(id, value); + if (paramTimer.current !== null) return; + paramTimer.current = window.setTimeout(() => { + paramTimer.current = null; + socket?.send({ type: "param.set", values: Object.fromEntries(pendingParams.current) }); + pendingParams.current.clear(); + }, 60); + }; + + // Disarm delete when clicking anywhere outside the armed button. + useEffect(() => { + if (!armed) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + if (!target?.closest(`[data-delete-armed="${CSS.escape(armed)}"]`)) setArmed(null); + }; + window.addEventListener("pointerdown", onPointerDown, true); + return () => window.removeEventListener("pointerdown", onPointerDown, true); + }, [armed]); + + if (cells.length === 0) { + return

Waiting for the kernel to publish cells…

; + } + + const allCollapsed = cells.every((cell) => collapsed.has(cell.id)); + return ( +
+
+ {cells.length} cells + +
+ +
+ + {cells.map((cell, index) => ( +
+ param.cell === cell.id)} + draft={drafts.get(cell.id)} + folded={collapsed.has(cell.id)} + pending={running === cell.id} + live={cell.status === "running" || cell.status === "queued"} + armed={armed === cell.id} + socket={socket} + forwardParam={forwardParam} + onToggle={() => toggle(cell.id)} + onRun={() => run(cell)} + onInterrupt={onInterrupt} + onRunAbove={() => runAbove(cell)} + onDelete={() => (armed === cell.id ? remove(cell) : setArmed(cell.id))} + onEdit={(value) => edit(cell, value)} + /> + +
+ ))} +
+
+
+ ); +} + +/** Hover divider between cells with a "+" insertion button. */ +function CellGap({ after, onInsert }: { after: string | null; onInsert: (after: string | null) => void }) { + return ( +
+
+ +
+ ); +} + +function IconButton({ + title, + onClick, + disabled, + className, + children, + ...rest +}: { + title: string; + onClick: () => void; + disabled?: boolean; + className?: string; + children: React.ReactNode; +} & Record) { + return ( + + ); +} + +/** One cell: header handle + (when open) code, errors, output, console. */ +function CellBlock({ + cell, + index, + role, + params, + draft, + folded, + pending, + live, + armed, + socket, + forwardParam, + onToggle, + onRun, + onInterrupt, + onRunAbove, + onDelete, + onEdit, +}: { + cell: NotebookCell; + index: number; + role?: string; + params: ParamMeta[]; + draft: string | undefined; + folded: boolean; + /** Run was just asked for; the kernel has not said anything yet. */ + pending: boolean; + /** The kernel is running or has queued the cell. */ + live: boolean; + armed: boolean; + socket: SocketLike; + forwardParam: (id: string, value: ParamValue) => void; + onToggle: () => void; + onRun: () => void; + onInterrupt: () => void; + onRunAbove: () => void; + onDelete: () => void; + onEdit: (value: string) => void; +}) { + const failed = cell.status === "exception" || cell.status === "error" || (cell.errors?.length ?? 0) > 0; + const stale = cell.status === "stale"; + const dirty = draft !== undefined && draft !== cell.code; + const isSelection = cell.title === "selection"; + const title = cell.title || cell.name; + const hasOutput = Boolean(cell.output) || params.length > 0; + const [copied, setCopied] = useState(false); + // Click: a reference an agent can find in the file. Alt/Shift+click: the + // reference and the cell's code. + const copyReference = (withCode: boolean) => { + const reference = cell.ref ?? title; + const text = withCode ? `${reference}\n\`\`\`python\n${draft ?? cell.code}\n\`\`\`` : reference; + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }); + }; + return ( +
+
+ + {dirty && } + {cell.status === "running" && running} + {cell.status === "queued" && queued} + {failed && failed} + {cell.blocked && !failed && blocked} + {stale && !failed && stale} + {cell.readonly && function} + + + + + {/* One button, two states: run, and while the kernel is running the cell, stop. */} + + {live ? ( + + ) : pending ? ( + + ) : ( + + )} + + + + +
+ {live &&
} + {!folded && ( +
+ { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + onRun(); + } + }} + /> + {(cell.errors?.length ?? 0) > 0 && ( +
+ {cell.errors?.map((message, errorIndex) => ( +
+                  {message}
+                
+ ))} +
+ )} + {hasOutput && ( +
+ {params.length > 0 && ( +
+
+ {params.map((param) => ( + + ))} +
+ {params.some((param) => param.modified) && ( +
+ + +
+ )} +
+ )} + {cell.output && ( +
{cell.output}
+ )} +
+ )} + {(cell.console?.length ?? 0) > 0 && ( +
+ {cell.console?.map((line, lineIndex) => ( +
+                  {line}
+                
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/editor-web/src/components/Dock.tsx b/editor-web/src/components/Dock.tsx new file mode 100644 index 00000000..cb486324 --- /dev/null +++ b/editor-web/src/components/Dock.tsx @@ -0,0 +1,124 @@ +import { ChevronDown, ChevronUp, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; +import type { LogEntry, LogLevel, RunBatch } from "@/lib/runlog"; + +const INK: Record = { + info: "text-muted-foreground", + run: "text-focus", + ok: "text-pass", + error: "text-fail", +}; + +const TAG: Record = { info: " ", run: "RUN", ok: "OK ", error: "ERR" }; + +const clock = (at: number) => new Date(at).toLocaleTimeString([], { hour12: false }); + +/** The bottom dock: while cells are being re-evaluated, which ones and how far + * along; the log of what happened and what failed. Opens itself on an error. */ +export function Dock({ + entries, + batch, + status, + unseenErrors, + onClear, + onSeen, +}: { + entries: LogEntry[]; + batch: RunBatch | null; + status: string; + unseenErrors: number; + onClear: () => void; + onSeen: () => void; +}) { + const [open, setOpen] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const tick = batch ? Date.now() : now; + const list = useRef(null); + + // An error opens the log; the person closes it again. + useEffect(() => { + if (unseenErrors > 0) setOpen(true); + }, [unseenErrors]); + + useEffect(() => { + if (open) onSeen(); + }, [open, entries.length, onSeen]); + + useEffect(() => { + if (open && list.current) list.current.scrollTop = list.current.scrollHeight; + }, [open, entries.length]); + + useEffect(() => { + if (!batch) return; + const timer = window.setInterval(() => setNow(Date.now()), 200); + return () => window.clearInterval(timer); + }, [batch]); + + const last = entries[entries.length - 1]; + const errors = entries.filter((entry) => entry.level === "error").length; + const percent = batch && batch.total > 0 ? Math.round((batch.done / batch.total) * 100) : 0; + + return ( +
+ {open && ( +
+ {entries.length === 0 ? ( +

Nothing yet. Runs, reloads and errors are listed here.

+ ) : ( + entries.map((entry) => ( +

+ {clock(entry.at)} + {TAG[entry.level]} + {entry.text} +

+ )) + )} +
+ )} +
+ + {errors > 0 && ( + + {errors} {errors === 1 ? "error" : "errors"} + + )} + +
+
+ ); +} diff --git a/editor-web/src/components/Header.tsx b/editor-web/src/components/Header.tsx new file mode 100644 index 00000000..b6b29d3b --- /dev/null +++ b/editor-web/src/components/Header.tsx @@ -0,0 +1,69 @@ +import { ExternalLink, Play, Square } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +/** Top bar: which notebook is open, whether its kernel is alive, and the + * model's last message. Everything else lives on the cells. */ +export function Header({ + notebook, + kind, + kernelAlive, + status, + onRunAll, + running, + onStop, + marimoUrl, +}: { + notebook: string | null; + kind: string | null; + kernelAlive: boolean; + status: string; + onRunAll: () => void; + /** The kernel is running cells: the button stops them instead. */ + running: boolean; + onStop: () => void; + marimoUrl: string | null; +}) { + const failing = status.startsWith("error"); + return ( +
+ SimpleCAD + + {notebook ? ( + + {notebook} + {kind && kind !== "helper" && {kind}} + + ) : ( + no notebook + )} + + + {status} + + {running ? ( + + ) : ( + + )} + {marimoUrl && ( + + )} +
+ ); +} diff --git a/editor-web/src/components/Inspector.tsx b/editor-web/src/components/Inspector.tsx new file mode 100644 index 00000000..c12f8c88 --- /dev/null +++ b/editor-web/src/components/Inspector.tsx @@ -0,0 +1,71 @@ +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { JsonTree } from "@/components/JsonTree"; +import type { SelectionItem } from "@/lib/selection"; + +/** Right-hand inspector: what is selected in the viewport, each item with + * the variable the selection cell assigns it to and its data as a tree. */ +export function Inspector({ selection }: { selection: SelectionItem[] }) { + return ( +
+
+ selection + {selection.length > 0 && {selection.length}} +
+ + {selection.length === 0 ? ( +

+ Click a solid, face, edge or vertex in the viewport. Ctrl/Cmd+click adds to the selection; the selection + is written to the notebook as selected_item. +

+ ) : ( +
    + {selection.map((item) => ( +
  • +
    + {item.kind} + + {item.variable} + +
    +
    + + + {item.definitionId && } +
    + {item.pending &&

    loading details…

    } + {(item.properties || item.geometry) && ( +
    + {item.properties && } + {item.geometry && } + {item.tags && item.tags.length > 0 && } +
    + )} +
  • + ))} +
+ )} +
+
+ ); +} + +function Line({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function Group({ title, value }: { title: string; value: unknown }) { + return ( +
+

{title}

+ +
+ ); +} diff --git a/editor-web/src/components/JsonTree.tsx b/editor-web/src/components/JsonTree.tsx new file mode 100644 index 00000000..b0551b94 --- /dev/null +++ b/editor-web/src/components/JsonTree.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** A generic, collapsible view of JSON data: objects and arrays open to + * `openDepth` levels, short number arrays stay on one line, numbers are + * rounded for reading. Used wherever the editor shows raw entity data. */ +export function JsonTree({ + value, + openDepth = 2, + className, +}: { + value: unknown; + openDepth?: number; + className?: string; +}) { + return ( +
+ +
+ ); +} + +type Entry = [string, unknown]; + +function isContainer(value: unknown): value is Record | unknown[] { + return typeof value === "object" && value !== null; +} + +function entriesOf(value: Record | unknown[]): Entry[] { + return Array.isArray(value) + ? value.map((item, index) => [String(index), item]) + : Object.entries(value); +} + +/** A flat array of a few numbers reads best inline: [1.2, 0, -3]. */ +function inlineNumbers(value: unknown): string | null { + if (!Array.isArray(value) || value.length === 0 || value.length > 6) return null; + if (!value.every((item) => typeof item === "number")) return null; + return `[${value.map((item) => formatNumber(item as number)).join(", ")}]`; +} + +export function formatNumber(value: number): string { + if (!Number.isFinite(value)) return String(value); + if (Number.isInteger(value)) return String(value); + const text = value.toFixed(4).replace(/\.?0+$/, ""); + return text === "-0" ? "0" : text; +} + +function Scalar({ value }: { value: unknown }) { + if (typeof value === "number") return {formatNumber(value)}; + if (typeof value === "boolean") return {String(value)}; + if (value === null || value === undefined) return null; + return {String(value)}; +} + +function summary(value: Record | unknown[]): string { + return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`; +} + +function Node({ value, depth, openDepth }: { value: unknown; depth: number; openDepth: number }) { + if (!isContainer(value)) return ; + const inline = inlineNumbers(value); + if (inline) return {inline}; + const entries = entriesOf(value); + if (entries.length === 0) return {Array.isArray(value) ? "[]" : "{}"}; + return ( +
    0 && "ml-[5px] border-l border-border pl-2")}> + {entries.map(([key, child]) => ( + + ))} +
+ ); +} + +function Row({ name, value, depth, openDepth }: { name: string; value: unknown; depth: number; openDepth: number }) { + const container = isContainer(value) && inlineNumbers(value) === null; + const empty = container && entriesOf(value as Record | unknown[]).length === 0; + const [open, setOpen] = useState(depth + 1 < openDepth); + if (!container || empty) { + return ( +
  • + {name} + +
  • + ); + } + return ( +
  • + + {open && } +
  • + ); +} diff --git a/editor-web/src/components/LoadingMask.tsx b/editor-web/src/components/LoadingMask.tsx new file mode 100644 index 00000000..9cdd1b3c --- /dev/null +++ b/editor-web/src/components/LoadingMask.tsx @@ -0,0 +1,173 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +/** Cells of the 3x3 spinner that form its ring, clockwise from the top left. */ +const RING = [0, 1, 2, 5, 8, 7, 6, 3]; + +const STEP = 8; // dot pitch, CSS px +const DOT = 2; // dot size, CSS px +/** Dot brightness in four steps (a 2-bit look), kept low: the veil should sit + * behind the window, not compete with it. The brightest step is the crest. */ +const LEVELS = [0.03, 0.075, 0.13, 0.22]; +/** One long swell: wavelength 2π/WAVE_NUMBER ≈ 780 px, a period of ≈ 7 s. */ +const WAVE_NUMBER = 0.008; +const WAVE_SPEED = 0.0009; + +/** A gray veil over the whole workspace while the model is being made: a + * dot-matrix with a ripple that spreads from the middle, and a small retro + * window saying what is going on and for how long. It appears only after + * `DELAY_MS`, so quick operations do not flash it. */ +export function LoadingMask({ active, message }: { active: boolean; message: string }) { + const DELAY_MS = 250; + const [visible, setVisible] = useState(false); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!active) { + setVisible(false); + setElapsed(0); + return; + } + const started = Date.now(); + const show = window.setTimeout(() => setVisible(true), DELAY_MS); + const tick = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 500); + return () => { + window.clearTimeout(show); + window.clearInterval(tick); + }; + }, [active]); + + if (!visible) return null; + const minutes = String(Math.floor(elapsed / 60)).padStart(2, "0"); + const seconds = String(elapsed % 60).padStart(2, "0"); + return ( +
    + +
    +
    + loading + + {minutes}:{seconds} + +
    +
    + +
    +

    + {message} +

    +

    + please wait_ +

    +
    +
    + +
    +
    + ); +} + +/** Eight squares chasing each other round a ring. */ +function Spinner() { + return ( +
    + {Array.from({ length: 9 }, (_, cell) => { + const order = RING.indexOf(cell); + return ( + = 0 && "sca-chase")} + style={order >= 0 ? { animationDelay: `${order * 0.1}s` } : undefined} + /> + ); + })} +
    + ); +} + +/** A strip of blocks filling and emptying: the load has no known length. */ +function Progress() { + return ( +
    + {Array.from({ length: 20 }, (_, index) => ( + + ))} +
    + ); +} + +/** The dot-matrix: square dots whose brightness follows a ring-shaped wave + * travelling out from the middle, quantised to four levels. */ +function Ripple() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + let width = 0; + let height = 0; + let frame = 0; + let last = 0; + + const resize = () => { + const ratio = Math.min(window.devicePixelRatio || 1, 2); + width = canvas.clientWidth; + height = canvas.clientHeight; + canvas.width = Math.max(1, Math.round(width * ratio)); + canvas.height = Math.max(1, Math.round(height * ratio)); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + }; + + const draw = (time: number) => { + context.clearRect(0, 0, width, height); + const cx = width / 2; + const cy = height / 2; + const reach = Math.hypot(cx, cy); + const paths = LEVELS.map(() => new Path2D()); + for (let y = STEP / 2; y < height; y += STEP) { + for (let x = STEP / 2; x < width; x += STEP) { + const distance = Math.hypot(x - cx, y - cy); + // a long swell spreading outwards, fading gently with distance + const wave = (Math.sin(distance * WAVE_NUMBER - time * WAVE_SPEED) + 1) / 2; + const strength = wave * (1 - 0.4 * Math.min(distance / reach, 1)); + const level = Math.min(LEVELS.length - 1, Math.floor(strength * LEVELS.length)); + paths[level].rect(x - DOT / 2, y - DOT / 2, DOT, DOT); + } + } + paths.forEach((path, level) => { + context.fillStyle = level === LEVELS.length - 1 ? `rgba(255,154,46,${LEVELS[level]})` : `rgba(255,255,255,${LEVELS[level]})`; + context.fill(path); + }); + }; + + const loop = (time: number) => { + if (time - last > 42) { + last = time; + draw(time); + } + frame = window.requestAnimationFrame(loop); + }; + + resize(); + const observer = new ResizeObserver(() => { + resize(); + if (still) draw(0); + }); + observer.observe(canvas); + if (still) draw(0); + else frame = window.requestAnimationFrame(loop); + return () => { + window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + + return ; +} diff --git a/editor-web/src/components/ModuleView.tsx b/editor-web/src/components/ModuleView.tsx new file mode 100644 index 00000000..4dc7c04b --- /dev/null +++ b/editor-web/src/components/ModuleView.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from "react"; +import CodeMirror from "@uiw/react-codemirror"; +import { python } from "@codemirror/lang-python"; +import { EditorView } from "@codemirror/view"; +import { X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl"; +import { cadTheme } from "@/lib/cm-theme"; + +/** A local module of the workspace (shared dimensions, helpers): what the + * server sends as the `module` envelope. */ +export type ModuleState = { + path: string; + name: string; + code: string; + used_by: string[]; + params: ParamMeta[]; + origin?: string; +}; + +const AUTOSAVE_MS = 700; +const EXTENSIONS = [python(), EditorView.lineWrapping]; + +/** A module open next to the notebooks: its dataclass fields as controls, its + * source below. Every edit is written to the file at once, and every notebook + * that imports it follows (the server reloads it and reruns what reads it). */ +export function ModuleView({ + module, + socket, + onClose, +}: { + module: ModuleState; + socket: { send: (message: Record) => void } | null; + onClose: () => void; +}) { + const [draft, setDraft] = useState(module.code); + const dirty = useRef(false); + const saveTimer = useRef(null); + const paramTimer = useRef(null); + const pending = useRef(new Map()); + + // A different module, an external edit or a parameter edit replaces the + // text; our own autosave echo must not clobber what is being typed. + useEffect(() => { + if (module.origin === "save" && dirty.current) return; + setDraft(module.code); + dirty.current = false; + }, [module.path, module.code, module.origin]); + + const edit = (value: string) => { + setDraft(value); + dirty.current = true; + if (saveTimer.current !== null) window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + saveTimer.current = null; + dirty.current = false; + socket?.send({ type: "module.save", path: module.path, code: value }); + }, AUTOSAVE_MS); + }; + + const forward = (id: string, value: ParamValue) => { + pending.current.set(id, value); + if (paramTimer.current !== null) return; + paramTimer.current = window.setTimeout(() => { + paramTimer.current = null; + socket?.send({ type: "module.param", path: module.path, values: Object.fromEntries(pending.current) }); + pending.current.clear(); + }, 120); + }; + + const groups = new Map(); + for (const param of module.params) { + const list = groups.get(param.group) ?? []; + list.push(param); + groups.set(param.group, list); + } + + return ( +
    +
    + + {module.path} + + {module.used_by.length > 0 ? ( + + used by {module.used_by.map((path) => path.replace(/\.py$/, "")).join(", ")} + + ) : ( + unused + )} + +
    + +
    + {[...groups].map(([group, params]) => ( +
    +
    + {group} + {params.length} fields +
    +
    + {params.map((param) => ( + + ))} +
    +
    + ))} +
    +
    + source + autosaved +
    + +
    +
    +
    +
    + ); +} diff --git a/editor-web/src/components/NotebookList.tsx b/editor-web/src/components/NotebookList.tsx new file mode 100644 index 00000000..05510151 --- /dev/null +++ b/editor-web/src/components/NotebookList.tsx @@ -0,0 +1,84 @@ +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import type { ModuleRow, NotebookRow } from "@/lib/api"; + +/** The workspace: its SimpleCAD notebooks (assemblies show what they use) and + * the local modules they import. Editing a module changes every notebook + * that imports it. */ +export function NotebookList({ + notebooks, + current, + modules, + openModule, + onOpen, + onOpenModule, +}: { + notebooks: NotebookRow[]; + /** Workspace-relative path of the open notebook. */ + current: string | null; + modules: ModuleRow[]; + openModule: string | null; + onOpen: (path: string) => void; + onOpenModule: (path: string) => void; +}) { + if (notebooks.length === 0 && modules.length === 0) { + return

    No SimpleCAD notebooks in this workspace.

    ; + } + return ( + +

    notebooks

    +
      + {notebooks.map((row) => ( +
    • + +
    • + ))} +
    + {modules.length > 0 && ( + <> +

    modules

    +
      + {modules.map((module) => ( +
    • + +
    • + ))} +
    + + )} +
    + ); +} diff --git a/editor-web/src/components/ParamControl.tsx b/editor-web/src/components/ParamControl.tsx new file mode 100644 index 00000000..822a08e7 --- /dev/null +++ b/editor-web/src/components/ParamControl.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; + +import { Slider } from "@/components/ui/slider"; +import { cn } from "@/lib/utils"; + +export type ParamValue = number | boolean; +/** A parameter the notebook declares (see `sca params`); `cell` is the id of + * the declaring cell. `modified`: the value differs from the source. */ +export type ParamMeta = { + id: string; + label: string; + cell: string; + group: string; + kind: "float" | "int" | "bool"; + control: "slider" | "number" | "toggle"; + min: number | null; + max: number | null; + step: number | null; + unit: string | null; + default: ParamValue; + value: ParamValue; + modified?: boolean; + comment?: string | null; + issues?: string[]; +}; + +/** One parameter: a slider, a number box or a toggle, by its `# ui:` comment. */ +export function ParamControl({ + param, + forwardParam, +}: { + param: ParamMeta; + forwardParam: (id: string, value: ParamValue) => void; +}) { + const label = param.label || param.id; + const title = [param.comment, ...(param.issues ?? [])].filter(Boolean).join("\n") || undefined; + const ink = param.modified ? "text-focus" : "text-name"; + if (param.control === "toggle") { + const on = Boolean(param.value); + return ( + + ); + } + const value = Number(param.value); + return ( +
    +
    + {label} + + {param.control === "number" ? ( + forwardParam(param.id, next)} + /> + ) : ( + {value} + )} + {param.unit && {param.unit}} + +
    + {param.control === "slider" && param.min !== null && param.max !== null && ( + forwardParam(param.id, next)} + /> + )} +
    + ); +} + +/** A number box that keeps what is being typed: the text is a local draft, + * committed when typing pauses, on Enter, or on blur. (A controlled box that + * sends every keystroke gets the server's echo of the first digit back while + * the second is being typed.) */ +function NumberField({ + value, + min, + max, + step, + onCommit, +}: { + value: number; + min: number | null; + max: number | null; + step: number | null; + onCommit: (value: number) => void; +}) { + const [text, setText] = useState(String(value)); + const editing = useRef(false); + const timer = useRef(null); + + useEffect(() => { + if (!editing.current) setText(String(value)); + }, [value]); + + const commit = (raw: string) => { + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = null; + const next = Number(raw); + if (raw.trim() !== "" && Number.isFinite(next) && next !== value) onCommit(next); + }; + + return ( + { + editing.current = true; + }} + onChange={(event) => { + setText(event.target.value); + if (timer.current !== null) window.clearTimeout(timer.current); + const raw = event.target.value; + timer.current = window.setTimeout(() => commit(raw), 700); + }} + onBlur={(event) => { + editing.current = false; + commit(event.target.value); + setText(String(value)); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + commit((event.target as HTMLInputElement).value); + (event.target as HTMLInputElement).blur(); + } + }} + /> + ); +} diff --git a/editor-web/src/components/ui/badge.tsx b/editor-web/src/components/ui/badge.tsx new file mode 100644 index 00000000..bda43152 --- /dev/null +++ b/editor-web/src/components/ui/badge.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center border px-1.5 py-px text-[10px] font-semibold uppercase leading-4 tracking-[0.08em]", + { + variants: { + variant: { + default: "border-white bg-white text-black", + secondary: "border-input text-muted-foreground", + outline: "border-input text-foreground", + focus: "border-focus text-focus", + pass: "border-pass text-pass", + fail: "border-fail text-fail", + name: "border-name text-name", + destructive: "border-fail bg-fail text-black", + }, + }, + defaultVariants: { variant: "default" }, + }, +); + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ; +} + +export { Badge, badgeVariants }; diff --git a/editor-web/src/components/ui/button.tsx b/editor-web/src/components/ui/button.tsx new file mode 100644 index 00000000..a6aa38f3 --- /dev/null +++ b/editor-web/src/components/ui/button.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap border-2 text-[11px] font-semibold uppercase tracking-[0.08em] transition-colors focus-visible:outline-none focus-visible:border-focus disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "border-white bg-white text-black hover:border-focus hover:bg-focus", + destructive: "border-fail bg-fail text-black hover:bg-transparent hover:text-fail", + outline: "border-input bg-transparent text-foreground hover:border-white", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:border-input", + ghost: "border-transparent bg-transparent text-muted-foreground hover:text-foreground", + link: "border-transparent text-name underline-offset-4 hover:underline", + }, + size: { + default: "h-8 px-3", + sm: "h-7 px-2.5", + lg: "h-9 px-4", + icon: "h-7 w-7", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "button"; + return ; +} + +export { Button, buttonVariants }; diff --git a/editor-web/src/components/ui/resizable.tsx b/editor-web/src/components/ui/resizable.tsx new file mode 100644 index 00000000..24f3c6c1 --- /dev/null +++ b/editor-web/src/components/ui/resizable.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { Group, Panel, Separator } from "react-resizable-panels"; + +import { cn } from "@/lib/utils"; + +/** Horizontal split container: … */ +function ResizablePanelGroup({ + className, + ...props +}: React.ComponentProps) { + return ; +} + +const ResizablePanel = Panel; + +function ResizableHandle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/editor-web/src/components/ui/scroll-area.tsx b/editor-web/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..cd78a91a --- /dev/null +++ b/editor-web/src/components/ui/scroll-area.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +/** Scrolls without a visible bar until the pointer is over it. */ +function ScrollArea({ className, children, ...props }: React.ComponentProps) { + return ( + + {/* Radix sets `display: table` inline on the viewport, which lets wide + children (code editors) stretch the panel. Force block + full width + so inner scrollers own the overflow instead. */} + {children} + + + + ); +} + +function ScrollBar({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/editor-web/src/components/ui/separator.tsx b/editor-web/src/components/ui/separator.tsx new file mode 100644 index 00000000..4cbc1776 --- /dev/null +++ b/editor-web/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/editor-web/src/components/ui/slider.tsx b/editor-web/src/components/ui/slider.tsx new file mode 100644 index 00000000..d1d3f2ad --- /dev/null +++ b/editor-web/src/components/ui/slider.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "@/lib/utils"; + +/** A flat rule with a square handle; the range turns orange once the value + * differs from the source (`modified`). */ +function Slider({ + className, + modified, + ...props +}: React.ComponentProps & { modified?: boolean }) { + return ( + + + + + + + ); +} + +export { Slider }; diff --git a/editor-web/src/components/ui/tabs.tsx b/editor-web/src/components/ui/tabs.tsx new file mode 100644 index 00000000..8ff85c50 --- /dev/null +++ b/editor-web/src/components/ui/tabs.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +function Tabs({ className, ...props }: React.ComponentProps) { + return ; +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ; +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/editor-web/src/index.css b/editor-web/src/index.css new file mode 100644 index 00000000..3f3251b0 --- /dev/null +++ b/editor-web/src/index.css @@ -0,0 +1,192 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* Black and white, square, hard 2px rules (after the SimpleCAD landing page). + * Color is reserved for meaning: focus (selection, what is being edited), + * pass, fail, name (the names of steps and parameters). */ +:root { + --radius: 0px; + --background: #000000; + --foreground: #ffffff; + --card: #050505; + --card-foreground: #ffffff; + --popover: #000000; + --popover-foreground: #ffffff; + --primary: #ffffff; + --primary-foreground: #000000; + --secondary: #111111; + --secondary-foreground: #ffffff; + --muted: #111111; + --muted-foreground: #a8a8a8; + --accent: #1c1c1c; + --accent-foreground: #ffffff; + --destructive: #ff4d5e; + --destructive-foreground: #000000; + --border: #262626; + --input: #3a3a3a; + --ring: #ff9a2e; + --focus: #ff9a2e; + --pass: #3ddc84; + --fail: #ff4d5e; + --name: #4aa3ff; + --rule: 2px; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-focus: var(--focus); + --color-pass: var(--pass); + --color-fail: var(--fail); + --color-name: var(--name); + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --font-mono: var(--mono); +} + +@layer base { + * { + @apply border-border outline-ring/50; + border-radius: 0; + /* Thin, square scrollbars everywhere; the thumb shows on hover. */ + scrollbar-width: thin; + scrollbar-color: #333 transparent; + } + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + *::-webkit-scrollbar-thumb { + background: #333; + } + *::-webkit-scrollbar-thumb:hover { + background: #777; + } + *::-webkit-scrollbar-track, + *::-webkit-scrollbar-corner { + background: transparent; + } + html, + body { + background: #000; + } + body { + @apply bg-background text-foreground; + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + } + ::selection { + background: #fff; + color: #000; + } + .font-mono, + code, + pre { + font-family: var(--mono); + } + button { + cursor: pointer; + } + /* Notebook cells: the editor fills its block and wraps long lines, so a + * cell never shows a horizontal scrollbar. */ + .cm-editor { + width: 100%; + background: transparent !important; + } + .cm-editor .cm-scroller { + overflow-x: hidden; + overflow-y: hidden; + scrollbar-width: none; + font-family: var(--mono); + } + .cm-editor .cm-scroller::-webkit-scrollbar { + display: none; + } + .cm-editor.cm-focused { + outline: none; + } +} + +/* Small caps label used for section titles and badges. */ +.label { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +/* Loading mask: the spinner's chase, the progress strip, the blinking cursor. */ +@keyframes sca-chase { + 0% { + opacity: 1; + } + 100% { + opacity: 0.12; + } +} +@keyframes sca-bar { + 0%, + 100% { + opacity: 0.12; + } + 50% { + opacity: 1; + } +} +@keyframes sca-blink { + 50% { + opacity: 0; + } +} +.sca-chase { + animation: sca-chase 0.8s steps(5, end) infinite; +} +.sca-bar { + animation: sca-bar 1.4s steps(4, end) infinite; +} +.sca-blink { + animation: sca-blink 1s steps(1, end) infinite; +} +@media (prefers-reduced-motion: reduce) { + .sca-chase, + .sca-bar, + .sca-blink { + animation: none; + } +} + +/* A cell the kernel is running or has queued: a thin bar marching under its header. */ +.cell-running { + background: repeating-linear-gradient(90deg, var(--focus, #f5a623) 0 12px, transparent 12px 24px); + background-size: 24px 100%; + animation: cell-march 0.6s linear infinite; +} +@keyframes cell-march { + to { + background-position: 24px 0; + } +} diff --git a/editor-web/src/lib/api.ts b/editor-web/src/lib/api.ts new file mode 100644 index 00000000..8509c6c0 --- /dev/null +++ b/editor-web/src/lib/api.ts @@ -0,0 +1,91 @@ +/** REST surface of the runtime server (`sca edit `). + * + * The WebSocket (`lib/envelope.ts`) carries live model events; these calls + * cover discovery, control, and the agent-facing selection contract. + */ + +export type NotebookRow = { + name: string; + path: string; + entry: boolean; + kind: "part" | "assembly" | "helper"; + current: boolean; + /** Definition id from the notebook header. */ + id?: string; + /** Notebooks this one runs with use(...), workspace-relative. */ + uses?: string[]; + error?: string | null; +}; + +/** A local module some notebook imports (shared dimensions, helpers). */ +export type ModuleRow = { name: string; path: string; used_by: string[] }; + +export type SelectedInfo = { + selected: Record; + notebook: string | null; +}; + +async function json(response: Response): Promise { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`${response.status} ${response.statusText}: ${body.slice(0, 200)}`); + } + return (await response.json()) as T; +} + +/** Notebooks in the workspace with entry/helper classification. */ +export function getWorkspace(): Promise<{ notebooks: NotebookRow[]; modules: ModuleRow[] }> { + return fetch("/api/workspace").then((response) => json<{ notebooks: NotebookRow[]; modules: ModuleRow[] }>(response)); +} + +/** Switch the active entry notebook (server restarts the kernel). */ +export function openNotebook(path: string): Promise<{ ok: boolean }> { + return fetch("/api/workspace/open", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }).then((response) => json<{ ok: boolean }>(response)); +} + +/** Provision a new entry notebook from the server template and open it. */ +export function newNotebook(name: string, kind: "part" | "assembly"): Promise<{ created: string }> { + return fetch("/api/workspace/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, kind }), + }).then((response) => json<{ created: string }>(response)); +} + +/** What the viewport shows (PNG data URL), for coding agents to look at. */ +export async function postViewport(dataUrl: string): Promise { + const blob = await (await fetch(dataUrl)).blob(); + await fetch("/api/studio/viewport", { method: "POST", headers: { "Content-Type": "image/png" }, body: blob }); +} + +/** The latest preview of the model (`simplecadapi.scene.preview`). */ +export async function fetchPreview(): Promise { + const response = await fetch("/api/preview/latest"); + if (!response.ok) throw new Error("no preview published yet"); + return response.arrayBuffer(); +} + +/** Latest in-memory scadpkg bytes (rule 4: never a disk read). */ +export async function fetchPackage(): Promise { + const response = await fetch("/api/package/latest"); + if (!response.ok) throw new Error("no package published yet"); + return new Uint8Array(await response.arrayBuffer()); +} + +/** Publish the viewport selection so agents can read it. */ +export function select(selection: Record): Promise<{ ok: boolean }> { + return fetch("/api/studio/select", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(selection), + }).then((response) => json<{ ok: boolean }>(response)); +} + +/** Agent-facing read: what the human has selected right now. */ +export function getSelected(): Promise { + return fetch("/api/studio/selected").then((response) => json(response)); +} diff --git a/editor-web/src/lib/cm-theme.ts b/editor-web/src/lib/cm-theme.ts new file mode 100644 index 00000000..43b229bb --- /dev/null +++ b/editor-web/src/lib/cm-theme.ts @@ -0,0 +1,33 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { tags } from "@lezer/highlight"; + +/** Black cell, white code; color only where it carries meaning: + * names (blue), literals (orange), strings (green), comments (dim). */ +const theme = EditorView.theme( + { + "&": { color: "#ffffff", backgroundColor: "transparent", fontSize: "12px" }, + ".cm-content": { caretColor: "#ff9a2e", padding: "6px 0" }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#ff9a2e", borderLeftWidth: "2px" }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "#ffffff33", + }, + ".cm-activeLine": { backgroundColor: "transparent" }, + ".cm-gutters": { display: "none" }, + ".cm-line": { padding: "0 10px" }, + }, + { dark: true }, +); + +const highlight = HighlightStyle.define([ + { tag: [tags.keyword, tags.controlKeyword, tags.definitionKeyword, tags.moduleKeyword], color: "#ffffff", fontWeight: "700" }, + { tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: "#4aa3ff" }, + { tag: [tags.definition(tags.variableName), tags.className], color: "#4aa3ff" }, + { tag: [tags.number, tags.bool, tags.null, tags.atom], color: "#ff9a2e" }, + { tag: [tags.string, tags.special(tags.string)], color: "#3ddc84" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "#777777", fontStyle: "italic" }, + { tag: [tags.operator, tags.punctuation, tags.bracket], color: "#a8a8a8" }, + { tag: tags.self, color: "#ffffff", fontStyle: "italic" }, +]); + +export const cadTheme = [theme, syntaxHighlighting(highlight)]; diff --git a/editor-web/src/lib/envelope.ts b/editor-web/src/lib/envelope.ts new file mode 100644 index 00000000..eb1423c2 --- /dev/null +++ b/editor-web/src/lib/envelope.ts @@ -0,0 +1,67 @@ +/** Live event socket: server-pushed model envelopes over /ws. + * + * Envelope types (workspace_server.replay order): params.schema, + * params.values, geometry, tree, notebook.cells, notebook.features, + * package, selection, status, hello. + */ + +export type Envelope = Record & { type: string }; +type Handler = (envelope: Envelope) => void; + +export class EnvelopeSocket { + private ws: WebSocket | null = null; + private handlers = new Map>(); + private backoffMs = 500; + private closed = false; + + constructor(private readonly url: string) {} + + on(type: string, handler: Handler): () => void { + let set = this.handlers.get(type); + if (!set) { + set = new Set(); + this.handlers.set(type, set); + } + set.add(handler); + return () => set!.delete(handler); + } + + connect(): void { + this.closed = false; + const ws = new WebSocket(this.url); + this.ws = ws; + ws.onopen = () => { + this.backoffMs = 500; + }; + ws.onmessage = (event) => { + let envelope: Envelope; + try { + envelope = JSON.parse(String(event.data)); + } catch { + return; + } + const set = this.handlers.get(envelope.type); + if (set) for (const handler of set) handler(envelope); + const anySet = this.handlers.get("*"); + if (anySet) for (const handler of anySet) handler(envelope); + }; + ws.onclose = () => { + if (this.closed) return; + // Kernel/server restarts must transparently re-attach the UI. + setTimeout(() => this.connect(), this.backoffMs); + this.backoffMs = Math.min(this.backoffMs * 2, 8000); + }; + } + + /** Send one ui command (param.set / export.request / code.edit / rerun.request). */ + send(message: Record): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + close(): void { + this.closed = true; + this.ws?.close(); + } +} diff --git a/editor-web/src/lib/preview.ts b/editor-web/src/lib/preview.ts new file mode 100644 index 00000000..1f12b374 --- /dev/null +++ b/editor-web/src/lib/preview.ts @@ -0,0 +1,102 @@ +/** The preview a kernel makes of its model (`simplecadapi.scene.preview`): + * the arrays a viewer draws, the index range of every face and edge, each part + * once and where each of its instances stands. + * + * Layout: `SCAPREV1`, a u32 header length, 4 bytes of padding, the JSON header + * (padded to 8 bytes), then the little-endian arrays the header points into. + */ + +export type PreviewGroup = { entity_id: string; first_index: number; index_count: number }; +export type PreviewTransform = { + origin: [number, number, number]; + x_axis: [number, number, number]; + y_axis: [number, number, number]; + z_axis: [number, number, number]; +}; + +export type PreviewPart = { + key: string; + definitionId: string; + /** glTF metres (+Y up), as in the package's GLB assets. */ + positions: Float32Array; + normals: Float32Array; + indices: Uint32Array; + edgePositions: Float32Array; + edgeIndices: Uint32Array; + vertexPositions: Float32Array; + vertexIds: string[]; + faceGroups: PreviewGroup[]; + edgeGroups: PreviewGroup[]; + solidEntityId: string; +}; + +export type PreviewNode = { + nodeId: string; + partKey: string; + definitionId: string; + displayName: string; + transform: PreviewTransform; +}; + +export type PreviewData = { parts: Map; nodes: PreviewNode[] }; + +type ArrayRef = { offset: number; count: number }; +type HeaderPart = { + key: string; + definition_id: string; + positions: ArrayRef; + normals: ArrayRef; + indices: ArrayRef; + edge_positions: ArrayRef; + edge_indices: ArrayRef; + vertex_positions: ArrayRef; + vertex_ids: string[]; + face_groups: PreviewGroup[]; + edge_groups: PreviewGroup[]; + solid_entity_id: string; +}; +type Header = { + schema: string; + parts: HeaderPart[]; + nodes: { node_id: string; part: string; definition_id: string; display_name: string; transform: PreviewTransform }[]; +}; + +const MAGIC = "SCAPREV1"; + +export function parsePreview(buffer: ArrayBuffer): PreviewData { + const bytes = new Uint8Array(buffer); + if (new TextDecoder().decode(bytes.subarray(0, MAGIC.length)) !== MAGIC) throw new Error("not a preview"); + const view = new DataView(buffer); + const headerLength = view.getUint32(MAGIC.length, true); + const headerStart = MAGIC.length + 8; + const header = JSON.parse(new TextDecoder().decode(bytes.subarray(headerStart, headerStart + headerLength))) as Header; + if (header.schema !== "sca-preview-1") throw new Error(`unsupported preview schema: ${header.schema}`); + const base = headerStart + headerLength; + const floats = (ref: ArrayRef) => new Float32Array(buffer, base + ref.offset, ref.count); + const uints = (ref: ArrayRef) => new Uint32Array(buffer, base + ref.offset, ref.count); + const parts = new Map(); + for (const part of header.parts) { + parts.set(part.key, { + key: part.key, + definitionId: part.definition_id, + positions: floats(part.positions), + normals: floats(part.normals), + indices: uints(part.indices), + edgePositions: floats(part.edge_positions), + edgeIndices: uints(part.edge_indices), + vertexPositions: floats(part.vertex_positions), + vertexIds: part.vertex_ids, + faceGroups: part.face_groups, + edgeGroups: part.edge_groups, + solidEntityId: part.solid_entity_id, + }); + } + const nodes: PreviewNode[] = header.nodes.map((node) => ({ + nodeId: node.node_id, + partKey: node.part, + definitionId: node.definition_id, + displayName: node.display_name, + transform: node.transform, + })); + return { parts, nodes }; +} diff --git a/editor-web/src/lib/runlog.ts b/editor-web/src/lib/runlog.ts new file mode 100644 index 00000000..8a895428 --- /dev/null +++ b/editor-web/src/lib/runlog.ts @@ -0,0 +1,163 @@ +import { useCallback, useRef, useState } from "react"; + +export type LogLevel = "info" | "run" | "ok" | "error"; + +export interface LogEntry { + id: number; + at: number; + level: LogLevel; + text: string; +} + +/** Cells being re-evaluated together: a parameter change, a run, a reload. */ +export interface RunBatch { + total: number; + done: number; + /** Title of the cell running now, if one is. */ + current: string | null; + startedAt: number; +} + +const MAX_ENTRIES = 400; +/** The gap between two rounds of one run is shorter than this; a longer quiet ends the batch. */ +const BATCH_QUIET_MS = 700; + +const ACTIVE = new Set(["queued", "running"]); +const FAILED = new Set(["exception", "error", "marimo-error"]); +/** Not run because a cell it depends on failed: counted, but the cause is what is logged. */ +const BLOCKED = "cancelled"; + +const seconds = (ms: number) => `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`; + +/** The editor's log (what happened, what failed) and the progress of the cells + * being re-evaluated, both fed by the kernel's cell states. Errors belong here, + * not in the header: they stay, with the time and the cell they came from. */ +export function useRunLog() { + const [entries, setEntries] = useState([]); + const [batch, setBatch] = useState(null); + const [unseenErrors, setUnseenErrors] = useState(0); + const counter = useRef(0); + // Per cell: when it began running, and the batch's bookkeeping. + const began = useRef(new Map()); + const members = useRef(new Map()); + const batchStart = useRef(0); + const quiet = useRef(null); + const titles = useRef(new Map()); + const failures = useRef(0); + const reported = useRef(new Set()); + + const push = useCallback((level: LogLevel, text: string) => { + counter.current += 1; + const entry: LogEntry = { id: counter.current, at: Date.now(), level, text }; + setEntries((previous) => [...previous.slice(-(MAX_ENTRIES - 1)), entry]); + if (level === "error") setUnseenErrors((count) => count + 1); + }, []); + + const clear = useCallback(() => { + setEntries([]); + setUnseenErrors(0); + }, []); + const markSeen = useCallback(() => setUnseenErrors(0), []); + + const refresh = useCallback((current: string | null) => { + const all = [...members.current.values()]; + setBatch({ + total: all.length, + done: all.filter((state) => state === "done").length, + current, + startedAt: batchStart.current, + }); + }, []); + + const finishBatch = useCallback(() => { + quiet.current = null; + const total = members.current.size; + if (total > 0) { + const text = `${total} ${total === 1 ? "cell" : "cells"} re-evaluated in ${seconds(Date.now() - batchStart.current)}`; + if (failures.current > 0) push("error", `${text}; ${failures.current} did not complete`); + else push("ok", text); + } + members.current.clear(); + failures.current = 0; + setBatch(null); + }, [push]); + + /** Keep the cells' titles to name them in the log, and log each error a cell has newly. */ + const knowCells = useCallback( + (cells: { id: string; title?: string; name?: string; errors?: string[] | null }[]) => { + titles.current = new Map(cells.map((cell, index) => [cell.id, cell.title || cell.name || `cell ${index + 1}`])); + const seen = new Set(); + for (const cell of cells) { + for (const message of cell.errors ?? []) { + const key = `${cell.id}:${message}`; + seen.add(key); + if (!reported.current.has(key)) push("error", `${titles.current.get(cell.id)}: ${message}`); + } + } + reported.current = seen; + }, + [push], + ); + + /** A cell's state changed in the kernel. */ + const cellStatus = useCallback( + (id: string, status: string, hint?: string | null) => { + const title = titles.current.get(id) ?? hint ?? id; + const now = Date.now(); + if (ACTIVE.has(status)) { + if (quiet.current !== null) { + window.clearTimeout(quiet.current); + quiet.current = null; + } + if (members.current.size === 0) { + batchStart.current = now; + push("run", "re-evaluating…"); + } + members.current.set(id, "pending"); + if (status === "running") began.current.set(id, now); + refresh(status === "running" ? title : null); + return; + } + const started = began.current.get(id); + began.current.delete(id); + if (!members.current.has(id)) { + return; + } + members.current.set(id, "done"); + if (status === BLOCKED) { + failures.current += 1; + } else if (FAILED.has(status)) { + failures.current += 1; + push("error", `${title} failed${started ? ` after ${seconds(now - started)}` : ""}`); + } else if (started !== undefined && now - started >= 300) { + push("info", `${title} ${seconds(now - started)}`); + } + const pending = [...members.current.values()].includes("pending"); + refresh(null); + if (!pending) { + quiet.current = window.setTimeout(finishBatch, BATCH_QUIET_MS); + } + }, + [finishBatch, push, refresh], + ); + + /** The model of the run arrived: the batch is over if every cell in it is. */ + const modelShown = useCallback(() => { + if (members.current.size > 0 && ![...members.current.values()].includes("pending")) { + if (quiet.current !== null) window.clearTimeout(quiet.current); + finishBatch(); + } + }, [finishBatch]); + + /** The run was stopped or the notebook changed: nothing is in flight any more. */ + const resetBatch = useCallback(() => { + if (quiet.current !== null) window.clearTimeout(quiet.current); + quiet.current = null; + members.current.clear(); + began.current.clear(); + failures.current = 0; + setBatch(null); + }, []); + + return { entries, batch, unseenErrors, push, clear, markSeen, knowCells, cellStatus, modelShown, resetBatch }; +} diff --git a/editor-web/src/lib/selection.ts b/editor-web/src/lib/selection.ts new file mode 100644 index 00000000..72cb7198 --- /dev/null +++ b/editor-web/src/lib/selection.ts @@ -0,0 +1,42 @@ +/** One picked entity, as the editor tracks it and sends it to the server. */ +export type SelectionItem = { + /** `nodeId::entityId`, unique per picked thing. */ + key: string; + /** `component` (an occurrence), or the entity's own kind. */ + kind: "component" | "solid" | "face" | "edge" | "vertex"; + nodeId: string; + entityId: string | null; + definitionId: string | null; + properties: Record | null; + geometry: Record | null; + tags: string[] | null; + /** The `selected_*` variable the notebook assigns it to (set by the server). */ + variable: string; + /** Its properties are being asked for (a preview carries none). */ + pending?: boolean; +}; + +/** The names the server gives the items, mirrored for the inspector: + * `selected_`, or `selected__` per kind when there are several. */ +export function variableNames(items: { kind: string }[]): string[] { + const totals = new Map(); + for (const item of items) totals.set(item.kind, (totals.get(item.kind) ?? 0) + 1); + const seen = new Map(); + return items.map((item) => { + const index = (seen.get(item.kind) ?? 0) + 1; + seen.set(item.kind, index); + return totals.get(item.kind) === 1 ? `selected_${item.kind}` : `selected_${item.kind}_${index}`; + }); +} + +/** What goes over the wire (`selection.set`). */ +export function selectionPayload(items: SelectionItem[]): Record[] { + return items.map((item) => ({ + kind: item.kind, + entity_id: item.entityId, + node_id: item.nodeId, + definition_id: item.definitionId, + properties: item.properties, + geometry: item.geometry, + })); +} diff --git a/editor-web/src/lib/utils.ts b/editor-web/src/lib/utils.ts new file mode 100644 index 00000000..16c20303 --- /dev/null +++ b/editor-web/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** shadcn/ui class combiner. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/editor-web/src/main.tsx b/editor-web/src/main.tsx new file mode 100644 index 00000000..2d80b4ec --- /dev/null +++ b/editor-web/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/editor-web/src/scene/Viewport.tsx b/editor-web/src/scene/Viewport.tsx new file mode 100644 index 00000000..2995d3aa --- /dev/null +++ b/editor-web/src/scene/Viewport.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; + +import { SceneView, type PickResult, type SelectionMode } from "@/scene/scene-view"; + +export type { PickResult, SelectionMode }; + +/** Selection highlight: the "focus" ink. */ +const SELECTION_COLOR = "#ff9a2e"; + +/** What the viewport highlights: the picked entities. */ +export type Highlight = { nodeId: string; entityId: string | null }; + +/** Mounts the three.js SceneView once and draws the selection highlight. + * + * The parent owns the SceneView instance (imperative scene loading does not + * map to React state) and the selection; this component guarantees + * mount/dispose and keeps the highlight overlays in step with `selection`. + * `sceneVersion` changes whenever a scene was (re)loaded, which discards the + * old overlays with the old geometry. + */ +export function Viewport({ + viewRef, + selectionMode, + selection, + sceneVersion, + onPick, + onClear, +}: { + viewRef: React.MutableRefObject; + selectionMode: SelectionMode; + selection: Highlight[]; + sceneVersion: number; + onPick: (pick: PickResult) => void; + onClear: () => void; +}) { + const hostRef = useRef(null); + const pickRef = useRef(onPick); + pickRef.current = onPick; + const clearRef = useRef(onClear); + clearRef.current = onClear; + const overlayRef = useRef([]); + + const dropOverlays = () => { + for (const object of overlayRef.current) { + object.parent?.remove(object); + object.traverse((child) => { + const mesh = child as THREE.Mesh; + mesh.geometry?.dispose?.(); + const material = mesh.material as THREE.Material | THREE.Material[] | undefined; + if (Array.isArray(material)) material.forEach((item) => item.dispose()); + else material?.dispose?.(); + }); + } + overlayRef.current = []; + }; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const view = new SceneView(host, { + onPick: (result) => pickRef.current(result), + onClear: () => clearRef.current(), + selectionMode: "solid", + background: "#000000", + }); + viewRef.current = view; + // Debug/agent affordance: reach the imperative scene from the console. + (window as unknown as { __studioView?: SceneView }).__studioView = view; + return () => { + dropOverlays(); + view.dispose(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + viewRef.current?.setSelectionMode(selectionMode); + }, [selectionMode, viewRef]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + dropOverlays(); + for (const item of selection) { + overlayRef.current.push(...view.buildEntityOverlays(item.nodeId, item.entityId, SELECTION_COLOR, 0.8)); + } + view.engine.invalidate(); + document.documentElement.dataset.overlayCount = String(overlayRef.current.length); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selection, sceneVersion]); + + return
    ; +} diff --git a/editor-web/src/scene/cad-three.ts b/editor-web/src/scene/cad-three.ts new file mode 100644 index 00000000..a84ef13b --- /dev/null +++ b/editor-web/src/scene/cad-three.ts @@ -0,0 +1,93 @@ +// Shared CAD<->glTF three.js helpers, extracted from main.ts so the package +// viewer and the reverse-engineering mode load scenes through one code path. + +import * as THREE from 'three'; +import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; +import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; +import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; +import type { SceneNode, Transform, Vec3 } from './scene2'; + +export const CAD_TO_GLTF = new THREE.Matrix4().set(0.001, 0, 0, 0, 0, 0, 0.001, 0, 0, -0.001, 0, 0, 0, 0, 0, 1); +export const GLTF_TO_CAD = new THREE.Matrix4().set(1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1); + +export function placementMatrix(transform: Transform): THREE.Matrix4 { + const { origin, x_axis, y_axis, z_axis } = transform; + const cad = new THREE.Matrix4().set( + x_axis[0], y_axis[0], z_axis[0], origin[0], + x_axis[1], y_axis[1], z_axis[1], origin[1], + x_axis[2], y_axis[2], z_axis[2], origin[2], + 0, 0, 0, 1, + ); + return CAD_TO_GLTF.clone().multiply(cad).multiply(GLTF_TO_CAD); +} + +export function cadPointToGltf(point: Vec3): THREE.Vector3 { + return new THREE.Vector3(point[0] / 1000, point[2] / 1000, -point[1] / 1000); +} + +export const CAD_EDGE_LINE_WIDTH = 2.4; +/** Explicit dark edge color — must read as a firm outline on the lit body + * while staying distinguishable from the pure-black viewport background. */ +export const CAD_EDGE_COLOR = '#3a414d'; +export const DEFAULT_BASE_COLOR: [number, number, number, 1] = [0.72, 0.75, 0.78, 1]; + +export type Resolution = { width: number; height: number }; + +export function materialFor(node: SceneNode): THREE.MeshStandardMaterial { + const color = node.material_id ? [0.68, 0.78, 0.88, 1] as const : DEFAULT_BASE_COLOR; + // Faces are pushed back in depth: edge lines lie exactly ON the surface, and + // without the offset the face triangles win the depth fight per-pixel — + // edges render as broken dashed fragments (classic CAD z-fighting). + return new THREE.MeshStandardMaterial({ color: new THREE.Color(color[0], color[1], color[2]), metalness: 0.08, roughness: 0.52, side: THREE.FrontSide, transparent: color[3] < 1, opacity: color[3], polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 }); +} + +export function edgeMaterial(resolution: Resolution): LineMaterial { + // Negative slope-scaled polygon offset: at silhouettes the line runs along + // a surface seen edge-on and parts of its quad sit genuinely behind the + // local surface depth — a constant bias loses there, a slope-scaled pull + // (mirror of the faces' +1 push) wins at every viewing angle. + const material = new LineMaterial({ color: CAD_EDGE_COLOR, linewidth: CAD_EDGE_LINE_WIDTH, worldUnits: false, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }); + material.resolution.set(Math.max(resolution.width, 1), Math.max(resolution.height, 1)); + return material; +} + +export function addWideEdgeVisual(source: THREE.LineSegments, resolution: Resolution): void { + const position = source.geometry.getAttribute('position'); + const index = source.geometry.index; + const indexCount = index?.count ?? position.count; + const positions: number[] = []; + for (let offset = 0; offset + 1 < indexCount; offset += 2) { + const a = index ? index.getX(offset) : offset; + const b = index ? index.getX(offset + 1) : offset + 1; + positions.push(position.getX(a), position.getY(a), position.getZ(a), position.getX(b), position.getY(b), position.getZ(b)); + } + const geometry = new LineSegmentsGeometry(); + geometry.setPositions(positions); + const visual = new LineSegments2(geometry, edgeMaterial(resolution)); + visual.name = 'cad-edge-visual'; + visual.userData.pickable = false; + source.add(visual); + const pickingMaterial = new THREE.LineBasicMaterial(); + pickingMaterial.visible = false; + source.material = pickingMaterial; +} + +export function disposeObject(root: THREE.Object3D): void { + const disposedGeometries = new Set(); + const disposedMaterials = new Set(); + root.traverse((object) => { + if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments || object instanceof THREE.Points) { + if (!disposedGeometries.has(object.geometry)) { + object.geometry.dispose(); + disposedGeometries.add(object.geometry); + } + const materials = Array.isArray(object.material) ? object.material : [object.material]; + materials.forEach((material) => { + if (!disposedMaterials.has(material)) { + material.dispose(); + disposedMaterials.add(material); + } + }); + } + }); +} diff --git a/editor-web/src/scene/components/click-selection.ts b/editor-web/src/scene/components/click-selection.ts new file mode 100644 index 00000000..187fd82a --- /dev/null +++ b/editor-web/src/scene/components/click-selection.ts @@ -0,0 +1,73 @@ +import * as THREE from 'three'; + +type ClickSelectionControls = { + target: THREE.Vector3; + update(): void; +}; + +type ClickSelectionOptions = { + element: HTMLElement; + camera: THREE.PerspectiveCamera; + controls: ClickSelectionControls; + movementThreshold?: number; + onPointerStateChange?: (active: boolean) => void; + onClick: (event: PointerEvent) => void; +}; + +export function bindClickSelection(options: ClickSelectionOptions): () => void { + const { element, camera, controls, onClick, movementThreshold = 4 } = options; + let pointerDown: { + x: number; + y: number; + button: number; + position: THREE.Vector3; + up: THREE.Vector3; + target: THREE.Vector3; + zoom: number; + } | null = null; + + const onPointerDown = (event: PointerEvent): void => { + pointerDown = { + x: event.clientX, + y: event.clientY, + button: event.button, + position: camera.position.clone(), + up: camera.up.clone(), + target: controls.target.clone(), + zoom: camera.zoom, + }; + options.onPointerStateChange?.(true); + }; + const onPointerUp = (event: PointerEvent): void => { + const down = pointerDown; + pointerDown = null; + options.onPointerStateChange?.(false); + if (!down || down.button !== 0 || event.button !== 0) return; + if (Math.hypot(event.clientX - down.x, event.clientY - down.y) > movementThreshold) return; + + // The controls may have applied pointer movement between down and up + // (turntable damping, trackball momentum). Drain it, then restore the + // click-time camera — position and orientation — before picking. + controls.update(); + camera.position.copy(down.position); + camera.up.copy(down.up); + camera.zoom = down.zoom; + camera.updateProjectionMatrix(); + controls.target.copy(down.target); + controls.update(); + onClick(event); + }; + const onPointerLeave = (): void => { + pointerDown = null; + options.onPointerStateChange?.(false); + }; + + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointerleave', onPointerLeave); + return () => { + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointerleave', onPointerLeave); + }; +} diff --git a/editor-web/src/scene/product-package.ts b/editor-web/src/scene/product-package.ts new file mode 100644 index 00000000..d5275c9f --- /dev/null +++ b/editor-web/src/scene/product-package.ts @@ -0,0 +1,474 @@ +import { strFromU8, unzipSync } from 'fflate'; +import type { SceneManifest } from './scene2'; + +export type PackageFiles = Record; + +export type OpenedCadPackage = { + files: PackageFiles; + packageContentHash: string; + schemaVersion: '3.0'; +}; + +type HashRecord = { + uri?: string; + path?: string; + sha256: string; + byte_length: number; +}; + +// A record whose payload is one exact ZIP member (definition, occurrence +// graph, member blob, scene projection manifest); v3 always addresses these +// by an explicit path. +type MemberRecord = { + path: string; + sha256: string; + byte_length: number; +}; + +// A scene asset record addressed by its scene URI. +type AssetRecord = HashRecord & { uri: string }; + +type DefinitionKind = 'single_solid' | 'assembly'; + +type DefinitionRecord = MemberRecord & { + definition_kind: DefinitionKind; + definition_id: string; + revision: string; + content_hash: string; +}; + +type PackageRoot = { + path: string; + definition_kind: DefinitionKind; + definition_id: string; + revision: string; + content_hash: string; +}; + +type MemberStorage = { kind: 'member'; path: string }; + +type ArchiveStorage = { + kind: 'archive'; + manifest_name: string; + members: { path: string; sha256: string }[]; +}; + +type BlobRecord = { + sha256: string; + byte_length: number; + media_type: string; + storage: MemberStorage | ArchiveStorage; +}; + +type SceneAssetSource = + | { kind: 'definition'; definition_id: string } + | { kind: 'blob'; sha256: string }; + +type SceneProjection = { + manifest: MemberRecord; + scene_id: string; + revision: string; + assets: { uri: string; source: SceneAssetSource }[]; +}; + +type ProductManifest = { + schema_version: string; + artifact_kind: string; + content_hash: string; + root: PackageRoot; + definitions: DefinitionRecord[]; + blobs: BlobRecord[]; + occurrence_graph: HashRecord; + projections: { scene?: SceneProjection }; +}; + + + +const MAX_PACKAGE_BYTES = 256 * 1024 * 1024; +const MAX_PACKAGE_MEMBERS = 10_000; +const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; +const MAX_MEMBER_BYTES = 256 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 32 * 1024 * 1024; +const MAX_COMPRESSION_RATIO = 100; + +const MANIFEST_KEYS = ['artifact_kind', 'blobs', 'content_hash', 'definitions', 'occurrence_graph', 'projections', 'root', 'schema_version']; + +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; + +function validateMemberName(name: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,4095}$/.test(name) + || name.split('/').some((segment) => !segment || segment === '.' || segment === '..')) { + throw new Error(`invalid package member: ${name}`); + } +} + +function extractArchive(raw: Uint8Array, manifestNames: readonly string[]): PackageFiles { + if (raw.byteLength > MAX_PACKAGE_BYTES) throw new Error('package exceeds browser size limit'); + let memberCount = 0; + let unpackedBytes = 0; + const seenNames = new Set(); + unzipSync(raw, { filter: (entry) => { + validateMemberName(entry.name); + const foldedName = entry.name.toLowerCase(); + if (seenNames.has(foldedName)) throw new Error(`duplicate or case-colliding package member: ${entry.name}`); + seenNames.add(foldedName); + memberCount += 1; + unpackedBytes += entry.originalSize; + if (entry.compression !== 0 && entry.compression !== 8) throw new Error(`unsupported ZIP compression method: ${entry.name}`); + if (memberCount > MAX_PACKAGE_MEMBERS) throw new Error('package member count exceeds browser limit'); + if (entry.originalSize > MAX_MEMBER_BYTES) throw new Error(`package member exceeds browser size limit: ${entry.name}`); + if (manifestNames.includes(entry.name) && entry.originalSize > MAX_MANIFEST_BYTES) throw new Error(`${entry.name} is too large`); + if (unpackedBytes > MAX_UNPACKED_BYTES) throw new Error('package expands beyond browser size limit'); + if (entry.originalSize > MAX_COMPRESSION_RATIO * Math.max(1, entry.size)) throw new Error(`package member compression ratio is too high: ${entry.name}`); + return false; + }}); + const presentManifests = manifestNames.filter((name) => seenNames.has(name.toLowerCase())); + if (presentManifests.length !== 1) throw new Error('package must contain exactly one supported manifest'); + if (unpackedBytes > MAX_COMPRESSION_RATIO * raw.byteLength) throw new Error('package compression ratio is too high'); + const files = unzipSync(raw) as PackageFiles; + const entries = Object.entries(files); + if (entries.length !== memberCount) throw new Error('package member count changed during extraction'); + if (entries.reduce((sum, [, value]) => sum + value.byteLength, 0) !== unpackedBytes) { + throw new Error('package decoded size differs from ZIP metadata'); + } + return files; +} + +function parseManifest(files: PackageFiles, name: string): T { + const payload = files[name]; + if (!payload || payload.byteLength > MAX_MANIFEST_BYTES) throw new Error(`${name} is missing or too large`); + let value: unknown; + try { + value = JSON.parse(strFromU8(payload)); + } catch { + throw new Error(`${name} is not valid JSON`); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} must contain an object`); + return value as T; +} + +function recordPath(record: HashRecord): string { + const path = record.uri ?? record.path; + if (typeof path !== 'string') throw new Error('package record path is missing'); + validateMemberName(path); + return path; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('manifest contains a non-finite number'); + if (Number.isInteger(value) && !Number.isSafeInteger(value)) throw new Error('manifest contains an unsafe integer'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value !== 'object') throw new Error('manifest contains a non-JSON value'); + const object = value as Record; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}`; +} + +async function sha256(payload: Uint8Array): Promise { + const view = payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer; + const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', view)); + return `sha256:${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}`; +} + +async function canonicalHash(value: unknown): Promise { + return sha256(new TextEncoder().encode(canonicalJson(value))); +} + +async function validateRecords(files: PackageFiles, records: HashRecord[], manifestName: string): Promise { + const referenced = new Set([manifestName]); + for (const record of records) { + const path = recordPath(record); + if (referenced.has(path)) throw new Error(`duplicate package reference: ${path}`); + referenced.add(path); + const payload = files[path]; + if (!payload) throw new Error(`package member is missing: ${path}`); + if (!Number.isSafeInteger(record.byte_length) || record.byte_length < 0 || payload.byteLength !== record.byte_length) { + throw new Error(`package member length differs: ${path}`); + } + if (!SHA256_PATTERN.test(record.sha256) || await sha256(payload) !== record.sha256) { + throw new Error(`package member hash differs: ${path}`); + } + } + const names = Object.keys(files); + if (names.length !== referenced.size || names.some((name) => !referenced.has(name))) { + throw new Error(`${manifestName} references do not match package members`); + } +} + +function requireObject(value: unknown, what: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${what} must contain an object`); + return value as Record; +} + +function requireRecordArray(value: unknown, what: string): Record[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'object' || item === null || Array.isArray(item))) { + throw new Error(`${what} must be an array of records`); + } + return value as Record[]; +} + +function requireString(holder: Record, field: string, what: string): string { + const value = holder[field]; + if (typeof value !== 'string') throw new Error(`${what} ${field} is missing`); + return value; +} + +function requireSha256(holder: Record, field: string, what: string): string { + const value = requireString(holder, field, what); + if (!SHA256_PATTERN.test(value)) throw new Error(`${what} ${field} is not a sha256 digest`); + return value; +} + +function requireByteLength(holder: Record, what: string): number { + const value = holder.byte_length; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error(`${what} byte_length is invalid`); + return value; +} + +function requireMemberPath(holder: Record, field: string, what: string): string { + const value = requireString(holder, field, what); + validateMemberName(value); + return value; +} + +function requireDefinitionKind(holder: Record, what: string): DefinitionKind { + const value = holder.definition_kind; + if (value !== 'single_solid' && value !== 'assembly') throw new Error(`${what} definition_kind is unsupported`); + return value; +} + +function requireAssetRecord(raw: Record, what: string): AssetRecord { + return { + uri: requireMemberPath(raw, 'uri', what), + sha256: requireSha256(raw, 'sha256', what), + byte_length: requireByteLength(raw, what), + }; +} + +// Strictly ascending adjacent pairs prove sortedness and uniqueness in one +// pass; member names and hex digests are pure ASCII, so JS string comparison +// equals the byte-order sort of the canonical writer. +function requireSortedUnique(values: string[], what: string): void { + if (!values.every((value, index) => index === 0 || values[index - 1] < value)) throw new Error(what); +} + +type ParsedPackage = { + definitionRecords: DefinitionRecord[]; + blobByHash: Map; + occurrenceGraph: MemberRecord; + sceneProjection: SceneProjection | null; + contentHash: string; +}; + +// Mirrors the structural invariants of product-package-3.schema.json and +// ProductPackage._validate_manifest without a JSON-Schema engine. +function parseProductManifest(manifest: Record): ParsedPackage { + const keys = Object.keys(manifest); + if (keys.length !== MANIFEST_KEYS.length || MANIFEST_KEYS.some((key) => !(key in manifest))) { + throw new Error(`product package manifest keys are not canonical: ${keys.sort().join(', ')}`); + } + + const definitionRecords = requireRecordArray(manifest.definitions, 'package definitions').map((raw) => ({ + path: requireMemberPath(raw, 'path', 'definition record'), + sha256: requireSha256(raw, 'sha256', 'definition record'), + byte_length: requireByteLength(raw, 'definition record'), + definition_kind: requireDefinitionKind(raw, 'definition record'), + definition_id: requireString(raw, 'definition_id', 'definition record'), + revision: requireString(raw, 'revision', 'definition record'), + content_hash: requireSha256(raw, 'content_hash', 'definition record'), + })) as DefinitionRecord[]; + if (definitionRecords.length === 0) throw new Error('package definitions are empty'); + requireSortedUnique(definitionRecords.map((record) => record.path), 'definition records must be unique and path-sorted'); + const definitionIds = definitionRecords.map((record) => record.definition_id); + if (new Set(definitionIds).size !== definitionIds.length) throw new Error('definition IDs must be unique'); + const definitionPathSet = new Set(definitionRecords.map((record) => record.path)); + + const rootRaw = requireObject(manifest.root, 'package root'); + if (!definitionPathSet.has(requireMemberPath(rootRaw, 'path', 'package root'))) throw new Error('package root does not resolve'); + + const blobRecords = requireRecordArray(manifest.blobs, 'package blobs').map((raw) => { + const sha256 = requireSha256(raw, 'sha256', 'blob record'); + const storageRaw = requireObject(raw.storage, `blob ${sha256} storage`); + let storage: MemberStorage | ArchiveStorage; + if (storageRaw.kind === 'member') { + storage = { kind: 'member', path: requireMemberPath(storageRaw, 'path', 'blob storage') }; + } else if (storageRaw.kind === 'archive') { + storage = { + kind: 'archive', + manifest_name: requireString(storageRaw, 'manifest_name', 'blob storage'), + members: requireRecordArray(storageRaw.members, 'archive blob members').map((member) => ({ + path: requireMemberPath(member, 'path', 'archive blob member'), + sha256: requireSha256(member, 'sha256', 'archive blob member'), + })), + }; + } else { + throw new Error(`blob ${sha256} storage kind is unsupported`); + } + return { sha256, byte_length: requireByteLength(raw, 'blob record'), media_type: requireString(raw, 'media_type', 'blob record'), storage }; + }) as BlobRecord[]; + requireSortedUnique(blobRecords.map((record) => record.sha256), 'blob records must be unique and hash-sorted'); + const blobByHash = new Map(blobRecords.map((record) => [record.sha256, record])); + for (const record of blobRecords) { + if (record.storage.kind === 'member') { + if (definitionPathSet.has(record.storage.path)) throw new Error('blob member path collides with definition'); + } else { + const memberPaths = record.storage.members.map((member) => member.path); + if (new Set(memberPaths).size !== memberPaths.length) throw new Error(`archive blob members are duplicated: ${record.sha256}`); + if (memberPaths.join('\u0000') !== [...memberPaths].sort().join('\u0000')) throw new Error('archive blob members must be path-sorted'); + for (const member of record.storage.members) { + if (!blobByHash.has(member.sha256)) throw new Error(`archive blob member is not declared: ${member.path}`); + } + } + } + + const occurrenceRaw = requireObject(manifest.occurrence_graph, 'occurrence graph record'); + const occurrenceGraph: MemberRecord = { + path: requireMemberPath(occurrenceRaw, 'path', 'occurrence graph record'), + sha256: requireSha256(occurrenceRaw, 'sha256', 'occurrence graph record'), + byte_length: requireByteLength(occurrenceRaw, 'occurrence graph record'), + }; + + const projectionsRaw = requireObject(manifest.projections, 'package projections'); + let sceneProjection: SceneProjection | null = null; + if (projectionsRaw.scene !== undefined) { + const raw = requireObject(projectionsRaw.scene, 'scene projection'); + const manifestRecord = requireObject(raw.manifest, 'scene projection manifest record'); + sceneProjection = { + manifest: { + path: requireMemberPath(manifestRecord, 'path', 'scene projection manifest'), + sha256: requireSha256(manifestRecord, 'sha256', 'scene projection manifest'), + byte_length: requireByteLength(manifestRecord, 'scene projection manifest'), + }, scene_id: requireString(raw, 'scene_id', 'scene projection'), + revision: requireString(raw, 'revision', 'scene projection'), + assets: requireRecordArray(raw.assets, 'scene projection assets').map((asset) => { + const uri = requireString(asset, 'uri', 'scene projection asset'); + const sourceRaw = requireObject(asset.source, `scene projection asset source: ${uri}`); + if (sourceRaw.kind === 'definition') { + return { uri, source: { kind: 'definition', definition_id: requireString(sourceRaw, 'definition_id', 'scene projection source') } }; + } + if (sourceRaw.kind === 'blob') { + return { uri, source: { kind: 'blob', sha256: requireSha256(sourceRaw, 'sha256', 'scene projection source') } }; + } + throw new Error(`scene projection asset source kind is unsupported: ${uri}`); + }), + }; + const definitionIdSet = new Set(definitionIds); + const seenUris = new Set(); + for (const asset of sceneProjection.assets) { + if (seenUris.has(asset.uri)) throw new Error(`scene projection asset URIs must be unique: ${asset.uri}`); + seenUris.add(asset.uri); + if (asset.source.kind === 'definition' && !definitionIdSet.has(asset.source.definition_id)) { + throw new Error(`scene projection definition does not resolve: ${asset.uri}`); + } + if (asset.source.kind === 'blob' && !blobByHash.has(asset.source.sha256)) { + throw new Error(`scene projection blob does not resolve: ${asset.uri}`); + } + } + } + + return { definitionRecords, blobByHash, occurrenceGraph, sceneProjection, contentHash: requireSha256(manifest, 'content_hash', 'product package') }; +} + +function parseSceneManifest(value: unknown, name: string): SceneManifest { + const manifest = requireObject(value, name); + if (requireString(manifest, 'schema_version', name) !== '2.0') throw new Error(`unsupported scene schema: ${String(manifest.schema_version)}`); + if (requireString(manifest, 'units', name) !== 'mm') throw new Error(`${name} units are unsupported`); + const revision = requireSha256(manifest, 'revision', name); + for (const field of ['roots', 'definitions', 'geometry_assets', 'entity_assets', 'product_assets', 'feature_graph_assets', 'source_assets', 'nodes', 'connectors', 'joints', 'feature_index', 'source_index', 'connector_index', 'joint_index']) { + if (!Array.isArray(manifest[field])) throw new Error(`${name} ${field} must be an array`); + } + requireObject(manifest.camera, `${name} camera`); + requireObject(manifest.extensions, `${name} extensions`); + const scene = manifest as unknown as SceneManifest; + for (const asset of [...scene.geometry_assets, ...scene.entity_assets, ...scene.source_assets]) { + if (typeof asset.uri !== 'string' || !SHA256_PATTERN.test(asset.sha256) || !Number.isSafeInteger(asset.byte_length)) { + throw new Error(`${name} contains an invalid asset record`); + } + } + if (!SHA256_PATTERN.test(revision)) throw new Error(`${name} revision is invalid`); + return scene; +} + +export async function openCadPackage(raw: Uint8Array): Promise { + const files = extractArchive(raw, ['package.json']); + const manifest = parseManifest(files, 'package.json'); + if (manifest.schema_version !== '3.0' || manifest.artifact_kind !== 'product_package') throw new Error('unsupported product package schema'); + const parsed = parseProductManifest(manifest as unknown as Record); + const draft = { ...(manifest as unknown as Record) }; + delete draft.content_hash; + if (parsed.contentHash !== await canonicalHash(draft)) throw new Error('product package content_hash is invalid'); + + // Member closure: every record above maps 1:1 onto a ZIP member and the + // archive may hold nothing else. Archive-kind blobs contribute no direct + // member; their inner payloads appear separately as member-kind blobs. + const memberBlobs = [...parsed.blobByHash.values()] + .filter((record) => record.storage.kind === 'member') + .map((record) => ({ path: (record.storage as MemberStorage).path, sha256: record.sha256, byte_length: record.byte_length })); + await validateRecords(files, [ + ...parsed.definitionRecords, + parsed.occurrenceGraph, + ...memberBlobs, + ...(parsed.sceneProjection ? [parsed.sceneProjection.manifest] : []), + ], 'package.json'); + + // The occurrence graph and the product/feature-graph ZIP payloads are never + // consumed by the viewer, so they are integrity-checked as members above but + // neither re-derived from definitions nor re-packed into canonical archives. + if (!parsed.sceneProjection) throw new Error('product package does not embed a scene projection'); + const projection = parsed.sceneProjection; + const sceneValue = parseManifest(files, projection.manifest.path); + const scene = parseSceneManifest(sceneValue, 'scene manifest'); + const sceneDraft = { ...(sceneValue as Record) }; + delete sceneDraft.revision; + if (scene.revision !== await canonicalHash(sceneDraft)) throw new Error('scene revision is invalid'); + if (scene.scene_id !== projection.scene_id || scene.revision !== projection.revision) { + throw new Error('embedded scene identity differs from package projection'); + } + + const sourceByUri = new Map(projection.assets.map((asset) => [asset.uri, asset.source])); + const outFiles: PackageFiles = { 'scene.json': files[projection.manifest.path] }; + const materialize = (records: HashRecord[], collection: string): void => { + for (const record of records) { + const uri = recordPath(record); + const source = sourceByUri.get(uri); + if (!source) throw new Error(`scene ${collection} asset is not covered by the package projection: ${uri}`); + if (source.kind !== 'blob') throw new Error(`scene ${collection} asset must resolve to a package blob: ${uri}`); + const blob = parsed.blobByHash.get(source.sha256); + if (!blob || blob.storage.kind !== 'member') throw new Error(`scene ${collection} asset resolves to an archive blob: ${uri}`); + if (record.sha256 !== blob.sha256 || record.byte_length !== blob.byte_length) { + throw new Error(`scene ${collection} asset identity differs from package blob: ${uri}`); + } + outFiles[uri] = files[blob.storage.path]; + } + }; + materialize(scene.geometry_assets, 'geometry'); + materialize(scene.entity_assets, 'entity'); + materialize(scene.source_assets, 'source'); + + for (const record of scene.product_assets) { + const uri = recordPath(record); + const source = sourceByUri.get(uri); + if (!source) throw new Error(`scene product asset is not covered by the package projection: ${uri}`); + if (source.kind !== 'definition') throw new Error(`scene product asset must resolve to a package definition: ${uri}`); + } + for (const record of scene.feature_graph_assets) { + const uri = recordPath(record); + const source = sourceByUri.get(uri); + if (!source) throw new Error(`scene feature graph asset is not covered by the package projection: ${uri}`); + if (source.kind !== 'blob') throw new Error(`scene feature graph asset must resolve to a package blob: ${uri}`); + } + + const identity = (record: { definition_kind: string; definition_id: string; revision: string; content_hash: string }): string => + [record.definition_kind, record.definition_id, record.revision, record.content_hash].join('\u0000'); + const packageIdentities = parsed.definitionRecords.map(identity).sort(); + const sceneIdentities = scene.product_assets.map(identity).sort(); + if (packageIdentities.length !== sceneIdentities.length + || packageIdentities.some((item, index) => item !== sceneIdentities[index])) { + throw new Error('product package definitions differ from embedded scene product assets'); + } + + return { files: outFiles, packageContentHash: parsed.contentHash, schemaVersion: '3.0' }; +} diff --git a/editor-web/src/scene/renderer/brep-renderer.ts b/editor-web/src/scene/renderer/brep-renderer.ts new file mode 100644 index 00000000..32aa8401 --- /dev/null +++ b/editor-web/src/scene/renderer/brep-renderer.ts @@ -0,0 +1,1003 @@ +// Standalone BRep scene renderer component. +// +// Owns everything visual about rendering a scene-2.0 package (GLB faces + +// line edges + entity sidecars): materials, lighting (studio environment + +// key light), postprocessing (GTAO ambient occlusion), camera (free +// trackball), edge display (seam/degenerate edges split into their own +// toggleable visuals), fit/snapshot and lifecycle. It has no notion of +// selection or annotation — embed it directly, or compose it under a +// higher-level view (see src/scene-view.ts for the picking/marks layer). +// +// import { BRepRenderer } from './renderer'; +// const viewer = new BRepRenderer(hostElement); +// await viewer.loadScene(files); // scene-2.0 files (see product-package) +// viewer.setSeamsVisible(false); // default +// viewer.setSSAO(true); // default + +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { TrackballControls } from 'three/addons/controls/TrackballControls.js'; +import { GTAOPass } from 'three/addons/postprocessing/GTAOPass.js'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; +import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; +import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; +import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; +import { strFromU8 } from 'fflate'; +import { DEFAULT_BASE_COLOR, CAD_EDGE_COLOR, CAD_EDGE_LINE_WIDTH, cadPointToGltf, disposeObject, materialFor, placementMatrix } from '../cad-three'; +import type { PackageFiles } from '../product-package'; +import type { PreviewData, PreviewPart } from '../../lib/preview'; +import type { Entity, EntitySidecar, FaceGroup, SceneManifest, SceneNode, Vec3 } from '../scene2'; + +/** Render layers: faces (0, default) go through lighting + AO; edges (1) and + * annotation overlays (2) composite AFTER the AO pass so their color is + * constant — no GI darkening — while still depth-testing against faces. */ +export const FACE_LAYER = 0; +export const EDGE_LAYER = 1; +export const OVERLAY_LAYER = 2; + +export type CameraState = { + position: Vec3; + target: Vec3; + up: Vec3; + fov_deg: number; + width: number; + height: number; +}; + +export type BRepRendererOptions = { + background?: string; + /** GTAO ambient occlusion pass (default on). */ + ssao?: boolean; + /** Show seam/degenerate edges (default off — pro viewers hide them). */ + seams?: boolean; +}; + +type Resolution = { width: number; height: number }; + +/** Direct geometry channel payload (no .scadpkg container): the authoring + * runtime tessellates the live solid and ships the render mesh as-is. */ +export type LiveGeometryPayload = { + schema: 'sca-live-geometry-1'; + step: string; + /** Triangle vertices in CAD space (mm, +Z up). */ + positions: number[][]; + normals: number[][]; + indices: number[]; + /** Flat segment endpoints in CAD space (x1,y1,z1,x2,y2,z2, ...). */ + edge_positions: number[]; + face_groups: { entity_id: string; first: number; count: number }[]; + stats: { volume: number; faces: number }; +}; + +function isHiddenEdge(entity: Entity | undefined): boolean { + const props = entity?.properties as { seam?: unknown; degenerate?: unknown } | undefined; + return props?.seam === true || props?.degenerate === true; +} + +/** + * Edges and faces are tessellated independently, so where a chord of the + * mesh bulges past the edge's own polyline the edge sinks behind the surface + * and breaks into dashes. Slope-scaled polygon offset cannot cover that (the + * gap is a distance, not a slope), so the line shader pulls its fragments + * towards the camera by EDGE_DEPTH_BIAS (scene units, metres): a little more + * than the mesh's chord deviation. Only depth moves; the line stays where it + * is on screen, and anything further behind than the bias still hides it. + */ +const EDGE_DEPTH_BIAS = 0.0004; + +function createEdgeMaterial(): LineMaterial { + const material = new LineMaterial({ color: CAD_EDGE_COLOR, linewidth: CAD_EDGE_LINE_WIDTH, worldUnits: false, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }); + material.onBeforeCompile = (shader) => { + shader.uniforms.edgeDepthBias = { value: EDGE_DEPTH_BIAS }; + shader.vertexShader = shader.vertexShader + .replace('void main() {', 'uniform float edgeDepthBias;\nvoid main() {') + .replace('gl_Position = clip;', 'float biasedView = -clip.w + edgeDepthBias;\n\t\t\tclip.z = (projectionMatrix[2][2] * biasedView + projectionMatrix[3][2]) / -biasedView * clip.w;\n\t\t\tgl_Position = clip;'); + }; + material.customProgramCacheKey = () => 'edge-depth-bias'; + return material; +} + +export class BRepRenderer { + readonly host: HTMLElement; + readonly threeScene: THREE.Scene; + readonly camera: THREE.PerspectiveCamera; + readonly renderer: THREE.WebGLRenderer; + readonly controls: TrackballControls; + readonly modelRoot = new THREE.Group(); + + manifest: SceneManifest | null = null; + files: PackageFiles | null = null; + nodeVisibility = new Map(); + + private loader = new GLTFLoader(); + private geometryCache = new Map(); + private edgeCache = new Map(); + private entityCache = new Map(); + private nodeObjects = new Map(); + private faceRT: THREE.WebGLRenderTarget | null = null; + private beautyRT: THREE.WebGLRenderTarget | null = null; + private ssaoPass: GTAOPass | null = null; + private ssaoEnabled: boolean; + private rendererClearColor: THREE.Color; + private blitScene: THREE.Scene | null = null; + private blitCamera: THREE.OrthographicCamera | null = null; + private copyMaterial: THREE.MeshBasicMaterial | null = null; + private presentMaterial: THREE.MeshBasicMaterial | null = null; + private edgesVisible = true; + private seamsVisible: boolean; + private observer: ResizeObserver; + private pendingFrame = true; + private disposed = false; + private grid: THREE.LineSegments | null = null; + /** Entity sidecars of the preview's parts, by node id (a preview has no package). */ + private previewSidecars = new Map(); + private previewGeometries: THREE.BufferGeometry[] = []; + private readonly gizmoScene = new THREE.Scene(); + private readonly gizmoCamera = new THREE.OrthographicCamera(-1.5, 1.5, 1.5, -1.5, 0.1, 20); + + constructor(host: HTMLElement, options: BRepRendererOptions = {}) { + this.host = host; + this.ssaoEnabled = options.ssao ?? true; + this.seamsVisible = options.seams ?? false; + this.threeScene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1000); + this.camera.up.set(0, 0, 1); + this.camera.position.set(2.4, 2.1, 3.0); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + // Background comes from the renderer clear color, NOT scene.background: + // a Color scene.background force-clears the framebuffer on EVERY + // renderer.render() (even autoClear=false), which would wipe the + // face/color buffer when the edge pass draws on top of it. The clear + // color is consumed raw as linear working-space values, so convert the + // sRGB design color first — otherwise the presented background is ~4x + // brighter than intended. + this.rendererClearColor = new THREE.Color(options.background ?? '#0b0e12').convertSRGBToLinear(); + this.renderer.setClearColor(this.rendererClearColor, 1); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.1; + host.append(this.renderer.domElement); + // resize() calls setSize(w, h, updateStyle=false), so the backing store is + // host-size × pixelRatio while the CSS display size stays unset — a host + // that does not style the canvas itself then renders the HiDPI buffer at + // attribute size and crops it. Pin the display size to the host. + this.renderer.domElement.style.width = '100%'; + this.renderer.domElement.style.height = '100%'; + this.renderer.domElement.style.display = 'block'; + // Trackball rotation: no fixed orbit axis, so the model can be rolled + // past any pole — a turntable (OrbitControls) caps vertical sweep below + // 180° no matter how the polar angle is clamped. + this.controls = new TrackballControls(this.camera, this.renderer.domElement); + this.controls.rotateSpeed = 1.0; + this.controls.zoomSpeed = 1.0; + this.controls.dynamicDampingFactor = 0.35; + this.controls.minDistance = 0.01; + this.controls.maxDistance = 1000; + // Host pages may own the keyboard (text inputs); TrackballControls' + // window-level A/S/D drag modifiers must not react to typing. The tuple + // type is fixed, so park unreachable key codes instead of leaving A/S/D live. + this.controls.keys = ['__unused_rotate', '__unused_zoom', '__unused_pan']; + // Shift+left-drag pans. TrackballControls has no modifier support, but its + // `keyState` field (the keyboard A/S/D path) deliberately outranks the + // mouse-button gesture, so borrowing it reuses the controls' own pan + // seeding, damping and release. onPointerUp only resets `state`, so the + // keyState clear is on us. Capture on the host precedes the controls' + // listeners on the canvas. + host.addEventListener( + 'pointerdown', + (event) => { + if (!event.shiftKey || event.button !== 0 || this.controls.enabled === false) return; + // `keyState` drives the controls' real keyboard-pan path but is not + // in the shipped typings; the runtime field is the documented state + // machine (PAN=2, NONE=-1). + const controls = this.controls as TrackballControls & { keyState: number }; + controls.keyState = 2; // TrackballControls._STATE.PAN + const release = (): void => { + controls.keyState = -1; // TrackballControls._STATE.NONE + window.removeEventListener('pointerup', release); + window.removeEventListener('pointercancel', release); + }; + window.addEventListener('pointerup', release); + window.addEventListener('pointercancel', release); + }, + true, + ); + this.modelRoot.name = 'scene-root'; + // Package geometry is glTF (Y up, CAD z -> y, CAD y -> -z). Rotate it back + // so the world is the CAD frame: x, y, z in meters and +Z up, which is + // what the camera's up vector, the ground grid and the axis gizmo assume. + this.modelRoot.rotation.x = Math.PI / 2; + this.threeScene.add(this.modelRoot); + this.buildGizmo(); + this.setupLighting(); + this.setupPostprocessing(); + this.observer = new ResizeObserver(() => this.resize()); + this.observer.observe(host); + this.resize(); + this.renderer.setAnimationLoop(() => this.render()); + } + + private loopRunning = true; + + /** Demand-render flag: the loop always ticks (damping needs it) but the + * expensive SSAO pipeline only runs when something actually changed. */ + private needsRender = true; + + /** Schedule one repaint (scene loads, picks, selection, external edits). */ + invalidate(): void { + this.needsRender = true; + } + + /** Pause/resume the render loop. Inactive panels must not burn GPU — + * several editor modes stay mounted, each with its own WebGL context. */ + setRunning(running: boolean): void { + if (running === this.loopRunning) return; + this.loopRunning = running; + if (running) { + this.renderer.setAnimationLoop(() => this.render()); + } else { + this.renderer.setAnimationLoop(null); + } + } + + isRunning(): boolean { + return this.loopRunning; + } + + private setupLighting(): void { + // Image-based studio lighting instead of hand-placed fill lights; one + // directional key stays for crisp specular definition on flats. + const pmrem = new THREE.PMREMGenerator(this.renderer); + this.threeScene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; + this.threeScene.environmentIntensity = 0.7; + pmrem.dispose(); + const keyLight = new THREE.DirectionalLight('#fff8e9', 1.4); + keyLight.position.set(4, 7, 5); + this.threeScene.add(keyLight, keyLight.target); + } + + private setupPostprocessing(): void { + // Manual pipeline instead of EffectComposer, so edges can composite AFTER + // the AO blend: + // 1. faces (layer 0) render into faceRT — this carries the face depth + // 2. GTAO blends beauty*ao into beautyRT; a copy brings it back onto + // faceRT, keeping faceRT's depth attachment intact + // 3. edges (layer 1) + overlays (layer 2) render into faceRT with plain + // depth testing — after AO, hence constant color, correctly occluded + // 4. faceRT presents to screen through tone mapping + const size = this.resolution(); + const pixelRatio = this.renderer.getPixelRatio(); + const width = Math.max(size.width * pixelRatio, 1); + const height = Math.max(size.height * pixelRatio, 1); + const options: THREE.RenderTargetOptions = { type: THREE.HalfFloatType, samples: 4 }; + this.faceRT = new THREE.WebGLRenderTarget(width, height, options); + this.beautyRT = new THREE.WebGLRenderTarget(width, height, options); + if (this.ssaoEnabled) { + const ssaoPass = new GTAOPass(this.threeScene, this.camera, width, height); + ssaoPass.output = GTAOPass.OUTPUT.Default; + ssaoPass.blendIntensity = 0.65; + this.tuneSSAO(ssaoPass, 0.01); + this.ssaoPass = ssaoPass; + } + this.blitScene = new THREE.Scene(); + this.blitCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + this.copyMaterial = new THREE.MeshBasicMaterial({ map: this.beautyRT.texture, toneMapped: false, depthTest: false, depthWrite: false }); + this.presentMaterial = new THREE.MeshBasicMaterial({ map: this.faceRT.texture, toneMapped: true, depthTest: false, depthWrite: false }); + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.copyMaterial); + quad.frustumCulled = false; + this.blitScene.add(quad); + } + + /** Draws one material over the whole current render target. */ + private blit(material: THREE.Material): void { + const quad = this.blitScene!.children[0] as THREE.Mesh; + quad.material = material; + this.renderer.render(this.blitScene!, this.blitCamera!); + } + + /** GTAO works in world units — retune the radius to the model's scale. */ + private tuneSSAO(pass: GTAOPass, modelRadius: number): void { + pass.updateGtaoMaterial({ + radius: Math.max(modelRadius * 0.14, 0.002), + distanceExponent: 1.0, + thickness: 1.0, + scale: 1.0, + samples: 16, + distanceFallOff: 1.0, + screenSpaceRadius: false, + }); + } + + private lastCameraPos = new THREE.Vector3(); + private lastCameraQuat = new THREE.Quaternion(); + + /** True when the camera pose changed since the previous call. */ + private cameraMoved(): boolean { + const changed = + !this.camera.position.equals(this.lastCameraPos) || + !this.camera.quaternion.equals(this.lastCameraQuat); + this.lastCameraPos.copy(this.camera.position); + this.lastCameraQuat.copy(this.camera.quaternion); + return changed; + } + + private render(): void { + if (!this.faceRT || !this.blitScene) return; + this.controls.update(); + // Camera pose change (damping, user input) or an explicit invalidation + // triggers the expensive SSAO chain; an idle scene skips it entirely. + if (!this.cameraMoved() && !this.needsRender) return; + this.needsRender = false; + const previousTarget = this.renderer.getRenderTarget(); + const previousAutoClear = this.renderer.autoClear; + + // 1. faces + background (layer 0) — carries the face depth buffer + this.camera.layers.set(FACE_LAYER); + this.renderer.setRenderTarget(this.faceRT); + this.renderer.clear(); + this.renderer.render(this.threeScene, this.camera); + + // 2. ambient occlusion on faces only + if (this.ssaoPass && this.ssaoEnabled && this.beautyRT) { + this.ssaoPass.render(this.renderer, this.beautyRT, this.faceRT, 0, false); + // 2b. bring the AO'ed color back onto faceRT; its depth survives because + // the copy quad does not write depth + this.renderer.setRenderTarget(this.faceRT); + this.renderer.autoClear = false; + this.copyMaterial!.map = this.beautyRT.texture; + this.blit(this.copyMaterial!); + } + + // 3. edges + annotation overlays, composited after AO with plain depth + // testing against the face depth — constant color, correctly occluded + this.camera.layers.set(EDGE_LAYER); + this.camera.layers.enable(OVERLAY_LAYER); + this.renderer.setRenderTarget(this.faceRT); + this.renderer.render(this.threeScene, this.camera); + this.camera.layers.set(FACE_LAYER); + + // 4. present through tone mapping + this.renderer.setRenderTarget(previousTarget); + this.renderer.autoClear = previousAutoClear; + this.blit(this.presentMaterial!); + this.renderGizmo(); + } + + // -- ground grid + axis gizmo --------------------------------------------------- + + /** A gray grid on the XY plane (z = 0), sized to the model. */ + private updateGrid(): void { + if (this.grid) { + this.threeScene.remove(this.grid); + this.grid.geometry.dispose(); + (this.grid.material as THREE.Material).dispose(); + this.grid = null; + } + this.modelRoot.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(this.modelRoot); + if (box.isEmpty()) return; + const reach = Math.max(Math.abs(box.min.x), Math.abs(box.max.x), Math.abs(box.min.y), Math.abs(box.max.y), 0.02) * 1.5; + // 1-2-5 step, about eight cells from the origin to the edge + const raw = reach / 8; + const decade = Math.pow(10, Math.floor(Math.log10(raw))); + const step = decade * ([1, 2, 5, 10].find((factor) => factor * decade >= raw) ?? 10); + const count = Math.ceil(reach / step); + const extent = count * step; + const positions: number[] = []; + const colors: number[] = []; + const minor = new THREE.Color('#262626'); + const major = new THREE.Color('#444444'); + for (let index = -count; index <= count; index += 1) { + const color = index % 5 === 0 ? major : minor; + const at = index * step; + positions.push(at, -extent, 0, at, extent, 0, -extent, at, 0, extent, at, 0); + for (let vertex = 0; vertex < 4; vertex += 1) colors.push(color.r, color.g, color.b); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); + const grid = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ vertexColors: true, depthWrite: false, toneMapped: false }), + ); + grid.name = 'ground-grid'; + grid.userData.pickable = false; + grid.layers.set(EDGE_LAYER); // composited after AO, depth-tested against the model + grid.renderOrder = -1; + this.threeScene.add(grid); + this.grid = grid; + this.needsRender = true; + } + + /** The three axes as blocks and letters; drawn in a corner of the canvas. */ + private buildGizmo(): void { + const axes: { dir: THREE.Vector3; color: string; label: string }[] = [ + { dir: new THREE.Vector3(1, 0, 0), color: '#ff4d5e', label: 'X' }, + { dir: new THREE.Vector3(0, 1, 0), color: '#3ddc84', label: 'Y' }, + { dir: new THREE.Vector3(0, 0, 1), color: '#4aa3ff', label: 'Z' }, + ]; + const basic = (color: string) => new THREE.MeshBasicMaterial({ color, toneMapped: false }); + for (const { dir, color, label } of axes) { + const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 1), basic(color)); + shaft.position.copy(dir).multiplyScalar(0.5); + shaft.lookAt(dir); + const tip = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.2), basic(color)); + tip.position.copy(dir); + const tail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, 0.12), basic('#555555')); + tail.position.copy(dir).multiplyScalar(-1); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 64; + const context = canvas.getContext('2d')!; + context.font = '700 44px ui-monospace, SFMono-Regular, Menlo, monospace'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillStyle = color; + context.fillText(label, 32, 34); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ map: texture, depthTest: false, toneMapped: false, transparent: true }), + ); + sprite.scale.set(0.42, 0.42, 1); + sprite.position.copy(dir).multiplyScalar(1.38); + this.gizmoScene.add(shaft, tip, tail, sprite); + } + } + + private renderGizmo(): void { + const size = this.renderer.getSize(new THREE.Vector2()); + const side = 108; + const margin = 12; + const offset = this.camera.position.clone().sub(this.controls.target).normalize().multiplyScalar(6); + this.gizmoCamera.position.copy(offset); + this.gizmoCamera.up.copy(this.camera.up); + this.gizmoCamera.lookAt(0, 0, 0); + const previousAutoClear = this.renderer.autoClear; + this.renderer.autoClear = false; + this.renderer.setRenderTarget(null); + this.renderer.setScissorTest(true); + this.renderer.setViewport(size.x - side - margin, margin, side, side); + this.renderer.setScissor(size.x - side - margin, margin, side, side); + this.renderer.clearDepth(); + this.renderer.render(this.gizmoScene, this.gizmoCamera); + this.renderer.setScissorTest(false); + this.renderer.setViewport(0, 0, size.x, size.y); + this.renderer.autoClear = previousAutoClear; + } + + // -- scene loading ------------------------------------------------------- + + private bytesFor(uri: string): Uint8Array { + const value = this.files?.[uri]; + if (!value) throw new Error(`scene file is missing: ${uri}`); + return value; + } + + private async loadGlb(uri: string): Promise { + const bytes = this.bytesFor(uri); + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const gltf = await this.loader.parseAsync(buffer, ''); + return gltf.scene; + } + + private resolution(): Resolution { + return { width: this.renderer.domElement.clientWidth, height: this.renderer.domElement.clientHeight }; + } + + sidecarFor(node: SceneNode | undefined): EntitySidecar | null { + const previewed = node ? this.previewSidecars.get(node.node_id) : undefined; + if (previewed) return previewed; + if (!this.manifest || !this.files || !node?.entity_asset_id) return null; + const asset = this.manifest.entity_assets.find((item) => item.asset_id === node.entity_asset_id); + if (!asset) return null; + const cached = this.entityCache.get(asset.asset_id); + if (cached) return cached; + const parsed = JSON.parse(strFromU8(this.bytesFor(asset.uri))) as EntitySidecar; + if ((parsed.schema_version !== '1.0' && parsed.schema_version !== '2.0') || parsed.definition_id !== node.definition_id) throw new Error(`entity sidecar identity differs: ${asset.uri}`); + this.entityCache.set(asset.asset_id, parsed); + return parsed; + } + + async loadScene(files: PackageFiles): Promise { + this.needsRender = true; + const hadScene = this.modelRoot.children.length > 0; + const sceneBytes = files['scene.json']; + if (!sceneBytes) throw new Error('scene.json is missing from the scene files'); + const manifest = JSON.parse(strFromU8(sceneBytes)) as SceneManifest; + if (manifest.schema_version !== '2.0' && manifest.schema_version !== 're-mode-source-1') throw new Error(`unsupported scene schema: ${manifest.schema_version}`); + this.clearScene(); + this.files = files; + this.manifest = manifest; + const nodesByParent = new Map(); + for (const node of manifest.nodes) nodesByParent.set(node.parent_node_id, [...(nodesByParent.get(node.parent_node_id) ?? []), node]); + const build = async (parent: THREE.Object3D, parentId: string | null): Promise => { + for (const node of nodesByParent.get(parentId) ?? []) { + const object = new THREE.Group(); + object.name = node.node_id; + object.matrixAutoUpdate = false; + object.matrix.copy(placementMatrix(node.transform)); + object.visible = this.nodeVisibility.get(node.node_id) ?? true; + object.userData.nodeId = node.node_id; + object.userData.definitionId = node.definition_id; + this.nodeObjects.set(node.node_id, object); + parent.add(object); + if (node.node_kind === 'part' || node.node_kind === 'solid') { + const instance = await this.instantiateNode(node); + instance.traverse((child: THREE.Object3D) => { child.userData.nodeId = node.node_id; child.userData.definitionId = node.definition_id; }); + object.add(instance); + } + await build(object, node.node_id); + } + }; + await build(this.modelRoot, null); + this.setVertexCloudsVisible(false); + // Re-frame only on the FIRST load; watched reloads (Studio live view, + // agent artifact iterations) must keep the user's camera pose and zoom. + if (!hadScene) this.pendingFrame = true; + this.updateGrid(); + this.resize(); + } + + /** Live view fast path: build the scene straight from a tessellated render + * mesh, skipping the .scadpkg container (zip, hash verification, BREP and + * feature-graph payloads entirely). Camera pose is preserved on reloads. */ + loadLiveGeometry(payload: LiveGeometryPayload): void { + const hadScene = this.modelRoot.children.length > 0; + this.clearScene(); + this.files = {}; + this.manifest = null; + const group = new THREE.Group(); + group.name = 'live-geometry'; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.positions.flat(), 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(payload.normals.flat(), 3)); + geometry.setIndex(payload.indices); + const [r, g, b] = DEFAULT_BASE_COLOR; + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: new THREE.Color(r, g, b), metalness: 0.08, roughness: 0.52, side: THREE.FrontSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 })); + mesh.name = 'live-faces'; + mesh.layers.set(FACE_LAYER); + group.add(mesh); + group.add(this.edgeLineVisual(payload.edge_positions, 'live-edges')); + this.modelRoot.add(group); + if (!hadScene) this.pendingFrame = true; + this.updateGrid(); + this.resize(); + } + + /** Build the scene from a preview (see `lib/preview.ts`): no package, no + * sidecar files. Each part's geometry is built once and shared by its + * instances; picking and highlighting use the same face and edge groups the + * package path has. Entity details are not part of a preview: a pick returns + * the entity without properties and the caller asks for them. */ + loadPreview(preview: PreviewData): void { + this.needsRender = true; + const hadScene = this.modelRoot.children.length > 0; + this.clearScene(); + this.files = {}; + const nodes: SceneNode[] = []; + const built = new Map(); + const material = (part: PreviewPart) => part && materialFor({ material_id: null } as SceneNode); + const buildPart = (part: PreviewPart) => { + const faces = new THREE.BufferGeometry(); + faces.setAttribute('position', new THREE.BufferAttribute(part.positions, 3)); + faces.setAttribute('normal', new THREE.BufferAttribute(part.normals, 3)); + faces.setIndex(new THREE.BufferAttribute(part.indices, 1)); + faces.computeBoundingSphere(); + const pick = new THREE.BufferGeometry(); + pick.setAttribute('position', new THREE.BufferAttribute(part.edgePositions, 3)); + pick.setIndex(new THREE.BufferAttribute(part.edgeIndices, 1)); + const segments: number[] = []; + for (let offset = 0; offset + 1 < part.edgeIndices.length; offset += 2) { + const a = part.edgeIndices[offset] * 3; + const b = part.edgeIndices[offset + 1] * 3; + segments.push( + part.edgePositions[a], part.edgePositions[a + 1], part.edgePositions[a + 2], + part.edgePositions[b], part.edgePositions[b + 1], part.edgePositions[b + 2], + ); + } + const edges = new LineSegmentsGeometry(); + if (segments.length) edges.setPositions(segments); + let vertices: THREE.BufferGeometry | null = null; + if (part.vertexIds.length) { + vertices = new THREE.BufferGeometry(); + vertices.setAttribute('position', new THREE.BufferAttribute(part.vertexPositions, 3)); + } + const empty = { properties: {}, geometry: {}, tags: [] as string[], parent_entity_ids: [] as string[], child_entity_ids: [] as string[], source: { kind: 'preview' } }; + const entity = (id: string, kind: 'solid' | 'face' | 'edge' | 'vertex') => ({ entity_id: id, kind, topo_id: id.split('/').pop() ?? id, ...empty }); + const sidecar: EntitySidecar = { + schema_version: '2.0', + definition_id: part.definitionId, + geometry_asset_id: '', + edge_asset_id: '', + entities: [ + entity(part.solidEntityId, 'solid'), + ...part.faceGroups.map((group) => entity(group.entity_id, 'face')), + ...part.edgeGroups.map((group) => entity(group.entity_id, 'edge')), + ...part.vertexIds.map((id) => entity(id, 'vertex')), + ], + face_groups: part.faceGroups, + edge_groups: part.edgeGroups, + }; + this.previewGeometries.push(faces, pick, edges, ...(vertices ? [vertices] : [])); + return { faces, pick, edges, vertices, sidecar }; + }; + const edgeMaterial = createEdgeMaterial(); + const size = this.resolution(); + edgeMaterial.resolution.set(Math.max(size.width, 1), Math.max(size.height, 1)); + for (const item of preview.nodes) { + const part = preview.parts.get(item.partKey); + if (!part) continue; + let geometry = built.get(part.key); + if (!geometry) { + geometry = buildPart(part); + built.set(part.key, geometry); + } + const node = { + node_id: item.nodeId, parent_node_id: null, display_name: item.displayName, node_kind: 'part', definition_id: item.definitionId, + definition_kind: 'single_solid', component_id: null, instance_id: null, transform: item.transform, geometry_asset_id: null, + entity_asset_id: null, material_id: null, properties: {}, + } as SceneNode; + nodes.push(node); + this.previewSidecars.set(item.nodeId, geometry.sidecar); + const object = new THREE.Group(); + object.name = item.nodeId; + object.matrixAutoUpdate = false; + object.matrix.copy(placementMatrix(item.transform)); + object.userData.nodeId = item.nodeId; + object.userData.definitionId = item.definitionId; + const group = new THREE.Group(); + group.name = item.displayName; + const faces = new THREE.Mesh(geometry.faces, material(part)); + faces.layers.set(FACE_LAYER); + group.add(faces); + // the invisible indexed line set is what edge picking hits; the visual hangs off it + const pickEdges = new THREE.LineSegments(geometry.pick, new THREE.LineBasicMaterial({ visible: false })); + if (geometry.edges.attributes.instanceStart) { + const visual = new LineSegments2(geometry.edges, edgeMaterial); + visual.name = 'cad-edge-visual'; + visual.layers.set(EDGE_LAYER); + visual.userData.pickable = false; + pickEdges.add(visual); + } + group.add(pickEdges); + if (geometry.vertices) { + const points = new THREE.Points(geometry.vertices, new THREE.PointsMaterial({ color: '#d8ff83', size: 7, sizeAttenuation: false, depthWrite: false, transparent: true, opacity: 0.48 })); + points.visible = false; + points.userData.pickRole = 'vertex'; + points.userData.pickable = true; + points.userData.vertexEntityIds = part.vertexIds; + group.add(points); + } + group.traverse((child: THREE.Object3D) => { + child.userData.nodeId = item.nodeId; + child.userData.definitionId = item.definitionId; + }); + object.add(group); + this.nodeObjects.set(item.nodeId, object); + this.modelRoot.add(object); + } + this.manifest = { schema_version: '2.0', nodes } as unknown as SceneManifest; + this.setVertexCloudsVisible(false); + if (!hadScene) this.pendingFrame = true; + this.updateGrid(); + this.resize(); + } + + /** True while the scene on screen is a preview (entity details are asked for, not read). */ + get isPreview(): boolean { + return this.previewSidecars.size > 0; + } + + private async instantiateNode(node: SceneNode): Promise { + if (!this.manifest) throw new Error('scene is not loaded'); + const sidecar = this.sidecarFor(node); + const group = new THREE.Group(); + group.name = node.display_name || node.node_id; + const geometryAsset = node.geometry_asset_id ? this.manifest.geometry_assets.find((asset) => asset.asset_id === node.geometry_asset_id) : undefined; + if (geometryAsset) { + let geometry = this.geometryCache.get(geometryAsset.asset_id); + if (!geometry) { + geometry = await this.loadGlb(geometryAsset.uri); + this.geometryCache.set(geometryAsset.asset_id, geometry); + } + const renderGeometry = geometry.clone(true); + renderGeometry.traverse((child) => { + if (child instanceof THREE.Mesh) child.material = materialFor(node); + }); + group.add(renderGeometry); + } + const edgeAsset = sidecar ? this.manifest.geometry_assets.find((asset) => asset.asset_id === sidecar.edge_asset_id) : undefined; + if (edgeAsset && sidecar) { + let edge = this.edgeCache.get(edgeAsset.asset_id); + if (!edge) { + edge = await this.loadGlb(edgeAsset.uri); + this.edgeCache.set(edgeAsset.asset_id, edge); + } + const edgeInstance = edge.clone(true); + this.buildEdgeVisuals(edgeInstance, sidecar); + group.add(edgeInstance); + } + if (sidecar) { + const vertices = sidecar.entities.filter((entity) => entity.kind === 'vertex'); + if (vertices.length) { + const positions = new Float32Array(vertices.length * 3); + vertices.forEach((entity, index) => { + const position = entity.properties.position; + if (Array.isArray(position) && position.length === 3 && position.every((item) => typeof item === 'number')) cadPointToGltf(position as Vec3).toArray(positions, index * 3); + }); + const vertexGeometry = new THREE.BufferGeometry(); + vertexGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const vertexPoints = new THREE.Points(vertexGeometry, new THREE.PointsMaterial({ color: '#d8ff83', size: 7, sizeAttenuation: false, depthWrite: false, transparent: true, opacity: 0.48 })); + vertexPoints.visible = false; + vertexPoints.userData.pickRole = 'vertex'; + vertexPoints.userData.pickable = true; + vertexPoints.userData.vertexEntityIds = vertices.map((entity) => entity.entity_id); + group.add(vertexPoints); + } + } + return group; + } + + /** Split the edge asset into regular and seam/degenerate wide-line visuals. */ + private buildEdgeVisuals(edgeObject: THREE.Object3D, sidecar: EntitySidecar): void { + const entitiesById = new Map(sidecar.entities.map((entity) => [entity.entity_id, entity])); + edgeObject.traverse((child) => { + if (!(child instanceof THREE.LineSegments)) return; + const source = child as THREE.LineSegments; + const index = source.geometry.index; + const position = source.geometry.getAttribute('position'); + const indexCount = index?.count ?? position.count; + const groups = [...sidecar.edge_groups].sort((a, b) => a.first_index - b.first_index); + const regular: number[] = []; + const seams: number[] = []; + const pushRange = (target: number[], first: number, count: number): void => { + for (let offset = first; offset + 1 < first + count; offset += 2) { + const a = index ? index.getX(offset) : offset; + const b = index ? index.getX(offset + 1) : offset + 1; + target.push(position.getX(a), position.getY(a), position.getZ(a), position.getX(b), position.getY(b), position.getZ(b)); + } + }; + let cursor = 0; + for (const group of groups) { + const start = Math.max(group.first_index, 0); + if (start > cursor) pushRange(regular, cursor, start - cursor); + const target = isHiddenEdge(entitiesById.get(group.entity_id)) ? seams : regular; + pushRange(target, start, group.index_count); + cursor = Math.max(cursor, start + group.index_count); + } + if (cursor < indexCount) pushRange(regular, cursor, indexCount - cursor); + // the source keeps the original geometry for index-based picking only + source.material = new THREE.LineBasicMaterial({ visible: false }); + if (regular.length) source.add(this.edgeLineVisual(regular, 'cad-edge-visual')); + if (seams.length) { + const seamVisual = this.edgeLineVisual(seams, 'cad-edge-seam'); + seamVisual.visible = this.seamsVisible && this.edgesVisible; + source.add(seamVisual); + } + }); + } + + private edgeLineVisual(positions: number[], name: string): LineSegments2 { + const geometry = new LineSegmentsGeometry(); + geometry.setPositions(positions); + // Negative slope-scaled offset mirrors the faces' push (see materialFor): + // keeps silhouette edges continuous where a flat bias loses. + const material = createEdgeMaterial(); + const size = this.resolution(); + material.resolution.set(Math.max(size.width, 1), Math.max(size.height, 1)); + const visual = new LineSegments2(geometry, material); + visual.name = name; + visual.layers.set(EDGE_LAYER); + visual.userData.pickable = false; + return visual; + } + + clearScene(): void { + for (const object of this.geometryCache.values()) disposeObject(object); + for (const object of this.edgeCache.values()) disposeObject(object); + while (this.modelRoot.children.length) { + const child = this.modelRoot.children[0]; + disposeObject(child); + this.modelRoot.remove(child); + } + for (const geometry of this.previewGeometries) geometry.dispose(); + this.previewGeometries = []; + this.previewSidecars.clear(); + this.nodeObjects.clear(); + this.nodeVisibility.clear(); + this.edgeCache.clear(); + this.entityCache.clear(); + this.geometryCache.clear(); + this.manifest = null; + this.files = null; + } + + // -- display options ------------------------------------------------------- + + setEdgesVisible(visible: boolean): void { + this.edgesVisible = visible; + this.modelRoot.traverse((object) => { + if (object.name === 'cad-edge-visual') object.visible = visible; + if (object.name === 'cad-edge-seam') object.visible = visible && this.seamsVisible; + }); + } + + setSeamsVisible(visible: boolean): void { + this.seamsVisible = visible; + this.modelRoot.traverse((object) => { + if (object.name === 'cad-edge-seam') object.visible = visible && this.edgesVisible; + }); + } + + get seamsShown(): boolean { + return this.seamsVisible; + } + + setSSAO(enabled: boolean): void { + if (!this.ssaoPass || this.ssaoEnabled === enabled) return; + this.ssaoEnabled = enabled; + this.ssaoPass.enabled = enabled; + } + + get ssaoOn(): boolean { + return this.ssaoEnabled && this.ssaoPass !== null; + } + + setVertexCloudsVisible(visible: boolean): void { + this.modelRoot.traverse((child) => { + if (child.userData.pickRole === 'vertex') child.visible = visible; + }); + } + + setNodeVisible(nodeId: string, visible: boolean): void { + const object = this.nodeObjects.get(nodeId); + if (!object) return; + object.visible = visible; + this.nodeVisibility.set(nodeId, visible); + } + + // -- accessors ------------------------------------------------------------- + + get nodes(): SceneNode[] { + return this.manifest?.nodes ?? []; + } + + get domElement(): HTMLCanvasElement { + return this.renderer.domElement; + } + + nodeObject(nodeId: string): THREE.Group | undefined { + return this.nodeObjects.get(nodeId); + } + + entityFor(nodeId: string, entityId: string): Entity | null { + const node = this.manifest?.nodes.find((item) => item.node_id === nodeId); + return this.sidecarFor(node)?.entities.find((item) => item.entity_id === entityId) ?? null; + } + + // -- camera + lifecycle -------------------------------------------------------- + + frame(): void { + this.needsRender = true; + const box = new THREE.Box3().setFromObject(this.modelRoot); + if (box.isEmpty()) return; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + // The 0.0005 floor only guards degenerate/empty geometry; it must stay + // well below real model radii (a 0.01 floor pushed small parts 3.6x away). + const radius = Math.max(size.length() * 0.5, 0.0005); + // FIT is also the escape hatch from a rolled-around trackball orientation: + // restore the canonical Z-up before re-framing. + this.camera.up.set(0, 0, 1); + // 2.3 (not the textbook ~2.8, nor 2.05): the bounding sphere over-covers + // flat parts, but 2.05 read as slightly too large in review. + // Fit the bounding sphere in the narrower of the two view angles, so a + // tall, narrow viewport frames the model as well as a wide one. + const verticalFov = THREE.MathUtils.degToRad(this.camera.fov); + const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * this.camera.aspect); + const distance = (radius / Math.sin(Math.min(verticalFov, horizontalFov) / 2)) * 0.82; + this.camera.position.copy(center).add(new THREE.Vector3(1, 0.78, 1).normalize().multiplyScalar(distance)); + this.camera.near = Math.max(radius / 100, 1e-5); + this.camera.far = Math.max(radius * 100, 0.5); + this.camera.updateProjectionMatrix(); + this.controls.target.copy(center); + // Absolute minDistance (0.01 in the constructor) would clamp the zoomed-in + // camera for small models; frame re-derives both bounds from the radius. + this.controls.minDistance = radius * 0.2; + this.controls.maxDistance = radius * 12; + // Kill the trackball's rotational inertia — otherwise it keeps rolling the + // freshly framed camera away from the canonical pose. These internals are + // stable across three.js versions; there is no public "stop momentum" API. + const trackball = this.controls as unknown as { + _movePrev: THREE.Vector2; _moveCurr: THREE.Vector2; _lastAngle: number; + _zoomStart: THREE.Vector2; _zoomEnd: THREE.Vector2; + _panStart: THREE.Vector2; _panEnd: THREE.Vector2; + }; + trackball._movePrev.copy(trackball._moveCurr); + trackball._lastAngle = 0; + trackball._zoomStart.copy(trackball._zoomEnd); + trackball._panStart.copy(trackball._panEnd); + this.controls.update(); + if (this.ssaoPass) this.tuneSSAO(this.ssaoPass, radius); + } + + getCameraState(): CameraState { + const bounds = this.renderer.domElement.getBoundingClientRect(); + return { + position: [this.camera.position.x, this.camera.position.y, this.camera.position.z], + target: [this.controls.target.x, this.controls.target.y, this.controls.target.z], + up: [this.camera.up.x, this.camera.up.y, this.camera.up.z], + fov_deg: this.camera.fov, + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }; + } + + snapshotPng(): string | null { + try { + this.render(); + return this.renderer.domElement.toDataURL('image/png'); + } catch { + return null; + } + } + + private resize(): void { + const width = this.host.clientWidth; + const height = this.host.clientHeight; + if (width <= 0 || height <= 0) return; + this.renderer.setSize(width, height, false); + // TrackballControls maps pointer deltas through the element rect, which + // this host changes on load and panel resizes. + this.controls.handleResize(); + this.camera.aspect = width / Math.max(height, 1); + this.camera.updateProjectionMatrix(); + const pixelRatio = this.renderer.getPixelRatio(); + const deviceWidth = Math.max(Math.round(width * pixelRatio), 1); + const deviceHeight = Math.max(Math.round(height * pixelRatio), 1); + this.faceRT?.setSize(deviceWidth, deviceHeight); + this.beautyRT?.setSize(deviceWidth, deviceHeight); + this.ssaoPass?.setSize(deviceWidth, deviceHeight); + // The scene may load before the host has its final layout; re-frame once. + if (this.pendingFrame && this.modelRoot.children.length) { + this.pendingFrame = false; + this.frame(); + } + const resolution = this.resolution(); + this.modelRoot.traverse((object) => { + if (object instanceof LineSegments2) object.material.resolution.set(resolution.width, resolution.height); + }); + // setSize cleared the canvas. + this.needsRender = true; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.observer.disconnect(); + this.renderer.setAnimationLoop(null); + this.clearScene(); + if (this.grid) { + this.grid.geometry.dispose(); + (this.grid.material as THREE.Material).dispose(); + } + this.gizmoScene.traverse((object) => { + const mesh = object as THREE.Mesh; + mesh.geometry?.dispose?.(); + (mesh.material as THREE.Material | undefined)?.dispose?.(); + }); + this.controls.dispose(); + this.faceRT?.dispose(); + this.beautyRT?.dispose(); + this.ssaoPass?.dispose?.(); + if (this.blitScene) { + const quad = this.blitScene.children[0] as THREE.Mesh | undefined; + quad?.geometry.dispose(); + this.copyMaterial?.dispose(); + this.presentMaterial?.dispose(); + } + this.renderer.dispose(); + this.renderer.domElement.remove(); + } +} diff --git a/editor-web/src/scene/renderer/index.ts b/editor-web/src/scene/renderer/index.ts new file mode 100644 index 00000000..fd63aa2e --- /dev/null +++ b/editor-web/src/scene/renderer/index.ts @@ -0,0 +1,17 @@ +// Standalone BRep renderer component — public entry point. +// +// Embed this to render a scene-2.0 package (faces + edges) with CAD-grade +// presentation: image-based studio lighting, GTAO ambient occlusion, free +// trackball camera, and toggleable seam/degenerate edge display. Selection +// and annotation are NOT part of this component; build them on top +// (reference implementation: src/scene-view.ts). + +export { + BRepRenderer, + EDGE_LAYER, + FACE_LAYER, + OVERLAY_LAYER, + type BRepRendererOptions, + type CameraState, + type LiveGeometryPayload, +} from './brep-renderer'; diff --git a/editor-web/src/scene/scene-view.ts b/editor-web/src/scene/scene-view.ts new file mode 100644 index 00000000..1574cdba --- /dev/null +++ b/editor-web/src/scene/scene-view.ts @@ -0,0 +1,366 @@ +// Interactive BRep selection view used by the reverse-engineering mode. +// A thin selection/annotation layer on top of the standalone BRepRenderer +// component (src/renderer): this class adds raycast picking, selection +// modes and annotation marks. All rendering — materials, edge display, +// lighting, ambient occlusion, camera — lives in the renderer component. + +import * as THREE from 'three'; +import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; +import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; +import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; +import { bindClickSelection } from './components/click-selection'; +import { disposeObject } from './cad-three'; +import { BRepRenderer, OVERLAY_LAYER, type CameraState, type LiveGeometryPayload } from './renderer'; + +export type { LiveGeometryPayload }; +import type { PackageFiles } from './product-package'; +import type { PreviewData } from '../lib/preview'; +import type { Entity, EntitySidecar, FaceGroup, SceneNode } from './scene2'; + +export type SelectionMode = 'component' | 'solid' | 'face' | 'edge' | 'vertex'; +export type PickResult = { + nodeId: string; + entityId: string | null; + mode: SelectionMode; + entity: Entity | null; + /** Ctrl/Cmd was held: add to (or toggle in) the selection instead of replacing it. */ + additive?: boolean; +}; +export type { CameraState }; + +export const MARK_COLORS = ['#ff5c5c', '#ffd23f', '#4ade80', '#60a5fa', '#c084fc', '#fb923c', '#f472b6', '#34d399']; + +export type SceneViewOptions = { + onPick?: (result: PickResult) => void; + /** A click that hit nothing. */ + onClear?: () => void; + selectionMode?: SelectionMode; + background?: string; + interactive?: boolean; + /** GTAO ambient occlusion (default on). */ + ssao?: boolean; + /** Show seam/degenerate edges (default off). */ + seams?: boolean; +}; + +type Mark = { key: string; color: string; overlays: THREE.Object3D[] }; + +export class SceneView { + readonly engine: BRepRenderer; + onPick: ((result: PickResult) => void) | null; + onClear: (() => void) | null; + + private raycaster = new THREE.Raycaster(); + private pointer = new THREE.Vector2(); + selectionMode: SelectionMode; + marks: Mark[] = []; + private unbindClick: (() => void) | null = null; + private disposed = false; + + constructor(host: HTMLElement, options: SceneViewOptions = {}) { + this.onPick = options.onPick ?? null; + this.onClear = options.onClear ?? null; + this.selectionMode = options.selectionMode ?? 'face'; + this.engine = new BRepRenderer(host, { background: options.background, ssao: options.ssao, seams: options.seams }); + if (options.interactive !== false) { + this.unbindClick = bindClickSelection({ + element: this.engine.domElement, + camera: this.engine.camera, + controls: this.engine.controls, + onClick: (event) => { + const result = this.pick(event); + if (result) this.onPick?.({ ...result, additive: event.ctrlKey || event.metaKey }); + else if (!(event.ctrlKey || event.metaKey)) this.onClear?.(); + }, + }); + } + } + + // -- scene loading --------------------------------------------------------- + + async loadScene(files: PackageFiles): Promise { + this.clearMarks(); + await this.engine.loadScene(files); + this.applySelectionModeVisibility(); + } + + loadPreview(preview: PreviewData): void { + this.clearMarks(); + this.engine.loadPreview(preview); + this.applySelectionModeVisibility(); + } + + get isPreview(): boolean { + return this.engine.isPreview; + } + + loadLiveGeometry(payload: LiveGeometryPayload): void { + this.clearMarks(); + this.engine.loadLiveGeometry(payload); + } + + /** Pause/resume the render loop (inactive editor modes must not render). */ + setRunning(running: boolean): void { + this.engine.setRunning(running); + } + + clearScene(): void { + this.clearMarks(); + this.engine.clearScene(); + } + + sidecarFor(node: SceneNode | undefined): EntitySidecar | null { + return this.engine.sidecarFor(node); + } + + entityFor(nodeId: string, entityId: string): Entity | null { + return this.engine.entityFor(nodeId, entityId); + } + + get nodes(): SceneNode[] { + return this.engine.nodes; + } + + nodeObject(nodeId: string): THREE.Group | undefined { + return this.engine.nodeObject(nodeId); + } + + // -- picking --------------------------------------------------------------- + + setSelectionMode(mode: SelectionMode): void { + this.selectionMode = mode; + this.applySelectionModeVisibility(); + } + + private applySelectionModeVisibility(): void { + this.engine.setVertexCloudsVisible(this.selectionMode === 'vertex'); + } + + private isEffectivelyVisible(object: THREE.Object3D): boolean { + for (let current: THREE.Object3D | null = object; current; current = current.parent) { + if (!current.visible) return false; + if (current === this.engine.modelRoot) break; + } + return true; + } + + private pointerRay(event: PointerEvent): void { + const bounds = this.engine.domElement.getBoundingClientRect(); + this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; + this.raycaster.setFromCamera(this.pointer, this.engine.camera); + const distance = this.engine.camera.position.distanceTo(this.engine.controls.target); + const worldPerPixel = 2 * Math.tan(THREE.MathUtils.degToRad(this.engine.camera.fov * 0.5)) * distance / Math.max(bounds.height, 1); + this.raycaster.params.Line.threshold = worldPerPixel * 5; + this.raycaster.params.Points.threshold = worldPerPixel * 8; + } + + /** A hidden seam/degenerate edge must not answer edge picks. */ + private isEdgePickable(entityId: string | undefined, sidecar: EntitySidecar | null): boolean { + if (this.engine.seamsShown) return true; + const entity = sidecar?.entities.find((item) => item.entity_id === entityId); + const props = entity?.properties as { seam?: unknown; degenerate?: unknown } | undefined; + return props?.seam !== true && props?.degenerate !== true; + } + + pick(event: PointerEvent): PickResult | null { + this.pointerRay(event); + const hits = this.raycaster.intersectObjects(this.engine.modelRoot.children, true).filter((hit) => { + if (hit.object.userData.pickable === false || !this.isEffectivelyVisible(hit.object)) return false; + return typeof hit.object.userData.nodeId === 'string'; + }); + const meshHit = hits.find((hit) => hit.object instanceof THREE.Mesh); + if (this.selectionMode === 'component' || this.selectionMode === 'solid' || this.selectionMode === 'face') { + if (!meshHit) return null; + } else { + const wanted = this.selectionMode === 'edge' + ? (hit: THREE.Intersection) => hit.object instanceof THREE.LineSegments + : (hit: THREE.Intersection) => hit.object instanceof THREE.Points; + let candidate: THREE.Intersection | undefined; + for (const hit of hits.filter(wanted)) { + if (this.selectionMode === 'edge') { + const nodeId = hit.object.userData.nodeId as string; + const node = this.engine.nodes.find((item) => item.node_id === nodeId); + const sidecar = this.engine.sidecarFor(node); + const entityId = this.edgeEntityIdForHit(hit, sidecar); + if (!this.isEdgePickable(entityId, sidecar)) continue; + } + candidate = hit; + break; + } + if (!candidate) return null; + if (meshHit) { + const occlusionAllowance = this.selectionMode === 'edge' ? this.raycaster.params.Line.threshold : this.raycaster.params.Points.threshold; + if (candidate.distance > meshHit.distance + occlusionAllowance * 1.5) return null; + } + } + const hit = (this.selectionMode === 'edge' || this.selectionMode === 'vertex') + ? hits.find((item) => this.selectionMode === 'edge' ? item.object instanceof THREE.LineSegments : item.object instanceof THREE.Points) ?? meshHit + : meshHit; + if (!hit) return null; + const nodeId = hit.object.userData.nodeId as string; + const node = this.engine.nodes.find((item) => item.node_id === nodeId); + const sidecar = this.sidecarFor(node); + if (this.selectionMode === 'component') { + return { nodeId, entityId: null, mode: 'component', entity: null }; + } + if (this.selectionMode === 'solid') { + const solid = sidecar?.entities.find((item) => item.kind === 'solid') ?? null; + return { nodeId, entityId: solid?.entity_id ?? null, mode: 'solid', entity: solid }; + } + let entityId: string | undefined; + if (this.selectionMode === 'face' && hit.object instanceof THREE.Mesh && typeof hit.faceIndex === 'number') { + const triangleOffset = hit.faceIndex * 3; + entityId = sidecar?.face_groups.find((group: FaceGroup) => triangleOffset >= group.first_index && triangleOffset < group.first_index + group.index_count)?.entity_id; + } else if (this.selectionMode === 'edge' && hit.object instanceof THREE.LineSegments && typeof hit.index === 'number') { + entityId = this.edgeEntityIdForHit(hit, sidecar); + } else if (this.selectionMode === 'vertex' && hit.object instanceof THREE.Points && typeof hit.index === 'number') { + entityId = hit.object.userData.vertexEntityIds?.[hit.index]; + } + if (!entityId) return null; + return { nodeId, entityId, mode: this.selectionMode, entity: this.entityFor(nodeId, entityId) }; + } + + private edgeEntityIdForHit(hit: THREE.Intersection, sidecar: EntitySidecar | null): string | undefined { + if (!(hit.object instanceof THREE.LineSegments) || typeof hit.index !== 'number' || !sidecar) return undefined; + return sidecar.edge_groups.find((group: FaceGroup) => hit.index! >= group.first_index && hit.index! < group.first_index + group.index_count)?.entity_id; + } + + // -- highlight + marks ------------------------------------------------------- + + private attachOverlay(source: THREE.Object3D, overlay: THREE.Object3D): THREE.Object3D { + this.engine.invalidate(); + overlay.name = 'entity-overlay'; + overlay.userData.pickable = false; + // Overlays composite in the post-AO edge pass (see BRepRenderer): their + // color must not be darkened by ambient occlusion. + overlay.layers.set(OVERLAY_LAYER); + overlay.renderOrder = 10; + overlay.matrix.copy(source.matrix); + overlay.matrixAutoUpdate = false; + overlay.visible = source.visible; + source.parent?.add(overlay); + return overlay; + } + + buildEntityOverlays(nodeId: string, entityId: string | null, color: string, opacity = 0.72): THREE.Object3D[] { + const object = this.engine.nodeObject(nodeId); + const node = this.engine.nodes.find((item) => item.node_id === nodeId); + const sidecar = this.engine.sidecarFor(node); + if (!object || !sidecar) return []; + const overlays: THREE.Object3D[] = []; + const entityGroup = entityId ? sidecar.face_groups.find((group: FaceGroup) => group.entity_id === entityId) : undefined; + const edgeGroup = entityId ? sidecar.edge_groups.find((group: FaceGroup) => group.entity_id === entityId) : undefined; + // Entity ids carry the owning graph segment (entity//solid/x); + // classify by path SEGMENT, never by a positional prefix. + const kindOf = (id: string | null | undefined) => + id?.split('/').find((part) => part === 'solid' || part === 'face' || part === 'edge' || part === 'vertex'); + const isSolid = kindOf(entityId) === 'solid'; + const isVertex = kindOf(entityId) === 'vertex'; + object.traverse((child: THREE.Object3D) => { + if (overlays.length || !this.isEffectivelyVisible(child) || !(child instanceof THREE.Mesh || child instanceof THREE.LineSegments || child instanceof THREE.Points)) return; + const source = child as THREE.Mesh | THREE.LineSegments | THREE.Points; + const range = entityGroup || edgeGroup; + if (!range && !isVertex && !isSolid) return; + if (entityGroup && !(source instanceof THREE.Mesh)) return; + if (edgeGroup && !entityGroup && !(source instanceof THREE.LineSegments)) return; + if (isVertex && !source.userData.vertexEntityIds?.includes(entityId!)) return; + if (source instanceof THREE.LineSegments && range) { + const lineGeometry = new LineSegmentsGeometry(); + const positions: number[] = []; + const index = source.geometry.index; + const position = source.geometry.getAttribute('position'); + for (let offset = range.first_index; offset < range.first_index + range.index_count; offset += 2) { + const a = index ? index.getX(offset) : offset; + const b = index ? index.getX(offset + 1) : offset + 1; + positions.push(position.getX(a), position.getY(a), position.getZ(a), position.getX(b), position.getY(b), position.getZ(b)); + } + lineGeometry.setPositions(positions); + const material = new LineMaterial({ color, linewidth: 5, worldUnits: false, transparent: true, opacity: 1, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 }); + material.resolution.set(this.engine.domElement.clientWidth, this.engine.domElement.clientHeight); + overlays.push(this.attachOverlay(source, new LineSegments2(lineGeometry, material))); + } else if (source instanceof THREE.Points) { + const geometry = source.geometry.clone(); + const vertexIndex = source.userData.vertexEntityIds.indexOf(entityId!); + geometry.setDrawRange(vertexIndex, 1); + overlays.push(this.attachOverlay(source, new THREE.Points(geometry, new THREE.PointsMaterial({ color, size: 16, sizeAttenuation: false, transparent: true, opacity: 1, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 })))); + } else { + const geometry = source.geometry.clone(); + if (range) geometry.setDrawRange(range.first_index, range.index_count); + // Depth-tested so highlights hide behind the body like real geometry; + // the polygon offset keeps the overlay from z-fighting its own face. + overlays.push(this.attachOverlay(source, new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: isSolid ? opacity * 0.55 : opacity, depthTest: true, depthWrite: false, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 })))); + } + }); + return overlays; + } + + addMark(nodeId: string, entityId: string | null, color: string): void { + const key = `${nodeId}::${entityId ?? 'component'}`; + if (this.marks.some((mark) => mark.key === key)) return; + const overlays = this.buildEntityOverlays(nodeId, entityId, color); + if (overlays.length) this.marks.push({ key, color, overlays }); + } + + removeMark(key: string): void { + const index = this.marks.findIndex((mark) => mark.key === key); + if (index < 0) return; + for (const overlay of this.marks[index].overlays) { + overlay.parent?.remove(overlay); + disposeObject(overlay); + } + this.marks.splice(index, 1); + } + + clearMarks(): void { + this.engine.invalidate(); + for (const mark of this.marks) { + for (const overlay of mark.overlays) { + overlay.parent?.remove(overlay); + disposeObject(overlay); + } + } + this.marks = []; + } + + hasMark(nodeId: string, entityId: string | null): boolean { + return this.marks.some((mark) => mark.key === `${nodeId}::${entityId ?? 'component'}`); + } + + // -- display options + camera + lifecycle -------------------------------------- + + setEdgesVisible(visible: boolean): void { + this.engine.invalidate(); + this.engine.setEdgesVisible(visible); + } + + setSeamsVisible(visible: boolean): void { + this.engine.invalidate(); + this.engine.setSeamsVisible(visible); + } + + setSSAO(enabled: boolean): void { + this.engine.invalidate(); + this.engine.setSSAO(enabled); + } + + frame(): void { + this.engine.invalidate(); + this.engine.frame(); + } + + getCameraState(): CameraState { + return this.engine.getCameraState(); + } + + snapshotPng(): string | null { + return this.engine.snapshotPng(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unbindClick?.(); + this.engine.dispose(); + } +} diff --git a/editor-web/src/scene/scene2.ts b/editor-web/src/scene/scene2.ts new file mode 100644 index 00000000..7b07aa22 --- /dev/null +++ b/editor-web/src/scene/scene2.ts @@ -0,0 +1,246 @@ +import { strFromU8 } from 'fflate'; +import type { PackageFiles } from './product-package'; + +export type Vec3 = [number, number, number]; +export type Transform = { origin: Vec3; x_axis: Vec3; y_axis: Vec3; z_axis: Vec3 }; +export type SourceRecord = Record & { + kind: string; + definition_id?: string; + graph_id?: string; + node_id?: string; + output_slot?: number; + topology_kind?: string; + topo_id?: string; +}; +export type Definition = { + definition_id: string; + definition_kind: 'single_solid' | 'assembly'; + revision: string; + content_hash: string; + product_asset_id: string; + feature_graph_asset_id: string; +}; +export type SceneNode = { + node_id: string; + parent_node_id: string | null; + display_name: string; + node_kind: 'part' | 'assembly' | 'solid'; + definition_id: string; + definition_kind: 'single_solid' | 'assembly'; + component_id: string | null; + instance_id: string | null; + transform: Transform; + geometry_asset_id: string | null; + entity_asset_id: string | null; + material_id: string | null; + properties: Record; +}; +export type Asset = { + asset_id: string; + uri: string; + media_type: string; + sha256: string; + byte_length: number; +}; +export type Entity = { + entity_id: string; + kind: 'solid' | 'face' | 'edge' | 'vertex'; + topo_id: string; + parent_entity_ids: string[]; + child_entity_ids: string[]; + source: SourceRecord; + properties: Record; + geometry: Record; + tags: string[]; +}; +export type EntitySidecar = { + schema_version: '1.0' | '2.0'; + definition_id: string; + geometry_asset_id: string; + edge_asset_id: string; + geometry_engine?: Record; + entities: Entity[]; + face_groups: FaceGroup[]; + edge_groups: FaceGroup[]; +}; +export type FaceGroup = { entity_id: string; first_index: number; index_count: number }; +export type SourceAsset = { + source_asset_id: string; + uri: string; + sha256: string; + byte_length: number; + media_type: string; + display_name: string; +}; +export type SourceSpan = { + source_asset_id: string; + uri: string; + start_line: number; + end_line: number; + symbol: string | null; +}; +export type Feature = { + feature_id: string; + definition_id: string; + graph_id: string; + node_id: string; + op: string; + label: string | null; + parameters: Record; + input_feature_ids: string[]; + output_count: number; + source_spans: SourceSpan[]; +}; +export type ConnectorSnapshot = { + connector_snapshot_id: string; + node_id: string; + definition_id: string; + definition_kind: 'single_solid' | 'assembly'; + connector_id: string; + name: string; + anchor_kind: 'placement' | 'geometry' | 'public'; + local_frame: Transform; + binding: Record | null; + source_connector_snapshot_id: string | null; + source_feature_id: string | null; +}; +export type ConnectorRef = { + definition_id: string; + component_id: string; + instance_id: string; + connector_id: string; + connector_snapshot_id: string; +}; +export type Joint = { + joint_id: string; + assembly_definition_id: string; + joint_type: 'fixed' | 'revolute' | 'prismatic' | 'cylindrical' | 'planar' | 'ball' | 'gear' | 'belt' | 'rack_pinion'; + connector_a: ConnectorRef; + connector_b: ConnectorRef; + parameters: Record; + limits: Record | null; + source_feature_id: string | null; +}; +export type SceneManifest = { + schema_version: '2.0' | 're-mode-source-1'; + artifact_kind?: 're_mode_source_scene'; + scene_id: string; + revision: string; + units: 'mm'; + roots: string[]; + definitions: Definition[]; + geometry_assets: Asset[]; + entity_assets: Asset[]; + product_assets: Array, + feature_graph_assets: Array; + source_assets: SourceAsset[]; + nodes: SceneNode[]; + connectors: ConnectorSnapshot[]; + joints: Joint[]; + feature_index: Feature[]; + source_index: Array<{ + source_index_id: string; + source_asset_id: string; + uri: string; + definition_id: string; + feature_id: string; + start_line: number; + end_line: number; + symbol: string | null; + }>; + connector_index: Array<{ + connector_index_id: string; + definition_id: string; + node_id: string; + connector_id: string; + connector_snapshot_id: string; + source_feature_id: string | null; + }>; + joint_index: Array<{ + joint_index_id: string; + assembly_definition_id: string; + joint_id: string; + source_feature_id: string | null; + }>; + camera: { target: Vec3; position: Vec3; up: Vec3; near: number; far: number; fit_mode: string; margin: number }; + extensions: Record; +}; +export type OperationSource = { + path?: string | null; + line: number; + column: number; + end_line: number; + end_column: number; + call_text?: string; + callsite_id: string; + assignment_targets: string[]; +}; +export type ModelNode = { + node_id: string; + definition_id: string; + graph_id: string; + local_node_id: string; + op: string; + params: Record; + inputs: string[]; + output_count: number; + display: { label?: string; category?: string; summary?: string }; + source?: OperationSource; + sources: OperationSource[]; +}; +export type ModelDocument = { graph: { graph_id: string; nodes: ModelNode[] }; leaf_ids: string[] }; + +export function buildFederatedFeatureModel( + manifest: SceneManifest, + files: PackageFiles, +): { model: ModelDocument; sources: Map } { + const pathBySourceId = new Map(); + const sources = new Map(); + for (const asset of manifest.source_assets) { + let path = asset.display_name; + const content = strFromU8(files[asset.uri]); + const existing = sources.get(path); + if (existing !== undefined && existing !== content) { + path = `${path} [${asset.source_asset_id.slice(7, 15)}]`; + } + sources.set(path, content); + pathBySourceId.set(asset.source_asset_id, path); + } + + const nodes: ModelNode[] = manifest.feature_index.map((feature) => { + const featureSources = feature.source_spans.map((span, index) => { + const sourcePath = pathBySourceId.get(span.source_asset_id); + return { + path: sourcePath ?? null, + line: span.start_line, + column: 0, + end_line: span.end_line, + end_column: 0, + callsite_id: `${feature.feature_id}/source/${index}`, + assignment_targets: span.symbol ? [span.symbol] : [], + } satisfies OperationSource; + }); + return { + node_id: feature.feature_id, + definition_id: feature.definition_id, + graph_id: feature.graph_id, + local_node_id: feature.node_id, + op: feature.op, + params: feature.parameters, + inputs: feature.input_feature_ids, + output_count: feature.output_count, + display: { + label: feature.label ?? undefined, + category: feature.definition_id, + }, + source: featureSources[0], + sources: featureSources, + }; + }); + const consumed = new Set(nodes.flatMap((node) => node.inputs)); + const leafIds = nodes.filter((node) => !consumed.has(node.node_id)).map((node) => node.node_id); + return { + model: { graph: { graph_id: 'federated', nodes }, leaf_ids: leafIds }, + sources, + }; +} diff --git a/editor-web/src/vite-env.d.ts b/editor-web/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/editor-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/editor-web/tsconfig.app.json b/editor-web/tsconfig.app.json new file mode 100644 index 00000000..6830b6f7 --- /dev/null +++ b/editor-web/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/editor-web/tsconfig.json b/editor-web/tsconfig.json new file mode 100644 index 00000000..2da88b0d --- /dev/null +++ b/editor-web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/editor-web/tsconfig.node.json b/editor-web/tsconfig.node.json new file mode 100644 index 00000000..8455dcbc --- /dev/null +++ b/editor-web/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/editor-web/vite.config.ts b/editor-web/vite.config.ts new file mode 100644 index 00000000..64b77d11 --- /dev/null +++ b/editor-web/vite.config.ts @@ -0,0 +1,29 @@ +import path from "node:path"; + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The studio webui. In dev, API/WebSocket traffic proxies to the runtime +// server (`sca edit`, default :7180); `npm run build` emits the +// static bundle the server hosts from simplecadapi/editor/webui. +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "./src"), + }, + }, + server: { + port: 5180, + proxy: { + "/api": "http://127.0.0.1:7180", + "/ws": { target: "ws://127.0.0.1:7180", ws: true }, + }, + }, + build: { + outDir: "../src/simplecadapi/editor/webui", + emptyOutDir: true, + chunkSizeWarningLimit: 1600, + }, +}); diff --git a/examples/README.md b/examples/README.md index 76af09ce..aac6e796 100644 --- a/examples/README.md +++ b/examples/README.md @@ -46,6 +46,7 @@ uv run python examples/four_bar_linkage/export.py # package + exchange files | `u_link_motor_mount/` | assembly | `u_link_motor_mount.py` | `export.py`, `verify/acceptance.py` | `u_link_assembly.scadpkg` + STEP/STL + renders (`demo/index.html` session replay) | | `l_link_motor_mount/` | assembly (part family) | `l_link_motor_mount.py` (`--id l-link-motor-mount- --set PRESET=`) | `export.py`, `verify/acceptance.py`, `verify/export_artifacts.py` | `l_link_assembly_.scadpkg` + STEP/STL per adapter preset + renders | | `pcg_attractor_canopy/` | part (procedural) | `attractor_canopy.py` | `export.py`, `verify.py` | `attractor_canopy.scadpkg` + STEP/STL/OBJ + GIF | +| `pcg_facade_tower/` | procedural building (plain modules) | — | `export.py [rect\|chamfer\|l_shape] [n_floors]`, then `verify/s1_verify.py` … `verify/s8_verify.py` | `facade_tower.scadpkg` + AP242 STEP + panel-schedule CSV + rationalization/scale sweeps + verify renders | | `compact_two_stage_planetary_reducer/` | assembly (part families) | `compact_two_stage_planetary_reducer.py` | `export.py`, `export_mjcf.py` | `compact_two_stage_planetary_reducer.scadpkg` + STEP/FCStd/MJCF | | `integrated_bldc_joint_actuator/` | product (sub-assembly notebooks) | `integrated_bldc_joint_actuator.py` | `export_all.py`, `render_showcase.py` | `integrated_bldc_joint_actuator.scadpkg` + STEP/FCStd/MJCF + renders | | `ap242_gmsh_volume_mesh/` | FEM / data exchange | `bracket.py` | see below | AP242 + Gmsh volume mesh + CalculiX statics | diff --git a/examples/SDK_DEFECTS.md b/examples/SDK_DEFECTS.md index 0f2c2614..93703c28 100644 --- a/examples/SDK_DEFECTS.md +++ b/examples/SDK_DEFECTS.md @@ -301,6 +301,33 @@ that surfaced it is in brackets. it without the cache), or treat a failed state write as a warning; optionally let the state directory be relocated (e.g. an env var or a `--state-dir`). +## 19. The executor raised on any cell that is not in the notebook [editor] + +- **Symptom:** in the editor kernel, every scratch snippet (the bridge's + `execute`) failed with `KeyError: '__scratch__'`. +- **Trigger:** a notebook with `[tool.simplecadapi]` running under marimo's + editor, then any cell marimo runs that the notebook does not contain. +- **Root cause:** `SimpleCadExecutor` looked the cell's key up in the notebook + cells without checking that the cell belongs to the notebook. +- **Status:** fixed. `NotebookCells.__contains__`; the executor delegates such + cells to marimo's default executor. Test: + `test_scratch_cells_are_not_recorded`. +- **Fix direction:** done. The headless runner only ever runs notebook cells, so + only the editor kernel could hit it; test in a kernel, not only headlessly. + +## 20. `ql.vertices()` found nothing on a solid or a face [editor] + +- **Symptom:** `scad.ql.vertices().resolve(solid)` returned no vertices, so a + selector for a picked vertex raised "expected exactly 1 vertex(s), got 0". +- **Trigger:** resolving `ql.vertices()` against a Solid, Face or Wire; only an + Edge scope worked. +- **Root cause:** `_resolve_scope_items` looked only at the scope's direct + children for vertices, and a solid's children are shells. +- **Status:** fixed. It falls back to the vertices of the scope's edges + (`_boundary_items`); test `test_vertices_resolve_through_a_solid_and_a_face`. +- **Fix direction:** done. Every `ql` factory should resolve against any scope + that contains its kind; check each (kind, scope) pair when adding a factory. + ## Heuristics - Run every notebook three times: fresh, all cached, `--no-cache`. The three diff --git a/examples/pcg_facade_tower/BUILD_PLAN.md b/examples/pcg_facade_tower/BUILD_PLAN.md new file mode 100644 index 00000000..09976683 --- /dev/null +++ b/examples/pcg_facade_tower/BUILD_PLAN.md @@ -0,0 +1,135 @@ +# BUILD_PLAN: pcg_facade_tower + +> 时点说明(2026-10-09):本文「同参数同 id 命中缓存」与 S7 增量重建条目写于 part cache 移除前 +> (dev PR #17);dev 上同参数为同一 Part 身份、全量重建,S7 改为计时报告。几何 / 布局策略部分不受影响。 + +Datum: 原点 = 地坪 ∩ 平面参考点(RECT/CHAMFER/L_SHAPE 的 SW 角),Z up,+X 东 +Y 北;平面多边形 CCW, +边 i 自顶点 i → i+1,切向 t_i,外法向 n_i = (t_y, −t_x)。building line = tier 多边形。 +板件局部系:x 沿边(0 = 板左端缝中线),y 向上(0 = 楼层标高),z 外法向(0 = building line)。 +placement:origin = V_i + s0·t_i + z_floor·ẑ,x_axis = t_i,y_axis = ẑ(右手:z_axis = t_i × ẑ = n_i)。 +角件局部系:origin = building line 顶点,x = 入边切向,y 向上,z = 入边外法向;平面轮廓在局部 (x, z) 内。 +竖挺局部系:轴同板件;origin = 板局部 (w − joint/2 − frame_sight/2, 0, frame_d)(右竖框中线 × 框外面)。 + +构造策略:**布局引擎 (layout.py) 是唯一的 PCG 源**,只产数据(FacadeSchedule:tier 多边形、每层每边的板序列、 +板型表、floor 类型表);几何文件只消费板型参数。板型 = `scad.part(id=)` 工厂,参数全部经 builder +实参进入 build_key → 同参数同 id 命中缓存;floor 类型 = `scad.assemble` 子装配,塔楼只放 floor 实例。 + +## 文件 + +| 文件 | 内容 | +| --- | --- | +| `layout.py` | FacadeParams、平面/退台、分格、收口、角部、波场 + 量化、抖动 WWR、板型/floor 类型去重 | +| `panels.py` | 板件族 builder(vision / solid / fin)+ Part 工厂 `make_panel_part(type_id, spec, params)` | +| `corners.py` | 凸角 / 凹角收口件(草图多边形拉伸) | +| `slabs.py` | 楼板 / 屋面退台板 / 雨棚 | +| `floor.py` | 带子装配(floor 类型 / lobby / 女儿墙;板 + 竖挺 + 角件,全 ground) | +| `tower.py` | 塔楼装配 | +| `export.py` | scadpkg / STEP / CSV / 渲染 | +| `verify/` | `sN_verify.py`、`render_stage.py`、`_common.py` | + +## 阶段 + +- **S1 布局引擎**(无几何) + - 输出边界: `build_schedule(params) -> FacadeSchedule`(JSON 可序列化)。 + - 验证: 每边 Σ宽 = 边长(1e-9);收口宽 ∈ [closer_min, closer_min + w_std/2);守卫矩阵(边过短 / 收口越界 / K<2 / 退台过深) + 恰等即拒;设计 WWR 与目标差 ≤ 0.03;板型数 / floor 类型数;三种平面都能出表。 + - 渲染: massing(tier 多边形拉伸 + lobby)四视图 + matplotlib 平面分格图(每 tier 的板/收口/角件着墨)。 +- **S2 板件族** + - 输出边界: 每 kind 至少一个板型单实体;玻璃面 tag。 + - 验证: V3 尺寸;框外面在 z=frame_d、building line 侧 z=0;缝 joint/2;玻璃 tag 面积 = 设计开口; + V5 竖挺角(各 bin);单实体 + brep valid。 + - 渲染: 每板型一张四视图(含一张 K 档竖挺并排)。 +- **S3 角件 + 楼板 + 雨棚** + - 验证: V7;角件多边形与相邻板端(布局值)缝 = joint;楼板内缩 slab_gap;雨棚在南边入口居中。 + - 渲染: 凸 90 / 凸 135 / 凹 270 角件各一 + 与两侧各一块板的局部拼装。 +- **S4 楼层子装配**(先 tier0 第 1 层) + - 验证: V1、V2(全层)、V4(本层)、ground 全部、solve 报告。 + - 渲染: 单层 iso + 顶视(缝可见)+ 角部特写。 +- **S5 塔楼装配** + - 验证: V1/V2 抽样(每 floor 类型一层 + 退台上下层)、V4 全楼、V5 包络、V6、V8、实例数 / 定义数。 + - 渲染: 四视图 + 南立面 + 退台特写。 +- **S6 合理化 / 尺度 / 平面扫描** + - 验证: K ∈ {2,3,4,6,8,12,24}:板型数单调、最大量化角误差 = 90/(2(K−1));层数 ∈ {8,20,36,48}(setback_every = ceil(n/3),保持 3 tier):标准板型集合不变; + RECT/CHAMFER/L_SHAPE 全过 V1/V4。 + - 产物: `out/rationalization.png`(板型数 vs 角误差)、`out/scale_sweep.json`。 +- **S7 增量重建** + - 验证: V9(cold → warm 全 hit;fin_depth 改 → miss = 竖挺件;n_floors 改 → 板型 hit);耗时表。 +- **S8 导出** + - 验证: V10;CSV 板块清单(type_id, kind, w, h, count)。 + +## Stage checklist + +```text +[x] Requirements +[x] Plan +[x] S1 layout [x] S1 verify [x] S1 render +[x] S2 panels [x] S2 verify [x] S2 render +[x] S3 corners [x] S3 verify [x] S3 render +[x] S4 floor [x] S4 verify [x] S4 render +[x] S5 tower [x] S5 verify [ ] S5 render +[ ] S6 sweeps [ ] S6 verify +[ ] S7 incremental [ ] S7 verify +[ ] S8 export [ ] S8 verify +``` + +## Feasibility probe(开工前,/tmp/pcg_probe) + +- 12 板型 × 3000 实例平铺单层装配:SDK 原 `OperationGraph.is_dag` 递归 DFS 在 ~1000 个 add_component 时 + RecursionError;`Assembly.__post_init__` 的 `ids.count` 去重使每次 add 为 O(N²)(3000 实例 69 s)。 + 已修(Kahn 迭代 + Counter)→ 3000 实例装配 12.6 s、capture 5.8 s、STEP 5.8 s(3000 occurrence,3.4 MB)。 +- 渲染 3000 个 Located 共享 TShape 的复合体 ~10 s。 +- `@scad.part` 的 part_id 必须等于装饰器 id → 每板型用 `scad.part(id=type_id)(builder)` 工厂。 +- 仍用楼层子装配分层:塔楼只放 ~40 个 floor 实例,floor 类型复用(tier × 相位)。 + +## Stage log + +- **S1**(`s1_verify.py` PASS,`s1_known_bad.py` 9/9 as designed) + - WWR 目标下调为 N 0.50 / E,W 0.40 / S 0.35:原 N 0.55 超出物理上限(框 + 层间 + 实体收口 → 全 vision 约 0.54), + 引擎现在对不可达目标直接报错。 + - WWR 分配改为**全楼按朝向合池** + **Bayer 秩选择**(按 4×4 阈值排序取前 round(f·N) 格):逐边 / 逐 tier 合池在 + 切角、缺口、顶 tier 短边上不可达;纯阈值法把比例量化到 1/16(误差到 0.024)。现三种平面 × 8/20/36 层误差 ≤ 0.005。 + - 默认 36 层 CHAMFER:26 板型(solid 12 / vision 8 / corner 6),标准 vision 7 = K+1,12 floor 类型,2735 实例。 + - 48 层 + 每 12 层退 3000 → 4 个 tier,CHAMFER 顶 tier 切角边 1029 < 2c+2·closer_min、L_SHAPE 缺口边反向 — + 守卫正确拒绝;S6 层数扫描改为 setback_every = ceil(n_floors/3)。 + - 观察(留到调参):W/E 立面 θ_sun = ±30 叠加幅值 30 → 大量饱和在 ±45 档。 +- **S2**(`s2_verify.py` PASS 20 型:solid 12 / vision 2 / fin 6;`s2_known_bad.py` 12/12 as designed) + - V3 逼出两处设计修改(回写 S1,S1 gate 加 C10 + 竖挺记录一致性检查,KB9/KB10): + 竖挺改为独立 Part(45° 挺装在板上 bbox ≈ 1676 > 1600)→ vision 板型不再乘 K,标准板型 = 2 + K 个竖挺件; + lobby 5400 > 4200 → 两排 2700。默认 CHAMFER 现 26 型(solid 12 / vision 2 / corner 6 / fin 6),3697 实例。 + - 框环 = 一张草图外环 + 1–2 个内环(全约束),玻璃 / 层间板为凹进板,向框内各伸 frame_sight/2 以融合成单实体。 + - SDK 行为:变换(translate/rotate)或后续布尔后,面 tag 只留在 lineage 作用域,`ql.tag(...)` 默认 effective 查不到; + 因此玻璃 tag 放在最后一步,渲染高亮用板局部坐标(不变换)。S4/S5 的 V4 用定义体上的 tag 面积 × 实例数。 +- **S3**(`s3_verify.py` PASS;`s3_known_bad.py` 14/14 as designed) + - 角件 9 型(turn −90 / +45 / +90 × h 2700 / 3600 / 1200);三种平面 × 每 (tier, 带类) 一带共 105 处角部在世界坐标实测: + 外面与两侧板带外面共面、building line 面、端面在 corner_c − joint/2,与两侧板最小距离 = joint。 + - K6 实测到 A7 的舍入:收口板按 0.001 mm 取整建型、按精确起点放置,其后的缝偏差 ≤ 3.1e-4 mm; + 缝容差取 A7 声明的 5e-4(不是放宽几何,是把 A7 写进 gate)。 + - 楼板:每 tier 一型,38 个实例(地坪 + 各层 + 各 tier 屋面;上 tier 首层立在下 tier 屋面上,不另设板); + 楼板与同层板件 / 角件最小距离 = slab_gap。雨棚 6 模数,两端落在 lobby 板缝上、居中、外挑起点距板外面 = joint, + 底面在 lobby 两排的横缝上。 + - SDK 修复(建筑尺度首次触发,均带回归测试 `test_building_scale_part_hashes_and_round_trips_its_state`): + ① 几何指纹按 1e-7 量化,36 m 楼板体积 / 1e-7 超出 2^53 → 哈希被 JSON 安全整数检查拒绝; + ② RFC 8785 把 < 1e21 的整值 double(楼板惯量 ≈ 1.2e19)写成无指数整数,读回 latest-parts 状态时被当作越界整数拒绝。 + - (S5 规划时回改)屋面板原按楼板惯例占 [roof, roof + slab_t],会切进上 tier 首层(其板件从 roof + j/2 起、位于上 tier + building line 上,而屋面板平面是下 tier 的)→ 屋面板改占 [roof − slab_t, roof](顶面 = 屋面 / 退台面,藏在顶层板带头部后)。 + K3 改写:屋面板顶 = 女儿墙标高、与顶层板件距离 = slab_gap、与上 tier 首层板件 / 角件 / 竖挺距离 = joint/2; + `s3_known_bad` 加 KB10(楼板式屋面板)→ 15/15。SDK 缺陷统一记在 `SDK_DEFECTS.md`。 +- **S4**(`s4_verify.py` PASS 全部 16 带;`s4_known_bad.py` 13/13 as designed,每例须触发列出的 gate) + - 带子装配 = `scad.assemble(id="band-")`,定义 = 本带用到的板型 Part(floor 带 11–12 个、lobby / 女儿墙 5 个); + 构件 id `r{row}-e{edge}-s{slot}` / `…-fin` / `r{row}-v{vertex}`,全部 ground。楼板、雨棚留给塔楼放置(REQUIREMENTS 6 已改)。 + - 验证在**求解后的 Placement** 上独立重建 gp_Trsf(不走布局帧),并与 SDK `make_compound_from_assembly` 的体积 / bbox 对账; + V1 每排每边「角件 | 板按 slot | 角件」沿 t 顺序 + 缝 = joint(A7 容差)+ z 跨度;V2 对 bbox 相交的全部构件对做 Common + (floor 带 22–70 对,含竖挺与所在板、lobby 上下排);V4 定义体 tag 玻璃面积 = 布局设计值。 + - 本带 WWR(仅报告,目标按全楼朝向合池,S5 检):tier0-phase0 E .530 / N .544 / S .439 / W .549,phase1/3 低至 .21–.35。 + - 重建全 warm(无 dirty instance,定义 hash 不变);16 带 cold + warm + 全部检查 88 s。 +- **S5**(`s5_verify.py` PASS;`s5_known_bad.py` 11/11 as designed) + - 塔楼 = `scad.assemble(id="facade-tower")`,79 构件(lobby + 36 floor + 3 女儿墙 + 38 楼板 / 屋面板 + 雨棚), + 20 定义(16 带 + 3 tier 楼板 + 雨棚),展开 3697 件 / 26 型;SDK 展平复合体 3736 实体(+ 板 / 雨棚),高 136.2 m。 + - 验证在「塔楼 placement × 带 placement」合成的 gp_Trsf 上重建世界几何:T0 构件 / 定义 / 叶子型集 / 件数; + T1 全部 placement 为纯 z 平移 + SDK 投影对账;V8 楼层 / 女儿墙 / 屋面板标高与 tier,建筑线内缩 = k·setback_depth; + V1 / V5 / V2 抽样 19 个实现(lobby、首末层、每 floor 类型首层、每次退台上下层、全部女儿墙),18 个接缝窗口 1845 对 Common。 + - V4 全楼(定义体 tag 玻璃面积 × 实例数):E .400 / N .499 / S .351 / W .398,与 `design_wwr` 一致、距目标 ≤ 0.001。 + - V6:标准板型 `solid-w1500-h3600` + `vision-w1500-h3600-s1100`,竖挺 6 型 = K。 + - known-bad 逼出的两处 gate 修正:KB2(缺一层)T1 是 SDK-对-我们的一致性检查,自洽时不该响 → 期望改 T0 + V4; + KB4(女儿墙放错 roof)按 schedule id 选接缝窗口会漏掉被放错的构件 → 窗口改为「实测 z 区间与窗口相交的全部构件」。 + - 耗时:塔楼 build 47.2 s,零改动 rebuild 45.8 s(warm 不比 cold 快,见 SDK_DEFECTS D7),全部检查 169 s。 diff --git a/examples/pcg_facade_tower/README.md b/examples/pcg_facade_tower/README.md new file mode 100644 index 00000000..9bfd424e --- /dev/null +++ b/examples/pcg_facade_tower/README.md @@ -0,0 +1,44 @@ +# pcg_facade_tower + +PCG 单元式幕墙塔楼:同一份参数化代码,任意平面轮廓(RECT / CHAMFER / L_SHAPE)、层数与退台, +自动生成**可加工**的外立面——板型 Part 只有一种身份, placement 才是数量(3,697 个实例落在 +22–43 个类型上,量化误差有硬上界)。设计策略全部写在代码里:分格规则、竖挺波场按 K 量化、 +窗墙比按朝向有序抖动到目标值、角件按转角+高度归型;不可行的参数直接 `LayoutError`,不静默降级。 + +## Run + +```bash +# 完整导出(包 + AP242 STEP + 板型清单 CSV + 主渲染),可带平面类型与层数 +../../.venv/bin/python export.py [rect|chamfer|l_shape] [n_floors] + +# 验证门(几何 / 布局 / 有效性 / 装配 / 导出回读),s1 最快,s4-s8 较慢 +../../.venv/bin/python verify/s1_verify.py +../../.venv/bin/python verify/s6_verify.py # 参数化扫描(K 量化 / 规模 / 平面) +../../.venv/bin/python verify/s6_verify.py --no-geometry +``` + +## 模块 + +- `layout.py` — 纯数据布局引擎:平面 → 退台 → 逐边分格 → 板型清单(所有程序化规则只在这里)。 +- `panels.py` / `corners.py` / `slabs.py` — 按 type id 的 Part 工厂(同参数即同一身份)。 +- `floor.py` / `tower.py` — 带 子装配(每 band 建一次,按标高平移放置)+ 塔楼装配。 +- `export.py` — scadpkg 闭包 + XCAF AP242 STEP(每个 definition 只写一次,occurrence 为定位引用) + + 板型清单 CSV + 主渲染。 +- `verify/` — s1 体量 / s2 板型 / s3 角件·楼板·雨棚 / s4 band 装配 / s5 塔楼 / s6 扫描 / + s7 重建计时 / s8 导出回读;`s*_known_bad.py` 证明每道门在故意做错的构件上会失败。 + +## 产物(out/,git 忽略) + +`facade_tower.scadpkg`、`facade_tower.step`、`facade_tower_panels.csv`、 +`rationalization.png`、`scale_sweep.json`、`incremental.json`、`verify/renders/*.png`。 + +## Known limitations + +- **L_SHAPE 全塔验证未闭合**:反射角(270°,转角 -90°)的角件轮廓为填充内凹缺口沿法向伸出 + 440 mm(> frame_d 150),在塔楼装配的 V5 包络检查(角件沿边法向 ≤ frame_d,为凸角所写)下 + 不成立——S6 的平面几何扫描因此只含 RECT / CHAMFER,L_SHAPE 目前仅做布局层验证 + (`facade_sweep` / s1 / s3 的 m90 角件局部框架检查)。`SDK_DEFECTS.md` D9 记录了该开放问题, + 欢迎贡献修复。 + +设计笔记:`REQUIREMENTS.md`(需求与验收门表)、`BUILD_PLAN.md`(布局引擎的构造顺序)、 +`SDK_DEFECTS.md`(把例子当压力测试记下的 SDK 设计问题;缓存时代的历史条目见文首时点说明)。 diff --git a/examples/pcg_facade_tower/REQUIREMENTS.md b/examples/pcg_facade_tower/REQUIREMENTS.md new file mode 100644 index 00000000..7cb512c8 --- /dev/null +++ b/examples/pcg_facade_tower/REQUIREMENTS.md @@ -0,0 +1,98 @@ +# REQUIREMENTS: pcg_facade_tower + +> 时点说明(2026-10-09):本文写于 SDK 移除 part cache(dev PR #17,notebook runtime 取代)之前。 +> 第 9 条与 V9 的「增量重建 / 命中 / solve 报告」需求描述的是旧设计;dev 上 S7 已改为全量重建计时报告, +> 装配层 A2 检查改为两次构建 definition hash 一致。其余几何 / 布局 / 有效性需求不受影响。 + +PCG 单元式幕墙塔楼:同一份代码,任意平面轮廓 / 层数 / 退台,自动生成**可加工**的外立面。 +论点:参数化的是**实例化**(少量板型 Part × 大量 placement),不是几何堆砌;GN 网格没有"板型"。 + +## CAD brief + +```text +- Model: pcg-facade-tower, assembly (panel/corner/slab Parts → floor subassemblies → tower) +- Inputs: prose (本会话讨论);inspiration, not reproduction +- Units: mm, degrees +- Coordinate convention: Z up;原点 = 地坪 z=0 ∩ 平面参考点;+X 东、+Y 北;平面多边形 CCW +- Overall dimensions (default CHAMFER plan): 36000 x 24000 底平面,NE 角 45° 切角 6000; + lobby 5400 + 36 层 x 3600 → 屋面 135000;每 12 层四边内退 3000(3 个 tier) +- Functional features: 标准板(vision + 遮阳竖挺 / vision 无挺 / solid 实体板)、收口板 closer、 + lobby 通高玻璃板、女儿墙板、角部收口件(凸角 90°/135°、凹角 270°)、楼板/屋面/退台板、主入口雨棚 +- Positioning/mating: 板件落在 building line(楼板外沿参考线)外侧 y∈[0, frame_d]; + placement 由布局引擎自具名基准(building line 顶点、楼层标高)计算;幕墙固定于结构 → 全部 ground +- Parameters: 见 layout.py FacadeParams(全部具名;板件尺寸经 builder 参数传入以进入 build_key) +- Paths: examples/pcg_facade_tower/{layout,panels,corners,slabs,floor,tower,export}.py;out/*.scadpkg|step|csv|png +- Validation targets: 见下文 V1–V10 +- Assumptions: 见下文 A1–A8 +``` + +## 设计参数(默认值) + +| 组 | 参数 | 默认 | 说明 | +| --- | --- | --- | --- | +| 平面 | `plan_kind` | CHAMFER | RECT / CHAMFER / L_SHAPE | +| | `plan_w`, `plan_d` | 36000, 24000 | X / Y 外包 | +| | `chamfer` | 6000 | CHAMFER:NE 角沿两边各切 6000(45° 边) | +| | `notch_w`, `notch_d` | 12000, 9000 | L_SHAPE:NE 角缺口 → 1 个 270° 凹角 | +| 竖向 | `n_floors` | 36 | lobby 之上的标准层数 | +| | `floor_h`, `lobby_h` | 3600, 5400 | | +| | `setback_every`, `setback_depth` | 12, 3000 | 每 tier 12 层,四边同时内退 | +| 模数 | `w_std` | 1500 | 标准板宽(含缝) | +| | `closer_min` | 450 | 收口板最小宽(含缝);两端对称 → 上限 closer_min + w_std/2(不含) | +| | `joint` | 20 | 板间缝(四周各 joint/2) | +| | `corner_c` | 450 | 角部收口件沿各边占宽(自 building line 顶点) | +| 板件 | `frame_d`, `frame_sight` | 150, 60 | 框深 / 框面宽 | +| | `spandrel_h` | 1100 | 层间实体区高(盖住楼板边) | +| | `glass_t`, `glass_recess` | 24, 40 | 玻璃厚 / 距框外面凹进 | +| 遮阳 | `fin_t`, `fin_depth` | 40, 300 | 竖挺厚 / 伸出深度(自框外面) | +| | `fin_amp`, `fin_wavelength`, `fin_period_floors` | 30°, 18000, 4 | 波场:θ = θ_sun(边) + A·sin(2π(u/λ + f/P)) | +| | `fin_bins` K | 6 | θ 量化到 [-45°, 45°] 的 K 档(合理化旋钮) | +| 窗墙比 | `wwr_target` | S 0.35 / E,W 0.40 / N 0.50 | 按边外法向归到 4 个朝向;全楼按朝向合池,上限约 0.54(不可达即报错) | +| 太阳 | `sun_azimuth` | 180° | 自北顺时针;朝北边(n·sun ≤ 0)无竖挺 | +| 板型 | `type_round_mm` | 1e-3 | 板型身份 = 参数四舍五入到 0.001 mm(闭合用精确值,误差 ≤ 5e-4 由缝吸收) | + +## 需求区 + +1. **布局引擎**(纯 Python,无几何):平面 → tier 退台多边形 → 每边:角部占宽 → 标准板数 n + 两端对称收口板 → + 每块板的 (floor, edge, s0, s1, kind, fin_bin) → 板型去重 → floor 类型去重(tier × 相位 f mod P)。 +2. **窗墙比分配**:每边 vision/solid 由有序抖动(4×4 Bayer,按 bay × floor)分配,使该朝向实测 WWR 贴近目标。 +3. **板件族**:vision(标准层带层间区 / lobby 无层间区)、solid(实体标准板、收口板、lobby 收口、女儿墙)、 + fin(K 档竖挺,独立 Part,现场挂在板右竖框上);板件单实体,玻璃面打 tag `facade.vision_glass`。 + lobby 5400 超运输高度 → 上下两排 2700 板(排数 = ceil(lobby_h / 4200))。 +4. **角部收口件**:由相邻两边方向 + frame_d + corner_c 计算的平面多边形 → 草图拉伸;凸角与凹角两族。 +5. **楼板 / 屋面 / 退台 / 雨棚**:tier 多边形内缩 slab_gap 拉伸;退台 = 下 tier 屋面;南向主入口雨棚。 +6. **楼层子装配**(带子装配):每个 floor 类型 / lobby / 各 tier 女儿墙一个 `scad.assemble` 定义,内含该带所有板、竖挺与角件(ground,placement 来自布局帧,带局部 z = 0 为本带标高;lobby 含上下两排);相同 floor 类型只建一次。楼板与雨棚由塔楼装配直接放置(上 tier 首层立在下 tier 屋面上,楼板不是 floor 类型的属性)。 +7. **塔楼装配**:lobby 层 + 36 标准层(floor 类型实例)+ 各 tier 屋面/女儿墙 + 雨棚。 +8. **合理化与尺度**:K 扫描(板型数 vs 最大角度偏差);层数扫描(8/20/36/48 层:标准板型数不增长);三种平面。 +9. **增量重建**:改 `fin_depth` → 只有竖挺件 miss,其余命中;改 `n_floors` → 板型全部命中。 +10. **导出**:scadpkg + AP242 STEP(共享定义)+ 板块清单 CSV + 渲染。 + +## 验证目标 + +| # | 检查 | 方法(几何实测,不信设计值) | +| --- | --- | --- | +| V1 | 立面闭合 | 每层每边:世界坐标下板/角件沿边区间(bbox 投影)首尾相接,缝 = joint ± 1e-3,无重叠;角件与两侧板缝 = joint | +| V2 | 零干涉 | 同层相邻件、相邻层上下件:采样分类 + Common 体积 = 0(抽样:每 floor 类型 1 层全查) | +| V3 | 运输尺寸 | 每板型 tight bbox ≤ 1600 x 4200 x 600(宽 x 高 x 深) | +| V4 | 窗墙比 | Σ(实例 × tag 玻璃面面积) / 朝向毛立面积(标准层板带),与目标差 ≤ 0.03 | +| V5 | 竖挺 | 竖挺侧面法向实测角 = bin 角 ± 0.05°;挺尖离 building line ≤ frame_d + fin_depth + 1 | +| V6 | 板型数 | 标准板型(vision + solid,w_std × floor_h)= 2,竖挺件 ≤ K;48 层与 8 层标准板型集合相同 | +| V7 | 角件 | 凸/凹角件为单实体、外轮廓与两侧板带外面共面(± 1e-3) | +| V8 | 楼层与退台 | 每层 z 标高、tier 退台深度、女儿墙位于 tier 屋面边 | +| V9 | 增量 | fin_depth 改动:miss 集合 = 竖挺件;n_floors 改动:板型全 hit;装配 solve 报告符合预期 | +| V10 | 导出 | 新进程重开 scadpkg:体积和 / 实例数 / 定义数一致;STEP occurrence 数 = 实例数 | + +## 假设(需要报告的) + +- A1 板件是"外观级"单元板:框 + 玻璃 + 层间板合为一个实体(Part 只含 1 个 Solid),不分铝/玻璃材质; + 不含锚件、密封胶、排水,不做结构/热工/风压分析(manufacturing-boundaries)。 +- A2 幕墙固定于结构,不建连接件约束:placement 由布局基准计算,全部 ground(intent ladder: placement tied to named datum)。 +- A3 building line 即楼板外沿参考线;板件在其外侧;楼板内缩 slab_gap = 30,楼层板占 [level, level + slab_t](藏在层间板后); + 屋面板占 [roof − slab_t, roof](顶面 = 屋面 / 退台面,藏在顶层板上沿后);上 tier 首层立在下 tier 屋面板上 + (其板件底 = roof + joint/2;若屋面板也取 [roof, roof + slab_t],会在上 tier building line 外被上 tier 首层板件穿过 — S5 规划时发现,S3 K3 补 terrace 检查 + KB10)。 +- A4 退台四边同距内退,保持平面形状;任何边内退后长度不足(< 2·corner_c + closer_min·2)时布局报错而非降级。 +- A5 收口板两端对称分布;收口板为实体板、无竖挺。 +- A6 朝北边无竖挺;竖挺位于每块板右侧竖框中线(沿边正向的末端),为独立的现场安装件: + 固定在板上时 45° 挺使单元宽约 1676 > 1600(V3),且 fin_depth 只应影响竖挺件(V9)。 +- A7 板型身份取参数到 0.001 mm 的四舍五入值(与上次讨论一致:身份用离散化,闭合与检查用精确值 + 容差)。 +- A8 渲染为灰度实体(玻璃不透明),用于形态评审,不是效果图。 diff --git a/examples/pcg_facade_tower/SDK_DEFECTS.md b/examples/pcg_facade_tower/SDK_DEFECTS.md new file mode 100644 index 00000000..1686aab6 --- /dev/null +++ b/examples/pcg_facade_tower/SDK_DEFECTS.md @@ -0,0 +1,178 @@ +# SDK 设计缺陷记录(pcg_facade_tower 构建过程中发现) + +> 时点说明(2026-10-09):本文各条针对写作时的 SDK(part cache + 装配 solve_report 时代,dev PR #17 之前)。 +> dev 已移除 part cache 与 solve_report(notebook runtime 取代):涉及二者的条目(如 D8、缓存命中路径、 +> 增量基线)自此成为历史记录,不再对应现行代码;几何 / 布局 / 布尔相关条目仍然有效。 + +目的:把 PCG 建筑尺度的例子当作压力测试,记下每处「SDK 本身设计上的问题」——症状、触发条件、根因、 +当前处置、修复方向——之后据此对症修复。末尾「启发」一节把单条缺陷归纳成可复用的排查规则 +(下次在别处先按这些规则找同类问题)。 + +状态:`已修(未提交)` = 工作区里有补丁 + 回归测试,未 commit;`绕开` = 例子侧规避,SDK 未改;`待修` = 只记录。 + +规模参照:默认 36 层 CHAMFER 塔 = 26 板型 Part、16 个带子装配(每个 70–250 构件)、塔楼 79 构件 / 20 定义、 +共 3697 个外立面件;楼板 36 m × 36 m。 + +--- + +## D1 `OperationGraph.is_dag` 递归 DFS → 长操作链 RecursionError + +- 状态:已修(未提交)— `src/simplecadapi/topology/model.py` +- 症状:单层装配 `add_component_rassembly` 约 1000 次后抛 `RecursionError`。 +- 根因:每次 add 是 feature graph 上的一个节点,链长 = 构件数;`is_dag` 用递归 DFS,深度 = 链长。 +- 修复:`_kahn_order()` 迭代 Kahn,`is_dag` / `topological_order` 共用(后者原本还先跑一遍 DFS 再跑 Kahn)。 + 顺带把 `queue.pop(0)`(O(N))换成 `deque.popleft()`。 +- 缺:没有「≥ 数千节点链」的回归测试。 + +## D2 id 去重用 `list.count` → 每次 add O(N²) + +- 状态:已修(未提交)— `product/assembly.py`、`product/connector.py`、`product/constraint.py` +- 症状:3000 实例平铺装配 69 s;修后 12.6 s。 +- 根因:`Assembly.__post_init__` 每次构造(不可变值,每 add 一次重建)都 `ids.count(id)` 查重 → 单次 O(N²),整条链 O(N³)。 +- 修复:`Counter`。 +- 遗留:不可变 Assembly 每 add 整体重建 + 全量校验本身仍是每次 O(N),整链 O(N²); + 应提供批量 API(`add_components_rassembly(components=[...])`)或增量校验。 + +## D3 几何指纹量化整数超出 JSON 安全整数范围 + +- 状态:已修(未提交)— `artifacts/geometry_interface.py`;回归测试 + `test/test_geometry_summary.py::test_building_scale_part_hashes_and_round_trips_its_state` +- 症状:36 m 楼板 Part 建型后求指纹失败(哈希被 JSON 安全整数检查拒绝)。 +- 根因:指纹把体积 / 面积 / 坐标按 1e-7 量化成 int;36e3² × 250 / 1e-7 ≈ 3e18 > 2^53。 + 量化步长是**绝对**的,隐含了「零件尺度」假设。 +- 修复:`_json_safe` 在哈希前把超界 int 编码为 `"int:<十进制>"`;范围内的值与旧哈希一致(不失效既有缓存)。 +- 更好的方向:量化步长相对于包围盒对角线(与 `geometry_summary` 的相对容差一致),根本不产生这种量级。 + +## D4 RFC 8785 状态文件读回时拒绝大整值 double + +- 状态:已修(未提交)— `scene/canonical.py`(同 D3 回归测试) +- 症状:写入 `latest-parts.json` 成功,下次读取时报越界整数。 +- 根因:JCS 把 < 1e21 的整值 double(楼板惯量 ≈ 1.18e19)打印成无指数的整数 token + `11773562012270215000`;`parse_strict_json` 按 Python int 读回 → 超 2^53 被拒 / 重新规范化不一致。 + 即「写」和「读」对数字域的约定不对称。 +- 修复:`parse_canonical_json` 用 `parse_int=_jcs_int`,超界整数 token 读成 double。 + +## D5 面 tag 经变换 / 后续布尔后退到 lineage 作用域,默认查询查不到 + +- 状态:绕开 — 例子把玻璃 tag 放在建型最后一步;渲染高亮用板局部坐标;V4 用「定义体 tag 面积 × 实例数」。 +- 症状:`translate` / `rotate` 或其后布尔之后,`ql.tag(...)`(默认 effective)返回空。 +- 根因:tag 绑定在具体面上,变换产生新面后只在 lineage 中可追溯;effective 作用域不跟随纯刚体变换。 +- 影响:装配展平(`make_compound_from_assembly_rcompound`)后的整楼上无法按 tag 选玻璃面—— + 「按语义选面」在装配层失效,而这恰是 PCG 统计(WWR)最需要的。 +- 修复方向:纯刚体变换应保持 effective tag(面一一对应,无歧义);或装配投影时按实例 placement 转发定义体 tag。 + +## D6 `@scad.part` 的 part_id 必须等于装饰器 id → 参数化族需要工厂写法 + +- 状态:绕开 — `scad.part(id=type_id, revision=..., project_root=ROOT)(builder)(**kwargs)`,每个板型动态造一个装饰器。 +- 根因:Part 身份 = 装饰器 id,而 PCG 的「类型」是运行时由参数算出来的(`solid-w1600-h3600`), + 装饰器在 import 时就要定 id。 +- 修复方向:官方支持 `part_family` / `id=lambda **kw: ...`,或文档化工厂写法作为推荐模式。 + +## D7 装配 geometry_summary 递归内嵌子摘要 + 每次 assemble 全量读写状态文件 → warm 比 cold 慢 + +- 状态:待修(本分支 commit 8b218d4 引入的摘要设计) +- 症状:塔楼 cold 构建 31 s,**warm 49 s**(无任何改动的重建反而更慢)。 +- 证据:`.simplecad/state/latest-assemblies.json` 4.7 MB,其中 facade-tower 一条 3.6 MB + (`public_interface.geometry_summary.instances` 2.6 MB、`dependency_interfaces` 1.0 MB)。 + profiler(~114 s 含开销):`load_latest_assembly_state` 57 s / 34 次,`canonical_json_bytes` 60 s, + `update_latest_assembly_state` 29 s,part cache 读取 22 s,`validate_assembly_definition_graph` 17 s。 +- 根因(两层叠加): + 1. `assembly_geometry_summary`(`artifacts/geometry_summary.py`,调用处 `build/assembly_builder.py` + `_assembly_geometry_summary`)把每个子定义的**完整摘要**按实例复制进父摘要:塔楼 ~40 个 floor 实例 × + 每个带摘要 50–115 KB。同一定义被实例化 N 次就存 N 份,嵌套再乘一层。 + 2. 每次 `assemble` 都把整个状态文件解析 + JCS 规范化校验一遍,写回时再全量序列化 → 代价 ∝ 调用次数 × 全项目状态大小, + 与本次改动无关。 +- 修复方向: + - 摘要里实例只存 `(instance_id, definition_id, placement)`,子摘要按 `definition_id` 去重存一份 + (或只存子摘要 hash,等价判定时按引用取);`geometry_summaries_equivalent` 相应按引用比较。 + - 状态存储按条目分文件(`state/assemblies/.json`)或进程内缓存 + 只写脏条目。 +- 需要的回归测试:N 个同定义实例的父摘要大小不随 N 线性增长;warm 重建耗时 ≤ cold。 +- S5 复测(z 区间窗口版 gate):塔楼 warm 构建 47.2 s、再 warm 45.8 s(零改动),而 3697 件的 S5 全部几何检查才 169 s —— + 一次「什么都没变」的重建占到完整验证的 ~1/4。S6 扫描 6 座塔 / S7 六步各付一次这个代价。 + +## D8 增量基线是「每个 assembly id 全项目唯一的 latest 槽」→ 参数族 / 多进程互相覆盖基线 + +- 状态:待修(设计层面) +- 症状:`solve_report.dirty_instances` / `geometry_dirty_instances` 的对比对象是 `latest-assemblies.json` 里该 + assembly id 的**最后一次**构建,不论是谁、以什么参数建的。S6 在同一 id `facade-tower` 下依次建 8/20/36/48 层 + + RECT/L_SHAPE,每次都把上一座当基线 → 报告的是「和上一个扫描点的差」,不是「和这组参数上次的差」;两个进程 + (例如 S6 扫描与 S7 增量 gate)同时跑时,彼此的基线在中途被替换,dirty 报告不可复现。 + 状态文件本身有 O_EXCL 锁(`artifacts/interface.py` `_state_lock`),所以不会写坏,但**语义**上互相污染。 +- 对照:part 缓存按 build_key 内容寻址,所有变体并存、回退即命中 —— 同一 SDK 里 part 层可以多变体、装配层只有一个槽。 +- 触发:PCG 参数族(同一 id 多参数集)、CI 并行、notebook 与脚本并行。 +- 修复方向:基线槽按 (assembly id, build_key/参数指纹) 分;或 `assemble(..., baseline=…)` 显式给出对比对象 + (上一次同参数 / 指定 build);`solve_report` 里写出基线来自哪次构建(build_key + 时间),让报告可解释。 +- 需要的回归测试:同一 id 交替构建参数 A、B、A → 第三次对 A 报告零 dirty。 +- 规避(本例):几何 gate 串行运行;S7 在同一进程里自建基线链(prime 步只建立基线、不判定)。 + +## D9 读 .scadpkg 的代价 ∝ 实例数 × 重复规范化 / 哈希 / schema 校验(读 20 s,materialize 只要 2.5 s) + +- 状态:待修 +- 症状:可行性探针的平铺包(12 板型 × 3000 实例,单层装配)在新进程里 `read_product_package` 20 s(profiler 下 40.7 s), + 其后 `materialize_definition` 仅 2.5 s —— 读包比把几何建出来还贵一个量级。 +- profiler(`read_product_package`,40.7 s): + - `_decode_definitions` 38.4 s;其中 `canonical_bytes` 5401 次 / 22.9 s、`content_hash` 3913 次 / 15.0 s + (次数 ≈ 实例数,而不是定义数);rfc8785 `dump` 递归 744 万次。 + - `validate_assembly_definition_graph` 20.1 s(读包时整图再验一遍,而 `_mark_package_validated` 之后 materialize 又查一次标记)。 + - `load_feature_graph_artifact` 13 次 / 17.2 s(`FeatureGraph.from_dict` 12.7 s、`__post_init__` 8.5 s)。 + - jsonschema `validate_manifest` 53 次 / 11.7 s(per-item `items` / `ref` 递归 58 万次)。 +- 根因:读路径把「完整性」拆成了逐对象、逐实例的重复工作:每个 component / placement 记录单独 JCS 规范化 + 哈希, + 同一份内容在 decode、图校验、feature graph 构造里各算一遍;schema 校验对大数组逐项走完整 `$ref` 解析。 +- 修复方向: + - 每个 blob 只按 zip 成员哈希一次(manifest 已记内容 hash),decode 后的派生对象不再重算 `content_hash`,缓存 canonical bytes。 + - 读包路径一次图校验,结果随对象携带(已有 `_validated` 标记的思路,推广到 assembly graph / feature graph)。 + - 实例数组用紧凑 schema(或跳过逐项 jsonschema、用类型化解码代替)。 +- 需要的回归测试:读包耗时对实例数近似线性且常数小(例如 3000 实例 < 2 s);同一 blob 在一次读中只哈希一次。 + +## D10 SDK 自己的输出类型不被兄弟 API 接受:`make_compound_from_assembly_rcompound` → `render_screenshot_rpath` 报错 + +- 状态:待修(小,但每次都撞) +- 症状:`render_screenshot_rpath(shapes=[scad.make_compound_from_assembly_rcompound(assembly=asm)])` → + `ValueError: render_screenshot_rpath supports Solid or raw TopoDS_Shape inputs`;要手动 `.wrapped` 降到 OCP 原始类型。 + 而且错误在构建 + 展平 3700 件(~5 min 的整个渲染脚本)之后才抛出。 +- 根因:渲染入口按 `Solid | TopoDS_Shape` 白名单判型,没有覆盖 SDK 自己的 `Compound` / `Shell` / `Face` 包装类型; + 没有「SDK 返回的几何值都可直接喂给 inspect / render」的类型闭包约定。 +- 修复方向:渲染 / 测量入口统一接受任何带 `.wrapped` 的 SDK 形状包装(或一个 `ShapeLike` 协议);输入判型放在最前面。 +- 需要的回归测试:对 SDK 每个返回形状的 `make_*` / `*_rcompound`,结果都能直接 render / measure。 + +## D11 反射角(270°)角件轮廓与 V5 包络规则冲突:L_SHAPE 全塔验证未闭合(开放,欢迎贡献) + +- 状态:开放问题(示例侧设计缺口,非 SDK 缺陷;2026-10-09 首次全塔验证时暴露) +- 症状:L_SHAPE 36F 全塔 S5 检查 40 项 V5 失败,全部同一模式——每个 band 在反射角顶点 v3 + `corner 440.0 > frame_d`(凸角全部通过)。归档的 `out/scale_sweep.json` 里 `plan: []`: + 作者从未完成平面几何扫描,该矛盾在入库前未被暴露。 +- 根因:`layout.corner_profile_local` 的凸角分支正确(miter 点在 frame_d,两腿 440 = + corner_c − joint/2,由分格规则预留);凹角分支为填充内凹缺口,轮廓沿入边法向伸出 440 + (local y[0, +440]),而 S5 的 V5 规则写的是「角件沿边法向 ≤ frame_d」——规则为凸角所写, + 两条约定在凹角处矛盾。s3 只在角件局部框架内验证 m90 角件(自洽),到塔楼 fit 才互相矛盾。 +- 修复方向(三选一,作者/贡献者拍板): + 1. 重写凹角轮廓,使角件收进两邻边的 frame_d 包络(改 `corner_profile_local` 的 turn<0 分支; + s3 的 m90 渲染与局部检查需随之重验); + 2. V5 为凹角加豁免:以「填满内凹缺口」的实测规则替代 frame_d 上限(改 s5_verify); + 3. 维持现状:L_SHAPE 仅做布局层验证(facade_sweep / s1 / s3),s6 平面几何扫描只含 + RECT / CHAMFER(当前实现,见 README Known limitations)。 +- 需要的回归测试:任一方案落地后,L_SHAPE 36F 全塔 S5 零失败(或豁免规则下的等价判定)。 + +--- + +## 启发(排查规则) + +1. **尺度假设**:凡是「绝对」量化步长 / 容差(1e-7、JSON 2^53、float→int)都要问:100 m 级、1e5 件级时还成立吗? + 优先相对于包围盒的容差。(D3、D4) +2. **读写对称**:序列化规范(JCS)写出来的每一种 token,解析器都必须按同一数域读回;给每种持久化格式做一次 + 极值 round-trip 测试。(D4) +3. **与用户规模成正比的递归**:图遍历、链式 feature、嵌套装配——深度随用户输入增长的地方一律迭代实现。(D1) +4. **不可变值 × 逐项追加**:每次追加都重建 + 全量校验 → 整链 O(N²);校验里再有 O(N) 查找就是 O(N³)。 + 找所有 `__post_init__` 中的 `.count` / `in list` / 全量扫描,并提供批量构造入口。(D2) +5. **实例化 ≠ 复制**:任何「按实例」记录的派生数据(摘要、接口、依赖)都应按定义去重、按引用链接; + 否则大小 ∝ 实例数 × 嵌套深度。(D7) +6. **缓存命中路径的代价**:命中路径必须 ≤ 未命中路径;凡是命中时仍要读 / 解析 / 规范化全局状态的,按项目规模退化。 + 给 warm/cold 耗时比加基准测试。(D7) +7. **语义标签要跟随刚体变换**:tag / 选择器在变换、装配投影后仍应可用,否则语义层在装配层失效。(D5) +8. **运行时类型族**:PCG 的类型是算出来的,身份(id)不能只能在 import 时声明。(D6) +9. **缓存各层的键要一致**:part 层内容寻址(多变体并存)、装配层 id 单槽(后写覆盖)——同一个「增量」概念在两层 + 语义不同,参数族与并行一来就暴露。凡是 “latest” 语义的全局状态,都要问:谁的 latest?(D8) +10. **完整性检查只做一次**:内容哈希 / 规范化 / schema 校验在一次读写里按「对象」计次而不是按「引用」计次; + 给每条 I/O 路径数一下同一 blob 被哈希了几次。验证结果要能随对象携带,别让下游再验。(D7、D9) +11. **API 类型闭包**:SDK 任一函数的返回值,应能直接作为其它 SDK 函数的同类输入;判型白名单要从「SDK 输出类型全集」 + 生成,不是手写。(D10) diff --git a/examples/pcg_facade_tower/corners.py b/examples/pcg_facade_tower/corners.py new file mode 100644 index 00000000..a50dc305 --- /dev/null +++ b/examples/pcg_facade_tower/corners.py @@ -0,0 +1,39 @@ +"""pcg_facade_tower corner pieces: one Part per (turn, height) type from the layout schedule. + +Corner-local frame (BUILD_PLAN): origin = building-line vertex, x = incoming edge tangent, y up (0 = band level), +z = incoming outward normal. The plan polygon (layout.corner_profile_local) is bounded by the building line, +both panel bands' outer faces (offset frame_d) and the two band end lines at corner_c - joint/2 from the vertex, +so the piece sits one joint off each neighbouring panel. Convex (turn > 0) and reflex (turn < 0) share the builder. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import simplecadapi as scad + +import layout as L +import panels as P + + +def corner_section(turn: float, corner_c: float, frame_d: float, joint: float) -> list[tuple[float, float]]: + return L.corner_profile_local(turn, SimpleNamespace(corner_c=corner_c, frame_d=frame_d, joint=joint)) + + +def build_corner(*, turn: float, h: float, corner_c: float, frame_d: float, joint: float): + pts = corner_section(turn, corner_c, frame_d, joint) + + # ---- feature: corner-post (build, profile=sketch) ---- + # XZ sketch: (u, v) -> corner-local (x, z); extruded up the band inside the joint + sk = P.polygon_loop(scad.make_sketch_rsketch(name="corner-post", plane="XZ"), "c", pts) + face = scad.make_face_from_sketch_rface(sketch=sk, profile="c_l0", require_fully_constrained=True) + post = scad.extrude_rsolid(profile=face, direction=(0.0, 1.0, 0.0), distance=h - joint) + return scad.translate_shape(shape=post, vector=(0.0, joint / 2.0, 0.0)) + + +def builder_args(spec: dict, p: L.FacadeParams) -> tuple: + return build_corner, dict(turn=spec["turn"], h=spec["h"], corner_c=p.corner_c, frame_d=p.frame_d, joint=p.joint) + + +def make_corner_part(type_id: str, spec: dict, p: L.FacadeParams): + builder, kwargs = builder_args(spec, p) + return scad.part(id=type_id, revision="1.0.0", project_root=P.ROOT)(builder)(**kwargs) diff --git a/examples/pcg_facade_tower/export.py b/examples/pcg_facade_tower/export.py new file mode 100644 index 00000000..e0793b82 --- /dev/null +++ b/examples/pcg_facade_tower/export.py @@ -0,0 +1,80 @@ +"""pcg_facade_tower export: product package, AP242 STEP (shared definitions), panel schedule CSV, hero render. + + ../../.venv/bin/python export.py [rect|chamfer|l_shape] [n_floors] + +out/.scadpkg the tower closure (tower -> bands -> panel / fin / corner types; slabs, canopy) +out/.step XCAF assembly: every definition written once, every occurrence a located reference +out/_panels.csv one row per facade piece type (type_id, family, sizes, count) — the fabrication list +out/.png iso from the south-west +""" +from __future__ import annotations + +import csv +import math +import sys +import time +from pathlib import Path + +import simplecadapi as scad + +import layout as L +import tower as T + +OUT = Path(__file__).resolve().parent / "out" +CSV_FIELDS = ("type_id", "family", "w_mm", "h_mm", "spandrel_mm", "fin_angle_deg", "corner_turn_deg", "count") + + +def panel_rows(sched: L.FacadeSchedule) -> list[dict]: + counts = sched.stats["instances_by_type"] + rows = [] + for tid in sorted(sched.types): + spec = sched.types[tid] + rows.append({"type_id": tid, "family": spec["family"], "w_mm": spec.get("w", ""), "h_mm": spec["h"], + "spandrel_mm": spec.get("spandrel", ""), "fin_angle_deg": spec.get("angle", ""), + "corner_turn_deg": spec.get("turn", ""), "count": counts[tid]}) + return rows + + +def write_csv(sched: L.FacadeSchedule, path: Path) -> Path: + with path.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS) + writer.writeheader() + writer.writerows(panel_rows(sched)) + return path + + +def export(sched: L.FacadeSchedule, stem: str = "facade_tower", render: bool = True) -> dict: + OUT.mkdir(exist_ok=True) + timings = {} + start = time.perf_counter() + result = T.make_tower(sched) + timings["build"] = time.perf_counter() - start + package = OUT / f"{stem}.scadpkg" + start = time.perf_counter() + scad.capture(result, package, include_scene=False) # the scene projection of 3700 pieces is not needed here + timings["capture"] = time.perf_counter() - start + start = time.perf_counter() + step = scad.exporter.export_product_package_to_step(package, OUT / f"{stem}.step") + timings["step"] = time.perf_counter() - start + paths = {"scadpkg": package, "step": step.output_path, "csv": write_csv(sched, OUT / f"{stem}_panels.csv")} + if render: + start = time.perf_counter() + whole = scad.make_compound_from_assembly_rcompound(assembly=result.value).wrapped + paths["png"] = Path(scad.render_screenshot_rpath( + shapes=[whole], output_path=str(OUT / f"{stem}.png"), view=(20.0, 225.0), view_up=(0.0, 0.0, 1.0), + show_callouts=False, show_legend=False)) + timings["render"] = time.perf_counter() - start + return {"result": result, "step_report": step, "paths": paths, "timings": timings} + + +if __name__ == "__main__": + kind = L.PlanKind(sys.argv[1]) if len(sys.argv) > 1 else L.PlanKind.CHAMFER + n = int(sys.argv[2]) if len(sys.argv) > 2 else None + params = (L.default_params(plan_kind=kind) if n is None else + L.default_params(plan_kind=kind, n_floors=n, setback_every=math.ceil(n / 3))) + s = L.build_schedule(params) + out = export(s, stem="facade_tower" if n is None and kind is L.PlanKind.CHAMFER else f"facade_tower_{kind.value}_{s.params.n_floors}f") + for name, path in out["paths"].items(): + print(f"{name:8s} {path} ({Path(path).stat().st_size / 1e6:.1f} MB)") + print(f"{out['step_report'].occurrence_count} STEP occurrences, {len(out['step_report'].definition_ids)} definitions; " + + ", ".join(f"{k} {v:.1f}s" for k, v in out["timings"].items())) diff --git a/examples/pcg_facade_tower/floor.py b/examples/pcg_facade_tower/floor.py new file mode 100644 index 00000000..c0d04b7e --- /dev/null +++ b/examples/pcg_facade_tower/floor.py @@ -0,0 +1,67 @@ +"""pcg_facade_tower band subassemblies: one `scad.assemble` definition per schedule band. + +A band (tier x phase floor band, the lobby, a tier's parapet) is realized many times up the tower, so it is built once +in band-local coordinates (plan axes = world, z = 0 at the band's level) and the tower places it by level. +Contents: every panel, fin and corner piece of the band, placed from the layout frames and grounded (A2); the lobby +holds its stacked rows. Slabs and the canopy are placed by the tower, not here: a tier's first floor stands on the roof +below, so a slab is not a property of the floor band. + +Component ids: r{row}-e{edge}-s{slot} (panel), r{row}-e{edge}-s{slot}-fin, r{row}-v{vertex} (corner). +""" +from __future__ import annotations + +import simplecadapi as scad + +import corners as C +import layout as L +import panels as P + + +def band_assembly_id(band_id: str) -> str: + return f"band-{band_id}" + + +def band_components(sched: L.FacadeSchedule, band: dict) -> list[dict]: + """[{component_id, type_id, frame}] in band-local coordinates (layout frames at the in-band row offsets).""" + out = [] + for row in range(band["rows"]): + z = row * band["height"] + out += [{"component_id": f"r{row}-e{r['edge']}-s{r['slot']}", "type_id": r["type_id"], + "frame": L.panel_frame(sched, band, r, z)} for r in band["panels"]] + out += [{"component_id": f"r{row}-e{r['edge']}-s{r['slot']}-fin", "type_id": r["type_id"], + "frame": L.fin_frame(sched, band, r, z)} for r in band["fins"]] + out += [{"component_id": f"r{row}-v{r['vertex']}", "type_id": r["type_id"], + "frame": L.corner_frame(sched, band, r, z)} for r in band["corners"]] + return out + + +def type_parts(sched: L.FacadeSchedule, type_ids) -> dict: + """type id -> PartBuildResult (one part per type id; identical parameters rebuild identically).""" + out = {} + for tid in sorted(set(type_ids)): + spec = sched.types[tid] + make = C.make_corner_part if spec["family"] == "corner" else P.make_panel_part + out[tid] = make(tid, spec, sched.params) + return out + + +def make_band_assembly(sched: L.FacadeSchedule, band_id: str) -> scad.AssemblyBuildResult: + band = sched.band(band_id) + comps = band_components(sched, band) + parts = type_parts(sched, (c["type_id"] for c in comps)) + aid = band_assembly_id(band_id) + + @scad.assemble(id=aid, revision="1.0.0", definitions=tuple(parts.values()), project_root=P.ROOT) + def build(band_id: str) -> scad.Assembly: + # ---- feature: band-components (assemble) ---- + # every piece placed from its layout frame (x = edge tangent, y = up, z = outward) and grounded + asm = scad.make_assembly_rassembly(assembly_id=aid, name=f"facade band {band_id}") + for c in comps: + f = c["frame"] + asm = scad.add_component_rassembly( + assembly=asm, item=parts[c["type_id"]].value, component_id=c["component_id"], + placement=scad.make_placement_rplacement(origin=f["origin"], x_axis=f["x_axis"], y_axis=f["y_axis"])) + asm = scad.ground_component_rassembly(assembly=asm, component_id=c["component_id"]) + return asm + + return build(band_id=band_id) diff --git a/examples/pcg_facade_tower/layout.py b/examples/pcg_facade_tower/layout.py new file mode 100644 index 00000000..8b4305ce --- /dev/null +++ b/examples/pcg_facade_tower/layout.py @@ -0,0 +1,719 @@ +"""PCG layout engine: plan -> setback tiers -> per-edge module subdivision -> panel schedule. + +Pure data, no geometry. Every geometric file consumes the FacadeSchedule this module emits; this is the one +place where the procedural rules live (subdivision, closers, corners, fin wave field + quantization, ordered +dithering for the window-to-wall ratio, type and floor-type rationalization). + +Frames (BUILD_PLAN datum): + world Z up, +X east, +Y north, origin = ground level at the plan's SW corner; plan polygons are CCW. + edge i from vertex i to i+1, tangent t_i, outward normal n_i = (t_y, -t_x). + panel local x along the edge (0 = left module boundary), y up (0 = level), z outward (0 = building line). + placement: origin = V_i + s0*t_i + level*Z, x_axis = t_i, y_axis = Z -> z_axis = t_i x Z = n_i. + corner local x = incoming tangent, y up, z = incoming normal; origin = the building-line vertex. + fin panel orientation; origin = right-jamb centreline on the frame outer face (panel-local + (w - joint/2 - frame_sight/2, joint/2, frame_d)). Fins are separate site-attached Parts: fixed to the + panel, a 45 deg fin pushes the unit to ~1676 mm wide, past the 1600 transport envelope (V3). +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass, field, replace +from enum import Enum +from typing import Any + +import simplecadapi as scad + +# ---- params ---- +PLAN_W = scad.var("plan_w", 36000.0, comment="平面 X 外包(东西)", unit="mm") +PLAN_D = scad.var("plan_d", 24000.0, comment="平面 Y 外包(南北)", unit="mm") +CHAMFER = scad.var("chamfer", 6000.0, comment="CHAMFER:NE 角沿两边各切", unit="mm") +NOTCH_W = scad.var("notch_w", 12000.0, comment="L_SHAPE:NE 缺口 X 宽", unit="mm") +NOTCH_D = scad.var("notch_d", 9000.0, comment="L_SHAPE:NE 缺口 Y 深", unit="mm") +N_FLOORS = scad.var("n_floors", 36, comment="lobby 之上的标准层数") +FLOOR_H = scad.var("floor_h", 3600.0, comment="标准层高", unit="mm") +LOBBY_H = scad.var("lobby_h", 5400.0, comment="首层 lobby 层高", unit="mm") +SETBACK_EVERY = scad.var("setback_every", 12, comment="每 tier 层数") +SETBACK_DEPTH = scad.var("setback_depth", 3000.0, comment="每 tier 四边内退", unit="mm") +PARAPET_H = scad.var("parapet_h", 1200.0, comment="女儿墙高", unit="mm") +W_STD = scad.var("w_std", 1500.0, comment="标准板模数宽(含缝)", unit="mm") +CLOSER_MIN = scad.var("closer_min", 450.0, comment="收口板最小模数宽(含缝)", unit="mm") +JOINT = scad.var("joint", 20.0, comment="板缝(四周各 joint/2)", unit="mm") +CORNER_C = scad.var("corner_c", 450.0, comment="角件沿各边占宽(自 building line 顶点)", unit="mm") +FRAME_D = scad.var("frame_d", 150.0, comment="框深(building line 外)", unit="mm") +FRAME_SIGHT = scad.var("frame_sight", 60.0, comment="框面宽", unit="mm") +SPANDREL_H = scad.var("spandrel_h", 1100.0, comment="层间实体区高", unit="mm") +GLASS_T = scad.var("glass_t", 24.0, comment="玻璃厚", unit="mm") +GLASS_RECESS = scad.var("glass_recess", 40.0, comment="玻璃外面距框外面凹进", unit="mm") +FIN_T = scad.var("fin_t", 40.0, comment="竖挺厚", unit="mm") +FIN_DEPTH = scad.var("fin_depth", 300.0, comment="竖挺伸出深度(自框外面)", unit="mm") +FIN_AMP = scad.var("fin_amp", 30.0, comment="波场幅值", unit="deg") +FIN_WAVELENGTH = scad.var("fin_wavelength", 18000.0, comment="波场沿周长波长", unit="mm") +FIN_PERIOD_FLOORS = scad.var("fin_period_floors", 4, comment="波场 / 抖动竖向周期(层),须为 4 的倍数") +FIN_BINS = scad.var("fin_bins", 6, comment="竖挺角量化档数 K(合理化旋钮)") +SUN_AZIMUTH = scad.var("sun_azimuth", 180.0, comment="太阳方位角(自北顺时针)", unit="deg") +SLAB_T = scad.var("slab_t", 300.0, comment="楼板厚", unit="mm") +SLAB_GAP = scad.var("slab_gap", 30.0, comment="楼板外沿距 building line 内缩", unit="mm") +CANOPY_MODULES = scad.var("canopy_modules", 6, comment="南向入口雨棚跨越的标准模数(奇偶自动对齐到板缝)") +CANOPY_DEPTH = scad.var("canopy_depth", 3000.0, comment="雨棚外挑(自板外面 + 缝)", unit="mm") +CANOPY_T = scad.var("canopy_t", 250.0, comment="雨棚厚", unit="mm") + +FIN_LIMIT = 45.0 # 竖挺角范围 [-45°, 45°](0 = 垂直立面) +SUN_THETA_LIMIT = 30.0 # 朝向基准角 θ_sun 的限幅 +FIN_FACING_MIN = -0.2 # n·sun > -0.2 的边带竖挺(E/W/S),其余(N、NE 切角)无挺 +TYPE_ROUND_DIGITS = 3 # 板型身份:宽/角四舍五入到 0.001(A7) +WWR_TOL = 0.03 +MAX_PANEL_H = 4200.0 # transport envelope height (V3); taller bands (lobby) split into stacked rows +BAYER4 = ((0, 8, 2, 10), (12, 4, 14, 6), (3, 11, 1, 9), (15, 7, 13, 5)) + + +class PlanKind(str, Enum): + RECT = "rect" + CHAMFER = "chamfer" + L_SHAPE = "l_shape" + + +class Band(str, Enum): + LOBBY = "lobby" + TOWER = "tower" + PARAPET = "parapet" + + +def _value(value: object) -> float: + evaluate = getattr(value, "evaluate", None) + return float(evaluate()) if callable(evaluate) else float(value) + + +@dataclass(frozen=True) +class FacadeParams: + plan_kind: PlanKind = PlanKind.CHAMFER + plan_w: float = _value(PLAN_W) + plan_d: float = _value(PLAN_D) + chamfer: float = _value(CHAMFER) + notch_w: float = _value(NOTCH_W) + notch_d: float = _value(NOTCH_D) + n_floors: int = int(_value(N_FLOORS)) + floor_h: float = _value(FLOOR_H) + lobby_h: float = _value(LOBBY_H) + setback_every: int = int(_value(SETBACK_EVERY)) + setback_depth: float = _value(SETBACK_DEPTH) + parapet_h: float = _value(PARAPET_H) + w_std: float = _value(W_STD) + closer_min: float = _value(CLOSER_MIN) + joint: float = _value(JOINT) + corner_c: float = _value(CORNER_C) + frame_d: float = _value(FRAME_D) + frame_sight: float = _value(FRAME_SIGHT) + spandrel_h: float = _value(SPANDREL_H) + glass_t: float = _value(GLASS_T) + glass_recess: float = _value(GLASS_RECESS) + fin_t: float = _value(FIN_T) + fin_depth: float = _value(FIN_DEPTH) + fin_amp: float = _value(FIN_AMP) + fin_wavelength: float = _value(FIN_WAVELENGTH) + fin_period_floors: int = int(_value(FIN_PERIOD_FLOORS)) + fin_bins: int = int(_value(FIN_BINS)) + sun_azimuth: float = _value(SUN_AZIMUTH) + slab_t: float = _value(SLAB_T) + slab_gap: float = _value(SLAB_GAP) + canopy_modules: int = int(_value(CANOPY_MODULES)) + canopy_depth: float = _value(CANOPY_DEPTH) + canopy_t: float = _value(CANOPY_T) + # glass / gross per orientation; ceiling ~0.54 (frames + spandrel + solid closers), south lowest for solar gain + wwr_target: tuple = (("N", 0.50), ("E", 0.40), ("S", 0.35), ("W", 0.40)) + + def to_dict(self) -> dict: + data = asdict(self) + data["plan_kind"] = self.plan_kind.value + data["wwr_target"] = dict(self.wwr_target) + return data + + +def default_params(**overrides: Any) -> FacadeParams: + return replace(FacadeParams(), **overrides) + + +# ---------------------------------------------------------------- guards + + +class LayoutError(ValueError): + """Infeasible parameter set (raised instead of silently degrading the layout).""" + + +def assert_params(p: FacadeParams) -> None: + def need(ok: bool, message: str) -> None: + if not ok: + raise LayoutError(message) + + positive = ("plan_w", "plan_d", "floor_h", "lobby_h", "parapet_h", "w_std", "closer_min", "joint", "corner_c", + "frame_d", "frame_sight", "glass_t", "fin_t", "fin_depth", "fin_wavelength", "slab_t") + for name in positive: + need(math.isfinite(getattr(p, name)) and getattr(p, name) > 0.0, f"{name} must be positive") + need(p.n_floors >= 1 and p.setback_every >= 1, "n_floors / setback_every must be >= 1") + need(p.setback_depth >= 0.0 and p.slab_gap >= 0.0, "setback_depth / slab_gap must be >= 0") + need(p.fin_bins >= 2, "fin_bins must be >= 2") + need(p.fin_period_floors >= 4 and p.fin_period_floors % 4 == 0, "fin_period_floors must be a multiple of 4") + need(p.closer_min > p.joint + 2.0 * p.frame_sight, "closer_min must leave a frame inside the joint") + need(p.closer_min < p.w_std, "closer_min must be < w_std") + need(p.corner_c > p.frame_d + p.joint, "corner_c must exceed frame_d + joint (concave corner bands overlap)") + need(p.spandrel_h + 2.0 * p.frame_sight + p.joint < p.floor_h, "spandrel_h leaves no vision opening") + need(p.glass_recess + p.glass_t < p.frame_d, "glass must sit inside the frame depth") + need(p.fin_t < p.w_std / 2.0, "fin_t too thick for the module") + need(p.floor_h <= MAX_PANEL_H and p.parapet_h <= MAX_PANEL_H, "floor_h / parapet_h exceed the transport height") + # slab occupies [level, level + slab_t] and must hide behind the spandrel (A3); the parapet hides the roof slab + need(p.slab_t < p.spandrel_h - p.frame_sight / 2.0, "slab_t shows below the spandrel") + need(p.slab_t < p.parapet_h - p.joint / 2.0 - p.frame_sight, "slab_t shows above the parapet") + need(p.slab_gap > 0.0, "slab_gap must keep the slab off the building line") + need(p.canopy_modules >= 1 and p.canopy_depth > 0.0 and p.canopy_t > 0.0, "canopy size") + need(p.canopy_t < p.lobby_h / math.ceil(p.lobby_h / MAX_PANEL_H - 1.0e-9), "canopy_t taller than a lobby row") + need(0.0 <= p.fin_amp <= FIN_LIMIT, "fin_amp must lie in [0, 45]") + if p.plan_kind is PlanKind.CHAMFER: + need(0.0 < p.chamfer < min(p.plan_w, p.plan_d), "chamfer must lie inside the plan") + if p.plan_kind is PlanKind.L_SHAPE: + need(0.0 < p.notch_w < p.plan_w and 0.0 < p.notch_d < p.plan_d, "notch must lie inside the plan") + need(all(0.0 < value <= 1.0 for _, value in p.wwr_target), "wwr targets must lie in (0, 1]") + + +# ---------------------------------------------------------------- plan + tiers + + +def base_plan(p: FacadeParams) -> list[tuple[float, float]]: + w, d = p.plan_w, p.plan_d + if p.plan_kind is PlanKind.RECT: + return [(0.0, 0.0), (w, 0.0), (w, d), (0.0, d)] + if p.plan_kind is PlanKind.CHAMFER: + c = p.chamfer + return [(0.0, 0.0), (w, 0.0), (w, d - c), (w - c, d), (0.0, d)] + nw, nd = p.notch_w, p.notch_d + return [(0.0, 0.0), (w, 0.0), (w, d - nd), (w - nw, d - nd), (w - nw, d), (0.0, d)] + + +def _sub(a, b): + return (a[0] - b[0], a[1] - b[1]) + + +def _add(a, b): + return (a[0] + b[0], a[1] + b[1]) + + +def _mul(a, k): + return (a[0] * k, a[1] * k) + + +def _dot(a, b): + return a[0] * b[0] + a[1] * b[1] + + +def _cross(a, b): + return a[0] * b[1] - a[1] * b[0] + + +def _unit(a): + n = math.hypot(*a) + return (a[0] / n, a[1] / n) + + +def edge_frames(poly: list) -> list[dict]: + """Per edge: start vertex, tangent, outward normal, length.""" + out = [] + for i, v0 in enumerate(poly): + v1 = poly[(i + 1) % len(poly)] + t = _unit(_sub(v1, v0)) + out.append({"v0": v0, "v1": v1, "t": t, "n": (t[1], -t[0]), "length": math.hypot(*_sub(v1, v0))}) + return out + + +def signed_area(poly: list) -> float: + return 0.5 * sum(_cross(poly[i], poly[(i + 1) % len(poly)]) for i in range(len(poly))) + + +def _segments_cross(a0, a1, b0, b1) -> bool: + d1, d2 = _cross(_sub(a1, a0), _sub(b0, a0)), _cross(_sub(a1, a0), _sub(b1, a0)) + d3, d4 = _cross(_sub(b1, b0), _sub(a0, b0)), _cross(_sub(b1, b0), _sub(a1, b0)) + return d1 * d2 < 0.0 and d3 * d4 < 0.0 + + +def is_simple(poly: list) -> bool: + n = len(poly) + for i in range(n): + for j in range(i + 2, n): + if i == 0 and j == n - 1: + continue + if _segments_cross(poly[i], poly[(i + 1) % n], poly[j], poly[(j + 1) % n]): + return False + return True + + +def offset_polygon(poly: list, d: float) -> list: + """Move every edge inward by d (edge lines shift along -n); vertex = intersection of adjacent offset lines.""" + frames = edge_frames(poly) + out = [] + for i in range(len(poly)): + n_in, n_out = frames[i - 1]["n"], frames[i]["n"] + k = 1.0 + _dot(n_in, n_out) + if k < 1.0e-6: + raise LayoutError("edge reversal: adjacent edges are anti-parallel") + out.append(_sub(poly[i], _mul(_add(n_in, n_out), d / k))) + return out + + +def tier_plans(p: FacadeParams) -> list[list]: + base = base_plan(p) + n_tiers = (p.n_floors + p.setback_every - 1) // p.setback_every + plans = [] + for k in range(n_tiers): + poly = offset_polygon(base, k * p.setback_depth) if k else base + before, after = edge_frames(base), edge_frames(poly) + for b, a in zip(before, after): + if _dot(b["t"], a["t"]) < 0.999999 or a["length"] <= 0.0: + raise LayoutError(f"tier {k}: setback collapses an edge") + if signed_area(poly) <= 0.0 or not is_simple(poly): + raise LayoutError(f"tier {k}: setback polygon is not simple / CCW") + plans.append(poly) + return plans + + +# ---------------------------------------------------------------- corners + + +def corner_turn(frames: list, i: int) -> float: + """Signed turn at vertex i (incoming edge i-1 -> outgoing edge i), degrees; + = convex (left turn).""" + t_in, t_out = frames[i - 1]["t"], frames[i]["t"] + return math.degrees(math.atan2(_cross(t_in, t_out), _dot(t_in, t_out))) + + +def corner_profile_local(turn_deg: float, p: FacadeParams, joint: float | None = None) -> list[tuple[float, float]]: + """Corner piece plan polygon in the corner frame (x = incoming tangent, second coord = incoming normal). + + Bounded by the building line (two reserves of corner_c - joint/2 each side of the vertex), the two panel-band + outer faces (offset frame_d) and the band end lines. Returned CCW in (x, n) coordinates.""" + j = p.joint if joint is None else joint + c, fd = p.corner_c - j / 2.0, p.frame_d + a = math.radians(turn_deg) + # Plan coords with the incoming edge along +x; a CCW plan puts the outward normal at -y. + t_in, n_in = (1.0, 0.0), (0.0, -1.0) + t_out = (math.cos(a), math.sin(a)) + n_out = (t_out[1], -t_out[0]) + k = 1.0 + _dot(n_in, n_out) + if k < 1.0e-6: + raise LayoutError("corner too sharp") + v = (0.0, 0.0) + a0 = _mul(t_in, -c) + a1 = _add(a0, _mul(n_in, fd)) + apex = _mul(_add(n_in, n_out), fd / k) + b1 = _add(_mul(t_out, c), _mul(n_out, fd)) + b0 = _mul(t_out, c) + poly = [a0, a1, apex, b1, b0, v] + if signed_area(poly) < 0.0: + poly.reverse() + if not is_simple(poly): + raise LayoutError(f"corner profile not simple at turn {turn_deg:.3f}") + # (x, y) -> (x, outward = -y); the mirror flips orientation, so reverse to stay CCW + local = [(x, -y) for x, y in poly] + return list(reversed(local)) + + +# ---------------------------------------------------------------- field + quantization + + +def fin_bin_angles(k: int) -> list[float]: + return [-FIN_LIMIT + 2.0 * FIN_LIMIT * i / (k - 1) for i in range(k)] + + +def quantize_fin(theta: float, k: int) -> tuple[int, float]: + theta = max(-FIN_LIMIT, min(FIN_LIMIT, theta)) + index = int(round((theta + FIN_LIMIT) / (2.0 * FIN_LIMIT) * (k - 1))) + return index, fin_bin_angles(k)[index] + + +def sun_vector(p: FacadeParams) -> tuple[float, float]: + az = math.radians(p.sun_azimuth) + return (math.sin(az), math.cos(az)) + + +def edge_has_fins(n, p: FacadeParams) -> bool: + return _dot(n, sun_vector(p)) > FIN_FACING_MIN + + +def theta_sun(n, p: FacadeParams) -> float: + s = sun_vector(p) + phi = math.degrees(math.atan2(_cross(n, s), _dot(n, s))) + return max(-SUN_THETA_LIMIT, min(SUN_THETA_LIMIT, phi)) + + +def orientation(n) -> str: + """N/E/S/W by outward normal; ties (45° edges) go to N/S (|ny| >= |nx|).""" + if abs(n[1]) >= abs(n[0]): + return "N" if n[1] > 0.0 else "S" + return "E" if n[0] > 0.0 else "W" + + +# ---------------------------------------------------------------- subdivision + + +def subdivide_edge(length: float, p: FacadeParams) -> dict: + """[corner c][closer][n x w_std][closer][corner c]; closers symmetric and in [closer_min, closer_min + w_std/2).""" + eff = length - 2.0 * p.corner_c + if eff < 2.0 * p.closer_min - 1.0e-9: + raise LayoutError(f"edge {length:.3f} too short for two corners and two closers") + n = int(math.floor((eff - 2.0 * p.closer_min) / p.w_std + 1.0e-9)) + closer = (eff - n * p.w_std) / 2.0 + widths = [closer] + [p.w_std] * n + [closer] + starts, s = [], p.corner_c + for w in widths: + starts.append(s) + s += w + return {"n_std": n, "closer": closer, "widths": widths, "starts": starts} + + +def _r(value: float) -> float: + return round(value, TYPE_ROUND_DIGITS) + + +def _mm(value: float) -> str: + text = f"{_r(value):.3f}".rstrip("0").rstrip(".") + return text.replace("-", "m") + + +def solid_type_id(w: float, h: float) -> str: + return f"solid-w{_mm(w)}-h{_mm(h)}" + + +def vision_type_id(w: float, h: float, spandrel: float) -> str: + return f"vision-w{_mm(w)}-h{_mm(h)}-s{_mm(spandrel)}" + + +def fin_type_id(angle: float, h: float) -> str: + return f"fin-a{_mm(angle)}-h{_mm(h)}" + + +def fin_root_s(s0: float, w: float, p: FacadeParams) -> float: + """Fin root along the edge: centreline of the panel's right jamb (A6).""" + return s0 + w - p.joint / 2.0 - p.frame_sight / 2.0 + + +def corner_type_id(turn: float, h: float) -> str: + return f"corner-t{_mm(turn)}-h{_mm(h)}" + + +# ---------------------------------------------------------------- schedule + + +@dataclass +class FacadeSchedule: + params: FacadeParams + tiers: list # tier polygons + levels: dict # band / floor -> level z + types: dict = field(default_factory=dict) # type_id -> spec + bands: list = field(default_factory=list) # band records (floor bands, lobby, parapets) + floor_types: dict = field(default_factory=dict) # floor_type_id -> {"tier", "phase", "band"} + floors: list = field(default_factory=list) # [{"floor", "tier", "level", "floor_type"}] + stats: dict = field(default_factory=dict) + vision_fraction: dict = field(default_factory=dict) # orientation -> share of standard modules that are vision + + def to_dict(self) -> dict: + return {"params": self.params.to_dict(), "tiers": self.tiers, "levels": self.levels, "types": self.types, + "bands": self.bands, "floor_types": self.floor_types, "floors": self.floors, "stats": self.stats, + "vision_fraction": self.vision_fraction} + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=1, ensure_ascii=False) + + def band(self, band_id: str) -> dict: + return next(b for b in self.bands if b["band_id"] == band_id) + + +def _register(types: dict, type_id: str, spec: dict) -> str: + known = types.setdefault(type_id, spec) + if known != spec: + raise LayoutError(f"type id collision: {type_id}") + return type_id + + +def _glass_area(w: float, h: float, spandrel: float, p: FacadeParams) -> float: + """Design vision opening of a vision panel (module w x h). + + Horizontally inside the jambs; vertically from the transom (centred on the spandrel line) or, with no + spandrel, the sill frame, up to the head frame.""" + gw = w - p.joint - 2.0 * p.frame_sight + top = h - p.joint / 2.0 - p.frame_sight + bottom = spandrel + p.frame_sight / 2.0 if spandrel > 0.0 else p.joint / 2.0 + p.frame_sight + return gw * (top - bottom) + + +def _band_panels(tier_poly, band: Band, height: float, p: FacadeParams, types: dict, phase: int | None, + vision_cells: set | None) -> tuple[list, list, list]: + """Panels, corners and fins of one horizontal band on one tier polygon (level-independent: local to the band).""" + frames = edge_frames(tier_poly) + panels, corners, fins_out = [], [], [] + u0 = 0.0 + for e, fr in enumerate(frames): + sub = subdivide_edge(fr["length"], p) + fins, facing = edge_has_fins(fr["n"], p), orientation(fr["n"]) + for k, (s0, w) in enumerate(zip(sub["starts"], sub["widths"])): + is_closer = k in (0, len(sub["widths"]) - 1) + record = {"edge": e, "slot": k, "s0": s0, "w": w, "orientation": facing} + if band is Band.PARAPET or is_closer: + record.update(kind="solid", type_id=_register( + types, solid_type_id(w, height), {"family": "solid", "w": _r(w), "h": _r(height)})) + elif band is Band.LOBBY: + record.update(kind="vision", type_id=_register(types, vision_type_id(w, height, 0.0), { + "family": "vision", "w": _r(w), "h": _r(height), "spandrel": 0.0})) + else: + if (e, k) in vision_cells: + fin = None + if fins: + u = u0 + s0 + w / 2.0 + raw = theta_sun(fr["n"], p) + p.fin_amp * math.sin( + 2.0 * math.pi * (u / p.fin_wavelength + phase / p.fin_period_floors)) + _, fin = quantize_fin(raw, p.fin_bins) + record["fin_raw"] = raw + fins_out.append({"edge": e, "slot": k, "s": fin_root_s(s0, w, p), "angle": fin, + "type_id": _register(types, fin_type_id(fin, height), + {"family": "fin", "angle": _r(fin), "h": _r(height)})}) + record.update(kind="vision", fin=fin, type_id=_register( + types, vision_type_id(w, height, p.spandrel_h), + {"family": "vision", "w": _r(w), "h": _r(height), "spandrel": _r(p.spandrel_h)})) + else: + record.update(kind="solid", type_id=_register( + types, solid_type_id(w, height), {"family": "solid", "w": _r(w), "h": _r(height)})) + panels.append(record) + u0 += fr["length"] + for i in range(len(frames)): + turn = corner_turn(frames, i) + corners.append({"vertex": i, "turn": turn, "type_id": _register( + types, corner_type_id(turn, height), {"family": "corner", "turn": _r(turn), "h": _r(height)})}) + return panels, corners, fins_out + + +def _vision_cells(tier_poly, p: FacadeParams, fractions: dict) -> dict: + """phase -> {(edge, slot)} of standard modules that are vision on this tier. + + Ordered dithering by rank: every (phase, standard module) cell of one orientation is ranked by its 4x4 Bayer + threshold (column = running module index along that orientation, continuous across edges; row = phase), then + exactly round(fraction x cells) lowest-ranked cells become vision. Rank selection hits the count to one cell + (plain thresholding would quantize the fraction to 1/16) while keeping the Bayer spread.""" + cells: dict = {} + column: dict = {} + for e, fr in enumerate(edge_frames(tier_poly)): + o = orientation(fr["n"]) + for k in range(1, subdivide_edge(fr["length"], p)["n_std"] + 1): + bay = column[o] = column.get(o, -1) + 1 + for phase in range(p.fin_period_floors): + rank = (BAYER4[phase % 4][bay % 4], phase // 4, bay // 4) + cells.setdefault(o, []).append((rank, phase, e, k)) + chosen: dict = {phase: set() for phase in range(p.fin_period_floors)} + for o, items in cells.items(): + items.sort() + for _, phase, e, k in items[:int(round(fractions[o] * len(items)))]: + chosen[phase].add((e, k)) + return chosen + + +def _vision_fractions(tiers: list, floors_per_tier: list, p: FacadeParams) -> dict: + """Per orientation: fraction of standard modules that are vision. + + Pooled over the whole tower (every edge of that orientation on every floor): short chamfer / notch / upper-tier + edges carry proportionally more solid closer area, so a per-edge or per-tier target is often unreachable while + the orientation total (what V4 measures) is not.""" + targets = dict(p.wwr_target) + g = _glass_area(p.w_std, p.floor_h, p.spandrel_h, p) + gross, n_std = {}, {} + for poly, reps in zip(tiers, floors_per_tier): + for fr in edge_frames(poly): + o = orientation(fr["n"]) + gross[o] = gross.get(o, 0.0) + reps * (fr["length"] - 2.0 * p.corner_c) * p.floor_h + n_std[o] = n_std.get(o, 0) + reps * subdivide_edge(fr["length"], p)["n_std"] + fraction = {} + for o in gross: + fraction[o] = targets[o] * gross[o] / g / n_std[o] if n_std[o] else 0.0 + if fraction[o] > 1.0: + raise LayoutError(f"WWR target {o}={targets[o]} unreachable: needs {fraction[o]:.3f} of modules as vision") + return fraction + + +def build_schedule(p: FacadeParams | None = None) -> FacadeSchedule: + p = p or default_params() + assert_params(p) + tiers = tier_plans(p) + types: dict = {} + levels = {"lobby": 0.0} + for f in range(1, p.n_floors + 1): + levels[f"floor{f}"] = p.lobby_h + (f - 1) * p.floor_h + sched = FacadeSchedule(params=p, tiers=[list(map(list, t)) for t in tiers], levels=levels) + + rows = math.ceil(p.lobby_h / MAX_PANEL_H - 1.0e-9) + panels, corners, fins = _band_panels(tiers[0], Band.LOBBY, p.lobby_h / rows, p, types, None, None) + sched.bands.append({"band_id": "lobby", "band": Band.LOBBY.value, "tier": 0, "height": p.lobby_h / rows, + "rows": rows, "level": 0.0, "panels": panels, "corners": corners, "fins": fins}) + floors_per_tier = [sum(1 for f in range(1, p.n_floors + 1) if (f - 1) // p.setback_every == k) + for k in range(len(tiers))] + fractions = _vision_fractions(tiers, floors_per_tier, p) + for k, poly in enumerate(tiers): + vision = _vision_cells(poly, p, fractions) + for phase in range(p.fin_period_floors): + floors = [f for f in range(1, p.n_floors + 1) + if (f - 1) // p.setback_every == k and (f - 1) % p.fin_period_floors == phase] + if not floors: + continue + band_id = f"tier{k}-phase{phase}" + panels, corners, fins = _band_panels(poly, Band.TOWER, p.floor_h, p, types, phase, vision[phase]) + sched.bands.append({"band_id": band_id, "band": Band.TOWER.value, "tier": k, "phase": phase, + "height": p.floor_h, "rows": 1, "panels": panels, "corners": corners, "fins": fins}) + sched.floor_types[band_id] = {"tier": k, "phase": phase, "floors": floors} + panels, corners, fins = _band_panels(poly, Band.PARAPET, p.parapet_h, p, types, None, None) + top = max(f for f in range(1, p.n_floors + 1) if (f - 1) // p.setback_every == k) + levels[f"roof{k}"] = p.lobby_h + top * p.floor_h + sched.bands.append({"band_id": f"parapet{k}", "band": Band.PARAPET.value, "tier": k, "height": p.parapet_h, + "rows": 1, "panels": panels, "corners": corners, "fins": fins, "level": levels[f"roof{k}"]}) + for f in range(1, p.n_floors + 1): + k, phase = (f - 1) // p.setback_every, (f - 1) % p.fin_period_floors + sched.floors.append({"floor": f, "tier": k, "level": levels[f"floor{f}"], "floor_type": f"tier{k}-phase{phase}"}) + sched.vision_fraction = fractions + sched.types = types + sched.stats = schedule_stats(sched) + return sched + + +# ---------------------------------------------------------------- design-side statistics (S1 evidence) + + +def band_instances(sched: FacadeSchedule, band_id: str) -> int: + """How many times a band is realized (floor bands repeat per floor; lobby per stacked row; parapet once).""" + b = sched.band(band_id) + return len(sched.floor_types[band_id]["floors"]) if b["band"] == Band.TOWER.value else b["rows"] + + +def design_wwr(sched: FacadeSchedule) -> dict: + """Glass / gross area per orientation over the tower floor bands (design values; S5 re-measures on geometry).""" + p = sched.params + glass, gross = {}, {} + for b in sched.bands: + if b["band"] != Band.TOWER.value: + continue + reps = band_instances(sched, b["band_id"]) + frames = edge_frames(sched.tiers[b["tier"]]) + for e, fr in enumerate(frames): + o = orientation(fr["n"]) + gross[o] = gross.get(o, 0.0) + reps * (fr["length"] - 2.0 * p.corner_c) * p.floor_h + for rec in b["panels"]: + if rec["kind"] == "vision": + glass[rec["orientation"]] = glass.get(rec["orientation"], 0.0) + reps * _glass_area( + rec["w"], p.floor_h, p.spandrel_h, p) + return {o: glass.get(o, 0.0) / gross[o] for o in sorted(gross)} + + +def schedule_stats(sched: FacadeSchedule) -> dict: + counts: dict = {} + for b in sched.bands: + reps = band_instances(sched, b["band_id"]) + for rec in b["panels"] + b["corners"] + b["fins"]: + counts[rec["type_id"]] = counts.get(rec["type_id"], 0) + reps + fam = {} + for tid, spec in sched.types.items(): + fam[spec["family"]] = fam.get(spec["family"], 0) + 1 + p = sched.params + std_types = sorted(t for t, s in sched.types.items() + if s["family"] in ("vision", "solid") and s["w"] == _r(p.w_std) and s["h"] == _r(p.floor_h)) + fin_types = sorted(t for t, s in sched.types.items() if s["family"] == "fin") + fin_err = [abs(max(-FIN_LIMIT, min(FIN_LIMIT, rec["fin_raw"])) - rec["fin"]) + for b in sched.bands for rec in b["panels"] if "fin_raw" in rec] + return { + "n_types": len(sched.types), + "types_by_family": fam, + "standard_panel_types": std_types, + "fin_types": fin_types, + "n_instances": sum(counts.values()), + "instances_by_type": dict(sorted(counts.items())), + "n_floor_types": len(sched.floor_types), + "n_floors": sched.params.n_floors, + "n_tiers": len(sched.tiers), + "design_wwr": design_wwr(sched), + "max_fin_quant_error": max(fin_err) if fin_err else 0.0, + "height": sched.levels[f"roof{len(sched.tiers) - 1}"] + sched.params.parapet_h, + } + + +if __name__ == "__main__": + s = build_schedule() + print(json.dumps({k: v for k, v in s.stats.items() if k != "instances_by_type"}, indent=1)) + + +# ---------------------------------------------------------------- world frames (placement data; y_axis is always +Z) + + +def _frame(origin2, level: float, t) -> dict: + return {"origin": (origin2[0], origin2[1], level), "x_axis": (t[0], t[1], 0.0), "y_axis": (0.0, 0.0, 1.0)} + + +def band_levels(sched: FacadeSchedule, band_id: str) -> list[float]: + """Base level of every realization of a band (per floor; per lobby row; the parapet's roof).""" + b = sched.band(band_id) + if b["band"] == Band.TOWER.value: + return [sched.levels[f"floor{f}"] for f in sched.floor_types[band_id]["floors"]] + if b["band"] == Band.LOBBY.value: + return [row * b["height"] for row in range(b["rows"])] + return [b["level"]] + + +def panel_frame(sched: FacadeSchedule, band: dict, rec: dict, level: float) -> dict: + fr = edge_frames(sched.tiers[band["tier"]])[rec["edge"]] + return _frame(_add(fr["v0"], _mul(fr["t"], rec["s0"])), level, fr["t"]) + + +def fin_frame(sched: FacadeSchedule, band: dict, rec: dict, level: float) -> dict: + fr = edge_frames(sched.tiers[band["tier"]])[rec["edge"]] + root = _add(_add(fr["v0"], _mul(fr["t"], rec["s"])), _mul(fr["n"], sched.params.frame_d)) + return _frame(root, level, fr["t"]) + + +def corner_frame(sched: FacadeSchedule, band: dict, rec: dict, level: float) -> dict: + """x = incoming edge tangent, origin = the building-line vertex.""" + poly = sched.tiers[band["tier"]] + frames = edge_frames(poly) + return _frame(tuple(poly[rec["vertex"]]), level, frames[rec["vertex"] - 1]["t"]) + + +# ---------------------------------------------------------------- slabs + canopy (data) + + +def slab_plans(sched: FacadeSchedule) -> list: + """Per tier: the building line pulled in by slab_gap (floor slabs and that tier's roof share it).""" + return [offset_polygon([tuple(v) for v in poly], sched.params.slab_gap) for poly in sched.tiers] + + +def slab_instances(sched: FacadeSchedule) -> list[dict]: + """Slab occupies [level, level + slab_t]: floor slabs from their floor level (behind the spandrel), roof slabs + below the roof level (top = the roof / terrace surface, behind the top floor's head). A tier's first floor stands + on the tier below's roof, so it gets no slab of its own; its panels start j/2 above that roof, which a floor-style + slab at the roof level would cut through outside the upper building line.""" + p = sched.params + out = [{"tier": 0, "level": 0.0, "role": "ground"}] + for rec in sched.floors: + if rec["tier"] > 0 and (rec["floor"] - 1) % p.setback_every == 0: + continue + out.append({"tier": rec["tier"], "level": rec["level"], "role": f"floor{rec['floor']}"}) + for k in range(len(sched.tiers)): + out.append({"tier": k, "level": sched.levels[f"roof{k}"] - p.slab_t, "role": f"roof{k}"}) + return out + + +def canopy_box(sched: FacadeSchedule) -> dict: + """South entrance canopy over the lobby's central modules on edge 0, snapped to module joints. + Plan x in [x0, x1] along edge 0, y in [-(frame_d + joint + depth), -(frame_d + joint)], z = [z0, z0 + canopy_t] + with z0 on the lobby row joint.""" + p = sched.params + lobby = sched.band("lobby") + fr = edge_frames(sched.tiers[0])[0] + if abs(fr["t"][0] - 1.0) > 1.0e-12 or fr["n"][1] > -1.0 + 1.0e-12: + raise LayoutError("edge 0 is not the south edge") + inner = sorted((r for r in lobby["panels"] if r["edge"] == 0), key=lambda r: r["slot"])[1:-1] + m = p.canopy_modules if (len(inner) - p.canopy_modules) % 2 == 0 else p.canopy_modules - 1 + if m < 1 or m > len(inner): + raise LayoutError(f"canopy spans {m} of {len(inner)} south modules") + first = (len(inner) - m) // 2 + x0 = fr["v0"][0] + inner[first]["s0"] + x1 = fr["v0"][0] + inner[first + m - 1]["s0"] + inner[first + m - 1]["w"] + y1 = fr["v0"][1] - (p.frame_d + p.joint) + z0 = lobby["height"] if lobby["rows"] > 1 else lobby["height"] - p.canopy_t + return {"x0": x0, "x1": x1, "y0": y1 - p.canopy_depth, "y1": y1, "z0": z0, "z1": z0 + p.canopy_t, "modules": m} diff --git a/examples/pcg_facade_tower/panels.py b/examples/pcg_facade_tower/panels.py new file mode 100644 index 00000000..4401a691 --- /dev/null +++ b/examples/pcg_facade_tower/panels.py @@ -0,0 +1,209 @@ +"""pcg_facade_tower panel family: one Part per panel type id from the layout schedule. + +Panel-local frame (BUILD_PLAN): x along the building edge (0 = left joint centreline), y up (0 = floor level), +z outward (0 = building line). The module is [0, w] x [0, h]; the unit body sits inside the joint, +[j/2, w - j/2] x [j/2, h - j/2] x [0, frame_d]. + +Fin-local frame: same axes as the panel; origin = right-jamb centreline on the frame outer face. +Fins are separate site-attached Parts (see layout.py header): theta > 0 tilts the tip toward +x. + +Families + vision frame ring (vision opening + spandrel opening when spandrel > 0) + recessed glass + spandrel infill + solid frame ring (one opening) + opaque infill (closers, parapets, the Bayer-solid modules) + fin parallelogram blade: root flush on the frame face, sides at theta, thickness fin_t, length fin_depth +""" +from __future__ import annotations + +import math +from pathlib import Path + +import simplecadapi as scad +from simplecadapi import ql + +import layout as L + +ROOT = Path(__file__).resolve().parent +GLASS_TAG = "facade.vision_glass" +PLANE_TOL = 1.0e-3 + + +# ---------------------------------------------------------------- sketch helpers + + +def _rect_loop(sk, key: str, x0: float, y0: float, x1: float, y1: float): + """Closed, fully constrained rectangle loop; profile id = f"{key}_lb" (its first line).""" + for pid, x, y in (("a", x0, y0), ("b", x1, y0), ("c", x1, y1), ("d", x0, y1)): + sk = scad.add_point_rsketch(sketch=sk, point_id=f"{key}_{pid}", x=x, y=y) + for lid, s, e in (("lb", "a", "b"), ("lr", "b", "c"), ("lt", "c", "d"), ("ll", "d", "a")): + sk = scad.add_line_rsketch(sketch=sk, entity_id=f"{key}_{lid}", start=f"{key}_{s}", end=f"{key}_{e}") + sk = scad.constrain_fix_rsketch(sketch=sk, target=f"{key}_a") + sk = scad.constrain_horizontal_rsketch(sketch=sk, line=f"{key}_lb") + sk = scad.constrain_vertical_rsketch(sketch=sk, line=f"{key}_lr") + sk = scad.constrain_horizontal_rsketch(sketch=sk, line=f"{key}_lt") + sk = scad.constrain_vertical_rsketch(sketch=sk, line=f"{key}_ll") + sk = scad.constrain_distance_x_rsketch(sketch=sk, a=f"{key}_a", b=f"{key}_b", value=x1 - x0) + sk = scad.constrain_distance_y_rsketch(sketch=sk, a=f"{key}_a", b=f"{key}_d", value=y1 - y0) + return sk + + +def polygon_loop(sk, key: str, pts): + """Closed polygon loop, fully constrained: first vertex fixed, the rest dimensioned x / y from it. + Profile id = f"{key}_l0".""" + n = len(pts) + for i, (x, y) in enumerate(pts): + sk = scad.add_point_rsketch(sketch=sk, point_id=f"{key}_p{i}", x=x, y=y) + for i in range(n): + sk = scad.add_line_rsketch(sketch=sk, entity_id=f"{key}_l{i}", start=f"{key}_p{i}", end=f"{key}_p{(i + 1) % n}") + sk = scad.constrain_fix_rsketch(sketch=sk, target=f"{key}_p0") + for i in range(1, n): + sk = scad.constrain_distance_x_rsketch(sketch=sk, a=f"{key}_p0", b=f"{key}_p{i}", value=pts[i][0] - pts[0][0]) + sk = scad.constrain_distance_y_rsketch(sketch=sk, a=f"{key}_p0", b=f"{key}_p{i}", value=pts[i][1] - pts[0][1]) + return sk + + +def _rect_plate(name: str, x0: float, y0: float, x1: float, y1: float, z0: float, z1: float): + """profile=sketch: one constrained rectangle extruded from z0 to z1.""" + sk = _rect_loop(scad.make_sketch_rsketch(name=name, plane="XY"), "o", x0, y0, x1, y1) + face = scad.make_face_from_sketch_rface(sketch=sk, profile="o_lb", require_fully_constrained=True) + plate = scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=z1 - z0) + return scad.translate_shape(shape=plate, vector=(0.0, 0.0, z0)) + + +# ---------------------------------------------------------------- design openings (shared with the verifier) + + +def openings(w: float, h: float, spandrel: float, frame_sight: float, joint: float) -> dict: + """Clear openings in panel-local (x0, y0, x1, y1). 'vision' only when the panel carries glass.""" + j, s = joint, frame_sight + x0, x1 = j / 2.0 + s, w - j / 2.0 - s + top = h - j / 2.0 - s + out = {} + if spandrel > 0.0: + out["vision"] = (x0, spandrel + s / 2.0, x1, top) + out["spandrel"] = (x0, j / 2.0 + s, x1, spandrel - s / 2.0) + else: + out["vision"] = (x0, j / 2.0 + s, x1, top) + return out + + +def _grow(r, d: float): + return (r[0] - d, r[1] - d, r[2] + d, r[3] + d) + + +def _frame_ring(w, h, holes: dict, frame_d, joint): + j = joint + sk = _rect_loop(scad.make_sketch_rsketch(name="frame-ring", plane="XY"), "o", j / 2.0, j / 2.0, w - j / 2.0, + h - j / 2.0) + for key, r in holes.items(): + sk = _rect_loop(sk, key, *r) + face = scad.make_face_from_sketch_rface(sketch=sk, profile="o_lb", inner_profiles=tuple(f"{k}_lb" for k in holes), + require_fully_constrained=True) + return scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=frame_d) + + +def _tag_front_face(body, rect, z: float, tag: str): + """Resolve the +Z planar face at z whose centre lies inside rect, then tag it (exactly one).""" + x0, y0, x1, y1 = rect + faces = ql.faces().where(ql.and_( + ql.prop("geom.normal.z", ">=", 1.0 - 1.0e-6), + ql.prop("geom.center.z", ">=", z - PLANE_TOL), ql.prop("geom.center.z", "<=", z + PLANE_TOL), + ql.prop("geom.center.x", ">=", x0), ql.prop("geom.center.x", "<=", x1), + ql.prop("geom.center.y", ">=", y0), ql.prop("geom.center.y", "<=", y1), + )).resolve(body) + if len(faces) != 1: + raise ValueError(f"{tag}: expected one glass face at z={z}, found {len(faces)}") + return scad.apply_tag_rselection(scope=body, targets=list(faces), tag=tag) + + +# ---------------------------------------------------------------- builders (every dimension is a builder argument) + + +def build_vision(*, w: float, h: float, spandrel: float, frame_d: float, frame_sight: float, joint: float, + glass_t: float, glass_recess: float): + s = frame_sight + ops = openings(w, h, spandrel, frame_sight, joint) + glass_z1 = frame_d - glass_recess + glass_z0 = glass_z1 - glass_t + + # ---- feature: frame-ring (build, profile=sketch) ---- + body = _frame_ring(w, h, ops, frame_d, joint) + + # ---- feature: spandrel-infill (add, profile=sketch) ---- + # opaque infill sits half a recess proud of the glass so it reads as a separate plane + if "spandrel" in ops: + body = scad.union_rsolid(body, _rect_plate("spandrel-infill", *_grow(ops["spandrel"], s / 2.0), glass_z0, + frame_d - glass_recess / 2.0)) + + # ---- feature: vision-glass (add, profile=sketch) ---- + # grown by s/2 into the jambs so the plate bonds to the ring; its exposed front = the clear opening + body = scad.union_rsolid(body, _rect_plate("vision-glass", *_grow(ops["vision"], s / 2.0), glass_z0, glass_z1)) + + # ---- feature: vision-glass-name (annotate) ---- + return _tag_front_face(body, ops["vision"], glass_z1, GLASS_TAG) + + +def build_solid(*, w: float, h: float, frame_d: float, frame_sight: float, joint: float, glass_t: float, + glass_recess: float): + s = frame_sight + opening = openings(w, h, 0.0, frame_sight, joint)["vision"] + + # ---- feature: frame-ring (build, profile=sketch) ---- + body = _frame_ring(w, h, {"op": opening}, frame_d, joint) + + # ---- feature: opaque-infill (add, profile=sketch) ---- + return scad.union_rsolid(body, _rect_plate("opaque-infill", *_grow(opening, s / 2.0), + frame_d - glass_recess - glass_t, frame_d - glass_recess / 2.0)) + + +def fin_section(angle: float, fin_t: float, fin_depth: float) -> list[tuple[float, float]]: + """Parallelogram in fin-local (x, z): root on z = 0 centred on x = 0, sides along (sin a, cos a).""" + th = math.radians(angle) + half = fin_t / (2.0 * math.cos(th)) + dx, dz = fin_depth * math.sin(th), fin_depth * math.cos(th) + return [(-half, 0.0), (half, 0.0), (half + dx, dz), (-half + dx, dz)] + + +def build_fin(*, angle: float, h: float, joint: float, fin_t: float, fin_depth: float): + (r0x, _), (r1x, _), (t1x, t1z), (t0x, _) = fin_section(angle, fin_t, fin_depth) + + # ---- feature: fin-blade (build, profile=sketch) ---- + # XZ sketch: (u, v) -> world (x, z); explicit x / z offsets keep the sign of the angle unambiguous + sk = scad.make_sketch_rsketch(name="fin-blade", plane="XZ") + for pid, x, z in (("r0", r0x, 0.0), ("r1", r1x, 0.0), ("t1", t1x, t1z), ("t0", t0x, t1z)): + sk = scad.add_point_rsketch(sketch=sk, point_id=pid, x=x, y=z) + for lid, a, b in (("root", "r0", "r1"), ("side_r", "r1", "t1"), ("tip", "t1", "t0"), ("side_l", "t0", "r0")): + sk = scad.add_line_rsketch(sketch=sk, entity_id=lid, start=a, end=b) + sk = scad.constrain_fix_rsketch(sketch=sk, target="r0") + sk = scad.constrain_horizontal_rsketch(sketch=sk, line="root") + sk = scad.constrain_horizontal_rsketch(sketch=sk, line="tip") + sk = scad.constrain_distance_x_rsketch(sketch=sk, a="r0", b="r1", value=r1x - r0x) + sk = scad.constrain_distance_x_rsketch(sketch=sk, a="r0", b="t0", value=t0x - r0x) + sk = scad.constrain_distance_y_rsketch(sketch=sk, a="r0", b="t0", value=t1z) + sk = scad.constrain_distance_x_rsketch(sketch=sk, a="t0", b="t1", value=t1x - t0x) + face = scad.make_face_from_sketch_rface(sketch=sk, profile="root", require_fully_constrained=True) + blade = scad.extrude_rsolid(profile=face, direction=(0.0, 1.0, 0.0), distance=h - joint) + return scad.translate_shape(shape=blade, vector=(0.0, joint / 2.0, 0.0)) + + +# ---------------------------------------------------------------- type table -> Part + + +def builder_args(spec: dict, p: L.FacadeParams) -> tuple: + """(builder, kwargs) for one type spec. Every value that shapes the solid is an argument (build_key).""" + common = dict(frame_d=p.frame_d, frame_sight=p.frame_sight, joint=p.joint, glass_t=p.glass_t, + glass_recess=p.glass_recess) + fam = spec["family"] + if fam == "vision": + return build_vision, dict(w=spec["w"], h=spec["h"], spandrel=spec["spandrel"], **common) + if fam == "solid": + return build_solid, dict(w=spec["w"], h=spec["h"], **common) + if fam == "fin": + return build_fin, dict(angle=spec["angle"], h=spec["h"], joint=p.joint, fin_t=p.fin_t, + fin_depth=p.fin_depth) + raise ValueError(f"not a panel family: {fam}") + + +def make_panel_part(type_id: str, spec: dict, p: L.FacadeParams): + """Part factory: part id == type id — equal parameters are the same part identity, rebuilt identically.""" + builder, kwargs = builder_args(spec, p) + return scad.part(id=type_id, revision="1.0.0", project_root=ROOT)(builder)(**kwargs) diff --git a/examples/pcg_facade_tower/slabs.py b/examples/pcg_facade_tower/slabs.py new file mode 100644 index 00000000..6d04488b --- /dev/null +++ b/examples/pcg_facade_tower/slabs.py @@ -0,0 +1,54 @@ +"""pcg_facade_tower slabs and the entrance canopy. + +Slab-local frame = world axes, z = 0 at the slab's level: each tier's building line pulled in by slab_gap, +extruded slab_t up (A3: the slab occupies [level, level + slab_t]; floor slabs from the floor level, roof slabs +ending at the roof; `layout.slab_instances`). +Floor slabs and the tier's roof share one Part per tier; placement only translates by the level. +Canopy-local frame = world (it is built where it stands; placed with the identity). +""" +from __future__ import annotations + +import simplecadapi as scad + +import layout as L +import panels as P + + +def slab_type_id(tier: int) -> str: + return f"slab-tier{tier}" + + +CANOPY_ID = "canopy-south" + + +def build_slab(*, plan: tuple, slab_t: float): + # ---- feature: slab-plate (build, profile=sketch) ---- + sk = P.polygon_loop(scad.make_sketch_rsketch(name="slab-plate", plane="XY"), "s", plan) + face = scad.make_face_from_sketch_rface(sketch=sk, profile="s_l0", require_fully_constrained=True) + return scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=slab_t) + + +def build_canopy(*, x0: float, x1: float, y0: float, y1: float, z0: float, z1: float): + # ---- feature: canopy-plate (build, profile=sketch) ---- + sk = P._rect_loop(scad.make_sketch_rsketch(name="canopy-plate", plane="XY"), "o", x0, y0, x1, y1) + face = scad.make_face_from_sketch_rface(sketch=sk, profile="o_lb", require_fully_constrained=True) + plate = scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=z1 - z0) + return scad.translate_shape(shape=plate, vector=(0.0, 0.0, z0)) + + +def slab_args(sched: L.FacadeSchedule, tier: int) -> dict: + plan = tuple((round(x, 6), round(y, 6)) for x, y in L.slab_plans(sched)[tier]) + return dict(plan=plan, slab_t=sched.params.slab_t) + + +def canopy_args(sched: L.FacadeSchedule) -> dict: + box = L.canopy_box(sched) + return {k: box[k] for k in ("x0", "x1", "y0", "y1", "z0", "z1")} + + +def make_slab_part(sched: L.FacadeSchedule, tier: int): + return scad.part(id=slab_type_id(tier), revision="1.0.0", project_root=P.ROOT)(build_slab)(**slab_args(sched, tier)) + + +def make_canopy_part(sched: L.FacadeSchedule): + return scad.part(id=CANOPY_ID, revision="1.0.0", project_root=P.ROOT)(build_canopy)(**canopy_args(sched)) diff --git a/examples/pcg_facade_tower/tower.py b/examples/pcg_facade_tower/tower.py new file mode 100644 index 00000000..b2ccc5e4 --- /dev/null +++ b/examples/pcg_facade_tower/tower.py @@ -0,0 +1,76 @@ +"""pcg_facade_tower tower assembly: band subassembly instances by level + slabs + the entrance canopy. + +Tower frame = world. Band subassemblies are band-local (plan axes = world, z = 0 at the band's level), so every +instance is a pure translation to its level: the lobby at 0 (it holds both rows), each floor at its floor level +(floor type tier{k}-phase{j}), each tier's parapet at its roof. Slabs translate by their instance level +(`layout.slab_instances`); the canopy is built where it stands. Everything is grounded (A2). + +Component ids: lobby, floor{f}, parapet{k}, slab-{role}, canopy. + + ../../.venv/bin/python tower.py [rect|chamfer|l_shape] # build + print counts +""" +from __future__ import annotations + +import sys +import time + +import simplecadapi as scad + +import floor as F +import layout as L +import panels as P +import slabs as S + +TOWER_ID = "facade-tower" + + +def tower_components(sched: L.FacadeSchedule) -> list[dict]: + """[{component_id, ref, level}]: ref = ("band", band_id) | ("slab", tier) | ("canopy", None).""" + out = [{"component_id": "lobby", "ref": ("band", "lobby"), "level": 0.0}] + out += [{"component_id": f"floor{r['floor']}", "ref": ("band", r["floor_type"]), "level": r["level"]} + for r in sched.floors] + out += [{"component_id": b["band_id"], "ref": ("band", b["band_id"]), "level": b["level"]} + for b in sched.bands if b["band"] == L.Band.PARAPET.value] + out += [{"component_id": f"slab-{i['role']}", "ref": ("slab", i["tier"]), "level": i["level"]} + for i in L.slab_instances(sched)] + out.append({"component_id": "canopy", "ref": ("canopy", None), "level": 0.0}) + return out + + +def definitions(sched: L.FacadeSchedule, comps: list[dict]) -> dict: + """ref -> build result (band AssemblyBuildResult / slab or canopy PartBuildResult), each built once.""" + out = {} + for ref in dict.fromkeys(c["ref"] for c in comps): + kind, key = ref + out[ref] = (F.make_band_assembly(sched, key) if kind == "band" + else S.make_slab_part(sched, key) if kind == "slab" else S.make_canopy_part(sched)) + return out + + +def make_tower(sched: L.FacadeSchedule) -> scad.AssemblyBuildResult: + comps = tower_components(sched) + defs = definitions(sched, comps) + + @scad.assemble(id=TOWER_ID, revision="1.0.0", definitions=tuple(defs.values()), project_root=P.ROOT) + def build(plan_kind: str, n_floors: int) -> scad.Assembly: + # ---- feature: tower-stack (assemble) ---- + # band instances, slabs and canopy translated to their levels (world axes) and grounded + asm = scad.make_assembly_rassembly(assembly_id=TOWER_ID, name=f"facade tower {plan_kind} {n_floors}F") + for c in comps: + asm = scad.add_component_rassembly( + assembly=asm, item=defs[c["ref"]].value, component_id=c["component_id"], + placement=scad.make_placement_rplacement(origin=(0.0, 0.0, c["level"]), x_axis=(1.0, 0.0, 0.0), + y_axis=(0.0, 1.0, 0.0))) + asm = scad.ground_component_rassembly(assembly=asm, component_id=c["component_id"]) + return asm + + return build(plan_kind=sched.params.plan_kind.value, n_floors=sched.params.n_floors) + + +if __name__ == "__main__": + kind = L.PlanKind(sys.argv[1]) if len(sys.argv) > 1 else L.PlanKind.CHAMFER + start = time.perf_counter() + s = L.build_schedule(L.default_params(plan_kind=kind)) + r = make_tower(s) + print(f"{kind.value}: {len(r.value.components)} tower components, {len(r.definition.definition_refs)} definitions, " + f"{s.stats['n_instances']} facade pieces, {time.perf_counter() - start:.1f}s") diff --git a/examples/pcg_facade_tower/verify/_common.py b/examples/pcg_facade_tower/verify/_common.py new file mode 100644 index 00000000..5bcd9273 --- /dev/null +++ b/examples/pcg_facade_tower/verify/_common.py @@ -0,0 +1,55 @@ +"""Shared verifier helpers (diagnostic only; never used inside the model graph).""" +from __future__ import annotations + +import sys +from pathlib import Path + +PART_DIR = Path(__file__).resolve().parents[1] +if str(PART_DIR) not in sys.path: + sys.path.insert(0, str(PART_DIR)) + +OUT = Path(__file__).resolve().parent / "renders" + + +def tight_bbox(shape) -> tuple: + """Exact bbox (AddOptimal, no gap); accepts a SimpleCAD shape or a raw TopoDS_Shape.""" + from OCP.Bnd import Bnd_Box + from OCP.BRepBndLib import BRepBndLib + + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(getattr(shape, "wrapped", shape), box, useTriangulation=False) + return box.Get() + + +def report(title: str, failures: list) -> int: + print(f"== {title}: {'PASS' if not failures else 'FAIL'}") + for failure in failures: + print(f" - {failure}") + return 1 if failures else 0 + + +def place_shape(shape, frame: dict): + """Diagnostic placement (layout frame -> world) for local-frame solids: y_axis must be +Z. + Local x -> x_axis, y -> +Z, z -> x_axis x Z (the outward normal).""" + import math + + import simplecadapi as scad + + ox, oy, oz = frame["origin"] + tx, ty, _ = frame["x_axis"] + assert tuple(frame["y_axis"]) == (0.0, 0.0, 1.0) + upright = scad.rotate_shape(shape=shape, angle=90.0, axis=(1.0, 0.0, 0.0), origin=(0.0, 0.0, 0.0)) + turned = scad.rotate_shape(shape=upright, angle=math.degrees(math.atan2(ty, tx)), axis=(0.0, 0.0, 1.0), + origin=(0.0, 0.0, 0.0)) + return scad.translate_shape(shape=turned, vector=(ox, oy, oz)) + + +def min_distance(a, b) -> float: + from OCP.BRepExtrema import BRepExtrema_DistShapeShape + + dist = BRepExtrema_DistShapeShape(getattr(a, "wrapped", a), getattr(b, "wrapped", b)) + dist.Perform() + if not dist.IsDone(): + raise RuntimeError("distance computation failed") + return dist.Value() diff --git a/examples/pcg_facade_tower/verify/render_stage.py b/examples/pcg_facade_tower/verify/render_stage.py new file mode 100644 index 00000000..076c8209 --- /dev/null +++ b/examples/pcg_facade_tower/verify/render_stage.py @@ -0,0 +1,394 @@ +"""Per-stage render -> verify/renders/_*.png (Z up). + + ../../../.venv/bin/python render_stage.py s1 [rect|chamfer|l_shape ...] # massing four-view + plan + unrolled elevation + ../../../.venv/bin/python render_stage.py s2 # panel types + the K fin row + ../../../.venv/bin/python render_stage.py s3 # corner fits, slab skeletons, canopy + ../../../.venv/bin/python render_stage.py s4 # band subassemblies (solved placements) + ../../../.venv/bin/python render_stage.py s5 # tower four-view, south, setback + +S2 renders mount panel-local parts as on a south facade (x -> +X, y up -> +Z, outward z -> -Y). +""" +from __future__ import annotations + +import math +import sys +from dataclasses import replace + +import simplecadapi as scad +from _common import OUT, tight_bbox + +import layout as L + +MASSING_VIEWS = [(25, 215, "iso from SW"), (25, 35, "iso from NE"), (0, 180, "south elevation"), + (90, 0, "plan (from +Z)")] + + +def _prism(poly, z0: float, z1: float): + wire = scad.make_polyline_rwire(points=[(x, y, z0) for x, y in poly], closed=True) + face = scad.make_face_from_wire_rface(wire=wire, normal=(0.0, 0.0, 1.0)) + return scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=z1 - z0) + + +def massing(sched: L.FacadeSchedule) -> list: + """Tier prisms: building line from the tier below's roof up to this tier's roof (tier 0 includes the lobby).""" + solids, z0 = [], 0.0 + for k, poly in enumerate(sched.tiers): + z1 = sched.levels[f"roof{k}"] + solids.append(_prism(poly, z0, z1)) + z0 = z1 + return solids + + +def plan_figure(sched: L.FacadeSchedule, path) -> str: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.patches import Polygon + + p = sched.params + depth = 400.0 # band drawn ~2.7x frame_d so it reads at plan scale (corner reserve must stay > depth) + drawn = replace(p, frame_d=depth) + fig, axes = plt.subplots(1, len(sched.tiers), figsize=(6.2 * len(sched.tiers), 5.6)) + axes = [axes] if len(sched.tiers) == 1 else list(axes) + shades = {"vision": "#d9d9d9", "solid": "#555555"} + for k, ax in enumerate(axes): + band = sched.band(f"tier{k}-phase0") + poly = sched.tiers[k] + frames = L.edge_frames(poly) + ax.add_patch(Polygon(poly, closed=True, fill=False, lw=0.6, ls="--", ec="#888888")) + for r in band["panels"]: + fr = frames[r["edge"]] + a = L._add(fr["v0"], L._mul(fr["t"], r["s0"] + p.joint / 2.0)) + b = L._add(fr["v0"], L._mul(fr["t"], r["s0"] + r["w"] - p.joint / 2.0)) + quad = [a, b, L._add(b, L._mul(fr["n"], depth)), L._add(a, L._mul(fr["n"], depth))] + closer = r["slot"] in (0, max(q["slot"] for q in band["panels"] if q["edge"] == r["edge"])) + ax.add_patch(Polygon(quad, closed=True, lw=0.2, ec="white", + fc="#e8743b" if closer else shades[r["kind"]])) + if r.get("fin") is not None: # fin at the right jamb, rotated about the jamb by the bin angle + th = math.radians(r["fin"]) + root = L._add(b, L._mul(fr["n"], depth)) + tip_dir = L._add(L._mul(fr["n"], math.cos(th)), L._mul(fr["t"], math.sin(th))) + tip = L._add(root, L._mul(tip_dir, 2.0 * depth)) + ax.plot([root[0], tip[0]], [root[1], tip[1]], color="black", lw=0.7) + for c in band["corners"]: + prof = L.corner_profile_local(c["turn"], drawn) + fr = frames[c["vertex"] - 1] + v = tuple(poly[c["vertex"]]) + world = [L._add(L._add(v, L._mul(fr["t"], x)), L._mul(fr["n"], z)) for x, z in prof] + ax.add_patch(Polygon(world, closed=True, fc="black", ec="black", lw=0.3)) + xs = [v[0] for v in sched.tiers[0]] + ys = [v[1] for v in sched.tiers[0]] + ax.set_xlim(min(xs) - 3000, max(xs) + 3000) + ax.set_ylim(min(ys) - 3000, max(ys) + 3000) + ax.set_aspect("equal") + ax.set_title(f"tier {k} (setback {k * p.setback_depth:.0f}) phase 0", fontsize=10) + ax.tick_params(labelsize=7) + fig.suptitle(f"{p.plan_kind.value}: grey = vision, dark = solid, orange = closer, black = corner piece, " + f"line = fin (bin angle); band depth exaggerated", fontsize=10) + fig.tight_layout() + fig.savefig(path, dpi=150) + plt.close(fig) + return str(path) + + +def elevation_figure(sched: L.FacadeSchedule, path) -> str: + """Unrolled perimeter elevation, every floor: the Bayer WWR dither and the quantized fin wave.""" + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.colors import Normalize + from matplotlib.cm import ScalarMappable + from matplotlib.patches import Rectangle + + p = sched.params + cmap = plt.get_cmap("coolwarm") + norm = Normalize(-L.FIN_LIMIT, L.FIN_LIMIT) + fig, ax = plt.subplots(figsize=(18, 7.5)) + width = 0.0 + for rec in sched.floors: + band = sched.band(rec["floor_type"]) + frames = L.edge_frames(sched.tiers[rec["tier"]]) + starts, u = [], 0.0 + for fr in frames: + starts.append(u) + u += fr["length"] + width = max(width, u) + for r in band["panels"]: + x = starts[r["edge"]] + r["s0"] + if r["kind"] == "solid": + fc = "#444444" + elif r.get("fin") is None: + fc = "#e6e6e6" + else: + fc = cmap(norm(r["fin"])) + ax.add_patch(Rectangle((x, rec["level"]), r["w"], p.floor_h, fc=fc, ec="white", lw=0.15)) + for e, s in enumerate(starts): + ax.add_patch(Rectangle((s - p.corner_c, rec["level"]), 2 * p.corner_c, p.floor_h, fc="black", lw=0)) + ax.set_xlim(-1000, width + 1000) + ax.set_ylim(p.lobby_h - 1000, sched.stats["height"]) + ax.set_xlabel("perimeter u (mm), from SW corner CCW: S -> E -> ... ") + ax.set_ylabel("level z (mm)") + ax.set_title(f"{p.plan_kind.value}: unrolled elevation, {p.n_floors} floors. dark = solid, light = vision no fin, " + f"colour = fin bin angle (K={p.fin_bins}); WWR " + + " ".join(f"{o} {v:.3f}" for o, v in sched.stats["design_wwr"].items()), fontsize=10) + fig.colorbar(ScalarMappable(norm=norm, cmap=cmap), ax=ax, label="fin angle (deg)", fraction=0.02) + fig.tight_layout() + fig.savefig(path, dpi=130) + plt.close(fig) + return str(path) + + +# (elev, azim) are world-fixed (elev toward +Z, azim from +X); panel-local renders use view_up +y +PANEL_VIEWS_LOCAL = [(30, 35, "iso, outside"), (90, 0, "outside (+z)"), (-90, 0, "inside (-z)"), + (0, 0, "side (+x): depth profile")] + + +def _south(shape, dx: float = 0.0): + """Panel-local -> world as mounted on a south edge, shifted dx along the facade.""" + upright = scad.rotate_shape(shape=shape, angle=90.0, axis=(1.0, 0.0, 0.0), origin=(0.0, 0.0, 0.0)) + return scad.translate_shape(shape=upright, vector=(dx, 0.0, 0.0)) + + +def _fin_on_panel(fin_body, w: float, p) -> object: + """Fin-local -> panel-local: root on the right-jamb centreline of the frame outer face (A6).""" + return scad.translate_shape(shape=fin_body, vector=(w - p.joint / 2.0 - p.frame_sight / 2.0, 0.0, p.frame_d)) + + +def render_s2() -> list[str]: + import panels as P + from s2_verify import all_types + + OUT.mkdir(exist_ok=True) + p = L.default_params() + types = all_types() + body = {tid: P.make_panel_part(tid, spec, p).value.body for tid, spec in types.items()} + out = [] + for tid, spec in sorted(types.items()): # one four-view per vision type (glass tag highlighted) + if spec["family"] == "vision": + # panel-local, untransformed: a moved shape keeps its tags only in lineage scope, and the renderer + # highlights effective tags + out.append(scad.render_screenshot_rpath( + shapes=[body[tid]], output_path=str(OUT / f"s2_{tid}.png"), views=PANEL_VIEWS_LOCAL, + view_up=(0.0, 1.0, 0.0), highlight_tags=[P.GLASS_TAG], show_callouts=False, show_legend=False)) + solids = sorted((t for t, s in types.items() if s["family"] == "solid"), key=lambda t: (types[t]["h"], types[t]["w"])) + row, x = [], 0.0 + for tid in solids: # every solid type side by side, 300 apart + row.append(_south(body[tid], x)) + x += types[tid]["w"] + 300.0 + out.append(scad.render_screenshot_rpath(shapes=row, output_path=str(OUT / "s2_solid_types.png"), + views=[(20, 235, "iso, outside"), (0, 270, "outside (south)")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + std = L.vision_type_id(p.w_std, p.floor_h, p.spandrel_h) + fins = sorted((t for t, s in types.items() if s["family"] == "fin"), key=lambda t: types[t]["angle"]) + row = [] + for i, tid in enumerate(fins): # the K bins, each on a standard vision panel, adjacent modules + row.append(_south(body[std], i * p.w_std)) + row.append(_south(_fin_on_panel(body[tid], p.w_std, p), i * p.w_std)) + out.append(scad.render_screenshot_rpath( + shapes=row, output_path=str(OUT / "s2_fin_bins.png"), + views=[(25, 235, "iso, outside"), (0, 270, "outside (south)"), (60, 270, "high, outside"), + (90, 0, "plan from +Z")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + # blade sections alone, fin-local, 500 apart in x, looking down the extrusion (+y -> viewer) + sections = [scad.translate_shape(shape=body[tid], vector=(i * 500.0, 0.0, 0.0)) for i, tid in enumerate(fins)] + out.append(scad.render_screenshot_rpath( + shapes=sections, output_path=str(OUT / "s2_fin_sections.png"), + views=[(0, 90, "from +y (x runs left): " + " ".join(f"{types[t]['angle']:g}" for t in fins)), + (30, 60, "iso")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + return out + + +def _corner_assembly(sched: L.FacadeSchedule, band: dict, rec: dict, n_side: int = 2) -> tuple[list, tuple]: + """World-placed corner + n_side panels each side (s3_verify placement), shifted so the vertex is the origin. + Returns (shapes, outward bisector).""" + from _common import place_shape + from s3_verify import Parts + + parts, level = Parts(sched), L.band_levels(sched, band["band_id"])[0] + poly = [tuple(v) for v in sched.tiers[band["tier"]]] + i, frames = rec["vertex"], L.edge_frames(poly) + e_in = (i - 1) % len(poly) + near = sorted((r for r in band["panels"] if r["edge"] == e_in), key=lambda r: r["slot"])[-n_side:] + near += sorted((r for r in band["panels"] if r["edge"] == i), key=lambda r: r["slot"])[:n_side] + shapes = [parts.corner(band, rec, level)] + [parts.panel(band, r, level) for r in near] + v = poly[i] + moved = [scad.translate_shape(shape=sh, vector=(-v[0], -v[1], -level)) for sh in shapes] + return moved, L._add(frames[e_in]["n"], frames[i]["n"]) + + +def render_s3() -> list[str]: + import slabs as S + from s3_verify import Parts, fit_bands + + OUT.mkdir(exist_ok=True) + out = [] + picks = [(L.PlanKind.RECT, 90.0, "p90"), (L.PlanKind.CHAMFER, 45.0, "p45"), (L.PlanKind.L_SHAPE, -90.0, "m90")] + for kind, turn, tag in picks: # corner + two panels each side, tier-0 tower band, vertex at the origin + sched = L.build_schedule(L.default_params(plan_kind=kind)) + band = next(b for b in fit_bands(sched) if b["band"] == L.Band.TOWER.value and b["tier"] == 0) + rec = next(r for r in band["corners"] if abs(r["turn"] - turn) < 1.0e-9) + shapes, bis = _corner_assembly(sched, band, rec) + az = math.degrees(math.atan2(bis[1], bis[0])) % 360.0 + out.append(scad.render_screenshot_rpath( + shapes=shapes, output_path=str(OUT / f"s3_corner_{tag}.png"), + views=[(25, az, f"{kind.value} v{rec['vertex']} turn {turn:+g}: outside"), + (25, (az + 180.0) % 360.0, "inside"), (90, 0, "plan from +Z"), (-90, 0, "plan from below")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + close, _ = _corner_assembly(sched, band, rec, n_side=1) + out.append(scad.render_screenshot_rpath( + shapes=close, output_path=str(OUT / f"s3_corner_{tag}_plan.png"), view=(90.0, 0.0), zoom=10.0, + view_up=(0.0, 1.0, 0.0), show_callouts=False, show_legend=False)) + + for kind in L.PlanKind: # slab stack + every corner column: the structural skeleton of the facade + sched = L.build_schedule(L.default_params(plan_kind=kind)) + parts = Parts(sched) + slab = {k: S.make_slab_part(sched, k).value.body for k in range(len(sched.tiers))} + shapes = [scad.translate_shape(shape=slab[i["tier"]], vector=(0.0, 0.0, i["level"])) + for i in L.slab_instances(sched)] + for band in sched.bands: + for level in L.band_levels(sched, band["band_id"]): + shapes += [parts.corner(band, rec, level) for rec in band["corners"]] + out.append(scad.render_screenshot_rpath( + shapes=shapes, output_path=str(OUT / f"s3_skeleton_{kind.value}.png"), views=MASSING_VIEWS, + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + + sched = L.build_schedule(L.default_params()) # entrance: south lobby rows + corners + canopy + ground slab + parts, lobby = Parts(sched), sched.band("lobby") + shapes = [S.make_canopy_part(sched).value.body] + for level in L.band_levels(sched, "lobby"): + shapes += [parts.panel(lobby, r, level) for r in lobby["panels"] if r["edge"] == 0] + shapes += [parts.corner(lobby, r, level) for r in lobby["corners"] if r["vertex"] in (0, 1)] + shapes.append(S.make_slab_part(sched, 0).value.body) + out.append(scad.render_screenshot_rpath( + shapes=shapes, output_path=str(OUT / "s3_canopy.png"), + views=[(15, 240, "iso, south-west"), (0, 270, "south elevation"), (0, 0, "from east: canopy on the row joint"), + (55, 300, "high, south-east")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + return out + + +def _near(sched: L.FacadeSchedule, band: dict, shapes: dict, vertex: int, radius: float) -> list: + """Placed band components whose frame origin lies within radius (plan) of a tier vertex.""" + import floor as F + + vx, vy = sched.tiers[band["tier"]][vertex] + near = {c["component_id"] for c in F.band_components(sched, band) + if math.hypot(c["frame"]["origin"][0] - vx, c["frame"]["origin"][1] - vy) < radius} + return [s for cid, s in shapes.items() if cid in near] + + +def render_s4() -> list[str]: + import floor as F + from s4_verify import placed + + OUT.mkdir(exist_ok=True) + out = [] + sched = L.build_schedule(L.default_params()) + for band_id, views in ( + ("tier0-phase0", [(25, 225, "tier0-phase0 (floor 1): iso south-west"), (25, 45, "iso north-east (chamfer)"), + (0, 270, "south elevation"), (90, 0, "plan from +Z")]), + ("lobby", [(20, 225, "lobby, 2 rows: iso south-west"), (0, 270, "south elevation")])): + band = sched.band(band_id) + asm = F.make_band_assembly(sched, band_id).value # rendered from the solved placements + shapes = {c.component_id: placed(c) for c in asm.components} + out.append(scad.render_screenshot_rpath( + shapes=list(shapes.values()), output_path=str(OUT / f"s4_band_{band_id}.png"), views=views, + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + if band_id != "tier0-phase0": + continue + chamfer = next(r["vertex"] for r in band["corners"] if abs(r["turn"] - 45.0) < 1.0e-9) + out.append(scad.render_screenshot_rpath( # joints at plan scale: chamfer corner, fins on the east face + shapes=_near(sched, band, shapes, chamfer, 3500.0), output_path=str(OUT / "s4_plan_chamfer.png"), + view=(90.0, 0.0), view_up=(0.0, 1.0, 0.0), show_callouts=False, show_legend=False)) + sw = next(r["vertex"] for r in band["corners"] if abs(r["turn"] - 90.0) < 1.0e-9) + out.append(scad.render_screenshot_rpath( + shapes=_near(sched, band, shapes, sw, 2600.0), output_path=str(OUT / "s4_corner_sw.png"), + views=[(20, 225, f"v{sw} +90 corner: outside"), (20, 45, "inside"), (90, 0, "plan from +Z"), + (0, 270, "south elevation")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + return out + + +def _clip(shape, lo: tuple, hi: tuple): + """Diagnostic: shape ∩ axis box (for close-ups of whole-plan slabs).""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common + from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox + from OCP.gp import gp_Pnt + + return BRepAlgoAPI_Common(shape, BRepPrimAPI_MakeBox(gp_Pnt(*lo), gp_Pnt(*hi)).Shape()).Shape() + + +def render_s5() -> list[str]: + from tower import make_tower + from s5_verify import realize + + OUT.mkdir(exist_ok=True) + out = [] + sched = L.build_schedule(L.default_params()) + p = sched.params + asm = make_tower(sched).value + whole = scad.make_compound_from_assembly_rcompound(assembly=asm).wrapped # the SDK's flattened tower (the renderer takes Solid / raw TopoDS only) + out.append(scad.render_screenshot_rpath( + shapes=[whole], output_path=str(OUT / "s5_tower.png"), + views=[(20, 225, "tower: iso south-west"), (20, 45, "iso north-east (chamfer)"), (0, 270, "south elevation"), + (90, 0, "plan from +Z")], view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + out.append(scad.render_screenshot_rpath( + shapes=[whole], output_path=str(OUT / "s5_south.png"), view=(0.0, 270.0), view_up=(0.0, 0.0, 1.0), + show_callouts=False, show_legend=False)) + # setback close-up: first tier roof at the SW corner, two floors either side, parapet, roof slab + comps = {c.component_id: c for c in asm.components} + top0 = p.setback_every + vx, vy = sched.tiers[0][0] + r = 7000.0 + lo, hi = (vx - r, vy - r, sched.levels[f"floor{top0 - 1}"] - 1.0), (vx + r, vy + r, sched.levels[f"floor{top0 + 3}"]) + shapes = [] + for cid in (f"floor{top0 - 1}", f"floor{top0}", "parapet0", "slab-roof0", f"slab-floor{top0}", + f"floor{top0 + 1}", f"floor{top0 + 2}", f"slab-floor{top0 + 2}"): + for pid, s in realize(comps[cid]).items(): + if not pid: + shapes.append(_clip(s, lo, hi)) + continue + b = tight_bbox(s) + if lo[0] < (b[0] + b[3]) / 2 < hi[0] and lo[1] < (b[1] + b[4]) / 2 < hi[1]: + shapes.append(s) + out.append(scad.render_screenshot_rpath( + shapes=shapes, output_path=str(OUT / "s5_setback.png"), + views=[(25, 225, f"tier 0 roof / tier 1 setback at v0 (floors {top0 - 1}-{top0 + 2})"), + (25, 135, "from south-east"), (0, 270, "south elevation"), (0, 180, "west elevation")], + view_up=(0.0, 0.0, 1.0), show_callouts=False, show_legend=False)) + return out + + +def render_s1(kinds) -> list[str]: + OUT.mkdir(exist_ok=True) + out = [] + for kind in kinds: + sched = L.build_schedule(L.default_params(plan_kind=L.PlanKind(kind))) + out.append(scad.render_screenshot_rpath(shapes=massing(sched), output_path=str(OUT / f"s1_massing_{kind}.png"), + views=MASSING_VIEWS, view_up=(0.0, 0.0, 1.0), show_callouts=False, + show_legend=False)) + out.append(plan_figure(sched, OUT / f"s1_plan_{kind}.png")) + out.append(elevation_figure(sched, OUT / f"s1_elevation_{kind}.png")) + return out + + +if __name__ == "__main__": + stage = sys.argv[1] if len(sys.argv) > 1 else "s1" + if stage == "s1": + for item in render_s1(sys.argv[2:] or [k.value for k in L.PlanKind]): + print(item) + elif stage == "s2": + for item in render_s2(): + print(item) + elif stage == "s3": + for item in render_s3(): + print(item) + elif stage == "s4": + for item in render_s4(): + print(item) + elif stage == "s5": + for item in render_s5(): + print(item) + else: + raise SystemExit(f"unknown stage {stage}") diff --git a/examples/pcg_facade_tower/verify/renders/s1_elevation_chamfer.png b/examples/pcg_facade_tower/verify/renders/s1_elevation_chamfer.png new file mode 100644 index 00000000..59fc719e Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_elevation_chamfer.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_elevation_l_shape.png b/examples/pcg_facade_tower/verify/renders/s1_elevation_l_shape.png new file mode 100644 index 00000000..fe9bb48f Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_elevation_l_shape.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_elevation_rect.png b/examples/pcg_facade_tower/verify/renders/s1_elevation_rect.png new file mode 100644 index 00000000..b003665b Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_elevation_rect.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_massing_chamfer.png b/examples/pcg_facade_tower/verify/renders/s1_massing_chamfer.png new file mode 100644 index 00000000..ecade156 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_massing_chamfer.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_massing_l_shape.png b/examples/pcg_facade_tower/verify/renders/s1_massing_l_shape.png new file mode 100644 index 00000000..61fbacff Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_massing_l_shape.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_massing_rect.png b/examples/pcg_facade_tower/verify/renders/s1_massing_rect.png new file mode 100644 index 00000000..ecad0a76 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_massing_rect.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_plan_chamfer.png b/examples/pcg_facade_tower/verify/renders/s1_plan_chamfer.png new file mode 100644 index 00000000..b0f36cb8 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_plan_chamfer.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_plan_l_shape.png b/examples/pcg_facade_tower/verify/renders/s1_plan_l_shape.png new file mode 100644 index 00000000..0343cd95 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_plan_l_shape.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s1_plan_rect.png b/examples/pcg_facade_tower/verify/renders/s1_plan_rect.png new file mode 100644 index 00000000..58c88ca5 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s1_plan_rect.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s2_fin_bins.png b/examples/pcg_facade_tower/verify/renders/s2_fin_bins.png new file mode 100644 index 00000000..1442acfb Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s2_fin_bins.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s2_fin_sections.png b/examples/pcg_facade_tower/verify/renders/s2_fin_sections.png new file mode 100644 index 00000000..ea415cfc Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s2_fin_sections.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s2_solid_types.png b/examples/pcg_facade_tower/verify/renders/s2_solid_types.png new file mode 100644 index 00000000..22afd4b3 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s2_solid_types.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s2_vision-w1500-h2700-s0.png b/examples/pcg_facade_tower/verify/renders/s2_vision-w1500-h2700-s0.png new file mode 100644 index 00000000..19753a21 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s2_vision-w1500-h2700-s0.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s2_vision-w1500-h3600-s1100.png b/examples/pcg_facade_tower/verify/renders/s2_vision-w1500-h3600-s1100.png new file mode 100644 index 00000000..cd85f8ca Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s2_vision-w1500-h3600-s1100.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_canopy.png b/examples/pcg_facade_tower/verify/renders/s3_canopy.png new file mode 100644 index 00000000..d72d1c4c Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_canopy.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_corner_m90.png b/examples/pcg_facade_tower/verify/renders/s3_corner_m90.png new file mode 100644 index 00000000..6e7500ff Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_corner_m90.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_corner_m90_plan.png b/examples/pcg_facade_tower/verify/renders/s3_corner_m90_plan.png new file mode 100644 index 00000000..cd2ab37d Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_corner_m90_plan.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_corner_p45.png b/examples/pcg_facade_tower/verify/renders/s3_corner_p45.png new file mode 100644 index 00000000..ee29f240 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_corner_p45.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_corner_p45_plan.png b/examples/pcg_facade_tower/verify/renders/s3_corner_p45_plan.png new file mode 100644 index 00000000..25e98de1 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_corner_p45_plan.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_corner_p90.png b/examples/pcg_facade_tower/verify/renders/s3_corner_p90.png new file mode 100644 index 00000000..e77d735d Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_corner_p90.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_corner_p90_plan.png b/examples/pcg_facade_tower/verify/renders/s3_corner_p90_plan.png new file mode 100644 index 00000000..8c2346f8 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_corner_p90_plan.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_skeleton_chamfer.png b/examples/pcg_facade_tower/verify/renders/s3_skeleton_chamfer.png new file mode 100644 index 00000000..af6c6722 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_skeleton_chamfer.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_skeleton_l_shape.png b/examples/pcg_facade_tower/verify/renders/s3_skeleton_l_shape.png new file mode 100644 index 00000000..bb3a953c Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_skeleton_l_shape.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s3_skeleton_rect.png b/examples/pcg_facade_tower/verify/renders/s3_skeleton_rect.png new file mode 100644 index 00000000..751a4ab5 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s3_skeleton_rect.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s4_band_lobby.png b/examples/pcg_facade_tower/verify/renders/s4_band_lobby.png new file mode 100644 index 00000000..849edb38 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s4_band_lobby.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s4_band_tier0-phase0.png b/examples/pcg_facade_tower/verify/renders/s4_band_tier0-phase0.png new file mode 100644 index 00000000..9f330d01 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s4_band_tier0-phase0.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s4_corner_sw.png b/examples/pcg_facade_tower/verify/renders/s4_corner_sw.png new file mode 100644 index 00000000..e39ec2c2 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s4_corner_sw.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s4_plan_chamfer.png b/examples/pcg_facade_tower/verify/renders/s4_plan_chamfer.png new file mode 100644 index 00000000..32b6e0ff Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s4_plan_chamfer.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s5_setback.png b/examples/pcg_facade_tower/verify/renders/s5_setback.png new file mode 100644 index 00000000..782dc784 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s5_setback.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s5_south.png b/examples/pcg_facade_tower/verify/renders/s5_south.png new file mode 100644 index 00000000..6bc789a9 Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s5_south.png differ diff --git a/examples/pcg_facade_tower/verify/renders/s5_tower.png b/examples/pcg_facade_tower/verify/renders/s5_tower.png new file mode 100644 index 00000000..6125fddc Binary files /dev/null and b/examples/pcg_facade_tower/verify/renders/s5_tower.png differ diff --git a/examples/pcg_facade_tower/verify/s1_known_bad.py b/examples/pcg_facade_tower/verify/s1_known_bad.py new file mode 100644 index 00000000..3c39d3db --- /dev/null +++ b/examples/pcg_facade_tower/verify/s1_known_bad.py @@ -0,0 +1,118 @@ +"""S1 known-bad proof: check_s1 must FAIL on each deliberately corrupted schedule and PASS on the clean one. + + KB0 clean CHAMFER schedule -> must pass + KB1 one standard panel 1 mm narrower -> C1 (edge sum) + C2 (inner width) + KB2 closers made asymmetric (+/- 300) -> C2 + KB3 closer turned into a vision panel -> C2 + KB4 every south solid flipped to vision -> C4 (WWR S overshoots) + KB5 fin moved off its bin by 1 degree -> C6 + KB6 a fin on the north edge -> C6 + KB7 floor 13 levelled one floor too low -> C8 + KB8 tier 1 setback 2999 instead of 3000 -> C8 + KB9 one fin record dropped -> C6 + KB10 lobby as a single 5400 row -> C10 +""" +import copy +import sys + +from _common import report # noqa: F401 (sys.path setup) +from s1_verify import check_s1 + +import layout as L + +CLEAN = L.build_schedule(L.default_params(plan_kind=L.PlanKind.CHAMFER)) + + +def mutate(fn): + sched = copy.deepcopy(CLEAN) + fn(sched) + return sched + + +def _tower(sched, band_id="tier0-phase0"): + return sched.band(band_id) + + +def kb1(s): + r = next(r for r in _tower(s)["panels"] if r["slot"] == 3) + r["w"] -= 1.0 + + +def kb2(s): + recs = [r for r in _tower(s)["panels"] if r["edge"] == 0] + recs[0]["w"] += 300.0 + recs[-1]["w"] -= 300.0 + for r in recs[1:]: + r["s0"] += 300.0 + + +def kb3(s): + _tower(s)["panels"][0]["kind"] = "vision" + + +def kb4(s): + for b in s.bands: + if b["band"] == "tower": + last = {} + for r in b["panels"]: + last[r["edge"]] = max(last.get(r["edge"], 0), r["slot"]) + for r in b["panels"]: + if r["orientation"] == "S" and r["kind"] == "solid" and 0 < r["slot"] < last[r["edge"]]: + r["kind"] = "vision" + + +def kb5(s): + r = next(r for r in _tower(s)["panels"] if r.get("fin") is not None) + r["fin"] += 1.0 + next(f for f in _tower(s)["fins"] if (f["edge"], f["slot"]) == (r["edge"], r["slot"]))["angle"] = r["fin"] + + +def kb6(s): + frames = L.edge_frames(s.tiers[0]) + north = next(e for e, fr in enumerate(frames) if not L.edge_has_fins(fr["n"], s.params)) + r = next(r for r in _tower(s)["panels"] if r["edge"] == north and r["kind"] == "vision") + r["fin"], r["fin_raw"] = 9.0, 9.0 + _tower(s)["fins"].append({"edge": north, "slot": r["slot"], "s": L.fin_root_s(r["s0"], r["w"], s.params), + "angle": 9.0, "type_id": _tower(s)["fins"][0]["type_id"]}) + + +def kb7(s): + s.floors[12]["level"] -= s.params.floor_h + + +def kb8(s): + s.tiers[1] = [list(v) for v in L.offset_polygon([tuple(v) for v in s.tiers[0]], 2999.0)] + + +def kb9(s): + _tower(s)["fins"].pop() + + +def kb10(s): + lobby = s.band("lobby") + lobby["rows"], lobby["height"] = 1, s.params.lobby_h + + +cases = { + "KB0 clean": (CLEAN, True), + "KB1 panel 1 mm narrow": (mutate(kb1), False), + "KB2 closers asymmetric": (mutate(kb2), False), + "KB3 closer as vision": (mutate(kb3), False), + "KB4 south all vision": (mutate(kb4), False), + "KB5 fin off bin": (mutate(kb5), False), + "KB6 fin on north edge": (mutate(kb6), False), + "KB7 floor 13 level": (mutate(kb7), False), + "KB8 tier1 setback 2999": (mutate(kb8), False), + "KB9 fin record dropped": (mutate(kb9), False), + "KB10 lobby single row": (mutate(kb10), False), +} +wrong = [] +for name, (sched, should_pass) in cases.items(): + failures = check_s1(sched) + passed = not failures + verdict = "as designed" if passed == should_pass else "GATE BROKEN" + print(f"--- {name:26s} => {'pass' if passed else 'fail'} ({verdict})" + + ("" if passed else f" first: {failures[0]} [+{len(failures) - 1}]")) + if passed != should_pass: + wrong.append(name) +sys.exit(report("S1 known-bad", [f"{name}: gate did not discriminate" for name in wrong])) diff --git a/examples/pcg_facade_tower/verify/s1_verify.py b/examples/pcg_facade_tower/verify/s1_verify.py new file mode 100644 index 00000000..9e7154f2 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s1_verify.py @@ -0,0 +1,227 @@ +"""S1 gate: layout engine (pure data). + + C1 closure every band, every edge: corner_c + sum(widths) + corner_c == edge length (1e-9); slots contiguous + C2 closers both end slots equal, solid, in [closer_min, closer_min + w_std/2) + C3 guards each guard rejects just past its boundary and accepts just inside (fail loudly, never degrade) + C4 WWR design glass / gross per orientation within 0.03 of target + C5 rationalized one standard vision + one standard solid type; fin types <= K; floor types == tiers x P; ids valid + C6 fins quantization error <= 90 / (2 (K - 1)); north-facing edges carry no fins; every fin panel has + exactly one fin record at its right-jamb root with the same angle + C10 transport every band height <= MAX_PANEL_H; lobby rows x row height == lobby_h + C7 corners corner profiles for every turn in use: simple, CCW, area > 0; reflex corners fit + C8 vertical floor levels, tier assignment, roof levels, setback depth per tier + C9 plans RECT / CHAMFER / L_SHAPE all schedule; JSON round-trip + + ../../../.venv/bin/python s1_verify.py +""" +from __future__ import annotations + +import json +import math +import re +import sys + +from _common import report # noqa: F401 (sys.path setup) + +import layout as L + +ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,255}$") +EPS = 1.0e-9 + + +def check_s1(sched: L.FacadeSchedule) -> list[str]: + p = sched.params + bad: list[str] = [] + + # C1 / C2 + for b in sched.bands: + frames = L.edge_frames(sched.tiers[b["tier"]]) + for e, fr in enumerate(frames): + recs = sorted((r for r in b["panels"] if r["edge"] == e), key=lambda r: r["slot"]) + if not recs: + bad.append(f"C1 {b['band_id']} edge {e}: no panels") + continue + total = 2.0 * p.corner_c + sum(r["w"] for r in recs) + if abs(total - fr["length"]) > EPS * max(1.0, fr["length"]): + bad.append(f"C1 {b['band_id']} edge {e}: widths sum {total:.9f} != length {fr['length']:.9f}") + s = p.corner_c + for r in recs: + if abs(r["s0"] - s) > EPS * max(1.0, fr["length"]): + bad.append(f"C1 {b['band_id']} edge {e} slot {r['slot']}: s0 {r['s0']:.6f} != {s:.6f}") + s = r["s0"] + r["w"] + first, last = recs[0], recs[-1] + if abs(first["w"] - last["w"]) > EPS * max(1.0, fr["length"]): + bad.append(f"C2 {b['band_id']} edge {e}: closers asymmetric {first['w']:.6f} / {last['w']:.6f}") + for r in (first, last): + if not (p.closer_min - EPS <= r["w"] < p.closer_min + p.w_std / 2.0): + bad.append(f"C2 {b['band_id']} edge {e}: closer {r['w']:.6f} outside " + f"[{p.closer_min}, {p.closer_min + p.w_std / 2.0})") + if r["kind"] != "solid": + bad.append(f"C2 {b['band_id']} edge {e}: closer slot {r['slot']} is {r['kind']}") + for r in recs[1:-1]: + if abs(r["w"] - p.w_std) > EPS: + bad.append(f"C2 {b['band_id']} edge {e} slot {r['slot']}: inner width {r['w']} != w_std") + + # C4 + wwr = L.design_wwr(sched) + for o, target in p.wwr_target: + if o in wwr and abs(wwr[o] - target) > L.WWR_TOL: + bad.append(f"C4 WWR {o}: design {wwr[o]:.4f} vs target {target}") + + # C5 + std = sched.stats["standard_panel_types"] + if len(std) > 2: + bad.append(f"C5 {len(std)} standard panel types > 2: {std}") + if len(sched.stats["fin_types"]) > p.fin_bins: + bad.append(f"C5 {len(sched.stats['fin_types'])} fin types > K = {p.fin_bins}") + expected_ft = sum(1 for k in range(len(sched.tiers)) for ph in range(p.fin_period_floors) + if any((f - 1) // p.setback_every == k and (f - 1) % p.fin_period_floors == ph + for f in range(1, p.n_floors + 1))) + if len(sched.floor_types) != expected_ft: + bad.append(f"C5 floor types {len(sched.floor_types)} != tiers x phases {expected_ft}") + used = {r["type_id"] for b in sched.bands for r in b["panels"] + b["corners"] + b["fins"]} + if used != set(sched.types): + bad.append(f"C5 type table mismatch: unused {sorted(set(sched.types) - used)}, " + f"unregistered {sorted(used - set(sched.types))}") + for tid in list(sched.types) + list(sched.floor_types): + if not ID_RE.match(tid): + bad.append(f"C5 invalid identifier {tid!r}") + + # C6 + bins = L.fin_bin_angles(p.fin_bins) + limit = 90.0 / (2.0 * (p.fin_bins - 1)) + EPS + for b in sched.bands: + frames = L.edge_frames(sched.tiers[b["tier"]]) + fins = {} + for fr_ in b["fins"]: + fins.setdefault((fr_["edge"], fr_["slot"]), []).append(fr_) + fin_panels = {(r["edge"], r["slot"]) for r in b["panels"] if r.get("fin") is not None} + if set(fins) != fin_panels: + bad.append(f"C6 {b['band_id']}: fin records {len(fins)} do not match fin panels {len(fin_panels)}") + for r in b["panels"]: + fin = r.get("fin") + if fin is None: + continue + recs = fins.get((r["edge"], r["slot"]), []) + if len(recs) != 1 or recs[0]["angle"] != fin or abs(recs[0]["s"] - L.fin_root_s(r["s0"], r["w"], p)) > EPS: + bad.append(f"C6 {b['band_id']} edge {r['edge']} slot {r['slot']}: fin record mismatch {recs}") + if not L.edge_has_fins(frames[r["edge"]]["n"], p): + bad.append(f"C6 {b['band_id']} edge {r['edge']}: fin on a non-sun edge") + if min(abs(fin - a) for a in bins) > EPS: + bad.append(f"C6 {b['band_id']}: fin {fin} not a bin angle") + err = abs(max(-L.FIN_LIMIT, min(L.FIN_LIMIT, r["fin_raw"])) - fin) + if err > limit: + bad.append(f"C6 {b['band_id']} edge {r['edge']} slot {r['slot']}: quant error {err:.4f} > {limit:.4f}") + + # C7 + for turn in sorted({c["turn"] for b in sched.bands for c in b["corners"]}): + try: + prof = L.corner_profile_local(turn, p) + except L.LayoutError as exc: + bad.append(f"C7 corner turn {turn:.3f}: {exc}") + continue + if L.signed_area(prof) <= 0.0 or not L.is_simple(prof): + bad.append(f"C7 corner turn {turn:.3f}: profile not simple / CCW") + + # C10 + for b in sched.bands: + if b["height"] > L.MAX_PANEL_H + EPS: + bad.append(f"C10 {b['band_id']}: band height {b['height']} > {L.MAX_PANEL_H}") + lobby = sched.band("lobby") + if abs(lobby["rows"] * lobby["height"] - p.lobby_h) > EPS: + bad.append(f"C10 lobby rows {lobby['rows']} x {lobby['height']} != {p.lobby_h}") + + # C8 + for rec in sched.floors: + f = rec["floor"] + if abs(rec["level"] - (p.lobby_h + (f - 1) * p.floor_h)) > EPS: + bad.append(f"C8 floor {f}: level {rec['level']}") + if rec["tier"] != (f - 1) // p.setback_every: + bad.append(f"C8 floor {f}: tier {rec['tier']}") + if f not in sched.floor_types[rec["floor_type"]]["floors"]: + bad.append(f"C8 floor {f}: not listed under its floor type {rec['floor_type']}") + base = L.edge_frames(sched.tiers[0]) + for k, poly in enumerate(sched.tiers): + for eb, et in zip(base, L.edge_frames(poly)): # each tier edge line sits k * depth inside the base edge line + gap = L._dot(L._sub(eb["v0"], et["v0"]), eb["n"]) + if abs(gap - k * p.setback_depth) > 1.0e-6: + bad.append(f"C8 tier {k}: edge setback {gap:.6f} != {k * p.setback_depth}") + top = max(f for f in range(1, p.n_floors + 1) if (f - 1) // p.setback_every == k) + if abs(sched.levels[f"roof{k}"] - (p.lobby_h + top * p.floor_h)) > EPS: + bad.append(f"C8 roof{k} level {sched.levels[f'roof{k}']}") + + return bad + + +# ---------------------------------------------------------------- C3 guard matrix + + +def _rejects(stage, **overrides) -> bool: + """stage: the narrowest engine entry that owns the guard, so a neighbouring guard cannot mask it.""" + try: + params = L.default_params(**overrides) + stage(params) + except L.LayoutError: + return True + return False + + +def guard_matrix() -> list[str]: + p = L.default_params() + d = 1.0e-6 + # shortest default edge: tier-top chamfer. Setback that shrinks it to exactly 2c + 2 closer_min is the boundary. + chamfer_top = L.edge_frames(L.tier_plans(p)[-1])[2]["length"] + tiers = len(L.tier_plans(p)) + # the chamfer edge shrinks by 2 * tan(22.5 deg) per unit of setback + shrink = 2.0 * math.tan(math.radians(22.5)) * (tiers - 1) + limit_depth = p.setback_depth + (chamfer_top - 2.0 * p.corner_c - 2.0 * p.closer_min) / shrink + full, params_only, tiers_only = L.build_schedule, L.assert_params, L.tier_plans + rows = [ + # (name, entry, overrides past boundary -> must reject, overrides just inside -> must accept) + ("edge too short", full, dict(setback_depth=limit_depth + d), dict(setback_depth=limit_depth - d)), + ("closer_min >= w_std", params_only, dict(closer_min=p.w_std), dict(closer_min=p.w_std - d)), + ("K < 2", params_only, dict(fin_bins=1), dict(fin_bins=2)), + ("P not multiple of 4", params_only, dict(fin_period_floors=6), dict(fin_period_floors=8)), + ("corner_c <= frame_d + joint", params_only, dict(corner_c=p.frame_d + p.joint), + dict(corner_c=p.frame_d + p.joint + d)), + ("setback reverses an edge", tiers_only, # RECT, 2 tiers: the 24000 edge vanishes at depth 12000 + dict(plan_kind=L.PlanKind.RECT, setback_depth=p.plan_d / 2.0 + d, n_floors=24), + dict(plan_kind=L.PlanKind.RECT, setback_depth=p.plan_d / 2.0 - 1.0, n_floors=24)), + ("WWR unreachable", full, dict(wwr_target=(("N", 0.62), ("E", 0.4), ("S", 0.35), ("W", 0.4))), + dict(wwr_target=(("N", 0.52), ("E", 0.4), ("S", 0.35), ("W", 0.4)))), + ("fin_amp > 45", params_only, dict(fin_amp=45.0 + d), dict(fin_amp=45.0)), + ("floor_h > transport height", params_only, dict(floor_h=L.MAX_PANEL_H + d), dict(floor_h=L.MAX_PANEL_H)), + ("glass outside frame", params_only, dict(glass_recess=p.frame_d - p.glass_t), + dict(glass_recess=p.frame_d - p.glass_t - d)), + ] + bad = [] + for name, entry, reject, accept in rows: + r, a = _rejects(entry, **reject), not _rejects(entry, **accept) + print(f" guard {name:28s} past -> {'rejected' if r else 'ACCEPTED'} inside -> {'accepted' if a else 'REJECTED'}") + if not r: + bad.append(f"C3 {name}: accepted past the boundary {reject}") + if not a: + bad.append(f"C3 {name}: rejected inside the boundary {accept}") + return bad + + +def main() -> int: + failures = [] + for kind in L.PlanKind: + sched = L.build_schedule(L.default_params(plan_kind=kind)) + again = json.loads(sched.to_json()) + if again["stats"]["n_instances"] != sched.stats["n_instances"] or len(again["bands"]) != len(sched.bands): + failures.append(f"C9 {kind.value}: JSON round-trip mismatch") + errs = check_s1(sched) + st = sched.stats + print(f"-- {kind.value:8s} types {st['n_types']:3d} {st['types_by_family']} std panels {len(st['standard_panel_types'])} fins {len(st['fin_types'])}" + f" floor types {st['n_floor_types']} instances {st['n_instances']} height {st['height']:.0f}") + print(" WWR design " + " ".join(f"{o} {v:.3f}/{dict(sched.params.wwr_target)[o]}" for o, v in st["design_wwr"].items()) + + f" max fin err {st['max_fin_quant_error']:.3f} deg") + failures += [f"[{kind.value}] {e}" for e in errs] + failures += guard_matrix() + return report("S1 layout", failures) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/pcg_facade_tower/verify/s2_known_bad.py b/examples/pcg_facade_tower/verify/s2_known_bad.py new file mode 100644 index 00000000..2a7b0341 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s2_known_bad.py @@ -0,0 +1,81 @@ +"""S2 known-bad proof: check_body must FAIL on each deliberately wrong solid and PASS on the clean ones. + +Each case builds the body from mutated builder arguments (or a mutated step) but checks it against the +unchanged type spec, the way a drifted builder would be caught. + + KB0 clean vision / solid / fin -> must pass + KB1 vision frame 1 mm shallow -> P2 / P3 + KB2 glass recessed 1 mm deeper -> P4 (plane) + P5 + KB3 glass face never tagged -> P4 + KB4 panel built to the module, no joint -> P2 / P3 + KB5 solid panel carries a glass tag -> P4 + KB6 fin built 0.1 deg off its bin -> P6 (angle) + KB7 fin angle sign flipped -> P6 (angle + lean) + KB8 fin lifted 1 mm off the frame face -> P6 (root) + KB9 fin 1 mm thicker -> P6 (side planes) +""" +import sys +from types import SimpleNamespace + +from _common import report +from s2_verify import check_body + +import layout as L +import panels as P +import simplecadapi as scad + +p = L.default_params() +TYPES = L.build_schedule(p).types +VISION = next(t for t, s in TYPES.items() if s["family"] == "vision" and s["spandrel"] > 0) +SOLID = next(t for t, s in TYPES.items() if s["family"] == "solid") +FIN = next(t for t, s in TYPES.items() if s["family"] == "fin" and s["angle"] > 0) + + +def case(tid, mutate_kwargs=None, post=None): + builder, kwargs = P.builder_args(TYPES[tid], p) + body = builder(**{**kwargs, **(mutate_kwargs or {})}) + if post is not None: + body = post(body) + return tid, SimpleNamespace(part_id=tid, body=body) + + +def _untagged_vision(_body): + kw = P.builder_args(TYPES[VISION], p)[1] + ops = P.openings(kw["w"], kw["h"], kw["spandrel"], kw["frame_sight"], kw["joint"]) + z1 = kw["frame_d"] - kw["glass_recess"] + body = P._frame_ring(kw["w"], kw["h"], ops, kw["frame_d"], kw["joint"]) + body = scad.union_rsolid(body, P._rect_plate("s", *P._grow(ops["spandrel"], 30.0), z1 - kw["glass_t"], + kw["frame_d"] - kw["glass_recess"] / 2.0)) + return scad.union_rsolid(body, P._rect_plate("g", *P._grow(ops["vision"], 30.0), z1 - kw["glass_t"], z1)) + + +def _tag_solid(body): + kw = P.builder_args(TYPES[SOLID], p)[1] + op = P.openings(kw["w"], kw["h"], 0.0, kw["frame_sight"], kw["joint"])["vision"] + return P._tag_front_face(body, op, kw["frame_d"] - kw["glass_recess"] / 2.0, P.GLASS_TAG) + + +cases = { + "KB0 clean vision": (case(VISION), True), + "KB0 clean solid": (case(SOLID), True), + "KB0 clean fin": (case(FIN), True), + "KB1 frame 1 mm shallow": (case(VISION, dict(frame_d=p.frame_d - 1.0)), False), + "KB2 glass 1 mm deeper": (case(VISION, dict(glass_recess=p.glass_recess + 1.0)), False), + "KB3 glass never tagged": (case(VISION, post=_untagged_vision), False), + "KB4 no joint": (case(VISION, dict(joint=0.0)), False), + "KB5 solid tagged as glass": (case(SOLID, post=_tag_solid), False), + "KB6 fin 0.1 deg off bin": (case(FIN, dict(angle=TYPES[FIN]["angle"] + 0.1)), False), + "KB7 fin sign flipped": (case(FIN, dict(angle=-TYPES[FIN]["angle"])), False), + "KB8 fin lifted 1 mm": (case(FIN, post=lambda b: scad.translate_shape(shape=b, vector=(0.0, 0.0, 1.0))), False), + "KB9 fin 1 mm thicker": (case(FIN, dict(fin_t=p.fin_t + 1.0)), False), +} +wrong = [] +for name, ((tid, part), should_pass) in cases.items(): + failures = check_body(tid, TYPES[tid], part, p) + passed = not failures + verdict = "as designed" if passed == should_pass else "GATE BROKEN" + print(f"--- {name:26s} => {'pass' if passed else 'fail'} ({verdict})" + + ("" if passed else f" first: {failures[0]} [+{len(failures) - 1}]")) + if passed != should_pass: + wrong.append(name) +sys.exit(report("S2 known-bad", [f"{name}: gate did not discriminate" for name in wrong])) diff --git a/examples/pcg_facade_tower/verify/s2_verify.py b/examples/pcg_facade_tower/verify/s2_verify.py new file mode 100644 index 00000000..27870663 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s2_verify.py @@ -0,0 +1,182 @@ +"""S2 gate: panel family (every vision / solid / fin type used by RECT, CHAMFER and L_SHAPE), measured geometry. + + P1 body Part id == type id; one solid; BRepCheck valid + P2 envelope panel bbox == [j/2, w - j/2] x [j/2, h - j/2] x [0, frame_d] (1e-6); V3 transport <= 1600 x 4200 x 600 + P3 frame +Z planar area at z = frame_d and -Z planar area at z = 0 both == frame ring area + P4 glass vision: exactly one face tagged facade.vision_glass, planar +Z at frame_d - glass_recess, + area == layout._glass_area (the WWR numerator); solid: no tagged face + P5 volume == ring + recessed plates (analytic, 1e-6 relative) + P6 fin (V5) side-face normals measure the bin angle +/- 0.05 deg; side planes fin_t apart; root flush on z = 0 + with width fin_t / cos(a); tip z <= fin_depth + 1 (building line: frame_d + fin_depth + 1); + y span [j/2, h - j/2]; volume fin_t * fin_depth * (h - j) + + ../../../.venv/bin/python s2_verify.py +""" +from __future__ import annotations + +import math +import sys + +from _common import report, tight_bbox + +import layout as L +import panels as P +from simplecadapi import ql + +TOL = 1.0e-6 +ENVELOPE = (1600.0, L.MAX_PANEL_H, 600.0) + + +def _faces(body, nz: float, z: float): + return ql.faces().where(ql.and_( + ql.prop("geom.type", "==", "PLANE"), + ql.prop("geom.normal.z", ">=", nz - 1.0e-9) if nz > 0 else ql.prop("geom.normal.z", "<=", nz + 1.0e-9), + ql.prop("geom.center.z", ">=", z - 1.0e-6), ql.prop("geom.center.z", "<=", z + 1.0e-6), + )).resolve(body) + + +def _area(faces) -> float: + return sum(f.get_area() for f in faces) + + +def _valid(body) -> bool: + from OCP.BRepCheck import BRepCheck_Analyzer + return BRepCheck_Analyzer(body.wrapped).IsValid() + + +def _rect_area(r) -> float: + return (r[2] - r[0]) * (r[3] - r[1]) + + +def check_panel(tid: str, spec: dict, body, p: L.FacadeParams) -> list[str]: + bad = [] + w, h, j, fd = spec["w"], spec["h"], p.joint, p.frame_d + spandrel = spec.get("spandrel", 0.0) + ops = P.openings(w, h, spandrel if spec["family"] == "vision" else 0.0, p.frame_sight, j) + + x0, y0, z0, x1, y1, z1 = tight_bbox(body) + want = (j / 2.0, j / 2.0, 0.0, w - j / 2.0, h - j / 2.0, fd) + if max(abs(a - b) for a, b in zip((x0, y0, z0, x1, y1, z1), want)) > TOL: + bad.append(f"P2 {tid}: bbox {tuple(round(v, 6) for v in (x0, y0, z0, x1, y1, z1))} != {want}") + size = (x1 - x0, y1 - y0, z1 - z0) + if any(s > e for s, e in zip(size, ENVELOPE)): + bad.append(f"P2 {tid}: size {tuple(round(s) for s in size)} exceeds transport {ENVELOPE}") + + ring = (w - j) * (h - j) - sum(_rect_area(r) for r in ops.values()) + front, back = _area(_faces(body, 1.0, fd)), _area(_faces(body, -1.0, 0.0)) + if abs(front - ring) > TOL * ring or abs(back - ring) > TOL * ring: + bad.append(f"P3 {tid}: frame face areas front {front:.3f} / back {back:.3f} != ring {ring:.3f}") + + tagged = ql.faces().where(ql.tag(P.GLASS_TAG)).resolve(body) + if spec["family"] == "vision": + design = L._glass_area(w, h, spandrel, p) + if len(tagged) != 1: + bad.append(f"P4 {tid}: {len(tagged)} tagged glass faces") + else: + g = tagged[0] + n, c = g.get_normal_at(), g.get_center() + if abs(n.z - 1.0) > 1.0e-9 or abs(c.z - (fd - p.glass_recess)) > TOL: + bad.append(f"P4 {tid}: glass face normal {n.z:.9f} at z {c.z:.6f}") + if abs(g.get_area() - design) > TOL * design or abs(_rect_area(ops["vision"]) - design) > TOL * design: + bad.append(f"P4 {tid}: glass area {g.get_area():.3f} / opening {_rect_area(ops['vision']):.3f} " + f"!= design {design:.3f}") + elif tagged: + bad.append(f"P4 {tid}: solid panel carries {len(tagged)} glass tags") + + plates = 0.0 + if spec["family"] == "vision": + plates += _rect_area(ops["vision"]) * p.glass_t + if "spandrel" in ops: + plates += _rect_area(ops["spandrel"]) * (p.glass_t + p.glass_recess / 2.0) + else: + plates += _rect_area(ops["vision"]) * (p.glass_t + p.glass_recess / 2.0) + vol = ring * fd + plates + if abs(body.get_volume() - vol) > TOL * vol: + bad.append(f"P5 {tid}: volume {body.get_volume():.3f} != {vol:.3f}") + return bad + + +def check_fin(tid: str, spec: dict, body, p: L.FacadeParams) -> list[str]: + bad = [] + a, h, j = spec["angle"], spec["h"], p.joint + th = math.radians(a) + sides = ql.faces().where(ql.and_( + ql.prop("geom.type", "==", "PLANE"), + ql.prop("geom.normal.y", ">=", -1.0e-9), ql.prop("geom.normal.y", "<=", 1.0e-9), + ql.prop("geom.normal.x", ">=", 1.0e-6), + )).resolve(body) + if len(sides) != 1: + bad.append(f"P6 {tid}: {len(sides)} +x side faces") + else: + n = sides[0].get_normal_at() + measured = math.degrees(math.atan2(-n.z, n.x)) # side along (sin a, cos a) -> outward normal (cos a, -sin a) + if abs(measured - a) > 0.05: + bad.append(f"P6 {tid}: side normal measures {measured:.4f} deg != bin {a}") + c_r = sides[0].get_center() + opp = ql.faces().where(ql.and_(ql.prop("geom.type", "==", "PLANE"), + ql.prop("geom.normal.x", "<=", -1.0e-6))).resolve(body) + if len(opp) == 1: + c_l = opp[0].get_center() + gap = (c_r.x - c_l.x) * n.x + (c_r.z - c_l.z) * n.z + if abs(gap - p.fin_t) > TOL: + bad.append(f"P6 {tid}: side planes {gap:.6f} apart != fin_t {p.fin_t}") + else: + bad.append(f"P6 {tid}: {len(opp)} -x side faces") + root = _faces(body, -1.0, 0.0) + want_root = p.fin_t / math.cos(th) * (h - j) + if abs(_area(root) - want_root) > TOL * want_root: + bad.append(f"P6 {tid}: root area {_area(root):.3f} != {want_root:.3f} (not flush on the frame face)") + x0, y0, z0, x1, y1, z1 = tight_bbox(body) + if abs(z0) > TOL or z1 > p.fin_depth + 1.0 or abs(y0 - j / 2.0) > TOL or abs(y1 - (h - j / 2.0)) > TOL: + bad.append(f"P6 {tid}: bbox z [{z0:.6f}, {z1:.3f}] y [{y0:.6f}, {y1:.6f}]") + if a != 0.0 and math.copysign(1.0, (x0 + x1) / 2.0) != math.copysign(1.0, a): + bad.append(f"P6 {tid}: tip leans to x {(x0 + x1) / 2.0:.1f}, angle {a} (sign convention broken)") + if any(s > e for s, e in zip((x1 - x0, y1 - y0, z1 - z0), ENVELOPE)): + bad.append(f"P6 {tid}: fin exceeds transport {ENVELOPE}") + vol = p.fin_t * p.fin_depth * (h - j) + if abs(body.get_volume() - vol) > TOL * vol: + bad.append(f"P6 {tid}: volume {body.get_volume():.3f} != {vol:.3f}") + return bad + + +def all_types() -> dict: + """Union of the vision / solid / fin types of the three default plans (identical ids must mean identical specs).""" + out = {} + for kind in L.PlanKind: + for tid, spec in L.build_schedule(L.default_params(plan_kind=kind)).types.items(): + if spec["family"] == "corner": + continue + if out.setdefault(tid, spec) != spec: + raise AssertionError(f"type id {tid} names two specs: {out[tid]} / {spec}") + return out + + +def check_body(tid: str, spec: dict, part, p: L.FacadeParams) -> list[str]: + bad = [] + if part.part_id != tid: + bad.append(f"P1 {tid}: part id {part.part_id}") + solids = ql.solids().resolve(part.body) + if len(solids) != 1: + return bad + [f"P1 {tid}: {len(solids)} solids"] + if not _valid(part.body): + bad.append(f"P1 {tid}: BRep invalid") + return bad + (check_fin if spec["family"] == "fin" else check_panel)(tid, spec, part.body, p) + + +def main() -> int: + p = L.default_params() + types = all_types() + failures, parts = [], {} + for tid, spec in sorted(types.items()): + result = P.make_panel_part(tid, spec, p) + parts[tid] = result.value + failures += check_body(tid, spec, result.value, p) + fams = {} + for spec in types.values(): + fams[spec["family"]] = fams.get(spec["family"], 0) + 1 + print(f"-- checked {len(types)} types {fams}") + return report("S2 panels", failures) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/pcg_facade_tower/verify/s3_known_bad.py b/examples/pcg_facade_tower/verify/s3_known_bad.py new file mode 100644 index 00000000..9f7449b3 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s3_known_bad.py @@ -0,0 +1,105 @@ +"""S3 known-bad proof: the S3 checks must FAIL on each deliberately wrong solid / placement and PASS on the clean ones. + +Bodies are built from mutated builder arguments but checked against the unchanged schedule (a drifted builder). + + KB0 clean corners (+90 / +45 / -90), slab, canopy -> must pass (L_SHAPE; +45 from CHAMFER) + KB1 corner legs 1 mm long (corner_c + 1) -> K2 (end faces) + K6 (joint) + KB2 corner 1 mm shallow (frame_d - 1) -> K1 (volume) + K2 (outer faces) + KB3 +90 corner built as +45 -> K1 + K2 + KB4 corner built without joint -> K1 (y span) + K6 + KB5 slab on the building line (slab_gap 0) -> K4 + K3 (clearance) + KB6 slab 1 mm thick -> K4 (z span) + KB7 a floor slab placed on top of its level -> K3 (level) + KB8 canopy shifted one module east -> K5 (centre) + KB9 canopy against the panel face (no standoff) -> K7 + KB10 roof slabs floor-style, on top of the roof level -> K3 (roof end + terrace cut by the upper tier) +""" +import sys +from types import SimpleNamespace + +from _common import report +from s3_verify import Parts, check_canopy, check_canopy_fit, check_corner, check_corner_fit, check_slab, check_slab_fit +from s3_verify import fit_bands + +import corners as C +import layout as L +import slabs as S + +p = L.default_params(plan_kind=L.PlanKind.L_SHAPE) +sched = L.build_schedule(p) +chamfer = L.build_schedule(L.default_params(plan_kind=L.PlanKind.CHAMFER)) + + +def corner_case(which: float, **mutate) -> list[str]: + """Tier-0 tower band corner of this turn (L_SHAPE has +/-90, CHAMFER has +45).""" + s = sched if abs(which) == 90.0 else chamfer + band = next(b for b in fit_bands(s) if b["band"] == L.Band.TOWER.value and b["tier"] == 0) + rec = next(r for r in band["corners"] if abs(r["turn"] - which) < 1.0e-9) + tid, spec = rec["type_id"], s.types[rec["type_id"]] + body = C.build_corner(**{**C.builder_args(spec, s.params)[1], **mutate}) + parts = Parts(s) + parts.bodies[tid] = body + return (check_corner(tid, spec, SimpleNamespace(part_id=tid, body=body), s.params) + + check_corner_fit(parts, band, rec)) + + +def slab_case(plan=None, slab_t=None, instances=None) -> list[str]: + kwargs = S.slab_args(sched, 0) + body = S.build_slab(plan=plan or kwargs["plan"], slab_t=slab_t or kwargs["slab_t"]) + bodies = {k: S.make_slab_part(sched, k).value.body for k in range(len(sched.tiers))} + bodies[0] = body + real = L.slab_instances + if instances is not None: + L.slab_instances = lambda _s: instances + try: + return (check_slab(sched, 0, SimpleNamespace(part_id=S.slab_type_id(0), body=body)) + + check_slab_fit(Parts(sched), bodies)) + finally: + L.slab_instances = real + + +def canopy_case(dx=0.0, dy=0.0) -> list[str]: + kwargs = S.canopy_args(sched) + body = S.build_canopy(**{**kwargs, "x0": kwargs["x0"] + dx, "x1": kwargs["x1"] + dx, "y1": kwargs["y1"] + dy}) + return (check_canopy(sched, SimpleNamespace(part_id=S.CANOPY_ID, body=body)) + + check_canopy_fit(Parts(sched), body)) + + +def _raised_floor() -> list[dict]: + out = [dict(i) for i in L.slab_instances(sched)] + out[3]["level"] += p.slab_t + return out + + +def _floor_style_roofs() -> list[dict]: + return [dict(i, level=sched.levels[i["role"]]) if i["role"].startswith("roof") else dict(i) + for i in L.slab_instances(sched)] + + +cases = { + "KB0 clean corner +90": (lambda: corner_case(90.0), True), + "KB0 clean corner +45": (lambda: corner_case(45.0), True), + "KB0 clean corner -90": (lambda: corner_case(-90.0), True), + "KB0 clean slab": (lambda: slab_case(), True), + "KB0 clean canopy": (lambda: canopy_case(), True), + "KB1 corner legs +1 mm": (lambda: corner_case(90.0, corner_c=p.corner_c + 1.0), False), + "KB2 corner 1 mm shallow": (lambda: corner_case(-90.0, frame_d=p.frame_d - 1.0), False), + "KB3 +90 built as +45": (lambda: corner_case(90.0, turn=45.0), False), + "KB4 corner without joint": (lambda: corner_case(90.0, joint=0.0), False), + "KB5 slab on building line": (lambda: slab_case(plan=tuple(map(tuple, sched.tiers[0]))), False), + "KB6 slab 1 mm thick": (lambda: slab_case(slab_t=p.slab_t + 1.0), False), + "KB7 floor slab raised": (lambda: slab_case(instances=_raised_floor()), False), + "KB8 canopy one module east": (lambda: canopy_case(dx=p.w_std), False), + "KB9 canopy on panel face": (lambda: canopy_case(dy=p.joint), False), + "KB10 floor-style roof slabs": (lambda: slab_case(instances=_floor_style_roofs()), False), +} +wrong = [] +for name, (run, should_pass) in cases.items(): + failures = run() + passed = not failures + verdict = "as designed" if passed == should_pass else "GATE BROKEN" + print(f"--- {name:28s} => {'pass' if passed else 'fail'} ({verdict})" + + ("" if passed else f" first: {failures[0]} [+{len(failures) - 1}]")) + if passed != should_pass: + wrong.append(name) +sys.exit(report("S3 known-bad", [f"{name}: gate did not discriminate" for name in wrong])) diff --git a/examples/pcg_facade_tower/verify/s3_verify.py b/examples/pcg_facade_tower/verify/s3_verify.py new file mode 100644 index 00000000..d9ae3d44 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s3_verify.py @@ -0,0 +1,309 @@ +"""S3 gate: corner pieces, floor slabs and the entrance canopy, measured on the built solids. + +Part checks (local frame): + K1 corner Part id == type id; one solid; BRepCheck valid; y span [j/2, h - j/2]; + volume == profile area x (h - j) + K4 slab one solid, valid; z span [0, slab_t]; one side face per tier edge, each parallel to it and + slab_gap inside the building line; volume == |plan area| x slab_t + K5 canopy one solid, valid; bbox == layout canopy box; centred on the south edge; both ends on lobby + module joints; z0 on the lobby row joint +Fit checks (placed in world through the layout frames, per plan, one band of each (tier, kind)): + K2 corner (V7) outer faces coplanar with both panel bands' outer planes (building line + frame_d), + building-line faces on the building line, end faces at corner_c - joint/2 from the vertex + K6 joint min distance corner <-> last panel of the incoming edge and first panel of the outgoing edge == joint + (+/- 5e-4: A7 rounds type widths to 0.001 mm, the joint absorbs it) + K3 slab floor slabs sit on a realized floor band level of their tier (ground at 0), roof slabs end at + their tier's roof (parapet level); a tier's first floor stands on the roof below; min distance + slab <-> same-level panels / corners == slab_gap (roof: the top floor's); terrace: roof k-1 <-> + tier k first-floor panels / corners / fins == joint/2 (the panels stand j/2 above it) + K7 canopy min distance canopy <-> south lobby panels == joint (the standoff) + + ../../../.venv/bin/python s3_verify.py +""" +from __future__ import annotations + +import sys + +from _common import min_distance, place_shape, report, tight_bbox + +import corners as C +import layout as L +import panels as P +import simplecadapi as scad +import slabs as S +from simplecadapi import ql + +TOL = 1.0e-6 +# A7: panel types are built at the width rounded to 0.001 mm and placed at the exact layout start, so the joint +# after a closer absorbs up to 5e-4 mm. +JOINT_TOL = 0.5 * 10.0 ** -L.TYPE_ROUND_DIGITS + TOL + + +def _valid(body) -> bool: + from OCP.BRepCheck import BRepCheck_Analyzer + return BRepCheck_Analyzer(body.wrapped).IsValid() + + +def _one_valid_solid(tag: str, tid: str, part) -> list[str]: + if part.part_id != tid: + return [f"{tag} {tid}: part id {part.part_id}"] + solids = ql.solids().resolve(part.body) + if len(solids) != 1: + return [f"{tag} {tid}: {len(solids)} solids"] + return [] if _valid(part.body) else [f"{tag} {tid}: BRep invalid"] + + +def _side_planes(body) -> list[tuple]: + """(unit plan normal, plan centre, area) of every vertical planar face.""" + out = [] + for f in ql.faces().where(ql.prop("geom.type", "==", "PLANE")).resolve(body): + n, c = f.get_normal_at(), f.get_center() + if abs(n.z) < 1.0e-9: + out.append(((n.x, n.y), (c.x, c.y), f.get_area())) + return out + + +def _has_plane(planes, normal, anchor, offset) -> bool: + """A face with this outward plan normal whose plane lies `offset` along it from `anchor`.""" + return any(abs(n[0] - normal[0]) < 1.0e-9 and abs(n[1] - normal[1]) < 1.0e-9 + and abs(L._dot(normal, L._sub(c, anchor)) - offset) < TOL for n, c, _ in planes) + + +# ---------------------------------------------------------------- part checks + + +def check_corner(tid: str, spec: dict, part, p: L.FacadeParams) -> list[str]: + bad = _one_valid_solid("K1", tid, part) + if bad: + return bad + j, h = p.joint, spec["h"] + _, y0, _, _, y1, _ = tight_bbox(part.body) + if abs(y0 - j / 2.0) > TOL or abs(y1 - (h - j / 2.0)) > TOL: + bad.append(f"K1 {tid}: y span [{y0:.6f}, {y1:.6f}] != [{j / 2.0}, {h - j / 2.0}]") + area = abs(L.signed_area(C.corner_section(spec["turn"], p.corner_c, p.frame_d, j))) + vol = area * (h - j) + if abs(part.body.get_volume() - vol) > TOL * vol: + bad.append(f"K1 {tid}: volume {part.body.get_volume():.3f} != profile x height {vol:.3f}") + return bad + + +def check_slab(sched: L.FacadeSchedule, tier: int, part) -> list[str]: + tid = S.slab_type_id(tier) + bad = _one_valid_solid("K4", tid, part) + if bad: + return bad + p = sched.params + _, _, z0, _, _, z1 = tight_bbox(part.body) + if abs(z0) > TOL or abs(z1 - p.slab_t) > TOL: + bad.append(f"K4 {tid}: z span [{z0:.6f}, {z1:.6f}] != [0, {p.slab_t}]") + planes = _side_planes(part.body) + frames = L.edge_frames([tuple(v) for v in sched.tiers[tier]]) + if len(planes) != len(frames): + bad.append(f"K4 {tid}: {len(planes)} side faces for {len(frames)} tier edges") + for e, fr in enumerate(frames): + if not _has_plane(planes, fr["n"], fr["v0"], -p.slab_gap): + bad.append(f"K4 {tid}: no side face slab_gap {p.slab_gap} inside edge {e}") + vol = abs(L.signed_area(L.slab_plans(sched)[tier])) * p.slab_t + if abs(part.body.get_volume() - vol) > TOL * vol: + bad.append(f"K4 {tid}: volume {part.body.get_volume():.1f} != {vol:.1f}") + return bad + + +def check_canopy(sched: L.FacadeSchedule, part) -> list[str]: + bad = _one_valid_solid("K5", S.CANOPY_ID, part) + if bad: + return bad + box, lobby = L.canopy_box(sched), sched.band("lobby") + want = (box["x0"], box["y0"], box["z0"], box["x1"], box["y1"], box["z1"]) + got = tight_bbox(part.body) + if max(abs(a - b) for a, b in zip(got, want)) > TOL: + bad.append(f"K5 canopy: bbox {tuple(round(v, 6) for v in got)} != {want}") + fr = L.edge_frames([tuple(v) for v in sched.tiers[0]])[0] + mid = fr["v0"][0] + fr["length"] / 2.0 + if abs((got[0] + got[3]) / 2.0 - mid) > TOL: + bad.append(f"K5 canopy: centre x {(got[0] + got[3]) / 2.0:.6f} != south edge midpoint {mid:.6f}") + south = [r for r in lobby["panels"] if r["edge"] == 0] + starts = [fr["v0"][0] + r["s0"] for r in south] + ends = [fr["v0"][0] + r["s0"] + r["w"] for r in south] + if min(abs(got[0] - s) for s in starts) > TOL or min(abs(got[3] - e) for e in ends) > TOL: + bad.append(f"K5 canopy: ends x [{got[0]:.6f}, {got[3]:.6f}] not on south lobby module joints") + if lobby["rows"] > 1 and abs(got[2] - lobby["height"]) > TOL: + bad.append(f"K5 canopy: z0 {got[2]:.6f} not on the lobby row joint {lobby['height']}") + return bad + + +# ---------------------------------------------------------------- fit checks (world) + + +class Parts: + """Part bodies by type id, memoized here to avoid rebuilding within one gate.""" + + def __init__(self, sched: L.FacadeSchedule): + self.sched, self.bodies = sched, {} + + def body(self, tid: str): + if tid not in self.bodies: + spec, p = self.sched.types[tid], self.sched.params + make = C.make_corner_part if spec["family"] == "corner" else P.make_panel_part + self.bodies[tid] = make(tid, spec, p).value.body + return self.bodies[tid] + + def panel(self, band: dict, rec: dict, level: float): + return place_shape(self.body(rec["type_id"]), L.panel_frame(self.sched, band, rec, level)) + + def corner(self, band: dict, rec: dict, level: float): + return place_shape(self.body(rec["type_id"]), L.corner_frame(self.sched, band, rec, level)) + + +def fit_bands(sched: L.FacadeSchedule) -> list[dict]: + """One band per (tier, kind): corners and closers repeat unchanged across a tier's phases.""" + seen, out = set(), [] + for b in sched.bands: + if (b["tier"], b["band"]) not in seen: + seen.add((b["tier"], b["band"])) + out.append(b) + return out + + +def _edge_panel(band: dict, edge: int, first: bool) -> dict: + recs = [r for r in band["panels"] if r["edge"] == edge] + return min(recs, key=lambda r: r["slot"]) if first else max(recs, key=lambda r: r["slot"]) + + +def check_corner_fit(parts: Parts, band: dict, rec: dict) -> list[str]: + sched, p = parts.sched, parts.sched.params + level = L.band_levels(sched, band["band_id"])[0] + poly = [tuple(v) for v in sched.tiers[band["tier"]]] + frames = L.edge_frames(poly) + i = rec["vertex"] + fin, fout, v = frames[i - 1], frames[i], poly[i] + body = parts.corner(band, rec, level) + planes, c = _side_planes(body), p.corner_c - p.joint / 2.0 + where = f"{band['band_id']} v{i} ({rec['turn']:+.1f} deg)" + bad = [] + for name, normal, offset in (("incoming outer", fin["n"], p.frame_d), ("outgoing outer", fout["n"], p.frame_d), + ("incoming building line", L._mul(fin["n"], -1.0), 0.0), + ("outgoing building line", L._mul(fout["n"], -1.0), 0.0), + ("incoming end", L._mul(fin["t"], -1.0), c), ("outgoing end", fout["t"], c)): + if not _has_plane(planes, normal, v, offset): + bad.append(f"K2 {where}: no {name} face at {offset}") + _, _, z0, _, _, z1 = tight_bbox(body) + if abs(z0 - (level + p.joint / 2.0)) > TOL or abs(z1 - (level + band["height"] - p.joint / 2.0)) > TOL: + bad.append(f"K2 {where}: z span [{z0:.6f}, {z1:.6f}] off the band") + for side, prec in (("incoming", _edge_panel(band, (i - 1) % len(poly), first=False)), + ("outgoing", _edge_panel(band, i, first=True))): + gap = min_distance(body, parts.panel(band, prec, level)) + if abs(gap - p.joint) > JOINT_TOL: + bad.append(f"K6 {where}: {side} panel {prec['type_id']} gap {gap:.6f} != joint {p.joint}") + return bad + + +def check_slab_fit(parts: Parts, slab_bodies: dict) -> list[str]: + sched, p = parts.sched, parts.sched.params + bad = [] + floor_levels = {0: {0.0}} + for b in sched.bands: + if b["band"] == L.Band.TOWER.value: + floor_levels.setdefault(b["tier"], set()).update(round(z, 6) for z in L.band_levels(sched, b["band_id"])) + insts = L.slab_instances(sched) + for inst in insts: + if inst["role"].startswith("roof"): + roof = sched.levels[inst["role"]] + if abs(inst["level"] + p.slab_t - roof) > TOL or abs(sched.band(f"parapet{inst['tier']}")["level"] - roof) > TOL: + bad.append(f"K3 slab {inst['role']}: [{inst['level']}, +slab_t] does not end at the roof {roof}") + elif round(inst["level"], 6) not in floor_levels[inst["tier"]]: + bad.append(f"K3 slab {inst['role']}: level {inst['level']} is no floor level of tier {inst['tier']}") + have = {round(i["level"], 6) for i in insts if not i["role"].startswith("roof")} + for rec in sched.floors: + if round(rec["level"], 6) not in have: + below = sched.levels.get(f"roof{rec['tier'] - 1}") + if below is None or abs(below - rec["level"]) > TOL: + bad.append(f"K3 floor{rec['floor']}: no slab and not standing on a roof") + + def placed_slab(inst): + return scad.translate_shape(shape=slab_bodies[inst["tier"]], vector=(0.0, 0.0, inst["level"])) + + def edge_ends(band, level): + near = [parts.panel(band, _edge_panel(band, e, first=True), level) for e in {r["edge"] for r in band["panels"]}] + return near + [parts.corner(band, rec, level) for rec in band["corners"]] + + for band in fit_bands(sched): # floor slabs at their band level, behind the spandrel + level = L.band_levels(sched, band["band_id"])[0] + inst = next((i for i in insts if abs(i["level"] - level) < TOL and not i["role"].startswith("roof")), None) + if inst is None: + continue + gap = min(min_distance(placed_slab(inst), shape) for shape in edge_ends(band, level)) + if abs(gap - p.slab_gap) > TOL: + bad.append(f"K3 {band['band_id']}: slab clearance {gap:.6f} != slab_gap {p.slab_gap}") + for inst in (i for i in insts if i["role"].startswith("roof")): + k = inst["tier"] + top = next(b for b in sched.bands if b["band"] == L.Band.TOWER.value and b["tier"] == k + and max(sched.floor_types[b["band_id"]]["floors"]) == max( + r["floor"] for r in sched.floors if r["tier"] == k)) + gap = min(min_distance(placed_slab(inst), shape) for shape in edge_ends(top, inst["level"] + p.slab_t - p.floor_h)) + if abs(gap - p.slab_gap) > TOL: + bad.append(f"K3 roof{k}: clearance to the top floor {gap:.6f} != slab_gap {p.slab_gap}") + if k + 1 >= len(sched.tiers): + continue + first = sched.band(f"tier{k + 1}-phase0") + level = sched.levels[f"roof{k}"] + near = [parts.panel(first, r, level) for r in first["panels"]] + edge_ends(first, level) + near += [place_shape(parts.body(r["type_id"]), L.fin_frame(sched, first, r, level)) for r in first["fins"]] + gap = min(min_distance(placed_slab(inst), shape) for shape in near) + if abs(gap - p.joint / 2.0) > TOL: + bad.append(f"K3 roof{k} terrace: clearance to tier {k + 1} first floor {gap:.6f} != joint/2 {p.joint / 2.0}") + return bad + + +def check_canopy_fit(parts: Parts, canopy_body) -> list[str]: + sched, p = parts.sched, parts.sched.params + band = sched.band("lobby") + south = [r for r in band["panels"] if r["edge"] == 0] + gaps = [min_distance(canopy_body, parts.panel(band, r, level)) + for level in L.band_levels(sched, "lobby") for r in south] + if abs(min(gaps) - p.joint) > TOL: + return [f"K7 canopy: standoff {min(gaps):.6f} != joint {p.joint}"] + return [] + + +def corner_types() -> dict: + out = {} + for kind in L.PlanKind: + for tid, spec in L.build_schedule(L.default_params(plan_kind=kind)).types.items(): + if spec["family"] == "corner" and out.setdefault(tid, spec) != spec: + raise AssertionError(f"type id {tid} names two specs: {out[tid]} / {spec}") + return out + + +def main() -> int: + failures = [] + p0 = L.default_params() + ctypes = corner_types() + for tid, spec in sorted(ctypes.items()): + failures += check_corner(tid, spec, C.make_corner_part(tid, spec, p0).value, p0) + turns = sorted({s["turn"] for s in ctypes.values()}) + print(f"-- {len(ctypes)} corner types, turns {turns}") + + for kind in L.PlanKind: + sched = L.build_schedule(L.default_params(plan_kind=kind)) + parts = Parts(sched) + slab_bodies = {} + for k in range(len(sched.tiers)): + part = S.make_slab_part(sched, k).value + failures += check_slab(sched, k, part) + slab_bodies[k] = part.body + n_fit = 0 + for band in fit_bands(sched): + for rec in band["corners"]: + failures += check_corner_fit(parts, band, rec) + n_fit += 1 + failures += check_slab_fit(parts, slab_bodies) + canopy = S.make_canopy_part(sched).value + failures += check_canopy(sched, canopy) + check_canopy_fit(parts, canopy.body) + print(f"-- {kind.value}: {len(sched.tiers)} slab types, {len(L.slab_instances(sched))} slab instances, " + f"{n_fit} corner fits, canopy {L.canopy_box(sched)['modules']} modules") + return report("S3 corners / slabs / canopy", failures) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/pcg_facade_tower/verify/s4_known_bad.py b/examples/pcg_facade_tower/verify/s4_known_bad.py new file mode 100644 index 00000000..0acfa60b --- /dev/null +++ b/examples/pcg_facade_tower/verify/s4_known_bad.py @@ -0,0 +1,131 @@ +"""S4 known-bad proof: the S4 checks must FAIL on each deliberately wrong band assembly and PASS on the clean ones. + +Assemblies are rebuilt from the layout component list with one edit, then checked against the unchanged schedule. +Each bad case must fire (at least) the listed gates; the clean ones none. + + KB0 clean tier0-phase0 / lobby -> must pass + KB1 one panel 1 mm along the edge -> A1 + V1 (joints 21 / 19) + KB2 one panel 30 mm along the edge (into neighbour) -> A1 + V1 (overlap) + V2 + KB3 one panel missing -> A0 + V1 + KB4 one panel not grounded -> A0 + KB5 a vision panel swapped for a solid of its width -> A0 + V4 + KB6 a fin root pushed into its panel (frame_d in) -> A1 + V2 + KB7 a panel turned inside out (x = -t) -> A1 + KB8 a corner piece with the wrong turn -> A0 + V1 + KB9 lobby upper row dropped 30 mm (joint is 20) -> A1 + V1 (z span, row joint) + V2 + KB10 a warm rebuild of a different design -> A2 +""" +import sys + +from _common import report +from s4_verify import check_band, check_solve + +import floor as F +import layout as L +import simplecadapi as scad + +sched = L.build_schedule(L.default_params()) +p = sched.params +FLOOR, LOBBY = sched.band("tier0-phase0"), sched.band("lobby") + + +def assembly(band: dict, edit=None, unground=()) -> scad.Assembly: + comps = F.band_components(sched, band) + if edit: + comps = edit([dict(c, frame=dict(c["frame"])) for c in comps]) + parts = F.type_parts(sched, [c["type_id"] for c in comps]) + asm = scad.make_assembly_rassembly(assembly_id=F.band_assembly_id(band["band_id"])) + for c in comps: + f = c["frame"] + asm = scad.add_component_rassembly( + assembly=asm, item=parts[c["type_id"]].value, component_id=c["component_id"], + placement=scad.make_placement_rplacement(origin=f["origin"], x_axis=f["x_axis"], y_axis=f["y_axis"])) + if c["component_id"] not in unground: + asm = scad.ground_component_rassembly(assembly=asm, component_id=c["component_id"]) + return asm + + +def _find(comps, cid): + return next(c for c in comps if c["component_id"] == cid) + + +def shift(cid, d, dz=0.0, axis=None): + def edit(comps): + for c in comps: + if c["component_id"] == cid or (cid.endswith("*") and c["component_id"].startswith(cid[:-1])): + a = axis or c["frame"]["x_axis"] + o = c["frame"]["origin"] + c["frame"]["origin"] = (o[0] + d * a[0], o[1] + d * a[1], o[2] + dz) + return comps + return edit + + +def drop(cid): + return lambda comps: [c for c in comps if c["component_id"] != cid] + + +def retype(cid, type_id): + def edit(comps): + _find(comps, cid)["type_id"] = type_id + return comps + return edit + + +def flip(cid): + def edit(comps): + c = _find(comps, cid) + rec = next(r for r in FLOOR["panels"] if f"r0-e{r['edge']}-s{r['slot']}" == cid) + o, (tx, ty, _) = c["frame"]["origin"], c["frame"]["x_axis"] + c["frame"]["origin"] = (o[0] + rec["w"] * tx, o[1] + rec["w"] * ty, o[2]) + c["frame"]["x_axis"] = (-tx, -ty, 0.0) + return comps + return edit + + +vision = next(r for r in FLOOR["panels"] if r["kind"] == "vision" and r["edge"] == 0 and r["fin"] is not None) +VISION_ID = f"r0-e0-s{vision['slot']}" +SOLID_SAME_W = L.solid_type_id(vision["w"], FLOOR["height"]) +sched.types.setdefault(SOLID_SAME_W, {"family": "solid", "w": L._r(vision["w"]), "h": L._r(FLOOR["height"])}) +n0 = L.edge_frames(sched.tiers[0])[0]["n"] +other_turn = next(r["type_id"] for r in FLOOR["corners"] if r["type_id"] != FLOOR["corners"][0]["type_id"]) + + +def band_case(band, **kw) -> list[str]: + return check_band(sched, band, assembly(band, **kw))[0] + + +def solve_case() -> list[str]: + """A warm rebuild that is not the same design (different band) must not pass A2.""" + cold = F.make_band_assembly(sched, "parapet0") + warm = F.make_band_assembly(sched, "lobby") + return check_solve("parapet0", cold, warm) + + +cases = { + "KB0 clean tier0-phase0": (lambda: band_case(FLOOR), set()), + "KB0 clean lobby": (lambda: band_case(LOBBY), set()), + "KB0 clean solve": (lambda: check_solve("parapet0", F.make_band_assembly(sched, "parapet0"), + F.make_band_assembly(sched, "parapet0")), set()), + "KB1 panel +1 mm": (lambda: band_case(FLOOR, edit=shift("r0-e0-s4", 1.0)), {"A1", "V1"}), + "KB2 panel +30 mm": (lambda: band_case(FLOOR, edit=shift("r0-e0-s4", 30.0)), {"A1", "V1", "V2"}), + "KB3 panel missing": (lambda: band_case(FLOOR, edit=drop("r0-e3-s5")), {"A0", "V1"}), + "KB4 panel ungrounded": (lambda: band_case(FLOOR, unground=("r0-e1-s3",)), {"A0"}), + "KB5 vision -> solid": (lambda: band_case(FLOOR, edit=retype(VISION_ID, SOLID_SAME_W)), {"A0", "V4"}), + "KB6 fin into its panel": (lambda: band_case(FLOOR, edit=shift(VISION_ID + "-fin", -p.frame_d, + axis=(n0[0], n0[1], 0.0))), {"A1", "V2"}), + "KB7 panel inside out": (lambda: band_case(FLOOR, edit=flip("r0-e0-s6")), {"A1"}), + "KB8 corner wrong turn": (lambda: band_case(FLOOR, edit=retype("r0-v0", other_turn)), {"A0", "V1"}), + "KB9 lobby row 1 -30 mm": (lambda: band_case(LOBBY, edit=shift("r1-*", 0.0, dz=-30.0)), {"A1", "V1", "V2"}), + "KB10 warm rebuild, different design": (solve_case, {"A2"}), +} +wrong = [] +for name, (run, gates) in cases.items(): + failures = run() + fired = {f.split()[0] for f in failures} + ok = fired == gates if not gates else gates <= fired + print(f"--- {name:26s} => {'pass' if not failures else 'fail ' + '+'.join(sorted(fired))} " + f"({'as designed' if ok else 'GATE BROKEN, want ' + '+'.join(sorted(gates))})" + + ("" if not failures else f" first: {failures[0]} [+{len(failures) - 1}]")) + if not ok: + wrong.append(name) +sys.exit(report("S4 known-bad", [f"{name}: gate did not discriminate" for name in wrong])) diff --git a/examples/pcg_facade_tower/verify/s4_verify.py b/examples/pcg_facade_tower/verify/s4_verify.py new file mode 100644 index 00000000..b027379c --- /dev/null +++ b/examples/pcg_facade_tower/verify/s4_verify.py @@ -0,0 +1,283 @@ +"""S4 gate: band subassemblies (floor types, lobby, parapets), measured on the solved component placements. + +Every component is placed here from its solved `Placement` (columns x, y, z, origin -> gp_Trsf), independently of +the layout frames, then: + A0 contents component ids / types == schedule (panels + fins + corners per row); definitions == the band's + unique type ids; every component grounded (assembly and definition) + A1 placement placement == layout frame (origin, x = t, y = +Z) and z_axis == outward normal n; the SDK's own + projection (make_compound_from_assembly) has the same volume and bbox as ours + V1 closure per row and edge, corner v_e | panels by slot | corner v_e+1 in that order along t (vertex + projection), consecutive gap == joint (A7 tolerance), no overlap; fins excluded. Every piece spans + z [row + j/2, row + h - j/2]; stacked lobby rows meet at a joint + V2 clash all component pairs whose boxes touch: Common volume == 0 (panels, fins, corners, both rows) + V4 glass band glass == sum of tagged glass area on the definition bodies (tags stay local, SDK scope rule) + == layout design glass; WWR of this band per orientation is reported + A2 solve rebuild is deterministic: a second build of the same band carries the identical definition hash + + ../../../.venv/bin/python s4_verify.py [band_id ...] (default: every band of the default schedule) +""" +from __future__ import annotations + +import sys +import time + +from _common import report, tight_bbox +from s3_verify import JOINT_TOL, TOL + +import floor as F +import layout as L +import panels as P +import simplecadapi as scad +from simplecadapi import ql + +CLASH_TOL = 1.0e-3 # mm^3 of Common volume + + +def trsf(pl): + """gp_Trsf of a solved Placement (columns x, y, z, origin).""" + from OCP.gp import gp_Trsf + + (xx, xy, xz), (yx, yy, yz), (zx, zy, zz), (ox, oy, oz) = pl.x_axis, pl.y_axis, pl.z_axis, pl.origin + out = gp_Trsf() + out.SetValues(xx, yx, zx, ox, xy, yy, zy, oy, xz, yz, zz, oz) + return out + + +def placed(component, outer=None): + """World shape of a band component from its solved placement (diagnostic, not the SDK path); `outer` = the + parent's gp_Trsf when the band is itself placed (tower).""" + from OCP.TopLoc import TopLoc_Location + + t = trsf(component.placement) + if outer is not None: + t = outer.Multiplied(t) + return component.item.body.wrapped.Moved(TopLoc_Location(t)) + + +def _points(shape) -> list[tuple]: + from OCP.BRep import BRep_Tool + from OCP.TopAbs import TopAbs_VERTEX + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS + + out, it = [], TopExp_Explorer(shape, TopAbs_VERTEX) + while it.More(): + pnt = BRep_Tool.Pnt_s(TopoDS.Vertex_s(it.Current())) + out.append((pnt.X(), pnt.Y(), pnt.Z())) + it.Next() + return out + + +def _volume(shape) -> float: + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps + + props = GProp_GProps() + BRepGProp.VolumeProperties_s(shape, props) + return props.Mass() + + +def _common_volume(a, b) -> float: + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common + + op = BRepAlgoAPI_Common(a, b) + if not op.IsDone(): + raise RuntimeError("Common failed") + return _volume(op.Shape()) + + +def expected_components(sched: L.FacadeSchedule, band: dict) -> dict: + return {c["component_id"]: c for c in F.band_components(sched, band)} + + +def check_contents(sched: L.FacadeSchedule, band: dict, asm, definition=None) -> list[str]: + bad, want = [], expected_components(sched, band) + got = {c.component_id: c for c in asm.components} + if set(got) != set(want): + bad.append(f"A0 {band['band_id']}: missing {sorted(set(want) - set(got))[:4]} " + f"extra {sorted(set(got) - set(want))[:4]}") + for cid in sorted(set(got) & set(want)): + if got[cid].item.part_id != want[cid]["type_id"]: + bad.append(f"A0 {band['band_id']} {cid}: type {got[cid].item.part_id} != {want[cid]['type_id']}") + if set(asm.grounded_component_ids) != set(got): + bad.append(f"A0 {band['band_id']}: ungrounded {sorted(set(got) - set(asm.grounded_component_ids))[:4]}") + if definition is not None: + if set(definition.grounded_instance_ids) != set(got): + bad.append(f"A0 {band['band_id']}: definition grounds {len(definition.grounded_instance_ids)}/{len(got)}") + refs = {getattr(r, "definition_id", r) for r in definition.definition_refs} + types = {c["type_id"] for c in want.values()} + if len(refs) != len(types): + bad.append(f"A0 {band['band_id']}: {len(refs)} definitions for {len(types)} types") + return bad + + +def check_placements(sched: L.FacadeSchedule, band: dict, asm, shapes: dict) -> list[str]: + bad, want = [], expected_components(sched, band) + normals = [fr["n"] for fr in L.edge_frames(sched.tiers[band["tier"]])] + for c in asm.components: + if c.component_id not in want: + continue + f, pl = want[c.component_id]["frame"], c.placement + edge = (int(c.component_id.split("-v")[1]) if "-v" in c.component_id + else int(c.component_id.split("-e")[1].split("-")[0])) + n = normals[edge - 1] if "-v" in c.component_id else normals[edge] # corner x = incoming edge + dev = max(max(abs(a - b) for a, b in zip(pl.origin, f["origin"])), + 1.0e3 * max(abs(a - b) for a, b in zip(pl.x_axis, f["x_axis"])), + 1.0e3 * max(abs(a - b) for a, b in zip(pl.y_axis, f["y_axis"])), + 1.0e3 * max(abs(a - b) for a, b in zip(pl.z_axis, (n[0], n[1], 0.0)))) + if dev > TOL: + bad.append(f"A1 {band['band_id']} {c.component_id}: placement off layout frame by {dev:.3g}") + compound = scad.make_compound_from_assembly_rcompound(assembly=asm) + vol = sum(_volume(s) for s in shapes.values()) + sdk_vol = _volume(compound.wrapped) + if abs(sdk_vol - vol) > TOL * vol: + bad.append(f"A1 {band['band_id']}: SDK projection volume {sdk_vol:.6g} != {vol:.6g}") + ours = [tight_bbox(s) for s in shapes.values()] + box = tuple(min(b[i] for b in ours) for i in range(3)) + tuple(max(b[i] for b in ours) for i in range(3, 6)) + if max(abs(a - b) for a, b in zip(tight_bbox(compound), box)) > TOL: + bad.append(f"A1 {band['band_id']}: SDK projection bbox != ours") + return bad + + +def check_closure(sched: L.FacadeSchedule, band: dict, shapes: dict, z_base: float = 0.0, + label: str | None = None) -> list[str]: + """`z_base` = the band's level when the shapes are tower-placed; `label` names the realization in messages.""" + bad, p = [], sched.params + name = label or band["band_id"] + frames = L.edge_frames(sched.tiers[band["tier"]]) + j, h, n = p.joint, band["height"], len(frames) + spans = {} + for cid, shape in shapes.items(): + z = [pt[2] for pt in _points(shape)] + spans[cid] = (min(z), max(z)) + for row in range(band["rows"]): + z0 = z_base + row * h + for cid in (c for c in shapes if c.startswith(f"r{row}-")): + lo, hi = spans[cid] + if abs(lo - (z0 + j / 2.0)) > TOL or abs(hi - (z0 + h - j / 2.0)) > TOL: + bad.append(f"V1 {name} {cid}: z span [{lo:.6f}, {hi:.6f}] != row {row} band") + for e, fr in enumerate(frames): + order = ([f"r{row}-v{e}"] + [f"r{row}-e{e}-s{r['slot']}" for r in + sorted((r for r in band["panels"] if r["edge"] == e), key=lambda r: r["slot"])] + + [f"r{row}-v{(e + 1) % n}"]) + missing = [cid for cid in order if cid not in shapes] + if missing: + bad.append(f"V1 {name} r{row} e{e}: no piece at {missing[:3]}") + order = [cid for cid in order if cid in shapes] + v0, t = fr["v0"], fr["t"] + ext = {} + for cid in order: + s = [(x - v0[0]) * t[0] + (y - v0[1]) * t[1] for x, y, _ in _points(shapes[cid])] + ext[cid] = (min(s), max(s)) + ext[order[0]] = (float("-inf"), ext[order[0]][1]) # corner at the edge start: only its max faces edge e + ext[order[-1]] = (ext[order[-1]][0], float("inf")) # corner at the edge end: only its min + by_pos = sorted(order, key=lambda cid: ext[cid][0]) + if by_pos != order: + bad.append(f"V1 {name} r{row} e{e}: order along t {by_pos[:3]}... != schedule") + continue + for a, b in zip(order, order[1:]): + gap = ext[b][0] - ext[a][1] + if abs(gap - j) > JOINT_TOL: + bad.append(f"V1 {name} {a} | {b}: gap {gap:.6f} != joint {j}") + for row in range(1, band["rows"]): # stacked lobby rows + below = max(spans[c][1] for c in shapes if c.startswith(f"r{row - 1}-")) + above = min(spans[c][0] for c in shapes if c.startswith(f"r{row}-")) + if abs(above - below - j) > TOL: + bad.append(f"V1 {name} rows {row - 1}|{row}: gap {above - below:.6f} != joint {j}") + return bad + + +def check_clash(band: dict, shapes: dict) -> tuple[list[str], int]: + from OCP.Bnd import Bnd_Box + from OCP.BRepBndLib import BRepBndLib + + boxes = {} + for cid, shape in shapes.items(): + box = Bnd_Box() + BRepBndLib.Add_s(shape, box) + box.Enlarge(1.0e-3) + boxes[cid] = box + bad, ids, tested = [], sorted(shapes), 0 + for i, a in enumerate(ids): + for b in ids[i + 1:]: + if boxes[a].IsOut(boxes[b]): + continue + tested += 1 + v = _common_volume(shapes[a], shapes[b]) + if v > CLASH_TOL: + bad.append(f"V2 {band['band_id']} {a} x {b}: Common volume {v:.4g} mm^3") + return bad, tested + + +def band_glass(sched: L.FacadeSchedule, band: dict, asm) -> tuple[dict, dict, dict]: + """(measured glass, design glass, gross facade area) per orientation, one realization of the band.""" + p, area_by_type = sched.params, {} + orient = {f"r{row}-e{r['edge']}-s{r['slot']}": r["orientation"] + for row in range(band["rows"]) for r in band["panels"]} + glass, design, gross = {}, {}, {} + for c in asm.components: + if c.component_id not in orient: + continue + tid = c.item.part_id + if tid not in area_by_type: + area_by_type[tid] = sum(f.get_area() for f in ql.faces().where(ql.tag(P.GLASS_TAG)).resolve(c.item.body)) + o = orient[c.component_id] + glass[o] = glass.get(o, 0.0) + area_by_type[tid] + for rec in band["panels"]: + if rec["kind"] == "vision": + spandrel = sched.types[rec["type_id"]]["spandrel"] + design[rec["orientation"]] = (design.get(rec["orientation"], 0.0) + + band["rows"] * L._glass_area(rec["w"], band["height"], spandrel, p)) + for fr in L.edge_frames(sched.tiers[band["tier"]]): + o = L.orientation(fr["n"]) + gross[o] = gross.get(o, 0.0) + (fr["length"] - 2.0 * p.corner_c) * band["height"] * band["rows"] + return glass, design, gross + + +def check_glass(sched: L.FacadeSchedule, band: dict, asm) -> tuple[list[str], dict]: + glass, design, gross = band_glass(sched, band, asm) + bad = [] + for o in sorted(set(glass) | set(design)): + g, d = glass.get(o, 0.0), design.get(o, 0.0) + if abs(g - d) > TOL * max(d, 1.0): + bad.append(f"V4 {band['band_id']} {o}: tagged glass {g:.1f} != design {d:.1f}") + return bad, {o: glass.get(o, 0.0) / gross[o] for o in sorted(gross)} + + +def check_band(sched: L.FacadeSchedule, band: dict, asm, definition=None) -> tuple[list[str], dict]: + bad = check_contents(sched, band, asm, definition) + shapes = {c.component_id: placed(c) for c in asm.components} + bad += check_placements(sched, band, asm, shapes) + bad += check_closure(sched, band, shapes) + clash, tested = check_clash(band, shapes) + glass, wwr = check_glass(sched, band, asm) + return bad + clash + glass, {"components": len(shapes), "clash_pairs": tested, "wwr": wwr} + + +def check_solve(band_id: str, cold, warm) -> list[str]: + bad = [] + if warm.definition.content_hash != cold.definition.content_hash: + bad.append(f"A2 {band_id}: rebuild changed the definition hash") + return bad + + +def main(band_ids: list[str]) -> int: + sched = L.build_schedule(L.default_params()) + failures = [] + for band_id in band_ids or [b["band_id"] for b in sched.bands]: + start = time.perf_counter() + band = sched.band(band_id) + cold = F.make_band_assembly(sched, band_id) + warm = F.make_band_assembly(sched, band_id) + bad, info = check_band(sched, band, cold.value, cold.definition) + bad += check_solve(band_id, cold, warm) + failures += bad + wwr = " ".join(f"{o}={v:.3f}" for o, v in info["wwr"].items()) + print(f" {band_id:14s} {info['components']:4d} comps {len(cold.definition.definition_refs):3d} defs " + f"{info['clash_pairs']:4d} clash pairs wwr {wwr} {'ok' if not bad else f'{len(bad)} FAIL'} " + f"({time.perf_counter() - start:.1f}s)") + return report("S4", failures) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/examples/pcg_facade_tower/verify/s5_known_bad.py b/examples/pcg_facade_tower/verify/s5_known_bad.py new file mode 100644 index 00000000..81d34837 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s5_known_bad.py @@ -0,0 +1,120 @@ +"""S5 known-bad proof: the S5 checks must FAIL on each deliberately wrong tower assembly and PASS on the clean one. + +Towers are rebuilt from the tower component list with one edit (same band / slab / canopy definitions), then checked +against the unchanged schedule. Each bad case must fire (at least) the listed gates; the clean ones none. + + KB0 clean tower -> must pass + KB1 floor2 30 mm high (into floor3, a sampled floor) -> V8 + V1 + V2 + KB2 floor7 missing -> T0 + V4 (T1 is SDK-vs-ours, self-consistent here) + KB3 floor13 (tier 1's first) given a tier 0 band -> T0 + V8 + V2 (into parapet0) + V4 + KB4 parapet1 standing on roof 0 -> V8 + V1 + V2 + KB5 roof slab 0 floor-style (on the roof, not below it) -> V8 + V2 (cuts tier 1's first floor) + KB6 floor5 with the wrong fin phase -> T0 + V4 + KB7 floor20 not grounded -> T0 + KB8 slab under floor2 pushed 40 mm east (slab_gap is 30) -> T1 + V2 + KB9 floor3 turned 90 degrees about its centre -> T1 + V1 + V5 + KB10 a warm rebuild of a different design -> A2 +""" +import math +import sys + +from _common import report +from s4_verify import check_solve +from s5_verify import check_tower + +import layout as L +import simplecadapi as scad +from tower import TOWER_ID, definitions, make_tower, tower_components + +sched = L.build_schedule(L.default_params()) +p = sched.params +COMPS = tower_components(sched) +DEFS = definitions(sched, COMPS) + + +def tower(edit=None, unground=()) -> scad.Assembly: + comps = [dict(c) for c in COMPS] + if edit: + comps = edit(comps) + asm = scad.make_assembly_rassembly(assembly_id=TOWER_ID) + for c in comps: + dx, dy = c.get("xy", (0.0, 0.0)) + a = math.radians(c.get("rot", 0.0)) + cx, cy = c.get("centre", (0.0, 0.0)) + # rotation about (cx, cy): origin = centre - R centre + ox = cx - (math.cos(a) * cx - math.sin(a) * cy) + dx + oy = cy - (math.sin(a) * cx + math.cos(a) * cy) + dy + asm = scad.add_component_rassembly( + assembly=asm, item=DEFS[c["ref"]].value, component_id=c["component_id"], + placement=scad.make_placement_rplacement(origin=(ox, oy, c["level"]), x_axis=(math.cos(a), math.sin(a), 0.0), + y_axis=(-math.sin(a), math.cos(a), 0.0))) + if c["component_id"] not in unground: + asm = scad.ground_component_rassembly(assembly=asm, component_id=c["component_id"]) + return asm + + +def _find(comps, cid): + return next(c for c in comps if c["component_id"] == cid) + + +def change(cid, **kw): + def edit(comps): + c = _find(comps, cid) + for key, value in kw.items(): + c[key] = value(c[key]) if callable(value) else value + return comps + return edit + + +def drop(cid): + return lambda comps: [c for c in comps if c["component_id"] != cid] + + +def tower_case(**kw) -> list[str]: + return check_tower(sched, tower(**kw))[0] + + +def solve_case() -> list[str]: + """A warm rebuild that is not the same design (edited schedule) must not pass A2.""" + cold = make_tower(sched) + warm = make_tower(L.build_schedule(L.default_params(n_floors=35))) + return check_solve("tower", cold, warm) + + +first13 = _find(COMPS, "floor13") +poly0 = sched.tiers[0] +centre = (sum(v[0] for v in poly0) / len(poly0), sum(v[1] for v in poly0) / len(poly0)) +phase5 = _find(COMPS, "floor5")["ref"][1] +other_phase = next(b["band_id"] for b in sched.bands + if b.get("tier") == 0 and b["band"] == L.Band.TOWER.value and b["band_id"] != phase5) +roof0 = sched.levels["roof0"] + +cases = { + "KB0 clean tower": (lambda: tower_case(), set()), + "KB1 floor2 +30 mm": (lambda: tower_case(edit=change("floor2", level=lambda z: z + 30.0)), {"V8", "V1", "V2"}), + "KB2 floor7 missing": (lambda: tower_case(edit=drop("floor7")), {"T0", "V4"}), + "KB3 floor13 tier-0 band": (lambda: tower_case(edit=change("floor13", ref=("band", "tier0-phase0"))), + {"T0", "V8", "V2", "V4"}), + "KB4 parapet1 on roof 0": (lambda: tower_case(edit=change("parapet1", level=roof0)), {"V8", "V1", "V2"}), + "KB5 floor-style roof slab 0": (lambda: tower_case(edit=change("slab-roof0", level=roof0)), {"V8", "V2"}), + "KB6 floor5 wrong phase": (lambda: tower_case(edit=change("floor5", ref=("band", other_phase))), {"T0", "V4"}), + "KB7 floor20 ungrounded": (lambda: tower_case(unground=("floor20",)), {"T0"}), + "KB8 slab-floor2 +40 mm E": (lambda: tower_case(edit=change("slab-floor2", xy=(40.0, 0.0))), {"T1", "V2"}), + "KB9 floor3 turned 90": (lambda: tower_case(edit=change("floor3", rot=90.0, centre=centre)), {"T1", "V1", "V5"}), + "KB10 warm rebuild, different design": (solve_case, {"A2"}), +} +assert first13["ref"][1].startswith("tier1-"), first13 +only = sys.argv[1:] +wrong = [] +for name, (run, gates) in cases.items(): + if only and not any(name.startswith(o + " ") for o in only): + continue + failures = run() + fired = {f.split()[0] for f in failures} + ok = fired == gates if not gates else gates <= fired + print(f"--- {name:28s} => {'pass' if not failures else 'fail ' + '+'.join(sorted(fired))} " + f"({'as designed' if ok else 'GATE BROKEN, want ' + '+'.join(sorted(gates))})" + + ("" if not failures else f" first: {failures[0]} [+{len(failures) - 1}]"), flush=True) + if not ok: + wrong.append(name) +sys.exit(report("S5 known-bad", [f"{name}: gate did not discriminate" for name in wrong])) diff --git a/examples/pcg_facade_tower/verify/s5_verify.py b/examples/pcg_facade_tower/verify/s5_verify.py new file mode 100644 index 00000000..49f7a0c3 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s5_verify.py @@ -0,0 +1,359 @@ +"""S5 gate: the tower assembly (band subassemblies + slabs + canopy), measured on the solved, composed placements. + +Band pieces are placed in world here as tower placement x band placement (gp_Trsf product, not the SDK path); +levels and setbacks are recomputed from the parameters, not read back from the schedule. + + T0 contents tower component ids / items == tower_components; all grounded; one definition per unique ref; + leaf part set == schedule type table; flattened piece count == schedule instance count + T1 placement every tower placement is a pure translation (0, 0, level); the SDK projection of the whole tower + has pieces + slabs + canopy solids, the summed volume and the bbox z [0, height - j/2] + V8 levels floor f at lobby_h + (f - 1) floor_h with a band of tier (f - 1) // setback_every; parapet k and + roof slab k top at tier k's roof; measured on sampled realizations: building-line plane of every edge + at k x setback_depth inside tier 0's + V1 closure sampled realizations (lobby, first floor of each floor type, both floors of every setback, the top + floor, every parapet): S4 closure at the tower level + V2 clash windows around every sampled junction (element below | element | slabs at the junction | parapet on a + setback | canopy on the lobby), plus every component whose measured z range overlaps the window: + Common volume == 0 on every bbox-touching pair + V4 glass whole-building WWR per orientation from the tagged definition glass x realizations == design (1e-6) + and within 0.03 of the target + V5 envelope sampled realizations: panels span exactly [0, frame_d] off their edge's building line, corners + <= frame_d, fin tips <= frame_d + fin_depth + 1 + V6 types standard panel types (w_std x floor_h, vision + solid) == 2; fin types <= fin_bins + A2 solve warm rebuild: no dirty instances, no corrupt entries, identical definition hash + + ../../../.venv/bin/python s5_verify.py +""" +from __future__ import annotations + +import sys +import time + +from _common import report, tight_bbox +from s3_verify import TOL +from s4_verify import _points, _volume, check_clash, check_closure, check_solve, placed, trsf + +import floor as F +import layout as L +import panels as P +import slabs as S +import simplecadapi as scad +from simplecadapi import Part +from tower import make_tower, tower_components + +WWR_TOL = 0.03 + + +def item_id(item) -> str: + return item.part_id if isinstance(item, Part) else item.assembly_id + + +def expected_item_id(ref) -> str: + kind, key = ref + return F.band_assembly_id(key) if kind == "band" else S.slab_type_id(key) if kind == "slab" else S.CANOPY_ID + + +def level_of(p: L.FacadeParams, floor: int) -> float: + return p.lobby_h + (floor - 1) * p.floor_h + + +def roof_of(p: L.FacadeParams, tier: int) -> float: + return level_of(p, min(p.n_floors, (tier + 1) * p.setback_every) + 1) + + +def realize(component) -> dict: + """World shapes of one tower component: band pieces keyed by their band id, a part by ''.""" + outer = trsf(component.placement) + if isinstance(component.item, Part): + from OCP.TopLoc import TopLoc_Location + return {"": component.item.body.wrapped.Moved(TopLoc_Location(outer))} + return {c.component_id: placed(c, outer) for c in component.item.components} + + +def band_of(sched: L.FacadeSchedule, component): + return sched.band(component.item.assembly_id.removeprefix("band-")) + + +# ---------------------------------------------------------------- contents, placements, levels (every component) + + +def check_contents(sched: L.FacadeSchedule, asm, definition=None) -> tuple[list[str], dict]: + bad, want = [], {c["component_id"]: c for c in tower_components(sched)} + got = {c.component_id: c for c in asm.components} + if set(got) != set(want): + bad.append(f"T0 missing {sorted(set(want) - set(got))[:4]} extra {sorted(set(got) - set(want))[:4]}") + for cid in sorted(set(got) & set(want)): + if item_id(got[cid].item) != expected_item_id(want[cid]["ref"]): + bad.append(f"T0 {cid}: item {item_id(got[cid].item)} != {expected_item_id(want[cid]['ref'])}") + if set(asm.grounded_component_ids) != set(got): + bad.append(f"T0 ungrounded {sorted(set(got) - set(asm.grounded_component_ids))[:4]}") + if definition is not None: + refs = {getattr(r, "definition_id", r) for r in definition.definition_refs} + if len(refs) != len({c["ref"] for c in want.values()}): + bad.append(f"T0 {len(refs)} definitions for {len({c['ref'] for c in want.values()})} refs") + if set(definition.grounded_instance_ids) != set(got): + bad.append(f"T0 definition grounds {len(definition.grounded_instance_ids)}/{len(got)}") + leaves, pieces = set(), 0 + for c in asm.components: + if not isinstance(c.item, Part): + leaves |= {sub.item.part_id for sub in c.item.components} + pieces += len(c.item.components) + if leaves != set(sched.types): + bad.append(f"T0 leaf parts: missing {sorted(set(sched.types) - leaves)[:3]} extra {sorted(leaves - set(sched.types))[:3]}") + if pieces != sched.stats["n_instances"]: + bad.append(f"T0 {pieces} facade pieces != schedule {sched.stats['n_instances']}") + return bad, {"components": len(got), "pieces": pieces, "leaf_types": len(leaves)} + + +def check_levels(sched: L.FacadeSchedule, asm) -> list[str]: + """T1 pure translations + V8 levels / tiers (placement values, every component).""" + bad, p = [], sched.params + n_tiers = (p.n_floors - 1) // p.setback_every + 1 + for c in asm.components: + pl = c.placement + axes = max(max(abs(a - b) for a, b in zip(pl.x_axis, (1, 0, 0))), + max(abs(a - b) for a, b in zip(pl.y_axis, (0, 1, 0))), + max(abs(a - b) for a, b in zip(pl.z_axis, (0, 0, 1))), abs(pl.origin[0]), abs(pl.origin[1])) + if axes > TOL: + bad.append(f"T1 {c.component_id}: placement is not a pure z translation ({axes:.3g})") + cid, z = c.component_id, pl.origin[2] + if cid.startswith("floor"): + f = int(cid[5:]) + band = band_of(sched, c) + if abs(z - level_of(p, f)) > TOL: + bad.append(f"V8 {cid}: at z {z:.3f} != {level_of(p, f):.3f}") + if band.get("tier") != (f - 1) // p.setback_every or band["band"] != L.Band.TOWER.value: + bad.append(f"V8 {cid}: band {band['band_id']} is not a tier {(f - 1) // p.setback_every} floor band") + elif cid.startswith("parapet"): + k = int(cid[7:]) + if abs(z - roof_of(p, k)) > TOL or band_of(sched, c).get("tier") != k: + bad.append(f"V8 {cid}: {band_of(sched, c)['band_id']} at z {z:.3f} != tier {k} roof {roof_of(p, k):.3f}") + elif cid.startswith("slab-roof"): + k = int(cid[9:]) + top = z + tight_bbox(c.item.body)[5] + if abs(top - roof_of(p, k)) > TOL: + bad.append(f"V8 {cid}: top {top:.3f} != tier {k} roof {roof_of(p, k):.3f}") + roofs = sorted(c.component_id for c in asm.components if c.component_id.startswith("parapet")) + if roofs != [f"parapet{k}" for k in range(n_tiers)]: + bad.append(f"V8 parapets {roofs} != one per tier ({n_tiers})") + return bad + + +def check_projection(sched: L.FacadeSchedule, asm) -> tuple[list[str], dict]: + """T1: the SDK's flattened tower vs definition volumes x counts.""" + from OCP.TopAbs import TopAbs_SOLID + from OCP.TopExp import TopExp_Explorer + + bad, p = [], sched.params + vol_by_item, want_vol, want_solids = {}, 0.0, 0 + for c in asm.components: + leaves = [c.item] if isinstance(c.item, Part) else [sub.item for sub in c.item.components] + for leaf in leaves: + if leaf.part_id not in vol_by_item: + vol_by_item[leaf.part_id] = _volume(leaf.body.wrapped) + want_vol += vol_by_item[leaf.part_id] + want_solids += 1 + compound = scad.make_compound_from_assembly_rcompound(assembly=asm) + solids, it = 0, TopExp_Explorer(compound.wrapped, TopAbs_SOLID) + while it.More(): + solids += 1 + it.Next() + vol = _volume(compound.wrapped) + box = tight_bbox(compound) + height = roof_of(p, (p.n_floors - 1) // p.setback_every) + p.parapet_h + if solids != want_solids: + bad.append(f"T1 SDK projection has {solids} solids != {want_solids}") + if abs(vol - want_vol) > TOL * want_vol: + bad.append(f"T1 SDK projection volume {vol:.6g} != {want_vol:.6g}") + if abs(box[2]) > TOL or abs(box[5] - (height - p.joint / 2.0)) > TOL: + bad.append(f"T1 SDK projection z [{box[2]:.3f}, {box[5]:.3f}] != [0, {height - p.joint / 2.0:.3f}]") + return bad, {"solids": solids, "bbox": box, "height": height} + + +# ---------------------------------------------------------------- sampled realizations (geometry) + + +def sample_ids(sched: L.FacadeSchedule) -> list[str]: + p = sched.params + floors = {1, p.n_floors} + floors |= {info["floors"][0] for info in sched.floor_types.values()} + for k in range((p.n_floors - 1) // p.setback_every): + top = (k + 1) * p.setback_every + floors |= {top, top + 1} + parapets = [c["component_id"] for c in tower_components(sched) if c["component_id"].startswith("parapet")] + return ["lobby"] + [f"floor{f}" for f in sorted(floors)] + parapets + + +def windows(sched: L.FacadeSchedule, asm, sampled: list[str]) -> list[tuple[str, list[str]]]: + """(name, tower component ids) around each sampled floor's lower junction, plus each tier's roof.""" + p, ids = sched.params, {c.component_id for c in asm.components} + out = [] + for cid in sampled: + if not cid.startswith("floor"): + continue + f = int(cid[5:]) + below = "lobby" if f == 1 else f"floor{f - 1}" + group = [below, cid, f"slab-floor{f}"] + if f == 1: + group += ["canopy", "slab-ground"] + if f > 1 and (f - 1) % p.setback_every == 0: + k = (f - 1) // p.setback_every - 1 + group += [f"parapet{k}", f"slab-roof{k}"] + out.append((f"{below}|{cid}", [g for g in group if g in ids])) + for k in range((p.n_floors - 1) // p.setback_every + 1): + top = min(p.n_floors, (k + 1) * p.setback_every) + out.append((f"floor{top}|parapet{k}", [g for g in (f"floor{top}", f"parapet{k}", f"slab-roof{k}") if g in ids])) + return out + + +def check_realization(sched: L.FacadeSchedule, component, shapes: dict) -> list[str]: + """V1 at the tower level + V8 setback depth + V5 envelope for one band realization.""" + p, band, cid = sched.params, band_of(sched, component), component.component_id + k = band["tier"] + z_base = (0.0 if cid == "lobby" else roof_of(p, int(cid[7:])) if cid.startswith("parapet") + else level_of(p, int(cid[5:]))) + bad = check_closure(sched, band, shapes, z_base=z_base, label=cid) + base = L.edge_frames(L.tier_plans(p)[0]) + own = L.edge_frames(L.tier_plans(p)[k]) + env = p.frame_d + p.fin_depth + 1.0 + inset = {} + for pid, shape in shapes.items(): + pts = _points(shape) + if "-v" in pid: + v = int(pid.split("-v")[1]) + edges = [(v - 1) % len(own), v] + else: + edges = [int(pid.split("-e")[1].split("-")[0])] + for e in edges: + fr = own[e] + d = [(x - fr["v0"][0]) * fr["n"][0] + (y - fr["v0"][1]) * fr["n"][1] for x, y, _ in pts] + if pid.endswith("-fin"): + if max(d) > env: + bad.append(f"V5 {cid} {pid}: fin tip {max(d):.3f} > {env:.3f} off the building line") + elif "-v" in pid: + if max(d) > p.frame_d + TOL: + bad.append(f"V5 {cid} {pid}: corner {max(d):.4f} > frame_d off edge {e}") + elif abs(min(d)) > TOL or abs(max(d) - p.frame_d) > TOL: + bad.append(f"V5 {cid} {pid}: spans [{min(d):.4f}, {max(d):.4f}] != [0, frame_d] off its edge") + if not pid.endswith("-fin") and "-v" not in pid: + b0 = base[e] + s = min((x - b0["v0"][0]) * b0["n"][0] + (y - b0["v0"][1]) * b0["n"][1] for x, y, _ in pts) + inset[e] = min(inset.get(e, 0.0), s) + for e, s in sorted(inset.items()): + if abs(-s - k * p.setback_depth) > TOL: + bad.append(f"V8 {cid} e{e}: building line {-s:.4f} inside tier 0's != {k} x setback {p.setback_depth}") + return bad + + +def z_ranges(asm) -> dict: + """Measured world z range of every tower component (local item bbox through its placement).""" + local, out = {}, {} + for c in asm.components: + key = item_id(c.item) + if key not in local: + body = c.item.body if isinstance(c.item, Part) else scad.make_compound_from_assembly_rcompound(assembly=c.item) + local[key] = tight_bbox(body) + b, t = local[key], trsf(c.placement) + zs = [t.Value(3, 1) * x + t.Value(3, 2) * y + t.Value(3, 3) * z + t.Value(3, 4) + for x in (b[0], b[3]) for y in (b[1], b[4]) for z in (b[2], b[5])] + out[c.component_id] = (min(zs), max(zs)) + return out + + +def check_sampled(sched: L.FacadeSchedule, asm) -> tuple[list[str], dict]: + comps = {c.component_id: c for c in asm.components} + sampled = [cid for cid in sample_ids(sched) if cid in comps] + # each window also takes whatever actually stands in its z range (a misplaced component meets its real neighbours) + zr, groups = z_ranges(asm), [] + for name, group in windows(sched, asm, sampled): + lo, hi = min(zr[g][0] for g in group), max(zr[g][1] for g in group) + groups.append((name, sorted(set(group) | {cid for cid, (a, b) in zr.items() if a < hi - 1.0 and b > lo + 1.0}))) + world, bad = {}, [] + for cid in {g for _, group in groups for g in group} | set(sampled): + world[cid] = realize(comps[cid]) + for cid in sampled: + bad += check_realization(sched, comps[cid], world[cid]) + pairs = 0 + for name, group in groups: + shapes = {f"{g}/{pid}" if pid else g: s for g in group for pid, s in world[g].items()} + clash, tested = check_clash({"band_id": name}, shapes) + bad += clash + pairs += tested + return bad, {"sampled": len(sampled), "windows": len(groups), "clash_pairs": pairs} + + +# ---------------------------------------------------------------- whole-building glass + types + + +def check_glass(sched: L.FacadeSchedule, asm) -> tuple[list[str], dict]: + from s4_verify import band_glass + + bad, p = [], sched.params + per_band, glass, gross = {}, {}, {} + for c in asm.components: + if not c.component_id.startswith("floor"): + continue + band = band_of(sched, c) + if band["band_id"] not in per_band: + per_band[band["band_id"]] = band_glass(sched, band, c.item) + g, _, a = per_band[band["band_id"]] + for o in a: + glass[o] = glass.get(o, 0.0) + g.get(o, 0.0) + gross[o] = gross.get(o, 0.0) + a[o] + wwr = {o: glass.get(o, 0.0) / gross[o] for o in sorted(gross)} + design, target = L.design_wwr(sched), dict(p.wwr_target) + for o, v in wwr.items(): + if abs(v - design[o]) > TOL: + bad.append(f"V4 {o}: measured WWR {v:.6f} != design {design[o]:.6f}") + if abs(v - target[o]) > WWR_TOL: + bad.append(f"V4 {o}: WWR {v:.3f} off target {target[o]:.2f} by more than {WWR_TOL}") + return bad, wwr + + +def check_types(sched: L.FacadeSchedule, asm) -> tuple[list[str], dict]: + p, bad = sched.params, [] + leaves = {sub.item.part_id for c in asm.components if not isinstance(c.item, Part) for sub in c.item.components} + std = sorted(t for t in leaves if sched.types[t]["family"] in ("vision", "solid") + and sched.types[t]["w"] == L._r(p.w_std) and sched.types[t]["h"] == L._r(p.floor_h)) + fins = sorted(t for t in leaves if sched.types[t]["family"] == "fin") + if len(std) != 2: + bad.append(f"V6 {len(std)} standard panel types {std} != 2") + if len(fins) > p.fin_bins: + bad.append(f"V6 {len(fins)} fin types > K = {p.fin_bins}") + return bad, {"standard": std, "fins": len(fins)} + + +def check_tower(sched: L.FacadeSchedule, asm, definition=None) -> tuple[list[str], dict]: + bad, info = check_contents(sched, asm, definition) + bad += check_levels(sched, asm) + proj, info["projection"] = check_projection(sched, asm) + sampled, info["sampled"] = check_sampled(sched, asm) + glass, info["wwr"] = check_glass(sched, asm) + types, info["types"] = check_types(sched, asm) + return bad + proj + sampled + glass + types, info + + +def main() -> int: + sched = L.build_schedule(L.default_params()) + t0 = time.perf_counter() + cold = make_tower(sched) + t1 = time.perf_counter() + warm = make_tower(sched) + t2 = time.perf_counter() + bad, info = check_tower(sched, cold.value, cold.definition) + bad += check_solve("tower", cold, warm) + t3 = time.perf_counter() + pr, sm = info["projection"], info["sampled"] + print(f" tower: {info['components']} components, {len(cold.definition.definition_refs)} definitions, " + f"{info['pieces']} facade pieces of {info['leaf_types']} types; SDK projection {pr['solids']} solids, " + f"height {pr['height']:.0f}") + print(f" sampled {sm['sampled']} realizations, {sm['windows']} junction windows, {sm['clash_pairs']} clash pairs") + print(f" WWR " + " ".join(f"{o}={v:.3f}" for o, v in info["wwr"].items()) + + f" (target {dict(sched.params.wwr_target)})") + print(f" standard types {info['types']['standard']}, {info['types']['fins']} fin types") + print(f" build {t1 - t0:.1f}s, rebuild {t2 - t1:.1f}s, checks {t3 - t2:.1f}s") + return report("S5", bad) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/pcg_facade_tower/verify/s6_verify.py b/examples/pcg_facade_tower/verify/s6_verify.py new file mode 100644 index 00000000..9a4e8cac --- /dev/null +++ b/examples/pcg_facade_tower/verify/s6_verify.py @@ -0,0 +1,159 @@ +"""S6 gate: rationalization / scale / plan sweeps — the same rules at any size. + + R1 rationalization K in {2, 3, 4, 6, 8, 12, 24} (fin bins): fin types <= K, total types non-decreasing in K, + max quantization error <= 90 / (2 (K - 1)) (half a bin), design WWR independent of K + R2 scale n_floors in {8, 20, 36, 48}, setback_every = ceil(n / 3) (3 tiers): the standard panel type + set is the same at every size; each tower is built and passes the S5 gate + R3 plan RECT / CHAMFER (36 floors): each tower is built and passes the S5 gate (V1, V4, ...); + L_SHAPE is layout-verified only — see the README "Known limitations" + +Artifacts: out/rationalization.png (types and error vs K), out/scale_sweep.json (per size / plan: types, pieces, +definitions, build seconds, S5 result). + + ../../../.venv/bin/python s6_verify.py [--no-geometry] +""" +from __future__ import annotations + +import json +import math +import sys +import time +from pathlib import Path + +from _common import PART_DIR, report +from s3_verify import TOL + +import layout as L + +OUT = PART_DIR / "out" +KS = (2, 3, 4, 6, 8, 12, 24) +SIZES = (8, 20, 36, 48) + + +def rationalization() -> tuple[list[str], list[dict]]: + bad, rows = [], [] + base_wwr = None + for k in KS: + s = L.build_schedule(L.default_params(fin_bins=k)) + st = s.stats + bound = 90.0 / (2.0 * (k - 1)) + row = {"K": k, "types": st["n_types"], "fin_types": len(st["fin_types"]), "instances": st["n_instances"], + "max_error": st["max_fin_quant_error"], "bound": bound, "wwr": st["design_wwr"]} + rows.append(row) + if row["fin_types"] > k: + bad.append(f"R1 K={k}: {row['fin_types']} fin types > K") + if row["max_error"] > bound + TOL: + bad.append(f"R1 K={k}: max quantization error {row['max_error']:.4f} > half bin {bound:.4f}") + base_wwr = base_wwr or st["design_wwr"] + if any(abs(st["design_wwr"][o] - base_wwr[o]) > TOL for o in base_wwr): + bad.append(f"R1 K={k}: design WWR {st['design_wwr']} depends on K") + for a, b in zip(rows, rows[1:]): + if b["types"] < a["types"]: + bad.append(f"R1 types fall from {a['types']} (K={a['K']}) to {b['types']} (K={b['K']})") + return bad, rows + + +def rationalization_figure(rows: list[dict], path: Path) -> str: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + ks = [r["K"] for r in rows] + fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10.5, 3.8)) + ink, muted = "#222222", "#8a8a8a" + ax0.plot(ks, [r["fin_types"] for r in rows], color=ink, lw=2, marker="o", ms=5) + for r in rows: + ax0.annotate(str(r["fin_types"]), (r["K"], r["fin_types"]), textcoords="offset points", xytext=(0, 7), + ha="center", fontsize=8, color=ink) + ax0.set_title("fin part types vs K (panels, corners, slabs unchanged)", fontsize=10) + ax0.set_ylabel("fin part types") + ax1.plot(ks, [r["max_error"] for r in rows], color=ink, lw=2, marker="o", ms=5) + ax1.plot(ks, [r["bound"] for r in rows], color=muted, lw=1, ls="--") + ax1.annotate("half bin 90/(2(K-1))", (ks[1], rows[1]["bound"]), textcoords="offset points", xytext=(8, 4), + fontsize=8, color=muted) + ax1.set_title("max fin angle quantization error vs K", fontsize=10) + ax1.set_ylabel("degrees") + for ax in (ax0, ax1): + ax.set_xscale("log", base=2) + ax.set_xticks(ks, [str(k) for k in ks]) + ax.set_xlabel("fin bins K") + ax.grid(True, color="#e6e6e6", lw=0.6) + ax.set_axisbelow(True) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + fig.tight_layout() + fig.savefig(path, dpi=150) + plt.close(fig) + return str(path) + + +def scale_schedules() -> tuple[list[str], dict]: + bad, out = [], {} + std = None + for n in SIZES: + s = L.build_schedule(L.default_params(n_floors=n, setback_every=math.ceil(n / 3))) + out[n] = s + if std is None: + std = s.stats["standard_panel_types"] + if s.stats["standard_panel_types"] != std: + bad.append(f"R2 {n} floors: standard types {s.stats['standard_panel_types']} != {std}") + if s.stats["n_tiers"] != 3: + bad.append(f"R2 {n} floors: {s.stats['n_tiers']} tiers != 3") + return bad, out + + +def build_and_check(tag: str, sched: L.FacadeSchedule) -> tuple[list[str], dict]: + from s5_verify import check_tower + from tower import make_tower + + start = time.perf_counter() + r = make_tower(sched) + built = time.perf_counter() - start + bad, info = check_tower(sched, r.value, r.definition) + st = sched.stats + row = {"plan": sched.params.plan_kind.value, "n_floors": sched.params.n_floors, + "setback_every": sched.params.setback_every, "height_m": st["height"] / 1000.0, "types": st["n_types"], + "standard_types": st["standard_panel_types"], "floor_types": st["n_floor_types"], + "pieces": info["pieces"], "tower_components": info["components"], + "definitions": len(r.definition.definition_refs), "build_s": round(built, 1), + "wwr": {o: round(v, 4) for o, v in info["wwr"].items()}, "s5_failures": len(bad)} + print(f" {tag:18s} {row['height_m']:6.1f} m {row['types']:3d} types {row['pieces']:5d} pieces " + f"{row['definitions']:3d} defs build {built:5.1f}s S5 {'ok' if not bad else f'{len(bad)} FAIL'}", flush=True) + return [f"{tag}: {b}" for b in bad], row + + +def main(geometry: bool) -> int: + OUT.mkdir(exist_ok=True) + failures, sweep = [], {"rationalization": [], "scale": [], "plan": []} + bad, rows = rationalization() + failures += bad + sweep["rationalization"] = rows + print(" K " + " ".join(f"{r['K']:6d}" for r in rows)) + print(" types " + " ".join(f"{r['types']:6d}" for r in rows)) + print(" fins " + " ".join(f"{r['fin_types']:6d}" for r in rows)) + print(" err " + " ".join(f"{r['max_error']:6.2f}" for r in rows)) + print(" " + rationalization_figure(rows, OUT / "rationalization.png")) + bad, scheds = scale_schedules() + failures += bad + for n, s in scheds.items(): + print(f" {n:2d} floors: {s.stats['n_types']} types, {s.stats['n_instances']} pieces, " + f"{s.stats['n_floor_types']} floor types, std {s.stats['standard_panel_types']}") + if geometry: + for n, s in scheds.items(): + bad, row = build_and_check(f"chamfer {n}F", s) + failures += bad + sweep["scale"].append(row) + # L_SHAPE is layout-verified only (see the example README "Known limitations"): + # its reflex-corner profile extends past frame_d and fails S5 V5 at tower fit. + bad, row = build_and_check(f"{L.PlanKind.RECT.value} 36F", L.build_schedule(L.default_params(plan_kind=L.PlanKind.RECT))) + failures += bad + sweep["plan"].append(row) + sweep["plan"].append(next(r for r in sweep["scale"] if r["n_floors"] == 36)) + path = OUT / "scale_sweep.json" + path.write_text(json.dumps(sweep, indent=1, default=str)) + print(f" {path}") + return report("S6", failures) + + +if __name__ == "__main__": + sys.exit(main(geometry="--no-geometry" not in sys.argv)) diff --git a/examples/pcg_facade_tower/verify/s7_verify.py b/examples/pcg_facade_tower/verify/s7_verify.py new file mode 100644 index 00000000..45914921 --- /dev/null +++ b/examples/pcg_facade_tower/verify/s7_verify.py @@ -0,0 +1,69 @@ +"""S7 report (V9): what a parameter edit costs — full rebuild timings per edit step. + +The part cache this gate used to verify was removed from the SDK (dev now rebuilds through the +notebook runtime), so there are no hit / dirty reports to assert anymore. What survives is the +question the gate always answered, now measured honestly as full rebuilds: how long each edit +step takes, and how the piece count moves. Every build is a complete, deterministic rebuild of +the same design policy. + +Steps: prime (default) -> warm (default again) -> fin_depth 300 -> 350 -> revert -> +n_floors 36 -> 48, setback_every 16 -> revert. Artifacts: out/incremental.json (per step: +build seconds, pieces, types). + + ../../../.venv/bin/python s7_verify.py +""" +from __future__ import annotations + +import json +import sys +import time + +from _common import PART_DIR, report + +import layout as L +import tower as T + + +class Run: + def __init__(self): + self.rows: list = [] + + def step(self, name: str, params: L.FacadeParams) -> list[str]: + start = time.perf_counter() + sched = L.build_schedule(params) + tower = T.make_tower(sched) + seconds = time.perf_counter() - start + st = sched.stats + row = { + "step": name, + "build_s": round(seconds, 1), + "pieces": st["n_instances"], + "types": st["n_types"], + "fin_types": len(st["fin_types"]), + "height_m": round(st["height"] / 1000.0, 2), + } + self.rows.append(row) + print(f" {name:22s} {seconds:6.1f}s pieces {row['pieces']:5d} types {row['types']:3d} " + f"fins {row['fin_types']:3d} {row['height_m']:6.2f} m", flush=True) + return [] + + +def main() -> int: + base = L.default_params() + run = Run() + bad = [] + bad += run.step("prime (default)", base) + bad += run.step("warm (default)", base) + bad += run.step("fin_depth 350", L.default_params(fin_depth=350.0)) + bad += run.step("revert fin_depth", base) + bad += run.step("n_floors 48 (every 16)", L.default_params(n_floors=48, setback_every=16)) + bad += run.step("revert n_floors", base) + out = PART_DIR / "out" + out.mkdir(exist_ok=True) + (out / "incremental.json").write_text(json.dumps(run.rows, indent=1)) + print(f" {out / 'incremental.json'}") + return report("S7 rebuild timings", bad) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/pcg_facade_tower/verify/s8_verify.py b/examples/pcg_facade_tower/verify/s8_verify.py new file mode 100644 index 00000000..9048ea8d --- /dev/null +++ b/examples/pcg_facade_tower/verify/s8_verify.py @@ -0,0 +1,238 @@ +"""S8 gate (V10): the exported tower reopens, in a fresh process, as the same product. + +Expected values come from the schedule and the per-definition part volumes of this process' build (not from the +package): + P1 package fresh process: read + materialize the .scadpkg; root id; distinct definitions = tower + bands + piece + types + tier slabs + canopy; leaf occurrences per part id = schedule counts (facade pieces) / slab + instances / 1 canopy; volume sum (part volume x multiplicity) within 1e-9 relative; the SDK world + compound of the reopened tower has that many solids and z extent [0, height - joint/2] + P2 STEP report: AP242, definition ids = the package's, occurrence count = Σ components over the assembly + definitions (each definition written once, occurrences are located references); fresh process: read + the STEP back -> leaf solids = pieces + slabs + canopy, volume within 1e-6 relative + P3 CSV one row per piece type, counts = schedule, Σ count = pieces +Known-bad (run after the gate): a truncated package copy must be rejected by the fresh process (KB1); a CSV with one +count off by one must fail P3 (KB2). + + ../../../.venv/bin/python s8_verify.py +""" +from __future__ import annotations + +import csv +import json +import subprocess +import sys +import time +from collections import Counter +from pathlib import Path + +from _common import PART_DIR, report + +import export as E +import layout as L +import slabs as S +import tower as T + +CHILD = r""" +import json, sys, time +import simplecadapi as scad +from OCP.BRepGProp import BRepGProp +from OCP.GProp import GProp_GProps +from OCP.IFSelect import IFSelect_RetDone +from OCP.STEPControl import STEPControl_Reader +from OCP.TopAbs import TopAbs_SOLID +from OCP.TopExp import TopExp_Explorer +from OCP.Bnd import Bnd_Box +from OCP.BRepBndLib import BRepBndLib + +def volume(shape): + g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g); return g.Mass() + +pkg_path, step_path = sys.argv[1], sys.argv[2] +out = {} +start = time.perf_counter() +package = scad.read_product_package(pkg_path) +root = scad.materialize_definition(package.root_definition) +out["read_s"] = time.perf_counter() - start +out["root_id"] = package.root_id +leaves, defs, part_volume = {}, set(), {} + +def walk(item): # every occurrence path to a part is one leaf + if isinstance(item, scad.Assembly): + defs.add(("assembly", item.assembly_id)) + for c in item.components: + walk(c.item) + else: + defs.add(("part", item.part_id)) + leaves[item.part_id] = leaves.get(item.part_id, 0) + 1 + if item.part_id not in part_volume: + part_volume[item.part_id] = item.body.get_volume() + +walk(root) +out["definitions"] = sorted(f"{k}:{i}" for k, i in defs) +out["leaves"] = leaves +out["volume"] = sum(part_volume[p] * n for p, n in leaves.items()) +whole = scad.make_compound_from_assembly_rcompound(assembly=root).wrapped +n = 0 +ex = TopExp_Explorer(whole, TopAbs_SOLID) +while ex.More(): + n += 1; ex.Next() +box = Bnd_Box(); box.SetGap(0.0); BRepBndLib.AddOptimal_s(whole, box, useTriangulation=False) +out["world_solids"] = n +out["world_bbox"] = list(box.Get()) +start = time.perf_counter() +reader = STEPControl_Reader() +if reader.ReadFile(step_path) != IFSelect_RetDone: + raise SystemExit("STEP read failed") +reader.TransferRoots() +shape = reader.OneShape() +n, vol = 0, 0.0 +ex = TopExp_Explorer(shape, TopAbs_SOLID) +while ex.More(): + n += 1; vol += volume(ex.Current()); ex.Next() +out["step_solids"], out["step_volume"], out["step_read_s"] = n, vol, time.perf_counter() - start +print("RESULT " + json.dumps(out)) +""" + + +def fresh_process(package: Path, step: Path) -> tuple[int, dict | None, str]: + proc = subprocess.run([sys.executable, "-c", CHILD, str(package), str(step)], capture_output=True, text=True, + cwd=PART_DIR) + line = next((ln for ln in proc.stdout.splitlines() if ln.startswith("RESULT ")), None) + return proc.returncode, (json.loads(line[7:]) if line else None), proc.stderr[-600:] + + +def expected(sched: L.FacadeSchedule, result) -> dict: + comps = T.tower_components(sched) + leaves = Counter(sched.stats["instances_by_type"]) + for c in comps: + kind, key = c["ref"] + if kind == "slab": + leaves[S.slab_type_id(key)] += 1 + elif kind == "canopy": + leaves[S.CANOPY_ID] += 1 + bands = {c.item.assembly_id: c.item for c in result.value.components if hasattr(c.item, "assembly_id")} + parts = {c.item.part_id: c.item for b in bands.values() for c in b.components} + parts |= {c.item.part_id: c.item for c in result.value.components if hasattr(c.item, "part_id")} + defs = sorted([f"assembly:{T.TOWER_ID}"] + [f"assembly:{b}" for b in bands] + [f"part:{p}" for p in parts]) + return {"leaves": dict(leaves), "definitions": defs, "pieces": sum(leaves.values()), + "volume": sum(parts[p].body.get_volume() * n for p, n in leaves.items()), + "occurrences": len(result.value.components) + sum(len(b.components) for b in bands.values()), + "height": sched.stats["height"] - sched.params.joint / 2.0} + + +def check_package(want: dict, got: dict | None, code: int, err: str) -> list[str]: + if got is None: + return [f"P1 fresh process failed (exit {code}): {err.strip().splitlines()[-1] if err.strip() else '?'}"] + bad = [] + if got["root_id"] != T.TOWER_ID: + bad.append(f"P1 root {got['root_id']} != {T.TOWER_ID}") + if got["definitions"] != want["definitions"]: + missing = sorted(set(want["definitions"]) - set(got["definitions"])) + extra = sorted(set(got["definitions"]) - set(want["definitions"])) + bad.append(f"P1 definitions: missing {missing[:4]} extra {extra[:4]}") + if got["leaves"] != want["leaves"]: + diff = sorted(k for k in set(got["leaves"]) | set(want["leaves"]) if got["leaves"].get(k) != want["leaves"].get(k)) + bad.append(f"P1 leaf occurrences differ for {diff[:4]} (+{len(diff)})") + if abs(got["volume"] - want["volume"]) > 1e-9 * want["volume"]: + bad.append(f"P1 volume {got['volume']:.6e} != {want['volume']:.6e}") + if got["world_solids"] != want["pieces"]: + bad.append(f"P1 world compound {got['world_solids']} solids != {want['pieces']}") + z0, z1 = got["world_bbox"][2], got["world_bbox"][5] + if abs(z0) > 1e-6 or abs(z1 - want["height"]) > 1e-6: + bad.append(f"P1 world z [{z0:.4f}, {z1:.4f}] != [0, {want['height']:.4f}]") + return bad + + +def check_step(want: dict, rep, got: dict | None) -> list[str]: + bad = [] + if "AP242" not in rep.schema.upper(): + bad.append(f"P2 schema {rep.schema}") + ids = sorted(d.split(":", 1)[1] for d in want["definitions"]) + if sorted(rep.definition_ids) != ids: + bad.append(f"P2 {len(rep.definition_ids)} STEP definitions != {len(ids)}") + if rep.occurrence_count != want["occurrences"]: + bad.append(f"P2 {rep.occurrence_count} STEP occurrences != {want['occurrences']} (Σ assembly components)") + if got is not None: + if got["step_solids"] != want["pieces"]: + bad.append(f"P2 STEP read back {got['step_solids']} solids != {want['pieces']}") + if abs(got["step_volume"] - want["volume"]) > 1e-6 * want["volume"]: + bad.append(f"P2 STEP volume {got['step_volume']:.6e} != {want['volume']:.6e}") + return bad + + +def check_csv(sched: L.FacadeSchedule, path: Path) -> list[str]: + with path.open(newline="") as fh: + rows = list(csv.DictReader(fh)) + bad = [] + counts = sched.stats["instances_by_type"] + got = {r["type_id"]: int(r["count"]) for r in rows} + if len(rows) != len(sched.types) or set(got) != set(sched.types): + bad.append(f"P3 CSV {len(rows)} rows / types != {len(sched.types)} schedule types") + wrong = sorted(t for t in got if got[t] != counts.get(t)) + if wrong: + bad.append(f"P3 CSV counts differ for {wrong[:4]}") + if sum(got.values()) != sched.stats["n_instances"]: + bad.append(f"P3 CSV Σ count {sum(got.values())} != {sched.stats['n_instances']} pieces") + for r in rows: + spec = sched.types.get(r["type_id"], {}) + if r["family"] != spec.get("family") or float(r["h_mm"]) != spec.get("h"): + bad.append(f"P3 CSV row {r['type_id']}: family / h do not match the type table") + break + return bad + + +def known_bad(sched: L.FacadeSchedule, paths: dict) -> list[str]: + from _common import OUT as RENDERS # scratch space next to the renders, not in out/ + + wrong = [] + RENDERS.mkdir(exist_ok=True) + cut = RENDERS / "_kb_truncated.scadpkg" + data = Path(paths["scadpkg"]).read_bytes() + cut.write_bytes(data[: len(data) // 2]) + code, got, err = fresh_process(cut, Path(paths["step"])) + fired = got is None and code != 0 + print(f"--- KB1 truncated package => {'rejected' if fired else 'ACCEPTED'} " + f"({'as designed' if fired else 'GATE BROKEN'}) {err.strip().splitlines()[-1][:110] if err.strip() else ''}") + wrong += [] if fired else ["KB1"] + cut.unlink() + rows = E.panel_rows(sched) + rows[3]["count"] += 1 + off = RENDERS / "_kb_panels.csv" + with off.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=E.CSV_FIELDS) + writer.writeheader() + writer.writerows(rows) + bad = check_csv(sched, off) + print(f"--- KB2 CSV count off by one => {'fail ' + bad[0] if bad else 'pass'} " + f"({'as designed' if bad else 'GATE BROKEN'})") + wrong += [] if bad else ["KB2"] + off.unlink() + return [f"{k}: gate did not discriminate" for k in wrong] + + +def main() -> int: + sched = L.build_schedule(L.default_params()) + start = time.perf_counter() + out = E.export(sched) + total = time.perf_counter() - start + paths = out["paths"] + for name, path in paths.items(): + print(f" {name:8s} {path} ({Path(path).stat().st_size / 1e6:.2f} MB)") + print(" export " + ", ".join(f"{k} {v:.1f}s" for k, v in out["timings"].items()) + f" (total {total:.1f}s)") + want = expected(sched, out["result"]) + code, got, err = fresh_process(paths["scadpkg"], paths["step"]) + bad = check_package(want, got, code, err) + bad += check_step(want, out["step_report"], got) + bad += check_csv(sched, paths["csv"]) + if got: + print(f" fresh process: {len(got['definitions'])} definitions, {sum(got['leaves'].values())} leaves, " + f"{got['world_solids']} world solids, read {got['read_s']:.1f}s; STEP {got['step_solids']} solids, " + f"read {got['step_read_s']:.1f}s") + print(f" STEP {out['step_report'].schema}: {out['step_report'].occurrence_count} occurrences " + f"(expanded {want['pieces']}), {len(out['step_report'].definition_ids)} definitions") + status = report("S8", bad) + return status or report("S8 known-bad", known_bad(sched, paths)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 11420f9f..c905f0e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "py-slvs==1.0.6", "ocp-gordon>=0.2,<0.3", "ezdxf>=1.4.4", - "pymupdf>=1.28.2", "marimo>=0.25.0", "plotly>=7.1.0", ] @@ -45,8 +44,16 @@ dev = [ "mypy>=1.0", "black>=23.0", "isort>=5.0", + "pymupdf>=1.28.2", +] +drawing = [ + "pymupdf>=1.28.2", +] +editor = [ + "starlette>=1.7.0", + "uvicorn>=0.54.0", + "websockets>=16.1.1", ] -editor = [] inspect = [ "matplotlib>=3.7.0", ] @@ -78,13 +85,24 @@ core-metadata-version = "2.4" # Ship harness-neutral source; sca skill compiles it on the user's machine. packages = ["src/simplecadapi"] include = ["src/**/*"] +# The built webui (`cd editor-web && npm run build`) is git-ignored; ship it +# with `sca edit` when it has been built. +artifacts = ["src/simplecadapi/editor/webui/**"] [tool.hatch.build.targets.wheel.force-include] "docs/skill" = "simplecadapi/skill/resources/docs/skill" "skillproj.toml" = "simplecadapi/skill/resources/skillproj.toml" +# The notebooks `sca example u_link` copies out. +"examples/u_link_motor_mount/u_link.py" = "simplecadapi/example/resources/u_link_motor_mount/u_link.py" +"examples/u_link_motor_mount/shell.py" = "simplecadapi/example/resources/u_link_motor_mount/shell.py" +"examples/u_link_motor_mount/u_link_motor_mount.py" = "simplecadapi/example/resources/u_link_motor_mount/u_link_motor_mount.py" +"examples/u_link_motor_mount/dimensions.py" = "simplecadapi/example/resources/u_link_motor_mount/dimensions.py" +"examples/u_link_motor_mount/common.py" = "simplecadapi/example/resources/u_link_motor_mount/common.py" [tool.hatch.build.targets.sdist] core-metadata-version = "2.4" +# The release builds its wheel from this sdist, so the built webui has to be in it. +artifacts = ["src/simplecadapi/editor/webui/**"] include = [ "/README.md", "/LICENSE", @@ -103,10 +121,24 @@ exclude = [ "/examples", ] +# The sdist leaves out /examples, but `sca example u_link` builds its wheel +# from these five notebooks (the wheel is built from the sdist on release). +[tool.hatch.build.targets.sdist.force-include] +"examples/u_link_motor_mount/u_link.py" = "examples/u_link_motor_mount/u_link.py" +"examples/u_link_motor_mount/shell.py" = "examples/u_link_motor_mount/shell.py" +"examples/u_link_motor_mount/u_link_motor_mount.py" = "examples/u_link_motor_mount/u_link_motor_mount.py" +"examples/u_link_motor_mount/dimensions.py" = "examples/u_link_motor_mount/dimensions.py" +"examples/u_link_motor_mount/common.py" = "examples/u_link_motor_mount/common.py" + [dependency-groups] dev = [ "twine>=6.1.0", "pytest", + "pymupdf>=1.28.2", + # the editor tests (test/test_editor.py) need the editor extra's packages + "starlette>=1.7.0", + "uvicorn>=0.54.0", + "websockets>=16.1.1", "jupyterlab>=4.5.5", "ipykernel>=6.29.5", "matplotlib>=3.10.8", diff --git a/src/simplecadapi/_internal/mesh.py b/src/simplecadapi/_internal/mesh.py index 04a930f9..23ff899b 100644 --- a/src/simplecadapi/_internal/mesh.py +++ b/src/simplecadapi/_internal/mesh.py @@ -1,8 +1,9 @@ """Internal mesh companion data for SimpleCAD geometry. -This module intentionally stays out of the public API. Users should not need to -extract or manage meshes directly; future structural checks will consume this -cache internally. +This module intentionally stays out of the public API. A solid's mesh is built +on first use by :func:`cached_mesh` and kept on the solid per tolerance; it is +derived data (never pickled, never copied) and nothing builds one while a model +is being made. """ from __future__ import annotations @@ -144,19 +145,6 @@ def build_solid_trimesh( ) -def attach_default_mesh(solid: Any) -> None: - """Attach the default internal mesh cache to a Solid wrapper if possible.""" - - try: - mesh = build_solid_trimesh(solid) - except Exception as exc: - _set_runtime(solid, MESH_ERROR_RUNTIME_KEY, str(exc)) - return - - _set_runtime(solid, MESH_RUNTIME_KEY, mesh) - _set_runtime(solid, MESH_ERROR_RUNTIME_KEY, None) - - def cached_mesh( solid: Any, *, diff --git a/src/simplecadapi/cli.py b/src/simplecadapi/cli.py index e83b5c60..900b6d8a 100644 --- a/src/simplecadapi/cli.py +++ b/src/simplecadapi/cli.py @@ -5,9 +5,13 @@ sca init # machine setup: addon home + shell wiring sca addon add|update|remove|list|use - sca skill targets|install + sca skill targets|where|install sca export [--format ...] sca run [--set NAME=VALUE] [--out ...] + sca ls [DIR] | sca params + sca edit [WORKSPACE] # the web editor (needs the 'editor' extra) + sca example [NAME] [DIR] # copy a bundled example workspace out + sca snapshot [WORKSPACE] # what the editor user has selected / sees Shared contract: group handlers return ``(report, exit_code)`` and are followed by the group's own printer (human-readable for init/addon, JSON @@ -29,6 +33,8 @@ def build_parser() -> argparse.ArgumentParser: from .addon import cli as addon_cli + from .editor import cli as editor_cli + from .example import cli as example_cli from .exporter import cli as exporter_cli from .runtime import cli as runtime_cli from .skill import cli as skill_cli @@ -43,6 +49,8 @@ def build_parser() -> argparse.ArgumentParser: ) subparsers = parser.add_subparsers(dest="group", required=True) addon_cli.configure(subparsers) + editor_cli.configure(subparsers) + example_cli.configure(subparsers) exporter_cli.configure(subparsers) runtime_cli.configure(subparsers) skill_cli.configure(subparsers) diff --git a/src/simplecadapi/core.py b/src/simplecadapi/core.py index 83bbfc49..363f0541 100644 --- a/src/simplecadapi/core.py +++ b/src/simplecadapi/core.py @@ -1441,12 +1441,9 @@ def __init__(self, solid: Any, cache: Optional[_TopologyEntityCache] = None): TopoMixein.__init__(self, level=4, self_shape_ref=self) for face in faces_of(self.wrapped): self.add_child(Face(face, cache=self._topology_cache)) - try: - from ._internal.mesh import attach_default_mesh - - attach_default_mesh(self) - except Exception as mesh_error: - self._set_runtime("mesh.error", str(mesh_error)) + # No mesh is built here: every intermediate solid of a feature chain + # would pay for one nobody reads. ``_internal.mesh.cached_mesh`` + # builds it on first use (a preview, a package) and keeps it. except Exception as e: raise ValueError(f"初始化实体失败: {e}. 请检查输入的实体对象是否有效。") diff --git a/src/simplecadapi/editor/__init__.py b/src/simplecadapi/editor/__init__.py new file mode 100644 index 00000000..86d45958 --- /dev/null +++ b/src/simplecadapi/editor/__init__.py @@ -0,0 +1,7 @@ +"""The SimpleCAD editor: ``sca edit ``. + +A code-first CAD editor over a workspace of SimpleCAD notebooks. It runs +the notebooks in a marimo kernel with the SDK's own cell executor, so a +model gives the same product in the editor as under ``sca run``. Needs the +``editor`` extra (``pip install "simplecadapi[editor]"``). +""" diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py new file mode 100644 index 00000000..b9baa450 --- /dev/null +++ b/src/simplecadapi/editor/cli.py @@ -0,0 +1,89 @@ +"""The ``sca edit`` command (see :mod:`simplecadapi.cli`). + +:: + + sca edit [WORKSPACE] # open the editor on a directory of notebooks + sca edit path/to/model.py # ... starting at one notebook + sca edit --port 7180 --no-browser + sca snapshot [WORKSPACE] # what the person has selected / is looking at +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +DEFAULT_PORT = 7180 + + +def configure(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser( + "edit", + help="open the web editor on a workspace of SimpleCAD notebooks", + description="Serve the code-first CAD editor for WORKSPACE (default: the " + "current directory): a directory of notebooks with [tool.simplecadapi] " + "headers, or one such notebook. Needs the 'editor' extra.", + ) + parser.add_argument("workspace", type=Path, nargs="?", default=Path.cwd()) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--no-browser", action="store_true", help="do not open a browser tab") + parser.set_defaults(handler=_execute, printer=lambda report: None) + + snapshot = subparsers.add_parser( + "snapshot", + help="print what the editor user has selected and is looking at", + description="Print the editor's snapshot for WORKSPACE (default: the current " + "directory) as JSON: the selection (the ql code is in the notebook's selection " + "cell, saved in the file), the cells' state and errors, the parameter values, and " + "the path of the viewport image (a PNG an agent can look at). The editor keeps " + "it in WORKSPACE/.sca-editor/ while it runs.", + ) + snapshot.add_argument("workspace", type=Path, nargs="?", default=Path.cwd()) + snapshot.set_defaults(handler=_snapshot, printer=_print_snapshot) + + +def _execute(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + try: + from .server import serve_workspace + except ModuleNotFoundError as error: + raise ValueError( + f"the editor needs the 'editor' extra ({error.name} is missing): " + 'pip install "simplecadapi[editor]"' + ) from error + target = args.workspace.expanduser().resolve() + notebook = None + if target.is_file(): + notebook, target = target, target.parent + elif not target.is_dir(): + raise ValueError(f"{args.workspace}: no such file or directory") + code = serve_workspace( + target, + host=args.host, + port=args.port, + open_browser=not args.no_browser, + notebook=notebook, + ) + return {}, code + + +def _snapshot(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + import json + + root = args.workspace.expanduser().resolve() + if root.is_file(): + root = root.parent + path = root / ".sca-editor" / "snapshot.json" + if not path.is_file(): + raise ValueError(f"{root}: no editor snapshot; is `sca edit` running on this workspace?") + return json.loads(path.read_text(encoding="utf-8")), 0 + + +def _print_snapshot(report: dict[str, Any]) -> None: + import json + + print(json.dumps(report, indent=2, ensure_ascii=False)) + + +__all__ = ["configure"] diff --git a/src/simplecadapi/editor/kernel.py b/src/simplecadapi/editor/kernel.py new file mode 100644 index 00000000..1c60f4c0 --- /dev/null +++ b/src/simplecadapi/editor/kernel.py @@ -0,0 +1,671 @@ +"""Host the workspace's marimo kernel as a subprocess and drive it. + +The bridge owns exactly one headless ``marimo edit`` server per workspace. +It creates the kernel session by holding one WebSocket connection (the same +thing a browser tab does) and drives everything else through marimo's HTTP +API: + +- ``POST /api/kernel/execute`` runs driver snippets in the kernel's scratch + cell (deliberately auth-exempt for agents); +- ``marimo._code_mode`` edits, runs and deletes notebook cells; +- the kernel runs the notebook with the SimpleCAD cell executor + (:mod:`simplecadapi.runtime.executor`), exactly as ``sca run`` does; the + bridge asks it for the product with + :func:`simplecadapi.runtime.live.publish_live` once the cells settle. + +The held WebSocket also carries marimo's own messages; ``completed-run`` ends +every batch of cell runs, whoever started it (this bridge, or a person +typing in the embedded marimo editor), and is what the server reacts to. +""" + +from __future__ import annotations + +import json +import os +import re +import socket +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Callable + +import websockets + +SESSION_ID = "sca-runtime" +_TRACE = bool(os.environ.get("SCA_EDITOR_TRACE")) + + +class KernelError(RuntimeError): + pass + + +class MarimoKernel: + def __init__( + self, + notebook: Path, + *, + server_port: int, + on_run_complete: Callable[[], None] | None = None, + on_cell_status: Callable[[str, str], None] | None = None, + ) -> None: + self.notebook = notebook.expanduser().resolve() + self.on_run_complete = on_run_complete + self.on_cell_status = on_cell_status + self._interrupted = threading.Event() + self.setup_errors: list[str] = [] + self._ran = threading.Event() # a run of cells completed (see wait_instantiated) + self._own_calls = 0 + self._own_ended = 0.0 + self.workspace = self.notebook.parent + self.marimo_port = _free_port(server_port + 1) + self.marimo_url = f"http://127.0.0.1:{self.marimo_port}" + self._process: subprocess.Popen[bytes] | None = None + self._token: str | None = None + self._ws_thread: threading.Thread | None = None + self._stop = threading.Event() + self._lock = threading.Lock() + + # -- lifecycle --------------------------------------------------------------- + + def _ensure_lazy_config(self) -> None: + """Provision marimo's lazy reactive mode for the workspace. + + The editor's execution semantics (cell rollback, differential + re-runs, stale suffixes) are marimo's lazy mode: + runtime.on_cell_change="lazy". marimo discovers config by walking up + from its cwd (the workspace), so when NO .marimo.toml exists on that + path this writes one at the workspace root — workspaces may live + anywhere on disk. An existing config anywhere up the tree always + wins (the user's choice). + """ + probe = self.workspace + while True: + if (probe / ".marimo.toml").is_file(): + return + parent = probe.parent + if parent == probe: + break + probe = parent + try: + (self.workspace / ".marimo.toml").write_text( + "# Provisioned by sca edit: rollback / differential rerun\n" + "# semantics need marimo's lazy reactive mode.\n" + '[runtime]\non_cell_change = "lazy"\n', + encoding="utf-8", + ) + except OSError as exc: + print(f"[sca edit] could not write lazy-mode config: {exc}", file=sys.stderr) + + def start(self, timeout: float = 60.0) -> None: + """Spawn marimo, wait for readiness, create the kernel session and + instantiate the notebook (runs all cells top-to-bottom).""" + self._ensure_lazy_config() + env = dict(os.environ) + log_path = self.workspace / ".sca-editor" / "marimo-kernel.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + self._log_file = log_path.open("ab") + self._process = subprocess.Popen( + [ + sys.executable, "-m", "marimo", "edit", str(self.notebook), + "--headless", "--no-token", "--no-sandbox", + "--port", str(self.marimo_port), + ], + cwd=str(self.workspace), + env=env, + # A notebook with inline dependencies makes marimo ask whether to + # sandbox it; nobody is there to answer. + stdin=subprocess.DEVNULL, + stdout=self._log_file, + stderr=subprocess.STDOUT, + ) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._process.poll() is not None: + raise KernelError(f"marimo exited with code {self._process.returncode}") + try: + with urllib.request.urlopen(f"{self.marimo_url}/api/sessions", timeout=2) as resp: + resp.read() + break + except (urllib.error.URLError, OSError): + time.sleep(0.3) + else: + self.stop() + raise KernelError(f"marimo not ready after {timeout:.0f}s") + + self._token = self._fetch_token() + self._hold_session() + self.instantiate() + + @property + def alive(self) -> bool: + return self._process is not None and self._process.poll() is None + + def stop(self) -> None: + self._stop.set() + if self._process is not None and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + if getattr(self, "_log_file", None) is not None: + try: + self._log_file.close() + except OSError: + pass + + # -- session plumbing ---------------------------------------------------------- + + def _fetch_token(self) -> str: + """The skew-protection token ships in the marimo homepage HTML.""" + with urllib.request.urlopen(f"{self.marimo_url}/", timeout=5) as resp: + html = resp.read().decode("utf-8", "replace") + match = re.search(r'"serverToken":\s*"([^"]+)"', html) + if match is None: + raise KernelError("could not read marimo server token from homepage") + return match.group(1) + + def _hold_session(self) -> None: + """Hold one WS connection to marimo — this creates and keeps the + kernel session alive (a headless browser tab, effectively). + + marimo restarts its websocket service whenever the notebook file + changes on disk (code.edit persists) — the connection dies with + 1012/service-restart. The holder must reconnect, or the session + silently dies and every later edit/run becomes a no-op. + """ + + def run() -> None: + import asyncio + + async def main() -> None: + uri = ( + f"ws://127.0.0.1:{self.marimo_port}/ws" + f"?session_id={SESSION_ID}&file_key={self.notebook}" + ) + while not self._stop.is_set(): + try: + async with websockets.connect(uri, max_size=None) as ws: + async for message in ws: + self._on_message(message) + except Exception: + if self._stop.is_set(): + return + # marimo restarted; re-hold the same session id. + await asyncio.sleep(1.0) + + asyncio.run(main()) + + self._ws_thread = threading.Thread(target=run, name="marimo-bridge-ws", daemon=True) + self._ws_thread.start() + time.sleep(1.0) + + def session_exists(self) -> bool: + """Whether marimo still has this bridge's session. + + Unlike a scratch snippet this never touches the kernel, so it can be + asked while a cell runs. + """ + try: + with urllib.request.urlopen(f"{self.marimo_url}/api/sessions", timeout=5) as response: + return SESSION_ID in json.loads(response.read()) + except (urllib.error.URLError, OSError, ValueError): + return False + + def _on_message(self, message: str | bytes) -> None: + try: + data = json.loads(message) + except (TypeError, ValueError): + return + if isinstance(data, dict) and data.get("op") == "cell-op": + # A cell started, finished, was queued...: the live state of its run button. + payload = data.get("data") or {} + cell_id, status = payload.get("cell_id"), payload.get("status") + # (the setup cell and the bridge's scratch snippets are not cells of the notebook) + if cell_id and cell_id != "setup" and not str(cell_id).startswith("__") and isinstance(status, str) and self.on_cell_status is not None: + self.on_cell_status(str(cell_id), status) + return + if not isinstance(data, dict) or data.get("op") != "completed-run": + return + self._ran.set() + # Runs this bridge asked for (scratch snippets, publishing) end in a + # completed-run too; only the cell runs of someone else count. + if self._own_calls or time.monotonic() - self._own_ended < 0.5: + return + if self.on_run_complete is not None: + self.on_run_complete() + + def wait_instantiated(self, timeout: float = 120.0) -> bool: + """Wait until marimo reports the run that :meth:`instantiate` asked for as completed. + + The kernel takes a moment to begin that run, and a probe sent in that + moment (see :meth:`wait_idle`) finds it idle: the cells would be read, + and the product published, before they ran. + """ + return self._ran.wait(timeout) + + def instantiate(self) -> None: + """Run all notebook cells (what the marimo frontend does on load).""" + self._ran.clear() + self._post("/api/kernel/instantiate", {"objectIds": [], "values": []}) + + def list_cells(self) -> list[dict]: + """Notebook cells as [{id, name, code, status, output, console}]. + + Mirrors the marimo reading experience: each cell carries its last + execution status, main output text, and buffered console output so + the studio can render code → output interleaved. Infrastructure + cells (the setup cell) are hidden; every other cell, anonymous ones + included, is one block of the notebook. Cells are addressed by id. + """ + hidden = {"setup"} + result = self.execute( + "import json as _json\n" + "import marimo._code_mode as _cm\n" + "def _text(_o):\n" + " if _o is None:\n" + " return None\n" + " # CellOutput.data is str | list[Error] | dict[str, Any] — render\n" + " # each shape as plain text instead of its repr.\n" + " _d = getattr(_o, 'data', None)\n" + " if isinstance(_d, str):\n" + " _v = _d\n" + " elif isinstance(_d, dict):\n" + " _v = _d.get('text/plain') or str(_d)\n" + " elif isinstance(_d, (list, tuple)):\n" + " _parts = []\n" + " for _item in _d:\n" + " _m = getattr(_item, 'message', None)\n" + " _parts.append(_m if isinstance(_m, str) else str(_item))\n" + " _v = '\\n'.join(_parts)\n" + " else:\n" + " _v = str(_d) if _d is not None else None\n" + " if isinstance(_v, str):\n" + " if _v.lstrip().startswith('['):\n" + " # marimo serialises mimebundle outputs as a JSON array of\n" + " # ':' strings — prefer the text/plain entry.\n" + " try:\n" + " import json as _json\n" + " _arr = _json.loads(_v)\n" + " except Exception:\n" + " _arr = None\n" + " if isinstance(_arr, list):\n" + " _plain = [str(_x)[len('text/plain:'):] for _x in _arr if isinstance(_x, str) and _x.startswith('text/plain:')]\n" + " _v = _plain[0] if _plain else '\\n'.join(str(_x) for _x in _arr)\n" + " if _v.startswith('text/plain:'):\n" + " _v = _v[len('text/plain:'):]\n" + " if _v.lstrip().startswith('<'):\n" + " import html as _html, re as _re\n" + " _v = _html.unescape(_re.sub(r'<[^>]+>', '', _v)).strip()\n" + " return _v\n" + " return str(_v) if _v is not None else None\n" + "def _failures(_c):\n" + " # An error is the cell's output: a list of marimo error objects.\n" + " _d = getattr(getattr(_c, 'output', None), 'data', None)\n" + " _own, _blocked = [], False\n" + " if isinstance(_d, (list, tuple)):\n" + " for _e in _d:\n" + " _kind = str(getattr(_e, 'exception_type', '') or '')\n" + " _msg = str(getattr(_e, 'msg', _e))\n" + " if _kind == 'Ancestor raised':\n" + " _blocked = True\n" + " else:\n" + " _own.append((_kind + ': ' if _kind else '') + _msg)\n" + " return _own, _blocked\n" + "_ctx = _cm.get_context()\n" + "_rows = []\n" + "for _c in _ctx.cells:\n" + " _st = getattr(_c, 'status', None)\n" + " _own, _blocked = _failures(_c)\n" + " _failed = bool(_own) or _blocked\n" + " _rows.append({\n" + " 'id': _c.id, 'name': _c.name, 'code': _c.code,\n" + " 'status': getattr(_st, 'value', None) if _st is not None else None,\n" + " 'errors': ([str(getattr(_e, 'msg', _e))[:300] for _e in (getattr(_c, 'errors', None) or [])][:4] + [_m[:400] for _m in _own])[:4],\n" + " 'blocked': _blocked,\n" + " 'output': None if _failed else ((_text(_c.output) or '')[:400] if getattr(_c, 'output', None) is not None else None),\n" + " 'console': [t for t in ((_text(_o) or '')[-4000:] for _o in (getattr(_c, 'console_outputs', None) or [])[-4:]) if t],\n" + " })\n" + "print(_json.dumps(_rows))\n" + ) + for line in (result.get("log") or "").splitlines(): + line = line.strip() + if line.startswith("["): + try: + cells = json.loads(line) + if isinstance(cells, list): + # What the setup cell raised (a module it imports does not compile) + # blocks every cell: that is the error to show. + self.setup_errors = [ + str(message) + for c in cells + if isinstance(c, dict) and c.get("name") == "setup" + for message in c.get("errors") or [] + ] + return [c for c in cells if isinstance(c, dict) and str(c.get("name", "")) not in hidden] + except json.JSONDecodeError: + continue + return [] + + # -- cell commands ---------------------------------------------------------- + + def run_cells(self, cell_ids: list[str]) -> dict: + """Queue cells to run (lazy mode: their dependents turn stale).""" + return self.execute(_driver("\n".join(f" ctx.run_cell({cell_id!r})" for cell_id in cell_ids))) + + def edit_cell(self, cell_id: str, code: str) -> dict: + """Replace a cell's code (persisted to the file) and run it.""" + return self.execute( + "import json as _json\n" + f"__code = _json.loads({json.dumps(json.dumps(code))})\n" + + _driver(f" ctx.edit_cell({cell_id!r}, code=__code)\n ctx.run_cell({cell_id!r})") + ) + + def save_cell(self, cell_id: str, code: str) -> dict: + """Replace a cell's code (persisted to the file) without running it.""" + return self.execute( + "import json as _json\n" + f"__code = _json.loads({json.dumps(json.dumps(code))})\n" + + _driver(f" ctx.edit_cell({cell_id!r}, code=__code)") + ) + + def put_cell(self, code: str, *, cell_id: str | None, run: bool = True) -> str | None: + """Write *code* into cell *cell_id*, or append a new cell holding it. + + Returns the id of the cell, or ``None`` if the kernel did not answer. + The cell is run unless *run* is false. + """ + run_line = " ctx.run_cell(__id)\n" if run else "" + if cell_id is not None: + body = f" __id = {cell_id!r}\n ctx.edit_cell(__id, code=__code)\n" + run_line + else: + body = ( + " __last = [c.id for c in ctx.cells]\n" + " if __last:\n" + " __id = ctx.create_cell(__code, after=__last[-1])\n" + " else:\n" + " __id = ctx.create_cell(__code)\n" + run_line + ) + result = self.execute( + "import json as _json\n" + f"__code = _json.loads({json.dumps(json.dumps(code))})\n" + + _driver(body) + + "print('SCA-CELL', __id)\n" + ) + for line in (result.get("log") or "").splitlines(): + if line.startswith("SCA-CELL "): + return line[len("SCA-CELL "):].strip() + return None + + def delete_cell(self, cell_id: str) -> dict: + return self.execute(_driver(f" ctx.delete_cell({cell_id!r})")) + + def insert_cell(self, after: str | None) -> dict: + """Insert one empty, named cell after *after* (at the top if None). + + The cell carries a comment placeholder — marimo's create_cell drops + trailing cells when saving an EMPTY-code insertion — and is not + executed. + """ + if after: + create = f" ctx.create_cell('# new cell', after={after!r}, name=__name)\n" + else: + create = ( + " if __names:\n" + " ctx.create_cell('# new cell', before=__names[0], name=__name)\n" + " else:\n" + " ctx.create_cell('# new cell', name=__name)\n" + ) + return self.execute( + _driver( + " __names = [c.name for c in ctx.cells]\n" + " __name = 'new_cell'\n" + " __i = 1\n" + " while __name in __names:\n" + " __i += 1\n" + " __name = f'new_cell_{__i}'\n" + create + ) + ) + + def reload_modules(self, names: list[str]) -> dict: + """Forget the local modules *names* and run every cell again. + + The notebook's setup cell imported them once; a changed file is seen + only by a fresh import, so the modules (and their bytecode, which + Python checks by size and whole-second mtime only) are dropped and + every cell, the setup cell included, runs against the new files. + """ + self.execute( + "import importlib, os, sys\n" + f"__names = {names!r}\n" + "for __n in [m for m in sys.modules if m in __names or any(m.startswith(x + '.') for x in __names)]:\n" + " __cached = getattr(sys.modules.pop(__n), '__cached__', None)\n" + " if isinstance(__cached, str):\n" + " try:\n" + " os.unlink(__cached)\n" + " except OSError:\n" + " pass\n" + "importlib.invalidate_caches()\n" + ) + return self.execute( + _driver(" for __c in ctx.cells:\n ctx.run_cell(__c.id)\n ctx.run_cell('setup')"), + ) + + def stale_cells(self) -> list[str]: + """Ids of the cells that need a run (inputs changed, code edited).""" + result = self.execute( + "import json as _json\n" + "import marimo._code_mode as _cm\n" + "_ids = []\n" + "for _c in _cm.get_context().cells:\n" + " _st = getattr(_c, 'status', None)\n" + " if _c.name != 'setup' and getattr(_st, 'value', _st) == 'stale':\n" + " _ids.append(_c.id)\n" + "print('SCA-STALE', _json.dumps(_ids))\n" + ) + for line in (result.get("log") or "").splitlines(): + if line.startswith("SCA-STALE "): + try: + return [str(item) for item in json.loads(line[len("SCA-STALE "):])] + except json.JSONDecodeError: + break + return [] + + def wait_idle(self, timeout: float = 120.0) -> bool: + """Wait until no cell is queued or running; False on timeout. + + ``run_cell`` only awaits submission; the cells' state is final once + the kernel goes idle. Poll with short scratch executes (never sleep + inside the kernel itself). + """ + probe = ( + "import marimo._code_mode as _cm\n" + "_busy = [c.name for c in _cm.get_context().cells" + " if str(getattr(getattr(c, 'status', ''), 'value', getattr(c, 'status', ''))) in ('queued', 'running')]\n" + "print('SCA-BUSY' if _busy else 'SCA-IDLE')\n" + ) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if "SCA-IDLE" in (self.execute(probe).get("log") or ""): + return True + time.sleep(0.2) + return False + + def interrupt(self) -> None: + """Stop what the kernel is running (the same as marimo's interrupt button). + + Work this bridge has queued behind the running cell (the cells a parameter + change made stale) is dropped too. + """ + self._interrupted.set() + self._post("/api/kernel/interrupt", {}) + + def run_until_settled(self, cell_ids: list[str], *, rounds: int = 8) -> None: + """Run *cell_ids*, then every cell that turned stale, until none is. + + Lazy mode marks a run cell's dependents stale instead of running + them; a parameter change wants the whole chain, so follow up. + """ + self._interrupted.clear() + if cell_ids: + self.run_cells(cell_ids) + self.wait_idle() + for _ in range(rounds): + if self._interrupted.is_set(): + return + stale = self.stale_cells() + if not stale: + return + self.run_cells(stale) + self.wait_idle() + + def publish_preview(self, url: str, step: str = "") -> dict: + """Have the kernel preview its product and POST the bytes to *url*. + + Returns ``{"ok": True, ...}`` or ``{"ok": False, "error"}``; a notebook that + has not produced its product yet is an error value, not an exception. + """ + result = self.execute( + "from simplecadapi.runtime.live import publish_preview as _preview\n" + "try:\n" + f" _p = _preview({url!r}, step={step!r})\n" + " print('SCA-PREVIEWED', _p['nodes'], _p['parts'], _p['bytes'])\n" + "except Exception as _e:\n" + " print('SCA-PREVIEW-ERROR', type(_e).__name__ + ':', str(_e).splitlines()[0] if str(_e) else '')\n", + ) + for line in (result.get("log") or "").splitlines(): + if line.startswith("SCA-PREVIEWED "): + _, nodes, parts, size = line.split(" ") + return {"ok": True, "nodes": int(nodes), "parts": int(parts), "bytes": int(size)} + if line.startswith("SCA-PREVIEW-ERROR "): + return {"ok": False, "error": line[len("SCA-PREVIEW-ERROR "):]} + return {"ok": False, "error": (result.get("log") or "preview failed").strip()[-300:]} + + def entity_info(self, entity_id: str) -> dict | None: + """The properties of one entity of the scene last previewed (``None`` if unknown).""" + result = self.execute( + "import json as _json\n" + "from simplecadapi.runtime.live import preview_entity_info as _info\n" + f"_i = _info({entity_id!r})\n" + "print('SCA-ENTITY', _json.dumps(_i))\n" + ) + for line in (result.get("log") or "").splitlines(): + if line.startswith("SCA-ENTITY "): + try: + return json.loads(line[len("SCA-ENTITY "):]) + except json.JSONDecodeError: + return None + return None + + def publish(self, url: str, step: str = "") -> dict: + """Have the kernel project its product and POST the package to *url*. + + Returns ``{"ok": True, "id", "kind"}`` or ``{"ok": False, "error"}``; + a notebook that has not produced its product yet is an error value, + not an exception. + """ + result = self.execute( + "from simplecadapi.runtime.live import publish_live as _publish\n" + "try:\n" + f" _p = _publish({url!r}, step={step!r})\n" + " print('SCA-PUBLISHED', _p.definition_id, _p.definition_kind)\n" + "except Exception as _e:\n" + " print('SCA-PUBLISH-ERROR', type(_e).__name__ + ':', str(_e).splitlines()[0] if str(_e) else '')\n", + ) + for line in (result.get("log") or "").splitlines(): + if line.startswith("SCA-PUBLISHED "): + _, definition_id, kind = line.split(" ", 2) + return {"ok": True, "id": definition_id, "kind": kind} + if line.startswith("SCA-PUBLISH-ERROR "): + return {"ok": False, "error": line[len("SCA-PUBLISH-ERROR "):]} + return {"ok": False, "error": (result.get("log") or "publishing failed").strip()[-300:]} + + # -- driving -------------------------------------------------------------------- + + def execute(self, code: str, timeout: float | None = None) -> dict: + """Run a scratch snippet in the kernel; returns {success, output, log}. + + The snippet queues behind whatever the kernel is running, and the call + waits for it. There is deliberately no client-side timeout by default: + marimo interrupts the KERNEL when the client of ``/api/kernel/execute`` + disconnects, whatever the kernel is running at that moment, so a call + that gave up after 10 s killed any cell that ran longer (a ``use`` of a + child notebook, a big rebuild). + """ + self._own_calls += 1 + started = time.monotonic() + try: + return self._execute(code, timeout) + finally: + self._own_ended = time.monotonic() + self._own_calls -= 1 + if _TRACE: + first = next((line for line in code.splitlines() if line.strip()), "")[:70] + print(f"[sca edit] {time.strftime('%H:%M:%S')}.{int(time.time() * 1000) % 1000:03d} kernel {self._own_ended - started:6.2f}s {first}", file=sys.stderr, flush=True) + + def _execute(self, code: str, timeout: float | None) -> dict: + body = json.dumps({"code": code}).encode("utf-8") + request = urllib.request.Request( + f"{self.marimo_url}/api/kernel/execute", + data=body, + headers={"Content-Type": "application/json", "Marimo-Session-Id": SESSION_ID}, + method="POST", + ) + result: dict = {"success": None, "output": "", "log": ""} + with urllib.request.urlopen(request, timeout=timeout) as response: + event = "" + for raw in response: + line = raw.decode("utf-8", "replace").rstrip("\r\n") + if line.startswith("event:"): + event = line[len("event:"):].strip() + continue + if not line.startswith("data:"): + continue + try: + payload = json.loads(line[len("data:"):].strip()) + except json.JSONDecodeError: + continue + if event == "stdout" and isinstance(payload.get("data"), str): + result["log"] += payload["data"] + elif event == "stderr" and isinstance(payload.get("data"), str): + result["log"] += payload["data"] + elif event == "done": + result["success"] = bool(payload.get("success", False)) + output = (payload.get("output") or {}).get("data") + if isinstance(output, str): + result["output"] = output + return result + + # -- helpers ---------------------------------------------------------------------- + + def _post(self, path: str, payload: dict, timeout: float = 30.0) -> dict: + body = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json", "Marimo-Session-Id": SESSION_ID} + if self._token is not None: + headers["Marimo-Server-Token"] = self._token + request = urllib.request.Request( + f"{self.marimo_url}{path}", data=body, headers=headers, method="POST" + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + data = response.read() + return json.loads(data) if data else {} + + +def _driver(inner: str) -> str: + """A scratch snippet running *inner* (indented lines) in a code-mode batch.""" + return "import marimo._code_mode as cm\nasync with cm.get_context() as ctx:\n" + inner + "\n" + + +def _free_port(start: int) -> int: + for port in range(start, start + 32): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + continue + return port + raise KernelError(f"no free port at or above {start}") diff --git a/src/simplecadapi/editor/selection.py b/src/simplecadapi/editor/selection.py new file mode 100644 index 00000000..08898884 --- /dev/null +++ b/src/simplecadapi/editor/selection.py @@ -0,0 +1,142 @@ +"""The viewport selection as notebook code. + +Picking in the viewport writes a cell: every picked entity becomes a ``ql`` +selector built from its geometry (type, centre, size) and is assigned to +``selected_`` (``selected__`` when several of a kind are +picked), and ``selected_item`` lists them all. The cell is autosaved with +the notebook, so a coding agent reads what the person selected from the file +on disk and builds on it:: + + # ---- selection ---- + selected_edge_1 = scad.ql.edges().where(scad.ql.and_(...)).exactly(1) + selected_edge_2 = scad.ql.edges().where(scad.ql.and_(...)).exactly(1) + selected_item = [selected_edge_1, selected_edge_2] + # next: scad.chamfer_rsolid(solid=body, edges=selected_item[0], ...) + +The selectors say where the geometry is, not which id it had, so they keep +selecting it while the model is edited, as long as it stays put. +""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Mapping, Sequence +from typing import Any + +MARKER = "# ---- selection ----" +"""First line of the selection cell; how the editor finds it again.""" + +_QL_TYPES = { + "plane": "PLANE", + "cylinder": "CYLINDER", + "cone": "CONE", + "sphere": "SPHERE", + "torus": "TORUS", + "line": "LINE", + "circle": "CIRCLE", + "bspline_surface": "BSPLINE", + "bspline_curve": "BSPLINE", + "bezier_surface": "BEZIER", + "bezier_curve": "BEZIER", +} +_FACTORY = {"face": "faces", "edge": "edges", "solid": "solids", "vertex": "vertices"} +_POSITION_TOLERANCE = 0.1 +_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``. + + *properties* and *geometry* are the scene sidecar's entity fields. + """ + factory = _FACTORY.get(kind) + if factory is None: + return None + conditions: list[str] = [] + ql_type = _QL_TYPES.get(str(geometry.get("type", ""))) + if ql_type is not None: + conditions.append(f'scad.ql.prop("geom.type", "==", "{ql_type}")') + # A solid has no ql centre; it is told apart by its volume. + centre = None if kind == "solid" else _vector( + properties.get("position") if kind == "vertex" else properties.get("centroid") + ) + if centre is not None: + for axis, value in zip("xyz", centre): + conditions.append(f'scad.ql.prop("geom.center.{axis}", ">=", {_number(value - _POSITION_TOLERANCE)})') + conditions.append(f'scad.ql.prop("geom.center.{axis}", "<=", {_number(value + _POSITION_TOLERANCE)})') + for name, key in (("geom.area", "area"), ("geom.length", "length"), ("geom.volume", "volume")): + size = properties.get(key) + if isinstance(size, (int, float)) and not isinstance(size, bool) and size > 0: + 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 None + body = ",\n ".join(conditions) + return f"scad.ql.{factory}().where(scad.ql.and_(\n {body},\n)).exactly(1)" + + +def selection_cell_code(items: Sequence[Mapping[str, Any]]) -> str: + """The source of the selection cell for the picked *items*. + + Each item has ``kind`` (``solid|face|edge|vertex|component``) and, for + the first four, the entity's ``properties`` and ``geometry``; every item + may carry ``definition_id`` and ``node_id`` for the comment above it. + """ + lines = [MARKER] + names: list[str] = [] + totals = Counter(str(item.get("kind", "")) for item in items) + seen: Counter[str] = Counter() + for item in items: + kind = str(item.get("kind", "")) + seen[kind] += 1 + name = f"selected_{kind}" if totals[kind] == 1 else f"selected_{kind}_{seen[kind]}" + where = _where(item) + if kind == "component": + expression = repr(str(item.get("node_id", ""))) + else: + expression = selector_expression(kind, item.get("properties") or {}, item.get("geometry") or {}) + if expression is None: + lines.append(f"# {name}: nothing to select by (no geometry on this entity){where}") + continue + lines.append(f"# {kind}{where}") + lines.append(f"{name} = {expression}") + names.append(name) + lines.append(f"selected_item = [{', '.join(names)}]") + return "\n".join(lines) + + +def variable_names(items: Sequence[Mapping[str, Any]]) -> list[str]: + """The ``selected_*`` variable of each item, in order (as in the code).""" + totals = Counter(str(item.get("kind", "")) for item in items) + seen: Counter[str] = Counter() + names = [] + for item in items: + kind = str(item.get("kind", "")) + seen[kind] += 1 + names.append(f"selected_{kind}" if totals[kind] == 1 else f"selected_{kind}_{seen[kind]}") + return names + + +def _where(item: Mapping[str, Any]) -> str: + parts = [] + if item.get("definition_id"): + parts.append(f"of {item['definition_id']}") + if item.get("node_id"): + parts.append(f"node {item['node_id']}") + return f" ({', '.join(parts)})" if parts else "" + + +def _vector(value: Any) -> tuple[float, float, float] | None: + if ( + isinstance(value, (list, tuple)) + and len(value) == 3 + and all(isinstance(part, (int, float)) and not isinstance(part, bool) for part in value) + ): + return float(value[0]), float(value[1]), float(value[2]) + return None + + +def _number(value: float) -> str: + text = f"{value:.4f}".rstrip("0").rstrip(".") + return "0" if text in ("-0", "") else text diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py new file mode 100644 index 00000000..35d48ce1 --- /dev/null +++ b/src/simplecadapi/editor/server.py @@ -0,0 +1,1565 @@ +"""The editor server: webui static files + WS hub + the workspace's marimo kernel. + +This is the ``sca edit `` application. The workspace is a plain +directory of SimpleCAD notebooks (:mod:`simplecadapi.runtime.workspace`). +The server hosts a marimo kernel for one notebook at a time +(:mod:`.kernel`); the kernel runs the notebook with the same executor +``sca run`` uses. When the cells settle — whoever ran them — the server +asks the kernel for the product's package (``publish_live``), keeps the +bytes, and tells the webuis. Commands from the webui are translated into +kernel cell operations; parameter values the webui sets are saved where the +kernel executor applies them (:mod:`simplecadapi.runtime.params`), so +marimo's own dataflow reruns exactly the cells that read them. +""" + +from __future__ import annotations + +import ast +import asyncio +import hashlib +import json +import re +import sys +import threading +import time +import webbrowser +from collections import OrderedDict +from pathlib import Path +from typing import Any + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse, Response +from starlette.routing import Route, WebSocketRoute +from starlette.staticfiles import StaticFiles +from starlette.websockets import WebSocketDisconnect + +from ..runtime.cells import cell_keys +from ..runtime.config import NotebookConfig, read_notebook_config +from ..runtime.params import ( + ParamSpec, + commit_field_value, + commit_value, + list_module_parameters, + parameters_in_cell, + read_saved_params, + write_saved_params, +) +from ..runtime.deps import file_digest, input_files +from ..runtime.workspace import features_in_cell, notebook_modules, scan_modules, scan_workspace +from .kernel import MarimoKernel +from .selection import MARKER, selection_cell_code, variable_names +from .templates import create_notebook +from .ws import WsHub + + +def _webui_dir() -> Path: + """The built webui: ``editor-web`` builds into the package directory + (``npm run build``), and the wheel ships it from there.""" + return Path(__file__).resolve().parent / "webui" + + +DIST_DIR = _webui_dir() + + +def pick_notebook(workspace: Path, prefer: Path | None = None) -> Path | None: + """The notebook to open first: the one asked for, else the product. + + Without a request that is the first assembly nothing else uses (the + top of the product), else the first notebook. + """ + if prefer is not None and prefer.is_file(): + return prefer.resolve() + entries = scan_workspace(workspace) + if not entries: + return None + used = {use for entry in entries for use in entry.uses} + tops = [e for e in entries if e.kind == "assembly" and e.path not in used] + chosen = (tops or entries)[0] + return (workspace / chosen.path).resolve() + + +class WorkspaceState: + """Shared server state: hub + kernel + workspace info + latest envelopes. + + A webui that connects (or refreshes) later must still see the current + model, so the latest envelope per type is cached and replayed. + + Parameter bursts collapse: while one rebuild is in flight, newer values + accumulate in ``_pending_params`` and exactly one follow-up rebuild runs + with the merged latest values ("latest wins"). + """ + + # Envelope types worth replaying, in replay order. "preview" is a light + # revision event; the bytes themselves live on /api/package/latest. + _REPLAYED = ( + "module", + "notebook.cells", + "notebook.features", + "params.schema", + "params.values", + "preview", + "selection", + ) + + def __init__(self, workspace: Path, notebook: Path | None = None) -> None: + self.workspace = workspace.expanduser().resolve() + self.hub = WsHub() + self.kernel: MarimoKernel | None = None + self.notebook: Path | None = pick_notebook(self.workspace, notebook) + self.port = 0 + self.lock = threading.Lock() + self.latest: dict[str, dict] = {} + self.latest_package_bytes: bytes | None = None # a full package, made on demand (export) + self.latest_preview_bytes: bytes | None = None + self.preview_revision = 0 + self._package_event = threading.Event() + self.selection: dict = {} + self.selection_items: list[dict] = [] + self.viewport_png: bytes | None = None + self.cells: list[dict] = [] + self.specs: dict[str, ParamSpec] = {} + self._pending_params: dict[str, float | int | bool] = {} + self._rebuild_inflight = False + self._rebuild_cond = threading.Condition() + # One rebuild per interval; later slider batches keep merging. + self._rebuild_min_interval = 0.35 + self._last_rebuild_at = 0.0 + self._watched: dict[Path, int] = {} + self.module_path: Path | None = None + self._pending_modules: dict[tuple[str, str], float | int | bool] = {} + self._module_dirty: set[Path] = set() + self._module_busy = False + self._module_lock = threading.Lock() + self._packages: OrderedDict[str, tuple[str, bytes]] = OrderedDict() + self._publish_signature: str | None = None + self._stopped = False + self._selection_code: str | None = None + self._selection_busy = False + self._selection_lock = threading.Lock() + self._settle_timer: threading.Timer | None = None + self._settle_lock = threading.Lock() + self._last_publish_error = "" + self._generation = 0 + self._switch_lock = threading.Lock() + self._booting: MarimoKernel | None = None + + # -- notebook / kernel lifecycle --------------------------------------------- + + @property + def config(self) -> NotebookConfig | None: + return read_notebook_config(self.notebook) if self.notebook else None + + def open_notebook(self, rel_path: str, generation: int | None = None) -> None: + """Switch the active notebook: restart the kernel on it. + + Runs synchronously (the caller wraps it in a thread): marimo boot + takes seconds. If nothing the notebook depends on changed since its + model was last published, that model and the notebook's cells are shown + at once, while the kernel boots. + """ + target = self.resolve_notebook(rel_path) + if generation is None: + generation = self.reserve_switch() + self.announce_opening(target) + if not self._begin_switch(target, generation): + return # a later open was asked for meanwhile + restored = self._restore(target, generation) + self.start_kernel(target, restored=restored, generation=generation) + + def resolve_notebook(self, rel_path: str) -> Path: + """The file of the workspace notebook *rel_path*; ``ValueError`` if it is none.""" + target = (self.workspace / rel_path).resolve() + if not target.is_file() or self.workspace not in target.parents: + raise ValueError(f"notebook outside workspace: {rel_path}") + if read_notebook_config(target) is None: + raise ValueError(f"not a SimpleCAD notebook (no [tool.simplecadapi]): {rel_path}") + return target + + def announce_opening(self, target: Path) -> None: + """Tell the webuis a notebook is being opened (they show it at once).""" + self.hub.broadcast( + {"type": "opening", "notebook": target.name, "path": target.relative_to(self.workspace).as_posix()} + ) + + def reserve_switch(self) -> int: + """Take the next place in the order of opens. + + Taken where a request arrives, so that a later click wins however the + threads that carry the opens out happen to be scheduled. + """ + with self._switch_lock: + self._generation += 1 + return self._generation + + def _begin_switch(self, target: Path, generation: int) -> bool: + """Make *target* the notebook being opened, superseding any open in flight. + + The list of notebooks shows it as the current one from now on, not + once its kernel is up; whatever an older open is still doing (booting a + kernel, publishing) is cancelled: it notices it was superseded. + """ + with self._switch_lock: + if generation != self._generation: + return False + self.notebook = target + # Let the webuis drop the old model now; commands have no kernel until the new one is up. + self.latest.clear() + self.latest_preview_bytes = None + self._drop_kernel() + return True + + def _superseded(self, generation: int) -> bool: + return generation != self._generation + + def _drop_kernel(self) -> None: + with self.lock: + old, self.kernel = self.kernel, None + booting, self._booting = self._booting, None + for kernel in (old, booting): + if kernel is not None: + kernel.stop() + + def start_kernel(self, notebook: Path, *, restored: bool = False, generation: int | None = None) -> None: + """Boot a marimo kernel for *notebook*, adopt it, and publish its product. + + Shared by server boot, open_notebook, and the auto-heal watch. With + *restored* the notebook's last model and cells are already on screen. + An open that is superseded while it boots (the person opened another + notebook meanwhile) ends quietly: its kernel is stopped, nothing is shown. + """ + if generation is None: + generation = self.reserve_switch() + if not self._begin_switch(notebook, generation): + return + restored = self._restore(notebook, generation) + kernel = MarimoKernel( + notebook, + server_port=self.port, + on_run_complete=lambda: self._superseded(generation) or self.schedule_settle(), + on_cell_status=lambda cell_id, status: self._superseded(generation) or self.cell_status(cell_id, status), + ) + with self.lock: + if self._superseded(generation): + return + self._booting = kernel + try: + kernel.start() + with self.lock: + if self._superseded(generation): + kernel.stop() + return + self._booting = None + self.kernel = kernel + # Envelopes from the previous model must not replay. + self.selection = {} + self.selection_items = [] + self.viewport_png = None + self._last_publish_error = "" + if not restored: + self.latest.clear() + self.latest_preview_bytes = None + self.cells = [] + self.specs = {} + self._watched = self._watched_files()[0] + self.hub.broadcast(self.hello()) + kernel.wait_instantiated() + kernel.wait_idle() + if self._superseded(generation): + return + self.settle() + except Exception: + kernel.stop() + if self._superseded(generation): + return # its kernel was stopped under it; not an error + raise + threading.Thread( + target=self._watch_files, args=(kernel,), name="file-watch", daemon=True + ).start() + + # -- remembering what was published ----------------------------------------------- + + def _signature(self, notebook: Path) -> str: + """A digest of everything the notebook's model depends on. + + The notebook, the local modules it imports, the notebooks it runs and what + they depend on, the data files they list, the values an editor saved, and the + software versions: if it is the same as when a model was published, that + model is the model. + """ + import marimo + + from ..build.keys import generator_profile + + files, _own, _inputs = self._dependency_files(notebook) + files.add(notebook) + config = read_notebook_config(notebook) + payload = { + "files": {path.as_posix(): file_digest(path) for path in sorted(files)}, + "saved": read_saved_params(config) if config is not None else {}, + "generator": dict(generator_profile()), + "marimo": marimo.__version__, + } + return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest() + + def _model_file(self, notebook: Path) -> Path: + name = notebook.relative_to(self.workspace).as_posix().replace("/", "__") + return self.snapshot_dir / "models" / f"{name}.scapreview" + + def remember_model(self, notebook: Path, signature: str, data: bytes) -> None: + """Keep *data* (a preview) as the model of *notebook* for *signature*, in memory and on disk.""" + key = notebook.relative_to(self.workspace).as_posix() + self._packages[key] = (signature, data) + self._packages.move_to_end(key) + while len(self._packages) > 6: + self._packages.popitem(last=False) + try: + target = self._model_file(notebook) + target.parent.mkdir(parents=True, exist_ok=True) + target.with_suffix(".sig").write_text(signature, encoding="utf-8") + temporary = target.with_suffix(".tmp") + temporary.write_bytes(data) + temporary.replace(target) + except OSError as error: + print(f"[sca edit] could not keep the model: {error}", file=sys.stderr) + + def _recall_model(self, notebook: Path, signature: str) -> bytes | None: + key = notebook.relative_to(self.workspace).as_posix() + known = self._packages.get(key) + if known is not None and known[0] == signature: + return known[1] + target = self._model_file(notebook) + try: + if target.with_suffix(".sig").read_text(encoding="utf-8") == signature: + return target.read_bytes() + except OSError: + pass + return None + + def _restore(self, notebook: Path, generation: int | None = None) -> bool: + """Show *notebook* from its last published model, if nothing it depends on changed. + + Its cells appear too, read from the file (not editable until the kernel is + up); the kernel's own cells replace them, and its model replaces this one + only if it differs. + """ + try: + data = self._recall_model(notebook, self._signature(notebook)) + except (OSError, ValueError): + data = None + if data is None or (generation is not None and self._superseded(generation)): + return False + self.notebook = notebook + self._publish_cells(_static_cells(notebook)) + self.latest_preview_bytes = data + self.preview_revision += 1 + self.emit( + { + "type": "preview", + "revision": self.preview_revision, + "step": notebook.stem, + "name": notebook.stem, + "size": len(data), + "cached": True, + } + ) + self.hub.broadcast({"type": "status", "info": "restored from the last run; the kernel is starting…"}) + return True + + def watch_kernel(self) -> None: + """Auto-heal: the marimo python session can die on crashes or + external file edits while the marimo SERVER process stays alive + (`kernel.alive` is blind to that). Ask marimo whether it still has our + session; two consecutive misses mean it is gone — restart. + """ + failures = 0 + while True: + time.sleep(5) + kernel = self.kernel + # Between kernels (a notebook is being opened) there is nothing to watch. + if kernel is None or kernel._stop.is_set(): + failures = 0 + continue + if not kernel.alive: + self.hub.broadcast({"type": "status", "error": "marimo server exited — restarting kernel"}) + else: + # Not a scratch snippet: one queued behind a long cell and given + # up on interrupts the kernel (see MarimoKernel.execute). + failures = 0 if kernel.session_exists() else failures + 1 + if failures < 2: + continue + self.hub.broadcast({"type": "status", "error": "kernel session lost — restarting"}) + failures = 0 + try: + assert self.notebook is not None + self.start_kernel(self.notebook) + self.hub.broadcast({"type": "status", "recovered": self.notebook.name}) + except Exception as exc: + self.hub.broadcast({"type": "status", "error": f"kernel restart failed: {exc}"}) + + # -- child notebooks --------------------------------------------------------- + + def _watched_files(self) -> tuple[dict[Path, int], set[Path], set[Path]]: + """The files whose edit changes the open notebook, with their mtimes. + + Returns ``(mtimes, own_modules, own_inputs)``. *own_modules* are the + local modules the notebook itself imports: the kernel has them loaded, + so a change needs a reload and a rerun of every cell. *own_inputs* are + the project files its header lists (``inputs``, data the cells read): + nothing to reload, but any cell may read them, so every cell reruns. + The rest are the notebooks it runs with ``use`` (and the modules and data + those use) and the module open in the module view: ``use`` runs a child + headless, outside the kernel's dataflow, so marimo never sees an edit to + it; the editor does and reruns the cells that call ``use`` (the children's + own cell cache, keyed by their files, decides what is rebuilt). + """ + files: set[Path] = set() + own: set[Path] = set() + inputs: set[Path] = set() + if self.notebook is not None: + files, own, inputs = self._dependency_files(self.notebook) + if self.module_path is not None: + files.add(self.module_path) + mtimes: dict[Path, int] = {} + for path in files: + try: + mtimes[path] = path.stat().st_mtime_ns + except OSError: + mtimes[path] = 0 + return mtimes, own, inputs + + def _dependency_files(self, notebook: Path) -> tuple[set[Path], set[Path], set[Path]]: + """What *notebook* depends on: ``(files, own_modules, own_inputs)``.""" + entries = {entry.path: entry for entry in scan_workspace(self.workspace)} + start = notebook.relative_to(self.workspace).as_posix() + own = set(notebook_modules(notebook).values()) + config = read_notebook_config(notebook) + inputs = set(input_files(config)) if config is not None else set() + files = own | inputs + seen: set[str] = set() + pending = list(entries[start].uses) if start in entries else [] + while pending: + path = pending.pop() + if path in seen: + continue + seen.add(path) + if path in entries: + pending.extend(entries[path].uses) + files.update(self.workspace / module for module in entries[path].modules) + child = read_notebook_config(self.workspace / path) + if child is not None: + files |= set(input_files(child)) + files |= {self.workspace / path for path in seen} + return files, own, inputs + + def _watch_files(self, kernel: MarimoKernel) -> None: + while not kernel._stop.is_set(): + time.sleep(1.0) + if self.kernel is not kernel: + return + try: + current, _own, _inputs = self._watched_files() + changed = {path for path, mtime in current.items() if self._watched.get(path) != mtime} + changed |= {path for path in self._watched if path not in current} + self._watched = current + if changed: + self._files_changed(changed) + except Exception as exc: + self.hub.broadcast({"type": "status", "error": f"refresh failed: {str(exc)[-200:]}"}) + + def _files_changed(self, paths: set[Path], *, announce: bool = True) -> None: + """Files the notebook depends on changed: reload / rerun what reads them.""" + if announce and self.module_path in paths: + self.emit_module("external") + with self._module_lock: + self._module_dirty |= paths + if self._module_busy: + return + self._module_busy = True + threading.Thread(target=self._propagate, name="propagate", daemon=True).start() + + def _propagate(self) -> None: + """Serialized, coalescing: one reload covers every change that arrived meanwhile.""" + try: + while True: + with self._module_lock: + paths, self._module_dirty = self._module_dirty, set() + if not paths: + self._module_busy = False + return + kernel = self.kernel + if kernel is None: + continue + own_names = self._own_module_names() + _mtimes, _own, own_inputs = self._watched_files() + if any(file in paths for file in own_names.values()): + shown = ", ".join(Path(path).name for path in sorted(paths)) + self.hub.broadcast({"type": "status", "working": f"reloading {shown}…"}) + kernel.reload_modules(list(own_names)) + kernel.wait_idle(300) + elif any(file in paths for file in own_inputs): + shown = ", ".join(Path(path).name for path in sorted(paths & own_inputs)) + self.hub.broadcast({"type": "status", "working": f"{shown} changed: rerunning…"}) + kernel.run_until_settled([cell["id"] for cell in self.cells]) + else: + uses = [cell["id"] for cell in self.cells if "use(" in cell["code"]] + if not uses: + continue + kernel.run_until_settled(uses) + self.schedule_settle() + except Exception as exc: + with self._module_lock: + self._module_busy = False + self.hub.broadcast({"type": "status", "error": f"reload failed: {str(exc)[-300:]}"}) + + def _own_module_names(self) -> dict[str, Path]: + return notebook_modules(self.notebook) if self.notebook else {} + + # -- the module view ----------------------------------------------------------- + + def modules(self) -> list[dict]: + return [module.to_dict() for module in scan_modules(self.workspace)] + + def emit_module(self, origin: str) -> None: + """Tell the webuis about the module open in the module view.""" + path = self.module_path + if path is None or not path.is_file(): + self.emit({"type": "module", "path": None}) + return + try: + code = path.read_text(encoding="utf-8") + except OSError: + return + relative = path.relative_to(self.workspace).as_posix() + used_by = next((m["used_by"] for m in self.modules() if m["path"] == relative), []) + params = [] + for spec in list_module_parameters(path): + row = spec.to_dict() + row["id"] = spec.name + row["value"] = spec.default + row["modified"] = False + params.append(row) + self.emit( + { + "type": "module", + "origin": origin, + "path": relative, + "name": path.name, + "code": code, + "used_by": used_by, + "params": params, + } + ) + + def open_module(self, rel_path: str) -> None: + target = (self.workspace / rel_path).resolve() + if self.workspace not in target.parents or not target.is_file() or target.suffix != ".py": + raise ValueError(f"not a module of this workspace: {rel_path}") + self.module_path = target + self._watched = self._watched_files()[0] + self.emit_module("open") + + def save_module(self, rel_path: str, code: str) -> None: + """Autosave from the module view; the dependents follow.""" + target = self._module_file(rel_path) + if target.read_text(encoding="utf-8") == code: + return + target.write_text(code, encoding="utf-8") + self._after_module_write(target, "save") + + def set_module_params(self, rel_path: str, values: dict[str, float | int | bool]) -> None: + """Write parameter values into the dataclass defaults of the module.""" + target = self._module_file(rel_path) + code = target.read_text(encoding="utf-8") + for name, value in values.items(): + class_name, dot, field = name.partition(".") + changed = ( + commit_field_value(code, class_name, field, value) if dot else commit_value(code, name, value) + ) + if changed is None: + self.hub.broadcast({"type": "status", "error": f"cannot write {name} into {target.name}"}) + continue + code = changed + if code != target.read_text(encoding="utf-8"): + target.write_text(code, encoding="utf-8") + self._after_module_write(target, "param") + + def _module_file(self, rel_path: str) -> Path: + target = (self.workspace / rel_path).resolve() + if self.workspace not in target.parents or not target.is_file() or target.suffix != ".py": + raise ValueError(f"not a module of this workspace: {rel_path}") + return target + + def _after_module_write(self, target: Path, origin: str) -> None: + # Our own write: the watcher must not fire a second reload for it. + try: + self._watched[target] = target.stat().st_mtime_ns + except OSError: + pass + self.emit_module(origin) + self._files_changed({target}, announce=False) + + def cell_status(self, cell_id: str, status: str) -> None: + """A cell's state changed in the kernel: tell the webuis (its run button shows it).""" + title = None + for cell in self.cells: + if cell["id"] == cell_id: + cell["status"] = status + title = cell.get("title") + self.hub.broadcast({"type": "cell.status", "id": cell_id, "status": status, "title": title}) + + def remember(self, envelope: dict) -> None: + if envelope.get("type") in self._REPLAYED: + self.latest[envelope["type"]] = envelope + + def emit(self, envelope: dict) -> None: + self.remember(envelope) + self.hub.broadcast(envelope) + + def replay(self) -> list[dict]: + return [self.latest[key] for key in self._REPLAYED if key in self.latest] + + def hello(self) -> dict: + return { + "type": "hello", + "workspace": str(self.workspace), + "notebook": self.notebook.name if self.notebook else None, + "path": self.notebook.relative_to(self.workspace).as_posix() if self.notebook else None, + "kernel": self.kernel is not None and self.kernel.alive, + "marimo_url": self.kernel.marimo_url if self.kernel else None, + } + + # -- settling: the cells ran; publish what they made ----------------------- + + def schedule_settle(self) -> None: + """A batch of cell runs ended: settle once the activity calms down.""" + with self._settle_lock: + if self._settle_timer is not None: + self._settle_timer.cancel() + self._settle_timer = threading.Timer(0.4, self._settle_in_thread) + self._settle_timer.daemon = True + self._settle_timer.start() + + def _settle_in_thread(self) -> None: + try: + self.settle() + except Exception as exc: # never let a timer thread die silently + self.hub.broadcast({"type": "status", "error": str(exc)[-300:]}) + + def settle(self) -> None: + """Push the cells, features and parameters, then the product package.""" + kernel = self.kernel + if kernel is None: + return + with self._settle_lock: + self.push_cells() + # The signature of what is published is taken before it is built. + try: + self._publish_signature = self._signature(self.notebook) if self.notebook else None + except (OSError, ValueError): + self._publish_signature = None + result = kernel.publish_preview(self.preview_url, step=self.notebook.stem if self.notebook else "") + if result.get("ok"): + self._last_publish_error = "" + self._stopped = False + return + message = str(result.get("error", "")) + if kernel.setup_errors: + # No product because the notebook's setup failed (a module it imports + # does not compile): that is the cause, not the missing product. + message = f"setup failed — {kernel.setup_errors[0]}" + if self._stopped: + # The person stopped the run: its cells have no product, and that is no fault. + self._stopped = False + self._last_publish_error = "" + self.hub.broadcast({"type": "status", "info": "stopped"}) + return + # A notebook mid-edit has no product yet; say so once, not per run. + if message != self._last_publish_error: + self._last_publish_error = message + self.hub.broadcast({"type": "status", "error": message[-300:]}) + + @property + def preview_url(self) -> str: + return f"http://127.0.0.1:{self.port}/api/runtime/preview" + + @property + def package_url(self) -> str: + return f"http://127.0.0.1:{self.port}/api/runtime/package" + + def push_cells(self) -> None: + """Broadcast the cells, their feature headers and the parameters.""" + kernel = self.kernel + if kernel is None: + return + self._publish_cells(kernel.list_cells()) + + def _publish_cells(self, cells: list[dict]) -> None: + keys = cell_keys(cell["code"] for cell in cells) + features: list[dict] = [] + specs: list[ParamSpec] = [] + for cell, key in zip(cells, keys): + cell["key"] = key + found = features_in_cell(cell["code"], key) + cell["feature"] = found[0].slug if found else None + cell["title"] = cell_title(cell["code"], cell["feature"]) + # code mode would turn an @app.function into a plain cell + cell["readonly"] = cell.get("readonly", False) or _is_function_cell(cell["code"]) + features.extend( + {"cell": cell["id"], "label": feature.slug, "kind": feature.role or "feature"} + for feature in found + ) + specs.extend( + ParamSpec(**{**spec.__dict__, "cell": cell["id"]}) + for spec in parameters_in_cell(cell["code"], key) + ) + try: + text = self.notebook.read_text(encoding="utf-8") if self.notebook else "" + except OSError: + text = "" + for number, (cell, lines) in enumerate(zip(cells, file_line_ranges(text, [c["code"] for c in cells])), start=1): + where = f"{self.notebook}:L{lines[0]}-L{lines[1]}" if lines and self.notebook else str(self.notebook) + cell["lines"] = list(lines) if lines else None + cell["ref"] = f"{where} (cell {number:02d} `{cell['title']}`)" + self.cells = cells + self.specs = {spec.name: spec for spec in specs} + self.emit({"type": "notebook.cells", "cells": cells}) + self.emit({"type": "notebook.features", "features": features}) + self.emit_params() + self.write_snapshot() + + def emit_params(self) -> None: + config = self.config + saved = read_saved_params(config) if config is not None else {} + variables = [] + for spec in self.specs.values(): + row = spec.to_dict() + row["id"] = spec.name + row["value"] = saved.get(spec.name, spec.default) + row["modified"] = spec.name in saved and saved[spec.name] != spec.default + variables.append(row) + self.emit({"type": "params.schema", "vars": variables}) + self.emit({"type": "params.values", "values": {row["id"]: row["value"] for row in variables}}) + + # -- webui command -> kernel operation --------------------------------------- + + def dispatch_command(self, message: dict) -> dict: + """Translate one ui command into kernel operations. Returns an ack.""" + kind = message.get("type") + if kind == "module.open": + try: + self.open_module(str(message.get("path", ""))) + except ValueError as error: + return {"type": "error", "message": str(error)} + return {"type": "ack", "command": kind} + if kind == "module.close": + self.module_path = None + self.emit_module("close") + return {"type": "ack", "command": kind} + if kind == "module.save": + self._in_thread(lambda: self.save_module(str(message.get("path", "")), str(message.get("code", "")))) + return {"type": "ack", "command": kind} + if kind == "module.param": + path = str(message.get("path", "")) + values = {str(k): v for k, v in dict(message.get("values", {})).items() if isinstance(v, (int, float, bool))} + self._in_thread(lambda: self.set_module_params(path, values)) + return {"type": "ack", "command": kind} + kernel = self.kernel + if kernel is None: + return {"type": "error", "message": "no kernel"} + if kind == "entity.info": + entity_id = str(message.get("entity_id", "")) + node_id = message.get("node_id") + + def lookup() -> None: + info = kernel.entity_info(entity_id) + self.hub.broadcast( + {"type": "entity.info", "entity_id": entity_id, "node_id": node_id, "entity": info} + ) + + self._in_thread(lookup) + return {"type": "ack", "command": kind} + if kind == "interrupt.request": + self._stopped = True + self._in_thread(kernel.interrupt) + return {"type": "ack", "command": kind} + if kind == "param.set": + self._enqueue_params(kernel, dict(message.get("values", {}))) + return {"type": "ack", "command": kind} + if kind == "export.request": + self._export() + return {"type": "ack", "command": kind} + if kind in ("param.commit", "param.revert"): + names = [str(name) for name in message.get("names", [])] or list(self.specs) + self._in_thread(lambda: self._commit(kernel, names, revert=kind == "param.revert")) + return {"type": "ack", "command": kind} + + if kind == "selection.set": + items = [item for item in message.get("items", []) if isinstance(item, dict)] + self._in_thread(lambda: self._set_selection(kernel, items)) + return {"type": "ack", "command": kind} + + cell = str(message.get("cell") or "") + if kind == "cell.save": + # Autosave: write the edit to the notebook file, do not run it. + code = _module_code(str(message.get("code", ""))) + current = next((c["code"] for c in self.cells if c["id"] == cell), None) + if current is None or code.strip() == _module_code(current).strip(): + return {"type": "ack", "command": kind} + if _is_function_cell(current): + return { + "type": "error", + "message": "this cell is an @app.function; edit it in the file or in the marimo editor", + } + + def save() -> None: + kernel.save_cell(cell, code) + self.push_cells() + + self._in_thread(save) + return {"type": "ack", "command": kind} + if kind == "rerun.request": + # Header rerun (no cell) refreshes the whole notebook; a per-cell + # rerun runs that cell and leaves its dependents stale. + ids = [cell] if cell else [c["id"] for c in self.cells] + if message.get("above"): + # Every cell above this one, in notebook order (not itself). + order = [c["id"] for c in self.cells] + ids = order[: order.index(cell)] if cell in order else [] + if not ids: + return {"type": "ack", "command": kind} + action = lambda: kernel.run_cells(ids) # noqa: E731 + elif kind == "insert.request": + after = message.get("after") + action = lambda: kernel.insert_cell(str(after) if after else None) # noqa: E731 + elif kind == "rollback.request": + # Delete the cell (persisted to the file); marimo marks what read + # its variables stale. + action = lambda: kernel.delete_cell(cell) # noqa: E731 + elif kind == "code.edit": + code = _module_code(str(message.get("code", ""))) + current = next((c["code"] for c in self.cells if c["id"] == cell), None) + if current is not None and code.strip() != _module_code(current).strip() and _is_function_cell(current): + # marimo's code mode would turn an @app.function into a plain + # cell; edit those in the file or in marimo's own editor. + return { + "type": "error", + "message": "this cell is an @app.function; edit it in the file or in the marimo editor", + } + if current is not None and code.strip() == _module_code(current).strip(): + # Nothing to change: run the cell, never rewrite the notebook file. + action = lambda: kernel.run_cells([cell]) # noqa: E731 + else: + action = lambda: kernel.edit_cell(cell, code) # noqa: E731 + else: + return {"type": "error", "message": f"unknown command: {kind}"} + + def run() -> None: + result = action() + if result.get("success") is False: + self.hub.broadcast( + {"type": "status", "error": (result.get("log") or "kernel command failed")[-400:]} + ) + kernel.wait_idle() + # The kernel reports a completed run for these too, but settle + # even when it reported failure: the edit may have applied and + # the error state is what the notebook UI must show. + self.schedule_settle() + + self._in_thread(run) + return {"type": "ack", "command": kind} + + def _in_thread(self, work: Any) -> None: + def run() -> None: + try: + work() + except Exception as exc: # surface, never crash the WS handler + self.hub.broadcast({"type": "status", "error": str(exc)[-400:]}) + + threading.Thread(target=run, name="kernel-command", daemon=True).start() + + # -- parameters -------------------------------------------------------------- + + def _clean_params(self, values: dict) -> dict[str, float | int | bool]: + """Keep the values that name a parameter, clamped and typed to it.""" + clean: dict[str, float | int | bool] = {} + for name, raw in values.items(): + spec = self.specs.get(str(name)) + if spec is None or isinstance(raw, str): + continue + if spec.kind == "bool": + clean[spec.name] = bool(raw) + continue + if isinstance(raw, bool): + continue + number = float(raw) + if spec.min is not None: + number = max(spec.min, number) + if spec.max is not None: + number = min(spec.max, number) + clean[spec.name] = int(round(number)) if spec.kind == "int" else number + return clean + + def _enqueue_params(self, kernel: MarimoKernel, values: dict) -> None: + """Coalesce slider bursts: at most one rebuild in flight; newer values + always win; one follow-up rebuild per settled gesture.""" + clean = self._clean_params(values) + if not clean: + return + with self._rebuild_cond: + self._pending_params.update(clean) + threading.Thread(target=self._rebuild_loop, args=(kernel,), name="param-rebuild", daemon=True).start() + + def _rebuild_loop(self, kernel: MarimoKernel) -> None: + with self._rebuild_cond: + if self._rebuild_inflight: + return # the running loop will pick up our pending values + self._rebuild_inflight = True + try: + while True: + with self._rebuild_cond: + if not self._pending_params: + return + partial = dict(self._pending_params) + self._pending_params.clear() + since = time.monotonic() - self._last_rebuild_at + if since < self._rebuild_min_interval: + time.sleep(self._rebuild_min_interval - since) + self._last_rebuild_at = time.monotonic() + try: + self._apply_params(kernel, partial) + except Exception as exc: + self.hub.broadcast({"type": "status", "error": str(exc)[-400:]}) + finally: + with self._rebuild_cond: + self._rebuild_inflight = False + + def _apply_params(self, kernel: MarimoKernel, values: dict[str, float | int | bool]) -> None: + """Save the values, then run the cells that declare them. + + The kernel executor puts the saved values in place as those cells + finish; marimo's dataflow marks what reads them stale, and + ``run_until_settled`` runs exactly that. + """ + config = self.config + if config is None: + raise RuntimeError("no SimpleCAD notebook is open") + saved = read_saved_params(config) + cells: list[str] = [] + for name, value in values.items(): + spec = self.specs[name] + if value == spec.default: + saved.pop(name, None) # back at the source value + else: + saved[name] = value + if spec.cell not in cells: + cells.append(spec.cell) + write_saved_params(config, saved) + self.emit_params() + kernel.run_until_settled(cells) + self.schedule_settle() + + def _commit(self, kernel: MarimoKernel, names: list[str], *, revert: bool) -> None: + """Write saved values into the notebook source, or drop them.""" + config = self.config + if config is None: + return + saved = read_saved_params(config) + names = [name for name in names if name in saved] + if not names: + return + if not revert: + codes = {cell["id"]: cell["code"] for cell in self.cells} + changed: dict[str, str] = {} + for name in names: + spec = self.specs.get(name) + if spec is None: + continue + code = commit_value(changed.get(spec.cell, codes[spec.cell]), name, saved[name]) + if code is None: + self.hub.broadcast({"type": "status", "error": f"cannot write {name} into the source"}) + return + changed[spec.cell] = code + for cell_id, code in changed.items(): + kernel.edit_cell(cell_id, code) + kernel.wait_idle() + for name in names: + saved.pop(name, None) + write_saved_params(config, saved) + if revert: + kernel.run_until_settled([self.specs[name].cell for name in names if name in self.specs]) + self.schedule_settle() + + # -- selection and the agent snapshot ---------------------------------------- + + def _set_selection(self, kernel: MarimoKernel, items: list[dict]) -> None: + """Write the viewport selection into the notebook's selection cell. + + The webuis are told at once, from here: the kernel runs scratch code + one piece at a time, so a write that queues behind a long run (a + publish of a big assembly takes seconds) must not hold the picture + back. The write itself is coalesced: while one is in flight only the + latest selection waits, and it is the one written. + """ + if kernel is not self.kernel: + return # the notebook was switched under this pick + keep = ("kind", "entity_id", "node_id", "definition_id", "properties", "geometry") + self.selection_items = [{key: item.get(key) for key in keep if key in item} for item in items] + cell = next((c for c in self.cells if c["code"].lstrip().startswith(MARKER)), None) + if cell is None and not items: + self.write_snapshot() + return + code = selection_cell_code(self.selection_items) + if cell is not None and code.strip() == cell["code"].strip(): + self.write_snapshot() + return + if cell is not None: + cell["code"] = code + else: + self.cells.append( + { + "id": PENDING_SELECTION_ID, + "name": "_", + "code": code, + "status": "idle", + "errors": [], + "output": None, + "console": [], + "key": "", + "feature": None, + "title": "selection", + "readonly": False, + "lines": None, + "ref": f"{self.notebook} (cell {len(self.cells) + 1:02d} `selection`)", + } + ) + self.emit({"type": "notebook.cells", "cells": self.cells}) + self.emit({"type": "selection", "items": self._selection_summary(), "variables": variable_names(self.selection_items)}) + self.write_snapshot() + with self._selection_lock: + self._selection_code = code + if self._selection_busy: + return + self._selection_busy = True + self._write_selection(kernel) + + def _write_selection(self, kernel: MarimoKernel) -> None: + """Write the newest selection code into the kernel's cell; repeat while a newer one arrived.""" + try: + while True: + with self._selection_lock: + code, self._selection_code = self._selection_code, None + if code is None or kernel is not self.kernel: + # nothing (more) to write, or the notebook was switched meanwhile: + # a stale write must not land in the new notebook's cells + self._selection_code = None + self._selection_busy = False + return + cell = next( + (c for c in self.cells if c["code"].lstrip().startswith(MARKER) and c["id"] != PENDING_SELECTION_ID), + None, + ) + kernel.put_cell(code, cell_id=cell["id"] if cell else None) + kernel.wait_idle(10) + if kernel is not self.kernel: + continue + self.push_cells() + except Exception: + with self._selection_lock: + self._selection_busy = False + raise + + def _selection_summary(self) -> list[dict]: + names = variable_names(self.selection_items) + return [ + { + "variable": name, + "kind": item.get("kind"), + "entity_id": item.get("entity_id"), + "node_id": item.get("node_id"), + "definition_id": item.get("definition_id"), + } + for name, item in zip(names, self.selection_items) + ] + + @property + def snapshot_dir(self) -> Path: + return self.workspace / ".sca-editor" + + def snapshot(self) -> dict: + """What the person is looking at, as plain data for a coding agent.""" + selection_cell = next((c for c in self.cells if c["code"].lstrip().startswith(MARKER)), None) + config = self.config + saved = read_saved_params(config) if config is not None else {} + png = self.snapshot_dir / "viewport.png" + return { + "workspace": str(self.workspace), + "notebook": str(self.notebook) if self.notebook else None, + "updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "selection": { + "items": self._selection_summary(), + "cell": {"id": selection_cell["id"], "code": selection_cell["code"]} if selection_cell else None, + "note": "the selection cell is saved in the notebook file; read it there", + }, + "viewport_png": str(png) if png.is_file() else None, + "cells": [ + { + "id": c["id"], + "title": c.get("title"), + "status": c.get("status"), + "errors": c.get("errors") or [], + } + for c in self.cells + ], + "parameters": { + name: saved.get(name, spec.default) for name, spec in self.specs.items() + }, + "modified_parameters": sorted(saved), + "model_revision": self.preview_revision, + } + + def write_snapshot(self) -> None: + """Keep ``.sca-editor/snapshot.json`` current; ``sca snapshot`` reads it.""" + try: + self.snapshot_dir.mkdir(parents=True, exist_ok=True) + (self.snapshot_dir / "snapshot.json").write_text( + json.dumps(self.snapshot(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + except OSError as error: + print(f"[sca edit] could not write the snapshot: {error}", file=sys.stderr) + + def save_viewport(self, png: bytes) -> None: + self.viewport_png = png + try: + self.snapshot_dir.mkdir(parents=True, exist_ok=True) + (self.snapshot_dir / "viewport.png").write_bytes(png) + except OSError as error: + print(f"[sca edit] could not write the viewport image: {error}", file=sys.stderr) + self.write_snapshot() + + # -- export ------------------------------------------------------------------ + + def _export(self) -> None: + """Write a product package next to the notebook: ``out/.scadpkg``. + + The editor shows previews; a package (the durable form, with the definition + and the feature graph) takes seconds to make, so it is made when asked for. + """ + kernel = self.kernel + if kernel is None or self.notebook is None: + self.hub.broadcast({"type": "status", "error": "nothing to export yet"}) + return + self.hub.broadcast({"type": "status", "working": "building the package"}) + self._package_event.clear() + result = kernel.publish(self.package_url, step=self.notebook.stem) + if not result.get("ok") or not self._package_event.wait(30) or self.latest_package_bytes is None: + self.hub.broadcast({"type": "status", "error": str(result.get("error", "the package was not made"))[-300:]}) + return + target = self.notebook.parent / "out" / f"{self.notebook.stem}.scadpkg" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(self.latest_package_bytes) + self.hub.broadcast({"type": "status", "exported": str(target)}) + + +def _module_code(code: str) -> str: + """marimo's code_mode compiles cell code as a MODULE: a ``return`` in the + cell's own scope (normal in marimo's file format) is a SyntaxError there. + Strip those — the returned expression stays in place and becomes the cell + output — and nothing else: a ``return`` inside a function or class the + cell defines (an ``@app.function`` cell is one) is code, not syntax. + """ + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return code + returns: list[ast.Return] = [] + + def visit(node: ast.AST) -> None: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + continue # their returns are their own + if isinstance(child, ast.Return): + returns.append(child) + visit(child) + + visit(tree) + lines = code.splitlines() + for node in sorted(returns, key=lambda r: (r.lineno, r.col_offset), reverse=True): + line = lines[node.lineno - 1] + before, after = line[: node.col_offset], line[node.col_offset + len("return"):] + lines[node.lineno - 1] = before + ("pass" if node.value is None else after.lstrip()) + return "\n".join(lines) + + +def _static_cells(notebook: Path) -> list[dict]: + """The notebook's cells as the file has them, for showing before a kernel runs them.""" + from ..runtime._marimo import parse_notebook + + try: + parsed = parse_notebook(notebook.read_text(encoding="utf-8")) + except (OSError, UnicodeError, SyntaxError): + return [] + if parsed is None: + return [] + cells = [] + for index, cell in enumerate(parsed.cells): + if getattr(cell, "name", None) == "setup": + continue + cells.append( + { + "id": f"static-{index}", + "name": getattr(cell, "name", None) or "_", + "code": cell.code, + "status": "idle", + "errors": [], + "output": None, + "console": [], + "readonly": True, # not editable until the kernel has the cell + } + ) + return cells + + +PENDING_SELECTION_ID = "selection-pending" +"""Id of the selection cell the webuis see before the kernel has created it.""" + + +def file_line_ranges(text: str, codes: list[str]) -> list[tuple[int, int] | None]: + """Where each cell's code is in the notebook file (1-based, inclusive). + + *codes* are the cells' code in notebook order; they are matched to the + cells of the file in order, so a cell whose edit is not saved yet (or a + function cell the file spells differently) gets ``None``. + """ + from ..runtime._marimo import parse_notebook + + try: + notebook = parse_notebook(text) + except SyntaxError: + return [None] * len(codes) + if notebook is None: + return [None] * len(codes) + saved = [(cell.code.strip(), cell.lineno + 1, cell.lineno + max(1, len(cell.code.splitlines()))) for cell in notebook.cells] + ranges: list[tuple[int, int] | None] = [] + cursor = 0 + for code in codes: + found = None + for index in range(cursor, len(saved)): + if saved[index][0] == code.strip(): + found = (saved[index][1], saved[index][2]) + cursor = index + 1 + break + ranges.append(found) + return ranges + + +_PARAMS_HEADER = re.compile(r"^#\s*-{2,}\s*params(?::\s*(.*?))?\s*-*\s*$", re.MULTILINE) + + +def cell_title(code: str, feature: str | None) -> str: + """The step's name: its feature slug, its params header, else what it defines.""" + if feature: + return feature + if code.lstrip().startswith(MARKER): + return "selection" + header = _PARAMS_HEADER.search(code) + if header: + return f"params: {header.group(1)}" if header.group(1) else "params" + try: + body = ast.parse(code).body + except (SyntaxError, ValueError): + body = [] + for statement in body: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return statement.name + targets = ( + statement.targets if isinstance(statement, ast.Assign) + else [statement.target] if isinstance(statement, ast.AnnAssign) else [] + ) + for target in targets: + if isinstance(target, ast.Name) and not target.id.startswith("_"): + return target.id + for line in code.splitlines(): + if line.strip().startswith("#"): + text = line.strip().lstrip("#").strip(" -") + if text: + return text[:40] + return "cell" + + +def _is_function_cell(code: str) -> bool: + """Whether the cell is one ``def``/``class`` (an ``@app.function`` cell).""" + try: + body = ast.parse(code).body + except (SyntaxError, ValueError): + return False + return len(body) == 1 and isinstance(body[0], (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + + +def create_app(state: WorkspaceState) -> Starlette: + hub = state.hub + + async def health(request: Request) -> JSONResponse: + return JSONResponse( + { + "ok": True, + "workspace": str(state.workspace), + "notebook": state.notebook.name if state.notebook else None, + "kernel": state.kernel is not None and state.kernel.alive, + } + ) + + async def push_preview(request: Request) -> JSONResponse: + """The kernel hands over the preview of the model it just built. + + The bytes are cached for late webuis; webuis only get a lightweight + revision event and fetch the bytes on demand. + """ + body = await request.body() + if not body: + return JSONResponse({"ok": False, "error": "empty preview"}, status_code=400) + unchanged = state.latest_preview_bytes == body + state.latest_preview_bytes = bytes(body) + state.preview_revision += 1 + state.emit( + { + "type": "preview", + "revision": state.preview_revision, + "step": request.headers.get("X-Live-Step", ""), + "name": state.notebook.stem if state.notebook else None, + "size": len(body), + # The model on screen is this model: nothing to reload. + "unchanged": unchanged, + } + ) + if state.notebook is not None and state._publish_signature is not None: + await asyncio.to_thread(state.remember_model, state.notebook, state._publish_signature, bytes(body)) + # The model is fine again: clear a stale "no product yet" message. + hub.broadcast({"type": "status", "error": ""}) + await asyncio.to_thread(state.write_snapshot) + return JSONResponse({"ok": True, "revision": state.preview_revision}) + + async def latest_preview(request: Request) -> Response: + """The freshest preview bytes.""" + if state.latest_preview_bytes: + return Response(content=state.latest_preview_bytes, media_type="application/vnd.simplecad.preview") + return JSONResponse({"error": "no preview published yet"}, status_code=404) + + async def push_package(request: Request) -> JSONResponse: + """The kernel hands over a full package, made because an export asked for it.""" + body = await request.body() + if not body: + return JSONResponse({"ok": False, "error": "empty package"}, status_code=400) + state.latest_package_bytes = bytes(body) + state._package_event.set() + return JSONResponse({"ok": True}) + + async def latest_package(request: Request) -> Response: + """The package last made for an export.""" + if state.latest_package_bytes: + return Response( + content=state.latest_package_bytes, + media_type="application/vnd.simplecad.scadpkg", + headers={"Content-Disposition": 'inline; filename="latest.scadpkg"'}, + ) + return JSONResponse({"error": "no package made yet"}, status_code=404) + + async def workspace_list(request: Request) -> JSONResponse: + """The SimpleCAD notebooks of the workspace.""" + current = state.notebook.relative_to(state.workspace).as_posix() if state.notebook else None + rows = [ + { + "name": Path(entry.path).stem, + "path": entry.path, + "entry": True, + "kind": entry.kind, + "id": entry.id, + "uses": list(entry.uses), + "current": entry.path == current, + "error": entry.error, + } + for entry in scan_workspace(state.workspace) + ] + return JSONResponse({"notebooks": rows, "modules": state.modules()}) + + async def workspace_open(request: Request) -> JSONResponse: + """Switch the active notebook (restarts the kernel).""" + try: + message = json.loads(await request.body()) + rel_path = str(message["path"]) + except Exception: + return JSONResponse({"ok": False, "error": "body must be {path}"}, status_code=400) + + try: + target = state.resolve_notebook(rel_path) + except ValueError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + generation = state.reserve_switch() + state.announce_opening(target) + + def run() -> None: + try: + state.open_notebook(rel_path, generation) + except Exception as exc: + hub.broadcast({"type": "status", "error": f"open failed: {exc}"}) + + threading.Thread(target=run, name="notebook-open", daemon=True).start() + return JSONResponse({"ok": True, "opening": rel_path}) + + async def workspace_new(request: Request) -> JSONResponse: + """Write a new notebook from a template, then open it.""" + try: + message = json.loads(await request.body()) + path = create_notebook( + state.workspace, str(message["name"]), str(message.get("kind", "part")) + ) + except (FileExistsError, ValueError) as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + rel_path = path.relative_to(state.workspace).as_posix() + + generation = state.reserve_switch() + state.announce_opening(path) + + def run() -> None: + try: + state.open_notebook(rel_path, generation) + hub.broadcast({"type": "status", "created": rel_path}) + except Exception as exc: + hub.broadcast({"type": "status", "error": f"open failed: {exc}"}) + + threading.Thread(target=run, name="notebook-new", daemon=True).start() + return JSONResponse({"ok": True, "created": rel_path}) + + async def studio_select(request: Request) -> JSONResponse: + """Webui reports the viewport selection; agents read it back.""" + try: + message = json.loads(await request.body()) + except (json.JSONDecodeError, UnicodeDecodeError): + return JSONResponse({"ok": False, "error": "invalid json"}, status_code=400) + state.selection = message if isinstance(message, dict) else {"value": message} + state.emit({"type": "selection", **state.selection}) + return JSONResponse({"ok": True}) + + async def viewport_image(request: Request) -> JSONResponse: + """The webui posts what the viewport shows (PNG) when the selection changes.""" + body = await request.body() + if not body.startswith(b"\x89PNG"): + return JSONResponse({"ok": False, "error": "body must be a PNG"}, status_code=400) + await asyncio.to_thread(state.save_viewport, bytes(body)) + return JSONResponse({"ok": True}) + + async def snapshot(request: Request) -> JSONResponse: + """Agent-facing: the selection (as ql code in the notebook), the cells' state, the viewport image path.""" + return JSONResponse(state.snapshot()) + + async def studio_selected(request: Request) -> JSONResponse: + """Agent-facing read: what does the human have selected right now?""" + return JSONResponse({"selected": state.selection, "notebook": state.notebook.name if state.notebook else None}) + + async def ws_endpoint(websocket) -> None: + await hub.connect(websocket) + await websocket.send_json(state.hello()) + for envelope in state.replay(): + await websocket.send_json(envelope) + try: + while True: + message = await websocket.receive_json() + try: + if isinstance(message, dict) and str(message.get("type", "")).endswith( + (".set", ".request", ".edit", ".commit", ".revert", ".save", ".open", ".close", ".param", ".info") + ): + ack = await asyncio.to_thread(state.dispatch_command, message) + await websocket.send_json(ack) + else: + await websocket.send_json({"type": "echo", "message": message}) + except Exception as exc: # one bad command must not kill the socket + await websocket.send_json({"type": "error", "message": str(exc)[-300:]}) + except WebSocketDisconnect: + hub.disconnect(websocket) + + routes = [ + Route("/api/health", health), + Route("/api/runtime/preview", push_preview, methods=["POST"]), + Route("/api/preview/latest", latest_preview, methods=["GET"]), + Route("/api/runtime/package", push_package, methods=["POST"]), + Route("/api/package/latest", latest_package, methods=["GET"]), + Route("/api/workspace", workspace_list, methods=["GET"]), + Route("/api/workspace/open", workspace_open, methods=["POST"]), + Route("/api/workspace/new", workspace_new, methods=["POST"]), + Route("/api/studio/select", studio_select, methods=["POST"]), + Route("/api/studio/selected", studio_selected, methods=["GET"]), + Route("/api/studio/viewport", viewport_image, methods=["POST"]), + Route("/api/snapshot", snapshot, methods=["GET"]), + WebSocketRoute("/ws", ws_endpoint), + ] + app = Starlette(routes=routes) + if DIST_DIR.is_dir(): + app.mount("/", StaticFiles(directory=DIST_DIR, html=True), name="webui") + return app + + +def serve_workspace( + workspace: Path, + *, + host: str, + port: int, + open_browser: bool, + notebook: Path | None = None, +) -> int: + workspace = workspace.expanduser().resolve() + if not workspace.is_dir(): + print(f"[sca edit] workspace directory does not exist: {workspace}", file=sys.stderr) + return 2 + + state = WorkspaceState(workspace, notebook) + state.port = port + app = create_app(state) + config = uvicorn.Config(app, host=host, port=port, log_level="warning") + server = uvicorn.Server(config) + + original_startup = server.startup + + async def startup(sockets=None): + await original_startup(sockets) + hub = state.hub + hub.attach_loop(asyncio.get_running_loop()) + print(f"[sca edit] workspace {state.workspace}") + + if state.notebook is None: + print("[sca edit] no SimpleCAD notebook found in the workspace", file=sys.stderr) + hub.broadcast({"type": "status", "error": "no SimpleCAD notebook in the workspace; create one"}) + else: + def boot() -> None: + try: + assert state.notebook is not None + state.start_kernel(state.notebook) + except Exception as exc: + print(f"[sca edit] kernel boot failed: {exc}", file=sys.stderr) + hub.broadcast({"type": "status", "error": f"kernel boot failed: {exc}"}) + return + kernel = state.kernel + if kernel is not None: # None: a notebook was opened meanwhile + print(f"[sca edit] marimo kernel ready on {kernel.marimo_url}") + state.watch_kernel() + + threading.Thread(target=boot, name="kernel-boot", daemon=True).start() + + print(f"[sca edit] serving http://{host}:{port}/") + if open_browser: + threading.Thread( + target=webbrowser.open, args=(f"http://127.0.0.1:{port}/",), daemon=True + ).start() + + server.startup = startup + + original_shutdown = server.shutdown + + async def shutdown(sockets=None): + if state.kernel is not None: + state.kernel.stop() + await original_shutdown(sockets=sockets) + + server.shutdown = shutdown + server.run() + return 0 diff --git a/src/simplecadapi/editor/templates.py b/src/simplecadapi/editor/templates.py new file mode 100644 index 00000000..b03aa09d --- /dev/null +++ b/src/simplecadapi/editor/templates.py @@ -0,0 +1,135 @@ +"""New notebooks: a part or an assembly, as the editor's "new" action writes them. + +Both follow the feature tree convention (``docs/skill/.../feature-tree-convention.md``): +a ``# ---- params ----`` cell, one cell per feature, the product last. +The parameters carry ``# ui:`` comments so the editor offers sliders. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from ..runtime.workspace import scan_workspace + +_HEADER = '''# /// script +# requires-python = ">=3.10" +# dependencies = ["simplecadapi"] +# +# [tool.simplecadapi] +# id = "{id}" +# revision = "1.0.0" +# /// +import marimo + +app = marimo.App(width="medium") + +with app.setup: + import simplecadapi as scad +''' + +_PART = ''' + +@app.cell +def _(): + # ---- params ---- + RADIUS = scad.var("radius", 25.0, comment="Radius", unit="mm") # ui: slider(5, 60, step=1, label="Radius") + HEIGHT = scad.var("height", 10.0, comment="Height", unit="mm") # ui: slider(2, 40, step=1, label="Height") + return HEIGHT, RADIUS + + +@app.cell +def _(HEIGHT, RADIUS): + # ---- feature: base (build) ---- + base = scad.make_cylinder_rsolid( + radius=RADIUS, + height=HEIGHT, + bottom_face_center=(0.0, 0.0, 0.0), + axis=(0.0, 0.0, 1.0), + ) + return (base,) + + +@app.cell +def _(base): + {variable} = scad.Part(part_id=scad.notebook_id(), body=base) + return ({variable},) +''' + +_ASSEMBLY = ''' + +@app.cell +def _(): + # ---- feature: parts (add) ---- +{uses} + return {names}, + + +@app.cell +def _({names}): + # ---- feature: layout (add) ---- + _assembly = scad.make_assembly_rassembly( + assembly_id=scad.notebook_id(), name="{title}" + ) +{components} + {variable} = _assembly + return ({variable},) +''' + +_RUNNER = ''' + +if __name__ == "__main__": + app.run() +''' + +_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*") + + +def render_notebook(name: str, kind: str = "part", parts: list[tuple[str, str]] | None = None) -> str: + """The source of a new notebook called *name*. + + An assembly lists the part notebooks it uses as ``(path, variable)``. + """ + if not _NAME.fullmatch(name): + raise ValueError(f"invalid notebook name: {name!r}") + variable = name.replace("-", "_") + header = _HEADER.format(id=name) + if kind == "part": + return header + _PART.format(variable=variable) + _RUNNER + if kind != "assembly": + raise ValueError(f"unknown notebook kind: {kind}") + if not parts: + raise ValueError("an assembly uses part notebooks; create a part first") + names = ", ".join(variable_name for _, variable_name in parts) + uses = "\n".join(f' {variable_name} = scad.use("{path}")' for path, variable_name in parts) + components = "\n".join( + " _assembly = scad.add_component_rassembly(\n" + f' assembly=_assembly, item={variable_name}, component_id="{variable_name}",\n' + " placement=scad.identity_placement_rplacement(),\n" + " )" + for _, variable_name in parts + ) + body = _ASSEMBLY.format( + uses=uses, names=names, title=name, components=components, variable=variable + ) + return header + body + _RUNNER + + +def create_notebook(workspace: Path, name: str, kind: str = "part") -> Path: + """Write a new notebook into *workspace*; refuses to clobber a file.""" + if not _NAME.fullmatch(name): + raise ValueError(f"invalid notebook name: {name!r}") + root = workspace.resolve() + path = root / f"{name}.py" + if path.exists(): + raise FileExistsError(f"notebook already exists: {path}") + parts: list[tuple[str, str]] = [] + if kind == "assembly": + for entry in scan_workspace(root): + if entry.kind == "part": + # Relative to the new notebook, which sits at the workspace root. + parts.append((entry.path, re.sub(r"\W", "_", Path(entry.path).stem))) + if len(parts) == 2: + break + path.write_text(render_notebook(name, kind, parts), encoding="utf-8") + return path diff --git a/src/simplecadapi/editor/ws.py b/src/simplecadapi/editor/ws.py new file mode 100644 index 00000000..fb4b50fb --- /dev/null +++ b/src/simplecadapi/editor/ws.py @@ -0,0 +1,53 @@ +"""WebSocket hub: connection registry and JSON broadcast. + +The runtime (server-side, possibly from a worker thread) pushes messages; +every connected webui receives them. Delivery is fire-and-forget — the +webui is a viewport, not a durable consumer. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from starlette.websockets import WebSocket + + +class WsHub: + def __init__(self) -> None: + self._clients: set[WebSocket] = set() + self._lock = asyncio.Lock() + self._loop: asyncio.AbstractEventLoop | None = None + + def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Bind the running server loop so worker threads can push.""" + self._loop = loop + + async def connect(self, websocket: WebSocket) -> None: + await websocket.accept() + async with self._lock: + self._clients.add(websocket) + + def disconnect(self, websocket: WebSocket) -> None: + self._clients.discard(websocket) + + def broadcast(self, message: dict[str, Any]) -> None: + """Push one JSON message to every connected client (thread-safe).""" + self.broadcast_text(json.dumps(message, ensure_ascii=False)) + + def broadcast_text(self, text: str) -> None: + """Push one pre-serialized JSON frame to every client (thread-safe).""" + loop = self._loop + if loop is None or loop.is_closed(): + return + asyncio.run_coroutine_threadsafe(self._send_all(text), loop) + + async def _send_all(self, text: str) -> None: + async with self._lock: + clients = tuple(self._clients) + for client in clients: + try: + await client.send_text(text) + except Exception: + self._clients.discard(client) diff --git a/src/simplecadapi/example/__init__.py b/src/simplecadapi/example/__init__.py new file mode 100644 index 00000000..7fee03af --- /dev/null +++ b/src/simplecadapi/example/__init__.py @@ -0,0 +1,6 @@ +"""Bundled example workspaces, copied out for a person to open in ``sca edit``.""" + +from .cli import configure +from .runtime import EXAMPLES, copy_example, example_names + +__all__ = ["EXAMPLES", "configure", "copy_example", "example_names"] diff --git a/src/simplecadapi/example/cli.py b/src/simplecadapi/example/cli.py new file mode 100644 index 00000000..d164f250 --- /dev/null +++ b/src/simplecadapi/example/cli.py @@ -0,0 +1,52 @@ +"""The ``sca example`` command (see :mod:`simplecadapi.cli`). + +:: + + sca example # list the examples + sca example u_link [DIR] # copy one out, ready for ``sca edit`` +""" + +from __future__ import annotations + +import argparse +import shlex +from pathlib import Path +from typing import Any + +from .runtime import EXAMPLES, copy_example, example_names + + +def configure(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser( + "example", + help="copy a bundled example workspace out to open in the editor", + description="Copy a bundled example (a directory of SimpleCAD notebooks) into DIR " + "(default: ./), then open it with `sca edit`. Without a name, " + "list the examples. An existing directory is never overwritten.", + ) + parser.add_argument("name", nargs="?", choices=example_names(), help="the example to copy") + parser.add_argument("directory", nargs="?", type=Path, help="where to copy it to") + parser.set_defaults(handler=_run, printer=_print) + + +def _run(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + if args.name is None: + return {"examples": {name: EXAMPLES[name].summary for name in example_names()}}, 0 + target = copy_example(args.name, args.directory) + example = EXAMPLES[args.name] + opened = (target / example.open_with).resolve() + return { + "name": args.name, + "path": str(target), + "files": sorted(path.name for path in target.iterdir()), + "edit": f"sca edit {shlex.quote(str(opened))}", + }, 0 + + +def _print(report: dict[str, Any]) -> None: + if "examples" in report: + for name, summary in report["examples"].items(): + print(f"{name}\t{summary}") + return + print(f"copied {report['name']} to {report['path']} ({len(report['files'])} files)") + print(f"open it: {report['edit']}") diff --git a/src/simplecadapi/example/runtime.py b/src/simplecadapi/example/runtime.py new file mode 100644 index 00000000..990c6015 --- /dev/null +++ b/src/simplecadapi/example/runtime.py @@ -0,0 +1,83 @@ +"""Find and copy the bundled example workspaces. + +An example is a directory of notebooks. The wheel carries the sources under +``resources/`` (see ``pyproject.toml``); a checkout (editable install) reads +them from ``examples/`` at the repository root. +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Example: + name: str + directory: str + """Name of the directory it is copied to by default.""" + open_with: str + """What to hand ``sca edit``, relative to the copy.""" + summary: str + + +EXAMPLES: dict[str, Example] = { + "u_link": Example( + name="u_link", + directory="u_link_motor_mount", + open_with=".", + summary="a motor mount: two parts and the assembly that fixes them together, " + "driven by the shared dimensions in dimensions.py", + ), +} + +_SOURCES = { + "u_link": ( + "u_link.py", + "shell.py", + "u_link_motor_mount.py", + "dimensions.py", + "common.py", + ), +} + + +def example_names() -> list[str]: + return sorted(EXAMPLES) + + +def _source_root(example: Example) -> Path: + bundled = Path(__file__).parent / "resources" / example.directory + if bundled.is_dir(): + return bundled + # An editable install keeps the examples where the repository has them. + return Path(__file__).resolve().parents[3] / "examples" / example.directory + + +def copy_example(name: str, destination: Path | None = None) -> Path: + """Copy example *name* to *destination* (default: its own name in the current directory). + + Refuses to write into an existing directory, so nothing of the person's is + overwritten. Returns the directory made. + """ + if name not in EXAMPLES: + raise ValueError(f"unknown example {name!r}; choose from: {', '.join(example_names())}") + example = EXAMPLES[name] + target = (destination if destination is not None else Path.cwd() / example.directory).expanduser() + target = target.absolute() + if target.exists(): + raise ValueError(f"{target} already exists; pick another directory (nothing was changed)") + source = _source_root(example) + files = [source / relative for relative in _SOURCES[name]] + missing = [file.name for file in files if not file.is_file()] + if missing: + raise ValueError(f"the bundled example is incomplete (missing {', '.join(missing)}); reinstall simplecadapi") + target.mkdir(parents=True) + try: + for file in files: + shutil.copy2(file, target / file.name) + except OSError: + shutil.rmtree(target, ignore_errors=True) + raise + return target diff --git a/src/simplecadapi/inspect/drawing/_pdf.py b/src/simplecadapi/inspect/drawing/_pdf.py index 64475116..254bc3a8 100644 --- a/src/simplecadapi/inspect/drawing/_pdf.py +++ b/src/simplecadapi/inspect/drawing/_pdf.py @@ -14,9 +14,12 @@ from __future__ import annotations from pathlib import Path -from typing import Sequence +from typing import TYPE_CHECKING, Sequence -import pymupdf +if TYPE_CHECKING: + import pymupdf +else: + from ._pymupdf import pymupdf _ITEM_KINDS = {"l": "line", "c": "bezier", "qu": "quad", "re": "rect"} diff --git a/src/simplecadapi/inspect/drawing/_pymupdf.py b/src/simplecadapi/inspect/drawing/_pymupdf.py new file mode 100644 index 00000000..f4a9d49d --- /dev/null +++ b/src/simplecadapi/inspect/drawing/_pymupdf.py @@ -0,0 +1,40 @@ +"""Lazy access to pymupdf for the drawing inspection namespace. + +pymupdf (AGPL-3.0 / Artifex commercial dual license) is an optional dependency +of the ``drawing`` extra. It is needed only by the engineering-drawing family, +never by modeling, export or rendering, so ``import simplecadapi`` must work +without it. Modules bind the proxy exported here under the name ``pymupdf`` +(real module under ``TYPE_CHECKING``); the first attribute access performs the +actual import, and a missing pymupdf surfaces as an ImportError naming the +extra to install. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import pymupdf # noqa: F401 + +_MISSING_PYMUPDF_MESSAGE = ( + "simplecadapi.inspect.drawing requires the optional 'drawing' extra; " + "install it with: pip install 'simplecadapi[drawing]' (provides pymupdf)" +) + + +class _LazyPymupdf: + """Stand-in for the ``pymupdf`` module that imports it on first use.""" + + __slots__ = () + + def __getattr__(self, name: str) -> Any: + try: + import pymupdf + except ImportError as exc: + raise ImportError(_MISSING_PYMUPDF_MESSAGE) from exc + return getattr(pymupdf, name) + + +pymupdf = _LazyPymupdf() + +__all__ = ["pymupdf"] diff --git a/src/simplecadapi/inspect/drawing/_section_layout.py b/src/simplecadapi/inspect/drawing/_section_layout.py index f5c91710..2502e571 100644 --- a/src/simplecadapi/inspect/drawing/_section_layout.py +++ b/src/simplecadapi/inspect/drawing/_section_layout.py @@ -2,7 +2,7 @@ import math -import pymupdf +from ._pymupdf import pymupdf from ._provenance import digest COLOR = (0.0, 0.0, 0.75) diff --git a/src/simplecadapi/inspect/drawing/geometry.py b/src/simplecadapi/inspect/drawing/geometry.py index f47a43ee..d3909e61 100644 --- a/src/simplecadapi/inspect/drawing/geometry.py +++ b/src/simplecadapi/inspect/drawing/geometry.py @@ -5,9 +5,12 @@ import json from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Sequence +from typing import TYPE_CHECKING, Any, Sequence -import pymupdf +if TYPE_CHECKING: + import pymupdf +else: + from ._pymupdf import pymupdf from ._pdf import color_to_hex, item_kind, open_page, parse_color, point_to_display from ._provenance import page_metadata diff --git a/src/simplecadapi/inspect/drawing/render.py b/src/simplecadapi/inspect/drawing/render.py index 44cabf95..a6ba1ff0 100644 --- a/src/simplecadapi/inspect/drawing/render.py +++ b/src/simplecadapi/inspect/drawing/render.py @@ -4,9 +4,12 @@ import json from pathlib import Path -from typing import Any, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Mapping, Sequence -import pymupdf +if TYPE_CHECKING: + import pymupdf +else: + from ._pymupdf import pymupdf from ._pdf import open_page from ._provenance import page_metadata, pixel_transform diff --git a/src/simplecadapi/inspect/drawing/section.py b/src/simplecadapi/inspect/drawing/section.py index 06ef2ca2..6966e776 100644 --- a/src/simplecadapi/inspect/drawing/section.py +++ b/src/simplecadapi/inspect/drawing/section.py @@ -13,9 +13,13 @@ import math from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Mapping, Sequence + +if TYPE_CHECKING: + import pymupdf +else: + from ._pymupdf import pymupdf -import pymupdf import numpy as np from ._provenance import digest, model_metadata, pixel_transform, file_hash diff --git a/src/simplecadapi/inspect/drawing/summary.py b/src/simplecadapi/inspect/drawing/summary.py index 49fda483..5feb1f69 100644 --- a/src/simplecadapi/inspect/drawing/summary.py +++ b/src/simplecadapi/inspect/drawing/summary.py @@ -5,9 +5,12 @@ import json from dataclasses import asdict, dataclass from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any -import pymupdf +if TYPE_CHECKING: + import pymupdf +else: + from ._pymupdf import pymupdf from ._pdf import color_to_hex, item_kind diff --git a/src/simplecadapi/inspect/drawing/text.py b/src/simplecadapi/inspect/drawing/text.py index 773febcc..6fed8dcf 100644 --- a/src/simplecadapi/inspect/drawing/text.py +++ b/src/simplecadapi/inspect/drawing/text.py @@ -5,9 +5,12 @@ import json from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any -import pymupdf +if TYPE_CHECKING: + import pymupdf +else: + from ._pymupdf import pymupdf from ._pdf import box_to_display, open_page from ._provenance import page_metadata diff --git a/src/simplecadapi/ql.py b/src/simplecadapi/ql.py index 28821558..05ee57a2 100644 --- a/src/simplecadapi/ql.py +++ b/src/simplecadapi/ql.py @@ -1307,12 +1307,11 @@ def _boundary_items(scope: Any, target_kind: str) -> List[Any]: for child in scope.get_children() if child.__class__.__name__ == "Vertex" ] - if target_kind == "vertex" and hasattr(scope, "get_edges"): - vertices = [] - for edge in _boundary_items(scope, "edge"): - vertices.extend(_boundary_items(edge, "vertex")) - return _dedupe_items(vertices) - return [] + # Any scope with edges to walk: a wire, a face, a solid. + vertices = [] + for edge in _boundary_items(scope, "edge"): + vertices.extend(_boundary_items(edge, "vertex")) + return _dedupe_items(vertices) if target_kind == "solid": if cls_name == "Compound" and hasattr(scope, "get_solids"): @@ -1712,11 +1711,17 @@ def _resolve_scope_items(scope: Any, target_kind: str) -> List[Any]: ] if target_kind == "vertex": if hasattr(scope, "get_children"): - return [ + vertices = [ child for child in scope.get_children() if child.__class__.__name__ == "Vertex" ] + if vertices: + return vertices + # A wire, face or solid has vertices only through its edges. + vertices = _boundary_items(scope, "vertex") + if vertices: + return vertices if isinstance(scope, Iterable) and not isinstance(scope, (str, bytes, dict)): return list(scope) raise TypeError(f"cannot resolve QL selector scope for target_kind={target_kind}") diff --git a/src/simplecadapi/runtime/_marimo.py b/src/simplecadapi/runtime/_marimo.py index 971161f8..2760aea5 100644 --- a/src/simplecadapi/runtime/_marimo.py +++ b/src/simplecadapi/runtime/_marimo.py @@ -78,7 +78,7 @@ def load_notebook(path: Path, *, cell_prefix: str) -> App | None: marimo compiles each cell under a file name made of its cell id, and registers the cell's source in :mod:`linecache` under that name. Cell ids come from a fixed seed, so every notebook has the same ones: two - notebooks loaded into one process (a ``use``\ d child, or a headless + notebooks loaded into one process (a child run by ``use``, or a headless run inside the editor kernel) would show each other's source in tracebacks, source positions and restored functions. A prefix per notebook keeps them apart, as marimo's own prefix for nested apps does. diff --git a/src/simplecadapi/runtime/cache.py b/src/simplecadapi/runtime/cache.py new file mode 100644 index 00000000..8cb3f083 --- /dev/null +++ b/src/simplecadapi/runtime/cache.py @@ -0,0 +1,239 @@ +"""The cell cache: marimo's, kept per cell. + +marimo keys a cell's cache entry by the cell's code and the values it reads. +It cannot see what a SimpleCAD notebook also depends on — local modules, child +notebooks, project input files — so an entry is also stored under a digest of +those files (:mod:`.deps`). The digest used to be one for the whole notebook: +any change to any such file discarded every cell's entries. It is now one +per cell, over only the files that cell depends on (:func:`.deps.cell_module_files`), +so editing ``dimensions.py`` recomputes the cells that read the dimensions and +restores the rest, and a cell downstream of a recomputed one is restored again +if the values it reads came out the same. + +The same cache serves a headless run (:func:`~.runner.run_notebook`, ``sca +run``) and the editor kernel (:mod:`.executor`), so what one computed the +other restores. + +Layout: ``/cache//`` holds the entries of every cell whose +digest it is. :func:`prune` removes the directories the last two runs did not +use, so going back to a previous value is still instant. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from ._marimo import ( + CachedLifecycle, + CellId_t, + CellImpl, + DirectedGraph, + FileStore, + LazyLoader, + LazyStore, + MutableGlobals, + RunResult, + Skip, +) +from .config import NotebookConfig +from .deps import ( + CellSource, + ancestors, + cell_digest, + cell_module_files, + read_dependencies, + recorded_dependents, + state_dir, +) + +_CELLS = "cells" +"""The cell cache's loader name, fixed so a cache directory can be renamed: +its entries reference their values by paths starting with this name.""" + +_RUNS = "_runs.json" + + +def cache_root(config: NotebookConfig) -> Path: + """The notebook's cell caches: one directory per dependency digest.""" + + return state_dir(config) / "cache" + + +class CellDigests: + """The digest of each cell of a notebook, from what it depends on. + + *saved* are values an editor applies to variables after the cell that + defines them ran (:mod:`.params`). They are not in any cell's code, and + marimo keys a cell that reads an object by the code of the cells that made + it, so every cell downstream of a saved value carries it in its digest. + With *kernel*, cells that depend on what a run records (child notebooks) + have no digest: only a headless run records them, so the kernel runs those + cells live (the child notebooks themselves are cached). + """ + + def __init__( + self, + config: NotebookConfig, + cells: list[CellImpl], + setup: CellImpl | None, + *, + saved: Mapping[str, Any] | None = None, + kernel: bool = False, + ) -> None: + self.config = config + self.setup_id = setup.cell_id if setup is not None else None + sources = [ + CellSource(cell.cell_id, cell.code, frozenset(cell.refs), frozenset(cell.defs)) + for cell in cells + if cell.cell_id != self.setup_id + ] + self._files = cell_module_files( + config, + setup.code if setup is not None else None, + sources, + read_dependencies(config), + ) + # A cell that runs a child notebook, or reads what one makes, depends + # on what the last run recorded; its entries are kept apart so they + # can follow the record. + self._uses = recorded_dependents(sources) + self._kernel = kernel + self._saved_tag: dict[str, str] = {} + if saved: + reached = ancestors(sources) + own = {source.key: source.defs & saved.keys() for source in sources} + for source in sources: + names = set(own[source.key]).union(*(own[key] for key in reached[source.key])) + if names: + payload = json.dumps({name: saved[name] for name in sorted(names)}, sort_keys=True) + self._saved_tag[source.key] = "p" + hashlib.sha256(payload.encode()).hexdigest()[:12] + + def digest(self, cell_id: CellId_t) -> str | None: + """The digest for *cell_id*, ``None`` for a cell that is not cached.""" + + files = self._files.get(cell_id) + if files is None: + return None # the setup cell + if self._kernel and cell_id in self._uses: + return None + tag = ("use" if cell_id in self._uses else "") + self._saved_tag.get(cell_id, "") + return cell_digest(self.config, files, tag=tag) + + +class CellCache(CachedLifecycle): + """marimo's cell cache, a store per digest, reporting its hits.""" + + def __init__( + self, + graph: DirectedGraph, + root: Path, + digest_for: Callable[[CellImpl], str | None], + ) -> None: + # CachedLifecycle only takes a loader kind and stores in marimo's + # default place; its loader is replaced by one stored under *root* + # (the pickle kind is merely the cheapest to build and discard). + super().__init__(graph, loader="pickle") + self._root = root + self._digest_for = digest_for + self._stores: dict[str, LazyLoader] = {} + self._cell_store: dict[CellId_t, LazyLoader] = {} + self._active: dict[CellId_t, LazyLoader] = {} + self.restored: set[CellId_t] = set() + self._used: dict[str, None] = {} + + @property + def used(self) -> set[str]: + """Digests of the cells this lifecycle has run or restored.""" + + return set(self._used) + + def recent(self, limit: int) -> set[str]: + """The *limit* digests used most recently.""" + + return set(list(self._used)[-limit:]) + + def _store(self, digest: str) -> LazyLoader: + if digest not in self._stores: + self._stores[digest] = LazyLoader( + name=_CELLS, store=LazyStore(FileStore(str(self._root / digest))) + ) + return self._stores[digest] + + def setup(self, cell: CellImpl, glbls: MutableGlobals) -> Skip | None: + digest = self._digest_for(cell) + if digest is None: + return None # an uncached cell runs live + loader = self._store(digest) + self._used.pop(digest, None) + self._used[digest] = None # most recently used last + self._loader = loader + self._cell_store[cell.cell_id] = loader + self._active[cell.cell_id] = loader + decision = super().setup(cell, glbls) + if isinstance(decision, Skip): + self.restored.add(cell.cell_id) + return decision + + def teardown(self, cell: CellImpl, glbls: MutableGlobals, run_result: RunResult) -> None: + loader = self._active.pop(cell.cell_id, None) + if loader is None: + return + self._loader = loader + super().teardown(cell, glbls, run_result) + + def _invalidate(self, cell_id: CellId_t) -> None: + # The producer's entry lives in the store of its own digest. + loader = self._cell_store.get(cell_id) + if loader is None: + super()._invalidate(cell_id) + return + current, self._loader = self._loader, loader + try: + super()._invalidate(cell_id) + finally: + self._loader = current + + def move(self, old: str, new: str) -> None: + """Rename the store of digest *old* to *new* (a cell whose dependencies became known).""" + + source = self._root / old + target = self._root / new + self._stores.pop(old, None) + if source != target and source.is_dir(): + shutil.rmtree(target, ignore_errors=True) + source.rename(target) + + def flush(self) -> None: + """Wait until every entry is written (they are pickled on background threads).""" + + for loader in self._stores.values(): + loader.flush() + + +def prune(root: Path, used: set[str]) -> None: + """Keep the stores this run used and the last run's; remove the rest.""" + + if not root.is_dir(): + return + runs = root / _RUNS + try: + previous = set(json.loads(runs.read_text(encoding="utf-8"))) + except (OSError, ValueError): + previous = set() + keep = used | previous + for entry in root.iterdir(): + if entry.name == _RUNS or entry.name in keep: + continue + if entry.is_dir(): + shutil.rmtree(entry, ignore_errors=True) + else: + entry.unlink(missing_ok=True) + runs.write_text(json.dumps(sorted(used)), encoding="utf-8") + + +__all__ = ["CellCache", "CellDigests", "cache_root", "prune"] diff --git a/src/simplecadapi/runtime/cells.py b/src/simplecadapi/runtime/cells.py index b6fced29..36b7bf53 100644 --- a/src/simplecadapi/runtime/cells.py +++ b/src/simplecadapi/runtime/cells.py @@ -85,6 +85,14 @@ def __init__(self, path: Path, cells: Iterable[CellImpl]) -> None: self._spans.setdefault(filename, []).append(span) self._placements: dict[str, CellPlacement] | None = None + def __contains__(self, cell_id: object) -> bool: + """Whether *cell_id* is a cell of the notebook. + + The editor kernel also runs cells that are not (its scratch cell, + for one); they are not recorded. + """ + return cell_id in self._keys + def key(self, cell_id: CellId_t) -> str: """Return the key of the cell marimo calls *cell_id*.""" diff --git a/src/simplecadapi/runtime/cli.py b/src/simplecadapi/runtime/cli.py index 25409b32..8765b6d7 100644 --- a/src/simplecadapi/runtime/cli.py +++ b/src/simplecadapi/runtime/cli.py @@ -7,6 +7,8 @@ sca run link_bar.py --id crank # run one member of a part family sca run bracket.py --out bracket.scadpkg # also write the product package sca run bracket.py --no-cache # ignore the cell cache + sca ls [DIR] # the notebooks under DIR + sca params bracket.py # features and parameters The report is one JSON object on stdout. """ @@ -21,6 +23,7 @@ from ..product.packages import build_product_package, encode_product_package from .runner import run_notebook +from .workspace import describe_notebook, scan_modules, scan_workspace def configure(subparsers: argparse._SubParsersAction) -> None: @@ -51,6 +54,23 @@ def configure(subparsers: argparse._SubParsersAction) -> None: parser.add_argument("--out", type=Path, help="write the product package here") parser.set_defaults(handler=_execute, printer=_print_report) + listing = subparsers.add_parser( + "ls", + help="list the SimpleCAD notebooks under a directory", + description="List the notebooks under DIR whose script header has " + "[tool.simplecadapi]: id, kind, the notebooks each uses, and the local " + "modules (shared dimensions, helpers) their cells import.", + ) + listing.add_argument("directory", type=Path, nargs="?", default=Path.cwd()) + listing.set_defaults(handler=_list, printer=_print_report) + + describe = subparsers.add_parser( + "params", + help="list a notebook's features and parameters (without running it)", + ) + describe.add_argument("notebook", type=Path, help="marimo notebook (.py)") + describe.set_defaults(handler=_describe, printer=_print_report) + def parse_overrides(items: Sequence[str]) -> dict[str, Any]: """Parse ``NAME=VALUE`` items; ``VALUE`` is a literal or else a string.""" @@ -97,6 +117,25 @@ def _execute(args: argparse.Namespace) -> tuple[dict[str, Any], int]: return report, 0 +def _list(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + root = args.directory.expanduser().resolve() + if not root.is_dir(): + raise ValueError(f"{args.directory}: not a directory") + entries = scan_workspace(root) + return { + "root": str(root), + "notebooks": [entry.to_dict() for entry in entries], + "modules": [module.to_dict() for module in scan_modules(root, entries)], + }, 0 + + +def _describe(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + path = args.notebook.expanduser().resolve() + if not path.is_file(): + raise ValueError(f"{args.notebook}: no such file") + return {"notebook": str(path), **describe_notebook(path)}, 0 + + def _print_report(report: dict[str, Any]) -> None: print(json.dumps(report, sort_keys=True)) diff --git a/src/simplecadapi/runtime/deps.py b/src/simplecadapi/runtime/deps.py index 26aa842e..fdc2d547 100644 --- a/src/simplecadapi/runtime/deps.py +++ b/src/simplecadapi/runtime/deps.py @@ -29,9 +29,11 @@ import os import sys import threading -from collections.abc import Iterable, Iterator, Mapping +import time +from collections.abc import Iterable, Iterator, Mapping, Sequence from contextlib import contextmanager from contextvars import ContextVar +from dataclasses import dataclass from pathlib import Path from types import ModuleType @@ -130,11 +132,19 @@ def local_modules(config: NotebookConfig, codes: Iterable[str]) -> dict[str, Pat and marimo's output directory never count. """ - root = config.directory - output = notebook_output_dir(config.path) found: dict[str, Path] = {} # (source, package its relative imports resolve against) - pending: list[tuple[str, str]] = [(code, "") for code in codes] + _follow_imports(config, [(code, "") for code in codes], found) + return found + + +def _follow_imports( + config: NotebookConfig, pending: list[tuple[str, str]], found: dict[str, Path] +) -> None: + """Add to *found* every local module the imports in *pending* sources reach.""" + + root = config.directory + output = notebook_output_dir(config.path) while pending: source, package = pending.pop() for imported in _imported_names(source, package): @@ -148,7 +158,6 @@ def local_modules(config: NotebookConfig, codes: Iterable[str]) -> dict[str, Pat continue # still a dependency; its imports are unknown is_package = path.name == "__init__.py" pending.append((text, name if is_package else name.rpartition(".")[0])) - return found _loaded: dict[str, tuple[Path, str | None]] = {} @@ -176,7 +185,7 @@ def refresh_local_modules(modules: Mapping[str, Path]) -> None: with _loaded_lock: for name, path in modules.items(): - state = (path, _file_digest(path)) + state = (path, file_digest(path)) module = sys.modules.get(name) if module is not None and _loaded.get(name) != state and _replaceable(name, module): for loaded in [item for item in sys.modules if item == name or item.startswith(name + ".")]: @@ -263,6 +272,234 @@ def _module_files(root: Path, name: str) -> list[tuple[str, Path]]: return files +# --------------------------------------------------------------------------- +# What each cell depends on +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CellSource: + """A cell as the dependency analysis needs it.""" + + key: str + code: str + refs: frozenset[str] + """The names the cell reads (marimo's ``cell.refs``).""" + defs: frozenset[str] + """The names the cell defines (marimo's ``cell.defs``).""" + + +def cell_module_files( + config: NotebookConfig, + setup: str | None, + cells: Sequence[CellSource], + recorded: Iterable[Path] = (), +) -> dict[str, frozenset[Path]]: + """Return, per cell key, the local files whose content its result depends on. + + A cell depends on a local module when it reads a name that an ``import`` + in the notebook (the setup cell, or any cell) bound to that module, or + calls a function the notebook defines (an ``@app.function`` cell, a + ``def`` in the setup cell) that reads such a name; and on every local + module that module imports in turn. It also depends on whatever the cells + it reads from depend on: marimo keys a cell that reads an object (a solid, + a part) by the code of the cells that made it, not by its value, so it + cannot see a module that changed upstream. A cell that calls ``use`` also + depends on everything the last run recorded (the child notebooks, which + only a run can name, and what they depend on): *recorded*. + + Reading a name never imported (a standard-library or installed module) adds + nothing: those are pinned by the software versions in the digest. + """ + + root = config.directory + sources = ([setup] if setup else []) + [cell.code for cell in cells] + bound: dict[str, set[tuple[str, Path]]] = {} + for source in sources: + for name, modules in _import_bindings(root, source).items(): + bound.setdefault(name, set()).update(modules) + star = bound.pop("*", set()) + + closures: dict[tuple[str, Path], frozenset[Path]] = {} + + def closure(module: tuple[str, Path]) -> frozenset[Path]: + if module not in closures: + name, path = module + found: dict[str, Path] = {name: path} + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + text = "" + package = name if path.name == "__init__.py" else name.rpartition(".")[0] + _follow_imports(config, [(text, package)], found) + closures[module] = frozenset(found.values()) + return closures[module] + + # Names each function or class of the notebook reads. + reads: dict[str, set[str]] = {} + for source in sources: + for name, loaded in _definitions(source).items(): + reads.setdefault(name, set()).update(loaded) + + def reach(name: str, seen: set[str]) -> set[str]: + """The imported names the notebook function *name* reads, through other functions too.""" + if name in seen: + return set() + seen.add(name) + imported: set[str] = set() + for read in reads.get(name, ()): + if read in bound: + imported.add(read) + elif read in reads: + imported |= reach(read, seen) + return imported + + star_files = frozenset(path for module in star for path in closure(module)) + recorded_files = frozenset(recorded) + result: dict[str, frozenset[Path]] = {} + for cell in cells: + names = set(cell.refs) | (cell.defs & bound.keys()) | _loaded_names(cell.code) + needed: set[str] = set() + for name in names: + if name in bound: + needed.add(name) + if name in reads: + needed |= reach(name, set()) + for name in cell.defs & reads.keys(): + needed |= reach(name, set()) + files: set[Path] = set(star_files) + for name in needed: + for module in bound.get(name, ()): + files |= closure(module) + if calls_use(cell.code): + files |= recorded_files + result[cell.key] = frozenset(files) + + # marimo keys a cell that reads an object (a solid, a part) by the code of + # the cells that made it, not by its value: a file an ancestor depends on + # is one this cell depends on. + reached = ancestors(cells) + return { + cell.key: frozenset(result[cell.key].union(*(result[key] for key in reached[cell.key]))) + for cell in cells + } + + +def ancestors(cells: Sequence[CellSource]) -> dict[str, frozenset[str]]: + """Per cell key, the keys of every cell it reads from, directly or not.""" + + producers = {name: cell.key for cell in cells for name in cell.defs} + parents = { + cell.key: {producers[name] for name in cell.refs if name in producers} - {cell.key} + for cell in cells + } + found: dict[str, frozenset[str]] = {} + + def of(key: str, stack: frozenset[str] = frozenset()) -> frozenset[str]: + if key not in found: + reached: set[str] = set() + for parent in parents[key] - stack - {key}: + reached.add(parent) + reached |= of(parent, stack | {key}) + found[key] = frozenset(reached) + return found[key] + + return {cell.key: of(cell.key) for cell in cells} + + +def recorded_dependents(cells: Sequence[CellSource]) -> frozenset[str]: + """The keys of the cells that depend on the files a run records. + + Those are the cells that call ``use`` and every cell that reads, however + indirectly, what such a cell makes (see :func:`cell_module_files`). + """ + + direct = {cell.key for cell in cells if calls_use(cell.code)} + reached = ancestors(cells) + return frozenset(key for key in reached if key in direct or reached[key] & direct) + + +def _import_bindings(root: Path, source: str) -> dict[str, set[tuple[str, Path]]]: + """The local modules each name an ``import`` in *source* binds comes from. + + ``from m import a`` binds ``a`` to ``m`` (and to ``m.a`` when that is a + submodule); ``import m.n`` binds ``m``; a star import binds an unknown set + of names, recorded under ``"*"``. + """ + + try: + tree = ast.parse(source) + except (SyntaxError, ValueError): + return {} + bindings: dict[str, set[tuple[str, Path]]] = {} + + def bind(name: str, modules: Iterable[tuple[str, Path]]) -> None: + found = set(modules) + if found: + bindings.setdefault(name, set()).update(found) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + bind(alias.asname or alias.name.split(".")[0], _module_files(root, alias.name)) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module, node.level, "") + if base is None: + continue + for alias in node.names: + modules = list(_module_files(root, base)) + if alias.name == "*": + bind("*", modules) + else: + modules += _module_files(root, f"{base}.{alias.name}") + bind(alias.asname or alias.name, modules) + return bindings + + +def _definitions(source: str) -> dict[str, set[str]]: + """The names each top-level function and class in *source* reads.""" + + try: + tree = ast.parse(source) + except (SyntaxError, ValueError): + return {} + return { + node.name: _loaded_names_of(node) + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } + + +def _loaded_names(source: str) -> set[str]: + try: + return _loaded_names_of(ast.parse(source)) + except (SyntaxError, ValueError): + return set() + + +def _loaded_names_of(node: ast.AST) -> set[str]: + return { + child.id + for child in ast.walk(node) + if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load) + } + + +def calls_use(source: str) -> bool: + try: + tree = ast.parse(source) + except (SyntaxError, ValueError): + return True # cannot tell: depend on everything + for node in ast.walk(tree): + if isinstance(node, ast.Call): + function = node.func + if (isinstance(function, ast.Name) and function.id == "use") or ( + isinstance(function, ast.Attribute) and function.attr == "use" + ): + return True + return False + + # --------------------------------------------------------------------------- # The digest # --------------------------------------------------------------------------- @@ -272,14 +509,15 @@ def input_files(config: NotebookConfig) -> frozenset[Path]: return frozenset((config.directory / item.path).resolve() for item in config.inputs) -def dependency_digest(config: NotebookConfig, files: Iterable[Path]) -> str: +def dependency_digest(config: NotebookConfig, files: Iterable[Path], *, tag: str = "") -> str: """Return *D* for *files* and the inputs as they are now: 16 hex digits. - A missing file digests as missing, so deleting one invalidates too. + A missing file digests as missing, so deleting one invalidates too. A + *tag* keeps digests of different kinds of cell apart. """ contents = { - _display(config, path): _file_digest(path) + _display(config, path): file_digest(path) for path in set(files) | input_files(config) } payload = { @@ -288,14 +526,51 @@ def dependency_digest(config: NotebookConfig, files: Iterable[Path]) -> str: "marimo": marimo.__version__, "files": contents, } + if tag: + payload["tag"] = tag return hashlib.sha256(canonical_bytes(payload)).hexdigest()[:16] -def _file_digest(path: Path) -> str | None: +def cell_digest(config: NotebookConfig, files: Iterable[Path], *, tag: str = "") -> str: + """The digest a cell's cache entries are stored under: 16 hex digits. + + Like :func:`dependency_digest`, but over the files one cell depends on (see + :func:`cell_module_files`); the project inputs are every cell's. + """ + + return dependency_digest(config, files, tag=tag) + + +_digests: dict[Path, tuple[int, int, str | None]] = {} +_digests_lock = threading.Lock() + + +def file_digest(path: Path) -> str | None: + """The content digest of *path*; ``None`` if it cannot be read. + + Remembered per (mtime, size): the editor kernel asks for the digests of + the same few files for every cell it runs. + """ + try: - return hashlib.sha256(path.read_bytes()).hexdigest() + stat = path.stat() except OSError: return None + signature = (stat.st_mtime_ns, stat.st_size) + # A file touched a moment ago may be edited again within the clock's + # resolution without its (mtime, size) changing: read those every time. + settled = time.time_ns() - stat.st_mtime_ns > 2_000_000_000 + with _digests_lock: + known = _digests.get(path) + if settled and known is not None and known[:2] == signature: + return known[2] + try: + digest: str | None = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + digest = None + with _digests_lock: + _digests[path] = (*signature, digest) + return digest def _display(config: NotebookConfig, path: Path) -> str: @@ -308,7 +583,14 @@ def _display(config: NotebookConfig, path: Path) -> str: __all__ = [ + "CellSource", + "ancestors", + "calls_use", + "cell_digest", + "cell_module_files", "dependency_digest", + "file_digest", + "recorded_dependents", "deps_file", "input_files", "local_modules", diff --git a/src/simplecadapi/runtime/executor.py b/src/simplecadapi/runtime/executor.py index 9a3f8b5f..d3e3900d 100644 --- a/src/simplecadapi/runtime/executor.py +++ b/src/simplecadapi/runtime/executor.py @@ -25,10 +25,12 @@ from __future__ import annotations +import os from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass +from pathlib import Path from typing import Any from ..recording.graph import GraphSession @@ -37,12 +39,16 @@ CellImpl, DefaultExecutor, Executor, + MarimoRescheduleError, MarimoRuntimeException, MutableGlobals, + RunResult, safe_get_context, ) +from .cache import CellCache, CellDigests, cache_root, prune from .cells import NotebookCells, bound_requirements from .config import NotebookConfig, read_notebook_config +from .params import apply_saved_params, read_saved_params class UnboundRequirementError(RuntimeError): @@ -59,6 +65,8 @@ class NotebookScope: config: NotebookConfig cells: NotebookCells + graph: Any = None + """marimo's dataflow graph of the notebook, known when marimo itself runs it.""" _running: ContextVar[NotebookScope | None] = ContextVar( @@ -88,7 +96,9 @@ class SimpleCadExecutor: Pass *scope* when the caller knows the notebook (a headless run, which may be nested inside another notebook's kernel). Without it the executor looks the notebook up from marimo's runtime context for each - cell, which is what the editor kernel needs. + cell, which is what the editor kernel needs. The kernel's cells go + through the same cell cache a headless run uses (:mod:`.cache`); a headless + run has its own lifecycle for that. """ name = "simplecadapi" @@ -99,17 +109,141 @@ def __init__(self, scope: NotebookScope | None = None) -> None: def execute_cell(self, cell: CellImpl, glbls: MutableGlobals) -> Any: scope = self._scope or _context_scope() - if scope is None: - return self._delegate.execute_cell(cell, glbls) - with _cell_recording(scope, cell, glbls): + if scope is None or cell.cell_id not in scope.cells: return self._delegate.execute_cell(cell, glbls) + cache = self._cache_for(scope, cell) + restored = _restore(cache, cell, glbls) + if restored is not _MISS: + return restored + cache = cache if restored is _MISS else None + try: + with _cell_recording(scope, cell, glbls): + result = self._delegate.execute_cell(cell, glbls) + self._apply_saved(scope, cell, glbls) + except BaseException as error: + _store(cache, cell, glbls, None, error) + raise + _store(cache, cell, glbls, result, None) + return result async def execute_cell_async(self, cell: CellImpl, glbls: MutableGlobals) -> Any: scope = self._scope or _context_scope() - if scope is None: - return await self._delegate.execute_cell_async(cell, glbls) - with _cell_recording(scope, cell, glbls): + if scope is None or cell.cell_id not in scope.cells: return await self._delegate.execute_cell_async(cell, glbls) + cache = self._cache_for(scope, cell) + restored = _restore(cache, cell, glbls) + if restored is not _MISS: + return restored + try: + with _cell_recording(scope, cell, glbls): + result = await self._delegate.execute_cell_async(cell, glbls) + self._apply_saved(scope, cell, glbls) + except BaseException as error: + _store(cache, cell, glbls, None, error) + raise + _store(cache, cell, glbls, result, None) + return result + + def _apply_saved( + self, scope: NotebookScope, cell: CellImpl, glbls: MutableGlobals + ) -> None: + # Only the editor kernel (no scope given) takes the values an editor + # saved; a headless run takes the overrides it is passed (see params). + if self._scope is None: + apply_saved_params(scope.config, set(cell.defs), glbls) + + def _cache_for(self, scope: NotebookScope, cell: CellImpl) -> CellCache | None: + """The cell cache of the editor kernel's notebook; ``None`` where there is none.""" + + if self._scope is not None or scope.graph is None or os.environ.get("SCA_NO_KERNEL_CACHE"): + return None + return _kernel_cache(scope, cell) + + +_MISS = object() +"""Returned by :func:`_restore` when the cell must run.""" + + +def _restore(cache: CellCache | None, cell: CellImpl, glbls: MutableGlobals) -> Any: + """Restore *cell* from the cache: its output, or ``_MISS`` if it has to run.""" + + if cache is None: + return _MISS + try: + skip = cache.setup(cell, glbls) + except MarimoRescheduleError: + # A value the cache could not restore: run the cell live, store nothing. + cache._active.pop(cell.cell_id, None) + return _MISS + if skip is None: + return _MISS + return skip.result.output + + +def _store( + cache: CellCache | None, + cell: CellImpl, + glbls: MutableGlobals, + output: Any, + error: BaseException | None, +) -> None: + if cache is not None: + cache.teardown(cell, glbls, RunResult(output=output, exception=error)) + + +@dataclass +class _KernelCache: + graph: Any + signature: tuple + digests: CellDigests + lifecycle: CellCache + + +_kernel_caches: dict[Path, _KernelCache] = {} +_KEEP_DIGESTS = 64 +"""How many of the most recently used digests the kernel keeps on disk.""" + + +def _kernel_cache(scope: NotebookScope, cell: CellImpl) -> CellCache | None: + """The cell cache for the notebook marimo is running; rebuilt when its cells change.""" + + if cell.cell_id == "setup": + return None # like marimo, never cache the setup cell + config = scope.config + graph = scope.graph + cells = list(graph.cells.values()) + saved = read_saved_params(config) + signature = ( + tuple((item.cell_id, item.code) for item in cells), + tuple(sorted(saved.items())), + ) + entry = _kernel_caches.get(config.path) + if entry is not None and entry.graph is graph and entry.signature == signature: + return entry.lifecycle + digests = CellDigests(config, cells, graph.cells.get("setup"), saved=saved, kernel=True) + if entry is not None and entry.graph is graph: + entry.signature, entry.digests = signature, digests + return entry.lifecycle + entry = _KernelCache(graph=graph, signature=signature, digests=digests, lifecycle=None) # type: ignore[arg-type] + entry.lifecycle = CellCache( + graph, cache_root(config), lambda item: entry.digests.digest(item.cell_id) + ) + _kernel_caches[config.path] = entry + return entry.lifecycle + + +def finish_kernel_cache(config: NotebookConfig) -> None: + """Write out the kernel's pending cache entries and drop the old ones. + + The cache pickles values on background threads, and projecting the product + modifies it: flush first. Called by :func:`.live.live_package`. + """ + + entry = _kernel_caches.get(config.path) + if entry is None: + return + entry.lifecycle.flush() + prune(cache_root(config), entry.lifecycle.recent(_KEEP_DIGESTS)) def executor_factory() -> Executor: @@ -127,7 +261,9 @@ def _context_scope() -> NotebookScope | None: config = read_notebook_config(context.filename) if config is None: return None - return NotebookScope(config, NotebookCells(config.path, context.graph.cells.values())) + return NotebookScope( + config, NotebookCells(config.path, context.graph.cells.values()), graph=context.graph + ) @contextmanager @@ -178,5 +314,6 @@ def _check_bound(session: GraphSession, cell: CellImpl, glbls: MutableGlobals) - "SimpleCadExecutor", "UnboundRequirementError", "executor_factory", + "finish_kernel_cache", "notebook_id", ] diff --git a/src/simplecadapi/runtime/live.py b/src/simplecadapi/runtime/live.py new file mode 100644 index 00000000..4e323688 --- /dev/null +++ b/src/simplecadapi/runtime/live.py @@ -0,0 +1,171 @@ +"""The product of a notebook running in an editor's marimo kernel. + +Headless, :func:`~.runner.run_notebook` runs the cells and projects the +product. In the editor marimo runs them, reactively, and keeps their +values in the kernel; :func:`live_package` projects the product from +those. Same executor, same projection, same package: a notebook gives the +same product wherever it runs. + +The editor's bridge calls :func:`publish_live` from a snippet it executes +in the kernel once the cells have settled. +""" + +from __future__ import annotations + +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any + +from ..artifacts.assembly_io import attached_definition +from ..product.packages import build_product_package, encode_product_package +from ..scene.preview import PreviewScene, compile_preview, encode_preview, entity_info +from ._marimo import safe_get_context +from .cells import NotebookCells +from .config import read_notebook_config +from .executor import finish_kernel_cache +from .projection import find_product, project_product + +PACKAGE_MEDIA_TYPE = "application/vnd.simplecad.scadpkg" +PREVIEW_MEDIA_TYPE = "application/vnd.simplecad.preview" + + +class LiveUnavailableError(RuntimeError): + """No SimpleCAD notebook is running in this kernel.""" + + +@dataclass(frozen=True) +class LivePackage: + data: bytes + """The ``.scadpkg``.""" + definition_id: str + definition_kind: str + content_hash: str + + +def live_package(*, validate: bool = False) -> LivePackage: + """Project the running notebook's product and package it. + + Call from inside the marimo kernel. Raises :class:`LiveUnavailableError` + outside one, and :class:`~.projection.ProductNotFoundError` while no + cell has produced the product yet (or a cell failed). ``validate`` + runs the package's full contract validation; the live path skips it. + """ + + context = safe_get_context() + if context is None or context.filename is None: + raise LiveUnavailableError("not running inside a marimo kernel") + config = read_notebook_config(context.filename) + if config is None: + raise LiveUnavailableError( + f"{context.filename}: not a SimpleCAD notebook (no [tool.simplecadapi])" + ) + finish_kernel_cache(config) # the product is modified below: let the cache finish pickling it + glbls = context.globals + ordered = list(context.graph.cells.values()) + cells = NotebookCells(config.path, ordered) + values = { + name: glbls[name] for cell in ordered for name in cell.defs if name in glbls + } + read = frozenset().union(*(cell.refs for cell in ordered)) + product = project_product( + find_product(config, values, read), + config=config, + cells=cells, + values=values.values(), + ) + definition = attached_definition(product) + # project_product attaches one to every product it returns. + assert definition is not None + package = build_product_package(definition, validate=validate) + return LivePackage( + data=encode_product_package(package), + definition_id=definition.definition_id, + definition_kind=definition.definition_kind, + content_hash=definition.content_hash, + ) + + +_last_preview: PreviewScene | None = None +"""The scene last previewed in this kernel: what :func:`preview_entity_info` answers about.""" + + +def live_preview() -> tuple[bytes, PreviewScene]: + """The preview of the running notebook's product, from the values in the kernel. + + Unlike :func:`live_package` this does not project the product onto a + definition: it looks at the solids as they are, which takes a fraction of the + time (see :mod:`simplecadapi.scene.preview`). + """ + + global _last_preview + context = safe_get_context() + if context is None or context.filename is None: + raise LiveUnavailableError("not running inside a marimo kernel") + config = read_notebook_config(context.filename) + if config is None: + raise LiveUnavailableError( + f"{context.filename}: not a SimpleCAD notebook (no [tool.simplecadapi])" + ) + glbls = context.globals + ordered = list(context.graph.cells.values()) + values = {name: glbls[name] for cell in ordered for name in cell.defs if name in glbls} + read = frozenset().union(*(cell.refs for cell in ordered)) + scene = compile_preview(find_product(config, values, read)) + _last_preview = scene + return encode_preview(scene), scene + + +def publish_preview(url: str, *, step: str = "", timeout: float = 60.0) -> dict[str, Any]: + """:func:`live_preview`, then POST the bytes to *url*; returns a short summary.""" + + data, scene = live_preview() + request = urllib.request.Request( + url, + data=data, + headers={"Content-Type": PREVIEW_MEDIA_TYPE, "X-Live-Step": step}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + response.read() + except (urllib.error.URLError, OSError) as error: + raise RuntimeError(f"publishing the preview to {url} failed: {error}") from error + return {"nodes": len(scene.nodes), "parts": len(scene.parts), "bytes": len(data)} + + +def preview_entity_info(entity_id: str) -> dict[str, Any] | None: + """The properties of one entity of the scene last previewed (see :func:`entity_info`).""" + + if _last_preview is None: + return None + return entity_info(_last_preview, entity_id) + + +def publish_live(url: str, *, step: str = "", timeout: float = 60.0) -> LivePackage: + """:func:`live_package`, then POST the package bytes to *url*.""" + + package = live_package() + request = urllib.request.Request( + url, + data=package.data, + headers={"Content-Type": PACKAGE_MEDIA_TYPE, "X-Live-Step": step}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + response.read() + except (urllib.error.URLError, OSError) as error: + raise RuntimeError(f"publishing the package to {url} failed: {error}") from error + return package + + +__all__ = [ + "LivePackage", + "LiveUnavailableError", + "live_package", + "live_preview", + "preview_entity_info", + "publish_live", + "publish_preview", +] diff --git a/src/simplecadapi/runtime/params.py b/src/simplecadapi/runtime/params.py new file mode 100644 index 00000000..57e565ed --- /dev/null +++ b/src/simplecadapi/runtime/params.py @@ -0,0 +1,635 @@ +"""Notebook parameters: what an editor can drive, and the values it saved. + +A parameter is a top-level variable a cell assigns a literal (or a +``scad.var``), in a cell headed ``# ---- params ----`` (``# ---- params: ----`` +to name the group; the feature tree convention's header) or on a +line that carries a ``# ui:`` comment:: + + # ---- params: flange ---- + OUTER_D = 100.0 # ui: slider(50, 200, step=1, unit="mm", label="Outer diameter") + BOLTS = 6 # ui: number(3, 12, step=1) + CHAMFER = True # no comment: a toggle, inferred + width = scad.var("width", 20.0, unit="mm") # ui: slider(10, 40) + +The comment is optional: without it the control follows the literal +(a number box for numbers, a toggle for booleans). Controls are +``slider(min, max, step=...)``, ``number(min=, max=, step=...)`` and +``toggle()``, each taking ``unit=`` and ``label=``. Notebooks written +before this convention (a params cell of plain constants) list their +parameters with no change. + +:func:`list_parameters` reads them from the file, without running it. + +**Saved values.** An editor that drives a slider keeps the values in +``params.json`` in the notebook's :func:`~.deps.state_dir`. The editor's +kernel executor (:mod:`.executor`) applies them as the defining cell +finishes, so the cells that read the variable see the saved value and +marimo's dataflow reruns exactly those. A saved value replaces a literal +and, for a ``scad.var``, the variable's default (its unit, tolerance and +comment stay). A headless run (``sca run``, ``run_notebook``, ``use``) +takes only the overrides it is given, never the saved values: the notebook +file stays the one source of truth, and committing a value is an edit of +the file. +""" + +from __future__ import annotations + +import ast +import io +import json +import re +import tokenize +from dataclasses import dataclass, field, replace +from functools import lru_cache +from pathlib import Path +from typing import Any + +from ._marimo import parse_notebook +from .cells import cell_keys +from .config import NotebookConfig +from .deps import state_dir + +_GROUP = re.compile(r"^#\s*-{2,}\s*params(?::\s*(.*?))?\s*-*\s*$", re.MULTILINE) +_UI = re.compile(r"#\s*ui:\s*(.+?)\s*$") +_CONTROLS = {"slider", "number", "toggle"} +_UI_KEYS = {"min", "max", "step", "unit", "label"} +_SAVED_SCHEMA = 1 + + +@dataclass(frozen=True) +class ParamSpec: + """One parameter of a notebook, as declared in its file.""" + + name: str + """The top-level variable.""" + group: str + """The label of the ``# ---- params: ... ----`` header, or ``""``.""" + cell: str + """Key of the declaring cell (see :func:`~.cells.cell_keys`).""" + line: int + """Line of the assignment within the cell's code, 1-based.""" + kind: str + """``"float"``, ``"int"`` or ``"bool"``.""" + declared: str + """``"literal"`` or ``"var"`` (a ``scad.var``).""" + default: float | int | bool + control: str + """``"slider"``, ``"number"`` or ``"toggle"``.""" + min: float | None = None + max: float | None = None + step: float | None = None + unit: str | None = None + label: str | None = None + comment: str | None = None + issues: tuple[str, ...] = field(default=()) + """Problems with the ``# ui:`` comment; the control fell back.""" + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "group": self.group, + "cell": self.cell, + "line": self.line, + "kind": self.kind, + "declared": self.declared, + "default": self.default, + "control": self.control, + "min": self.min, + "max": self.max, + "step": self.step, + "unit": self.unit, + "label": self.label or self.name.rsplit(".", 1)[-1], + "comment": self.comment, + "issues": list(self.issues), + } + + +# --------------------------------------------------------------------------- +# Listing +# --------------------------------------------------------------------------- + + +def list_parameters(path: str | Path) -> list[ParamSpec]: + """Return the parameters declared in the notebook file at *path*. + + The file is parsed, not run, so the answer is available before any cell + has executed. Cells that do not parse contribute nothing. + """ + + resolved = Path(path).expanduser().resolve() + stat = resolved.stat() + return list(_list_version(resolved, stat.st_mtime_ns, stat.st_size)) + + +@lru_cache(maxsize=32) +def _list_version(path: Path, _mtime_ns: int, _size: int) -> tuple[ParamSpec, ...]: + try: + notebook = parse_notebook(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, SyntaxError): + return () + if notebook is None: + return () + cells = list(notebook.cells) + keys = cell_keys(cell.code for cell in cells) + specs: list[ParamSpec] = [] + for cell, key in zip(cells, keys): + specs.extend(parameters_in_cell(cell.code, key)) + return tuple(specs) + + +def parameters_in_cell(code: str, key: str = "") -> list[ParamSpec]: + """Return the parameters one cell's *code* declares.""" + + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return [] + header = _GROUP.search(code) + comments = _line_comments(code) + specs: list[ParamSpec] = [] + for statement in tree.body: + if not ( + isinstance(statement, ast.Assign) + and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name) + ): + continue + name = statement.targets[0].id + if name.startswith("_"): + continue + literal = _read_value(statement.value) + if literal is None: + continue + ui = _ui_comment(statement, comments) + if header is None and ui is None: + continue # an ordinary constant, not offered as a parameter + specs.append( + _spec( + name, + (header.group(1) or "") if header else "", + key, + statement.lineno, + literal, + ui, + ) + ) + return specs + + +@dataclass(frozen=True) +class _Value: + default: float | int | bool + declared: str + unit: str | None = None + comment: str | None = None + + +def _read_value(node: ast.expr) -> _Value | None: + """A literal number or bool, or ``var(name, default, ...)`` of them.""" + + literal = _literal(node) + if literal is not None: + return _Value(literal, "literal") + if ( + isinstance(node, ast.Call) + and _callee(node.func) == "var" + and len(node.args) >= 2 + ): + default = _literal(node.args[1]) + if default is None or isinstance(default, bool): + return None + unit = comment = None + for keyword in node.keywords: + if keyword.arg == "unit" and isinstance(keyword.value, ast.Constant): + unit = keyword.value.value if isinstance(keyword.value.value, str) else None + if keyword.arg == "comment" and isinstance(keyword.value, ast.Constant): + comment = keyword.value.value if isinstance(keyword.value.value, str) else None + if comment is None and len(node.args) >= 3: + third = node.args[2] + if isinstance(third, ast.Constant) and isinstance(third.value, str): + comment = third.value + return _Value(default, "var", unit, comment) + return None + + +def _callee(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _literal(node: ast.expr) -> float | int | bool | None: + if isinstance(node, ast.Constant) and isinstance(node.value, (bool, int, float)): + return node.value + if ( + isinstance(node, ast.UnaryOp) + and isinstance(node.op, (ast.USub, ast.UAdd)) + and isinstance(node.operand, ast.Constant) + and isinstance(node.operand.value, (int, float)) + and not isinstance(node.operand.value, bool) + ): + value = node.operand.value + return -value if isinstance(node.op, ast.USub) else value + return None + + +def _line_comments(code: str) -> dict[int, str]: + comments: dict[int, str] = {} + try: + for token in tokenize.generate_tokens(io.StringIO(code).readline): + if token.type == tokenize.COMMENT: + comments[token.start[0]] = token.string + except (tokenize.TokenError, IndentationError, SyntaxError): + pass + return comments + + +def _ui_comment(statement: ast.Assign, comments: dict[int, str]) -> str | None: + end = statement.end_lineno or statement.lineno + for line in range(statement.lineno, end + 1): + match = _UI.search(comments.get(line, "")) + if match: + return match.group(1) + return None + + +def _spec( + name: str, + group: str, + cell: str, + line: int, + value: _Value, + ui: str | None, +) -> ParamSpec: + default = value.default + kind = "bool" if isinstance(default, bool) else "int" if isinstance(default, int) else "float" + control = "toggle" if kind == "bool" else "number" + options: dict[str, Any] = {} + issues: list[str] = [] + if ui is not None: + parsed = _parse_ui(ui, issues) + if parsed is not None: + requested, options = parsed + if kind == "bool" and requested != "toggle": + issues.append(f"{requested} needs a number; {name} is a bool") + elif kind != "bool" and requested == "toggle": + issues.append(f"toggle needs a bool; {name} is a number") + elif requested == "slider" and ("min" not in options or "max" not in options): + issues.append("slider needs min and max") + else: + control = requested + lower, upper = options.get("min"), options.get("max") + if lower is not None and upper is not None and lower > upper: + issues.append(f"min {lower} is above max {upper}") + lower = upper = None + control = "number" if kind != "bool" else control + return ParamSpec( + name=name, + group=group, + cell=cell, + line=line, + kind=kind, + declared=value.declared, + default=default, + control=control, + min=lower, + max=upper, + step=options.get("step"), + unit=options.get("unit", value.unit), + label=options.get("label"), + comment=value.comment, + issues=tuple(issues), + ) + + +def _parse_ui(text: str, issues: list[str]) -> tuple[str, dict[str, Any]] | None: + """Parse ``slider(1, 5, step=1, unit="mm")`` into control and options.""" + + try: + call = ast.parse(text, mode="eval").body + except SyntaxError: + issues.append(f"cannot parse ui comment {text!r}") + return None + if not isinstance(call, ast.Call) or _callee(call.func) not in _CONTROLS: + issues.append(f"ui comment must be one of {sorted(_CONTROLS)}: {text!r}") + return None + control = _callee(call.func) + assert control is not None + options: dict[str, Any] = {} + positional = ("min", "max") if control != "toggle" else () + if len(call.args) > len(positional): + issues.append(f"too many positional arguments for {control}") + return None + pairs = [(key, arg) for key, arg in zip(positional, call.args)] + pairs += [(keyword.arg, keyword.value) for keyword in call.keywords] + for key, node in pairs: + if key not in _UI_KEYS: + issues.append(f"unknown ui option {key!r}") + return None + try: + value = ast.literal_eval(node) + except (ValueError, SyntaxError): + issues.append(f"ui option {key} must be a literal") + return None + if key in ("min", "max", "step"): + if isinstance(value, bool) or not isinstance(value, (int, float)): + issues.append(f"ui option {key} must be a number") + return None + elif not isinstance(value, str): + issues.append(f"ui option {key} must be a string") + return None + options[key] = value + if options.get("step") is not None and options["step"] <= 0: + issues.append("step must be positive") + return None + return control, options + + +# --------------------------------------------------------------------------- +# Parameters of a plain module: the fields of its dataclasses +# --------------------------------------------------------------------------- + + +def list_module_parameters(path: str | Path) -> list[ParamSpec]: + """Return the parameter fields of the dataclasses in the module at *path*. + + A notebook that imports shared dimensions (``DIMS = ULinkDimensions()``) + has no parameter cell; the numbers live in the module. Every field of a + ``@dataclass`` with a literal default (a number or bool) is a parameter, + named ``.``, and so is a top-level constant that carries a + ``# ui:`` comment (named as itself, group ``module``); the comment picks + the control exactly as it does in a notebook cell. Editing a value means + editing the default in the file (:func:`commit_field_value`, + :func:`commit_value`). + """ + + resolved = Path(path).expanduser().resolve() + stat = resolved.stat() + return list(_module_version(resolved, stat.st_mtime_ns, stat.st_size)) + + +@lru_cache(maxsize=32) +def _module_version(path: Path, _mtime_ns: int, _size: int) -> tuple[ParamSpec, ...]: + try: + return tuple(parameters_in_module(path.read_text(encoding="utf-8"))) + except (OSError, UnicodeError): + return () + + +def parameters_in_module(code: str) -> list[ParamSpec]: + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return [] + comments = _line_comments(code) + specs: list[ParamSpec] = [] + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and not node.targets[0].id.startswith("_") + ): + # A module constant is a parameter only when it says so (`# ui:`): + # most are tags and tables, not numbers to tune. + literal = _read_value(node.value) + ui = _ui_comment(node, comments) + if literal is not None and literal.declared == "literal" and ui is not None: + specs.append(_spec(node.targets[0].id, "module", "", node.lineno, literal, ui)) + continue + if not (isinstance(node, ast.ClassDef) and _is_dataclass(node)): + continue + for statement in node.body: + if not ( + isinstance(statement, ast.AnnAssign) + and isinstance(statement.target, ast.Name) + and statement.value is not None + and not statement.target.id.startswith("_") + ): + continue + literal = _read_value(statement.value) + if literal is None or literal.declared != "literal": + continue + ui = _ui_comment(statement, comments) # type: ignore[arg-type] + specs.append( + _spec(f"{node.name}.{statement.target.id}", node.name, "", statement.lineno, literal, ui) + ) + return specs + + +def _is_dataclass(node: ast.ClassDef) -> bool: + for decorator in node.decorator_list: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + if _callee(target) == "dataclass": + return True + return False + + +def commit_field_value(code: str, class_name: str, field: str, value: float | int | bool) -> str | None: + """Return *code* with the default of ``.`` set to *value*. + + The literal is replaced in place; the rest of the line (type annotation, + comments) is kept. ``None`` if the module has no such dataclass field or + *value* does not fit its kind. + """ + + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return None + for node in tree.body: + if not (isinstance(node, ast.ClassDef) and node.name == class_name and _is_dataclass(node)): + continue + for statement in node.body: + if not ( + isinstance(statement, ast.AnnAssign) + and isinstance(statement.target, ast.Name) + and statement.target.id == field + and statement.value is not None + ): + continue + declared = _read_value(statement.value) + if declared is None or declared.declared != "literal": + return None + text = _format_value(declared.default, value) + target = statement.value + if text is None or target.end_lineno != target.lineno: + return None + lines = code.splitlines(keepends=True) + line = lines[target.lineno - 1].encode("utf-8") + replaced = line[: target.col_offset] + text.encode("utf-8") + line[target.end_col_offset :] + lines[target.lineno - 1] = replaced.decode("utf-8") + return "".join(lines) + return None + + +# --------------------------------------------------------------------------- +# Saved values +# --------------------------------------------------------------------------- + + +def saved_params_file(config: NotebookConfig) -> Path: + return state_dir(config) / "params.json" + + +def read_saved_params(config: NotebookConfig) -> dict[str, float | int | bool]: + """Return the values the editor saved for the notebook; none if absent.""" + + path = saved_params_file(config) + try: + stat = path.stat() + except OSError: + return {} + return dict(_read_saved_version(path, stat.st_mtime_ns, stat.st_size)) + + +@lru_cache(maxsize=64) +def _read_saved_version( + path: Path, _mtime_ns: int, _size: int +) -> dict[str, float | int | bool]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + return {} + if not isinstance(data, dict) or data.get("schema") != _SAVED_SCHEMA: + return {} + values = data.get("values") + if not isinstance(values, dict): + return {} + return { + name: value + for name, value in values.items() + if isinstance(name, str) and isinstance(value, (bool, int, float)) + } + + +def write_saved_params( + config: NotebookConfig, values: dict[str, float | int | bool] +) -> None: + """Replace the saved values; an empty mapping removes the file.""" + + path = saved_params_file(config) + if not values: + path.unlink(missing_ok=True) + return + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"schema": _SAVED_SCHEMA, "values": dict(sorted(values.items()))} + temporary = path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def apply_saved_params( + config: NotebookConfig, defined: set[str], glbls: dict[str, Any] +) -> list[str]: + """Replace the variables a cell just defined with their saved values. + + Returns the names replaced. A saved value of the wrong kind for the + variable (a number for a bool) is ignored. + """ + + saved = read_saved_params(config) + applied: list[str] = [] + for name in defined & saved.keys(): + if name not in glbls: + continue + replacement = _replace(glbls[name], saved[name]) + if replacement is not _NO_REPLACEMENT: + glbls[name] = replacement + applied.append(name) + return applied + + +_NO_REPLACEMENT = object() + + +def _replace(current: Any, saved: float | int | bool) -> Any: + from ..params.expr import Var + + if isinstance(current, Var): + if isinstance(saved, bool): + return _NO_REPLACEMENT + return replace(current, default=float(saved), expr_id="") + if isinstance(current, bool): + return saved if isinstance(saved, bool) else _NO_REPLACEMENT + if isinstance(saved, bool): + return _NO_REPLACEMENT + if isinstance(current, int): + return int(saved) if float(saved).is_integer() else float(saved) + if isinstance(current, float): + return float(saved) + return _NO_REPLACEMENT + + +# --------------------------------------------------------------------------- +# Committing a value to the source +# --------------------------------------------------------------------------- + + +def commit_value(code: str, name: str, value: float | int | bool) -> str | None: + """Return *code* with the declaration of parameter *name* set to *value*. + + The literal (or the default argument of a ``scad.var``) is replaced in + place; the rest of the line, a ``# ui:`` comment included, is kept. + Returns ``None`` if the cell does not declare *name* as a parameter or + *value* does not fit its kind. + """ + + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return None + for statement in tree.body: + if not ( + isinstance(statement, ast.Assign) + and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name) + and statement.targets[0].id == name + ): + continue + declared = _read_value(statement.value) + if declared is None: + return None + node = statement.value + if declared.declared == "var": + assert isinstance(node, ast.Call) + node = node.args[1] + text = _format_value(declared.default, value) + if text is None or node.end_lineno != node.lineno: + return None + lines = code.splitlines(keepends=True) + line = lines[node.lineno - 1].encode("utf-8") + # ast offsets count UTF-8 bytes. + replaced = line[: node.col_offset] + text.encode("utf-8") + line[node.end_col_offset :] + lines[node.lineno - 1] = replaced.decode("utf-8") + return "".join(lines) + return None + + +def _format_value( + current: float | int | bool, value: float | int | bool +) -> str | None: + if isinstance(current, bool): + return repr(value) if isinstance(value, bool) else None + if isinstance(value, bool): + return None + if isinstance(current, int) and float(value).is_integer(): + return str(int(value)) + return repr(float(value)) + + +__all__ = [ + "ParamSpec", + "apply_saved_params", + "commit_field_value", + "commit_value", + "list_module_parameters", + "list_parameters", + "parameters_in_cell", + "parameters_in_module", + "read_saved_params", + "saved_params_file", + "write_saved_params", +] diff --git a/src/simplecadapi/runtime/runner.py b/src/simplecadapi/runtime/runner.py index 228b8a2c..37bc59e2 100644 --- a/src/simplecadapi/runtime/runner.py +++ b/src/simplecadapi/runtime/runner.py @@ -6,9 +6,9 @@ * cells run through :class:`~.executor.SimpleCadExecutor`, so each records into a session of its own; -* cells are cached with marimo's cell cache, under the dependency digest - *D* (:mod:`.deps`) so that a changed local module, child notebook or - input file invalidates the cache; +* cells are cached with marimo's cell cache, each under a digest of the + local modules, child notebooks and input files *it* depends on + (:mod:`.cache`), so a changed file recomputes only the cells that read it; * after the last cell the product is projected onto its definition (:mod:`.projection`). @@ -23,7 +23,6 @@ from __future__ import annotations import hashlib -import shutil import sys from collections.abc import Callable, Iterator, Mapping from concurrent.futures import ThreadPoolExecutor @@ -38,15 +37,11 @@ from ..recording.graph import isolated_recording from ._marimo import ( AppScriptRunner, - CachedLifecycle, CellId_t, CellImpl, Evaluator, ExecutionLifecycle, - FileStore, InternalApp, - LazyLoader, - LazyStore, MarimoRescheduleError, MarimoRuntimeException, MutableGlobals, @@ -57,16 +52,15 @@ runtime_context_installed, safe_get_context, ) +from .cache import CellCache, CellDigests, cache_root, prune from .cells import NotebookCells from .config import NotebookConfig, NotebookConfigError, read_notebook_config from .deps import ( - dependency_digest, local_modules, read_dependencies, record_dependencies, recording_dependencies, refresh_local_modules, - state_dir, write_dependencies, ) from .executor import NotebookScope, SimpleCadExecutor @@ -78,11 +72,6 @@ """``ran``: executed; ``cached``: restored from the cell cache; ``skipped``: not run, because overrides replace its variables or the cell is disabled.""" -_CELLS = "cells" -"""The cell cache's loader name, fixed so a cache directory can be renamed: -its entries reference their values by paths starting with this name.""" - - @dataclass(frozen=True) class CellReport: """What happened to one cell during a run. @@ -204,10 +193,13 @@ def _run(config: NotebookConfig, overrides: dict[str, Any], *, cache: bool) -> N modules = local_modules(config, (cell.code for cell in ordered)) module_files = frozenset(modules.values()) previous = read_dependencies(config) - digest = dependency_digest(config, module_files | previous) - cache_root = _cache_root(config) + setup_cell = internal.graph.cells.get(internal.cell_manager.setup_cell_id) + digests = CellDigests(config, ordered, setup_cell) + root = cache_root(config) progress = _Progress() - cell_cache = _CellCache(internal, cache_root / digest) if cache else None + cell_cache = ( + CellCache(internal.graph, root, lambda cell: digests.digest(cell.cell_id)) if cache else None + ) refresh_local_modules(modules) with isolated_recording(), _importable(config.directory), recording_dependencies() as used: @@ -247,8 +239,18 @@ def _run(config: NotebookConfig, overrides: dict[str, Any], *, cache: bool) -> N recorded = frozenset(used) | (previous if restored else frozenset()) write_dependencies(config, recorded) dependencies = module_files | recorded - if cache: - _keep_cache(cache_root, digest, dependency_digest(config, dependencies)) + if cache and cell_cache is not None: + # A cell that runs a child notebook depends on the record just written: + # its entries move to the digest of what it now depends on. + after = CellDigests(config, ordered, setup_cell) + used_digests = set(cell_cache.used) + for cell in ordered: + old, new = digests.digest(cell.cell_id), after.digest(cell.cell_id) + if old is not None and new is not None and old != new and old in used_digests: + cell_cache.move(old, new) + used_digests.discard(old) + used_digests.add(new) + prune(root, used_digests) reports = tuple( CellReport( @@ -276,7 +278,7 @@ def _execute( scope: NotebookScope, overrides: dict[str, Any], progress: _Progress, - cell_cache: _CellCache | None = None, + cell_cache: CellCache | None = None, ) -> MutableGlobals: glbls = {**_run_setup(internal, scope, progress), **overrides} runner = AppScriptRunner(internal, str(scope.config.path), glbls) @@ -371,30 +373,6 @@ def teardown(self, cell: CellImpl, glbls: MutableGlobals, run_result: RunResult) return None -class _CellCache(CachedLifecycle): - """marimo's cell cache, stored in *directory* and reporting its hits.""" - - def __init__(self, internal: InternalApp, directory: Path) -> None: - # CachedLifecycle only takes a loader kind and stores in marimo's - # default place; its loader is replaced by one stored in *directory* - # (the pickle kind is merely the cheapest to build and discard). - super().__init__(internal.graph, loader="pickle") - self._lazy_loader = LazyLoader( - name=_CELLS, store=LazyStore(FileStore(str(directory))) - ) - self._loader = self._lazy_loader - self.restored: set[CellId_t] = set() - - def setup(self, cell: CellImpl, glbls: MutableGlobals) -> Skip | None: - decision = super().setup(cell, glbls) - if isinstance(decision, Skip): - self.restored.add(cell.cell_id) - return decision - - def flush(self) -> None: - self._lazy_loader.flush() - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -406,36 +384,6 @@ def _cell_prefix(config: NotebookConfig) -> str: return hashlib.sha256(str(config.path).encode()).hexdigest()[:8] + "-" -def _cache_root(config: NotebookConfig) -> Path: - """The notebook's cell caches: one directory per dependency digest.""" - - return state_dir(config) / "cache" - - -def _keep_cache(root: Path, used: str, current: str) -> None: - """Keep the cache the run used, under the digest of what it now depends on. - - The two digests differ when the run found out what it depends on anew - (a ``use`` it had not made before, or one it no longer makes); every - entry is valid for the files' current content either way. - Caches under any other digest are stale and removed. - """ - - source = root / used - target = root / current - if source != target and source.is_dir(): - shutil.rmtree(target, ignore_errors=True) - source.rename(target) - if not root.is_dir(): # nothing was cached - return - for stale in root.iterdir(): - if stale != target: - if stale.is_dir(): - shutil.rmtree(stale, ignore_errors=True) - else: - stale.unlink(missing_ok=True) - - @contextmanager def _importable(directory: Path) -> Iterator[None]: """Let the cells import modules next to the notebook, as marimo does.""" diff --git a/src/simplecadapi/runtime/workspace.py b/src/simplecadapi/runtime/workspace.py new file mode 100644 index 00000000..3a1445e6 --- /dev/null +++ b/src/simplecadapi/runtime/workspace.py @@ -0,0 +1,292 @@ +"""A workspace: a directory of SimpleCAD notebooks. + +There is no manifest and no layout to adopt. A workspace is any directory +tree; its models are the notebooks whose PEP 723 header carries +``[tool.simplecadapi]`` (:mod:`.config`), wherever they are. Plain modules, +verification scripts and generated output are not notebooks and are never +listed. What :func:`scan_workspace` reports is read from the files alone: + +* the notebook's id and revision (the header); +* ``kind`` — ``"assembly"`` if a cell builds an assembly or calls + :func:`~.runner.use`, else ``"part"``; +* ``uses`` — the notebooks it runs with ``use("")``, so a + front end can order and refresh a product's parts; +* the feature tree (:func:`list_features`, the ``# ---- feature: ... ----`` + cell headers of the feature tree convention) and the parameters + (:func:`~.params.list_parameters`). +""" + +from __future__ import annotations + +import ast +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ._marimo import parse_notebook +from .cells import cell_keys +from .config import NotebookConfigError, read_notebook_config +from .deps import local_modules +from .params import list_parameters + +_FEATURE = re.compile( + r"^#\s*-{2,}\s*feature:\s*(?P[^\s(]+)\s*(?:\((?P[^)]*)\))?\s*-*\s*$", + re.MULTILINE, +) +_SKIPPED = frozenset({"node_modules", "venv", "site-packages", "build", "dist"}) +_ASSEMBLY_CALLS = frozenset({"make_assembly_rassembly", "Assembly", "assemble", "use"}) + + +@dataclass(frozen=True) +class FeatureSpec: + """One ``# ---- feature: (, ...) ----`` cell header.""" + + slug: str + role: str + """``build``, ``add``, ``subtract``, ... ``""`` if the header gives none.""" + tags: tuple[tuple[str, str], ...] + """The ``key=value`` annotations, e.g. ``("profile", "sketch")``.""" + cell: str + """Key of the cell the header opens.""" + line: int + """Line of the header within the cell's code, 1-based.""" + + def to_dict(self) -> dict[str, Any]: + return { + "slug": self.slug, + "role": self.role, + "tags": dict(self.tags), + "cell": self.cell, + "line": self.line, + } + + +@dataclass(frozen=True) +class NotebookEntry: + path: str + """Relative to the workspace root, POSIX separators.""" + id: str + revision: str + kind: str + """``"part"`` or ``"assembly"``.""" + uses: tuple[str, ...] + """Notebooks run with ``use("...")`` in a literal path, workspace-relative.""" + error: str | None = None + """Why the header could not be read, if it could not.""" + modules: tuple[str, ...] = () + """The local modules its cells import (workspace-relative), however deep: + plain ``.py`` files such as shared dimensions.""" + + def to_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "id": self.id, + "revision": self.revision, + "kind": self.kind, + "uses": list(self.uses), + "modules": list(self.modules), + "error": self.error, + } + + +@dataclass(frozen=True) +class ModuleEntry: + """A local module some notebook imports.""" + + path: str + """Relative to the workspace root, POSIX separators.""" + name: str + used_by: tuple[str, ...] + """The notebooks (workspace-relative) whose cells import it.""" + + def to_dict(self) -> dict[str, Any]: + return {"path": self.path, "name": self.name, "used_by": list(self.used_by)} + + +def scan_workspace(root: str | Path) -> list[NotebookEntry]: + """Return the SimpleCAD notebooks under *root*, sorted by path.""" + + base = Path(root).expanduser().resolve() + entries: list[NotebookEntry] = [] + for directory, subdirectories, files in os.walk(base): + subdirectories[:] = sorted( + name + for name in subdirectories + if not name.startswith((".", "_")) and name not in _SKIPPED + ) + for name in sorted(files): + if not name.endswith(".py") or name.startswith("_"): + continue + path = Path(directory) / name + entry = _entry(base, path) + if entry is not None: + entries.append(entry) + return sorted(entries, key=lambda entry: entry.path) + + +def _entry(base: Path, path: Path) -> NotebookEntry | None: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + if "tool.simplecadapi" not in text: # cheap filter before parsing the header + return None + relative = path.relative_to(base).as_posix() + try: + config = read_notebook_config(path) + except NotebookConfigError as error: + return NotebookEntry(relative, path.stem, "", "part", (), error=str(error)) + if config is None: + return None + kind, uses = _structure(base, path, text) + modules = tuple( + sorted( + file.relative_to(base).as_posix() + for file in notebook_modules(path).values() + if file.is_relative_to(base) + ) + ) + return NotebookEntry(relative, config.id, config.revision, kind, uses, modules=modules) + + +def notebook_modules(path: str | Path) -> dict[str, Path]: + """The local modules the notebook at *path* imports, name -> file. + + Found statically, from the imports in every cell (the setup cell + included), as the cell cache's dependency digest finds them. + """ + + resolved = Path(path).expanduser().resolve() + config = read_notebook_config(resolved) + if config is None: + return {} + try: + notebook = parse_notebook(resolved.read_text(encoding="utf-8")) + except (OSError, UnicodeError, SyntaxError): + return {} + if notebook is None: + return {} + return local_modules(config, [cell.code for cell in notebook.cells]) + + +def scan_modules(root: str | Path, entries: list[NotebookEntry] | None = None) -> list[ModuleEntry]: + """The local modules the notebooks under *root* import, sorted by path.""" + + base = Path(root).expanduser().resolve() + users: dict[str, set[str]] = {} + names: dict[str, str] = {} + for entry in entries if entries is not None else scan_workspace(base): + for name, file in notebook_modules(base / entry.path).items(): + if not file.is_relative_to(base): + continue + relative = file.relative_to(base).as_posix() + users.setdefault(relative, set()).add(entry.path) + names[relative] = name + return [ + ModuleEntry(path=path, name=names[path], used_by=tuple(sorted(users[path]))) + for path in sorted(users) + ] + + +def _structure(base: Path, path: Path, text: str) -> tuple[str, tuple[str, ...]]: + try: + notebook = parse_notebook(text) + except SyntaxError: + notebook = None + codes = [cell.code for cell in notebook.cells] if notebook is not None else [] + assembly = False + uses: list[str] = [] + for code in codes: + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + continue + for node in ast.walk(tree): + if isinstance(node, ast.Call): + name = _callee(node.func) + if name in _ASSEMBLY_CALLS: + assembly = True + if name == "use" and node.args: + target = node.args[0] + if isinstance(target, ast.Constant) and isinstance(target.value, str): + resolved = (path.parent / target.value).resolve() + try: + uses.append(resolved.relative_to(base).as_posix()) + except ValueError: + pass # a notebook outside the workspace + return ("assembly" if assembly else "part"), tuple(dict.fromkeys(uses)) + + +def _callee(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def list_features(path: str | Path) -> list[FeatureSpec]: + """Return the feature headers of the notebook at *path*, in cell order.""" + + try: + notebook = parse_notebook(Path(path).read_text(encoding="utf-8")) + except (OSError, UnicodeError, SyntaxError): + return [] + if notebook is None: + return [] + cells = list(notebook.cells) + keys = cell_keys(cell.code for cell in cells) + features: list[FeatureSpec] = [] + for cell, key in zip(cells, keys): + features.extend(features_in_cell(cell.code, key)) + return features + + +def features_in_cell(code: str, key: str = "") -> list[FeatureSpec]: + features: list[FeatureSpec] = [] + for match in _FEATURE.finditer(code): + role = "" + tags: list[tuple[str, str]] = [] + for part in (match.group("spec") or "").split(","): + part = part.strip() + if not part: + continue + name, separator, value = part.partition("=") + if separator: + tags.append((name.strip(), value.strip())) + elif not role: + role = part + features.append( + FeatureSpec( + slug=match.group("slug"), + role=role, + tags=tuple(tags), + cell=key, + line=code.count("\n", 0, match.start()) + 1, + ) + ) + return features + + +def describe_notebook(path: str | Path) -> dict[str, Any]: + """Features and parameters of one notebook, as plain data.""" + + return { + "features": [feature.to_dict() for feature in list_features(path)], + "parameters": [spec.to_dict() for spec in list_parameters(path)], + } + + +__all__ = [ + "FeatureSpec", + "ModuleEntry", + "NotebookEntry", + "describe_notebook", + "features_in_cell", + "list_features", + "notebook_modules", + "scan_modules", + "scan_workspace", +] diff --git a/src/simplecadapi/scene/preview.py b/src/simplecadapi/scene/preview.py new file mode 100644 index 00000000..9fc726a3 --- /dev/null +++ b/src/simplecadapi/scene/preview.py @@ -0,0 +1,488 @@ +"""A preview of a product for a viewer that is being edited with. + +A product package (:mod:`.product_scene`) is the durable, exchangeable form of +a product: definitions, BREP, feature graph, topology snapshot, hashed and +validated GLB assets and entity sidecars. Making one takes seconds, nearly all +of it for what a viewer of a model under edit never reads. This module makes the +other thing a viewer needs, straight from the solids in memory: + +* the triangles of each part (positions, normals, indices), the line segments of + its edges and the positions of its vertices, with the index range each face and + edge occupies, so a viewer can highlight and pick them; +* the parts of an assembly, each once, and where each of its instances stands; +* :func:`entity_info`, the geometric properties of one picked face, edge, vertex + or solid, worked out on demand (about 0.05 ms for a face). + +The geometry of a part is derived data: it is kept per (BREP content, tolerance), +so a part that did not change costs nothing to preview again, even if its object +was rebuilt (a notebook cell ran again, a cache restored it). Nothing here is +hashed canonically, validated or written as a document. + +Coordinates are glTF's (metres, +Y up: CAD x, z, -y / 1000), as in the package's +GLB assets, so a viewer built for those uses the arrays as they are. +""" + +from __future__ import annotations + +import hashlib +import json +import struct +import threading +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from .._internal.mesh import DEFAULT_ANGULAR_TOLERANCE, DEFAULT_LINEAR_TOLERANCE, cached_mesh +from ..artifacts.brep import _bintools_bytes +from ..core import Solid +from ..kernel.ocp_properties import center_of_mass +from ..product.assembly import Assembly +from ..product.part import Part +from ..product.placement import Placement, identity_placement +from .compiler import ( + _curve_geometry, + _edge_endpoints, + _frame, + _normalize, + _orientation, + _shape_bounds, + _surface_geometry, + _vec, +) +from .compiler import _bounds as _bounds_document +from .render_mesh import build_edge_mesh, solid_asset_bounds + +PREVIEW_SCHEMA = "sca-preview-1" +MAGIC = b"SCAPREV1" +_CACHE_PARTS = 96 + + +class PreviewError(ValueError): + """The product cannot be previewed (it has no solid to draw).""" + + +@dataclass(frozen=True) +class PartPreview: + """Everything a viewer draws of one solid; immutable and independent of its object.""" + + key: str + positions: np.ndarray # (n, 3) float32, glTF metres + normals: np.ndarray # (n, 3) float32 + indices: np.ndarray # (3 * triangles,) uint32 + face_groups: tuple[tuple[str, int, int], ...] # (face topo id, first index, index count) + edge_positions: np.ndarray # (k, 3) float32 + edge_indices: np.ndarray # (2 * segments,) uint32 + edge_groups: tuple[tuple[str, int, int], ...] + vertex_positions: np.ndarray # (v, 3) float32 + vertex_ids: tuple[str, ...] + solid_topo_id: str + triangle_count: int + + +@dataclass(frozen=True) +class PreviewNode: + node_id: str + part_key: str + definition_id: str + display_name: str + placement: Placement # CAD space, millimetres, relative to the product + + +@dataclass +class PreviewScene: + parts: dict[str, PartPreview] + nodes: list[PreviewNode] + solids: dict[str, Solid] + """The solid behind each part key, for :func:`entity_info` (not serialized).""" + definitions: dict[str, str] + """The definition id each part key belongs to.""" + + +# -- the per-part cache -------------------------------------------------------------- + +_parts: OrderedDict[str, PartPreview] = OrderedDict() +_parts_lock = threading.Lock() + + +def _remember(preview: PartPreview) -> PartPreview: + with _parts_lock: + _parts[preview.key] = preview + _parts.move_to_end(preview.key) + while len(_parts) > _CACHE_PARTS: + _parts.popitem(last=False) + return preview + + +def _recall(key: str) -> PartPreview | None: + with _parts_lock: + preview = _parts.get(key) + if preview is not None: + _parts.move_to_end(key) + return preview + + +def clear_preview_cache() -> None: + with _parts_lock: + _parts.clear() + + +def part_key(solid: Solid, linear_tolerance: float, angular_tolerance: float) -> str: + """A key for the geometry of *solid* at a tolerance: its BREP content, not its identity. + + Remembered on the solid, so asking again about the same object is free. + """ + tolerance = f"{float(linear_tolerance).hex()}:{float(angular_tolerance).hex()}" + remembered = solid._get_runtime("preview.key", None) + if isinstance(remembered, tuple) and remembered[0] == tolerance: + return remembered[1] + digest = hashlib.sha256(_bintools_bytes(solid.wrapped)).hexdigest()[:24] + key = f"{digest}-{hashlib.sha256(tolerance.encode()).hexdigest()[:6]}" + solid._set_runtime("preview.key", (tolerance, key)) + return key + + +# -- one solid ----------------------------------------------------------------------- + + +def preview_solid( + solid: Solid, + *, + linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE, + angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE, +) -> PartPreview: + """The preview geometry of *solid*, from the cache if its content was seen at this tolerance.""" + + key = part_key(solid, linear_tolerance, angular_tolerance) + known = _recall(key) + if known is not None: + return known + mesh = cached_mesh(solid, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance) + if mesh is None: + raise PreviewError("the solid has no mesh") + triangles = np.asarray(mesh.triangles, dtype=np.int64) + corner_normals = np.asarray(mesh.corner_normals, dtype=np.float64) + vertices = np.asarray(mesh.vertices, dtype=np.float64) + positions, normals, indices = _vertex_buffers(vertices, triangles, corner_normals) + face_groups = tuple( + (str(item.source_topo_id), item.start * 3, item.count * 3) + for item in mesh.face_triangle_ranges + if item.count > 0 + ) + + edges = solid._iter_edges() + edge_mesh = build_edge_mesh( + solid, + edge_entity_ids=[edge.topo_id for edge in edges], + linear_tolerance=linear_tolerance, + angular_tolerance=angular_tolerance, + ) + edge_positions = np.asarray(edge_mesh.positions, dtype=np.float32).reshape((-1, 3)) + edge_indices = np.asarray(edge_mesh.indices, dtype=np.uint32) + edge_groups = tuple((group.entity_id, group.first_index, group.index_count) for group in edge_mesh.groups) + + unique: dict[str, Any] = {} + for edge in edges: + for vertex in edge._iter_vertices(): + unique.setdefault(vertex.topo_id, vertex) + vertex_ids = tuple(unique) + vertex_positions = _gltf(np.asarray([list(_vec(unique[i].get_coordinates())) for i in vertex_ids], dtype=np.float64).reshape((-1, 3))) + + return _remember( + PartPreview( + key=key, + positions=positions, + normals=normals, + indices=indices, + face_groups=face_groups, + edge_positions=edge_positions, + edge_indices=edge_indices, + edge_groups=edge_groups, + vertex_positions=vertex_positions.astype(np.float32), + vertex_ids=vertex_ids, + solid_topo_id=str(solid.topo_id), + triangle_count=int(len(triangles)), + ) + ) + + +def _gltf(points: np.ndarray) -> np.ndarray: + """CAD millimetres (x, y, z) to glTF metres (x, z, -y).""" + return np.stack([points[:, 0], points[:, 2], -points[:, 1]], axis=1) / 1000.0 + + +def _gltf_direction(vectors: np.ndarray) -> np.ndarray: + return np.stack([vectors[:, 0], vectors[:, 2], -vectors[:, 1]], axis=1) + + +def _vertex_buffers( + vertices: np.ndarray, triangles: np.ndarray, corner_normals: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Per-vertex position and normal buffers and the index buffer. + + The mesh keeps a normal for each triangle corner. Where every corner that + uses a vertex agrees on it (the usual case: a face's vertices are its own) + the vertex keeps that normal; otherwise vertices are split by (position, + normal). + """ + if len(triangles) == 0: + empty = np.zeros((0, 3), dtype=np.float32) + return empty, empty, np.zeros(0, dtype=np.uint32) + flat = triangles.reshape(-1) + corners = corner_normals.reshape(-1, 3) + per_vertex = np.zeros((len(vertices), 3), dtype=np.float64) + per_vertex[flat] = corners + if np.abs(per_vertex[flat] - corners).max() <= 1e-9: + return ( + _gltf(vertices).astype(np.float32), + _gltf_direction(per_vertex).astype(np.float32), + flat.astype(np.uint32), + ) + combined = np.concatenate([vertices[flat], corners], axis=1) + unique, inverse = np.unique(combined, axis=0, return_inverse=True) + return ( + _gltf(unique[:, :3]).astype(np.float32), + _gltf_direction(unique[:, 3:]).astype(np.float32), + inverse.reshape(-1).astype(np.uint32), + ) + + +# -- a product ----------------------------------------------------------------------- + + +def compile_preview( + product: Part | Assembly, + *, + linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE, + angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE, +) -> PreviewScene: + """Preview *product*: every part once, every instance with its placement.""" + + parts: dict[str, PartPreview] = {} + solids: dict[str, Solid] = {} + definitions: dict[str, str] = {} + nodes: list[PreviewNode] = [] + + def add(part: Part, path: tuple[str, ...], placement: Placement, name: str) -> None: + body = part.body + if not isinstance(body, Solid): + raise PreviewError(f"part {part.part_id!r} has no solid body") + preview = preview_solid(body, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance) + parts[preview.key] = preview + solids.setdefault(preview.key, body) + definitions.setdefault(preview.key, part.part_id) + nodes.append( + PreviewNode( + node_id="node/" + "/".join(path), + part_key=preview.key, + definition_id=part.part_id, + display_name=name, + placement=placement, + ) + ) + + def visit(item: Part | Assembly, path: tuple[str, ...], placement: Placement, name: str) -> None: + if isinstance(item, Part): + add(item, path, placement, name) + return + for component in item.components: + visit( + component.item, + path + (component.component_id,), + placement.compose(component.placement), + component.name or component.component_id, + ) + + root_id = product.part_id if isinstance(product, Part) else product.assembly_id + visit(product, (root_id,), identity_placement(), root_id) + if not nodes: + raise PreviewError("the product has nothing to draw") + return PreviewScene(parts=parts, nodes=nodes, solids=solids, definitions=definitions) + + +# -- the wire format ----------------------------------------------------------------- + + +def encode_preview(scene: PreviewScene) -> bytes: + """Serialize *scene*: ``SCAPREV1``, a header length, a JSON header, then the arrays. + + The header lists the parts (each array as an ``offset`` and ``count`` into the + data that follows), the face and edge groups, and the nodes. Arrays are + little-endian ``float32`` (positions, normals) and ``uint32`` (indices). + """ + chunks: list[bytes] = [] + cursor = 0 + + def array(values: np.ndarray, dtype: str) -> dict[str, int]: + nonlocal cursor + data = np.ascontiguousarray(values, dtype=dtype).tobytes() + entry = {"offset": cursor, "count": int(values.size)} + chunks.append(data) + cursor += len(data) + return entry + + parts: list[dict[str, Any]] = [] + for key, part in scene.parts.items(): + definition = scene.definitions[key] + prefix = f"entity/{definition}/" + parts.append( + { + "key": key, + "definition_id": definition, + "positions": array(part.positions, " dict[str, Any]: + """The header of an encoded preview (the arrays follow it).""" + if not data.startswith(MAGIC): + raise PreviewError("not a preview") + (length,) = struct.unpack_from(" dict[str, Any] | None: + """The geometric properties of the entity *entity_id* of a part of *scene*. + + ``entity///``; returns what the package's entity + sidecar holds for it (``properties``, ``geometry``, ``tags``) except the + ``source`` links into the feature graph, or ``None`` if there is no such entity. + """ + _, _, rest = entity_id.partition("entity/") + definition, _, tail = rest.partition("/") + kind, _, topo_id = tail.partition("/") + key = next((k for k, d in scene.definitions.items() if d == definition), None) + if key is None: + return None + solid = scene.solids[key] + if kind == "solid": + return { + "entity_id": entity_id, + "kind": "solid", + "topo_id": solid.topo_id, + "geometry": {"type": "brep_solid"}, + "properties": { + "bounds": _bounds_document(solid_asset_bounds(solid)), + "volume": solid.get_volume(), + "surface_area": sum(face.get_area() for face in solid._iter_faces()), + "centroid": list(_vec(center_of_mass(solid.wrapped))), + }, + "tags": sorted(solid._list_tags()), + } + if kind == "face": + face = next((f for f in solid._iter_faces() if f.topo_id == topo_id), None) + if face is None: + return None + center = _vec(face.get_center()) + return { + "entity_id": entity_id, + "kind": "face", + "topo_id": topo_id, + "geometry": _surface_geometry(face), + "properties": { + "bounds": _bounds_document(_shape_bounds(face)), + "area": face.get_area(), + "centroid": list(center), + "orientation": _orientation(face.wrapped.Orientation()), + "connector_frame": _frame(center, _vec(face.get_normal_at())), + }, + "tags": sorted(face._list_tags()), + } + if kind == "edge": + edge = next((e for e in solid._iter_edges() if e.topo_id == topo_id), None) + if edge is None: + return None + start, end = _edge_endpoints(edge) + frame = None + if start is not None and end is not None and start != end: + direction = _normalize(tuple(end[i] - start[i] for i in range(3))) + frame = _frame(_vec(edge.get_center()), direction) + return { + "entity_id": entity_id, + "kind": "edge", + "topo_id": topo_id, + "geometry": _curve_geometry(edge), + "properties": { + "bounds": _bounds_document(_shape_bounds(edge)), + "length": edge.get_length(), + "centroid": list(_vec(edge.get_center())), + "connector_frame": frame, + }, + "tags": sorted(edge._list_tags()), + } + if kind == "vertex": + for edge in solid._iter_edges(): + for vertex in edge._iter_vertices(): + if vertex.topo_id == topo_id: + point = list(_vec(vertex.get_coordinates())) + return { + "entity_id": entity_id, + "kind": "vertex", + "topo_id": topo_id, + "geometry": {"type": "point", "position": point}, + "properties": {"bounds": {"min": point, "max": point}, "position": point}, + "tags": sorted(vertex._list_tags()), + } + return None + return None + + +__all__ = [ + "MAGIC", + "PREVIEW_SCHEMA", + "PartPreview", + "PreviewError", + "PreviewNode", + "PreviewScene", + "clear_preview_cache", + "compile_preview", + "decode_preview_header", + "encode_preview", + "entity_info", + "part_key", + "preview_solid", +] diff --git a/src/simplecadapi/scene/render_mesh.py b/src/simplecadapi/scene/render_mesh.py index d7a27f79..04c061f6 100644 --- a/src/simplecadapi/scene/render_mesh.py +++ b/src/simplecadapi/scene/render_mesh.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from typing import Iterable, Sequence +import numpy as np + from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.GCPnts import GCPnts_TangentialDeflection from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED @@ -219,43 +221,57 @@ def _triangle_block( face_range, entity_id: str, ) -> CanonicalTriangleBlock: - vertex_records: dict[bytes, tuple[tuple[float, float, float], tuple[float, float, float]]] = {} - triangle_keys: list[tuple[bytes, bytes, bytes]] = [] - triangle_start = face_range.start - triangle_end = triangle_start + face_range.count - for triangle_index in range(triangle_start, triangle_end): - node_indices = tuple(int(index) for index in mesh.triangles[triangle_index]) - positions = tuple(cad_to_gltf(mesh.vertices[index]) for index in node_indices) - if len(set(_position_key(position) for position in positions)) != 3: - continue - cross = profile_cross( - tuple(positions[1][axis] - positions[0][axis] for axis in range(3)), - tuple(positions[2][axis] - positions[0][axis] for axis in range(3)), - ) - if all(component == 0.0 for component in cross): - continue - fallback_normal = profile_normalize(cross) - keys: list[bytes] = [] - for corner in range(3): - raw_normal = tuple( - float(value) for value in mesh.corner_normals[triangle_index, corner] - ) - normal = ( - fallback_normal - if all(component == 0.0 for component in raw_normal) - else profile_normalize(cad_direction_to_gltf(raw_normal)) - ) - position = positions[corner] - key = _vertex_key(position, normal) - vertex_records[key] = (position, normal) - keys.append(key) - triangle_keys.append(_least_cyclic_rotation(tuple(keys))) - if not triangle_keys: + """One face's canonical triangle block. + + Every value follows the profile's fixed steps: binary64 arithmetic in a + fixed order, then rounding to binary32 with ``-0`` written as ``+0`` + (:func:`~simplecadapi.scene.glb.profile_f32`). They are evaluated here for + all of the face's triangles at once, with numpy, which performs the same + IEEE operations in the same order as the scalar helpers (and never fuses a + multiply and an add), so the bytes are identical, at a fraction of the cost. + """ + + start = face_range.start + stop = start + face_range.count + corners = np.asarray(mesh.triangles[start:stop], dtype=np.int64) + vertices = np.asarray(mesh.vertices, dtype=np.float64) + positions = _cad_to_gltf_rows(vertices[corners]) # (n, 3 corners, 3 axes) + + # A triangle whose corners collapse to fewer than three distinct positions + # (after binary32 rounding) is dropped. + distinct = ( + (positions[:, 0] != positions[:, 1]).any(axis=1) + & (positions[:, 0] != positions[:, 2]).any(axis=1) + & (positions[:, 1] != positions[:, 2]).any(axis=1) + ) + keep = np.flatnonzero(distinct) + positions = positions[keep] + a, b, c = positions[:, 0], positions[:, 1], positions[:, 2] + cross = _cross_rows(b - a, c - a) + nonzero = ~(cross == 0.0).all(axis=1) + keep = keep[nonzero] + positions = positions[nonzero] + cross = cross[nonzero] + if len(keep) == 0: raise ValueError(f"face {entity_id} has no non-degenerate triangle") + + fallback = _normalize_rows(cross) + raw = np.asarray(mesh.corner_normals[start:stop], dtype=np.float64)[keep] # (n, 3 corners, 3) + given = ~(raw == 0.0).all(axis=2) # corners that carry a normal of their own + normals = np.broadcast_to(fallback[:, None, :], raw.shape).copy() + if given.any(): + normals[given] = _normalize_rows(_cad_to_gltf_rows(raw[given])) + + # One 24-byte key per corner: position bits, then normal bits. + packed = np.concatenate([positions, normals], axis=2).astype(np.float32).astype(" "np.ndarray": + """:func:`profile_f32` of every value: binary32 rounding, ``-0`` as ``+0`` (as binary64).""" + + if not np.isfinite(values).all(): + raise ValueError("f32 input must be a finite number") + with np.errstate(over="ignore"): + narrow = values.astype(np.float32) + if not np.isfinite(narrow).all(): + raise ValueError("f32 conversion overflowed") + return np.where(narrow == 0, np.float32(0.0), narrow).astype(np.float64) + + +def _cad_to_gltf_rows(points: "np.ndarray") -> "np.ndarray": + """:func:`cad_to_gltf` of every point (the last axis is x, y, z).""" + + return _f32_rows( + np.stack( + [points[..., 0] / 1000.0, points[..., 2] / 1000.0, (-points[..., 1]) / 1000.0], + axis=-1, + ) + ) + + +def _cross_rows(left: "np.ndarray", right: "np.ndarray") -> "np.ndarray": + """:func:`profile_cross` of every pair, in its fixed operation order.""" + + p1x, p2x = left[:, 1] * right[:, 2], left[:, 2] * right[:, 1] + p1y, p2y = left[:, 2] * right[:, 0], left[:, 0] * right[:, 2] + p1z, p2z = left[:, 0] * right[:, 1], left[:, 1] * right[:, 0] + result = np.stack([p1x - p2x, p1y - p2y, p1z - p2z], axis=1) + if not np.isfinite(result).all(): + raise ValueError("cross product produced a non-finite value") + return result + + +def _normalize_rows(vectors: "np.ndarray") -> "np.ndarray": + """:func:`profile_normalize` of every vector.""" + + if not np.isfinite(vectors).all(): + raise ValueError("normalize input must contain finite values") + squared = vectors[:, 0] * vectors[:, 0] + vectors[:, 1] * vectors[:, 1] + squared = squared + vectors[:, 2] * vectors[:, 2] + if not (np.isfinite(squared).all() and (squared > 0).all()): + raise ValueError("normalize input has zero or non-finite length") + result = _f32_rows(vectors / np.sqrt(squared)[:, None]) + norm_squared = result[:, 0] * result[:, 0] + result[:, 1] * result[:, 1] + norm_squared = norm_squared + result[:, 2] * result[:, 2] + norm = np.sqrt(norm_squared) + if not ((norm >= 1.0 - 1e-6) & (norm <= 1.0 + 1e-6)).all(): + raise ValueError("normalized binary32 vector is outside the profile tolerance") + return result + + def _edge_block( edge, *, diff --git a/src/simplecadapi/skill/cli.py b/src/simplecadapi/skill/cli.py index c10c4fc2..8073c898 100644 --- a/src/simplecadapi/skill/cli.py +++ b/src/simplecadapi/skill/cli.py @@ -15,6 +15,13 @@ def configure(subparsers: argparse._SubParsersAction) -> None: ) commands = skill.add_subparsers(dest="command", required=True) commands.add_parser("targets", help="list supported harness targets") + where = commands.add_parser( + "where", help="print where `install` would put the skill (nothing is written)" + ) + where.add_argument( + "--skills-dir", type=Path, + help="skills parent directory (default: config/env or ~/.agents/skills)", + ) install = commands.add_parser( "install", help="compile the bundled skill into /" ) @@ -37,6 +44,9 @@ def _run(args: argparse.Namespace) -> tuple[dict[str, Any], int]: if args.command == "targets": return {"targets": list(project.targets)}, 0 skills_dir = resolve_paths(skills_dir=args.skills_dir).skills_dir + if args.command == "where": + destination = skills_dir.expanduser().absolute() / project.name + return {"where": str(destination), "exists": destination.exists()}, 0 report = build_skill( project=project, target=args.target, @@ -50,6 +60,8 @@ def _run(args: argparse.Namespace) -> tuple[dict[str, Any], int]: def _print(report: dict[str, Any]) -> None: if "targets" in report: print("\n".join(report["targets"])) + elif "where" in report: + print(report["where"]) else: print( f"installed {report['name']} for {report['target']}: " diff --git a/test/test_drawing_lazy_pymupdf.py b/test/test_drawing_lazy_pymupdf.py new file mode 100644 index 00000000..fd3866b7 --- /dev/null +++ b/test/test_drawing_lazy_pymupdf.py @@ -0,0 +1,34 @@ +"""pymupdf stays off the package import path; drawing functions name the extra.""" + +import builtins +import subprocess +import sys + +import pytest + +from simplecadapi.inspect.drawing._pymupdf import _LazyPymupdf + + +def test_package_import_does_not_pull_pymupdf() -> None: + code = ( + "import sys, simplecadapi; " + "assert 'pymupdf' not in sys.modules, 'pymupdf imported eagerly'" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_missing_pymupdf_names_the_drawing_extra(monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def poisoned(name, *args, **kwargs): + if name == "pymupdf": + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", poisoned) + with pytest.raises(ImportError, match=r"simplecadapi\[drawing\]"): + _LazyPymupdf().Rect + + +def test_present_pymupdf_passes_through() -> None: + assert callable(_LazyPymupdf().open) diff --git a/test/test_editor.py b/test/test_editor.py new file mode 100644 index 00000000..f0021c03 --- /dev/null +++ b/test/test_editor.py @@ -0,0 +1,825 @@ +"""The editor's server-side logic and templates (no kernel is started). + +An end-to-end run against a real marimo kernel is opt-in: +``SCA_EDITOR_E2E=1 pytest test/test_editor.py``. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + +import pytest + +pytest.importorskip("starlette") + +from simplecadapi.editor.server import WorkspaceState, _is_function_cell, _module_code, pick_notebook +from simplecadapi.editor.templates import create_notebook, render_notebook +from simplecadapi.runtime import run_notebook +from simplecadapi.runtime.params import ParamSpec, parameters_in_cell + + +def specs_of(code: str) -> dict[str, ParamSpec]: + return {spec.name: spec for spec in parameters_in_cell(code)} + + +class TemplatesTest(unittest.TestCase): + def test_new_part_runs_and_offers_sliders(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = create_notebook(Path(tmp), "bracket", "part") + run = run_notebook(path) + self.assertEqual(run.definition.definition_kind, "single_solid") + self.assertEqual(run.definition.definition_id, "bracket") + from simplecadapi.runtime.params import list_parameters + + controls = {spec.name: spec.control for spec in list_parameters(path)} + self.assertEqual(controls, {"RADIUS": "slider", "HEIGHT": "slider"}) + + def test_new_assembly_uses_the_workspace_parts(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with self.assertRaisesRegex(ValueError, "create a part first"): + create_notebook(root, "unit", "assembly") + create_notebook(root, "bracket", "part") + create_notebook(root, "pin-a", "part") + path = create_notebook(root, "unit", "assembly") + run = run_notebook(path) + self.assertEqual(run.definition.definition_kind, "assembly") + self.assertEqual(run.definition.definition_id, "unit") + + def test_names_and_kinds_are_checked(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + for bad in ("", "1abc", "a b", "../x"): + with self.assertRaises(ValueError): + create_notebook(Path(tmp), bad) + with self.assertRaises(ValueError): + render_notebook("x", "gadget") + create_notebook(Path(tmp), "once") + with self.assertRaises(FileExistsError): + create_notebook(Path(tmp), "once") + + +class PickNotebookTest(unittest.TestCase): + def test_prefers_the_asked_one_then_the_top_assembly(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.assertIsNone(pick_notebook(root)) + create_notebook(root, "a-part", "part") + create_notebook(root, "b-part", "part") + self.assertEqual(pick_notebook(root), (root / "a-part.py").resolve()) + create_notebook(root, "top", "assembly") + self.assertEqual(pick_notebook(root), (root / "top.py").resolve()) + self.assertEqual(pick_notebook(root, root / "b-part.py"), (root / "b-part.py").resolve()) + + +class ParamCleaningTest(unittest.TestCase): + def state(self) -> WorkspaceState: + state = WorkspaceState(Path(tempfile.gettempdir())) + state.specs = specs_of( + "# ---- params ----\n" + "W = 20.0 # ui: slider(10, 40)\n" + "N = 4 # ui: slider(2, 8)\n" + "F = False\n" + ) + return state + + def test_values_are_clamped_and_typed(self) -> None: + clean = self.state()._clean_params({"W": 99, "N": 3.6, "F": 1, "NOPE": 1}) + self.assertEqual(clean, {"W": 40.0, "N": 4, "F": True}) + self.assertIsInstance(clean["N"], int) + + def test_wrong_kinds_are_dropped(self) -> None: + self.assertEqual(self.state()._clean_params({"W": "7", "F": "yes"}), {}) + self.assertEqual(self.state()._clean_params({"N": True}), {}) + + +class ModuleCodeTest(unittest.TestCase): + def test_top_level_return_is_stripped_for_code_mode(self) -> None: + code = "x = 1\nif x:\n return\nreturn (x,)" + self.assertEqual(_module_code(code), "x = 1\nif x:\n pass\n(x,)") + + def test_returns_inside_functions_and_classes_are_code(self) -> None: + code = ( + "def f(x):\n if x:\n return 1\n return\n" + "class C:\n def g(self):\n return self\n" + "g = lambda: 1\n" + ) + self.assertEqual(_module_code(code), code.rstrip("\n")) + + def test_only_the_cells_own_return_goes_and_comments_stay(self) -> None: + code = "def f():\n return 2 # keep\ny = f() # call\nreturn (y,) # export" + self.assertEqual( + _module_code(code), + "def f():\n return 2 # keep\ny = f() # call\n(y,) # export", + ) + + +class FunctionCellTest(unittest.TestCase): + def test_a_function_cell_is_one_def_or_class(self) -> None: + self.assertTrue(_is_function_cell("def f(x):\n return x")) + self.assertTrue(_is_function_cell("class C:\n pass")) + self.assertFalse(_is_function_cell("def f():\n pass\nf()")) + self.assertFalse(_is_function_cell("x = 1")) + self.assertFalse(_is_function_cell("def broken(")) + + def test_editing_one_is_refused_and_unchanged_code_is_not(self) -> None: + state = WorkspaceState(Path(tempfile.gettempdir())) + state.kernel = type("K", (), {"run_cells": lambda self, ids: {}, "wait_idle": lambda self: True})() # type: ignore[assignment] + state.cells = [{"id": "f1", "code": "def f():\n return 1"}] + state.schedule_settle = lambda: None # type: ignore[method-assign] + refused = state.dispatch_command({"type": "code.edit", "cell": "f1", "code": "def f():\n return 2"}) + self.assertEqual(refused["type"], "error") + self.assertIn("@app.function", refused["message"]) + accepted = state.dispatch_command({"type": "code.edit", "cell": "f1", "code": "def f():\n return 1"}) + self.assertEqual(accepted["type"], "ack") + + +@unittest.skipUnless(os.environ.get("SCA_EDITOR_E2E"), "set SCA_EDITOR_E2E=1 to boot a marimo kernel") +class EditorEndToEndTest(unittest.TestCase): + def test_slider_rebuilds_the_part_and_commit_writes_the_source(self) -> None: + import threading + + import uvicorn + + from simplecadapi.editor.server import create_app + from simplecadapi.runtime.params import read_saved_params + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = create_notebook(root, "bracket", "part") + state = WorkspaceState(root) + state.port = 17731 + app = create_app(state) + server = uvicorn.Server(uvicorn.Config(app, 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) + self.assertIn("RADIUS", state.specs) + first = state.latest_preview_bytes + self.assertTrue(first) + state.dispatch_command({"type": "param.set", "values": {"RADIUS": 40}}) + deadline = time.monotonic() + 90 + while state.latest_preview_bytes == first and time.monotonic() < deadline: + time.sleep(0.5) + self.assertNotEqual(state.latest_preview_bytes, first) + self.assertEqual(read_saved_params(state.config), {"RADIUS": 40.0}) + state.dispatch_command({"type": "param.commit", "names": ["RADIUS"]}) + deadline = time.monotonic() + 60 + while "40.0" not in path.read_text() and time.monotonic() < deadline: + time.sleep(0.5) + self.assertIn('scad.var("radius", 40.0', path.read_text()) + self.assertEqual(read_saved_params(state.config), {}) + with urllib.request.urlopen(f"http://127.0.0.1:{state.port}/api/health") as response: + self.assertTrue(json.load(response)["kernel"]) + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True + + +if __name__ == "__main__": + unittest.main() + + +# --------------------------------------------------------------------------- +# Selection as notebook code, the step titles, the agent snapshot +# --------------------------------------------------------------------------- + +from simplecadapi.editor.selection import ( + MARKER, + selection_cell_code, + selector_expression, + variable_names, +) +from simplecadapi.editor.server import cell_title + +FACE = {"centroid": [1.0, 2.0, 3.0], "area": 100.0} +EDGE = {"centroid": [0.0, 0.0, 5.0], "length": 12.5} + + +class SelectionCodeTest(unittest.TestCase): + def test_a_face_is_selected_by_type_place_and_size(self) -> None: + expression = selector_expression("face", FACE, {"type": "plane"}) + assert expression is not None + self.assertTrue(expression.startswith("scad.ql.faces().where(scad.ql.and_(")) + self.assertTrue(expression.endswith(".exactly(1)")) + for part in ('"geom.type", "==", "PLANE"', '"geom.center.x", ">=", 0.9', '"geom.area", "<=", 101'): + self.assertIn(part, expression) + + def test_a_solid_is_selected_by_volume_only(self) -> None: + expression = selector_expression("solid", {"centroid": [1, 2, 3], "volume": 50.0}, {"type": "brep_solid"}) + assert expression is not None + self.assertIn("geom.volume", expression) + self.assertNotIn("geom.center", expression) + + def test_nothing_to_select_by_gives_none(self) -> None: + self.assertIsNone(selector_expression("face", {}, {})) + self.assertIsNone(selector_expression("gadget", FACE, {})) + + def test_one_item_per_kind_is_named_by_its_kind(self) -> None: + code = selection_cell_code([{"kind": "face", "properties": FACE, "geometry": {"type": "plane"}}]) + self.assertTrue(code.startswith(MARKER)) + self.assertIn("selected_face = scad.ql.faces()", code) + self.assertTrue(code.endswith("selected_item = [selected_face]")) + compile(code, "", "exec") + + def test_several_items_are_numbered_per_kind_and_listed(self) -> None: + items = [ + {"kind": "edge", "properties": EDGE, "geometry": {"type": "line"}}, + {"kind": "face", "properties": FACE, "geometry": {"type": "plane"}}, + {"kind": "edge", "properties": EDGE, "geometry": {"type": "circle"}}, + ] + self.assertEqual(variable_names(items), ["selected_edge_1", "selected_face", "selected_edge_2"]) + code = selection_cell_code(items) + self.assertTrue(code.endswith("selected_item = [selected_edge_1, selected_face, selected_edge_2]")) + compile(code, "", "exec") + + def test_an_empty_selection_is_an_empty_list(self) -> None: + self.assertEqual(selection_cell_code([]), f"{MARKER}\nselected_item = []") + + def test_a_component_is_its_node_id(self) -> None: + code = selection_cell_code([{"kind": "component", "node_id": "node/asm/shell"}]) + self.assertIn("selected_component = 'node/asm/shell'", code) + + def test_the_generated_selectors_find_the_picked_entities(self) -> None: + import simplecadapi as scad + + body = scad.make_box_rsolid(10.0, 20.0, 30.0) + for kind, entity in ( + ("solid", {"properties": {"volume": body.get_volume()}, "geometry": {"type": "brep_solid"}}), + ("face", {"properties": {"centroid": [0.0, 0.0, 0.0], "area": 200.0}, "geometry": {"type": "plane"}}), + ("vertex", {"properties": {"position": [-5.0, -10.0, 0.0]}, "geometry": {"type": "point"}}), + ): + expression = selector_expression(kind, entity["properties"], entity["geometry"]) + assert expression is not None + found = eval(expression, {"scad": scad}).resolve(body) # noqa: S307 + self.assertEqual(len(found), 1, kind) + + +class CellTitleTest(unittest.TestCase): + def test_the_name_comes_from_the_most_specific_source(self) -> None: + self.assertEqual(cell_title("x = 1", "base-plate"), "base-plate") + self.assertEqual(cell_title(f"{MARKER}\nselected_item = []", None), "selection") + self.assertEqual(cell_title("# ---- params ----\nA = 1", None), "params") + self.assertEqual(cell_title("# ---- params: disc ----\nA = 1", None), "params: disc") + self.assertEqual(cell_title("_t = 1\nbody = _t", None), "body") + self.assertEqual(cell_title("def mount(x):\n return x", None), "mount") + self.assertEqual(cell_title("# only a comment", None), "only a comment") + self.assertEqual(cell_title("print(1)", None), "cell") + + +class SnapshotTest(unittest.TestCase): + def test_the_snapshot_is_written_and_read_back_by_the_cli(self) -> None: + from simplecadapi import cli as sca_cli + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state = WorkspaceState(root) + state.cells = [{"id": "a", "code": "x = 1", "title": "x", "status": "idle", "errors": []}] + state.selection_items = [{"kind": "face", "entity_id": "e/1", "node_id": "n/1", "definition_id": "d"}] + state.write_snapshot() + data = json.loads((root / ".sca-editor" / "snapshot.json").read_text()) + self.assertEqual(data["selection"]["items"][0]["variable"], "selected_face") + self.assertEqual(data["cells"][0]["title"], "x") + report, code, _printer = sca_cli.run(["snapshot", str(root)]) + self.assertEqual((code, report["selection"]["items"][0]["entity_id"]), (0, "e/1")) + png = b"\x89PNG\r\n\x1a\nrest" + state.save_viewport(png) + self.assertEqual((root / ".sca-editor" / "viewport.png").read_bytes(), png) + self.assertTrue(state.snapshot()["viewport_png"].endswith("viewport.png")) + with self.assertRaises(ValueError): + sca_cli.run(["snapshot", str(root / "nowhere")]) + + +class RunAboveTest(unittest.TestCase): + def test_run_above_runs_the_cells_before_this_one_in_order(self) -> None: + ran: list[list[str]] = [] + state = WorkspaceState(Path(tempfile.gettempdir())) + state.kernel = type( # type: ignore[assignment] + "K", (), {"run_cells": lambda self, ids: ran.append(list(ids)) or {}, "wait_idle": lambda self: True} + )() + state.cells = [{"id": name, "code": ""} for name in ("a", "b", "c")] + state.schedule_settle = lambda: None # type: ignore[method-assign] + for _ in range(2): + state.dispatch_command({"type": "rerun.request", "cell": "c", "above": True}) + state.dispatch_command({"type": "rerun.request", "cell": "a", "above": True}) + deadline = time.monotonic() + 3 + while len(ran) < 2 and time.monotonic() < deadline: + time.sleep(0.05) + self.assertEqual(ran[:2], [["a", "b"], ["a", "b"]]) + self.assertEqual(len(ran), 2) # nothing is above the first cell + + +class CellLinesTest(unittest.TestCase): + NOTEBOOK = ( + "import marimo\n\napp = marimo.App()\n\nwith app.setup:\n import simplecadapi as scad\n\n\n" + "@app.cell\ndef _():\n a = 1\n b = 2\n return a, b\n\n\n" + "@app.cell\ndef _(a):\n c = a\n return (c,)\n" + ) + + def test_cells_are_located_in_the_file_by_order(self) -> None: + from simplecadapi.editor.server import file_line_ranges + + lines = self.NOTEBOOK.splitlines() + ranges = file_line_ranges(self.NOTEBOOK, ["a = 1\nb = 2", "c = a"]) + self.assertEqual(lines[ranges[0][0] - 1].strip(), "a = 1") + self.assertEqual(lines[ranges[0][1] - 1].strip(), "b = 2") + self.assertEqual(lines[ranges[1][0] - 1].strip(), "c = a") + + def test_an_unsaved_cell_has_no_location(self) -> None: + from simplecadapi.editor.server import file_line_ranges + + self.assertEqual(file_line_ranges(self.NOTEBOOK, ["a = 1\nb = 2", "c = 99"])[1], None) + self.assertEqual(file_line_ranges("def broken(", ["x"]), [None]) + + +class ModulesTest(unittest.TestCase): + def _workspace(self, root: Path) -> None: + (root / "dims.py").write_text( + "from dataclasses import dataclass\n\n@dataclass(frozen=True)\nclass D:\n L: float = 80.0\n\nDIMS = D()\n" + ) + (root / "helper.py").write_text("def helper():\n return 1\n") + notebook = create_notebook(root, "part", "part") + notebook.write_text( + notebook.read_text().replace( + "with app.setup:\n import simplecadapi as scad\n", + "with app.setup:\n import simplecadapi as scad\n from dims import DIMS\n from helper import helper\n", + ) + ) + + def test_the_workspace_lists_the_modules_notebooks_import(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._workspace(root) + state = WorkspaceState(root) + modules = {m["path"]: m for m in state.modules()} + self.assertEqual(sorted(modules), ["dims.py", "helper.py"]) + self.assertEqual(modules["dims.py"]["used_by"], ["part.py"]) + + def test_a_module_opens_with_its_dataclass_fields_as_parameters(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._workspace(root) + state = WorkspaceState(root) + seen: list[dict] = [] + state.emit = seen.append # type: ignore[method-assign] + state.open_module("dims.py") + envelope = seen[-1] + self.assertEqual((envelope["type"], envelope["origin"], envelope["used_by"]), ("module", "open", ["part.py"])) + self.assertEqual([p["id"] for p in envelope["params"]], ["D.L"]) + with self.assertRaises(ValueError): + state.open_module("../outside.py") + + def test_a_parameter_edit_is_written_to_the_module_and_propagated(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._workspace(root) + state = WorkspaceState(root) + state.emit = lambda envelope: None # type: ignore[method-assign] + changed: list[set[Path]] = [] + state._files_changed = lambda paths, announce=True: changed.append(paths) # type: ignore[method-assign] + state.open_module("dims.py") + state.set_module_params("dims.py", {"D.L": 120}) + self.assertIn("L: float = 120.0", (root / "dims.py").read_text()) + self.assertEqual(changed, [{(root / "dims.py").resolve()}]) + state.save_module("dims.py", "X = 1\n") + self.assertEqual((root / "dims.py").read_text(), "X = 1\n") + self.assertEqual(len(changed), 2) + + +class SelectionLatencyTest(unittest.TestCase): + """A pick shows up at once and the kernel only ever writes the newest selection.""" + + def test_the_cell_is_pushed_before_the_kernel_writes_and_writes_coalesce(self) -> None: + import threading + + from simplecadapi.editor.server import PENDING_SELECTION_ID + + gate = threading.Event() + written: list[str] = [] + + class Kernel: + def put_cell(self, code: str, *, cell_id: str | None, run: bool = True) -> str: + gate.wait(5) # the kernel is busy with something long + written.append(code) + return cell_id or "real" + + def wait_idle(self, timeout: float = 0) -> bool: + return True + + state = WorkspaceState(Path(tempfile.gettempdir())) + kernel = Kernel() + state.kernel = kernel # type: ignore[assignment] + sent: list[dict] = [] + state.emit = sent.append # type: ignore[method-assign] + state.push_cells = lambda: None # type: ignore[method-assign] + state.write_snapshot = lambda: None # type: ignore[method-assign] + face = {"kind": "face", "properties": FACE, "geometry": {"type": "plane"}} + worker = threading.Thread(target=state._set_selection, args=(kernel, [face])) + worker.start() + deadline = time.monotonic() + 3 + while not any(m["type"] == "notebook.cells" for m in sent) and time.monotonic() < deadline: + time.sleep(0.01) + cells = next(m for m in sent if m["type"] == "notebook.cells")["cells"] + self.assertEqual(cells[-1]["id"], PENDING_SELECTION_ID) # shown while the kernel is still busy + self.assertEqual(written, []) + # two more picks while the first write is stuck: only the newest is written afterwards + edge = {"kind": "edge", "properties": EDGE, "geometry": {"type": "line"}} + state._set_selection(kernel, [face, edge]) + state._set_selection(kernel, [edge]) + gate.set() + worker.join(5) + self.assertEqual(len(written), 2) # the first, then the newest + self.assertIn("selected_edge = ", written[-1]) + self.assertNotIn("selected_face", written[-1]) + + +@unittest.skipUnless(os.environ.get("SCA_EDITOR_E2E"), "set SCA_EDITOR_E2E=1 to boot a marimo kernel") +class KernelCacheTest(unittest.TestCase): + """The editor kernel shares the cell cache: a changed module recomputes only what reads it.""" + + NOTEBOOK = ( + "# /// script\n# [tool.simplecadapi]\n# id = \"two\"\n# ///\n" + "import marimo\n\napp = marimo.App()\n\nwith app.setup:\n import simplecadapi as scad\n\n\n" + "@app.cell\ndef _():\n from lib.alpha import A\n" + " open('/tmp/_sca_cache_log.txt', 'a').write('A')\n return (A,)\n\n\n" + "@app.cell\ndef _():\n from lib.beta import B\n" + " open('/tmp/_sca_cache_log.txt', 'a').write('B')\n return (B,)\n\n\n" + "@app.cell\ndef _(A, B):\n part = scad.Part(part_id='two', body=scad.make_box_rsolid(A, B, 1.0))\n" + " open('/tmp/_sca_cache_log.txt', 'a').write('P')\n return (part,)\n" + ) + + def test_only_the_cells_that_read_a_changed_module_run_again(self) -> None: + import threading + + import uvicorn + + from simplecadapi.editor.server import create_app + + log = Path("/tmp/_sca_cache_log.txt") + log.unlink(missing_ok=True) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + (root / "lib").mkdir() + (root / "lib" / "alpha.py").write_text("A = 10.0\n") + (root / "lib" / "beta.py").write_text("B = 6.0\n") + path = root / "two.py" + path.write_text(self.NOTEBOOK) + state = WorkspaceState(root) + state.port = 17733 + app = create_app(state) + server = uvicorn.Server(uvicorn.Config(app, 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) + first = log.read_text() + self.assertEqual(sorted(first), ["A", "B", "P"]) # everything ran once + (root / "lib" / "alpha.py").write_text("A = 12.0\n") + kernel = state.kernel + assert kernel is not None + kernel.reload_modules(["lib", "lib.alpha", "lib.beta"]) + kernel.wait_idle(120) + again = log.read_text()[len(first):] + # alpha changed: the cell that reads it and the part rerun; beta's cell is restored. + self.assertEqual(sorted(again), ["A", "P"]) + # going back is restored for both + (root / "lib" / "alpha.py").write_text("A = 10.0\n") + before = log.read_text() + kernel.reload_modules(["lib", "lib.alpha", "lib.beta"]) + kernel.wait_idle(120) + self.assertEqual(log.read_text()[len(before):], "") + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True + log.unlink(missing_ok=True) + + +class WatchedFilesTest(unittest.TestCase): + """What the editor watches: modules, child notebooks, and the data files a header lists.""" + + def test_input_files_of_the_notebook_and_its_children_are_watched(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + (root / "data").mkdir() + (root / "data" / "profile.dxf").write_text("0\nEOF\n") + (root / "data" / "child.csv").write_text("1,2\n") + header = '# /// script\n# [tool.simplecadapi]\n# id = "{id}"\n# inputs = ["{inputs}"]\n# ///\n' + body = "import marimo\n\napp = marimo.App()\n\nwith app.setup:\n import simplecadapi as scad\n" + (root / "child.py").write_text(header.format(id="child", inputs="data/child.csv") + body) + (root / "top.py").write_text( + header.format(id="top", inputs="data/profile.dxf") + + body + + '\n\n@app.cell\ndef _():\n c = scad.use("child.py")\n return (c,)\n' + ) + state = WorkspaceState(root, root / "top.py") + mtimes, _own, inputs = state._watched_files() + self.assertEqual(inputs, {root / "data" / "profile.dxf"}) + self.assertIn(root / "data" / "profile.dxf", mtimes) + self.assertIn(root / "data" / "child.csv", mtimes) # a child's data: its cells rerun + self.assertIn(root / "child.py", mtimes) + + +@unittest.skipUnless(os.environ.get("SCA_EDITOR_E2E"), "set SCA_EDITOR_E2E=1 to boot a marimo kernel") +class LongCellTest(unittest.TestCase): + """A cell that runs longer than the bridge's probes must not be interrupted by them.""" + + def test_a_long_cell_survives_the_bridge_polling_the_kernel(self) -> None: + import threading + + import uvicorn + + from simplecadapi.editor.server import create_app + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = root / "slow.py" + path.write_text( + "# /// script\n# [tool.simplecadapi]\n# id = \"slow\"\n# ///\n" + "import marimo\n\napp = marimo.App()\n\nwith app.setup:\n import simplecadapi as scad\n\n\n" + "@app.cell\ndef _():\n import time\n time.sleep(22)\n done = 1\n return (done,)\n" + ) + state = WorkspaceState(root) + state.port = 17735 + app = create_app(state) + server = uvicorn.Server(uvicorn.Config(app, port=state.port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + try: + # start_kernel waits for the notebook to go idle with scratch probes while the + # cell sleeps: they used to time out after 15 s and interrupt the cell. + state.start_kernel(path) + kernel = state.kernel + assert kernel is not None + self.assertTrue(kernel.session_exists()) + cell = next(c for c in kernel.list_cells() if "time.sleep" in c["code"]) + self.assertEqual(cell["status"], "idle") + self.assertEqual(cell["errors"], []) + self.assertIn("DONE 1", kernel.execute("print('DONE', done)").get("log", "").upper()) + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True + + +class RestoreTest(unittest.TestCase): + """Switching back to a notebook shows its last model at once, if nothing it depends on changed.""" + + def _workspace(self, root: Path) -> Path: + (root / "dims.py").write_text("L = 10.0\n") + path = create_notebook(root, "part", "part") + path.write_text(path.read_text().replace("import simplecadapi as scad\n", "import simplecadapi as scad\n import dims\n", 1)) + return path + + def test_the_signature_follows_what_the_model_depends_on(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = self._workspace(root) + state = WorkspaceState(root, path) + first = state._signature(path) + self.assertEqual(first, state._signature(path)) + time.sleep(2.1) # file digests are remembered per (mtime, size) once settled + (root / "dims.py").write_text("L = 11.0\n") + changed = state._signature(path) + self.assertNotEqual(first, changed) # a module it imports + path.write_text(path.read_text() + "\n# edited\n") + self.assertNotEqual(changed, state._signature(path)) # the notebook itself + + def test_a_package_is_recalled_from_memory_and_from_disk_for_its_signature_only(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = self._workspace(root) + state = WorkspaceState(root, path) + state.remember_model(path, "sig-1", b"PKG") + self.assertEqual(state._recall_model(path, "sig-1"), b"PKG") + self.assertIsNone(state._recall_model(path, "sig-2")) + fresh = WorkspaceState(root, path) # a new server: only the disk remembers + self.assertEqual(fresh._recall_model(path, "sig-1"), b"PKG") + self.assertIsNone(fresh._recall_model(path, "sig-2")) + + def test_restoring_shows_the_cells_and_the_model_before_any_kernel(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = self._workspace(root) + state = WorkspaceState(root, path) + sent: list[dict] = [] + state.emit = sent.append # type: ignore[method-assign] + state.hub.broadcast = lambda message: None # type: ignore[method-assign] + self.assertFalse(state._restore(path)) # nothing published yet + state.remember_model(path, state._signature(path), b"PKG") + self.assertTrue(state._restore(path)) + package = next(m for m in sent if m["type"] == "preview") + self.assertEqual((package["cached"], package["size"]), (True, 3)) + self.assertEqual(state.latest_preview_bytes, b"PKG") + cells = next(m for m in sent if m["type"] == "notebook.cells")["cells"] + self.assertEqual([c["title"] for c in cells], ["params", "base", "part"]) + self.assertTrue(all(c["readonly"] for c in cells)) # not editable until a kernel has them + self.assertIn("RADIUS", state.specs) + + def test_static_cells_leave_out_the_setup_cell(self) -> None: + from simplecadapi.editor.server import _static_cells + + with tempfile.TemporaryDirectory() as tmp: + path = create_notebook(Path(tmp), "part", "part") + names = [cell["name"] for cell in _static_cells(path)] + self.assertNotIn("setup", names) + self.assertEqual(len(names), 3) + + +class SwitchOrderTest(unittest.TestCase): + """Opening one notebook after another: the last one asked for is the one that ends up open.""" + + def test_the_last_open_wins_however_the_threads_are_scheduled(self) -> None: + import simplecadapi.editor.server as server + + booted: list[str] = [] + + class FakeKernel: + marimo_url = "http://127.0.0.1:1" + alive = True + + def __init__(self, notebook, **_kw) -> None: + self.notebook = notebook + self._stop = __import__("threading").Event() + + def start(self) -> None: + time.sleep(0.2) + if self._stop.is_set(): + raise RuntimeError("stopped while booting") + booted.append(self.notebook.stem) + + def stop(self) -> None: + self._stop.set() + + def wait_instantiated(self, *_a, **_k) -> bool: + return True + + def wait_idle(self, *_a, **_k) -> bool: + return True + + original = server.MarimoKernel + server.MarimoKernel = FakeKernel # type: ignore[misc] + try: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + a = create_notebook(root, "alpha", "part") + b = create_notebook(root, "beta", "part") + state = WorkspaceState(root, a) + sent: list[dict] = [] + state.hub.broadcast = sent.append # type: ignore[method-assign] + state.settle = lambda: None # type: ignore[method-assign] + state._watch_files = lambda kernel: None # type: ignore[method-assign] + first = state.reserve_switch() + second = state.reserve_switch() + # the later request's thread runs first, the earlier one after it + state.open_notebook("beta.py", second) + state.open_notebook("alpha.py", first) + self.assertEqual(state.notebook, b) + self.assertEqual(booted, ["beta"]) + self.assertEqual([m for m in sent if m.get("error")], []) + finally: + server.MarimoKernel = original # type: ignore[misc] + + def test_an_open_superseded_while_booting_ends_quietly(self) -> None: + import threading + + import simplecadapi.editor.server as server + + class SlowKernel: + marimo_url = "http://127.0.0.1:1" + alive = True + + def __init__(self, notebook, **_kw) -> None: + self._stop = threading.Event() + + def start(self) -> None: + self._stop.wait(5) + raise RuntimeError("marimo exited with code -15") + + def stop(self) -> None: + self._stop.set() + + def wait_instantiated(self, *_a, **_k) -> bool: + return True + + def wait_idle(self, *_a, **_k) -> bool: + return True + + original = server.MarimoKernel + server.MarimoKernel = SlowKernel # type: ignore[misc] + try: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + create_notebook(root, "alpha", "part") + b = create_notebook(root, "beta", "part") + state = WorkspaceState(root, None) + state.hub.broadcast = lambda message: None # type: ignore[method-assign] + errors: list[BaseException] = [] + + def first() -> None: + try: + state.open_notebook("alpha.py") + except BaseException as exc: # noqa: BLE001 + errors.append(exc) + + thread = threading.Thread(target=first) + thread.start() + time.sleep(0.3) # alpha is booting + state.reserve_switch() + state._begin_switch(b, state._generation) # beta is asked for: alpha's kernel is stopped + thread.join(5) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(state.notebook, b) + finally: + server.MarimoKernel = original # type: ignore[misc] + + +class InterruptTest(unittest.TestCase): + def test_the_stop_command_interrupts_the_kernel(self) -> None: + state = WorkspaceState(Path(tempfile.gettempdir())) + stopped: list[bool] = [] + state.kernel = type("K", (), {"interrupt": lambda self: stopped.append(True)})() # type: ignore[assignment] + self.assertEqual(state.dispatch_command({"type": "interrupt.request"})["type"], "ack") + deadline = time.monotonic() + 2 + while not stopped and time.monotonic() < deadline: + time.sleep(0.01) + self.assertEqual(stopped, [True]) + + def test_cell_states_from_marimo_reach_the_webuis(self) -> None: + from simplecadapi.editor.kernel import MarimoKernel + + seen: list[tuple[str, str]] = [] + kernel = MarimoKernel(Path(tempfile.gettempdir()) / "x.py", server_port=40000, on_cell_status=lambda i, s: seen.append((i, s))) + kernel._on_message(json.dumps({"op": "cell-op", "data": {"cell_id": "Hbol", "status": "running"}})) + kernel._on_message(json.dumps({"op": "cell-op", "data": {"cell_id": "Hbol", "status": None}})) + kernel._on_message("not json") + # the setup cell and the bridge's own scratch snippets are not cells of the notebook + kernel._on_message(json.dumps({"op": "cell-op", "data": {"cell_id": "setup", "status": "running"}})) + kernel._on_message(json.dumps({"op": "cell-op", "data": {"cell_id": "__scratch__", "status": "running"}})) + self.assertEqual(seen, [("Hbol", "running")]) + state = WorkspaceState(Path(tempfile.gettempdir())) + sent: list[dict] = [] + state.hub.broadcast = sent.append # type: ignore[method-assign] + state.cells = [{"id": "Hbol", "code": "", "status": "idle", "title": "base"}] + state.cell_status("Hbol", "queued") + self.assertEqual(state.cells[0]["status"], "queued") + self.assertEqual(sent, [{"type": "cell.status", "id": "Hbol", "status": "queued", "title": "base"}]) + + +@unittest.skipUnless(os.environ.get("SCA_EDITOR_E2E"), "set SCA_EDITOR_E2E=1 to boot a marimo kernel") +class InterruptEndToEndTest(unittest.TestCase): + def test_a_running_cell_is_stopped_and_the_kernel_stays_usable(self) -> None: + import threading + + import uvicorn + + from simplecadapi.editor.server import create_app + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = root / "slow.py" + path.write_text( + '# /// script\n# [tool.simplecadapi]\n# id = "slow"\n# ///\n' + "import marimo\n\napp = marimo.App()\n\nwith app.setup:\n import simplecadapi as scad\n\n\n" + "@app.cell\ndef _():\n value = 1\n return (value,)\n\n\n" + "@app.cell\ndef _(value):\n import time\n time.sleep(60)\n slow = value\n return (slow,)\n" + ) + state = WorkspaceState(root) + state.port = 17737 + app = create_app(state) + server = uvicorn.Server(uvicorn.Config(app, port=state.port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + statuses: list[tuple[str, str]] = [] + try: + # boot without running the 60 s cell: start the kernel directly and interrupt the run + from simplecadapi.editor.kernel import MarimoKernel + + kernel = MarimoKernel(path, server_port=state.port, on_cell_status=lambda i, s: statuses.append((i, s))) + state.kernel = kernel + state.notebook = path + threading.Thread(target=kernel.start, daemon=True).start() + deadline = time.monotonic() + 60 + while not any(s == "running" for _, s in statuses) and time.monotonic() < deadline: + time.sleep(0.2) + self.assertTrue(any(s == "running" for _, s in statuses), "the slow cell never started") + started = time.monotonic() + kernel.interrupt() + deadline = time.monotonic() + 20 + while not any(s == "idle" for _, s in statuses[-3:]) and time.monotonic() < deadline: + time.sleep(0.2) + self.assertLess(time.monotonic() - started, 15) # stopped, not 60 s later + self.assertIn("OK", kernel.execute("print('ok')").get("log", "").upper()) # still usable + finally: + kernel = state.kernel + if kernel is not None: + kernel.stop() + server.should_exit = True diff --git a/test/test_example_cli.py b/test/test_example_cli.py new file mode 100644 index 00000000..1e5dd923 --- /dev/null +++ b/test/test_example_cli.py @@ -0,0 +1,46 @@ +"""``sca example``: a bundled example workspace copied out for the editor.""" +from pathlib import Path + +import pytest + +from simplecadapi.cli import main, run +from simplecadapi.runtime.workspace import scan_workspace + + +def test_without_a_name_the_examples_are_listed(): + report, code, printer = run(["example"]) + assert code == 0 and "u_link" in report["examples"] + printer(report) + + +def test_copy_makes_a_workspace_the_editor_can_open(tmp_path, capsys): + target = tmp_path / "demo" + report, code, printer = run(["example", "u_link", str(target)]) + assert code == 0 and Path(report["path"]) == target + assert report["edit"].startswith("sca edit ") and str(target) in report["edit"] + entries = {entry.path: entry for entry in scan_workspace(target)} + assert set(entries) == {"shell.py", "u_link.py", "u_link_motor_mount.py"} + assert entries["u_link_motor_mount.py"].kind == "assembly" + assert set(entries["u_link_motor_mount.py"].uses) == {"u_link.py", "shell.py"} + printer(report) + assert "open it:" in capsys.readouterr().out + + +def test_an_existing_directory_is_never_overwritten(tmp_path, capsys): + target = tmp_path / "mine" + target.mkdir() + (target / "notes.txt").write_text("keep me") + assert main(["example", "u_link", str(target)]) == 2 + assert "already exists" in capsys.readouterr().err + assert [path.name for path in target.iterdir()] == ["notes.txt"] + + +def test_the_default_directory_is_the_examples_own_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + report, _, _ = run(["example", "u_link"]) + assert Path(report["path"]) == tmp_path / "u_link_motor_mount" + + +def test_an_unknown_example_is_refused(): + with pytest.raises(SystemExit): + run(["example", "nope"]) diff --git a/test/test_notebook_params.py b/test/test_notebook_params.py new file mode 100644 index 00000000..42a2e973 --- /dev/null +++ b/test/test_notebook_params.py @@ -0,0 +1,278 @@ +"""Notebook parameters, features and workspace scanning (read from files).""" + +from __future__ import annotations + +import json +import tempfile +import textwrap +import unittest +from pathlib import Path + +import simplecadapi as scad +from simplecadapi.runtime.config import read_notebook_config +from simplecadapi.runtime.params import ( + apply_saved_params, + commit_value, + list_parameters, + parameters_in_cell, + read_saved_params, + write_saved_params, +) +from simplecadapi.runtime.workspace import ( + features_in_cell, + list_features, + scan_workspace, +) + +HEADER = '# /// script\n# [tool.simplecadapi]\n# id = "{id}"\n# ///\n' + + +def notebook(id: str, *cells: str) -> str: + body = "".join( + f"@app.cell\ndef _():\n{textwrap.indent(textwrap.dedent(c).strip(), ' ')}\n return\n\n\n" + for c in cells + ) + return HEADER.format(id=id) + "import marimo\n\napp = marimo.App()\n\n\n" + body + + +class ParameterCommentTest(unittest.TestCase): + def test_header_forms_and_inference(self) -> None: + for header in ("# ---- params ----", "# ---- params: disc ----"): + specs = parameters_in_cell(f"{header}\nA = 1.5\nN = 4\nF = True\nS = 'x'\n_t = 3\n") + self.assertEqual([s.name for s in specs], ["A", "N", "F"]) + self.assertEqual([s.kind for s in specs], ["float", "int", "bool"]) + self.assertEqual([s.control for s in specs], ["number", "number", "toggle"]) + self.assertEqual(parameters_in_cell("# ---- params: disc ----\nA = 1\n")[0].group, "disc") + self.assertEqual(parameters_in_cell("# ---- params ----\nA = 1\n")[0].group, "") + + def test_plain_constants_outside_a_params_cell_are_not_parameters(self) -> None: + self.assertEqual(parameters_in_cell("X = 3.0\n"), []) + + def test_ui_comment_makes_a_parameter_anywhere(self) -> None: + (spec,) = parameters_in_cell('W = 20.0 # ui: slider(10, 40, step=2, unit="mm", label="Width")\n') + self.assertEqual( + (spec.control, spec.min, spec.max, spec.step, spec.unit, spec.label), + ("slider", 10, 40, 2, "mm", "Width"), + ) + self.assertEqual(spec.issues, ()) + + def test_scad_var_carries_unit_and_comment(self) -> None: + (spec,) = parameters_in_cell( + '# ---- params ----\nOD = scad.var("od", 100.0, comment="outer", unit="mm") # ui: slider(50, 200)\n' + ) + self.assertEqual((spec.declared, spec.unit, spec.comment), ("var", "mm", "outer")) + self.assertEqual((spec.min, spec.max), (50, 200)) + + def test_bad_ui_comments_fall_back_and_say_why(self) -> None: + cases = { + "A = 1.0 # ui: slider(5, 1)": "min 5 is above max 1", + "A = 1.0 # ui: slider()": "slider needs min and max", + "A = 1.0 # ui: dial(1, 2)": "must be one of", + "A = 1 # ui: toggle()": "toggle needs a bool", + "A = True # ui: slider(1, 2)": "needs a number", + "A = 1.0 # ui: number(step=0)": "step must be positive", + "A = 1.0 # ui: number(speed=1)": "unknown ui option", + } + for code, message in cases.items(): + (spec,) = parameters_in_cell(code + "\n") + self.assertTrue(any(message in issue for issue in spec.issues), (code, spec.issues)) + self.assertIn(spec.control, ("number", "toggle"), code) + + def test_list_parameters_reads_the_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "m.py" + path.write_text( + notebook("m", "# ---- params ----\nW = 20.0 # ui: slider(10, 40)\nFLAG = False"), + encoding="utf-8", + ) + specs = list_parameters(path) + self.assertEqual([s.name for s in specs], ["W", "FLAG"]) + self.assertTrue(specs[0].cell.startswith("c")) + + +class CommitValueTest(unittest.TestCase): + CODE = ( + "# ---- params ----\n" + 'W = 20.0 # ui: slider(10, 40)\n' + "N = 4\n" + "F = False\n" + 'OD = scad.var("od", 100.0, comment="é", unit="mm") # 外径\n' + "T = scad.var(\n 't', 3.0)\n" + ) + + def test_literals_keep_the_rest_of_the_line(self) -> None: + out = commit_value(self.CODE, "W", 25) + assert out is not None + self.assertIn("W = 25.0 # ui: slider(10, 40)", out) + self.assertIn("N = 7", commit_value(self.CODE, "N", 7.0) or "") + self.assertIn("F = True", commit_value(self.CODE, "F", True) or "") + + def test_var_default_is_replaced_not_the_name(self) -> None: + out = commit_value(self.CODE, "OD", 120) + assert out is not None + self.assertIn('OD = scad.var("od", 120.0, comment="é", unit="mm") # 外径', out) + + def test_refuses_what_does_not_fit(self) -> None: + self.assertIsNone(commit_value(self.CODE, "MISSING", 1)) + self.assertIsNone(commit_value(self.CODE, "F", 1.0)) + self.assertIsNone(commit_value(self.CODE, "W", True)) + + def test_a_declaration_spanning_lines_is_edited_where_the_value_is(self) -> None: + self.assertIn("'t', 4.0)", commit_value(self.CODE, "T", 4.0) or "") + + +class FeatureHeaderTest(unittest.TestCase): + def test_header_parts(self) -> None: + (feature,) = features_in_cell( + "# ---- feature: base-plate (build, profile=sketch) ----\nx = 1\n", "c1" + ) + self.assertEqual( + (feature.slug, feature.role, feature.tags, feature.cell), + ("base-plate", "build", (("profile", "sketch"),), "c1"), + ) + (bare,) = features_in_cell("\n# ---- feature: boss ----\n") + self.assertEqual((bare.slug, bare.role, bare.line), ("boss", "", 2)) + + def test_list_features_in_cell_order(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "m.py" + path.write_text( + notebook("m", "# ---- feature: a (build) ----\na = 1", "# ---- feature: b (add) ----\nb = 2"), + encoding="utf-8", + ) + self.assertEqual([f.slug for f in list_features(path)], ["a", "b"]) + + +class SavedParamsTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + path = Path(self._tmp.name) / "m.py" + path.write_text(notebook("m", "# ---- params ----\nW = 1.0"), encoding="utf-8") + config = read_notebook_config(path) + assert config is not None + self.config = config + + def test_round_trip_and_clear(self) -> None: + self.assertEqual(read_saved_params(self.config), {}) + write_saved_params(self.config, {"W": 2.5, "N": 3, "F": True}) + self.assertEqual(read_saved_params(self.config), {"W": 2.5, "N": 3, "F": True}) + write_saved_params(self.config, {"W": 9.0}) + self.assertEqual(read_saved_params(self.config), {"W": 9.0}) + write_saved_params(self.config, {}) + self.assertEqual(read_saved_params(self.config), {}) + + def test_corrupt_file_is_ignored(self) -> None: + write_saved_params(self.config, {"W": 2.0}) + from simplecadapi.runtime.params import saved_params_file + + saved_params_file(self.config).write_text("{not json", encoding="utf-8") + self.assertEqual(read_saved_params(self.config), {}) + + def test_apply_keeps_type_and_var_declaration(self) -> None: + var = scad.var("od", 100.0, comment="outer", unit="mm") + glbls = {"OD": var, "N": 6, "F": False, "S": 1.5, "OTHER": 1.0} + write_saved_params(self.config, {"OD": 120, "N": 8.0, "F": True, "S": True, "OTHER": 9.0}) + applied = apply_saved_params(self.config, {"OD", "N", "F", "S"}, glbls) + self.assertEqual(sorted(applied), ["F", "N", "OD"]) # S: a bool for a number is refused + self.assertEqual(glbls["OD"].default, 120.0) + self.assertEqual((glbls["OD"].name, glbls["OD"].unit, glbls["OD"].comment), + ("od", var.unit, "outer")) + self.assertEqual((glbls["N"], type(glbls["N"])), (8, int)) + self.assertIs(glbls["F"], True) + self.assertEqual(glbls["S"], 1.5) + self.assertEqual(glbls["OTHER"], 1.0) # not defined by the cell + + +class ScratchCellTest(unittest.TestCase): + def test_scratch_cells_are_not_recorded(self) -> None: + from simplecadapi.runtime.cells import NotebookCells + + cells = NotebookCells(Path("m.py"), []) + self.assertNotIn("__scratch__", cells) + + +class WorkspaceScanTest(unittest.TestCase): + def test_scan_lists_only_simplecad_notebooks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "parts").mkdir() + (root / "parts" / "bar.py").write_text(notebook("bar", "b = 1"), encoding="utf-8") + (root / "parts" / "dims.py").write_text("X = 1\n", encoding="utf-8") + (root / "product.py").write_text( + notebook("product", 'p = scad.use("parts/bar.py", id="a")\np2 = scad.use("parts/gone.py")'), + encoding="utf-8", + ) + (root / "__marimo__").mkdir() + (root / "__marimo__" / "x.py").write_text(notebook("x", "b = 1"), encoding="utf-8") + (root / "plain.py").write_text("import marimo\n", encoding="utf-8") + entries = {entry.path: entry for entry in scan_workspace(root)} + self.assertEqual(sorted(entries), ["parts/bar.py", "product.py"]) + self.assertEqual(entries["parts/bar.py"].kind, "part") + product = entries["product.py"] + self.assertEqual(product.kind, "assembly") + self.assertEqual(product.uses, ("parts/bar.py", "parts/gone.py")) + json.dumps(product.to_dict()) + + def test_examples_are_a_workspace_without_changes(self) -> None: + examples = Path(__file__).resolve().parents[1] / "examples" + entries = {entry.path: entry for entry in scan_workspace(examples)} + link = entries["four_bar_linkage/link_bar.py"] + self.assertEqual((link.id, link.kind), ("link_bar", "part")) + assembly = entries["four_bar_linkage/four_bar_linkage.py"] + self.assertEqual(assembly.kind, "assembly") + self.assertIn("four_bar_linkage/link_bar.py", assembly.uses) + names = [spec.name for spec in list_parameters(examples / "flange_plate" / "flange_plate.py")] + self.assertIn("FLANGE_OD", names) + + +if __name__ == "__main__": + unittest.main() + + +class ModuleParametersTest(unittest.TestCase): + CODE = ( + "from dataclasses import dataclass\n\n" + 'TAG = "x"\n' + "WALL = 2.0 # ui: slider(1, 5, step=0.5, unit=\"mm\")\n" + "TABLE = 3\n\n\n" + "@dataclass(frozen=True)\n" + "class Dims:\n" + " L: float = 80.0 # arm span\n" + " N: int = 4 # ui: slider(2, 8)\n" + " name: str = 'a'\n\n" + " @property\n" + " def r(self) -> float:\n" + " return self.L / 2\n\n\n" + "class Plain:\n" + " K: float = 1.0\n" + ) + + def test_dataclass_fields_and_commented_constants_are_parameters(self) -> None: + from simplecadapi.runtime.params import parameters_in_module + + specs = {spec.name: spec for spec in parameters_in_module(self.CODE)} + self.assertEqual(sorted(specs), ["Dims.L", "Dims.N", "WALL"]) + self.assertEqual((specs["WALL"].control, specs["WALL"].unit, specs["WALL"].group), ("slider", "mm", "module")) + self.assertEqual((specs["Dims.N"].kind, specs["Dims.N"].control), ("int", "slider")) + self.assertEqual(specs["Dims.L"].to_dict()["label"], "L") + + def test_values_are_written_where_they_are_declared(self) -> None: + from simplecadapi.runtime.params import commit_field_value + + out = commit_field_value(self.CODE, "Dims", "L", 100) + assert out is not None + self.assertIn("L: float = 100.0 # arm span", out) + self.assertIsNone(commit_field_value(self.CODE, "Plain", "K", 2)) # not a dataclass + self.assertIsNone(commit_field_value(self.CODE, "Dims", "name", 2)) + self.assertIn("WALL = 3.5 # ui:", commit_value(self.CODE, "WALL", 3.5) or "") + + +class VertexScopeTest(unittest.TestCase): + def test_vertices_resolve_through_a_solid_and_a_face(self) -> None: + import simplecadapi as scad + + solid = scad.make_box_rsolid(10.0, 20.0, 30.0) + self.assertEqual(len(scad.ql.vertices().resolve(solid)), 8) + face = scad.ql.faces().where(scad.ql.prop("geom.center.z", "<=", 0.01)).exactly(1).resolve(solid)[0] + self.assertEqual(len(scad.ql.vertices().resolve(face)), 4) diff --git a/test/test_notebook_runtime.py b/test/test_notebook_runtime.py index 16d1bd2d..563b172f 100644 --- a/test/test_notebook_runtime.py +++ b/test/test_notebook_runtime.py @@ -348,11 +348,15 @@ def test_a_changed_local_module_invalidates_the_cache(self) -> None: first = run_notebook(self.bracket) self.write("lib/dims.py", "HOLE_RADIUS = 2.0\n") changed = run_notebook(self.bracket) - self.assertEqual(self.statuses(changed), ["ran"] * 4) + # `width` reads no module: it is restored; the cell that imports the + # module, and the part made from its result, are computed again. + self.assertEqual(self.statuses(changed), ["ran", "cached", "ran", "ran"]) self.assertNotEqual(first.definition.content_hash, changed.definition.content_hash) self.assertEqual(self.statuses(run_notebook(self.bracket))[1:], ["cached"] * 3) - # Only the cache for the current dependencies is kept. - self.assertEqual(len(list((state_dir(first.config) / "cache").iterdir())), 1) + # The caches of the current dependencies and of the previous run are kept. + self.assertEqual( + len([d for d in (state_dir(first.config) / "cache").iterdir() if d.is_dir()]), 2 + ) def test_sources_are_positions_in_the_notebook_file(self) -> None: lines = BRACKET.splitlines() @@ -560,10 +564,83 @@ def test_a_changed_child_invalidates_the_parent(self) -> None: first = run_notebook(self.rig) self.write("bracket.py", BRACKET.replace("10.0, 5.0)", "12.0, 5.0)")) changed = run_notebook(self.rig) - self.assertEqual(self.statuses(changed), ["ran"] * 4) + # The pin does not depend on the child: it is restored. + self.assertEqual(self.statuses(changed), ["ran", "ran", "cached", "ran"]) self.assertNotEqual(first.definition.content_hash, changed.definition.content_hash) +TWO_MODULES = notebook_text( + cell("from lib.alpha import A", returns="A"), + cell("from lib.beta import B", returns="B"), + cell("box = scad.make_box_rsolid(A, 4.0, 4.0)", reads="A", returns="box"), + cell("plate = scad.make_box_rsolid(B, B, 1.0)", reads="B", returns="plate"), + cell("part = scad.Part(part_id='two', body=scad.union_rsolid(box, plate))", reads="box, plate", returns="part"), + table='id = "two"', +) + + +class TestCellLevelDependencies(NotebookTestCase): + """A changed file recomputes the cells that read it, and nothing stale survives.""" + + def setUp(self) -> None: + super().setUp() + self.write("lib/alpha.py", "A = 10.0\n") + self.write("lib/beta.py", "B = 6.0\n") + self.two = self.write("two.py", TWO_MODULES) + + def test_only_the_cells_that_read_a_module_and_their_dependents_rerun(self) -> None: + first = run_notebook(self.two) + self.assertEqual(self.statuses(first), ["ran"] * 6) + self.write("lib/alpha.py", "A = 12.0\n") + changed = run_notebook(self.two) + # setup, A (reads alpha), B (beta: untouched), box (made from A), + # plate (made from B: untouched), part (reads both products). + self.assertEqual(self.statuses(changed), ["ran", "ran", "cached", "ran", "cached", "ran"]) + + def test_the_result_is_what_a_fresh_run_gives_after_any_change(self) -> None: + run_notebook(self.two) + for name, text in ( + ("lib/alpha.py", "A = 12.0\n"), + ("lib/beta.py", "B = 8.0\n"), + ("lib/alpha.py", "A = 10.0\n"), # back to an earlier state + ("lib/beta.py", "B = 6.0\n"), + ): + self.write(name, text) + cached = run_notebook(self.two) + fresh = run_notebook(self.two, cache=False) + self.assertEqual(cached.definition.content_hash, fresh.definition.content_hash, name) + + def test_going_back_to_an_earlier_value_is_restored(self) -> None: + run_notebook(self.two) + self.write("lib/alpha.py", "A = 12.0\n") + run_notebook(self.two) + self.write("lib/alpha.py", "A = 10.0\n") + back = run_notebook(self.two) + self.assertEqual(self.statuses(back), ["ran", "cached", "cached", "cached", "cached", "cached"]) + + def test_a_function_defined_in_the_notebook_carries_the_dependency(self) -> None: + self.write("lib/size.py", "SIZE = 3.0\n") + text = notebook_text( + cell("side = 5.0", returns="side"), + cell("block = scad.make_box_rsolid(edge(), side, side)", reads="side", returns="block"), + cell("part = scad.Part(part_id='f', body=block)", reads="block", returns="part"), + table='id = "f"', + ).replace( + "with app.setup:\n import simplecadapi as scad\n", + "with app.setup:\n import simplecadapi as scad\n from lib.size import SIZE\n\n\n" + " def edge():\n return SIZE\n", + ) + path = self.write("f.py", text) + run_notebook(path) + self.write("lib/size.py", "SIZE = 4.0\n") + changed = run_notebook(path) + # `side` reads nothing; the block calls edge(), which reads the imported SIZE. + self.assertEqual(self.statuses(changed), ["ran", "cached", "ran", "ran"]) + self.assertEqual( + changed.definition.content_hash, run_notebook(path, cache=False).definition.content_hash + ) + + LINK = notebook_text( cell("length = 40.0", returns="length"), cell("bar = scad.make_box_rsolid(length, 6.0, 3.0)", reads="length", returns="bar"), diff --git a/test/test_scene_preview.py b/test/test_scene_preview.py new file mode 100644 index 00000000..4bb00dda --- /dev/null +++ b/test/test_scene_preview.py @@ -0,0 +1,207 @@ +"""The preview: a product's geometry straight from its solids, for a viewer under edit.""" + +from __future__ import annotations + +import json +import math +import struct +import unittest + +import numpy as np + +import simplecadapi as scad +from simplecadapi.scene import preview as pv + + +def box_part(part_id: str, size=(10.0, 20.0, 30.0)) -> scad.Part: + return scad.Part(part_id=part_id, body=scad.make_box_rsolid(*size)) + + +class PreviewGeometryTest(unittest.TestCase): + def setUp(self) -> None: + pv.clear_preview_cache() + + def test_a_part_previews_with_groups_that_tile_the_triangles(self) -> None: + scene = pv.compile_preview(box_part("box")) + (part,) = scene.parts.values() + self.assertEqual(part.triangle_count, 12) # two per face + self.assertEqual(len(part.face_groups), 6) + self.assertEqual(sum(count for _, _, count in part.face_groups), len(part.indices)) + self.assertEqual(len(part.edge_groups), 12) + self.assertEqual(len(part.vertex_ids), 8) + self.assertEqual([n.node_id for n in scene.nodes], ["node/box"]) + positions = part.positions.astype(np.float64) * 1000.0 # glTF metres -> mm (x, z, -y) + self.assertAlmostEqual(float(positions[:, 0].max() - positions[:, 0].min()), 10.0, places=3) + self.assertAlmostEqual(float(positions[:, 2].max() - positions[:, 2].min()), 20.0, places=3) # glTF z is -y + self.assertAlmostEqual(float(positions[:, 1].max() - positions[:, 1].min()), 30.0, places=3) # glTF y is z + + def test_triangle_areas_add_up_to_the_surface(self) -> None: + part = box_part("box") + (preview,) = pv.compile_preview(part).parts.values() + total = 0.0 + for _, first, count in preview.face_groups: + tri = preview.indices[first : first + count].reshape(-1, 3) + a, b, c = (preview.positions[tri[:, k]].astype(np.float64) * 1000.0 for k in range(3)) + total += 0.5 * float(np.linalg.norm(np.cross(b - a, c - a), axis=1).sum()) + self.assertAlmostEqual(total, 2 * (10 * 20 + 10 * 30 + 20 * 30), places=2) + + def test_the_same_content_is_remembered_even_when_the_object_is_rebuilt(self) -> None: + first = pv.compile_preview(box_part("a")) + second = pv.compile_preview(box_part("b")) # another object, the same box + self.assertIs(next(iter(first.parts.values())), next(iter(second.parts.values()))) + other = pv.compile_preview(box_part("c", (11.0, 20.0, 30.0))) + self.assertIsNot(next(iter(first.parts.values())), next(iter(other.parts.values()))) + + def test_a_tolerance_is_part_of_the_key(self) -> None: + part = scad.Part(part_id="cyl", body=scad.make_cylinder_rsolid(5.0, 10.0)) + fine = next(iter(pv.compile_preview(part, linear_tolerance=0.01, angular_tolerance=0.05).parts.values())) + coarse = next(iter(pv.compile_preview(part, linear_tolerance=0.5, angular_tolerance=0.5).parts.values())) + self.assertGreater(fine.triangle_count, coarse.triangle_count) + + +class PreviewAssemblyTest(unittest.TestCase): + def setUp(self) -> None: + pv.clear_preview_cache() + + def nested(self) -> scad.Assembly: + leaf = scad.make_assembly_rassembly(assembly_id="leaf") + leaf = scad.add_component_rassembly( + assembly=leaf, item=box_part("pin", (2.0, 2.0, 8.0)), component_id="pin", + placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)), + ) + top = scad.make_assembly_rassembly(assembly_id="top") + top = scad.add_component_rassembly( + assembly=top, item=box_part("base"), component_id="base", + placement=scad.identity_placement_rplacement(), + ) + # rotated 90 degrees about z, then moved: x -> y + return scad.add_component_rassembly( + assembly=top, item=leaf, component_id="arm", + placement=scad.make_placement_rplacement(origin=(100.0, 0.0, 0.0), x_axis=(0.0, 1.0, 0.0), y_axis=(-1.0, 0.0, 0.0)), + ) + + def test_nested_placements_are_composed_and_parts_are_not_repeated(self) -> None: + asm = self.nested() + asm = scad.add_component_rassembly( + assembly=asm, item=box_part("pin", (2.0, 2.0, 8.0)), component_id="spare", + placement=scad.make_placement_rplacement(origin=(0.0, 50.0, 0.0)), + ) + scene = pv.compile_preview(asm) + by_id = {n.node_id: n for n in scene.nodes} + self.assertEqual(sorted(by_id), ["node/top/arm/pin", "node/top/base", "node/top/spare"]) + pin = by_id["node/top/arm/pin"].placement + # the pin sits at x=5 in the arm; the arm is turned x -> y and moved to x=100 + self.assertTrue(np.allclose(pin.origin, (100.0, 5.0, 0.0))) + self.assertTrue(np.allclose(pin.x_axis, (0.0, 1.0, 0.0))) + self.assertEqual(len(scene.parts), 2) # "pin" is one geometry, two instances + self.assertEqual(by_id["node/top/arm/pin"].part_key, by_id["node/top/spare"].part_key) + + def test_an_empty_assembly_cannot_be_previewed(self) -> None: + with self.assertRaises(pv.PreviewError): + pv.compile_preview(scad.make_assembly_rassembly(assembly_id="none")) + + +class PreviewWireTest(unittest.TestCase): + def test_the_encoding_round_trips_through_its_header(self) -> None: + scene = pv.compile_preview(box_part("box")) + data = pv.encode_preview(scene) + self.assertTrue(data.startswith(pv.MAGIC)) + header = pv.decode_preview_header(data) + self.assertEqual(header["schema"], pv.PREVIEW_SCHEMA) + (part,) = header["parts"] + (length,) = struct.unpack_from(" None: + pv.clear_preview_cache() + self.scene = pv.compile_preview(box_part("box")) + (self.part,) = self.scene.parts.values() + + def test_a_face_reports_what_the_package_sidecar_would(self) -> None: + topo, _, _ = self.part.face_groups[0] + info = pv.entity_info(self.scene, f"entity/box/face/{topo}") + assert info is not None + self.assertEqual(info["kind"], "face") + self.assertEqual(info["geometry"]["type"], "plane") + self.assertIn(round(info["properties"]["area"], 6), (200.0, 300.0, 600.0)) + self.assertEqual(len(info["properties"]["centroid"]), 3) + + def test_an_edge_a_vertex_and_the_solid(self) -> None: + edge = pv.entity_info(self.scene, f"entity/box/edge/{self.part.edge_groups[0][0]}") + assert edge is not None + self.assertEqual(edge["geometry"]["type"], "line") + self.assertIn(round(edge["properties"]["length"], 6), (10.0, 20.0, 30.0)) + vertex = pv.entity_info(self.scene, f"entity/box/vertex/{self.part.vertex_ids[0]}") + assert vertex is not None + self.assertEqual(vertex["geometry"]["type"], "point") + solid = pv.entity_info(self.scene, f"entity/box/solid/{self.part.solid_topo_id}") + assert solid is not None + self.assertTrue(math.isclose(solid["properties"]["volume"], 6000.0, rel_tol=1e-9)) + + def test_unknown_entities_have_no_info(self) -> None: + self.assertIsNone(pv.entity_info(self.scene, "entity/box/face/face_999")) + self.assertIsNone(pv.entity_info(self.scene, "entity/other/face/face_0")) + self.assertIsNone(pv.entity_info(self.scene, "entity/box/gadget/x")) + + +if __name__ == "__main__": + unittest.main() + + +class AgainstThePackageTest(unittest.TestCase): + """The preview names and describes entities exactly as the package's sidecar does.""" + + def test_a_real_part_has_the_packages_entity_ids_and_properties(self) -> None: + import io + import zipfile + from pathlib import Path + + from simplecadapi.product.packages import build_product_package, encode_product_package + from simplecadapi.runtime import run_notebook + + run = run_notebook(Path(__file__).resolve().parents[1] / "examples" / "flange_plate" / "flange_plate.py") + scene = pv.compile_preview(run.product) + archive = zipfile.ZipFile(io.BytesIO(encode_product_package(build_product_package(run.definition, validate=False)))) + sidecar = None + for name in archive.namelist(): + blob = archive.read(name) + if blob[:1] == b"{" and b'"face_groups"' in blob: + sidecar = json.loads(blob) + assert sidecar is not None + reference = {e["entity_id"]: e for e in sidecar["entities"]} + header = pv.decode_preview_header(pv.encode_preview(scene)) + (part,) = header["parts"] + mine = ( + {g["entity_id"] for g in part["face_groups"]} + | {g["entity_id"] for g in part["edge_groups"]} + | set(part["vertex_ids"]) + | {part["solid_entity_id"]} + ) + self.assertEqual(mine, set(reference)) + + def close(x, y) -> bool: + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + return math.isclose(x, y, rel_tol=1e-6, abs_tol=1e-6) + if isinstance(x, dict) and isinstance(y, dict): + return x.keys() == y.keys() and all(close(x[k], y[k]) for k in x) + if isinstance(x, list) and isinstance(y, list): + return len(x) == len(y) and all(close(p, q) for p, q in zip(x, y)) + return x == y + + for entity_id in sorted(mine)[::5]: + info = pv.entity_info(scene, entity_id) + assert info is not None, entity_id + for field in ("properties", "geometry", "tags"): + self.assertTrue(close(info[field], reference[entity_id][field]), (entity_id, field)) diff --git a/test/test_skill_cli.py b/test/test_skill_cli.py index 36abf4ad..0e0f9574 100644 --- a/test/test_skill_cli.py +++ b/test/test_skill_cli.py @@ -124,3 +124,27 @@ def test_main_reports_unknown_target_without_traceback(project, tmp_path, capsys error = capsys.readouterr().err assert "unknown target" in error and "Traceback" not in error assert not (tmp_path / "s").exists() + + +def test_where_reports_the_destination_without_writing(project, tmp_path): + report, code, printer = run(["skill", "where"]) + expected = tmp_path / "home" / ".agents" / "skills" / "demo" + assert code == 0 and Path(report["where"]) == expected and report["exists"] is False + assert not expected.parent.exists() # nothing was created + printer(report) + run(install_args()) + assert run(["skill", "where"])[0]["exists"] is True + elsewhere, _, _ = run(["skill", "where", "--skills-dir", str(tmp_path / "other")]) + assert Path(elsewhere["where"]) == tmp_path / "other" / "demo" + + +def test_where_reports_the_destination_without_writing(project, tmp_path): + report, code, printer = run(["skill", "where"]) + expected = tmp_path / "home" / ".agents" / "skills" / "demo" + assert code == 0 and Path(report["where"]) == expected and report["exists"] is False + assert not expected.parent.exists() # nothing was created + printer(report) + run(install_args()) + assert run(["skill", "where"])[0]["exists"] is True + elsewhere, _, _ = run(["skill", "where", "--skills-dir", str(tmp_path / "other")]) + assert Path(elsewhere["where"]) == tmp_path / "other" / "demo" diff --git a/uv.lock b/uv.lock index 233b89f8..55fd8dec 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10, <3.14" resolution-markers = [ "python_full_version >= '3.12'", @@ -3239,7 +3239,6 @@ dependencies = [ { name = "ocp-gordon" }, { name = "plotly" }, { name = "py-slvs" }, - { name = "pymupdf" }, { name = "rfc8785" }, { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -3251,9 +3250,19 @@ dev = [ { name = "black" }, { name = "isort" }, { name = "mypy" }, + { name = "pymupdf" }, { name = "pytest" }, { name = "pytest-cov" }, ] +drawing = [ + { name = "pymupdf" }, +] +editor = [ + { name = "starlette" }, + { name = "uvicorn" }, + { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] fem = [ { name = "ccx2paraview", extra = ["vtk"] }, { name = "gmsh" }, @@ -3275,10 +3284,15 @@ dev = [ { name = "jupyterlab" }, { name = "matplotlib" }, { name = "numpy-stl" }, + { name = "pymupdf" }, { name = "pytest" }, + { name = "starlette" }, { name = "trimesh" }, { name = "twine" }, { name = "ty" }, + { name = "uvicorn" }, + { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.metadata] @@ -3299,15 +3313,19 @@ requires-dist = [ { name = "ocp-gordon", specifier = ">=0.2,<0.3" }, { name = "plotly", specifier = ">=7.1.0" }, { name = "py-slvs", specifier = "==1.0.6" }, - { name = "pymupdf", specifier = ">=1.28.2" }, + { name = "pymupdf", marker = "extra == 'dev'", specifier = ">=1.28.2" }, + { name = "pymupdf", marker = "extra == 'drawing'", specifier = ">=1.28.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "rfc8785", specifier = ">=0.1.4,<0.2.0" }, { name = "rich", specifier = ">=14.0.0" }, + { name = "starlette", marker = "extra == 'editor'", specifier = ">=1.7.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0,<3" }, { name = "typing-extensions", specifier = ">=4.12,<5" }, + { name = "uvicorn", marker = "extra == 'editor'", specifier = ">=0.54.0" }, + { name = "websockets", marker = "extra == 'editor'", specifier = ">=16.1.1" }, ] -provides-extras = ["dev", "editor", "inspect", "whucad", "gmsh", "fem"] +provides-extras = ["dev", "drawing", "editor", "inspect", "whucad", "gmsh", "fem"] [package.metadata.requires-dev] dev = [ @@ -3316,10 +3334,14 @@ dev = [ { name = "jupyterlab", specifier = ">=4.5.5" }, { name = "matplotlib", specifier = ">=3.10.8" }, { name = "numpy-stl", specifier = ">=3.2.0" }, + { name = "pymupdf", specifier = ">=1.28.2" }, { name = "pytest" }, + { name = "starlette", specifier = ">=1.7.0" }, { name = "trimesh", specifier = ">=4.11.3" }, { name = "twine", specifier = ">=6.1.0" }, { name = "ty", specifier = ">=0.0.18" }, + { name = "uvicorn", specifier = ">=0.54.0" }, + { name = "websockets", specifier = ">=16.1.1" }, ] [[package]]